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:
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck, Check, X } from "lucide-react";
|
||||
import { pendingApproval, type RunState } from "../lib/run";
|
||||
import { approveTask } from "../lib/api";
|
||||
import { Badge } from "../ui";
|
||||
|
||||
// ApprovalBar:HITL 人工审批中断条 —— 全局常驻(任务停在审批节点时,不管在哪个页面都要能看见+操作)。
|
||||
// 触发以「后端状态 waiting」为准(落 PG,可靠);exec 的 await 事件仅用于丰富摘要(可能抢跑丢失)。
|
||||
// 决定经 approveTask 发回;dispatcher 续跑后状态轮询/SSE 更新,本条随 waiting 解除自动消失。
|
||||
export function ApprovalBar({ run }: { run: RunState }) {
|
||||
const approval =
|
||||
run.taskId && run.lifecycle === "waiting"
|
||||
? pendingApproval(run.exec) ?? { node: "", title: run.detail || "人工审批", summary: "" }
|
||||
: null;
|
||||
if (!approval || !run.taskId) return null;
|
||||
return <Bar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />;
|
||||
}
|
||||
|
||||
function Bar({ taskId, node, title, summary }: { taskId: string; node: string; title: string; summary: string }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const decide = async (approved: boolean) => {
|
||||
setBusy(approved ? "approve" : "reject");
|
||||
setErr("");
|
||||
try {
|
||||
await approveTask(taskId, approved, { node, note });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2">
|
||||
<div className="flex items-center gap-2 text-[12px] text-amber-200">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-400" strokeWidth={2.2} />
|
||||
<span className="font-semibold">人工审批</span>
|
||||
<span className="text-amber-300/80">{title}</span>
|
||||
<Badge tone="warn">等待决定</Badge>
|
||||
</div>
|
||||
{summary && <pre className="max-h-20 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-amber-100/80">{summary}</pre>}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选,拒绝原因等)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-ink-950/60 px-2 py-1 text-[11px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<button onClick={() => decide(true)} disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-success/20 px-2.5 py-1 text-[11px] font-medium text-success hover:bg-success/30 disabled:opacity-50">
|
||||
<Check className="h-3.5 w-3.5" /> {busy === "approve" ? "提交中…" : "批准"}
|
||||
</button>
|
||||
<button onClick={() => decide(false)} disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-danger/20 px-2.5 py-1 text-[11px] font-medium text-danger hover:bg-danger/30 disabled:opacity-50">
|
||||
<X className="h-3.5 w-3.5" /> {busy === "reject" ? "提交中…" : "拒绝"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="text-[11px] text-danger">提交失败:{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react";
|
||||
import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
|
||||
import { ExecTrace } from "../components/ExecTrace";
|
||||
import { ChartView } from "../components/ChartView";
|
||||
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
||||
import { approveTask } from "../lib/api";
|
||||
import { Tabs, Badge, cn, type TabDef } from "../ui";
|
||||
|
||||
type Tab = "output" | "trace" | "tools" | "cite" | "eval";
|
||||
|
||||
// 底部抽屉:运行输出 / 轨迹 / 工具调用 / 引用 / 评测(深色,全局常驻)。
|
||||
export function BottomDrawer({ run }: { run: RunState }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("output");
|
||||
|
||||
const nodes = deriveNodes(run.exec);
|
||||
const toolCount = nodes.filter((n) => n.kind === "tool").length;
|
||||
const tabs: TabDef<Tab>[] = [
|
||||
{ key: "output", label: "输出" },
|
||||
{ key: "trace", label: "轨迹", count: nodes.length },
|
||||
{ key: "tools", label: "工具调用", count: toolCount },
|
||||
{ key: "cite", label: "引用" },
|
||||
{ key: "eval", label: "评测" },
|
||||
];
|
||||
|
||||
const statusCls =
|
||||
run.phase === "streaming" ? "text-accent-400" : run.phase === "done" ? "text-success" : run.phase === "error" ? "text-danger" : "text-slate-500";
|
||||
const statusText =
|
||||
run.phase === "streaming" ? "流式中…" : run.phase === "done" ? "完成 ✓" : run.phase === "error" ? `✗ ${run.error ?? "出错"}` : run.phase === "submitting" ? "提交中…" : "就绪";
|
||||
|
||||
// 审批条触发以「后端状态 waiting」为准(可靠,落 PG);exec 的 await 事件仅用于丰富摘要(可能抢跑丢失)。
|
||||
const approval =
|
||||
run.taskId && run.lifecycle === "waiting"
|
||||
? pendingApproval(run.exec) ?? { node: "", title: run.detail || "人工审批", summary: "" }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-line bg-ink-900">
|
||||
{approval && run.taskId && <ApprovalBar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />}
|
||||
<div className="flex items-center border-b border-line px-2">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
value={tab}
|
||||
onChange={(t) => {
|
||||
setTab(t);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
<span className={cn("ml-2 text-[11px]", statusCls)}>{statusText}</span>
|
||||
<button onClick={() => setOpen((o) => !o)} className="ml-auto flex items-center gap-1 px-2 py-2 text-xs text-slate-500 hover:text-slate-300">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
|
||||
{open ? "收起" : "展开"}
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="h-44 overflow-auto p-3 text-xs">
|
||||
{tab === "output" && <OutputView output={run.output} />}
|
||||
{tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />}
|
||||
{tab === "tools" && <ToolCalls run={run} />}
|
||||
{tab === "cite" && <p className="text-slate-600">引用列表:RAG 答案的来源块(源文档 + 分数 + 来源徽标)。</p>}
|
||||
{tab === "eval" && <p className="text-slate-600">评测:忠实度 / 完整度质量门结果(需 harness eval)。</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ApprovalBar:HITL 人工审批中断条。任务停在审批节点时常驻顶部,展示待审摘要 + 批准/拒绝。
|
||||
// 决定经 approveTask 发回;dispatcher 续跑后 SSE 会推来 end/error 事件,本条随 pendingApproval 归 null 自动消失。
|
||||
function ApprovalBar({ taskId, node, title, summary }: { taskId: string; node: string; title: string; summary: string }) {
|
||||
const [note, setNote] = useState("");
|
||||
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
const decide = async (approved: boolean) => {
|
||||
setBusy(approved ? "approve" : "reject");
|
||||
setErr("");
|
||||
try {
|
||||
await approveTask(taskId, approved, { node, note });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-[12px] text-amber-200">
|
||||
<ShieldCheck className="h-4 w-4 text-amber-400" strokeWidth={2.2} />
|
||||
<span className="font-semibold">人工审批</span>
|
||||
<span className="text-amber-300/80">{title}</span>
|
||||
<Badge tone="warn">等待决定</Badge>
|
||||
</div>
|
||||
{summary && <pre className="max-h-20 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-amber-100/80">{summary}</pre>}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="备注(可选,拒绝原因等)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-ink-950/60 px-2 py-1 text-[11px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => decide(true)}
|
||||
disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-success/20 px-2.5 py-1 text-[11px] font-medium text-success hover:bg-success/30 disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" /> {busy === "approve" ? "提交中…" : "批准"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => decide(false)}
|
||||
disabled={busy !== null}
|
||||
className="flex items-center gap-1 rounded bg-danger/20 px-2.5 py-1 text-[11px] font-medium text-danger hover:bg-danger/30 disabled:opacity-50"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> {busy === "reject" ? "提交中…" : "拒绝"}
|
||||
</button>
|
||||
</div>
|
||||
{err && <p className="text-[11px] text-danger">提交失败:{err}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// OutputView:渲染模型输出。含 ```chart 块时分段渲染(文本 + SVG 图表),否则纯文本。
|
||||
function OutputView({ output }: { output: string }) {
|
||||
if (!output) {
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
|
||||
在编排页搭图 → 运行,模型注入画像与历史后流式作答,token 在此呈现。
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
if (!hasChart(output)) {
|
||||
return <pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">{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 leading-relaxed text-emerald-300">
|
||||
{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-slate-600">本次运行暂无工具调用。图里挂了检索/工具节点,或报告挂了知识库时,每次 sundynix.tools.* 调用会在此列出入参与产出。</p>;
|
||||
}
|
||||
return (
|
||||
<ul className="space-y-1.5">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user