@artooi/ag-ui-web-component 0.25.2 → 0.26.1

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,
@@ -23,6 +24,7 @@ import {
23
24
  import { fillTemplate } from "../skills/fill_template.js";
24
25
  import { parseSkills } from "../skills/parse_skills.js";
25
26
  import type { Skill } from "../skills/skill.js";
27
+ import type { ChartRenderer } from "../tools/client_tool_registry.js";
26
28
  import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
27
29
  import { isDestructive } from "../tools/is_destructive.js";
28
30
  import { isNavigates } from "../tools/is_navigates.js";
@@ -39,6 +41,9 @@ import {
39
41
  import { attachCopyButtons } from "../ui/attach_copy_buttons.js";
40
42
  import { renderAttachmentChips } from "../ui/attachment_chips.js";
41
43
  import { AttachmentTray } from "../ui/attachment_tray.js";
44
+ import { renderChart } from "../ui/chart_block.js";
45
+ import { chartSpecFrom } from "../ui/chart_spec_from.js";
46
+ import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
42
47
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
43
48
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
44
49
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
@@ -392,6 +397,15 @@ export class AgUiChat extends HTMLElement {
392
397
  * (`TOOL_CALL_RESULT`), so the post-run executeTool sweep doesn't overwrite
393
398
  * the real output with the generic "executed on the server" fallback.
394
399
  */
400
+ /** Whether a server-pushed chart activity is drawn. Off unless asked for. */
401
+ #chartActivity = false;
402
+
403
+ /** Card elements by call id, so a rendering handler can find its own card. */
404
+ readonly #cardElements = new Map<string, HTMLElement>();
405
+
406
+ /** Chart blocks by activity message id, so an update redraws in place. */
407
+ readonly #activityBlocks = new Map<string, HTMLElement>();
408
+
395
409
  readonly #serverSettled = new Set<string>();
396
410
  /**
397
411
  * Tool calls made during the current interaction, in the order they started,
@@ -1662,6 +1676,8 @@ export class AgUiChat extends HTMLElement {
1662
1676
  this.#hidePending();
1663
1677
  this.#toolCards.clear();
1664
1678
  this.#serverSettled.clear();
1679
+ this.#cardElements.clear();
1680
+ this.#activityBlocks.clear();
1665
1681
  this.#initialMessages = [];
1666
1682
  this.#attachTray?.clear();
1667
1683
  // Keep the empty-state region; everything else clears.
@@ -1822,7 +1838,29 @@ export class AgUiChat extends HTMLElement {
1822
1838
  if (this.#noticeIfSkillLoad(restored)) {
1823
1839
  continue;
1824
1840
  }
1825
- this.#cardFor(restored);
1841
+ this.#cardElements.set(restored.id, this.#cardFor(restored).element);
1842
+ // Only `render` is replayed, never `handler`. A restored transcript
1843
+ // redraws what the call drew; it must not re-run what the call *did*.
1844
+ // Only the renderer is handed over, never the tool. The guarantee that
1845
+ // a reload cannot re-run a tool's *effect* is worth more than a comment
1846
+ // saying so: this signature cannot reach `handler`, so a later
1847
+ // maintainer adding a "no render? fall back to the handler" convenience
1848
+ // here has to change the type first, which is exactly the moment the
1849
+ // question should be asked.
1850
+ const render = this.#resolveTool(restored.name)?.render;
1851
+ if (render !== undefined) {
1852
+ this.#renderToolOutput(render, restored);
1853
+ }
1854
+ }
1855
+ return;
1856
+ }
1857
+ if (message.role === "activity") {
1858
+ // The client materialises a pushed activity as a message of its own, so a
1859
+ // chart's data is in the transcript already and survives a reload. Only
1860
+ // the drawing had to be put back.
1861
+ const activity = message as unknown as { activityType?: unknown; content?: unknown };
1862
+ if (activity.activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
1863
+ this.#drawActivityChart(message.id, activity.content);
1826
1864
  }
1827
1865
  return;
1828
1866
  }
@@ -2539,6 +2577,10 @@ export class AgUiChat extends HTMLElement {
2539
2577
  }
2540
2578
  const card = this.#cardFor(call);
2541
2579
  this.#toolCards.delete(call.id);
2580
+ // Kept after the card leaves `#toolCards`: a tool that renders into the
2581
+ // transcript places itself against its own card, and by the time it runs the
2582
+ // card is no longer reachable by id.
2583
+ this.#cardElements.set(call.id, card.element);
2542
2584
  const tool = this.#resolveTool(call.name);
2543
2585
  if (tool === null) {
2544
2586
  // Not a client tool. A server-side tool's real output arrives via
@@ -2613,7 +2655,14 @@ export class AgUiChat extends HTMLElement {
2613
2655
  this.conversationStore.saveCheckpoint(this.#threadId, { toolCallId: call.id });
2614
2656
  }
2615
2657
  try {
2616
- const result = await tool.handler(call.args);
2658
+ // The call id lets a handler that renders into the transcript find its
2659
+ // own card; handlers that only act on the page ignore it.
2660
+ const result = await tool.handler(call.args, call.id);
2661
+ // Drawn from the arguments rather than the result, so the live path and
2662
+ // the replay path render the same thing from the same input.
2663
+ if (tool.render !== undefined) {
2664
+ this.#renderToolOutput(tool.render, call);
2665
+ }
2617
2666
  if (navigates) {
2618
2667
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
2619
2668
  return { content: "", halt: true };
@@ -2775,7 +2824,13 @@ export class AgUiChat extends HTMLElement {
2775
2824
  this.#runTools.push({ id: call.id, name: call.name });
2776
2825
  this.#cardFor(call);
2777
2826
  },
2778
- onActivity: (activityType, content) => {
2827
+ onActivity: (activityType, content, messageId) => {
2828
+ if (activityType === CHART_ACTIVITY_TYPE) {
2829
+ if (this.#chartActivity) {
2830
+ this.#drawActivityChart(messageId, content);
2831
+ }
2832
+ return;
2833
+ }
2779
2834
  if (activityType !== COMPACTION_ACTIVITY_TYPE) {
2780
2835
  return;
2781
2836
  }
@@ -2811,6 +2866,11 @@ export class AgUiChat extends HTMLElement {
2811
2866
  // that shipped in the same release is what makes showing them safe now.
2812
2867
  this.#showPending();
2813
2868
  },
2869
+ onActivityChanged: (messageId, activityType, content) => {
2870
+ if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
2871
+ this.#drawActivityChart(messageId, content);
2872
+ }
2873
+ },
2814
2874
  onRunEnd: () => {
2815
2875
  // Per-round end; the button stays on Stop until the whole interaction
2816
2876
  // settles — the user must be able to cancel between tool rounds.
@@ -2983,6 +3043,108 @@ export class AgUiChat extends HTMLElement {
2983
3043
  this.#messages.scrollTop = this.#messages.scrollHeight;
2984
3044
  }
2985
3045
 
3046
+ /**
3047
+ * Turn on chart rendering, by whichever route this consumer wants.
3048
+ *
3049
+ * Both routes converge on one renderer deliberately. Built apart they become
3050
+ * two chart implementations with two sets of bugs, and the choice between them
3051
+ * is about *where the data lives* rather than how a bar should look:
3052
+ *
3053
+ * - `"tool"` registers the built-in `render_chart`. The agent decides a chart
3054
+ * helps and calls it, so the numbers are in its context and it can discuss
3055
+ * them afterwards. Costs one model round, and works over any transport.
3056
+ * - `"activity"` draws a server-pushed `ACTIVITY_SNAPSHOT` of type `chart`.
3057
+ * No round trip, and the data never enters the model's context at all —
3058
+ * which is what makes it the one for a large or sensitive dataset. Only this
3059
+ * route can update a chart in place as the server computes.
3060
+ *
3061
+ * Off unless asked for, both of them: a component that renders whatever
3062
+ * arrives is not something to switch on for everybody.
3063
+ */
3064
+ enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3065
+ const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
3066
+ if (routes.includes("activity")) {
3067
+ this.#chartActivity = true;
3068
+ }
3069
+ if (routes.includes("tool")) {
3070
+ this.registerTool(createChartTool());
3071
+ }
3072
+ // Called after the element is connected, the history has already replayed
3073
+ // and every chart in it was skipped -- charts were off at the time. That is
3074
+ // the ordinary way to call this (you have to query the element to call
3075
+ // anything on it), so redrawing rather than documenting an ordering rule is
3076
+ // the only answer that does not make the obvious usage wrong.
3077
+ if (first && this.isConnected) {
3078
+ this.reload();
3079
+ }
3080
+ }
3081
+
3082
+ /**
3083
+ * Place a tool's rendered node against its own card.
3084
+ *
3085
+ * Anchored rather than appended because a client tool's handler does not run
3086
+ * until the round is over: appending would put the node after everything the
3087
+ * model said next, visibly detached from the call that produced it, and in a
3088
+ * different order than the same transcript takes on reload. The card was
3089
+ * created inline, in the right place, so anchoring makes *when* the handler
3090
+ * runs stop mattering.
3091
+ */
3092
+ #renderToolOutput(render: ChartRenderer, call: AgUiToolCall): void {
3093
+ let node: Node | null;
3094
+ try {
3095
+ node = render(call.args);
3096
+ } catch (error) {
3097
+ // `render` is consumer code and this runs inside the history replay, where
3098
+ // a throw abandons the loop and takes every later turn of the transcript
3099
+ // with it -- silently, and again on every reload. A chart that fails to
3100
+ // draw is worth losing; the rest of the conversation is not. Reported so
3101
+ // the failure is findable rather than merely survived.
3102
+ console.warn(`ag-ui-chat: render failed for tool ${call.name}`, error);
3103
+ return;
3104
+ }
3105
+ if (node === null) {
3106
+ return;
3107
+ }
3108
+ // `after` rather than an insert-or-append branch: both callers set the card
3109
+ // element immediately before calling, and a parentless anchor makes `after`
3110
+ // a no-op, so the alternative would be a branch nothing can reach.
3111
+ this.#cardElements.get(call.id)?.after(node);
3112
+ this.#afterTranscriptGrew();
3113
+ }
3114
+
3115
+ /** Draw, or redraw in place, the chart for one activity message. */
3116
+ #drawActivityChart(messageId: string, content: unknown): void {
3117
+ const spec = chartSpecFrom(content);
3118
+ const block = spec === null ? null : renderChart(spec);
3119
+ if (block === null) {
3120
+ // The server superseded this chart with something undrawable. Leaving the
3121
+ // old one up is the worst available answer: it shows numbers that have
3122
+ // been retracted, reading as current, and a reload then drops the chart
3123
+ // entirely because the *stored* content is the version we could not draw.
3124
+ // Live and reload should agree, and both should say "gone" rather than
3125
+ // one of them lying.
3126
+ this.#activityBlocks.get(messageId)?.remove();
3127
+ this.#activityBlocks.delete(messageId);
3128
+ return;
3129
+ }
3130
+ const existing = this.#activityBlocks.get(messageId);
3131
+ if (existing === undefined) {
3132
+ this.#ensureGroup().appendChild(block);
3133
+ } else {
3134
+ // Replaced rather than appended: a server redrawing a chart under the same
3135
+ // id means *this chart changed*, and a second copy below the first would
3136
+ // read as two measurements instead of one that moved.
3137
+ existing.replaceWith(block);
3138
+ }
3139
+ this.#activityBlocks.set(messageId, block);
3140
+ this.#afterTranscriptGrew();
3141
+ }
3142
+
3143
+ #afterTranscriptGrew(): void {
3144
+ this.#updateEmptyState();
3145
+ this.#messages.scrollTop = this.#messages.scrollHeight;
3146
+ }
3147
+
2986
3148
  #cardFor(call: AgUiToolCall): ToolCallCard {
