@artooi/ag-ui-web-component 0.38.0 → 0.40.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 +515 -1
- package/README.md +232 -56
- package/dist/ag-ui-web-component.bundle.js +370 -139
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +17 -14
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/activity_registry.d.ts +46 -0
- package/dist/core/activity_registry.d.ts.map +1 -0
- package/dist/core/ag_ui_chat.d.ts +23 -10
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +28 -0
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/core/client_seed.d.ts +27 -0
- package/dist/core/client_seed.d.ts.map +1 -0
- package/dist/core/read_max_tool_rounds.d.ts +9 -0
- package/dist/core/read_max_tool_rounds.d.ts.map +1 -0
- package/dist/core/run_handlers.d.ts +80 -0
- package/dist/core/run_handlers.d.ts.map +1 -0
- package/dist/core/storage_scope.d.ts +110 -0
- package/dist/core/storage_scope.d.ts.map +1 -0
- package/dist/core/tool_outcome.d.ts +2 -3
- package/dist/core/tool_outcome.d.ts.map +1 -1
- package/dist/core/utils.d.ts +46 -0
- package/dist/core/utils.d.ts.map +1 -1
- package/dist/index.js +16268 -14747
- package/dist/index.js.map +4 -4
- package/dist/skills/skill_catalog.d.ts +67 -0
- package/dist/skills/skill_catalog.d.ts.map +1 -0
- package/dist/skills/skill_name_from.d.ts +12 -0
- package/dist/skills/skill_name_from.d.ts.map +1 -0
- package/dist/tools/tool_catalog.d.ts +79 -0
- package/dist/tools/tool_catalog.d.ts.map +1 -0
- package/dist/tools/tool_dispatch.d.ts +104 -0
- package/dist/tools/tool_dispatch.d.ts.map +1 -0
- package/dist/ui/composer/attachment_tray.d.ts.map +1 -1
- package/dist/ui/composer/auto_grow.d.ts +10 -0
- package/dist/ui/composer/auto_grow.d.ts.map +1 -0
- package/dist/ui/composer/composer_attachments.d.ts +57 -0
- package/dist/ui/composer/composer_attachments.d.ts.map +1 -0
- package/dist/ui/composer/composer_voice.d.ts +48 -0
- package/dist/ui/composer/composer_voice.d.ts.map +1 -0
- package/dist/ui/composer/voice_input.d.ts.map +1 -1
- package/dist/ui/excerpts/transcript_quote_offer.d.ts +57 -0
- package/dist/ui/excerpts/transcript_quote_offer.d.ts.map +1 -0
- package/dist/ui/fill_ui_string.d.ts +32 -0
- package/dist/ui/fill_ui_string.d.ts.map +1 -0
- package/dist/ui/history/conversation_history.d.ts +203 -0
- package/dist/ui/history/conversation_history.d.ts.map +1 -0
- package/dist/ui/history/relative_time.d.ts.map +1 -1
- package/dist/ui/history/thread_drawer.d.ts +0 -14
- package/dist/ui/history/thread_drawer.d.ts.map +1 -1
- package/dist/ui/interrupts/confirmation_card.d.ts.map +1 -1
- package/dist/ui/interrupts/pending_decision.d.ts +23 -0
- package/dist/ui/interrupts/pending_decision.d.ts.map +1 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts +18 -0
- package/dist/ui/placement/is_collapsible_placement.d.ts.map +1 -0
- package/dist/ui/placement/is_draggable_placement.d.ts +9 -0
- package/dist/ui/placement/is_draggable_placement.d.ts.map +1 -0
- package/dist/ui/placement/launcher_drag.d.ts +6 -0
- package/dist/ui/placement/launcher_drag.d.ts.map +1 -1
- package/dist/ui/placement/panel_placement.d.ts +119 -0
- package/dist/ui/placement/panel_placement.d.ts.map +1 -0
- package/dist/ui/progress/run_announcer.d.ts +41 -0
- package/dist/ui/progress/run_announcer.d.ts.map +1 -0
- package/dist/ui/progress/subagent_progress.d.ts +58 -0
- package/dist/ui/progress/subagent_progress.d.ts.map +1 -0
- package/dist/ui/progress/tool_call_card.d.ts.map +1 -1
- package/dist/ui/shell/adopt_styles.d.ts +23 -0
- package/dist/ui/shell/adopt_styles.d.ts.map +1 -0
- package/dist/ui/shell/glyph_slot.d.ts +7 -0
- package/dist/ui/shell/glyph_slot.d.ts.map +1 -0
- package/dist/ui/shell/header_button.d.ts +10 -0
- package/dist/ui/shell/header_button.d.ts.map +1 -0
- package/dist/ui/shell/icon_element.d.ts +12 -0
- package/dist/ui/shell/icon_element.d.ts.map +1 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts +10 -0
- package/dist/ui/shell/is_unread_badge_enabled.d.ts.map +1 -0
- package/dist/ui/shell/read_launcher_icon_url.d.ts +8 -0
- package/dist/ui/shell/read_launcher_icon_url.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/transcript/answer_actions.d.ts +39 -0
- package/dist/ui/transcript/answer_actions.d.ts.map +1 -0
- package/dist/ui/transcript/answer_stream.d.ts +56 -0
- package/dist/ui/transcript/answer_stream.d.ts.map +1 -0
- package/dist/ui/transcript/render_or_warn.d.ts +16 -0
- package/dist/ui/transcript/render_or_warn.d.ts.map +1 -0
- package/dist/ui/transcript/starter_chips.d.ts +21 -0
- package/dist/ui/transcript/starter_chips.d.ts.map +1 -0
- package/dist/ui/transcript/transcript.d.ts +171 -0
- package/dist/ui/transcript/transcript.d.ts.map +1 -0
- package/dist/ui/ui_strings.d.ts +29 -1
- package/dist/ui/ui_strings.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +17 -15
- package/src/core/activity_registry.ts +133 -0
- package/src/core/ag_ui_chat.ts +945 -4419
- package/src/core/agui_client.ts +172 -16
- package/src/core/client_seed.ts +27 -0
- package/src/core/read_max_tool_rounds.ts +13 -0
- package/src/core/remote_conversation_store.ts +1 -1
- package/src/core/run_handlers.ts +501 -0
- package/src/core/storage_scope.ts +279 -0
- package/src/core/tool_outcome.ts +5 -3
- package/src/core/utils.ts +109 -0
- package/src/skills/skill_catalog.ts +166 -0
- package/src/skills/skill_name_from.ts +19 -0
- package/src/tools/tool_catalog.ts +354 -0
- package/src/tools/tool_dispatch.ts +521 -0
- package/src/ui/composer/attachment_tray.ts +6 -5
- package/src/ui/composer/auto_grow.ts +12 -0
- package/src/ui/composer/composer_attachments.ts +324 -0
- package/src/ui/composer/composer_voice.ts +93 -0
- package/src/ui/composer/voice_input.ts +4 -4
- package/src/ui/excerpts/transcript_quote_offer.ts +198 -0
- package/src/ui/fill_ui_string.ts +41 -0
- package/src/ui/history/conversation_history.ts +699 -0
- package/src/ui/history/relative_time.ts +5 -4
- package/src/ui/history/thread_drawer.ts +3 -50
- package/src/ui/interrupts/confirmation_card.ts +4 -2
- package/src/ui/interrupts/pending_decision.ts +34 -0
- package/src/ui/placement/is_collapsible_placement.ts +19 -0
- package/src/ui/placement/is_draggable_placement.ts +23 -0
- package/src/ui/placement/launcher_drag.ts +104 -89
- package/src/ui/placement/panel_placement.ts +1130 -0
- package/src/ui/progress/run_announcer.ts +67 -0
- package/src/ui/progress/subagent_progress.ts +201 -0
- package/src/ui/progress/tool_call_card.ts +2 -0
- package/src/ui/shell/adopt_styles.ts +28 -0
- package/src/ui/shell/glyph_slot.ts +12 -0
- package/src/ui/shell/header_button.ts +21 -0
- package/src/ui/shell/icon_element.ts +33 -0
- package/src/ui/shell/is_unread_badge_enabled.ts +11 -0
- package/src/ui/shell/read_launcher_icon_url.ts +9 -0
- package/src/ui/styles.ts +301 -70
- package/src/ui/transcript/answer_actions.ts +139 -0
- package/src/ui/transcript/answer_stream.ts +123 -0
- package/src/ui/transcript/render_or_warn.ts +22 -0
- package/src/ui/transcript/starter_chips.ts +42 -0
- package/src/ui/transcript/transcript.ts +429 -0
- package/src/ui/ui_strings.ts +38 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CUSTOM_AGENT_EVENT,
|
|
3
|
+
INVALIDATE_CUSTOM_NAME,
|
|
4
|
+
INVALIDATE_EVENT,
|
|
5
|
+
MESSAGE_ROLE,
|
|
6
|
+
RUN_FINISHED_EVENT,
|
|
7
|
+
SUBAGENT_CUSTOM_NAME,
|
|
8
|
+
TOOL_CALL_STATUS,
|
|
9
|
+
} from "../constants.js";
|
|
10
|
+
import { attachCopyButtons } from "../ui/excerpts/attach_copy_buttons.js";
|
|
11
|
+
import type { RunAnnouncer } from "../ui/progress/run_announcer.js";
|
|
12
|
+
import type { SubAgentProgress } from "../ui/progress/subagent_progress.js";
|
|
13
|
+
import type { AnswerActions } from "../ui/transcript/answer_actions.js";
|
|
14
|
+
import type { AnswerStream } from "../ui/transcript/answer_stream.js";
|
|
15
|
+
import type { Transcript } from "../ui/transcript/transcript.js";
|
|
16
|
+
import type { UiStrings } from "../ui/ui_strings.js";
|
|
17
|
+
import type { ActivityRegistry } from "./activity_registry.js";
|
|
18
|
+
import type { AgUiClientHandlers } from "./agui_client.js";
|
|
19
|
+
import type { CustomAgentDetail } from "./events/custom_agent_detail.js";
|
|
20
|
+
import type { InvalidateDetail } from "./events/invalidate_detail.js";
|
|
21
|
+
import type { RunFinishedDetail } from "./events/run_finished_detail.js";
|
|
22
|
+
import type { ToolRun } from "./events/tool_run.js";
|
|
23
|
+
import type { MessageRole } from "./message_role.js";
|
|
24
|
+
import { toolStatusFromOutcome } from "./tool_outcome.js";
|
|
25
|
+
|
|
26
|
+
/** What the run's event handlers need from the element that owns them. */
|
|
27
|
+
export interface RunHandlersHost {
|
|
28
|
+
/** The custom element, which the run's events are dispatched on. */
|
|
29
|
+
readonly element: HTMLElement;
|
|
30
|
+
/** The transcript the run draws into. */
|
|
31
|
+
readonly transcript: Transcript;
|
|
32
|
+
/** The answer streaming into its bubble. */
|
|
33
|
+
readonly stream: AnswerStream;
|
|
34
|
+
/** The action row under each finished answer. */
|
|
35
|
+
readonly actions: AnswerActions;
|
|
36
|
+
/** The activity renderers, and the blocks they drew. */
|
|
37
|
+
readonly activities: ActivityRegistry;
|
|
38
|
+
/** A delegated sub-agent's progress, on the card that delegated. */
|
|
39
|
+
readonly subagents: SubAgentProgress;
|
|
40
|
+
/** The screen-reader status region. */
|
|
41
|
+
readonly announcer: RunAnnouncer;
|
|
42
|
+
/** The resolved string table. */
|
|
43
|
+
readonly strings: () => UiStrings;
|
|
44
|
+
/** Whether an interaction is already in flight, which the composer owns. */
|
|
45
|
+
readonly running: () => boolean;
|
|
46
|
+
/** Swap the composer between Send and Stop; settling sends a queued turn. */
|
|
47
|
+
readonly setRunning: (running: boolean) => void;
|
|
48
|
+
/** The element's `appendMessage`, which a failed run's bubble opens through. */
|
|
49
|
+
readonly appendMessage: (role: MessageRole, content: string) => HTMLDivElement;
|
|
50
|
+
/** Count an answer that finished while the widget was collapsed. */
|
|
51
|
+
readonly noteUnread: () => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What one conversation's runs accumulate for the host between an
|
|
56
|
+
* interaction's first round and its settle.
|
|
57
|
+
*
|
|
58
|
+
* A record per conversation rather than fields on the controller, because a
|
|
59
|
+
* run can outlive the conversation it belongs to. Cancelling is a request, not
|
|
60
|
+
* an ending: a stopped run reports how it ended once its request has closed --
|
|
61
|
+
* or once a host tool's handler, which cancelling cannot interrupt, has
|
|
62
|
+
* returned -- and by then New chat may have cleared the transcript and the next
|
|
63
|
+
* message may already be running. Each handler table keeps the record it was
|
|
64
|
+
* built with, so that report describes the run that made it and nothing the
|
|
65
|
+
* next conversation did.
|
|
66
|
+
*/
|
|
67
|
+
interface RunLedger {
|
|
68
|
+
/**
|
|
69
|
+
* Tool calls made during the current interaction, in the order they started,
|
|
70
|
+
* so {@link RUN_FINISHED_EVENT} can report them once the whole thing settles.
|
|
71
|
+
* Spans tool rounds and an approval interrupt; emptied when the event fires.
|
|
72
|
+
*/
|
|
73
|
+
readonly tools: { readonly id: string; readonly name: string }[];
|
|
74
|
+
/**
|
|
75
|
+
* Which of those calls had a result streamed back, and so ran on the server.
|
|
76
|
+
*
|
|
77
|
+
* The transcript keeps the same fact for its cards, and this used to be read
|
|
78
|
+
* from there. But New chat clears the transcript, and a run it cut off
|
|
79
|
+
* reports afterwards -- so a booking the server made came out as the host's
|
|
80
|
+
* own tool, which is the one side the documented listener skips a refetch for.
|
|
81
|
+
*/
|
|
82
|
+
readonly serverSettled: Set<string>;
|
|
83
|
+
/**
|
|
84
|
+
* Keys announced during this interaction, de-duplicated in first-seen order.
|
|
85
|
+
*
|
|
86
|
+
* Per element, never module-level: a second mounted chat is a second run, and
|
|
87
|
+
* sharing this would tell one page to refetch on the other's writes. Emptied
|
|
88
|
+
* by {@link RunHandlers.#dispatchRunFinished}, which is the one place that has
|
|
89
|
+
* read it.
|
|
90
|
+
*/
|
|
91
|
+
readonly invalidated: Set<string>;
|
|
92
|
+
/**
|
|
93
|
+
* Whether this turn already announced how it ended.
|
|
94
|
+
*
|
|
95
|
+
* `onSettled` is the terminal guarantee and fires however the run ended, so
|
|
96
|
+
* it is the only place that can promise the user hears *something*. But a
|
|
97
|
+
* stopped or failed run has already said the truer thing from `onCancelled`
|
|
98
|
+
* or `onError`, and "assistant answered" after "response stopped" is worse
|
|
99
|
+
* than silence.
|
|
100
|
+
*/
|
|
101
|
+
announcedOutcome: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** An empty ledger, for a conversation nothing has run in yet. */
|
|
105
|
+
function emptyLedger(): RunLedger {
|
|
106
|
+
return {
|
|
107
|
+
tools: [],
|
|
108
|
+
serverSettled: new Set<string>(),
|
|
109
|
+
invalidated: new Set<string>(),
|
|
110
|
+
announcedOutcome: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Handlers with the same signatures as `table`, each doing nothing once `attached` is false. */
|
|
115
|
+
function whileAttached<T extends object>(table: T, attached: () => boolean): T {
|
|
116
|
+
const guarded: Record<string, (...args: unknown[]) => void> = {};
|
|
117
|
+
for (const [name, handler] of Object.entries(table) as [string, (...args: unknown[]) => void][]) {
|
|
118
|
+
guarded[name] = (...args) => {
|
|
119
|
+
if (attached()) {
|
|
120
|
+
handler(...args);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return guarded as T;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The AG-UI event handlers a run is drawn by, and what one interaction
|
|
129
|
+
* accumulates for the host between its first round and its settle: the tool
|
|
130
|
+
* calls it made, the keys it invalidated, and whether it already said how it
|
|
131
|
+
* ended.
|
|
132
|
+
*
|
|
133
|
+
* A controller rather than a factory. The handlers hold nothing of their own,
|
|
134
|
+
* but that bookkeeping is state, and it has never belonged to one handler
|
|
135
|
+
* table: a table is built per client -- the conversation's own, rebuilt after
|
|
136
|
+
* a reset, and one for each checkpoint continuation -- and every table built
|
|
137
|
+
* for one conversation reports into the same bookkeeping. {@link forClient}
|
|
138
|
+
* still returns a fresh table per call.
|
|
139
|
+
*
|
|
140
|
+
* Owned one-to-one by an `<ag-ui-chat>`, and holding no state outside the
|
|
141
|
+
* instance.
|
|
142
|
+
*/
|
|
143
|
+
export class RunHandlers {
|
|
144
|
+
readonly #host: RunHandlersHost;
|
|
145
|
+
/**
|
|
146
|
+
* The bookkeeping of the conversation on screen. Replaced rather than
|
|
147
|
+
* emptied by {@link detach}, so a table built before then keeps its own.
|
|
148
|
+
*/
|
|
149
|
+
#ledger: RunLedger = emptyLedger();
|
|
150
|
+
|
|
151
|
+
constructor(host: RunHandlersHost) {
|
|
152
|
+
this.#host = host;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Detach every handler table built so far, because the conversation they
|
|
157
|
+
* drew into has been cleared away.
|
|
158
|
+
*
|
|
159
|
+
* The element calls this when it resets, having first cancelled what was
|
|
160
|
+
* running. A cancelled run still ends later, and a detached table still
|
|
161
|
+
* reports to the host what that run did: a run stopped part-way may already
|
|
162
|
+
* have written something, and {@link RUN_FINISHED_EVENT} fires on
|
|
163
|
+
* cancellation for exactly that reason. Everything else it would do -- draw a
|
|
164
|
+
* stopped note, announce, put the composer back to Send, settle the pending
|
|
165
|
+
* cards -- would land in a transcript and a composer that now belong to
|
|
166
|
+
* another conversation, possibly mid-run, and is dropped. So is any event the
|
|
167
|
+
* run streams after the reset, invalidations included: an abort closes the
|
|
168
|
+
* request those would arrive on, so what a cancelled run still delivers is
|
|
169
|
+
* how it ended.
|
|
170
|
+
*/
|
|
171
|
+
detach(): void {
|
|
172
|
+
this.#ledger = emptyLedger();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A handler table for one AG-UI client. Every table built for the current
|
|
177
|
+
* conversation draws into the same transcript and shares its bookkeeping, so
|
|
178
|
+
* a continuation run reports into the interaction the user is looking at.
|
|
179
|
+
*/
|
|
180
|
+
forClient(): AgUiClientHandlers {
|
|
181
|
+
const ledger = this.#ledger;
|
|
182
|
+
const attached = (): boolean => ledger === this.#ledger;
|
|
183
|
+
const { onSettled, ...drawing } = this.#tableFor(ledger);
|
|
184
|
+
return {
|
|
185
|
+
...whileAttached(drawing, attached),
|
|
186
|
+
onSettled: () => {
|
|
187
|
+
if (attached()) {
|
|
188
|
+
onSettled();
|
|
189
|
+
}
|
|
190
|
+
// Last, so a listener that refetches sees a transcript that has already
|
|
191
|
+
// stopped changing -- and outside the check, because what a run did is
|
|
192
|
+
// true whichever conversation is on screen when it ends.
|
|
193
|
+
this.#dispatchRunFinished(ledger);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The handler table itself, recording into `ledger`. {@link forClient}
|
|
200
|
+
* decides how much of it still runs once the table is detached.
|
|
201
|
+
*/
|
|
202
|
+
#tableFor(ledger: RunLedger): AgUiClientHandlers {
|
|
203
|
+
return {
|
|
204
|
+
onRunStart: () => {
|
|
205
|
+
// Per *round*, so guard on the turn: a run that calls three tools fires
|
|
206
|
+
// this three times and the user needs telling once.
|
|
207
|
+
if (!this.#host.running()) {
|
|
208
|
+
ledger.announcedOutcome = false;
|
|
209
|
+
this.#host.announcer.announce(this.#host.strings().announceResponding);
|
|
210
|
+
}
|
|
211
|
+
this.#host.setRunning(true);
|
|
212
|
+
// Open the answer group on the turn's first run so the pending
|
|
213
|
+
// indicator (and everything after) lands inside the well. Idempotent:
|
|
214
|
+
// later rounds of the same turn reuse it.
|
|
215
|
+
this.#host.transcript.ensureGroup();
|
|
216
|
+
this.#host.transcript.showPending();
|
|
217
|
+
},
|
|
218
|
+
onReasoningStart: () => {
|
|
219
|
+
// The model is thinking: swap the pending dots for a live thoughts
|
|
220
|
+
// region at the top of the turn's answer group.
|
|
221
|
+
this.#host.transcript.hidePending();
|
|
222
|
+
this.#host.transcript.showThoughts();
|
|
223
|
+
},
|
|
224
|
+
onReasoningDelta: (buffer) => {
|
|
225
|
+
this.#host.transcript.showThoughts().stream(buffer);
|
|
226
|
+
},
|
|
227
|
+
onReasoningEnd: () => {
|
|
228
|
+
// Leave the region expanded until the answer text starts — it collapses
|
|
229
|
+
// on the first text delta (onTextDelta).
|
|
230
|
+
},
|
|
231
|
+
onTextDelta: (buffer) => {
|
|
232
|
+
this.#host.transcript.hidePending();
|
|
233
|
+
// The answer has begun — fold the thoughts away so they don't crowd it.
|
|
234
|
+
this.#host.transcript.collapseThoughts();
|
|
235
|
+
this.#host.stream.queue(buffer);
|
|
236
|
+
this.#host.stream.countDelta();
|
|
237
|
+
},
|
|
238
|
+
onTextEnd: (buffer) => {
|
|
239
|
+
// A text message that carried no content is a declaration, not an
|
|
240
|
+
// answer, and drawing one puts an empty bubble above every tool call.
|
|
241
|
+
//
|
|
242
|
+
// `TOOL_CALL_START` names the assistant message a call belongs to, and
|
|
243
|
+
// a response whose first part is a tool call has no text to open that
|
|
244
|
+
// message with. pydantic-ai 2.37 started opening and closing an empty
|
|
245
|
+
// one there so the id names a message the stream actually announced --
|
|
246
|
+
// before that it named one no event carried, which a client could only
|
|
247
|
+
// answer by inventing an id of its own that matches nothing echoed
|
|
248
|
+
// back. So this envelope is a correctness fix upstream, it is legal
|
|
249
|
+
// AG-UI, and any server may send one.
|
|
250
|
+
//
|
|
251
|
+
// `ConversationHistory.replay` already declines to draw a bubble for
|
|
252
|
+
// an assistant message with no text. Without the same rule
|
|
253
|
+
// here the live transcript and the reloaded one disagree about the
|
|
254
|
+
// same conversation, which is the harder half of the bug to notice.
|
|
255
|
+
if (buffer === "") {
|
|
256
|
+
this.#host.stream.end();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const bubble = this.#host.stream.into(buffer);
|
|
260
|
+
// Only reveal word-by-word when the message arrived at once. If it
|
|
261
|
+
// streamed across multiple deltas it already revealed progressively, so
|
|
262
|
+
// wrapping it now would re-animate the whole message — the awkward
|
|
263
|
+
// "finished response replays one word at a time" bug.
|
|
264
|
+
if (this.#host.stream.deltas <= 1) {
|
|
265
|
+
this.#host.transcript.revealWords(bubble);
|
|
266
|
+
}
|
|
267
|
+
attachCopyButtons(bubble, this.#host.strings());
|
|
268
|
+
this.#host.actions.attach(bubble);
|
|
269
|
+
this.#host.stream.end();
|
|
270
|
+
this.#host.noteUnread();
|
|
271
|
+
},
|
|
272
|
+
onToolCall: (call) => {
|
|
273
|
+
this.#host.transcript.hidePending();
|
|
274
|
+
// A skill activation is an ordinary `load_capability` tool call — the
|
|
275
|
+
// deferred-capability mechanism pydantic-ai already uses — so it arrives
|
|
276
|
+
// here rather than on a channel of its own. Render it as a notice and
|
|
277
|
+
// *return*: falling through would show a raw tool card beside the chip,
|
|
278
|
+
// which is worse than the card alone.
|
|
279
|
+
if (this.#host.transcript.noticeIfSkillLoad(call)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Recorded after the skill-load return: a capability load is the agent
|
|
283
|
+
// arranging itself, not work a host's data could have moved under.
|
|
284
|
+
ledger.tools.push({ id: call.id, name: call.name });
|
|
285
|
+
this.#host.transcript.cardFor(call);
|
|
286
|
+
},
|
|
287
|
+
onActivity: (activityType, content, messageId) => {
|
|
288
|
+
this.#host.activities.draw(messageId, activityType, content);
|
|
289
|
+
},
|
|
290
|
+
onCustomEvent: (name, value) => {
|
|
291
|
+
if (name === INVALIDATE_CUSTOM_NAME) {
|
|
292
|
+
this.#dispatchInvalidation(ledger, value);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (name === SUBAGENT_CUSTOM_NAME) {
|
|
296
|
+
this.#host.subagents.report(value);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// Straight out to the host page, uninterpreted. This is the imperative
|
|
300
|
+
// carrier: whatever it means, it means it to the page, not to the
|
|
301
|
+
// transcript -- so it is dispatched and deliberately not rendered,
|
|
302
|
+
// persisted or replayed. A host that does not know the name simply has
|
|
303
|
+
// no listener, which is the graceful outcome the open field is for.
|
|
304
|
+
this.#host.element.dispatchEvent(
|
|
305
|
+
new CustomEvent<CustomAgentDetail>(CUSTOM_AGENT_EVENT, {
|
|
306
|
+
detail: { name, value },
|
|
307
|
+
bubbles: true,
|
|
308
|
+
composed: true,
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
// The delegation's own lifetime, on the protocol's events rather than the
|
|
313
|
+
// CUSTOM channel its steps ride. Both end at the same panel.
|
|
314
|
+
onSubAgentStarted: (subagentRunId, agent, parentToolCallId) => {
|
|
315
|
+
this.#host.subagents.start(subagentRunId, agent, parentToolCallId);
|
|
316
|
+
},
|
|
317
|
+
onSubAgentFinished: (subagentRunId) => {
|
|
318
|
+
this.#host.subagents.finish(subagentRunId);
|
|
319
|
+
},
|
|
320
|
+
onSubAgentError: (subagentRunId, message) => {
|
|
321
|
+
this.#host.subagents.fail(subagentRunId, message);
|
|
322
|
+
},
|
|
323
|
+
onMessagesSnapshot: () => {
|
|
324
|
+
// Honoured for persistence and announced, not re-rendered.
|
|
325
|
+
//
|
|
326
|
+
// The store follows the server, because the server is authoritative
|
|
327
|
+
// about what the conversation *is* -- and it would follow it anyway:
|
|
328
|
+
// `@ag-ui/client` replaces `agent.messages` before any subscriber runs,
|
|
329
|
+
// and the run loop persists `agent.messages`. What was wrong was that
|
|
330
|
+
// it happened in silence, so the screen and the store disagreed and
|
|
331
|
+
// nobody found out until a reload served a transcript they had never
|
|
332
|
+
// seen. That is not reportable as a bug; it is reportable as "the chat
|
|
333
|
+
// lost my messages".
|
|
334
|
+
//
|
|
335
|
+
// Re-rendering from the snapshot was the other candidate and is
|
|
336
|
+
// declined: a snapshot can land mid-run, and rebuilding the transcript
|
|
337
|
+
// then would destroy the in-flight run's own UI state -- the streaming
|
|
338
|
+
// bubble, the open answer group, and every tool card keyed by call id,
|
|
339
|
+
// some of which are still waiting on results. Telling the reader costs
|
|
340
|
+
// none of that, and this is the same answer the same question already
|
|
341
|
+
// got for compaction, one handler up.
|
|
342
|
+
this.#host.transcript.appendNotice(
|
|
343
|
+
"\u{1F504}",
|
|
344
|
+
this.#host.strings().historyReplaced,
|
|
345
|
+
"history-replaced",
|
|
346
|
+
);
|
|
347
|
+
},
|
|
348
|
+
onToolResult: (toolCallId, content, outcome) => {
|
|
349
|
+
const card = this.#host.transcript.card(toolCallId);
|
|
350
|
+
if (card === undefined) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Settled as the server says it ended, not as "it ended". This path used
|
|
354
|
+
// to pass DONE unconditionally, so a refusal arrived as a green card
|
|
355
|
+
// with the reason folded inside it -- a booking the server declined
|
|
356
|
+
// read, at a glance, as a booking that was made. An absent or
|
|
357
|
+
// unrecognised outcome still means DONE, so every server written before
|
|
358
|
+
// the field existed renders exactly as it did.
|
|
359
|
+
card.settle(toolStatusFromOutcome(outcome), content);
|
|
360
|
+
this.#host.transcript.markServerSettled(toolCallId);
|
|
361
|
+
ledger.serverSettled.add(toolCallId);
|
|
362
|
+
// The card stops being the live thing the moment it settles, and the
|
|
363
|
+
// server goes straight back to the model with the result -- a wait with
|
|
364
|
+
// nothing on screen to own it, and the longest one in a run when the
|
|
365
|
+
// result is a large inlined attachment being re-sent with every request.
|
|
366
|
+
// The dots go back where ``onToolCall`` took them from, after the card,
|
|
367
|
+
// and whatever comes next clears them: reasoning, the first text delta,
|
|
368
|
+
// the round ending, or ``onSettled``'s terminal guarantee.
|
|
369
|
+
//
|
|
370
|
+
// Not the same case as the one ``ToolDispatch.execute`` refuses to show
|
|
371
|
+
// them for. That runs after the run has ended, so there is nothing left to
|
|
372
|
+
// clear them and they would hang -- which is what happened before 0.2.1
|
|
373
|
+
// and is why they were removed from here too. The terminal guarantee
|
|
374
|
+
// that shipped in the same release is what makes showing them safe now.
|
|
375
|
+
this.#host.transcript.showPending();
|
|
376
|
+
},
|
|
377
|
+
onActivityChanged: (messageId, activityType, content) => {
|
|
378
|
+
this.#host.activities.draw(messageId, activityType, content);
|
|
379
|
+
},
|
|
380
|
+
onRunEnd: () => {
|
|
381
|
+
// Per-round end; the button stays on Stop until the whole interaction
|
|
382
|
+
// settles — the user must be able to cancel between tool rounds.
|
|
383
|
+
this.#host.transcript.hidePending();
|
|
384
|
+
this.#host.stream.end();
|
|
385
|
+
},
|
|
386
|
+
onError: (message) => {
|
|
387
|
+
ledger.announcedOutcome = true;
|
|
388
|
+
this.#host.announcer.announce(this.#host.strings().announceFailed);
|
|
389
|
+
this.#host.transcript.hidePending();
|
|
390
|
+
const bubble = this.#host.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
|
|
391
|
+
bubble.classList.add("message--failed");
|
|
392
|
+
// A failure is the one message whose action row is only worth having
|
|
393
|
+
// for Retry: there is nothing here worth copying and nothing to rate.
|
|
394
|
+
// A dropped connection with no way back was the whole of the gap --
|
|
395
|
+
// uploads had a retry and runs did not.
|
|
396
|
+
//
|
|
397
|
+
// Not a `run-notice`: that element's contract is that it "never
|
|
398
|
+
// settles, takes no action, and carries no controls", and is explicitly
|
|
399
|
+
// "distinct from an error, which is a failure". This is a failure, so
|
|
400
|
+
// it stays an error and gains the control instead.
|
|
401
|
+
this.#host.actions.attach(bubble, { rateable: false });
|
|
402
|
+
this.#host.transcript.revealWords(bubble);
|
|
403
|
+
this.#host.stream.end();
|
|
404
|
+
},
|
|
405
|
+
onCancelled: () => {
|
|
406
|
+
// Deliberate stop, not a failure: keep whatever partial text already
|
|
407
|
+
// streamed and add a muted note instead of an error bubble.
|
|
408
|
+
ledger.announcedOutcome = true;
|
|
409
|
+
this.#host.announcer.announce(this.#host.strings().announceStopped);
|
|
410
|
+
this.#host.transcript.hidePending();
|
|
411
|
+
this.#host.transcript.appendStoppedNote();
|
|
412
|
+
this.#host.stream.end();
|
|
413
|
+
},
|
|
414
|
+
onSettled: () => {
|
|
415
|
+
// Terminal guarantee: whatever path ended the run, return to rest.
|
|
416
|
+
if (!ledger.announcedOutcome) {
|
|
417
|
+
this.#host.announcer.announce(this.#host.strings().announceAnswerReady);
|
|
418
|
+
}
|
|
419
|
+
this.#host.transcript.hidePending();
|
|
420
|
+
this.#host.setRunning(false);
|
|
421
|
+
this.#host.stream.end();
|
|
422
|
+
// A tool card still pending at settle would hang forever: a call Stop or
|
|
423
|
+
// RUN_ERROR ended before it ran, a server tool whose result never
|
|
424
|
+
// streamed because the connection dropped, an approved call Stop kept
|
|
425
|
+
// from resuming. None of them finished and nobody refused them, so the
|
|
426
|
+
// card says exactly that, in the words the client sends the agent for
|
|
427
|
+
// the same call on the next request.
|
|
428
|
+
for (const card of this.#host.transcript.cards()) {
|
|
429
|
+
if (!card.settled) {
|
|
430
|
+
card.settle(TOOL_CALL_STATUS.INTERRUPTED, this.#host.strings().callNotFinished);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
this.#host.transcript.closeGroup();
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Tell the host the interaction is over and what ran in it.
|
|
440
|
+
*
|
|
441
|
+
* Last thing in `onSettled`, so a listener that refetches sees a transcript
|
|
442
|
+
* that has already stopped changing. `side` is read from the streamed-result
|
|
443
|
+
* bookkeeping rather than from the tool list: whether a call executed on the
|
|
444
|
+
* server is a fact about the run, and a name can appear on both sides across a
|
|
445
|
+
* conversation.
|
|
446
|
+
*/
|
|
447
|
+
#dispatchRunFinished(ledger: RunLedger): void {
|
|
448
|
+
const tools: ToolRun[] = ledger.tools.map(({ id, name }) => ({
|
|
449
|
+
name,
|
|
450
|
+
side: ledger.serverSettled.has(id) ? "server" : "client",
|
|
451
|
+
}));
|
|
452
|
+
const invalidated = [...ledger.invalidated];
|
|
453
|
+
ledger.tools.length = 0;
|
|
454
|
+
ledger.serverSettled.clear();
|
|
455
|
+
ledger.invalidated.clear();
|
|
456
|
+
this.#host.element.dispatchEvent(
|
|
457
|
+
new CustomEvent<RunFinishedDetail>(RUN_FINISHED_EVENT, {
|
|
458
|
+
detail: { tools, invalidated },
|
|
459
|
+
bubbles: true,
|
|
460
|
+
composed: true,
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Route one invalidation to the host, and remember it for the run summary.
|
|
467
|
+
*
|
|
468
|
+
* Dispatched immediately rather than only at the end, because that is what
|
|
469
|
+
* makes a long multi-step run feel live -- the list refreshes as the third of
|
|
470
|
+
* eight writes lands. The accumulated set rides
|
|
471
|
+
* {@link RUN_FINISHED_EVENT} as well, so a host that would rather refetch once
|
|
472
|
+
* upgrades by reading one extra field instead of adding a listener.
|
|
473
|
+
*
|
|
474
|
+
* Nothing is rendered, persisted or replayed. An invalidation is an
|
|
475
|
+
* imperative: it has no place in the transcript and no meaning once acted on,
|
|
476
|
+
* and replaying one on every thread load would be a refetch storm. That is the
|
|
477
|
+
* whole reason the server sends it as `CUSTOM` rather than as an activity.
|
|
478
|
+
*/
|
|
479
|
+
#dispatchInvalidation(ledger: RunLedger, value: unknown): void {
|
|
480
|
+
const payload = (value ?? {}) as { keys?: unknown; reason?: unknown };
|
|
481
|
+
// Defensive about the payload, not about the name: `value` is typed
|
|
482
|
+
// `unknown` by the protocol, so a server can put anything there, and a
|
|
483
|
+
// malformed announcement must not take the run down with it.
|
|
484
|
+
const keys = Array.isArray(payload.keys)
|
|
485
|
+
? payload.keys.filter((key): key is string => typeof key === "string")
|
|
486
|
+
: [];
|
|
487
|
+
if (keys.length === 0) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
for (const key of keys) {
|
|
491
|
+
ledger.invalidated.add(key);
|
|
492
|
+
}
|
|
493
|
+
this.#host.element.dispatchEvent(
|
|
494
|
+
new CustomEvent<InvalidateDetail>(INVALIDATE_EVENT, {
|
|
495
|
+
detail: { keys, reason: typeof payload.reason === "string" ? payload.reason : null },
|
|
496
|
+
bubbles: true,
|
|
497
|
+
composed: true,
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|