@artooi/ag-ui-web-component 0.1.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +471 -0
  4. package/dist/ag-ui-web-component.bundle.js +319 -0
  5. package/dist/ag-ui-web-component.bundle.js.map +7 -0
  6. package/dist/ag_ui_chat.d.ts +85 -0
  7. package/dist/ag_ui_chat.d.ts.map +1 -0
  8. package/dist/agui_client.d.ts +99 -0
  9. package/dist/agui_client.d.ts.map +1 -0
  10. package/dist/animations.d.ts +33 -0
  11. package/dist/animations.d.ts.map +1 -0
  12. package/dist/client_tool_registry.d.ts +32 -0
  13. package/dist/client_tool_registry.d.ts.map +1 -0
  14. package/dist/confirmation_modal.d.ts +14 -0
  15. package/dist/confirmation_modal.d.ts.map +1 -0
  16. package/dist/constants.d.ts +40 -0
  17. package/dist/constants.d.ts.map +1 -0
  18. package/dist/conversation_store.d.ts +54 -0
  19. package/dist/conversation_store.d.ts.map +1 -0
  20. package/dist/create_http_agent.d.ts +22 -0
  21. package/dist/create_http_agent.d.ts.map +1 -0
  22. package/dist/define_ag_ui_chat.d.ts +9 -0
  23. package/dist/define_ag_ui_chat.d.ts.map +1 -0
  24. package/dist/dom_driver.d.ts +24 -0
  25. package/dist/dom_driver.d.ts.map +1 -0
  26. package/dist/index.d.ts +18 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +1159 -0
  29. package/dist/index.js.map +7 -0
  30. package/dist/is_destructive.d.ts +8 -0
  31. package/dist/is_destructive.d.ts.map +1 -0
  32. package/dist/is_navigates.d.ts +9 -0
  33. package/dist/is_navigates.d.ts.map +1 -0
  34. package/dist/page_map.d.ts +16 -0
  35. package/dist/page_map.d.ts.map +1 -0
  36. package/dist/route_map.d.ts +27 -0
  37. package/dist/route_map.d.ts.map +1 -0
  38. package/dist/state_hook.d.ts +23 -0
  39. package/dist/state_hook.d.ts.map +1 -0
  40. package/dist/styles.d.ts +2 -0
  41. package/dist/styles.d.ts.map +1 -0
  42. package/dist/tool_call_card.d.ts +29 -0
  43. package/dist/tool_call_card.d.ts.map +1 -0
  44. package/dist/version.d.ts +2 -0
  45. package/dist/version.d.ts.map +1 -0
  46. package/package.json +79 -0
  47. package/src/ag_ui_chat.ts +411 -0
  48. package/src/agui_client.ts +212 -0
  49. package/src/animations.ts +86 -0
  50. package/src/client_tool_registry.ts +56 -0
  51. package/src/confirmation_modal.ts +69 -0
  52. package/src/constants.ts +48 -0
  53. package/src/conversation_store.ts +103 -0
  54. package/src/create_http_agent.ts +40 -0
  55. package/src/define_ag_ui_chat.ts +15 -0
  56. package/src/dom_driver.ts +60 -0
  57. package/src/index.ts +60 -0
  58. package/src/is_destructive.ts +11 -0
  59. package/src/is_navigates.ts +12 -0
  60. package/src/page_map.ts +25 -0
  61. package/src/route_map.ts +83 -0
  62. package/src/state_hook.ts +44 -0
  63. package/src/styles.ts +296 -0
  64. package/src/tool_call_card.ts +95 -0
  65. package/src/version.ts +1 -0
