@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.
- package/CHANGELOG.md +92 -1
- package/README.md +89 -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 +726 -165
- package/dist/index.js.map +4 -4
- package/dist/tools/client_tool_registry.d.ts +33 -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 +165 -3
- package/src/core/agui_client.ts +51 -3
- package/src/index.ts +8 -0
- package/src/tools/client_tool_registry.ts +34 -1
- package/src/ui/chart_block.ts +354 -0
- package/src/ui/chart_spec_from.ts +125 -0
- package/src/ui/chart_tool.ts +72 -0
- package/src/ui/styles.ts +44 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
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
|
+
// `?? 0` rather than a cast: `renderChart` is exported, so a caller can
|
|
178
|
+
// hand it a series carrying more points than there are labels, which
|
|
179
|
+
// `chartSpecFrom` would have refused. The cast that used to be here
|
|
180
|
+
// claimed that could not happen and wrote `y="NaN"` into the DOM when it
|
|
181
|
+
// did.
|
|
182
|
+
const from = running[i] ?? 0;
|
|
183
|
+
const to = from + value;
|
|
184
|
+
running[i] = to;
|
|
185
|
+
const y = scaleY(to, min, max);
|
|
186
|
+
svg.appendChild(
|
|
187
|
+
el("rect", {
|
|
188
|
+
x: PAD.left + step * i + step * 0.15,
|
|
189
|
+
y,
|
|
190
|
+
width,
|
|
191
|
+
height: Math.max(1, scaleY(from, min, max) - y),
|
|
192
|
+
fill: seriesColor(s),
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function drawLines(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
200
|
+
spec.series.forEach((series, s) => {
|
|
201
|
+
const points = series.points
|
|
202
|
+
.map((value, i) => `${bandCentre(i, spec.labels.length)},${scaleY(value, min, max)}`)
|
|
203
|
+
.join(" ");
|
|
204
|
+
svg.appendChild(
|
|
205
|
+
el("polyline", {
|
|
206
|
+
points,
|
|
207
|
+
fill: "none",
|
|
208
|
+
stroke: seriesColor(s),
|
|
209
|
+
"stroke-width": 2,
|
|
210
|
+
"stroke-linejoin": "round",
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function drawScatter(svg: SVGSVGElement, spec: ChartSpec, min: number, max: number): void {
|
|
217
|
+
spec.series.forEach((series, s) => {
|
|
218
|
+
series.points.forEach((value, i) => {
|
|
219
|
+
svg.appendChild(
|
|
220
|
+
el("circle", {
|
|
221
|
+
cx: bandCentre(i, spec.labels.length),
|
|
222
|
+
cy: scaleY(value, min, max),
|
|
223
|
+
r: 4,
|
|
224
|
+
fill: seriesColor(s),
|
|
225
|
+
"fill-opacity": 0.85,
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Pie draws the **first** series' points as shares of their own total, one
|
|
234
|
+
* wedge per label — the only kind whose slices are the labels rather than the
|
|
235
|
+
* series, so a second series has nowhere to go and is ignored rather than
|
|
236
|
+
* silently summed into the first.
|
|
237
|
+
*/
|
|
238
|
+
function drawPie(svg: SVGSVGElement, points: readonly number[]): void {
|
|
239
|
+
const total = points.reduce((sum, value) => sum + value, 0);
|
|
240
|
+
const cx = WIDTH / 2;
|
|
241
|
+
const cy = PAD.top + PLOT_H / 2;
|
|
242
|
+
const r = Math.min(PLOT_W, PLOT_H) / 2;
|
|
243
|
+
if (total === 0) {
|
|
244
|
+
// Every share is zero, so there is no wedge to draw and a full circle would
|
|
245
|
+
// claim one slice owns everything. An outline says "nothing here" honestly.
|
|
246
|
+
svg.appendChild(
|
|
247
|
+
el("circle", { cx, cy, r, fill: "none", stroke: "currentColor", "stroke-opacity": 0.3 }),
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
let angle = -Math.PI / 2;
|
|
252
|
+
points.forEach((value, i) => {
|
|
253
|
+
const sweep = (value / total) * Math.PI * 2;
|
|
254
|
+
const end = angle + sweep;
|
|
255
|
+
// A wedge of the whole circle cannot be drawn as one arc (start and end
|
|
256
|
+
// coincide, so the path collapses); draw it as a plain circle instead.
|
|
257
|
+
if (sweep >= Math.PI * 2) {
|
|
258
|
+
svg.appendChild(el("circle", { cx, cy, r, fill: seriesColor(i) }));
|
|
259
|
+
} else {
|
|
260
|
+
const x1 = cx + r * Math.cos(angle);
|
|
261
|
+
const y1 = cy + r * Math.sin(angle);
|
|
262
|
+
const x2 = cx + r * Math.cos(end);
|
|
263
|
+
const y2 = cy + r * Math.sin(end);
|
|
264
|
+
const large = sweep > Math.PI ? 1 : 0;
|
|
265
|
+
svg.appendChild(
|
|
266
|
+
el("path", {
|
|
267
|
+
d: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z`,
|
|
268
|
+
fill: seriesColor(i),
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
angle = end;
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function buildLegend(entries: readonly string[]): HTMLDivElement | null {
|
|
277
|
+
if (entries.length < 2) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
const row = document.createElement("div");
|
|
281
|
+
row.className = "chart-legend";
|
|
282
|
+
row.setAttribute("part", "chart-legend");
|
|
283
|
+
entries.forEach((label, i) => {
|
|
284
|
+
const item = document.createElement("span");
|
|
285
|
+
item.className = "chart-legend-item";
|
|
286
|
+
const swatch = document.createElement("span");
|
|
287
|
+
swatch.className = "chart-legend-swatch";
|
|
288
|
+
swatch.style.background = seriesColor(i);
|
|
289
|
+
item.append(swatch, document.createTextNode(label));
|
|
290
|
+
row.appendChild(item);
|
|
291
|
+
});
|
|
292
|
+
return row;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Render one spec as a self-contained block, or `null` when it says nothing.
|
|
297
|
+
*
|
|
298
|
+
* A spec with no labels or no series is not drawn: an empty frame reads as
|
|
299
|
+
* "there is no data" when the truth is "the caller sent nothing", and the two
|
|
300
|
+
* deserve different answers.
|
|
301
|
+
*/
|
|
302
|
+
export function renderChart(spec: ChartSpec): HTMLDivElement | null {
|
|
303
|
+
if (spec.labels.length === 0 || spec.series.length === 0) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const block = document.createElement("div");
|
|
307
|
+
block.className = "chart-block";
|
|
308
|
+
block.setAttribute("part", "chart-block");
|
|
309
|
+
|
|
310
|
+
if (spec.title !== undefined && spec.title !== "") {
|
|
311
|
+
const heading = document.createElement("div");
|
|
312
|
+
heading.className = "chart-title";
|
|
313
|
+
heading.setAttribute("part", "chart-title");
|
|
314
|
+
heading.textContent = spec.title;
|
|
315
|
+
block.appendChild(heading);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const svg = el("svg", { viewBox: `0 0 ${WIDTH} ${HEIGHT}`, width: "100%", role: "img" });
|
|
319
|
+
svg.setAttribute("aria-label", spec.title ?? `${spec.kind} chart`);
|
|
320
|
+
|
|
321
|
+
if (spec.kind === "pie") {
|
|
322
|
+
// `series[0]` is guaranteed by the early return above; a pie's slices are
|
|
323
|
+
// its labels, so a second series has nowhere to go and is ignored rather
|
|
324
|
+
// than silently summed into the first. Negative shares are floored, since a
|
|
325
|
+
// wedge cannot sweep backwards.
|
|
326
|
+
const first = spec.series[0] as ChartSeries;
|
|
327
|
+
drawPie(
|
|
328
|
+
svg,
|
|
329
|
+
first.points.map((value) => Math.max(0, value)),
|
|
330
|
+
);
|
|
331
|
+
} else {
|
|
332
|
+
const { min, max } = extent(spec);
|
|
333
|
+
drawAxes(svg, spec, min, max);
|
|
334
|
+
if (spec.kind === "bar") {
|
|
335
|
+
drawBars(svg, spec, min, max);
|
|
336
|
+
} else if (spec.kind === "stacked") {
|
|
337
|
+
drawStacked(svg, spec, min, max);
|
|
338
|
+
} else if (spec.kind === "line") {
|
|
339
|
+
drawLines(svg, spec, min, max);
|
|
340
|
+
} else {
|
|
341
|
+
drawScatter(svg, spec, min, max);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
block.appendChild(svg);
|
|
345
|
+
|
|
346
|
+
// Pie's slices are its labels; every other kind's are its series.
|
|
347
|
+
const legend = buildLegend(
|
|
348
|
+
spec.kind === "pie" ? spec.labels : spec.series.map((series) => series.label),
|
|
349
|
+
);
|
|
350
|
+
if (legend !== null) {
|
|
351
|
+
block.appendChild(legend);
|
|
352
|
+
}
|
|
353
|
+
return block;
|
|
354
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow untrusted input into a {@link ChartSpec}.
|
|
3
|
+
*
|
|
4
|
+
* Both arrival routes are untrusted in the same way — a model writes one, a
|
|
5
|
+
* server the other — so neither is taken on shape. Kept out of the renderer
|
|
6
|
+
* because the renderer's job is drawing, and a value that reaches it has
|
|
7
|
+
* already been vouched for.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ChartKind, ChartSpec } from "./chart_block.js";
|
|
11
|
+
|
|
12
|
+
const KINDS: readonly ChartKind[] = ["bar", "line", "pie", "scatter", "stacked"];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Most points a spec may carry, across every series.
|
|
16
|
+
*
|
|
17
|
+
* Not a taste limit. `Math.max(0, ...values)` throws `RangeError` on a large
|
|
18
|
+
* enough spread, and the renderer runs inside the history replay, where a throw
|
|
19
|
+
* abandons the replay and takes every later turn of the transcript with it —
|
|
20
|
+
* permanently, on every reload. A chart nobody can read is a far smaller
|
|
21
|
+
* problem than a conversation that silently loses its tail.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_POINTS = 20_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a spec's *labels* are cheap enough to draw.
|
|
27
|
+
*
|
|
28
|
+
* `MAX_POINTS` bounds the data; this bounds the DOM. Every label produces an
|
|
29
|
+
* axis text node whatever the series count, so a spec well inside the point
|
|
30
|
+
* budget can still emit tens of thousands of nodes and block the main thread —
|
|
31
|
+
* again on every reload, since it is in the transcript. Kept separate because
|
|
32
|
+
* the two limits answer different questions and a single number cannot.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_LABELS = 2_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Largest magnitude a point may carry.
|
|
38
|
+
*
|
|
39
|
+
* `Number.isFinite` is not enough on its own: two finite extremes still give an
|
|
40
|
+
* infinite *range*, and `(value - min) / Infinity` is `NaN`, which reaches the
|
|
41
|
+
* DOM as `y="NaN"`. Bounding the values bounds the range.
|
|
42
|
+
*/
|
|
43
|
+
const MAX_MAGNITUDE = 1e15;
|
|
44
|
+
|
|
45
|
+
function asKind(value: unknown): ChartKind {
|
|
46
|
+
// Anything unrecognised falls back to `bar` rather than refusing the spec: an
|
|
47
|
+
// unknown kind is a caller reaching for a chart type we do not draw, and the
|
|
48
|
+
// data is still worth showing.
|
|
49
|
+
return KINDS.includes(value as ChartKind) ? (value as ChartKind) : "bar";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asNumbers(value: unknown): number[] | null {
|
|
53
|
+
if (!Array.isArray(value)) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const out: number[] = [];
|
|
57
|
+
for (const item of value) {
|
|
58
|
+
// `Number.isFinite` rather than `typeof === "number"`: JSON encoders render
|
|
59
|
+
// NaN and Infinity as nulls or strings depending on the encoder, and either
|
|
60
|
+
// would scale into a chart with no visible extent.
|
|
61
|
+
if (typeof item !== "number" || !Number.isFinite(item)) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (Math.abs(item) > MAX_MAGNITUDE) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
out.push(item);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function asStrings(value: unknown): string[] | null {
|
|
73
|
+
if (!Array.isArray(value)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
// `every` over an index range rather than `Array.prototype.some`, which skips
|
|
77
|
+
// holes: a sparse array passed the old check and drew a chart with blank axis
|
|
78
|
+
// labels, which reads as a rendering bug rather than bad input.
|
|
79
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
80
|
+
if (typeof value[i] !== "string") {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return value as string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A well-formed spec, or `null` for anything that cannot be drawn honestly. */
|
|
88
|
+
export function chartSpecFrom(value: unknown): ChartSpec | null {
|
|
89
|
+
if (typeof value !== "object" || value === null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
const raw = value as Record<string, unknown>;
|
|
93
|
+
const labels = asStrings(raw["labels"]);
|
|
94
|
+
if (labels === null || !Array.isArray(raw["series"])) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const series: { label: string; points: number[] }[] = [];
|
|
99
|
+
for (const entry of raw["series"]) {
|
|
100
|
+
if (typeof entry !== "object" || entry === null) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const item = entry as Record<string, unknown>;
|
|
104
|
+
const points = asNumbers(item["points"]);
|
|
105
|
+
// A series with a different number of points than there are labels would
|
|
106
|
+
// silently misalign every value after the gap. A chart that is subtly wrong
|
|
107
|
+
// still reads as authoritative, which is worse than no chart at all.
|
|
108
|
+
if (points === null || points.length !== labels.length) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
series.push({ label: typeof item["label"] === "string" ? item["label"] : "", points });
|
|
112
|
+
}
|
|
113
|
+
if (series.length === 0) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (series.length * labels.length > MAX_POINTS || labels.length > MAX_LABELS) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const kind = asKind(raw["kind"]);
|
|
121
|
+
const title = raw["title"];
|
|
122
|
+
// The key is omitted rather than set to `undefined`: `title` is genuinely
|
|
123
|
+
// optional and this tsconfig distinguishes absent from present-and-undefined.
|
|
124
|
+
return typeof title === "string" ? { kind, title, labels, series } : { kind, labels, series };
|
|
125
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built-in `render_chart` tool: the agent-called route to a chart.
|
|
3
|
+
*
|
|
4
|
+
* A thin thing on purpose. It carries no `handler` of its own beyond reporting
|
|
5
|
+
* back to the model, because everything it does is drawing — which lives in
|
|
6
|
+
* `render`, the half the component replays. A consumer wanting charts gets this
|
|
7
|
+
* without writing a renderer; a consumer wanting something else writes their own
|
|
8
|
+
* tool against the same seam.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ClientTool } from "../tools/client_tool_registry.js";
|
|
12
|
+
import { renderChart } from "./chart_block.js";
|
|
13
|
+
import { chartSpecFrom } from "./chart_spec_from.js";
|
|
14
|
+
|
|
15
|
+
/** The name the built-in chart tool registers under. */
|
|
16
|
+
export const CHART_TOOL_NAME = "render_chart";
|
|
17
|
+
|
|
18
|
+
function draw(args: Record<string, unknown>): HTMLDivElement | null {
|
|
19
|
+
const spec = chartSpecFrom(args);
|
|
20
|
+
return spec === null ? null : renderChart(spec);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Whether these arguments would produce a chart, without producing one. */
|
|
24
|
+
function drawable(args: Record<string, unknown>): boolean {
|
|
25
|
+
const spec = chartSpecFrom(args);
|
|
26
|
+
return spec !== null && spec.labels.length > 0 && spec.series.length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const REJECTED =
|
|
30
|
+
"chart not rendered: expected labels (strings) and series, each with one finite number per label";
|
|
31
|
+
|
|
32
|
+
/** The built-in chart tool. */
|
|
33
|
+
export function createChartTool(): ClientTool {
|
|
34
|
+
return {
|
|
35
|
+
name: CHART_TOOL_NAME,
|
|
36
|
+
description:
|
|
37
|
+
"Show a chart in the conversation. Supply the data and the page draws it. " +
|
|
38
|
+
"Every series must have exactly one point per label.",
|
|
39
|
+
parameters: {
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
kind: { type: "string", enum: ["bar", "line", "pie", "scatter", "stacked"] },
|
|
43
|
+
title: { type: "string" },
|
|
44
|
+
labels: { type: "array", items: { type: "string" } },
|
|
45
|
+
series: {
|
|
46
|
+
type: "array",
|
|
47
|
+
items: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
label: { type: "string" },
|
|
51
|
+
points: { type: "array", items: { type: "number" } },
|
|
52
|
+
},
|
|
53
|
+
required: ["points"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
required: ["labels", "series"],
|
|
58
|
+
"x-summary": "Draw a chart",
|
|
59
|
+
},
|
|
60
|
+
// Says what happened and nothing else; the drawing is `render`'s job. Told
|
|
61
|
+
// plainly when the arguments are unusable, because the model can fix that
|
|
62
|
+
// and retry — a silent no-op would leave it believing the chart is on screen.
|
|
63
|
+
// Answers on what will actually be drawn, not on what validated: a spec can
|
|
64
|
+
// pass validation and still have nothing to show, and reporting success
|
|
65
|
+
// then would leave the model believing a chart is on screen. Asks the
|
|
66
|
+
// question without building the chart, because `render` is about to build
|
|
67
|
+
// the same one a moment later and drawing it twice is pure waste on a spec
|
|
68
|
+
// large enough to matter.
|
|
69
|
+
handler: (args: Record<string, unknown>) => (drawable(args) ? "chart rendered" : REJECTED),
|
|
70
|
+
render: draw,
|
|
71
|
+
};
|
|
72
|
+
}
|
package/src/ui/styles.ts
CHANGED
|
@@ -1528,6 +1528,50 @@ export const STYLES = `
|
|
|
1528
1528
|
margin-top: 6px;
|
|
1529
1529
|
}
|
|
1530
1530
|
|
|
1531
|
+
/* Charts.
|
|
1532
|
+
*
|
|
1533
|
+
* The SVG scales to the column and carries no colours of its own beyond the
|
|
1534
|
+
* series palette, so a host restyles it the same way it restyles everything
|
|
1535
|
+
* else. Series colours are custom properties with fallbacks rather than fixed
|
|
1536
|
+
* values, and the axis furniture inherits currentColor at low opacity so it
|
|
1537
|
+
* reads correctly in either theme without a second palette.
|
|
1538
|
+
*/
|
|
1539
|
+
.chart-block {
|
|
1540
|
+
align-self: stretch;
|
|
1541
|
+
max-width: 100%;
|
|
1542
|
+
margin: 6px 0;
|
|
1543
|
+
color: var(--_fg);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
.chart-title {
|
|
1547
|
+
margin-bottom: 2px;
|
|
1548
|
+
font-size: 0.85em;
|
|
1549
|
+
font-weight: 600;
|
|
1550
|
+
opacity: 0.85;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
.chart-legend {
|
|
1554
|
+
display: flex;
|
|
1555
|
+
flex-wrap: wrap;
|
|
1556
|
+
gap: 4px 12px;
|
|
1557
|
+
margin-top: 4px;
|
|
1558
|
+
font-size: 0.78em;
|
|
1559
|
+
opacity: 0.75;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
.chart-legend-item {
|
|
1563
|
+
display: inline-flex;
|
|
1564
|
+
align-items: center;
|
|
1565
|
+
gap: 5px;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
.chart-legend-swatch {
|
|
1569
|
+
width: 9px;
|
|
1570
|
+
height: 9px;
|
|
1571
|
+
border-radius: 2px;
|
|
1572
|
+
flex: 0 0 auto;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1531
1575
|
.run-notice {
|
|
1532
1576
|
display: inline-flex;
|
|
1533
1577
|
align-items: center;
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION: string = "0.
|
|
1
|
+
export const VERSION: string = "0.26.1";
|