@artooi/ag-ui-web-component 0.9.0 → 0.11.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 +104 -7
- package/README.md +89 -7
- package/dist/ag-ui-web-component.bundle.js +168 -47
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +39 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -1
- 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/upload_attachment.d.ts +8 -2
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +887 -128
- package/dist/index.js.map +4 -4
- package/dist/ui/approval_card.d.ts +51 -0
- package/dist/ui/approval_card.d.ts.map +1 -0
- package/dist/ui/attachment_chips.d.ts.map +1 -1
- package/dist/ui/attachment_tray.d.ts +7 -1
- package/dist/ui/attachment_tray.d.ts.map +1 -1
- package/dist/ui/question_card.d.ts +52 -0
- package/dist/ui/question_card.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +5 -3
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/skills_menu.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 +2 -2
- package/dist/ui/thoughts_block.d.ts.map +1 -1
- 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 +16 -0
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +9 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/core/ag_ui_chat.ts +251 -14
- package/src/core/agui_client.ts +95 -9
- 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/upload_attachment.ts +8 -1
- package/src/index.ts +14 -0
- package/src/ui/approval_card.ts +119 -0
- package/src/ui/attachment_chips.ts +5 -0
- package/src/ui/attachment_tray.ts +50 -5
- package/src/ui/question_card.ts +216 -0
- package/src/ui/relative_time.ts +8 -3
- package/src/ui/skills_menu.ts +6 -0
- package/src/ui/styles.ts +130 -9
- package/src/ui/thoughts_block.ts +3 -2
- package/src/ui/thread_drawer.ts +94 -9
- package/src/ui/tool_call_card.ts +6 -0
- package/src/ui/ui_strings.ts +30 -0
- package/src/ui/voice_input.ts +21 -1
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Context, Message, Tool } from "@ag-ui/core";
|
|
1
|
+
import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
|
|
2
2
|
import {
|
|
3
3
|
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
4
4
|
MESSAGE_ROLE,
|
|
@@ -20,10 +20,20 @@ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
|
20
20
|
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
21
21
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
22
22
|
import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
|
|
23
|
+
import {
|
|
24
|
+
type ApprovalRenderer,
|
|
25
|
+
type ApprovalRequest,
|
|
26
|
+
requestApproval,
|
|
27
|
+
} from "../ui/approval_card.js";
|
|
23
28
|
import { renderAttachmentChips } from "../ui/attachment_chips.js";
|
|
24
29
|
import { AttachmentTray } from "../ui/attachment_tray.js";
|
|
25
30
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
26
31
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
32
|
+
import {
|
|
33
|
+
type QuestionRenderer,
|
|
34
|
+
type QuestionRequest,
|
|
35
|
+
requestQuestion,
|
|
36
|
+
} from "../ui/question_card.js";
|
|
27
37
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
28
38
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
29
39
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
@@ -37,6 +47,7 @@ import {
|
|
|
37
47
|
AgUiClient,
|
|
38
48
|
type AgUiClientHandlers,
|
|
39
49
|
type AgUiToolCall,
|
|
50
|
+
type InterruptResponse,
|
|
40
51
|
type ToolExecution,
|
|
41
52
|
} from "./agui_client.js";
|
|
42
53
|
import { type AttachmentRef, messageAttachments } from "./attachment.js";
|
|
@@ -68,7 +79,7 @@ export interface ToggleDetail {
|
|
|
68
79
|
/** Per-tab persistence key for the collapsed state (survives MPA reloads). */
|
|
69
80
|
const COLLAPSED_KEY = "ag-ui-chat:collapsed";
|
|
70
81
|
|
|
71
|
-
/** Per-tab persistence key for the built-in theme toggle
|
|
82
|
+
/** Per-tab persistence key for the built-in theme toggle. */
|
|
72
83
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
73
84
|
|
|
74
85
|
/**
|
|
@@ -101,6 +112,36 @@ export class AgUiChat extends HTMLElement {
|
|
|
101
112
|
/** When true, destructive tools execute without a confirmation modal. */
|
|
102
113
|
autoConfirm = false;
|
|
103
114
|
|
|
115
|
+
/**
|
|
116
|
+
* When true, the built-in `ask_user` frontend tool is offered to the agent:
|
|
117
|
+
* calling it renders an inline question card (radio choices and/or a free-text
|
|
118
|
+
* field) and returns the user's answer. Off by default — like the other
|
|
119
|
+
* built-in tool groups (route / page-action), it is opt-in so it doesn't
|
|
120
|
+
* change the advertised catalog until a host asks for it.
|
|
121
|
+
*/
|
|
122
|
+
askUser = false;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Optional full replacement for the `ask_user` question UI. When set, calling
|
|
126
|
+
* `ask_user` invokes this instead of the built-in inline card: the host
|
|
127
|
+
* renders whatever it likes (a native modal, a framework component, …) and
|
|
128
|
+
* resolves with the answer. Unset (default) uses the built-in
|
|
129
|
+
* {@link requestQuestion} card — style that via the `strings` override and the
|
|
130
|
+
* `question*` CSS `::part()`s. Requires {@link askUser} to be enabled.
|
|
131
|
+
*/
|
|
132
|
+
askUserRenderer: QuestionRenderer | null = null;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Optional full replacement for the server-side-tool approval UI. When set, an
|
|
136
|
+
* approval interrupt invokes this instead of the built-in inline approval
|
|
137
|
+
* card: the host renders whatever it likes and resolves `true` to approve /
|
|
138
|
+
* `false` to deny. Unset (default) uses the built-in {@link requestApproval}
|
|
139
|
+
* card — style that via the `strings` override and the `approval*` CSS
|
|
140
|
+
* `::part()`s. The gate itself is enabled server-side; this only changes how
|
|
141
|
+
* the decision is collected.
|
|
142
|
+
*/
|
|
143
|
+
approvalRenderer: ApprovalRenderer | null = null;
|
|
144
|
+
|
|
104
145
|
/**
|
|
105
146
|
* Optional per-call confirmation predicate. When set, it is authoritative:
|
|
106
147
|
* given a tool name + args it decides whether *this* call needs confirmation
|
|
@@ -259,7 +300,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
259
300
|
readonly #attachButton: HTMLButtonElement;
|
|
260
301
|
readonly #fileInput: HTMLInputElement;
|
|
261
302
|
readonly #attachSlot: HTMLDivElement;
|
|
262
|
-
/** Optional built-in header theme toggle
|
|
303
|
+
/** Optional built-in header theme toggle; shown only with `data-theme-toggle`. */
|
|
263
304
|
readonly #themeToggle: HTMLButtonElement;
|
|
264
305
|
/** The collapsed-sidebar rail (an expand affordance; shown only for `placement="sidebar"`). */
|
|
265
306
|
readonly #rail: HTMLButtonElement;
|
|
@@ -288,18 +329,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
288
329
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
289
330
|
#streamDeltas = 0;
|
|
290
331
|
#pending: HTMLDivElement | null = null;
|
|
291
|
-
// The current assistant turn's grouping container
|
|
332
|
+
// The current assistant turn's grouping container. One `.answer`
|
|
292
333
|
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
293
334
|
// pending indicator — so it can be boxed as one "well" by CSS. Opened on the
|
|
294
335
|
// turn's first run start, closed at settle, so it spans the whole multi-round
|
|
295
336
|
// frontend-tool loop (which is several AG-UI runs), not one run. `null`
|
|
296
337
|
// between turns; user bubbles never enter it.
|
|
297
338
|
#currentGroup: HTMLDivElement | null = null;
|
|
298
|
-
// The current turn's streamed-reasoning region
|
|
339
|
+
// The current turn's streamed-reasoning region, shown at the top of
|
|
299
340
|
// the answer group while a reasoning model thinks and collapsed once the
|
|
300
341
|
// answer's first text token arrives. `null` outside a reasoning turn.
|
|
301
342
|
#thoughts: ThoughtsBlock | null = null;
|
|
302
343
|
#threadId = "";
|
|
344
|
+
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
345
|
+
// active thread), so two instances on one origin don't clobber each other.
|
|
346
|
+
// Empty ⇒ the pre-namespacing global keys (back-compat). Resolved on connect.
|
|
347
|
+
#storageNs = "";
|
|
348
|
+
// Bumped on every #rehydrate; a replay whose generation is stale (a newer
|
|
349
|
+
// thread switch started while it awaited a slow store) drops its result.
|
|
350
|
+
#rehydrateGeneration = 0;
|
|
303
351
|
#initialMessages: readonly Message[] = [];
|
|
304
352
|
// Skill catalog by source; merged backend → embed → client (later wins).
|
|
305
353
|
#backendSkills: readonly Skill[] = [];
|
|
@@ -423,9 +471,84 @@ export class AgUiChat extends HTMLElement {
|
|
|
423
471
|
return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
|
|
424
472
|
}
|
|
425
473
|
|
|
426
|
-
/** All built-in (route + page + page-action) frontend tools. */
|
|
474
|
+
/** All built-in (route + page + page-action + ask_user) frontend tools. */
|
|
427
475
|
#builtinTools(): ClientTool[] {
|
|
428
|
-
return [
|
|
476
|
+
return [
|
|
477
|
+
...this.#routeTools(),
|
|
478
|
+
...this.#pageTools(),
|
|
479
|
+
...this.#pageActionTools(),
|
|
480
|
+
...this.#askUserTool(),
|
|
481
|
+
];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The built-in `ask_user` frontend tool, or `[]` when {@link askUser} is off.
|
|
486
|
+
*
|
|
487
|
+
* A generic "ask the user a typed question" primitive: the agent calls it, the
|
|
488
|
+
* client executes it locally via the normal frontend-tool path (rendering a
|
|
489
|
+
* {@link requestQuestion} card), and the chosen/typed answer flows back as the
|
|
490
|
+
* tool result — no new protocol, reusing the machinery already in place.
|
|
491
|
+
*/
|
|
492
|
+
#askUserTool(): ClientTool[] {
|
|
493
|
+
if (!this.askUser) {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
return [
|
|
497
|
+
{
|
|
498
|
+
name: "ask_user",
|
|
499
|
+
description:
|
|
500
|
+
"Ask the user a question and wait for their answer. Provide `options` for a " +
|
|
501
|
+
"multiple-choice prompt; set `allow_custom` to also accept a free-text answer.",
|
|
502
|
+
parameters: {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
question: { type: "string", description: "The question to ask the user." },
|
|
506
|
+
options: {
|
|
507
|
+
type: "array",
|
|
508
|
+
items: { type: "string" },
|
|
509
|
+
description: "Preset choices offered as radio buttons.",
|
|
510
|
+
},
|
|
511
|
+
allow_custom: {
|
|
512
|
+
type: "boolean",
|
|
513
|
+
description: "Allow a free-text answer in addition to any options.",
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
required: ["question"],
|
|
517
|
+
},
|
|
518
|
+
handler: (args) => this.#askUser(args),
|
|
519
|
+
},
|
|
520
|
+
];
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Render the `ask_user` question card and resolve with the user's answer. */
|
|
524
|
+
async #askUser(args: Record<string, unknown>): Promise<string> {
|
|
525
|
+
const question = typeof args["question"] === "string" ? args["question"] : "";
|
|
526
|
+
const request: QuestionRequest = { question };
|
|
527
|
+
const rawOptions = args["options"];
|
|
528
|
+
if (Array.isArray(rawOptions)) {
|
|
529
|
+
request.options = rawOptions.filter((option): option is string => typeof option === "string");
|
|
530
|
+
}
|
|
531
|
+
if (args["allow_custom"] === true) {
|
|
532
|
+
request.allowCustom = true;
|
|
533
|
+
}
|
|
534
|
+
// The run is suspended on the card; a Stop aborts the controller, resolving
|
|
535
|
+
// it with an empty answer (the run is then cancelled).
|
|
536
|
+
this.#confirmAbort = new AbortController();
|
|
537
|
+
const signal = this.#confirmAbort.signal;
|
|
538
|
+
this.#hidePending();
|
|
539
|
+
// A host-supplied renderer takes full control of the UI; otherwise the
|
|
540
|
+
// built-in inline card renders into the current answer group.
|
|
541
|
+
const answer =
|
|
542
|
+
this.askUserRenderer !== null
|
|
543
|
+
? await this.askUserRenderer(request, { signal })
|
|
544
|
+
: await requestQuestion(this.#ensureGroup(), request, {
|
|
545
|
+
signal,
|
|
546
|
+
strings: this.#strings,
|
|
547
|
+
});
|
|
548
|
+
this.#confirmAbort = null;
|
|
549
|
+
this.#updateEmptyState();
|
|
550
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
551
|
+
return answer;
|
|
429
552
|
}
|
|
430
553
|
|
|
431
554
|
/** Resolve a tool by name: built-in tools first, then the registry. */
|
|
@@ -470,25 +593,34 @@ export class AgUiChat extends HTMLElement {
|
|
|
470
593
|
}
|
|
471
594
|
|
|
472
595
|
connectedCallback(): void {
|
|
596
|
+
// Resolve the per-instance storage namespace (id, else endpoint) before any
|
|
597
|
+
// key read/write, so this instance doesn't share collapsed/theme/thread
|
|
598
|
+
// state with another on the same origin.
|
|
599
|
+
this.#storageNs = this.id !== "" ? this.id : this.endpoint;
|
|
473
600
|
// Resolve the string table before rendering any chrome (defaults are the
|
|
474
601
|
// floor; `data-strings` then the `strings` property layer over them).
|
|
475
602
|
this.#strings = mergeUiStrings({ ...this.#readStringOverrides(), ...this.strings });
|
|
476
603
|
// Restore a theme the built-in toggle persisted last visit (opt-in only, so
|
|
477
604
|
// it never overrides a host that drives `theme` itself).
|
|
478
605
|
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
479
|
-
const saved =
|
|
606
|
+
const saved = this.#readScopedItem(THEME_KEY);
|
|
480
607
|
if (saved !== null) {
|
|
481
608
|
this.setAttribute("theme", saved);
|
|
482
609
|
}
|
|
483
610
|
}
|
|
484
611
|
this.#render();
|
|
485
612
|
this.#drawer.setStrings(this.#strings);
|
|
486
|
-
if (
|
|
613
|
+
if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
|
|
487
614
|
this.setAttribute("collapsed", "");
|
|
488
615
|
}
|
|
489
616
|
this.#syncRail();
|
|
490
617
|
this.#initSkills();
|
|
491
618
|
void this.#fetchToolCatalog();
|
|
619
|
+
// Namespace the built-in default store too (a host-injected store is used
|
|
620
|
+
// verbatim). Must precede #wireThreadStore, which wraps the current store.
|
|
621
|
+
if (this.#storageNs !== "" && this.conversationStore instanceof SessionStorageStore) {
|
|
622
|
+
this.conversationStore = new SessionStorageStore(this.#storageNs);
|
|
623
|
+
}
|
|
492
624
|
this.#wireThreadStore();
|
|
493
625
|
this.#wireAttachments();
|
|
494
626
|
this.#wireVoice();
|
|
@@ -496,6 +628,20 @@ export class AgUiChat extends HTMLElement {
|
|
|
496
628
|
void this.#rehydrate();
|
|
497
629
|
}
|
|
498
630
|
|
|
631
|
+
/**
|
|
632
|
+
* Tear down live resources when the element leaves the DOM (a removed node, a
|
|
633
|
+
* client-side route swap): cancel the in-flight run so its SSE stream closes,
|
|
634
|
+
* abort any in-flight uploads so they don't orphan server-side files, and
|
|
635
|
+
* release the mic so the browser's recording indicator clears. Without this a
|
|
636
|
+
* removed `<ag-ui-chat>` leaks a streaming request, uploads, and a live
|
|
637
|
+
* `MediaRecorder`.
|
|
638
|
+
*/
|
|
639
|
+
disconnectedCallback(): void {
|
|
640
|
+
this.#cancelRun();
|
|
641
|
+
this.#attachTray?.dispose();
|
|
642
|
+
this.#voice?.dispose();
|
|
643
|
+
}
|
|
644
|
+
|
|
499
645
|
/** Parse the inline `data-strings` JSON overrides (empty when absent/malformed). */
|
|
500
646
|
#readStringOverrides(): Partial<UiStrings> {
|
|
501
647
|
const raw = this.getAttribute("data-strings");
|
|
@@ -544,7 +690,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
544
690
|
if (url === null) {
|
|
545
691
|
return null;
|
|
546
692
|
}
|
|
547
|
-
|
|
693
|
+
// Forward the tray's abort signal so removing a chip (or tearing the
|
|
694
|
+
// element down) cancels the XHR.
|
|
695
|
+
return (file, onProgress, signal) =>
|
|
696
|
+
uploadAttachment(file, { url, headers: this.headers, onProgress, signal });
|
|
548
697
|
}
|
|
549
698
|
|
|
550
699
|
/**
|
|
@@ -770,7 +919,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
770
919
|
} else {
|
|
771
920
|
this.removeAttribute("collapsed");
|
|
772
921
|
}
|
|
773
|
-
sessionStorage.setItem(COLLAPSED_KEY, collapsed ? "1" : "0");
|
|
922
|
+
sessionStorage.setItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
|
|
774
923
|
this.#syncRail();
|
|
775
924
|
this.dispatchEvent(
|
|
776
925
|
new CustomEvent<ToggleDetail>(TOGGLE_EVENT, {
|
|
@@ -795,10 +944,28 @@ export class AgUiChat extends HTMLElement {
|
|
|
795
944
|
toggleTheme(): void {
|
|
796
945
|
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
797
946
|
this.setAttribute("theme", next);
|
|
798
|
-
sessionStorage.setItem(THEME_KEY, next);
|
|
947
|
+
sessionStorage.setItem(this.#storageKey(THEME_KEY), next);
|
|
799
948
|
this.#syncThemeGlyph();
|
|
800
949
|
}
|
|
801
950
|
|
|
951
|
+
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
952
|
+
#storageKey(base: string): string {
|
|
953
|
+
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Read a namespaced origin-scoped value, falling back once to the legacy
|
|
958
|
+
* pre-namespacing global key (left in place) so an existing collapsed/theme
|
|
959
|
+
* preference survives the upgrade.
|
|
960
|
+
*/
|
|
961
|
+
#readScopedItem(base: string): string | null {
|
|
962
|
+
const scoped = sessionStorage.getItem(this.#storageKey(base));
|
|
963
|
+
if (scoped !== null || this.#storageNs === "") {
|
|
964
|
+
return scoped;
|
|
965
|
+
}
|
|
966
|
+
return sessionStorage.getItem(base);
|
|
967
|
+
}
|
|
968
|
+
|
|
802
969
|
/** Reflect the current theme on the toggle: show the destination's glyph. */
|
|
803
970
|
#syncThemeGlyph(): void {
|
|
804
971
|
const dark = this.getAttribute("theme") === "dark";
|
|
@@ -875,7 +1042,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
875
1042
|
* result from the page we landed on.
|
|
876
1043
|
*/
|
|
877
1044
|
async #rehydrate(): Promise<void> {
|
|
1045
|
+
// Guard against a thread-switch race: with a slow remote store, picking
|
|
1046
|
+
// thread B then C would interleave both replays into one transcript. Each
|
|
1047
|
+
// rehydrate claims a generation before awaiting and bails if a newer one
|
|
1048
|
+
// started meanwhile (its `#resetState` already cleared the transcript).
|
|
1049
|
+
this.#rehydrateGeneration += 1;
|
|
1050
|
+
const generation = this.#rehydrateGeneration;
|
|
878
1051
|
const messages = await this.conversationStore.loadMessages(this.#threadId);
|
|
1052
|
+
if (generation !== this.#rehydrateGeneration) {
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
879
1055
|
if (messages !== null) {
|
|
880
1056
|
this.#initialMessages = messages;
|
|
881
1057
|
for (const message of messages) {
|
|
@@ -973,7 +1149,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
973
1149
|
* stays literal text (no need to parse what the user typed, and it avoids
|
|
974
1150
|
* rendering user-authored markup).
|
|
975
1151
|
*
|
|
976
|
-
* Assistant bubbles land in the current answer group
|
|
1152
|
+
* Assistant bubbles land in the current answer group, opening one if
|
|
977
1153
|
* needed; a user bubble closes the prior group and sits directly in the list
|
|
978
1154
|
* (the well wraps the *assistant* turn, the user message precedes it).
|
|
979
1155
|
*/
|
|
@@ -1060,7 +1236,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1060
1236
|
collapse.addEventListener("click", () => this.toggleCollapsed());
|
|
1061
1237
|
|
|
1062
1238
|
controls.append(history, newChat);
|
|
1063
|
-
// Optional built-in theme toggle
|
|
1239
|
+
// Optional built-in theme toggle: off unless the host opts in, so
|
|
1064
1240
|
// it never competes with a host-supplied switch in `slot="header-actions"`.
|
|
1065
1241
|
if (this.getAttribute("data-theme-toggle") !== null) {
|
|
1066
1242
|
this.#themeToggle.type = "button";
|
|
@@ -1120,6 +1296,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1120
1296
|
});
|
|
1121
1297
|
|
|
1122
1298
|
this.#skillHint.className = "skill-hint";
|
|
1299
|
+
this.#skillHint.setAttribute("part", "skill-hint");
|
|
1123
1300
|
this.#skillHint.hidden = true;
|
|
1124
1301
|
|
|
1125
1302
|
// File-upload affordance: a 📎 button (hidden until `data-attachments-url`
|
|
@@ -1269,6 +1446,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1269
1446
|
}
|
|
1270
1447
|
|
|
1271
1448
|
async #submit(): Promise<void> {
|
|
1449
|
+
// Ignore a submit while a run is in flight — the single choke point for
|
|
1450
|
+
// both Enter and the Send button. The button already turns into Stop, but
|
|
1451
|
+
// Enter has no such guard; without this it would start a second concurrent
|
|
1452
|
+
// SSE run that orphans the first (unabortable) and lets the second run's
|
|
1453
|
+
// settle sweep corrupt the first's still-pending tool cards.
|
|
1454
|
+
if (this.#running) {
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1272
1457
|
const content = this.#input.value.trim();
|
|
1273
1458
|
const attachments = this.#attachTray?.readyRefs() ?? [];
|
|
1274
1459
|
// Allow an attachments-only message (no typed text), but nothing empty.
|
|
@@ -1320,6 +1505,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1320
1505
|
getTools: () => this.getTools(),
|
|
1321
1506
|
getContext: () => this.getContext(),
|
|
1322
1507
|
executeTool: (call) => this.#executeTool(call),
|
|
1508
|
+
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
1323
1509
|
onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
|
|
1324
1510
|
connectionLostMessage: this.#strings.connectionLost,
|
|
1325
1511
|
});
|
|
@@ -1410,6 +1596,57 @@ export class AgUiChat extends HTMLElement {
|
|
|
1410
1596
|
}
|
|
1411
1597
|
}
|
|
1412
1598
|
|
|
1599
|
+
/**
|
|
1600
|
+
* Render an approval card per server-side-tool interrupt and collect the
|
|
1601
|
+
* user's decisions (approve → run it, deny → decline it).
|
|
1602
|
+
*
|
|
1603
|
+
* The run is suspended on these cards; a Stop while any is open aborts the
|
|
1604
|
+
* shared {@link #confirmAbort} controller, resolving every still-open card as
|
|
1605
|
+
* denied (and the client loop then sees the cancellation and stops). An
|
|
1606
|
+
* approved tool runs on the follow-up (resume) run and streams its result
|
|
1607
|
+
* back into the same pending card; a denied one is settled here, since no
|
|
1608
|
+
* result will ever arrive for it.
|
|
1609
|
+
*/
|
|
1610
|
+
async #resolveInterrupts(
|
|
1611
|
+
interrupts: readonly Interrupt[],
|
|
1612
|
+
): Promise<Record<string, InterruptResponse>> {
|
|
1613
|
+
const responses: Record<string, InterruptResponse> = {};
|
|
1614
|
+
// One controller covers the whole batch: a single Stop denies all of them.
|
|
1615
|
+
this.#confirmAbort = new AbortController();
|
|
1616
|
+
this.#hidePending();
|
|
1617
|
+
for (const interrupt of interrupts) {
|
|
1618
|
+
const request: ApprovalRequest = {};
|
|
1619
|
+
if (interrupt.message !== undefined) {
|
|
1620
|
+
request.message = interrupt.message;
|
|
1621
|
+
}
|
|
1622
|
+
const card =
|
|
1623
|
+
interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
|
|
1624
|
+
const toolName = card?.element.getAttribute("data-tool-name");
|
|
1625
|
+
if (toolName !== null && toolName !== undefined) {
|
|
1626
|
+
request.toolName = toolName;
|
|
1627
|
+
}
|
|
1628
|
+
const signal = this.#confirmAbort.signal;
|
|
1629
|
+
// A host-supplied renderer takes full control of the approval UI;
|
|
1630
|
+
// otherwise the built-in inline card renders into the current answer group.
|
|
1631
|
+
const approved =
|
|
1632
|
+
this.approvalRenderer !== null
|
|
1633
|
+
? await this.approvalRenderer(request, { signal })
|
|
1634
|
+
: await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
|
|
1635
|
+
this.#updateEmptyState();
|
|
1636
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
1637
|
+
if (approved) {
|
|
1638
|
+
responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
|
|
1639
|
+
} else {
|
|
1640
|
+
responses[interrupt.id] = { status: "cancelled" };
|
|
1641
|
+
// No TOOL_CALL_RESULT will stream for a denied tool — settle its pending
|
|
1642
|
+
// card now rather than leaving it hanging until the onSettled sweep.
|
|
1643
|
+
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
this.#confirmAbort = null;
|
|
1647
|
+
return responses;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1413
1650
|
#handlers(): AgUiClientHandlers {
|
|
1414
1651
|
return {
|
|
1415
1652
|
onRunStart: () => {
|
package/src/core/agui_client.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
type AbstractAgent,
|
|
3
|
+
type AgentSubscriber,
|
|
4
|
+
buildResumeArray,
|
|
5
|
+
type RunAgentParameters,
|
|
6
|
+
randomUUID,
|
|
7
|
+
} from "@ag-ui/client";
|
|
8
|
+
import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
|
|
3
9
|
import { MAX_TOOL_ROUNDS } from "../constants.js";
|
|
4
10
|
import type { AttachmentRef } from "./attachment.js";
|
|
5
11
|
|
|
@@ -33,6 +39,27 @@ export interface ToolExecution {
|
|
|
33
39
|
*/
|
|
34
40
|
export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
|
|
35
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One user decision for a server-side-tool approval interrupt. Structurally
|
|
44
|
+
* matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
|
|
45
|
+
* {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
|
|
46
|
+
* an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
|
|
47
|
+
*/
|
|
48
|
+
export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolves the approval interrupts a run finished on, keyed by interrupt id.
|
|
52
|
+
*
|
|
53
|
+
* When a gated **server-side** tool defers instead of executing, the run
|
|
54
|
+
* finishes on an AG-UI interrupt outcome; the host renders an approval card per
|
|
55
|
+
* interrupt and returns each decision here, and the loop resumes the run with
|
|
56
|
+
* the answers. Omit for agents that never gate server-side tools — an
|
|
57
|
+
* unresolved interrupt then simply ends the loop.
|
|
58
|
+
*/
|
|
59
|
+
export type ResolveInterrupts = (
|
|
60
|
+
interrupts: readonly Interrupt[],
|
|
61
|
+
) => Promise<Record<string, InterruptResponse>>;
|
|
62
|
+
|
|
36
63
|
/**
|
|
37
64
|
* Callbacks the {@link AgUiClient} invokes as a run progresses. The host
|
|
38
65
|
* (the `<ag-ui-chat>` element) implements these to render streaming text and
|
|
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
93
120
|
handlers: AgUiClientHandlers;
|
|
94
121
|
/** Executes frontend tool calls. Omit for server-only tool sets. */
|
|
95
122
|
executeTool?: ExecuteTool;
|
|
123
|
+
/**
|
|
124
|
+
* Resolves server-side-tool approval interrupts. Omit when no server-side
|
|
125
|
+
* tool is gated for approval — an interrupt then ends the loop unanswered.
|
|
126
|
+
*/
|
|
127
|
+
resolveInterrupts?: ResolveInterrupts;
|
|
96
128
|
/**
|
|
97
129
|
* Invoked with the latest history whenever it changes, so the host can
|
|
98
130
|
* persist it for durability across page reloads. Omit to keep the
|
|
@@ -134,6 +166,7 @@ export class AgUiClient {
|
|
|
134
166
|
readonly #getTools: () => Tool[];
|
|
135
167
|
readonly #getContext: () => Context[];
|
|
136
168
|
readonly #executeTool: ExecuteTool | null;
|
|
169
|
+
readonly #resolveInterrupts: ResolveInterrupts | null;
|
|
137
170
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
138
171
|
readonly #connectionLostMessage: string;
|
|
139
172
|
// Set by cancel(); reset at the top of each #run(). Checked by the loop so
|
|
@@ -146,6 +179,7 @@ export class AgUiClient {
|
|
|
146
179
|
this.#getTools = config.getTools ?? (() => []);
|
|
147
180
|
this.#getContext = config.getContext ?? (() => []);
|
|
148
181
|
this.#executeTool = config.executeTool ?? null;
|
|
182
|
+
this.#resolveInterrupts = config.resolveInterrupts ?? null;
|
|
149
183
|
this.#onPersist = config.onPersist ?? (() => {});
|
|
150
184
|
this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
|
|
151
185
|
}
|
|
@@ -241,6 +275,11 @@ export class AgUiClient {
|
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
async #runLoop(): Promise<void> {
|
|
278
|
+
// Carries the resolved approval answers into the *next* run when a round
|
|
279
|
+
// finished on a server-side-tool interrupt. Distinct from the public
|
|
280
|
+
// resume() navigation-reload path (which continues an unfinished
|
|
281
|
+
// frontend-tool round after a page load) — this stays inside one #run().
|
|
282
|
+
let resume: ResumeEntry[] | undefined;
|
|
244
283
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
245
284
|
// A cancel during the previous round's frontend-tool execution lands
|
|
246
285
|
// here: the running handler completed, but no further round starts.
|
|
@@ -248,11 +287,16 @@ export class AgUiClient {
|
|
|
248
287
|
return;
|
|
249
288
|
}
|
|
250
289
|
const pending: AgUiToolCall[] = [];
|
|
251
|
-
const runState = { terminal: false };
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
this.#
|
|
255
|
-
|
|
290
|
+
const runState: RunState = { terminal: false, errored: false, interrupts: [] };
|
|
291
|
+
const params: RunAgentParameters = {
|
|
292
|
+
tools: this.#getTools(),
|
|
293
|
+
context: this.#getContext(),
|
|
294
|
+
};
|
|
295
|
+
if (resume !== undefined) {
|
|
296
|
+
params.resume = resume;
|
|
297
|
+
}
|
|
298
|
+
await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
|
|
299
|
+
resume = undefined;
|
|
256
300
|
this.#onPersist(this.#agent.messages);
|
|
257
301
|
// Cancelled mid-stream: the user said stop — don't execute the tool
|
|
258
302
|
// calls collected before the abort.
|
|
@@ -265,6 +309,29 @@ export class AgUiClient {
|
|
|
265
309
|
if (!runState.terminal) {
|
|
266
310
|
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
267
311
|
}
|
|
312
|
+
// RUN_ERROR is terminal: the agent already reported the failure via
|
|
313
|
+
// onError. Don't execute the tool calls collected before it or start
|
|
314
|
+
// another round — that would run into a broken context and surface a
|
|
315
|
+
// confusing second error. Any pending tool card is swept at onSettled.
|
|
316
|
+
if (runState.errored) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
// A gated server-side tool deferred instead of executing: the run finished
|
|
320
|
+
// on an interrupt outcome. Ask the host to resolve each interrupt, then
|
|
321
|
+
// re-enter the loop carrying the answers — the follow-up run runs or denies
|
|
322
|
+
// the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
|
|
323
|
+
// over the frontend-tool sweep below: a server-side tool isn't ours to run.
|
|
324
|
+
if (runState.interrupts.length > 0) {
|
|
325
|
+
if (this.#resolveInterrupts === null) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const responses = await this.#resolveInterrupts(runState.interrupts);
|
|
329
|
+
if (this.#cancelled) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
resume = buildResumeArray(runState.interrupts, responses);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
268
335
|
if (this.#executeTool === null || pending.length === 0) {
|
|
269
336
|
return;
|
|
270
337
|
}
|
|
@@ -294,7 +361,7 @@ export class AgUiClient {
|
|
|
294
361
|
}
|
|
295
362
|
}
|
|
296
363
|
|
|
297
|
-
#buildSubscriber(pending: AgUiToolCall[], runState:
|
|
364
|
+
#buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
|
|
298
365
|
const h = this.#handlers;
|
|
299
366
|
return {
|
|
300
367
|
onRunInitialized() {
|
|
@@ -318,7 +385,7 @@ export class AgUiClient {
|
|
|
318
385
|
onToolCallResultEvent({ event }) {
|
|
319
386
|
h.onToolResult(event.toolCallId, event.content);
|
|
320
387
|
},
|
|
321
|
-
// Reasoning
|
|
388
|
+
// Reasoning. `@ag-ui/client` already maps the deprecated
|
|
322
389
|
// THINKING_* events onto these REASONING_* callbacks, so handling the
|
|
323
390
|
// reasoning family alone covers both protocol versions.
|
|
324
391
|
onReasoningStartEvent() {
|
|
@@ -330,8 +397,19 @@ export class AgUiClient {
|
|
|
330
397
|
onReasoningEndEvent() {
|
|
331
398
|
h.onReasoningEnd();
|
|
332
399
|
},
|
|
400
|
+
onRunFinishedEvent(params) {
|
|
401
|
+
// RUN_FINISHED is terminal for both a normal finish and an interrupt.
|
|
402
|
+
// Capturing the interrupts here (rather than reading the agent's
|
|
403
|
+
// `pendingInterrupts` field afterwards) keeps the loop self-contained
|
|
404
|
+
// and independent of that field's cross-run clearing semantics.
|
|
405
|
+
runState.terminal = true;
|
|
406
|
+
if (params.outcome === "interrupt") {
|
|
407
|
+
runState.interrupts = params.interrupts;
|
|
408
|
+
}
|
|
409
|
+
},
|
|
333
410
|
onRunErrorEvent({ event }) {
|
|
334
411
|
runState.terminal = true;
|
|
412
|
+
runState.errored = true;
|
|
335
413
|
h.onError(event.message);
|
|
336
414
|
},
|
|
337
415
|
onRunFinalized() {
|
|
@@ -342,6 +420,14 @@ export class AgUiClient {
|
|
|
342
420
|
}
|
|
343
421
|
}
|
|
344
422
|
|
|
423
|
+
/** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
|
|
424
|
+
interface RunState {
|
|
425
|
+
terminal: boolean;
|
|
426
|
+
errored: boolean;
|
|
427
|
+
/** Approval interrupts a run finished on (empty for a normal finish). */
|
|
428
|
+
interrupts: Interrupt[];
|
|
429
|
+
}
|
|
430
|
+
|
|
345
431
|
/**
|
|
346
432
|
* Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
|
|
347
433
|
* with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
|
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
|
}
|