@progress/kendo-angular-conversational-ui
Version:
Kendo UI for Angular Conversational UI components
502 lines (495 loc) • 22.8 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { ChangeDetectorRef, Component, ElementRef, EventEmitter, HostBinding, Input, Output, Renderer2, ViewChild } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { LocalizationService } from '@progress/kendo-angular-l10n';
import { closest, guid, isPresent, Keys } from '@progress/kendo-angular-common';
import { paperPlaneIcon, paperclipIcon, fileIcon, xIcon } from '@progress/kendo-svg-icons';
import { ButtonComponent, SpeechToTextButtonComponent } from '@progress/kendo-angular-buttons';
import { SendMessageEvent } from './api/post-message-event';
import { ChatMessageBoxTemplateDirective } from './templates/message-box.directive';
import { InputSpacerComponent, TextAreaComponent, TextAreaPrefixComponent, TextAreaSuffixComponent } from '@progress/kendo-angular-inputs';
import { FileSelectComponent } from '@progress/kendo-angular-upload';
import { FormsModule } from '@angular/forms';
import { ChatService } from './common/chat.service';
import { SuggestedActionsComponent } from "./suggested-actions.component";
import { Subscription } from 'rxjs';
import { ChatFileComponent } from './chat-file.component';
import { MessageReferenceComponent } from './message-reference-content.component';
import { ChatSuggestionTemplateDirective } from './templates/suggestion-template.directive';
import * as i0 from "@angular/core";
import * as i1 from "./common/chat.service";
/**
* @hidden
*/
export class MessageBoxComponent {
chatService;
cdr;
element;
renderer;
borderColor = 'inherit';
messageBoxWrapperClass = true;
messageBoxInput;
fileSelectComponent;
suggestedActionsComponent;
authorId;
autoScroll;
suggestions;
placeholder;
inputValue = '';
localization;
messageBoxTemplate;
suggestionTemplate;
sendMessage = new EventEmitter();
executeSuggestion = new EventEmitter();
fileSelect = new EventEmitter();
fileRemove = new EventEmitter();
files = [];
sendIcon = paperPlaneIcon;
attachmentIcon = paperclipIcon;
deleteIcon = xIcon;
fileIcon = fileIcon;
isListening = false;
get reply() {
return this.chatService.reply;
}
selectedItem;
subs = new Subscription();
constructor(chatService, cdr, element, renderer) {
this.chatService = chatService;
this.cdr = cdr;
this.element = element;
this.renderer = renderer;
}
ngOnInit() {
const elRef = this.element.nativeElement;
this.subs.add(this.renderer.listen(elRef, 'focusout', event => this.onBlur(event)));
this.subs.add(this.chatService.contextMenuAction$.subscribe((action) => {
if (action.action.id === 'reply') {
this.messageBoxInput.focus();
}
}));
}
ngOnDestroy() {
if (this.subs) {
this.subs.unsubscribe();
}
}
sendClick() {
const hasMessage = this.inputValue?.trim() || this.files?.length;
const isCustomDisabled = isPresent(this.sendButtonSettings?.disabled);
if (!hasMessage && !isCustomDisabled) {
return;
}
const message = {
id: guid(),
text: this.inputValue,
timestamp: new Date(),
author: { id: this.authorId },
...(this.files && this.files.length > 0 && { files: this.files }),
...(this.reply && { replyToId: this.reply.id })
};
this.sendMessage.emit(new SendMessageEvent(message));
this.inputValue = '';
this.files = [];
this.chatService.reply = null;
this.messageBoxInput.focus();
this.autoScroll = true;
}
inputKeydown(e) {
if (e.code === Keys.Enter || e.code === Keys.NumpadEnter) {
this.sendClick();
}
}
textAreaKeydown(e) {
const isEnter = e.code === Keys.Enter || e.code === Keys.NumpadEnter;
if (!isEnter) {
return;
}
const newLine = (e.metaKey || e.ctrlKey);
const enterOnly = !(e.shiftKey || e.metaKey || e.ctrlKey);
if (enterOnly) {
e.preventDefault();
this.sendClick();
}
if (newLine) {
this.inputValue += `\r\n`;
}
}
handleSpeechResult(event) {
if (event.alternatives && event.alternatives.length > 0) {
if (!isPresent(this.inputValue)) {
this.inputValue = '';
}
const appendedValue = event.alternatives[0].transcript + ' ';
if (!appendedValue.trim()) {
return;
}
this.inputValue += appendedValue;
this.chatService.emit('inputValueChange', this.inputValue);
}
}
textFor(key) {
return this.localization.get(key);
}
removeReply() {
this.chatService.reply = null;
}
onReplyReferenceClick(event) {
event.stopPropagation();
this.chatService.emit('replyReferenceClick', this.chatService.reply?.id);
}
handleFileSelect(event) {
const processedFiles = event.files.map(currentFile => {
return {
id: currentFile.uid,
name: currentFile.name,
extension: currentFile.extension,
size: currentFile.size,
type: currentFile.rawFile.type,
rawFile: currentFile.rawFile
};
});
this.files = [...this.files, ...processedFiles];
this.fileSelect.emit(event);
}
selectFiles() {
if (this.fileSelectComponent?.fileSelectInput) {
this.fileSelectComponent.fileSelectInput.nativeElement.click();
}
}
removeFile(index) {
this.files = this.files.filter((_, i) => i !== index);
const removedFile = this.files[index];
this.fileRemove.emit(removedFile);
}
get speechToTextButtonSettings() {
return this.chatService.enableSpeechToText;
}
get sendButtonSettings() {
return this.chatService.sendButtonSettings;
}
get enableFileSelect() {
return this.chatService.enableFileSelect;
}
get isDisabledSendButton() {
if (isPresent(this.sendButtonSettings?.disabled)) {
return this.sendButtonSettings.disabled;
}
const isEmptyInput = !this.inputValue?.length || !this.inputValue?.trim();
const hasFiles = this.files?.length > 0;
return (isEmptyInput && !hasFiles) || this.isListening;
}
select(item, event) {
if (event) {
const target = event.target;
if (!target.classList.contains('k-suggestion')) {
return;
}
}
if (!this.chatService.toggleMessageState) {
const prevItem = this.selectedItem;
if (prevItem) {
prevItem.selected = false;
}
if (item) {
item.selected = true;
this.selectedItem = item;
}
this.cdr.detectChanges();
}
this.chatService.toggleMessageState = false;
}
onBlur(args) {
const next = args.relatedTarget || document.activeElement;
const outside = !closest(next, (node) => node === this.element.nativeElement);
if (outside) {
this.select(null);
}
}
onInputValueChange(value) {
this.inputValue = value;
this.chatService.emit('inputValueChange', value);
}
dispatchSuggestion(suggestion) {
this.executeSuggestion.emit(suggestion);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageBoxComponent, deps: [{ token: i1.ChatService }, { token: i0.ChangeDetectorRef }, { token: i0.ElementRef }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: MessageBoxComponent, isStandalone: true, selector: "kendo-message-box", inputs: { authorId: "authorId", autoScroll: "autoScroll", suggestions: "suggestions", placeholder: "placeholder", inputValue: "inputValue", localization: "localization", messageBoxTemplate: "messageBoxTemplate", suggestionTemplate: "suggestionTemplate" }, outputs: { sendMessage: "sendMessage", executeSuggestion: "executeSuggestion", fileSelect: "fileSelect", fileRemove: "fileRemove" }, host: { properties: { "style.border-color": "this.borderColor", "class.k-message-box-wrapper": "this.messageBoxWrapperClass" } }, viewQueries: [{ propertyName: "messageBoxInput", first: true, predicate: ["messageBoxInput"], descendants: true }, { propertyName: "fileSelectComponent", first: true, predicate: ["fileSelect"], descendants: true }, { propertyName: "suggestedActionsComponent", first: true, predicate: SuggestedActionsComponent, descendants: true }], ngImport: i0, template: `
@if (suggestions?.length > 0) {
<kendo-chat-suggested-actions
#suggestedActions
[suggestions]="suggestions"
type="suggestion"
[suggestionTemplate]="suggestionTemplate"
[tabbable]="true"
(dispatchSuggestion)="dispatchSuggestion($event)"
(click)="select(suggestedActions, $event)"
(focus)="select(suggestedActions, $event)"
></kendo-chat-suggested-actions>
}
@if (!messageBoxTemplate?.templateRef) {
<kendo-textarea
#messageBoxInput
class="k-message-box"
resizable="none"
[rows]="3"
[inputAttributes]="{
'aria-label': textFor('messageBoxInputLabel')
}"
[placeholder]="placeholder || textFor('messagePlaceholder')"
[showSuffixSeparator]="false"
(keydown)="textAreaKeydown($event)"
[value]="inputValue"
(valueChange)="onInputValueChange($event)"
>
@if (reply || (files && files.length > 0)) {
<kendo-textarea-prefix>
@if (reply) {
<div class="k-message-reference k-message-reference-sender" (click)="onReplyReferenceClick($event)">
<chat-message-reference-content [message]="reply"></chat-message-reference-content>
<span class="k-spacer"></span>
<button
kendoButton
[attr.title]="textFor('removeReplyTitle')"
[svgIcon]="deleteIcon"
(click)="removeReply()"
fillMode="flat"
></button>
</div>
}
<ul class="k-chat-file-wrapper">
@for (file of files; track file; let i = $index) {
<li class="k-chat-file"
[chatFile]="file"
[removable]="true"
(remove)="removeFile(i)"
></li>
}
</ul>
</kendo-textarea-prefix>
}
<kendo-textarea-suffix>
@if (speechToTextButtonSettings) {
<button
kendoSpeechToTextButton
[attr.title]="textFor('speechToTextButtonTitle')"
[continuous]="speechToTextButtonSettings?.continuous"
[disabled]="speechToTextButtonSettings?.disabled"
[fillMode]="speechToTextButtonSettings?.fillMode ?? 'clear'"
[integrationMode]="speechToTextButtonSettings?.integrationMode ?? 'webSpeech'"
[interimResults]="speechToTextButtonSettings?.interimResults"
[lang]="speechToTextButtonSettings?.lang"
[maxAlternatives]="speechToTextButtonSettings?.maxAlternatives"
[rounded]="speechToTextButtonSettings?.rounded"
[size]="speechToTextButtonSettings?.size"
[themeColor]="speechToTextButtonSettings?.themeColor"
(result)="handleSpeechResult($event)"
(start)="isListening = true"
(end)="isListening = false"
></button>
}
@if (enableFileSelect) {
<button
kendoButton
[attr.title]="textFor('fileSelectButtonTitle')"
[svgIcon]="attachmentIcon"
icon="attachment"
fillMode="clear"
(click)="selectFiles()"
></button>
}
<kendo-input-spacer></kendo-input-spacer>
<button
kendoButton
[fillMode]="sendButtonSettings?.fillMode"
[themeColor]="sendButtonSettings?.themeColor"
[rounded]="sendButtonSettings?.rounded"
[class]="sendButtonSettings?.buttonClass || 'k-chat-send'"
[icon]="sendButtonSettings?.icon"
[svgIcon]="sendButtonSettings?.svgIcon"
[tabindex]="0"
[attr.title]="textFor('send')"
[class.k-disabled]="isDisabledSendButton"
[attr.aria-disabled]="isDisabledSendButton"
(click)="sendClick()"
>
</button>
</kendo-textarea-suffix>
</kendo-textarea>
}
@if (messageBoxTemplate?.templateRef) {
<ng-template [ngTemplateOutlet]="messageBoxTemplate?.templateRef"></ng-template>
}
<kendo-fileselect
#fileSelect
class="k-hidden"
[multiple]="true"
[showFileList]="false"
(select)="handleFileSelect($event)"
></kendo-fileselect>
`, isInline: true, dependencies: [{ kind: "component", type: ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon", "primary", "look"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: TextAreaComponent, selector: "kendo-textarea", inputs: ["focusableId", "flow", "inputAttributes", "adornmentsOrientation", "rows", "cols", "maxlength", "maxResizableRows", "tabindex", "tabIndex", "resizable", "size", "rounded", "fillMode", "showPrefixSeparator", "showSuffixSeparator"], outputs: ["focus", "blur", "valueChange"], exportAs: ["kendoTextArea"] }, { kind: "component", type: MessageReferenceComponent, selector: "chat-message-reference-content", inputs: ["message"] }, { kind: "component", type: TextAreaSuffixComponent, selector: "kendo-textarea-suffix", inputs: ["flow", "orientation"], exportAs: ["kendoTextAreaSuffix"] }, { kind: "component", type: TextAreaPrefixComponent, selector: "kendo-textarea-prefix", inputs: ["flow", "orientation"], exportAs: ["kendoTextAreaPrefix"] }, { kind: "component", type: SpeechToTextButtonComponent, selector: "button[kendoSpeechToTextButton]", inputs: ["disabled", "size", "rounded", "fillMode", "themeColor", "integrationMode", "lang", "continuous", "interimResults", "maxAlternatives"], outputs: ["start", "end", "result", "error", "click"], exportAs: ["kendoSpeechToTextButton"] }, { kind: "component", type: InputSpacerComponent, selector: "kendo-input-spacer, kendo-textbox-spacer", inputs: ["width"] }, { kind: "component", type: FileSelectComponent, selector: "kendo-fileselect", inputs: ["name"], outputs: ["valueChange"], exportAs: ["kendoFileSelect"] }, { kind: "component", type: SuggestedActionsComponent, selector: "kendo-chat-suggested-actions", inputs: ["actions", "suggestions", "tabbable", "type", "suggestionTemplate"], outputs: ["dispatchAction", "dispatchSuggestion"] }, { kind: "component", type: ChatFileComponent, selector: "li[chatFile]", inputs: ["chatFile", "removable", "fileActions"], outputs: ["remove", "actionClick", "actionsToggle", "actionButtonClick"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: MessageBoxComponent, decorators: [{
type: Component,
args: [{
selector: 'kendo-message-box',
template: `
@if (suggestions?.length > 0) {
<kendo-chat-suggested-actions
#suggestedActions
[suggestions]="suggestions"
type="suggestion"
[suggestionTemplate]="suggestionTemplate"
[tabbable]="true"
(dispatchSuggestion)="dispatchSuggestion($event)"
(click)="select(suggestedActions, $event)"
(focus)="select(suggestedActions, $event)"
></kendo-chat-suggested-actions>
}
@if (!messageBoxTemplate?.templateRef) {
<kendo-textarea
#messageBoxInput
class="k-message-box"
resizable="none"
[rows]="3"
[inputAttributes]="{
'aria-label': textFor('messageBoxInputLabel')
}"
[placeholder]="placeholder || textFor('messagePlaceholder')"
[showSuffixSeparator]="false"
(keydown)="textAreaKeydown($event)"
[value]="inputValue"
(valueChange)="onInputValueChange($event)"
>
@if (reply || (files && files.length > 0)) {
<kendo-textarea-prefix>
@if (reply) {
<div class="k-message-reference k-message-reference-sender" (click)="onReplyReferenceClick($event)">
<chat-message-reference-content [message]="reply"></chat-message-reference-content>
<span class="k-spacer"></span>
<button
kendoButton
[attr.title]="textFor('removeReplyTitle')"
[svgIcon]="deleteIcon"
(click)="removeReply()"
fillMode="flat"
></button>
</div>
}
<ul class="k-chat-file-wrapper">
@for (file of files; track file; let i = $index) {
<li class="k-chat-file"
[chatFile]="file"
[removable]="true"
(remove)="removeFile(i)"
></li>
}
</ul>
</kendo-textarea-prefix>
}
<kendo-textarea-suffix>
@if (speechToTextButtonSettings) {
<button
kendoSpeechToTextButton
[attr.title]="textFor('speechToTextButtonTitle')"
[continuous]="speechToTextButtonSettings?.continuous"
[disabled]="speechToTextButtonSettings?.disabled"
[fillMode]="speechToTextButtonSettings?.fillMode ?? 'clear'"
[integrationMode]="speechToTextButtonSettings?.integrationMode ?? 'webSpeech'"
[interimResults]="speechToTextButtonSettings?.interimResults"
[lang]="speechToTextButtonSettings?.lang"
[maxAlternatives]="speechToTextButtonSettings?.maxAlternatives"
[rounded]="speechToTextButtonSettings?.rounded"
[size]="speechToTextButtonSettings?.size"
[themeColor]="speechToTextButtonSettings?.themeColor"
(result)="handleSpeechResult($event)"
(start)="isListening = true"
(end)="isListening = false"
></button>
}
@if (enableFileSelect) {
<button
kendoButton
[attr.title]="textFor('fileSelectButtonTitle')"
[svgIcon]="attachmentIcon"
icon="attachment"
fillMode="clear"
(click)="selectFiles()"
></button>
}
<kendo-input-spacer></kendo-input-spacer>
<button
kendoButton
[fillMode]="sendButtonSettings?.fillMode"
[themeColor]="sendButtonSettings?.themeColor"
[rounded]="sendButtonSettings?.rounded"
[class]="sendButtonSettings?.buttonClass || 'k-chat-send'"
[icon]="sendButtonSettings?.icon"
[svgIcon]="sendButtonSettings?.svgIcon"
[tabindex]="0"
[attr.title]="textFor('send')"
[class.k-disabled]="isDisabledSendButton"
[attr.aria-disabled]="isDisabledSendButton"
(click)="sendClick()"
>
</button>
</kendo-textarea-suffix>
</kendo-textarea>
}
@if (messageBoxTemplate?.templateRef) {
<ng-template [ngTemplateOutlet]="messageBoxTemplate?.templateRef"></ng-template>
}
<kendo-fileselect
#fileSelect
class="k-hidden"
[multiple]="true"
[showFileList]="false"
(select)="handleFileSelect($event)"
></kendo-fileselect>
`,
standalone: true,
imports: [ButtonComponent, FormsModule, NgTemplateOutlet, TextAreaComponent, MessageReferenceComponent, TextAreaSuffixComponent, TextAreaPrefixComponent, SpeechToTextButtonComponent, InputSpacerComponent, FileSelectComponent, SuggestedActionsComponent, ChatFileComponent]
}]
}], ctorParameters: () => [{ type: i1.ChatService }, { type: i0.ChangeDetectorRef }, { type: i0.ElementRef }, { type: i0.Renderer2 }], propDecorators: { borderColor: [{
type: HostBinding,
args: ['style.border-color']
}], messageBoxWrapperClass: [{
type: HostBinding,
args: ['class.k-message-box-wrapper']
}], messageBoxInput: [{
type: ViewChild,
args: ['messageBoxInput']
}], fileSelectComponent: [{
type: ViewChild,
args: ['fileSelect']
}], suggestedActionsComponent: [{
type: ViewChild,
args: [SuggestedActionsComponent]
}], authorId: [{
type: Input
}], autoScroll: [{
type: Input
}], suggestions: [{
type: Input
}], placeholder: [{
type: Input
}], inputValue: [{
type: Input
}], localization: [{
type: Input
}], messageBoxTemplate: [{
type: Input
}], suggestionTemplate: [{
type: Input
}], sendMessage: [{
type: Output
}], executeSuggestion: [{
type: Output
}], fileSelect: [{
type: Output
}], fileRemove: [{
type: Output
}] } });