feat(tools): chart 图表工具 —— 工具产出 JSON spec,前端 SVG 渲染(职责分离)

按「工具只产数据、渲染交前端」设计:
- 后端 chart 工具(mcp-go):校验并返回规范化图表 JSON(type=bar/line/pie + labels + series,
  校验类型/长度一致/pie 取首系列)。工具说明指示 agent 用 ```chart 围栏原样包裹返回的 JSON。
- 前端:lib/chartspec.ts 从输出抽取 ```chart 块(解析失败回退为文本不丢内容);
  components/ChartView.tsx 自绘 SVG 柱/线/饼图(无第三方图表依赖);
  BottomDrawer 输出区含图表块时分段渲染(文本 + SVG),否则纯文本。

测试:前端 chartspec 单测 12 例(isChartSpec 校验、分段抽取、非法块回退、多块、hasChart);
tsc 干净,vitest 48 过。live 自主 agent:chart 工具产出 {"type":"bar",...},
agent 正确用 ```chart 围栏嵌入答复,前端据此渲染。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 17:24:41 +08:00
parent ca38dcd0c9
commit 592b2a3d97
6 changed files with 336 additions and 5 deletions
@@ -0,0 +1,126 @@
import type { ChartSpec } from "../lib/chartspec";
// ChartView:把 chart spec 自绘成 SVGbar / line / pie),无第三方图表依赖。
const PALETTE = ["#60a5fa", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#22d3ee", "#fb923c", "#4ade80"];
export function ChartView({ spec }: { spec: ChartSpec }) {
return (
<figure className="my-2 rounded-lg border border-line bg-ink-950/50 p-3">
{spec.title && <figcaption className="mb-2 text-[12px] font-medium text-slate-200">{spec.title}</figcaption>}
{spec.type === "pie" ? <Pie spec={spec} /> : <BarLine spec={spec} />}
{spec.type !== "pie" && spec.series.length > 1 && <Legend names={spec.series.map((s, i) => s.name || `系列${i + 1}`)} />}
</figure>
);
}
function Legend({ names }: { names: string[] }) {
return (
<div className="mt-2 flex flex-wrap gap-3">
{names.map((n, i) => (
<span key={i} className="flex items-center gap-1 text-[10px] text-slate-400">
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: PALETTE[i % PALETTE.length] }} />
{n}
</span>
))}
</div>
);
}
// BarLine:柱状图 / 折线图(共用坐标系)。
function BarLine({ spec }: { spec: ChartSpec }) {
const W = 480, H = 220, padL = 40, padB = 28, padT = 8, padR = 8;
const plotW = W - padL - padR, plotH = H - padT - padB;
const all = spec.series.flatMap((s) => s.data);
const max = Math.max(1, ...all);
const min = Math.min(0, ...all);
const span = max - min || 1;
const y = (v: number) => padT + plotH - ((v - min) / span) * plotH;
const n = spec.labels.length;
const slot = plotW / n;
return (
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={spec.title || "图表"}>
{/* y 轴基准线 */}
<line x1={padL} y1={y(min)} x2={W - padR} y2={y(min)} stroke="#334155" strokeWidth={1} />
<text x={padL - 6} y={y(max)} fill="#64748b" fontSize={9} textAnchor="end">{fmt(max)}</text>
<text x={padL - 6} y={y(min) + 3} fill="#64748b" fontSize={9} textAnchor="end">{fmt(min)}</text>
{spec.type === "bar"
? spec.series.map((s, si) =>
s.data.map((v, i) => {
const bw = (slot * 0.7) / spec.series.length;
const x = padL + i * slot + slot * 0.15 + si * bw;
return (
<rect key={`${si}-${i}`} x={x} y={Math.min(y(v), y(0))} width={bw} height={Math.abs(y(v) - y(0))}
fill={PALETTE[si % PALETTE.length]} rx={1}>
<title>{`${spec.labels[i]}: ${v}`}</title>
</rect>
);
}),
)
: spec.series.map((s, si) => {
const pts = s.data.map((v, i) => `${padL + i * slot + slot / 2},${y(v)}`).join(" ");
return (
<g key={si}>
<polyline points={pts} fill="none" stroke={PALETTE[si % PALETTE.length]} strokeWidth={2} />
{s.data.map((v, i) => (
<circle key={i} cx={padL + i * slot + slot / 2} cy={y(v)} r={2.5} fill={PALETTE[si % PALETTE.length]}>
<title>{`${spec.labels[i]}: ${v}`}</title>
</circle>
))}
</g>
);
})}
{/* x 轴标签 */}
{spec.labels.map((lb, i) => (
<text key={i} x={padL + i * slot + slot / 2} y={H - padB + 14} fill="#64748b" fontSize={9} textAnchor="middle">
{lb.length > 6 ? lb.slice(0, 6) + "…" : lb}
</text>
))}
</svg>
);
}
// Pie:饼图(用第一条系列)。
function Pie({ spec }: { spec: ChartSpec }) {
const data = spec.series[0]?.data ?? [];
const total = data.reduce((a, b) => a + Math.max(0, b), 0) || 1;
const cx = 110, cy = 110, r = 90;
let acc = -Math.PI / 2; // 从 12 点方向起
const arcs = data.map((v, i) => {
const frac = Math.max(0, v) / total;
const a0 = acc;
const a1 = acc + frac * Math.PI * 2;
acc = a1;
const large = a1 - a0 > Math.PI ? 1 : 0;
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const d = `M${cx},${cy} L${x0.toFixed(2)},${y0.toFixed(2)} A${r},${r} 0 ${large} 1 ${x1.toFixed(2)},${y1.toFixed(2)} Z`;
return { d, color: PALETTE[i % PALETTE.length], label: spec.labels[i], pct: Math.round(frac * 100) };
});
return (
<div className="flex items-center gap-4">
<svg viewBox="0 0 220 220" className="h-44 w-44 shrink-0" role="img" aria-label={spec.title || "饼图"}>
{arcs.map((a, i) => (
<path key={i} d={a.d} fill={a.color} stroke="#0b1220" strokeWidth={1}>
<title>{`${a.label}: ${data[i]} (${a.pct}%)`}</title>
</path>
))}
</svg>
<div className="flex flex-col gap-1">
{arcs.map((a, i) => (
<span key={i} className="flex items-center gap-1.5 text-[10px] text-slate-400">
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: a.color }} />
{a.label} · {a.pct}%
</span>
))}
</div>
</div>
);
}
function fmt(v: number): string {
if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1) + "k";
return String(Math.round(v * 100) / 100);
}