@artooi/ag-ui-web-component 0.27.0 → 0.29.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 +663 -1
- package/README.md +557 -11
- package/dist/ag-ui-web-component.bundle.js +294 -36
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +69 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +262 -1
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +46 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +43 -1
- 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 +23 -1
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/utils.d.ts +28 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2104 -194
- 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/approval_card.d.ts +18 -0
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/checkpoint_menu.d.ts +10 -0
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +16 -0
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/message_actions.d.ts +46 -0
- package/dist/ui/message_actions.d.ts.map +1 -0
- package/dist/ui/page_quote_offer.d.ts +33 -0
- package/dist/ui/page_quote_offer.d.ts.map +1 -0
- package/dist/ui/quote_selection.d.ts +66 -0
- package/dist/ui/quote_selection.d.ts.map +1 -0
- package/dist/ui/relative_time.d.ts +10 -0
- package/dist/ui/relative_time.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/stick_to_bottom.d.ts +55 -0
- package/dist/ui/stick_to_bottom.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/suggestion_chips.d.ts +29 -0
- package/dist/ui/suggestion_chips.d.ts.map +1 -0
- package/dist/ui/thread_drawer.d.ts +10 -0
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +8 -0
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +53 -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/constants.ts +75 -0
- package/src/core/ag_ui_chat.ts +1357 -113
- package/src/core/agui_client.ts +81 -1
- package/src/core/conversation_store.ts +128 -42
- package/src/core/create_http_agent.ts +24 -2
- package/src/core/remote_conversation_store.ts +35 -2
- package/src/core/utils.ts +58 -0
- package/src/index.ts +39 -0
- package/src/tools/is_destructive.ts +8 -2
- package/src/tools/parse_tool_catalog.ts +18 -6
- package/src/ui/approval_card.ts +90 -2
- package/src/ui/checkpoint_menu.ts +22 -5
- package/src/ui/confirmation_card.ts +29 -1
- package/src/ui/message_actions.ts +158 -0
- package/src/ui/page_quote_offer.ts +215 -0
- package/src/ui/quote_selection.ts +345 -0
- package/src/ui/relative_time.ts +11 -0
- package/src/ui/render_markdown.ts +111 -21
- package/src/ui/resize_handle.ts +32 -2
- package/src/ui/stick_to_bottom.ts +126 -0
- package/src/ui/styles.ts +227 -0
- package/src/ui/suggestion_chips.ts +73 -0
- package/src/ui/thread_drawer.ts +22 -2
- package/src/ui/tool_call_card.ts +9 -0
- package/src/ui/ui_strings.ts +79 -8
- package/src/ui/voice_input.ts +43 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
+
import { randomUUID } from "@ag-ui/client";
|
|
1
2
|
import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
|
|
2
3
|
import {
|
|
4
|
+
ANNOUNCE_CLEAR_MS,
|
|
3
5
|
ATTACHMENT_EVENT,
|
|
4
6
|
CHART_ACTIVITY_TYPE,
|
|
5
7
|
COMPACTION_ACTIVITY_TYPE,
|
|
8
|
+
CUSTOM_AGENT_EVENT,
|
|
6
9
|
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
10
|
+
FEEDBACK_EVENT,
|
|
7
11
|
ICON_ATTACH,
|
|
8
12
|
ICON_LAUNCHER,
|
|
9
13
|
ICON_SEND,
|
|
10
14
|
ICON_STOP,
|
|
15
|
+
INVALIDATE_CUSTOM_NAME,
|
|
16
|
+
INVALIDATE_EVENT,
|
|
11
17
|
LOAD_CAPABILITY_TOOL,
|
|
12
18
|
MESSAGE_ROLE,
|
|
13
19
|
READ_PAGE_TOOL,
|
|
14
20
|
RUN_FINISHED_EVENT,
|
|
15
21
|
STATE_EVENT,
|
|
16
22
|
SUBMIT_EVENT,
|
|
23
|
+
SUGGESTIONS_ACTIVITY_TYPE,
|
|
17
24
|
TOGGLE_EVENT,
|
|
18
25
|
TOOL_CALL_STATUS,
|
|
19
26
|
TOOL_DISPLAY,
|
|
@@ -31,7 +38,7 @@ import { isNavigates } from "../tools/is_navigates.js";
|
|
|
31
38
|
import { createPageActionTools, type ResolvePageTarget } from "../tools/page_action_tools.js";
|
|
32
39
|
import { createPageMapContext, type PageMap } from "../tools/page_map.js";
|
|
33
40
|
import { createPageStateTools, type PageState } from "../tools/page_state.js";
|
|
34
|
-
import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
|
|
41
|
+
import { parseToolCatalog, type ToolCatalogEntry } from "../tools/parse_tool_catalog.js";
|
|
35
42
|
import { createRouteTools, type RouteMap } from "../tools/route_map.js";
|
|
36
43
|
import {
|
|
37
44
|
type ApprovalRenderer,
|
|
@@ -46,12 +53,20 @@ import { chartSpecFrom } from "../ui/chart_spec_from.js";
|
|
|
46
53
|
import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
|
|
47
54
|
import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
|
|
48
55
|
import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
|
|
56
|
+
import {
|
|
57
|
+
attachMessageActions,
|
|
58
|
+
messageActionBar,
|
|
59
|
+
messageActionButton,
|
|
60
|
+
} from "../ui/message_actions.js";
|
|
61
|
+
import { attachQuoteOffer, type PageQuoteOffer } from "../ui/page_quote_offer.js";
|
|
49
62
|
import { prettifyToolName } from "../ui/prettify_tool_name.js";
|
|
50
63
|
import {
|
|
51
64
|
type QuestionRenderer,
|
|
52
65
|
type QuestionRequest,
|
|
53
66
|
requestQuestion,
|
|
54
67
|
} from "../ui/question_card.js";
|
|
68
|
+
import { asQuote, quotableSelection } from "../ui/quote_selection.js";
|
|
69
|
+
import type { RelativeTimeFormatter } from "../ui/relative_time.js";
|
|
55
70
|
import { renderMarkdown } from "../ui/render_markdown.js";
|
|
56
71
|
import {
|
|
57
72
|
createResizeHandle,
|
|
@@ -62,7 +77,9 @@ import {
|
|
|
62
77
|
import { wrapWords } from "../ui/reveal_words.js";
|
|
63
78
|
import { renderRunNotice } from "../ui/run_notice.js";
|
|
64
79
|
import { SkillsMenu } from "../ui/skills_menu.js";
|
|
80
|
+
import { createStickToBottom, type StickToBottom } from "../ui/stick_to_bottom.js";
|
|
65
81
|
import { STYLES } from "../ui/styles.js";
|
|
82
|
+
import { renderSuggestionChips } from "../ui/suggestion_chips.js";
|
|
66
83
|
import { ThoughtsBlock } from "../ui/thoughts_block.js";
|
|
67
84
|
import { ThreadDrawer } from "../ui/thread_drawer.js";
|
|
68
85
|
import { ToolCallCard, type ToolDisplayMode } from "../ui/tool_call_card.js";
|
|
@@ -80,13 +97,14 @@ import {
|
|
|
80
97
|
type ClientConversationStore,
|
|
81
98
|
type NavigationCheckpoint,
|
|
82
99
|
SessionStorageStore,
|
|
100
|
+
writeStoredItem,
|
|
83
101
|
} from "./conversation_store.js";
|
|
84
102
|
import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
|
|
85
103
|
import { RemoteConversationStore } from "./remote_conversation_store.js";
|
|
86
104
|
import { RunIndex } from "./run_index.js";
|
|
87
105
|
import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
|
|
88
106
|
import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
|
|
89
|
-
import { mintThread, withCredentials } from "./utils.js";
|
|
107
|
+
import { mintThread, warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
|
|
90
108
|
|
|
91
109
|
/** The role a rendered chat message takes. */
|
|
92
110
|
export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
|
|
@@ -107,6 +125,13 @@ export interface AttachmentsDetail {
|
|
|
107
125
|
}
|
|
108
126
|
|
|
109
127
|
/** `detail` shape of the {@link STATE_EVENT} CustomEvent. */
|
|
128
|
+
/** {@link FEEDBACK_EVENT} detail: what was rated, and how. */
|
|
129
|
+
export interface FeedbackDetail {
|
|
130
|
+
/** The rated message's text, as rendered. */
|
|
131
|
+
readonly content: string;
|
|
132
|
+
readonly rating: "up" | "down";
|
|
133
|
+
}
|
|
134
|
+
|
|
110
135
|
export interface StateDetail {
|
|
111
136
|
readonly state: Readonly<Record<string, unknown>>;
|
|
112
137
|
}
|
|
@@ -126,6 +151,83 @@ export interface ToolRun {
|
|
|
126
151
|
export interface RunFinishedDetail {
|
|
127
152
|
/** In settle order. Empty when the interaction called no tools. */
|
|
128
153
|
readonly tools: readonly ToolRun[];
|
|
154
|
+
/**
|
|
155
|
+
* Every key announced during the interaction, de-duplicated, first-seen order.
|
|
156
|
+
*
|
|
157
|
+
* **This is the field that makes adoption one line** for a host already
|
|
158
|
+
* listening here, and the `else` is the whole compatibility story:
|
|
159
|
+
*
|
|
160
|
+
* ```js
|
|
161
|
+
* if (detail.invalidated.length > 0) refetchOnly(detail.invalidated);
|
|
162
|
+
* else if (detail.tools.some((t) => t.side === "server")) refetchEverything();
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* Empty against a server that announces nothing, so an old server and a new
|
|
166
|
+
* client fall through to the coarse refetch that shipped before either.
|
|
167
|
+
*/
|
|
168
|
+
readonly invalidated: readonly string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Draw one activity, from its content alone.
|
|
173
|
+
*
|
|
174
|
+
* The contract is {@link ClientTool.render}'s, and for the same reason rather
|
|
175
|
+
* than by analogy. An activity is materialised into a `role: "activity"`
|
|
176
|
+
* message, persisted with the transcript, and re-fired on every restore -- so a
|
|
177
|
+
* renderer that writes to the page instead of returning DOM fires again on
|
|
178
|
+
* every thread load, which is exactly the bug the tool registry's purity rule
|
|
179
|
+
* was written to make unmakeable.
|
|
180
|
+
*
|
|
181
|
+
* - a pure function of `content` -- no host state, no network, no clock;
|
|
182
|
+
* - deterministic, so a reload reproduces what was there before;
|
|
183
|
+
* - free of effects outside the node it returns, which the component places.
|
|
184
|
+
*
|
|
185
|
+
* Return `null` for content that says nothing worth drawing. Anything already
|
|
186
|
+
* drawn under that message id is then removed: live and reload should agree,
|
|
187
|
+
* and the stored content is the version that could not be drawn.
|
|
188
|
+
*/
|
|
189
|
+
export type ActivityRenderer = (content: unknown) => Node | null;
|
|
190
|
+
|
|
191
|
+
/** One `activity_type` a host can draw. See {@link AgUiChat.registerActivityRenderer}. */
|
|
192
|
+
export interface ActivityRegistration {
|
|
193
|
+
/**
|
|
194
|
+
* The AG-UI `activity_type` this draws, matched exactly.
|
|
195
|
+
*
|
|
196
|
+
* An open string the protocol does not enumerate -- which is the whole reason
|
|
197
|
+
* this is a registry rather than a branch.
|
|
198
|
+
*/
|
|
199
|
+
readonly type: string;
|
|
200
|
+
readonly render: ActivityRenderer;
|
|
201
|
+
/**
|
|
202
|
+
* Shown in the transcript when something already drawn under this type stops
|
|
203
|
+
* being renderable. Omit for an activity whose disappearance needs no
|
|
204
|
+
* explanation.
|
|
205
|
+
*/
|
|
206
|
+
readonly removedNotice?: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `detail` shape of the {@link CUSTOM_AGENT_EVENT} CustomEvent. */
|
|
210
|
+
export interface CustomAgentDetail {
|
|
211
|
+
/** The `CUSTOM` event's `name`, verbatim. An open string; never interpreted here. */
|
|
212
|
+
readonly name: string;
|
|
213
|
+
/** Its `value`, verbatim and unparsed. `unknown` because the protocol says nothing about it. */
|
|
214
|
+
readonly value: unknown;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** `detail` shape of the {@link INVALIDATE_EVENT} CustomEvent. */
|
|
218
|
+
export interface InvalidateDetail {
|
|
219
|
+
/**
|
|
220
|
+
* The resources that moved, as the server named them.
|
|
221
|
+
*
|
|
222
|
+
* **Opaque strings, and matching is exact.** `orders/42` does not imply
|
|
223
|
+
* `orders` -- a prefix rule would be this component guessing at a scheme it
|
|
224
|
+
* does not own, and `orders/1` would match `orders/11`. A server that wants
|
|
225
|
+
* the collection refreshed names it. Your own matching may be hierarchical,
|
|
226
|
+
* because in your vocabulary the scheme is known.
|
|
227
|
+
*/
|
|
228
|
+
readonly keys: readonly string[];
|
|
229
|
+
/** What caused the write -- usually the tool's name. `null` when unstated. */
|
|
230
|
+
readonly reason: string | null;
|
|
129
231
|
}
|
|
130
232
|
|
|
131
233
|
/** `detail` shape of the {@link TOGGLE_EVENT} CustomEvent. */
|
|
@@ -156,6 +258,7 @@ const CONNECT_TIME_ATTRIBUTES = [
|
|
|
156
258
|
"data-attachment-max-bytes",
|
|
157
259
|
"data-transcribe-url",
|
|
158
260
|
"data-threads-url",
|
|
261
|
+
"data-threads-cache",
|
|
159
262
|
"data-tools-url",
|
|
160
263
|
"data-skills-url",
|
|
161
264
|
"data-skills",
|
|
@@ -187,6 +290,20 @@ const SIZE_KEY = "ag-ui-chat:size";
|
|
|
187
290
|
/** Per-tab persistence key for the built-in theme toggle. */
|
|
188
291
|
const THEME_KEY = "ag-ui-chat:theme";
|
|
189
292
|
|
|
293
|
+
/** Pixels between a selection and the offer to quote it. */
|
|
294
|
+
const QUOTE_GAP = 6;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Storage namespaces already spoken for in this document.
|
|
298
|
+
*
|
|
299
|
+
* Per document rather than per origin, and released on disconnect, because the
|
|
300
|
+
* question it answers is "is another element on this page using these keys right
|
|
301
|
+
* now" — not "has anything ever used them". A registry that never released would
|
|
302
|
+
* turn every remount, and every framework re-render that moves the node, into a
|
|
303
|
+
* false collision that costs the element its own conversation.
|
|
304
|
+
*/
|
|
305
|
+
const CLAIMED_NAMESPACES = new Set<string>();
|
|
306
|
+
|
|
190
307
|
/**
|
|
191
308
|
* `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
|
|
192
309
|
*
|
|
@@ -224,6 +341,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
224
341
|
*/
|
|
225
342
|
getHeaders: (() => Record<string, string>) | null = null;
|
|
226
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Origins, besides the page's own, that this element may send {@link headers}
|
|
346
|
+
* and {@link getHeaders} credentials to without saying so on the console.
|
|
347
|
+
*
|
|
348
|
+
* Seven attributes name a URL, and every one of them carries these headers.
|
|
349
|
+
* They are plain HTML, so a page that builds one from a query parameter or
|
|
350
|
+
* from tenant-authored configuration has handed an attacker the destination,
|
|
351
|
+
* and the token leaves on the element's first request. Naming the origins you
|
|
352
|
+
* expect turns that from silent into either confirmed or reported.
|
|
353
|
+
*
|
|
354
|
+
* A notice rather than a refusal: a cross-origin agent is a documented
|
|
355
|
+
* deployment, so refusing would break working installations to defend against
|
|
356
|
+
* a page that is already interpolating untrusted data into its own markup.
|
|
357
|
+
* Leaving this empty costs nothing but one console line per foreign origin.
|
|
358
|
+
*/
|
|
359
|
+
trustedOrigins: readonly string[] = [];
|
|
360
|
+
|
|
227
361
|
/**
|
|
228
362
|
* Permit `<img>` in rendered assistant markdown. **Off by default**: a
|
|
229
363
|
* model-controlled image URL is fetched with no user interaction, which
|
|
@@ -232,6 +366,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
232
366
|
*/
|
|
233
367
|
allowImages = false;
|
|
234
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Replace the relative timestamps in the thread drawer and the checkpoint
|
|
371
|
+
* panel -- `"5m ago"`, `"2d ago"` -- with the host's own formatting.
|
|
372
|
+
*
|
|
373
|
+
* The built-in is locale-neutral on purpose: there is no `Intl` anywhere in
|
|
374
|
+
* this component, so it never disagrees with the page it is embedded in by
|
|
375
|
+
* guessing a locale. That is a good default and a bad requirement, which is
|
|
376
|
+
* what this is for.
|
|
377
|
+
*
|
|
378
|
+
* ```js
|
|
379
|
+
* const rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
|
|
380
|
+
* chat.formatRelativeTime = (ts) =>
|
|
381
|
+
* rtf.format(Math.round((ts - Date.now()) / 60000), "minute");
|
|
382
|
+
* ```
|
|
383
|
+
*/
|
|
384
|
+
formatRelativeTime: RelativeTimeFormatter | null = null;
|
|
385
|
+
|
|
235
386
|
/** When true, destructive tools execute without a confirmation modal. */
|
|
236
387
|
autoConfirm = false;
|
|
237
388
|
|
|
@@ -259,6 +410,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
259
410
|
*/
|
|
260
411
|
approvalRenderer: ApprovalRenderer | null = null;
|
|
261
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Let the user edit a gated call's arguments before approving it.
|
|
415
|
+
*
|
|
416
|
+
* Off by default and **an assertion about your server**, not a negotiation:
|
|
417
|
+
* AG-UI carries `editedArgs` in the resume payload and gates it on the
|
|
418
|
+
* agent's own `approveWithEdits` capability, which this component never sees
|
|
419
|
+
* -- capabilities are not on the wire it reads. So the host says whether its
|
|
420
|
+
* agent honours them. Turned on against a server that does not, the user
|
|
421
|
+
* would edit arguments it silently discards, which is worse than not
|
|
422
|
+
* offering.
|
|
423
|
+
*
|
|
424
|
+
* Only affects calls whose arguments are known here: an interrupt names a
|
|
425
|
+
* `toolCallId`, and the tool card for that call is where the arguments still
|
|
426
|
+
* are. An interrupt naming no card gets the plain approve/deny.
|
|
427
|
+
*/
|
|
428
|
+
approveWithEdits = false;
|
|
429
|
+
|
|
262
430
|
/**
|
|
263
431
|
* Optional per-call confirmation predicate. When set it is authoritative,
|
|
264
432
|
* deciding from the tool name and args whether this particular call needs
|
|
@@ -381,14 +549,40 @@ export class AgUiChat extends HTMLElement {
|
|
|
381
549
|
resolvePageTarget: ResolvePageTarget = (target) => document.querySelector<HTMLElement>(target);
|
|
382
550
|
|
|
383
551
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
552
|
+
* The server tool catalog fetched from `data-tools-url`, keyed by tool
|
|
553
|
+
* name. Cards label themselves from each entry's `summary`, the base
|
|
554
|
+
* layer behind {@link toolSummaries}: an explicit entry in `toolSummaries`
|
|
555
|
+
* wins, this fills the rest. Held as whole entries rather than labels so a
|
|
556
|
+
* field the server sent is not lost on the way in. Populated once on connect.
|
|
387
557
|
*/
|
|
388
|
-
#toolCatalog: Record<string,
|
|
558
|
+
#toolCatalog: Record<string, ToolCatalogEntry> = {};
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Foreign origins already reported, so the notice is once per origin per
|
|
562
|
+
* element rather than once per request. Per-element rather than module-level,
|
|
563
|
+
* because two elements on one page are two separate configurations.
|
|
564
|
+
*/
|
|
565
|
+
#warnedOrigins = new Set<string>();
|
|
389
566
|
/** The resolved string table (defaults ← `data-strings` ← `strings`). */
|
|
390
567
|
#strings: UiStrings = DEFAULT_UI_STRINGS;
|
|
391
568
|
|
|
569
|
+
/**
|
|
570
|
+
* The tool names the current round handed the agent, captured as the catalog
|
|
571
|
+
* went out.
|
|
572
|
+
*
|
|
573
|
+
* The registry is mount-wide but {@link getTools} is per-run, so a host is
|
|
574
|
+
* free to scope what a given page offers — and a call naming a tool this run
|
|
575
|
+
* withheld must not reach the handler that is merely still registered.
|
|
576
|
+
* Snapshotted rather than re-asked at dispatch: a provider is a function, and
|
|
577
|
+
* calling it again asks a question the run already answered, which is exactly
|
|
578
|
+
* the window a scoped catalog exists to close.
|
|
579
|
+
*
|
|
580
|
+
* Empty until the first round advertises, which cannot precede a call: the
|
|
581
|
+
* client builds `RunAgentInput.tools` at the top of every round, before the
|
|
582
|
+
* calls that round produces are executed.
|
|
583
|
+
*/
|
|
584
|
+
#advertisedTools: ReadonlySet<string> = new Set();
|
|
585
|
+
|
|
392
586
|
readonly #toolRegistry = new ClientToolRegistry();
|
|
393
587
|
/** Tool-call cards awaiting execution, keyed by call id. */
|
|
394
588
|
readonly #toolCards = new Map<string, ToolCallCard>();
|
|
@@ -398,7 +592,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
398
592
|
* the real output with the generic "executed on the server" fallback.
|
|
399
593
|
*/
|
|
400
594
|
/** Whether a server-pushed chart activity is drawn. Off unless asked for. */
|
|
401
|
-
|
|
595
|
+
/**
|
|
596
|
+
* Which `activity_type`s this element can draw, by name.
|
|
597
|
+
*
|
|
598
|
+
* A registry rather than a branch because `activity_type` is an open string
|
|
599
|
+
* the protocol does not enumerate. The two built-ins go through it like any
|
|
600
|
+
* host registration, which is the test that the seam is real.
|
|
601
|
+
*/
|
|
602
|
+
readonly #activityRenderers = new Map<string, ActivityRegistration>();
|
|
603
|
+
/** Types that arrived with nobody registered to draw them. See {@link unhandledActivityTypes}. */
|
|
604
|
+
readonly #unhandledActivityTypes = new Set<string>();
|
|
402
605
|
|
|
403
606
|
/** Card elements by call id, so a rendering handler can find its own card. */
|
|
404
607
|
readonly #cardElements = new Map<string, HTMLElement>();
|
|
@@ -413,7 +616,77 @@ export class AgUiChat extends HTMLElement {
|
|
|
413
616
|
* Spans tool rounds and an approval interrupt; cleared when the event fires.
|
|
414
617
|
*/
|
|
415
618
|
#runTools: { readonly id: string; readonly name: string }[] = [];
|
|
619
|
+
/**
|
|
620
|
+
* Keys announced during this interaction, de-duplicated in first-seen order.
|
|
621
|
+
*
|
|
622
|
+
* Per element, never module-level: a second mounted chat is a second run, and
|
|
623
|
+
* sharing this would tell one page to refetch on the other's writes. Reset by
|
|
624
|
+
* {@link AgUiChat.#dispatchRunFinished}, which is the one place that has read
|
|
625
|
+
* it.
|
|
626
|
+
*/
|
|
627
|
+
/**
|
|
628
|
+
* Tool names the user waived confirmation for, for the life of this element.
|
|
629
|
+
*
|
|
630
|
+
* Per instance and never persisted: a session decision that outlived the tab
|
|
631
|
+
* would be a permanent grant made by one click, which is the thing
|
|
632
|
+
* `autoConfirm` already exists to say deliberately. Cleared with the element.
|
|
633
|
+
*/
|
|
634
|
+
readonly #sessionApproved = new Set<string>();
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* The one action row currently carrying Retry, if any.
|
|
638
|
+
*
|
|
639
|
+
* Retry belongs to the **last** turn only: re-running an older one is
|
|
640
|
+
* branching, and for a page-driving agent editing a past turn is not neutral
|
|
641
|
+
* -- those turns clicked buttons, and re-running turn 3 does not un-save what
|
|
642
|
+
* turn 5 saved. Holding a single owner is what keeps exactly one offer on
|
|
643
|
+
* screen without per-bubble bookkeeping.
|
|
644
|
+
*/
|
|
645
|
+
#retryOwner: HTMLElement | null = null;
|
|
646
|
+
|
|
647
|
+
#runInvalidated = new Set<string>();
|
|
416
648
|
readonly #root: ShadowRoot;
|
|
649
|
+
/** Screen-reader-only status region -- see {@link AgUiChat.#announce}. */
|
|
650
|
+
readonly #announcer = document.createElement("div");
|
|
651
|
+
/** Return-to-foot affordance, shown only once something has been missed. */
|
|
652
|
+
readonly #jumpButton = document.createElement("button");
|
|
653
|
+
/**
|
|
654
|
+
* Offer to quote the current selection, floated beside it.
|
|
655
|
+
*
|
|
656
|
+
* Shares {@link AgUiChat.#messagesWrap} with the jump button for the same
|
|
657
|
+
* reason: it is positioned against the transcript, and must not scroll away
|
|
658
|
+
* with the words it is pointing at.
|
|
659
|
+
*/
|
|
660
|
+
readonly #quoteButton = document.createElement("button");
|
|
661
|
+
/** What {@link AgUiChat.#quoteButton} would quote, while it is showing. */
|
|
662
|
+
#quoting = "";
|
|
663
|
+
/** The host-page offer, while one is attached; see {@link AgUiChat.offerQuoteInPage}. */
|
|
664
|
+
#pageQuote: PageQuoteOffer | null = null;
|
|
665
|
+
/**
|
|
666
|
+
* Positioning context for {@link AgUiChat.#jumpButton}.
|
|
667
|
+
*
|
|
668
|
+
* The button cannot live in the scrolling list -- it would scroll away with
|
|
669
|
+
* the content it is offering to scroll to -- and it cannot be positioned
|
|
670
|
+
* against the panel either: the panel's foot is below the composer, the skill
|
|
671
|
+
* chips and the footer, so `bottom` measured from there lands the button on
|
|
672
|
+
* top of the composer rather than over the transcript. This wrapper is the
|
|
673
|
+
* only box whose foot *is* the transcript's foot.
|
|
674
|
+
*/
|
|
675
|
+
readonly #messagesWrap = document.createElement("div");
|
|
676
|
+
/** Follows the foot of the transcript, and stops when the reader scrolls away. */
|
|
677
|
+
#scroller!: StickToBottom;
|
|
678
|
+
/** Pending clear of {@link AgUiChat.#announcer}; see why it is cleared at all. */
|
|
679
|
+
#announceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
680
|
+
/**
|
|
681
|
+
* Whether this turn already announced how it ended.
|
|
682
|
+
*
|
|
683
|
+
* `onSettled` is the terminal guarantee and fires however the run ended, so
|
|
684
|
+
* it is the only place that can promise the user hears *something*. But a
|
|
685
|
+
* stopped or failed run has already said the truer thing from `onCancelled`
|
|
686
|
+
* or `onError`, and "assistant answered" after "response stopped" is worse
|
|
687
|
+
* than silence.
|
|
688
|
+
*/
|
|
689
|
+
#announcedOutcome = false;
|
|
417
690
|
readonly #chat: HTMLDivElement;
|
|
418
691
|
readonly #messages: HTMLDivElement;
|
|
419
692
|
readonly #input: HTMLTextAreaElement;
|
|
@@ -470,6 +743,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
470
743
|
// revealed progressively as it streamed, so the word reveal must not re-animate
|
|
471
744
|
// it; ≤1 ⇒ it arrived at once and the word reveal is appropriate.
|
|
472
745
|
#streamDeltas = 0;
|
|
746
|
+
// The accumulated answer the next render will draw. Deltas overwrite it
|
|
747
|
+
// (each one carries the whole answer), so a frame always draws the latest.
|
|
748
|
+
#streamBuffer = "";
|
|
749
|
+
// The frame that render is queued on, or `null` when nothing is queued —
|
|
750
|
+
// also the flag saying a delta is still undrawn.
|
|
751
|
+
#streamFrame: number | null = null;
|
|
473
752
|
#pending: HTMLDivElement | null = null;
|
|
474
753
|
// The current assistant turn's grouping container. One `.answer`
|
|
475
754
|
// wraps everything a single answer produces — streamed text, tool cards, the
|
|
@@ -484,9 +763,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
484
763
|
#thoughts: ThoughtsBlock | null = null;
|
|
485
764
|
#threadId = "";
|
|
486
765
|
// Per-instance suffix for the origin-scoped storage keys (collapsed / theme /
|
|
487
|
-
//
|
|
488
|
-
//
|
|
766
|
+
// size), so two instances on one origin don't clobber each other. Empty ⇒ the
|
|
767
|
+
// pre-namespacing global keys (back-compat). Resolved on connect; the
|
|
768
|
+
// conversation adds `user-key` on top of it, see #conversationNs.
|
|
489
769
|
#storageNs = "";
|
|
770
|
+
// The entry this element put in CLAIMED_NAMESPACES, to take back out on
|
|
771
|
+
// disconnect. `null` when it claimed nothing (no id, no endpoint, or it lost
|
|
772
|
+
// the claim to an element that mounted first).
|
|
773
|
+
#claimedNs: string | null = null;
|
|
774
|
+
// The fallback namespace minted when the preferred one was already claimed,
|
|
775
|
+
// with the preferred value it was minted for — so the element keeps it across
|
|
776
|
+
// remounts, but re-resolves if the host answers the warning with an `id`.
|
|
777
|
+
#generatedNs = "";
|
|
778
|
+
#generatedFor = "";
|
|
779
|
+
// The `sessionStorage`-backed store, which the element may therefore re-scope
|
|
780
|
+
// on a principal change. `null` when the host injected a store of its own
|
|
781
|
+
// kind, whose keying the element does not know and must not guess at.
|
|
782
|
+
#builtinStore: SessionStorageStore | null = null;
|
|
490
783
|
// Bumped on every #rehydrate; a replay whose generation is stale (a newer
|
|
491
784
|
// thread switch started while it awaited a slow store) drops its result.
|
|
492
785
|
#rehydrateGeneration = 0;
|
|
@@ -513,6 +806,33 @@ export class AgUiChat extends HTMLElement {
|
|
|
513
806
|
this.#launcher = document.createElement("button");
|
|
514
807
|
this.#badge = document.createElement("span");
|
|
515
808
|
this.#emptyWrap = document.createElement("div");
|
|
809
|
+
// The compaction notice is a registration, not a branch -- and going through
|
|
810
|
+
// the seam earns it two things it did not have: a reload puts it back (it is
|
|
811
|
+
// content, and content replays), and a server redrawing under the same id
|
|
812
|
+
// replaces it rather than adding a second notice for one event.
|
|
813
|
+
this.registerActivityRenderer({
|
|
814
|
+
type: COMPACTION_ACTIVITY_TYPE,
|
|
815
|
+
render: (content) => {
|
|
816
|
+
const removed = compactionRemoved(content);
|
|
817
|
+
return removed === null
|
|
818
|
+
? null
|
|
819
|
+
: renderRunNotice(
|
|
820
|
+
"\u{1F5DC}",
|
|
821
|
+
this.#strings.historyCompacted.replace("{count}", String(removed)),
|
|
822
|
+
"compaction",
|
|
823
|
+
);
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
// Follow-up chips, registered through the same seam for the same reasons:
|
|
827
|
+
// a reload puts them back, and a server pushing a new set under a new id
|
|
828
|
+
// supersedes the old one rather than leaving two offers on screen.
|
|
829
|
+
this.registerActivityRenderer({
|
|
830
|
+
type: SUGGESTIONS_ACTIVITY_TYPE,
|
|
831
|
+
render: (content) =>
|
|
832
|
+
renderSuggestionChips(content, this.#strings, (prompt) => {
|
|
833
|
+
void this.sendMessage(prompt);
|
|
834
|
+
}),
|
|
835
|
+
});
|
|
516
836
|
this.#skillsMenu = new SkillsMenu((skill) => this.#applySkill(skill));
|
|
517
837
|
this.#drawer = new ThreadDrawer({
|
|
518
838
|
onSelect: (threadId) => {
|
|
@@ -544,7 +864,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
544
864
|
if (this.#runIndex === null) {
|
|
545
865
|
this.#runIndex = new RunIndex(
|
|
546
866
|
url,
|
|
547
|
-
() => this.#
|
|
867
|
+
() => this.#headersFor(url),
|
|
548
868
|
() => this.#requestCredentials(),
|
|
549
869
|
);
|
|
550
870
|
}
|
|
@@ -579,6 +899,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
579
899
|
endpoint,
|
|
580
900
|
headers: this.#requestHeaders(),
|
|
581
901
|
getHeaders: () => this.#requestHeaders(),
|
|
902
|
+
trustedOrigins: this.trustedOrigins,
|
|
582
903
|
...this.#credentialsOption(),
|
|
583
904
|
threadId: this.#threadId,
|
|
584
905
|
// The seed the endpoints assume: nothing. The snapshot is the history.
|
|
@@ -587,7 +908,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
587
908
|
const client = new AgUiClient({
|
|
588
909
|
agent,
|
|
589
910
|
handlers: this.#handlers(),
|
|
590
|
-
getTools: () => this
|
|
911
|
+
getTools: () => this.#advertiseTools(),
|
|
591
912
|
getContext: () => this.#buildContext(),
|
|
592
913
|
executeTool: (call) => this.#executeTool(call),
|
|
593
914
|
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
@@ -599,12 +920,15 @@ export class AgUiChat extends HTMLElement {
|
|
|
599
920
|
/** Load the checkpoint panel with the runs that can actually be continued. */
|
|
600
921
|
async #refreshCheckpoints(): Promise<void> {
|
|
601
922
|
const index = this.#runs();
|
|
923
|
+
// Pushed at render rather than at connect: `formatRelativeTime` is a
|
|
924
|
+
// property, so a host may set it long after the element mounted.
|
|
925
|
+
this.#checkpoints.setRelativeTimeFormatter(this.formatRelativeTime);
|
|
602
926
|
this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
|
|
603
927
|
}
|
|
604
928
|
|
|
605
929
|
/** Attributes the element reacts to after it has been connected. */
|
|
606
930
|
static get observedAttributes(): string[] {
|
|
607
|
-
return ["title-text", "placement", "credentials", ...CONNECT_TIME_ATTRIBUTES];
|
|
931
|
+
return ["title-text", "placement", "credentials", "user-key", ...CONNECT_TIME_ATTRIBUTES];
|
|
608
932
|
}
|
|
609
933
|
|
|
610
934
|
attributeChangedCallback(name: string, previous: string | null, value: string | null): void {
|
|
@@ -638,6 +962,16 @@ export class AgUiChat extends HTMLElement {
|
|
|
638
962
|
this.#title.textContent = value ?? this.#strings.title;
|
|
639
963
|
return;
|
|
640
964
|
}
|
|
965
|
+
if (name === "user-key") {
|
|
966
|
+
// Before connect there is nothing to move: connectedCallback resolves the
|
|
967
|
+
// namespace from the attribute as it stands by then. An absent attribute
|
|
968
|
+
// and an empty one name the same (unnamed) principal, so neither is a
|
|
969
|
+
// change worth acting on.
|
|
970
|
+
if (this.#connected && (previous ?? "") !== (value ?? "")) {
|
|
971
|
+
this.#changePrincipal(previous ?? "", value ?? "");
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
641
975
|
// Everything else here is read once, in connectedCallback, to build chrome
|
|
642
976
|
// that then exists or does not. A later change is silently ignored and the
|
|
643
977
|
// symptom is an affordance that never appears, which reads as a broken
|
|
@@ -657,7 +991,18 @@ export class AgUiChat extends HTMLElement {
|
|
|
657
991
|
);
|
|
658
992
|
}
|
|
659
993
|
|
|
660
|
-
/**
|
|
994
|
+
/**
|
|
995
|
+
* Declare a frontend tool the agent may call.
|
|
996
|
+
*
|
|
997
|
+
* **A handler's thrown message leaves the browser.** When a handler rejects,
|
|
998
|
+
* its `Error.message` is posted back as that call's tool result — into the
|
|
999
|
+
* conversation, on to the AG-UI endpoint, persisted server-side, and
|
|
1000
|
+
* forwarded to the model provider on every later round. That is deliberate,
|
|
1001
|
+
* since it is what lets the agent recover from a failure it caused; but it
|
|
1002
|
+
* means an internal hostname, a signed URL or a stack-derived path in a
|
|
1003
|
+
* rethrown error is disclosed to parties the host never chose. Throw the
|
|
1004
|
+
* message you would be content for the model to read, and log the detail.
|
|
1005
|
+
*/
|
|
661
1006
|
registerTool(tool: ClientTool): void {
|
|
662
1007
|
this.#toolRegistry.register(tool);
|
|
663
1008
|
}
|
|
@@ -831,10 +1176,23 @@ export class AgUiChat extends HTMLElement {
|
|
|
831
1176
|
});
|
|
832
1177
|
this.#confirmAbort = null;
|
|
833
1178
|
this.#updateEmptyState();
|
|
834
|
-
this.#
|
|
1179
|
+
this.#scroller.follow();
|
|
835
1180
|
return answer;
|
|
836
1181
|
}
|
|
837
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* The catalog for the round about to start, remembering what it offered.
|
|
1185
|
+
*
|
|
1186
|
+
* Every path to a frontend tool goes through here first — the client asks
|
|
1187
|
+
* for `RunAgentInput.tools` at the top of each round — so this is the one
|
|
1188
|
+
* place that can know what the agent was actually told about.
|
|
1189
|
+
*/
|
|
1190
|
+
#advertiseTools(): Tool[] {
|
|
1191
|
+
const tools = this.getTools();
|
|
1192
|
+
this.#advertisedTools = new Set(tools.map((tool) => tool.name));
|
|
1193
|
+
return tools;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
838
1196
|
/** Resolve a tool by name: built-in tools first, then the registry. */
|
|
839
1197
|
#resolveTool(name: string): ClientTool | null {
|
|
840
1198
|
const builtin = this.#builtinTools().find((t) => t.name === name);
|
|
@@ -856,6 +1214,39 @@ export class AgUiChat extends HTMLElement {
|
|
|
856
1214
|
this.setAttribute("endpoint", value);
|
|
857
1215
|
}
|
|
858
1216
|
|
|
1217
|
+
/**
|
|
1218
|
+
* Who the stored conversation belongs to, from the `user-key` attribute.
|
|
1219
|
+
*
|
|
1220
|
+
* Set it to whatever identifies the signed-in principal — a user id, an
|
|
1221
|
+
* account id, a hash of one. The value joins the storage namespace, so two
|
|
1222
|
+
* principals in the same tab cannot read each other's transcript, and
|
|
1223
|
+
* **changing it purges what the previous one left behind**.
|
|
1224
|
+
*
|
|
1225
|
+
* That purge is the reason this is a live attribute rather than a
|
|
1226
|
+
* connect-time one. `sessionStorage` survives same-tab navigation, so it
|
|
1227
|
+
* survives a logout; and a single-page app signs out through its own router
|
|
1228
|
+
* without remounting anything, so there is no other moment at which the
|
|
1229
|
+
* element could find out. The host naming the new principal — or dropping the
|
|
1230
|
+
* attribute — is the signal.
|
|
1231
|
+
*
|
|
1232
|
+
* Absent means exactly today's behaviour, which is why nothing breaks by
|
|
1233
|
+
* leaving it off: the conversation is scoped to the element and to nobody in
|
|
1234
|
+
* particular, and on a shared workstation it carries into whoever signs in
|
|
1235
|
+
* next in the same tab.
|
|
1236
|
+
*
|
|
1237
|
+
* The first value to arrive is treated as a host naming the user who was
|
|
1238
|
+
* already there, not as a handover: the conversation in progress moves into
|
|
1239
|
+
* the principal's namespace rather than being destroyed, so an element
|
|
1240
|
+
* configured by an async auth handshake keeps what is on screen.
|
|
1241
|
+
*/
|
|
1242
|
+
get userKey(): string {
|
|
1243
|
+
return this.getAttribute("user-key") ?? "";
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
set userKey(value: string) {
|
|
1247
|
+
this.setAttribute("user-key", value);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
859
1250
|
/**
|
|
860
1251
|
* Cookie policy for **every** request this element makes, as `fetch`'s own
|
|
861
1252
|
* `credentials` mode (`"omit"` / `"same-origin"` / `"include"`). Mirrored to
|
|
@@ -907,6 +1298,25 @@ export class AgUiChat extends HTMLElement {
|
|
|
907
1298
|
return { ...this.headers, ...this.getHeaders?.() };
|
|
908
1299
|
}
|
|
909
1300
|
|
|
1301
|
+
/**
|
|
1302
|
+
* The request headers, having first reported the destination if it is foreign.
|
|
1303
|
+
*
|
|
1304
|
+
* Every caller that sends these headers knows its URL, and `#requestHeaders`
|
|
1305
|
+
* does not -- so the check lives here, on the path that has both, rather than
|
|
1306
|
+
* being repeated at each call site with a chance to be forgotten at the next
|
|
1307
|
+
* one added.
|
|
1308
|
+
*/
|
|
1309
|
+
#headersFor(url: string): Record<string, string> {
|
|
1310
|
+
const headers = this.#requestHeaders();
|
|
1311
|
+
warnOnCrossOriginCredentials(
|
|
1312
|
+
url,
|
|
1313
|
+
Object.keys(headers),
|
|
1314
|
+
this.trustedOrigins,
|
|
1315
|
+
this.#warnedOrigins,
|
|
1316
|
+
);
|
|
1317
|
+
return headers;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
910
1320
|
/** The configured cookie policy as `fetch` spells it; `undefined` when unset. */
|
|
911
1321
|
#requestCredentials(): RequestCredentials | undefined {
|
|
912
1322
|
return this.credentials ?? undefined;
|
|
@@ -927,8 +1337,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
927
1337
|
}
|
|
928
1338
|
|
|
929
1339
|
/** The `fetch` init for the element's own plain GETs (catalogs). */
|
|
930
|
-
#fetchInit(): RequestInit | undefined {
|
|
931
|
-
return withCredentials({ headers: this.#
|
|
1340
|
+
#fetchInit(url: string): RequestInit | undefined {
|
|
1341
|
+
return withCredentials({ headers: this.#headersFor(url) }, this.#requestCredentials());
|
|
932
1342
|
}
|
|
933
1343
|
|
|
934
1344
|
/**
|
|
@@ -956,10 +1366,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
956
1366
|
}
|
|
957
1367
|
|
|
958
1368
|
connectedCallback(): void {
|
|
959
|
-
// Resolve the per-instance storage namespace
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
this.#storageNs = this
|
|
1369
|
+
// Resolve the per-instance storage namespace before any key read/write, so
|
|
1370
|
+
// this instance doesn't share collapsed/theme/thread state with another on
|
|
1371
|
+
// the same origin.
|
|
1372
|
+
this.#storageNs = this.#claimNamespace();
|
|
963
1373
|
// Restore a dragged size before the panel paints, so it does not snap from
|
|
964
1374
|
// the placement default to the user's width on the first frame.
|
|
965
1375
|
this.#applySize(this.#readSize());
|
|
@@ -989,8 +1399,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
989
1399
|
this.#initSkills();
|
|
990
1400
|
// Namespace the built-in default store too (a host-injected store is used
|
|
991
1401
|
// verbatim). Must precede #wireThreadStore, which wraps the current store.
|
|
992
|
-
if (this
|
|
993
|
-
|
|
1402
|
+
if (this.conversationStore instanceof SessionStorageStore) {
|
|
1403
|
+
const namespace = this.#conversationNs();
|
|
1404
|
+
// Remembered either way: this is the element's own store, so a later
|
|
1405
|
+
// `user-key` change may move it to another namespace.
|
|
1406
|
+
this.#builtinStore =
|
|
1407
|
+
namespace === "" ? this.conversationStore : new SessionStorageStore(namespace);
|
|
1408
|
+
this.conversationStore = this.#builtinStore;
|
|
994
1409
|
}
|
|
995
1410
|
this.#wireThreadStore();
|
|
996
1411
|
this.#wireAttachments();
|
|
@@ -1064,9 +1479,26 @@ export class AgUiChat extends HTMLElement {
|
|
|
1064
1479
|
*/
|
|
1065
1480
|
disconnectedCallback(): void {
|
|
1066
1481
|
this.#connected = false;
|
|
1482
|
+
// Give the namespace back. A disconnect is not necessarily a farewell — a
|
|
1483
|
+
// DOM move and a framework re-render both look like one — and an element
|
|
1484
|
+
// that could not reclaim its own namespace on the way back in would lose
|
|
1485
|
+
// its conversation to a false collision.
|
|
1486
|
+
if (this.#claimedNs !== null) {
|
|
1487
|
+
CLAIMED_NAMESPACES.delete(this.#claimedNs);
|
|
1488
|
+
this.#claimedNs = null;
|
|
1489
|
+
}
|
|
1067
1490
|
this.#cancelRun();
|
|
1491
|
+
// The page offer listens on the host's document, not on anything of ours,
|
|
1492
|
+
// so nothing else would ever take it down.
|
|
1493
|
+
this.#pageQuote?.detach();
|
|
1494
|
+
this.#pageQuote = null;
|
|
1068
1495
|
this.#attachTray?.dispose();
|
|
1069
1496
|
this.#voice?.dispose();
|
|
1497
|
+
this.#scroller.dispose();
|
|
1498
|
+
if (this.#announceTimer !== null) {
|
|
1499
|
+
clearTimeout(this.#announceTimer);
|
|
1500
|
+
this.#announceTimer = null;
|
|
1501
|
+
}
|
|
1070
1502
|
}
|
|
1071
1503
|
|
|
1072
1504
|
/**
|
|
@@ -1143,7 +1575,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1143
1575
|
return (file, onProgress, signal) =>
|
|
1144
1576
|
uploadAttachment(file, {
|
|
1145
1577
|
url,
|
|
1146
|
-
headers: this.#
|
|
1578
|
+
headers: this.#headersFor(url),
|
|
1147
1579
|
...this.#credentialsOption(),
|
|
1148
1580
|
onProgress,
|
|
1149
1581
|
signal,
|
|
@@ -1179,7 +1611,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1179
1611
|
return (audio) =>
|
|
1180
1612
|
transcribeAudio(audio, {
|
|
1181
1613
|
url,
|
|
1182
|
-
headers: this.#
|
|
1614
|
+
headers: this.#headersFor(url),
|
|
1183
1615
|
...this.#credentialsOption(),
|
|
1184
1616
|
});
|
|
1185
1617
|
}
|
|
@@ -1192,6 +1624,126 @@ export class AgUiChat extends HTMLElement {
|
|
|
1192
1624
|
this.#input.focus();
|
|
1193
1625
|
}
|
|
1194
1626
|
|
|
1627
|
+
/**
|
|
1628
|
+
* Put `text` into the composer as a markdown quotation, and focus it.
|
|
1629
|
+
*
|
|
1630
|
+
* Deliberately **not** a send. Quoting is how a question narrows to one part
|
|
1631
|
+
* of an answer, so the quotation is the preamble and the question is what
|
|
1632
|
+
* comes next -- the caret is left after it, on its own line.
|
|
1633
|
+
*
|
|
1634
|
+
* This is also the seam for the half of this feature the component cannot
|
|
1635
|
+
* build: selection in the **host page**. A widget mounted beside a table can
|
|
1636
|
+
* be asked about a row, and nothing in a chat's own transcript can offer
|
|
1637
|
+
* that. A host reads its own selection, however it likes, and calls this.
|
|
1638
|
+
*
|
|
1639
|
+
* No-ops on text that is empty or only whitespace.
|
|
1640
|
+
*/
|
|
1641
|
+
quote(text: string): void {
|
|
1642
|
+
const quoted = asQuote(text);
|
|
1643
|
+
if (quoted === "") {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
// Appended after whatever is already typed, on a fresh paragraph: a second
|
|
1647
|
+
// quotation is a second thing being asked about, not a replacement for the
|
|
1648
|
+
// first. Trailing blank lines are dropped so repeated quoting does not
|
|
1649
|
+
// accumulate gaps.
|
|
1650
|
+
const current = this.#input.value.replace(/\s+$/, "");
|
|
1651
|
+
this.#input.value = current === "" ? quoted : `${current}\n\n${quoted}`;
|
|
1652
|
+
this.#autoGrow();
|
|
1653
|
+
this.#input.focus();
|
|
1654
|
+
const end = this.#input.value.length;
|
|
1655
|
+
this.#input.setSelectionRange(end, end);
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* Offer to quote what the user selects in the **host page**, not just in the
|
|
1660
|
+
* transcript. Returns a function that stops offering.
|
|
1661
|
+
*
|
|
1662
|
+
* The same select-then-offer gesture, over a table, a diff, a report -- the
|
|
1663
|
+
* surface the user actually works in, which is the half of quoting no hosted
|
|
1664
|
+
* chat can reach. Opt-in, because it listens on the host's document and that
|
|
1665
|
+
* is theirs to grant.
|
|
1666
|
+
*
|
|
1667
|
+
* Deliberately **not** a four-line recipe, which is how this shipped first
|
|
1668
|
+
* and was wrong: a page listener that quotes every settled selection appends
|
|
1669
|
+
* to the composer on every drag made to read, to copy or to fix a typo -- and
|
|
1670
|
+
* it cannot tell a selection in the page's prose from one inside the user's
|
|
1671
|
+
* own half-typed `<input>`, because Chrome reports a field's internal
|
|
1672
|
+
* selection as an ordinary range over the field's *wrapper*. See
|
|
1673
|
+
* {@link attachQuoteOffer} for the guards.
|
|
1674
|
+
*
|
|
1675
|
+
* Detached automatically when the element leaves the document; a host that
|
|
1676
|
+
* re-mounts it calls this again.
|
|
1677
|
+
*/
|
|
1678
|
+
offerQuoteInPage(within: HTMLElement = document.body): () => void {
|
|
1679
|
+
this.#pageQuote?.detach();
|
|
1680
|
+
const offer = attachQuoteOffer({
|
|
1681
|
+
within,
|
|
1682
|
+
label: this.#strings.quoteSelection,
|
|
1683
|
+
exclude: this,
|
|
1684
|
+
onQuote: (text) => this.quote(text),
|
|
1685
|
+
});
|
|
1686
|
+
this.#pageQuote = offer;
|
|
1687
|
+
return () => {
|
|
1688
|
+
offer.detach();
|
|
1689
|
+
if (this.#pageQuote === offer) {
|
|
1690
|
+
this.#pageQuote = null;
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
/** Whether the transcript offers to quote what the user selects. */
|
|
1696
|
+
#quoteEnabled(): boolean {
|
|
1697
|
+
return this.getAttribute("data-quote-selection") !== "false";
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/**
|
|
1701
|
+
* Offer to quote the settled selection, or retire the offer.
|
|
1702
|
+
*
|
|
1703
|
+
* `event` is passed for its coordinates and only those: they say which line
|
|
1704
|
+
* of a selection spanning several messages the offer should hang from. A
|
|
1705
|
+
* keyboard selection has none, and the first line is used instead.
|
|
1706
|
+
*/
|
|
1707
|
+
#onSelectionSettled(event?: MouseEvent): void {
|
|
1708
|
+
if (!this.#quoteEnabled()) {
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
const near = event === undefined ? undefined : { x: event.clientX, y: event.clientY };
|
|
1712
|
+
const selected = quotableSelection(this.#messages, [this.#root], near);
|
|
1713
|
+
if (selected === null) {
|
|
1714
|
+
this.#hideQuote();
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
this.#quoting = selected.text;
|
|
1718
|
+
this.#placeQuote(selected.rect);
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
/** Float the offer beside `rect`, kept inside the transcript's own box. */
|
|
1722
|
+
#placeQuote(rect: DOMRect): void {
|
|
1723
|
+
// Unhidden first: a hidden element measures zero, and its own size is what
|
|
1724
|
+
// decides whether it fits above the selection and how far to pull it left.
|
|
1725
|
+
this.#quoteButton.hidden = false;
|
|
1726
|
+
const wrap = this.#messagesWrap.getBoundingClientRect();
|
|
1727
|
+
const top = rect.top - wrap.top;
|
|
1728
|
+
// Above the selection by default, below it when there is no room --
|
|
1729
|
+
// selecting the first line of the transcript is the ordinary case, not an
|
|
1730
|
+
// edge one, and an offer clipped by the header is an offer nobody takes.
|
|
1731
|
+
const below = top < QUOTE_GAP + this.#quoteButton.offsetHeight;
|
|
1732
|
+
this.#quoteButton.dataset["below"] = String(below);
|
|
1733
|
+
this.#quoteButton.style.top = `${below ? rect.bottom - wrap.top + QUOTE_GAP : top - QUOTE_GAP}px`;
|
|
1734
|
+
// Centred on the selection, then pulled back by its own half-width so a
|
|
1735
|
+
// selection at either margin does not push the offer out of the panel.
|
|
1736
|
+
const half = this.#quoteButton.offsetWidth / 2;
|
|
1737
|
+
const centre = rect.left + rect.width / 2 - wrap.left;
|
|
1738
|
+
this.#quoteButton.style.left = `${Math.min(Math.max(centre, half), wrap.width - half)}px`;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/** Retire the offer, and forget what it was pointing at. */
|
|
1742
|
+
#hideQuote(): void {
|
|
1743
|
+
this.#quoteButton.hidden = true;
|
|
1744
|
+
this.#quoting = "";
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1195
1747
|
/** The client-side upload size cap from `data-attachment-max-bytes`. */
|
|
1196
1748
|
#attachmentMaxBytes(): number {
|
|
1197
1749
|
const attr = this.getAttribute("data-attachment-max-bytes");
|
|
@@ -1240,15 +1792,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
1240
1792
|
* delete through that server endpoint (wrapping the current store as the
|
|
1241
1793
|
* client-only fallback), so the history drawer shows durable, cross-device
|
|
1242
1794
|
* threads. Without it, the client store's per-tab threads are used.
|
|
1795
|
+
*
|
|
1796
|
+
* `data-threads-cache="false"` drops the local copy of the message bodies —
|
|
1797
|
+
* for the deployment that pointed history at a server precisely so that
|
|
1798
|
+
* transcripts do not sit in the browser. The client-only concerns (the active
|
|
1799
|
+
* thread id, the navigation checkpoint) keep their local store either way.
|
|
1243
1800
|
*/
|
|
1244
1801
|
#wireThreadStore(): void {
|
|
1245
1802
|
const url = this.getAttribute("data-threads-url");
|
|
1246
1803
|
if (url !== null) {
|
|
1247
1804
|
this.conversationStore = new RemoteConversationStore(
|
|
1248
1805
|
url,
|
|
1249
|
-
() => this.#
|
|
1806
|
+
() => this.#headersFor(url),
|
|
1250
1807
|
this.conversationStore,
|
|
1251
1808
|
() => this.#requestCredentials(),
|
|
1809
|
+
this.getAttribute("data-threads-cache") !== "false",
|
|
1252
1810
|
);
|
|
1253
1811
|
}
|
|
1254
1812
|
}
|
|
@@ -1260,7 +1818,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1260
1818
|
return;
|
|
1261
1819
|
}
|
|
1262
1820
|
try {
|
|
1263
|
-
const response = await fetch(url, this.#fetchInit());
|
|
1821
|
+
const response = await fetch(url, this.#fetchInit(url));
|
|
1264
1822
|
this.#toolCatalog = parseToolCatalog(await response.json());
|
|
1265
1823
|
} catch {
|
|
1266
1824
|
// Network/parse failure: cards fall back to toolSummaries / raw names.
|
|
@@ -1308,7 +1866,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1308
1866
|
return;
|
|
1309
1867
|
}
|
|
1310
1868
|
try {
|
|
1311
|
-
const response = await fetch(url, this.#fetchInit());
|
|
1869
|
+
const response = await fetch(url, this.#fetchInit(url));
|
|
1312
1870
|
this.#backendSkills = parseSkills(await response.json());
|
|
1313
1871
|
this.#recomputeSkills();
|
|
1314
1872
|
} catch {
|
|
@@ -1409,7 +1967,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1409
1967
|
} else {
|
|
1410
1968
|
this.removeAttribute("collapsed");
|
|
1411
1969
|
}
|
|
1412
|
-
|
|
1970
|
+
writeStoredItem(this.#storageKey(COLLAPSED_KEY), collapsed ? "1" : "0");
|
|
1413
1971
|
// Expanding is what marks the waiting answers read; collapsing starts a
|
|
1414
1972
|
// fresh count. Either way the badge is cleared and the host told.
|
|
1415
1973
|
this.#setUnread(0);
|
|
@@ -1446,7 +2004,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1446
2004
|
toggleTheme(): void {
|
|
1447
2005
|
const next = this.getAttribute("theme") === "dark" ? "light" : "dark";
|
|
1448
2006
|
this.setAttribute("theme", next);
|
|
1449
|
-
|
|
2007
|
+
writeStoredItem(this.#storageKey(THEME_KEY), next);
|
|
1450
2008
|
this.#syncThemeGlyph();
|
|
1451
2009
|
}
|
|
1452
2010
|
|
|
@@ -1557,7 +2115,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1557
2115
|
/** Persist a dragged size per tab, alongside the collapsed/theme preferences. */
|
|
1558
2116
|
#persistSize(size: ResizeSize): void {
|
|
1559
2117
|
const stored = { ...this.#readSize(), ...size };
|
|
1560
|
-
|
|
2118
|
+
writeStoredItem(this.#storageKey(SIZE_KEY), JSON.stringify(stored));
|
|
1561
2119
|
}
|
|
1562
2120
|
|
|
1563
2121
|
/** The persisted size for this instance, or an empty record. */
|
|
@@ -1576,6 +2134,130 @@ export class AgUiChat extends HTMLElement {
|
|
|
1576
2134
|
}
|
|
1577
2135
|
}
|
|
1578
2136
|
|
|
2137
|
+
/**
|
|
2138
|
+
* Claim this element's storage namespace: its `id`, else its `endpoint`.
|
|
2139
|
+
*
|
|
2140
|
+
* The endpoint fallback exists so a lone widget restores its conversation
|
|
2141
|
+
* across reloads with nothing asked of the page author. It stops working the
|
|
2142
|
+
* moment there are two of them — a docked support panel and an inline page
|
|
2143
|
+
* assistant against one agent mount, neither carrying an `id`, which nothing
|
|
2144
|
+
* requires — because both resolve to the same string and then share a thread
|
|
2145
|
+
* pointer, a drawer index and every message key. Whichever mounts second
|
|
2146
|
+
* adopts the first's active thread and rehydrates its transcript into its own
|
|
2147
|
+
* panel: one conversation's content inside another, on the same page.
|
|
2148
|
+
*
|
|
2149
|
+
* So the namespace is claimed by the first element to mount under it, and a
|
|
2150
|
+
* second is given one of its own plus a warning naming the fix. The first
|
|
2151
|
+
* element keeps the endpoint namespace, which is what leaves the ordinary
|
|
2152
|
+
* single-element case exactly as it was.
|
|
2153
|
+
*
|
|
2154
|
+
* The generated namespace is random rather than derived from mount order.
|
|
2155
|
+
* That costs the second element its history across reloads — the warning says
|
|
2156
|
+
* so, and an `id` fixes it — which is the honest trade against an order-based
|
|
2157
|
+
* name that would silently hand a stored conversation to whichever element
|
|
2158
|
+
* happened to mount second on the next load.
|
|
2159
|
+
*/
|
|
2160
|
+
#claimNamespace(): string {
|
|
2161
|
+
const preferred = this.id !== "" ? this.id : this.endpoint;
|
|
2162
|
+
// Nothing to key on. The pre-namespacing global keys, as before: an element
|
|
2163
|
+
// with neither an id nor an endpoint cannot send anything, so what it would
|
|
2164
|
+
// be claiming is an empty conversation.
|
|
2165
|
+
if (preferred === "") {
|
|
2166
|
+
return "";
|
|
2167
|
+
}
|
|
2168
|
+
// Already lost this claim once. Keep the fallback rather than drifting back
|
|
2169
|
+
// onto a namespace the other element may since have released, which would
|
|
2170
|
+
// swap this panel's conversation for that one's.
|
|
2171
|
+
if (this.#generatedFor === preferred) {
|
|
2172
|
+
return this.#generatedNs;
|
|
2173
|
+
}
|
|
2174
|
+
if (!CLAIMED_NAMESPACES.has(preferred)) {
|
|
2175
|
+
CLAIMED_NAMESPACES.add(preferred);
|
|
2176
|
+
this.#claimedNs = preferred;
|
|
2177
|
+
return preferred;
|
|
2178
|
+
}
|
|
2179
|
+
this.#generatedFor = preferred;
|
|
2180
|
+
this.#generatedNs = `${preferred}~${randomUUID()}`;
|
|
2181
|
+
console.warn(
|
|
2182
|
+
`<ag-ui-chat>: another element on this page already stores its ` +
|
|
2183
|
+
`conversation under "${preferred}", so this one has been given a ` +
|
|
2184
|
+
"throwaway namespace of its own — the two would otherwise share a " +
|
|
2185
|
+
"thread pointer, a history drawer and every message. Give each " +
|
|
2186
|
+
"<ag-ui-chat> its own id to keep them apart and let this one restore " +
|
|
2187
|
+
"its conversation across reloads.",
|
|
2188
|
+
);
|
|
2189
|
+
return this.#generatedNs;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* The conversation store's namespace: this element's, scoped to the principal
|
|
2194
|
+
* {@link userKey} names.
|
|
2195
|
+
*
|
|
2196
|
+
* Only the conversation is principal-scoped. The panel's own collapsed / size
|
|
2197
|
+
* / theme preferences stay on `#storageNs`, because they are this element's
|
|
2198
|
+
* UI state rather than anyone's data — they carry no word of what was said —
|
|
2199
|
+
* and because they are read once while connecting, so re-scoping them under a
|
|
2200
|
+
* live element would rearrange the panel around a user who had only just
|
|
2201
|
+
* signed in.
|
|
2202
|
+
*/
|
|
2203
|
+
#conversationNs(key: string = this.userKey): string {
|
|
2204
|
+
return key === "" ? this.#storageNs : `${this.#storageNs}#${key}`;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
/**
|
|
2208
|
+
* Move the element's client state from one principal to another.
|
|
2209
|
+
*
|
|
2210
|
+
* The whole reason {@link userKey} is live: `sessionStorage` outlives a
|
|
2211
|
+
* logout, because a logout is a navigation (or, in a single-page app, not
|
|
2212
|
+
* even that) rather than a tab close. Nothing remounts, so the host naming
|
|
2213
|
+
* the new principal is the only signal the element will ever get.
|
|
2214
|
+
*/
|
|
2215
|
+
#changePrincipal(previousKey: string, nextKey: string): void {
|
|
2216
|
+
const previous = this.#conversationNs(previousKey);
|
|
2217
|
+
const next = this.#conversationNs(nextKey);
|
|
2218
|
+
if (previousKey === "") {
|
|
2219
|
+
// Absent to present is not a handover. It is the documented late
|
|
2220
|
+
// configuration shape — the element mounts, an auth handshake resolves,
|
|
2221
|
+
// and only then is the user known — so the conversation already on screen
|
|
2222
|
+
// belongs to this principal and moves with them. Moving rather than
|
|
2223
|
+
// copying also matters: a copy left behind under the unscoped namespace
|
|
2224
|
+
// is a transcript the next key-less mount would happily adopt.
|
|
2225
|
+
SessionStorageStore.adopt(previous, next);
|
|
2226
|
+
this.#rescopeStore(next);
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
SessionStorageStore.purge(previous);
|
|
2230
|
+
this.#rescopeStore(next);
|
|
2231
|
+
// The transcript on screen, the run in flight and the replayed history all
|
|
2232
|
+
// belong to the principal who just left. Purging storage without clearing
|
|
2233
|
+
// these would leave the previous user's conversation visible to the new one.
|
|
2234
|
+
this.#cancelRun();
|
|
2235
|
+
this.#resetState();
|
|
2236
|
+
this.#setRunning(false);
|
|
2237
|
+
this.#setUnread(0);
|
|
2238
|
+
this.#threadId = this.conversationStore.threadId();
|
|
2239
|
+
void this.#rehydrate();
|
|
2240
|
+
void this.#refreshDrawer();
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* Rebuild the `sessionStorage` store under `namespace`, re-wrapping it for
|
|
2245
|
+
* `data-threads-url` exactly as connecting did.
|
|
2246
|
+
*
|
|
2247
|
+
* A store of the host's own kind is left alone: a store that holds its data
|
|
2248
|
+
* somewhere the element cannot see has to scope itself. The transcript on
|
|
2249
|
+
* screen is still cleared either way — the host swapped principals, and that
|
|
2250
|
+
* much is the element's to act on.
|
|
2251
|
+
*/
|
|
2252
|
+
#rescopeStore(namespace: string): void {
|
|
2253
|
+
if (this.#builtinStore === null) {
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
2256
|
+
this.#builtinStore = new SessionStorageStore(namespace);
|
|
2257
|
+
this.conversationStore = this.#builtinStore;
|
|
2258
|
+
this.#wireThreadStore();
|
|
2259
|
+
}
|
|
2260
|
+
|
|
1579
2261
|
/** This instance's namespaced form of an origin-scoped storage key. */
|
|
1580
2262
|
#storageKey(base: string): string {
|
|
1581
2263
|
return this.#storageNs === "" ? base : `${base}:${this.#storageNs}`;
|
|
@@ -1679,7 +2361,22 @@ export class AgUiChat extends HTMLElement {
|
|
|
1679
2361
|
/** Drop the in-memory run + transcript, leaving the thread id untouched. */
|
|
1680
2362
|
#resetState(): void {
|
|
1681
2363
|
this.#client = null;
|
|
1682
|
-
this.#
|
|
2364
|
+
this.#clearTranscript();
|
|
2365
|
+
this.#initialMessages = [];
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* Wipe the rendered transcript and everything that indexes into it.
|
|
2370
|
+
*
|
|
2371
|
+
* Split from {@link #resetState} because a retry re-renders the transcript
|
|
2372
|
+
* while keeping the *client*: dropping the client there would take the
|
|
2373
|
+
* agent's message list with it, which is the thing being truncated.
|
|
2374
|
+
*/
|
|
2375
|
+
#clearTranscript(): void {
|
|
2376
|
+
// Before the transcript goes: a render still queued would otherwise fire
|
|
2377
|
+
// against the wiped list and open a fresh bubble holding the discarded
|
|
2378
|
+
// conversation's last tokens.
|
|
2379
|
+
this.#endStream();
|
|
1683
2380
|
this.#currentGroup = null;
|
|
1684
2381
|
this.#thoughts = null;
|
|
1685
2382
|
this.#hidePending();
|
|
@@ -1687,13 +2384,50 @@ export class AgUiChat extends HTMLElement {
|
|
|
1687
2384
|
this.#serverSettled.clear();
|
|
1688
2385
|
this.#cardElements.clear();
|
|
1689
2386
|
this.#activityBlocks.clear();
|
|
1690
|
-
this.#
|
|
2387
|
+
this.#retryOwner = null;
|
|
1691
2388
|
this.#attachTray?.clear();
|
|
1692
2389
|
// Keep the empty-state region; everything else clears.
|
|
1693
2390
|
this.#messages.replaceChildren(this.#emptyWrap);
|
|
1694
2391
|
this.#updateEmptyState();
|
|
1695
2392
|
}
|
|
1696
2393
|
|
|
2394
|
+
/**
|
|
2395
|
+
* Ask the same question again and replace the answer.
|
|
2396
|
+
*
|
|
2397
|
+
* History is truncated to the most recent user message inclusive and the run
|
|
2398
|
+
* repeats, so the agent answers what it was asked rather than being told its
|
|
2399
|
+
* last answer was wrong. Returns `false` when there is nothing to retry or a
|
|
2400
|
+
* run is already in flight.
|
|
2401
|
+
*
|
|
2402
|
+
* Public because a host with its own message UI wants the same button, and
|
|
2403
|
+
* because the failed-run notice reaches it from outside the action row.
|
|
2404
|
+
*
|
|
2405
|
+
* **A retried turn re-runs its tools**, which for a page-driving agent is not
|
|
2406
|
+
* neutral: the previous attempt already clicked what it clicked, and this
|
|
2407
|
+
* does not undo it. Confirmation still applies, so a destructive tool asks
|
|
2408
|
+
* again -- unless the user waived it for this session.
|
|
2409
|
+
*/
|
|
2410
|
+
async retryLastTurn(): Promise<boolean> {
|
|
2411
|
+
if (this.#running) {
|
|
2412
|
+
return false;
|
|
2413
|
+
}
|
|
2414
|
+
const client = this.#ensureClient();
|
|
2415
|
+
const kept = client.truncateToLastUser();
|
|
2416
|
+
if (kept === null) {
|
|
2417
|
+
return false;
|
|
2418
|
+
}
|
|
2419
|
+
// Re-render between the truncation and the run: the kept turns replay as
|
|
2420
|
+
// restored history (static, no entrance animation), and only the new answer
|
|
2421
|
+
// arrives live. Streaming into the old transcript would put the new answer
|
|
2422
|
+
// underneath the one it replaces.
|
|
2423
|
+
this.#clearTranscript();
|
|
2424
|
+
for (const message of kept) {
|
|
2425
|
+
this.#renderHistoricMessage(message);
|
|
2426
|
+
}
|
|
2427
|
+
await client.resume();
|
|
2428
|
+
return true;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
1697
2431
|
/** Switch the active conversation to an existing thread and replay it. */
|
|
1698
2432
|
async #switchThread(threadId: string): Promise<void> {
|
|
1699
2433
|
if (threadId === this.#threadId) {
|
|
@@ -1724,6 +2458,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
1724
2458
|
|
|
1725
2459
|
/** Reload the drawer's thread list, marking the active thread. */
|
|
1726
2460
|
async #refreshDrawer(): Promise<void> {
|
|
2461
|
+
this.#drawer.setRelativeTimeFormatter(this.formatRelativeTime);
|
|
1727
2462
|
this.#drawer.setThreads(await this.conversationStore.listThreads(), this.#threadId);
|
|
1728
2463
|
}
|
|
1729
2464
|
|
|
@@ -1829,7 +2564,9 @@ export class AgUiChat extends HTMLElement {
|
|
|
1829
2564
|
// transcript mounts at once, so animating every bubble's text in
|
|
1830
2565
|
// parallel looks wrong. Mark it so the fade CSS skips it, and don't
|
|
1831
2566
|
// wrap words.
|
|
1832
|
-
this.appendMessage(MESSAGE_ROLE.ASSISTANT, text)
|
|
2567
|
+
const restoredBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, text);
|
|
2568
|
+
restoredBubble.classList.add("message--restored");
|
|
2569
|
+
this.#attachActions(restoredBubble);
|
|
1833
2570
|
}
|
|
1834
2571
|
// Narrowed rather than trusted, for the same reason `messageAttachments`
|
|
1835
2572
|
// narrows the neighbouring field: anything that throws in this loop aborts
|
|
@@ -1868,8 +2605,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
1868
2605
|
// chart's data is in the transcript already and survives a reload. Only
|
|
1869
2606
|
// the drawing had to be put back.
|
|
1870
2607
|
const activity = message as unknown as { activityType?: unknown; content?: unknown };
|
|
1871
|
-
if (activity.activityType ===
|
|
1872
|
-
this.#
|
|
2608
|
+
if (typeof activity.activityType === "string") {
|
|
2609
|
+
this.#drawActivity(message.id, activity.activityType, activity.content);
|
|
1873
2610
|
}
|
|
1874
2611
|
return;
|
|
1875
2612
|
}
|
|
@@ -1944,7 +2681,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
1944
2681
|
this.#messages.appendChild(bubble);
|
|
1945
2682
|
}
|
|
1946
2683
|
this.#updateEmptyState();
|
|
1947
|
-
|
|
2684
|
+
// A user bubble means someone just pressed Send, which is as deliberate as
|
|
2685
|
+
// pressing the jump button -- so it goes to the bottom even if they had
|
|
2686
|
+
// scrolled away to re-read something before typing.
|
|
2687
|
+
if (role === MESSAGE_ROLE.USER) {
|
|
2688
|
+
this.#scroller.jump();
|
|
2689
|
+
} else {
|
|
2690
|
+
this.#scroller.follow();
|
|
2691
|
+
}
|
|
1948
2692
|
return bubble;
|
|
1949
2693
|
}
|
|
1950
2694
|
|
|
@@ -1968,9 +2712,6 @@ export class AgUiChat extends HTMLElement {
|
|
|
1968
2712
|
}
|
|
1969
2713
|
|
|
1970
2714
|
#render(): void {
|
|
1971
|
-
const style = document.createElement("style");
|
|
1972
|
-
style.textContent = STYLES;
|
|
1973
|
-
|
|
1974
2715
|
this.#chat.className = "chat";
|
|
1975
2716
|
this.#chat.setAttribute("part", "panel");
|
|
1976
2717
|
|
|
@@ -2045,11 +2786,68 @@ export class AgUiChat extends HTMLElement {
|
|
|
2045
2786
|
|
|
2046
2787
|
this.#messages.className = "messages";
|
|
2047
2788
|
this.#messages.setAttribute("part", "messages");
|
|
2048
|
-
// Screen readers announce streamed messages as they arrive.
|
|
2049
2789
|
this.#messages.setAttribute("role", "log");
|
|
2050
|
-
|
|
2790
|
+
// NOT a live region. The streaming bubble's innerHTML is replaced inside
|
|
2791
|
+
// this element on every animation frame, and `role="log"` already implies
|
|
2792
|
+
// polite announcement whose default `aria-relevant` includes text
|
|
2793
|
+
// additions -- so a screen reader was asked to re-announce the whole answer
|
|
2794
|
+
// tens of times as it streamed. `aria-live="off"` is an explicit override
|
|
2795
|
+
// of the role's implicit value, which is why the role can stay: the log
|
|
2796
|
+
// semantics are what let the transcript be navigated as one, and only the
|
|
2797
|
+
// announcing is the defect. Status goes to #announcer instead.
|
|
2798
|
+
this.#messages.setAttribute("aria-live", "off");
|
|
2051
2799
|
this.#messages.setAttribute("aria-label", this.#strings.conversation);
|
|
2052
2800
|
|
|
2801
|
+
this.#jumpButton.className = "jump-latest";
|
|
2802
|
+
this.#jumpButton.type = "button";
|
|
2803
|
+
this.#jumpButton.setAttribute("part", "jump-latest");
|
|
2804
|
+
this.#jumpButton.textContent = this.#strings.jumpToLatest;
|
|
2805
|
+
this.#jumpButton.addEventListener("click", () => {
|
|
2806
|
+
this.#scroller.jump();
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
this.#quoteButton.className = "quote-selection";
|
|
2810
|
+
this.#quoteButton.type = "button";
|
|
2811
|
+
this.#quoteButton.setAttribute("part", "quote-selection");
|
|
2812
|
+
this.#quoteButton.textContent = this.#strings.quoteSelection;
|
|
2813
|
+
this.#quoteButton.hidden = true;
|
|
2814
|
+
// `mousedown` rather than `click`: pressing anywhere else collapses the
|
|
2815
|
+
// selection first, and by the time a click lands there is nothing left to
|
|
2816
|
+
// quote. Preventing the default keeps the selection alive long enough to
|
|
2817
|
+
// read it.
|
|
2818
|
+
this.#quoteButton.addEventListener("mousedown", (event) => {
|
|
2819
|
+
event.preventDefault();
|
|
2820
|
+
});
|
|
2821
|
+
this.#quoteButton.addEventListener("click", () => {
|
|
2822
|
+
this.quote(this.#quoting);
|
|
2823
|
+
window.getSelection()?.removeAllRanges();
|
|
2824
|
+
this.#hideQuote();
|
|
2825
|
+
});
|
|
2826
|
+
|
|
2827
|
+
// A settled selection, by either input. `mouseup` rather than
|
|
2828
|
+
// `selectionchange` so the offer does not chase the pointer mid-drag; the
|
|
2829
|
+
// second half of the same gesture, `mousedown`, retires the previous offer
|
|
2830
|
+
// before the new selection exists.
|
|
2831
|
+
this.#messages.addEventListener("mouseup", (event) => this.#onSelectionSettled(event));
|
|
2832
|
+
this.#messages.addEventListener("keyup", () => this.#onSelectionSettled());
|
|
2833
|
+
this.#messages.addEventListener("mousedown", () => this.#hideQuote());
|
|
2834
|
+
|
|
2835
|
+
// Built here rather than at field initialisation: the viewport has to exist
|
|
2836
|
+
// and the observer has to have something to observe.
|
|
2837
|
+
this.#scroller = createStickToBottom({
|
|
2838
|
+
viewport: this.#messages,
|
|
2839
|
+
onMissedContent: (missed) => {
|
|
2840
|
+
this.#jumpButton.dataset["missed"] = String(missed);
|
|
2841
|
+
},
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
this.#announcer.className = "sr-only";
|
|
2845
|
+
this.#announcer.setAttribute("role", "status");
|
|
2846
|
+
this.#announcer.setAttribute("aria-live", "polite");
|
|
2847
|
+
// Atomic: each announcement replaces the last and is read whole. Without
|
|
2848
|
+
// it a reader may announce only the changed words between two statuses.
|
|
2849
|
+
this.#announcer.setAttribute("aria-atomic", "true");
|
|
2850
|
+
|
|
2053
2851
|
// Empty-state region: a host slot at the top of the list, hidden as soon as
|
|
2054
2852
|
// anything renders.
|
|
2055
2853
|
this.#emptyWrap.className = "empty";
|
|
@@ -2140,9 +2938,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2140
2938
|
inputRow.append(composer, this.#fileInput);
|
|
2141
2939
|
// Skill surfaces sit just above the input: palette (opens on `/`), chips,
|
|
2142
2940
|
// the missing-placeholder hint, and the pending-attachments tray.
|
|
2941
|
+
this.#messagesWrap.className = "messages-wrap";
|
|
2942
|
+
// Sibling of the list inside a shared box, not a child of it: the
|
|
2943
|
+
// affordance offering to scroll must not scroll away with the content.
|
|
2944
|
+
this.#messagesWrap.append(this.#messages, this.#jumpButton, this.#quoteButton);
|
|
2945
|
+
|
|
2143
2946
|
this.#chat.append(
|
|
2144
2947
|
header,
|
|
2145
|
-
this.#
|
|
2948
|
+
this.#messagesWrap,
|
|
2146
2949
|
this.#skillsMenu.palette,
|
|
2147
2950
|
this.#skillsMenu.chips,
|
|
2148
2951
|
this.#skillHint,
|
|
@@ -2207,7 +3010,68 @@ export class AgUiChat extends HTMLElement {
|
|
|
2207
3010
|
label: this.#strings.resizePanel,
|
|
2208
3011
|
}),
|
|
2209
3012
|
);
|
|
2210
|
-
this.#
|
|
3013
|
+
this.#adoptStyles();
|
|
3014
|
+
this.#root.append(this.#announcer, this.#chat, this.#launcher);
|
|
3015
|
+
}
|
|
3016
|
+
|
|
3017
|
+
/**
|
|
3018
|
+
* Attach the stylesheet without an inline `<style>` element.
|
|
3019
|
+
*
|
|
3020
|
+
* A host with a strict `style-src` and no `'unsafe-inline'` drops an injected
|
|
3021
|
+
* `<style>` silently: the component mounts, functions, and renders completely
|
|
3022
|
+
* unstyled, with nothing in the console to point at. `adoptedStyleSheets`
|
|
3023
|
+
* carries no inline-style origin, so it is unaffected by that policy.
|
|
3024
|
+
*
|
|
3025
|
+
* The sheet is constructed **per instance** rather than shared at module
|
|
3026
|
+
* scope. A shared sheet would additionally avoid re-parsing the stylesheet
|
|
3027
|
+
* once per mounted element, which is what `adoptedStyleSheets` is usually
|
|
3028
|
+
* reached for -- but a module-level singleton is exactly what this package
|
|
3029
|
+
* forbids, and the CSP defect is fixed either way. Per instance is no worse
|
|
3030
|
+
* than the `<style>` element it replaces, which also parsed once per mount.
|
|
3031
|
+
*
|
|
3032
|
+
* No fallback: constructible `CSSStyleSheet` is Chrome 73, Firefox 101 and
|
|
3033
|
+
* Safari 16.4, all below this package's declared Safari 17 runtime target. A
|
|
3034
|
+
* guard here would be code no supported browser can reach, and the only way
|
|
3035
|
+
* to keep it would be to exempt it from the coverage gate.
|
|
3036
|
+
*/
|
|
3037
|
+
/**
|
|
3038
|
+
* Say one short thing to a screen reader, without touching the transcript.
|
|
3039
|
+
*
|
|
3040
|
+
* The transcript cannot do this job. It is rewritten on every animation
|
|
3041
|
+
* frame while an answer streams, so as a live region it re-announced the
|
|
3042
|
+
* whole answer tens of times per turn -- not merely unhelpful but actively
|
|
3043
|
+
* hostile. The published fix for this exact bug (Microsoft's Bot Framework
|
|
3044
|
+
* WebChat #3236) is architectural rather than a matter of tuning attributes:
|
|
3045
|
+
* demote the visible transcript out of live-region duty and put one
|
|
3046
|
+
* synthesised status per event into a separate invisible region. MDN and
|
|
3047
|
+
* Scott O'Hara prescribe the same empty-region-then-inject shape.
|
|
3048
|
+
*
|
|
3049
|
+
* Roughly four calls land per turn -- responding, answered, a card is waiting,
|
|
3050
|
+
* stopped or failed -- so the user is told what happened and reads the answer
|
|
3051
|
+
* itself by navigating the log, at their own pace, rather than having it
|
|
3052
|
+
* shouted at them a token at a time.
|
|
3053
|
+
*
|
|
3054
|
+
* **The clear is load-bearing, twice.** A reader announces a live region when
|
|
3055
|
+
* its content *changes*, so setting the same string twice running -- two turns
|
|
3056
|
+
* in a row both starting -- is not a change and is silently not announced.
|
|
3057
|
+
* Emptying first makes the next set a change again. It also stops a stale
|
|
3058
|
+
* status being read out when a reader later lands on the region.
|
|
3059
|
+
*/
|
|
3060
|
+
#announce(message: string): void {
|
|
3061
|
+
if (this.#announceTimer !== null) {
|
|
3062
|
+
clearTimeout(this.#announceTimer);
|
|
3063
|
+
}
|
|
3064
|
+
this.#announcer.textContent = message;
|
|
3065
|
+
this.#announceTimer = setTimeout(() => {
|
|
3066
|
+
this.#announceTimer = null;
|
|
3067
|
+
this.#announcer.textContent = "";
|
|
3068
|
+
}, ANNOUNCE_CLEAR_MS);
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
#adoptStyles(): void {
|
|
3072
|
+
const sheet = new CSSStyleSheet();
|
|
3073
|
+
sheet.replaceSync(STYLES);
|
|
3074
|
+
this.#root.adoptedStyleSheets = [sheet];
|
|
2211
3075
|
}
|
|
2212
3076
|
|
|
2213
3077
|
/**
|
|
@@ -2534,6 +3398,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2534
3398
|
// token must still reach every request — the factory's fetch wrapper
|
|
2535
3399
|
// re-reads this on each call.
|
|
2536
3400
|
getHeaders: () => this.#requestHeaders(),
|
|
3401
|
+
trustedOrigins: this.trustedOrigins,
|
|
2537
3402
|
...this.#credentialsOption(),
|
|
2538
3403
|
threadId: this.#threadId,
|
|
2539
3404
|
initialMessages: this.#initialMessages,
|
|
@@ -2542,7 +3407,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2542
3407
|
this.#client = new AgUiClient({
|
|
2543
3408
|
agent,
|
|
2544
3409
|
handlers: this.#handlers(),
|
|
2545
|
-
getTools: () => this
|
|
3410
|
+
getTools: () => this.#advertiseTools(),
|
|
2546
3411
|
getContext: () => this.#buildContext(),
|
|
2547
3412
|
executeTool: (call) => this.#executeTool(call),
|
|
2548
3413
|
resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
|
|
@@ -2566,15 +3431,75 @@ export class AgUiChat extends HTMLElement {
|
|
|
2566
3431
|
);
|
|
2567
3432
|
}
|
|
2568
3433
|
|
|
2569
|
-
/**
|
|
2570
|
-
|
|
3434
|
+
/**
|
|
3435
|
+
* Give a finished assistant bubble its action row, and hand it Retry.
|
|
3436
|
+
*
|
|
3437
|
+
* Every finished bubble gets copy and feedback -- both are safe on a message
|
|
3438
|
+
* of any age. Retry moves to the newest, because it is the only one where
|
|
3439
|
+
* re-running answers the same question rather than rewriting history.
|
|
3440
|
+
*/
|
|
3441
|
+
#attachActions(bubble: HTMLDivElement, options: { rateable?: boolean } = {}): void {
|
|
3442
|
+
attachMessageActions(bubble, {
|
|
3443
|
+
strings: this.#strings,
|
|
3444
|
+
// Read at click time, not captured: a bubble rendered from markdown holds
|
|
3445
|
+
// its text in the DOM, and that is what the user sees and means to copy.
|
|
3446
|
+
text: () => bubble.textContent as string,
|
|
3447
|
+
// A failed run is copyable -- error text is what people paste into a bug
|
|
3448
|
+
// report -- but not rateable: a rating is a statement about an *answer*,
|
|
3449
|
+
// and mixing "the connection dropped" into that signal makes the host's
|
|
3450
|
+
// feedback data say less than it did before.
|
|
3451
|
+
...(options.rateable === false
|
|
3452
|
+
? {}
|
|
3453
|
+
: {
|
|
3454
|
+
onFeedback: (rating: "up" | "down") => {
|
|
3455
|
+
this.dispatchEvent(
|
|
3456
|
+
new CustomEvent<FeedbackDetail>(FEEDBACK_EVENT, {
|
|
3457
|
+
detail: { content: bubble.textContent as string, rating },
|
|
3458
|
+
bubbles: true,
|
|
3459
|
+
composed: true,
|
|
3460
|
+
}),
|
|
3461
|
+
);
|
|
3462
|
+
},
|
|
3463
|
+
}),
|
|
3464
|
+
});
|
|
3465
|
+
this.#moveRetryTo(messageActionBar(bubble, this.#strings));
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
/** Move the Retry button onto `bar`, taking it off whoever held it. */
|
|
3469
|
+
#moveRetryTo(bar: HTMLElement): void {
|
|
3470
|
+
this.#retryOwner?.querySelector(".message-action--retry")?.remove();
|
|
3471
|
+
const retry = messageActionButton("retry", this.#strings.retryMessage, "\u21BB");
|
|
3472
|
+
retry.addEventListener("click", () => {
|
|
3473
|
+
void this.retryLastTurn();
|
|
3474
|
+
});
|
|
3475
|
+
// First in the row: it is the action a reader reaches for when the answer
|
|
3476
|
+
// was wrong, which is when they are least inclined to hunt for a control.
|
|
3477
|
+
bar.prepend(retry);
|
|
3478
|
+
this.#retryOwner = bar;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
/**
|
|
3482
|
+
* Which rule gates `call`, or `null` when it runs straight through.
|
|
3483
|
+
*
|
|
3484
|
+
* The rule, rather than a bare boolean, because it decides whether the user
|
|
3485
|
+
* may *waive* the prompt for the rest of the session. Only the default
|
|
3486
|
+
* `x-destructive` gate is waivable: `confirmPredicate` is documented as
|
|
3487
|
+
* authoritative, so letting one click retire it would silently defeat a host
|
|
3488
|
+
* policy — and the session allowlist is consulted on the same path it can
|
|
3489
|
+
* be added from, so the button is never offered where honouring it would be
|
|
3490
|
+
* refused.
|
|
3491
|
+
*/
|
|
3492
|
+
async #confirmationRule(call: AgUiToolCall, tool: ClientTool): Promise<ConfirmationRule | null> {
|
|
2571
3493
|
if (this.autoConfirm) {
|
|
2572
|
-
return
|
|
3494
|
+
return null;
|
|
2573
3495
|
}
|
|
2574
3496
|
if (this.confirmPredicate !== null) {
|
|
2575
|
-
return (await this.confirmPredicate(call.name, call.args)) === true;
|
|
3497
|
+
return (await this.confirmPredicate(call.name, call.args)) === true ? "predicate" : null;
|
|
3498
|
+
}
|
|
3499
|
+
if (this.#sessionApproved.has(call.name)) {
|
|
3500
|
+
return null;
|
|
2576
3501
|
}
|
|
2577
|
-
return isDestructive(tool.parameters);
|
|
3502
|
+
return isDestructive(tool.parameters) ? "destructive" : null;
|
|
2578
3503
|
}
|
|
2579
3504
|
|
|
2580
3505
|
async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
|
|
@@ -2590,7 +3515,15 @@ export class AgUiChat extends HTMLElement {
|
|
|
2590
3515
|
// transcript places itself against its own card, and by the time it runs the
|
|
2591
3516
|
// card is no longer reachable by id.
|
|
2592
3517
|
this.#cardElements.set(call.id, card.element);
|
|
2593
|
-
|
|
3518
|
+
// Scoped out of this round's catalog ⇒ not a frontend tool of ours, for
|
|
3519
|
+
// this round. A host that offers `delete_record` only on the page where
|
|
3520
|
+
// deleting makes sense has said something about *this* run, and a call
|
|
3521
|
+
// arriving anyway (a hallucinated name, or one steered by text the model
|
|
3522
|
+
// just read) must not find the handler that happens to be registered
|
|
3523
|
+
// mount-wide. Treated exactly as an unknown name rather than as a refusal:
|
|
3524
|
+
// withholding a tool and never registering it are the same statement, and
|
|
3525
|
+
// the branch below already says the honest thing for both.
|
|
3526
|
+
const tool = this.#advertisedTools.has(call.name) ? this.#resolveTool(call.name) : null;
|
|
2594
3527
|
if (tool === null) {
|
|
2595
3528
|
// Not a client tool. A server-side tool's real output arrives via
|
|
2596
3529
|
// `onToolResult` (TOOL_CALL_RESULT) and already settled the card — only
|
|
@@ -2625,7 +3558,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
2625
3558
|
this.#showPending();
|
|
2626
3559
|
return { content: `Error: ${message}`, error: message };
|
|
2627
3560
|
}
|
|
2628
|
-
|
|
3561
|
+
const rule = await this.#confirmationRule(call, tool);
|
|
3562
|
+
if (rule !== null) {
|
|
2629
3563
|
const request: ConfirmationRequest = { toolName: call.name, args: call.args };
|
|
2630
3564
|
const confirmText = tool.parameters[X_CONFIRM_KEY];
|
|
2631
3565
|
if (typeof confirmText === "string") {
|
|
@@ -2641,9 +3575,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2641
3575
|
const decision = requestConfirmation(this.#ensureGroup(), request, {
|
|
2642
3576
|
signal: this.#confirmAbort.signal,
|
|
2643
3577
|
strings: this.#strings,
|
|
3578
|
+
// Offered only where it can be honoured -- see `#confirmationRule`.
|
|
3579
|
+
...(rule === "destructive"
|
|
3580
|
+
? { onAlwaysAllow: () => this.#sessionApproved.add(call.name) }
|
|
3581
|
+
: {}),
|
|
2644
3582
|
});
|
|
2645
3583
|
this.#updateEmptyState();
|
|
2646
|
-
this.#
|
|
3584
|
+
this.#scroller.follow();
|
|
2647
3585
|
const accepted = await decision;
|
|
2648
3586
|
this.#confirmAbort = null;
|
|
2649
3587
|
card.recordDecision(accepted ? "approved" : "declined");
|
|
@@ -2685,6 +3623,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2685
3623
|
// The navigation never happened; drop the dangling checkpoint.
|
|
2686
3624
|
this.conversationStore.saveCheckpoint(this.#threadId, null);
|
|
2687
3625
|
}
|
|
3626
|
+
// The handler's own message, verbatim, in two places at once: the card,
|
|
3627
|
+
// which the user sees, and the tool result, which goes to the endpoint,
|
|
3628
|
+
// is persisted there and is replayed to the model on every later round.
|
|
3629
|
+
// Kept verbatim because a real reason is what lets the agent recover —
|
|
3630
|
+
// and said out loud on `registerTool`, because the second destination is
|
|
3631
|
+
// invisible from the host's side and is not one it can take back.
|
|
2688
3632
|
const message = error instanceof Error ? error.message : String(error);
|
|
2689
3633
|
card.settle(TOOL_CALL_STATUS.ERROR, message);
|
|
2690
3634
|
this.#showPending();
|
|
@@ -2719,6 +3663,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2719
3663
|
): Promise<Record<string, InterruptResponse>> {
|
|
2720
3664
|
// One controller covers the whole batch: a single Stop denies all of them.
|
|
2721
3665
|
this.#confirmAbort = new AbortController();
|
|
3666
|
+
// The run has stopped and is waiting on a person. Nothing else on screen
|
|
3667
|
+
// says so to a screen reader: the cards appear inside the transcript, which
|
|
3668
|
+
// is deliberately not a live region, so without this the run simply goes
|
|
3669
|
+
// quiet and the user has no reason to go looking.
|
|
3670
|
+
this.#announce(
|
|
3671
|
+
this.#strings.announceAwaitingDecision.replace("{count}", String(interrupts.length)),
|
|
3672
|
+
);
|
|
2722
3673
|
this.#hidePending();
|
|
2723
3674
|
const signal = this.#confirmAbort.signal;
|
|
2724
3675
|
const answered = await Promise.all(
|
|
@@ -2736,6 +3687,14 @@ export class AgUiChat extends HTMLElement {
|
|
|
2736
3687
|
if (toolName !== null && toolName !== undefined) {
|
|
2737
3688
|
request.toolName = toolName;
|
|
2738
3689
|
}
|
|
3690
|
+
// Offered only where it can be honoured: the host has said its agent
|
|
3691
|
+
// accepts `editedArgs`, and this interrupt named a call whose arguments
|
|
3692
|
+
// we still hold.
|
|
3693
|
+
let editedArgs: Record<string, unknown> | undefined;
|
|
3694
|
+
const editable = this.approveWithEdits && card !== undefined;
|
|
3695
|
+
if (editable) {
|
|
3696
|
+
request.args = card.args;
|
|
3697
|
+
}
|
|
2739
3698
|
card?.mark(TOOL_CALL_STATUS.DEFERRED);
|
|
2740
3699
|
// A host-supplied renderer takes full control of the approval UI. The
|
|
2741
3700
|
// built-in card renders into the gated call's own card, falling back to
|
|
@@ -2746,6 +3705,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
2746
3705
|
: await requestApproval(card?.approvalSlot ?? this.#ensureGroup(), request, {
|
|
2747
3706
|
signal,
|
|
2748
3707
|
strings: this.#strings,
|
|
3708
|
+
...(editable
|
|
3709
|
+
? {
|
|
3710
|
+
onEdit: (args: Record<string, unknown>) => {
|
|
3711
|
+
editedArgs = args;
|
|
3712
|
+
},
|
|
3713
|
+
}
|
|
3714
|
+
: {}),
|
|
2749
3715
|
});
|
|
2750
3716
|
// Same annotation as the client-side confirmation gate. Without it the
|
|
2751
3717
|
// two gates read differently for the same act: a locally-confirmed call
|
|
@@ -2760,16 +3726,21 @@ export class AgUiChat extends HTMLElement {
|
|
|
2760
3726
|
// now rather than leaving it hanging until the onSettled sweep.
|
|
2761
3727
|
card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
|
|
2762
3728
|
}
|
|
2763
|
-
return { id: interrupt.id, approved };
|
|
3729
|
+
return { id: interrupt.id, approved, editedArgs };
|
|
2764
3730
|
}),
|
|
2765
3731
|
);
|
|
2766
3732
|
this.#updateEmptyState();
|
|
2767
|
-
this.#
|
|
3733
|
+
this.#scroller.follow();
|
|
2768
3734
|
this.#confirmAbort = null;
|
|
2769
3735
|
const responses: Record<string, InterruptResponse> = {};
|
|
2770
|
-
for (const { id, approved } of answered) {
|
|
3736
|
+
for (const { id, approved, editedArgs } of answered) {
|
|
3737
|
+
// `editedArgs` rides only when the user actually changed something, so a
|
|
3738
|
+
// server can tell "approved as proposed" from "approved, but like this".
|
|
2771
3739
|
responses[id] = approved
|
|
2772
|
-
? {
|
|
3740
|
+
? {
|
|
3741
|
+
status: "resolved",
|
|
3742
|
+
payload: editedArgs === undefined ? { approved: true } : { approved: true, editedArgs },
|
|
3743
|
+
}
|
|
2773
3744
|
: { status: "cancelled" };
|
|
2774
3745
|
}
|
|
2775
3746
|
return responses;
|
|
@@ -2778,6 +3749,12 @@ export class AgUiChat extends HTMLElement {
|
|
|
2778
3749
|
#handlers(): AgUiClientHandlers {
|
|
2779
3750
|
return {
|
|
2780
3751
|
onRunStart: () => {
|
|
3752
|
+
// Per *round*, so guard on the turn: a run that calls three tools fires
|
|
3753
|
+
// this three times and the user needs telling once.
|
|
3754
|
+
if (!this.#running) {
|
|
3755
|
+
this.#announcedOutcome = false;
|
|
3756
|
+
this.#announce(this.#strings.announceResponding);
|
|
3757
|
+
}
|
|
2781
3758
|
this.#setRunning(true);
|
|
2782
3759
|
// Open the answer group on the turn's first run so the pending
|
|
2783
3760
|
// indicator (and everything after) lands inside the well. Idempotent:
|
|
@@ -2802,7 +3779,10 @@ export class AgUiChat extends HTMLElement {
|
|
|
2802
3779
|
this.#hidePending();
|
|
2803
3780
|
// The answer has begun — fold the thoughts away so they don't crowd it.
|
|
2804
3781
|
this.#thoughts?.collapse();
|
|
2805
|
-
this.#
|
|
3782
|
+
this.#queueStream(buffer);
|
|
3783
|
+
// Counted per delta received, not per render: the word reveal asks
|
|
3784
|
+
// whether the answer *arrived* progressively, which coalescing renders
|
|
3785
|
+
// must not change the answer to.
|
|
2806
3786
|
this.#streamDeltas += 1;
|
|
2807
3787
|
},
|
|
2808
3788
|
onTextEnd: (buffer) => {
|
|
@@ -2815,7 +3795,8 @@ export class AgUiChat extends HTMLElement {
|
|
|
2815
3795
|
this.#revealWords(bubble);
|
|
2816
3796
|
}
|
|
2817
3797
|
attachCopyButtons(bubble, this.#strings);
|
|
2818
|
-
this.#
|
|
3798
|
+
this.#attachActions(bubble);
|
|
3799
|
+
this.#endStream();
|
|
2819
3800
|
this.#noteUnread();
|
|
2820
3801
|
},
|
|
2821
3802
|
onToolCall: (call) => {
|
|
@@ -2834,25 +3815,47 @@ export class AgUiChat extends HTMLElement {
|
|
|
2834
3815
|
this.#cardFor(call);
|
|
2835
3816
|
},
|
|
2836
3817
|
onActivity: (activityType, content, messageId) => {
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
}
|
|
2843
|
-
if (activityType !== COMPACTION_ACTIVITY_TYPE) {
|
|
2844
|
-
return;
|
|
2845
|
-
}
|
|
2846
|
-
const removed = compactionRemoved(content);
|
|
2847
|
-
if (removed === null) {
|
|
3818
|
+
this.#drawActivity(messageId, activityType, content);
|
|
3819
|
+
},
|
|
3820
|
+
onCustomEvent: (name, value) => {
|
|
3821
|
+
if (name === INVALIDATE_CUSTOM_NAME) {
|
|
3822
|
+
this.#dispatchInvalidation(value);
|
|
2848
3823
|
return;
|
|
2849
3824
|
}
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
3825
|
+
// Straight out to the host page, uninterpreted. This is the imperative
|
|
3826
|
+
// carrier: whatever it means, it means it to the page, not to the
|
|
3827
|
+
// transcript -- so it is dispatched and deliberately not rendered,
|
|
3828
|
+
// persisted or replayed. A host that does not know the name simply has
|
|
3829
|
+
// no listener, which is the graceful outcome the open field is for.
|
|
3830
|
+
this.dispatchEvent(
|
|
3831
|
+
new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
|
|
3832
|
+
detail: { name, value },
|
|
3833
|
+
bubbles: true,
|
|
3834
|
+
composed: true,
|
|
3835
|
+
}),
|
|
2854
3836
|
);
|
|
2855
3837
|
},
|
|
3838
|
+
onMessagesSnapshot: () => {
|
|
3839
|
+
// Honoured for persistence and announced, not re-rendered.
|
|
3840
|
+
//
|
|
3841
|
+
// The store follows the server, because the server is authoritative
|
|
3842
|
+
// about what the conversation *is* -- and it would follow it anyway:
|
|
3843
|
+
// `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
|
|
3844
|
+
// and the run loop persists `agent.messages`. What was wrong was that
|
|
3845
|
+
// it happened in silence, so the screen and the store disagreed and
|
|
3846
|
+
// nobody found out until a reload served a transcript they had never
|
|
3847
|
+
// seen. That is not reportable as a bug; it is reportable as "the chat
|
|
3848
|
+
// lost my messages".
|
|
3849
|
+
//
|
|
3850
|
+
// Re-rendering from the snapshot was the other candidate and is
|
|
3851
|
+
// declined: a snapshot can land mid-run, and rebuilding the transcript
|
|
3852
|
+
// then would destroy the in-flight run's own UI state -- the streaming
|
|
3853
|
+
// bubble, the open answer group, and every tool card keyed by call id,
|
|
3854
|
+
// some of which are still waiting on results. Telling the reader costs
|
|
3855
|
+
// none of that, and this is the same answer the same question already
|
|
3856
|
+
// got for compaction, one handler up.
|
|
3857
|
+
this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
|
|
3858
|
+
},
|
|
2856
3859
|
onToolResult: (toolCallId, content) => {
|
|
2857
3860
|
const card = this.#toolCards.get(toolCallId);
|
|
2858
3861
|
if (card === undefined) {
|
|
@@ -2876,33 +3879,50 @@ export class AgUiChat extends HTMLElement {
|
|
|
2876
3879
|
this.#showPending();
|
|
2877
3880
|
},
|
|
2878
3881
|
onActivityChanged: (messageId, activityType, content) => {
|
|
2879
|
-
|
|
2880
|
-
this.#drawActivityChart(messageId, content);
|
|
2881
|
-
}
|
|
3882
|
+
this.#drawActivity(messageId, activityType, content);
|
|
2882
3883
|
},
|
|
2883
3884
|
onRunEnd: () => {
|
|
2884
3885
|
// Per-round end; the button stays on Stop until the whole interaction
|
|
2885
3886
|
// settles — the user must be able to cancel between tool rounds.
|
|
2886
3887
|
this.#hidePending();
|
|
2887
|
-
this.#
|
|
3888
|
+
this.#endStream();
|
|
2888
3889
|
},
|
|
2889
3890
|
onError: (message) => {
|
|
3891
|
+
this.#announcedOutcome = true;
|
|
3892
|
+
this.#announce(this.#strings.announceFailed);
|
|
2890
3893
|
this.#hidePending();
|
|
2891
|
-
this
|
|
2892
|
-
|
|
3894
|
+
const bubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
|
|
3895
|
+
bubble.classList.add("message--failed");
|
|
3896
|
+
// A failure is the one message whose action row is only worth having
|
|
3897
|
+
// for Retry: there is nothing here worth copying and nothing to rate.
|
|
3898
|
+
// A dropped connection with no way back was the whole of the gap --
|
|
3899
|
+
// uploads had a retry and runs did not.
|
|
3900
|
+
//
|
|
3901
|
+
// Not a `run-notice`: that element's contract is that it "never
|
|
3902
|
+
// settles, takes no action, and carries no controls", and is explicitly
|
|
3903
|
+
// "distinct from an error, which is a failure". This is a failure, so
|
|
3904
|
+
// it stays an error and gains the control instead.
|
|
3905
|
+
this.#attachActions(bubble, { rateable: false });
|
|
3906
|
+
this.#revealWords(bubble);
|
|
3907
|
+
this.#endStream();
|
|
2893
3908
|
},
|
|
2894
3909
|
onCancelled: () => {
|
|
2895
3910
|
// Deliberate stop, not a failure: keep whatever partial text already
|
|
2896
3911
|
// streamed and add a muted note instead of an error bubble.
|
|
3912
|
+
this.#announcedOutcome = true;
|
|
3913
|
+
this.#announce(this.#strings.announceStopped);
|
|
2897
3914
|
this.#hidePending();
|
|
2898
3915
|
this.#appendStoppedNote();
|
|
2899
|
-
this.#
|
|
3916
|
+
this.#endStream();
|
|
2900
3917
|
},
|
|
2901
3918
|
onSettled: () => {
|
|
2902
3919
|
// Terminal guarantee: whatever path ended the run, return to rest.
|
|
3920
|
+
if (!this.#announcedOutcome) {
|
|
3921
|
+
this.#announce(this.#strings.announceAnswerReady);
|
|
3922
|
+
}
|
|
2903
3923
|
this.#hidePending();
|
|
2904
3924
|
this.#setRunning(false);
|
|
2905
|
-
this.#
|
|
3925
|
+
this.#endStream();
|
|
2906
3926
|
// Belt-and-suspenders: a tool card still pending at settle (e.g. a
|
|
2907
3927
|
// server tool whose result never streamed because the connection
|
|
2908
3928
|
// dropped) would hang forever — settle it to the no-result fallback.
|
|
@@ -2940,9 +3960,48 @@ export class AgUiChat extends HTMLElement {
|
|
|
2940
3960
|
side: this.#serverSettled.has(id) ? "server" : "client",
|
|
2941
3961
|
}));
|
|
2942
3962
|
this.#runTools = [];
|
|
3963
|
+
const invalidated = [...this.#runInvalidated];
|
|
3964
|
+
this.#runInvalidated = new Set<string>();
|
|
2943
3965
|
this.dispatchEvent(
|
|
2944
3966
|
new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
|
|
2945
|
-
detail: { tools },
|
|
3967
|
+
detail: { tools, invalidated },
|
|
3968
|
+
bubbles: true,
|
|
3969
|
+
composed: true,
|
|
3970
|
+
}),
|
|
3971
|
+
);
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
/**
|
|
3975
|
+
* Route one invalidation to the host, and remember it for the run summary.
|
|
3976
|
+
*
|
|
3977
|
+
* Dispatched immediately rather than only at the end, because that is what
|
|
3978
|
+
* makes a long multi-step run feel live -- the list refreshes as the third of
|
|
3979
|
+
* eight writes lands. The accumulated set rides
|
|
3980
|
+
* {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
|
|
3981
|
+
* upgrades by reading one extra field instead of adding a listener.
|
|
3982
|
+
*
|
|
3983
|
+
* Nothing is rendered, persisted or replayed. An invalidation is an
|
|
3984
|
+
* imperative: it has no place in the transcript and no meaning once acted on,
|
|
3985
|
+
* and replaying one on every thread load would be a refetch storm. That is the
|
|
3986
|
+
* whole reason the server sends it as `CUSTOM` rather than as an activity.
|
|
3987
|
+
*/
|
|
3988
|
+
#dispatchInvalidation(value: unknown): void {
|
|
3989
|
+
const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
|
|
3990
|
+
// Defensive about the payload, not about the name: `value` is typed
|
|
3991
|
+
// `unknown` by the protocol, so a server can put anything there, and a
|
|
3992
|
+
// malformed announcement must not take the run down with it.
|
|
3993
|
+
const keys = Array.isArray(payload.keys)
|
|
3994
|
+
? payload.keys.filter((key): key is string => typeof key === "string")
|
|
3995
|
+
: [];
|
|
3996
|
+
if (keys.length === 0) {
|
|
3997
|
+
return;
|
|
3998
|
+
}
|
|
3999
|
+
for (const key of keys) {
|
|
4000
|
+
this.#runInvalidated.add(key);
|
|
4001
|
+
}
|
|
4002
|
+
this.dispatchEvent(
|
|
4003
|
+
new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
|
|
4004
|
+
detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
|
|
2946
4005
|
bubbles: true,
|
|
2947
4006
|
composed: true,
|
|
2948
4007
|
}),
|
|
@@ -2958,7 +4017,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2958
4017
|
note.textContent = this.#strings.stopped;
|
|
2959
4018
|
this.#ensureGroup().appendChild(note);
|
|
2960
4019
|
this.#updateEmptyState();
|
|
2961
|
-
this.#
|
|
4020
|
+
this.#scroller.follow();
|
|
2962
4021
|
}
|
|
2963
4022
|
|
|
2964
4023
|
/**
|
|
@@ -2983,7 +4042,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
2983
4042
|
this.#pending = pending;
|
|
2984
4043
|
this.#ensureGroup().appendChild(pending);
|
|
2985
4044
|
this.#updateEmptyState();
|
|
2986
|
-
this.#
|
|
4045
|
+
this.#scroller.follow();
|
|
2987
4046
|
}
|
|
2988
4047
|
|
|
2989
4048
|
/** Remove the pending indicator if shown. */
|
|
@@ -3003,21 +4062,83 @@ export class AgUiChat extends HTMLElement {
|
|
|
3003
4062
|
const group = this.#ensureGroup();
|
|
3004
4063
|
group.insertBefore(this.#thoughts.element, group.firstChild);
|
|
3005
4064
|
this.#updateEmptyState();
|
|
3006
|
-
this.#
|
|
4065
|
+
this.#scroller.follow();
|
|
3007
4066
|
}
|
|
3008
4067
|
return this.#thoughts;
|
|
3009
4068
|
}
|
|
3010
4069
|
|
|
3011
|
-
|
|
4070
|
+
/**
|
|
4071
|
+
* The bubble the current answer streams into, opening it on first sight.
|
|
4072
|
+
*
|
|
4073
|
+
* Opened the moment a token arrives rather than on the frame that draws it,
|
|
4074
|
+
* so the answer's container replaces the pending dots straight away and the
|
|
4075
|
+
* turn never shows a gap while the first render waits for a frame.
|
|
4076
|
+
*/
|
|
4077
|
+
#openStream(): HTMLDivElement {
|
|
3012
4078
|
if (this.#streamingBubble === null) {
|
|
3013
4079
|
this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
|
|
3014
4080
|
this.#streamDeltas = 0;
|
|
3015
4081
|
}
|
|
3016
|
-
this.#streamingBubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
3017
|
-
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
3018
4082
|
return this.#streamingBubble;
|
|
3019
4083
|
}
|
|
3020
4084
|
|
|
4085
|
+
/**
|
|
4086
|
+
* Queue a render of the answer so far, at most one per frame.
|
|
4087
|
+
*
|
|
4088
|
+
* Each `TEXT_MESSAGE_CONTENT` event carries the *whole* accumulated answer,
|
|
4089
|
+
* and drawing it means marked + DOMPurify over the entire document and a
|
|
4090
|
+
* wholesale replacement of the bubble's subtree. Once per token that is
|
|
4091
|
+
* quadratic in the answer's length — a long answer is agent-controlled, so
|
|
4092
|
+
* an ordinary run becomes a progressively stalling tab — and every rebuild
|
|
4093
|
+
* takes any selection or focus inside the bubble with it.
|
|
4094
|
+
*
|
|
4095
|
+
* A frame is the right grain: it is the fastest anything on screen can
|
|
4096
|
+
* change anyway, so a burst of tokens costs one parse and the text still
|
|
4097
|
+
* appears to flow rather than in visible chunks.
|
|
4098
|
+
*/
|
|
4099
|
+
#queueStream(buffer: string): void {
|
|
4100
|
+
this.#streamBuffer = buffer;
|
|
4101
|
+
this.#openStream();
|
|
4102
|
+
if (this.#streamFrame !== null) {
|
|
4103
|
+
return;
|
|
4104
|
+
}
|
|
4105
|
+
this.#streamFrame = requestAnimationFrame(() => {
|
|
4106
|
+
this.#streamFrame = null;
|
|
4107
|
+
this.#streamInto(this.#streamBuffer);
|
|
4108
|
+
});
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
/** Render `buffer` into the streaming bubble now, dropping any queued frame. */
|
|
4112
|
+
#streamInto(buffer: string): HTMLDivElement {
|
|
4113
|
+
// A frame still queued would otherwise fire after this and repaint the
|
|
4114
|
+
// bubble with whatever the last delta held — behind the buffer just drawn.
|
|
4115
|
+
if (this.#streamFrame !== null) {
|
|
4116
|
+
cancelAnimationFrame(this.#streamFrame);
|
|
4117
|
+
this.#streamFrame = null;
|
|
4118
|
+
}
|
|
4119
|
+
this.#streamBuffer = buffer;
|
|
4120
|
+
const bubble = this.#openStream();
|
|
4121
|
+
bubble.innerHTML = renderMarkdown(buffer, { allowImages: this.allowImages });
|
|
4122
|
+
this.#scroller.follow();
|
|
4123
|
+
return bubble;
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
/**
|
|
4127
|
+
* Close the current answer's streaming bubble.
|
|
4128
|
+
*
|
|
4129
|
+
* Draws a queued render first. A run that ends without a text end — a
|
|
4130
|
+
* cancel, an error, a round boundary — leaves the last delta sitting in the
|
|
4131
|
+
* queue, and simply dropping the bubble here would strand it: the partial
|
|
4132
|
+
* answer the user stopped mid-sentence would lose its final tokens, or be an
|
|
4133
|
+
* empty bubble above the stopped note.
|
|
4134
|
+
*/
|
|
4135
|
+
#endStream(): void {
|
|
4136
|
+
if (this.#streamFrame !== null) {
|
|
4137
|
+
this.#streamInto(this.#streamBuffer);
|
|
4138
|
+
}
|
|
4139
|
+
this.#streamingBubble = null;
|
|
4140
|
+
}
|
|
4141
|
+
|
|
3021
4142
|
/**
|
|
3022
4143
|
* The card for ``call``, creating and appending it on first sight.
|
|
3023
4144
|
*
|
|
@@ -3049,7 +4170,7 @@ export class AgUiChat extends HTMLElement {
|
|
|
3049
4170
|
#appendNotice(icon: string, text: string, kind: string): void {
|
|
3050
4171
|
this.#ensureGroup().appendChild(renderRunNotice(icon, text, kind));
|
|
3051
4172
|
this.#updateEmptyState();
|
|
3052
|
-
this.#
|
|
4173
|
+
this.#scroller.follow();
|
|
3053
4174
|
}
|
|
3054
4175
|
|
|
3055
4176
|
/**
|
|
@@ -3071,9 +4192,20 @@ export class AgUiChat extends HTMLElement {
|
|
|
3071
4192
|
* arrives is not something to switch on for everybody.
|
|
3072
4193
|
*/
|
|
3073
4194
|
enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
|
|
3074
|
-
const first =
|
|
4195
|
+
const first =
|
|
4196
|
+
!this.#activityRenderers.has(CHART_ACTIVITY_TYPE) && !this.#toolRegistry.has(CHART_TOOL_NAME);
|
|
3075
4197
|
if (routes.includes("activity")) {
|
|
3076
|
-
|
|
4198
|
+
// The chart is a registration like any host's, not a privileged branch.
|
|
4199
|
+
// If the built-in cannot be expressed through the seam, the seam is not
|
|
4200
|
+
// one -- so this is the test as much as the feature.
|
|
4201
|
+
this.registerActivityRenderer({
|
|
4202
|
+
type: CHART_ACTIVITY_TYPE,
|
|
4203
|
+
render: (content) => {
|
|
4204
|
+
const spec = chartSpecFrom(content);
|
|
4205
|
+
return spec === null ? null : renderChart(spec);
|
|
4206
|
+
},
|
|
4207
|
+
removedNotice: this.#strings.chartUndrawable,
|
|
4208
|
+
});
|
|
3077
4209
|
}
|
|
3078
4210
|
if (routes.includes("tool")) {
|
|
3079
4211
|
this.registerTool(createChartTool());
|
|
@@ -3121,37 +4253,140 @@ export class AgUiChat extends HTMLElement {
|
|
|
3121
4253
|
this.#afterTranscriptGrew();
|
|
3122
4254
|
}
|
|
3123
4255
|
|
|
3124
|
-
/**
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
4256
|
+
/**
|
|
4257
|
+
* Teach this element to draw one kind of AG-UI activity.
|
|
4258
|
+
*
|
|
4259
|
+
* `activity_type` is one of exactly two fields the protocol leaves an open
|
|
4260
|
+
* string, and it is the **content** one: an activity is materialised into a
|
|
4261
|
+
* message, persisted with the thread, and replayed on every restore. Its
|
|
4262
|
+
* sibling `CUSTOM` carries an imperative and is dispatched to the page
|
|
4263
|
+
* instead ({@link CUSTOM_AGENT_EVENT}).
|
|
4264
|
+
*
|
|
4265
|
+
* That asymmetry decides which carrier a server should use. Content has a
|
|
4266
|
+
* place in the conversation and should come back; an imperative has no place
|
|
4267
|
+
* and no meaning once acted on.
|
|
4268
|
+
*
|
|
4269
|
+
* ```js
|
|
4270
|
+
* chat.registerActivityRenderer({
|
|
4271
|
+
* type: "build_status",
|
|
4272
|
+
* render: (content) => {
|
|
4273
|
+
* const el = document.createElement("div");
|
|
4274
|
+
* el.textContent = `Build ${content.status}`;
|
|
4275
|
+
* return el;
|
|
4276
|
+
* },
|
|
4277
|
+
* });
|
|
4278
|
+
* ```
|
|
4279
|
+
*
|
|
4280
|
+
* Registering a type twice replaces the earlier renderer, so a host can
|
|
4281
|
+
* override a built-in -- `chart` and `compaction` are registrations like any
|
|
4282
|
+
* other, not privileged branches.
|
|
4283
|
+
*
|
|
4284
|
+
* ⚠ `render` runs again on every thread load. See {@link ActivityRenderer}
|
|
4285
|
+
* for what that requires of it.
|
|
4286
|
+
*/
|
|
4287
|
+
registerActivityRenderer(registration: ActivityRegistration): void {
|
|
4288
|
+
this.#activityRenderers.set(registration.type, registration);
|
|
4289
|
+
this.#unhandledActivityTypes.delete(registration.type);
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4292
|
+
/**
|
|
4293
|
+
* Activity types that arrived with nobody registered to draw them.
|
|
4294
|
+
*
|
|
4295
|
+
* Deliberately the only trace an unhandled activity leaves. Ignoring an
|
|
4296
|
+
* unknown name is the protocol's own answer and the whole point of an open
|
|
4297
|
+
* field, so warning would fire on every forward-compatible server -- but
|
|
4298
|
+
* "nothing happened and nothing was said" is impossible to debug, so the set
|
|
4299
|
+
* is readable. Accumulates for the element's lifetime, across threads.
|
|
4300
|
+
*/
|
|
4301
|
+
get unhandledActivityTypes(): readonly string[] {
|
|
4302
|
+
return [...this.#unhandledActivityTypes];
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
/**
|
|
4306
|
+
* Draw, replace or remove one activity, whatever kind it is.
|
|
4307
|
+
*
|
|
4308
|
+
* The single path for all three routes an activity arrives by -- pushed
|
|
4309
|
+
* (`onActivity`), patched (`onActivityChanged`) and replayed from history --
|
|
4310
|
+
* which is why the renderer contract has to be pure: the same content is
|
|
4311
|
+
* drawn again on every thread load.
|
|
4312
|
+
*
|
|
4313
|
+
* An unregistered type draws nothing and says nothing. That is the protocol's
|
|
4314
|
+
* own answer -- a client that does not know a name ignores the event -- and a
|
|
4315
|
+
* warning here would fire on every well-behaved forward-compatible server,
|
|
4316
|
+
* while a placeholder would put the protocol's growth in the user's face.
|
|
4317
|
+
* {@link unhandledActivityTypes} is the way to find out what arrived.
|
|
4318
|
+
*/
|
|
4319
|
+
#drawActivity(messageId: string, activityType: string, content: unknown): void {
|
|
4320
|
+
const registration = this.#activityRenderers.get(activityType);
|
|
4321
|
+
if (registration === undefined) {
|
|
4322
|
+
this.#unhandledActivityTypes.add(activityType);
|
|
4323
|
+
return;
|
|
4324
|
+
}
|
|
4325
|
+
let node: Node | null;
|
|
4326
|
+
try {
|
|
4327
|
+
node = registration.render(content);
|
|
4328
|
+
} catch (error) {
|
|
4329
|
+
// `render` is consumer code and this runs inside the history replay,
|
|
4330
|
+
// where a throw abandons the loop and takes every later turn of the
|
|
4331
|
+
// transcript with it -- silently, and again on every reload. One activity
|
|
4332
|
+
// that fails to draw is worth losing; the rest of the conversation is not.
|
|
4333
|
+
console.warn(`ag-ui-chat: render failed for activity ${activityType}`, error);
|
|
4334
|
+
node = null;
|
|
4335
|
+
}
|
|
4336
|
+
if (node === null) {
|
|
4337
|
+
this.#removeActivity(messageId, activityType, registration.removedNotice, content);
|
|
3137
4338
|
return;
|
|
3138
4339
|
}
|
|
3139
4340
|
const existing = this.#activityBlocks.get(messageId);
|
|
3140
4341
|
if (existing === undefined) {
|
|
3141
|
-
this.#ensureGroup().appendChild(
|
|
4342
|
+
this.#ensureGroup().appendChild(node as HTMLElement);
|
|
3142
4343
|
} else {
|
|
3143
|
-
// Replaced rather than appended: a server redrawing
|
|
3144
|
-
//
|
|
3145
|
-
//
|
|
3146
|
-
existing.replaceWith(
|
|
4344
|
+
// Replaced rather than appended: a server redrawing under the same id
|
|
4345
|
+
// means *this one changed*, and a second copy below the first would read
|
|
4346
|
+
// as two measurements instead of one that moved.
|
|
4347
|
+
existing.replaceWith(node);
|
|
3147
4348
|
}
|
|
3148
|
-
this.#activityBlocks.set(messageId,
|
|
4349
|
+
this.#activityBlocks.set(messageId, node as HTMLElement);
|
|
3149
4350
|
this.#afterTranscriptGrew();
|
|
3150
4351
|
}
|
|
3151
4352
|
|
|
4353
|
+
/**
|
|
4354
|
+
* Take away an activity whose content stopped being drawable.
|
|
4355
|
+
*
|
|
4356
|
+
* Leaving the old one up is the worst available answer: it shows values that
|
|
4357
|
+
* have been retracted, reading as current, and a reload drops it anyway
|
|
4358
|
+
* because the *stored* content is the version that could not be drawn. Live
|
|
4359
|
+
* and reload should agree, and both should say "gone".
|
|
4360
|
+
*
|
|
4361
|
+
* Removing is right; doing it in silence was not. A chart that had been drawn
|
|
4362
|
+
* simply disappeared, with no `console` call anywhere on the path -- which
|
|
4363
|
+
* nobody reports as a bug, they report as "the charts are flaky".
|
|
4364
|
+
*/
|
|
4365
|
+
#removeActivity(
|
|
4366
|
+
messageId: string,
|
|
4367
|
+
activityType: string,
|
|
4368
|
+
notice: string | undefined,
|
|
4369
|
+
content: unknown,
|
|
4370
|
+
): void {
|
|
4371
|
+
const had = this.#activityBlocks.has(messageId);
|
|
4372
|
+
this.#activityBlocks.get(messageId)?.remove();
|
|
4373
|
+
this.#activityBlocks.delete(messageId);
|
|
4374
|
+
console.warn(
|
|
4375
|
+
`ag-ui-chat: activity ${messageId} (${activityType}) was not drawable and has been ` +
|
|
4376
|
+
"removed. A chart's points must each be a finite JSON number; a numeric column " +
|
|
4377
|
+
"serialised as a string (a Decimal, typically) is rejected rather than coerced.",
|
|
4378
|
+
content,
|
|
4379
|
+
);
|
|
4380
|
+
// Only when something was on screen: content that never drew has no
|
|
4381
|
+
// disappearance to explain, and a notice for every rejected push is noise.
|
|
4382
|
+
if (had && notice !== undefined) {
|
|
4383
|
+
this.#appendNotice("\u{1F4C9}", notice, "chart-undrawable");
|
|
4384
|
+
}
|
|
4385
|
+
}
|
|
4386
|
+
|
|
3152
4387
|
#afterTranscriptGrew(): void {
|
|
3153
4388
|
this.#updateEmptyState();
|
|
3154
|
-
this.#
|
|
4389
|
+
this.#scroller.follow();
|
|
3155
4390
|
}
|
|
3156
4391
|
|
|
3157
4392
|
#cardFor(call: AgUiToolCall): ToolCallCard {
|
|
@@ -3167,13 +4402,13 @@ export class AgUiChat extends HTMLElement {
|
|
|
3167
4402
|
typeof labelled === "string"
|
|
3168
4403
|
? labelled
|
|
3169
4404
|
: (this.toolSummaries[call.name] ??
|
|
3170
|
-
this.#toolCatalog[call.name] ??
|
|
4405
|
+
this.#toolCatalog[call.name]?.summary ??
|
|
3171
4406
|
prettifyToolName(call.name));
|
|
3172
4407
|
const card = new ToolCallCard(call.name, call.args, summary, this.#strings);
|
|
3173
4408
|
this.#toolCards.set(call.id, card);
|
|
3174
4409
|
this.#ensureGroup().appendChild(card.element);
|
|
3175
4410
|
this.#updateEmptyState();
|
|
3176
|
-
this.#
|
|
4411
|
+
this.#scroller.follow();
|
|
3177
4412
|
return card;
|
|
3178
4413
|
}
|
|
3179
4414
|
}
|
|
@@ -3198,6 +4433,15 @@ function confirmPhrase(interrupt: Interrupt): string | undefined {
|
|
|
3198
4433
|
}
|
|
3199
4434
|
|
|
3200
4435
|
/** One tool call as a restored assistant message carries it. */
|
|
4436
|
+
/**
|
|
4437
|
+
* Why a client tool call is gated behind the confirmation card.
|
|
4438
|
+
*
|
|
4439
|
+
* Only `"destructive"` -- the default `x-destructive` gate -- may be waived for
|
|
4440
|
+
* the session. `confirmPredicate` is documented as authoritative, so a call it
|
|
4441
|
+
* gates keeps asking.
|
|
4442
|
+
*/
|
|
4443
|
+
type ConfirmationRule = "destructive" | "predicate";
|
|
4444
|
+
|
|
3201
4445
|
interface RestoredToolCall {
|
|
3202
4446
|
readonly id: string;
|
|
3203
4447
|
readonly function: { readonly name: string; readonly arguments?: unknown };
|