diff --git a/sundynix-desktop/frontend/src/components/OfficeView.tsx b/sundynix-desktop/frontend/src/components/OfficeView.tsx index b7249a2..cd7ab8f 100644 --- a/sundynix-desktop/frontend/src/components/OfficeView.tsx +++ b/sundynix-desktop/frontend/src/components/OfficeView.tsx @@ -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 && } + {handoff && !reduced && } {cue.bubble && } ); @@ -317,7 +319,7 @@ function Actor({ cue, reduced }: { cue: Cue; reduced: boolean }) { - {done ? ( + {handoff ? ( @@ -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
- {streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"} + {lead + ? streaming + ? "综合中…" + : lead.status === "error" + ? "协调失败" + : lead.status === "done" + ? "已综合" + : "协调中" + : streaming + ? "生成中…" + : "任务产出"}
- {/* 协调者 */} - - - - - + {/* 协调者:只有真走了协调者节点才画。用户自己并排多个 agent 的图没有协调者, + 那就不该凭空画一个人出来——白板照样挂产出,专家各自干各自的。 */} + {lead && ( + + + + + + )} - {/* 派发连线:只画正在工作的工位 */} - {cues + {/* 派发连线:只画正在工作的工位;没有协调者就没有派发这回事 */} + {(lead ? cues : []) .filter((c) => c.seat.status === "running") .map((c) => ( ( - + ))} {cues.map((c) => ( diff --git a/sundynix-desktop/frontend/src/components/TeamView.tsx b/sundynix-desktop/frontend/src/components/TeamView.tsx index 0b1dfab..f0f085b 100644 --- a/sundynix-desktop/frontend/src/components/TeamView.tsx +++ b/sundynix-desktop/frontend/src/components/TeamView.tsx @@ -110,12 +110,23 @@ export function TeamView({ events, output, phase }: { events: ExecEvent[]; outpu
- 协调者 + + {lead ? "协调者" : "任务产出"}
-
{lead?.label ?? "多智能体协调"}
+
{lead?.label ?? "多智能体任务"}
- {streaming ? "综合中…" : lead?.status === "error" ? "协调失败" : lead?.status === "done" ? "已综合" : "协调中"} + {lead + ? streaming + ? "综合中…" + : lead.status === "error" + ? "协调失败" + : lead.status === "done" + ? "已综合" + : "协调中" + : streaming + ? "生成中…" + : "已产出"}
{thinking || lead?.detail || ""} diff --git a/sundynix-desktop/frontend/src/lib/run.test.ts b/sundynix-desktop/frontend/src/lib/run.test.ts index ae363cb..bc711c8 100644 --- a/sundynix-desktop/frontend/src/lib/run.test.ts +++ b/sundynix-desktop/frontend/src/lib/run.test.ts @@ -86,13 +86,26 @@ describe("pendingApproval(HITL 待审批中断)", () => { // 后端约定:协调者 coordinator:/kind=model;专家 agent:<名>/kind=agent, // 收尾 detail="简报 X → Y";工具 tool:<名>/kind=tool。 -describe("isMultiAgent(是否走了多智能体协调)", () => { +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); }); + // 编排里并排多个 agent 的图没有协调者,且 agent 节点发的是 kind=model(不是 kind=agent)。 + // 按 kind 判会把这种图整个漏掉——用户从编排点执行后就看不到团队 tab。 + it("图里 ≥2 个 agent 节点(kind=model,无协调者)→ true", () => { + expect( + isMultiAgent([ + ev("agent:a", "start", { kind: "model" }), + ev("agent:b", "start", { kind: "model" }), + ]), + ).toBe(true); + }); + it("只有 1 个 agent → false(一个人不叫团队)", () => { + expect(isMultiAgent([ev("agent:a", "start", { kind: "model" }), ev("agent:a", "end", { kind: "model" })])).toBe(false); + }); }); describe("deriveTeam(协调者 + 工位)", () => { @@ -134,6 +147,26 @@ describe("deriveTeam(协调者 + 工位)", () => { expect(t.seats[0].output).toBe("命中 4 段"); }); + it("图里的 agent 节点(kind=model)也算工位", () => { + const t = deriveTeam([ + ev("agent:writer", "start", { kind: "model", ts: 0 }), + ev("agent:writer", "end", { kind: "model", ts: 300, ms: 300, detail: "成稿 800 字" }), + ]); + expect(t.seats.map((s) => s.name)).toEqual(["writer"]); + expect(t.seats[0].status).toBe("done"); + expect(t.lead).toBeNull(); // 没有协调者 → 不能凭空造一个 + }); + + it("retriever/map/render 不当工位(同为 kind=tool,但不是 tool: 前缀)", () => { + const t = deriveTeam([ + ev("retriever:r", "start", { kind: "tool", ts: 0 }), + ev("retriever:r", "end", { kind: "tool", ts: 50, ms: 50 }), + ev("map:m", "start", { kind: "plan", ts: 10 }), + ev("render:o", "end", { kind: "render", ts: 60 }), + ]); + expect(t.seats).toEqual([]); + }); + it("协调者被识别为 lead,且不混进工位", () => { const t = deriveTeam([ ev("coordinator:c", "start", { kind: "model", label: "多智能体协调", detail: "2 个专家可派发" }), diff --git a/sundynix-desktop/frontend/src/lib/run.ts b/sundynix-desktop/frontend/src/lib/run.ts index 4b65d1e..a0932f7 100644 --- a/sundynix-desktop/frontend/src/lib/run.ts +++ b/sundynix-desktop/frontend/src/lib/run.ts @@ -128,9 +128,14 @@ export interface TeamModel { t1: number; // 最晚结束(未完则为 now) } -// isMultiAgent 判定这次运行是否走了多智能体协调(据此才显示「团队」视图)。 +// isMultiAgent 判定这次运行值不值得看「团队」: +// 1) 走了协调者(coordinator:)——orchestrator 自主派发;或 +// 2) 图里挂了 ≥2 个专家(agent:)——用户自己在编排里并排/接力多个 agent,那也是个团队。 +// 只有 1 个 agent 的普通任务不显示(一个人不叫团队,看轨迹就够)。 export function isMultiAgent(events: ExecEvent[]): boolean { - return events.some((e) => e.node.startsWith("coordinator:")); + if (events.some((e) => e.node.startsWith("coordinator:"))) return true; + const agents = new Set(events.filter((e) => e.node.startsWith("agent:")).map((e) => e.node)); + return agents.size >= 2; } // splitBrief 解析专家收尾 detail:"简报 X → Y" → {brief:X, output:Y};不匹配则整段当产出。 @@ -164,8 +169,12 @@ export function deriveTeam(events: ExecEvent[], now = Date.now()): TeamModel { else if (e.phase === "error") lead.status = "error"; continue; } - const isAgent = e.kind === "agent"; - const isTool = e.kind === "tool"; + // 按节点名前缀挑工位,不按 kind:两条产生 agent 的路径 kind 并不一致 + // —— 协调者派发的专家是 kind=agent,图里的 agent 节点是 kind=model。 + // 前缀才是后端一直遵守的约定(agent:<名字> / tool:<名字>), + // 而且它天然把 retriever:/map:/render: 这些同为 kind=tool 的节点挡在工位之外。 + const isAgent = e.node.startsWith("agent:"); + const isTool = e.node.startsWith("tool:"); if (!isAgent && !isTool) continue; let s = map.get(e.node); if (!s) { diff --git a/sundynix-dispatcher/internal/eino/compose_compiler.go b/sundynix-dispatcher/internal/eino/compose_compiler.go index a923188..26b8736 100644 --- a/sundynix-dispatcher/internal/eino/compose_compiler.go +++ b/sundynix-dispatcher/internal/eino/compose_compiler.go @@ -66,7 +66,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t tr.info("task", "system", "无结构化图", "按单轮对话执行(compose)") b.profile = o.fetchMemory(ctx, b.uid, b.query) b.history = o.fetchHistory(ctx, b.sid) - o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent") + o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理") return b.answer, refsOf(b), b.fatalErr // 模型失败 → 上抛判 failed(对齐 graph.go) } @@ -211,7 +211,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t if cerr != nil { // 编译失败(罕见)→ 降级为单轮对话兜底(自研 graph.go 已退役,不再回退)。 tr.info("task", "system", "compose 编译失败", "降级单轮对话:"+cerr.Error()) - o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent") + o.runComposeConversation(ctx, t.ID, b, plan.System, tr, "agent", "模型流式推理") return b.answer, refsOf(b), b.fatalErr } // checkpoint id = task id:审批中断时 compose 据此把整图状态(含 board)落进 store。 @@ -253,7 +253,7 @@ func (o *Orchestrator) execComposeGraph(ctx context.Context, t *contract.Task, t // 图里无 agent 节点(纯工具/检索图)也要出一段答复。 if fb.answer == "" { - o.runComposeConversation(ctx, t.ID, fb, plan.System, tr, "agent") + o.runComposeConversation(ctx, t.ID, fb, plan.System, tr, "agent", "模型流式推理") if fb.fatalErr != nil { // 兜底对话也可能触预算顶 / 模型失败 return fb.answer, nil, fb.fatalErr } @@ -289,7 +289,7 @@ func (o *Orchestrator) execDSLNode(ctx context.Context, t *contract.Task, n dsl. if cbool(n.Config, "autonomous") { o.runReactAgent(ctx, t.ID, b, sys, n, tr, "agent:"+n.ID) } else { - o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID) + o.runAgent(ctx, t.ID, b, sys, tr, "agent:"+n.ID, labelOf(n, "智能体")) } case "coordinator": // 多智能体协调:orchestrator 自主把子任务派给专家(agent-as-tool)再综合 o.runCoordinator(ctx, t.ID, b, firstNonEmpty(cstr(n.Config, "system"), plan.System), n, tr, "coordinator:"+n.ID) diff --git a/sundynix-dispatcher/internal/eino/compose_graph.go b/sundynix-dispatcher/internal/eino/compose_graph.go index 9c33331..a118b62 100644 --- a/sundynix-dispatcher/internal/eino/compose_graph.go +++ b/sundynix-dispatcher/internal/eino/compose_graph.go @@ -16,17 +16,17 @@ import ( // runComposeConversation 用 Eino compose.Graph 跑对话主流程: // START → ChatModel 节点 → END,编译为 Runnable 后流式执行;可观测经 callbacks 桥到 ExecEvent。 // 模型未就绪 / 编译失败时降级回 runAgent(同样的流式回流,保证不回归)。 -func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) { +func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) { cm := o.pool.ChatModel() if cm == nil { - o.runAgent(ctx, taskID, b, system, tr, node) // 无模型 → 降级桩 + o.runAgent(ctx, taskID, b, system, tr, node, label) // 无模型 → 降级桩 return } g := compose.NewGraph[[]*schema.Message, *schema.Message]() if err := g.AddChatModelNode("model", cm); err != nil { tr.info(node, "system", "compose 降级", "建图失败,退回自研路径:"+err.Error()) - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, label) return } _ = g.AddEdge(compose.START, "model") @@ -34,7 +34,7 @@ func (o *Orchestrator) runComposeConversation(ctx context.Context, taskID string r, err := g.Compile(ctx) if err != nil { tr.info(node, "system", "compose 降级", "编译失败,退回自研路径:"+err.Error()) - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, label) return } diff --git a/sundynix-dispatcher/internal/eino/compose_graph_test.go b/sundynix-dispatcher/internal/eino/compose_graph_test.go index e4313cb..e739b95 100644 --- a/sundynix-dispatcher/internal/eino/compose_graph_test.go +++ b/sundynix-dispatcher/internal/eino/compose_graph_test.go @@ -2,7 +2,9 @@ package eino import ( "context" + "encoding/json" "strings" + "sync" "testing" "github.com/cloudwego/eino/components/model" @@ -11,6 +13,7 @@ import ( "github.com/sundynix/sundynix-dispatcher/internal/harness" "github.com/sundynix/sundynix-dispatcher/internal/llm" + "github.com/sundynix/sundynix-shared/contract" ) // stubModel 是实现 Eino model.BaseChatModel 的测试桩,固定回一段文本(确定性)。 @@ -59,7 +62,7 @@ func TestComposeConversation(t *testing.T) { sink: fs, } b := &board{query: "你好"} - o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "agent") + o.runComposeConversation(context.Background(), "task_compose", b, "", &execTracer{}, "agent", "模型流式推理") if !strings.Contains(b.answer, "compose 路径") { t.Fatalf("成稿未含模型输出: %q", b.answer) @@ -79,8 +82,70 @@ func TestComposeConversationDegradesToRunAgent(t *testing.T) { sink: fs, } b := &board{query: "你好"} - o.runComposeConversation(context.Background(), "task_degrade", b, "", &execTracer{}, "agent") + o.runComposeConversation(context.Background(), "task_degrade", b, "", &execTracer{}, "agent", "模型流式推理") if !strings.Contains(b.answer, "降级路径") { t.Fatalf("无 ChatModel 应降级 runAgent 出稿: %q", b.answer) } } + +// 图里可以并排好几个 agent(研究/撰写/审查)。之前 runAgent 把轨迹标签写死成 +// "模型流式推理",用户在编排里起的节点名整个丢了 —— 轨迹里三行同名,团队视图里 +// 工位只能退回节点 ID(r/w/rev),谁是谁全靠猜。这里钉住:标签必须来自节点 label。 +type execCapture struct { + mu sync.Mutex + buf [][]byte +} + +func (c *execCapture) PublishExec(_ string, data []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + c.buf = append(c.buf, append([]byte(nil), data...)) + return nil +} +func (c *execCapture) CompleteExec(string) error { return nil } +func (c *execCapture) labels() []string { + c.mu.Lock() + defer c.mu.Unlock() + var out []string + for _, b := range c.buf { + var e contract.ExecEvent + if json.Unmarshal(b, &e) == nil { + out = append(out, e.Label) + } + } + return out +} + +func TestRunAgentUsesNodeLabel(t *testing.T) { + cap := &execCapture{} + o := &Orchestrator{ + pool: &fakeLLM{ready: true, cm: &stubModel{reply: "要点若干"}}, + breaker: harness.NewCircuitBreaker(), + sink: &fakeSink{}, + exec: cap, + } + o.runAgent(context.Background(), "task_label", &board{query: "选型"}, "", o.tracer("task_label"), "agent:r", "研究 Agent") + + got := strings.Join(cap.labels(), "|") + if !strings.Contains(got, "研究 Agent") { + t.Fatalf("轨迹标签应为节点名「研究 Agent」,实际: %q", got) + } + if strings.Contains(got, "模型流式推理") { + t.Fatalf("不该再出现写死的兜底标签: %q", got) + } +} + +func TestRunAgentLabelFallback(t *testing.T) { + cap := &execCapture{} + o := &Orchestrator{ + pool: &fakeLLM{ready: true, cm: &stubModel{reply: "x"}}, + breaker: harness.NewCircuitBreaker(), + sink: &fakeSink{}, + exec: cap, + } + o.runAgent(context.Background(), "task_fb", &board{query: "q"}, "", o.tracer("task_fb"), "agent:x", "") + + if got := strings.Join(cap.labels(), "|"); !strings.Contains(got, "模型流式推理") { + t.Fatalf("节点没起名时应回兜底标签: %q", got) + } +} diff --git a/sundynix-dispatcher/internal/eino/coordinator.go b/sundynix-dispatcher/internal/eino/coordinator.go index f89396f..32e48aa 100644 --- a/sundynix-dispatcher/internal/eino/coordinator.go +++ b/sundynix-dispatcher/internal/eino/coordinator.go @@ -222,13 +222,13 @@ func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *boa tcm := o.pool.ToolCallingModel() if tcm == nil || len(specs) == 0 { tr.info(node, "system", "协调者降级", "模型不支持函数调用或未配置专家,退回普通对话") - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调")) return } specialists := o.buildSpecialists(ctx, specs, b, taskID, tr) if len(specialists) == 0 { tr.info(node, "system", "协调者降级", "无可用专家,退回普通对话") - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调")) return } @@ -240,7 +240,7 @@ func (o *Orchestrator) runCoordinator(ctx context.Context, taskID string, b *boa }) if err != nil { tr.emit(node, "model", "error", "构建协调者", err.Error(), 0) - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "多智能体协调")) return } diff --git a/sundynix-dispatcher/internal/eino/graph.go b/sundynix-dispatcher/internal/eino/graph.go index 2291a23..a3ff4b8 100644 --- a/sundynix-dispatcher/internal/eino/graph.go +++ b/sundynix-dispatcher/internal/eino/graph.go @@ -132,7 +132,10 @@ func (o *Orchestrator) execToolNode(ctx context.Context, taskID string, n dsl.No } // runAgent 执行 agent/模型节点:据黑板拼消息 → 流式回流 token → 累计成稿。 -func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node string) { +// label 是这个节点在轨迹里的显示名,一律由调用方按 labelOf(n, 兜底) 传入: +// 图里可以并排好几个 agent,写死成同一个名字的话轨迹/团队视图里根本分不出谁是谁。 +func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, system string, tr *execTracer, node, label string) { + label = firstNonEmpty(label, "模型流式推理") rc := &RunCtx{ System: firstNonEmpty(system, defaultAgentSystem), Query: b.query, @@ -155,7 +158,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy return } } - tr.emit(node, "model", "start", "模型流式推理", "", 0) + tr.emit(node, "model", "start", label, "", 0) t0 := time.Now() n := 0 var produced strings.Builder // 本节点自身产出(用于沿图向下游传递) @@ -184,7 +187,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy tr.info(node, "model", "推理过程", fmt.Sprintf("思考 %d 字:%s", len([]rune(rc)), truncate(rc, 200))) } if err != nil { - tr.emit(node, "model", "error", "模型流式推理", err.Error(), time.Since(t0).Milliseconds()) + tr.emit(node, "model", "error", label, err.Error(), time.Since(t0).Milliseconds()) // 未产出任何 token 即失败 → 标记致命错,让任务判 failed(暴露原因,便于监控告警), // 而非静默 done-空。已流出部分 token 的中断也算失败(结果不完整)。 if b.fatalErr == nil { @@ -200,7 +203,7 @@ func (o *Orchestrator) runAgent(ctx context.Context, taskID string, b *board, sy tr.info(node, "system", "输出护栏", fmt.Sprintf("已脱敏 %d 处疑似密钥/PII", red.Hits())) } o.recordAgentOutput(b, produced.String()) // 产出入黑板:成当前成稿 + 供下游接力 - tr.emit(node, "model", "end", "模型流式推理", + tr.emit(node, "model", "end", label, fmt.Sprintf("%d tokens / %d 字", n, len([]rune(produced.String()))), time.Since(t0).Milliseconds()) } diff --git a/sundynix-dispatcher/internal/eino/react_agent.go b/sundynix-dispatcher/internal/eino/react_agent.go index d92bffe..2d90d3c 100644 --- a/sundynix-dispatcher/internal/eino/react_agent.go +++ b/sundynix-dispatcher/internal/eino/react_agent.go @@ -174,7 +174,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar tools := o.agentTools(b, taskID, tr) if tcm == nil || len(tools) == 0 { tr.info(node, "system", "ReAct 降级", "模型不支持函数调用或无可用工具,退回普通对话") - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "ReAct 智能体")) return } @@ -188,7 +188,7 @@ func (o *Orchestrator) runReactAgent(ctx context.Context, taskID string, b *boar }) if err != nil { tr.emit(node, "model", "error", "构建 ReAct 智能体", err.Error(), 0) - o.runAgent(ctx, taskID, b, system, tr, node) + o.runAgent(ctx, taskID, b, system, tr, node, labelOf(n, "ReAct 智能体")) return }