@@ -0,0 +1,411 @@
1
+ import type { Context, Message, Tool } from "@ag-ui/core";
2
+ import {
3
+ AgUiClient,
4
+ type AgUiClientHandlers,
5
+ type AgUiToolCall,
6
+ type ToolExecution,
7
+ } from "./agui_client.js";
8
+ import { type ClientTool, ClientToolRegistry } from "./client_tool_registry.js";
9
+ import { requestConfirmation } from "./confirmation_modal.js";
10
+ import { MESSAGE_ROLE, SUBMIT_EVENT, TOOL_CALL_STATUS } from "./constants.js";
11
+ import {
12
+ type ClientConversationStore,
13
+ type NavigationCheckpoint,
14
+ SessionStorageStore,
15
+ } from "./conversation_store.js";
16
+ import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
17
+ import { isDestructive } from "./is_destructive.js";
18
+ import { isNavigates } from "./is_navigates.js";
19
+ import { createPageMapContext, type PageMap } from "./page_map.js";
20
+ import { createRouteTools, type RouteMap } from "./route_map.js";
21
+ import { createStateHookTools, type StateHook } from "./state_hook.js";
22
+ import { STYLES } from "./styles.js";
23
+ import { ToolCallCard } from "./tool_call_card.js";
24
+
25
+ /** The role a rendered chat message takes. */
26
+ export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
27
+
28
+ /** `detail` shape of the {@link SUBMIT_EVENT} CustomEvent. */
29
+ export interface SubmitDetail {
30
+ readonly content: string;
31
+ }
32
+
33
+ /**
34
+ * `<ag-ui-chat>` — a framework-free chat sidebar Web Component over AG-UI.
35
+ *
36
+ * Owns the Shadow DOM shell (header, scrolling message list, input row),
37
+ * builds an {@link AgUiClient} on first send (via the overridable
38
+ * {@link agentFactory}), and renders streaming assistant text plus tool-call
39
+ * activity. Emits a {@link SUBMIT_EVENT} for host visibility.
40
+ *
41
+ * The per-run frontend tool catalog and context are supplied by
42
+ * {@link getTools} / {@link getContext}, which later phases (the tool
43
+ * registry, DOM driver) populate.
44
+ */
45
+ export class AgUiChat extends HTMLElement {
46
+ /** Agent factory; override to inject a custom or fake agent (tests). */
47
+ agentFactory: AgentFactory = createHttpAgent;
48
+
49
+ /** Extra HTTP headers for the AG-UI endpoint (e.g. CSRF). */
50
+ headers: Record<string, string> = {};
51
+
52
+ /** When true, destructive tools execute without a confirmation modal. */
53
+ autoConfirm = false;
54
+
55
+ /**
56
+ * Per-run frontend tool catalog provider. Defaults to the built-in
57
+ * `route.*` tools (when a {@link routeMap} is set) plus the tools registered
58
+ * via {@link registerTool} / {@link registerStateHook}; override to supply a
59
+ * fully custom catalog.
60
+ */
61
+ getTools: () => Tool[] = () => [
62
+ ...this.#routeTools().map((t) => ({
63
+ name: t.name,
64
+ description: t.description,
65
+ parameters: t.parameters,
66
+ })),
67
+ ...this.#toolRegistry.tools(),
68
+ ];
69
+
70
+ /**
71
+ * Per-run context provider. Defaults to the compact page map (when a
72
+ * {@link getPageMap} provider is set and {@link autoInjectPageMap} is on).
73
+ */
74
+ getContext: () => Context[] = () => createPageMapContext(this.getPageMap, this.autoInjectPageMap);
75
+
76
+ /**
77
+ * Navigable routes the agent can jump to via the built-in `route.*` tools.
78
+ * A compact summary also rides in each run's context.
79
+ */
80
+ routeMap: RouteMap = [];
81
+
82
+ /**
83
+ * Optional client-side router. When set (an SPA), `navigate_to_route` routes
84
+ * in-page and the run loop continues; when unset (an MPA like the admin), it
85
+ * falls back to `window.location` and the resumable-loop machinery applies.
86
+ */
87
+ navigate: ((path: string) => void) | null = null;
88
+
89
+ /** Provider for the per-run page map; see {@link getContext}. */
90
+ getPageMap: (() => PageMap) | null = null;
91
+
92
+ /** Whether to auto-inject the page map into context each run. */
93
+ autoInjectPageMap = true;
94
+
95
+ /**
96
+ * Persistence for the conversation + navigation checkpoint. Defaults to
97
+ * per-tab `sessionStorage` so the chat survives full page reloads; inject a
98
+ * server-backed store for cross-tab/device durability.
99
+ */
100
+ conversationStore: ClientConversationStore = new SessionStorageStore();
101
+
102
+ /**
103
+ * Builds the tool result a navigating tool resumes with after the page
104
+ * reloads. Defaults to the landed URL; a host (e.g. the admin package) can
105
+ * override to include a page snapshot or post-reload validation errors.
106
+ */
107
+ navigationResult: (checkpoint: NavigationCheckpoint) => unknown = () => ({
108
+ navigated: true,
109
+ url: window.location.href,
110
+ });
111
+
112
+ readonly #toolRegistry = new ClientToolRegistry();
113
+ /** Tool-call cards awaiting execution, keyed by call id. */
114
+ readonly #toolCards = new Map<string, ToolCallCard>();
115
+ readonly #root: ShadowRoot;
116
+ readonly #chat: HTMLDivElement;
117
+ readonly #messages: HTMLDivElement;
118
+ readonly #input: HTMLTextAreaElement;
119
+ readonly #send: HTMLButtonElement;
120
+
121
+ #client: AgUiClient | null = null;
122
+ #streamingBubble: HTMLDivElement | null = null;
123
+ #threadId = "";
124
+ #initialMessages: readonly Message[] = [];
125
+
126
+ constructor() {
127
+ super();
128
+ this.#root = this.attachShadow({ mode: "open" });
129
+ this.#chat = document.createElement("div");
130
+ this.#messages = document.createElement("div");
131
+ this.#input = document.createElement("textarea");
132
+ this.#send = document.createElement("button");
133
+ }
134
+
135
+ /** Declare a frontend tool the agent may call. */
136
+ registerTool(tool: ClientTool): void {
137
+ this.#toolRegistry.register(tool);
138
+ }
139
+
140
+ /** Bind a piece of host state to `read_<name>` / `set_<name>` tools. */
141
+ registerStateHook(hook: StateHook): void {
142
+ for (const tool of createStateHookTools(hook)) {
143
+ this.#toolRegistry.register(tool);
144
+ }
145
+ }
146
+
147
+ /** The built-in `route.*` tools, present only when a route map is set. */
148
+ #routeTools(): ClientTool[] {
149
+ if (this.routeMap.length === 0) {
150
+ return [];
151
+ }
152
+ return createRouteTools(
153
+ () => this.routeMap,
154
+ () => this.navigate,
155
+ );
156
+ }
157
+
158
+ /** Resolve a tool by name: built-in route tools first, then the registry. */
159
+ #resolveTool(name: string): ClientTool | null {
160
+ const route = this.#routeTools().find((t) => t.name === name);
161
+ if (route !== undefined) {
162
+ return route;
163
+ }
164
+ return this.#toolRegistry.has(name) ? this.#toolRegistry.get(name) : null;
165
+ }
166
+
167
+ /** The AG-UI endpoint URL, read from the `endpoint` attribute. */
168
+ get endpoint(): string {
169
+ return this.getAttribute("endpoint") ?? "";
170
+ }
171
+
172
+ connectedCallback(): void {
173
+ this.#render();
174
+ this.#threadId = this.conversationStore.threadId();
175
+ void this.#rehydrate();
176
+ }
177
+
178
+ /**
179
+ * Restore the conversation from the store on mount, then — if a navigating
180
+ * tool reloaded the page mid-run — resume the loop by supplying that tool's
181
+ * result from the page we landed on.
182
+ */
183
+ async #rehydrate(): Promise<void> {
184
+ const messages = await this.conversationStore.loadMessages(this.#threadId);
185
+ if (messages !== null) {
186
+ this.#initialMessages = messages;
187
+ for (const message of messages) {
188
+ this.#renderHistoricMessage(message);
189
+ }
190
+ }
191
+ const checkpoint = this.conversationStore.loadCheckpoint(this.#threadId);
192
+ if (checkpoint !== null) {
193
+ await this.#resumeFrom(checkpoint);
194
+ }
195
+ }
196
+
197
+ /** Render a restored message as a chat bubble (text turns only). */
198
+ #renderHistoricMessage(message: Message): void {
199
+ if (typeof message.content !== "string" || message.content === "") {
200
+ return;
201
+ }
202
+ if (message.role === MESSAGE_ROLE.USER) {
203
+ this.appendMessage(MESSAGE_ROLE.USER, message.content);
204
+ } else if (message.role === MESSAGE_ROLE.ASSISTANT) {
205
+ this.appendMessage(MESSAGE_ROLE.ASSISTANT, message.content);
206
+ }
207
+ }
208
+
209
+ /** Complete the checkpointed navigating tool call and continue the run. */
210
+ async #resumeFrom(checkpoint: NavigationCheckpoint): Promise<void> {
211
+ this.conversationStore.saveCheckpoint(this.#threadId, null);
212
+ const client = this.#ensureClient();
213
+ client.addToolResult(checkpoint.toolCallId, JSON.stringify(this.navigationResult(checkpoint)));
214
+ await client.resume();
215
+ }
216
+
217
+ /** Append a message bubble and return it. */
218
+ appendMessage(role: MessageRole, content: string): HTMLDivElement {
219
+ const bubble = document.createElement("div");
220
+ bubble.className = `message message--${role}`;
221
+ bubble.textContent = content;
222
+ this.#messages.appendChild(bubble);
223
+ this.#messages.scrollTop = this.#messages.scrollHeight;
224
+ return bubble;
225
+ }
226
+
227
+ #render(): void {
228
+ const style = document.createElement("style");
229
+ style.textContent = STYLES;
230
+
231
+ this.#chat.className = "chat";
232
+
233
+ const header = document.createElement("div");
234
+ header.className = "header";
235
+ header.textContent = this.getAttribute("title-text") ?? "Assistant";
236
+
237
+ this.#messages.className = "messages";
238
+
239
+ const inputRow = document.createElement("div");
240
+ inputRow.className = "input-row";
241
+
242
+ this.#input.className = "input";
243
+ this.#input.rows = 2;
244
+ this.#input.placeholder = "Ask anything…";
245
+ this.#input.addEventListener("keydown", (event) => this.#onKeydown(event));
246
+
247
+ this.#send.className = "send";
248
+ this.#send.type = "button";
249
+ this.#send.textContent = "Send";
250
+ this.#send.addEventListener("click", () => {
251
+ void this.#submit();
252
+ });
253
+
254
+ inputRow.append(this.#input, this.#send);
255
+ this.#chat.append(header, this.#messages, inputRow);
256
+ this.#root.append(style, this.#chat);
257
+ }
258
+
259
+ #onKeydown(event: KeyboardEvent): void {
260
+ if (event.key === "Enter" && !event.shiftKey) {
261
+ event.preventDefault();
262
+ void this.#submit();
263
+ }
264
+ }
265
+
266
+ async #submit(): Promise<void> {
267
+ const content = this.#input.value.trim();
268
+ if (content === "") {
269
+ return;
270
+ }
271
+ this.appendMessage(MESSAGE_ROLE.USER, content);
272
+ this.#input.value = "";
273
+ this.dispatchEvent(
274
+ new CustomEvent<SubmitDetail>(SUBMIT_EVENT, {
275
+ detail: { content },
276
+ bubbles: true,
277
+ composed: true,
278
+ }),
279
+ );
280
+ await this.#client_send(content);
281
+ }
282
+
283
+ async #client_send(content: string): Promise<void> {
284
+ if (this.endpoint === "") {
285
+ return;
286
+ }
287
+ await this.#ensureClient().send(content);
288
+ }
289
+
290
+ #ensureClient(): AgUiClient {
291
+ if (this.#client === null) {
292
+ const agent = this.agentFactory({
293
+ endpoint: this.endpoint,
294
+ headers: this.headers,
295
+ threadId: this.#threadId,
296
+ initialMessages: this.#initialMessages,
297
+ });
298
+ this.#client = new AgUiClient({
299
+ agent,
300
+ handlers: this.#handlers(),
301
+ getTools: () => this.getTools(),
302
+ getContext: () => this.getContext(),
303
+ executeTool: (call) => this.#executeTool(call),
304
+ onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
305
+ });
306
+ }
307
+ return this.#client;
308
+ }
309
+
310
+ async #executeTool(call: AgUiToolCall): Promise<ToolExecution | null> {
311
+ const card = this.#cardFor(call);
312
+ this.#toolCards.delete(call.id);
313
+ const tool = this.#resolveTool(call.name);
314
+ if (tool === null) {
315
+ // A server-side tool the server already executed — not ours to re-run.
316
+ card.settle(TOOL_CALL_STATUS.DONE, "Executed on the server.");
317
+ return null;
318
+ }
319
+ if (isDestructive(tool.parameters) && !this.autoConfirm) {
320
+ const accepted = await requestConfirmation(this.#chat, {
321
+ toolName: call.name,
322
+ args: call.args,
323
+ });
324
+ if (!accepted) {
325
+ const message = "User declined the action.";
326
+ card.settle(TOOL_CALL_STATUS.DECLINED, message);
327
+ return { content: message };
328
+ }
329
+ }
330
+ // A navigating tool reloads only without a client-side router; with a
331
+ // host `navigate()` (SPA) it routes in-page and the loop just continues.
332
+ const navigates = isNavigates(tool.parameters) && this.navigate === null;
333
+ if (navigates) {
334
+ // Checkpoint before the handler reloads the page; the history (incl.
335
+ // this tool call) was already persisted when the run that produced it
336
+ // settled. The result is supplied on the next mount via the resume path.
337
+ this.conversationStore.saveCheckpoint(this.#threadId, { toolCallId: call.id });
338
+ }
339
+ try {
340
+ const result = await tool.handler(call.args);
341
+ if (navigates) {
342
+ card.settle(TOOL_CALL_STATUS.DONE, "Navigating…");
343
+ return { content: "", halt: true };
344
+ }
345
+ const content = JSON.stringify(result ?? null);
346
+ card.settle(TOOL_CALL_STATUS.DONE, content);
347
+ return { content };
348
+ } catch (error) {
349
+ if (navigates) {
350
+ // The navigation never happened; drop the dangling checkpoint.
351
+ this.conversationStore.saveCheckpoint(this.#threadId, null);
352
+ }
353
+ const message = error instanceof Error ? error.message : String(error);
354
+ card.settle(TOOL_CALL_STATUS.ERROR, message);
355
+ return { content: `Error: ${message}`, error: message };
356
+ }
357
+ }
358
+
359
+ #handlers(): AgUiClientHandlers {
360
+ return {
361
+ onRunStart: () => {
362
+ this.#send.disabled = true;
363
+ },
364
+ onTextDelta: (buffer) => {
365
+ this.#streamInto(buffer);
366
+ },
367
+ onTextEnd: (buffer) => {
368
+ this.#streamInto(buffer);
369
+ this.#streamingBubble = null;
370
+ },
371
+ onToolCall: (call) => {
372
+ this.#cardFor(call);
373
+ },
374
+ onRunEnd: () => {
375
+ this.#send.disabled = false;
376
+ this.#streamingBubble = null;
377
+ },
378
+ onError: (message) => {
379
+ this.appendMessage(MESSAGE_ROLE.ASSISTANT, `⚠️ ${message}`);
380
+ this.#send.disabled = false;
381
+ this.#streamingBubble = null;
382
+ },
383
+ };
384
+ }
385
+
386
+ #streamInto(buffer: string): void {
387
+ if (this.#streamingBubble === null) {
388
+ this.#streamingBubble = this.appendMessage(MESSAGE_ROLE.ASSISTANT, "");
389
+ }
390
+ this.#streamingBubble.textContent = buffer;
391
+ this.#messages.scrollTop = this.#messages.scrollHeight;
392
+ }
393
+
394
+ /**
395
+ * The card for ``call``, creating and appending it on first sight.
396
+ *
397
+ * {@link AgUiClientHandlers.onToolCall} creates the card (pending) during the
398
+ * run; {@link #executeTool} later retrieves the same card to settle it.
399
+ */
400
+ #cardFor(call: AgUiToolCall): ToolCallCard {
401
+ const existing = this.#toolCards.get(call.id);
402
+ if (existing !== undefined) {
403
+ return existing;
404
+ }
405
+ const card = new ToolCallCard(call.name, call.args);
406
+ this.#toolCards.set(call.id, card);
407
+ this.#messages.appendChild(card.element);
408
+ this.#messages.scrollTop = this.#messages.scrollHeight;
409
+ return card;
410
+ }
411
+ }
@@ -0,0 +1,212 @@
1
+ import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
2
+ import type { Context, Message, Tool } from "@ag-ui/core";
3
+ import { MAX_TOOL_ROUNDS } from "./constants.js";
4
+
5
+ /** A tool call surfaced to the host by {@link AgUiClient}. */
6
+ export interface AgUiToolCall {
7
+ readonly id: string;
8
+ readonly name: string;
9
+ readonly args: Record<string, unknown>;
10
+ }
11
+
12
+ /** The result of executing a frontend tool, destined for a tool-result message. */
13
+ export interface ToolExecution {
14
+ /** String content for the AG-UI tool-result message. */
15
+ content: string;
16
+ /** Present when the handler failed; surfaced for logging. */
17
+ error?: string;
18
+ /**
19
+ * When `true`, a navigating tool triggered a page reload. The loop stops
20
+ * without appending a result — the result is supplied after the next mount
21
+ * (see the resume path). Mutually exclusive with a usable `content`.
22
+ */
23
+ halt?: boolean;
24
+ }
25
+
26
+ /**
27
+ * Executes a frontend tool call.
28
+ *
29
+ * Returns the {@link ToolExecution} to post back to the agent, or ``null`` when
30
+ * the call is not a frontend tool the host owns (a server-side tool the server
31
+ * already executed — the client must not re-run for it).
32
+ */
33
+ export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
34
+
35
+ /**
36
+ * Callbacks the {@link AgUiClient} invokes as a run progresses. The host
37
+ * (the `<ag-ui-chat>` element) implements these to render streaming text and
38
+ * tool activity into the chat.
39
+ */
40
+ export interface AgUiClientHandlers {
41
+ onRunStart(): void;
42
+ /** Fired on every streamed token; ``buffer`` is the full text so far. */
43
+ onTextDelta(buffer: string): void;
44
+ /** Fired when the assistant message completes; ``buffer`` is the final text. */
45
+ onTextEnd(buffer: string): void;
46
+ /** Fired when the agent finishes calling a tool (server- or frontend-side). */
47
+ onToolCall(call: AgUiToolCall): void;
48
+ onRunEnd(): void;
49
+ onError(message: string): void;
50
+ }
51
+
52
+ /**
53
+ * Provider of the per-run frontend tool catalog and context. Both are read
54
+ * fresh on every {@link AgUiClient.send} so the catalog reflects the current
55
+ * page state.
56
+ */
57
+ export interface AgUiRunInputs {
58
+ getTools?: () => Tool[];
59
+ getContext?: () => Context[];
60
+ }
61
+
62
+ export interface AgUiClientConfig extends AgUiRunInputs {
63
+ /** The AG-UI agent to drive. Injected so it can be faked in tests. */
64
+ agent: AbstractAgent;
65
+ handlers: AgUiClientHandlers;
66
+ /** Executes frontend tool calls. Omit for server-only tool sets. */
67
+ executeTool?: ExecuteTool;
68
+ /**
69
+ * Invoked with the latest history whenever it changes, so the host can
70
+ * persist it for durability across page reloads. Omit to keep the
71
+ * conversation in-memory only.
72
+ */
73
+ onPersist?: (messages: readonly Message[]) => void;
74
+ }
75
+
76
+ /**
77
+ * Thin orchestration layer over an AG-UI {@link AbstractAgent}.
78
+ *
79
+ * Translates AG-UI's subscriber callbacks into the host's
80
+ * {@link AgUiClientHandlers}, and appends the user message + current frontend
81
+ * tool catalog + context to each run.
82
+ */
83
+ export class AgUiClient {
84
+ readonly #agent: AbstractAgent;
85
+ readonly #handlers: AgUiClientHandlers;
86
+ readonly #getTools: () => Tool[];
87
+ readonly #getContext: () => Context[];
88
+ readonly #executeTool: ExecuteTool | null;
89
+ readonly #onPersist: (messages: readonly Message[]) => void;
90
+
91
+ constructor(config: AgUiClientConfig) {
92
+ this.#agent = config.agent;
93
+ this.#handlers = config.handlers;
94
+ this.#getTools = config.getTools ?? (() => []);
95
+ this.#getContext = config.getContext ?? (() => []);
96
+ this.#executeTool = config.executeTool ?? null;
97
+ this.#onPersist = config.onPersist ?? (() => {});
98
+ }
99
+
100
+ /** Whether a run is currently in flight. */
101
+ get running(): boolean {
102
+ return this.#agent.isRunning;
103
+ }
104
+
105
+ /** The current conversation history (for persistence / rehydration). */
106
+ get messages(): readonly Message[] {
107
+ return this.#agent.messages;
108
+ }
109
+
110
+ /**
111
+ * Append a user message and run the agent to completion.
112
+ *
113
+ * When the agent calls frontend tools, this executes them and re-runs the
114
+ * agent with the results, looping until the agent stops calling frontend
115
+ * tools (bounded by {@link MAX_TOOL_ROUNDS}).
116
+ */
117
+ async send(content: string): Promise<void> {
118
+ this.#agent.addMessage({ id: randomUUID(), role: "user", content });
119
+ this.#onPersist(this.#agent.messages);
120
+ await this.#run();
121
+ }
122
+
123
+ /**
124
+ * Resume the run loop after a navigating tool's result was supplied
125
+ * post-reload (via {@link addToolResult}). Unlike {@link send}, adds no user
126
+ * message — it simply continues the conversation already in history.
127
+ */
128
+ async resume(): Promise<void> {
129
+ await this.#run();
130
+ }
131
+
132
+ /** Append a frontend tool result to history (used by the resume path). */
133
+ addToolResult(toolCallId: string, content: string): void {
134
+ this.#agent.addMessage({ id: randomUUID(), role: "tool", content, toolCallId });
135
+ this.#onPersist(this.#agent.messages);
136
+ }
137
+
138
+ async #run(): Promise<void> {
139
+ try {
140
+ await this.#runLoop();
141
+ } catch (error) {
142
+ this.#handlers.onError(error instanceof Error ? error.message : String(error));
143
+ }
144
+ }
145
+
146
+ async #runLoop(): Promise<void> {
147
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
148
+ const pending: AgUiToolCall[] = [];
149
+ await this.#agent.runAgent(
150
+ { tools: this.#getTools(), context: this.#getContext() },
151
+ this.#buildSubscriber(pending),
152
+ );
153
+ this.#onPersist(this.#agent.messages);
154
+ if (this.#executeTool === null || pending.length === 0) {
155
+ return;
156
+ }
157
+ let executed = false;
158
+ for (const call of pending) {
159
+ const result = await this.#executeTool(call);
160
+ if (result === null) {
161
+ continue;
162
+ }
163
+ if (result.halt === true) {
164
+ // A navigating tool reloaded the page; the result arrives on the
165
+ // next mount. Stop here rather than re-running into a dead context.
166
+ return;
167
+ }
168
+ this.#agent.addMessage({
169
+ id: randomUUID(),
170
+ role: "tool",
171
+ content: result.content,
172
+ toolCallId: call.id,
173
+ });
174
+ this.#onPersist(this.#agent.messages);
175
+ executed = true;
176
+ }
177
+ if (!executed) {
178
+ return;
179
+ }
180
+ }
181
+ }
182
+
183
+ #buildSubscriber(pending: AgUiToolCall[]): AgentSubscriber {
184
+ const h = this.#handlers;
185
+ return {
186
+ onRunInitialized() {
187
+ h.onRunStart();
188
+ },
189
+ onTextMessageContentEvent({ textMessageBuffer }) {
190
+ h.onTextDelta(textMessageBuffer);
191
+ },
192
+ onTextMessageEndEvent({ textMessageBuffer }) {
193
+ h.onTextEnd(textMessageBuffer);
194
+ },
195
+ onToolCallEndEvent({ event, toolCallName, toolCallArgs }) {
196
+ const call: AgUiToolCall = {
197
+ id: event.toolCallId,
198
+ name: toolCallName,
199
+ args: toolCallArgs,
200
+ };
201
+ pending.push(call);
202
+ h.onToolCall(call);
203
+ },
204
+ onRunErrorEvent({ event }) {
205
+ h.onError(event.message);
206
+ },
207
+ onRunFinalized() {
208
+ h.onRunEnd();
209
+ },
210
+ };
211
+ }
212
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Visible-action animation primitives.
3
+ *
4
+ * These run against the **host page** DOM (not the element's shadow root) so a
5
+ * user watches the agent type, highlight, and click at human-readable speed.
6
+ * Each is configurable; pass small/zero durations in tests (or use fake timers).
7
+ */
8
+
9
+ const ACCENT = "#4f46e5";
10
+
11
+ function delay(ms: number): Promise<void> {
12
+ return new Promise<void>((resolve) => {
13
+ setTimeout(resolve, ms);
14
+ });
15
+ }
16
+
17
+ /** An element whose `value` can be typed into. */
18
+ export type TextLikeElement = HTMLInputElement | HTMLTextAreaElement;
19
+
20
+ export interface TypeOptions {
21
+ /** Milliseconds between characters. Default 35. */
22
+ charDelayMs?: number;
23
+ }
24
+
25
+ /**
26
+ * Clear ``el`` and type ``value`` one character at a time, firing ``input``
27
+ * events as a real user would, then a final ``change`` event.
28
+ */
29
+ export async function typeInto(
30
+ el: TextLikeElement,
31
+ value: string,
32
+ options: TypeOptions = {},
33
+ ): Promise<void> {
34
+ const charDelayMs = options.charDelayMs ?? 35;
35
+ el.value = "";
36
+ el.dispatchEvent(new Event("input", { bubbles: true }));
37
+ for (const char of value) {
38
+ el.value += char;
39
+ el.dispatchEvent(new Event("input", { bubbles: true }));
40
+ if (charDelayMs > 0) {
41
+ await delay(charDelayMs);
42
+ }
43
+ }
44
+ el.dispatchEvent(new Event("change", { bubbles: true }));
45
+ }
46
+
47
+ export interface HighlightClickOptions {
48
+ /** Milliseconds to hold the highlight before clicking. Default 280. */
49
+ highlightMs?: number;
50
+ }
51
+
52
+ /** Outline ``el``, pause so the user sees it, then click and restore. */
53
+ export async function highlightThenClick(
54
+ el: HTMLElement,
55
+ options: HighlightClickOptions = {},
56
+ ): Promise<void> {
57
+ const highlightMs = options.highlightMs ?? 280;
58
+ const previousOutline = el.style.outline;
59
+ const previousOffset = el.style.outlineOffset;
60
+ el.style.outline = `2px solid ${ACCENT}`;
61
+ el.style.outlineOffset = "2px";
62
+ await delay(highlightMs);
63
+ el.style.outline = previousOutline;
64
+ el.style.outlineOffset = previousOffset;
65
+ el.click();
66
+ }
67
+
68
+ /** Scroll ``el`` to the vertical centre of the viewport. */
69
+ export function scrollIntoCenterView(el: HTMLElement): void {
70
+ el.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
71
+ }
72
+
73
+ export interface FlashOptions {
74
+ /** Milliseconds to hold the focus flash. Default 200. */
75
+ flashMs?: number;
76
+ }
77
+
78
+ /** Focus ``el`` and briefly flash a ring around it. */
79
+ export async function focusWithFlash(el: HTMLElement, options: FlashOptions = {}): Promise<void> {
80
+ const flashMs = options.flashMs ?? 200;
81
+ el.focus();
82
+ const previousShadow = el.style.boxShadow;
83
+ el.style.boxShadow = `0 0 0 3px rgba(79, 70, 229, 0.4)`;
84
+ await delay(flashMs);
85
+ el.style.boxShadow = previousShadow;
86
+ }