@artooi/ag-ui-web-component 0.23.0 → 0.24.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 +149 -36
- package/README.md +140 -9
- package/dist/ag-ui-web-component.bundle.js +113 -122
- package/dist/ag-ui-web-component.bundle.js.map +3 -3
- package/dist/constants.d.ts +71 -85
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +105 -127
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +24 -30
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/attachment.d.ts +9 -14
- package/dist/core/attachment.d.ts.map +1 -1
- package/dist/core/conversation_store.d.ts +38 -27
- package/dist/core/conversation_store.d.ts.map +1 -1
- package/dist/core/create_http_agent.d.ts +13 -15
- package/dist/core/create_http_agent.d.ts.map +1 -1
- package/dist/core/remote_conversation_store.d.ts +10 -9
- package/dist/core/remote_conversation_store.d.ts.map +1 -1
- package/dist/core/run_index.d.ts +11 -20
- package/dist/core/run_index.d.ts.map +1 -1
- package/dist/core/transcribe_audio.d.ts +8 -8
- package/dist/core/transcribe_audio.d.ts.map +1 -1
- package/dist/core/upload_attachment.d.ts +15 -18
- package/dist/core/upload_attachment.d.ts.map +1 -1
- package/dist/core/utils.d.ts +4 -6
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/dom/animations.d.ts +22 -30
- package/dist/dom/animations.d.ts.map +1 -1
- package/dist/dom/dom_driver.d.ts +7 -7
- package/dist/dom/native_setter.d.ts +2 -2
- package/dist/dom/native_setter.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +357 -383
- package/dist/index.js.map +2 -2
- package/dist/skills/fill_template.d.ts +4 -5
- package/dist/skills/fill_template.d.ts.map +1 -1
- package/dist/skills/parse_skills.d.ts.map +1 -1
- package/dist/skills/skill.d.ts +7 -8
- package/dist/skills/skill.d.ts.map +1 -1
- package/dist/tools/client_tool_registry.d.ts +2 -2
- package/dist/tools/page_action_tools.d.ts +7 -10
- package/dist/tools/page_action_tools.d.ts.map +1 -1
- package/dist/tools/page_state.d.ts +5 -8
- package/dist/tools/page_state.d.ts.map +1 -1
- package/dist/tools/route_map.d.ts +7 -10
- package/dist/tools/route_map.d.ts.map +1 -1
- package/dist/ui/approval_card.d.ts +15 -20
- package/dist/ui/approval_card.d.ts.map +1 -1
- package/dist/ui/attach_copy_buttons.d.ts +4 -10
- package/dist/ui/attach_copy_buttons.d.ts.map +1 -1
- package/dist/ui/attachment_chips.d.ts +7 -10
- package/dist/ui/attachment_chips.d.ts.map +1 -1
- package/dist/ui/attachment_tray.d.ts +6 -6
- package/dist/ui/checkpoint_menu.d.ts +7 -8
- package/dist/ui/checkpoint_menu.d.ts.map +1 -1
- package/dist/ui/confirmation_card.d.ts +10 -15
- package/dist/ui/confirmation_card.d.ts.map +1 -1
- package/dist/ui/question_card.d.ts +12 -15
- package/dist/ui/question_card.d.ts.map +1 -1
- package/dist/ui/relative_time.d.ts +5 -7
- package/dist/ui/relative_time.d.ts.map +1 -1
- package/dist/ui/render_markdown.d.ts +8 -8
- package/dist/ui/render_markdown.d.ts.map +1 -1
- package/dist/ui/resize_handle.d.ts +21 -34
- package/dist/ui/resize_handle.d.ts.map +1 -1
- package/dist/ui/run_notice.d.ts +5 -7
- package/dist/ui/run_notice.d.ts.map +1 -1
- package/dist/ui/skills_menu.d.ts +4 -5
- package/dist/ui/skills_menu.d.ts.map +1 -1
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/dist/ui/thoughts_block.d.ts +9 -11
- package/dist/ui/thoughts_block.d.ts.map +1 -1
- package/dist/ui/thread_drawer.d.ts +6 -5
- package/dist/ui/thread_drawer.d.ts.map +1 -1
- package/dist/ui/tool_call_card.d.ts +17 -25
- package/dist/ui/tool_call_card.d.ts.map +1 -1
- package/dist/ui/ui_strings.d.ts +6 -12
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/dist/ui/voice_input.d.ts +10 -11
- package/dist/ui/voice_input.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +74 -87
- package/src/core/ag_ui_chat.ts +313 -269
- package/src/core/agui_client.ts +60 -71
- package/src/core/attachment.ts +9 -14
- package/src/core/conversation_store.ts +55 -33
- package/src/core/create_http_agent.ts +18 -22
- package/src/core/remote_conversation_store.ts +28 -15
- package/src/core/run_index.ts +14 -23
- package/src/core/transcribe_audio.ts +9 -10
- package/src/core/upload_attachment.ts +18 -21
- package/src/core/utils.ts +4 -6
- package/src/dom/animations.ts +33 -43
- package/src/dom/dom_driver.ts +7 -7
- package/src/dom/native_setter.ts +11 -12
- package/src/index.ts +3 -0
- package/src/skills/fill_template.ts +4 -5
- package/src/skills/parse_skills.ts +3 -4
- package/src/skills/skill.ts +7 -8
- package/src/tools/client_tool_registry.ts +2 -2
- package/src/tools/page_action_tools.ts +12 -15
- package/src/tools/page_state.ts +5 -8
- package/src/tools/route_map.ts +15 -19
- package/src/ui/approval_card.ts +15 -20
- package/src/ui/attach_copy_buttons.ts +9 -18
- package/src/ui/attachment_chips.ts +7 -10
- package/src/ui/attachment_tray.ts +6 -6
- package/src/ui/checkpoint_menu.ts +7 -8
- package/src/ui/confirmation_card.ts +10 -15
- package/src/ui/question_card.ts +12 -15
- package/src/ui/relative_time.ts +5 -7
- package/src/ui/render_markdown.ts +25 -51
- package/src/ui/resize_handle.ts +25 -38
- package/src/ui/run_notice.ts +9 -12
- package/src/ui/skills_menu.ts +4 -5
- package/src/ui/styles.ts +92 -101
- package/src/ui/thoughts_block.ts +11 -13
- package/src/ui/thread_drawer.ts +6 -5
- package/src/ui/tool_call_card.ts +22 -32
- package/src/ui/ui_strings.ts +6 -12
- package/src/ui/voice_input.ts +10 -11
- package/src/version.ts +1 -1
package/src/core/agui_client.ts
CHANGED
|
@@ -33,9 +33,9 @@ export interface ToolExecution {
|
|
|
33
33
|
/**
|
|
34
34
|
* Executes a frontend tool call.
|
|
35
35
|
*
|
|
36
|
-
* Returns the {@link ToolExecution} to post back to the agent, or
|
|
37
|
-
* the call is not a frontend tool the host owns
|
|
38
|
-
* already executed
|
|
36
|
+
* Returns the {@link ToolExecution} to post back to the agent, or `null` when
|
|
37
|
+
* the call is not a frontend tool the host owns — a server-side tool the server
|
|
38
|
+
* already executed, which the client must not re-run.
|
|
39
39
|
*/
|
|
40
40
|
export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
|
|
41
41
|
|
|
@@ -50,11 +50,10 @@ export type InterruptResponse = { status: "resolved"; payload?: unknown } | { st
|
|
|
50
50
|
/**
|
|
51
51
|
* Resolves the approval interrupts a run finished on, keyed by interrupt id.
|
|
52
52
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* unresolved interrupt then simply ends the loop.
|
|
53
|
+
* A gated server-side tool defers instead of executing, so the run finishes on
|
|
54
|
+
* an interrupt outcome; the host collects one decision per interrupt and the
|
|
55
|
+
* loop resumes with the answers. Omit for agents that never gate server-side
|
|
56
|
+
* tools — an unresolved interrupt then ends the loop.
|
|
58
57
|
*/
|
|
59
58
|
export type ResolveInterrupts = (
|
|
60
59
|
interrupts: readonly Interrupt[],
|
|
@@ -67,9 +66,9 @@ export type ResolveInterrupts = (
|
|
|
67
66
|
*/
|
|
68
67
|
export interface AgUiClientHandlers {
|
|
69
68
|
onRunStart(): void;
|
|
70
|
-
/** Fired on every streamed token;
|
|
69
|
+
/** Fired on every streamed token; `buffer` is the full text so far. */
|
|
71
70
|
onTextDelta(buffer: string): void;
|
|
72
|
-
/** Fired when the assistant message completes;
|
|
71
|
+
/** Fired when the assistant message completes; `buffer` is the final text. */
|
|
73
72
|
onTextEnd(buffer: string): void;
|
|
74
73
|
/** Fired when the agent finishes calling a tool (server- or frontend-side). */
|
|
75
74
|
onToolCall(call: AgUiToolCall): void;
|
|
@@ -95,17 +94,15 @@ export interface AgUiClientHandlers {
|
|
|
95
94
|
onError(message: string): void;
|
|
96
95
|
/**
|
|
97
96
|
* Fired when the user cancelled the run ({@link AgUiClient.cancel}) — the
|
|
98
|
-
* deliberate-stop sibling of `onError`.
|
|
99
|
-
* streamed stays valid
|
|
100
|
-
*
|
|
97
|
+
* deliberate-stop sibling of `onError`. Partial assistant text already
|
|
98
|
+
* streamed stays valid, so the host keeps the bubble and shows a stopped
|
|
99
|
+
* affordance rather than an error. `onSettled` still follows.
|
|
101
100
|
*/
|
|
102
101
|
onCancelled(): void;
|
|
103
102
|
/**
|
|
104
|
-
* Fired exactly once when the
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* to rest (pending indicator cleared, input re-enabled) no matter how the
|
|
108
|
-
* run finished.
|
|
103
|
+
* Fired exactly once when the interaction settles, however the run loop ended
|
|
104
|
+
* — server-only round, rounds exhausted, cancellation, or error. The terminal
|
|
105
|
+
* guarantee that the UI returns to rest.
|
|
109
106
|
*/
|
|
110
107
|
onSettled(): void;
|
|
111
108
|
}
|
|
@@ -138,17 +135,15 @@ export interface AgUiClientConfig extends AgUiRunInputs {
|
|
|
138
135
|
*/
|
|
139
136
|
onPersist?: (messages: readonly Message[]) => void;
|
|
140
137
|
/**
|
|
141
|
-
* Called whenever AG-UI shared state changes —
|
|
142
|
-
* `
|
|
143
|
-
*
|
|
144
|
-
* the result so a host can react.
|
|
138
|
+
* Called whenever AG-UI shared state changes — a streamed `STATE_SNAPSHOT` /
|
|
139
|
+
* `STATE_DELTA`, or {@link AgUiClient.setState}. `@ag-ui/client` owns applying
|
|
140
|
+
* those events; this only forwards the result.
|
|
145
141
|
*/
|
|
146
142
|
onStateChanged?: (state: Readonly<Record<string, unknown>>) => void;
|
|
147
143
|
/**
|
|
148
|
-
* Error text
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* localized string.
|
|
144
|
+
* Error text for {@link AgUiClientHandlers.onError} when a run's stream closes
|
|
145
|
+
* without a terminal AG-UI event. Defaults to `"Connection lost"`; the host
|
|
146
|
+
* passes its localized string.
|
|
152
147
|
*/
|
|
153
148
|
connectionLostMessage?: string;
|
|
154
149
|
}
|
|
@@ -182,10 +177,9 @@ export class AgUiClient {
|
|
|
182
177
|
readonly #resolveInterrupts: ResolveInterrupts | null;
|
|
183
178
|
readonly #onPersist: (messages: readonly Message[]) => void;
|
|
184
179
|
/**
|
|
185
|
-
* Message ids the server has already closed, so a reuse can be reported.
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* case a per-run set would miss entirely.
|
|
180
|
+
* Message ids the server has already closed, so a reuse can be reported. Per
|
|
181
|
+
* client rather than per run — the merge happens across runs, which a per-run
|
|
182
|
+
* set would miss entirely.
|
|
189
183
|
*/
|
|
190
184
|
readonly #closedMessageIds = new Set<string>();
|
|
191
185
|
readonly #connectionLostMessage: string;
|
|
@@ -205,8 +199,8 @@ export class AgUiClient {
|
|
|
205
199
|
const onStateChanged = config.onStateChanged;
|
|
206
200
|
if (onStateChanged !== undefined) {
|
|
207
201
|
// The agent applies STATE_SNAPSHOT / STATE_DELTA itself; subscribing is
|
|
208
|
-
// how
|
|
209
|
-
//
|
|
202
|
+
// how the result is learned rather than re-derived from the event stream.
|
|
203
|
+
// Lives for the agent's lifetime, which is this client's.
|
|
210
204
|
this.#agent.subscribe({
|
|
211
205
|
onStateChanged: ({ state }) => {
|
|
212
206
|
onStateChanged(state as Record<string, unknown>);
|
|
@@ -243,9 +237,8 @@ export class AgUiClient {
|
|
|
243
237
|
* tools (bounded by {@link MAX_TOOL_ROUNDS}).
|
|
244
238
|
*
|
|
245
239
|
* `attachments` ride on the user message as a non-standard field so the
|
|
246
|
-
* default
|
|
247
|
-
*
|
|
248
|
-
* unknown message field), then reads bytes via the `read_attachment` tool.
|
|
240
|
+
* default store round-trips them for history replay; see
|
|
241
|
+
* {@link messageAttachments}.
|
|
249
242
|
*/
|
|
250
243
|
async send(content: string, attachments: readonly AttachmentRef[] = []): Promise<void> {
|
|
251
244
|
// Cast at the AG-UI boundary: `attachments` is a web-component augmentation
|
|
@@ -263,7 +256,7 @@ export class AgUiClient {
|
|
|
263
256
|
/**
|
|
264
257
|
* Resume the run loop after a navigating tool's result was supplied
|
|
265
258
|
* post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
|
|
266
|
-
* message
|
|
259
|
+
* message; it continues the conversation already in history.
|
|
267
260
|
*/
|
|
268
261
|
async resume(): Promise<void> {
|
|
269
262
|
await this.#run();
|
|
@@ -309,21 +302,21 @@ export class AgUiClient {
|
|
|
309
302
|
}
|
|
310
303
|
|
|
311
304
|
#onCancelled(): void {
|
|
312
|
-
//
|
|
313
|
-
//
|
|
305
|
+
// Persist so the truncated exchange, partial assistant text included,
|
|
306
|
+
// survives a reload.
|
|
314
307
|
this.#onPersist(this.#agent.messages);
|
|
315
308
|
this.#handlers.onCancelled();
|
|
316
309
|
}
|
|
317
310
|
|
|
318
311
|
async #runLoop(): Promise<void> {
|
|
319
|
-
// Carries
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
312
|
+
// Carries resolved approval answers into the next run when a round finished
|
|
313
|
+
// on a server-side-tool interrupt. Distinct from the public resume(), which
|
|
314
|
+
// continues an unfinished frontend-tool round after a page load; this stays
|
|
315
|
+
// inside one #run().
|
|
323
316
|
let resume: ResumeEntry[] | undefined;
|
|
324
317
|
for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
|
|
325
|
-
// A cancel during the previous round's
|
|
326
|
-
//
|
|
318
|
+
// A cancel during the previous round's tool execution lands here: the
|
|
319
|
+
// running handler completed, but no further round starts.
|
|
327
320
|
if (this.#cancelled) {
|
|
328
321
|
return;
|
|
329
322
|
}
|
|
@@ -339,29 +332,28 @@ export class AgUiClient {
|
|
|
339
332
|
await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
|
|
340
333
|
resume = undefined;
|
|
341
334
|
this.#onPersist(this.#agent.messages);
|
|
342
|
-
// Cancelled mid-stream:
|
|
343
|
-
//
|
|
335
|
+
// Cancelled mid-stream: don't execute the tool calls collected before the
|
|
336
|
+
// abort.
|
|
344
337
|
if (this.#cancelled) {
|
|
345
338
|
return;
|
|
346
339
|
}
|
|
347
|
-
// The stream resolved without RUN_FINISHED / RUN_ERROR
|
|
348
|
-
// dropped mid-run. Surface it as an error
|
|
349
|
-
// with a stuck pending indicator
|
|
340
|
+
// The stream resolved without RUN_FINISHED / RUN_ERROR, so the transport
|
|
341
|
+
// dropped mid-run. Surface it as an error (via #run) rather than resting
|
|
342
|
+
// silently with a stuck pending indicator.
|
|
350
343
|
if (!runState.terminal) {
|
|
351
344
|
throw new ConnectionLostError(this.#connectionLostMessage);
|
|
352
345
|
}
|
|
353
|
-
// RUN_ERROR is terminal
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
346
|
+
// RUN_ERROR is terminal and the agent already reported it via onError.
|
|
347
|
+
// Executing the calls collected before it, or starting another round,
|
|
348
|
+
// would run into a broken context and raise a confusing second error.
|
|
349
|
+
// Pending tool cards are swept at onSettled.
|
|
357
350
|
if (runState.errored) {
|
|
358
351
|
return;
|
|
359
352
|
}
|
|
360
|
-
// A gated server-side tool deferred
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
// over the frontend-tool sweep below: a server-side tool isn't ours to run.
|
|
353
|
+
// A gated server-side tool deferred, so the run finished on an interrupt
|
|
354
|
+
// outcome: resolve each interrupt, then re-enter carrying the answers so
|
|
355
|
+
// the follow-up run executes or denies the tool. Must precede the
|
|
356
|
+
// frontend-tool sweep below — a server-side tool isn't ours to run.
|
|
365
357
|
if (runState.interrupts.length > 0) {
|
|
366
358
|
if (this.#resolveInterrupts === null) {
|
|
367
359
|
return;
|
|
@@ -410,13 +402,10 @@ export class AgUiClient {
|
|
|
410
402
|
h.onRunStart();
|
|
411
403
|
},
|
|
412
404
|
onTextMessageStartEvent({ event }) {
|
|
413
|
-
// A
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
// but it should not be silent, because the corruption outlives the
|
|
418
|
-
// session and reads as a client bug. Found by a demo harness doing
|
|
419
|
-
// exactly this.
|
|
405
|
+
// A reused message id merges two answers into one persisted transcript
|
|
406
|
+
// entry. The protocol has no rule to enforce and refusing the event
|
|
407
|
+
// would be worse than the merge, so warn and continue — but do not stay
|
|
408
|
+
// silent, since the corruption outlives the session.
|
|
420
409
|
if (closed.has(event.messageId)) {
|
|
421
410
|
console.warn(
|
|
422
411
|
`<ag-ui-chat>: the server reused message id "${event.messageId}", which was ` +
|
|
@@ -448,9 +437,9 @@ export class AgUiClient {
|
|
|
448
437
|
onActivitySnapshotEvent({ event }) {
|
|
449
438
|
h.onActivity(event.activityType, event.content);
|
|
450
439
|
},
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
440
|
+
// `@ag-ui/client` maps the deprecated THINKING_* events onto these
|
|
441
|
+
// REASONING_* callbacks, so the reasoning family alone covers both
|
|
442
|
+
// protocol versions.
|
|
454
443
|
onReasoningStartEvent() {
|
|
455
444
|
h.onReasoningStart();
|
|
456
445
|
},
|
|
@@ -462,9 +451,9 @@ export class AgUiClient {
|
|
|
462
451
|
},
|
|
463
452
|
onRunFinishedEvent(params) {
|
|
464
453
|
// RUN_FINISHED is terminal for both a normal finish and an interrupt.
|
|
465
|
-
//
|
|
466
|
-
// `pendingInterrupts`
|
|
467
|
-
//
|
|
454
|
+
// Capture the interrupts here rather than reading the agent's
|
|
455
|
+
// `pendingInterrupts` afterwards, to stay independent of that field's
|
|
456
|
+
// cross-run clearing semantics.
|
|
468
457
|
runState.terminal = true;
|
|
469
458
|
if (params.outcome === "interrupt") {
|
|
470
459
|
runState.interrupts = params.interrupts;
|
package/src/core/attachment.ts
CHANGED
|
@@ -4,11 +4,9 @@ import type { Message } from "@ag-ui/core";
|
|
|
4
4
|
* A durable, lightweight reference to one uploaded file — what an upload
|
|
5
5
|
* returns and what rides on a sent message, never the bytes.
|
|
6
6
|
*
|
|
7
|
-
* Mirrors django-ag-ui's `AttachmentRef`:
|
|
7
|
+
* Mirrors django-ag-ui's `AttachmentRef`: the file uploads out-of-band to the
|
|
8
8
|
* attachments endpoint, the server hands back this ref, and the agent reads the
|
|
9
|
-
*
|
|
10
|
-
* message stream free of base64 mirrors how the tool catalog keeps schemas off
|
|
11
|
-
* the wire.
|
|
9
|
+
* content server-side via the `read_attachment` tool.
|
|
12
10
|
*/
|
|
13
11
|
export interface AttachmentRef {
|
|
14
12
|
/** Opaque, owner-scoped handle the server resolves back to bytes. */
|
|
@@ -26,17 +24,14 @@ export interface AttachmentRef {
|
|
|
26
24
|
/**
|
|
27
25
|
* The attachment refs a user message carries.
|
|
28
26
|
*
|
|
29
|
-
* Refs
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `RunAgentInput` validation ignores the unknown field — the model learns the
|
|
34
|
-
* ids from the run context manifest instead.
|
|
27
|
+
* Refs ride on the message as a non-standard `attachments` field, which the
|
|
28
|
+
* default store round-trips and `@ag-ui/client` preserves through `addMessage`
|
|
29
|
+
* / `structuredClone`, so a restored conversation re-renders its chips. The
|
|
30
|
+
* server's strict `RunAgentInput` validation ignores the unknown field.
|
|
35
31
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* abort the whole history replay.
|
|
32
|
+
* Storage is untrusted — hand-edited, truncated, or corrupted — so malformed
|
|
33
|
+
* entries are dropped here; a shapeless one would throw in `iconFor` and abort
|
|
34
|
+
* the whole history replay.
|
|
40
35
|
*/
|
|
41
36
|
export function messageAttachments(message: Message): readonly AttachmentRef[] {
|
|
42
37
|
const refs = (message as { attachments?: unknown }).attachments;
|
|
@@ -13,13 +13,11 @@ export interface NavigationCheckpoint {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Lightweight metadata for one conversation — the thread-drawer row shape
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* is a one-line excerpt of the latest message; `updatedAt` is epoch ms of the
|
|
22
|
-
* last change, used to order the list.
|
|
16
|
+
* Lightweight metadata for one conversation — the thread-drawer row shape, so
|
|
17
|
+
* the drawer renders without loading message bodies. `title` defaults to a
|
|
18
|
+
* truncation of the first user message until an explicit rename, `preview` is a
|
|
19
|
+
* one-line excerpt of the latest message, and `updatedAt` is epoch ms of the
|
|
20
|
+
* last change, which orders the list.
|
|
23
21
|
*/
|
|
24
22
|
export interface ThreadMeta {
|
|
25
23
|
readonly threadId: string;
|
|
@@ -32,16 +30,14 @@ export interface ThreadMeta {
|
|
|
32
30
|
* Client-side persistence seam for the conversation and a pending-navigation
|
|
33
31
|
* checkpoint, keyed by `thread_id`.
|
|
34
32
|
*
|
|
35
|
-
* The default {@link SessionStorageStore} keeps everything per-tab
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* are therefore async-friendly. The checkpoint methods stay synchronous — the
|
|
40
|
-
* marker is a tiny local hint a server store can derive from history and no-op.
|
|
33
|
+
* The default {@link SessionStorageStore} keeps everything per-tab. A host may
|
|
34
|
+
* inject a server-backed store instead, which is why `loadMessages` and
|
|
35
|
+
* `listThreads` are async. The checkpoint methods stay synchronous: the marker
|
|
36
|
+
* is a small local hint a server store can derive from history and no-op.
|
|
41
37
|
*
|
|
42
|
-
* Thread enumeration
|
|
43
|
-
*
|
|
44
|
-
*
|
|
38
|
+
* Thread enumeration backs the chat-history drawer; deleting a thread reuses
|
|
39
|
+
* {@link clear} and "new chat" reuses {@link threadId} after clearing the
|
|
40
|
+
* active thread.
|
|
45
41
|
*/
|
|
46
42
|
export interface ClientConversationStore {
|
|
47
43
|
/** The active conversation id, generated and persisted on first read. */
|
|
@@ -62,6 +58,23 @@ export interface ClientConversationStore {
|
|
|
62
58
|
setActiveThread(threadId: string): void;
|
|
63
59
|
/** Set a thread's display title (the drawer renaming a row). */
|
|
64
60
|
renameThread(threadId: string, title: string): void;
|
|
61
|
+
/**
|
|
62
|
+
* Whether `threadId` was minted here and has never been saved — a thread in
|
|
63
|
+
* which nothing has been sent yet.
|
|
64
|
+
*
|
|
65
|
+
* Exists so a server-backed store can skip asking a server for history that
|
|
66
|
+
* cannot be there: the element mints an id on first mount and immediately tries
|
|
67
|
+
* to restore it, which answers `404` and logs one in the console on every first
|
|
68
|
+
* visit. Nothing is wrong, and it looks exactly like something being wrong.
|
|
69
|
+
*
|
|
70
|
+
* Optional, and deliberately narrow. A store that cannot tell omits it and the
|
|
71
|
+
* fetch happens as before, which is also the right answer for a store that
|
|
72
|
+
* holds nothing locally: "I have no messages for this id" is not the same claim
|
|
73
|
+
* as "this id is new", and only the store that minted the id can make the
|
|
74
|
+
* second one. A thread picked from the drawer was never minted here, so it is
|
|
75
|
+
* still fetched.
|
|
76
|
+
*/
|
|
77
|
+
isUnsent?(threadId: string): boolean;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
const KEY_ROOT = "ag-ui-chat";
|
|
@@ -69,6 +82,9 @@ const THREAD_SUFFIX = "thread";
|
|
|
69
82
|
const THREADS_SUFFIX = "threads";
|
|
70
83
|
const MESSAGES_SUFFIX = "messages:";
|
|
71
84
|
const CHECKPOINT_SUFFIX = "checkpoint:";
|
|
85
|
+
// Marks an id this store minted and nothing has been sent in yet. Dropped on
|
|
86
|
+
// the first save, so it never outlives the one question it answers.
|
|
87
|
+
const MINTED_SUFFIX = "minted:";
|
|
72
88
|
|
|
73
89
|
const TITLE_LIMIT = 60;
|
|
74
90
|
const PREVIEW_LIMIT = 100;
|
|
@@ -86,18 +102,15 @@ interface StoredThread {
|
|
|
86
102
|
/**
|
|
87
103
|
* Default {@link ClientConversationStore}: per-tab `sessionStorage`.
|
|
88
104
|
*
|
|
89
|
-
* Survives full page reloads and same-tab navigation, clears on tab close
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* the drawer so it works with no server.
|
|
105
|
+
* Survives full page reloads and same-tab navigation, clears on tab close.
|
|
106
|
+
* Tracks multiple threads per tab: the active id under one key, message history
|
|
107
|
+
* and checkpoint namespaced by id, and a small index feeding the drawer with no
|
|
108
|
+
* server involved.
|
|
94
109
|
*
|
|
95
|
-
* An optional `namespace` scopes every key to one element
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* (`ag-ui-chat:*`) keys into it once, so an existing conversation survives the
|
|
100
|
-
* upgrade; the default empty namespace keeps the legacy origin-global keys.
|
|
110
|
+
* An optional `namespace` scopes every key to one element, so two
|
|
111
|
+
* `<ag-ui-chat>` instances on the same origin keep separate active-thread
|
|
112
|
+
* pointers and drawer indexes instead of clobbering each other. The default
|
|
113
|
+
* empty namespace keeps the origin-global keys; see {@link #migrateLegacyKeys}.
|
|
101
114
|
*/
|
|
102
115
|
export class SessionStorageStore implements ClientConversationStore {
|
|
103
116
|
readonly #root: string;
|
|
@@ -117,15 +130,24 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
117
130
|
}
|
|
118
131
|
const id = randomUUID();
|
|
119
132
|
sessionStorage.setItem(key, id);
|
|
133
|
+
sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
|
|
120
134
|
return id;
|
|
121
135
|
}
|
|
122
136
|
|
|
137
|
+
isUnsent(threadId: string): boolean {
|
|
138
|
+
return (
|
|
139
|
+
sessionStorage.getItem(this.#key(MINTED_SUFFIX + threadId)) !== null &&
|
|
140
|
+
sessionStorage.getItem(this.#key(MESSAGES_SUFFIX + threadId)) === null
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
124
145
|
return Promise.resolve(this.#readJson<Message[]>(this.#key(MESSAGES_SUFFIX + threadId)));
|
|
125
146
|
}
|
|
126
147
|
|
|
127
148
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
128
149
|
sessionStorage.setItem(this.#key(MESSAGES_SUFFIX + threadId), JSON.stringify(messages));
|
|
150
|
+
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
129
151
|
this.#touchThread(threadId, messages);
|
|
130
152
|
}
|
|
131
153
|
|
|
@@ -145,6 +167,7 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
145
167
|
clear(threadId: string): void {
|
|
146
168
|
sessionStorage.removeItem(this.#key(MESSAGES_SUFFIX + threadId));
|
|
147
169
|
sessionStorage.removeItem(this.#key(CHECKPOINT_SUFFIX + threadId));
|
|
170
|
+
sessionStorage.removeItem(this.#key(MINTED_SUFFIX + threadId));
|
|
148
171
|
this.#writeThreads(this.#readThreads().filter((thread) => thread.threadId !== threadId));
|
|
149
172
|
// Only drop the active pointer when the active thread itself is cleared, so
|
|
150
173
|
// the next `threadId()` mints a fresh one. Deleting another thread from the
|
|
@@ -219,12 +242,11 @@ export class SessionStorageStore implements ClientConversationStore {
|
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
/**
|
|
222
|
-
* One-time move of
|
|
223
|
-
* namespace, so an existing conversation isn't orphaned
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* second namespace finds it gone and starts fresh.
|
|
245
|
+
* One-time move of un-namespaced `ag-ui-chat:*` keys into this instance's
|
|
246
|
+
* namespace, so an existing conversation isn't orphaned. Only this store's own
|
|
247
|
+
* keys move — the element's `collapsed` / `theme` keys are left alone. The
|
|
248
|
+
* first namespaced instance to mount adopts the data; a second namespace
|
|
249
|
+
* finds it gone and starts fresh.
|
|
228
250
|
*/
|
|
229
251
|
#migrateLegacyKeys(): void {
|
|
230
252
|
const legacyRoot = `${KEY_ROOT}:`;
|
|
@@ -8,20 +8,18 @@ export interface HttpAgentOptions {
|
|
|
8
8
|
headers?: Record<string, string>;
|
|
9
9
|
/**
|
|
10
10
|
* Cookie policy for the run request, as `fetch`'s own `credentials` mode.
|
|
11
|
-
* Unset leaves the browser default (`same-origin`)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Unset leaves the browser default (`same-origin`), which sends no cookies
|
|
12
|
+
* to an agent endpoint on a different origin or subdomain. A
|
|
13
|
+
* cookie-authenticated cross-origin deployment needs `"include"` here plus a
|
|
14
|
+
* server answering with `Access-Control-Allow-Credentials: true` and a
|
|
15
|
+
* concrete origin.
|
|
16
16
|
*/
|
|
17
17
|
credentials?: RequestCredentials;
|
|
18
18
|
/**
|
|
19
|
-
* Live header source, re-read on
|
|
20
|
-
* static `headers`
|
|
21
|
-
* for the whole conversation
|
|
22
|
-
*
|
|
23
|
-
* mid-conversation. When set, the fetch wrapper overlays these values on
|
|
24
|
-
* each call; `headers` still seeds the initial/static configuration.
|
|
19
|
+
* Live header source, re-read on every request, overlaid on `headers`.
|
|
20
|
+
* `HttpAgent` bakes static `headers` in at construction and the element
|
|
21
|
+
* caches the agent for the whole conversation, so rotating credentials (CSRF,
|
|
22
|
+
* short-lived JWT) must come through here.
|
|
25
23
|
*/
|
|
26
24
|
getHeaders?: () => Record<string, string>;
|
|
27
25
|
/** Stable conversation id, so the agent's runs share a thread. */
|
|
@@ -37,11 +35,11 @@ export interface HttpAgentOptions {
|
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
/**
|
|
40
|
-
* Build an AG-UI {@link HttpAgent} pointed at
|
|
38
|
+
* Build an AG-UI {@link HttpAgent} pointed at `endpoint`.
|
|
41
39
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* The default agent factory for `<ag-ui-chat>`; tests and advanced hosts
|
|
41
|
+
* override the element's `agentFactory` to inject a different
|
|
42
|
+
* {@link AbstractAgent}.
|
|
45
43
|
*/
|
|
46
44
|
export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
47
45
|
return new HttpAgent({
|
|
@@ -49,12 +47,10 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
|
49
47
|
headers: options.headers ?? {},
|
|
50
48
|
initialState: { ...(options.initialState ?? {}) },
|
|
51
49
|
// HttpAgent invokes its configured fetch as a method (`this.fetch(...)`),
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// reaches the stream even though the agent instance is cached, and applies
|
|
57
|
-
// the configured cookie policy — the agent's own config has no seam for it.
|
|
50
|
+
// rebinding the global `fetch` to the agent instance — "Illegal invocation"
|
|
51
|
+
// in browsers. The wrapper keeps `fetch` a free call, and is also where the
|
|
52
|
+
// per-request `getHeaders()` overlay and the cookie policy go, the agent's
|
|
53
|
+
// own config having no seam for either.
|
|
58
54
|
fetch: (url, init) => {
|
|
59
55
|
const fresh = options.getHeaders?.();
|
|
60
56
|
if (fresh === undefined) {
|
|
@@ -67,7 +63,7 @@ export function createHttpAgent(options: HttpAgentOptions): AbstractAgent {
|
|
|
67
63
|
return fetch(url, withCredentials({ ...init, headers }, options.credentials));
|
|
68
64
|
},
|
|
69
65
|
// Spread conditionally: under `exactOptionalPropertyTypes` an explicit
|
|
70
|
-
// `undefined` is not assignable to
|
|
66
|
+
// `undefined` is not assignable to an optional field.
|
|
71
67
|
...(options.threadId !== undefined ? { threadId: options.threadId } : {}),
|
|
72
68
|
...(options.initialMessages !== undefined
|
|
73
69
|
? { initialMessages: [...options.initialMessages] }
|
|
@@ -19,10 +19,9 @@ interface ServerThreadRow {
|
|
|
19
19
|
type HeadersProvider = () => Record<string, string>;
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Live cookie policy, read per request. A provider rather than a value
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* during that first frame.
|
|
22
|
+
* Live cookie policy, read per request. A provider rather than a value: the
|
|
23
|
+
* store is built once on connect, but a host may configure the element after
|
|
24
|
+
* inserting it, and a captured value would pin that first frame's setting.
|
|
26
25
|
*/
|
|
27
26
|
type CredentialsProvider = () => RequestCredentials | undefined;
|
|
28
27
|
|
|
@@ -36,11 +35,11 @@ type CredentialsProvider = () => RequestCredentials | undefined;
|
|
|
36
35
|
* - `PATCH <url><id>/` → rename (`{ "title": … }`);
|
|
37
36
|
* - `DELETE <url><id>/` → delete.
|
|
38
37
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
38
|
+
* Wraps a local store (default {@link SessionStorageStore}) for the client-only
|
|
39
|
+
* concerns — active thread id, navigation checkpoint, message cache — and as
|
|
40
|
+
* the fallback when a request fails. Rename and delete apply optimistically via
|
|
41
|
+
* a local overlay, so the drawer reflects them before the fire-and-forget
|
|
42
|
+
* round-trip lands.
|
|
44
43
|
*/
|
|
45
44
|
export class RemoteConversationStore implements ClientConversationStore {
|
|
46
45
|
readonly #url: string;
|
|
@@ -70,6 +69,11 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
70
69
|
this.#local.setActiveThread(threadId);
|
|
71
70
|
}
|
|
72
71
|
|
|
72
|
+
/** Delegated, so wrapping a store does not lose what it knows about its own ids. */
|
|
73
|
+
isUnsent(threadId: string): boolean {
|
|
74
|
+
return this.#local.isUnsent?.(threadId) === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
73
77
|
saveMessages(threadId: string, messages: readonly Message[]): void {
|
|
74
78
|
// The agent run persists server-side; keep a local cache for offline replay.
|
|
75
79
|
this.#local.saveMessages(threadId, messages);
|
|
@@ -104,13 +108,22 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
async loadMessages(threadId: string): Promise<readonly Message[] | null> {
|
|
111
|
+
// Don't ask the server for a thread it cannot have. The element mints an id
|
|
112
|
+
// on first mount and immediately tries to restore it, so every first visit
|
|
113
|
+
// spent a request to be told `404` — and logged one in the console, on a page
|
|
114
|
+
// where nothing had gone wrong. Only the store that minted the id can say
|
|
115
|
+
// that; a thread chosen from the drawer, or one created on another device, is
|
|
116
|
+
// still fetched. See `ClientConversationStore.isUnsent`.
|
|
117
|
+
if (this.#local.isUnsent?.(threadId) === true) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
107
120
|
const response = await this.#get(`${this.#url}${encodeURIComponent(threadId)}/`);
|
|
108
121
|
if (response === null || !response.ok) {
|
|
109
122
|
return this.#local.loadMessages(threadId);
|
|
110
123
|
}
|
|
111
|
-
// A 200 whose body isn't
|
|
112
|
-
//
|
|
113
|
-
// `void #rehydrate()` swallows
|
|
124
|
+
// A 200 whose body isn't JSON (a proxy's HTML error page, a truncated
|
|
125
|
+
// stream) must not throw an unhandled rejection that the caller's
|
|
126
|
+
// `void #rehydrate()` swallows; fall back to the local cache.
|
|
114
127
|
const body = await this.#readJson<{ messages?: readonly Message[] }>(response);
|
|
115
128
|
if (body === null) {
|
|
116
129
|
return this.#local.loadMessages(threadId);
|
|
@@ -143,9 +156,9 @@ export class RemoteConversationStore implements ClientConversationStore {
|
|
|
143
156
|
return {
|
|
144
157
|
threadId: row.thread_id,
|
|
145
158
|
title: this.#renamed.get(row.thread_id) ?? row.title,
|
|
146
|
-
// `null`
|
|
147
|
-
// signal
|
|
148
|
-
//
|
|
159
|
+
// `null` and an unparseable date both become NaN, Date.parse's own
|
|
160
|
+
// signal, which `relativeTime` renders as a neutral label rather than an
|
|
161
|
+
// epoch-0 or NaN duration.
|
|
149
162
|
updatedAt: row.updated_at === null ? Number.NaN : Date.parse(row.updated_at),
|
|
150
163
|
preview: row.preview,
|
|
151
164
|
};
|