fix(desktop,dispatcher): 从编排执行的多 agent 图看不到「团队」tab

现象:编排里并排三个 agent(研究/撰写/审查)跑完,运行页没有团队 tab。

两处卡住,不是一处:
1) isMultiAgent 只认 coordinator: 节点。用户自己在图里并排多个 agent 也是
   团队,却被整个漏掉。改成:有协调者,或 ≥2 个 agent: 节点。
2) 就算放宽 1,deriveTeam 仍按 kind==="agent" 挑工位——而两条产生 agent 的
   路径 kind 并不一致:协调者派发的专家是 kind=agent,图里的 agent 节点是
   kind=model。改成按节点名前缀(agent:/tool:)判,这本来就是后端一直遵守的
   约定;顺带天然把 retriever:/map:/render: 这些同为 kind=tool 的节点挡在
   工位之外(之前它们会混进来当工位)。

连带修一个更要命的:runAgent 把轨迹标签写死成"模型流式推理"、runReactAgent
写死成"ReAct 智能体(自主调工具)",用户在编排里给节点起的名字(研究 Agent /
撰写 Agent / 审查 Agent)整个丢了。后果不止办公室:执行轨迹里三行同名,根本
分不出谁是谁;团队视图只能退回节点 ID,工位显示成 r/w/rev。
改成一律 labelOf(n, 兜底) 由调用方传入,+2 单测钉住。

另:没有协调者时不再凭空画一个"协调者"小人(白板改挂「任务产出」),也不演
递简报那一程(没人可递),✓ 气泡改为收工即冒。

注意:已存的历史轨迹是落库的,仍是旧标签;只有新跑的任务才有节点名。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 17:18:52 +08:00
parent 02608bb6a4
commit c2811e79a3
10 changed files with 188 additions and 51 deletions
@@ -260,9 +260,11 @@ interface Cue {
}
// 角色层:走位全在这里。桌子不跟着走,所以单独画在上层。
function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) {
function Actor({ cue, reduced, hasLead }: { cue: Cue; reduced: boolean; hasLead: boolean }) {
const { seat, x, y, scale: s, index } = cue;
const done = seat.status === "done";
const finished = seat.status === "done";
// 没有协调者就没人可递:干完仍然眯眼笑、打✓,只是不演交付那一程。
const handoff = finished && hasLead;
// 入场:从门口走到工位(局部坐标 = 场景增量 ÷ 本工位缩放)。
const enterVars: Vars = {
@@ -294,11 +296,11 @@ function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) {
hair={HAIRS[index % HAIRS.length]}
style={index % 3}
typing={typing}
happy={done}
happy={finished}
sad={seat.status === "error"}
delay={index * 0.23}
/>
{done && !reduced && <Paper dur={HANDOFF_MS} delay={cue.handoffAt} />}
{handoff && !reduced && <Paper dur={HANDOFF_MS} delay={cue.handoffAt} />}
{cue.bubble && <Bubble status={seat.status} delay={cue.bubbleAt} />}
</>
);
@@ -317,7 +319,7 @@ function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) {
<g transform={`translate(${x}, ${y}) scale(${s})`}>
<g className="sdx-enter" style={enterVars}>
<g className="sdx-walk" style={{ "--wcount": Math.round(ENTER_MS / 300), animationDelay: `${cue.enterAt}s` } as Vars}>
{done ? (
{handoff ? (
<g className="sdx-handoff" style={handoffVars}>
<g className="sdx-walk" style={{ "--wcount": Math.round(HANDOFF_MS / 300), animationDelay: `${cue.handoffAt}s` } as Vars}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
@@ -411,11 +413,12 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
typeAt: typeAtMs / 1000,
typeCount: streaming ? 0 : Math.max(1, Math.round((workEndMs - typeAtMs) / TYPE_MS)),
handoffAt: workEndMs / 1000,
// ✓ 气泡等交付走完再冒;没有协调者就不演交付,收工即冒。
bubbleAt:
(streaming
? 0
: seat.status === "done"
? workEndMs + HANDOFF_MS
? workEndMs + (lead ? HANDOFF_MS : 0)
: seat.status === "error"
? workEndMs
: typeAtMs) / 1000,
@@ -478,7 +481,17 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
<foreignObject x={188} y={26} width={296} height={74}>
<div style={{ fontFamily: "system-ui, sans-serif", color: "#4a4238", lineHeight: 1.35 }}>
<div style={{ fontSize: 12, color: leadTone, fontWeight: 600 }}>
{streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"}
{lead
? streaming
? "综合中…"
: lead.status === "error"
? "协调失败"
: lead.status === "done"
? "已综合"
: "协调中"
: streaming
? "生成中…"
: "任务产出"}
</div>
<div
style={{
@@ -498,23 +511,26 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
</foreignObject>
</g>
{/* 协调者 */}
<g transform={`translate(${LEAD_X}, ${LEAD_Y}) scale(${LEAD_SCALE})`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
<Chibi
shirt={RUN}
hair="#3f3229"
style={2}
typing={streaming ? { count: Infinity, delay: 0 } : undefined}
happy={lead?.status === "done"}
sad={lead?.status === "error"}
delay={0}
/>
<Deskette name={lead?.label ?? "协调者"} sub="协调者" tone={leadTone} glow={streaming} w={230} />
</g>
{/* 协调者:只有真走了协调者节点才画。用户自己并排多个 agent 的图没有协调者,
那就不该凭空画一个人出来——白板照样挂产出,专家各自干各自的。 */}
{lead && (
<g transform={`translate(${LEAD_X}, ${LEAD_Y}) scale(${LEAD_SCALE})`}>
<ellipse cx={0} cy={2} rx={46} ry={9} fill="#000" opacity={0.09} />
<Chibi
shirt={RUN}
hair="#3f3229"
style={2}
typing={streaming ? { count: Infinity, delay: 0 } : undefined}
happy={lead.status === "done"}
sad={lead.status === "error"}
delay={0}
/>
<Deskette name={lead.label} sub="协调者" tone={leadTone} glow={streaming} w={230} />
</g>
)}
{/* 派发连线:只画正在工作的工位 */}
{cues
{/* 派发连线:只画正在工作的工位;没有协调者就没有派发这回事 */}
{(lead ? cues : [])
.filter((c) => c.seat.status === "running")
.map((c) => (
<path
@@ -530,7 +546,7 @@ export function OfficeView({ events, output, phase }: { events: ExecEvent[]; out
{/* 角色层在前、桌子层在后:角色才能走到别人桌前而不被自己那张桌子焊死 */}
{cues.map((c) => (
<Actor key={c.seat.node} cue={c} reduced={reduced} />
<Actor key={c.seat.node} cue={c} reduced={reduced} hasLead={!!lead} />
))}
{cues.map((c) => (
<g key={`d-${c.seat.node}`} transform={`translate(${c.x}, ${c.y}) scale(${c.scale})`}>
@@ -110,12 +110,23 @@ export function TeamView({ events, output, phase }: { events: ExecEvent[]; outpu
<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} />
<Network className="h-3.5 w-3.5" strokeWidth={1.8} />
{lead ? "协调者" : "任务产出"}
</div>
<div className="mt-1 truncate text-[13px] font-medium text-slate-100">{lead?.label ?? "多智能体协调"}</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" ? "已综合" : "协调中"}
{lead
? streaming
? "综合中…"
: lead.status === "error"
? "协调失败"
: lead.status === "done"
? "已综合"
: "协调中"
: streaming
? "生成中…"
: "已产出"}
</div>
<div className="mt-0.5 line-clamp-2 text-[10px] leading-snug text-slate-500">
{thinking || lead?.detail || ""}