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:
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.
|
||||
|
||||
+56
@@ -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")
|
||||
+6
@@ -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(
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user