2987
3149
  const existing = this.#toolCards.get(call.id);
2988
3150
  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,
@@ -7,11 +7,44 @@ import type { Tool } from "@ag-ui/core";
7
7
  * `handler` receives the parsed arguments and returns a result that is
8
8
  * JSON-serialised into the AG-UI tool-result message sent back to the agent.
9
9
  */
10
+ /**
11
+ * Draws one call from its arguments alone.
12
+ *
13
+ * Named separately so the replay path can take *this* and never the tool that
14
+ * owns it: a function of this type cannot reach a `handler`, which is what
15
+ * makes "a reload never re-runs a tool's effect" a property of the code rather
16
+ * than a note asking maintainers to be careful.
17
+ */
18
+ export type ChartRenderer = (args: Record<string, unknown>) => Node | null;
19
+
10
20
  export interface ClientTool {
11
21
  name: string;
12
22
  description: string;
13
23
  parameters: Record<string, unknown>;
14
- handler: (args: Record<string, unknown>) => unknown | Promise<unknown>;
24
+ /**
25
+ * Run the tool. `callId` identifies the call being executed, for a handler
26
+ * that renders into the transcript and needs to place itself against its own
27
+ * card; handlers that only act on the page ignore it.
28
+ */
29
+ handler: (args: Record<string, unknown>, callId?: string) => unknown | Promise<unknown>;
30
+ /**
31
+ * Draw this call, from its arguments alone.
32
+ *
33
+ * Optional, and **the only half that is replayed**. A restored transcript
34
+ * redraws by calling `render`; it never calls {@link handler}. That is what
35
+ * keeps a replayable tool apart from an effectful one *structurally* rather
36
+ * than by promise: the restore path holds no reference to the half that acts,
37
+ * so re-running `fill_field` on every reload is not a mistake anyone can make.
38
+ *
39
+ * The contract this must keep, because it runs again on every restore:
40
+ *
41
+ * - a pure function of `args` — no host state, no network, no clock;
42
+ * - deterministic, so a reload reproduces what was there before;
43
+ * - free of effects outside the node it returns, which the component places.
44
+ *
45
+ * Return `null` for arguments that say nothing worth drawing.
46
+ */
47
+ render?: ChartRenderer;
15
48
  }
16
49
 
17
50
  /**