UNPKG

@progress/kendo-angular-conversational-ui

Version:

Kendo UI for Angular Conversational UI components

502 lines (495 loc) 22.8 kB
/**----------------------------------------------------------------------------------------- * 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 }] } });