@dbx-tools/appkit-mastra 0.6.15 → 0.6.36
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/README.md +87 -11
- package/index.ts +6 -3
- package/lib/index.d.ts +6 -3
- package/lib/index.js +5 -2
- package/lib/src/chart.d.ts +39 -9
- package/lib/src/chart.js +460 -77
- package/lib/src/genie.js +24 -24
- package/lib/src/plugin.js +29 -3
- package/lib/src/remote-skills.js +10 -10
- package/lib/src/skill-paths.d.ts +15 -0
- package/lib/src/skill-paths.js +18 -0
- package/lib/src/workspaces.d.ts +85 -15
- package/lib/src/workspaces.js +128 -64
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +13 -13
- package/src/chart.ts +519 -83
- package/src/genie.ts +24 -23
- package/src/plugin.ts +28 -2
- package/src/remote-skills.ts +10 -11
- package/src/skill-paths.ts +19 -0
- package/src/workspaces.ts +194 -84
package/src/chart.ts
CHANGED
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { AppKitError, CacheManager, ExecutionError } from "@databricks/appkit";
|
|
31
|
-
import { async, error, hash, log, string, type BrandContext } from "@dbx-tools/shared-core";
|
|
32
|
-
import { wire, type Chart, type ChartResult } from "@dbx-tools/shared-mastra";
|
|
31
|
+
import { async, error, hash, json, log, string, type BrandContext } from "@dbx-tools/shared-core";
|
|
32
|
+
import { marker, wire, type Chart, type ChartResult } from "@dbx-tools/shared-mastra";
|
|
33
33
|
import { model } from "@dbx-tools/shared-model";
|
|
34
34
|
import { Agent } from "@mastra/core/agent";
|
|
35
35
|
import type { RequestContext } from "@mastra/core/request-context";
|
|
@@ -70,22 +70,23 @@ const CHART_FAILED_MESSAGE = "Chart generation failed";
|
|
|
70
70
|
* One series data point. Wide variant set so the planner agent can
|
|
71
71
|
* faithfully pass through whatever the SQL row set contained
|
|
72
72
|
* (numbers, stringified numbers, nulls for missing measurements,
|
|
73
|
-
* `[x, y]` tuples for scatter, `
|
|
74
|
-
*
|
|
73
|
+
* `[x, y]` tuples for scatter, `[x, y, value]` triples for heatmap,
|
|
74
|
+
* `{name, value}` slices for pie / funnel / treemap) without the
|
|
75
|
+
* structured-output guard rejecting the whole plan.
|
|
75
76
|
*
|
|
76
77
|
* Three layers of tolerance:
|
|
77
78
|
*
|
|
78
79
|
* 1. {@link z.preprocess} normalizes wire shapes BEFORE union
|
|
79
80
|
* dispatch: stringified numbers parse to numbers, finite
|
|
80
|
-
* checks reject `NaN` / `Infinity`, 2-element arrays coerce
|
|
81
|
+
* checks reject `NaN` / `Infinity`, 2-/3-element arrays coerce
|
|
81
82
|
* tuple components, and `{value}` objects with missing /
|
|
82
83
|
* stringified `value` get coerced or rejected uniformly.
|
|
83
84
|
* Anything not handleable becomes `null`.
|
|
84
85
|
* 2. The union accepts `null` as a first-class variant. Echarts
|
|
85
86
|
* renders null as a gap on bar / line / area (which is the
|
|
86
|
-
* right visual signal for "missing reading"). Scatter
|
|
87
|
-
*
|
|
88
|
-
* Echarts crashes on null tuples / slices.
|
|
87
|
+
* right visual signal for "missing reading"). Scatter, heatmap,
|
|
88
|
+
* and slice charts filter nulls in {@link planToEchartsOption}
|
|
89
|
+
* because Echarts crashes on null tuples / slices.
|
|
89
90
|
* 3. {@link z.union#catch} backstops the whole thing: if
|
|
90
91
|
* preprocess somehow produces a shape that still doesn't
|
|
91
92
|
* match any variant, the bad item becomes `null` instead of
|
|
@@ -101,10 +102,11 @@ const chartDataPointSchema = z
|
|
|
101
102
|
const n = Number(v);
|
|
102
103
|
return Number.isFinite(n) ? n : null;
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Scatter `[x, y]` or heatmap `[xIndex, yIndex, value]`. Coerce
|
|
106
|
+
// stringified components; reject if any component is non-finite.
|
|
107
|
+
if (Array.isArray(v) && (v.length === 2 || v.length === 3)) {
|
|
108
|
+
const nums = v.map((c) => (typeof c === "number" ? c : Number(c)));
|
|
109
|
+
return nums.every((n) => Number.isFinite(n)) ? nums : null;
|
|
108
110
|
}
|
|
109
111
|
if (typeof v === "object" && v !== null && "value" in v) {
|
|
110
112
|
const obj = v as { name?: unknown; value: unknown };
|
|
@@ -122,20 +124,26 @@ const chartDataPointSchema = z
|
|
|
122
124
|
z.union([
|
|
123
125
|
z.number(),
|
|
124
126
|
z.null(),
|
|
125
|
-
// `[x, y]` scatter
|
|
126
|
-
// homogeneous array rather than `z.tuple`:
|
|
127
|
-
// JSON Schema 2020-12 `prefixItems`, and
|
|
128
|
-
// endpoints reject a `response_json_schema`
|
|
129
|
-
// ("must be a boolean or an object", since
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
z.array(z.number()).length(2),
|
|
127
|
+
// `[x, y]` scatter or `[x, y, value]` heatmap cell. Modelled as a
|
|
128
|
+
// length-constrained homogeneous array rather than `z.tuple`:
|
|
129
|
+
// Zod emits a tuple as JSON Schema 2020-12 `prefixItems`, and
|
|
130
|
+
// Databricks' Gemini endpoints reject a `response_json_schema`
|
|
131
|
+
// containing it ("must be a boolean or an object", since
|
|
132
|
+
// `items` is absent). `minItems` / `maxItems` + `items` is what
|
|
133
|
+
// every provider understands.
|
|
134
|
+
z.array(z.number()).min(2).max(3),
|
|
134
135
|
z.object({ name: z.string(), value: z.number() }),
|
|
135
136
|
]),
|
|
136
137
|
)
|
|
137
138
|
.catch(null);
|
|
138
139
|
|
|
140
|
+
/** Per-series mark type used by `combo` charts (bar + line overlay). */
|
|
141
|
+
const seriesMarkTypeSchema = z
|
|
142
|
+
.union([z.literal("bar"), z.literal("line"), z.literal("area")])
|
|
143
|
+
.describe(
|
|
144
|
+
"Mark type for this series. Required for `combo` (mix bar and line/area); ignored for other chart types.",
|
|
145
|
+
);
|
|
146
|
+
|
|
139
147
|
/**
|
|
140
148
|
* Compact, model-friendly representation of an Echarts spec. The
|
|
141
149
|
* planner agent emits this; {@link planToEchartsOption} expands it
|
|
@@ -162,8 +170,9 @@ export const chartPlanSchema = z.object({
|
|
|
162
170
|
.optional()
|
|
163
171
|
.describe(
|
|
164
172
|
string.toDescription(`
|
|
165
|
-
Axis label
|
|
166
|
-
|
|
173
|
+
Axis label for the primary (usually bottom / value) axis.
|
|
174
|
+
Used for bar / horizontalBar / line / area / combo / waterfall
|
|
175
|
+
/ scatter / heatmap; ignored for pie / funnel / treemap / radar.
|
|
167
176
|
`),
|
|
168
177
|
),
|
|
169
178
|
yAxisLabel: z
|
|
@@ -171,8 +180,9 @@ export const chartPlanSchema = z.object({
|
|
|
171
180
|
.optional()
|
|
172
181
|
.describe(
|
|
173
182
|
string.toDescription(`
|
|
174
|
-
Axis label
|
|
175
|
-
|
|
183
|
+
Axis label for the secondary (usually left) axis. Used for
|
|
184
|
+
bar / horizontalBar / line / area / combo / waterfall /
|
|
185
|
+
scatter / heatmap; ignored for pie / funnel / treemap / radar.
|
|
176
186
|
`),
|
|
177
187
|
),
|
|
178
188
|
categories: z
|
|
@@ -180,9 +190,22 @@ export const chartPlanSchema = z.object({
|
|
|
180
190
|
.optional()
|
|
181
191
|
.describe(
|
|
182
192
|
string.toDescription(`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
Primary category labels. For \`bar\` / \`horizontalBar\` /
|
|
194
|
+
\`line\` / \`area\` / \`combo\` / \`waterfall\`: one label per
|
|
195
|
+
data point. For \`heatmap\`: x-axis categories. For \`radar\`:
|
|
196
|
+
indicator names. Omit for \`scatter\` (\`[x, y]\` tuples) and
|
|
197
|
+
slice charts (\`pie\` / \`funnel\` / \`treemap\`, each slice
|
|
198
|
+
carries its own \`name\`).
|
|
199
|
+
`),
|
|
200
|
+
),
|
|
201
|
+
yCategories: z
|
|
202
|
+
.array(z.string())
|
|
203
|
+
.optional()
|
|
204
|
+
.describe(
|
|
205
|
+
string.toDescription(`
|
|
206
|
+
Y-axis (row) labels for \`heatmap\`. Optional - when omitted the
|
|
207
|
+
row labels come from the series names, which is the preferred
|
|
208
|
+
way to build a heatmap. Omit for every other chart type.
|
|
186
209
|
`),
|
|
187
210
|
),
|
|
188
211
|
series: z
|
|
@@ -193,22 +216,51 @@ export const chartPlanSchema = z.object({
|
|
|
193
216
|
Legend name for this series.
|
|
194
217
|
`),
|
|
195
218
|
),
|
|
219
|
+
type: seriesMarkTypeSchema.optional(),
|
|
220
|
+
yAxisIndex: z
|
|
221
|
+
.union([z.literal(0), z.literal(1)])
|
|
222
|
+
.optional()
|
|
223
|
+
.describe(
|
|
224
|
+
"Which y-axis to bind (0 = left, 1 = right). Use on `combo` when series have different units or scales.",
|
|
225
|
+
),
|
|
196
226
|
data: z.array(chartDataPointSchema).describe(
|
|
197
227
|
string.toDescription(`
|
|
198
|
-
Data points. For \`bar\` / \`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
\`
|
|
228
|
+
Data points. For category charts (\`bar\` / \`horizontalBar\`
|
|
229
|
+
/ \`line\` / \`area\` / \`combo\` / \`waterfall\` / \`radar\`),
|
|
230
|
+
an array of numbers aligned to \`categories\`; for
|
|
231
|
+
\`waterfall\` those numbers are signed deltas (one series
|
|
232
|
+
only - never a cumulative base series). For \`heatmap\`,
|
|
233
|
+
one series per matrix row, holding that row's numbers
|
|
234
|
+
aligned to \`categories\`. For \`scatter\`, an array of
|
|
235
|
+
\`[x, y]\` numeric tuples. For \`pie\` / \`funnel\` /
|
|
236
|
+
\`treemap\`, an array of \`{name, value}\` objects.
|
|
202
237
|
`),
|
|
203
238
|
),
|
|
204
239
|
}),
|
|
205
240
|
)
|
|
206
|
-
.
|
|
241
|
+
.default([])
|
|
207
242
|
.describe(
|
|
208
243
|
string.toDescription(`
|
|
209
|
-
One or more series to plot.
|
|
210
|
-
|
|
211
|
-
\`
|
|
244
|
+
One or more series to plot. Required for every chart type
|
|
245
|
+
except \`custom\`, which carries its series inside \`option\`.
|
|
246
|
+
Slice charts (\`pie\` / \`funnel\` / \`treemap\`) and
|
|
247
|
+
\`waterfall\` / \`heatmap\` use exactly one series; \`bar\` /
|
|
248
|
+
\`line\` / \`area\` / \`combo\` / \`radar\` / \`scatter\` can
|
|
249
|
+
carry multiple series.
|
|
250
|
+
`),
|
|
251
|
+
),
|
|
252
|
+
option: z
|
|
253
|
+
.string()
|
|
254
|
+
.optional()
|
|
255
|
+
.describe(
|
|
256
|
+
string.toDescription(`
|
|
257
|
+
Required for \`custom\`, ignored for every other chart type. A
|
|
258
|
+
COMPLETE Echarts option, as a JSON object encoded in a string:
|
|
259
|
+
\`series\` (each with its own \`type\` and that series' own data
|
|
260
|
+
shape) plus whatever else the chart needs - a coordinate system,
|
|
261
|
+
\`visualMap\`, axes. Plain JSON values only, never a JavaScript
|
|
262
|
+
function. A centered title is filled in when the object omits
|
|
263
|
+
one.
|
|
212
264
|
`),
|
|
213
265
|
),
|
|
214
266
|
});
|
|
@@ -258,6 +310,24 @@ export const chartPlannerRequestSchema = z.object({
|
|
|
258
310
|
|
|
259
311
|
export type ChartPlannerRequest = z.infer<typeof chartPlannerRequestSchema>;
|
|
260
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Agent-facing result of either chart-producing tool.
|
|
315
|
+
*
|
|
316
|
+
* `marker` is deliberately redundant with `chartId`: the host still keys the
|
|
317
|
+
* cache by id, while the model gets the exact opaque token to copy into prose
|
|
318
|
+
* and has no reason to invent or retype a UUID.
|
|
319
|
+
*/
|
|
320
|
+
export const chartToolOutputSchema = wire.ChartSchema.pick({ chartId: true }).extend({
|
|
321
|
+
marker: z
|
|
322
|
+
.string()
|
|
323
|
+
.describe(
|
|
324
|
+
"Exact embed marker. Copy this complete value verbatim onto its own line; never construct a marker from chartId.",
|
|
325
|
+
),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/** Result returned synchronously while chart planning continues in the background. */
|
|
329
|
+
export type ChartToolOutput = z.infer<typeof chartToolOutputSchema>;
|
|
330
|
+
|
|
261
331
|
/* --------------------------- planner instructions --------------------------- */
|
|
262
332
|
|
|
263
333
|
/**
|
|
@@ -288,18 +358,58 @@ const CHART_PLANNER_INSTRUCTIONS = string.toDescription(`
|
|
|
288
358
|
|
|
289
359
|
When in doubt between bar and line, prefer bar for unordered
|
|
290
360
|
categories and line for ordered ones (dates, time buckets, ranks).
|
|
291
|
-
|
|
361
|
+
Prefer \`horizontalBar\` when category labels are long. Prefer
|
|
362
|
+
\`combo\` when one measure is a count/volume and another is a
|
|
363
|
+
rate/trend. Prefer \`waterfall\` for bridges of signed deltas.
|
|
364
|
+
Prefer \`scatter\` when correlating two numeric fields (no
|
|
365
|
+
category axis). Prefer \`heatmap\` for a category x category
|
|
366
|
+
matrix. Prefer \`radar\` for scoring the same entities across a
|
|
367
|
+
fixed set of dimensions. Never pick pie for more than 7 slices
|
|
368
|
+
(use \`treemap\` instead). Prefer \`funnel\` for ordered conversion
|
|
369
|
+
stages.
|
|
370
|
+
|
|
371
|
+
For bar / horizontalBar / line / area / combo / waterfall: pick one
|
|
372
|
+
column as the category axis (usually the only string-valued column)
|
|
373
|
+
and one or more numeric columns as series. Sort categories by the
|
|
374
|
+
primary series value descending unless the data is naturally ordered
|
|
375
|
+
(dates, ranks, funnel stages, waterfall steps). For \`combo\`, set
|
|
376
|
+
each series' \`type\` to \`bar\`, \`line\`, or \`area\`, and use
|
|
377
|
+
\`yAxisIndex: 1\` when a series needs a second scale.
|
|
292
378
|
|
|
293
|
-
For
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
the
|
|
379
|
+
For waterfall, emit exactly ONE series holding the signed step values
|
|
380
|
+
(deltas), one per category, in order - positive for a rise, negative
|
|
381
|
+
for a drop. Do NOT add a cumulative / running-total / base series and
|
|
382
|
+
do NOT convert the deltas into running totals yourself; the running
|
|
383
|
+
total is computed for you, and a hand-built base series renders as a
|
|
384
|
+
plain bar chart instead of a bridge.
|
|
297
385
|
|
|
298
|
-
For pie: pick the category column for slice names
|
|
299
|
-
column for slice values. Emit a single series
|
|
386
|
+
For pie / funnel / treemap: pick the category column for slice names
|
|
387
|
+
and one numeric column for slice values. Emit a single series of
|
|
388
|
+
\`{name, value}\` objects.
|
|
300
389
|
|
|
301
|
-
For scatter: pick two numeric columns and emit \`[x, y]\` tuples in
|
|
302
|
-
|
|
390
|
+
For scatter: pick two numeric columns and emit \`[x, y]\` tuples in
|
|
391
|
+
one or more series (one series per group if a grouping column exists).
|
|
392
|
+
|
|
393
|
+
For heatmap: pick two category columns and one numeric measure. Put
|
|
394
|
+
the x-axis categories in \`categories\`, then emit ONE SERIES PER ROW
|
|
395
|
+
of the matrix - the series \`name\` is the row label and \`data\` is
|
|
396
|
+
the row's numbers, one per entry in \`categories\`, in the same order.
|
|
397
|
+
Do not compute cell indices.
|
|
398
|
+
|
|
399
|
+
For radar: \`categories\` are the indicator names; each series is an
|
|
400
|
+
array of numbers (one value per indicator).
|
|
401
|
+
|
|
402
|
+
For anything the types above cannot express - a sankey, boxplot,
|
|
403
|
+
candlestick, sunburst, gauge, network graph, calendar, parallel-
|
|
404
|
+
coordinates plot, or any other Echarts series - use \`custom\` and
|
|
405
|
+
hand-write the whole Echarts option into \`option\` as a JSON string.
|
|
406
|
+
Include every part that chart needs: the series with their own
|
|
407
|
+
\`type\` and data shape, plus any coordinate system, \`visualMap\`,
|
|
408
|
+
or axes. Leave \`series\`, \`categories\`, and the axis labels out;
|
|
409
|
+
they are ignored for \`custom\`. Prefer a listed type whenever one
|
|
410
|
+
genuinely fits - \`custom\` gives up the shared tooltip, legend, and
|
|
411
|
+
grid defaults, so it is the answer for an unsupported chart shape,
|
|
412
|
+
not a way to restyle a supported one.
|
|
303
413
|
|
|
304
414
|
Keep series names human-readable (use the column name; title case it
|
|
305
415
|
lightly if needed). Keep titles concise; do not repeat the user's
|
|
@@ -466,14 +576,14 @@ export interface PrepareChartOptions {
|
|
|
466
576
|
* - on planner success: `{ chartId, result }`
|
|
467
577
|
* - on data / planner failure: `{ chartId, error }`
|
|
468
578
|
*/
|
|
469
|
-
export async function prepareChart(opts: PrepareChartOptions): Promise<
|
|
579
|
+
export async function prepareChart(opts: PrepareChartOptions): Promise<ChartToolOutput> {
|
|
470
580
|
const chartId = hash.id();
|
|
471
581
|
await writeChart({ chartId }, opts.userKey);
|
|
472
582
|
logger.debug("queued", { chartId });
|
|
473
583
|
// Fire-and-forget. Failures land in the cache as `error` entries;
|
|
474
584
|
// never escape into an unhandled rejection.
|
|
475
585
|
void runPrepareChart(chartId, opts);
|
|
476
|
-
return { chartId };
|
|
586
|
+
return { chartId, marker: marker.formatMarker("chart", chartId) };
|
|
477
587
|
}
|
|
478
588
|
|
|
479
589
|
async function runPrepareChart(chartId: string, opts: PrepareChartOptions): Promise<void> {
|
|
@@ -575,13 +685,21 @@ export async function fetchChart(
|
|
|
575
685
|
|
|
576
686
|
/**
|
|
577
687
|
* The slice of an Echarts option that carries brand identity: the series
|
|
578
|
-
* color cycle and the base
|
|
579
|
-
*
|
|
580
|
-
*
|
|
688
|
+
* color cycle and the base font stack. Derived from a {@link BrandContext}
|
|
689
|
+
* by {@link brandChartTheme} and merged into every spec by
|
|
690
|
+
* {@link planToEchartsOption}.
|
|
691
|
+
*
|
|
692
|
+
* Deliberately carries no text COLOR. A spec is planned here, on the
|
|
693
|
+
* server, and read later in a browser whose light/dark theme this code
|
|
694
|
+
* cannot know; baking in the brand's single (light) foreground produced
|
|
695
|
+
* near-black labels that disappeared against a dark chat surface. The
|
|
696
|
+
* renderer resolves chrome colors from AppKit's live CSS tokens instead
|
|
697
|
+
* (`@dbx-tools/ui-mastra`'s `chart-theme` + `normalizeChartOption`),
|
|
698
|
+
* leaving this theme the parts that read the same in either mode.
|
|
581
699
|
*/
|
|
582
700
|
interface ChartTheme {
|
|
583
701
|
color: string[];
|
|
584
|
-
textStyle: { fontFamily: string
|
|
702
|
+
textStyle: { fontFamily: string };
|
|
585
703
|
}
|
|
586
704
|
|
|
587
705
|
/**
|
|
@@ -623,18 +741,172 @@ function brandColorCycle(primary: string, accent: string): string[] {
|
|
|
623
741
|
|
|
624
742
|
/**
|
|
625
743
|
* Derive an Echarts {@link ChartTheme} from a brand context: the primary +
|
|
626
|
-
* accent colors seed the series cycle
|
|
627
|
-
*
|
|
744
|
+
* accent colors seed the series cycle and the sans stack becomes the base
|
|
745
|
+
* font. Text colors are the renderer's job - see {@link ChartTheme}.
|
|
628
746
|
*/
|
|
629
747
|
function brandChartTheme(brand: BrandContext): ChartTheme {
|
|
630
748
|
return {
|
|
631
749
|
color: brandColorCycle(brand.colors.primary, brand.colors.accent),
|
|
632
|
-
textStyle: { fontFamily: brand.typography.sans
|
|
750
|
+
textStyle: { fontFamily: brand.typography.sans },
|
|
633
751
|
};
|
|
634
752
|
}
|
|
635
753
|
|
|
636
754
|
/* ----------------------------- echarts expansion ----------------------------- */
|
|
637
755
|
|
|
756
|
+
type NamedSlice = { name: string; value: number };
|
|
757
|
+
type ScatterPoint = [number, number];
|
|
758
|
+
type HeatmapCell = [number, number, number];
|
|
759
|
+
|
|
760
|
+
/** Keep only `{name, value}` slices; drop nulls / bare numbers / tuples. */
|
|
761
|
+
function namedSlices(data: ChartPlan["series"][number]["data"]): NamedSlice[] {
|
|
762
|
+
return data.filter(
|
|
763
|
+
(d): d is NamedSlice => d !== null && typeof d === "object" && !Array.isArray(d),
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/** Keep only finite numbers (category / radar / waterfall series). */
|
|
768
|
+
function numericPoints(data: ChartPlan["series"][number]["data"]): number[] {
|
|
769
|
+
return data.filter((d): d is number => typeof d === "number" && Number.isFinite(d));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Keep only `[x, y]` scatter tuples. */
|
|
773
|
+
function scatterPoints(data: ChartPlan["series"][number]["data"]): ScatterPoint[] {
|
|
774
|
+
return data.filter(
|
|
775
|
+
(d): d is ScatterPoint => Array.isArray(d) && d.length === 2,
|
|
776
|
+
) as ScatterPoint[];
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Keep only `[xIndex, yIndex, value]` heatmap cells. */
|
|
780
|
+
function heatmapCells(data: ChartPlan["series"][number]["data"]): HeatmapCell[] {
|
|
781
|
+
return data.filter((d): d is HeatmapCell => Array.isArray(d) && d.length === 3) as HeatmapCell[];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Resolve a heatmap's cells and row labels from either shape the
|
|
786
|
+
* planner may produce.
|
|
787
|
+
*
|
|
788
|
+
* The documented shape is ONE series of `[xIndex, yIndex, value]`
|
|
789
|
+
* triples, but index arithmetic is exactly the kind of bookkeeping a
|
|
790
|
+
* fast model gets wrong, and the failure is silent (every cell is
|
|
791
|
+
* dropped and the grid renders empty). So the row-per-series shape it
|
|
792
|
+
* already produces reliably for bar charts - one series per matrix
|
|
793
|
+
* row, numbers aligned to `categories` - is accepted too and turned
|
|
794
|
+
* into triples here.
|
|
795
|
+
*
|
|
796
|
+
* Row order reads top-to-bottom: Echarts' category y-axis counts index
|
|
797
|
+
* 0 from the BOTTOM, so a row-derived matrix reverses both the axis
|
|
798
|
+
* labels and the row indices, putting the first series at the top the
|
|
799
|
+
* way the model listed it.
|
|
800
|
+
*/
|
|
801
|
+
function heatmapMatrix(plan: ChartPlan): { cells: HeatmapCell[]; yCategories: string[] } {
|
|
802
|
+
const triples = heatmapCells(plan.series[0]?.data ?? []);
|
|
803
|
+
if (triples.length > 0) {
|
|
804
|
+
return { cells: triples, yCategories: plan.yCategories ?? [] };
|
|
805
|
+
}
|
|
806
|
+
const rows = plan.series.map((s) => ({ name: s.name, values: numericPoints(s.data) }));
|
|
807
|
+
const labels = plan.yCategories ?? rows.map((r) => r.name);
|
|
808
|
+
const lastRow = rows.length - 1;
|
|
809
|
+
return {
|
|
810
|
+
cells: rows.flatMap((row, rowIndex) =>
|
|
811
|
+
row.values.map((value, columnIndex): HeatmapCell => [columnIndex, lastRow - rowIndex, value]),
|
|
812
|
+
),
|
|
813
|
+
yCategories: [...labels].reverse(),
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Sequential light-to-brand ramp for heatmap intensity. */
|
|
818
|
+
const HEATMAP_RAMP = ["#EAF1FE", "#9CBDF7", "#4C86EE", "#2463EB", "#14387F"];
|
|
819
|
+
|
|
820
|
+
/** Above this many cells, printed values overlap and are suppressed. */
|
|
821
|
+
const HEATMAP_LABEL_MAX_CELLS = 60;
|
|
822
|
+
|
|
823
|
+
/** Rising step fill (emerald) and falling step fill (rose). */
|
|
824
|
+
const WATERFALL_INCREASE_COLOR = "#2EB88A";
|
|
825
|
+
const WATERFALL_DECREASE_COLOR = "#DD2C4D";
|
|
826
|
+
|
|
827
|
+
/** Series names the waterfall expansion emits. */
|
|
828
|
+
const WATERFALL_HELPER_NAME = "Running total";
|
|
829
|
+
const WATERFALL_INCREASE_NAME = "Increase";
|
|
830
|
+
const WATERFALL_DECREASE_NAME = "Decrease";
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* A model asked for a bridge often builds the running total itself and
|
|
834
|
+
* hands back a cumulative-base series alongside the deltas - which
|
|
835
|
+
* renders as two plain bar series, not a waterfall (the base bars are
|
|
836
|
+
* the tall ones). The base is recognizable by name, so it is dropped
|
|
837
|
+
* here and the running total recomputed from the deltas.
|
|
838
|
+
*/
|
|
839
|
+
const WATERFALL_BASE_NAME = /\b(base|cumulative|running|helper|total|start(ing)?)\b/i;
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Pick the signed-delta series out of a waterfall plan, ignoring any
|
|
843
|
+
* cumulative-base series the planner built by hand
|
|
844
|
+
* ({@link WATERFALL_BASE_NAME}). Falls back to the first series when
|
|
845
|
+
* every name looks like a base, since dropping them all would leave
|
|
846
|
+
* nothing to plot.
|
|
847
|
+
*/
|
|
848
|
+
function waterfallDeltaSeries(
|
|
849
|
+
series: ChartPlan["series"],
|
|
850
|
+
): ChartPlan["series"][number] | undefined {
|
|
851
|
+
return series.find((s) => !WATERFALL_BASE_NAME.test(s.name)) ?? series[0];
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Build the stacked transparent-base + increase + decrease series that
|
|
856
|
+
* Echarts uses for a waterfall (it has no native waterfall type).
|
|
857
|
+
* Values are signed deltas; the transparent helper carries the running
|
|
858
|
+
* total so each visible bar starts where the previous one ended.
|
|
859
|
+
*
|
|
860
|
+
* The helper is `silent`, so with an item-triggered tooltip it is
|
|
861
|
+
* invisible to both the eye and the pointer - the alternative, an axis
|
|
862
|
+
* tooltip that hides the helper row, needs a function formatter, and
|
|
863
|
+
* this spec has to survive JSON serialization to the browser.
|
|
864
|
+
*/
|
|
865
|
+
function waterfallSeries(values: number[]): Array<Record<string, unknown>> {
|
|
866
|
+
const helpers: number[] = [];
|
|
867
|
+
const increases: Array<number | "-"> = [];
|
|
868
|
+
const decreases: Array<number | "-"> = [];
|
|
869
|
+
let cumulative = 0;
|
|
870
|
+
for (const value of values) {
|
|
871
|
+
if (value >= 0) {
|
|
872
|
+
helpers.push(cumulative);
|
|
873
|
+
increases.push(value);
|
|
874
|
+
decreases.push("-");
|
|
875
|
+
} else {
|
|
876
|
+
helpers.push(cumulative + value);
|
|
877
|
+
increases.push("-");
|
|
878
|
+
decreases.push(-value);
|
|
879
|
+
}
|
|
880
|
+
cumulative += value;
|
|
881
|
+
}
|
|
882
|
+
const transparent = { borderColor: "transparent", color: "transparent" };
|
|
883
|
+
return [
|
|
884
|
+
{
|
|
885
|
+
name: WATERFALL_HELPER_NAME,
|
|
886
|
+
type: "bar",
|
|
887
|
+
stack: "total",
|
|
888
|
+
silent: true,
|
|
889
|
+
itemStyle: transparent,
|
|
890
|
+
emphasis: { itemStyle: transparent },
|
|
891
|
+
data: helpers,
|
|
892
|
+
},
|
|
893
|
+
{
|
|
894
|
+
name: WATERFALL_INCREASE_NAME,
|
|
895
|
+
type: "bar",
|
|
896
|
+
stack: "total",
|
|
897
|
+
itemStyle: { color: WATERFALL_INCREASE_COLOR },
|
|
898
|
+
data: increases,
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
name: WATERFALL_DECREASE_NAME,
|
|
902
|
+
type: "bar",
|
|
903
|
+
stack: "total",
|
|
904
|
+
itemStyle: { color: WATERFALL_DECREASE_COLOR },
|
|
905
|
+
data: decreases,
|
|
906
|
+
},
|
|
907
|
+
];
|
|
908
|
+
}
|
|
909
|
+
|
|
638
910
|
/**
|
|
639
911
|
* Expand a {@link ChartPlan} into a full Echarts `EChartsOption`
|
|
640
912
|
* JSON. Centralized here so the planner agent only fills in the
|
|
@@ -646,7 +918,7 @@ function brandChartTheme(brand: BrandContext): ChartTheme {
|
|
|
646
918
|
* color cycle and base text style (see {@link brandChartTheme}); otherwise
|
|
647
919
|
* Echarts' defaults apply.
|
|
648
920
|
*/
|
|
649
|
-
function planToEchartsOption(
|
|
921
|
+
export function planToEchartsOption(
|
|
650
922
|
plan: ChartPlan,
|
|
651
923
|
fallbackTitle: string,
|
|
652
924
|
brand?: BrandContext,
|
|
@@ -656,25 +928,41 @@ function planToEchartsOption(
|
|
|
656
928
|
const theme = brand ? brandChartTheme(brand) : undefined;
|
|
657
929
|
const themed = (option: Record<string, unknown>): Record<string, unknown> =>
|
|
658
930
|
theme ? { ...theme, ...option } : option;
|
|
931
|
+
const title = { text: baseTitle, left: "center" };
|
|
932
|
+
const legend = { bottom: 0 };
|
|
659
933
|
|
|
660
|
-
if (plan.chartType === "
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
934
|
+
if (plan.chartType === "custom") {
|
|
935
|
+
// The option arrives as a JSON STRING, not a nested object: this plan is
|
|
936
|
+
// the planner's provider-enforced structured output, and a free-form
|
|
937
|
+
// object becomes an unconstrained `additionalProperties` schema that
|
|
938
|
+
// strict OpenAI and Gemini endpoints reject - which would break EVERY
|
|
939
|
+
// chart, not just this one (same class of hazard as the `prefixItems`
|
|
940
|
+
// note on `chartDataPointSchema`). A malformed string costs one chart.
|
|
941
|
+
// Nothing here is eval'd; a string-valued Echarts formatter is a
|
|
942
|
+
// template, not code.
|
|
943
|
+
const option = json.parseRecord(plan.option);
|
|
944
|
+
if (!option) {
|
|
945
|
+
throw new Error('chartType "custom" needs an `option` holding a JSON object');
|
|
946
|
+
}
|
|
947
|
+
// Title is the one default worth filling: every other branch guarantees
|
|
948
|
+
// one and the renderer reserves grid space for it. A title the model set
|
|
949
|
+
// wins, as does every other field it declared.
|
|
950
|
+
return themed({ title, ...option });
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (plan.chartType === "pie" || plan.chartType === "funnel" || plan.chartType === "treemap") {
|
|
954
|
+
const slices = namedSlices(plan.series[0]?.data ?? []);
|
|
955
|
+
const seriesType = plan.chartType;
|
|
669
956
|
return themed({
|
|
670
|
-
title
|
|
957
|
+
title,
|
|
671
958
|
tooltip: { trigger: "item" },
|
|
672
|
-
legend:
|
|
959
|
+
legend: seriesType === "treemap" ? undefined : legend,
|
|
673
960
|
series: [
|
|
674
961
|
{
|
|
675
962
|
name: plan.series[0]?.name ?? baseTitle,
|
|
676
|
-
type:
|
|
677
|
-
radius: ["35%", "65%"],
|
|
963
|
+
type: seriesType,
|
|
964
|
+
...(seriesType === "pie" ? { radius: ["35%", "65%"] } : {}),
|
|
965
|
+
...(seriesType === "funnel" ? { sort: "descending" } : {}),
|
|
678
966
|
data: slices,
|
|
679
967
|
},
|
|
680
968
|
],
|
|
@@ -686,27 +974,175 @@ function planToEchartsOption(
|
|
|
686
974
|
// `[x, y]` tuples. Bare numbers / objects / nulls from a
|
|
687
975
|
// mismatched plan get dropped silently.
|
|
688
976
|
return themed({
|
|
689
|
-
title
|
|
977
|
+
title,
|
|
690
978
|
tooltip: { trigger: "item" },
|
|
691
|
-
legend
|
|
979
|
+
legend,
|
|
692
980
|
grid,
|
|
693
981
|
xAxis: { type: "value", name: plan.xAxisLabel },
|
|
694
982
|
yAxis: { type: "value", name: plan.yAxisLabel },
|
|
695
983
|
series: plan.series.map((s) => ({
|
|
696
984
|
name: s.name,
|
|
697
985
|
type: "scatter",
|
|
698
|
-
data: s.data
|
|
986
|
+
data: scatterPoints(s.data),
|
|
699
987
|
})),
|
|
700
988
|
});
|
|
701
989
|
}
|
|
702
990
|
|
|
991
|
+
if (plan.chartType === "heatmap") {
|
|
992
|
+
const { cells, yCategories } = heatmapMatrix(plan);
|
|
993
|
+
const values = cells.map((c) => c[2]);
|
|
994
|
+
const min = values.length > 0 ? Math.min(...values) : 0;
|
|
995
|
+
const max = values.length > 0 ? Math.max(...values) : 1;
|
|
996
|
+
return themed({
|
|
997
|
+
title,
|
|
998
|
+
tooltip: { position: "top" },
|
|
999
|
+
// The visualMap ramp sits below the plot, so the grid gives up
|
|
1000
|
+
// more bottom room than an axis-only chart needs.
|
|
1001
|
+
grid: { ...grid, bottom: 72 },
|
|
1002
|
+
xAxis: {
|
|
1003
|
+
type: "category",
|
|
1004
|
+
data: plan.categories ?? [],
|
|
1005
|
+
name: plan.xAxisLabel,
|
|
1006
|
+
splitArea: { show: true },
|
|
1007
|
+
},
|
|
1008
|
+
yAxis: {
|
|
1009
|
+
type: "category",
|
|
1010
|
+
data: yCategories,
|
|
1011
|
+
name: plan.yAxisLabel,
|
|
1012
|
+
splitArea: { show: true },
|
|
1013
|
+
},
|
|
1014
|
+
visualMap: {
|
|
1015
|
+
// Echarts hides every cell when min === max (a uniform matrix),
|
|
1016
|
+
// so a degenerate range is widened by one.
|
|
1017
|
+
min,
|
|
1018
|
+
max: max > min ? max : min + 1,
|
|
1019
|
+
calculable: true,
|
|
1020
|
+
orient: "horizontal",
|
|
1021
|
+
left: "center",
|
|
1022
|
+
bottom: 8,
|
|
1023
|
+
inRange: { color: HEATMAP_RAMP },
|
|
1024
|
+
},
|
|
1025
|
+
series: [
|
|
1026
|
+
{
|
|
1027
|
+
name: plan.series[0]?.name ?? baseTitle,
|
|
1028
|
+
type: "heatmap",
|
|
1029
|
+
data: cells,
|
|
1030
|
+
// Printed cell values are unreadable once the grid is dense.
|
|
1031
|
+
label: { show: cells.length <= HEATMAP_LABEL_MAX_CELLS },
|
|
1032
|
+
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: "rgba(0, 0, 0, 0.3)" } },
|
|
1033
|
+
},
|
|
1034
|
+
],
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (plan.chartType === "radar") {
|
|
1039
|
+
const indicators = (plan.categories ?? []).map((name, index) => {
|
|
1040
|
+
const peak = Math.max(
|
|
1041
|
+
0,
|
|
1042
|
+
...plan.series.map((s) => {
|
|
1043
|
+
const nums = numericPoints(s.data);
|
|
1044
|
+
return nums[index] ?? 0;
|
|
1045
|
+
}),
|
|
1046
|
+
);
|
|
1047
|
+
return { name, max: peak > 0 ? peak : 1 };
|
|
1048
|
+
});
|
|
1049
|
+
return themed({
|
|
1050
|
+
title,
|
|
1051
|
+
tooltip: { trigger: "item" },
|
|
1052
|
+
legend,
|
|
1053
|
+
radar: { indicator: indicators },
|
|
1054
|
+
series: [
|
|
1055
|
+
{
|
|
1056
|
+
type: "radar",
|
|
1057
|
+
data: plan.series.map((s) => ({
|
|
1058
|
+
name: s.name,
|
|
1059
|
+
value: numericPoints(s.data),
|
|
1060
|
+
})),
|
|
1061
|
+
},
|
|
1062
|
+
],
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (plan.chartType === "waterfall") {
|
|
1067
|
+
const values = numericPoints(waterfallDeltaSeries(plan.series)?.data ?? []);
|
|
1068
|
+
return themed({
|
|
1069
|
+
title,
|
|
1070
|
+
// Item-triggered so the transparent `silent` offset bars never
|
|
1071
|
+
// surface in a tooltip (see `waterfallSeries`).
|
|
1072
|
+
tooltip: { trigger: "item" },
|
|
1073
|
+
// Only the two visible steps belong in the legend.
|
|
1074
|
+
legend: { ...legend, data: [WATERFALL_INCREASE_NAME, WATERFALL_DECREASE_NAME] },
|
|
1075
|
+
grid,
|
|
1076
|
+
xAxis: {
|
|
1077
|
+
type: "category",
|
|
1078
|
+
data: plan.categories ?? [],
|
|
1079
|
+
name: plan.xAxisLabel,
|
|
1080
|
+
},
|
|
1081
|
+
yAxis: { type: "value", name: plan.yAxisLabel },
|
|
1082
|
+
series: waterfallSeries(values),
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (plan.chartType === "horizontalBar") {
|
|
1087
|
+
return themed({
|
|
1088
|
+
title,
|
|
1089
|
+
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
|
|
1090
|
+
legend,
|
|
1091
|
+
grid,
|
|
1092
|
+
xAxis: { type: "value", name: plan.xAxisLabel },
|
|
1093
|
+
yAxis: {
|
|
1094
|
+
type: "category",
|
|
1095
|
+
data: plan.categories ?? [],
|
|
1096
|
+
name: plan.yAxisLabel,
|
|
1097
|
+
},
|
|
1098
|
+
series: plan.series.map((s) => ({
|
|
1099
|
+
name: s.name,
|
|
1100
|
+
type: "bar",
|
|
1101
|
+
data: s.data,
|
|
1102
|
+
})),
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (plan.chartType === "combo") {
|
|
1107
|
+
const usesRightAxis = plan.series.some((s) => s.yAxisIndex === 1);
|
|
1108
|
+
return themed({
|
|
1109
|
+
title,
|
|
1110
|
+
tooltip: { trigger: "axis" },
|
|
1111
|
+
legend,
|
|
1112
|
+
grid,
|
|
1113
|
+
xAxis: {
|
|
1114
|
+
type: "category",
|
|
1115
|
+
data: plan.categories ?? [],
|
|
1116
|
+
name: plan.xAxisLabel,
|
|
1117
|
+
},
|
|
1118
|
+
yAxis: usesRightAxis
|
|
1119
|
+
? [
|
|
1120
|
+
{ type: "value", name: plan.yAxisLabel },
|
|
1121
|
+
{ type: "value", name: undefined },
|
|
1122
|
+
]
|
|
1123
|
+
: { type: "value", name: plan.yAxisLabel },
|
|
1124
|
+
series: plan.series.map((s) => {
|
|
1125
|
+
const mark = s.type ?? "bar";
|
|
1126
|
+
const seriesType = mark === "area" ? "line" : mark;
|
|
1127
|
+
return {
|
|
1128
|
+
name: s.name,
|
|
1129
|
+
type: seriesType,
|
|
1130
|
+
data: s.data,
|
|
1131
|
+
yAxisIndex: s.yAxisIndex ?? 0,
|
|
1132
|
+
smooth: seriesType === "line",
|
|
1133
|
+
...(mark === "area" ? { areaStyle: {} } : {}),
|
|
1134
|
+
};
|
|
1135
|
+
}),
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
|
|
703
1139
|
// bar / line / area share the same axis layout.
|
|
704
1140
|
const isArea = plan.chartType === "area";
|
|
705
1141
|
const seriesType = plan.chartType === "bar" ? "bar" : "line";
|
|
706
1142
|
return themed({
|
|
707
|
-
title
|
|
1143
|
+
title,
|
|
708
1144
|
tooltip: { trigger: "axis" },
|
|
709
|
-
legend
|
|
1145
|
+
legend,
|
|
710
1146
|
grid,
|
|
711
1147
|
xAxis: {
|
|
712
1148
|
type: "category",
|
|
@@ -735,9 +1171,8 @@ function planToEchartsOption(
|
|
|
735
1171
|
* Thin wrapper over {@link prepareChart} for callers that already
|
|
736
1172
|
* have a dataset in hand. Mints a `chartId` synchronously, caches
|
|
737
1173
|
* an empty placeholder, and kicks off the chart-planner in the
|
|
738
|
-
* background. Returns
|
|
739
|
-
*
|
|
740
|
-
* `/embed/chart/:id` route.
|
|
1174
|
+
* background. Returns the `chartId` plus its ready-to-copy `marker`; the host
|
|
1175
|
+
* UI resolves that marker through the plugin's `/embed/chart/:id` route.
|
|
741
1176
|
*
|
|
742
1177
|
* For Genie statement results, prefer the Genie agent's
|
|
743
1178
|
* `prepare_chart` tool, which accepts a `statement_id` and
|
|
@@ -751,14 +1186,15 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
|
|
|
751
1186
|
Submit a tabular dataset for inline rendering as a chart in
|
|
752
1187
|
the user's view. Pass a title, the raw rows (array of objects
|
|
753
1188
|
keyed by column name), and an optional one-line description
|
|
754
|
-
of the insight to highlight. Returns
|
|
755
|
-
|
|
756
|
-
|
|
1189
|
+
of the insight to highlight. Returns \`chartId\` plus the
|
|
1190
|
+
complete \`marker\`; copy the returned marker VERBATIM onto
|
|
1191
|
+
its own line where the chart should render. Never construct,
|
|
1192
|
+
alter, or invent a chart marker yourself.
|
|
757
1193
|
`,
|
|
758
1194
|
`
|
|
759
|
-
Placement contract: embed \`
|
|
760
|
-
line (blank lines above and below) wherever you want the
|
|
761
|
-
|
|
1195
|
+
Placement contract: embed the returned \`marker\` on its own
|
|
1196
|
+
line (blank lines above and below) wherever you want the chart
|
|
1197
|
+
to appear in your reply. The chart resolves
|
|
762
1198
|
asynchronously - the tool returns the id immediately and the
|
|
763
1199
|
host UI fetches the chart from the cache once the planner
|
|
764
1200
|
lands. You can call \`render_data\` multiple times in the
|
|
@@ -774,7 +1210,7 @@ export function buildRenderDataTool(config: MastraPluginConfig) {
|
|
|
774
1210
|
`,
|
|
775
1211
|
]),
|
|
776
1212
|
inputSchema: chartPlannerRequestSchema,
|
|
777
|
-
outputSchema:
|
|
1213
|
+
outputSchema: chartToolOutputSchema,
|
|
778
1214
|
execute: async (input, ctxRaw) => {
|
|
779
1215
|
const { title, description, data } = input as ChartPlannerRequest;
|
|
780
1216
|
const ctx = ctxRaw as { requestContext?: RequestContext } | undefined;
|