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
+60
View File
@@ -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)}
}