158fe094ab
现象:已完成的审批任务,复盘轨迹停在「已中断等待审批」、审批节点永远转圈,看着 像卡住(实际任务已 done、有输出)。 根因:任务中断时 Handle 的 defer tr.done() 给 exec 流发了 CompleteExec → 网关 exec 录制器关闭。resume 是另一次独立调用,其 exec 事件(审批通过/拒绝、续跑节点)发到 同一主题时录制器已关 → 没录进去。(token 流中断时特意没关,所以输出录到了;exec 流却被关了,不对称。) - exec.go: execTracer 加 suspended 标志,done() 挂起时不关流。 - orchestrator.go: 中断分支置 tr.suspended=true(与 token 流一致)。 - ExecTrace.tsx: 前端兜底——运行已完成(phase done)时把悬挂的 waiting/running 节点 收敛为 done,修旧任务(已 done、轨迹缺 resume 事件)的转圈假象。 live 验证:审批任务批准后复盘轨迹完整呈现 approval await→end「批准:放行」→ agent start→推理→end,审批节点不再转圈。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
105 lines
4.9 KiB
TypeScript
105 lines
4.9 KiB
TypeScript
import {
|
||
Cpu,
|
||
Bookmark,
|
||
Wrench,
|
||
MessageSquareText,
|
||
Sparkles,
|
||
ListTree,
|
||
FileText,
|
||
FileOutput,
|
||
CheckCircle2,
|
||
XCircle,
|
||
Loader2,
|
||
Circle,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import { deriveNodes, type NodeTrace, type RunPhase } from "../lib/run";
|
||
import type { ExecEvent } from "../lib/api";
|
||
import { cn } from "../ui";
|
||
|
||
// 各节点类别的图标与配色(与后端 ExecEvent.kind 对应)。
|
||
const KIND: Record<string, { icon: LucideIcon; cls: string; name: string }> = {
|
||
system: { icon: Cpu, cls: "text-slate-400", name: "系统" },
|
||
memory: { icon: Bookmark, cls: "text-brand-400", name: "记忆召回" },
|
||
tool: { icon: Wrench, cls: "text-warn", name: "工具调用" },
|
||
prompt: { icon: MessageSquareText, cls: "text-sky-300", name: "提示词" },
|
||
model: { icon: Sparkles, cls: "text-success", name: "模型推理" },
|
||
plan: { icon: ListTree, cls: "text-accent-400", name: "规划" },
|
||
section: { icon: FileText, cls: "text-indigo-300", name: "章节" },
|
||
render: { icon: FileOutput, cls: "text-danger", name: "渲染" },
|
||
};
|
||
|
||
function meta(kind: string) {
|
||
return KIND[kind] ?? { icon: Circle, cls: "text-slate-400", name: kind };
|
||
}
|
||
|
||
function StatusDot({ status }: { status: NodeTrace["status"] }) {
|
||
if (status === "running") return <Loader2 className="h-4 w-4 animate-spin text-accent-400" strokeWidth={2.4} />;
|
||
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-success" strokeWidth={2.2} />;
|
||
if (status === "error") return <XCircle className="h-4 w-4 text-danger" strokeWidth={2.2} />;
|
||
if (status === "waiting") return <Loader2 className="h-4 w-4 animate-spin text-amber-400" strokeWidth={2.4} />; // 待审批
|
||
return <Circle className="h-4 w-4 text-slate-600" strokeWidth={2} />;
|
||
}
|
||
|
||
// ExecTrace 把执行事件流渲染为竖向轨道:每个节点一颗灯,实时点亮 + 耗时 + 入参/产出。
|
||
export function ExecTrace({ events, phase, compact }: { events: ExecEvent[]; phase?: RunPhase; compact?: boolean }) {
|
||
const raw = deriveNodes(events);
|
||
// 运行整体已完成,但轨迹可能缺了中断后(resume)续录的事件(如审批通过、续跑节点),
|
||
// 导致审批节点停在 waiting 转圈。既然已完成,把悬挂的 waiting/running 收敛为 done,不再假装进行中。
|
||
const nodes =
|
||
phase === "done"
|
||
? raw.map((n) => (n.status === "waiting" || n.status === "running" ? { ...n, status: "done" as const } : n))
|
||
: raw;
|
||
if (nodes.length === 0) {
|
||
return (
|
||
<div className="flex h-full flex-col items-center justify-center gap-1 px-4 py-8 text-center text-xs text-slate-600">
|
||
<span>运行后逐节点点亮执行轨迹</span>
|
||
<span className="text-slate-700">记忆召回 → 工具调用 → 提示词 → 模型推理 / 报告:规划 → 各章并行 → 渲染</span>
|
||
</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-accent-400">● 执行中</span>}
|
||
{phase === "done" && <span className="text-success">✓ 完成</span>}
|
||
{phase === "error" && <span className="text-danger">✗ 出错</span>}
|
||
</div>
|
||
)}
|
||
<ol className="relative ml-2 space-y-2 border-l border-line pl-4">
|
||
{nodes.map((n) => {
|
||
const m = meta(n.kind);
|
||
const Icon = m.icon;
|
||
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-md border border-line bg-ink-950/60 px-3 py-2">
|
||
<div className="flex items-center gap-2">
|
||
<Icon className={cn("h-3.5 w-3.5 shrink-0", m.cls)} strokeWidth={2} />
|
||
<span className="text-xs font-medium text-slate-200">{n.label}</span>
|
||
<span className={cn("rounded bg-ink-800 px-1.5 py-0.5 text-[9px]", m.cls)}>{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-accent-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>
|
||
);
|
||
}
|