@artooi/ag-ui-web-component 0.25.1 → 0.26.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.
@@ -1,6 +1,7 @@
1
1
  import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
2
2
  import {
3
3
  ATTACHMENT_EVENT,
4
+ CHART_ACTIVITY_TYPE,
4
5
  COMPACTION_ACTIVITY_TYPE,
5
6
  DEFAULT_ATTACHMENT_MAX_BYTES,
6
7
  ICON_ATTACH,
@@ -39,6 +40,9 @@ import {
39
40
  import { attachCopyButtons } from "../ui/attach_copy_buttons.js";
40
41
  import { renderAttachmentChips } from "../ui/attachment_chips.js";
41
42
  import { AttachmentTray } from "../ui/attachment_tray.js";
43
+ import { renderChart } from "../ui/chart_block.js";
44
+ import { chartSpecFrom } from "../ui/chart_spec_from.js";
45
+ import { createChartTool } from "../ui/chart_tool.js";
42
46
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
43
47
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
44
48
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
@@ -392,6 +396,15 @@ export class AgUiChat extends HTMLElement {
392
396
  * (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
393
397
  * the real output with the generic "executed on the server" fallback.
394
398
  */
399
+ /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
400
+ #chartActivity = false;
401
+
402
+ /** Card elements by call id, so a rendering handler can find its own card. */
403
+ readonly #cardElements = new Map<string, HTMLElement>();
404
+
405
+ /** Chart blocks by activity message id, so an update redraws in place. */
406
+ readonly #activityBlocks = new Map<string, HTMLElement>();
407
+
395
408
  readonly #serverSettled = new Set<string>();
396
409
  /**
397
410
  * Tool calls made during the current interaction, in the order they started,
@@ -1662,6 +1675,8 @@ export class AgUiChat extends HTMLElement {
1662
1675
  this.#hidePending();
1663
1676
  this.#toolCards.clear();
1664
1677
  this.#serverSettled.clear();
1678
+ this.#cardElements.clear();
1679
+ this.#activityBlocks.clear();
1665
1680
  this.#initialMessages = [];
1666
1681
  this.#attachTray?.clear();
1667
1682
  // Keep the empty-state region; everything else clears.
@@ -1822,7 +1837,23 @@ export class AgUiChat extends HTMLElement {
1822
1837
  if (this.#noticeIfSkillLoad(restored)) {
1823
1838
  continue;
1824
1839
  }
1825
- this.#cardFor(restored);
1840
+ this.#cardElements.set(restored.id, this.#cardFor(restored).element);
1841
+ // Only `render` is replayed, never `handler`. A restored transcript
1842
+ // redraws what the call drew; it must not re-run what the call *did*.
1843
+ const tool = this.#resolveTool(restored.name);
1844
+ if (tool !== null) {
1845
+ this.#renderToolOutput(tool, restored);
1846
+ }
1847
+ }
1848
+ return;
1849
+ }
1850
+ if (message.role === "activity") {
1851
+ // The client materialises a pushed activity as a message of its own, so a
1852
+ // chart's data is in the transcript already and survives a reload. Only
1853
+ // the drawing had to be put back.
1854
+ const activity = message as unknown as { activityType?: unknown; content?: unknown };
1855
+ if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
1856
+ this.#drawActivityChart(message.id, activity.content);
1826
1857
  }
1827
1858
  return;
1828
1859
  }
@@ -2539,6 +2570,10 @@ export class AgUiChat extends HTMLElement {
2539
2570
  }
2540
2571
  const card = this.#cardFor(call);
2541
2572
  this.#toolCards.delete(call.id);
2573
+ // Kept after the card leaves `#toolCards`: a tool that renders into the
2574
+ // transcript places itself against its own card, and by the time it runs the
2575
+ // card is no longer reachable by id.
2576
+ this.#cardElements.set(call.id, card.element);
2542
2577
  const tool = this.#resolveTool(call.name);
2543
2578
  if (tool === null) {
2544
2579
  // Not a client tool. A server-side tool's real output arrives via
@@ -2613,7 +2648,12 @@ export class AgUiChat extends HTMLElement {
2613
2648
  this.conversationStore.saveCheckpoint(this.#threadId, { toolCallId: call.id });
2614
2649
  }
2615
2650
  try {
2616
- const result = await tool.handler(call.args);
2651
+ // The call id lets a handler that renders into the transcript find its
2652
+ // own card; handlers that only act on the page ignore it.
2653
+ const result = await tool.handler(call.args, call.id);
2654
+ // Drawn from the arguments rather than the result, so the live path and
2655
+ // the replay path render the same thing from the same input.
2656
+ this.#renderToolOutput(tool, call);
2617
2657
  if (navigates) {
2618
2658
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
2619
2659
  return { content: "", halt: true };
@@ -2775,7 +2815,13 @@ export class AgUiChat extends HTMLElement {
2775
2815
  this.#runTools.push({ id: call.id, name: call.name });
2776
2816
  this.#cardFor(call);
2777
2817
  },
2778
- onActivity: (activityType, content) => {
2818
+ onActivity: (activityType, content, messageId) => {
2819
+ if (activityType === CHART_ACTIVITY_TYPE) {
2820
+ if (this.#chartActivity) {
2821
+ this.#drawActivityChart(messageId, content);
2822
+ }
2823
+ return;
2824
+ }
2779
2825
  if (activityType !== COMPACTION_ACTIVITY_TYPE) {
2780
2826
  return;
2781
2827
  }
@@ -2796,6 +2842,25 @@ export class AgUiChat extends HTMLElement {
2796
2842
  }
2797
2843
  card.settle(TOOL_CALL_STATUS.DONE, content);
2798
2844
  this.#serverSettled.add(toolCallId);
2845
+ // The card stops being the live thing the moment it settles, and the
2846
+ // server goes straight back to the model with the result -- a wait with
2847
+ // nothing on screen to own it, and the longest one in a run when the
2848
+ // result is a large inlined attachment being re-sent with every request.
2849
+ // The dots go back where ``onToolCall`` took them from, after the card,
2850
+ // and whatever comes next clears them: reasoning, the first text delta,
2851
+ // the round ending, or ``onSettled``'s terminal guarantee.
2852
+ //
2853
+ // Not the same case as the one ``#executeTool`` refuses to show them
2854
+ // for. That runs after the run has ended, so there is nothing left to
2855
+ // clear them and they would hang -- which is what happened before 0.2.1
2856
+ // and is why they were removed from here too. The terminal guarantee
2857
+ // that shipped in the same release is what makes showing them safe now.
2858
+ this.#showPending();
2859
+ },
2860
+ onActivityChanged: (messageId, activityType, content) => {
2861
+ if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
2862
+ this.#drawActivityChart(messageId, content);
2863
+ }
2799
2864
  },
2800
2865
  onRunEnd: () => {
2801
2866
  // Per-round end; the button stays on Stop until the whole interaction
@@ -2969,6 +3034,97 @@ export class AgUiChat extends HTMLElement {
2969
3034
  this.#messages.scrollTop = this.#messages.scrollHeight;
2970
3035
  }
2971
3036
 
3037
+ /**
3038
+ * Turn on chart rendering, by whichever route this consumer wants.
3039
+ *
3040
+ * Both routes converge on one renderer deliberately. Built apart they become
3041
+ * two chart implementations with two sets of bugs, and the choice between them
3042
+ * is about *where the data lives* rather than how a bar should look:
3043
+ *
3044
+ * - `"tool"` registers the built-in `render_chart`. The agent decides a chart
3045
+ * helps and calls it, so the numbers are in its context and it can discuss
3046
+ * them afterwards. Costs one model round, and works over any transport.
3047
+ * - `"activity"` draws a server-pushed `ACTIVITY_SNAPSHOT` of type `chart`.
3048
+ * No round trip, and the data never enters the model's context at all —
3049
+ * which is what makes it the one for a large or sensitive dataset. Only this
3050
+ * route can update a chart in place as the server computes.
3051
+ *
3052
+ * Off unless asked for, both of them: a component that renders whatever
3053
+ * arrives is not something to switch on for everybody.
3054
+ */
3055
+ enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3056
+ if (routes.includes("activity")) {
3057
+ this.#chartActivity = true;
3058
+ }
3059
+ if (routes.includes("tool")) {
3060
+ this.registerTool(createChartTool());
3061
+ }
3062
+ }
3063
+
3064
+ /**
3065
+ * Place a tool's rendered node against its own card.
3066
+ *
3067
+ * Anchored rather than appended because a client tool's handler does not run
3068
+ * until the round is over: appending would put the node after everything the
3069
+ * model said next, visibly detached from the call that produced it, and in a
3070
+ * different order than the same transcript takes on reload. The card was
3071
+ * created inline, in the right place, so anchoring makes *when* the handler
3072
+ * runs stop mattering.
3073
+ */
3074
+ #renderToolOutput(tool: ClientTool, call: AgUiToolCall): void {
3075
+ if (tool.render === undefined) {
3076
+ return;
3077
+ }
3078
+ let node: Node | null;
3079
+ try {
3080
+ node = tool.render(call.args);
3081
+ } catch (error) {
3082
+ // `render` is consumer code and this runs inside the history replay, where
3083
+ // a throw abandons the loop and takes every later turn of the transcript
3084
+ // with it -- silently, and again on every reload. A chart that fails to
3085
+ // draw is worth losing; the rest of the conversation is not. Reported so
3086
+ // the failure is findable rather than merely survived.
3087
+ console.warn(`ag-ui-chat: render failed for tool ${call.name}`, error);
3088
+ return;
3089
+ }
3090
+ if (node === null) {
3091
+ return;
3092
+ }
3093
+ // `after` rather than an insert-or-append branch: both callers set the card
3094
+ // element immediately before calling, and a parentless anchor makes `after`
3095
+ // a no-op, so the alternative would be a branch nothing can reach.
3096
+ this.#cardElements.get(call.id)?.after(node);
3097
+ this.#afterTranscriptGrew();
3098
+ }
3099
+
3100
+ /** Draw, or redraw in place, the chart for one activity message. */
3101
+ #drawActivityChart(messageId: string, content: unknown): void {
3102
+ const spec = chartSpecFrom(content);
3103
+ if (spec === null) {
3104
+ return;
3105
+ }
3106
+ const block = renderChart(spec);
3107
+ if (block === null) {
3108
+ return;
3109
+ }
3110
+ const existing = this.#activityBlocks.get(messageId);
3111
+ if (existing === undefined) {
3112
+ this.#ensureGroup().appendChild(block);
3113
+ } else {
3114
+ // Replaced rather than appended: a server redrawing a chart under the same
3115
+ // id means *this chart changed*, and a second copy below the first would
3116
+ // read as two measurements instead of one that moved.
3117
+ existing.replaceWith(block);
3118
+ }
3119
+ this.#activityBlocks.set(messageId, block);
3120
+ this.#afterTranscriptGrew();
3121
+ }
3122
+
3123
+ #afterTranscriptGrew(): void {
3124
+ this.#updateEmptyState();
3125
+ this.#messages.scrollTop = this.#messages.scrollHeight;
3126
+ }
3127
+
2972
3128
  #cardFor(call: AgUiToolCall): ToolCallCard {
