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,126 @@
|
||||
import type { ChartSpec } from "../lib/chartspec";
|
||||
|
||||
// ChartView:把 chart spec 自绘成 SVG(bar / 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react";
|
||||
import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { ChartView } from "../components/ChartView";
|
||||
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
||||
import { approveTask } from "../lib/api";
|
||||
import { Tabs, Badge, cn, type TabDef } from "../ui";
|
||||
|
||||
@@ -53,11 +55,7 @@ export function BottomDrawer({ run }: { run: RunState }) {
|
||||
</div>
|
||||
{open && (
|
||||
<div className="h-44 overflow-auto p-3 text-xs">
|
||||
{tab === "output" && (
|
||||
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
|
||||
{run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
|
||||
</pre>
|
||||
)}
|
||||
{tab === "output" && <OutputView output={run.output} />}
|
||||
{tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />}
|
||||
{tab === "tools" && <ToolCalls run={run} />}
|
||||
{tab === "cite" && <p className="text-slate-600">引用列表:RAG 答案的来源块(源文档 + 分数 + 来源徽标)。</p>}
|
||||
@@ -122,6 +120,35 @@ function ApprovalBar({ taskId, node, title, summary }: { taskId: string; node: s
|
||||
);
|
||||
}
|
||||
|
||||
// OutputView:渲染模型输出。含 ```chart 块时分段渲染(文本 + SVG 图表),否则纯文本。
|
||||
function OutputView({ output }: { output: string }) {
|
||||
if (!output) {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
|
||||
在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (!hasChart(output)) {
|
||||
return <pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">{output}</pre>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{extractChartBlocks(output).map((seg, i) =>
|
||||
seg.kind === "chart" ? (
|
||||
<ChartView key={i} spec={seg.spec} />
|
||||
) : (
|
||||
seg.text.trim() && (
|
||||
<pre key={i} className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
|
||||
{seg.text}
|
||||
</pre>
|
||||
)
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
|
||||
function ToolCalls({ run }: { run: RunState }) {
|
||||
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
)
|
||||
|
||||
// chartSeries 是一条数据系列。
|
||||
type chartSeries struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Data []float64 `json:"data"`
|
||||
}
|
||||
|
||||
// chartSpec 是图表的结构化规范(工具产出,前端据此渲染 SVG,不在后端出图)。
|
||||
type chartSpec struct {
|
||||
Type string `json:"type"` // bar / line / pie
|
||||
Title string `json:"title,omitempty"` //
|
||||
Labels []string `json:"labels"` // x 轴/扇区标签
|
||||
Series []chartSeries `json:"series"` // 一条或多条数据系列(pie 取第一条)
|
||||
}
|
||||
|
||||
// chart 工具:只校验并返回规范化图表 JSON(渲染交前端)。职责单一、零图片传输。
|
||||
// 返回内容即一段 chart JSON;工具说明会指示 agent 在最终答复里用 ```chart 围栏原样包裹它。
|
||||
func (g *Gateway) chart(_ context.Context, call *contract.ToolCall) *contract.ToolResult {
|
||||
// 用 JSON round-trip 把 args 收进类型化结构(args 里 labels/series 是 []any,手解繁琐)。
|
||||
raw, _ := json.Marshal(call.Args)
|
||||
var in chartSpec
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return &contract.ToolResult{OK: false, Error: "chart: 参数解析失败 —— " + err.Error()}
|
||||
}
|
||||
switch in.Type {
|
||||
case "bar", "line", "pie":
|
||||
case "":
|
||||
in.Type = "bar"
|
||||
default:
|
||||
return &contract.ToolResult{OK: false, Error: "chart: type 仅支持 bar / line / pie"}
|
||||
}
|
||||
if len(in.Labels) == 0 {
|
||||
return &contract.ToolResult{OK: false, Error: "chart: labels 必填"}
|
||||
}
|
||||
if len(in.Series) == 0 || len(in.Series[0].Data) == 0 {
|
||||
return &contract.ToolResult{OK: false, Error: "chart: series 至少一条且 data 非空"}
|
||||
}
|
||||
for i, s := range in.Series {
|
||||
if len(s.Data) != len(in.Labels) {
|
||||
return &contract.ToolResult{OK: false,
|
||||
Error: fmt.Sprintf("chart: 第 %d 条系列 data 长度(%d) 与 labels 长度(%d) 不一致", i+1, len(s.Data), len(in.Labels))}
|
||||
}
|
||||
}
|
||||
if in.Type == "pie" {
|
||||
in.Series = in.Series[:1] // pie 只用第一条系列
|
||||
}
|
||||
out, _ := json.Marshal(in)
|
||||
// 提示 agent:把这段 JSON 用 ```chart 围栏原样放进最终答复,前端会渲染成图。
|
||||
return &contract.ToolResult{OK: true, Content: string(out)}
|
||||
}
|
||||
@@ -142,6 +142,17 @@ func (g *Gateway) buildRegistry() map[string]toolDef {
|
||||
params: []paramSpec{{Name: "sql", Type: "string", Desc: "只读 SQL,如 SELECT count(*) FROM sundynix_task", Required: true}},
|
||||
handler: g.sqlQuery,
|
||||
},
|
||||
"chart": {
|
||||
cn: "图表", desc: "把数据生成图表。返回图表 JSON——请在最终答复中用 ```chart 代码块原样包裹该 JSON,前端会渲染成图。需要可视化数据分布/趋势时调用。",
|
||||
agent: true,
|
||||
params: []paramSpec{
|
||||
{Name: "type", Type: "string", Desc: "图表类型:bar / line / pie", Required: true},
|
||||
{Name: "title", Type: "string", Desc: "图表标题"},
|
||||
{Name: "labels", Type: "array", Desc: "x 轴/扇区标签数组,如 [\"Q1\",\"Q2\"]", Required: true},
|
||||
{Name: "series", Type: "array", Desc: "数据系列数组,如 [{\"name\":\"销量\",\"data\":[120,180]}]", Required: true},
|
||||
},
|
||||
handler: g.chart,
|
||||
},
|
||||
|
||||
// —— 仅内部/流水线/管理用,不暴露给自主 agent ——
|
||||
"kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest},
|
||||
|
||||
Reference in New Issue
Block a user