diff --git a/sundynix-desktop/frontend/src/components/TeamView.tsx b/sundynix-desktop/frontend/src/components/TeamView.tsx new file mode 100644 index 0000000..511d98e --- /dev/null +++ b/sundynix-desktop/frontend/src/components/TeamView.tsx @@ -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 ( + +
+
+ + {running && } + + + + {seat.name} + {running ? "…" : fmtMs(seat.ms)} +
+
{desc || t.label}
+
+
+ ); +} + +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
暂无协调轨迹。
; + } + + const radial = seats.length > 0 && seats.length <= RADIAL_MAX; + + return ( +
+ + + {radial ? ( + + {seats.map((s, i) => { + const p = seatPos(i, seats.length); + const running = s.status === "running"; + return ( + + ); + })} + + +
+
+ 协调者 +
+
{lead?.label ?? "多智能体协调"}
+
+
+ {streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"} +
+
+ {thinking || lead?.detail || ""} + {streaming && } +
+
+
+
+ + {seats.map((s, i) => { + const p = seatPos(i, seats.length); + return ( + + + + ); + })} +
+ ) : ( +
+ {seats.map((s) => ( +
+ + + +
+ ))} +
+ )} + +
+
并发时间轴 · 共 {fmtMs(span)}
+
+ {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 ( +
+ {s.name} +
+
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/sundynix-desktop/frontend/src/lib/run.test.ts b/sundynix-desktop/frontend/src/lib/run.test.ts index 5efe026..5742c2c 100644 --- a/sundynix-desktop/frontend/src/lib/run.test.ts +++ b/sundynix-desktop/frontend/src/lib/run.test.ts @@ -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 { @@ -81,3 +81,82 @@ describe("pendingApproval(HITL 待审批中断)", () => { expect(pendingApproval([ev("a", "start"), ev("a", "end")])).toBeNull(); }); }); + +// ---- 团队视图派生(多智能体)---- +// 后端约定:协调者 coordinator:/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); + }); +}); diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts index 0acf644..f37c771 100644 --- a/sundynix-desktop/frontend/src/lib/run.ts +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -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:" 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(); + 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, + }; +} diff --git a/sundynix-desktop/frontend/src/views/RunsView.tsx b/sundynix-desktop/frontend/src/views/RunsView.tsx index c735d96..fca06a7 100644 --- a/sundynix-desktop/frontend/src/views/RunsView.tsx +++ b/sundynix-desktop/frontend/src/views/RunsView.tsx @@ -1,14 +1,15 @@ import { useCallback, useEffect, useState } from "react"; import { Activity, FileText, History, Users, Wrench } from "lucide-react"; import { ExecTrace } from "../components/ExecTrace"; +import { TeamView } from "../components/TeamView"; 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, isMultiAgent, emptyRun, type RunState } from "../lib/run"; import { listRuns, taskEval, runReplay, type RunSummary, type EvalResult } from "../lib/api"; 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 = { done: "success", failed: "danger", timeout: "danger", rejected: "danger", @@ -68,8 +69,13 @@ export function RunsView({ run }: { run: RunState }) { const nodes = deriveNodes(cur.exec); // 工具调用面板纳入专家派发:MCP 工具(kind=tool)与多智能体协调里的子智能体派发(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[] = [ { key: "trace", label: "执行轨迹", count: nodes.length }, + ...(multi ? ([{ key: "team", label: "团队" }] as TabDef[]) : []), { key: "tools", label: "工具/专家", count: calls.length }, { key: "eval", label: "评测" }, ]; @@ -124,15 +130,17 @@ export function RunsView({ run }: { run: RunState }) { {/* 中:tab 切换 轨迹/工具/评测 + 状态指示 */}
- + {statusText}
{empty ? ( - ) : tab === "trace" ? ( + ) : activeTab === "trace" ? ( - ) : tab === "tools" ? ( + ) : activeTab === "team" ? ( + + ) : activeTab === "tools" ? ( ) : (