feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -3,7 +3,7 @@ import { LayoutDashboard, Workflow, Database, FileText, Activity, Bookmark, Boxe
|
|||||||
|
|
||||||
import { TopBar } from "./shell/TopBar";
|
import { TopBar } from "./shell/TopBar";
|
||||||
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
import { LeftNav, type ViewKey } from "./shell/LeftNav";
|
||||||
import { BottomDrawer } from "./shell/BottomDrawer";
|
import { ApprovalBar } from "./shell/ApprovalBar";
|
||||||
import { StudioView } from "./studio/StudioView";
|
import { StudioView } from "./studio/StudioView";
|
||||||
import { MemoryView } from "./views/MemoryView";
|
import { MemoryView } from "./views/MemoryView";
|
||||||
import { KbView } from "./views/KbView";
|
import { KbView } from "./views/KbView";
|
||||||
@@ -108,6 +108,7 @@ export default function App() {
|
|||||||
stopPoll();
|
stopPoll();
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] });
|
setRun({ phase: "submitting", output: "", events: [{ t: 0, label: "提交任务" }], exec: [] });
|
||||||
|
setView("runs"); // 发起即跳「运行 · 观测」,实时看轨迹/输出(观测统一收敛在此页)
|
||||||
try {
|
try {
|
||||||
const taskId = await submitTask(dsl, identity);
|
const taskId = await submitTask(dsl, identity);
|
||||||
let first = true;
|
let first = true;
|
||||||
@@ -180,6 +181,7 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<TopBar user={user} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
<TopBar user={user} onLogout={onLogout} onCommand={() => setCmdOpen(true)} />
|
||||||
|
<ApprovalBar run={run} />
|
||||||
<div className="relative flex min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1">
|
||||||
<LeftNav active={view} onSelect={setView} />
|
<LeftNav active={view} onSelect={setView} />
|
||||||
<main className="min-w-0 flex-1 overflow-hidden">
|
<main className="min-w-0 flex-1 overflow-hidden">
|
||||||
@@ -200,7 +202,6 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<BottomDrawer run={run} />
|
|
||||||
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
<CommandPalette open={cmdOpen} onClose={() => setCmdOpen(false)} commands={commands} />
|
||||||
</div>
|
</div>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { 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 { deriveNodes, emptyRun, type RunState } from "../lib/run";
|
||||||
import { listRuns, taskEval, streamExec, streamTokens, type RunSummary, type EvalResult } from "../lib/api";
|
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"> = {
|
const STATUS_DOT: Record<string, "success" | "danger" | "warn" | "running" | "neutral"> = {
|
||||||
done: "success", failed: "danger", timeout: "danger", rejected: "danger",
|
done: "success", failed: "danger", timeout: "danger", rejected: "danger",
|
||||||
@@ -20,16 +23,16 @@ function relTime(iso: string): string {
|
|||||||
return `${Math.floor(d / 86_400_000)} 天前`;
|
return `${Math.floor(d / 86_400_000)} 天前`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 运行 · 观测:左侧运行历史,选中后从 Redis 回放该次执行轨迹 + 输出 + 评测(复盘)。
|
// 运行 · 观测:左侧运行历史,右侧 tab 切换查看选中运行的 轨迹/输出/工具调用/评测(复盘)。
|
||||||
// 「当前运行」固定在顶部,沿用实时订阅;历史项点击即复现(exec/token 流对完成任务回放)。
|
// 选中历史运行后从 Redis 回放(exec/token 流对完成任务即回放);「当前运行」置顶沿用实时订阅。
|
||||||
export function RunsView({ run }: { run: RunState }) {
|
export function RunsView({ run }: { run: RunState }) {
|
||||||
const [runs, setRuns] = useState<RunSummary[]>([]);
|
const [runs, setRuns] = useState<RunSummary[]>([]);
|
||||||
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
const [sel, setSel] = useState<string | null>(null); // null = 当前实时运行
|
||||||
const [replay, setReplay] = useState<RunState>(emptyRun);
|
const [replay, setReplay] = useState<RunState>(emptyRun);
|
||||||
const [evalRes, setEvalRes] = useState<EvalResult | null>(null);
|
const [evalRes, setEvalRes] = useState<EvalResult | null>(null);
|
||||||
|
const [tab, setTab] = useState<DetailTab>("trace");
|
||||||
const closeRef = useRef<(() => void) | null>(null);
|
const closeRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// 拉运行历史(轮询刷新,捕获新完成的运行)。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => listRuns(40).then((r) => alive && setRuns(r)).catch(() => {});
|
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?.(); };
|
return () => { alive = false; clearInterval(id); closeRef.current?.(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 选中历史运行:从 Redis 回放轨迹 + token + 取评测。
|
|
||||||
const selectRun = useCallback((taskId: string) => {
|
const selectRun = useCallback((taskId: string) => {
|
||||||
closeRef.current?.();
|
closeRef.current?.();
|
||||||
if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; } // 即当前实时运行
|
if (taskId === run.taskId) { setSel(null); setEvalRes(null); return; }
|
||||||
setSel(taskId);
|
setSel(taskId);
|
||||||
setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] });
|
setReplay({ phase: "streaming", taskId, output: "", events: [], exec: [] });
|
||||||
setEvalRes(null);
|
setEvalRes(null);
|
||||||
@@ -51,22 +53,39 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
taskEval(taskId).then(setEvalRes).catch(() => {});
|
taskEval(taskId).then(setEvalRes).catch(() => {});
|
||||||
}, [run.taskId]);
|
}, [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 liveActive = run.taskId && run.phase !== "idle";
|
||||||
const cur = sel ? replay : run;
|
const cur = sel ? replay : run;
|
||||||
const nodes = deriveNodes(cur.exec);
|
const nodes = deriveNodes(cur.exec);
|
||||||
const tools = nodes.filter((n) => n.kind === "tool");
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3 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">
|
<header className="flex items-center gap-3">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-lg font-semibold text-slate-100">运行 · 观测</h1>
|
<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>
|
</div>
|
||||||
<span className="text-xs text-slate-500">{nodes.length} 节点 · {tools.length} 次工具调用</span>
|
<span className="text-xs text-slate-500">{nodes.length} 节点 · {tools.length} 次工具调用</span>
|
||||||
</header>
|
</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 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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 执行轨迹 */}
|
{/* 详情:tab 切换 轨迹/输出/工具/评测 */}
|
||||||
<Panel title="执行轨迹" icon={Activity}>
|
<div className="flex min-h-0 flex-col rounded-lg border border-line bg-ink-900">
|
||||||
<ExecTrace events={cur.exec} phase={cur.phase} />
|
<div className="border-b border-line px-2">
|
||||||
</Panel>
|
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
||||||
|
</div>
|
||||||
{/* 模型输出 + 评测 */}
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
<div className="flex min-h-0 flex-col gap-3">
|
{empty ? (
|
||||||
<Panel title="模型输出" icon={FileText} className="min-h-0 flex-1">
|
|
||||||
{cur.output ? (
|
|
||||||
<Markdown text={cur.output} className="text-sm" />
|
|
||||||
) : (
|
|
||||||
<EmptyState icon={Activity} title="选择一次运行" desc="左侧点选历史运行即可回放轨迹与输出;或在编排/报告页发起新运行。" />
|
<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>
|
</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>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user