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,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)}
|
||||
}
|
||||
Reference in New Issue
Block a user