diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 461a006e..940e4f09 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -24,6 +24,8 @@ 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; } /** Partial update — send only what changed. */ @@ -33,6 +35,8 @@ 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; /** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */ isPublic?: boolean; } @@ -60,6 +64,7 @@ interface ServerChain { description: string | null; seats: ChainSeat[]; stages?: string[][] | null; + stage_goals?: (string | null)[] | null; is_public?: boolean; is_mine?: boolean; created_at: string | null; @@ -73,6 +78,7 @@ 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, isPublic: s.is_public ?? false, // 后端缺省时按「我的」处理(向后兼容旧响应)。 isMine: s.is_mine ?? true, @@ -88,6 +94,7 @@ 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 } : {}), }; } @@ -98,6 +105,8 @@ 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.isPublic !== undefined) out.is_public = p.isPublic; return out; } diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index 136cd561..cee84cad 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -113,7 +113,12 @@ export function BusinessChainPicker({ 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); + const plan = buildPlanFromStages( + selectedChain.stages, + surviveIds, + true, + selectedChain.stageGoals, + ); return plan.stages.some((st) => st.agentIds.length > 1) ? 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..fccc2704 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -1870,6 +1870,7 @@ export function useStep2Orchestration( totalStages: plan.stages.length, seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })), isFirst: s === 0, + goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空) }); const leaderAborter = new AbortController(); activeAbortersRef.current.add(leaderAborter); @@ -1984,6 +1985,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..83341f90 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -33,6 +33,12 @@ 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; /** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */ 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..2ff6cffa 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,7 +57,7 @@ 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"], @@ -60,24 +65,47 @@ test("stageGroupsToSeats flattens in order", () => { }); test("stageGroupsToStagePayload returns null when all singletons (linear)", () => { - const groups: StageGroups = [[seat("a")], [seat("b")]]; + const groups = grp([seat("a")], [seat("b")]); assert.equal(stageGroupsToStagePayload(groups), 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 (no parallel)", () => { + const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "目标"); + // 全单席位 = 线性 → 目标也折叠为 null(仅 dag 模式落库)。 + assert.equal(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 +114,81 @@ 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, "依赖上一阶段"); +}); + +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..1649685a 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -1,42 +1,57 @@ /** * 业务链条「分层编排」编辑器的纯数据操作。 * - * 编辑器内部用 `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); } /** @@ -45,15 +60,27 @@ export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] { * 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。 */ export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null { - const nonEmpty = groups.filter((g) => g.length > 0); - const hasParallel = nonEmpty.some((g) => g.length > 1); + const nonEmpty = groups.filter((g) => g.seats.length > 0); + const hasParallel = nonEmpty.some((g) => g.seats.length > 1); if (!hasParallel) return null; - return nonEmpty.map((g) => g.map((s) => s.agent_id)); + return nonEmpty.map((g) => g.seats.map((s) => s.agent_id)); +} + +/** + * 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠** + * (过滤空组、无并行返回 null),保证两个数组下标一一对应。每个存活组取其 `goal` + * (trim 后为空则该位为 null)。仅 dag 模式(有并行)才落库目标——与「目标仅分层 DAG 生效」一致。 + */ +export function stageGroupsToStageGoals(groups: StageGroups): (string | null)[] | null { + const nonEmpty = groups.filter((g) => g.seats.length > 0); + const hasParallel = nonEmpty.some((g) => g.seats.length > 1); + if (!hasParallel) 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 +90,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 +104,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 +118,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 +145,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 +204,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..72b3b393 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,25 @@ 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); +}); + // ── buildDependencySummary (§3.1 依赖摘要注入) ───────────────────────────────── test("buildDependencySummary is empty for no deliveries (first stage)", () => { @@ -424,3 +443,47 @@ 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, /本阶段目标/); +}); + +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..0ebee008 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 顺序即串行顺序。 */ @@ -180,17 +185,22 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan { * @param stages 链条分层(每组并行、组间串行)。 * @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。 * @param finalSynthesis 是否收口综合,默认 true。 + * @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal + * (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。 */ export function buildPlanFromStages( stages: string[][], allowedIds?: Iterable, finalSynthesis = true, + goals?: ReadonlyArray | 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 }; } @@ -330,13 +340,16 @@ export function buildDagStageLeaderTask(args: { /** 本 stage 计划钉死的席位。 */ seats: Array<{ agentId: string; name: string }>; isFirst: boolean; + /** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */ + goal?: string; }): string { - const { seedMessage, stageIndex, totalStages, seats, isFirst } = args; + const { seedMessage, stageIndex, totalStages, seats, isFirst, goal } = 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 lines: string[] = []; if (isFirst) { lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, ""); @@ -349,7 +362,15 @@ export function buildDagStageLeaderTask(args: { 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 +407,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 +428,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..f92e329e 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -32,6 +32,7 @@ import { GripVertical, Layers, Loader2, + Network, Plus, Save, SplitSquareVertical, @@ -60,6 +61,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,9 +100,12 @@ 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"; @@ -144,6 +154,7 @@ export default function BusinessChainEditorPage() { const [activeDrag, setActiveDrag] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); + const [flowOpen, setFlowOpen] = useState(false); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -155,7 +166,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 +176,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( @@ -291,6 +302,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 +321,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 +364,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 +404,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 () => { @@ -411,12 +426,15 @@ export default function BusinessChainEditorPage() { description: poolById.get(s.agent_id)?.description ?? s.description ?? "", })); const stages = stageGroupsToStagePayload(stageGroups); + // 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。 + const stageGoals = stageGroupsToStageGoals(stageGroups); if (currentChainId) { const updated = await updateChain(currentChainId, { title: title.trim(), description: description.trim() || undefined, seats: payloadSeats, stages, + stageGoals, }); if (!updated) { toast.error("链条已不存在,可能被删除"); @@ -433,6 +451,7 @@ export default function BusinessChainEditorPage() { description: description.trim() || undefined, seats: payloadSeats, stages, + stageGoals, }); setCurrentChainId(created.id); toast.success("已创建业务链条"); @@ -793,6 +812,7 @@ export default function BusinessChainEditorPage() { onRemoveSeat={removeSeat} onMergeSeat={mergeSeatToStage} onSplitSeat={splitSeat} + onStageGoalChange={setGoal} /> @@ -811,24 +831,48 @@ export default function BusinessChainEditorPage() { - {/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */} + {/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 + 改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */} {previewStages.length > 0 && ( -
-
+
+ 编排预览 总控派活 → 各 Stage(串行)→ 共识 → 结果绘制 -
- + +
)} + {/* 编排流程图弹窗 */} + {previewStages.length > 0 && ( + + + + + + 编排流程图 + + +
+ +
+
+
+ )} + {/* 页脚 */}