feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen #1
@@ -0,0 +1,172 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Users, Wrench, Network } from "lucide-react";
|
||||||
|
import { deriveTeam, type RunPhase, type TeamSeat } from "../lib/run";
|
||||||
|
import type { ExecEvent } from "../lib/api";
|
||||||
|
import { cn } from "../ui";
|
||||||
|
|
||||||
|
// 团队视图:把多智能体协调画成「协调者居中 + 专家工位环绕」。
|
||||||
|
// 只有活跃的东西才动(动效=状态编码,不是装饰),并带 prefers-reduced-motion 守卫。
|
||||||
|
// 数据全部来自现有执行事件流(agent:/tool:/coordinator: 节点),零后端改动。
|
||||||
|
|
||||||
|
const W = 680;
|
||||||
|
const H = 340;
|
||||||
|
const CX = 340;
|
||||||
|
const CY = 168;
|
||||||
|
const HUB_W = 210;
|
||||||
|
const HUB_H = 120;
|
||||||
|
const RX = 250; // 工位环绕椭圆半径
|
||||||
|
const RY = 115;
|
||||||
|
const SEAT_W = 152;
|
||||||
|
const SEAT_H = 64;
|
||||||
|
const RADIAL_MAX = 6; // 超过则降级为列表(环绕会挤)
|
||||||
|
|
||||||
|
function seatPos(i: number, n: number) {
|
||||||
|
const deg = 180 + (i * 360) / n; // 从左开始顺时针均分
|
||||||
|
const rad = (deg * Math.PI) / 180;
|
||||||
|
return { x: CX + RX * Math.cos(rad), y: CY + RY * Math.sin(rad) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s: string | undefined, n: number): string {
|
||||||
|
if (!s) return "";
|
||||||
|
return s.length > n ? s.slice(0, n) + "…" : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMs(ms?: number): string {
|
||||||
|
if (ms == null) return "";
|
||||||
|
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工位状态 → 语义色(Tailwind fill/stroke 工具类,跟随主题)。
|
||||||
|
function seatTone(s: TeamSeat) {
|
||||||
|
if (s.status === "error") return { stroke: "stroke-danger/60", dot: "fill-danger", text: "text-danger", label: "失败" };
|
||||||
|
if (s.status === "running") return { stroke: "stroke-brand/60", dot: "fill-brand", text: "text-brand-400", label: "工作中" };
|
||||||
|
return { stroke: "stroke-line", dot: "fill-success", text: "text-success", label: "已回报" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeatCard({ seat }: { seat: TeamSeat }) {
|
||||||
|
const t = seatTone(seat);
|
||||||
|
const running = seat.status === "running";
|
||||||
|
const Icon = seat.kind === "agent" ? Users : Wrench;
|
||||||
|
const desc = seat.brief ? `简报:${truncate(seat.brief, 22)}` : truncate(seat.output, 26);
|
||||||
|
return (
|
||||||
|
<foreignObject x={0} y={0} width={SEAT_W} height={SEAT_H}>
|
||||||
|
<div className={cn("h-full rounded-xl border bg-ink-850 px-3 py-2", running ? "border-brand/50" : seat.status === "error" ? "border-danger/50" : "border-line")}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={cn("relative flex h-2 w-2 shrink-0 items-center justify-center")}>
|
||||||
|
{running && <span className="sdx-ping absolute h-2 w-2 rounded-full bg-brand/60" />}
|
||||||
|
<span className={cn("h-1.5 w-1.5 rounded-full", running ? "bg-brand" : seat.status === "error" ? "bg-danger" : "bg-success")} />
|
||||||
|
</span>
|
||||||
|
<Icon className="h-3 w-3 shrink-0 text-slate-500" strokeWidth={1.8} />
|
||||||
|
<span className="truncate text-[12px] font-medium text-slate-100">{seat.name}</span>
|
||||||
|
<span className={cn("ml-auto shrink-0 text-[10px] tabular-nums", t.text)}>{running ? "…" : fmtMs(seat.ms)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 line-clamp-2 text-[10px] leading-snug text-slate-500">{desc || t.label}</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamView({ events, output, phase }: { events: ExecEvent[]; output?: string; phase?: RunPhase }) {
|
||||||
|
const team = useMemo(() => deriveTeam(events), [events]);
|
||||||
|
const { lead, seats, t0, t1 } = team;
|
||||||
|
const span = Math.max(t1 - t0, 1);
|
||||||
|
const streaming = phase === "streaming";
|
||||||
|
// 协调者思考流:取 token 流尾部(滚动窗口,避免撑爆布局)。
|
||||||
|
const thinking = truncate((output ?? "").slice(-90).replace(/\s+/g, " "), 90);
|
||||||
|
|
||||||
|
if (seats.length === 0 && !lead) {
|
||||||
|
return <div className="p-6 text-center text-xs text-slate-600">暂无协调轨迹。</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const radial = seats.length > 0 && seats.length <= RADIAL_MAX;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-auto">
|
||||||
|
<style>{`
|
||||||
|
@keyframes sdx-flow{to{stroke-dashoffset:-20}}
|
||||||
|
@keyframes sdx-ping{0%{transform:scale(1);opacity:.6}70%,100%{transform:scale(2.8);opacity:0}}
|
||||||
|
@keyframes sdx-blink{50%{opacity:0}}
|
||||||
|
.sdx-flow{stroke-dasharray:5 5;animation:sdx-flow 1.1s linear infinite}
|
||||||
|
.sdx-ping{animation:sdx-ping 1.8s ease-out infinite}
|
||||||
|
.sdx-caret{animation:sdx-blink 1.05s step-end infinite}
|
||||||
|
@media(prefers-reduced-motion:reduce){.sdx-flow,.sdx-ping,.sdx-caret{animation:none}}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{radial ? (
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full shrink-0" role="img" aria-label="团队视图:协调者与专家工位">
|
||||||
|
{seats.map((s, i) => {
|
||||||
|
const p = seatPos(i, seats.length);
|
||||||
|
const running = s.status === "running";
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`l-${s.node}`}
|
||||||
|
x1={CX} y1={CY} x2={p.x} y2={p.y}
|
||||||
|
strokeWidth={running ? 1.5 : 1}
|
||||||
|
className={cn(running ? "stroke-brand/70 sdx-flow" : s.status === "error" ? "stroke-danger/40" : "stroke-line")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<foreignObject x={CX - HUB_W / 2} y={CY - HUB_H / 2} width={HUB_W} height={HUB_H}>
|
||||||
|
<div className="h-full rounded-xl border border-brand/40 bg-ink-850 px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-slate-500">
|
||||||
|
<Network className="h-3.5 w-3.5" strokeWidth={1.8} />协调者
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 truncate text-[13px] font-medium text-slate-100">{lead?.label ?? "多智能体协调"}</div>
|
||||||
|
<div className="mt-1.5 border-t border-line pt-1.5">
|
||||||
|
<div className={cn("text-[11px]", streaming ? "text-brand-400" : "text-slate-500")}>
|
||||||
|
{streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-2 text-[10px] leading-snug text-slate-500">
|
||||||
|
{thinking || lead?.detail || ""}
|
||||||
|
{streaming && <span className="sdx-caret ml-0.5 text-brand-400">▌</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
|
||||||
|
{seats.map((s, i) => {
|
||||||
|
const p = seatPos(i, seats.length);
|
||||||
|
return (
|
||||||
|
<g key={s.node} transform={`translate(${p.x - SEAT_W / 2}, ${p.y - SEAT_H / 2})`}>
|
||||||
|
<SeatCard seat={s} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 p-2">
|
||||||
|
{seats.map((s) => (
|
||||||
|
<div key={s.node} className="h-16">
|
||||||
|
<svg viewBox={`0 0 ${SEAT_W} ${SEAT_H}`} className="h-full w-full" role="img" aria-label={s.name}>
|
||||||
|
<SeatCard seat={s} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-line px-3 pb-3 pt-2">
|
||||||
|
<div className="mb-1.5 text-[11px] text-slate-600">并发时间轴 · 共 {fmtMs(span)}</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{seats.map((s) => {
|
||||||
|
const left = ((s.startTS - t0) / span) * 100;
|
||||||
|
const width = Math.max((((s.endTS ?? t1) - s.startTS) / span) * 100, 2);
|
||||||
|
const running = s.status === "running";
|
||||||
|
return (
|
||||||
|
<div key={s.node} className="grid grid-cols-[80px_1fr] items-center gap-2">
|
||||||
|
<span className="truncate text-[11px] text-slate-500">{s.name}</span>
|
||||||
|
<div className="h-1.5 rounded-full bg-ink-800">
|
||||||
|
<div
|
||||||
|
className={cn("h-1.5 rounded-full", running ? "bg-brand" : s.status === "error" ? "bg-danger" : "bg-success")}
|
||||||
|
style={{ marginLeft: `${left}%`, width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import type { ExecEvent } from "./api";
|
import type { ExecEvent } from "./api";
|
||||||
import { deriveNodes, pendingApproval } from "./run";
|
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam } from "./run";
|
||||||
|
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
||||||
@@ -81,3 +81,82 @@ describe("pendingApproval(HITL 待审批中断)", () => {
|
|||||||
expect(pendingApproval([ev("a", "start"), ev("a", "end")])).toBeNull();
|
expect(pendingApproval([ev("a", "start"), ev("a", "end")])).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 团队视图派生(多智能体)----
|
||||||
|
// 后端约定:协调者 coordinator:<id>/kind=model;专家 agent:<名>/kind=agent,
|
||||||
|
// 收尾 detail="简报 X → Y";工具 tool:<名>/kind=tool。
|
||||||
|
|
||||||
|
describe("isMultiAgent(是否走了多智能体协调)", () => {
|
||||||
|
it("有 coordinator: 节点 → true", () => {
|
||||||
|
expect(isMultiAgent([ev("coordinator:c", "start", { kind: "model" })])).toBe(true);
|
||||||
|
});
|
||||||
|
it("普通任务(model/tool) → false,不显示团队 tab", () => {
|
||||||
|
expect(isMultiAgent([ev("model", "start", { kind: "model" }), ev("tool:wiki", "end", { kind: "tool" })])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveTeam(协调者 + 工位)", () => {
|
||||||
|
it("专家 start→end:done + 耗时 + 解析简报/产出", () => {
|
||||||
|
const t = deriveTeam([
|
||||||
|
ev("agent:条款专家", "start", { kind: "agent", ts: 100 }),
|
||||||
|
ev("agent:条款专家", "end", { kind: "agent", ts: 400, ms: 300, detail: "简报 核对违约金 → 发现3处问题" }),
|
||||||
|
]);
|
||||||
|
const s = t.seats[0];
|
||||||
|
expect(s.name).toBe("条款专家");
|
||||||
|
expect(s.kind).toBe("agent");
|
||||||
|
expect(s.status).toBe("done");
|
||||||
|
expect(s.ms).toBe(300);
|
||||||
|
expect(s.brief).toBe("核对违约金");
|
||||||
|
expect(s.output).toBe("发现3处问题");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未收口的专家 → running,endTS 为空", () => {
|
||||||
|
const t = deriveTeam([ev("agent:风险专家", "start", { kind: "agent", ts: 10 })]);
|
||||||
|
expect(t.seats[0].status).toBe("running");
|
||||||
|
expect(t.seats[0].endTS).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("专家失败 → error,detail 落到产出", () => {
|
||||||
|
const t = deriveTeam([
|
||||||
|
ev("agent:x", "start", { kind: "agent", ts: 0 }),
|
||||||
|
ev("agent:x", "error", { kind: "agent", ts: 50, ms: 50, detail: "专家超时" }),
|
||||||
|
]);
|
||||||
|
expect(t.seats[0].status).toBe("error");
|
||||||
|
expect(t.seats[0].output).toBe("专家超时");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detail 无「简报 → 」格式 → 整段当产出,不误判", () => {
|
||||||
|
const t = deriveTeam([
|
||||||
|
ev("tool:wiki", "start", { kind: "tool", ts: 0 }),
|
||||||
|
ev("tool:wiki", "end", { kind: "tool", ts: 20, ms: 20, detail: "命中 4 段" }),
|
||||||
|
]);
|
||||||
|
expect(t.seats[0].brief).toBeUndefined();
|
||||||
|
expect(t.seats[0].output).toBe("命中 4 段");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("协调者被识别为 lead,且不混进工位", () => {
|
||||||
|
const t = deriveTeam([
|
||||||
|
ev("coordinator:c", "start", { kind: "model", label: "多智能体协调", detail: "2 个专家可派发" }),
|
||||||
|
ev("agent:a", "start", { kind: "agent", ts: 5 }),
|
||||||
|
]);
|
||||||
|
expect(t.lead?.label).toBe("多智能体协调");
|
||||||
|
expect(t.lead?.detail).toBe("2 个专家可派发");
|
||||||
|
expect(t.seats.map((s) => s.node)).toEqual(["agent:a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("并发时间轴窗口:t0=最早开始,t1=最晚结束", () => {
|
||||||
|
const t = deriveTeam([
|
||||||
|
ev("agent:a", "start", { kind: "agent", ts: 100 }),
|
||||||
|
ev("agent:b", "start", { kind: "agent", ts: 150 }),
|
||||||
|
ev("agent:a", "end", { kind: "agent", ts: 900, ms: 800 }),
|
||||||
|
ev("agent:b", "end", { kind: "agent", ts: 400, ms: 250 }),
|
||||||
|
]);
|
||||||
|
expect(t.t0).toBe(100);
|
||||||
|
expect(t.t1).toBe(900);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未收口时 t1 用 now(进行中的条也能画满)", () => {
|
||||||
|
const t = deriveTeam([ev("agent:a", "start", { kind: "agent", ts: 100 })], 5000);
|
||||||
|
expect(t.t1).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -95,3 +95,108 @@ export function deriveNodes(events: ExecEvent[]): NodeTrace[] {
|
|||||||
}
|
}
|
||||||
return [...map.values()].sort((a, b) => a.order - b.order);
|
return [...map.values()].sort((a, b) => a.order - b.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 团队视图派生(多智能体专用)----
|
||||||
|
// 后端事件约定(dispatcher/internal/eino):
|
||||||
|
// 协调者 node="coordinator:<id>" kind="model";专家 node="agent:<名字>" kind="agent"
|
||||||
|
// 专家收尾 detail="简报 <简报> → <产出>";MCP 工具 node="tool:<名字>" kind="tool"
|
||||||
|
// 与 deriveNodes 的区别:这里保留 start/end 时间戳,才能画并发时间轴。
|
||||||
|
|
||||||
|
export interface TeamSeat {
|
||||||
|
node: string;
|
||||||
|
name: string; // 去掉 agent:/tool: 前缀
|
||||||
|
kind: "agent" | "tool"; // agent=专家(队友),tool=工具
|
||||||
|
status: NodeStatus;
|
||||||
|
ms?: number;
|
||||||
|
brief?: string; // 协调者给的定制简报(从 detail 解析)
|
||||||
|
output?: string; // 产出预览
|
||||||
|
startTS: number;
|
||||||
|
endTS?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamLead {
|
||||||
|
node: string;
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
status: NodeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamModel {
|
||||||
|
lead: TeamLead | null;
|
||||||
|
seats: TeamSeat[];
|
||||||
|
t0: number; // 最早开始
|
||||||
|
t1: number; // 最晚结束(未完则为 now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isMultiAgent 判定这次运行是否走了多智能体协调(据此才显示「团队」视图)。
|
||||||
|
export function isMultiAgent(events: ExecEvent[]): boolean {
|
||||||
|
return events.some((e) => e.node.startsWith("coordinator:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitBrief 解析专家收尾 detail:"简报 X → Y" → {brief:X, output:Y};不匹配则整段当产出。
|
||||||
|
function splitBrief(detail?: string): { brief?: string; output?: string } {
|
||||||
|
if (!detail) return {};
|
||||||
|
const m = detail.match(/^简报\s*([\s\S]*?)\s*→\s*([\s\S]*)$/);
|
||||||
|
if (m) return { brief: m[1], output: m[2] };
|
||||||
|
return { output: detail };
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveTeam 把事件流派生成「协调者 + 工位」模型(保留时间戳供并发时间轴)。
|
||||||
|
export function deriveTeam(events: ExecEvent[], now = Date.now()): TeamModel {
|
||||||
|
let lead: TeamLead | null = null;
|
||||||
|
const map = new Map<string, TeamSeat>();
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.node.startsWith("coordinator:")) {
|
||||||
|
if (!lead) lead = { node: e.node, label: e.label || "多智能体协调", status: "running" };
|
||||||
|
if (e.label) lead.label = e.label;
|
||||||
|
if (e.detail) lead.detail = e.detail;
|
||||||
|
if (e.phase === "end") lead.status = "done";
|
||||||
|
else if (e.phase === "error") lead.status = "error";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const isAgent = e.kind === "agent";
|
||||||
|
const isTool = e.kind === "tool";
|
||||||
|
if (!isAgent && !isTool) continue;
|
||||||
|
let s = map.get(e.node);
|
||||||
|
if (!s) {
|
||||||
|
s = {
|
||||||
|
node: e.node,
|
||||||
|
name: e.node.replace(/^(agent|tool):/, ""),
|
||||||
|
kind: isAgent ? "agent" : "tool",
|
||||||
|
status: "running",
|
||||||
|
startTS: e.ts,
|
||||||
|
};
|
||||||
|
map.set(e.node, s);
|
||||||
|
}
|
||||||
|
switch (e.phase) {
|
||||||
|
case "start":
|
||||||
|
s.startTS = e.ts;
|
||||||
|
if (s.status !== "done" && s.status !== "error") s.status = "running";
|
||||||
|
break;
|
||||||
|
case "end": {
|
||||||
|
s.status = "done";
|
||||||
|
s.ms = e.ms;
|
||||||
|
s.endTS = e.ts;
|
||||||
|
const p = splitBrief(e.detail);
|
||||||
|
s.brief = p.brief;
|
||||||
|
s.output = p.output;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "error":
|
||||||
|
s.status = "error";
|
||||||
|
s.ms = e.ms;
|
||||||
|
s.endTS = e.ts;
|
||||||
|
s.output = e.detail;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const seats = [...map.values()];
|
||||||
|
const starts = seats.map((s) => s.startTS);
|
||||||
|
const ends = seats.map((s) => s.endTS ?? now);
|
||||||
|
return {
|
||||||
|
lead,
|
||||||
|
seats,
|
||||||
|
t0: starts.length ? Math.min(...starts) : now,
|
||||||
|
t1: ends.length ? Math.max(...ends) : now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Activity, FileText, History, Users, Wrench } from "lucide-react";
|
import { Activity, FileText, History, Users, Wrench } from "lucide-react";
|
||||||
import { ExecTrace } from "../components/ExecTrace";
|
import { ExecTrace } from "../components/ExecTrace";
|
||||||
|
import { TeamView } from "../components/TeamView";
|
||||||
import { Markdown } from "../components/Markdown";
|
import { Markdown } from "../components/Markdown";
|
||||||
import { ChartView } from "../components/ChartView";
|
import { ChartView } from "../components/ChartView";
|
||||||
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
import { extractChartBlocks, hasChart } from "../lib/chartspec";
|
||||||
import { deriveNodes, emptyRun, type RunState } from "../lib/run";
|
import { deriveNodes, isMultiAgent, emptyRun, type RunState } from "../lib/run";
|
||||||
import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api";
|
import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api";
|
||||||
import { Tabs, Panel, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
|
import { Tabs, Panel, Dot, Badge, EmptyState, cn, type TabDef } from "../ui";
|
||||||
|
|
||||||
type DetailTab = "trace" | "tools" | "eval";
|
type DetailTab = "trace" | "team" | "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",
|
||||||
@@ -68,8 +69,13 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
const nodes = deriveNodes(cur.exec);
|
const nodes = deriveNodes(cur.exec);
|
||||||
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。
|
// 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(kind=agent)都是「调用」。
|
||||||
const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent");
|
const calls = nodes.filter((n) => n.kind === "tool" || n.kind === "agent");
|
||||||
|
// 「团队」视图只在多智能体协调的运行里出现(普通任务轨迹已足够,不塞多余 tab)。
|
||||||
|
const multi = isMultiAgent(cur.exec);
|
||||||
|
// 切到非多智能体运行时,「团队」tab 会消失——此时停在它上面要回落轨迹,否则内容悬空。
|
||||||
|
const activeTab: DetailTab = !multi && tab === "team" ? "trace" : tab;
|
||||||
const tabs: TabDef<DetailTab>[] = [
|
const tabs: TabDef<DetailTab>[] = [
|
||||||
{ key: "trace", label: "执行轨迹", count: nodes.length },
|
{ key: "trace", label: "执行轨迹", count: nodes.length },
|
||||||
|
...(multi ? ([{ key: "team", label: "团队" }] as TabDef<DetailTab>[]) : []),
|
||||||
{ key: "tools", label: "工具/专家", count: calls.length },
|
{ key: "tools", label: "工具/专家", count: calls.length },
|
||||||
{ key: "eval", label: "评测" },
|
{ key: "eval", label: "评测" },
|
||||||
];
|
];
|
||||||
@@ -124,15 +130,17 @@ export function RunsView({ run }: { run: RunState }) {
|
|||||||
{/* 中:tab 切换 轨迹/工具/评测 + 状态指示 */}
|
{/* 中:tab 切换 轨迹/工具/评测 + 状态指示 */}
|
||||||
<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 border-b border-line px-2">
|
<div className="flex items-center border-b border-line px-2">
|
||||||
<Tabs tabs={tabs} value={tab} onChange={setTab} />
|
<Tabs tabs={tabs} value={activeTab} onChange={setTab} />
|
||||||
<span className={cn("ml-auto pr-2 text-[11px]", statusCls)}>{statusText}</span>
|
<span className={cn("ml-auto pr-2 text-[11px]", statusCls)}>{statusText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<EmptyState icon={Activity} title="选择一次运行" desc="左侧点选历史运行即可回放轨迹;或在编排/报告页发起新运行。" />
|
<EmptyState icon={Activity} title="选择一次运行" desc="左侧点选历史运行即可回放轨迹;或在编排/报告页发起新运行。" />
|
||||||
) : tab === "trace" ? (
|
) : activeTab === "trace" ? (
|
||||||
<ExecTrace events={cur.exec} phase={cur.phase} />
|
<ExecTrace events={cur.exec} phase={cur.phase} />
|
||||||
) : tab === "tools" ? (
|
) : activeTab === "team" ? (
|
||||||
|
<TeamView events={cur.exec} output={cur.output} phase={cur.phase} />
|
||||||
|
) : activeTab === "tools" ? (
|
||||||
<ToolCalls run={cur} />
|
<ToolCalls run={cur} />
|
||||||
) : (
|
) : (
|
||||||
<EvalView ev={evalRes} />
|
<EvalView ev={evalRes} />
|
||||||
|
|||||||
Reference in New Issue
Block a user