diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index 71f834212..712dd344f 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -4,12 +4,14 @@ ```ts +import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import * as _angular_core from '@angular/core'; import { ElementRef } from '@angular/core'; import { FileUploadError } from '@siemens/element-ng/file-uploader'; import * as i1 from '@siemens/element-ng/resize-observer'; import { MenuItem } from '@siemens/element-ng/menu'; +import { OnDestroy } from '@angular/core'; import * as _siemens_element_translate_ng_translate_types from '@siemens/element-translate-ng/translate-types'; import { SiModalService } from '@siemens/element-ng/modal'; import { TemplateRef } from '@angular/core'; @@ -23,7 +25,7 @@ export interface Attachment { previewTemplate?: TemplateRef | (() => TemplateRef); } -// @public (undocumented) +// @public export interface ChatInputAttachment extends Attachment { file: File; size: number; @@ -77,7 +79,33 @@ export class SiAttachmentListComponent { static ɵfac: _angular_core.ɵɵFactoryDeclaration; } -// @public (undocumented) +// @public +export class SiChatContainerComponent implements AfterContentInit, OnDestroy { + constructor(); + readonly colorVariant: _angular_core.InputSignal; + focus(): void; + // (undocumented) + ngAfterContentInit(): void; + // (undocumented) + ngOnDestroy(): void; + readonly noAutoScroll: _angular_core.InputSignalWithTransform; + // (undocumented) + protected onScroll(): void; + // (undocumented) + static ɵcmp: _angular_core.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public +export class SiChatContainerInputDirective { + // (undocumented) + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: _angular_core.ɵɵFactoryDeclaration; +} + +// @public export class SiChatInputComponent implements AfterViewInit { readonly accept: _angular_core.InputSignal; readonly actionParam: _angular_core.InputSignal; diff --git a/docs/patterns/ai/ai-chat.md b/docs/patterns/ai/ai-chat.md index b28483083..be881270c 100644 --- a/docs/patterns/ai/ai-chat.md +++ b/docs/patterns/ai/ai-chat.md @@ -83,3 +83,21 @@ When the AI cannot complete a task due to a known limitation, it should respond These are not treated as errors and do not require a separate notification. ![AI errors and limitations](images/ai-errors.png) + +## Code --- + +Use the chat container with the chat messages to build chat message interfaces. + +The **si-chat-container** component is a wrapper component, it has slots for chat messages and an input + +The slots are: +- default (chat messages) +- `si-avatar/si-icon/img` - For the avatar or icon representing the message sender. +- `si-chat-input/siChatContainerInput (helper directive)` - For the input (whether default or custom). +- `si-inline-notification` - Slotted above the input for displaying the status. + + + + + + diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index 2fa83acd7..091329a60 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -115,3 +115,4 @@ test('si-chat-messages/si-user-message', ({ si }) => si.static()); test('si-chat-messages/si-chat-message', ({ si }) => si.static()); test('si-chat-messages/si-attachment-list', ({ si }) => si.static()); test('si-chat-messages/si-chat-input', ({ si }) => si.static()); +test('si-chat-messages/si-chat-container', ({ si }) => si.static()); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png new file mode 100644 index 000000000..306539191 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af8a39d7edb8ed34dcedb3f86fcf45a32c9c01c2539194f46ff0f9abe5e3a9db +size 45596 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png new file mode 100644 index 000000000..46c86dc07 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b386637c7ecd71dfdf358fcefb6de8c96b1bec0c846df9feea9aa1579dda41c +size 44278 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml new file mode 100644 index 000000000..8c06f612f --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container.yaml @@ -0,0 +1,30 @@ +- group: + - button "data-analysis.py" +- group: + - button "dataset.csv" +- paragraph: Can you help me analyze these files? +- paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. +- button "Copy message" +- paragraph: I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. +- paragraph: Let me examine the structure and provide guidance. +- button "Good response" +- button "Copy response" +- button "Retry response" +- button "More actions" +- paragraph: Perfect! What should I focus on first +- paragraph: I also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows? +- button "Copy message" +- paragraph: Great question! When analyzing large datasets, it's crucial to focus on... +- alert: Info AI responses are for demonstration purposes. +- group: + - text: requirements.pdf + - button "Remove attachment requirements.pdf" +- group: + - text: mockup.png + - button "Remove attachment mockup.png" +- textbox "Chat message input": + - /placeholder: Enter a command, question or topic… +- button "Attach file" +- button "Text formatting" +- button "Message templates" +- button "Send" diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png index a4ca0684e..f6996a1ae 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da41b13122636959844f35394c7a51c8df53fae54e2c7e38ee04f2bdbded8aff -size 21037 +oid sha256:4a945aa0a229d60d29e12c55313a2cede322a7f8985780eef464c1a61e8a1a88 +size 21049 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png index d7ff43a3f..66e7a9a24 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3b9cb32d78ddfe58a571b80487e73a38a64620a60f395a81a2f245af141ede8 -size 20133 +oid sha256:0ec236c9f4e4a2e6e279dfba82a8d50241d47ad587fda50d9dff25f044e0fc97 +size 20146 diff --git a/projects/element-ng/chat-messages/index.ts b/projects/element-ng/chat-messages/index.ts index ed9aaad10..d0442eb14 100644 --- a/projects/element-ng/chat-messages/index.ts +++ b/projects/element-ng/chat-messages/index.ts @@ -4,6 +4,8 @@ */ export * from './si-ai-message.component'; export * from './si-attachment-list.component'; +export * from './si-chat-container.component'; +export * from './si-chat-container-input.directive'; export * from './si-chat-input.component'; export * from './si-chat-input-disclaimer.directive'; export * from './si-chat-message-action.directive'; diff --git a/projects/element-ng/chat-messages/message-action.model.ts b/projects/element-ng/chat-messages/message-action.model.ts index cc50b0dd2..aa7b25239 100644 --- a/projects/element-ng/chat-messages/message-action.model.ts +++ b/projects/element-ng/chat-messages/message-action.model.ts @@ -5,8 +5,12 @@ import type { TranslatableString } from '@siemens/element-translate-ng/translate-types'; /** - * Actions for messages representing an action with icon, label (for accessibility), and handler. + * Actions for messages representing an action with icon, label (for accessibility), and handler, for use within {@link SiAiMessageComponent} and {@link SiUserMessageComponent}. * Only the icon will be displayed. + * + * @see {@link SiAiMessageComponent} for the AI message + * @see {@link SiUserMessageComponent} for thee user message + * * @experimental */ export interface MessageAction { diff --git a/projects/element-ng/chat-messages/si-ai-message.component.html b/projects/element-ng/chat-messages/si-ai-message.component.html index 34e04f4bf..6e5a47989 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.html +++ b/projects/element-ng/chat-messages/si-ai-message.component.html @@ -8,7 +8,7 @@ } } - @if (actions().length > 0 || secondaryActions().length > 0) { + @if ((actions()?.length ?? 0 > 0) || (secondaryActions()?.length ?? 0 > 0)) {
@for (action of actions(); track $index) {
* * ``` + * + * @see {@link SiChatInputComponent} for the chat input wrapper component + * + * @experimental */ @Directive({ selector: '[siChatInputDisclaimer]' diff --git a/projects/element-ng/chat-messages/si-chat-input.component.ts b/projects/element-ng/chat-messages/si-chat-input.component.ts index e17cdfc8f..68013121a 100644 --- a/projects/element-ng/chat-messages/si-chat-input.component.ts +++ b/projects/element-ng/chat-messages/si-chat-input.component.ts @@ -27,6 +27,18 @@ import { SiTranslatePipe, TranslatableString, t } from '@siemens/element-transla import { MessageAction } from './message-action.model'; import { SiAttachmentListComponent, Attachment } from './si-attachment-list.component'; +/** + * Attachment item interface for file attachments in chat messages, extension of {@link Attachment} for {@link SiAttachmentListComponent} to use within {@link SiChatInputComponent}. + * Adds the action file information. + * Can be used within {@link SiChatContainerComponent}. + * + * @see {@link Attachment} for base attachment interface + * @see {@link SiAttachmentListComponent} for the attachment list component + * @see {@link SiChatInputComponent} for the chat input component + * @see {@link SiChatContainerComponent} for the chat container component + * + * @experimental + */ export interface ChatInputAttachment extends Attachment { /** File object */ file: File; @@ -36,6 +48,28 @@ export interface ChatInputAttachment extends Attachment { type: string; } +/** + * Chat input component for composing and sending messages in conversational interfaces. + * + * The chat input component provides a text area for users to compose messages, + * supporting text, attachments, and contextual actions. It appears as a textarea + * with buttons for adding attachments and sending messages, as well as an optional disclaimer. + * + * The component automatically handles: + * - Styling for chat input and actions. + * - Dynamic resizing of the textarea based on content. + * - Uploading of and displaying of attachments above the input area. + * - Displaying primary and secondary actions. + * + * Additionally to the inputs and outputs documented here, the component supports content projection via the following slots: + * - Default content: Custom action buttons to display inline, prefer using the `actions` input for buttons, can be used in addition. + * - `siChatInputDisclaimer` selector: Custom disclaimer content to display below the input area, prefer using the `disclaimer` input for simple text disclaimers. + * + * @see {@link SiAttachmentListComponent} for the base attachment component + * @see {@link SiChatInputDisclaimerDirective} to slot in custom disclaimer content + * + * @experimental + */ @Component({ selector: 'si-chat-input', imports: [ diff --git a/projects/element-ng/chat-messages/si-chat-message-action.directive.ts b/projects/element-ng/chat-messages/si-chat-message-action.directive.ts index 0a43204cc..89a410db0 100644 --- a/projects/element-ng/chat-messages/si-chat-message-action.directive.ts +++ b/projects/element-ng/chat-messages/si-chat-message-action.directive.ts @@ -5,10 +5,9 @@ import { Directive } from '@angular/core'; /** - * Directive to mark content as chat message actions. + * Directive to mark content as chat message actions into {@link SiChatMessageComponent}. * Apply this directive to e.g. buttons that should be slotted into the message actions area. * - * @experimental * @example * ```html * @@ -17,6 +16,10 @@ import { Directive } from '@angular/core'; * * * ``` + * + * @see {@link SiChatMessageComponent} for the chat message wrapper component + * + * @experimental */ @Directive({ selector: '[siChatMessageAction]' diff --git a/projects/element-ng/chat-messages/si-chat-message.component.html b/projects/element-ng/chat-messages/si-chat-message.component.html index 22f2648f2..c58c18b35 100644 --- a/projects/element-ng/chat-messages/si-chat-message.component.html +++ b/projects/element-ng/chat-messages/si-chat-message.component.html @@ -23,6 +23,7 @@
} } - @if (actions().length > 0 || secondaryActions().length > 0) { + @if ((actions()?.length ?? 0 > 0) || (secondaryActions()?.length ?? 0 > 0)) {
@for (action of actions(); track $index) { + } + + } + } + + @if ( + loading() && (messages().length === 0 || messages()[messages().length - 1].type !== 'ai') + ) { + + } + + + + + +
+ + + + + diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts new file mode 100644 index 000000000..335ce8f05 --- /dev/null +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -0,0 +1,289 @@ +/** + * Copyright (c) Siemens 2016 - 2025 + * SPDX-License-Identifier: MIT + */ +import { Component, inject, signal, TemplateRef, viewChild } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { + SiChatContainerComponent, + SiAiMessageComponent, + SiUserMessageComponent, + SiChatInputComponent, + SiChatMessageComponent, + ChatInputAttachment, + MessageAction, + SiChatMessageActionDirective, + SiAttachmentListComponent, + Attachment +} from '@siemens/element-ng/chat-messages'; +import { SiIconComponent } from '@siemens/element-ng/icon'; +import { SiInlineNotificationComponent } from '@siemens/element-ng/inline-notification'; +import { + getMarkdownRenderer, + SiMarkdownRendererComponent +} from '@siemens/element-ng/markdown-renderer'; +import { MenuItem } from '@siemens/element-ng/menu'; +import { LOG_EVENT } from '@siemens/live-preview'; + +interface ChatMessage { + type: 'user' | 'ai' | 'custom'; + content: string; + attachments?: Attachment[]; + actions?: MessageAction[]; +} + +@Component({ + selector: 'app-sample', + imports: [ + SiChatContainerComponent, + SiAiMessageComponent, + SiUserMessageComponent, + SiInlineNotificationComponent, + SiChatInputComponent, + SiChatMessageComponent, + SiIconComponent, + SiMarkdownRendererComponent, + SiChatMessageActionDirective, + SiAttachmentListComponent + ], + templateUrl: './si-chat-container.html' +}) +export class SampleComponent { + private logEvent = inject(LOG_EVENT); + private readonly modalTemplate = viewChild>('modalTemplate'); + private sanitizer = inject(DomSanitizer); + + protected markdownRenderer = getMarkdownRenderer(this.sanitizer); + + readonly preAttachedFiles: ChatInputAttachment[] = [ + { + name: 'requirements.pdf', + size: 1234567, + type: 'application/pdf', + file: new File([''], 'requirements.pdf', { type: 'application/pdf' }) + }, + { + name: 'mockup.png', + size: 654321, + type: 'image/png', + file: new File([''], 'mockup.png', { type: 'image/png' }) + } + ]; + + readonly messages = signal([ + { + type: 'user', + content: `Can you help me analyze these files? + + I'm having trouble understanding the data structure + and need assistance with the implementation.`, + attachments: [ + { + name: 'data-analysis.py', + previewTemplate: () => this.modalTemplate()! + }, + { + name: 'dataset.csv', + previewTemplate: () => this.modalTemplate()! + } + ], + actions: [ + { + label: 'Copy message', + icon: 'element-export', + action: (message: ChatMessage) => + this.logEvent(`Copy user message ${message.content.slice(0, 20)}...`) + } + ] + }, + { + type: 'ai', + content: `I'd be happy to help you analyze your files! I can see you've shared a Python script and a CSV dataset. + + Let me examine the structure and provide guidance.`, + actions: [ + { + label: 'Good response', + icon: 'element-plus', + action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + }, + { + label: 'Copy response', + icon: 'element-export', + action: (_message: ChatMessage) => this.logEvent('Copy AI message') + }, + { + label: 'Retry response', + icon: 'element-refresh', + action: (_message: ChatMessage) => this.logEvent('Retry AI message') + }, + { + label: 'Bookmark', + icon: 'element-bookmark', + action: (_message: ChatMessage) => this.logEvent('Bookmark AI message') + } + ] + }, + { + type: 'user', + content: + 'Perfect! What should I focus on first\n\nI also want to make sure the performance is optimized for large datasets since this will be used in production with potentially millions of rows?', + actions: [ + { + label: 'Copy message', + icon: 'element-export', + action: (_message: ChatMessage) => + this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) + } + ] + }, + { + type: 'ai', + content: "Great question! When analyzing large datasets, it's crucial to focus on..." + } + ]); + + readonly loading = signal(false); + readonly sending = signal(false); + readonly disabled = signal(false); + readonly disableInterrupt = signal(false); + readonly interrupting = signal(false); + readonly inputValue = signal(''); + + inputActions: MessageAction[] = [ + { + label: 'Text formatting', + icon: 'element-brush', + action: () => this.logEvent('Text formatting clicked') + }, + { + label: 'Message templates', + icon: 'element-template', + action: () => this.logEvent('Templates clicked') + } + ]; + + userActions: MessageAction[] = [ + { + label: 'Copy message', + icon: 'element-export', + action: (_message: ChatMessage) => + this.logEvent(`Copy user message ${_message.content.slice(0, 20)}...`) + }, + { + label: 'Delete message', + icon: 'element-delete', + action: (_message: ChatMessage) => + this.logEvent(`Delete user message ${_message.content.slice(0, 20)}...`) + } + ]; + + aiActions: MessageAction[] = [ + { + label: 'Good response', + icon: 'element-plus', + action: (_message: ChatMessage) => this.logEvent('Thumbs up for AI message') + }, + { + label: 'Copy response', + icon: 'element-export', + action: (_message: ChatMessage) => this.logEvent('Copy AI message') + } + ]; + + onMessageSent(event: { content: string; attachments: ChatInputAttachment[] }): void { + this.logEvent(`Message sent: "${event.content}" with ${event.attachments.length} attachments`); + this.messages.update(current => [ + ...current, + { + type: 'user', + content: event.content, + actions: [ + { + label: 'Copy message', + icon: 'element-export', + action: () => this.logEvent('Copy user message') + } + ], + attachments: event.attachments.map(att => ({ + name: att.name, + previewTemplate: () => this.modalTemplate()! + })) + } + ]); + this.simulateAiResponse(event.content); + } + + onInterrupt(): void { + this.logEvent('Interrupt clicked'); + this.loading.set(false); + this.interrupting.set(false); + } + + private simulateAiResponse(userInput: string): void { + this.sending.set(true); + + setTimeout(() => { + this.sending.set(false); + this.loading.set(true); + + setTimeout(() => { + const response = `Thanks for your message: "${userInput}". I can help with that!`; + + this.messages.update(current => [ + ...current, + { + type: 'ai', + content: response, + actions: [ + { + label: 'Good response', + icon: 'element-plus', + action: () => this.logEvent('Thumbs up for AI message') + }, + { + label: 'Copy response', + icon: 'element-export', + action: () => this.logEvent('Copy AI message') + } + ] + } + ]); + this.loading.set(false); + }, 2000); + }, 1000); + } + + private getMessageActions(message: ChatMessage): { + primary: MessageAction[]; + secondary: MenuItem[]; + } { + const actions = message.actions ?? []; + let primary: MessageAction[] = []; + let secondary: MenuItem[] = []; + + const primaryActions = actions.slice(0, 3); + const secondaryActions = actions.slice(3); + + primary = primaryActions; + secondary = secondaryActions.map( + action => + ({ + ...action, + action: action.action as unknown as (actionParam: any, source: MenuItem) => void, + type: 'action' + }) as MenuItem + ); + + const result = { primary, secondary }; + return result; + } + + protected getMessagePrimaryActions(message: ChatMessage): MessageAction[] { + return this.getMessageActions(message).primary; + } + + protected getMessageSecondaryActions(message: ChatMessage): MenuItem[] { + return this.getMessageActions(message).secondary; + } +}