diff --git a/apps/website/content/docs/chat/components/chat-input.mdx b/apps/website/content/docs/chat/components/chat-input.mdx index 7e25f376e..973db590a 100644 --- a/apps/website/content/docs/chat/components/chat-input.mdx +++ b/apps/website/content/docs/chat/components/chat-input.mdx @@ -96,6 +96,18 @@ function submitMessage( The function calls `ref.submit({ message: trimmed })` under the hood. +## Slots + +### `[chatInputModelSelect]` + +Projects content into the controls row of the input pill, between `[chatInputTrailing]` and the send button. Designed for `` (a model picker), but accepts any element. + +```html + + + +``` + ## Styling The component renders a `
` containing a `
+ @if (isLoading() && canStop()) {
diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts new file mode 100644 index 000000000..b7c933268 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts @@ -0,0 +1,106 @@ +// libs/chat/src/lib/primitives/chat-select/chat-select.component.spec.ts +// SPDX-License-Identifier: MIT +import { describe, it, expect, beforeEach } from 'vitest'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ChatSelectComponent, type ChatSelectOption } from './chat-select.component'; + +const OPTS: readonly ChatSelectOption[] = [ + { value: 'a', label: 'Alpha' }, + { value: 'b', label: 'Bravo' }, + { value: 'c', label: 'Charlie', disabled: true }, +]; + +// SIGNAL-symbol writer for input signals (canonical pattern; setInput() +// silently no-ops with NG0303 under vitest JIT). +function setSignalInput(fixture: ComponentFixture, name: string, value: T): void { + const inputs = fixture.componentInstance as Record; + const sig = inputs[name]; + const obj = sig as Record; + const signalSymbol = Object.getOwnPropertySymbols(obj).find( + (s) => s.description === 'SIGNAL', + ); + if (!signalSymbol) throw new Error(`Could not find SIGNAL symbol on input "${name}"`); + const node = obj[signalSymbol] as { + applyValueToInputSignal?: (n: unknown, v: T) => void; + value?: T; + }; + if (typeof node.applyValueToInputSignal === 'function') { + node.applyValueToInputSignal(node, value); + } else { + node.value = value; + } +} + +describe('ChatSelectComponent', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(ChatSelectComponent); + setSignalInput(fixture, 'options', OPTS); + setSignalInput(fixture, 'value', 'a'); + fixture.detectChanges(); + host = fixture.nativeElement as HTMLElement; + }); + + it('renders the selected option label', () => { + expect(host.querySelector('.chat-select__label')?.textContent).toContain('Alpha'); + }); + + it('falls back to placeholder when value not in options', () => { + setSignalInput(fixture, 'value', ''); + setSignalInput(fixture, 'placeholder', 'Pick one'); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__label')?.textContent).toContain('Pick one'); + }); + + it('opens the menu on trigger click', () => { + expect(host.querySelector('.chat-select__menu')).toBeNull(); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).not.toBeNull(); + const opts = host.querySelectorAll('.chat-select__option'); + expect(opts.length).toBe(3); + }); + + it('emits valueChange and closes the menu on option click', () => { + let emitted: string | undefined; + fixture.componentInstance.value.subscribe((v) => { emitted = v; }); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + const bravo = host.querySelectorAll('.chat-select__option')[1]; + bravo.click(); + fixture.detectChanges(); + expect(emitted).toBe('b'); + expect(host.querySelector('.chat-select__menu')).toBeNull(); + }); + + it('does not select a disabled option', () => { + let emitted: string | undefined; + fixture.componentInstance.value.subscribe((v) => { emitted = v; }); + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + const charlie = host.querySelectorAll('.chat-select__option')[2]; + expect(charlie.disabled).toBe(true); + charlie.click(); + fixture.detectChanges(); + expect(emitted).toBeUndefined(); + }); + + it('closes the menu on Escape', () => { + host.querySelector('.chat-select__trigger')!.click(); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).not.toBeNull(); + const evt = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }); + host.querySelector('.chat-select__menu')!.dispatchEvent(evt); + fixture.detectChanges(); + expect(host.querySelector('.chat-select__menu')).toBeNull(); + }); + + it('disables the trigger when [disabled]=true', () => { + setSignalInput(fixture, 'disabled', true); + fixture.detectChanges(); + const btn = host.querySelector('.chat-select__trigger')!; + expect(btn.disabled).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts new file mode 100644 index 000000000..80114ae9b --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-select/chat-select.component.ts @@ -0,0 +1,195 @@ +// libs/chat/src/lib/primitives/chat-select/chat-select.component.ts +// SPDX-License-Identifier: MIT +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + ElementRef, + computed, + effect, + inject, + input, + model, + signal, + DOCUMENT, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; +import { CHAT_SELECT_STYLES } from '../../styles/chat-select.styles'; + +export interface ChatSelectOption { + value: string; + label: string; + disabled?: boolean; +} + +/** + * Generic single-select dropdown. Designed to slot into the chat input pill + * (via [chatInputModelSelect]) but usable anywhere. + * + * Inputs: + * options — array of { value, label, disabled? }; required + * value — currently selected value (two-way via model()) + * placeholder — trigger label when no option matches; default 'Select' + * disabled — disables the trigger; default false + * menuLabel — aria-label for the popover; defaults to placeholder + */ +@Component({ + selector: 'chat-select', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, CHAT_SELECT_STYLES], + template: ` + + @if (open()) { +
+ @for (opt of options(); track opt.value) { + + } +
+ } + `, +}) +export class ChatSelectComponent { + readonly options = input.required(); + readonly value = model(''); + readonly placeholder = input('Select'); + readonly disabled = input(false); + readonly menuLabel = input(undefined); + + protected readonly open = signal(false); + + protected readonly currentLabel = computed(() => { + const v = this.value(); + const match = this.options().find((o) => o.value === v); + return match?.label ?? this.placeholder(); + }); + + private readonly hostEl = inject(ElementRef).nativeElement as HTMLElement; + private readonly document = inject(DOCUMENT); + private readonly destroyRef = inject(DestroyRef); + + constructor() { + let onDocClick: ((e: Event) => void) | null = null; + effect(() => { + const isOpen = this.open(); + const win = this.document.defaultView; + if (!win) return; + if (isOpen && !onDocClick) { + onDocClick = (e) => { + const path = (e as Event & { composedPath?: () => EventTarget[] }).composedPath?.() ?? []; + if (!path.includes(this.hostEl as EventTarget)) { + this.open.set(false); + } + }; + win.addEventListener('mousedown', onDocClick, true); + } else if (!isOpen && onDocClick) { + win.removeEventListener('mousedown', onDocClick, true); + onDocClick = null; + } + }); + this.destroyRef.onDestroy(() => { + if (onDocClick) { + const win = this.document.defaultView; + win?.removeEventListener('mousedown', onDocClick, true); + onDocClick = null; + } + }); + } + + protected toggle(): void { + if (this.disabled()) return; + this.open.update((v) => !v); + } + + protected selectOption(opt: ChatSelectOption): void { + if (opt.disabled) return; + this.value.set(opt.value); + this.open.set(false); + } + + protected onTriggerKeydown(e: KeyboardEvent): void { + if (this.disabled()) return; + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + this.open.set(true); + requestAnimationFrame(() => this.focusOption(0)); + } + } + + protected onMenuKeydown(e: KeyboardEvent): void { + if (e.key === 'Escape') { + e.preventDefault(); + this.open.set(false); + this.focusTrigger(); + return; + } + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + this.moveFocus(e.key === 'ArrowDown' ? 1 : -1); + return; + } + if (e.key === 'Enter' || e.key === ' ') { + const t = e.target as HTMLElement; + if (t.classList.contains('chat-select__option')) { + e.preventDefault(); + (t as HTMLButtonElement).click(); + } + } + } + + private focusOption(index: number): void { + const opts = this.queryOptions(); + opts[index]?.focus(); + } + + private focusTrigger(): void { + this.queryTrigger()?.focus(); + } + + private moveFocus(dir: 1 | -1): void { + const opts = this.queryOptions().filter((b) => !b.disabled); + if (!opts.length) return; + const active = this.document.activeElement as HTMLElement | null; + const idx = active ? opts.indexOf(active as HTMLButtonElement) : -1; + const next = (idx + dir + opts.length) % opts.length; + opts[next]?.focus(); + } + + private queryOptions(): HTMLButtonElement[] { + return Array.from(this.hostEl.querySelectorAll('.chat-select__option')); + } + + private queryTrigger(): HTMLButtonElement | null { + return this.hostEl.querySelector('.chat-select__trigger'); + } +} diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts index ba545e2f8..19467c8b3 100644 --- a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.spec.ts @@ -1,32 +1,52 @@ // SPDX-License-Identifier: MIT -import { describe, it, expect, beforeEach } from 'vitest'; -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect } from 'vitest'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { ChatWelcomeComponent } from './chat-welcome.component'; -describe('ChatWelcomeComponent', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({}); - fixture = TestBed.createComponent(ChatWelcomeComponent); - fixture.detectChanges(); - }); +@Component({ + standalone: true, + imports: [ChatWelcomeComponent], + template: ` + + + + `, +}) +class Host {} +describe('ChatWelcomeComponent', () => { it('renders default greeting', () => { - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('.chat-welcome__title')?.textContent).toContain('How can I help?'); - expect(el.querySelector('.chat-welcome__subtitle')?.textContent).toContain('Ask anything'); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__title')?.textContent).toContain('How can I help?'); }); it('renders the beacon dot', () => { - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('.chat-welcome__beacon')).not.toBeNull(); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__beacon')).not.toBeNull(); }); it('exposes slots for title, subtitle, input, suggestions', () => { - const el = fixture.nativeElement as HTMLElement; - expect(el.querySelector('.chat-welcome__inner')).not.toBeNull(); - expect(el.querySelector('.chat-welcome__input')).not.toBeNull(); - expect(el.querySelector('.chat-welcome__suggestions')).not.toBeNull(); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__inner')).not.toBeNull(); + expect(win.querySelector('.chat-welcome__input')).not.toBeNull(); + expect(win.querySelector('.chat-welcome__suggestions')).not.toBeNull(); + }); + + it('does not render a subtitle slot', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(Host); + fx.detectChanges(); + const win = fx.nativeElement.querySelector('chat-welcome') as HTMLElement; + expect(win.querySelector('.chat-welcome__subtitle')).toBeNull(); }); }); diff --git a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts index 2d0d558f8..e7e4c48bc 100644 --- a/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts +++ b/libs/chat/src/lib/primitives/chat-welcome/chat-welcome.component.ts @@ -10,13 +10,12 @@ import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles'; * * Slots: * [chatWelcomeTitle] — replaces the default

"How can I help?" - * [chatWelcomeSubtitle] — replaces the default

"Ask anything to get started." * [chatWelcomeInput] — projects the chat input into the center column * [chatWelcomeSuggestions] — projects suggestion rows below the input * * Host CSS variables (override on :host or any ancestor): * --ngaf-chat-welcome-max-width default 36rem - * --ngaf-chat-welcome-gap default 1.5rem + * --ngaf-chat-welcome-gap default 1.25rem * --ngaf-chat-welcome-padding default 24px */ @Component({ @@ -30,9 +29,6 @@ import { CHAT_WELCOME_STYLES } from '../../styles/chat-welcome.styles';

How can I help?

- -

Ask anything to get started.

-
diff --git a/libs/chat/src/lib/streaming/markdown-render.ts b/libs/chat/src/lib/streaming/markdown-render.ts index a486f800a..b471718ff 100644 --- a/libs/chat/src/lib/streaming/markdown-render.ts +++ b/libs/chat/src/lib/streaming/markdown-render.ts @@ -10,7 +10,12 @@ function ensureMarkedLoaded(): void { // Eagerly kick off the dynamic import so it's ready for subsequent calls void import('marked') .then((m) => { - markedParse = (src: string) => (m as any).marked.parse(src, { async: false }) as string; + // GFM: enables tables, strikethrough, autolinks, task lists. + // breaks: single \n becomes
. LLM chat output frequently uses + // soft line breaks for visual structure where stricter markdown + // would treat them as continuation. Matching common chat UX. + const opts = { async: false, gfm: true, breaks: true } as const; + markedParse = (src: string) => (m as any).marked.parse(src, opts) as string; }) .catch(() => { markedParse = null; diff --git a/libs/chat/src/lib/streaming/streaming-markdown.component.ts b/libs/chat/src/lib/streaming/streaming-markdown.component.ts index ee5f9b5bb..abb7839ad 100644 --- a/libs/chat/src/lib/streaming/streaming-markdown.component.ts +++ b/libs/chat/src/lib/streaming/streaming-markdown.component.ts @@ -5,6 +5,7 @@ import { ChangeDetectionStrategy, DestroyRef, ElementRef, + ViewEncapsulation, effect, inject, input, @@ -13,6 +14,7 @@ import { import { DomSanitizer } from '@angular/platform-browser'; import { renderMarkdownToString } from './markdown-render'; import { isTraceEnabled, trace } from './trace'; +import { CHAT_MARKDOWN_STYLES } from '../styles/chat-markdown.styles'; /** * Renders markdown content via marked.parse + sanitized innerHTML, coalesced @@ -26,8 +28,16 @@ import { isTraceEnabled, trace } from './trace'; selector: 'chat-streaming-md', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + // Disable emulated view encapsulation. The component sets its content via + // `innerHTML` (Angular's sanitized markdown render), so the resulting DOM + // nodes never carry the `_ngcontent-xxx` attribute that emulated styles + // require to match descendants. Without this, `chat-streaming-md ul` and + // friends in CHAT_MARKDOWN_STYLES never apply, and bullets/headings/code + // blocks render unstyled. We scope every selector to `chat-streaming-md` + // in CHAT_MARKDOWN_STYLES so the rules don't leak globally. + encapsulation: ViewEncapsulation.None, template: '', - styles: `:host { display: block; }`, + styles: CHAT_MARKDOWN_STYLES, }) export class ChatStreamingMdComponent { readonly content = input.required(); diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index 8a89a5efa..e6f79c8e9 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -1,78 +1,86 @@ // libs/chat/src/lib/styles/chat-input.styles.ts // SPDX-License-Identifier: MIT export const CHAT_INPUT_STYLES = ` - :host { display: block; background: var(--ngaf-chat-bg); } - .chat-input__container { padding: 0 0 15px 0; background: var(--ngaf-chat-bg); } - .chat-input__pill { - cursor: text; - position: relative; - background: var(--ngaf-chat-surface-alt); - border: 1px solid var(--ngaf-chat-separator); - border-radius: var(--ngaf-chat-radius-input); - padding: 12px 14px; - padding-right: 56px; - min-height: 75px; - margin: 0 auto; - width: 95%; - box-sizing: border-box; - display: flex; - align-items: flex-start; - transition: border-color 200ms ease; - } - .chat-input__pill:focus-within { border-color: var(--ngaf-chat-text-muted); } - .chat-input__textarea { - flex: 1; - outline: 0; - border: 0; - resize: none; - background: transparent; - color: var(--ngaf-chat-text); - font-family: inherit; - font-size: var(--ngaf-chat-font-size-sm); - line-height: 1.5rem; - width: 100%; - margin: 0; - padding: 0; - field-sizing: content; - min-height: 1.5rem; - max-height: 9rem; - overflow-y: auto; - } - .chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); opacity: 1; } - .chat-input__textarea::-webkit-scrollbar { width: 6px; } - .chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 10px; } - .chat-input__controls { - position: absolute; - right: 14px; - bottom: 12px; - display: flex; - gap: 3px; - } - .chat-input__send { - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border: 0; - background: var(--ngaf-chat-primary); - color: var(--ngaf-chat-on-primary); - border-radius: 9999px; - cursor: pointer; - transition: transform 200ms ease, background 200ms ease; - } - .chat-input__send:hover:not(:disabled) { transform: scale(1.05); } - .chat-input__send:disabled { - background: var(--ngaf-chat-surface-alt); - color: var(--ngaf-chat-muted); - cursor: not-allowed; - opacity: 0.7; - } - .chat-input__send--stop { - /* Slightly subdued vs the active send so the user doesn't fear the button. */ - background: var(--ngaf-chat-text); - color: var(--ngaf-chat-bg); - } - .chat-input__send--stop:hover { transform: scale(1.05); } - .chat-input__send svg { width: 16px; height: 16px; } +:host { + display: block; + width: 100%; + padding: 0 var(--ngaf-chat-edge-pad); + box-sizing: border-box; +} + +.chat-input__container { + width: 100%; + max-width: var(--ngaf-chat-max-width); + margin: 0 auto; +} + +.chat-input__pill { + display: flex; + align-items: center; + gap: 8px; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; + padding: 8px 8px 8px 16px; + min-height: 56px; + box-sizing: border-box; +} + +.chat-input__textarea { + flex: 1 1 auto; + border: 0; + outline: none; + resize: none; + background: transparent; + color: var(--ngaf-chat-text); + font: inherit; + font-size: 1rem; + line-height: 1.5; + max-height: 1.5em; + padding: 0; + field-sizing: content; + overflow-y: auto; +} +.chat-input__textarea::placeholder { color: var(--ngaf-chat-text-muted); } +.chat-input__textarea::-webkit-scrollbar { width: 4px; } +.chat-input__textarea::-webkit-scrollbar-thumb { background: var(--ngaf-chat-separator); border-radius: 4px; } + +.chat-input__controls { + display: flex; + align-items: center; + gap: 4px; + flex: none; +} + +.chat-input__send, +.chat-input__send--stop { + width: 36px; + height: 36px; + border-radius: 50%; + border: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity 150ms ease, transform 150ms ease, background 150ms ease; + padding: 0; +} +.chat-input__send { + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); +} +.chat-input__send:disabled { + opacity: 0.35; + cursor: not-allowed; + background: var(--ngaf-chat-text-muted); +} +.chat-input__send:not(:disabled):hover { transform: scale(1.05); } +.chat-input__send svg { width: 16px; height: 16px; } + +.chat-input__send--stop { + background: var(--ngaf-chat-text-muted); + color: var(--ngaf-chat-bg); +} +.chat-input__send--stop:hover { transform: scale(1.05); } +.chat-input__send--stop svg { width: 14px; height: 14px; } `; diff --git a/libs/chat/src/lib/styles/chat-markdown.styles.ts b/libs/chat/src/lib/styles/chat-markdown.styles.ts index c745a568c..d8f9495ab 100644 --- a/libs/chat/src/lib/styles/chat-markdown.styles.ts +++ b/libs/chat/src/lib/styles/chat-markdown.styles.ts @@ -1,39 +1,116 @@ // libs/chat/src/lib/styles/chat-markdown.styles.ts // SPDX-License-Identifier: MIT +// +// Scoped to the `chat-streaming-md` element selector (not `:host`) because +// the component renders markdown via innerHTML and uses +// `ViewEncapsulation.None` — emulated encapsulation would skip these rules +// since innerHTML-injected nodes don't carry `_ngcontent-xxx` attributes. +// Keeping selectors prefixed with the element name preserves locality so +// these rules don't leak to other markup on the page. +// +// Covers the CommonMark + GFM surface: headings, paragraphs, links, lists +// (bullet/ordered/task), code (inline + fenced), blockquotes, horizontal +// rules, tables, images, bold (`strong`), italic (`em`), strikethrough +// (`del`/`s`). export const CHAT_MARKDOWN_STYLES = ` - :host { display: block; color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height); } - :host h1, :host h2, :host h3, :host h4, :host h5, :host h6 { font-weight: bold; line-height: 1.2; margin: 0 0 1rem; } - :host h1 { font-size: 1.5em; } - :host h2 { font-size: 1.25em; font-weight: 600; } - :host h3 { font-size: 1.1em; } - :host h4 { font-size: 1em; } - :host p { margin: 0 0 1rem; line-height: 1.75; font-size: var(--ngaf-chat-font-size); } - :host p:last-child { margin-bottom: 0; } - :host a { color: var(--ngaf-chat-primary); text-decoration: underline; } - :host ul, :host ol { margin: 0 0 1rem; padding-left: 1.25rem; } - :host code { + chat-streaming-md { display: block; color: var(--ngaf-chat-text); line-height: var(--ngaf-chat-line-height); } + + /* Headings */ + chat-streaming-md h1, chat-streaming-md h2, chat-streaming-md h3, chat-streaming-md h4, chat-streaming-md h5, chat-streaming-md h6 { + font-weight: 600; + line-height: 1.25; + margin: 1.25rem 0 0.75rem; + } + chat-streaming-md h1:first-child, chat-streaming-md h2:first-child, chat-streaming-md h3:first-child, + chat-streaming-md h4:first-child, chat-streaming-md h5:first-child, chat-streaming-md h6:first-child { margin-top: 0; } + chat-streaming-md h1 { font-size: 1.5em; font-weight: 700; } + chat-streaming-md h2 { font-size: 1.25em; } + chat-streaming-md h3 { font-size: 1.1em; } + chat-streaming-md h4 { font-size: 1em; } + chat-streaming-md h5, chat-streaming-md h6 { font-size: 0.95em; color: var(--ngaf-chat-text-muted); } + + /* Paragraphs and inline emphasis */ + chat-streaming-md p { margin: 0 0 0.75rem; line-height: 1.6; font-size: var(--ngaf-chat-font-size); } + chat-streaming-md p:last-child { margin-bottom: 0; } + chat-streaming-md strong, chat-streaming-md b { font-weight: 700; } + chat-streaming-md em, chat-streaming-md i { font-style: italic; } + chat-streaming-md del, chat-streaming-md s { text-decoration: line-through; color: var(--ngaf-chat-text-muted); } + chat-streaming-md mark { background: var(--ngaf-chat-surface-alt); padding: 0 2px; border-radius: 2px; } + chat-streaming-md sub { font-size: 0.75em; vertical-align: sub; } + chat-streaming-md sup { font-size: 0.75em; vertical-align: super; } + + /* Links */ + chat-streaming-md a { color: var(--ngaf-chat-primary); text-decoration: underline; text-underline-offset: 2px; } + chat-streaming-md a:hover { text-decoration-thickness: 2px; } + + /* Lists (CommonMark + GFM task lists) */ + chat-streaming-md ul, chat-streaming-md ol { margin: 0 0 0.75rem; padding-left: 1.5rem; } + chat-streaming-md ul { list-style: disc outside; } + chat-streaming-md ol { list-style: decimal outside; } + chat-streaming-md ul ul { list-style: circle outside; } + chat-streaming-md ul ul ul { list-style: square outside; } + chat-streaming-md li { margin: 0.2rem 0; } + chat-streaming-md li::marker { color: var(--ngaf-chat-text-muted); } + chat-streaming-md li > p { margin: 0 0 0.25rem; } + chat-streaming-md li > ul, chat-streaming-md li > ol { margin: 0.25rem 0 0; } + /* GFM task lists: marked emits
  • ... */ + chat-streaming-md li:has(> input[type="checkbox"]) { list-style: none; margin-left: -1.25rem; } + chat-streaming-md li > input[type="checkbox"] { margin-right: 0.5rem; vertical-align: middle; } + + /* Code (inline + fenced) */ + chat-streaming-md code { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); - padding: 1px 4px; + padding: 1px 5px; border-radius: 4px; font-family: var(--ngaf-chat-font-mono); font-size: 0.9em; } - :host pre { + chat-streaming-md pre { background: var(--ngaf-chat-surface-alt); color: var(--ngaf-chat-text); - padding: 12px; + padding: 12px 14px; border-radius: var(--ngaf-chat-radius-card); overflow-x: auto; font-family: var(--ngaf-chat-font-mono); font-size: var(--ngaf-chat-font-size-sm); - margin: 0 0 1rem; + line-height: 1.5; + margin: 0 0 0.75rem; } - :host pre code { background: transparent; padding: 0; border-radius: 0; } - :host blockquote { + chat-streaming-md pre code { background: transparent; padding: 0; border-radius: 0; font-size: inherit; } + + /* Blockquote */ + chat-streaming-md blockquote { border-left: 3px solid var(--ngaf-chat-separator); - padding-left: 12px; - margin: 0 0 1rem; + padding: 0.25rem 0 0.25rem 12px; + margin: 0 0 0.75rem; color: var(--ngaf-chat-text-muted); } + chat-streaming-md blockquote > :last-child { margin-bottom: 0; } + + /* Horizontal rule */ + chat-streaming-md hr { + border: none; + border-top: 1px solid var(--ngaf-chat-separator); + margin: 1rem 0; + } + + /* Tables (GFM) */ + chat-streaming-md table { + border-collapse: collapse; + margin: 0 0 0.75rem; + width: 100%; + font-size: 0.95em; + } + chat-streaming-md thead { background: var(--ngaf-chat-surface-alt); } + chat-streaming-md th, chat-streaming-md td { + border: 1px solid var(--ngaf-chat-separator); + padding: 6px 10px; + text-align: left; + vertical-align: top; + } + chat-streaming-md th { font-weight: 600; } + + /* Media */ + chat-streaming-md img { max-width: 100%; height: auto; border-radius: 6px; } `; diff --git a/libs/chat/src/lib/styles/chat-message.styles.ts b/libs/chat/src/lib/styles/chat-message.styles.ts index 1f9f556ad..fcd37baa5 100644 --- a/libs/chat/src/lib/styles/chat-message.styles.ts +++ b/libs/chat/src/lib/styles/chat-message.styles.ts @@ -38,22 +38,23 @@ export const CHAT_MESSAGE_STYLES = ` .chat-message__caret { display: none; - margin-left: 2px; - margin-top: 0.25rem; - color: var(--ngaf-chat-text-muted); - vertical-align: text-bottom; + width: 8px; + height: 8px; + border-radius: 50%; + vertical-align: middle; + margin-left: 4px; + margin-bottom: 2px; + background: radial-gradient(circle at 30% 30%, + var(--ngaf-chat-text) 0%, + var(--ngaf-chat-text-muted) 70%, + transparent 100%); + box-shadow: 0 0 6px var(--ngaf-chat-text-muted); + animation: ngaf-chat-caret-fade-in 200ms ease-out 300ms forwards, + ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 500ms infinite; + opacity: 0; } :host([data-role="assistant"][data-current="true"][data-streaming="true"]) .chat-message__caret { display: inline-block; - /* The caret is suppressed for the first 300ms of streaming so quick - responses (one-or-two-token "hello"-style replies) never flash the - cursor. Past 300ms the smooth pulse takes over (copilotkit-style) - — easier on the eyes than a hard blink during long streams. - Note: animations restart whenever the element is created/inserted, - so this delay re-applies on every new streaming message. */ - opacity: 0; - animation: ngaf-chat-caret-fade-in 200ms ease-out 300ms forwards, - ngaf-chat-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) 500ms infinite; } .chat-message__plain { /* system / tool fallback */ } diff --git a/libs/chat/src/lib/styles/chat-select.styles.ts b/libs/chat/src/lib/styles/chat-select.styles.ts new file mode 100644 index 000000000..280e1793c --- /dev/null +++ b/libs/chat/src/lib/styles/chat-select.styles.ts @@ -0,0 +1,80 @@ +// libs/chat/src/lib/styles/chat-select.styles.ts +// SPDX-License-Identifier: MIT +export const CHAT_SELECT_STYLES = ` + :host { + display: inline-block; + position: relative; + } + .chat-select__trigger { + height: 32px; + padding: 0 10px; + border: 0; + border-radius: 9999px; + background: transparent; + color: var(--ngaf-chat-text-muted); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease; + } + .chat-select__trigger:hover:not(:disabled) { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); + } + .chat-select__trigger:disabled { + opacity: 0.5; + cursor: not-allowed; + } + .chat-select__chevron { + width: 12px; + height: 12px; + transition: transform 120ms ease; + flex: none; + } + .chat-select__trigger.is-open .chat-select__chevron { + transform: rotate(180deg); + } + .chat-select__menu { + position: absolute; + bottom: calc(100% + 8px); + right: 0; + min-width: 180px; + max-height: 320px; + overflow-y: auto; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 12px; + box-shadow: var(--ngaf-chat-shadow-lg); + padding: 4px; + z-index: 10; + } + .chat-select__option { + display: block; + width: 100%; + text-align: left; + border: 0; + background: transparent; + padding: 8px 10px; + border-radius: 8px; + color: var(--ngaf-chat-text); + font: inherit; + font-size: var(--ngaf-chat-font-size-sm); + cursor: pointer; + } + .chat-select__option:hover:not(:disabled), + .chat-select__option:focus-visible { + background: var(--ngaf-chat-surface-alt); + outline: none; + } + .chat-select__option.is-active { + background: var(--ngaf-chat-surface-alt); + font-weight: 500; + } + .chat-select__option:disabled { + opacity: 0.4; + cursor: not-allowed; + } +`; diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index d4cb91d33..2ae0789d4 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -67,6 +67,7 @@ const SPACING_TOKENS = ` --ngaf-chat-space-5: 20px; --ngaf-chat-space-6: 24px; --ngaf-chat-space-8: 32px; + --ngaf-chat-edge-pad: 16px; `; const KEYFRAMES = ` diff --git a/libs/chat/src/lib/styles/chat-welcome.styles.ts b/libs/chat/src/lib/styles/chat-welcome.styles.ts index bdc514c75..d27cffcfd 100644 --- a/libs/chat/src/lib/styles/chat-welcome.styles.ts +++ b/libs/chat/src/lib/styles/chat-welcome.styles.ts @@ -16,7 +16,7 @@ export const CHAT_WELCOME_STYLES = ` display: flex; flex-direction: column; align-items: center; - gap: var(--ngaf-chat-welcome-gap, 1.5rem); + gap: var(--ngaf-chat-welcome-gap, 1.25rem); width: 100%; max-width: var(--ngaf-chat-welcome-max-width, 36rem); text-align: center; @@ -43,12 +43,6 @@ export const CHAT_WELCOME_STYLES = ` @media (min-width: 768px) { .chat-welcome__title { font-size: 1.5rem; } } - .chat-welcome__subtitle { - margin: 0; - font-size: var(--ngaf-chat-font-size-sm); - color: var(--ngaf-chat-text-muted); - line-height: 1.5; - } .chat-welcome__input { width: 100%; margin-top: 0.5rem; @@ -56,39 +50,40 @@ export const CHAT_WELCOME_STYLES = ` .chat-welcome__suggestions { width: 100%; display: flex; - flex-direction: column; - align-items: stretch; - gap: 0; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 4px; } .chat-welcome__suggestions:empty { display: none; } `; export const CHAT_WELCOME_SUGGESTION_STYLES = ` - :host { display: block; width: 100%; } + :host { display: inline-block; } .chat-welcome-suggestion { - width: 100%; - display: flex; + display: inline-flex; align-items: center; - gap: 0.75rem; - padding: 12px 14px; - background: transparent; - border: 0; - border-bottom: 1px solid var(--ngaf-chat-separator); + gap: 0.5rem; + padding: 10px 16px; + background: var(--ngaf-chat-surface); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; color: var(--ngaf-chat-text); font-family: inherit; font-size: var(--ngaf-chat-font-size-sm); - text-align: left; + text-align: center; cursor: pointer; - transition: background 150ms ease; + transition: background 150ms ease, border-color 150ms ease, transform 120ms ease; } - .chat-welcome-suggestion:hover { background: var(--ngaf-chat-surface-alt); } + .chat-welcome-suggestion:hover { + background: var(--ngaf-chat-surface-alt); + border-color: var(--ngaf-chat-text-muted); + } + .chat-welcome-suggestion:active { transform: scale(0.98); } .chat-welcome-suggestion:focus-visible { outline: 2px solid var(--ngaf-chat-text-muted); - outline-offset: -2px; - } - .chat-welcome-suggestion__label { flex: 1 1 auto; } - .chat-welcome-suggestion__chevron { - color: var(--ngaf-chat-text-muted); - font-size: 1.1em; + outline-offset: 2px; } + .chat-welcome-suggestion__label { white-space: nowrap; } + .chat-welcome-suggestion__chevron { display: none; } `; diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 16cdf5ec7..1b3a5636f 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -54,6 +54,8 @@ export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timel export { ChatGenerativeUiComponent } from './lib/primitives/chat-generative-ui/chat-generative-ui.component'; export { ChatWelcomeComponent } from './lib/primitives/chat-welcome/chat-welcome.component'; export { ChatWelcomeSuggestionComponent } from './lib/primitives/chat-welcome/chat-welcome-suggestion.component'; +export { ChatSelectComponent } from './lib/primitives/chat-select/chat-select.component'; +export type { ChatSelectOption } from './lib/primitives/chat-select/chat-select.component'; // DI provider export { provideChat, CHAT_CONFIG } from './lib/provide-chat'; diff --git a/libs/langgraph/package.json b/libs/langgraph/package.json index f1cde13b0..5c2f23a8d 100644 --- a/libs/langgraph/package.json +++ b/libs/langgraph/package.json @@ -1,6 +1,6 @@ { "name": "@ngaf/langgraph", - "version": "0.0.9", + "version": "0.0.10", "peerDependencies": { "@ngaf/chat": "*", "@ngaf/licensing": "*", diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 1b9770e28..61a031047 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -162,7 +162,13 @@ export function agent< // Convert to Angular Signals (must happen in injection context) const value = toSignal(maybeThrottle(values$), { initialValue: init }); - const rawMessages = toSignal(maybeThrottle(messages$), { initialValue: [] as BaseMessage[] }); + // No throttle on messages$: we need every token emission to propagate to + // Angular so streaming markdown actually streams. The bridge already + // batches per-tuple at the SDK level; further throttling at the signal + // boundary collapses tokens together and breaks visible token-by-token + // rendering. Same-frame multiple emissions are coalesced by Angular's + // CD anyway. + const rawMessages = toSignal(messages$, { initialValue: [] as BaseMessage[] }); const statusSig = toSignal(status$, { initialValue: ResourceStatus.Idle }); const errorSig = toSignal(error$, { initialValue: undefined as unknown }); const hasValueSig = toSignal(hasValue$, { initialValue: false }); @@ -184,20 +190,14 @@ export function agent< // ── Runtime-neutral projections ─────────────────────────────────────────── - // Memoise BaseMessage → Message projections by raw-message identity. This - // keeps the projected `id` stable for the same logical message across - // recomputes (e.g. token-by-token streaming emits a fresh array but the - // BaseMessage reference is the same). Track-by-id in chat-message-list - // depends on this identity to avoid DOM teardown + animation restarts. - const messageProjections = new WeakMap(); - const projectMessage = (m: BaseMessage): Message => { - let cached = messageProjections.get(m); - if (cached) return cached; - cached = toMessage(m); - messageProjections.set(m, cached); - return cached; - }; - const messagesNeutral = computed(() => rawMessages().map(projectMessage)); + // Project BaseMessage → Message on every recompute. We deliberately do + // NOT cache: the LangGraph SDK mutates the same AIMessage instance in + // place during token streaming (appends content to the same object), so + // any identity-based cache returns stale projections and Angular's + // `@let content = messageContent(message)` short-circuits — DOM never + // updates per token. DOM stability is provided by `track message.id` + // in chat-message-list, not by Message identity. + const messagesNeutral = computed(() => rawMessages().map(toMessage)); const toolCallsNeutral = computed(() => rawToolCalls().map(toToolCall)); @@ -349,13 +349,44 @@ function toMessage(m: BaseMessage): Message { return { id: (m.id as string | undefined) ?? (raw['id'] as string | undefined) ?? randomId(), role, - content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content), + content: extractTextContent(m.content), toolCallId: raw['tool_call_id'] as string | undefined, name: raw['name'] as string | undefined, extra: raw, }; } +/** + * Extract user-visible text from a `BaseMessage.content` value. + * + * LangChain's `BaseMessage.content` is `string | MessageContentComplex[]`. + * Reasoning-capable models (OpenAI gpt-5/o-series, Anthropic) emit complex + * arrays of typed blocks: `{type: 'text', text}`, `{type: 'reasoning', ...}`, + * tool-use blocks, etc. We render only the visible text portions and skip + * anything else. JSON-stringifying the whole array (the previous behaviour) + * would dump raw `[{"type":"text",...}]` into the chat bubble. + */ +function extractTextContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (typeof block === 'string') { + out += block; + continue; + } + if (!isRecord(block)) continue; + const t = block['type']; + // Common text-bearing block shapes across providers. + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = block['text']; + if (typeof text === 'string') out += text; + } + // Skip reasoning, tool_use, image, etc. — not chat-bubble content. + } + return out; +} + function toToolCall(tc: ToolCallWithResult): ToolCall { const stateMap: Record = { pending: 'pending', diff --git a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts index 28c573f65..5c743e65c 100644 --- a/libs/langgraph/src/lib/internals/stream-manager.bridge.ts +++ b/libs/langgraph/src/lib/internals/stream-manager.bridge.ts @@ -389,8 +389,24 @@ export function createStreamManagerBridge { + if (i !== projected.length - 1) return true; + const t = normalizeMessageType( + typeof m._getType === 'function' ? m._getType() : (m as unknown as Record)['type'] as string | undefined, + ); + if (t !== 'ai') return true; + const text = extractText(m.content); + return text.length > 0; + }); // Preserve existing ids by content match (server echo / final-id swap). - const remapped = preserveIds(subjects.messages$.value, projected); + const remapped = preserveIds(subjects.messages$.value, filtered); // ALWAYS merge values-derived messages into existing rather // than replacing. LangGraph emits intermediate values events // during streaming where state.messages can lag behind what @@ -703,10 +719,52 @@ function normalizeMessages(event: StreamEvent): unknown[] | null { return null; } +/** + * Collapse adjacent AI messages where one's text is a prefix of the other. + * + * When complex-content streaming is in play, the same conceptual assistant + * message can land in two slots: the canonical AI from values-sync (id + * `resp_…` or run id) and the chunk-streamed AIMessageChunk from + * messages-tuple (id `lc_run--…`). Both slots fill in parallel; once both + * carry the full text we collapse them, keeping the older slot's id so + * track-by-id stays stable in the chat list. + */ +function collapseAdjacentAi(messages: BaseMessage[]): BaseMessage[] { + if (messages.length < 2) return messages; + const out: BaseMessage[] = []; + for (const msg of messages) { + const last = out[out.length - 1]; + if (!last) { out.push(msg); continue; } + const lastType = normalizeMessageType( + typeof last._getType === 'function' ? last._getType() : (last as unknown as Record)['type'] as string | undefined, + ); + const msgType = normalizeMessageType( + typeof msg._getType === 'function' ? msg._getType() : (msg as unknown as Record)['type'] as string | undefined, + ); + if (lastType === 'ai' && msgType === 'ai') { + const lastText = extractText(last.content); + const msgText = extractText(msg.content); + if (lastText.length === 0 + || msgText.length === 0 + || lastText === msgText + || lastText.startsWith(msgText) + || msgText.startsWith(lastText)) { + // Keep the longer content; preserve last (older) id and metadata. + const longerText = msgText.length >= lastText.length ? msgText : lastText; + out[out.length - 1] = { ...(last as object), content: longerText } as BaseMessage; + continue; + } + } + out.push(msg); + } + return out; +} + function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { const merged = [...existing]; for (const msg of incoming) { - const id = (msg as unknown as Record)['id']; + const rawIn = msg as unknown as Record; + const id = rawIn['id']; let idx = id ? merged.findIndex(m => (m as unknown as Record)['id'] === id) : -1; // Fallback: match by (role, content) when ids differ. This is the path // that fires when the server echoes back our optimistic human message @@ -717,18 +775,100 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe if (idx < 0) { idx = findContentMatch(merged, msg); } + // When an AIMessageChunk arrives without an id-match or content-prefix + // match, treat the trailing AI message as its accumulator. The + // OpenAI Responses API emits per-chunk events whose ids identify the + // *event*, not the message, so consecutive chunks land here. Without + // this we'd append every chunk as a separate bubble. + if (idx < 0) { + const inType = normalizeMessageType(rawIn['type'] as string | undefined); + if (inType === 'ai') { + for (let i = merged.length - 1; i >= 0; i--) { + const t = normalizeMessageType( + typeof (merged[i] as BaseMessage)._getType === 'function' + ? (merged[i] as BaseMessage)._getType() + : (merged[i] as unknown as Record)['type'] as string | undefined, + ); + if (t === 'ai') { idx = i; break; } + if (t === 'human' || t === 'tool' || t === 'system') break; + } + } + } if (idx >= 0) { - const existingId = (merged[idx] as unknown as Record)['id']; + const existing = merged[idx]; + const existingId = (existing as unknown as Record)['id']; // Keep the *existing* id so downstream track-by-id sees stable identity. - // The replacement carries the latest content + metadata. - merged[idx] = existingId - ? ({ ...(msg as object), id: existingId } as BaseMessage) - : msg; + // For complex-content streaming (OpenAI gpt-5/o-series, Anthropic) the + // SDK emits per-chunk *delta* arrays — not accumulated arrays — so a + // straight replacement collapses the rendered bubble to just the + // latest token. Accumulate text-bearing content across chunks here + // and hand a string to consumers; downstream code already handles + // string content uniformly. + const accumulatedContent = accumulateContent( + existing.content as unknown, + (msg as unknown as Record)['content'], + ); + const next = { ...(msg as object), content: accumulatedContent } as BaseMessage; + if (existingId) { + (next as unknown as Record)['id'] = existingId; + } + merged[idx] = next; } else { merged.push(msg); } } - return merged; + return collapseAdjacentAi(merged); +} + +/** + * Merge an incoming chunk's content into prior accumulated content for the + * same message id. + * + * - string + string → concat (delta append) + * - array + array → concat extracted text from existing + incoming blocks + * - array + string → use the string (server final-id swap) + * - empty existing → use incoming as-is + * + * We deliberately collapse complex content arrays to a string at this layer. + * The langgraph-sdk client does not accumulate complex-content arrays the + * way it accumulates strings, and per-chunk arrays carry only the latest + * delta. Concatenating extracted text gives consumers the same uniform + * string they get for non-reasoning models. + */ +function accumulateContent(existing: unknown, incoming: unknown): string { + const existingText = extractText(existing); + const incomingText = extractText(incoming); + + // Always return a string. We never want array content escaping the bridge: + // (a) downstream consumers expect string content, and (b) findContentMatch + // stringifies arrays, which would prevent the canonical-message id-swap + // dedupe from matching the streamed-chunk message after a partial chunk. + if (existingText.length === 0) return incomingText; + if (incomingText.length === 0) return existingText; + // Incoming is a strict-superset of accumulated (final-id swap with full content). + if (incomingText.startsWith(existingText)) return incomingText; + // Existing already a strict-superset — chunk arrived after the canonical + // message merged in via values-sync. Keep what we have. + if (existingText.startsWith(incomingText)) return existingText; + // Otherwise treat incoming as a delta and append. + return existingText + incomingText; +} + +function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + let out = ''; + for (const block of content) { + if (typeof block === 'string') { out += block; continue; } + if (block == null || typeof block !== 'object') continue; + const rec = block as Record; + const t = rec['type']; + if (t === 'text' || t === 'output_text' || t === undefined) { + const text = rec['text']; + if (typeof text === 'string') out += text; + } + } + return out; } /** @@ -737,9 +877,9 @@ function mergeMessages(existing: BaseMessage[], incoming: BaseMessage[]): BaseMe * track-by-id stable across server echoes and final-id swaps. */ function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMessage[] { - if (existing.length === 0) return incoming; + if (existing.length === 0) return collapseAdjacentAi(incoming); const usedExisting = new Set(); - return incoming.map((msg, i) => { + const remapped = incoming.map((msg, i) => { const inRaw = msg as unknown as Record; const inId = inRaw['id']; // First try same-position match (the dominant case). @@ -756,11 +896,16 @@ function preserveIds(existing: BaseMessage[], incoming: BaseMessage[]): BaseMess if (!existingId || existingId === inId) return msg; return { ...(msg as object), id: existingId } as BaseMessage; }); + return collapseAdjacentAi(remapped); } function sameRoleAndContent(a: BaseMessage, b: BaseMessage): boolean { - const aType = typeof a._getType === 'function' ? a._getType() : (a as unknown as Record)['type']; - const bType = typeof b._getType === 'function' ? b._getType() : (b as unknown as Record)['type']; + const aType = normalizeMessageType( + typeof a._getType === 'function' ? a._getType() : (a as unknown as Record)['type'] as string | undefined, + ); + const bType = normalizeMessageType( + typeof b._getType === 'function' ? b._getType() : (b as unknown as Record)['type'] as string | undefined, + ); if (aType !== bType) return false; const aContent = typeof a.content === 'string' ? a.content : JSON.stringify(a.content); const bContent = typeof b.content === 'string' ? b.content : JSON.stringify(b.content); @@ -774,26 +919,56 @@ function sameRoleAndContent(a: BaseMessage, b: BaseMessage): boolean { function findContentMatch(merged: BaseMessage[], incoming: BaseMessage): number { const inRaw = incoming as unknown as Record; - const inType = typeof incoming._getType === 'function' ? incoming._getType() : (inRaw['type'] as string | undefined); + const inType = normalizeMessageType( + typeof incoming._getType === 'function' ? incoming._getType() : (inRaw['type'] as string | undefined), + ); const inContent = typeof incoming.content === 'string' ? incoming.content : JSON.stringify(incoming.content); // Only worth matching for human messages (where the optimistic→echo // mismatch happens) and for AI messages where content is a strict prefix // of the existing (token-streaming + final-id swap pattern). for (let i = merged.length - 1; i >= 0; i--) { const m = merged[i] as unknown as Record; - const mType = typeof (merged[i] as BaseMessage)._getType === 'function' - ? (merged[i] as BaseMessage)._getType() - : (m['type'] as string | undefined); + const mType = normalizeMessageType( + typeof (merged[i] as BaseMessage)._getType === 'function' + ? (merged[i] as BaseMessage)._getType() + : (m['type'] as string | undefined), + ); if (mType !== inType) continue; const mContent = typeof (merged[i] as BaseMessage).content === 'string' ? (merged[i] as BaseMessage).content as string : JSON.stringify((merged[i] as BaseMessage).content); if (inType === 'human' && mContent === inContent) return i; - if (inType === 'ai' && (mContent === inContent || (typeof mContent === 'string' && typeof inContent === 'string' && (inContent.startsWith(mContent) || mContent.startsWith(inContent))))) return i; + if (inType === 'ai') { + // Skip empty placeholders. We don't want a pre-existing empty AI + // (created by an early values-sync emission with `state.messages` + // including an unfilled assistant turn) to absorb the first chunk + // arriving via messages-tuple — that strands subsequent chunks in a + // separate slot whose content no longer prefix-matches the canonical. + const aSafe = typeof mContent === 'string' ? mContent : ''; + const bSafe = typeof inContent === 'string' ? inContent : ''; + if (aSafe.length === 0 || bSafe.length === 0) continue; + if (mContent === inContent || aSafe.startsWith(bSafe) || bSafe.startsWith(aSafe)) return i; + } } return -1; } +/** + * Normalize message type so AIMessage and AIMessageChunk compare equal. + * The LangGraph SDK emits type='AIMessageChunk' on the messages-tuple + * streaming path and type='ai' on the values-sync path for the same + * canonical assistant message — distinguishing them prevents the + * content-prefix dedupe from collapsing the duplicate bubbles. + */ +function normalizeMessageType(t: string | undefined): string | undefined { + if (!t) return t; + if (t === 'AIMessageChunk' || t === 'AIMessage' || t === 'assistant') return 'ai'; + if (t === 'HumanMessage' || t === 'HumanMessageChunk' || t === 'user') return 'human'; + if (t === 'ToolMessage') return 'tool'; + if (t === 'SystemMessage') return 'system'; + return t; +} + function toSubagentRefs( subagents: Map, ): Map {