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
@@ -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>
);
}