From 3405795371658608f7b83cfcf0f9bbeff6c29869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Tue, 9 Jun 2026 13:41:20 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(roundtable):=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=93=BE=E6=9D=A1=E3=80=8C=E6=9C=AC=E9=98=B6=E6=AE=B5=E7=9B=AE?= =?UTF-8?q?=E6=A0=87=E3=80=8D=E4=B8=B2=E8=A1=8C=E9=93=BE=E5=8F=AF=E4=BF=9D?= =?UTF-8?q?=E5=AD=98/=E5=9B=9E=E6=98=BE=20+=20=E6=96=B0=E5=A2=9E=E3=80=8C?= =?UTF-8?q?=E6=80=BB=E6=8E=A7=E6=8F=90=E7=A4=BA=E8=AF=8D=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本阶段目标 bug:每步只有一个智能体(无并行)时,stageGroupsToStagePayload/ stageGroupsToStageGoals 一律折叠为 null(旧设计=线性链),导致目标保存即丢、 无从回显。改为「有并行 或 任一 stage 填了目标」就落显式分层,串行带目标链按 分层 DAG 跑(总控逐阶段按目标派活);BusinessChainPicker 同步:有并行/有目标/ 有总控提示都走 dag。 新增「总控提示词」(per-chain,仅 dag 模式生效):写给总控的整体派活指引。 后端 roundtable_chains 加 coordinator_prompt LONGTEXT 列 + migration 20260608_06; 前端编辑器加输入框,注入 buildDagStageLeaderTask 每阶段派活提示。填了总控提示 也强制显式分层(forceExplicit),否则纯线性链里被静默忽略。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/roundtable-planning/api/chains.ts | 8 +++ .../components/BusinessChainPicker.tsx | 10 +++- .../hooks/useStep2Orchestration.ts | 1 + .../roundtable-planning/lib/business-chain.ts | 6 ++ .../lib/chain-stages.test.ts | 44 +++++++++++++-- .../roundtable-planning/lib/chain-stages.ts | 33 +++++++---- .../roundtable-planning/lib/dag-types.test.ts | 50 +++++++++++++++++ .../src/roundtable-planning/lib/dag-types.ts | 29 +++++++++- .../pages/BusinessChainEditorPage.tsx | 47 ++++++++++++++-- .../app/gateway/routers/roundtable_chains.py | 5 ++ .../20260608_06_chain_coordinator_prompt.py | 56 +++++++++++++++++++ .../persistence/roundtable_chains/model.py | 4 ++ .../persistence/roundtable_chains/sql.py | 6 ++ 13 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260608_06_chain_coordinator_prompt.py diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 940e4f09..dce9b6ca 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -26,6 +26,8 @@ export interface ChainCreatePayload { 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. */ @@ -37,6 +39,8 @@ export interface ChainUpdatePayload { 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; } @@ -65,6 +69,7 @@ interface ServerChain { seats: ChainSeat[]; stages?: string[][] | null; stage_goals?: (string | null)[] | null; + coordinator_prompt?: string | null; is_public?: boolean; is_mine?: boolean; created_at: string | null; @@ -79,6 +84,7 @@ function toChain(s: ServerChain): BusinessChain { 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, @@ -95,6 +101,7 @@ function fromCreate(p: ChainCreatePayload) { seats: p.seats, ...(p.stages !== undefined ? { stages: p.stages } : {}), ...(p.stageGoals !== undefined ? { stage_goals: p.stageGoals } : {}), + ...(p.coordinatorPrompt !== undefined ? { coordinator_prompt: p.coordinatorPrompt } : {}), }; } @@ -107,6 +114,7 @@ function fromUpdate(p: ChainUpdatePayload) { 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/BusinessChainPicker.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx index cee84cad..05dda9b6 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx @@ -108,7 +108,9 @@ 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; @@ -118,8 +120,12 @@ export function BusinessChainPicker({ surviveIds, true, selectedChain.stageGoals, + selectedChain.coordinatorPrompt, ); - return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null; + 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 fccc2704..8dcc2820 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -1871,6 +1871,7 @@ export function useStep2Orchestration( 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); diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index 83341f90..eac12627 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -39,6 +39,12 @@ export interface BusinessChain { * 为 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 2ff6cffa..c0364e55 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.test.ts @@ -64,11 +64,25 @@ test("stageGroupsToSeats flattens in order", () => { ); }); -test("stageGroupsToStagePayload returns null when all singletons (linear)", () => { +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 = grp([seat("a"), seat("b")], [seat("c")]); assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]); @@ -81,12 +95,18 @@ test("stageGroupsToStagePayload ignores empty stages", () => { // ── stage goals ───────────────────────────────────────────────────────────── -test("stageGroupsToStageGoals returns null when linear (no parallel)", () => { - const groups = setStageGoal(grp([seat("a")], [seat("b")]), 0, "目标"); - // 全单席位 = 线性 → 目标也折叠为 null(仅 dag 模式落库)。 +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, " 分析目的 "); @@ -182,6 +202,22 @@ test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups" 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({ diff --git a/frontend-web/src/roundtable-planning/lib/chain-stages.ts b/frontend-web/src/roundtable-planning/lib/chain-stages.ts index 1649685a..117ca058 100644 --- a/frontend-web/src/roundtable-planning/lib/chain-stages.ts +++ b/frontend-web/src/roundtable-planning/lib/chain-stages.ts @@ -54,27 +54,40 @@ export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] { 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 { +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) return null; + if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null; return nonEmpty.map((g) => g.seats.map((s) => s.agent_id)); } /** - * 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠** - * (过滤空组、无并行返回 null),保证两个数组下标一一对应。每个存活组取其 `goal` - * (trim 后为空则该位为 null)。仅 dag 模式(有并行)才落库目标——与「目标仅分层 DAG 生效」一致。 + * 折叠成落库的 `stageGoals`,与 {@link stageGroupsToStagePayload} 做**完全相同的折叠判定** + * (过滤空组;无并行且无目标、且未 `forceExplicit` 时才返回 null),保证两个数组下标一一对应。 + * 每个存活组取其 `goal`(trim 后为空则该位为 null)。只要落了 `stages`,就同步落目标。 */ -export function stageGroupsToStageGoals(groups: StageGroups): (string | null)[] | null { +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) return null; + if (!hasParallel && !hasAnyGoal(nonEmpty) && !forceExplicit) return null; return nonEmpty.map((g) => g.goal?.trim() || null); } 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 72b3b393..96a1f2ab 100644 --- a/frontend-web/src/roundtable-planning/lib/dag-types.test.ts +++ b/frontend-web/src/roundtable-planning/lib/dag-types.test.ts @@ -207,6 +207,16 @@ test("buildPlanFromStages without goals leaves stage.goal undefined (old plans)" 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)", () => { @@ -472,6 +482,46 @@ test("buildDagStageLeaderTask omits the goal line when goal is empty/blank", () 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); diff --git a/frontend-web/src/roundtable-planning/lib/dag-types.ts b/frontend-web/src/roundtable-planning/lib/dag-types.ts index 0ebee008..6f1ae7b4 100644 --- a/frontend-web/src/roundtable-planning/lib/dag-types.ts +++ b/frontend-web/src/roundtable-planning/lib/dag-types.ts @@ -34,6 +34,12 @@ export interface OrchestrationPlan { stages: OrchestrationStage[]; /** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */ finalSynthesis: boolean; + /** + * 可选「总控编排提示」:来自业务链条 `coordinatorPrompt`,写给总控的整体派活指引 + * (怎么派活 / 做什么)。注入总控**每阶段**派活提示({@link buildDagStageLeaderTask})。 + * 空 = 不注入。 + */ + coordinatorPrompt?: string; } // ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)─────────────────────── @@ -187,12 +193,15 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan { * @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[] = []; @@ -202,7 +211,10 @@ export function buildPlanFromStages( 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 决定显示。 */ @@ -342,14 +354,20 @@ export function buildDagStageLeaderTask(args: { isFirst: boolean; /** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */ goal?: string; + /** + * 可选「总控编排提示」:用户在业务链条里写给总控的整体派活指引(怎么派活 / 做什么 / + * 风格 / 约束)。每个 stage 的派活轮都注入,作为总控的高优先编排原则。空 = 不注入。 + */ + coordinatorPrompt?: string; }): string { - const { seedMessage, stageIndex, totalStages, seats, isFirst, goal } = 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, ""); @@ -359,6 +377,13 @@ export function buildDagStageLeaderTask(args: { "", ); } + if (coordText) { + lines.push( + "【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:", + coordText, + "", + ); + } lines.push( `本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`, `本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`, diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 09ac5560..9be1df56 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -150,6 +150,8 @@ 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); @@ -246,6 +248,7 @@ export default function BusinessChainEditorPage() { setCurrentChainMine(chain.isMine !== false); setTitle(chain.title); setDescription(chain.description ?? ""); + setCoordinatorPrompt(chain.coordinatorPrompt ?? ""); setStageGroups(chainToStageGroups(chain)); }, []); @@ -255,6 +258,7 @@ export default function BusinessChainEditorPage() { setCurrentChainMine(true); setTitle(""); setDescription(""); + setCoordinatorPrompt(""); setStageGroups([]); }, []); @@ -425,16 +429,21 @@ 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); - // 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。 - const stageGoals = stageGroupsToStageGoals(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(), @@ -442,6 +451,7 @@ export default function BusinessChainEditorPage() { seats: payloadSeats, stages, stageGoals, + coordinatorPrompt: coordPrompt, }); if (!updated) { toast.error("链条已不存在,可能被删除"); @@ -459,6 +469,7 @@ export default function BusinessChainEditorPage() { seats: payloadSeats, stages, stageGoals, + coordinatorPrompt: coordPrompt, }); setCurrentChainId(created.id); toast.success("已创建业务链条"); @@ -472,6 +483,7 @@ export default function BusinessChainEditorPage() { }, [ title, description, + coordinatorPrompt, stageGroups, seatCountInvalid, currentChainId, @@ -737,6 +749,29 @@ export default function BusinessChainEditorPage() { /> + + {/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */} +
+
+ 总控提示词 + + 写给总控:怎么派活、做什么(仅分层研讨生效,可选) + +
+