2973
3129
  const existing = this.#toolCards.get(call.id);
2974
3130
  if (existing !== undefined) {
@@ -83,7 +83,18 @@ export interface AgUiClientHandlers {
83
83
  * as opposed to work the agent asked for. `django-ag-ui` emits one with
84
84
  * `activityType: "compaction"` when it condensed the history.
85
85
  */
86
- onActivity(activityType: string, content: unknown): void;
86
+ onActivity(activityType: string, content: unknown, messageId: string): void;
87
+ /**
88
+ * An activity's content changed in place — a snapshot re-sent under the same
89
+ * `messageId` with `replace`, or an `ACTIVITY_DELTA` whose JSON patch
90
+ * `@ag-ui/client` has already applied.
91
+ *
92
+ * Reported after the client has updated its own message, so `content` is the
93
+ * result rather than the instruction. That is the whole reason this is a
94
+ * separate callback: the raw delta event fires *before* the patch lands, and
95
+ * a subscriber acting on it would redraw from stale content.
96
+ */
97
+ onActivityChanged(messageId: string, activityType: string, content: unknown): void;
87
98
  /** Fired when a reasoning model starts emitting its chain-of-thought. */
88
99
  onReasoningStart(): void;
89
100
  /** Fired on every reasoning token; ``buffer`` is the full reasoning text so far. */
@@ -397,6 +408,9 @@ export class AgUiClient {
397
408
  #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
398
409
  const h = this.#handlers;
399
410
  const closed = this.#closedMessageIds;
411
+ // Charts whose patch has been dispatched but not yet applied. Scoped to the
412
+ // subscriber, so it cannot outlive the run that created it.
413
+ const pendingDeltas = new Set<string>();
400
414
  return {
401
415
  onRunInitialized() {
402
416
  h.onRunStart();
@@ -434,8 +448,42 @@ export class AgUiClient {
434
448
  onToolCallResultEvent({ event }) {
435
449
  h.onToolResult(event.toolCallId, event.content);
436
450
  },
437
- onActivitySnapshotEvent({ event }) {
438
- h.onActivity(event.activityType, event.content);
451
+ onActivitySnapshotEvent({ event, messages }) {
452
+ // A snapshot for an id already in the list is a replacement, not a new
453
+ // activity: the client has swapped its content in place, and a second
454
+ // append would leave the superseded one on screen.
455
+ const known = messages.some(
456
+ (message) => message.id === event.messageId && message.role === "activity",
457
+ );
458
+ if (known) {
459
+ h.onActivityChanged(event.messageId, event.activityType, event.content);
460
+ return;
461
+ }
462
+ h.onActivity(event.activityType, event.content, event.messageId);
463
+ },
464
+ // Deliberately does *not* read the message here. `@ag-ui/client`
465
+ // dispatches this subscriber **before** applying the patch, so the
466
+ // message still holds its previous content: redrawing from it would leave
467
+ // the chart one revision behind for the life of the run, and disagreeing
468
+ // with what a reload shows. Note which chart moved and read the result on
469
+ // the change that follows.
470
+ onActivityDeltaEvent({ event }) {
471
+ pendingDeltas.add(event.messageId);
472
+ },
473
+ // Emitted after the client has written the patched messages, which is the
474
+ // first moment the result exists. Only the ids marked above are looked at,
475
+ // so an ordinary text delta does not walk the transcript.
476
+ onMessagesChanged({ messages }) {
477
+ if (pendingDeltas.size === 0) {
478
+ return;
479
+ }
480
+ for (const id of pendingDeltas) {
481
+ const message = messages.find((entry) => entry.id === id);
482
+ if (message !== undefined && message.role === "activity") {
483
+ h.onActivityChanged(id, message.activityType, message.content);
484
+ }
485
+ }
486
+ pendingDeltas.clear();
439
487
  },
440
488
  // `@ag-ui/client` maps the deprecated THINKING_* events onto these
441
489
  // REASONING_* callbacks, so the reasoning family alone covers both
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  export {
4
4
  ATTACHMENT_EVENT,
5
+ CHART_ACTIVITY_TYPE,
5
6
  COMPACTION_ACTIVITY_TYPE,
6
7
  ELEMENT_TAG,
7
8
  LOAD_CAPABILITY_TOOL,
@@ -129,6 +130,13 @@ export {
129
130
  type ApprovalRequest,
130
131
  requestApproval,
131
132
  } from "./ui/approval_card.js";
133
+ // Charts. `CHART_ACTIVITY_TYPE` above is the wire name a server sets on an
134
+ // ACTIVITY_SNAPSHOT; these are the shape it carries and the renderer itself,
135
+ // for a host building its own visual on the same seam.
136
+ export type { ChartKind, ChartSeries, ChartSpec } from "./ui/chart_block.js";
137
+ export { renderChart } from "./ui/chart_block.js";
138
+ export { chartSpecFrom } from "./ui/chart_spec_from.js";
139
+ export { CHART_TOOL_NAME } from "./ui/chart_tool.js";
132
140
  export { CheckpointMenu, type CheckpointVerb } from "./ui/checkpoint_menu.js";
133
141
  export {
134
142
  type ConfirmationOptions,
@@ -11,7 +11,30 @@ export interface ClientTool {
11
11
  name: string;
12
12
  description: string;
13
13
  parameters: Record<string, unknown>;
14
- handler: (args: Record<string, unknown>) => unknown | Promise<unknown>;
14
+ /**
15
+ * Run the tool. `callId` identifies the call being executed, for a handler
16
+ * that renders into the transcript and needs to place itself against its own
17
+ * card; handlers that only act on the page ignore it.
18
+ */
19
+ handler: (args: Record<string, unknown>, callId?: string) => unknown | Promise<unknown>;
20
+ /**
21
+ * Draw this call, from its arguments alone.
22
+ *
23
+ * Optional, and **the only half that is replayed**. A restored transcript
24
+ * redraws by calling `render`; it never calls {@link handler}. That is what
25
+ * keeps a replayable tool apart from an effectful one *structurally* rather
26
+ * than by promise: the restore path holds no reference to the half that acts,
27
+ * so re-running `fill_field` on every reload is not a mistake anyone can make.
28
+ *
29
+ * The contract this must keep, because it runs again on every restore:
30
+ *
31
+ * - a pure function of `args` — no host state, no network, no clock;
32
+ * - deterministic, so a reload reproduces what was there before;
33
+ * - free of effects outside the node it returns, which the component places.
34
+ *
35
+ * Return `null` for arguments that say nothing worth drawing.
36
+ */
37
+ render?: (args: Record<string, unknown>) => Node | null;
15
38
  }
16
39
 
17
40
  /**