mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-24 21:41:08 +02:00
feat(x): inline charts in chat via a charts skill
The chat's markdown fence override now renders ```chart blocks (the same ChartBlockSchema notes use) as interactive recharts inline — the mermaid pattern. A new ChartRenderer handles line/bar/pie with multi-series support, light/dark theming, and a quiet placeholder while the fence body is still streaming. ChartBlockSchema.y widens to string | string[] for multi-series charts (notes chart block updated to map series too), with a chartSeries() helper in shared. A new 'charts' skill teaches the model the fence format, form selection (line/bar/pie, no mixed scales), and data honesty rules; loaded on demand like any skill.
This commit is contained in:
parent
efa6a0523c
commit
5acd825903
6 changed files with 256 additions and 4 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import { isValidElement, type JSX } from 'react'
|
import { isValidElement, type JSX } from 'react'
|
||||||
import { FilePathCard } from './file-path-card'
|
import { FilePathCard } from './file-path-card'
|
||||||
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
import { MermaidRenderer } from '@/components/mermaid-renderer'
|
||||||
|
import { ChartRenderer } from '@/components/chart-renderer'
|
||||||
|
|
||||||
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||||
const { children, ...rest } = props
|
const { children, ...rest } = props
|
||||||
|
|
@ -31,6 +32,17 @@ export function MarkdownPreOverride(props: JSX.IntrinsicElements['pre']) {
|
||||||
return <MermaidRenderer source={text} />
|
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
|
// Passthrough for all other code blocks - return children directly
|
||||||
|
|
|
||||||
147
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
147
apps/x/apps/renderer/src/components/chart-renderer.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return blocks.ChartBlockSchema.parse(JSON.parse(source))
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [source])
|
||||||
|
|
||||||
|
if (!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" />
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 (error) return <div className="chart-block-error-msg">{error}</div>
|
||||||
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
if (!data || data.length === 0) return <div className="chart-block-empty">No data</div>
|
||||||
|
|
||||||
|
const series = blocks.chartSeries(config!)
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={250}>
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
{config!.chart === 'line' ? (
|
{config!.chart === 'line' ? (
|
||||||
|
|
@ -73,7 +74,9 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<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>
|
</LineChart>
|
||||||
) : config!.chart === 'bar' ? (
|
) : config!.chart === 'bar' ? (
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
|
|
@ -82,13 +85,15 @@ function ChartBlockView({ node, deleteNode }: { node: { attrs: Record<string, un
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<Legend />
|
||||||
<Bar dataKey={config!.y} fill="#8884d8" />
|
{series.map((key, index) => (
|
||||||
|
<Bar key={key} dataKey={key} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||||
|
))}
|
||||||
</BarChart>
|
</BarChart>
|
||||||
) : (
|
) : (
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Legend />
|
<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) => (
|
{data.map((_, index) => (
|
||||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
- **\`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
|
||||||
|
- **\`y\`** (required): key of the value field — a string for one series, or an array of keys for several series on one chart
|
||||||
|
- **\`title\`** (optional): short heading shown above the chart
|
||||||
|
|
||||||
|
## 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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 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;
|
||||||
|
|
@ -29,6 +29,7 @@ import backgroundTaskSkill from "./background-task/skill.js";
|
||||||
import notifyUserSkill from "./notify-user/skill.js";
|
import notifyUserSkill from "./notify-user/skill.js";
|
||||||
import appsSkill from "./apps/skill.js";
|
import appsSkill from "./apps/skill.js";
|
||||||
import slackSkill from "./slack/skill.js";
|
import slackSkill from "./slack/skill.js";
|
||||||
|
import chartsSkill from "./charts/skill.js";
|
||||||
|
|
||||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const CATALOG_PREFIX = "src/runtime/assembly/skills";
|
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.",
|
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
|
||||||
content: meetingPrepSkill,
|
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",
|
id: "organize-files",
|
||||||
title: "Organize Files",
|
title: "Organize Files",
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,18 @@ export const ChartBlockSchema = z.object({
|
||||||
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
data: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
x: z.string(),
|
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>;
|
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({
|
export const TableBlockSchema = z.object({
|
||||||
columns: z.array(z.string()),
|
columns: z.array(z.string()),
|
||||||
data: z.array(z.record(z.string(), z.unknown())),
|
data: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue