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:
@@ -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("pendingApproval(HITL 待审批中断)", () => {
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 团队视图派生(多智能体专用)----
|
||||
// 后端事件约定(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user