diff --git a/sundynix-desktop/frontend/src/components/ChartView.tsx b/sundynix-desktop/frontend/src/components/ChartView.tsx new file mode 100644 index 0000000..fddf146 --- /dev/null +++ b/sundynix-desktop/frontend/src/components/ChartView.tsx @@ -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 ( +
+ {spec.title &&
{spec.title}
} + {spec.type === "pie" ? : } + {spec.type !== "pie" && spec.series.length > 1 && s.name || `系列${i + 1}`)} />} +
+ ); +} + +function Legend({ names }: { names: string[] }) { + return ( +
+ {names.map((n, i) => ( + + + {n} + + ))} +
+ ); +} + +// 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 ( + + {/* y 轴基准线 */} + + {fmt(max)} + {fmt(min)} + + {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 ( + + {`${spec.labels[i]}: ${v}`} + + ); + }), + ) + : spec.series.map((s, si) => { + const pts = s.data.map((v, i) => `${padL + i * slot + slot / 2},${y(v)}`).join(" "); + return ( + + + {s.data.map((v, i) => ( + + {`${spec.labels[i]}: ${v}`} + + ))} + + ); + })} + + {/* x 轴标签 */} + {spec.labels.map((lb, i) => ( + + {lb.length > 6 ? lb.slice(0, 6) + "…" : lb} + + ))} + + ); +} + +// 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 ( +
+ + {arcs.map((a, i) => ( + + {`${a.label}: ${data[i]} (${a.pct}%)`} + + ))} + +
+ {arcs.map((a, i) => ( + + + {a.label} · {a.pct}% + + ))} +
+
+ ); +} + +function fmt(v: number): string { + if (Math.abs(v) >= 1000) return (v / 1000).toFixed(1) + "k"; + return String(Math.round(v * 100) / 100); +} diff --git a/sundynix-desktop/frontend/src/lib/chartspec.test.ts b/sundynix-desktop/frontend/src/lib/chartspec.test.ts new file mode 100644 index 0000000..8aaeaf0 --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/chartspec.test.ts @@ -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)); +}); diff --git a/sundynix-desktop/frontend/src/lib/chartspec.ts b/sundynix-desktop/frontend/src/lib/chartspec.ts new file mode 100644 index 0000000..da07bbd --- /dev/null +++ b/sundynix-desktop/frontend/src/lib/chartspec.ts @@ -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; + 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; +} diff --git a/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx index 4bdea0a..be91343 100644 --- a/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx +++ b/sundynix-desktop/frontend/src/shell/BottomDrawer.tsx @@ -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 }) { {open && (
- {tab === "output" && ( -
-              {run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
-            
- )} + {tab === "output" && } {tab === "trace" && } {tab === "tools" && } {tab === "cite" &&

引用列表:RAG 答案的来源块(源文档 + 分数 + 来源徽标)。

} @@ -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 ( +
+        在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。
+      
+ ); + } + if (!hasChart(output)) { + return
{output}
; + } + return ( +
+ {extractChartBlocks(output).map((seg, i) => + seg.kind === "chart" ? ( + + ) : ( + seg.text.trim() && ( +
+              {seg.text}
+            
+ ) + ), + )} +
+ ); +} + // ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。 function ToolCalls({ run }: { run: RunState }) { const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool"); diff --git a/sundynix-mcp-go/internal/mcp/chart.go b/sundynix-mcp-go/internal/mcp/chart.go new file mode 100644 index 0000000..a0aae2d --- /dev/null +++ b/sundynix-mcp-go/internal/mcp/chart.go @@ -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)} +} diff --git a/sundynix-mcp-go/internal/mcp/gateway.go b/sundynix-mcp-go/internal/mcp/gateway.go index eaa259c..78ea6bb 100644 --- a/sundynix-mcp-go/internal/mcp/gateway.go +++ b/sundynix-mcp-go/internal/mcp/gateway.go @@ -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},