@artooi/ag-ui-web-component 0.25.2 → 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.
- package/CHANGELOG.md +42 -1
- package/README.md +69 -0
- package/dist/ag-ui-web-component.bundle.js +73 -29
- package/dist/ag-ui-web-component.bundle.js.map +4 -4
- package/dist/constants.d.ts +10 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/core/ag_ui_chat.d.ts +19 -0
- package/dist/core/ag_ui_chat.d.ts.map +1 -1
- package/dist/core/agui_client.d.ts +12 -1
- package/dist/core/agui_client.d.ts.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +712 -165
- package/dist/index.js.map +4 -4
- package/dist/tools/client_tool_registry.d.ts +24 -1
- package/dist/tools/client_tool_registry.d.ts.map +1 -1
- package/dist/ui/chart_block.d.ts +39 -0
- package/dist/ui/chart_block.d.ts.map +1 -0
- package/dist/ui/chart_spec_from.d.ts +12 -0
- package/dist/ui/chart_spec_from.d.ts.map +1 -0
- package/dist/ui/chart_tool.d.ts +15 -0
- package/dist/ui/chart_tool.d.ts.map +1 -0
- package/dist/ui/styles.d.ts +1 -1
- package/dist/ui/styles.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +11 -0
- package/src/core/ag_ui_chat.ts +145 -3
- package/src/core/agui_client.ts +51 -3
- package/src/index.ts +8 -0
- package/src/tools/client_tool_registry.ts +24 -1
- package/src/ui/chart_block.ts +349 -0
- package/src/ui/chart_spec_from.ts +106 -0
- package/src/ui/chart_tool.ts +64 -0
- package/src/ui/styles.ts +44 -0
- package/src/version.ts +1 -1
package/src/core/ag_ui_chat.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -2811,6 +2857,11 @@ export class AgUiChat extends HTMLElement {
|
|
|
2811
2857
|
// that shipped in the same release is what makes showing them safe now.
|
|
2812
2858
|
this.#showPending();
|
|
2813
2859
|
},
|
|
2860
|
+
onActivityChanged: (messageId, activityType, content) => {
|
|
2861
|
+
if (activityType === CHART_ACTIVITY_TYPE && this.#chartActivity) {
|
|
2862
|
+
this.#drawActivityChart(messageId, content);
|
|
2863
|
+
}
|
|
2864
|
+
},
|
|
2814
2865
|
onRunEnd: () => {
|
|
2815
2866
|
// Per-round end; the button stays on Stop until the whole interaction
|
|
2816
2867
|
// settles — the user must be able to cancel between tool rounds.
|
|
@@ -2983,6 +3034,97 @@ export class AgUiChat extends HTMLElement {
|
|
|
2983
3034
|
this.#messages.scrollTop = this.#messages.scrollHeight;
|
|
2984
3035
|
}
|
|
2985
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
|
+
|
|
2986
3128
|
#cardFor(call: AgUiToolCall): ToolCallCard {
|
|
2987
3129
|
const existing = this.#toolCards.get(call.id);
|
|
2988
3130
|
if (existing !== undefined) {
|
package/src/core/agui_client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
/**
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Draw a {@link ChartSpec} as SVG.
|
|
3
|
+
*
|
|
4
|
+
* Every node is built with `createElement`, never parsed from a string, so this
|
|
5
|
+
* path never reaches the markdown sanitiser and cannot be widened by model
|
|
6
|
+
* output. That is the argument for taking a *spec* rather than markup: the model
|
|
7
|
+
* chooses the numbers, this module chooses the DOM. It is why a chart can be
|
|
8
|
+
* shown on a surface that keeps `img` off by default, a model-controlled URL
|
|
9
|
+
* being a zero-click exfiltration channel.
|
|
10
|
+
*
|
|
11
|
+
* Hand-rolled rather than a charting library, and the difference is not
|
|
12
|
+
* marginal: the whole renderer costs single-digit kilobytes where a library
|
|
13
|
+
* costs roughly half this bundle again, in a component distributed over a CDN.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
17
|
+
|
|
18
|
+
/** One named series of numbers. */
|
|
19
|
+
export interface ChartSeries {
|
|
20
|
+
readonly label: string;
|
|
21
|
+
readonly points: readonly number[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How a spec is drawn. */
|
|
25
|
+
export type ChartKind = "bar" | "line" | "pie" | "scatter" | "stacked";
|
|
26
|
+
|
|
27
|
+
/** A chart, as data. */
|
|
28
|
+
export interface ChartSpec {
|
|
29
|
+
readonly kind: ChartKind;
|
|
30
|
+
readonly title?: string;
|
|
31
|
+
readonly labels: readonly string[];
|
|
32
|
+
readonly series: readonly ChartSeries[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const WIDTH = 480;
|
|
36
|
+
const HEIGHT = 220;
|
|
37
|
+
const PAD = { top: 20, right: 12, bottom: 30, left: 44 };
|
|
38
|
+
const PLOT_W = WIDTH - PAD.left - PAD.right;
|
|
39
|
+
const PLOT_H = HEIGHT - PAD.top - PAD.bottom;
|
|
40
|
+
|
|
41
|
+
// Read from the host's own palette rather than a fixed ramp: the component
|
|
42
|
+
// themes through custom properties everywhere else, and a chart that ignored
|
|
43
|
+
// that would be the one element a host could not restyle.
|
|
44
|
+
const SERIES_COLORS: readonly string[] = [
|
|
45
|
+
"var(--ag-ui-chart-1, #4f7cff)",
|
|
46
|
+
"var(--ag-ui-chart-2, #21b573)",
|
|
47
|
+
"var(--ag-ui-chart-3, #e0803c)",
|
|
48
|
+
"var(--ag-ui-chart-4, #b563d8)",
|
|
49
|
+
"var(--ag-ui-chart-5, #d84f6e)",
|
|
50
|
+
"var(--ag-ui-chart-6, #3ba7c4)",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** The colour for series `index`, wrapping when there are more series than colours. */
|
|
54
|
+
export function seriesColor(index: number): string {
|
|
55
|
+
// Cast rather than a `??` fallback: the modulo guarantees a hit, so a
|
|
56
|
+
// fallback would be a branch no test could ever reach.
|
|
57
|
+
return SERIES_COLORS[index % SERIES_COLORS.length] as string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function el<K extends keyof SVGElementTagNameMap>(
|
|
61
|
+
name: K,
|
|
62
|
+
attrs: Record<string, string | number>,
|
|
63
|
+
): SVGElementTagNameMap[K] {
|
|
64
|
+
const node = document.createElementNS(SVG_NS, name);
|
|
65
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
66
|
+
node.setAttribute(key, String(value));
|
|
67
|
+
}
|
|
68
|
+
return node;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function text(value: string, attrs: Record<string, string | number>): SVGTextElement {
|
|
72
|
+
const node = el("text", {
|
|
73
|
+
"font-size": 10,
|
|
74
|
+
fill: "currentColor",
|
|
75
|
+
"fill-opacity": 0.65,
|
|
76
|
+
...attrs,
|
|
77
|
+
});
|
|
78
|
+
// `textContent`, so a model-supplied label is text and never markup.
|
|
79
|
+
node.textContent = value;
|
|
80
|
+
return node;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every running subtotal a stack passes through, column by column. */
|
|
84
|
+
function stackRunning(spec: ChartSpec): number[] {
|
|
85
|
+
const seen: number[] = [];
|
|
86
|
+
spec.labels.forEach((_label, i) => {
|
|
87
|
+
let running = 0;
|
|
88
|
+
for (const series of spec.series) {
|
|
89
|
+
running += series.points[i] ?? 0;
|
|
90
|
+
seen.push(running);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
return seen;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function extent(spec: ChartSpec): { min: number; max: number } {
|
|
97
|
+
// A stack's extent has to cover every *running subtotal*, not just the column
|
|
98
|
+
// totals: with mixed signs the running value swings wider than the total it
|
|
99
|
+
// ends on, and scaling to the total alone puts segments far off the canvas.
|
|
100
|
+
const values =
|
|
101
|
+
spec.kind === "stacked"
|
|
102
|
+
? stackRunning(spec)
|
|
103
|
+
: spec.series.flatMap((series) => [...series.points]);
|
|
104
|
+
const max = Math.max(0, ...values);
|
|
105
|
+
const min = Math.min(0, ...values);
|
|
106
|
+
// A flat series would divide by zero when scaling; give it a nominal span so
|
|
107
|
+
// it draws as a flat line rather than vanishing.
|
|
108
|
+
return max === min ? { min, max: max + 1 } : { min, max };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function scaleY(value: number, min: number, max: number): number {
|
|
112
|
+
return PAD.top + PLOT_H - ((value - min) / (max - min)) * PLOT_H;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bandCentre(index: number, count: number): number {
|
|
116
|
+
const step = PLOT_W / count;
|
|
117
|
+
return PAD.left + step * index + step / 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function drawAxes(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
121
|
+
for (const value of [min, max]) {
|
|
122
|
+
const y = scaleY(value, min, max);
|
|
123
|
+
svg.appendChild(
|
|
124
|
+
el("line", {
|
|
125
|
+
x1: PAD.left,
|
|
126
|
+
y1: y,
|
|
127
|
+
x2: WIDTH - PAD.right,
|
|
128
|
+
y2: y,
|
|
129
|
+
stroke: "currentColor",
|
|
130
|
+
"stroke-opacity": value === min ? 0.35 : 0.12,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
svg.appendChild(
|
|
134
|
+
text(String(Math.round(value)), { x: PAD.left - 6, y: y + 4, "text-anchor": "end" }),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
spec.labels.forEach((label, i) => {
|
|
138
|
+
svg.appendChild(
|
|
139
|
+
text(label, {
|
|
140
|
+
x: bandCentre(i, spec.labels.length),
|
|
141
|
+
y: HEIGHT - PAD.bottom + 16,
|
|
142
|
+
"text-anchor": "middle",
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function drawBars(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
149
|
+
const step = PLOT_W / spec.labels.length;
|
|
150
|
+
const width = (step * 0.7) / spec.series.length;
|
|
151
|
+
const base = scaleY(min, min, max);
|
|
152
|
+
spec.series.forEach((series, s) => {
|
|
153
|
+
series.points.forEach((value, i) => {
|
|
154
|
+
const y = scaleY(value, min, max);
|
|
155
|
+
svg.appendChild(
|
|
156
|
+
el("rect", {
|
|
157
|
+
x: PAD.left + step * i + step * 0.15 + width * s,
|
|
158
|
+
y,
|
|
159
|
+
width,
|
|
160
|
+
height: Math.max(1, base - y),
|
|
161
|
+
fill: seriesColor(s),
|
|
162
|
+
rx: 2,
|
|
163
|
+
}),
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
170
|
+
const step = PLOT_W / spec.labels.length;
|
|
171
|
+
const width = step * 0.7;
|
|
172
|
+
// Indexed by the same `i` it was built from, so every read is a hit; cast
|
|
173
|
+
// rather than defaulting, which would add a branch nothing can reach.
|
|
174
|
+
const running = spec.labels.map(() => 0);
|
|
175
|
+
spec.series.forEach((series, s) => {
|
|
176
|
+
series.points.forEach((value, i) => {
|
|
177
|
+
const from = running[i] as number;
|
|
178
|
+
const to = from + value;
|
|
179
|
+
running[i] = to;
|
|
180
|
+
const y = scaleY(to, min, max);
|
|
181
|
+
svg.appendChild(
|
|
182
|
+
el("rect", {
|
|
183
|
+
x: PAD.left + step * i + step * 0.15,
|
|
184
|
+
y,
|
|
185
|
+
width,
|
|
186
|
+
height: Math.max(1, scaleY(from, min, max) - y),
|
|
187
|
+
fill: seriesColor(s),
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
195
|
+
spec.series.forEach((series, s) => {
|
|
196
|
+
const points = series.points
|
|
197
|
+
.map((value, i) => `${bandCentre(i, spec.labels.length)},${scaleY(value, min, max)}`)
|
|
198
|
+
.join(" ");
|
|
199
|
+
svg.appendChild(
|
|
200
|
+
el("polyline", {
|
|
201
|
+
points,
|
|
202
|
+
fill: "none",
|
|
203
|
+
stroke: seriesColor(s),
|
|
204
|
+
"stroke-width": 2,
|
|
205
|
+
"stroke-linejoin": "round",
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
212
|
+
spec.series.forEach((series, s) => {
|
|
213
|
+
series.points.forEach((value, i) => {
|
|
214
|
+
svg.appendChild(
|
|
215
|
+
el("circle", {
|
|
216
|
+
cx: bandCentre(i, spec.labels.length),
|
|
217
|
+
cy: scaleY(value, min, max),
|
|
218
|
+
r: 4,
|
|
219
|
+
fill: seriesColor(s),
|
|
220
|
+
"fill-opacity": 0.85,
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Pie draws the **first** series' points as shares of their own total, one
|
|
229
|
+
* wedge per label — the only kind whose slices are the labels rather than the
|
|
230
|
+
* series, so a second series has nowhere to go and is ignored rather than
|
|
231
|
+
* silently summed into the first.
|
|
232
|
+
*/
|
|
233
|
+
function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
|
|
234
|
+
const total = points.reduce((sum, value) => sum + value, 0);
|
|
235
|
+
const cx = WIDTH / 2;
|
|
236
|
+
const cy = PAD.top + PLOT_H / 2;
|
|
237
|
+
const r = Math.min(PLOT_W, PLOT_H) / 2;
|
|
238
|
+
if (total === 0) {
|
|
239
|
+
// Every share is zero, so there is no wedge to draw and a full circle would
|
|
240
|
+
// claim one slice owns everything. An outline says "nothing here" honestly.
|
|
241
|
+
svg.appendChild(
|
|
242
|
+
el("circle", { cx, cy, r, fill: "none", stroke: "currentColor", "stroke-opacity": 0.3 }),
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
let angle = -Math.PI / 2;
|
|
247
|
+
points.forEach((value, i) => {
|
|
248
|
+
const sweep = (value / total) * Math.PI * 2;
|
|
249
|
+
const end = angle + sweep;
|
|
250
|
+
// A wedge of the whole circle cannot be drawn as one arc (start and end
|
|
251
|
+
// coincide, so the path collapses); draw it as a plain circle instead.
|
|
252
|
+
if (sweep >= Math.PI * 2) {
|
|
253
|
+
svg.appendChild(el("circle", { cx, cy, r, fill: seriesColor(i) }));
|
|
254
|
+
} else {
|
|
255
|
+
const x1 = cx + r * Math.cos(angle);
|
|
256
|
+
const y1 = cy + r * Math.sin(angle);
|
|
257
|
+
const x2 = cx + r * Math.cos(end);
|
|
258
|
+
const y2 = cy + r * Math.sin(end);
|
|
259
|
+
const large = sweep > Math.PI ? 1 : 0;
|
|
260
|
+
svg.appendChild(
|
|
261
|
+
el("path", {
|
|
262
|
+
d: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`,
|
|
263
|
+
fill: seriesColor(i),
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
angle = end;
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function buildLegend(entries: readonly string[]): HTMLDivElement | null {
|
|
272
|
+
if (entries.length < 2) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
const row = document.createElement("div");
|
|
276
|
+
row.className = "chart-legend";
|
|
277
|
+
row.setAttribute("part", "chart-legend");
|
|
278
|
+
entries.forEach((label, i) => {
|
|
279
|
+
const item = document.createElement("span");
|
|
280
|
+
item.className = "chart-legend-item";
|
|
281
|
+
const swatch = document.createElement("span");
|
|
282
|
+
swatch.className = "chart-legend-swatch";
|
|
283
|
+
swatch.style.background = seriesColor(i);
|
|
284
|
+
item.append(swatch, document.createTextNode(label));
|
|
285
|
+
row.appendChild(item);
|
|
286
|
+
});
|
|
287
|
+
return row;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Render one spec as a self-contained block, or `null` when it says nothing.
|
|
292
|
+
*
|
|
293
|
+
* A spec with no labels or no series is not drawn: an empty frame reads as
|
|
294
|
+
* "there is no data" when the truth is "the caller sent nothing", and the two
|
|
295
|
+
* deserve different answers.
|
|
296
|
+
*/
|
|
297
|
+
export function renderChart(spec: ChartSpec): HTMLDivElement | null {
|
|
298
|
+
if (spec.labels.length === 0 || spec.series.length === 0) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const block = document.createElement("div");
|
|
302
|
+
block.className = "chart-block";
|
|
303
|
+
block.setAttribute("part", "chart-block");
|
|
304
|
+
|
|
305
|
+
if (spec.title !== undefined && spec.title !== "") {
|
|
306
|
+
const heading = document.createElement("div");
|
|
307
|
+
heading.className = "chart-title";
|
|
308
|
+
heading.setAttribute("part", "chart-title");
|
|
309
|
+
heading.textContent = spec.title;
|
|
310
|
+
block.appendChild(heading);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const svg = el("svg", { viewBox: `0 0 ${WIDTH} ${HEIGHT}`, width: "100%", role: "img" });
|
|
314
|
+
svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
|
|
315
|
+
|
|
316
|
+
if (spec.kind === "pie") {
|
|
317
|
+
// `series[0]` is guaranteed by the early return above; a pie's slices are
|
|
318
|
+
// its labels, so a second series has nowhere to go and is ignored rather
|
|
319
|
+
// than silently summed into the first. Negative shares are floored, since a
|
|
320
|
+
// wedge cannot sweep backwards.
|
|
321
|
+
const first = spec.series[0] as ChartSeries;
|
|
322
|
+
drawPie(
|
|
323
|
+
svg,
|
|
324
|
+
first.points.map((value) => Math.max(0, value)),
|
|
325
|
+
);
|
|
326
|
+
} else {
|
|
327
|
+
const { min, max } = extent(spec);
|
|
328
|
+
drawAxes(svg, spec, min, max);
|
|
329
|
+
if (spec.kind === "bar") {
|
|
330
|
+
drawBars(svg, spec, min, max);
|
|
331
|
+
} else if (spec.kind === "stacked") {
|
|
332
|
+
drawStacked(svg, spec, min, max);
|
|
333
|
+
} else if (spec.kind === "line") {
|
|
334
|
+
drawLines(svg, spec, min, max);
|
|
335
|
+
} else {
|
|
336
|
+
drawScatter(svg, spec, min, max);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
block.appendChild(svg);
|
|
340
|
+
|
|
341
|
+
// Pie's slices are its labels; every other kind's are its series.
|
|
342
|
+
const legend = buildLegend(
|
|
343
|
+
spec.kind === "pie" ? spec.labels : spec.series.map((series) => series.label),
|
|
344
|
+
);
|
|
345
|
+
if (legend !== null) {
|
|
346
|
+
block.appendChild(legend);
|
|
347
|
+
}
|
|
348
|
+
return block;
|
|
349
|
+
}
|