feat(desktop): 运行·观测 重做为运行历史 + 复盘(Tier2,用上 exec Redis 回放)

RunsView 原本只能看「最近一次」实时运行;现做成完整的运行历史复盘:

后端 GET /api/v1/runs?limit=(store.RecentRuns):任务 LEFT JOIN 评测,返回
task_id/status/time + eval level/overall,供历史列表。

前端 RunsView:左侧运行历史列表(状态点 + 相对时间 + 评测分级徽标),点选任一历史运行
→ 经 streamExec/streamTokens 从 Redis 回放该次执行轨迹 + 模型输出(对已完成任务流即回放),
并取 /tasks/:id/eval 显示评测(分级/忠实度/纠偏/评语/flags)。「当前运行」固定置顶沿用实时订阅。
api 补 listRuns + taskEval。

这正是之前 exec 轨迹 Redis 回放的消费场景。live(vite + 预览):提一新任务 → 历史列表出现 →
点选 → 完整回放轨迹(2 节点/2518ms/含推理过程)+ 答案全文 + 评测 ok·1.00,无控制台报错。
tsc+vite 构建通过,gateway 全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 16:34:07 +08:00
parent 1344bf98a0
commit 3cf3c0b070
5 changed files with 182 additions and 38 deletions
+36
View File
@@ -483,3 +483,39 @@ export async function statsOverview(): Promise<Overview> {
if (!res.ok) throw new Error(`stats failed: ${res.status}`);
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>;
}
+109 -38
View File
@@ -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 { Markdown } from "../components/Markdown";
import { deriveNodes, type RunState } from "../lib/run";
import { Panel, Dot, EmptyState } from "../ui";
import { deriveNodes, emptyRun, type RunState } from "../lib/run";
import { listRuns, taskEval, streamExec, streamTokens, type RunSummary, type EvalResult } from "../lib/api";
import { Panel, Dot, Badge, EmptyState, cn } from "../ui";
// 运行·观测:把最近一次运行的执行轨迹实时可视化(节点逐个点亮 + 工具入参/产出 + 耗时),
// 右侧并列模型输出。数据来自 Studio 运行时订阅的 sundynix.exec.<id> 事件流。
const STATUS_DOT: Record<string, "success" | "danger" | "warn" | "running" | "neutral"> = {
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 }) {
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 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 (
<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">
<div>
<div className="flex-1">
<h1 className="text-lg font-semibold text-slate-100"> · </h1>
<p className="mt-1 text-xs text-slate-500">
/////
</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>
<p className="mt-1 text-xs text-slate-500"></p>
</div>
<span className="text-xs text-slate-500">{nodes.length} · {tools.length} </span>
</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}>
<ExecTrace events={run.exec} phase={run.phase} />
<ExecTrace events={cur.exec} phase={cur.phase} />
</Panel>
<Panel title="模型输出" icon={FileText}>
{run.output ? (
<Markdown text={run.output} className="text-sm" />
) : (
<EmptyState
icon={Activity}
title="尚无运行"
desc="在「编排」页搭图并运行,或在「报告」页生成报告,执行轨迹与输出会实时出现在这里。"
/>
{/* 模型输出 + 评测 */}
<div className="flex min-h-0 flex-col gap-3">
<Panel title="模型输出" icon={FileText} className="min-h-0 flex-1">
{cur.output ? (
<Markdown text={cur.output} className="text-sm" />
) : (
<EmptyState icon={Activity} title="选择一次运行" 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>
);
@@ -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 —— 列出当前用户的全部偏好(结构化,供记忆面板)。
func (h *Handler) ListMemory(c *gin.Context) {
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.GET("/billing", h.Billing)
p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合
p.GET("/runs", h.Runs) // 运行历史(复盘)
}
// 运维控制面:LLM 模型配置(含 API 密钥管理)—— 必须管理员(RequireAdmin)。
+25
View File
@@ -250,6 +250,31 @@ func (p *Postgres) RecentTasks(ctx context.Context, n int) []Task {
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 释放底层连接。
func (p *Postgres) Close() {
if p.db == nil {