diff --git a/.claude/settings.json b/.claude/settings.json index e02ee52c..e6bc4a38 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "PowerShell(uv --version)", "PowerShell(netstat -ano)", "PowerShell(Get-NetTCPConnection -LocalPort 8001 -ErrorAction SilentlyContinue)", - "PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })" + "PowerShell(Get-Process -Id 20560 -ErrorAction SilentlyContinue | Select-Object Id,ProcessName,Path,StartTime; try { \\(Invoke-WebRequest -Uri \"http://127.0.0.1:8001/health\" -UseBasicParsing -TimeoutSec 5\\).Content } catch { \"health check failed: $_\" })", + "Bash(curl -s -X POST -H \"Content-Type: application/json\" -d '{\"username\":\"demo\"}' http://localhost:8001/api/v1/auth/login/username)" ] } } diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 461a006e..dce9b6ca 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -24,6 +24,10 @@ export interface ChainCreatePayload { seats: ChainSeat[]; /** Optional layered orchestration (agent_id groups). null/absent = linear. */ stages?: string[][] | null; + /** Optional per-stage goals aligned to `stages` (dag-only). null = none. */ + stageGoals?: (string | null)[] | null; + /** Optional coordinator orchestration prompt (dag-only). null/"" = none. */ + coordinatorPrompt?: string | null; } /** Partial update — send only what changed. */ @@ -33,6 +37,10 @@ export interface ChainUpdatePayload { seats?: ChainSeat[]; /** Send `null` to clear layering back to linear; omit to leave unchanged. */ stages?: string[][] | null; + /** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */ + stageGoals?: (string | null)[] | null; + /** 写给总控的编排提示;send `null`/"" to clear, omit to leave. */ + coordinatorPrompt?: string | null; /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ isPublic?: boolean; } @@ -60,6 +68,8 @@ interface ServerChain { description: string | null; seats: ChainSeat[]; stages?: string[][] | null; + stage_goals?: (string | null)[] | null; + coordinator_prompt?: string | null; is_public?: boolean; is_mine?: boolean; created_at: string | null; @@ -73,6 +83,8 @@ function toChain(s: ServerChain): BusinessChain { description: s.description ?? undefined, seats: Array.isArray(s.seats) ? s.seats : [], stages: Array.isArray(s.stages) ? s.stages : null, + stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null, + coordinatorPrompt: s.coordinator_prompt ?? null, isPublic: s.is_public ?? false, // 后端缺省时按「我的」处理(向后兼容旧响应)。 isMine: s.is_mine ?? true, @@ -88,6 +100,8 @@ function fromCreate(p: ChainCreatePayload) { ...(p.description !== undefined ? { description: p.description } : {}), seats: p.seats, ...(p.stages !== undefined ? { stages: p.stages } : {}), + ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), + ...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}), }; } @@ -98,6 +112,9 @@ function fromUpdate(p: ChainUpdatePayload) { if (p.seats !== undefined) out.seats = p.seats; // Forward stages even when explicitly null (clears layering server-side). if (p.stages !== undefined) out.stages = p.stages; + // Forward stage goals even when explicitly null (clears goals server-side). + if (p.stageGoals !== undefined) out.stage_goals = p.stageGoals; + if (p.coordinatorPrompt !== undefined) out.coordinator_prompt = p.coordinatorPrompt; if (p.isPublic !== undefined) out.is_public = p.isPublic; return out; } diff --git a/frontend-web/src/roundtable-planning/components/AgentDetailSheet.tsx b/frontend-web/src/roundtable-planning/components/AgentDetailSheet.tsx new file mode 100644 index 00000000..45165f8f --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/AgentDetailSheet.tsx @@ -0,0 +1,151 @@ +/** + * 智能体详情抽屉(右侧 Sheet)。 + * + * 业务链条配置页:左栏候选卡片点击卡片本体、右栏席位点「查看详情」都打开这个抽屉, + * 展示该智能体的详情(与「智能体」详情页展示的内容差不多):名称 / 模型 / 描述 / + * 标签 / 技能 + 工具组 / 人设(SOUL.md)。 + * + * 详情按需拉取:`useAgent(open ? agentId : null)` —— 抽屉打开时才请求 `GET /api/agents/{id}` + * (该接口才返回 soul),关闭不请求。字段渲染对齐 `components/workspace/agents/agent-card.tsx`。 + */ +import { Bot, Globe, Loader2, Lock } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { TagChipList } from "@/components/workspace/tags/tag-chip"; +import { useAgent } from "@/core/agents"; +import { desensitize } from "@/lib/desensitize"; + +export function AgentDetailSheet({ + agentId, + /** 卡片/席位上的名称快照,详情还没拉回来时先用它占位。 */ + fallbackName, + open, + onOpenChange, +}: { + agentId: string | null; + fallbackName?: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + // 抽屉打开时才拉详情(含 soul);关闭传 null 让 useAgent 不发请求。 + const { agent, isLoading } = useAgent(open ? agentId : null); + const name = agent?.name || fallbackName || agentId || ""; + const skillItems = agent?.skills ?? []; + const toolGroups = agent?.tool_groups ?? []; + const hasSkillsOrTools = skillItems.length > 0 || toolGroups.length > 0; + + return ( + + + + + + + + {name} + {agent?.model && ( + + {desensitize(agent.model)} + + )} + + 智能体详情 + + +
+ {isLoading && !agent ? ( +
+ + 正在加载详情… +
+ ) : !agent ? ( +
+ 未找到该智能体的详情(可能已被删除)。 +
+ ) : ( + <> + {/* 状态徽标 */} +
+ {agent.builtin && ( + 内置 + )} + {agent.published && ( + + + 已发布 + + )} + {agent.is_private && ( + + + 私密 + + )} +
+ + {/* 描述 */} +
+

+ 描述 +

+

+ {agent.description?.trim() || "(无描述)"} +

+
+ + {/* 标签 */} + {agent.tags && agent.tags.length > 0 && ( +
+

+ 标签 +

+ +
+ )} + + {/* 技能 / 工具组 */} + {hasSkillsOrTools && ( +
+

+ 技能与工具 +

+
+ {toolGroups.map((group) => ( + + {desensitize(group)} + + ))} + {skillItems.map((skill) => ( + + {desensitize(skill)} + + ))} +
+
+ )} + + {/* 人设(SOUL.md) */} + {agent.soul?.trim() && ( +
+

+ 人设 +

+
+                    {agent.soul}
+                  
+
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 136cd561..05dda9b6 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -108,13 +108,24 @@ export function BusinessChainPicker({ ); const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS; - // 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在 + // 选中链条若有分层、且过滤到存活席位后**仍含并行 stage,或任一 stage 带「本阶段目标」** + // → 走 dag。并行要 dag 才能并行调度;带目标也要 dag —— 目标靠总控**逐阶段派活**注入 + // (buildDagStageLeaderTask),chain 模式不消费目标,故串行带目标链同样走 dag。此时让用户在 // 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。 const dagPlan = useMemo(() => { if (!selectedChain?.stages || selectedChain.stages.length === 0) return null; const surviveIds = survivingSeats.map((s) => s.agent_id); - const plan = buildPlanFromStages(selectedChain.stages, surviveIds); - return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null; + const plan = buildPlanFromStages( + selectedChain.stages, + surviveIds, + true, + selectedChain.stageGoals, + selectedChain.coordinatorPrompt, + ); + const hasParallel = plan.stages.some((st) => st.agentIds.length > 1); + const hasGoal = plan.stages.some((st) => (st.goal?.trim() ?? "") !== ""); + const hasCoord = (plan.coordinatorPrompt?.trim() ?? "") !== ""; + return hasParallel || hasGoal || hasCoord ? plan : null; }, [selectedChain, survivingSeats]); const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast"); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index f27035df..8dcc2820 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -1870,6 +1870,8 @@ export function useStep2Orchestration( totalStages: plan.stages.length, seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })), isFirst: s === 0, + goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空) + coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入) }); const leaderAborter = new AbortController(); activeAbortersRef.current.add(leaderAborter); @@ -1984,6 +1986,7 @@ export function useStep2Orchestration( seedMessage, priorDeliveries.length > 0, dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文 + stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐) ); const subAborter = new AbortController(); diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 33cee3ee..eac12627 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -33,6 +33,18 @@ export interface BusinessChain { * (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。 */ stages?: string[][] | null; + /** + * 可选「每阶段目标」:与 `stages` **同下标对齐**(`stageGoals[i]` 是 `stages[i]` 的 + * 目标),告诉总控本阶段要产出什么、如何依赖上一阶段产出。仅 dag 模式注入;`stages` + * 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。 + */ + stageGoals?: (string | null)[] | null; + /** + * 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、 + * 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。 + * 空 / null = 不注入。 + */ + coordinatorPrompt?: string | null; /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ isPublic?: boolean; /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts index fcdb5286..c0364e55 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts @@ -12,14 +12,19 @@ import { moveSeatToStage, moveStage, removeSeat, + setStageGoal, splitSeatToNewStage, stageGroupsToSeats, + stageGroupsToStageGoals, stageGroupsToStagePayload, type StageGroups, } from "./chain-stages"; const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" }); -const ids = (groups: StageGroups) => groups.map((g) => g.map((s) => s.agent_id)); +/** 把席位数组的数组包成 StageGroup[](测试构造便捷)。 */ +const grp = (...stages: ReturnType[][]): StageGroups => + stages.map((seats) => ({ seats })); +const ids = (groups: StageGroups) => groups.map((g) => g.seats.map((s) => s.agent_id)); // ── chainToStageGroups ──────────────────────────────────────────────────────── @@ -52,32 +57,75 @@ test("chainToStageGroups: stages that resolve to nothing → linear fallback", ( // ── serialization ───────────────────────────────────────────────────────────── test("stageGroupsToSeats flattens in order", () => { - const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + const groups = grp([seat("a"), seat("b")], [seat("c")]); assert.deepEqual( stageGroupsToSeats(groups).map((s) => s.agent_id), ["a", "b", "c"], ); }); -test("stageGroupsToStagePayload returns null when all singletons (linear)", () => { - const groups: StageGroups = [[seat("a")], [seat("b")]]; +test("stageGroupsToStagePayload returns null when all singletons AND no goals (linear)", () => { + const groups = grp([seat("a")], [seat("b")]); assert.equal(stageGroupsToStagePayload(groups), null); }); +test("stageGroupsToStagePayload returns explicit stages when serial but a goal is set", () => { + // 串行单席位 + 任一「本阶段目标」→ 落显式分层(目标必须挂在显式 stages 上,否则丢失)。 + const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "分析目的"); + assert.deepEqual(stageGroupsToStagePayload(groups), [["a"], ["b"]]); +}); + +test("stageGroupsToStagePayload forceExplicit lifts a pure-linear chain to explicit stages", () => { + // 无并行无目标,但调用方因「总控编排提示」要求强制显式分层(否则提示在线性链里被忽略)。 + const groups = grp([seat("a")], [seat("b")]); + assert.equal(stageGroupsToStagePayload(groups), null); + assert.deepEqual(stageGroupsToStagePayload(groups, true), [["a"], ["b"]]); + assert.deepEqual(stageGroupsToStageGoals(groups, true), [null, null]); +}); + test("stageGroupsToStagePayload returns groups when any stage is parallel", () => { - const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + const groups = grp([seat("a"), seat("b")], [seat("c")]); assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]); }); test("stageGroupsToStagePayload ignores empty stages", () => { - const groups: StageGroups = [[seat("a"), seat("b")], []]; + const groups = grp([seat("a"), seat("b")], []); assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"]]); }); +// ── stage goals ───────────────────────────────────────────────────────────── + +test("stageGroupsToStageGoals returns null when linear AND no goals", () => { + const groups = grp([seat("a")], [seat("b")]); + assert.equal(stageGroupsToStageGoals(groups), null); +}); + +test("stageGroupsToStageGoals persists goals on a serial chain when any goal is set", () => { + // 旧行为:串行 → 目标折叠为 null(被丢)。现改为:填了目标的串行链也落库,与显式分层对齐, + // 否则保存 / 回显皆失(用户反馈的 bug)。stage 1 无目标 → null。 + const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, " 分析目的 "); + assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]); +}); + +test("stageGroupsToStageGoals aligns to surviving stages, trims, nulls empties", () => { + let groups = grp([seat("a"), seat("b")], [seat("c")]); + groups = setStageGoal(groups, 0, " 分析目的 "); + // stage 1 无目标 → null;stage 0 目标 trim。 + assert.deepEqual(stageGroupsToStageGoals(groups), ["分析目的", null]); +}); + +test("setStageGoal is immutable + out-of-range no-op", () => { + const groups = grp([seat("a"), seat("b")], [seat("c")]); + const next = setStageGoal(groups, 1, "g1"); + assert.equal(next[1].goal, "g1"); + assert.equal(groups[1].goal, undefined); // 原对象不变 + assert.equal(setStageGoal(groups, 9, "x"), groups); // 越界返回原引用 +}); + // ── lookups ─────────────────────────────────────────────────────────────────── test("findStageIndexOfSeat / countSeats", () => { - const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; + const groups = grp([seat("a"), seat("b")], [seat("c")]); assert.equal(findStageIndexOfSeat(groups, "c"), 1); assert.equal(findStageIndexOfSeat(groups, "zzz"), -1); assert.equal(countSeats(groups), 3); @@ -86,60 +134,97 @@ test("findStageIndexOfSeat / countSeats", () => { // ── mutations ───────────────────────────────────────────────────────────────── test("addSeatAsNewStage appends a singleton stage, dedupes", () => { - let g: StageGroups = [[seat("a")]]; + let g = grp([seat("a")]); g = addSeatAsNewStage(g, seat("b")); assert.deepEqual(ids(g), [["a"], ["b"]]); g = addSeatAsNewStage(g, seat("a")); // dup → no-op assert.deepEqual(ids(g), [["a"], ["b"]]); }); -test("addSeatToStage inserts into a stage at index", () => { - const g = addSeatToStage([[seat("a")], [seat("c")]], seat("b"), 0, 0); +test("addSeatToStage inserts into a stage at index, preserves goal", () => { + const base = setStageGoal(grp([seat("a")], [seat("c")]), 0, "g0"); + const g = addSeatToStage(base, seat("b"), 0, 0); assert.deepEqual(ids(g), [["b", "a"], ["c"]]); + assert.equal(g[0].goal, "g0"); // goal 保留 }); test("removeSeat drops the seat and prunes an emptied stage", () => { - const g = removeSeat([[seat("a")], [seat("b"), seat("c")]], "a"); + const g = removeSeat(grp([seat("a")], [seat("b"), seat("c")]), "a"); assert.deepEqual(ids(g), [["b", "c"]]); }); test("moveSeatToStage merges across stages and prunes empty source", () => { - const g = moveSeatToStage([[seat("a")], [seat("b")]], "a", 1); + const g = moveSeatToStage(grp([seat("a")], [seat("b")]), "a", 1); assert.deepEqual(ids(g), [["b", "a"]]); }); test("moveSeatToStage within same stage reorders", () => { - const g = moveSeatToStage([[seat("a"), seat("b"), seat("c")]], "c", 0, 0); + const g = moveSeatToStage(grp([seat("a"), seat("b"), seat("c")]), "c", 0, 0); assert.deepEqual(ids(g), [["c", "a", "b"]]); }); -test("splitSeatToNewStage pulls a seat out into a new following stage", () => { - const g = splitSeatToNewStage([[seat("a"), seat("b")], [seat("c")]], "b"); +test("splitSeatToNewStage pulls a seat out; original keeps goal, split has none", () => { + const base = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 0, "g0"); + const g = splitSeatToNewStage(base, "b"); assert.deepEqual(ids(g), [["a"], ["b"], ["c"]]); + assert.equal(g[0].goal, "g0"); // 原 stage 保留目标 + assert.equal(g[1].goal, undefined); // 拆出的新单席位 stage 无目标 }); test("splitSeatToNewStage on an already-solo seat is a no-op", () => { - const g = splitSeatToNewStage([[seat("a")], [seat("b")]], "a"); + const g = splitSeatToNewStage(grp([seat("a")], [seat("b")]), "a"); assert.deepEqual(ids(g), [["a"], ["b"]]); }); -test("moveStage reorders serial stages", () => { - const g = moveStage([[seat("a")], [seat("b")], [seat("c")]], 2, -1); +test("moveStage reorders serial stages, goal travels with the stage", () => { + const base = setStageGoal(grp([seat("a")], [seat("b")], [seat("c")]), 2, "gc"); + const g = moveStage(base, 2, -1); assert.deepEqual(ids(g), [["a"], ["c"], ["b"]]); + assert.equal(g[1].goal, "gc"); // c 的目标随它移到了第 2 位 }); test("addEmptyStage / deleteStage", () => { - let g = addEmptyStage([[seat("a")]]); + let g = addEmptyStage(grp([seat("a")])); assert.deepEqual(ids(g), [["a"], []]); g = deleteStage(g, 1); assert.deepEqual(ids(g), [["a"]]); }); -// round-trip: editor groups → payload → reload preserves structure -test("round-trip: groups → {seats, stages} → chainToStageGroups", () => { - const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]]; +// round-trip: editor groups → payload → reload preserves structure + goals +test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups", () => { + const groups = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 1, "依赖上一阶段"); const seats = stageGroupsToSeats(groups); const stages = stageGroupsToStagePayload(groups); - const restored = chainToStageGroups({ seats, stages }); + const stageGoals = stageGroupsToStageGoals(groups); + const restored = chainToStageGroups({ seats, stages, stageGoals }); assert.deepEqual(ids(restored), [["a", "b"], ["c"]]); + assert.equal(restored[0].goal, undefined); + assert.equal(restored[1].goal, "依赖上一阶段"); +}); + +// round-trip on a **serial single-seat chain with a goal** (the reported bug): +// save must persist stages+goals, and reload must show the goal back on its stage. +test("round-trip: serial single-seat chain + goal preserves goal across save/reload", () => { + const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "作战目的分析"); + const seats = stageGroupsToSeats(groups); + const stages = stageGroupsToStagePayload(groups); + const stageGoals = stageGroupsToStageGoals(groups); + // 不再折叠为 null —— 串行+目标落显式分层。 + assert.deepEqual(stages, [["a"], ["b"]]); + assert.deepEqual(stageGoals, ["作战目的分析", null]); + const restored = chainToStageGroups({ seats, stages, stageGoals }); + assert.deepEqual(ids(restored), [["a"], ["b"]]); + assert.equal(restored[0].goal, "作战目的分析"); + assert.equal(restored[1].goal, undefined); +}); + +test("chainToStageGroups: goal drops with a stage whose agents are all gone", () => { + // stages[0] 全是 ghost → 整组(含 g0)丢弃;存活的 ["c"] 应拿到 g1,不是 g0。 + const restored = chainToStageGroups({ + seats: [seat("c")], + stages: [["ghosta", "ghostb"], ["c"]], + stageGoals: ["g0", "g1"], + }); + assert.deepEqual(ids(restored), [["c"]]); + assert.equal(restored[0].goal, "g1"); }); diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts index cd55cbc4..117ca058 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -1,59 +1,99 @@ /** * 业务链条「分层编排」编辑器的纯数据操作。 * - * 编辑器内部用 `StageGroups`(`ChainSeat[][]`,每个内层数组 = 一个 stage,stage 内 - * 并行、stage 间串行)作为**单一数据源**: + * 编辑器内部用 `StageGroups`(`StageGroup[]`,每个 `StageGroup` = 一个 stage,含其 + * 并行席位 `seats` 与可选「本阶段目标」`goal`;stage 内并行、stage 间串行)作为 + * **单一数据源**: * - 展平得到落库的有序 `seats`({@link stageGroupsToSeats}); - * - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。 + * - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload}); + * - 折叠得到落库的 `stageGoals`({@link stageGroupsToStageGoals}),与 `stages` 同下标对齐。 * + * `goal` 挂在 stage 对象上,随 stage 在增删改 / 重排中自动跟随,避免与席位错位。 * 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。 * 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。 */ import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain"; -/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */ -export type StageGroups = ChainSeat[][]; +/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */ +export interface StageGroup { + seats: ChainSeat[]; + /** 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 */ + goal?: string; +} + +/** 编辑器内部的分层结构:每个元素是一个并行 stage,外层顺序即串行顺序。 */ +export type StageGroups = StageGroup[]; /** * 从一条业务链条还原分层结构。 * - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组; - * 分层全空时回退到线性。 - * - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。 + * `goal` 按**原始 stage 下标**从 `stageGoals` 取,随被丢弃的组一并丢弃;分层全空时回退到线性。 + * - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层(无 goal)。 */ -export function chainToStageGroups(chain: Pick): StageGroups { +export function chainToStageGroups( + chain: Pick, +): StageGroups { const seats = chain.seats ?? []; const byId = new Map(seats.map((s) => [s.agent_id, s])); + const goals = chain.stageGoals ?? null; if (chain.stages && chain.stages.length > 0) { const groups: StageGroups = []; - for (const group of chain.stages) { + chain.stages.forEach((group, idx) => { const mapped = group.map((id) => byId.get(id)).filter((s): s is ChainSeat => Boolean(s)); - if (mapped.length > 0) groups.push(mapped); - } + if (mapped.length === 0) return; // 整组解析不到席位 → 连同其 goal 一并丢弃 + const goal = goals?.[idx]?.trim() || undefined; + groups.push(goal ? { seats: mapped, goal } : { seats: mapped }); + }); if (groups.length > 0) return groups; } - return seats.map((s) => [s]); + return seats.map((s) => ({ seats: [s] })); } /** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */ export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] { - return groups.flat(); + return groups.flatMap((g) => g.seats); +} + +/** 一组里是否有人填了「本阶段目标」(trim 后非空)。 */ +function hasAnyGoal(groups: StageGroups): boolean { + return groups.some((g) => (g.goal?.trim() ?? "") !== ""); } /** - * 折叠成落库的 `stages`。**全是单元素 stage(无并行)时返回 null** —— 语义上就是线性 - * 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位 - * 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。 + * 折叠成落库的 `stages`。返回 null = 线性链(Step 2 走 chain 模式,老行为不变);返回显式 + * 分层 = Step 2 走 dag 模式。判定:**只要有一个 stage ≥2 席位(并行),或用户给任一 stage + * 填了「本阶段目标」,就落显式分层**;否则(纯单席位且无目标)落 null。空 stage 已被忽略。 + * + * 为什么「填了目标也要落显式分层」:每阶段目标按 stage 下标对齐落库(见 + * {@link stageGroupsToStageGoals}),只能挂在显式 `stages` 上。若串行单席位链照旧折叠为 + * null,目标就会被一并丢掉——保存丢、回显也丢(正是用户反馈的「本阶段目标没保存上 / 没回显」)。 + * 填了目标的串行链因此落成「每席位一层」的显式分层,由总控**逐阶段按目标派活**(dag 模式)。 + * + * `forceExplicit`:调用方还有别的「必须走 dag」的理由时(如填了链条级「总控编排提示」, + * 仅 dag 模式注入)传 true,强制落显式分层,避免该提示在纯线性链里被静默忽略。 */ -export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null { - const nonEmpty = groups.filter((g) => g.length > 0); - const hasParallel = nonEmpty.some((g) => g.length > 1); - if (!hasParallel) return null; - return nonEmpty.map((g) => g.map((s) => s.agent_id)); +export function stageGroupsToStagePayload(groups: StageGroups, forceExplicit = false): string[][] | null { + const nonEmpty = groups.filter((g) => g.seats.length > 0); + const hasParallel = nonEmpty.some((g) => g.seats.length > 1); + if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null; + return nonEmpty.map((g) => g.seats.map((s) => s.agent_id)); +} + +/** + * 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定** + * (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。 + * 每个存活组取其 `goal`(trim 后为空则该位为 null)。只要落了 `stages`,就同步落目标。 + */ +export function stageGroupsToStageGoals(groups: StageGroups, forceExplicit = false): (string | null)[] | null { + const nonEmpty = groups.filter((g) => g.seats.length > 0); + const hasParallel = nonEmpty.some((g) => g.seats.length > 1); + if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null; + return nonEmpty.map((g) => g.goal?.trim() || null); } /** 该席位所在 stage 下标;不存在返回 -1。 */ export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number { - return groups.findIndex((g) => g.some((s) => s.agent_id === agentId)); + return groups.findIndex((g) => g.seats.some((s) => s.agent_id === agentId)); } /** 席位是否已在任意 stage 中。 */ @@ -63,7 +103,13 @@ export function seatExistsInGroups(groups: StageGroups, agentId: string): boolea /** 总席位数(用于 2–8 约束)。 */ export function countSeats(groups: StageGroups): number { - return groups.reduce((n, g) => n + g.length, 0); + return groups.reduce((n, g) => n + g.seats.length, 0); +} + +/** 设置某个 stage 的「本阶段目标」(不可变,供编辑器 onChange)。 */ +export function setStageGoal(groups: StageGroups, stageIdx: number, goal: string): StageGroups { + if (stageIdx < 0 || stageIdx >= groups.length) return groups; + return groups.map((g, i) => (i === stageIdx ? { ...g, goal } : g)); } // ── 变换(均返回新结构,不可变)────────────────────────────────────────────── @@ -71,10 +117,10 @@ export function countSeats(groups: StageGroups): number { /** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */ export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups { if (seatExistsInGroups(groups, seat.agent_id)) return groups; - return [...groups, [seat]]; + return [...groups, { seats: [seat] }]; } -/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */ +/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。保留目标 stage 的 goal。 */ export function addSeatToStage( groups: StageGroups, seat: ChainSeat, @@ -85,18 +131,18 @@ export function addSeatToStage( if (stageIdx < 0 || stageIdx >= groups.length) return groups; return groups.map((g, i) => { if (i !== stageIdx) return g; - const next = [...g]; + const next = [...g.seats]; const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length)); next.splice(at, 0, seat); - return next; + return { ...g, seats: next }; }); } -/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */ +/** 移除一个席位;该 stage 因此变空则连 stage(含其 goal)一起删掉。 */ export function removeSeat(groups: StageGroups, agentId: string): StageGroups { return groups - .map((g) => g.filter((s) => s.agent_id !== agentId)) - .filter((g) => g.length > 0); + .map((g) => ({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) })) + .filter((g) => g.seats.length > 0); } /** @@ -112,50 +158,53 @@ export function moveSeatToStage( ): StageGroups { const from = findStageIndexOfSeat(groups, agentId); if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups; - const seat = groups[from].find((s) => s.agent_id === agentId); + const seat = groups[from].seats.find((s) => s.agent_id === agentId); if (!seat) return groups; - // 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。 + // 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。goal 始终随 stage 保留。 const targetRef = groups[targetStageIdx]; let next: StageGroups = groups.map((g) => - g === targetRef ? g : g.filter((s) => s.agent_id !== agentId), + g === targetRef ? g : { ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }, ); next = next.map((g) => { if (g !== targetRef) return g; - if (g.some((s) => s.agent_id === agentId)) { + if (g.seats.some((s) => s.agent_id === agentId)) { // stage 内移动:先抽出再按 atIndex 插回。 - const without = g.filter((s) => s.agent_id !== agentId); + const without = g.seats.filter((s) => s.agent_id !== agentId); const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length)); without.splice(at, 0, seat); - return without; + return { ...g, seats: without }; } - const merged = [...g]; + const merged = [...g.seats]; const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length)); merged.splice(at, 0, seat); - return merged; + return { ...g, seats: merged }; }); - return next.filter((g) => g.length > 0); + return next.filter((g) => g.seats.length > 0); } -/** 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。 */ +/** + * 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。 + * 原 stage 保留其 `goal`(目标描述的是这一批),拆出的新单席位 stage 无 goal。 + */ export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups { const from = findStageIndexOfSeat(groups, agentId); if (from < 0) return groups; - const seat = groups[from].find((s) => s.agent_id === agentId); + const seat = groups[from].seats.find((s) => s.agent_id === agentId); if (!seat) return groups; - if (groups[from].length === 1) return groups; // 已经独占一层,无需拆 + if (groups[from].seats.length === 1) return groups; // 已经独占一层,无需拆 const out: StageGroups = []; groups.forEach((g, i) => { if (i === from) { - out.push(g.filter((s) => s.agent_id !== agentId)); - out.push([seat]); + out.push({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }); + out.push({ seats: [seat] }); } else { out.push(g); } }); - return out.filter((g) => g.length > 0); + return out.filter((g) => g.seats.length > 0); } -/** 整段 stage 上移 / 下移(改串行顺序)。 */ +/** 整段 stage 上移 / 下移(改串行顺序)。goal 随 stage 一起移动。 */ export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups { const target = stageIdx + dir; if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) { @@ -168,7 +217,7 @@ export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): S /** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */ export function addEmptyStage(groups: StageGroups): StageGroups { - return [...groups, []]; + return [...groups, { seats: [] }]; } /** 删除整个 stage(含其席位)。 */ diff --git a/frontend-web/src/roundtable-planning/lib/dag-types.test.ts b/frontend-web/src/roundtable-planning/lib/dag-types.test.ts index 4b90402c..96a1f2ab 100644 --- a/frontend-web/src/roundtable-planning/lib/dag-types.test.ts +++ b/frontend-web/src/roundtable-planning/lib/dag-types.test.ts @@ -188,6 +188,35 @@ test("a chain plan validates clean against its surviving roster", () => { assert.equal(res.ok, true); }); +test("buildPlanFromStages stamps per-stage goals (trimmed), aligned to source index", () => { + const plan = buildPlanFromStages([["a", "b"], ["c"]], undefined, true, [" 目的 ", ""]); + assert.equal(plan.stages[0].goal, "目的"); // trim + assert.equal(plan.stages[1].goal, undefined); // 空 → 无 goal +}); + +test("buildPlanFromStages drops a filtered-out stage's goal, keeps survivor's by source index", () => { + // stage 0 全被过滤 → 连同 goals[0] 丢弃;存活的 ["c"] 是源下标 1 → 拿 goals[1]。 + const plan = buildPlanFromStages([["x"], ["c"]], ["c"], true, ["g0", "g1"]); + assert.deepEqual(plan.stages.map((s) => s.agentIds), [["c"]]); + assert.equal(plan.stages[0].goal, "g1"); +}); + +test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)", () => { + const plan = buildPlanFromStages([["a"], ["b"]]); + assert.equal(plan.stages[0].goal, undefined); + assert.equal(plan.stages[1].goal, undefined); +}); + +test("buildPlanFromStages stamps trimmed coordinatorPrompt onto the plan", () => { + const plan = buildPlanFromStages([["a"], ["b"]], undefined, true, null, " 按阶段派活 "); + assert.equal(plan.coordinatorPrompt, "按阶段派活"); +}); + +test("buildPlanFromStages leaves coordinatorPrompt undefined when blank/absent", () => { + assert.equal(buildPlanFromStages([["a"], ["b"]]).coordinatorPrompt, undefined); + assert.equal(buildPlanFromStages([["a"]], undefined, true, null, " ").coordinatorPrompt, undefined); +}); + // ── buildDependencySummary (§3.1 依赖摘要注入) ───────────────────────────────── test("buildDependencySummary is empty for no deliveries (first stage)", () => { @@ -424,3 +453,87 @@ test("buildDagStageLeaderTask insists on actually calling the tool (not narratin assert.match(task, /真正调用 agent_orchestration 工具/); assert.match(task, /不要.*只用文字/); }); + +// ── per-stage goal injection (本阶段目标) ────────────────────────────────────── + +test("buildDagStageLeaderTask injects the stage goal when provided", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 1, + totalStages: 2, + seats: [{ agentId: "actor", name: "行为体分析" }], + isFirst: false, + goal: "根据上一阶段的各个目的,分析出行为体", + }); + assert.match(task, /本阶段目标/); + assert.match(task, /分析出行为体/); + assert.match(task, /显式承接上一阶段/); +}); + +test("buildDagStageLeaderTask omits the goal line when goal is empty/blank", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 0, + totalStages: 1, + seats: [{ agentId: "a", name: "甲" }], + isFirst: true, + goal: " ", + }); + assert.doesNotMatch(task, /本阶段目标/); +}); + +// ── coordinator prompt injection (写给总控的编排提示) ───────────────────────── + +test("buildDagStageLeaderTask injects the coordinator prompt as a top-priority guideline", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 0, + totalStages: 2, + seats: [{ agentId: "a", name: "甲" }], + isFirst: true, + coordinatorPrompt: "严格按阶段顺序派活,最终做综合收口", + }); + assert.match(task, /总控编排指引/); + assert.match(task, /严格按阶段顺序派活,最终做综合收口/); +}); + +test("buildDagStageLeaderTask injects the coordinator prompt on later stages too", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 1, + totalStages: 2, + seats: [{ agentId: "b", name: "乙" }], + isFirst: false, + coordinatorPrompt: "聚焦本职、显式承接", + }); + assert.match(task, /总控编排指引/); + assert.match(task, /聚焦本职、显式承接/); +}); + +test("buildDagStageLeaderTask omits the coordinator block when prompt is empty/blank", () => { + const task = buildDagStageLeaderTask({ + seedMessage: "x", + stageIndex: 0, + totalStages: 1, + seats: [{ agentId: "a", name: "甲" }], + isFirst: true, + coordinatorPrompt: " ", + }); + assert.doesNotMatch(task, /总控编排指引/); +}); + +test("pickDagSeatTask injects the stage goal in BOTH the exact-match and the miss branches", () => { + const goal = "根据上一阶段的各个目的,分析出行为体"; + const hit = pickDagSeatTask([["actor", "干这个"]], "actor", "行为体", "种子", true, false, goal); + const miss = pickDagSeatTask([], "actor", "行为体", "种子", true, false, goal); + for (const t of [hit, miss]) { + assert.match(t, /本阶段目标/); + assert.match(t, /分析出行为体/); + assert.match(t, /显式承接上一阶段/); + } +}); + +test("pickDagSeatTask omits the goal block when no goal (old plans)", () => { + const t = pickDagSeatTask([], "actor", "行为体", "种子", true); + assert.doesNotMatch(t, /本阶段目标/); +}); diff --git a/frontend-web/src/roundtable-planning/lib/dag-types.ts b/frontend-web/src/roundtable-planning/lib/dag-types.ts index bfcdd3d9..6f1ae7b4 100644 --- a/frontend-web/src/roundtable-planning/lib/dag-types.ts +++ b/frontend-web/src/roundtable-planning/lib/dag-types.ts @@ -19,6 +19,11 @@ export interface OrchestrationStage { id: string; /** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */ agentIds: string[]; + /** + * 可选「本阶段目标」:来自业务链条 `stageGoals`,注入总控派活提示(主)与席位任务 + * (兜底),告诉本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 + */ + goal?: string; } /** 用户编排出的执行计划。stages 顺序即串行顺序。 */ @@ -29,6 +34,12 @@ export interface OrchestrationPlan { stages: OrchestrationStage[]; /** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */ finalSynthesis: boolean; + /** + * 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引 + * (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。 + * 空 = 不注入。 + */ + coordinatorPrompt?: string; } // ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)─────────────────────── @@ -180,19 +191,30 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan { * @param stages 链条分层(每组并行、组间串行)。 * @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。 * @param finalSynthesis 是否收口综合,默认 true。 + * @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal + * (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。 + * @param coordinatorPrompt 可选「总控编排提示」(写给总控的整体派活指引),挂在计划上, + * 注入总控每阶段派活提示。空 = 不带。 */ export function buildPlanFromStages( stages: string[][], allowedIds?: Iterable, finalSynthesis = true, + goals?: ReadonlyArray | null, + coordinatorPrompt?: string | null, ): OrchestrationPlan { const allow = allowedIds ? new Set(allowedIds) : null; const cleaned: OrchestrationStage[] = []; stages.forEach((group, idx) => { const ids = allow ? group.filter((id) => allow.has(id)) : group.slice(); - if (ids.length > 0) cleaned.push({ id: stageId(idx), agentIds: ids }); + if (ids.length === 0) return; + const goal = goals?.[idx]?.trim() || undefined; + cleaned.push(goal ? { id: stageId(idx), agentIds: ids, goal } : { id: stageId(idx), agentIds: ids }); }); - return { mode: "dag", stages: cleaned, finalSynthesis }; + const cp = coordinatorPrompt?.trim() || undefined; + return cp + ? { mode: "dag", stages: cleaned, finalSynthesis, coordinatorPrompt: cp } + : { mode: "dag", stages: cleaned, finalSynthesis }; } /** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */ @@ -330,13 +352,22 @@ export function buildDagStageLeaderTask(args: { /** 本 stage 计划钉死的席位。 */ seats: Array<{ agentId: string; name: string }>; isFirst: boolean; + /** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */ + goal?: string; + /** + * 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 / + * 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。 + */ + coordinatorPrompt?: string; }): string { - const { seedMessage, stageIndex, totalStages, seats, isFirst } = args; + const { seedMessage, stageIndex, totalStages, seats, isFirst, goal, coordinatorPrompt } = args; const ordinal = `${stageIndex + 1}/${totalStages}`; const seatList = seats .map((s) => `「${s.name}」(agent_name: ${s.agentId})`) .join("、"); const isParallel = seats.length > 1; + const goalText = goal?.trim(); + const coordText = coordinatorPrompt?.trim(); const lines: string[] = []; if (isFirst) { lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, ""); @@ -346,10 +377,25 @@ export function buildDagStageLeaderTask(args: { "", ); } + if (coordText) { + lines.push( + "【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:", + coordText, + "", + ); + } lines.push( `本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`, `本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`, - `请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`, + ); + if (goalText) { + lines.push( + `【本阶段目标】${goalText}`, + "请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。", + ); + } + lines.push( + `请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景${goalText ? "、本阶段目标" : ""}与此前交付,为每个席位分配聚焦其职责的本阶段任务。`, "只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。", "⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。", ); @@ -386,6 +432,11 @@ export function pickDagSeatTask( * - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。 */ allowFiles = false, + /** + * 可选「本阶段目标」(兜底注入):总控派活质量差 / 没派到时,席位仍据此知道本阶段要产出 + * 什么、如何依赖上一阶段产出。空 = 不注入。 + */ + goal = "", ): string { const guard = `\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`; @@ -402,15 +453,20 @@ export function pickDagSeatTask( const peerRule = hasPeers ? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。` : ""; + // 本阶段目标(兜底):用户为本阶段写的产出/依赖说明,即便总控没派好,席位也据此对齐本阶段意图。 + const goalRule = goal.trim() + ? `\n\n【本阶段目标】${goal.trim()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。` + : ""; const wanted = seatAgentId.toLowerCase(); const exact = dispatched.find(([name]) => name.toLowerCase() === wanted); if (exact && exact[1]?.trim()) { - return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${fileRule}${peerRule}${guard}`; + return `${bg}请你作为「${seatName}」,完成总控派给你的本阶段任务:\n${exact[1].trim()}${goalRule}${fileRule}${peerRule}${guard}`; } // dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。 return ( `${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` + `此前各阶段席位的交付已在你的会话上下文里,请据此承接。` + + goalRule + fileRule + peerRule + guard diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index a1d484b5..9be1df56 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -30,8 +30,10 @@ import { GitBranch, Globe, GripVertical, + Info, Layers, Loader2, + Network, Plus, Save, SplitSquareVertical, @@ -60,6 +62,12 @@ import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar"; import { listAgents } from "@/core/agents"; @@ -93,13 +101,17 @@ import { moveStage, removeSeat as removeSeatFromGroups, seatExistsInGroups, + setStageGoal, splitSeatToNewStage, stageGroupsToSeats, + stageGroupsToStageGoals, stageGroupsToStagePayload, + type StageGroup, type StageGroups, } from "@/roundtable-planning/lib/chain-stages"; import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants"; import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart"; +import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet"; const POOL_PREFIX = "pool::"; const SEAT_PREFIX = "seat::"; @@ -138,12 +150,20 @@ export default function BusinessChainEditorPage() { const [publishing, setPublishing] = useState(false); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); + // 写给总控的「编排提示」:告诉总控该怎么派活、做什么(仅分层 DAG 模式注入)。 + const [coordinatorPrompt, setCoordinatorPrompt] = useState(""); // 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。 const [stageGroups, setStageGroups] = useState([]); const [saving, setSaving] = useState(false); const [activeDrag, setActiveDrag] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); + const [flowOpen, setFlowOpen] = useState(false); + // 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。 + const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null); + const showDetail = useCallback((id: string, name?: string) => { + setDetailAgent({ id, name }); + }, []); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -155,7 +175,7 @@ export default function BusinessChainEditorPage() { return m; }, [pool]); const seatIdSet = useMemo( - () => new Set(stageGroups.flat().map((s) => s.agent_id)), + () => new Set(stageGroups.flatMap((g) => g.seats).map((s) => s.agent_id)), [stageGroups], ); const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]); @@ -165,14 +185,14 @@ export default function BusinessChainEditorPage() { const previewStages = useMemo( () => stageGroups - .filter((g) => g.length > 0) - .map((g, i) => ({ id: `stage-${i}`, agentIds: g.map((s) => s.agent_id) })), + .filter((g) => g.seats.length > 0) + .map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })), [stageGroups], ); const seatNameById = useMemo(() => { const m = new Map(); for (const g of stageGroups) - for (const s of g) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id); + for (const s of g.seats) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id); return m; }, [stageGroups]); const resolvePreviewSeat = useCallback( @@ -228,6 +248,7 @@ export default function BusinessChainEditorPage() { setCurrentChainMine(chain.isMine !== false); setTitle(chain.title); setDescription(chain.description ?? ""); + setCoordinatorPrompt(chain.coordinatorPrompt ?? ""); setStageGroups(chainToStageGroups(chain)); }, []); @@ -237,6 +258,7 @@ export default function BusinessChainEditorPage() { setCurrentChainMine(true); setTitle(""); setDescription(""); + setCoordinatorPrompt(""); setStageGroups([]); }, []); @@ -291,6 +313,10 @@ export default function BusinessChainEditorPage() { setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx)); }, []); + const setGoal = useCallback((stageIdx: number, goal: string) => { + setStageGroups((prev) => setStageGoal(prev, stageIdx, goal)); + }, []); + const splitSeat = useCallback((agentId: string) => { setStageGroups((prev) => splitSeatToNewStage(prev, agentId)); }, []); @@ -306,8 +332,8 @@ export default function BusinessChainEditorPage() { const removeStage = useCallback((stageIdx: number) => { setStageGroups((prev) => { const stage = prev[stageIdx]; - if (stage && stage.length > 0) { - if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) { + if (stage && stage.seats.length > 0) { + if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.seats.length} 个席位,确认?`)) { return prev; } } @@ -349,7 +375,7 @@ export default function BusinessChainEditorPage() { } else if (overId.startsWith(SEAT_PREFIX)) { const overAgent = overId.slice(SEAT_PREFIX.length); targetStage = findStageIndexOfSeat(stageGroups, overAgent); - atIndex = stageGroups[targetStage]?.findIndex((s) => s.agent_id === overAgent); + atIndex = stageGroups[targetStage]?.seats.findIndex((s) => s.agent_id === overAgent); } const toNewStage = overId === NEWSTAGE_DROPPABLE_ID; @@ -389,7 +415,7 @@ export default function BusinessChainEditorPage() { // ─── 保存 / 删除 ────────────────────────────────────────────────────── const seatCountInvalid = seatCount < CHAIN_MIN_SEATS || seatCount > CHAIN_MAX_SEATS; - const hasEmptyStage = stageGroups.some((g) => g.length === 0); + const hasEmptyStage = stageGroups.some((g) => g.seats.length === 0); const canSave = title.trim().length > 0 && !seatCountInvalid && !saving; const handleSave = useCallback(async () => { @@ -403,20 +429,29 @@ export default function BusinessChainEditorPage() { } setSaving(true); try { - // 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。 + // 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 在「全单席位且无 + // 目标」时折叠为 null(线性链),有并行或填了「本阶段目标」时落显式分层。 const flat = stageGroupsToSeats(stageGroups); const payloadSeats = flat.map((s) => ({ agent_id: s.agent_id, name: poolById.get(s.agent_id)?.name ?? s.name, description: poolById.get(s.agent_id)?.description ?? s.description ?? "", })); - const stages = stageGroupsToStagePayload(stageGroups); + // 总控编排提示:trim 后空串落 null(清空),与后端「空=不注入」一致。 + const coordPrompt = coordinatorPrompt.trim() || null; + // 填了「总控编排提示」也强制落显式分层 —— 该提示仅 dag 模式注入,否则纯线性链里被静默忽略。 + const forceExplicit = coordPrompt !== null; + const stages = stageGroupsToStagePayload(stageGroups, forceExplicit); + // 每阶段目标,与 stages 同下标对齐、同步折叠(stages 为 null 时这里也是 null)。 + const stageGoals = stageGroupsToStageGoals(stageGroups, forceExplicit); if (currentChainId) { const updated = await updateChain(currentChainId, { title: title.trim(), description: description.trim() || undefined, seats: payloadSeats, stages, + stageGoals, + coordinatorPrompt: coordPrompt, }); if (!updated) { toast.error("链条已不存在,可能被删除"); @@ -433,6 +468,8 @@ export default function BusinessChainEditorPage() { description: description.trim() || undefined, seats: payloadSeats, stages, + stageGoals, + coordinatorPrompt: coordPrompt, }); setCurrentChainId(created.id); toast.success("已创建业务链条"); @@ -446,6 +483,7 @@ export default function BusinessChainEditorPage() { }, [ title, description, + coordinatorPrompt, stageGroups, seatCountInvalid, currentChainId, @@ -711,6 +749,29 @@ export default function BusinessChainEditorPage() { /> + + {/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */} +
+
+ 总控提示词 + + 写给总控:怎么派活、做什么(仅分层研讨生效,可选) + +
+