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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user