feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1

Merged
Blizzard merged 181 commits from feat/wails3 into main 2026-07-17 01:12:32 +00:00
6 changed files with 336 additions and 5 deletions
Showing only changes of commit 592b2a3d97 - Show all commits
@@ -0,0 +1,126 @@
import type { ChartSpec } from "../lib/chartspec";
// ChartView:把 chart spec 自绘成 SVGbar / 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 工具返回图表 JSONagent 在答复里用 ```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 { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react";
import { deriveNodes, pendingApproval, type RunState } from "../lib/run"; import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
import { ExecTrace } from "../components/ExecTrace"; import { ExecTrace } from "../components/ExecTrace";
import { ChartView } from "../components/ChartView";
import { extractChartBlocks, hasChart } from "../lib/chartspec";
import { approveTask } from "../lib/api"; import { approveTask } from "../lib/api";
import { Tabs, Badge, cn, type TabDef } from "../ui"; import { Tabs, Badge, cn, type TabDef } from "../ui";
@@ -53,11 +55,7 @@ export function BottomDrawer({ run }: { run: RunState }) {
</div> </div>
{open && ( {open && (
<div className="h-44 overflow-auto p-3 text-xs"> <div className="h-44 overflow-auto p-3 text-xs">
{tab === "output" && ( {tab === "output" && <OutputView output={run.output} />}
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
{run.output || "在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。"}
</pre>
)}
{tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />} {tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />}
{tab === "tools" && <ToolCalls run={run} />} {tab === "tools" && <ToolCalls run={run} />}
{tab === "cite" && <p className="text-slate-600">RAG + + </p>} {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:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。 // ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
function ToolCalls({ run }: { run: RunState }) { function ToolCalls({ run }: { run: RunState }) {
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool"); const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
+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)}
}
+11
View File
@@ -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}}, params: []paramSpec{{Name: "sql", Type: "string", Desc: "只读 SQL,如 SELECT count(*) FROM sundynix_task", Required: true}},
handler: g.sqlQuery, 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 —— // —— 仅内部/流水线/管理用,不暴露给自主 agent ——
"kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest}, "kb_ingest": {cn: "知识入库", desc: "文本切块 → 向量化 → 写入 Milvus / Bleve", handler: g.kbIngest},