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:
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractChartBlocks, isChartSpec, hasChart } from "./chartspec";
|
||||
|
||||
const spec = { type: "bar", title: "销量", labels: ["Q1", "Q2"], series: [{ name: "销量", data: [120, 180] }] };
|
||||
|
||||
describe("isChartSpec", () => {
|
||||
it("合法 spec 通过", () => expect(isChartSpec(spec)).toBe(true));
|
||||
it.each([
|
||||
{},
|
||||
{ type: "x", labels: ["a"], series: [{ data: [1] }] },
|
||||
{ type: "bar", labels: [], series: [{ data: [] }] },
|
||||
{ type: "bar", labels: ["a"], series: [] },
|
||||
{ type: "bar", labels: ["a"], series: [{ name: "x" }] }, // 无 data
|
||||
])("非法 spec 拒绝 %#", (bad) => expect(isChartSpec(bad)).toBe(false));
|
||||
});
|
||||
|
||||
describe("extractChartBlocks", () => {
|
||||
it("纯文本 → 单个 text 段", () => {
|
||||
const segs = extractChartBlocks("你好世界");
|
||||
expect(segs).toEqual([{ kind: "text", text: "你好世界" }]);
|
||||
});
|
||||
|
||||
it("文本 + chart 块 + 文本 → 三段且顺序正确", () => {
|
||||
const out = "看图:\n```chart\n" + JSON.stringify(spec) + "\n```\n以上。";
|
||||
const segs = extractChartBlocks(out);
|
||||
expect(segs.map((s) => s.kind)).toEqual(["text", "chart", "text"]);
|
||||
expect(segs[1].kind === "chart" && segs[1].spec.title).toBe("销量");
|
||||
});
|
||||
|
||||
it("非法 JSON 的 chart 块 → 回退为文本,不丢内容", () => {
|
||||
const out = "```chart\n{坏的}\n```";
|
||||
const segs = extractChartBlocks(out);
|
||||
expect(segs).toHaveLength(1);
|
||||
expect(segs[0].kind).toBe("text");
|
||||
});
|
||||
|
||||
it("两个 chart 块都解析", () => {
|
||||
const blk = "```chart\n" + JSON.stringify(spec) + "\n```";
|
||||
const segs = extractChartBlocks(blk + "\n中间\n" + blk);
|
||||
expect(segs.filter((s) => s.kind === "chart")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasChart", () => {
|
||||
it("含 chart 围栏 → true", () => expect(hasChart("a\n```chart\n{}\n```")).toBe(true));
|
||||
it("不含 → false", () => expect(hasChart("```json\n{}\n```")).toBe(false));
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// 图表 spec 与「从模型输出里抽取 ```chart 代码块」的解析。
|
||||
// 约定:chart 工具返回图表 JSON,agent 在答复里用 ```chart 围栏原样包裹;前端据此渲染 SVG。
|
||||
|
||||
export interface ChartSeries {
|
||||
name?: string;
|
||||
data: number[];
|
||||
}
|
||||
|
||||
export interface ChartSpec {
|
||||
type: "bar" | "line" | "pie";
|
||||
title?: string;
|
||||
labels: string[];
|
||||
series: ChartSeries[];
|
||||
}
|
||||
|
||||
// Segment:把一段输出拆成「普通文本」与「图表」交错的有序片段,供输出区分别渲染。
|
||||
export type Segment = { kind: "text"; text: string } | { kind: "chart"; spec: ChartSpec };
|
||||
|
||||
const chartFence = /```chart\s*\n([\s\S]*?)```/g;
|
||||
|
||||
// isChartSpec 校验解析出的对象是否是合法图表 spec(防脏数据炸渲染)。
|
||||
export function isChartSpec(v: unknown): v is ChartSpec {
|
||||
if (!v || typeof v !== "object") return false;
|
||||
const o = v as Record<string, unknown>;
|
||||
if (o.type !== "bar" && o.type !== "line" && o.type !== "pie") return false;
|
||||
if (!Array.isArray(o.labels) || o.labels.length === 0) return false;
|
||||
if (!Array.isArray(o.series) || o.series.length === 0) return false;
|
||||
return (o.series as unknown[]).every(
|
||||
(s) => s && typeof s === "object" && Array.isArray((s as ChartSeries).data),
|
||||
);
|
||||
}
|
||||
|
||||
// extractChartBlocks 把输出文本拆为 text / chart 片段(保持原顺序)。
|
||||
// ```chart 块解析失败或非法 → 回退为普通文本,绝不丢内容。
|
||||
export function extractChartBlocks(input: string): Segment[] {
|
||||
if (!input) return [];
|
||||
const segs: Segment[] = [];
|
||||
let last = 0;
|
||||
for (let m = chartFence.exec(input); m; m = chartFence.exec(input)) {
|
||||
if (m.index > last) segs.push({ kind: "text", text: input.slice(last, m.index) });
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(m[1].trim());
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
if (isChartSpec(parsed)) segs.push({ kind: "chart", spec: parsed });
|
||||
else segs.push({ kind: "text", text: m[0] }); // 解析失败:原样当文本,不丢
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
chartFence.lastIndex = 0;
|
||||
if (last < input.length) segs.push({ kind: "text", text: input.slice(last) });
|
||||
return segs;
|
||||
}
|
||||
|
||||
// hasChart 快速判断输出里是否含图表块(决定是否走分段渲染)。
|
||||
export function hasChart(input: string): boolean {
|
||||
const has = /```chart\s*\n/.test(input);
|
||||
return has;
|
||||
}
|
||||
Reference in New Issue
Block a user