feat: chart support — make_chart tool with recharts rendering
Adds inline data visualisation to the chat agent. After a successful
run_query, the agent can call make_chart(chart_type, x, y, [group,
title, orientation]) and the result is rendered as a bar / line / area
/ pie chart inline in the chat thread, sourced from the previous query
result.
Backend (commands/chat.rs, models/chat.rs)
- New ChartConfig{chart_type, x, y, group?, title?, orientation?} model.
- New AgentAction::MakeChart{config} variant. Parser accepts both
`chart_type` and the alternative `type` field name (qwen3 sometimes
emits the latter). Validates chart_type is one of bar/line/area/pie.
- last_successful_query_result helper finds the most recent successful
run_query in the working thread.
- MakeChart dispatcher: validates that x/y/group columns exist in the
attached query result, emits a tool_result with the same QueryResult
in `result` and the chart_config JSON in `text`. Mismatches surface
as a clear error ("y column `name` is not in the last result.
Available: company_name, legal_name, …").
- build_history compression unchanged: make_chart's tool_result text
field (the small chart_config JSON) is included in LLM history; the
large QueryResult.rows are NOT, since the per-tool branch only emits
text for non-run_query tools.
- System prompt: documents make_chart with concrete usage hints
(top-N → bar, time series → line/area, proportions → pie; skip for
≤2 or >500 rows). 7 new parser/dispatcher tests.
Frontend (src/components/chat/)
- recharts ^3.8 added.
- New ChartPreview component renders bar (vertical+horizontal), line,
area, pie. Supports grouped series via the `group` config field by
pivoting rows into a wide format. Y values coerced to numbers
(parses strings, nulls → 0). Caps to 500 points to keep things
responsive on huge results.
- ChatMessageView routes tool=="make_chart" tool_result through a new
ChartToolResult that parses the config JSON from the message text
and feeds the embedded QueryResult into ChartPreview.
- New labels/icons (BarChart3) and preview-extraction for make_chart
in tool-call collapsed headers (`bar: carrier_name → trip_count`).
Verification: cargo test --lib 77 pass (+7), tsc clean, vitest 20
pass.
This commit is contained in:
327
src/components/chat/ChartPreview.tsx
Normal file
327
src/components/chat/ChartPreview.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { ChartConfig } from "@/types";
|
||||
|
||||
interface Props {
|
||||
config: ChartConfig;
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const PALETTE = [
|
||||
"#60a5fa", // blue-400
|
||||
"#34d399", // emerald-400
|
||||
"#fbbf24", // amber-400
|
||||
"#f87171", // red-400
|
||||
"#a78bfa", // violet-400
|
||||
"#22d3ee", // cyan-400
|
||||
"#fb923c", // orange-400
|
||||
"#f472b6", // pink-400
|
||||
];
|
||||
|
||||
const MAX_POINTS = 500;
|
||||
|
||||
export function ChartPreview({ config, columns, rows, height = 280 }: Props) {
|
||||
const xIdx = columns.indexOf(config.x);
|
||||
const yIdx = columns.indexOf(config.y);
|
||||
const groupIdx = config.group ? columns.indexOf(config.group) : -1;
|
||||
|
||||
const limited = useMemo(() => rows.slice(0, MAX_POINTS), [rows]);
|
||||
|
||||
if (xIdx < 0 || yIdx < 0) {
|
||||
return (
|
||||
<ChartFallback
|
||||
config={config}
|
||||
message={`Column not found: ${xIdx < 0 ? config.x : config.y}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Coerce y values to numbers; chart libs need numeric Y.
|
||||
const numericY = (v: unknown): number => {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string") {
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const labelX = (v: unknown): string => {
|
||||
if (v == null) return "—";
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||||
return JSON.stringify(v);
|
||||
};
|
||||
|
||||
const isGrouped = groupIdx >= 0;
|
||||
|
||||
// ──────────── grouped data shape ────────────
|
||||
// For multi-series: pivot to { x: <xValue>, <group1>: yVal, <group2>: yVal, … }
|
||||
// Used by line, area, and grouped-bar.
|
||||
const pivoted = useMemo(() => {
|
||||
if (!isGrouped) return null;
|
||||
const map = new Map<string, Record<string, unknown>>();
|
||||
const groupSet = new Set<string>();
|
||||
for (const row of limited) {
|
||||
const xv = labelX(row[xIdx]);
|
||||
const gv = labelX(row[groupIdx!]);
|
||||
const yv = numericY(row[yIdx]);
|
||||
groupSet.add(gv);
|
||||
const acc = map.get(xv) ?? { _x: xv };
|
||||
acc[gv] = ((acc[gv] as number) ?? 0) + yv;
|
||||
map.set(xv, acc);
|
||||
}
|
||||
return {
|
||||
data: Array.from(map.values()),
|
||||
groups: Array.from(groupSet),
|
||||
};
|
||||
}, [isGrouped, limited, xIdx, yIdx, groupIdx]);
|
||||
|
||||
// Single series shape: [{ _x, _y }]
|
||||
const flat = useMemo(() => {
|
||||
return limited.map((row) => ({
|
||||
_x: labelX(row[xIdx]),
|
||||
_y: numericY(row[yIdx]),
|
||||
}));
|
||||
}, [limited, xIdx, yIdx]);
|
||||
|
||||
const tickStyle = {
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
} as const;
|
||||
|
||||
const axisLine = {
|
||||
stroke: "rgba(255, 255, 255, 0.08)",
|
||||
} as const;
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: "var(--popover)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 6,
|
||||
fontSize: 11,
|
||||
} as const;
|
||||
|
||||
if (config.chart_type === "pie") {
|
||||
// Pie: aggregate y by x label (sum), no group support.
|
||||
const agg = new Map<string, number>();
|
||||
for (const row of limited) {
|
||||
const xv = labelX(row[xIdx]);
|
||||
agg.set(xv, (agg.get(xv) ?? 0) + numericY(row[yIdx]));
|
||||
}
|
||||
const data = Array.from(agg.entries()).map(([name, value]) => ({ name, value }));
|
||||
return (
|
||||
<ChartFrame config={config} height={height} count={data.length} totalRows={rows.length}>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
outerRadius={Math.min(height / 2.5, 110)}
|
||||
label={(entry) =>
|
||||
typeof entry.name === "string" && entry.name.length < 20 ? entry.name : ""
|
||||
}
|
||||
>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={PALETTE[i % PALETTE.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }}
|
||||
verticalAlign="bottom"
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.chart_type === "line") {
|
||||
return (
|
||||
<ChartFrame
|
||||
config={config}
|
||||
height={height}
|
||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
||||
totalRows={rows.length}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={isGrouped ? pivoted!.data : flat} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{isGrouped ? (
|
||||
<>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
||||
{pivoted!.groups.map((g, i) => (
|
||||
<Line
|
||||
key={g}
|
||||
type="monotone"
|
||||
dataKey={g}
|
||||
stroke={PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Line type="monotone" dataKey="_y" stroke={PALETTE[0]} strokeWidth={2} dot={false} />
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
if (config.chart_type === "area") {
|
||||
return (
|
||||
<ChartFrame
|
||||
config={config}
|
||||
height={height}
|
||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
||||
totalRows={rows.length}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={isGrouped ? pivoted!.data : flat} margin={{ top: 8, right: 12, left: 0, bottom: 4 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{isGrouped ? (
|
||||
<>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
||||
{pivoted!.groups.map((g, i) => (
|
||||
<Area
|
||||
key={g}
|
||||
type="monotone"
|
||||
dataKey={g}
|
||||
stackId="1"
|
||||
stroke={PALETTE[i % PALETTE.length]}
|
||||
fill={PALETTE[i % PALETTE.length]}
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="_y"
|
||||
stroke={PALETTE[0]}
|
||||
fill={PALETTE[0]}
|
||||
fillOpacity={0.35}
|
||||
/>
|
||||
)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// bar (default)
|
||||
const horizontal = config.orientation === "horizontal";
|
||||
return (
|
||||
<ChartFrame
|
||||
config={config}
|
||||
height={height}
|
||||
count={isGrouped ? pivoted!.data.length : flat.length}
|
||||
totalRows={rows.length}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<BarChart
|
||||
layout={horizontal ? "vertical" : "horizontal"}
|
||||
data={isGrouped ? pivoted!.data : flat}
|
||||
margin={{ top: 8, right: 12, left: horizontal ? 24 : 0, bottom: 4 }}
|
||||
>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={horizontal} horizontal={!horizontal} />
|
||||
{horizontal ? (
|
||||
<>
|
||||
<XAxis type="number" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<YAxis dataKey="_x" type="category" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} width={100} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XAxis dataKey="_x" tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
<YAxis tick={tickStyle} axisLine={axisLine} tickLine={axisLine} />
|
||||
</>
|
||||
)}
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{isGrouped ? (
|
||||
<>
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: "var(--muted-foreground)" }} />
|
||||
{pivoted!.groups.map((g, i) => (
|
||||
<Bar key={g} dataKey={g} fill={PALETTE[i % PALETTE.length]} radius={[3, 3, 0, 0]} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Bar dataKey="_y" fill={PALETTE[0]} radius={[3, 3, 0, 0]} />
|
||||
)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartFrame({
|
||||
config,
|
||||
height,
|
||||
count,
|
||||
totalRows,
|
||||
children,
|
||||
}: {
|
||||
config: ChartConfig;
|
||||
height: number;
|
||||
count: number;
|
||||
totalRows: number;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border/40 bg-background">
|
||||
<div className="flex items-center gap-2 border-b border-border/30 px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">
|
||||
{config.title ?? `${capitalize(config.chart_type)} chart`}
|
||||
</span>
|
||||
<span className="ml-auto text-muted-foreground/60">
|
||||
{count} point{count === 1 ? "" : "s"}
|
||||
{totalRows > MAX_POINTS && ` (of ${totalRows}, capped at ${MAX_POINTS})`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-2" style={{ minHeight: height }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartFallback({ config, message }: { config: ChartConfig; message: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||
<div className="font-medium text-destructive">
|
||||
Chart {config.chart_type} failed
|
||||
</div>
|
||||
<div className="mt-1 text-muted-foreground">{message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { ResultsTable } from "@/components/results/ResultsTable";
|
||||
import { ExportDialog } from "@/components/export/ExportDialog";
|
||||
import { ChartPreview } from "./ChartPreview";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,8 +25,9 @@ import {
|
||||
BookmarkPlus,
|
||||
Maximize2,
|
||||
Download,
|
||||
BarChart3,
|
||||
} from "lucide-react";
|
||||
import type { ChatMessage } from "@/types";
|
||||
import type { ChartConfig, ChatMessage } from "@/types";
|
||||
|
||||
interface Props {
|
||||
message: ChatMessage;
|
||||
@@ -161,6 +163,11 @@ function ToolResultBlock({
|
||||
return <TextToolResult tool={tool} text={text} />;
|
||||
}
|
||||
|
||||
// make_chart — render chart inline using config from text + data from result.
|
||||
if (tool === "make_chart") {
|
||||
return <ChartToolResult text={text} result={result} />;
|
||||
}
|
||||
|
||||
// run_query — full results table with Open-full / Export actions.
|
||||
if (result) {
|
||||
return <RunQueryResultBlock result={result} />;
|
||||
@@ -169,6 +176,45 @@ function ToolResultBlock({
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChartToolResult({
|
||||
text,
|
||||
result,
|
||||
}: {
|
||||
text: string | null;
|
||||
result: { columns: string[]; types: string[]; rows: unknown[][]; row_count: number; execution_time_ms: number } | null;
|
||||
}) {
|
||||
let config: ChartConfig | null = null;
|
||||
try {
|
||||
if (text) {
|
||||
config = JSON.parse(text) as ChartConfig;
|
||||
}
|
||||
} catch {
|
||||
config = null;
|
||||
}
|
||||
if (!config || !result) {
|
||||
return (
|
||||
<div className="ml-8 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">Chart unavailable</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
The agent referenced a chart but the previous query result is not attached.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="ml-8">
|
||||
<ChartPreview
|
||||
config={config}
|
||||
columns={result.columns}
|
||||
rows={result.rows}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunQueryResultBlock({
|
||||
result,
|
||||
}: {
|
||||
@@ -318,6 +364,8 @@ function labelForTool(tool: string): string {
|
||||
return "Save query";
|
||||
case "find_queries":
|
||||
return "Find saved queries";
|
||||
case "make_chart":
|
||||
return "Make chart";
|
||||
case "get_schema":
|
||||
return "Load schema";
|
||||
default:
|
||||
@@ -343,6 +391,8 @@ function iconForTool(tool: string) {
|
||||
return BookmarkPlus;
|
||||
case "find_queries":
|
||||
return Bookmark;
|
||||
case "make_chart":
|
||||
return BarChart3;
|
||||
case "get_schema":
|
||||
return Database;
|
||||
default:
|
||||
@@ -368,6 +418,13 @@ function extractToolPreview(tool: string, inputJson: string): string | null {
|
||||
return typeof parsed.name === "string" ? parsed.name : null;
|
||||
case "find_queries":
|
||||
return typeof parsed.text === "string" ? parsed.text : null;
|
||||
case "make_chart": {
|
||||
const t = typeof parsed.chart_type === "string" ? parsed.chart_type : null;
|
||||
const x = typeof parsed.x === "string" ? parsed.x : null;
|
||||
const y = typeof parsed.y === "string" ? parsed.y : null;
|
||||
if (t && x && y) return `${t}: ${x} → ${y}`;
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -215,3 +215,14 @@ export interface ChatTurnResult {
|
||||
messages: ChatMessage[];
|
||||
usage: ContextUsage;
|
||||
}
|
||||
|
||||
export type ChartType = "bar" | "line" | "area" | "pie";
|
||||
|
||||
export interface ChartConfig {
|
||||
chart_type: ChartType;
|
||||
x: string;
|
||||
y: string;
|
||||
group?: string | null;
|
||||
title?: string | null;
|
||||
orientation?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user