feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控

- 业务链条配置页流程图从底部内嵌改为按钮 + Dialog 弹窗查看
- 每个 Stage 新增可选「本阶段目标」文本框,告诉总控本阶段要产出
  什么、如何依赖上一阶段产出(仅分层 DAG 生效)
- StageGroups 重构为 StageGroup[]({seats, goal?}),goal 随 stage
  在增删改/重排中自动跟随;新增 stageGoals 持久化(与 stages 同下标对齐)
- 运行时注入:buildDagStageLeaderTask(总控派活,主)+ pickDagSeatTask
  (席位任务,兜底)
- 后端 roundtable_chains 加 stage_goals 列 + 校验 + Alembic 20260608_04
- 老链条/草稿无该字段时不注入,行为不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
董宏凯andClaude Opus 4.8 committed 2026-06-08 16:10:46 +08:00
1 parent ce431ee155
commit 834df64650
13 files changed
+486 -94

No files matched your search

@@ -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;
}
@@ -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");
@@ -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();
@@ -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)。 */
@@ -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<typeof seat>[][]): 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");
});
@@ -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<BusinessChain, "seats" | "stages">): StageGroups {
export function chainToStageGroups(
chain: Pick<BusinessChain, "seats" | "stages" | "stageGoals">,
): 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(含其席位)。 */
@@ -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, /本阶段目标/);
});
@@ -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<string>,
finalSynthesis = true,
goals?: ReadonlyArray<string | null> | 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
@@ -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<ActiveDrag>(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<string, string>();
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}
/>
</main>
@@ -811,24 +831,48 @@ export default function BusinessChainEditorPage() {
</DragOverlay>
</DndContext>
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */}
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{previewStages.length > 0 && (
<section className="shrink-0 border-t border-border bg-card px-6 py-2">
<div className="mb-1 flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
<span className="text-muted-foreground/50">
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
</span>
</div>
<DagFlowChart
stages={previewStages}
resolveSeat={resolvePreviewSeat}
showReportMilestone={false}
compact
/>
</span>
<button
type="button"
onClick={() => setFlowOpen(true)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
查看编排流程图
</button>
</section>
)}
{/* 编排流程图弹窗 */}
{previewStages.length > 0 && (
<Dialog open={flowOpen} onOpenChange={setFlowOpen}>
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
<DialogHeader className="shrink-0">
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-primary" />
编排流程图
</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-auto pt-2">
<DagFlowChart
stages={previewStages}
resolveSeat={resolvePreviewSeat}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
@@ -923,6 +967,7 @@ function StagesColumn({
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onStageGoalChange,
}: {
stageGroups: StageGroups;
seatCount: number;
@@ -934,6 +979,7 @@ function StagesColumn({
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
const stageStartIndex = useMemo(() => {
@@ -941,10 +987,12 @@ function StagesColumn({
let acc = 0;
for (const g of stageGroups) {
out.push(acc);
acc += g.length;
acc += g.seats.length;
}
return out;
}, [stageGroups]);
// 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。
const hasParallelStage = stageGroups.some((g) => g.seats.length > 1);
return (
<section
@@ -1018,11 +1066,13 @@ function StagesColumn({
group={group}
stageCount={stageGroups.length}
startIndex={stageStartIndex[stageIdx]}
hasParallelStage={hasParallelStage}
onRemoveStage={() => onRemoveStage(stageIdx)}
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
onRemoveSeat={onRemoveSeat}
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
/>
))}
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
@@ -1040,27 +1090,33 @@ function StageSection({
group,
stageCount,
startIndex,
hasParallelStage,
onRemoveStage,
onReorderStage,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onGoalChange,
}: {
stageIdx: number;
group: ChainSeat[];
group: StageGroup;
stageCount: number;
startIndex: number;
/** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */
hasParallelStage: boolean;
onRemoveStage: () => void;
onReorderStage: (dir: -1 | 1) => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const isParallel = group.length > 1;
const seats = group.seats;
const isParallel = seats.length > 1;
const isFirstStage = stageIdx === 0;
const isLastStage = stageIdx === stageCount - 1;
const seatItemIds = group.map((s) => SEAT_PREFIX + s.agent_id);
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
return (
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
@@ -1086,7 +1142,7 @@ function StageSection({
: "border-border bg-muted text-muted-foreground",
)}
>
{isParallel ? `并行 ${group.length}` : "串行"}
{isParallel ? `并行 ${seats.length}` : "串行"}
</span>
</div>
<div className="flex items-center gap-0.5">
@@ -1131,18 +1187,45 @@ function StageSection({
</div>
</div>
{/* 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出(仅含并行的分层链条生效)。 */}
{seats.length > 0 && (
<div className="border-b border-border bg-muted/10 px-2.5 py-2">
<textarea
value={group.goal ?? ""}
onChange={(e) => onGoalChange(e.target.value)}
maxLength={500}
rows={2}
placeholder={
isFirstStage
? "本阶段目标(可选):本阶段要产出什么,供后续阶段承接"
: "本阶段目标(可选):本阶段要产出什么、如何依赖上一阶段产出"
}
className={cn(
"w-full resize-y bg-card px-2.5 py-1.5 text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/60",
"border border-border focus:border-primary/40 focus:outline-none",
R_CTRL,
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
提示:当前链条全为串行,目标仅在含并行 Stage 的分层(DAG)研讨中生效。
</p>
)}
</div>
)}
{/* Stage 内席位 */}
<div
ref={setNodeRef}
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
>
{group.length === 0 ? (
{seats.length === 0 ? (
<div className={cn("border border-dashed border-border bg-card/40 px-3 py-4 text-center text-[11px] text-muted-foreground", R_CARD)}>
空 Stage —— 把席位拖到此处,或删除该 Stage。
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{group.map((seat, i) => (
{seats.map((seat, i) => (
<SeatStrip
key={seat.agent_id}
seat={seat}
@@ -49,6 +49,8 @@ class ChainResponse(BaseModel):
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
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
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
@@ -69,6 +71,7 @@ class ChainCreateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
class ChainUpdateRequest(BaseModel):
@@ -77,6 +80,7 @@ class ChainUpdateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | None = None
@@ -151,6 +155,38 @@ def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | Non
)
# Per-goal length cap to keep injected prompts bounded.
MAX_STAGE_GOAL_CHARS = 1000
def _validate_stage_goals(
stages: list[list[str]] | None, stage_goals: list[str | None] | None
) -> None:
"""Validate the optional per-stage goals (None = no goals, always valid).
Rules:
- goals only make sense alongside a non-null layering (dag-only): reject
goals sent without ``stages``;
- length must match ``stages`` exactly (positional alignment);
- each goal (when present) must be ≤ ``MAX_STAGE_GOAL_CHARS`` chars.
"""
if stage_goals is None:
return
if stages is None:
raise HTTPException(status_code=400, detail="阶段目标需与编排分层一起提交(线性链不支持阶段目标)")
if len(stage_goals) != len(stages):
raise HTTPException(
status_code=400,
detail=f"阶段目标数量({len(stage_goals)})与编排分层阶段数({len(stages)})不一致",
)
for idx, goal in enumerate(stage_goals):
if goal is not None and len(goal) > MAX_STAGE_GOAL_CHARS:
raise HTTPException(
status_code=400,
detail=f"第 {idx + 1} 阶段目标过长(上限 {MAX_STAGE_GOAL_CHARS} 字)",
)
# ── routes ───────────────────────────────────────────────────────────────
@@ -174,6 +210,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
_validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
@@ -184,6 +221,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
"stages": body.stages,
"stage_goals": body.stage_goals,
},
)
return ChainResponse(**row)
@@ -209,6 +247,8 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
if body.seats is None:
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
_validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
@@ -0,0 +1,56 @@
"""Add stage_goals column to roundtable_chains.
Per-stage goals (每阶段目标) for business chains: an optional list aligned by index
to ``stages`` where ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` — what
that serial stage should produce / how it depends on the previous stage's output.
Injected to the coordinator dispatch prompt (and seat tasks as fallback) in dag-mode
only. NULL = no goals. The column is LONGTEXT on MySQL (mirrors ``stages`` headroom)
and plain TEXT elsewhere — matching ``PortableLongText`` on
``RoundtableChainRow.stage_goals``. ``create_all`` adds the column for brand-new DBs;
this revision patches DBs that already have the table.
Chained after ``20260608_03`` (chain is_public) so the revision graph stays
single-headed — otherwise ``alembic upgrade head`` aborts with "multiple heads" and
the auto-migrate on startup silently falls back to the create_all schema (which lacks
this column → 500 on list).
Revision ID: 20260608_04
Revises: 20260608_03
Create Date: 2026-06-08
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260608_04"
down_revision = "20260608_03"
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", "stage_goals"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_chains", sa.Column("stage_goals", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_chains", "stage_goals"):
op.drop_column("roundtable_chains", "stage_goals")
@@ -39,6 +39,12 @@ class RoundtableChainRow(Base):
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# Optional JSON: per-stage goals aligned by index to ``stages`` — a list where
# ``stage_goals[i]`` is the「本阶段目标」for ``stages[i]`` (what that serial stage
# should produce / how it depends on the previous stage's output). Injected to the
# 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)
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
is_public: Mapped[bool] = mapped_column(
@@ -52,6 +52,8 @@ class RoundtableChainRepository:
"seats": _loads(row.seats) or [],
# None when absent → frontend reads it as "linear chain" (no layering).
"stages": _loads(row.stages),
# Per-stage goals aligned to ``stages``; None when absent.
"stage_goals": _loads(getattr(row, "stage_goals", None)),
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
@@ -102,6 +104,7 @@ class RoundtableChainRepository:
description=(data.get("description") or None),
seats=_dumps(data.get("seats") or []),
stages=_dumps(data.get("stages")),
stage_goals=_dumps(data.get("stage_goals")),
is_public=bool(data.get("is_public") or False),
created_at=now,
updated_at=now,
@@ -121,6 +124,7 @@ class RoundtableChainRepository:
description=_UNSET,
seats=_UNSET,
stages=_UNSET,
stage_goals=_UNSET,
is_public=_UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -135,6 +139,8 @@ class RoundtableChainRepository:
row.seats = _dumps(seats)
if stages is not _UNSET:
row.stages = _dumps(stages)
if stage_goals is not _UNSET:
row.stage_goals = _dumps(stage_goals)
if is_public is not _UNSET and is_public is not None:
row.is_public = bool(is_public)
row.updated_at = datetime.now(UTC)