diff --git a/api-goldens/element-ng/chat-messages/index.api.md b/api-goldens/element-ng/chat-messages/index.api.md index eb8b04b3f5..59689ff5a7 100644 --- a/api-goldens/element-ng/chat-messages/index.api.md +++ b/api-goldens/element-ng/chat-messages/index.api.md @@ -12,8 +12,10 @@ 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 { OnInit } from '@angular/core'; import * as _siemens_element_translate_ng_translate from '@siemens/element-translate-ng/translate'; import { SiModalService } from '@siemens/element-ng/modal'; +import { SiPopoverDirective } from '@siemens/element-ng/popover'; import { TemplateRef } from '@angular/core'; import { TranslatableString } from '@siemens/element-translate-ng/translate-types'; import { TranslatableString as TranslatableString_2 } from '@siemens/element-translate-ng/translate'; @@ -57,6 +59,8 @@ export class SiAiMessageComponent { constructor(); readonly actionParam: _angular_core.InputSignal; readonly actions: _angular_core.InputSignal; + readonly annotatedText: _angular_core.InputSignal; + readonly citationClicked: _angular_core.OutputEmitterRef; readonly content: _angular_core.InputSignal; readonly contentFormatter: _angular_core.InputSignal<((text: string) => string | Node) | undefined>; readonly loading: _angular_core.InputSignalWithTransform; @@ -81,6 +85,28 @@ export class SiAttachmentListComponent { readonly removeLabel: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; } +// @public +export interface SiChatAnnotatedText { + citations: SiChatCitation[]; + segments: SiChatTextSegment[]; +} + +// @public +export interface SiChatCitation { + description?: string; + id: string; + title: string; + url?: string; +} + +// @public +export interface SiChatCitationRun { + // (undocumented) + citationId: string; + // (undocumented) + type: 'citation'; +} + // @public export class SiChatContainerComponent implements AfterContentInit, OnDestroy { constructor(); @@ -143,6 +169,25 @@ export class SiChatMessageComponent { readonly loading: _angular_core.InputSignal; } +// @public +export interface SiChatTextRun { + // (undocumented) + content: string; + // (undocumented) + type: 'text'; +} + +// @public (undocumented) +export type SiChatTextSegment = SiChatTextRun | SiChatCitationRun; + +// @public +export class SiCitationPillComponent implements OnInit { + readonly citation: _angular_core.InputSignal; + readonly clicked: _angular_core.OutputEmitterRef; + readonly icon: _angular_core.InputSignal; + readonly label: _angular_core.InputSignal<_siemens_element_translate_ng_translate.TranslatableString>; +} + // @public export class SiUserMessageComponent { constructor(); diff --git a/playwright/e2e/element-examples/static.spec.ts b/playwright/e2e/element-examples/static.spec.ts index 5da8c2f544..b07211cc78 100644 --- a/playwright/e2e/element-examples/static.spec.ts +++ b/playwright/e2e/element-examples/static.spec.ts @@ -112,13 +112,13 @@ test('typography/display-styles', ({ si }) => si.static()); test('typography/typography', ({ si }) => si.static()); test('si-markdown-renderer/si-markdown-renderer', ({ si }) => si.static({ disabledA11yRules: ['link-in-text-block'] })); -test('si-chat-messages/si-ai-message', ({ si }) => si.static()); +test('si-chat-messages/si-ai-message', ({ si }) => si.static({ withPopover: 'si-citation-pill' })); 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()); -// FIXME: test is unstable -// test('si-chat-messages/si-chat-container', ({ si }) => si.static()); +test('si-chat-messages/si-chat-container', ({ si }) => + si.static({ withPopover: 'si-citation-pill' })); test('si-chat-messages/si-ai-welcome-screen', ({ si }) => si.static()); test('ag-grid/ag-grid-empty-state', async ({ si }) => { await si.static({ disabledA11yRules: ['aria-required-children'] }); diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..f042561a2a --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40d32452672770e6756d4b87922c7af5e356df7f34bafe85a9c28520fe182d03 +size 41235 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..bb7ab95e7b --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9121c04b6afad0c0aafa06bdae46070a3eaf69a4bf7fa0439a6bbe5bfd4870a +size 41041 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover.yaml new file mode 100644 index 0000000000..853662aa73 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message--popover.yaml @@ -0,0 +1,36 @@ +- paragraph: + - text: Here's a + - strong: simple response + - text: with basic formatting. +- paragraph: + - text: You can use + - code: inline code + - text: "and create lists:" +- list: + - listitem: First item + - listitem: Second item +- group: + - button "Good response" + - button "Bad response" + - button "Copy response" + - button "More actions" +- text: Neural networks are composed of layers of interconnected nodes that process information in parallel, enabling the model to learn complex patterns from large datasets. +- group: + - link "Deep Learning – Ian Goodfellow et al.": + - /url: https://examples.org/deeplearningbook + - text: "" +- text: The training process adjusts the weights of these connections to minimize prediction error using backpropagation. +- group: + - link "Backpropagation Algorithm – Stanford CS231n": + - /url: https://examples.org/cs231n + - text: "" +- group: + - button "Good response" + - button "Bad response" + - button "Copy response" + - button "More actions" +- dialog "Deep Learning – Ian Goodfellow et al.": + - text: "" + - paragraph: Neural networks are universal function approximators composed of alternating linear transformations and non-linear activations, trained end-to-end via gradient descent. + - link "View source": + - /url: https://examples.org/deeplearningbook \ No newline at end of file diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png index 93e64aba3f..fe53cd015f 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-dark-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ef537fcdb4bdf2010c69e492639696ffb7a5c6b90268cedf3eb1a03a8b091650 -size 14065 +oid sha256:036300a4ecdac246dfec6835e7fa22acc0a130fdb9563f9f880576deae723df5 +size 28748 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png index bb5390101b..86710f4285 100644 --- a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-ai-message-element-examples-chromium-light-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d658adc4170aab7747ccfc062501489d14a49da294f3589744555a18f28168a -size 13747 +oid sha256:794b1c9959a95501cfc3d30938041ad06031d5c9a0e2d024cb665b5fa37e2863 +size 28240 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-dark-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-dark-linux.png new file mode 100644 index 0000000000..ed08fc8ee9 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-dark-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9872250b0514d43d0c420269c0812f48176524fbedd3870c5d4bfc4583a5529f +size 58732 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-light-linux.png b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-light-linux.png new file mode 100644 index 0000000000..a77cbf58e0 --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover-element-examples-chromium-light-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63b62222a7cd1d445b2513604e5447142b66054be349a07095f70af4ef61f2b8 +size 57626 diff --git a/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover.yaml b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover.yaml new file mode 100644 index 0000000000..ed77d5cece --- /dev/null +++ b/playwright/snapshots/static.spec.ts-snapshots/si-chat-messages--si-chat-container--popover.yaml @@ -0,0 +1,51 @@ +- 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. +- group: + - button "Export message" +- text: I'd be happy to help! Data processing pipelines typically follow a structured approach. +- group: + - link "Data Pipeline Design Patterns": + - /url: https://examples.org/articles/data-pipeline.html + - text: "" +- text: Let me examine your files and provide detailed guidance. +- group: + - button "Add to list" + - button "Export 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? +- group: + - button "Export message" +- text: Great question! When analyzing large datasets, it's crucial to focus on vectorized operations and avoid row-by-row iteration. +- group: + - link "Pandas Performance Guide": + - /url: https://examples.org/docs/user_guide/enhancingperf.html +- group: + - button "Add to list" + - button "Export response" + - button "Retry response" + - button "More actions" +- 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... +- group: + - button "More actions" + - button "Show Welcome Screen" +- button "Send" +- text: The content is AI generated. Always verify the information for accuracy. +- dialog "Data Pipeline Design Patterns": + - text: "" + - paragraph: Pipelines can be structured as linear chains or branching graphs. Each stage transforms data independently, enabling testability and reuse. + - link "View source": + - /url: https://examples.org/articles/data-pipeline.html \ No newline at end of file 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 index 3057e147e0..f4417d071c 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6f50563410762eac81d2279c1f1ae92118c3bd0a4b6e15e32a7b375663a25328 -size 42912 +oid sha256:fff7efbb2cb3a4ed3158b9a1e7612e6cb8f358e8375edaf66cf78c913917b5ae +size 46265 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 index a3d51e5cfd..168fe7585a 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2436212ab17e67d02fe514f185a435145d94bc653e57851a13b14fd874ea18a6 -size 42033 +oid sha256:211eba48a9d5193c346f777ef7bbfd5dfcddec00b6f478c19c50287bfc1ff542 +size 45346 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 index d67ffb9a2c..09bde1befd 100644 --- 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 @@ -6,22 +6,29 @@ - paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. - group: - button "Export 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. +- text: I'd be happy to help! Data processing pipelines typically follow a structured approach. - group: - - button "Good response" - - button "Bad response" - - button "Copy response" + - link "Data Pipeline Design Patterns": + - /url: https://examples.org/articles/data-pipeline.html + - text: "" +- text: Let me examine your files and provide detailed guidance. +- group: + - button "Add to list" + - button "Export 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? - group: - button "Export message" -- paragraph: Great question! When analyzing large datasets, it's crucial to focus on... +- text: Great question! When analyzing large datasets, it's crucial to focus on vectorized operations and avoid row-by-row iteration. +- group: + - link "Pandas Performance Guide": + - /url: https://examples.org/docs/user_guide/enhancingperf.html - group: - - button "Good response" - - button "Bad response" - - button "Copy response" + - button "Add to list" + - button "Export response" + - button "Retry response" - button "More actions" - alert: Info AI responses are for demonstration purposes. - group: diff --git a/playwright/support/test-helpers.ts b/playwright/support/test-helpers.ts index 345fe728cf..aeb3f13143 100644 --- a/playwright/support/test-helpers.ts +++ b/playwright/support/test-helpers.ts @@ -64,6 +64,8 @@ export type StaticTestOptions = { waitCallback?: (page: Page) => Promise; skipAutoScaleViewport?: boolean; skipAriaSnapshot?: boolean; + /** CSS selector of an element to hover after the initial snapshot to open a popover for a second snapshot. */ + withPopover?: string; }; // Playwright since 1.48 has the mouse cursor at 0/0 causing any element at this coordinate to be @@ -113,6 +115,15 @@ class SiTestHelpers { maxDiffPixels: options?.maxDiffPixels, skipAriaSnapshot: options?.skipAriaSnapshot }); + if (options?.withPopover) { + await this.page.locator(options.withPopover).first().hover(); + await expect(this.page.getByRole('dialog')).toBeVisible(); + await this.runVisualAndA11yTests(step ? `${step}--popover` : 'popover', { + axeRulesSet: options?.disabledA11yRules?.map(item => ({ id: item, enabled: false })), + maxDiffPixels: options?.maxDiffPixels, + skipAriaSnapshot: options?.skipAriaSnapshot + }); + } } } } diff --git a/projects/element-ng/chat-messages/index.ts b/projects/element-ng/chat-messages/index.ts index 1b1dd4ecff..77a3344c09 100644 --- a/projects/element-ng/chat-messages/index.ts +++ b/projects/element-ng/chat-messages/index.ts @@ -3,7 +3,9 @@ * SPDX-License-Identifier: MIT */ export * from './si-ai-message.component'; +export * from './si-annotated-text.model'; export * from './si-attachment-list.component'; +export * from './si-citation-pill.component'; export * from './si-chat-container.component'; export * from './si-chat-container-input.directive'; export * from './si-chat-input.component'; 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 2d2698b7f3..d30f442468 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.html +++ b/projects/element-ng/chat-messages/si-ai-message.component.html @@ -1,5 +1,20 @@ - @if (content()) { + @if (annotatedText(); as annotated) { + + @for (segment of annotated.segments; track $index) { + @if (segment.type === 'text') { + {{ segment.content }} + } @else { + @let cit = getCitation(segment.citationId); +
+ } + } +
+ } @else if (content()) { @let content = textContent(); @if (content) { {{ content }} diff --git a/projects/element-ng/chat-messages/si-ai-message.component.scss b/projects/element-ng/chat-messages/si-ai-message.component.scss index f457b9dbbc..ffb7262305 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.scss +++ b/projects/element-ng/chat-messages/si-ai-message.component.scss @@ -18,6 +18,10 @@ si-chat-message { margin-block-start: map.get(variables.$spacers, 5) - map.get(variables.$spacers, 2); } +.annotated-content { + line-height: 20px; +} + // Loading spinner size adjustment (inherited from generic component) :host ::ng-deep si-loading-spinner { --loading-spinner-size: 1.5em; diff --git a/projects/element-ng/chat-messages/si-ai-message.component.ts b/projects/element-ng/chat-messages/si-ai-message.component.ts index 6f5f9f8b5a..436718ef67 100644 --- a/projects/element-ng/chat-messages/si-ai-message.component.ts +++ b/projects/element-ng/chat-messages/si-ai-message.component.ts @@ -8,6 +8,7 @@ import { Component, effect, input, + output, viewChild, ElementRef, signal @@ -18,8 +19,10 @@ import { MenuItem, SiMenuFactoryComponent } from '@siemens/element-ng/menu'; import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; import { MessageAction } from './message-action.model'; +import { SiChatAnnotatedText, SiChatCitation } from './si-annotated-text.model'; import { SiChatMessageActionDirective } from './si-chat-message-action.directive'; import { SiChatMessageComponent } from './si-chat-message.component'; +import { SiCitationPillComponent } from './si-citation-pill.component'; /** * AI message component for displaying AI-generated responses in conversational interfaces. @@ -47,6 +50,7 @@ import { SiChatMessageComponent } from './si-chat-message.component'; imports: [ CdkMenuTrigger, SiChatMessageComponent, + SiCitationPillComponent, SiIconComponent, SiMenuFactoryComponent, SiChatMessageActionDirective, @@ -59,6 +63,29 @@ export class SiAiMessageComponent { protected readonly formattedContent = viewChild>('formattedContent'); protected readonly icons = addIcons({ elementOptionsVertical }); + /** + * Pre-segmented annotated text containing inline citation references. + * When provided, takes precedence over the `content` input. + * Use `parseCitationMarkers` or `parseCitationOffsets` to produce this value. + * @defaultValue undefined + */ + readonly annotatedText = input(undefined); + + /** + * Emitted when a citation pill inside the message is clicked. + * The emitted value is the {@link SiChatCitation} that was clicked. + */ + readonly citationClicked = output(); + + protected getCitation(id: string): SiChatCitation { + return ( + this.annotatedText()?.citations.find(c => c.id === id) ?? { + id, + title: id + } + ); + } + /** * The AI-generated message content * @defaultValue '' diff --git a/projects/element-ng/chat-messages/si-annotated-text.model.ts b/projects/element-ng/chat-messages/si-annotated-text.model.ts new file mode 100644 index 0000000000..68e7eca9ae --- /dev/null +++ b/projects/element-ng/chat-messages/si-annotated-text.model.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ + +/** A single source citation referenced within an AI message. */ +export interface SiChatCitation { + /** Unique identifier used to match citation segments to this citation. */ + id: string; + /** Human-readable title of the source (e.g. page title or document name). */ + title: string; + /** Optional URL to the original source. */ + url?: string; + /** Optional short snippet showing the exact passage or section the AI used. */ + description?: string; +} + +/** A plain-text run within an annotated message. */ +export interface SiChatTextRun { + type: 'text'; + content: string; +} + +/** A citation placeholder within an annotated message that maps to a {@link SiChatCitation}. */ +export interface SiChatCitationRun { + type: 'citation'; + citationId: string; +} + +export type SiChatTextSegment = SiChatTextRun | SiChatCitationRun; + +/** + * Normalized representation of an AI message that contains inline citations. + * + * Produced by helper functions such as `parseCitationMarkers` or + * `parseCitationOffsets` and consumed by {@link SiAiMessageComponent} via its + * `annotatedText` input. + */ +export interface SiChatAnnotatedText { + /** Ordered segments that make up the full message content. */ + segments: SiChatTextSegment[]; + /** All citations referenced by the segments. */ + citations: SiChatCitation[]; +} diff --git a/projects/element-ng/chat-messages/si-citation-pill.component.scss b/projects/element-ng/chat-messages/si-citation-pill.component.scss new file mode 100644 index 0000000000..cf2f467dff --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.scss @@ -0,0 +1,44 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: inline-block; + position: relative; + margin-inline: map.get(variables.$spacers, 3); + vertical-align: middle; +} + +.citation-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding-block: 0; + padding-inline: 0.375rem; + block-size: 20px; + box-sizing: border-box; + border: 1px solid variables.$element-ui-3; + border-radius: 9999px; + color: variables.$element-text-primary; + background: transparent; + font-size: 0.75rem; + line-height: 1; + font-weight: 400; + cursor: pointer; + text-decoration: none; + vertical-align: middle; + white-space: nowrap; + transition: background 0.15s ease; + + &:hover, + &:active { + background: variables.$element-base-1-hover; + border-color: variables.$element-ui-3; + color: variables.$element-text-primary; + text-decoration: none; + } + + &:focus-visible { + outline: 2px solid variables.$element-focus-default; + outline-offset: 2px; + } +} diff --git a/projects/element-ng/chat-messages/si-citation-pill.component.spec.ts b/projects/element-ng/chat-messages/si-citation-pill.component.spec.ts new file mode 100644 index 0000000000..4ab2758672 --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.spec.ts @@ -0,0 +1,267 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { DebugElement, inputBinding, signal, WritableSignal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { elementGlobal } from '@siemens/element-icons'; +import { SiPopoverDirective } from '@siemens/element-ng/popover'; + +import { SiChatCitation } from './si-annotated-text.model'; +import { SiCitationPillComponent as TestComponent } from './si-citation-pill.component'; + +const CITATION_WITH_URL: SiChatCitation = { + id: '1', + title: 'Deep Learning – Ian Goodfellow et al.', + url: 'https://www.deeplearningbook.org' +}; + +const CITATION_WITHOUT_URL: SiChatCitation = { + id: '2', + title: 'Backpropagation Algorithm – Stanford CS231n' +}; + +const CITATION_WITH_DESCRIPTION: SiChatCitation = { + id: '3', + title: 'Neural Networks – Deep Dive', + description: 'An excerpt describing neural networks in depth.', + url: 'https://example.com/neural-networks' +}; + +describe('SiCitationPillComponent', () => { + let fixture: ComponentFixture; + let debugElement: DebugElement; + let citation: WritableSignal; + let icon: WritableSignal; + + let clickedValues: SiChatCitation[]; + + beforeEach(() => { + citation = signal(CITATION_WITH_URL); + icon = signal(undefined); + clickedValues = []; + + fixture = TestBed.createComponent(TestComponent, { + bindings: [inputBinding('citation', citation), inputBinding('icon', icon)] + }); + debugElement = fixture.debugElement; + fixture.componentInstance.clicked.subscribe(c => clickedValues.push(c)); + }); + + describe('when citation has a URL', () => { + beforeEach(() => { + citation.set(CITATION_WITH_URL); + }); + + it('should render an anchor element', async () => { + await fixture.whenStable(); + + const anchor = debugElement.query(By.css('a.citation-pill')); + expect(anchor).toBeTruthy(); + }); + + it('should not render a button element', async () => { + await fixture.whenStable(); + + const button = debugElement.query(By.css('button.citation-pill')); + expect(button).toBeFalsy(); + }); + + it('should set href, target and rel on the anchor', async () => { + await fixture.whenStable(); + + const anchor: HTMLAnchorElement = debugElement.query(By.css('a.citation-pill')).nativeElement; + expect(anchor.getAttribute('href')).toBe(CITATION_WITH_URL.url); + expect(anchor.target).toBe('_blank'); + expect(anchor.rel).toContain('noopener'); + expect(anchor.rel).toContain('noreferrer'); + }); + + it('should set title and aria-label from citation title', async () => { + await fixture.whenStable(); + + const anchor: HTMLAnchorElement = debugElement.query(By.css('a.citation-pill')).nativeElement; + expect(anchor.title).toBe(CITATION_WITH_URL.title); + expect(anchor.getAttribute('aria-label')).toBe(CITATION_WITH_URL.title); + }); + + it('should emit clicked with the citation and prevent default navigation on click', async () => { + await fixture.whenStable(); + + const anchor: HTMLAnchorElement = debugElement.query(By.css('a.citation-pill')).nativeElement; + const event = new MouseEvent('click', { bubbles: true, cancelable: true }); + anchor.dispatchEvent(event); + await fixture.whenStable(); + + expect(event.defaultPrevented).toBe(true); + expect(clickedValues).toHaveLength(1); + expect(clickedValues[0]).toBe(CITATION_WITH_URL); + }); + }); + + describe('when citation has no URL', () => { + beforeEach(() => { + citation.set(CITATION_WITHOUT_URL); + }); + + it('should render a button element', async () => { + await fixture.whenStable(); + + const button = debugElement.query(By.css('button.citation-pill')); + expect(button).toBeTruthy(); + }); + + it('should not render an anchor element', async () => { + await fixture.whenStable(); + + const anchor = debugElement.query(By.css('a.citation-pill')); + expect(anchor).toBeFalsy(); + }); + + it('should set title and aria-label from citation title', async () => { + await fixture.whenStable(); + + const button: HTMLButtonElement = debugElement.query( + By.css('button.citation-pill') + ).nativeElement; + expect(button.title).toBe(CITATION_WITHOUT_URL.title); + expect(button.getAttribute('aria-label')).toBe(CITATION_WITHOUT_URL.title); + }); + + it('should emit clicked with the citation when button is clicked', async () => { + await fixture.whenStable(); + + const button: HTMLButtonElement = debugElement.query( + By.css('button.citation-pill') + ).nativeElement; + button.click(); + await fixture.whenStable(); + + expect(clickedValues).toHaveLength(1); + expect(clickedValues[0]).toBe(CITATION_WITHOUT_URL); + }); + }); + + describe('icon', () => { + it('should show the default icon when no icon input is set', async () => { + fixture = TestBed.createComponent(TestComponent, { + bindings: [inputBinding('citation', citation)] + }); + debugElement = fixture.debugElement; + await fixture.whenStable(); + + const iconEl = debugElement.query(By.css('si-icon')); + expect(iconEl).toBeTruthy(); + expect(iconEl.componentInstance.icon()).toBe(elementGlobal); + }); + + it('should hide the icon when icon is set to undefined', async () => { + icon.set('element-bookmark'); + await fixture.whenStable(); + expect(debugElement.query(By.css('si-icon'))).toBeTruthy(); + + icon.set(undefined); + await fixture.whenStable(); + + const iconEl = debugElement.query(By.css('si-icon')); + expect(iconEl).toBeFalsy(); + }); + + it('should render a custom icon when icon input is provided', async () => { + const customIcon = 'element-bookmark'; + icon.set(customIcon); + await fixture.whenStable(); + + const iconEl = debugElement.query(By.css('si-icon')); + expect(iconEl).toBeTruthy(); + expect(iconEl.componentInstance.icon()).toBe(customIcon); + }); + }); + + describe('popover', () => { + const getPopoverDir = (): SiPopoverDirective => + debugElement.query(By.directive(SiPopoverDirective)).injector.get(SiPopoverDirective); + + beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); + afterEach(async () => { + // Flush the applyFocus setTimeout while the overlay is still attached so + // the resulting afterNextRender runs against a valid injector. + vi.advanceTimersByTime(10); + await fixture.whenStable(); + getPopoverDir().hide(); + vi.useRealTimers(); + }); + + it('should open the popover on mouseenter', async () => { + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); + await fixture.whenStable(); + + expect(document.querySelector('.popover')).toBeInTheDocument(); + }); + + it('should show the citation title in the popover', async () => { + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); + await fixture.whenStable(); + + expect(document.querySelector('.popover')).toHaveTextContent(CITATION_WITH_URL.title); + }); + + it('should show the description in the popover when present', async () => { + citation.set(CITATION_WITH_DESCRIPTION); + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); + await fixture.whenStable(); + + expect(document.querySelector('.popover')).toHaveTextContent( + CITATION_WITH_DESCRIPTION.description! + ); + }); + + it('should show a "View source" link in the popover when citation has a URL', async () => { + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); + await fixture.whenStable(); + + const link = document.querySelector('.popover a[target="_blank"]') as HTMLAnchorElement; + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toBe(CITATION_WITH_URL.url); + }); + + it('should not show a "View source" link in the popover when citation has no URL', async () => { + citation.set(CITATION_WITHOUT_URL); + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); + await fixture.whenStable(); + + expect(document.querySelector('.popover a[target="_blank"]')).toBeFalsy(); + }); + + it('should close the popover on mouseleave after a short delay', async () => { + await fixture.whenStable(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + vi.advanceTimersByTime(10); // flush applyFocus timer while overlay is alive + await fixture.whenStable(); + expect(document.querySelector('.popover')).toBeInTheDocument(); + + fixture.nativeElement.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + vi.advanceTimersByTime(200); // exceeds the 150 ms scheduleHide delay + await fixture.whenStable(); + + expect(document.querySelector('.popover')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/projects/element-ng/chat-messages/si-citation-pill.component.ts b/projects/element-ng/chat-messages/si-citation-pill.component.ts new file mode 100644 index 0000000000..d6f51f1132 --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.ts @@ -0,0 +1,217 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { DOCUMENT } from '@angular/common'; +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + inject, + input, + OnInit, + output, + signal, + viewChild +} from '@angular/core'; +import { elementGlobal } from '@siemens/element-icons'; +import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; +import { + SiPopoverDirective, + SiPopoverTitleDirective, + SiPopoverBodyDirective +} from '@siemens/element-ng/popover'; +import { SiTranslatePipe, t } from '@siemens/element-translate-ng/translate'; + +import { SiChatCitation } from './si-annotated-text.model'; + +/** + * Inline citation pill button displayed within AI message text. + * + * Renders a fully-rounded pill-shaped button that references a single source + * citation. When a URL is provided on the citation it acts as a link and opens + * the source in a new tab; otherwise it renders as a plain button. + * In both cases a {@link SiCitationPillComponent#clicked} event is emitted on click, + * allowing the host to intercept default navigation and implement custom behaviour. + * + * Intended to be used inside the annotated-text rendering path of + * {@link SiAiMessageComponent}. + * + * @experimental + */ +@Component({ + selector: 'si-citation-pill', + imports: [ + SiIconComponent, + SiTranslatePipe, + SiPopoverDirective, + SiPopoverTitleDirective, + SiPopoverBodyDirective + ], + template: ` + + @if (citation().url) { + + @if (icon()) { + + } @else { + + } + + + + {{ citation().title }} + + @if (citation().description) { +

{{ citation().description }}

+ } + @if (citation().url) { + {{ viewSource | translate }} + + + } +
+
+ `, + styleUrl: './si-citation-pill.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'si-citation-pill-host', + '(mouseenter)': 'onMouseEnter()', + '(mouseleave)': 'scheduleHide()' + } +}) +export class SiCitationPillComponent implements OnInit { + protected readonly icons = addIcons({ elementGlobal }); + protected readonly viewSource = t(() => $localize`:@@SI_CITATION_PILL.VIEW_SOURCE:View source`); + private readonly elementRef = inject(ElementRef); + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + private hideTimer: ReturnType | undefined; + private overlayListenersAttached = false; + private observer: MutationObserver | undefined; + + /** The citation data to display. */ + readonly citation = input.required(); + + /** + * Icon displayed inside the pill. Pass `undefined` to hide the icon. + * @defaultValue elementGlobal + */ + readonly icon = input(elementGlobal); + + /** + * Label text displayed inside the pill. + * + * @defaultValue + * ``` + * t(() => $localize`:@@SI_CITATION_PILL.LABEL:Source for this paragraph`) + * ``` + */ + readonly label = input(t(() => $localize`:@@SI_CITATION_PILL.LABEL:Source for this paragraph`)); + + /** + * Emitted when the pill is clicked. For link pills, default navigation is + * prevented so the host can decide whether to open the URL, show a preview, + * trigger an alert, etc. + */ + readonly clicked = output(); + + protected readonly popoverDir = viewChild(SiPopoverDirective); + protected readonly placement = signal<'top' | 'bottom'>('bottom'); + + ngOnInit(): void { + this.destroyRef.onDestroy(() => { + clearTimeout(this.hideTimer); + this.observer?.disconnect(); + }); + } + + private updatePlacement(): void { + const windowRef = this.document.defaultView; + if (!windowRef) return; + const rect = (this.elementRef.nativeElement as HTMLElement).getBoundingClientRect(); + const spaceBelow = windowRef.innerHeight - rect.bottom; + this.placement.set(spaceBelow < rect.top ? 'top' : 'bottom'); + } + + private scheduleShow(): void { + clearTimeout(this.hideTimer); + this.popoverDir()?.show(); + } + + protected scheduleHide(): void { + clearTimeout(this.hideTimer); + this.hideTimer = setTimeout(() => this.popoverDir()?.hide(), 150); + } + + private attachOverlayHoverListeners(): void { + if (this.overlayListenersAttached) return; + // Run after the overlay is attached to the DOM + setTimeout(() => { + const dir = this.popoverDir(); + if (!dir) return; + const panel = this.document.getElementById(dir.popoverId)?.closest('.cdk-overlay-pane'); + if (!panel) return; + this.overlayListenersAttached = true; + const onEnter = (): void => clearTimeout(this.hideTimer); + const onLeave = (): void => this.scheduleHide(); + panel.addEventListener('mouseenter', onEnter); + panel.addEventListener('mouseleave', onLeave); + // Reset flag once the panel is removed from the DOM + this.observer = new MutationObserver(() => { + if (!this.document.contains(panel)) { + this.overlayListenersAttached = false; + this.observer?.disconnect(); + this.observer = undefined; + } + }); + this.observer.observe(this.document.body, { childList: true, subtree: true }); + }); + } + + protected onMouseEnter(): void { + this.updatePlacement(); + this.scheduleShow(); + this.attachOverlayHoverListeners(); + } + + protected onClicked(event?: MouseEvent): void { + event?.preventDefault(); + event?.stopPropagation(); + this.updatePlacement(); + this.clicked.emit(this.citation()); + } +} diff --git a/projects/element-ng/translate/si-translatable-keys.interface.ts b/projects/element-ng/translate/si-translatable-keys.interface.ts index 4beb9af908..2bf7bbc3a2 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -24,6 +24,8 @@ export interface SiTranslatableKeys { 'SI_CHAT_INPUT.PLACEHOLDER'?: string; 'SI_CHAT_INPUT.SECONDARY_ACTIONS'?: string; 'SI_CHAT_INPUT.SEND'?: string; + 'SI_CITATION_PILL.LABEL'?: string; + 'SI_CITATION_PILL.VIEW_SOURCE'?: string; 'SI_COLOR_PICKER.SELECTED_LABEL'?: string; 'SI_COLUMN_SELECTION_DIALOG.CANCEL'?: string; 'SI_COLUMN_SELECTION_DIALOG.HIDDEN'?: string; diff --git a/src/app/examples/si-chat-messages/si-ai-message.html b/src/app/examples/si-chat-messages/si-ai-message.html index 3e7836ff87..8b49da015d 100644 --- a/src/app/examples/si-chat-messages/si-ai-message.html +++ b/src/app/examples/si-chat-messages/si-ai-message.html @@ -8,6 +8,14 @@ [loading]="false" /> + +
diff --git a/src/app/examples/si-chat-messages/si-ai-message.ts b/src/app/examples/si-chat-messages/si-ai-message.ts index 533d9e615a..fd1e313741 100644 --- a/src/app/examples/si-chat-messages/si-ai-message.ts +++ b/src/app/examples/si-chat-messages/si-ai-message.ts @@ -12,7 +12,12 @@ import { elementThumbsDown, elementThumbsUp } from '@siemens/element-icons'; -import { MessageAction, SiAiMessageComponent } from '@siemens/element-ng/chat-messages'; +import { + MessageAction, + SiAiMessageComponent, + SiChatAnnotatedText, + SiChatCitation +} from '@siemens/element-ng/chat-messages'; import { addIcons } from '@siemens/element-ng/icon'; import { getMarkdownRenderer } from '@siemens/element-ng/markdown-renderer'; import { MenuItemAction } from '@siemens/element-ng/menu'; @@ -46,6 +51,43 @@ You can use \`inline code\` and create lists: - First item - Second item`; + annotatedText: SiChatAnnotatedText = { + segments: [ + { + type: 'text', + content: + 'Neural networks are composed of layers of interconnected nodes that process information in parallel, enabling the model to learn complex patterns from large datasets.' + }, + { type: 'citation', citationId: '1' }, + { + type: 'text', + content: + ' The training process adjusts the weights of these connections to minimize prediction error using backpropagation.' + }, + { type: 'citation', citationId: '2' } + ], + citations: [ + { + id: '1', + title: 'Deep Learning – Ian Goodfellow et al.', + url: 'https://examples.org/deeplearningbook', + description: + 'Neural networks are universal function approximators composed of alternating linear transformations and non-linear activations, trained end-to-end via gradient descent.' + }, + { + id: '2', + title: 'Backpropagation Algorithm – Stanford CS231n', + url: 'https://examples.org/cs231n', + description: + 'Backpropagation computes the gradient of the loss with respect to each weight by applying the chain rule recursively from the output layer to the input layer.' + } + ] + }; + + onCitationClicked(citation: SiChatCitation): void { + alert(`Source: ${citation.title}${citation.url ? `\n${citation.url}` : ''}`); + } + actions: MessageAction[] = [ { label: 'Good response', diff --git a/src/app/examples/si-chat-messages/si-chat-container.html b/src/app/examples/si-chat-messages/si-chat-container.html index 7ce75a38e6..1633d2fd17 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.html +++ b/src/app/examples/si-chat-messages/si-chat-container.html @@ -11,12 +11,21 @@ /> } @if (message.type === 'ai') { - + @if (message.annotatedText) { + + } @else { + + } } @if (message.type === 'custom') { diff --git a/src/app/examples/si-chat-messages/si-chat-container.ts b/src/app/examples/si-chat-messages/si-chat-container.ts index 6e17b97ea2..77e3f58cd7 100644 --- a/src/app/examples/si-chat-messages/si-chat-container.ts +++ b/src/app/examples/si-chat-messages/si-chat-container.ts @@ -36,7 +36,9 @@ import { Attachment, SiAiWelcomeScreenComponent, PromptCategory, - PromptSuggestion + PromptSuggestion, + SiChatAnnotatedText, + SiChatCitation } from '@siemens/element-ng/chat-messages'; import { FileUploadError } from '@siemens/element-ng/file-uploader'; import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; @@ -52,6 +54,7 @@ import { LOG_EVENT } from '@siemens/live-preview'; interface ChatMessage { type: 'user' | 'ai' | 'custom'; content: string; + annotatedText?: SiChatAnnotatedText; attachments?: Attachment[]; actions?: MessageAction[]; } @@ -174,7 +177,51 @@ export class SampleComponent { 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: this.aiActions + annotatedText: { + segments: [ + { + type: 'text', + content: + "I'd be happy to help! Data processing pipelines typically follow a structured approach." + }, + { type: 'citation', citationId: 'c1' }, + { + type: 'text', + content: ' Let me examine your files and provide detailed guidance.' + } + ], + citations: [ + { + id: 'c1', + title: 'Data Pipeline Design Patterns', + url: 'https://examples.org/articles/data-pipeline.html', + description: + 'Pipelines can be structured as linear chains or branching graphs. Each stage transforms data independently, enabling testability and reuse.' + } + ] + }, + actions: [ + { + label: 'Add to list', + icon: 'element-plus', + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') + }, + { + label: 'Export response', + icon: 'element-export', + action: (_message: ChatMessage) => this.logEvent('Export 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', @@ -192,7 +239,47 @@ export class SampleComponent { { type: 'ai', content: "Great question! When analyzing large datasets, it's crucial to focus on...", - actions: this.aiActions + annotatedText: { + segments: [ + { + type: 'text', + content: + "Great question! When analyzing large datasets, it's crucial to focus on vectorized operations and avoid row-by-row iteration." + }, + { type: 'citation', citationId: 'c2' } + ], + citations: [ + { + id: 'c2', + title: 'Pandas Performance Guide', + url: 'https://examples.org/docs/user_guide/enhancingperf.html', + description: + 'Vectorized operations with NumPy arrays outperform row-by-row iteration by orders of magnitude, especially on DataFrames with millions of rows.' + } + ] + }, + actions: [ + { + label: 'Add to list', + icon: 'element-plus', + action: (_message: ChatMessage) => this.logEvent('Add AI message to list') + }, + { + label: 'Export response', + icon: 'element-export', + action: (_message: ChatMessage) => this.logEvent('Export 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') + } + ] } ]); @@ -316,6 +403,10 @@ export class SampleComponent { this.interrupting.set(false); } + onCitationClicked(citation: SiChatCitation): void { + alert(`Source: ${citation.title}${citation.url ? `\n${citation.url}` : ''}`); + } + onFileError(error: FileUploadError): void { this.logEvent(`File error: ${error.errorText} - ${error.fileName}`); this.toastService.queueToastNotification('danger', error.errorText, error.fileName);