@artooi/ag-ui-web-component 0.26.1 → 0.28.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 +291 -1
- package/README.md +191 -8
- package/dist/ag-ui-web-component.bundle.js +50 -50
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/core/ag_ui_chat.d.ts +61 -3
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +8 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +58 -3
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -0
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +29 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +42 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +602 -104
- package/dist/index.js.map +4 -4
- package/dist/tools/is_destructive.d.ts +8 -2
- package/dist/tools/is_destructive.d.ts.map +1 -1
- package/dist/tools/parse_tool_catalog.d.ts +11 -4
- package/dist/tools/parse_tool_catalog.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +23 -5
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +5 -1
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +13 -7
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/core/ag_ui_chat.ts +444 -45
- package/src/core/agui_client.ts +43 -2
- package/src/core/conversation_store.ts +146 -49
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +45 -3
- package/src/core/utils.ts +83 -1
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/ui_strings.ts +19 -8
- package/src/ui/voice_input.ts +43 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "@ag-ui/client";
|
|
1
2
|
import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
|
|
2
3
|
import {
|
|
3
4
|
ATTACHMENT_EVENT,
|
|
@@ -31,7 +32,7 @@ import { isNavigates } from "../tools/is_navigates.js";
|
|
|
31
32
|
import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
|
|
32
33
|
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
33
34
|
import { createPageStateTools, type PageState } from "../tools/page_state.js";
|
|
34
|
-
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
35
|
+
import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
|
|
35
36
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
36
37
|
import {
|
|
37
38
|
type ApprovalRenderer,
|
|
@@ -80,13 +81,14 @@ import {
|
|
|
80
81
|
type ClientConversationStore,
|
|
81
82
|
type NavigationCheckpoint,
|
|
82
83
|
SessionStorageStore,
|
|
84
|
+
writeStoredItem,
|
|
83
85
|
} from "./conversation_store.js";
|
|
84
86
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
85
87
|
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
86
88
|
import { RunIndex } from "./run_index.js";
|
|
87
89
|
import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
|
|
88
90
|
import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
|
|
89
|
-
import { withCredentials } from "./utils.js";
|
|
91
|
+
import { mintThread, warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
|
|
90
92
|
|
|
91
93
|
/** The role a rendered chat message takes. */
|
|
92
94
|
export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
|
|
@@ -156,6 +158,7 @@ const CONNECT_TIME_ATTRIBUTES = [
|
|
|
156
158
|
"data-attachment-max-bytes",
|
|
157
159
|
"data-transcribe-url",
|
|
158
160
|
"data-threads-url",
|
|
161
|
+
"data-threads-cache",
|
|
159
162
|
"data-tools-url",
|
|
160
163
|
"data-skills-url",
|
|
161
164
|
"data-skills",
|
|
@@ -187,6 +190,17 @@ const SIZE_KEY = "ag-ui-chat:size";
|
|
|
187
190
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
188
191
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
189
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Storage namespaces already spoken for in this document.
|
|
195
|
+
*
|
|
196
|
+
* Per document rather than per origin, and released on disconnect, because the
|
|
197
|
+
* question it answers is "is another element on this page using these keys right
|
|
198
|
+
* now" — not "has anything ever used them". A registry that never released would
|
|
199
|
+
* turn every remount, and every framework re-render that moves the node, into a
|
|
200
|
+
* false collision that costs the element its own conversation.
|
|
201
|
+
*/
|
|
202
|
+
const CLAIMED_NAMESPACES = new Set<string>();
|
|
203
|
+
|
|
190
204
|
/**
|
|
191
205
|
* `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
|
|
192
206
|
*
|
|
@@ -224,6 +238,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
224
238
|
*/
|
|
225
239
|
getHeaders: (() => Record<string, string>) | null = null;
|
|
226
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Origins, besides the page's own, that this element may send {@link headers}
|
|
243
|
+
* and {@link getHeaders} credentials to without saying so on the console.
|
|
244
|
+
*
|
|
245
|
+
* Seven attributes name a URL, and every one of them carries these headers.
|
|
246
|
+
* They are plain HTML, so a page that builds one from a query parameter or
|
|
247
|
+
* from tenant-authored configuration has handed an attacker the destination,
|
|
248
|
+
* and the token leaves on the element's first request. Naming the origins you
|
|
249
|
+
* expect turns that from silent into either confirmed or reported.
|
|
250
|
+
*
|
|
251
|
+
* A notice rather than a refusal: a cross-origin agent is a documented
|
|
252
|
+
* deployment, so refusing would break working installations to defend against
|
|
253
|
+
* a page that is already interpolating untrusted data into its own markup.
|
|
254
|
+
* Leaving this empty costs nothing but one console line per foreign origin.
|
|
255
|
+
*/
|
|
256
|
+
trustedOrigins: readonly string[] = [];
|
|
257
|
+
|
|
227
258
|
/**
|
|
228
259
|
* Permit `<img>` in rendered assistant markdown. **Off by default**: a
|
|
229
260
|
* model-controlled image URL is fetched with no user interaction, which
|
|
@@ -381,14 +412,40 @@ export class AgUiChat extends HTMLElement {
|
|
|
381
412
|
resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
|
|
382
413
|
|
|
383
414
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
415
|
+
* The server tool catalog fetched from `data-tools-url`, keyed by tool
|
|
416
|
+
* name. Cards label themselves from each entry's `summary`, the base
|
|
417
|
+
* layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
|
|
418
|
+
* wins, this fills the rest. Held as whole entries rather than labels so a
|
|
419
|
+
* field the server sent is not lost on the way in. Populated once on connect.
|
|
387
420
|
*/
|
|
388
|
-
#toolCatalog: Record<string,
|
|
421
|
+
#toolCatalog: Record<string, ToolCatalogEntry> = {};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Foreign origins already reported, so the notice is once per origin per
|
|
425
|
+
* element rather than once per request. Per-element rather than module-level,
|
|
426
|
+
* because two elements on one page are two separate configurations.
|
|
427
|
+
*/
|
|
428
|
+
#warnedOrigins = new Set<string>();
|
|
389
429
|
/** The resolved string table (defaults ← `data-strings` ← `strings`). */
|
|
390
430
|
#strings: UiStrings = DEFAULT_UI_STRINGS;
|
|
391
431
|
|
|
432
|
+
/**
|
|
433
|
+
* The tool names the current round handed the agent, captured as the catalog
|
|
434
|
+
* went out.
|
|
435
|
+
*
|
|
436
|
+
* The registry is mount-wide but {@link getTools} is per-run, so a host is
|
|
437
|
+
* free to scope what a given page offers — and a call naming a tool this run
|
|
438
|
+
* withheld must not reach the handler that is merely still registered.
|
|
439
|
+
* Snapshotted rather than re-asked at dispatch: a provider is a function, and
|
|
440
|
+
* calling it again asks a question the run already answered, which is exactly
|
|
441
|
+
* the window a scoped catalog exists to close.
|
|
442
|
+
*
|
|
443
|
+
* Empty until the first round advertises, which cannot precede a call: the
|
|
444
|
+
* client builds `RunAgentInput.tools` at the top of every round, before the
|
|
445
|
+
* calls that round produces are executed.
|
|
446
|
+
*/
|
|
447
|
+
#advertisedTools: ReadonlySet<string> = new Set();
|
|
448
|
+
|
|
392
449
|
readonly #toolRegistry = new ClientToolRegistry();
|
|
393
450
|
/** Tool-call cards awaiting execution, keyed by call id. */
|
|
394
451
|
readonly #toolCards = new Map<string, ToolCallCard>();
|
|
@@ -470,6 +527,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
470
527
|
// revealed progressively as it streamed, so the word reveal must not re-animate
|
|
471
528
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
472
529
|
#streamDeltas = 0;
|
|
530
|
+
// The accumulated answer the next render will draw. Deltas overwrite it
|
|
531
|
+
// (each one carries the whole answer), so a frame always draws the latest.
|
|
532
|
+
#streamBuffer = "";
|
|
533
|
+
// The frame that render is queued on, or `null` when nothing is queued —
|
|
534
|
+
// also the flag saying a delta is still undrawn.
|
|
535
|
+
#streamFrame: number | null = null;
|
|
473
536
|
#pending: HTMLDivElement | null = null;
|
|
474
537
|
// The current assistant turn's grouping container. One `.answer`
|
|
475
538
|
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
@@ -484,9 +547,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
484
547
|
#thoughts: ThoughtsBlock | null = null;
|
|
485
548
|
#threadId = "";
|
|
486
549
|
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
487
|
-
//
|
|
488
|
-
//
|
|
550
|
+
// size), so two instances on one origin don't clobber each other. Empty ⇒ the
|
|
551
|
+
// pre-namespacing global keys (back-compat). Resolved on connect; the
|
|
552
|
+
// conversation adds `user-key` on top of it, see #conversationNs.
|
|
489
553
|
#storageNs = "";
|
|
554
|
+
// The entry this element put in CLAIMED_NAMESPACES, to take back out on
|
|
555
|
+
// disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
|
|
556
|
+
// the claim to an element that mounted first).
|
|
557
|
+
#claimedNs: string | null = null;
|
|
558
|
+
// The fallback namespace minted when the preferred one was already claimed,
|
|
559
|
+
// with the preferred value it was minted for — so the element keeps it across
|
|
560
|
+
// remounts, but re-resolves if the host answers the warning with an `id`.
|
|
561
|
+
#generatedNs = "";
|
|
562
|
+
#generatedFor = "";
|
|
563
|
+
// The `sessionStorage`-backed store, which the element may therefore re-scope
|
|
564
|
+
// on a principal change. `null` when the host injected a store of its own
|
|
565
|
+
// kind, whose keying the element does not know and must not guess at.
|
|
566
|
+
#builtinStore: SessionStorageStore | null = null;
|
|
490
567
|
// Bumped on every #rehydrate; a replay whose generation is stale (a newer
|
|
491
568
|
// thread switch started while it awaited a slow store) drops its result.
|
|
492
569
|
#rehydrateGeneration = 0;
|
|
@@ -544,7 +621,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
544
621
|
if (this.#runIndex === null) {
|
|
545
622
|
this.#runIndex = new RunIndex(
|
|
546
623
|
url,
|
|
547
|
-
() => this.#
|
|
624
|
+
() => this.#headersFor(url),
|
|
548
625
|
() => this.#requestCredentials(),
|
|
549
626
|
);
|
|
550
627
|
}
|
|
@@ -579,6 +656,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
579
656
|
endpoint,
|
|
580
657
|
headers: this.#requestHeaders(),
|
|
581
658
|
getHeaders: () => this.#requestHeaders(),
|
|
659
|
+
trustedOrigins: this.trustedOrigins,
|
|
582
660
|
...this.#credentialsOption(),
|
|
583
661
|
threadId: this.#threadId,
|
|
584
662
|
// The seed the endpoints assume: nothing. The snapshot is the history.
|
|
@@ -587,7 +665,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
587
665
|
const client = new AgUiClient({
|
|
588
666
|
agent,
|
|
589
667
|
handlers: this.#handlers(),
|
|
590
|
-
getTools: () => this
|
|
668
|
+
getTools: () => this.#advertiseTools(),
|
|
591
669
|
getContext: () => this.#buildContext(),
|
|
592
670
|
executeTool: (call) => this.#executeTool(call),
|
|
593
671
|
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
@@ -604,7 +682,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
604
682
|
|
|
605
683
|
/** Attributes the element reacts to after it has been connected. */
|
|
606
684
|
static get observedAttributes(): string[] {
|
|
607
|
-
return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
|
|
685
|
+
return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
|
|
608
686
|
}
|
|
609
687
|
|
|
610
688
|
attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
|
|
@@ -638,6 +716,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
638
716
|
this.#title.textContent = value ?? this.#strings.title;
|
|
639
717
|
return;
|
|
640
718
|
}
|
|
719
|
+
if (name === "user-key") {
|
|
720
|
+
// Before connect there is nothing to move: connectedCallback resolves the
|
|
721
|
+
// namespace from the attribute as it stands by then. An absent attribute
|
|
722
|
+
// and an empty one name the same (unnamed) principal, so neither is a
|
|
723
|
+
// change worth acting on.
|
|
724
|
+
if (this.#connected && (previous ?? "") !== (value ?? "")) {
|
|
725
|
+
this.#changePrincipal(previous ?? "", value ?? "");
|
|
726
|
+
}
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
641
729
|
// Everything else here is read once, in connectedCallback, to build chrome
|
|
642
730
|
// that then exists or does not. A later change is silently ignored and the
|
|
643
731
|
// symptom is an affordance that never appears, which reads as a broken
|
|
@@ -657,7 +745,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
657
745
|
);
|
|
658
746
|
}
|
|
659
747
|
|
|
660
|
-
/**
|
|
748
|
+
/**
|
|
749
|
+
* Declare a frontend tool the agent may call.
|
|
750
|
+
*
|
|
751
|
+
* **A handler's thrown message leaves the browser.** When a handler rejects,
|
|
752
|
+
* its `Error.message` is posted back as that call's tool result — into the
|
|
753
|
+
* conversation, on to the AG-UI endpoint, persisted server-side, and
|
|
754
|
+
* forwarded to the model provider on every later round. That is deliberate,
|
|
755
|
+
* since it is what lets the agent recover from a failure it caused; but it
|
|
756
|
+
* means an internal hostname, a signed URL or a stack-derived path in a
|
|
757
|
+
* rethrown error is disclosed to parties the host never chose. Throw the
|
|
758
|
+
* message you would be content for the model to read, and log the detail.
|
|
759
|
+
*/
|
|
661
760
|
registerTool(tool: ClientTool): void {
|
|
662
761
|
this.#toolRegistry.register(tool);
|
|
663
762
|
}
|
|
@@ -835,6 +934,19 @@ export class AgUiChat extends HTMLElement {
|
|
|
835
934
|
return answer;
|
|
836
935
|
}
|
|
837
936
|
|
|
937
|
+
/**
|
|
938
|
+
* The catalog for the round about to start, remembering what it offered.
|
|
939
|
+
*
|
|
940
|
+
* Every path to a frontend tool goes through here first — the client asks
|
|
941
|
+
* for `RunAgentInput.tools` at the top of each round — so this is the one
|
|
942
|
+
* place that can know what the agent was actually told about.
|
|
943
|
+
*/
|
|
944
|
+
#advertiseTools(): Tool[] {
|
|
945
|
+
const tools = this.getTools();
|
|
946
|
+
this.#advertisedTools = new Set(tools.map((tool) => tool.name));
|
|
947
|
+
return tools;
|
|
948
|
+
}
|
|
949
|
+
|
|
838
950
|
/** Resolve a tool by name: built-in tools first, then the registry. */
|
|
839
951
|
#resolveTool(name: string): ClientTool | null {
|
|
840
952
|
const builtin = this.#builtinTools().find((t) => t.name === name);
|
|
@@ -856,6 +968,39 @@ export class AgUiChat extends HTMLElement {
|
|
|
856
968
|
this.setAttribute("endpoint", value);
|
|
857
969
|
}
|
|
858
970
|
|
|
971
|
+
/**
|
|
972
|
+
* Who the stored conversation belongs to, from the `user-key` attribute.
|
|
973
|
+
*
|
|
974
|
+
* Set it to whatever identifies the signed-in principal — a user id, an
|
|
975
|
+
* account id, a hash of one. The value joins the storage namespace, so two
|
|
976
|
+
* principals in the same tab cannot read each other's transcript, and
|
|
977
|
+
* **changing it purges what the previous one left behind**.
|
|
978
|
+
*
|
|
979
|
+
* That purge is the reason this is a live attribute rather than a
|
|
980
|
+
* connect-time one. `sessionStorage` survives same-tab navigation, so it
|
|
981
|
+
* survives a logout; and a single-page app signs out through its own router
|
|
982
|
+
* without remounting anything, so there is no other moment at which the
|
|
983
|
+
* element could find out. The host naming the new principal — or dropping the
|
|
984
|
+
* attribute — is the signal.
|
|
985
|
+
*
|
|
986
|
+
* Absent means exactly today's behaviour, which is why nothing breaks by
|
|
987
|
+
* leaving it off: the conversation is scoped to the element and to nobody in
|
|
988
|
+
* particular, and on a shared workstation it carries into whoever signs in
|
|
989
|
+
* next in the same tab.
|
|
990
|
+
*
|
|
991
|
+
* The first value to arrive is treated as a host naming the user who was
|
|
992
|
+
* already there, not as a handover: the conversation in progress moves into
|
|
993
|
+
* the principal's namespace rather than being destroyed, so an element
|
|
994
|
+
* configured by an async auth handshake keeps what is on screen.
|
|
995
|
+
*/
|
|
996
|
+
get userKey(): string {
|
|
997
|
+
return this.getAttribute("user-key") ?? "";
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
set userKey(value: string) {
|
|
1001
|
+
this.setAttribute("user-key", value);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
859
1004
|
/**
|
|
860
1005
|
* Cookie policy for **every** request this element makes, as `fetch`'s own
|
|
861
1006
|
* `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
|
|
@@ -907,6 +1052,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
907
1052
|
return { ...this.headers, ...this.getHeaders?.() };
|
|
908
1053
|
}
|
|
909
1054
|
|
|
1055
|
+
/**
|
|
1056
|
+
* The request headers, having first reported the destination if it is foreign.
|
|
1057
|
+
*
|
|
1058
|
+
* Every caller that sends these headers knows its URL, and `#requestHeaders`
|
|
1059
|
+
* does not -- so the check lives here, on the path that has both, rather than
|
|
1060
|
+
* being repeated at each call site with a chance to be forgotten at the next
|
|
1061
|
+
* one added.
|
|
1062
|
+
*/
|
|
1063
|
+
#headersFor(url: string): Record<string, string> {
|
|
1064
|
+
const headers = this.#requestHeaders();
|
|
1065
|
+
warnOnCrossOriginCredentials(
|
|
1066
|
+
url,
|
|
1067
|
+
Object.keys(headers),
|
|
1068
|
+
this.trustedOrigins,
|
|
1069
|
+
this.#warnedOrigins,
|
|
1070
|
+
);
|
|
1071
|
+
return headers;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
910
1074
|
/** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
|
|
911
1075
|
#requestCredentials(): RequestCredentials | undefined {
|
|
912
1076
|
return this.credentials ?? undefined;
|
|
@@ -927,8 +1091,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
927
1091
|
}
|
|
928
1092
|
|
|
929
1093
|
/** The `fetch` init for the element's own plain GETs (catalogs). */
|
|
930
|
-
#fetchInit(): RequestInit | undefined {
|
|
931
|
-
return withCredentials({ headers: this.#
|
|
1094
|
+
#fetchInit(url: string): RequestInit | undefined {
|
|
1095
|
+
return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
|
|
932
1096
|
}
|
|
933
1097
|
|
|
934
1098
|
/**
|
|
@@ -956,10 +1120,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
956
1120
|
}
|
|
957
1121
|
|
|
958
1122
|
connectedCallback(): void {
|
|
959
|
-
// Resolve the per-instance storage namespace
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
this.#storageNs = this
|
|
1123
|
+
// Resolve the per-instance storage namespace before any key read/write, so
|
|
1124
|
+
// this instance doesn't share collapsed/theme/thread state with another on
|
|
1125
|
+
// the same origin.
|
|
1126
|
+
this.#storageNs = this.#claimNamespace();
|
|
963
1127
|
// Restore a dragged size before the panel paints, so it does not snap from
|
|
964
1128
|
// the placement default to the user's width on the first frame.
|
|
965
1129
|
this.#applySize(this.#readSize());
|
|
@@ -989,8 +1153,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
989
1153
|
this.#initSkills();
|
|
990
1154
|
// Namespace the built-in default store too (a host-injected store is used
|
|
991
1155
|
// verbatim). Must precede #wireThreadStore, which wraps the current store.
|
|
992
|
-
if (this
|
|
993
|
-
|
|
1156
|
+
if (this.conversationStore instanceof SessionStorageStore) {
|
|
1157
|
+
const namespace = this.#conversationNs();
|
|
1158
|
+
// Remembered either way: this is the element's own store, so a later
|
|
1159
|
+
// `user-key` change may move it to another namespace.
|
|
1160
|
+
this.#builtinStore =
|
|
1161
|
+
namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
|
|
1162
|
+
this.conversationStore = this.#builtinStore;
|
|
994
1163
|
}
|
|
995
1164
|
this.#wireThreadStore();
|
|
996
1165
|
this.#wireAttachments();
|
|
@@ -1064,6 +1233,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1064
1233
|
*/
|
|
1065
1234
|
disconnectedCallback(): void {
|
|
1066
1235
|
this.#connected = false;
|
|
1236
|
+
// Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
1237
|
+
// DOM move and a framework re-render both look like one — and an element
|
|
1238
|
+
// that could not reclaim its own namespace on the way back in would lose
|
|
1239
|
+
// its conversation to a false collision.
|
|
1240
|
+
if (this.#claimedNs !== null) {
|
|
1241
|
+
CLAIMED_NAMESPACES.delete(this.#claimedNs);
|
|
1242
|
+
this.#claimedNs = null;
|
|
1243
|
+
}
|
|
1067
1244
|
this.#cancelRun();
|
|
1068
1245
|
this.#attachTray?.dispose();
|
|
1069
1246
|
this.#voice?.dispose();
|
|
@@ -1143,7 +1320,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1143
1320
|
return (file, onProgress, signal) =>
|
|
1144
1321
|
uploadAttachment(file, {
|
|
1145
1322
|
url,
|
|
1146
|
-
headers: this.#
|
|
1323
|
+
headers: this.#headersFor(url),
|
|
1147
1324
|
...this.#credentialsOption(),
|
|
1148
1325
|
onProgress,
|
|
1149
1326
|
signal,
|
|
@@ -1179,7 +1356,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1179
1356
|
return (audio) =>
|
|
1180
1357
|
transcribeAudio(audio, {
|
|
1181
1358
|
url,
|
|
1182
|
-
headers: this.#
|
|
1359
|
+
headers: this.#headersFor(url),
|
|
1183
1360
|
...this.#credentialsOption(),
|
|
1184
1361
|
});
|
|
1185
1362
|
}
|
|
@@ -1240,15 +1417,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
1240
1417
|
* delete through that server endpoint (wrapping the current store as the
|
|
1241
1418
|
* client-only fallback), so the history drawer shows durable, cross-device
|
|
1242
1419
|
* threads. Without it, the client store's per-tab threads are used.
|
|
1420
|
+
*
|
|
1421
|
+
* `data-threads-cache="false"` drops the local copy of the message bodies —
|
|
1422
|
+
* for the deployment that pointed history at a server precisely so that
|
|
1423
|
+
* transcripts do not sit in the browser. The client-only concerns (the active
|
|
1424
|
+
* thread id, the navigation checkpoint) keep their local store either way.
|
|
1243
1425
|
*/
|
|
1244
1426
|
#wireThreadStore(): void {
|
|
1245
1427
|
const url = this.getAttribute("data-threads-url");
|
|
1246
1428
|
if (url !== null) {
|
|
1247
1429
|
this.conversationStore = new RemoteConversationStore(
|
|
1248
1430
|
url,
|
|
1249
|
-
() => this.#
|
|
1431
|
+
() => this.#headersFor(url),
|
|
1250
1432
|
this.conversationStore,
|
|
1251
1433
|
() => this.#requestCredentials(),
|
|
1434
|
+
this.getAttribute("data-threads-cache") !== "false",
|
|
1252
1435
|
);
|
|
1253
1436
|
}
|
|
1254
1437
|
}
|
|
@@ -1260,7 +1443,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1260
1443
|
return;
|
|
1261
1444
|
}
|
|
1262
1445
|
try {
|
|
1263
|
-
const response = await fetch(url, this.#fetchInit());
|
|
1446
|
+
const response = await fetch(url, this.#fetchInit(url));
|
|
1264
1447
|
this.#toolCatalog = parseToolCatalog(await response.json());
|
|
1265
1448
|
} catch {
|
|
1266
1449
|
// Network/parse failure: cards fall back to toolSummaries / raw names.
|
|
@@ -1308,7 +1491,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1308
1491
|
return;
|
|
1309
1492
|
}
|
|
1310
1493
|
try {
|
|
1311
|
-
const response = await fetch(url, this.#fetchInit());
|
|
1494
|
+
const response = await fetch(url, this.#fetchInit(url));
|
|
1312
1495
|
this.#backendSkills = parseSkills(await response.json());
|
|
1313
1496
|
this.#recomputeSkills();
|
|
1314
1497
|
} catch {
|
|
@@ -1409,7 +1592,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1409
1592
|
} else {
|
|
1410
1593
|
this.removeAttribute("collapsed");
|
|
1411
1594
|
}
|
|
1412
|
-
|
|
1595
|
+
writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
|
|
1413
1596
|
// Expanding is what marks the waiting answers read; collapsing starts a
|
|
1414
1597
|
// fresh count. Either way the badge is cleared and the host told.
|
|
1415
1598
|
this.#setUnread(0);
|
|
@@ -1446,7 +1629,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1446
1629
|
toggleTheme(): void {
|
|
1447
1630
|
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
1448
1631
|
this.setAttribute("theme", next);
|
|
1449
|
-
|
|
1632
|
+
writeStoredItem(this.#storageKey(THEME_KEY), next);
|
|
1450
1633
|
this.#syncThemeGlyph();
|
|
1451
1634
|
}
|
|
1452
1635
|
|
|
@@ -1557,7 +1740,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1557
1740
|
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
1558
1741
|
#persistSize(size: ResizeSize): void {
|
|
1559
1742
|
const stored = { ...this.#readSize(), ...size };
|
|
1560
|
-
|
|
1743
|
+
writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
|
|
1561
1744
|
}
|
|
1562
1745
|
|
|
1563
1746
|
/** The persisted size for this instance, or an empty record. */
|
|
@@ -1576,6 +1759,130 @@ export class AgUiChat extends HTMLElement {
|
|
|
1576
1759
|
}
|
|
1577
1760
|
}
|
|
1578
1761
|
|
|
1762
|
+
/**
|
|
1763
|
+
* Claim this element's storage namespace: its `id`, else its `endpoint`.
|
|
1764
|
+
*
|
|
1765
|
+
* The endpoint fallback exists so a lone widget restores its conversation
|
|
1766
|
+
* across reloads with nothing asked of the page author. It stops working the
|
|
1767
|
+
* moment there are two of them — a docked support panel and an inline page
|
|
1768
|
+
* assistant against one agent mount, neither carrying an `id`, which nothing
|
|
1769
|
+
* requires — because both resolve to the same string and then share a thread
|
|
1770
|
+
* pointer, a drawer index and every message key. Whichever mounts second
|
|
1771
|
+
* adopts the first's active thread and rehydrates its transcript into its own
|
|
1772
|
+
* panel: one conversation's content inside another, on the same page.
|
|
1773
|
+
*
|
|
1774
|
+
* So the namespace is claimed by the first element to mount under it, and a
|
|
1775
|
+
* second is given one of its own plus a warning naming the fix. The first
|
|
1776
|
+
* element keeps the endpoint namespace, which is what leaves the ordinary
|
|
1777
|
+
* single-element case exactly as it was.
|
|
1778
|
+
*
|
|
1779
|
+
* The generated namespace is random rather than derived from mount order.
|
|
1780
|
+
* That costs the second element its history across reloads — the warning says
|
|
1781
|
+
* so, and an `id` fixes it — which is the honest trade against an order-based
|
|
1782
|
+
* name that would silently hand a stored conversation to whichever element
|
|
1783
|
+
* happened to mount second on the next load.
|
|
1784
|
+
*/
|
|
1785
|
+
#claimNamespace(): string {
|
|
1786
|
+
const preferred = this.id !== "" ? this.id : this.endpoint;
|
|
1787
|
+
// Nothing to key on. The pre-namespacing global keys, as before: an element
|
|
1788
|
+
// with neither an id nor an endpoint cannot send anything, so what it would
|
|
1789
|
+
// be claiming is an empty conversation.
|
|
1790
|
+
if (preferred === "") {
|
|
1791
|
+
return "";
|
|
1792
|
+
}
|
|
1793
|
+
// Already lost this claim once. Keep the fallback rather than drifting back
|
|
1794
|
+
// onto a namespace the other element may since have released, which would
|
|
1795
|
+
// swap this panel's conversation for that one's.
|
|
1796
|
+
if (this.#generatedFor === preferred) {
|
|
1797
|
+
return this.#generatedNs;
|
|
1798
|
+
}
|
|
1799
|
+
if (!CLAIMED_NAMESPACES.has(preferred)) {
|
|
1800
|
+
CLAIMED_NAMESPACES.add(preferred);
|
|
1801
|
+
this.#claimedNs = preferred;
|
|
1802
|
+
return preferred;
|
|
1803
|
+
}
|
|
1804
|
+
this.#generatedFor = preferred;
|
|
1805
|
+
this.#generatedNs = `${preferred}~${randomUUID()}`;
|
|
1806
|
+
console.warn(
|
|
1807
|
+
`<ag-ui-chat>: another element on this page already stores its ` +
|
|
1808
|
+
`conversation under "${preferred}", so this one has been given a ` +
|
|
1809
|
+
"throwaway namespace of its own — the two would otherwise share a " +
|
|
1810
|
+
"thread pointer, a history drawer and every message. Give each " +
|
|
1811
|
+
"<ag-ui-chat> its own id to keep them apart and let this one restore " +
|
|
1812
|
+
"its conversation across reloads.",
|
|
1813
|
+
);
|
|
1814
|
+
return this.#generatedNs;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* The conversation store's namespace: this element's, scoped to the principal
|
|
1819
|
+
* {@link userKey} names.
|
|
1820
|
+
*
|
|
1821
|
+
* Only the conversation is principal-scoped. The panel's own collapsed / size
|
|
1822
|
+
* / theme preferences stay on `#storageNs`, because they are this element's
|
|
1823
|
+
* UI state rather than anyone's data — they carry no word of what was said —
|
|
1824
|
+
* and because they are read once while connecting, so re-scoping them under a
|
|
1825
|
+
* live element would rearrange the panel around a user who had only just
|
|
1826
|
+
* signed in.
|
|
1827
|
+
*/
|
|
1828
|
+
#conversationNs(key: string = this.userKey): string {
|
|
1829
|
+
return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
/**
|
|
1833
|
+
* Move the element's client state from one principal to another.
|
|
1834
|
+
*
|
|
1835
|
+
* The whole reason {@link userKey} is live: `sessionStorage` outlives a
|
|
1836
|
+
* logout, because a logout is a navigation (or, in a single-page app, not
|
|
1837
|
+
* even that) rather than a tab close. Nothing remounts, so the host naming
|
|
1838
|
+
* the new principal is the only signal the element will ever get.
|
|
1839
|
+
*/
|
|
1840
|
+
#changePrincipal(previousKey: string, nextKey: string): void {
|
|
1841
|
+
const previous = this.#conversationNs(previousKey);
|
|
1842
|
+
const next = this.#conversationNs(nextKey);
|
|
1843
|
+
if (previousKey === "") {
|
|
1844
|
+
// Absent to present is not a handover. It is the documented late
|
|
1845
|
+
// configuration shape — the element mounts, an auth handshake resolves,
|
|
1846
|
+
// and only then is the user known — so the conversation already on screen
|
|
1847
|
+
// belongs to this principal and moves with them. Moving rather than
|
|
1848
|
+
// copying also matters: a copy left behind under the unscoped namespace
|
|
1849
|
+
// is a transcript the next key-less mount would happily adopt.
|
|
1850
|
+
SessionStorageStore.adopt(previous, next);
|
|
1851
|
+
this.#rescopeStore(next);
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
SessionStorageStore.purge(previous);
|
|
1855
|
+
this.#rescopeStore(next);
|
|
1856
|
+
// The transcript on screen, the run in flight and the replayed history all
|
|
1857
|
+
// belong to the principal who just left. Purging storage without clearing
|
|
1858
|
+
// these would leave the previous user's conversation visible to the new one.
|
|
1859
|
+
this.#cancelRun();
|
|
1860
|
+
this.#resetState();
|
|
1861
|
+
this.#setRunning(false);
|
|
1862
|
+
this.#setUnread(0);
|
|
1863
|
+
this.#threadId = this.conversationStore.threadId();
|
|
1864
|
+
void this.#rehydrate();
|
|
1865
|
+
void this.#refreshDrawer();
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
|
|
1870
|
+
* `data-threads-url` exactly as connecting did.
|
|
1871
|
+
*
|
|
1872
|
+
* A store of the host's own kind is left alone: a store that holds its data
|
|
1873
|
+
* somewhere the element cannot see has to scope itself. The transcript on
|
|
1874
|
+
* screen is still cleared either way — the host swapped principals, and that
|
|
1875
|
+
* much is the element's to act on.
|
|
1876
|
+
*/
|
|
1877
|
+
#rescopeStore(namespace: string): void {
|
|
1878
|
+
if (this.#builtinStore === null) {
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
this.#builtinStore = new SessionStorageStore(namespace);
|
|
1882
|
+
this.conversationStore = this.#builtinStore;
|
|
1883
|
+
this.#wireThreadStore();
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1579
1886
|
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
1580
1887
|
#storageKey(base: string): string {
|
|
1581
1888
|
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
@@ -1653,16 +1960,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
1653
1960
|
}
|
|
1654
1961
|
|
|
1655
1962
|
/**
|
|
1656
|
-
* Start a fresh conversation:
|
|
1657
|
-
*
|
|
1963
|
+
* Start a fresh conversation: drop the in-memory run state, clear the
|
|
1964
|
+
* transcript, and mint a new thread id.
|
|
1965
|
+
*
|
|
1966
|
+
* The conversation being left is kept, and stays in the history drawer to
|
|
1967
|
+
* return to. Deleting one is the drawer row's own action; a button that
|
|
1968
|
+
* starts something new must not be the button that destroys what was there.
|
|
1658
1969
|
*/
|
|
1659
1970
|
newChat(): void {
|
|
1660
1971
|
// Stop any in-flight run first — discarding the client mid-run would
|
|
1661
1972
|
// leave the old agent streaming into a cleared transcript.
|
|
1662
1973
|
this.#cancelRun();
|
|
1663
|
-
|
|
1974
|
+
// A thread nothing was ever sent in has nothing to come back to, and the
|
|
1975
|
+
// drawer never listed it — so reap it here rather than strand one record
|
|
1976
|
+
// per press of a button whose whole use is being pressed again.
|
|
1977
|
+
if (this.conversationStore.isUnsent?.(this.#threadId) === true) {
|
|
1978
|
+
this.conversationStore.clear(this.#threadId);
|
|
1979
|
+
}
|
|
1664
1980
|
this.#resetState();
|
|
1665
|
-
this.#threadId = this.conversationStore
|
|
1981
|
+
this.#threadId = mintThread(this.conversationStore);
|
|
1666
1982
|
this.#setRunning(false);
|
|
1667
1983
|
this.#setUnread(0);
|
|
1668
1984
|
}
|
|
@@ -1670,7 +1986,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
1670
1986
|
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
1671
1987
|
#resetState(): void {
|
|
1672
1988
|
this.#client = null;
|
|
1673
|
-
|
|
1989
|
+
// Before the transcript goes: a render still queued would otherwise fire
|
|
1990
|
+
// against the wiped list and open a fresh bubble holding the discarded
|
|
1991
|
+
// conversation's last tokens.
|
|
1992
|
+
this.#endStream();
|
|
1674
1993
|
this.#currentGroup = null;
|
|
1675
1994
|
this.#thoughts = null;
|
|
1676
1995
|
this.#hidePending();
|
|
@@ -2525,6 +2844,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2525
2844
|
// token must still reach every request — the factory's fetch wrapper
|
|
2526
2845
|
// re-reads this on each call.
|
|
2527
2846
|
getHeaders: () => this.#requestHeaders(),
|
|
2847
|
+
trustedOrigins: this.trustedOrigins,
|
|
2528
2848
|
...this.#credentialsOption(),
|
|
2529
2849
|
threadId: this.#threadId,
|
|
2530
2850
|
initialMessages: this.#initialMessages,
|
|
@@ -2533,7 +2853,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2533
2853
|
this.#client = new AgUiClient({
|
|
2534
2854
|
agent,
|
|
2535
2855
|
handlers: this.#handlers(),
|
|
2536
|
-
getTools: () => this
|
|
2856
|
+
getTools: () => this.#advertiseTools(),
|
|
2537
2857
|
getContext: () => this.#buildContext(),
|
|
2538
2858
|
executeTool: (call) => this.#executeTool(call),
|
|
2539
2859
|
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
@@ -2581,7 +2901,15 @@ export class AgUiChat extends HTMLElement {
|
|
|
2581
2901
|
// transcript places itself against its own card, and by the time it runs the
|
|
2582
2902
|
// card is no longer reachable by id.
|
|
2583
2903
|
this.#cardElements.set(call.id, card.element);
|
|
2584
|
-
|
|
2904
|
+
// Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
|
|
2905
|
+
// this round. A host that offers `delete_record` only on the page where
|
|
2906
|
+
// deleting makes sense has said something about *this* run, and a call
|
|
2907
|
+
// arriving anyway (a hallucinated name, or one steered by text the model
|
|
2908
|
+
// just read) must not find the handler that happens to be registered
|
|
2909
|
+
// mount-wide. Treated exactly as an unknown name rather than as a refusal:
|
|
2910
|
+
// withholding a tool and never registering it are the same statement, and
|
|
2911
|
+
// the branch below already says the honest thing for both.
|
|
2912
|
+
const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
|
|
2585
2913
|
if (tool === null) {
|
|
2586
2914
|
// Not a client tool. A server-side tool's real output arrives via
|
|
2587
2915
|
// `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
|
|
@@ -2676,6 +3004,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2676
3004
|
// The navigation never happened; drop the dangling checkpoint.
|
|
2677
3005
|
this.conversationStore.saveCheckpoint(this.#threadId, null);
|
|
2678
3006
|
}
|
|
3007
|
+
// The handler's own message, verbatim, in two places at once: the card,
|
|
3008
|
+
// which the user sees, and the tool result, which goes to the endpoint,
|
|
3009
|
+
// is persisted there and is replayed to the model on every later round.
|
|
3010
|
+
// Kept verbatim because a real reason is what lets the agent recover —
|
|
3011
|
+
// and said out loud on `registerTool`, because the second destination is
|
|
3012
|
+
// invisible from the host's side and is not one it can take back.
|
|
2679
3013
|
const message = error instanceof Error ? error.message : String(error);
|
|
2680
3014
|
card.settle(TOOL_CALL_STATUS.ERROR, message);
|
|
2681
3015
|
this.#showPending();
|
|
@@ -2793,7 +3127,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
2793
3127
|
this.#hidePending();
|
|
2794
3128
|
// The answer has begun — fold the thoughts away so they don't crowd it.
|
|
2795
3129
|
this.#thoughts?.collapse();
|
|
2796
|
-
this.#
|
|
3130
|
+
this.#queueStream(buffer);
|
|
3131
|
+
// Counted per delta received, not per render: the word reveal asks
|
|
3132
|
+
// whether the answer *arrived* progressively, which coalescing renders
|
|
3133
|
+
// must not change the answer to.
|
|
2797
3134
|
this.#streamDeltas += 1;
|
|
2798
3135
|
},
|
|
2799
3136
|
onTextEnd: (buffer) => {
|
|
@@ -2806,7 +3143,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2806
3143
|
this.#revealWords(bubble);
|
|
2807
3144
|
}
|
|
2808
3145
|
attachCopyButtons(bubble, this.#strings);
|
|
2809
|
-
this.#
|
|
3146
|
+
this.#endStream();
|
|
2810
3147
|
this.#noteUnread();
|
|
2811
3148
|
},
|
|
2812
3149
|
onToolCall: (call) => {
|
|
@@ -2875,25 +3212,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
2875
3212
|
// Per-round end; the button stays on Stop until the whole interaction
|
|
2876
3213
|
// settles — the user must be able to cancel between tool rounds.
|
|
2877
3214
|
this.#hidePending();
|
|
2878
|
-
this.#
|
|
3215
|
+
this.#endStream();
|
|
2879
3216
|
},
|
|
2880
3217
|
onError: (message) => {
|
|
2881
3218
|
this.#hidePending();
|
|
2882
3219
|
this.#revealWords(this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`));
|
|
2883
|
-
this.#
|
|
3220
|
+
this.#endStream();
|
|
2884
3221
|
},
|
|
2885
3222
|
onCancelled: () => {
|
|
2886
3223
|
// Deliberate stop, not a failure: keep whatever partial text already
|
|
2887
3224
|
// streamed and add a muted note instead of an error bubble.
|
|
2888
3225
|
this.#hidePending();
|
|
2889
3226
|
this.#appendStoppedNote();
|
|
2890
|
-
this.#
|
|
3227
|
+
this.#endStream();
|
|
2891
3228
|
},
|
|
2892
3229
|
onSettled: () => {
|
|
2893
3230
|
// Terminal guarantee: whatever path ended the run, return to rest.
|
|
2894
3231
|
this.#hidePending();
|
|
2895
3232
|
this.#setRunning(false);
|
|
2896
|
-
this.#
|
|
3233
|
+
this.#endStream();
|
|
2897
3234
|
// Belt-and-suspenders: a tool card still pending at settle (e.g. a
|
|
2898
3235
|
// server tool whose result never streamed because the connection
|
|
2899
3236
|
// dropped) would hang forever — settle it to the no-result fallback.
|
|
@@ -2999,16 +3336,78 @@ export class AgUiChat extends HTMLElement {
|
|
|
2999
3336
|
return this.#thoughts;
|
|
3000
3337
|
}
|
|
3001
3338
|
|
|
3002
|
-
|
|
3339
|
+
/**
|
|
3340
|
+
* The bubble the current answer streams into, opening it on first sight.
|
|
3341
|
+
*
|
|
3342
|
+
* Opened the moment a token arrives rather than on the frame that draws it,
|
|
3343
|
+
* so the answer's container replaces the pending dots straight away and the
|
|
3344
|
+
* turn never shows a gap while the first render waits for a frame.
|
|
3345
|
+
*/
|
|
3346
|
+
#openStream(): HTMLDivElement {
|
|
3003
3347
|
if (this.#streamingBubble === null) {
|
|
3004
3348
|
this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
|
|
3005
3349
|
this.#streamDeltas = 0;
|
|
3006
3350
|
}
|
|
3007
|
-
this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
3008
|
-
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
3009
3351
|
return this.#streamingBubble;
|
|
3010
3352
|
}
|
|
3011
3353
|
|
|
3354
|
+
/**
|
|
3355
|
+
* Queue a render of the answer so far, at most one per frame.
|
|
3356
|
+
*
|
|
3357
|
+
* Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
|
|
3358
|
+
* and drawing it means marked + DOMPurify over the entire document and a
|
|
3359
|
+
* wholesale replacement of the bubble's subtree. Once per token that is
|
|
3360
|
+
* quadratic in the answer's length — a long answer is agent-controlled, so
|
|
3361
|
+
* an ordinary run becomes a progressively stalling tab — and every rebuild
|
|
3362
|
+
* takes any selection or focus inside the bubble with it.
|
|
3363
|
+
*
|
|
3364
|
+
* A frame is the right grain: it is the fastest anything on screen can
|
|
3365
|
+
* change anyway, so a burst of tokens costs one parse and the text still
|
|
3366
|
+
* appears to flow rather than in visible chunks.
|
|
3367
|
+
*/
|
|
3368
|
+
#queueStream(buffer: string): void {
|
|
3369
|
+
this.#streamBuffer = buffer;
|
|
3370
|
+
this.#openStream();
|
|
3371
|
+
if (this.#streamFrame !== null) {
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
this.#streamFrame = requestAnimationFrame(() => {
|
|
3375
|
+
this.#streamFrame = null;
|
|
3376
|
+
this.#streamInto(this.#streamBuffer);
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
3379
|
+
|
|
3380
|
+
/** Render `buffer` into the streaming bubble now, dropping any queued frame. */
|
|
3381
|
+
#streamInto(buffer: string): HTMLDivElement {
|
|
3382
|
+
// A frame still queued would otherwise fire after this and repaint the
|
|
3383
|
+
// bubble with whatever the last delta held — behind the buffer just drawn.
|
|
3384
|
+
if (this.#streamFrame !== null) {
|
|
3385
|
+
cancelAnimationFrame(this.#streamFrame);
|
|
3386
|
+
this.#streamFrame = null;
|
|
3387
|
+
}
|
|
3388
|
+
this.#streamBuffer = buffer;
|
|
3389
|
+
const bubble = this.#openStream();
|
|
3390
|
+
bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
3391
|
+
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
3392
|
+
return bubble;
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
/**
|
|
3396
|
+
* Close the current answer's streaming bubble.
|
|
3397
|
+
*
|
|
3398
|
+
* Draws a queued render first. A run that ends without a text end — a
|
|
3399
|
+
* cancel, an error, a round boundary — leaves the last delta sitting in the
|
|
3400
|
+
* queue, and simply dropping the bubble here would strand it: the partial
|
|
3401
|
+
* answer the user stopped mid-sentence would lose its final tokens, or be an
|
|
3402
|
+
* empty bubble above the stopped note.
|
|
3403
|
+
*/
|
|
3404
|
+
#endStream(): void {
|
|
3405
|
+
if (this.#streamFrame !== null) {
|
|
3406
|
+
this.#streamInto(this.#streamBuffer);
|
|
3407
|
+
}
|
|
3408
|
+
this.#streamingBubble = null;
|
|
3409
|
+
}
|
|
3410
|
+
|
|
3012
3411
|
/**
|
|
3013
3412
|
* The card for ``call``, creating and appending it on first sight.
|
|
3014
3413
|
*
|
|
@@ -3158,7 +3557,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3158
3557
|
typeof labelled === "string"
|
|
3159
3558
|
? labelled
|
|
3160
3559
|
: (this.toolSummaries[call.name] ??
|
|
3161
|
-
this.#toolCatalog[call.name] ??
|
|
3560
|
+
this.#toolCatalog[call.name]?.summary ??
|
|
3162
3561
|
prettifyToolName(call.name));
|
|
3163
3562
|
const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
|
|
3164
3563
|
this.#toolCards.set(call.id, card);
|