feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -483,3 +483,39 @@ export async function statsOverview(): Promise<Overview> {
|
|||||||
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
|
||||||
return res.json() as Promise<Overview>;
|
return res.json() as Promise<Overview>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 运行历史 / 复盘 ──
|
||||||
|
export interface RunSummary {
|
||||||
|
task_id: string;
|
||||||
|
status: string;
|
||||||
|
detail: string;
|
||||||
|
at: string;
|
||||||
|
eval_level: string;
|
||||||
|
eval_overall: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRuns(limit = 30): Promise<RunSummary[]> {
|
||||||
|
const res = guard401(await fetch(`${GATEWAY}/api/v1/runs?limit=${limit}`, { headers: bearer() }));
|
||||||
|
if (!res.ok) throw new Error(`list runs failed: ${res.status}`);
|
||||||
|
const d = (await res.json()) as { runs?: RunSummary[] };
|
||||||
|
return d.runs ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvalResult {
|
||||||
|
overall: number;
|
||||||
|
rule: number;
|
||||||
|
llm: number;
|
||||||
|
faithful: number;
|
||||||
|
level: string;
|
||||||
|
flags: string[];
|
||||||
|
reason: string;
|
||||||
|
sources: number;
|
||||||
|
corrected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskEval: 取一次任务的评测结果(无则返回 null)。
|
||||||
|
export async function taskEval(taskId: string): Promise<EvalResult | null> {
|
||||||
|
const res = await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/eval`, { headers: bearer() });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return res.json() as Promise<EvalResult>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,57 +1,128 @@
|
|||||||
import { Activity, FileText } from "lucide-react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Activity, FileText, History } from "lucide-react";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { deriveNodes, type RunState } from "../lib/run";
|
import { deriveNodes, emptyRun, type RunState } from "../lib/run";
|
||||||
import { Panel, Dot, EmptyState } from "../ui";
|
import { listRuns, taskEval, streamExec, streamTokens, type RunSummary, type EvalResult } from "../lib/api";
|
||||||
|
import { Panel, Dot, Badge, EmptyState, cn } from "../ui";
|
||||||
|
|
||||||
// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时),
|
const STATUS_DOT: Record<string, "success" | "danger" | "warn" | "running" | "neutral"> = {
|
||||||
// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。
|
done: "success", failed: "danger", timeout: "danger", rejected: "danger",
|
||||||
|
waiting: "warn", running: "running", submitted: "running",
|
||||||
|
};
|
||||||
|
const LEVEL_TONE: Record<string, "success" | "warn" | "danger"> = { ok: "success", warn: "warn", poor: "danger" };
|
||||||
|
|
||||||
|
function relTime(iso: string): string {
|
||||||
|
const d = Date.now() - new Date(iso).getTime();
|
||||||
|
if (d < 60_000) return "刚刚";
|
||||||
|
if (d < 3_600_000) return `${Math.floor(d / 60_000)} 分钟前`;
|
||||||
|
if (d < 86_400_000) return `${Math.floor(d / 3_600_000)} 小时前`;
|
||||||
|
return `${Math.floor(d / 86_400_000)} 天前`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行 · 观测:左侧运行历史,选中后从 Redis 回放该次执行轨迹 + 输出 + 评测(复盘)。
|
||||||
|
// 「当前运行」固定在顶部,沿用实时订阅;历史项点击即复现(exec/token 流对完成任务回放)。
|
||||||
export function RunsView({ run }: { run: RunState }) {
|
export function RunsView({ run }: { run: RunState }) {
|
||||||
const nodes = deriveNodes(run.exec);
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
|
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
||||||
|
const [replay, setReplay] = useState<RunState>(emptyRun);
|
||||||
|
const [evalRes, setEvalRes] = useState<EvalResult | null>(null);
|
||||||
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
// 拉运行历史(轮询刷新,捕获新完成的运行)。
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => listRuns(40).then((r) => alive && setRuns(r)).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 5000);
|
||||||
|
return () => { alive = false; clearInterval(id); closeRef.current?.(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 选中历史运行:从 Redis 回放轨迹 + token + 取评测。
|
||||||
|
const selectRun = useCallback((taskId: string) => {
|
||||||
|
closeRef.current?.();
|
||||||
|
if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; } // 即当前实时运行
|
||||||
|
setSel(taskId);
|
||||||
|
setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] });
|
||||||
|
setEvalRes(null);
|
||||||
|
const closeExec = streamExec(taskId, (ev) => setReplay((r) => ({ ...r, exec: [...r.exec, ev] })), () => setReplay((r) => ({ ...r, phase: "done" })), () => {});
|
||||||
|
const closeTok = streamTokens(taskId, (tok) => setReplay((r) => ({ ...r, output: r.output + tok })), () => {}, () => {});
|
||||||
|
closeRef.current = () => { closeExec(); closeTok(); };
|
||||||
|
taskEval(taskId).then(setEvalRes).catch(() => {});
|
||||||
|
}, [run.taskId]);
|
||||||
|
|
||||||
|
const liveActive = run.taskId && run.phase !== "idle";
|
||||||
|
const cur = sel ? replay : run;
|
||||||
|
const nodes = deriveNodes(cur.exec);
|
||||||
const tools = nodes.filter((n) => n.kind === "tool");
|
const tools = nodes.filter((n) => n.kind === "tool");
|
||||||
const phaseText =
|
|
||||||
run.phase === "streaming" ? "执行中" : run.phase === "done" ? "完成" : run.phase === "error" ? "出错" : run.phase === "submitting" ? "提交中" : "就绪";
|
|
||||||
const tone = run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : run.phase === "error" ? "danger" : "neutral";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-4 overflow-hidden p-6">
|
<div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden p-6">
|
||||||
<header className="flex items-center gap-3">
|
<header className="flex items-center gap-3">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="text-lg font-semibold text-slate-100">运行 · 观测</h1>
|
<h1 className="text-lg font-semibold text-slate-100">运行 · 观测</h1>
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
<p className="mt-1 text-xs text-slate-500">运行历史复盘:选一次运行,从持久化流回放节点轨迹、模型输出与评测。</p>
|
||||||
实时执行轨迹:每个节点(记忆/工具/提示词/模型,或报告的规划/分章/渲染)逐个点亮,附入参产出与耗时。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-auto flex items-center gap-4 text-xs">
|
|
||||||
<span className="text-slate-500">
|
|
||||||
任务 <span className="font-mono text-slate-300">{run.taskId ?? "—"}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1.5 text-slate-400">
|
|
||||||
<Dot tone={tone} pulse={run.phase === "streaming"} />
|
|
||||||
{phaseText}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500">
|
|
||||||
{nodes.length} 节点 · {tools.length} 次工具调用
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">{nodes.length} 节点 · {tools.length} 次工具调用</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid min-h-0 flex-1 grid-cols-[1.2fr_1fr] gap-4">
|
<div className="grid min-h-0 flex-1 grid-cols-[250px_1.1fr_1fr] gap-3">
|
||||||
|
{/* 运行历史列表 */}
|
||||||
|
<div className="flex min-h-0 flex-col rounded-lg border border-line bg-ink-900">
|
||||||
|
<div className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs font-medium text-slate-400">
|
||||||
|
<History className="h-3.5 w-3.5" /> 运行历史
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto p-1.5">
|
||||||
|
{liveActive && (
|
||||||
|
<button onClick={() => { closeRef.current?.(); setSel(null); setEvalRes(null); }}
|
||||||
|
className={cn("mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === null ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
|
||||||
|
<Dot tone={run.phase === "streaming" ? "running" : run.phase === "done" ? "success" : "neutral"} pulse={run.phase === "streaming"} />
|
||||||
|
<span className="flex-1 truncate text-xs text-slate-200">当前运行</span>
|
||||||
|
<span className="text-[10px] text-brand-400">live</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{runs.map((r) => (
|
||||||
|
<button key={r.task_id} onClick={() => selectRun(r.task_id)}
|
||||||
|
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-2 text-left", sel === r.task_id ? "bg-ink-800 ring-1 ring-brand/40" : "hover:bg-ink-850")}>
|
||||||
|
<Dot tone={STATUS_DOT[r.status] ?? "neutral"} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-mono text-[11px] text-slate-300">{r.task_id}</span>
|
||||||
|
<span className="block text-[10px] text-slate-600">{r.status} · {relTime(r.at)}</span>
|
||||||
|
</span>
|
||||||
|
{r.eval_level && <Badge tone={LEVEL_TONE[r.eval_level] ?? "neutral"}>{r.eval_overall.toFixed(2)}</Badge>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{runs.length === 0 && <div className="py-8 text-center text-xs text-slate-600">暂无运行记录</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 执行轨迹 */}
|
||||||
<Panel title="执行轨迹" icon={Activity}>
|
<Panel title="执行轨迹" icon={Activity}>
|
||||||
<ExecTrace events={run.exec} phase={run.phase} />
|
<ExecTrace events={cur.exec} phase={cur.phase} />
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="模型输出" icon={FileText}>
|
{/* 模型输出 + 评测 */}
|
||||||
{run.output ? (
|
<div className="flex min-h-0 flex-col gap-3">
|
||||||
<Markdown text={run.output} className="text-sm" />
|
<Panel title="模型输出" icon={FileText} className="min-h-0 flex-1">
|
||||||
) : (
|
{cur.output ? (
|
||||||
<EmptyState
|
<Markdown text={cur.output} className="text-sm" />
|
||||||
icon={Activity}
|
) : (
|
||||||
title="尚无运行"
|
<EmptyState icon={Activity} title="选择一次运行" desc="左侧点选历史运行即可回放轨迹与输出;或在编排/报告页发起新运行。" />
|
||||||
desc="在「编排」页搭图并运行,或在「报告」页生成报告,执行轨迹与输出会实时出现在这里。"
|
)}
|
||||||
/>
|
</Panel>
|
||||||
|
{evalRes && (
|
||||||
|
<div className="rounded-lg border border-line bg-ink-850 p-3 text-xs">
|
||||||
|
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium text-slate-300">评测</span>
|
||||||
|
<Badge tone={LEVEL_TONE[evalRes.level] ?? "neutral"}>{evalRes.level} · {evalRes.overall.toFixed(2)}</Badge>
|
||||||
|
{evalRes.corrected && <Badge tone="accent">已纠偏</Badge>}
|
||||||
|
{evalRes.sources > 0 && <span className="text-slate-500">忠实 {evalRes.faithful.toFixed(2)} · {evalRes.sources} 来源</span>}
|
||||||
|
</div>
|
||||||
|
{evalRes.reason && <p className="text-slate-500">{evalRes.reason}</p>}
|
||||||
|
{evalRes.flags?.length > 0 && <p className="mt-1 text-warn">{evalRes.flags.join(";")}</p>}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -421,6 +421,17 @@ func (h *Handler) StatsOverview(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Runs: GET /api/v1/runs?limit= —— 运行历史列表(任务 + 评测分级),供「运行 · 观测」复盘。
|
||||||
|
func (h *Handler) Runs(c *gin.Context) {
|
||||||
|
limit := 30
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"runs": h.db.RecentRuns(c.Request.Context(), limit)})
|
||||||
|
}
|
||||||
|
|
||||||
// ListMemory: GET /api/v1/memory —— 列出当前用户的全部偏好(结构化,供记忆面板)。
|
// ListMemory: GET /api/v1/memory —— 列出当前用户的全部偏好(结构化,供记忆面板)。
|
||||||
func (h *Handler) ListMemory(c *gin.Context) {
|
func (h *Handler) ListMemory(c *gin.Context) {
|
||||||
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_list"),
|
res, err := h.bus.CallTool(c.Request.Context(), contract.ToolSubjectGo("memory_list"),
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||||
p.GET("/billing", h.Billing)
|
p.GET("/billing", h.Billing)
|
||||||
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
|
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
|
||||||
|
p.GET("/runs", h.Runs) // 运行历史(复盘)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
|
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
|
||||||
|
|||||||
@@ -250,6 +250,31 @@ func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunRow 是「运行历史」一行:任务 + 其评测(LEFT JOIN,未评则 level 空)。
|
||||||
|
type RunRow struct {
|
||||||
|
TaskID string `json:"task_id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
EvalLevel string `json:"eval_level"`
|
||||||
|
EvalOverall float64 `json:"eval_overall"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecentRuns 返回最近 n 条运行(含评测分级,供「运行历史」列表)。
|
||||||
|
func (p *Postgres) RecentRuns(ctx context.Context, n int) []RunRow {
|
||||||
|
if p.db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []RunRow
|
||||||
|
p.db.WithContext(ctx).Table("sundynix_task as t").
|
||||||
|
Select("t.task_id, t.status, t.detail, t.created_at as at, " +
|
||||||
|
"coalesce(e.level,'') as eval_level, coalesce(e.overall,0) as eval_overall").
|
||||||
|
Joins("left join sundynix_eval e on e.task_id = t.task_id").
|
||||||
|
Where("t.deleted_at is null").
|
||||||
|
Order("t.created_at desc").Limit(n).Scan(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Close 释放底层连接。
|
// Close 释放底层连接。
|
||||||
func (p *Postgres) Close() {
|
func (p *Postgres) Close() {
|
||||||
if p.db == nil {
|
if p.db == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user