Merge pull request #773 from rowboatlabs/feat/inline-charts-skill

feat(x): inline interactive charts in chat via a charts skill
This commit is contained in:
gagan 2026-07-22 01:46:42 +05:30 committed by GitHub
commit 358e819a15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 363 additions and 5 deletions

View file

@ -1,6 +1,7 @@
import { isValidElement, type JSX } from 'react'
import { FilePathCard } from './file-path-card'
import { MermaidRenderer } from '@/components/mermaid-renderer'
import { ChartRenderer } from '@/components/chart-renderer'
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
const { children, ...rest } = props
@ -31,6 +32,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
return <MermaidRenderer source={text} />
}
}
if (
typeof childProps.className === 'string' &&
childProps.className.includes('language-chart')
) {
const text = typeof childProps.children === 'string'
? childProps.children.trim()
: ''
if (text) {
return <ChartRenderer source={text} />
}
}
}
// Passthrough for all other code blocks - return children directly

View file

@ -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'])
})
})

View file

@ -0,0 +1,178 @@
import { useMemo } from 'react'
import { BarChart3 } from 'lucide-react'
import { blocks } from '@x/shared'
import { useTheme } from '@/contexts/theme-context'
import {
LineChart, Line,
BarChart, Bar,
PieChart, Pie, Cell,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
} from 'recharts'
// Categorical palettes validated for CVD separation and surface contrast on
// each mode's chart surface (dataviz six-checks validator). Slots are
// assigned to series in fixed order — the dark column is the same hues
// re-stepped for the dark surface, not a different palette.
const SERIES_COLORS_LIGHT = ['#2a78d6', '#008300', '#e87ba4', '#eda100', '#1baf7a', '#eb6834']
const SERIES_COLORS_DARK = ['#3987e5', '#008300', '#d55181', '#c98500', '#199e70', '#d95926']
interface ChartRendererProps {
/** Raw contents of a ```chart fence: ChartBlockSchema JSON. */
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<string, unknown>
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
* still-streaming JSON renders as a quiet placeholder rather than an error
* the fence body arrives token by token while the model writes it.
*/
export function ChartRenderer({ source }: ChartRendererProps) {
const { resolvedTheme } = useTheme()
const colors = resolvedTheme === 'dark' ? SERIES_COLORS_DARK : SERIES_COLORS_LIGHT
const gridStroke = resolvedTheme === 'dark' ? '#3a3a38' : '#e4e4e0'
const textColor = resolvedTheme === 'dark' ? '#c3c2b7' : '#52514e'
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.
if (invalid || (config && (!config.data || config.data.length === 0))) {
return (
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<BarChart3 className="size-3.5" />
{invalid ? 'Chart config invalid — ask me to redraw it' : 'Chart has no data'}
</div>
)
}
if (!config) {
return (
<div className="my-2 flex h-24 items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<BarChart3 className="size-3.5" />
Preparing chart
</div>
)
}
const data = config.data ?? []
const series = blocks.chartSeries(config)
const axisProps = {
stroke: textColor,
tick: { fill: textColor, fontSize: 11 },
tickLine: false,
}
return (
<div className="my-2 rounded-md border border-border bg-card p-3">
{config.title && (
<div className="mb-2 text-sm font-medium text-foreground">{config.title}</div>
)}
<ResponsiveContainer width="100%" height={260}>
{config.chart === 'line' ? (
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
<XAxis dataKey={config.x} {...axisProps} />
<YAxis {...axisProps} width={44} />
<Tooltip
contentStyle={{
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
border: `1px solid ${gridStroke}`,
borderRadius: 6,
fontSize: 12,
}}
/>
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{series.map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
strokeWidth={2}
dot={{ r: 2.5 }}
/>
))}
</LineChart>
) : config.chart === 'bar' ? (
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke={gridStroke} vertical={false} />
<XAxis dataKey={config.x} {...axisProps} />
<YAxis {...axisProps} width={44} />
<Tooltip
cursor={{ fill: resolvedTheme === 'dark' ? '#ffffff14' : '#00000009' }}
contentStyle={{
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
border: `1px solid ${gridStroke}`,
borderRadius: 6,
fontSize: 12,
}}
/>
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{series.map((key, i) => (
<Bar
key={key}
dataKey={key}
fill={colors[i % colors.length]}
radius={[4, 4, 0, 0]}
maxBarSize={48}
/>
))}
</BarChart>
) : (
<PieChart>
<Tooltip
contentStyle={{
backgroundColor: resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb',
border: `1px solid ${gridStroke}`,
borderRadius: 6,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Pie
data={data}
dataKey={series[0]}
nameKey={config.x}
cx="50%"
cy="50%"
outerRadius={90}
stroke={resolvedTheme === 'dark' ? '#1a1a19' : '#fcfcfb'}
strokeWidth={2}
>
{data.map((_, i) => (
<Cell key={i} fill={colors[i % colors.length]} />
))}
</Pie>
</PieChart>
)}
</ResponsiveContainer>
</div>
)
}

View file

@ -64,6 +64,7 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
if (error) return <div className="chart-block-error-msg">{error}</div>
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
const series = blocks.chartSeries(config!)
return (
<ResponsiveContainer width="100%" height={250}>
{config!.chart === 'line' ? (
@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey={config!.y} stroke="#8884d8" />
{series.map((key, index) => (
<Line key={key} type="monotone" dataKey={key} stroke={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</LineChart>
) : config!.chart === 'bar' ? (
<BarChart data={data}>
@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey={config!.y} fill="#8884d8" />
{series.map((key, index) => (
<Bar key={key} dataKey={key} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}
</BarChart>
) : (
<PieChart>
<Tooltip />
<Legend />
<Pie data={data} dataKey={config!.y} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
<Pie data={data} dataKey={series[0]} nameKey={config!.x} cx="50%" cy="50%" outerRadius={80} label>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
))}

View file

@ -0,0 +1,97 @@
export const skill = String.raw`
# Charts
Load this skill when the user asks for a chart, graph, plot, trend, comparison, or "visualize" or when data you've gathered (prices over time, counts per category, proportions) would land better as a picture than a table.
## How it works
Emit a fenced code block with language \`chart\` anywhere in your reply. The app renders it as an interactive chart inline (tooltips, legend, light/dark theming are handled for you — never pick colors yourself). Everything outside the fence is normal markdown.
\`\`\`\`
\`\`\`chart
{ ...JSON config... }
\`\`\`
\`\`\`\`
## Config schema
The ONLY fields are \`chart\`, \`data\`, \`x\`, \`y\`, \`title\` — nothing else exists (no \`label\`, \`value\`, \`series\`, \`color\`, etc.). Unknown fields are ignored; a missing \`x\` or \`y\` breaks the chart.
- **\`chart\`** (required): \`"line"\` | \`"bar"\` | \`"pie"\`
- **\`data\`** (required): array of flat objects — the rows to plot. Put REAL values you gathered this turn here; never invent numbers.
- **\`x\`** (required): key of the label/category field in each row. For pie: the slice-name key.
- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart. For pie: the slice-value key.
- **\`title\`** (optional): short heading shown above the chart
**Data must be wide-format**: one row per x value, one key per series. Never long/tidy format (a row per series-point with a series-name column) multiple series means multiple keys in the SAME row:
WRONG: \`[{ "day": "Mon", "index": "S&P", "pct": 0.1 }, { "day": "Mon", "index": "Dow", "pct": 0.2 }]\`
RIGHT: \`[{ "day": "Mon", "S&P": 0.1, "Dow": 0.2 }]\` with \`"y": ["S&P", "Dow"]\`
## Picking the form
- **line** change over time (prices, counts by date). X is the time field.
- **bar** compare magnitudes across categories (issues per label, revenue per region).
- **pie** proportions of a whole; only with 6 slices, otherwise use a bar.
- Two measures with very different scales (e.g. a $600 stock vs a $5 one): do NOT mix them on one chart either normalize to % change and say so in the title, or emit two chart blocks.
## Examples
Multi-series line (comparison over time, normalized):
\`\`\`chart
{
"chart": "line",
"title": "5-Day % Change",
"x": "date",
"y": ["AAPL", "NVDA", "SPY"],
"data": [
{ "date": "Jul 15", "AAPL": 0, "NVDA": 0, "SPY": 0 },
{ "date": "Jul 16", "AAPL": -0.4, "NVDA": 1.2, "SPY": 0.1 },
{ "date": "Jul 17", "AAPL": -1.1, "NVDA": 0.8, "SPY": -0.3 }
]
}
\`\`\`
Single-series bar:
\`\`\`chart
{
"chart": "bar",
"title": "Open issues by area",
"x": "area",
"y": "count",
"data": [
{ "area": "sync", "count": 14 },
{ "area": "editor", "count": 9 },
{ "area": "billing", "count": 3 }
]
}
\`\`\`
Pie (x names the slice, y is its value same keys as every other chart):
\`\`\`chart
{
"chart": "pie",
"title": "Time spent by project",
"x": "project",
"y": "hours",
"data": [
{ "project": "Alpha", "hours": 14 },
{ "project": "Beta", "hours": 6 },
{ "project": "Internal", "hours": 3 }
]
}
\`\`\`
## Rules
- Data must come from what you actually fetched or computed this turn never fabricate points to make a chart possible. Too little real data? Say so instead of charting.
- Keep it readable: ~30 rows per chart, 6 series. Round values sensibly.
- Numbers must be JSON numbers, not strings ("3.1", "5%" won't plot).
- Follow the chart with one sentence of takeaway what the reader should see in it.
- One chart per distinct question; don't emit several variants of the same data.
`;
export default skill;

View file

@ -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.

View file

@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
import appsSkill from "./apps/skill.js";
import slackSkill from "./slack/skill.js";
import chartsSkill from "./charts/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/runtime/assembly/skills";
@ -77,6 +78,12 @@ const definitions: SkillDefinition[] = [
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
{
id: "charts",
title: "Charts",
summary: "Render interactive charts (line, bar, pie) inline in the chat reply. Use when the user asks for a chart/graph/visualization or when gathered data is clearer as a picture.",
content: chartsSkill,
},
{
id: "organize-files",
title: "Organize Files",

View file

@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({
data: z.array(z.record(z.string(), z.unknown())).optional(),
source: z.string().optional(),
x: z.string(),
y: z.string(),
// One series (string) or several (array of data keys). Pie ignores all
// but the first.
y: z.union([z.string(), z.array(z.string()).min(1)]),
});
export type ChartBlock = z.infer<typeof ChartBlockSchema>;
/** The y series list regardless of which form the block used. */
export function chartSeries(block: ChartBlock): string[] {
return Array.isArray(block.y) ? block.y : [block.y];
}
export const TableBlockSchema = z.object({
columns: z.array(z.string()),
data: z.array(z.record(z.string(), z.unknown())),