fix(roundtable): 业务链条「本阶段目标」串行链可保存/回显 + 新增「总控提示词」
本阶段目标 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
1938c80061
commit
3405795371
13 files changed
+275
-24
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -39,6 +39,12 @@ export interface BusinessChain {
|
||||
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
|
||||
*/
|
||||
stageGoals?: (string | null)[] | null;
|
||||
/**
|
||||
* 可选「总控编排提示」:用户写给**总控(协调)智能体**的一段指引,告诉总控该怎么派活、
|
||||
* 做什么(整体编排策略 / 风格 / 约束)。**仅 dag 模式生效**,注入总控每阶段派活提示。
|
||||
* 空 / null = 不注入。
|
||||
*/
|
||||
coordinatorPrompt?: string | null;
|
||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||
isPublic?: boolean;
|
||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>,
|
||||
finalSynthesis = true,
|
||||
goals?: ReadonlyArray<string | null> | 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}。`,
|
||||
|
||||
@@ -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<StageGroups>([]);
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
|
||||
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
|
||||
<span className="select-none text-[10px] text-muted-foreground/70">
|
||||
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={coordinatorPrompt}
|
||||
onChange={(e) => setCoordinatorPrompt(e.target.value)}
|
||||
maxLength={4000}
|
||||
rows={3}
|
||||
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
|
||||
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 双栏拖拽编辑器 */}
|
||||
@@ -1258,8 +1293,8 @@ function StageSection({
|
||||
)}
|
||||
/>
|
||||
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
|
||||
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
提示:当前链条全为串行,目标仅在含并行 Stage 的分层(DAG)研讨中生效。
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
提示:填写本阶段目标后,本链条将按阶段顺序由总控逐阶段派活(分层研讨),并把该目标注入总控。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,8 @@ class ChainResponse(BaseModel):
|
||||
stages: list[list[str]] | None = None
|
||||
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
|
||||
stage_goals: list[str | None] | None = None
|
||||
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
|
||||
coordinator_prompt: str | None = None
|
||||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||
is_public: bool = False
|
||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||
@@ -72,6 +74,7 @@ class ChainCreateRequest(BaseModel):
|
||||
seats: list[ChainSeatSchema] = []
|
||||
stages: list[list[str]] | None = None
|
||||
stage_goals: list[str | None] | None = None
|
||||
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class ChainUpdateRequest(BaseModel):
|
||||
@@ -81,6 +84,7 @@ class ChainUpdateRequest(BaseModel):
|
||||
seats: list[ChainSeatSchema] | None = None
|
||||
stages: list[list[str]] | None = None
|
||||
stage_goals: list[str | None] | None = None
|
||||
coordinator_prompt: str | None = Field(default=None, max_length=4000)
|
||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||
is_public: bool | None = None
|
||||
|
||||
@@ -222,6 +226,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
||||
"seats": [s.model_dump() for s in body.seats],
|
||||
"stages": body.stages,
|
||||
"stage_goals": body.stage_goals,
|
||||
"coordinator_prompt": body.coordinator_prompt,
|
||||
},
|
||||
)
|
||||
return ChainResponse(**row)
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
"""Add coordinator_prompt column to roundtable_chains.
|
||||
|
||||
Per-chain「总控编排提示」: an optional free-text instruction the user writes for the
|
||||
**coordinator (总控) agent** of a business chain — telling it how to dispatch / what to
|
||||
do (overall orchestration strategy / style / constraints). Injected into the coordinator
|
||||
per-stage dispatch prompt (``buildDagStageLeaderTask``) in **dag-mode only**. NULL = no
|
||||
prompt. The column is LONGTEXT on MySQL (mirrors ``stage_goals`` headroom) and plain TEXT
|
||||
elsewhere — matching ``PortableLongText`` on ``RoundtableChainRow.coordinator_prompt``.
|
||||
``create_all`` adds the column for brand-new DBs; this revision patches DBs that already
|
||||
have the table.
|
||||
|
||||
Chained after ``20260608_05`` (parallel_agent_tasks) so the revision graph stays
|
||||
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and the
|
||||
startup auto-migrate silently falls back to the create_all schema (which lacks this
|
||||
column → 500 on list).
|
||||
|
||||
Revision ID: 20260608_06
|
||||
Revises: 20260608_05
|
||||
Create Date: 2026-06-09
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
|
||||
revision = "20260608_06"
|
||||
down_revision = "20260608_05"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
inspector = inspect(op.get_bind())
|
||||
return table_name in inspector.get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
inspector = inspect(op.get_bind())
|
||||
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_table("roundtable_chains") and not _has_column("roundtable_chains", "coordinator_prompt"):
|
||||
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
op.add_column("roundtable_chains", sa.Column("coordinator_prompt", col_type, nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if _has_column("roundtable_chains", "coordinator_prompt"):
|
||||
op.drop_column("roundtable_chains", "coordinator_prompt")
|
||||
+4
@@ -45,6 +45,10 @@ class RoundtableChainRow(Base):
|
||||
# coordinator dispatch prompt (and seat tasks as fallback) in dag-mode only. NULL =
|
||||
# no goals (and always NULL when ``stages`` is NULL/linear). Stored as JSON string.
|
||||
stage_goals: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# 可选「总控编排提示」:用户为本链条写给**总控(协调)智能体**的一段指引,告诉总控该
|
||||
# 怎么派活、做什么(整体编排策略 / 风格 / 约束)。**仅分层 DAG 模式生效**:注入到总控
|
||||
# 每阶段派活提示(``buildDagStageLeaderTask``)。NULL = 不注入。纯文本,存为 LONGTEXT。
|
||||
coordinator_prompt: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||
is_public: Mapped[bool] = mapped_column(
|
||||
|
||||
+6
@@ -54,6 +54,8 @@ class RoundtableChainRepository:
|
||||
"stages": _loads(row.stages),
|
||||
# Per-stage goals aligned to ``stages``; None when absent.
|
||||
"stage_goals": _loads(getattr(row, "stage_goals", None)),
|
||||
# 总控编排提示(写给总控的派活指引,dag-only);None/空 = 不注入。纯文本,不 json。
|
||||
"coordinator_prompt": getattr(row, "coordinator_prompt", None),
|
||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||
"is_public": bool(getattr(row, "is_public", False)),
|
||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
||||
@@ -105,6 +107,7 @@ class RoundtableChainRepository:
|
||||
seats=_dumps(data.get("seats") or []),
|
||||
stages=_dumps(data.get("stages")),
|
||||
stage_goals=_dumps(data.get("stage_goals")),
|
||||
coordinator_prompt=(data.get("coordinator_prompt") or None),
|
||||
is_public=bool(data.get("is_public") or False),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -125,6 +128,7 @@ class RoundtableChainRepository:
|
||||
seats=_UNSET,
|
||||
stages=_UNSET,
|
||||
stage_goals=_UNSET,
|
||||
coordinator_prompt=_UNSET,
|
||||
is_public=_UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -141,6 +145,8 @@ class RoundtableChainRepository:
|
||||
row.stages = _dumps(stages)
|
||||
if stage_goals is not _UNSET:
|
||||
row.stage_goals = _dumps(stage_goals)
|
||||
if coordinator_prompt is not _UNSET:
|
||||
row.coordinator_prompt = coordinator_prompt or None
|
||||
if is_public is not _UNSET and is_public is not None:
|
||||
row.is_public = bool(is_public)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
|
||||
Reference in new issue
Block a user