diff --git a/apps/x/apps/renderer/src/components/chart-renderer.test.ts b/apps/x/apps/renderer/src/components/chart-renderer.test.ts new file mode 100644 index 00000000..22c9273b --- /dev/null +++ b/apps/x/apps/renderer/src/components/chart-renderer.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { parseChartSource } from './chart-renderer' + +const valid = { + chart: 'line', + x: 'day', + y: 'pct', + data: [{ day: 'Mon', pct: 0.1 }], +} + +describe('parseChartSource', () => { + it('parses a valid config', () => { + const { config, invalid } = parseChartSource(JSON.stringify(valid)) + expect(invalid).toBe(false) + expect(config?.chart).toBe('line') + }) + + it('treats incomplete JSON as streaming, not invalid', () => { + const partial = JSON.stringify(valid).slice(0, 25) + expect(parseChartSource(partial)).toEqual({ config: null, invalid: false }) + }) + + it('flags complete-but-schema-invalid JSON as invalid', () => { + const { config, invalid } = parseChartSource( + JSON.stringify({ chart: 'line', data: [] }), + ) + expect(config).toBeNull() + expect(invalid).toBe(true) + }) + + it('maps the label/value pie aliases to x/y', () => { + // Seen live: models emit pie configs with label/value field names. + const { config, invalid } = parseChartSource( + JSON.stringify({ + chart: 'pie', + label: 'index', + value: 'drop', + data: [{ index: 'Nasdaq', drop: 2.4 }], + }), + ) + expect(invalid).toBe(false) + expect(config?.x).toBe('index') + expect(config?.y).toBe('drop') + }) + + it('accepts a y array for multi-series charts', () => { + const { config } = parseChartSource( + JSON.stringify({ ...valid, y: ['a', 'b'] }), + ) + expect(config?.y).toEqual(['a', 'b']) + }) +}) diff --git a/apps/x/apps/renderer/src/components/chart-renderer.tsx b/apps/x/apps/renderer/src/components/chart-renderer.tsx index 7ecc217a..81095fbb 100644 --- a/apps/x/apps/renderer/src/components/chart-renderer.tsx +++ b/apps/x/apps/renderer/src/components/chart-renderer.tsx @@ -21,6 +21,33 @@ interface ChartRendererProps { source: string } +/** + * Parse a chart fence body. Distinguishes the three states the renderer + * shows: `streaming` (JSON incomplete — the model is still writing), + * `invalid` (complete JSON that fails the schema), and a parsed config. + * Models occasionally reach for pie-flavored field names despite the + * skill's schema; the predictable label/value aliases map to x/y. + */ +export function parseChartSource( + source: string, +): { config: blocks.ChartBlock | null; invalid: boolean } { + let parsed: unknown + try { + parsed = JSON.parse(source) + } catch { + return { config: null, invalid: false } + } + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const obj = parsed as Record + if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label + if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value + } + const result = blocks.ChartBlockSchema.safeParse(parsed) + return result.success + ? { config: result.data, invalid: false } + : { config: null, invalid: true } +} + /** * Inline chart for chat messages: renders a ```chart fenced block (same * ChartBlockSchema the notes chart block uses) via recharts. Invalid or @@ -33,26 +60,7 @@ export function ChartRenderer({ source }: ChartRendererProps) { const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0' const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e' - const { config, invalid } = useMemo(() => { - let parsed: unknown - try { - parsed = JSON.parse(source) - } catch { - // Incomplete JSON — the fence body is still streaming in. - return { config: null, invalid: false } - } - // Models occasionally reach for pie-flavored field names despite the - // skill's schema; map the predictable aliases before validating. - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const obj = parsed as Record - if (obj.x === undefined && typeof obj.label === 'string') obj.x = obj.label - if (obj.y === undefined && typeof obj.value === 'string') obj.y = obj.value - } - const result = blocks.ChartBlockSchema.safeParse(parsed) - return result.success - ? { config: result.data, invalid: false } - : { config: null, invalid: true } - }, [source]) + const { config, invalid } = useMemo(() => parseChartSource(source), [source]) // Complete JSON that fails the schema is a real error — say so instead of // showing the streaming placeholder forever. diff --git a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts index 27c5dea5..9678df7d 100644 --- a/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts +++ b/apps/x/packages/core/src/runtime/assembly/skills/doc-collab/skill.ts @@ -224,7 +224,7 @@ Renders a chart from inline data. - \`data\` (optional): Array of objects with the data points - \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data) - \`x\` (required): Key name for the x-axis / label field -- \`y\` (required): Key name for the y-axis / value field +- \`y\` (required): Key name for the y-axis / value field — or an array of key names to plot several series on one chart (each row then carries one key per series) ### Table Block Renders a styled table from structured data.