From ae082b9c55cf6e0681ae0214f303bfe65b252a2c Mon Sep 17 00:00:00 2001 From: Robert Wilde Date: Tue, 26 May 2026 08:01:35 +0200 Subject: [PATCH] feat(ai-chat): add inline source citation --- ...e-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- ...r-element-examples-chromium-dark-linux.png | 4 +- ...-element-examples-chromium-light-linux.png | 4 +- .../si-chat-messages--si-chat-container.yaml | 24 ++- projects/element-ng/chat-messages/index.ts | 2 + .../si-ai-message.component.html | 15 +- .../si-ai-message.component.scss | 4 + .../chat-messages/si-ai-message.component.ts | 27 +++ .../chat-messages/si-annotated-text.model.ts | 42 +++++ .../si-citation-pill.component.scss | 42 +++++ .../si-citation-pill.component.spec.ts | 173 ++++++++++++++++++ .../si-citation-pill.component.ts | 99 ++++++++++ .../si-translatable-keys.interface.ts | 1 + .../si-chat-messages/si-ai-message.html | 8 + .../si-chat-messages/si-ai-message.ts | 40 +++- .../si-chat-messages/si-chat-container.html | 21 ++- .../si-chat-messages/si-chat-container.ts | 93 +++++++++- 18 files changed, 579 insertions(+), 28 deletions(-) create mode 100644 projects/element-ng/chat-messages/si-annotated-text.model.ts create mode 100644 projects/element-ng/chat-messages/si-citation-pill.component.scss create mode 100644 projects/element-ng/chat-messages/si-citation-pill.component.spec.ts create mode 100644 projects/element-ng/chat-messages/si-citation-pill.component.ts 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 642150b351..392b1b7eb9 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:3eddd30072ca2b4523bb66c7a6d8879ccb75121d3d078afb44c3ffd86220e59d -size 14070 +oid sha256:2c5addd7b475b46354244b4c3ae94ed613cef81cbb757cb488221edb736a415c +size 28850 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 b71da303b5..581be89f60 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:b845bb84788a7768907ba98ecc3320f6006b2a921a038010de384917e281e68d -size 13750 +oid sha256:b6fb2bdfed7750e257808fab8954334322d812138e5a4ae79ee17e021d2a996b +size 28157 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 b86d334656..6214d084b1 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:0e28f1563da1dfd76f429e35a9c80376c738702eb712f62c86c48743f32d89ea -size 45925 +oid sha256:ee5b6dd722f3b0a0d692d65621bf326427f227d82a574c48366ee4a6f45d487c +size 49574 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 0d0b15aec9..efaed48add 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:b3bd37f2a408563a6f659f176e331bbb56564096e9ee34c35198e2007979915d -size 44604 +oid sha256:ce0d4672bc0a2247ab341fc5371542bc3166506a84297cb903471076070b5bdc +size 48215 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 e9c97f9401..201bb32cf7 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 @@ -5,19 +5,25 @@ - paragraph: Can you help me analyze these files? - paragraph: I'm having trouble understanding the data structure and need assistance with the implementation. - 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. -- button "Good response" -- button "Bad response" -- button "Copy response" +- text: I'd be happy to help! Data processing pipelines typically follow a structured approach. +- link "Data Pipeline Design Patterns": + - /url: https://martinfowler.com/articles/data-pipeline.html + - text: "" +- text: Let me examine your files and provide detailed guidance. +- 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? - button "Export message" -- paragraph: Great question! When analyzing large datasets, it's crucial to focus on... -- button "Good response" -- button "Bad response" -- button "Copy response" +- text: Great question! When analyzing large datasets, it's crucial to focus on vectorized operations and avoid row-by-row iteration. +- link "Pandas Performance Guide": + - /url: https://pandas.pydata.org/docs/user_guide/enhancingperf.html + - text: Source for this paragraph. +- 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/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 e13e8ad309..2df12e84ce 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,18 @@ - @if (content()) { + @if (annotatedText(); as annotated) { + + @for (segment of annotated.segments; track $index) { + @if (segment.type === 'text') { + {{ segment.content }} + } @else { +
+ } + } +
+ } @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..ad5423dfad --- /dev/null +++ b/projects/element-ng/chat-messages/si-annotated-text.model.ts @@ -0,0 +1,42 @@ +/** + * 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; +} + +/** 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..5425f2ec85 --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.scss @@ -0,0 +1,42 @@ +@use 'sass:map'; +@use '@siemens/element-theme/src/styles/variables'; + +:host { + display: inline; + margin-inline: map.get(variables.$spacers, 3); +} + +.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..2a39356e74 --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.spec.ts @@ -0,0 +1,173 @@ +/** + * 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 { 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' +}; + +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); + }); + }); +}); 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..efcd61ba48 --- /dev/null +++ b/projects/element-ng/chat-messages/si-citation-pill.component.ts @@ -0,0 +1,99 @@ +/** + * Copyright (c) Siemens 2016 - 2026 + * SPDX-License-Identifier: MIT + */ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { elementGlobal } from '@siemens/element-icons'; +import { addIcons, SiIconComponent } from '@siemens/element-ng/icon'; +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], + template: ` + @if (citation().url) { + + @if (icon()) { + + } @else { + + } + `, + styleUrl: './si-citation-pill.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'si-citation-pill-host' } +}) +export class SiCitationPillComponent { + protected readonly icons = addIcons({ elementGlobal }); + + /** 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 onClicked(event?: MouseEvent): void { + event?.preventDefault(); + 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 aae5bea63b..f09612a9a1 100644 --- a/projects/element-ng/translate/si-translatable-keys.interface.ts +++ b/projects/element-ng/translate/si-translatable-keys.interface.ts @@ -24,6 +24,7 @@ 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_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..2970f8dc46 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,39 @@ 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://www.deeplearningbook.org' + }, + { + id: '2', + title: 'Backpropagation Algorithm – Stanford CS231n', + url: 'https://cs231n.github.io' + } + ] + }; + + 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..187debba92 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,49 @@ 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://martinfowler.com/articles/data-pipeline.html' + } + ] + }, + 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 +237,45 @@ 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://pandas.pydata.org/docs/user_guide/enhancingperf.html' + } + ] + }, + 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 +399,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);