refactor(desktop): 观测收敛进「运行」页(tab 切换),删全局底部抽屉

底部抽屉与新版运行页重复,且默认展开常驻占 ~176px、引用/评测还是空壳。按高内聚收敛:

- 运行·观测 详情区改 tab 切换:执行轨迹 / 模型输出 / 工具调用 / 评测(去掉没接的「引用」空标签);
  OutputView(含 chart 渲染) + ToolCalls 从抽屉并入运行页;评测标签接 taskEval 全量展示。
- 删除全局 BottomDrawer,释放底部空间。
- HITL 审批条抽成独立 shell/ApprovalBar,全局常驻于 TopBar 下(审批中断必须随处可见可操作)。
- 发起运行自动跳「运行」页 + 新运行自动切回「当前运行」,实时观测不丢。

tsc+vite 构建通过;wails HMR 热加载生效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-26 16:56:52 +08:00
parent 3cf3c0b070
commit 7cc7f5fd26
4 changed files with 176 additions and 211 deletions
+110 -34
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, FileText, History } from "lucide-react";
import { Activity, History, Wrench } from "lucide-react";
import { ExecTrace } from "../components/ExecTrace";
import { Markdown } from "../components/Markdown";
import { ChartView } from "../components/ChartView";
import { extractChartBlocks, hasChart } from "../lib/chartspec";
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";
import { Tabs, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
type DetailTab = "trace" | "output" | "tools" | "eval";
const STATUS_DOT: Record<string, "success" | "danger" | "warn" | "running" | "neutral"> = {
done: "success", failed: "danger", timeout: "danger", rejected: "danger",
@@ -20,16 +23,16 @@ function relTime(iso: string): string {
return `${Math.floor(d / 86_400_000)} 天前`;
}
// 运行 · 观测:左侧运行历史,选中后从 Redis 回放该次执行轨迹 + 输出 + 评测(复盘)。
// 「当前运行」固定在顶部,沿用实时订阅;历史项点击即复现exec/token 流对完成任务回放)。
// 运行 · 观测:左侧运行历史,右侧 tab 切换查看选中运行的 轨迹/输出/工具调用/评测(复盘)。
// 选中历史运行后从 Redis 回放exec/token 流对完成任务回放);「当前运行」置顶沿用实时订阅
export function RunsView({ run }: { run: RunState }) {
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 [tab, setTab] = useState<DetailTab>("trace");
const closeRef = useRef<(() => void) | null>(null);
// 拉运行历史(轮询刷新,捕获新完成的运行)。
useEffect(() => {
let alive = true;
const load = () => listRuns(40).then((r) => alive && setRuns(r)).catch(() => {});
@@ -38,10 +41,9 @@ export function RunsView({ run }: { run: RunState }) {
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; } // 即当前实时运行
if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; }
setSel(taskId);
setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] });
setEvalRes(null);
@@ -51,22 +53,39 @@ export function RunsView({ run }: { run: RunState }) {
taskEval(taskId).then(setEvalRes).catch(() => {});
}, [run.taskId]);
// 新的实时运行开始 → 自动切回「当前运行」(若此前选着历史项),并回到轨迹标签。
useEffect(() => {
if (run.taskId) { closeRef.current?.(); setSel(null); setEvalRes(null); setTab("trace"); }
}, [run.taskId]);
// 当前实时运行的评测:完成后拉一次。
useEffect(() => {
if (!sel && run.taskId && run.phase === "done") taskEval(run.taskId).then(setEvalRes).catch(() => {});
}, [sel, run.taskId, run.phase]);
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 tabs: TabDef<DetailTab>[] = [
{ key: "trace", label: "执行轨迹", count: nodes.length },
{ key: "output", label: "模型输出" },
{ key: "tools", label: "工具调用", count: tools.length },
{ key: "eval", label: "评测" },
];
const empty = !cur.taskId && cur.exec.length === 0 && !cur.output;
return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-hidden p-6">
<header className="flex items-center gap-3">
<div className="flex-1">
<h1 className="text-lg font-semibold text-slate-100"> · </h1>
<p className="mt-1 text-xs text-slate-500"></p>
<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-[250px_1.1fr_1fr] gap-3">
<div className="grid min-h-0 flex-1 grid-cols-[250px_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">
@@ -96,34 +115,91 @@ export function RunsView({ run }: { run: RunState }) {
</div>
</div>
{/* 执行轨迹 */}
<Panel title="执行轨迹" icon={Activity}>
<ExecTrace events={cur.exec} phase={cur.phase} />
</Panel>
{/* 模型输出 + 评测 */}
<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" />
) : (
{/* 详情:tab 切换 轨迹/输出/工具/评测 */}
<div className="flex min-h-0 flex-col rounded-lg border border-line bg-ink-900">
<div className="border-b border-line px-2">
<Tabs tabs={tabs} value={tab} onChange={setTab} />
</div>
<div className="min-h-0 flex-1 overflow-auto p-4">
{empty ? (
<EmptyState icon={Activity} title="选择一次运行" desc="左侧点选历史运行即可回放轨迹与输出;或在编排/报告页发起新运行。" />
) : tab === "trace" ? (
<ExecTrace events={cur.exec} phase={cur.phase} />
) : tab === "output" ? (
<OutputView output={cur.output} />
) : tab === "tools" ? (
<ToolCalls run={cur} />
) : (
<EvalView ev={evalRes} />
)}
</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>
)}
</div>
</div>
</div>
</div>
);
}
// OutputView:渲染模型输出。含 ```chart 块时分段渲染(文本 + SVG 图表),否则纯文本。
function OutputView({ output }: { output: string }) {
if (!output) {
return <p className="text-xs text-slate-600"> token</p>;
}
if (!hasChart(output)) {
return <pre className="whitespace-pre-wrap font-mono text-[13px] leading-relaxed text-slate-200">{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 text-[13px] leading-relaxed text-slate-200">{seg.text}</pre>
)
),
)}
</div>
);
}
// ToolCalls:从执行事件筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
function ToolCalls({ run }: { run: RunState }) {
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
if (tools.length === 0) {
return <p className="text-xs text-slate-600">/</p>;
}
return (
<ul className="space-y-1.5 text-xs">
{tools.map((t) => (
<li key={t.node} className="rounded-md border border-line bg-ink-950/60 px-3 py-2">
<div className="flex items-center gap-2">
<Wrench className="h-3.5 w-3.5 text-warn" strokeWidth={2} />
<span className="font-mono text-[11px] text-slate-200">{t.node.replace(/^tool:/, "")}</span>
<Badge tone={t.status === "error" ? "danger" : t.status === "running" ? "accent" : "success"}>
{t.status === "error" ? "失败" : t.status === "running" ? "调用中" : "成功"}
</Badge>
{t.ms != null && t.ms > 0 && <span className="ml-auto font-mono text-[10px] text-slate-500">{t.ms} ms</span>}
</div>
{t.detail && <p className="mt-1 break-words text-[11px] leading-relaxed text-slate-400">{t.detail}</p>}
</li>
))}
</ul>
);
}
// EvalView:本次运行的自动化评测(分级/综合/忠实度/纠偏/评语/flags)。
function EvalView({ ev }: { ev: EvalResult | null }) {
if (!ev) return <p className="text-xs text-slate-600"></p>;
return (
<div className="space-y-2 text-xs">
<div className="flex flex-wrap items-center gap-2">
<Badge tone={LEVEL_TONE[ev.level] ?? "neutral"}>{ev.level} · {ev.overall.toFixed(2)}</Badge>
{ev.corrected && <Badge tone="accent"></Badge>}
<span className="text-slate-500"> {ev.rule.toFixed(2)} · {ev.llm.toFixed(2)}</span>
{ev.sources > 0 && <span className="text-slate-500"> {ev.faithful.toFixed(2)} · {ev.sources} </span>}
</div>
{ev.reason && <p className="leading-relaxed text-slate-400">{ev.reason}</p>}
{ev.flags?.length > 0 && <p className="text-warn">{ev.flags.join("")}</p>}
</div>
);
}