feat(observability): 执行可视化 — 节点级实时轨迹(运行·观测)

把任务执行做成可观测:Dispatcher 在每个节点/阶段发结构化 ExecEvent,
经独立 NATS 通道回流,前端逐节点点亮(状态/耗时/工具入参产出)。

- shared: contract.ExecEvent + ExecSubject(sundynix.exec.<id>,与 Token 流分流);
  bus.PublishExec/CompleteExec/SubscribeExec(core NATS,复用结束头)
- dispatcher: execTracer(自增 Seq 保序 + span 自动计耗时);
  Orchestrator 加 ExecSink;通用图(init 召回 / 各 tool 入参→产出 / prompt / model
  首token+token数)与报告编排(规划大纲 / 各章并行 start-end / 渲染)全程埋点
- gateway: SubscribeExec + GET /tasks/:id/exec SSE(与 token 流并行)
- desktop: streamExec + deriveNodes(按 node 归并 start/end/error/info);
  复用组件 ExecTrace(竖向轨道,按 kind 着色,运行中脉冲灯);
  新 RunsView(运行·观测:轨迹+输出双栏);BottomDrawer 轨迹/工具调用 tab 接真实数据;
  ReportView 加执行轨迹栏;左导航「运行」置就绪

实测:
- 报告任务 /exec:规划(2680ms,4章) → 4 章并行(seq 交错,各~7-8s 重叠=真并行,
  每章带 docs 知识库检索预览+成稿字数) → 渲染(docx 落盘)
- 通用图 /exec:tool:kb_search(678ms,入参→Milvus 产出) → prompt(2消息) →
  model(首token 860ms / 4 tokens)
- 浏览器(Preview):报告页执行轨迹逐节点点亮、章节带耗时/字数/检索片段,完成后下载 Word

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-12 14:29:28 +08:00
parent ba8c6b3c43
commit cdc5b3a847
19 changed files with 587 additions and 63 deletions
@@ -0,0 +1,92 @@
import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run";
import type { ExecEvent } from "../lib/api";
// 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。
const KIND: Record<string, { icon: string; cls: string; name: string }> = {
system: { icon: "▸", cls: "text-slate-400", name: "系统" },
memory: { icon: "◇", cls: "text-violet-300", name: "记忆召回" },
tool: { icon: "⚙", cls: "text-amber-300", name: "工具调用" },
prompt: { icon: "▤", cls: "text-sky-300", name: "提示词" },
model: { icon: "✦", cls: "text-emerald-300", name: "模型推理" },
plan: { icon: "◷", cls: "text-cyan-300", name: "规划" },
section: { icon: "¶", cls: "text-indigo-300", name: "章节" },
render: { icon: "▦", cls: "text-rose-300", name: "渲染" },
};
function meta(kind: string) {
return KIND[kind] ?? { icon: "•", cls: "text-slate-400", name: kind };
}
function StatusDot({ status }: { status: NodeTrace["status"] }) {
if (status === "running")
return <span className="h-2.5 w-2.5 animate-pulse rounded-full bg-cyan-400 shadow-[0_0_8px_rgba(34,211,238,0.8)]" />;
if (status === "done") return <span className="h-2.5 w-2.5 rounded-full bg-emerald-400" />;
if (status === "error") return <span className="h-2.5 w-2.5 rounded-full bg-rose-500" />;
return <span className="h-2.5 w-2.5 rounded-full bg-slate-600" />;
}
// ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。
export function ExecTrace({
events,
phase,
compact,
}: {
events: ExecEvent[];
phase?: RunPhase;
compact?: boolean;
}) {
const nodes = deriveNodes(events);
if (nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center px-4 py-8 text-center text-xs text-slate-600">
/
<br />
Word
</div>
);
}
const total = nodes.reduce((s, n) => s + (n.ms ?? 0), 0);
return (
<div className={compact ? "" : "p-1"}>
{!compact && (
<div className="mb-2 flex items-center gap-3 px-1 text-[11px] text-slate-500">
<span>{nodes.length} </span>
<span>·</span>
<span> {total} ms</span>
{phase === "streaming" && <span className="text-cyan-400"> </span>}
{phase === "done" && <span className="text-emerald-400"> </span>}
{phase === "error" && <span className="text-rose-400"> </span>}
</div>
)}
<ol className="relative ml-2 space-y-2 border-l border-line pl-4">
{nodes.map((n) => {
const m = meta(n.kind);
return (
<li key={n.node} className="relative">
<span className="absolute -left-[22px] top-1.5 flex h-4 w-4 items-center justify-center rounded-full bg-ink-900">
<StatusDot status={n.status} />
</span>
<div className="rounded-lg border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-sm leading-none ${m.cls}`}>{m.icon}</span>
<span className="text-xs font-medium text-slate-200">{n.label}</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] ${m.cls} bg-white/5`}>{m.name}</span>
{n.ms != null && n.ms > 0 && (
<span className="ml-auto font-mono text-[10px] text-slate-500">{n.ms} ms</span>
)}
{n.status === "running" && <span className="ml-auto text-[10px] text-cyan-400"></span>}
</div>
{n.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{n.detail}</p>}
{n.notes.map((note, i) => (
<p key={i} className="mt-1 break-words border-l border-line pl-2 text-[11px] text-slate-500">
{note}
</p>
))}
</div>
</li>
);
})}
</ol>
</div>
);
}