@artooi/ag-ui-web-component 0.8.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -4
- package/README.md +45 -7
- package/dist/ag-ui-web-component.bundle.js +163 -57
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +27 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +6 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +5 -0
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +8 -0
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/transcribe_audio.d.ts +25 -0
- package/dist/core/transcribe_audio.d.ts.map +1 -0
- package/dist/core/upload_attachment.d.ts +8 -2
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +735 -55
- package/dist/index.js.map +4 -4
- package/dist/ui/attachment_tray.d.ts +7 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thoughts_block.d.ts +30 -0
- package/dist/ui/thoughts_block.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +14 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +41 -0
- package/dist/ui/voice_input.d.ts.map +1 -0
- package/dist/version.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +217 -7
- package/src/core/agui_client.ts +31 -2
- package/src/core/attachment.ts +21 -1
- package/src/core/conversation_store.ts +84 -18
- package/src/core/remote_conversation_store.ts +24 -3
- package/src/core/transcribe_audio.ts +62 -0
- package/src/core/upload_attachment.ts +8 -1
- package/src/index.ts +5 -0
- package/src/ui/attachment_tray.ts +42 -5
- package/src/ui/relative_time.ts +8 -3
- package/src/ui/styles.ts +113 -7
- package/src/ui/thoughts_block.ts +83 -0
- package/src/ui/thread_drawer.ts +83 -9
- package/src/ui/tool_call_card.ts +6 -0
- package/src/ui/ui_strings.ts +20 -1
- package/src/ui/voice_input.ts +169 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -28,9 +28,11 @@ import { renderMarkdown } from "../ui/render_markdown.js";
|
|
|
28
28
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
29
29
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
30
30
|
import { STYLES } from "../ui/styles.js";
|
|
31
|
+
import { ThoughtsBlock } from "../ui/thoughts_block.js";
|
|
31
32
|
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
32
33
|
import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
|
|
33
34
|
import { DEFAULT_UI_STRINGS, mergeUiStrings, type UiStrings } from "../ui/ui_strings.js";
|
|
35
|
+
import { VoiceInput } from "../ui/voice_input.js";
|
|
34
36
|
import {
|
|
35
37
|
AgUiClient,
|
|
36
38
|
type AgUiClientHandlers,
|
|
@@ -45,6 +47,7 @@ import {
|
|
|
45
47
|
} from "./conversation_store.js";
|
|
46
48
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
47
49
|
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
50
|
+
import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
|
|
48
51
|
import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
|
|
49
52
|
|
|
50
53
|
/** The role a rendered chat message takes. */
|
|
@@ -65,6 +68,9 @@ export interface ToggleDetail {
|
|
|
65
68
|
/** Per-tab persistence key for the collapsed state (survives MPA reloads). */
|
|
66
69
|
const COLLAPSED_KEY = "ag-ui-chat:collapsed";
|
|
67
70
|
|
|
71
|
+
/** Per-tab persistence key for the built-in theme toggle. */
|
|
72
|
+
const THEME_KEY = "ag-ui-chat:theme";
|
|
73
|
+
|
|
68
74
|
/**
|
|
69
75
|
* `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
|
|
70
76
|
*
|
|
@@ -169,6 +175,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
169
175
|
*/
|
|
170
176
|
uploadHandler: UploadHandler | null = null;
|
|
171
177
|
|
|
178
|
+
/**
|
|
179
|
+
* How recorded voice clips are transcribed. `null` (default) POSTs the clip
|
|
180
|
+
* to `data-transcribe-url` (django-ag-ui's `TranscribeView`). Set a custom
|
|
181
|
+
* {@link TranscribeHandler} — `(audio: Blob) => Promise<string>` — to swap the
|
|
182
|
+
* transport (a different STT endpoint, a browser Web Speech adapter) without
|
|
183
|
+
* touching the mic button. When set, the 🎤 affordance appears even with no
|
|
184
|
+
* `data-transcribe-url`.
|
|
185
|
+
*/
|
|
186
|
+
transcribeHandler: TranscribeHandler | null = null;
|
|
187
|
+
|
|
172
188
|
/**
|
|
173
189
|
* Builds the tool result a navigating tool resumes with after the page
|
|
174
190
|
* reloads. Defaults to the landed URL; a host (e.g. the admin package) can
|
|
@@ -243,12 +259,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
243
259
|
readonly #attachButton: HTMLButtonElement;
|
|
244
260
|
readonly #fileInput: HTMLInputElement;
|
|
245
261
|
readonly #attachSlot: HTMLDivElement;
|
|
262
|
+
/** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
|
|
263
|
+
readonly #themeToggle: HTMLButtonElement;
|
|
246
264
|
/** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
|
|
247
265
|
readonly #rail: HTMLButtonElement;
|
|
248
266
|
/** Empty-state region at the top of the message list; hidden once anything renders. */
|
|
249
267
|
readonly #emptyWrap: HTMLDivElement;
|
|
250
268
|
/** Upload tray; created on connect only when `data-attachments-url` is set. */
|
|
251
269
|
#attachTray: AttachmentTray | null = null;
|
|
270
|
+
/** Mic button mount point (input row); the control mounts on connect when enabled. */
|
|
271
|
+
readonly #voiceSlot: HTMLSpanElement;
|
|
272
|
+
/** Voice-input control; created on connect when transcription is available. */
|
|
273
|
+
#voice: VoiceInput | null = null;
|
|
252
274
|
/** Refs attached to the message currently being sent (the context manifest). */
|
|
253
275
|
#runAttachments: readonly AttachmentRef[] = [];
|
|
254
276
|
|
|
@@ -266,14 +288,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
266
288
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
267
289
|
#streamDeltas = 0;
|
|
268
290
|
#pending: HTMLDivElement | null = null;
|
|
269
|
-
// The current assistant turn's grouping container
|
|
291
|
+
// The current assistant turn's grouping container. One `.answer`
|
|
270
292
|
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
271
293
|
// pending indicator — so it can be boxed as one "well" by CSS. Opened on the
|
|
272
294
|
// turn's first run start, closed at settle, so it spans the whole multi-round
|
|
273
295
|
// frontend-tool loop (which is several AG-UI runs), not one run. `null`
|
|
274
296
|
// between turns; user bubbles never enter it.
|
|
275
297
|
#currentGroup: HTMLDivElement | null = null;
|
|
298
|
+
// The current turn's streamed-reasoning region, shown at the top of
|
|
299
|
+
// the answer group while a reasoning model thinks and collapsed once the
|
|
300
|
+
// answer's first text token arrives. `null` outside a reasoning turn.
|
|
301
|
+
#thoughts: ThoughtsBlock | null = null;
|
|
276
302
|
#threadId = "";
|
|
303
|
+
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
304
|
+
// active thread), so two instances on one origin don't clobber each other.
|
|
305
|
+
// Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
|
|
306
|
+
#storageNs = "";
|
|
307
|
+
// Bumped on every #rehydrate; a replay whose generation is stale (a newer
|
|
308
|
+
// thread switch started while it awaited a slow store) drops its result.
|
|
309
|
+
#rehydrateGeneration = 0;
|
|
277
310
|
#initialMessages: readonly Message[] = [];
|
|
278
311
|
// Skill catalog by source; merged backend → embed → client (later wins).
|
|
279
312
|
#backendSkills: readonly Skill[] = [];
|
|
@@ -292,6 +325,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
292
325
|
this.#attachButton = document.createElement("button");
|
|
293
326
|
this.#fileInput = document.createElement("input");
|
|
294
327
|
this.#attachSlot = document.createElement("div");
|
|
328
|
+
this.#voiceSlot = document.createElement("span");
|
|
329
|
+
this.#themeToggle = document.createElement("button");
|
|
295
330
|
this.#rail = document.createElement("button");
|
|
296
331
|
this.#emptyWrap = document.createElement("div");
|
|
297
332
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
@@ -442,23 +477,55 @@ export class AgUiChat extends HTMLElement {
|
|
|
442
477
|
}
|
|
443
478
|
|
|
444
479
|
connectedCallback(): void {
|
|
480
|
+
// Resolve the per-instance storage namespace (id, else endpoint) before any
|
|
481
|
+
// key read/write, so this instance doesn't share collapsed/theme/thread
|
|
482
|
+
// state with another on the same origin.
|
|
483
|
+
this.#storageNs = this.id !== "" ? this.id : this.endpoint;
|
|
445
484
|
// Resolve the string table before rendering any chrome (defaults are the
|
|
446
485
|
// floor; `data-strings` then the `strings` property layer over them).
|
|
447
486
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
487
|
+
// Restore a theme the built-in toggle persisted last visit (opt-in only, so
|
|
488
|
+
// it never overrides a host that drives `theme` itself).
|
|
489
|
+
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
490
|
+
const saved = this.#readScopedItem(THEME_KEY);
|
|
491
|
+
if (saved !== null) {
|
|
492
|
+
this.setAttribute("theme", saved);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
448
495
|
this.#render();
|
|
449
496
|
this.#drawer.setStrings(this.#strings);
|
|
450
|
-
if (
|
|
497
|
+
if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
|
|
451
498
|
this.setAttribute("collapsed", "");
|
|
452
499
|
}
|
|
453
500
|
this.#syncRail();
|
|
454
501
|
this.#initSkills();
|
|
455
502
|
void this.#fetchToolCatalog();
|
|
503
|
+
// Namespace the built-in default store too (a host-injected store is used
|
|
504
|
+
// verbatim). Must precede #wireThreadStore, which wraps the current store.
|
|
505
|
+
if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
|
|
506
|
+
this.conversationStore = new SessionStorageStore(this.#storageNs);
|
|
507
|
+
}
|
|
456
508
|
this.#wireThreadStore();
|
|
457
509
|
this.#wireAttachments();
|
|
510
|
+
this.#wireVoice();
|
|
458
511
|
this.#threadId = this.conversationStore.threadId();
|
|
459
512
|
void this.#rehydrate();
|
|
460
513
|
}
|
|
461
514
|
|
|
515
|
+
/**
|
|
516
|
+
* Tear down live resources when the element leaves the DOM (a removed node, a
|
|
517
|
+
* client-side route swap): cancel the in-flight run so its SSE stream closes,
|
|
518
|
+
* abort any in-flight uploads so they don't orphan server-side files, and
|
|
519
|
+
* release the mic so the browser's recording indicator clears. Without this a
|
|
520
|
+
* removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
|
|
521
|
+
* `MediaRecorder`.
|
|
522
|
+
*/
|
|
523
|
+
disconnectedCallback(): void {
|
|
524
|
+
this.#cancelRun();
|
|
525
|
+
this.#attachTray?.dispose();
|
|
526
|
+
this.#voice?.dispose();
|
|
527
|
+
}
|
|
528
|
+
|
|
462
529
|
/** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
|
|
463
530
|
#readStringOverrides(): Partial<UiStrings> {
|
|
464
531
|
const raw = this.getAttribute("data-strings");
|
|
@@ -507,7 +574,47 @@ export class AgUiChat extends HTMLElement {
|
|
|
507
574
|
if (url === null) {
|
|
508
575
|
return null;
|
|
509
576
|
}
|
|
510
|
-
|
|
577
|
+
// Forward the tray's abort signal so removing a chip (or tearing the
|
|
578
|
+
// element down) cancels the XHR.
|
|
579
|
+
return (file, onProgress, signal) =>
|
|
580
|
+
uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Reveal the composer's 🎤 mic button when transcription is possible — either
|
|
585
|
+
* a custom {@link transcribeHandler} is set or `data-transcribe-url` provides
|
|
586
|
+
* the built-in POST endpoint. The control records via `MediaRecorder` and
|
|
587
|
+
* drops the transcript into the composer; with neither configured the mic
|
|
588
|
+
* stays hidden and the chat is text-only.
|
|
589
|
+
*/
|
|
590
|
+
#wireVoice(): void {
|
|
591
|
+
const url = this.getAttribute("data-transcribe-url");
|
|
592
|
+
const transcribe = this.transcribeHandler ?? this.#defaultTranscribeHandler(url);
|
|
593
|
+
if (transcribe === null) {
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
this.#voice = new VoiceInput({
|
|
597
|
+
transcribe,
|
|
598
|
+
onText: (text) => this.#insertVoiceText(text),
|
|
599
|
+
strings: this.#strings,
|
|
600
|
+
});
|
|
601
|
+
this.#voiceSlot.appendChild(this.#voice.element);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** The built-in transcription handler for `data-transcribe-url`, or `null`. */
|
|
605
|
+
#defaultTranscribeHandler(url: string | null): TranscribeHandler | null {
|
|
606
|
+
if (url === null) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return (audio) => transcribeAudio(audio, { url, headers: this.headers });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Drop a voice transcript into the composer (appended to any typed text). */
|
|
613
|
+
#insertVoiceText(text: string): void {
|
|
614
|
+
const current = this.#input.value.trim();
|
|
615
|
+
this.#input.value = current === "" ? text : `${current} ${text}`;
|
|
616
|
+
this.#onInput();
|
|
617
|
+
this.#input.focus();
|
|
511
618
|
}
|
|
512
619
|
|
|
513
620
|
/** The client-side upload size cap from `data-attachment-max-bytes`. */
|
|
@@ -696,7 +803,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
696
803
|
} else {
|
|
697
804
|
this.removeAttribute("collapsed");
|
|
698
805
|
}
|
|
699
|
-
sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
806
|
+
sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
|
|
700
807
|
this.#syncRail();
|
|
701
808
|
this.dispatchEvent(
|
|
702
809
|
new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
|
|
@@ -712,6 +819,43 @@ export class AgUiChat extends HTMLElement {
|
|
|
712
819
|
this.setCollapsed(!this.collapsed);
|
|
713
820
|
}
|
|
714
821
|
|
|
822
|
+
/**
|
|
823
|
+
* Flip the `theme` attribute between `light` and `dark` and persist the choice
|
|
824
|
+
* per tab. Bound to the optional built-in header theme toggle
|
|
825
|
+
* (`data-theme-toggle`); any non-dark theme (incl. `auto` / `code`) flips to
|
|
826
|
+
* `dark` first.
|
|
827
|
+
*/
|
|
828
|
+
toggleTheme(): void {
|
|
829
|
+
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
830
|
+
this.setAttribute("theme", next);
|
|
831
|
+
sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
|
|
832
|
+
this.#syncThemeGlyph();
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
836
|
+
#storageKey(base: string): string {
|
|
837
|
+
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Read a namespaced origin-scoped value, falling back once to the legacy
|
|
842
|
+
* pre-namespacing global key (left in place) so an existing collapsed/theme
|
|
843
|
+
* preference survives the upgrade.
|
|
844
|
+
*/
|
|
845
|
+
#readScopedItem(base: string): string | null {
|
|
846
|
+
const scoped = sessionStorage.getItem(this.#storageKey(base));
|
|
847
|
+
if (scoped !== null || this.#storageNs === "") {
|
|
848
|
+
return scoped;
|
|
849
|
+
}
|
|
850
|
+
return sessionStorage.getItem(base);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Reflect the current theme on the toggle: show the destination's glyph. */
|
|
854
|
+
#syncThemeGlyph(): void {
|
|
855
|
+
const dark = this.getAttribute("theme") === "dark";
|
|
856
|
+
this.#themeToggle.textContent = dark ? "☀️" : "🌙";
|
|
857
|
+
}
|
|
858
|
+
|
|
715
859
|
/**
|
|
716
860
|
* Start a fresh conversation: forget the persisted history, drop the
|
|
717
861
|
* in-memory run state, clear the transcript, and mint a new thread id.
|
|
@@ -731,6 +875,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
731
875
|
this.#client = null;
|
|
732
876
|
this.#streamingBubble = null;
|
|
733
877
|
this.#currentGroup = null;
|
|
878
|
+
this.#thoughts = null;
|
|
734
879
|
this.#hidePending();
|
|
735
880
|
this.#toolCards.clear();
|
|
736
881
|
this.#serverSettled.clear();
|
|
@@ -781,7 +926,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
781
926
|
* result from the page we landed on.
|
|
782
927
|
*/
|
|
783
928
|
async #rehydrate(): Promise<void> {
|
|
929
|
+
// Guard against a thread-switch race: with a slow remote store, picking
|
|
930
|
+
// thread B then C would interleave both replays into one transcript. Each
|
|
931
|
+
// rehydrate claims a generation before awaiting and bails if a newer one
|
|
932
|
+
// started meanwhile (its `#resetState` already cleared the transcript).
|
|
933
|
+
this.#rehydrateGeneration += 1;
|
|
934
|
+
const generation = this.#rehydrateGeneration;
|
|
784
935
|
const messages = await this.conversationStore.loadMessages(this.#threadId);
|
|
936
|
+
if (generation !== this.#rehydrateGeneration) {
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
785
939
|
if (messages !== null) {
|
|
786
940
|
this.#initialMessages = messages;
|
|
787
941
|
for (const message of messages) {
|
|
@@ -879,7 +1033,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
879
1033
|
* stays literal text (no need to parse what the user typed, and it avoids
|
|
880
1034
|
* rendering user-authored markup).
|
|
881
1035
|
*
|
|
882
|
-
* Assistant bubbles land in the current answer group
|
|
1036
|
+
* Assistant bubbles land in the current answer group, opening one if
|
|
883
1037
|
* needed; a user bubble closes the prior group and sits directly in the list
|
|
884
1038
|
* (the well wraps the *assistant* turn, the user message precedes it).
|
|
885
1039
|
*/
|
|
@@ -965,7 +1119,20 @@ export class AgUiChat extends HTMLElement {
|
|
|
965
1119
|
const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
|
|
966
1120
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
967
1121
|
|
|
968
|
-
controls.append(history, newChat
|
|
1122
|
+
controls.append(history, newChat);
|
|
1123
|
+
// Optional built-in theme toggle: off unless the host opts in, so
|
|
1124
|
+
// it never competes with a host-supplied switch in `slot="header-actions"`.
|
|
1125
|
+
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
1126
|
+
this.#themeToggle.type = "button";
|
|
1127
|
+
this.#themeToggle.className = "header-btn header-btn--theme";
|
|
1128
|
+
this.#themeToggle.setAttribute("part", "header-button theme-toggle");
|
|
1129
|
+
this.#themeToggle.title = this.#strings.toggleTheme;
|
|
1130
|
+
this.#themeToggle.setAttribute("aria-label", this.#strings.toggleTheme);
|
|
1131
|
+
this.#themeToggle.addEventListener("click", () => this.toggleTheme());
|
|
1132
|
+
this.#syncThemeGlyph();
|
|
1133
|
+
controls.append(this.#themeToggle);
|
|
1134
|
+
}
|
|
1135
|
+
controls.append(collapse);
|
|
969
1136
|
header.append(title, headerActions, controls);
|
|
970
1137
|
|
|
971
1138
|
this.#messages.className = "messages";
|
|
@@ -1035,11 +1202,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1035
1202
|
|
|
1036
1203
|
this.#attachSlot.className = "attachment-slot";
|
|
1037
1204
|
|
|
1205
|
+
// Mic button mount point (kept empty until #wireVoice mounts the control).
|
|
1206
|
+
this.#voiceSlot.className = "voice-slot";
|
|
1207
|
+
|
|
1038
1208
|
// A coarse footer slot below the composer.
|
|
1039
1209
|
const footer = document.createElement("slot");
|
|
1040
1210
|
footer.name = "footer";
|
|
1041
1211
|
|
|
1042
|
-
inputRow.append(this.#attachButton, this.#input, this.#send, this.#fileInput);
|
|
1212
|
+
inputRow.append(this.#attachButton, this.#voiceSlot, this.#input, this.#send, this.#fileInput);
|
|
1043
1213
|
// Skill surfaces sit just above the input: palette (opens on `/`), chips,
|
|
1044
1214
|
// the missing-placeholder hint, and the pending-attachments tray.
|
|
1045
1215
|
this.#chat.append(
|
|
@@ -1159,6 +1329,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1159
1329
|
}
|
|
1160
1330
|
|
|
1161
1331
|
async #submit(): Promise<void> {
|
|
1332
|
+
// Ignore a submit while a run is in flight — the single choke point for
|
|
1333
|
+
// both Enter and the Send button. The button already turns into Stop, but
|
|
1334
|
+
// Enter has no such guard; without this it would start a second concurrent
|
|
1335
|
+
// SSE run that orphans the first (unabortable) and lets the second run's
|
|
1336
|
+
// settle sweep corrupt the first's still-pending tool cards.
|
|
1337
|
+
if (this.#running) {
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1162
1340
|
const content = this.#input.value.trim();
|
|
1163
1341
|
const attachments = this.#attachTray?.readyRefs() ?? [];
|
|
1164
1342
|
// Allow an attachments-only message (no typed text), but nothing empty.
|
|
@@ -1310,8 +1488,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
1310
1488
|
this.#ensureGroup();
|
|
1311
1489
|
this.#showPending();
|
|
1312
1490
|
},
|
|
1491
|
+
onReasoningStart: () => {
|
|
1492
|
+
// The model is thinking: swap the pending dots for a live thoughts
|
|
1493
|
+
// region at the top of the turn's answer group.
|
|
1494
|
+
this.#hidePending();
|
|
1495
|
+
this.#showThoughts();
|
|
1496
|
+
},
|
|
1497
|
+
onReasoningDelta: (buffer) => {
|
|
1498
|
+
this.#showThoughts().stream(buffer);
|
|
1499
|
+
},
|
|
1500
|
+
onReasoningEnd: () => {
|
|
1501
|
+
// Leave the region expanded until the answer text starts — it collapses
|
|
1502
|
+
// on the first text delta (onTextDelta).
|
|
1503
|
+
},
|
|
1313
1504
|
onTextDelta: (buffer) => {
|
|
1314
1505
|
this.#hidePending();
|
|
1506
|
+
// The answer has begun — fold the thoughts away so they don't crowd it.
|
|
1507
|
+
this.#thoughts?.collapse();
|
|
1315
1508
|
this.#streamInto(buffer);
|
|
1316
1509
|
this.#streamDeltas += 1;
|
|
1317
1510
|
},
|
|
@@ -1380,6 +1573,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1380
1573
|
this.#updateEmptyState();
|
|
1381
1574
|
}
|
|
1382
1575
|
this.#currentGroup = null;
|
|
1576
|
+
this.#thoughts = null;
|
|
1383
1577
|
},
|
|
1384
1578
|
};
|
|
1385
1579
|
}
|
|
@@ -1427,6 +1621,22 @@ export class AgUiChat extends HTMLElement {
|
|
|
1427
1621
|
this.#pending = null;
|
|
1428
1622
|
}
|
|
1429
1623
|
|
|
1624
|
+
/**
|
|
1625
|
+
* The current turn's thoughts region, creating it (at the top of the answer
|
|
1626
|
+
* group, above any streamed text or tool cards) on first sight. Idempotent
|
|
1627
|
+
* across a turn's reasoning tokens.
|
|
1628
|
+
*/
|
|
1629
|
+
#showThoughts(): ThoughtsBlock {
|
|
1630
|
+
if (this.#thoughts === null) {
|
|
1631
|
+
this.#thoughts = new ThoughtsBlock(this.#strings);
|
|
1632
|
+
const group = this.#ensureGroup();
|
|
1633
|
+
group.insertBefore(this.#thoughts.element, group.firstChild);
|
|
1634
|
+
this.#updateEmptyState();
|
|
1635
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1636
|
+
}
|
|
1637
|
+
return this.#thoughts;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1430
1640
|
#streamInto(buffer: string): HTMLDivElement {
|
|
1431
1641
|
if (this.#streamingBubble === null) {
|
|
1432
1642
|
this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
|
package/src/core/agui_client.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface AgUiClientHandlers {
|
|
|
52
52
|
* their result itself — so this is the channel for server-executed output.
|
|
53
53
|
*/
|
|
54
54
|
onToolResult(toolCallId: string, content: string): void;
|
|
55
|
+
/** Fired when a reasoning model starts emitting its chain-of-thought. */
|
|
56
|
+
onReasoningStart(): void;
|
|
57
|
+
/** Fired on every reasoning token; ``buffer`` is the full reasoning text so far. */
|
|
58
|
+
onReasoningDelta(buffer: string): void;
|
|
59
|
+
/** Fired when the reasoning block ends (before the answer text streams). */
|
|
60
|
+
onReasoningEnd(): void;
|
|
55
61
|
onRunEnd(): void;
|
|
56
62
|
onError(message: string): void;
|
|
57
63
|
/**
|
|
@@ -242,7 +248,7 @@ export class AgUiClient {
|
|
|
242
248
|
return;
|
|
243
249
|
}
|
|
244
250
|
const pending: AgUiToolCall[] = [];
|
|
245
|
-
const runState = { terminal: false };
|
|
251
|
+
const runState = { terminal: false, errored: false };
|
|
246
252
|
await this.#agent.runAgent(
|
|
247
253
|
{ tools: this.#getTools(), context: this.#getContext() },
|
|
248
254
|
this.#buildSubscriber(pending, runState),
|
|
@@ -259,6 +265,13 @@ export class AgUiClient {
|
|
|
259
265
|
if (!runState.terminal) {
|
|
260
266
|
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
261
267
|
}
|
|
268
|
+
// RUN_ERROR is terminal: the agent already reported the failure via
|
|
269
|
+
// onError. Don't execute the tool calls collected before it or start
|
|
270
|
+
// another round — that would run into a broken context and surface a
|
|
271
|
+
// confusing second error. Any pending tool card is swept at onSettled.
|
|
272
|
+
if (runState.errored) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
262
275
|
if (this.#executeTool === null || pending.length === 0) {
|
|
263
276
|
return;
|
|
264
277
|
}
|
|
@@ -288,7 +301,10 @@ export class AgUiClient {
|
|
|
288
301
|
}
|
|
289
302
|
}
|
|
290
303
|
|
|
291
|
-
#buildSubscriber(
|
|
304
|
+
#buildSubscriber(
|
|
305
|
+
pending: AgUiToolCall[],
|
|
306
|
+
runState: { terminal: boolean; errored: boolean },
|
|
307
|
+
): AgentSubscriber {
|
|
292
308
|
const h = this.#handlers;
|
|
293
309
|
return {
|
|
294
310
|
onRunInitialized() {
|
|
@@ -312,8 +328,21 @@ export class AgUiClient {
|
|
|
312
328
|
onToolCallResultEvent({ event }) {
|
|
313
329
|
h.onToolResult(event.toolCallId, event.content);
|
|
314
330
|
},
|
|
331
|
+
// Reasoning. `@ag-ui/client` already maps the deprecated
|
|
332
|
+
// THINKING_* events onto these REASONING_* callbacks, so handling the
|
|
333
|
+
// reasoning family alone covers both protocol versions.
|
|
334
|
+
onReasoningStartEvent() {
|
|
335
|
+
h.onReasoningStart();
|
|
336
|
+
},
|
|
337
|
+
onReasoningMessageContentEvent({ reasoningMessageBuffer }) {
|
|
338
|
+
h.onReasoningDelta(reasoningMessageBuffer);
|
|
339
|
+
},
|
|
340
|
+
onReasoningEndEvent() {
|
|
341
|
+
h.onReasoningEnd();
|
|
342
|
+
},
|
|
315
343
|
onRunErrorEvent({ event }) {
|
|
316
344
|
runState.terminal = true;
|
|
345
|
+
runState.errored = true;
|
|
317
346
|
h.onError(event.message);
|
|
318
347
|
},
|
|
319
348
|
onRunFinalized() {
|
package/src/core/attachment.ts
CHANGED
|
@@ -32,8 +32,28 @@ export interface AttachmentRef {
|
|
|
32
32
|
* restored conversation re-renders its attachment chips. The server's strict
|
|
33
33
|
* `RunAgentInput` validation ignores the unknown field — the model learns the
|
|
34
34
|
* ids from the run context manifest instead.
|
|
35
|
+
*
|
|
36
|
+
* The persisted array is untrusted (it can be hand-edited, truncated, or
|
|
37
|
+
* corrupted in storage), so every entry is validated and malformed ones are
|
|
38
|
+
* dropped — a `null` or shapeless entry would otherwise throw in `iconFor` and
|
|
39
|
+
* abort the whole history replay.
|
|
35
40
|
*/
|
|
36
41
|
export function messageAttachments(message: Message): readonly AttachmentRef[] {
|
|
37
42
|
const refs = (message as { attachments?: unknown }).attachments;
|
|
38
|
-
return Array.isArray(refs) ? (
|
|
43
|
+
return Array.isArray(refs) ? refs.filter(isAttachmentRef) : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Whether an unknown value is a structurally valid {@link AttachmentRef}. */
|
|
47
|
+
function isAttachmentRef(value: unknown): value is AttachmentRef {
|
|
48
|
+
if (typeof value !== "object" || value === null) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
const ref = value as Record<string, unknown>;
|
|
52
|
+
return (
|
|
53
|
+
typeof ref["id"] === "string" &&
|
|
54
|
+
typeof ref["name"] === "string" &&
|
|
55
|
+
typeof ref["mime"] === "string" &&
|
|
56
|
+
typeof ref["size"] === "number" &&
|
|
57
|
+
(ref["url"] === undefined || typeof ref["url"] === "string")
|
|
58
|
+
);
|
|
39
59
|
}
|
|
@@ -64,10 +64,11 @@ export interface ClientConversationStore {
|
|
|
64
64
|
renameThread(threadId: string, title: string): void;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
const
|
|
70
|
-
const
|
|
67
|
+
const KEY_ROOT = "ag-ui-chat";
|
|
68
|
+
const THREAD_SUFFIX = "thread";
|
|
69
|
+
const THREADS_SUFFIX = "threads";
|
|
70
|
+
const MESSAGES_SUFFIX = "messages:";
|
|
71
|
+
const CHECKPOINT_SUFFIX = "checkpoint:";
|
|
71
72
|
|
|
72
73
|
const TITLE_LIMIT = 60;
|
|
73
74
|
const PREVIEW_LIMIT = 100;
|
|
@@ -90,33 +91,50 @@ interface StoredThread {
|
|
|
90
91
|
* Tracks multiple threads per tab: the active id lives under one key, the
|
|
91
92
|
* message history / checkpoint are namespaced by id, and a small index feeds
|
|
92
93
|
* the drawer so it works with no server.
|
|
94
|
+
*
|
|
95
|
+
* An optional `namespace` scopes every key to one element (its `id`, else its
|
|
96
|
+
* endpoint), so two `<ag-ui-chat>` instances — or two apps — on the same origin
|
|
97
|
+
* keep separate active-thread pointers and drawer indexes instead of clobbering
|
|
98
|
+
* each other. Constructing with a namespace migrates any pre-namespacing
|
|
99
|
+
* (`ag-ui-chat:*`) keys into it once, so an existing conversation survives the
|
|
100
|
+
* upgrade; the default empty namespace keeps the legacy origin-global keys.
|
|
93
101
|
*/
|
|
94
102
|
export class SessionStorageStore implements ClientConversationStore {
|
|
103
|
+
readonly #root: string;
|
|
104
|
+
|
|
105
|
+
constructor(namespace = "") {
|
|
106
|
+
this.#root = namespace === "" ? KEY_ROOT : `${KEY_ROOT}@${namespace}`;
|
|
107
|
+
if (namespace !== "") {
|
|
108
|
+
this.#migrateLegacyKeys();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
threadId(): string {
|
|
96
|
-
const
|
|
113
|
+
const key = this.#key(THREAD_SUFFIX);
|
|
114
|
+
const existing = sessionStorage.getItem(key);
|
|
97
115
|
if (existing !== null) {
|
|
98
116
|
return existing;
|
|
99
117
|
}
|
|
100
118
|
const id = randomUUID();
|
|
101
|
-
sessionStorage.setItem(
|
|
119
|
+
sessionStorage.setItem(key, id);
|
|
102
120
|
return id;
|
|
103
121
|
}
|
|
104
122
|
|
|
105
123
|
loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
106
|
-
return Promise.resolve(this.#readJson<Message[]>(
|
|
124
|
+
return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
110
|
-
sessionStorage.setItem(
|
|
128
|
+
sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
111
129
|
this.#touchThread(threadId, messages);
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
loadCheckpoint(threadId: string): NavigationCheckpoint | null {
|
|
115
|
-
return this.#readJson<NavigationCheckpoint>(
|
|
133
|
+
return this.#readJson<NavigationCheckpoint>(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
116
134
|
}
|
|
117
135
|
|
|
118
136
|
saveCheckpoint(threadId: string, checkpoint: NavigationCheckpoint | null): void {
|
|
119
|
-
const key =
|
|
137
|
+
const key = this.#key(CHECKPOINT_SUFFIX + threadId);
|
|
120
138
|
if (checkpoint === null) {
|
|
121
139
|
sessionStorage.removeItem(key);
|
|
122
140
|
return;
|
|
@@ -125,14 +143,14 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
clear(threadId: string): void {
|
|
128
|
-
sessionStorage.removeItem(
|
|
129
|
-
sessionStorage.removeItem(
|
|
146
|
+
sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
|
|
147
|
+
sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
130
148
|
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
131
149
|
// Only drop the active pointer when the active thread itself is cleared, so
|
|
132
150
|
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
133
151
|
// drawer must not disturb the conversation on screen.
|
|
134
|
-
if (sessionStorage.getItem(
|
|
135
|
-
sessionStorage.removeItem(
|
|
152
|
+
if (sessionStorage.getItem(this.#key(THREAD_SUFFIX)) === threadId) {
|
|
153
|
+
sessionStorage.removeItem(this.#key(THREAD_SUFFIX));
|
|
136
154
|
}
|
|
137
155
|
}
|
|
138
156
|
|
|
@@ -144,7 +162,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
144
162
|
}
|
|
145
163
|
|
|
146
164
|
setActiveThread(threadId: string): void {
|
|
147
|
-
sessionStorage.setItem(
|
|
165
|
+
sessionStorage.setItem(this.#key(THREAD_SUFFIX), threadId);
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
renameThread(threadId: string, title: string): void {
|
|
@@ -183,15 +201,53 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
183
201
|
}
|
|
184
202
|
|
|
185
203
|
#readThreads(): StoredThread[] {
|
|
186
|
-
return this.#readJson<StoredThread[]>(
|
|
204
|
+
return this.#readJson<StoredThread[]>(this.#key(THREADS_SUFFIX)) ?? [];
|
|
187
205
|
}
|
|
188
206
|
|
|
189
207
|
#writeThreads(threads: readonly StoredThread[]): void {
|
|
208
|
+
const key = this.#key(THREADS_SUFFIX);
|
|
190
209
|
if (threads.length === 0) {
|
|
191
|
-
sessionStorage.removeItem(
|
|
210
|
+
sessionStorage.removeItem(key);
|
|
192
211
|
return;
|
|
193
212
|
}
|
|
194
|
-
sessionStorage.setItem(
|
|
213
|
+
sessionStorage.setItem(key, JSON.stringify(threads));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** This store's fully-qualified key for a suffix (namespaced when set). */
|
|
217
|
+
#key(suffix: string): string {
|
|
218
|
+
return `${this.#root}:${suffix}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* One-time move of pre-namespacing (`ag-ui-chat:*`) keys into this instance's
|
|
223
|
+
* namespace, so an existing conversation isn't orphaned by the upgrade. Only
|
|
224
|
+
* this store's own keys move (thread pointer, drawer index, per-thread
|
|
225
|
+
* messages/checkpoints) — the element's `collapsed`/`theme` keys are left
|
|
226
|
+
* alone. The first namespaced instance to mount adopts the legacy data; a
|
|
227
|
+
* second namespace finds it gone and starts fresh.
|
|
228
|
+
*/
|
|
229
|
+
#migrateLegacyKeys(): void {
|
|
230
|
+
const legacyRoot = `${KEY_ROOT}:`;
|
|
231
|
+
const moves: Array<readonly [string, string]> = [];
|
|
232
|
+
for (let i = 0; i < sessionStorage.length; i += 1) {
|
|
233
|
+
const key = sessionStorage.key(i);
|
|
234
|
+
if (key === null || !key.startsWith(legacyRoot)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const suffix = key.slice(legacyRoot.length);
|
|
238
|
+
if (isOwnedSuffix(suffix)) {
|
|
239
|
+
moves.push([key, this.#key(suffix)]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// Collected first, mutated second — writing while iterating by index skips
|
|
243
|
+
// entries as the key list shifts.
|
|
244
|
+
for (const [from, to] of moves) {
|
|
245
|
+
const value = sessionStorage.getItem(from);
|
|
246
|
+
if (value !== null && sessionStorage.getItem(to) === null) {
|
|
247
|
+
sessionStorage.setItem(to, value);
|
|
248
|
+
}
|
|
249
|
+
sessionStorage.removeItem(from);
|
|
250
|
+
}
|
|
195
251
|
}
|
|
196
252
|
|
|
197
253
|
/** Parse a stored JSON value, returning `null` when absent or corrupt. */
|
|
@@ -208,6 +264,16 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
208
264
|
}
|
|
209
265
|
}
|
|
210
266
|
|
|
267
|
+
/** Whether a legacy key suffix belongs to the store (vs the element's own keys). */
|
|
268
|
+
function isOwnedSuffix(suffix: string): boolean {
|
|
269
|
+
return (
|
|
270
|
+
suffix === THREAD_SUFFIX ||
|
|
271
|
+
suffix === THREADS_SUFFIX ||
|
|
272
|
+
suffix.startsWith(MESSAGES_SUFFIX) ||
|
|
273
|
+
suffix.startsWith(CHECKPOINT_SUFFIX)
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
211
277
|
/** The thread title: the first user message, collapsed + truncated. */
|
|
212
278
|
function deriveTitle(messages: readonly Message[]): string {
|
|
213
279
|
for (const message of messages) {
|