feat(desktop): 多智能体「团队」视图(运行·观测新增,轨迹仍默认)

把多智能体协调画成「协调者居中 + 专家工位环绕」,让最难读的一段——并行派发与综合
——变得一眼可读。执行轨迹保持默认视图;「团队」tab 只在多智能体运行时出现,
普通任务不塞多余 tab。

- lib/run.ts 加 isMultiAgent()(据 coordinator: 节点判定) + deriveTeam():
  从现有执行事件流派生协调者/工位模型。与 deriveNodes 的区别是保留 start/end 时间戳,
  才能画并发时间轴;并解析专家收尾 detail("简报 X → Y")还原简报与产出。
- components/TeamView.tsx:径向布局(工位按角度均分环绕,>6 个降级为网格)、
  协调者实时思考流(接 token 流 + 闪烁光标)、并发时间轴(重叠一眼可见)。
  动效只标活跃态(派发链路流动/工位脉冲)——是状态编码不是装饰,带 prefers-reduced-motion 守卫。
  专家(kind=agent)与工具(kind=tool)图标区分。
- 零后端改动:数据全部来自现有 exec 事件(coordinator:/agent:/tool:)。

补 9 个单测覆盖派生逻辑(简报解析/失败态/时间窗/lead 不混入工位)。
实机经回放路径验证渲染(径向布局/简报/并发条/tab 条件出现)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 15:30:12 +08:00
parent 2d5b72930a
commit 0152e7837a
4 changed files with 370 additions and 6 deletions
+80 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { ExecEvent } from "./api";
import { deriveNodes, pendingApproval } from "./run";
import { deriveNodes, pendingApproval, isMultiAgent, deriveTeam } from "./run";
let seq = 0;
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
@@ -81,3 +81,82 @@ describe("pendingApprovalHITL 待审批中断)", () => {
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→enddone + 耗时 + 解析简报/产出", () => {
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("未收口的专家 → runningendTS 为空", () => {
const t = deriveTeam([ev("agent:风险专家", "start", { kind: "agent", ts: 10 })]);
expect(t.seats[0].status).toBe("running");
expect(t.seats[0].endTS).toBeUndefined();
});
it("专家失败 → errordetail 落到产出", () => {
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);
});
});
+105
View File
@@ -95,3 +95,108 @@ export function deriveNodes(events: ExecEvent[]): NodeTrace[] {
}
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,
};
}