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[];
|
seats: ChainSeat[];
|
||||||
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
|
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
/** Optional per-stage goals aligned to `stages` (dag-only). null = none. */
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Partial update — send only what changed. */
|
/** Partial update — send only what changed. */
|
||||||
@@ -33,6 +35,8 @@ export interface ChainUpdatePayload {
|
|||||||
seats?: ChainSeat[];
|
seats?: ChainSeat[];
|
||||||
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
/** Per-stage goals aligned to `stages`; send `null` to clear, omit to leave. */
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
/** 发布开关:true 发布为公共 / false 取消发布;omit 不改。仅 owner 生效。 */
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,6 +64,7 @@ interface ServerChain {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
seats: ChainSeat[];
|
seats: ChainSeat[];
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
stage_goals?: (string | null)[] | null;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
is_mine?: boolean;
|
is_mine?: boolean;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
@@ -73,6 +78,7 @@ function toChain(s: ServerChain): BusinessChain {
|
|||||||
description: s.description ?? undefined,
|
description: s.description ?? undefined,
|
||||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||||
|
stageGoals: Array.isArray(s.stage_goals) ? s.stage_goals : null,
|
||||||
isPublic: s.is_public ?? false,
|
isPublic: s.is_public ?? false,
|
||||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||||
isMine: s.is_mine ?? true,
|
isMine: s.is_mine ?? true,
|
||||||
@@ -88,6 +94,7 @@ function fromCreate(p: ChainCreatePayload) {
|
|||||||
...(p.description !== undefined ? { description: p.description } : {}),
|
...(p.description !== undefined ? { description: p.description } : {}),
|
||||||
seats: p.seats,
|
seats: p.seats,
|
||||||
...(p.stages !== undefined ? { stages: p.stages } : {}),
|
...(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;
|
if (p.seats !== undefined) out.seats = p.seats;
|
||||||
// Forward stages even when explicitly null (clears layering server-side).
|
// Forward stages even when explicitly null (clears layering server-side).
|
||||||
if (p.stages !== undefined) out.stages = p.stages;
|
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;
|
if (p.isPublic !== undefined) out.is_public = p.isPublic;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,12 @@ export function BusinessChainPicker({
|
|||||||
const dagPlan = useMemo(() => {
|
const dagPlan = useMemo(() => {
|
||||||
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
|
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
|
||||||
const surviveIds = survivingSeats.map((s) => s.agent_id);
|
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;
|
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null;
|
||||||
}, [selectedChain, survivingSeats]);
|
}, [selectedChain, survivingSeats]);
|
||||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
||||||
|
|||||||
@@ -1870,6 +1870,7 @@ export function useStep2Orchestration(
|
|||||||
totalStages: plan.stages.length,
|
totalStages: plan.stages.length,
|
||||||
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
|
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
|
||||||
isFirst: s === 0,
|
isFirst: s === 0,
|
||||||
|
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
|
||||||
});
|
});
|
||||||
const leaderAborter = new AbortController();
|
const leaderAborter = new AbortController();
|
||||||
activeAbortersRef.current.add(leaderAborter);
|
activeAbortersRef.current.add(leaderAborter);
|
||||||
@@ -1984,6 +1985,7 @@ export function useStep2Orchestration(
|
|||||||
seedMessage,
|
seedMessage,
|
||||||
priorDeliveries.length > 0,
|
priorDeliveries.length > 0,
|
||||||
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
|
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
|
||||||
|
stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐)
|
||||||
);
|
);
|
||||||
|
|
||||||
const subAborter = new AbortController();
|
const subAborter = new AbortController();
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ export interface BusinessChain {
|
|||||||
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
||||||
*/
|
*/
|
||||||
stages?: string[][] | null;
|
stages?: string[][] | null;
|
||||||
|
/**
|
||||||
|
* 可选「每阶段目标」:与 `stages` **同下标对齐**(`stageGoals[i]` 是 `stages[i]` 的
|
||||||
|
* 目标),告诉总控本阶段要产出什么、如何依赖上一阶段产出。仅 dag 模式注入;`stages`
|
||||||
|
* 为 null(线性)时本字段也为 null。某位为 null / 空 = 该阶段不带目标。
|
||||||
|
*/
|
||||||
|
stageGoals?: (string | null)[] | null;
|
||||||
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
/** 是否已发布为公共(所有账号可在「公共」筛选查看 / 选入研讨)。 */
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||||
|
|||||||
@@ -12,14 +12,19 @@ import {
|
|||||||
moveSeatToStage,
|
moveSeatToStage,
|
||||||
moveStage,
|
moveStage,
|
||||||
removeSeat,
|
removeSeat,
|
||||||
|
setStageGoal,
|
||||||
splitSeatToNewStage,
|
splitSeatToNewStage,
|
||||||
stageGroupsToSeats,
|
stageGroupsToSeats,
|
||||||
|
stageGroupsToStageGoals,
|
||||||
stageGroupsToStagePayload,
|
stageGroupsToStagePayload,
|
||||||
type StageGroups,
|
type StageGroups,
|
||||||
} from "./chain-stages";
|
} from "./chain-stages";
|
||||||
|
|
||||||
const seat = (id: string) => ({ agent_id: id, name: id.toUpperCase(), description: "" });
|
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 ────────────────────────────────────────────────────────
|
// ── chainToStageGroups ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,7 +57,7 @@ test("chainToStageGroups: stages that resolve to nothing → linear fallback", (
|
|||||||
// ── serialization ─────────────────────────────────────────────────────────────
|
// ── serialization ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("stageGroupsToSeats flattens in order", () => {
|
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(
|
assert.deepEqual(
|
||||||
stageGroupsToSeats(groups).map((s) => s.agent_id),
|
stageGroupsToSeats(groups).map((s) => s.agent_id),
|
||||||
["a", "b", "c"],
|
["a", "b", "c"],
|
||||||
@@ -60,24 +65,47 @@ test("stageGroupsToSeats flattens in order", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload returns null when all singletons (linear)", () => {
|
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);
|
assert.equal(stageGroupsToStagePayload(groups), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload returns groups when any stage is parallel", () => {
|
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"]]);
|
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stageGroupsToStagePayload ignores empty stages", () => {
|
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"]]);
|
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 ───────────────────────────────────────────────────────────────────
|
// ── lookups ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("findStageIndexOfSeat / countSeats", () => {
|
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, "c"), 1);
|
||||||
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
|
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
|
||||||
assert.equal(countSeats(groups), 3);
|
assert.equal(countSeats(groups), 3);
|
||||||
@@ -86,60 +114,81 @@ test("findStageIndexOfSeat / countSeats", () => {
|
|||||||
// ── mutations ─────────────────────────────────────────────────────────────────
|
// ── mutations ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
|
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
|
||||||
let g: StageGroups = [[seat("a")]];
|
let g = grp([seat("a")]);
|
||||||
g = addSeatAsNewStage(g, seat("b"));
|
g = addSeatAsNewStage(g, seat("b"));
|
||||||
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
|
g = addSeatAsNewStage(g, seat("a")); // dup → no-op
|
||||||
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("addSeatToStage inserts into a stage at index", () => {
|
test("addSeatToStage inserts into a stage at index, preserves goal", () => {
|
||||||
const g = addSeatToStage([[seat("a")], [seat("c")]], seat("b"), 0, 0);
|
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.deepEqual(ids(g), [["b", "a"], ["c"]]);
|
||||||
|
assert.equal(g[0].goal, "g0"); // goal 保留
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removeSeat drops the seat and prunes an emptied stage", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["b", "c"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveSeatToStage merges across stages and prunes empty source", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["b", "a"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveSeatToStage within same stage reorders", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["c", "a", "b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("splitSeatToNewStage pulls a seat out into a new following stage", () => {
|
test("splitSeatToNewStage pulls a seat out; original keeps goal, split has none", () => {
|
||||||
const g = splitSeatToNewStage([[seat("a"), seat("b")], [seat("c")]], "b");
|
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.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", () => {
|
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"]]);
|
assert.deepEqual(ids(g), [["a"], ["b"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("moveStage reorders serial stages", () => {
|
test("moveStage reorders serial stages, goal travels with the stage", () => {
|
||||||
const g = moveStage([[seat("a")], [seat("b")], [seat("c")]], 2, -1);
|
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.deepEqual(ids(g), [["a"], ["c"], ["b"]]);
|
||||||
|
assert.equal(g[1].goal, "gc"); // c 的目标随它移到了第 2 位
|
||||||
});
|
});
|
||||||
|
|
||||||
test("addEmptyStage / deleteStage", () => {
|
test("addEmptyStage / deleteStage", () => {
|
||||||
let g = addEmptyStage([[seat("a")]]);
|
let g = addEmptyStage(grp([seat("a")]));
|
||||||
assert.deepEqual(ids(g), [["a"], []]);
|
assert.deepEqual(ids(g), [["a"], []]);
|
||||||
g = deleteStage(g, 1);
|
g = deleteStage(g, 1);
|
||||||
assert.deepEqual(ids(g), [["a"]]);
|
assert.deepEqual(ids(g), [["a"]]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// round-trip: editor groups → payload → reload preserves structure
|
// round-trip: editor groups → payload → reload preserves structure + goals
|
||||||
test("round-trip: groups → {seats, stages} → chainToStageGroups", () => {
|
test("round-trip: groups → {seats, stages, stageGoals} → chainToStageGroups", () => {
|
||||||
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
|
const groups = setStageGoal(grp([seat("a"), seat("b")], [seat("c")]), 1, "依赖上一阶段");
|
||||||
const seats = stageGroupsToSeats(groups);
|
const seats = stageGroupsToSeats(groups);
|
||||||
const stages = stageGroupsToStagePayload(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.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 内
|
* 编辑器内部用 `StageGroups`(`StageGroup[]`,每个 `StageGroup` = 一个 stage,含其
|
||||||
* 并行、stage 间串行)作为**单一数据源**:
|
* 并行席位 `seats` 与可选「本阶段目标」`goal`;stage 内并行、stage 间串行)作为
|
||||||
|
* **单一数据源**:
|
||||||
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
|
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
|
||||||
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。
|
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload});
|
||||||
|
* - 折叠得到落库的 `stageGoals`({@link stageGroupsToStageGoals}),与 `stages` 同下标对齐。
|
||||||
*
|
*
|
||||||
|
* `goal` 挂在 stage 对象上,随 stage 在增删改 / 重排中自动跟随,避免与席位错位。
|
||||||
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
|
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
|
||||||
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
|
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
|
||||||
*/
|
*/
|
||||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||||
|
|
||||||
/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */
|
/** 一个并行 stage:含其并行席位与可选「本阶段目标」(注入给总控/席位,仅 dag 模式生效)。 */
|
||||||
export type StageGroups = ChainSeat[][];
|
export interface StageGroup {
|
||||||
|
seats: ChainSeat[];
|
||||||
|
/** 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。 */
|
||||||
|
goal?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 编辑器内部的分层结构:每个元素是一个并行 stage,外层顺序即串行顺序。 */
|
||||||
|
export type StageGroups = StageGroup[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从一条业务链条还原分层结构。
|
* 从一条业务链条还原分层结构。
|
||||||
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
|
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
|
||||||
* 分层全空时回退到线性。
|
* `goal` 按**原始 stage 下标**从 `stageGoals` 取,随被丢弃的组一并丢弃;分层全空时回退到线性。
|
||||||
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。
|
* - 无 `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 seats = chain.seats ?? [];
|
||||||
const byId = new Map(seats.map((s) => [s.agent_id, s]));
|
const byId = new Map(seats.map((s) => [s.agent_id, s]));
|
||||||
|
const goals = chain.stageGoals ?? null;
|
||||||
if (chain.stages && chain.stages.length > 0) {
|
if (chain.stages && chain.stages.length > 0) {
|
||||||
const groups: StageGroups = [];
|
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));
|
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;
|
if (groups.length > 0) return groups;
|
||||||
}
|
}
|
||||||
return seats.map((s) => [s]);
|
return seats.map((s) => ({ seats: [s] }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
|
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
|
||||||
export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
|
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 已被忽略。
|
* 就返回显式分层(Step 2 走 dag 模式)。空 stage 已被忽略。
|
||||||
*/
|
*/
|
||||||
export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null {
|
export function stageGroupsToStagePayload(groups: StageGroups): string[][] | null {
|
||||||
const nonEmpty = groups.filter((g) => g.length > 0);
|
const nonEmpty = groups.filter((g) => g.seats.length > 0);
|
||||||
const hasParallel = nonEmpty.some((g) => g.length > 1);
|
const hasParallel = nonEmpty.some((g) => g.seats.length > 1);
|
||||||
if (!hasParallel) return null;
|
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。 */
|
/** 该席位所在 stage 下标;不存在返回 -1。 */
|
||||||
export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number {
|
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 中。 */
|
/** 席位是否已在任意 stage 中。 */
|
||||||
@@ -63,7 +90,13 @@ export function seatExistsInGroups(groups: StageGroups, agentId: string): boolea
|
|||||||
|
|
||||||
/** 总席位数(用于 2–8 约束)。 */
|
/** 总席位数(用于 2–8 约束)。 */
|
||||||
export function countSeats(groups: StageGroups): number {
|
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 点击加入默认行为)。 */
|
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
|
||||||
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
|
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
|
||||||
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
||||||
return [...groups, [seat]];
|
return [...groups, { seats: [seat] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */
|
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。保留目标 stage 的 goal。 */
|
||||||
export function addSeatToStage(
|
export function addSeatToStage(
|
||||||
groups: StageGroups,
|
groups: StageGroups,
|
||||||
seat: ChainSeat,
|
seat: ChainSeat,
|
||||||
@@ -85,18 +118,18 @@ export function addSeatToStage(
|
|||||||
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
||||||
return groups.map((g, i) => {
|
return groups.map((g, i) => {
|
||||||
if (i !== stageIdx) return g;
|
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));
|
const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
|
||||||
next.splice(at, 0, seat);
|
next.splice(at, 0, seat);
|
||||||
return next;
|
return { ...g, seats: next };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */
|
/** 移除一个席位;该 stage 因此变空则连 stage(含其 goal)一起删掉。 */
|
||||||
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
|
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
|
||||||
return groups
|
return groups
|
||||||
.map((g) => g.filter((s) => s.agent_id !== agentId))
|
.map((g) => ({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) }))
|
||||||
.filter((g) => g.length > 0);
|
.filter((g) => g.seats.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,50 +145,53 @@ export function moveSeatToStage(
|
|||||||
): StageGroups {
|
): StageGroups {
|
||||||
const from = findStageIndexOfSeat(groups, agentId);
|
const from = findStageIndexOfSeat(groups, agentId);
|
||||||
if (from < 0 || targetStageIdx < 0 || targetStageIdx >= groups.length) return groups;
|
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;
|
if (!seat) return groups;
|
||||||
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。
|
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。goal 始终随 stage 保留。
|
||||||
const targetRef = groups[targetStageIdx];
|
const targetRef = groups[targetStageIdx];
|
||||||
let next: StageGroups = groups.map((g) =>
|
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) => {
|
next = next.map((g) => {
|
||||||
if (g !== targetRef) return 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 插回。
|
// 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));
|
const at = atIndex == null ? without.length : Math.max(0, Math.min(atIndex, without.length));
|
||||||
without.splice(at, 0, seat);
|
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));
|
const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length));
|
||||||
merged.splice(at, 0, seat);
|
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 {
|
export function splitSeatToNewStage(groups: StageGroups, agentId: string): StageGroups {
|
||||||
const from = findStageIndexOfSeat(groups, agentId);
|
const from = findStageIndexOfSeat(groups, agentId);
|
||||||
if (from < 0) return groups;
|
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 (!seat) return groups;
|
||||||
if (groups[from].length === 1) return groups; // 已经独占一层,无需拆
|
if (groups[from].seats.length === 1) return groups; // 已经独占一层,无需拆
|
||||||
const out: StageGroups = [];
|
const out: StageGroups = [];
|
||||||
groups.forEach((g, i) => {
|
groups.forEach((g, i) => {
|
||||||
if (i === from) {
|
if (i === from) {
|
||||||
out.push(g.filter((s) => s.agent_id !== agentId));
|
out.push({ ...g, seats: g.seats.filter((s) => s.agent_id !== agentId) });
|
||||||
out.push([seat]);
|
out.push({ seats: [seat] });
|
||||||
} else {
|
} else {
|
||||||
out.push(g);
|
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 {
|
export function moveStage(groups: StageGroups, stageIdx: number, dir: -1 | 1): StageGroups {
|
||||||
const target = stageIdx + dir;
|
const target = stageIdx + dir;
|
||||||
if (stageIdx < 0 || stageIdx >= groups.length || target < 0 || target >= groups.length) {
|
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」后再拖 / 加席位进去)。 */
|
/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */
|
||||||
export function addEmptyStage(groups: StageGroups): StageGroups {
|
export function addEmptyStage(groups: StageGroups): StageGroups {
|
||||||
return [...groups, []];
|
return [...groups, { seats: [] }];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除整个 stage(含其席位)。 */
|
/** 删除整个 stage(含其席位)。 */
|
||||||
|
|||||||
@@ -188,6 +188,25 @@ test("a chain plan validates clean against its surviving roster", () => {
|
|||||||
assert.equal(res.ok, true);
|
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 依赖摘要注入) ─────────────────────────────────
|
// ── buildDependencySummary (§3.1 依赖摘要注入) ─────────────────────────────────
|
||||||
|
|
||||||
test("buildDependencySummary is empty for no deliveries (first stage)", () => {
|
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, /真正调用 agent_orchestration 工具/);
|
||||||
assert.match(task, /不要.*只用文字/);
|
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;
|
id: string;
|
||||||
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
||||||
agentIds: string[];
|
agentIds: string[];
|
||||||
|
/**
|
||||||
|
* 可选「本阶段目标」:来自业务链条 `stageGoals`,注入总控派活提示(主)与席位任务
|
||||||
|
* (兜底),告诉本阶段要产出什么、如何依赖上一阶段产出。空 = 不注入。
|
||||||
|
*/
|
||||||
|
goal?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
||||||
@@ -180,17 +185,22 @@ export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
|
|||||||
* @param stages 链条分层(每组并行、组间串行)。
|
* @param stages 链条分层(每组并行、组间串行)。
|
||||||
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
|
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
|
||||||
* @param finalSynthesis 是否收口综合,默认 true。
|
* @param finalSynthesis 是否收口综合,默认 true。
|
||||||
|
* @param goals 可选「每阶段目标」,与 `stages` **同源下标**对齐;存活 stage 取对应 goal
|
||||||
|
* (被丢弃的 stage 其 goal 一并丢弃,因为按源下标取)。
|
||||||
*/
|
*/
|
||||||
export function buildPlanFromStages(
|
export function buildPlanFromStages(
|
||||||
stages: string[][],
|
stages: string[][],
|
||||||
allowedIds?: Iterable<string>,
|
allowedIds?: Iterable<string>,
|
||||||
finalSynthesis = true,
|
finalSynthesis = true,
|
||||||
|
goals?: ReadonlyArray<string | null> | null,
|
||||||
): OrchestrationPlan {
|
): OrchestrationPlan {
|
||||||
const allow = allowedIds ? new Set(allowedIds) : null;
|
const allow = allowedIds ? new Set(allowedIds) : null;
|
||||||
const cleaned: OrchestrationStage[] = [];
|
const cleaned: OrchestrationStage[] = [];
|
||||||
stages.forEach((group, idx) => {
|
stages.forEach((group, idx) => {
|
||||||
const ids = allow ? group.filter((id) => allow.has(id)) : group.slice();
|
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 };
|
return { mode: "dag", stages: cleaned, finalSynthesis };
|
||||||
}
|
}
|
||||||
@@ -330,13 +340,16 @@ export function buildDagStageLeaderTask(args: {
|
|||||||
/** 本 stage 计划钉死的席位。 */
|
/** 本 stage 计划钉死的席位。 */
|
||||||
seats: Array<{ agentId: string; name: string }>;
|
seats: Array<{ agentId: string; name: string }>;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
|
/** 可选「本阶段目标」:用户在业务链条里为本阶段写的产出 / 依赖说明。空 = 不注入。 */
|
||||||
|
goal?: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { seedMessage, stageIndex, totalStages, seats, isFirst } = args;
|
const { seedMessage, stageIndex, totalStages, seats, isFirst, goal } = args;
|
||||||
const ordinal = `${stageIndex + 1}/${totalStages}`;
|
const ordinal = `${stageIndex + 1}/${totalStages}`;
|
||||||
const seatList = seats
|
const seatList = seats
|
||||||
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
|
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
|
||||||
.join("、");
|
.join("、");
|
||||||
const isParallel = seats.length > 1;
|
const isParallel = seats.length > 1;
|
||||||
|
const goalText = goal?.trim();
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
||||||
@@ -349,7 +362,15 @@ export function buildDagStageLeaderTask(args: {
|
|||||||
lines.push(
|
lines.push(
|
||||||
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
|
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
|
||||||
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
|
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
|
||||||
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
|
);
|
||||||
|
if (goalText) {
|
||||||
|
lines.push(
|
||||||
|
`【本阶段目标】${goalText}`,
|
||||||
|
"请据此目标为每个席位拆解任务,并要求他们**显式承接上一阶段的产出**来完成本阶段目标。",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(
|
||||||
|
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景${goalText ? "、本阶段目标" : ""}与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
|
||||||
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
|
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
|
||||||
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
|
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
|
||||||
);
|
);
|
||||||
@@ -386,6 +407,11 @@ export function pickDagSeatTask(
|
|||||||
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
|
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
|
||||||
*/
|
*/
|
||||||
allowFiles = false,
|
allowFiles = false,
|
||||||
|
/**
|
||||||
|
* 可选「本阶段目标」(兜底注入):总控派活质量差 / 没派到时,席位仍据此知道本阶段要产出
|
||||||
|
* 什么、如何依赖上一阶段产出。空 = 不注入。
|
||||||
|
*/
|
||||||
|
goal = "",
|
||||||
): string {
|
): string {
|
||||||
const guard =
|
const guard =
|
||||||
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
|
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
|
||||||
@@ -402,15 +428,20 @@ export function pickDagSeatTask(
|
|||||||
const peerRule = hasPeers
|
const peerRule = hasPeers
|
||||||
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
|
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
|
||||||
: "";
|
: "";
|
||||||
|
// 本阶段目标(兜底):用户为本阶段写的产出/依赖说明,即便总控没派好,席位也据此对齐本阶段意图。
|
||||||
|
const goalRule = goal.trim()
|
||||||
|
? `\n\n【本阶段目标】${goal.trim()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。`
|
||||||
|
: "";
|
||||||
const wanted = seatAgentId.toLowerCase();
|
const wanted = seatAgentId.toLowerCase();
|
||||||
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
|
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
|
||||||
if (exact && exact[1]?.trim()) {
|
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 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
|
// dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
|
||||||
return (
|
return (
|
||||||
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
|
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
|
||||||
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
|
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
|
||||||
|
goalRule +
|
||||||
fileRule +
|
fileRule +
|
||||||
peerRule +
|
peerRule +
|
||||||
guard
|
guard
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
GripVertical,
|
GripVertical,
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
SplitSquareVertical,
|
SplitSquareVertical,
|
||||||
@@ -60,6 +61,12 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||||
import { listAgents } from "@/core/agents";
|
import { listAgents } from "@/core/agents";
|
||||||
|
|
||||||
@@ -93,9 +100,12 @@ import {
|
|||||||
moveStage,
|
moveStage,
|
||||||
removeSeat as removeSeatFromGroups,
|
removeSeat as removeSeatFromGroups,
|
||||||
seatExistsInGroups,
|
seatExistsInGroups,
|
||||||
|
setStageGoal,
|
||||||
splitSeatToNewStage,
|
splitSeatToNewStage,
|
||||||
stageGroupsToSeats,
|
stageGroupsToSeats,
|
||||||
|
stageGroupsToStageGoals,
|
||||||
stageGroupsToStagePayload,
|
stageGroupsToStagePayload,
|
||||||
|
type StageGroup,
|
||||||
type StageGroups,
|
type StageGroups,
|
||||||
} from "@/roundtable-planning/lib/chain-stages";
|
} from "@/roundtable-planning/lib/chain-stages";
|
||||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
@@ -144,6 +154,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
|
|
||||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
const [flowOpen, setFlowOpen] = useState(false);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
@@ -155,7 +166,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
return m;
|
return m;
|
||||||
}, [pool]);
|
}, [pool]);
|
||||||
const seatIdSet = useMemo(
|
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],
|
[stageGroups],
|
||||||
);
|
);
|
||||||
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||||
@@ -165,14 +176,14 @@ export default function BusinessChainEditorPage() {
|
|||||||
const previewStages = useMemo(
|
const previewStages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
stageGroups
|
stageGroups
|
||||||
.filter((g) => g.length > 0)
|
.filter((g) => g.seats.length > 0)
|
||||||
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.map((s) => s.agent_id) })),
|
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })),
|
||||||
[stageGroups],
|
[stageGroups],
|
||||||
);
|
);
|
||||||
const seatNameById = useMemo(() => {
|
const seatNameById = useMemo(() => {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
for (const g of stageGroups)
|
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;
|
return m;
|
||||||
}, [stageGroups]);
|
}, [stageGroups]);
|
||||||
const resolvePreviewSeat = useCallback(
|
const resolvePreviewSeat = useCallback(
|
||||||
@@ -291,6 +302,10 @@ export default function BusinessChainEditorPage() {
|
|||||||
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
|
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setGoal = useCallback((stageIdx: number, goal: string) => {
|
||||||
|
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const splitSeat = useCallback((agentId: string) => {
|
const splitSeat = useCallback((agentId: string) => {
|
||||||
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
|
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -306,8 +321,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
const removeStage = useCallback((stageIdx: number) => {
|
const removeStage = useCallback((stageIdx: number) => {
|
||||||
setStageGroups((prev) => {
|
setStageGroups((prev) => {
|
||||||
const stage = prev[stageIdx];
|
const stage = prev[stageIdx];
|
||||||
if (stage && stage.length > 0) {
|
if (stage && stage.seats.length > 0) {
|
||||||
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) {
|
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.seats.length} 个席位,确认?`)) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +364,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
} else if (overId.startsWith(SEAT_PREFIX)) {
|
} else if (overId.startsWith(SEAT_PREFIX)) {
|
||||||
const overAgent = overId.slice(SEAT_PREFIX.length);
|
const overAgent = overId.slice(SEAT_PREFIX.length);
|
||||||
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
|
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;
|
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 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 canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
@@ -411,12 +426,15 @@ export default function BusinessChainEditorPage() {
|
|||||||
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
||||||
}));
|
}));
|
||||||
const stages = stageGroupsToStagePayload(stageGroups);
|
const stages = stageGroupsToStagePayload(stageGroups);
|
||||||
|
// 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。
|
||||||
|
const stageGoals = stageGroupsToStageGoals(stageGroups);
|
||||||
if (currentChainId) {
|
if (currentChainId) {
|
||||||
const updated = await updateChain(currentChainId, {
|
const updated = await updateChain(currentChainId, {
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
seats: payloadSeats,
|
seats: payloadSeats,
|
||||||
stages,
|
stages,
|
||||||
|
stageGoals,
|
||||||
});
|
});
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
toast.error("链条已不存在,可能被删除");
|
toast.error("链条已不存在,可能被删除");
|
||||||
@@ -433,6 +451,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
seats: payloadSeats,
|
seats: payloadSeats,
|
||||||
stages,
|
stages,
|
||||||
|
stageGoals,
|
||||||
});
|
});
|
||||||
setCurrentChainId(created.id);
|
setCurrentChainId(created.id);
|
||||||
toast.success("已创建业务链条");
|
toast.success("已创建业务链条");
|
||||||
@@ -793,6 +812,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
onRemoveSeat={removeSeat}
|
onRemoveSeat={removeSeat}
|
||||||
onMergeSeat={mergeSeatToStage}
|
onMergeSeat={mergeSeatToStage}
|
||||||
onSplitSeat={splitSeat}
|
onSplitSeat={splitSeat}
|
||||||
|
onStageGoalChange={setGoal}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -811,24 +831,48 @@ export default function BusinessChainEditorPage() {
|
|||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */}
|
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
|
||||||
|
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
|
||||||
{previewStages.length > 0 && (
|
{previewStages.length > 0 && (
|
||||||
<section className="shrink-0 border-t border-border bg-card px-6 py-2">
|
<section className="shrink-0 flex items-center justify-between gap-2 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">
|
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
<span>编排预览</span>
|
<span>编排预览</span>
|
||||||
<span className="text-muted-foreground/50">
|
<span className="text-muted-foreground/50">
|
||||||
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
|
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<DagFlowChart
|
<button
|
||||||
stages={previewStages}
|
type="button"
|
||||||
resolveSeat={resolvePreviewSeat}
|
onClick={() => setFlowOpen(true)}
|
||||||
showReportMilestone={false}
|
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"
|
||||||
compact
|
>
|
||||||
/>
|
<Network className="size-3.5" />
|
||||||
|
查看编排流程图
|
||||||
|
</button>
|
||||||
</section>
|
</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">
|
<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 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||||
@@ -923,6 +967,7 @@ function StagesColumn({
|
|||||||
onRemoveSeat,
|
onRemoveSeat,
|
||||||
onMergeSeat,
|
onMergeSeat,
|
||||||
onSplitSeat,
|
onSplitSeat,
|
||||||
|
onStageGoalChange,
|
||||||
}: {
|
}: {
|
||||||
stageGroups: StageGroups;
|
stageGroups: StageGroups;
|
||||||
seatCount: number;
|
seatCount: number;
|
||||||
@@ -934,6 +979,7 @@ function StagesColumn({
|
|||||||
onRemoveSeat: (agentId: string) => void;
|
onRemoveSeat: (agentId: string) => void;
|
||||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||||
onSplitSeat: (agentId: string) => void;
|
onSplitSeat: (agentId: string) => void;
|
||||||
|
onStageGoalChange: (stageIdx: number, goal: string) => void;
|
||||||
}) {
|
}) {
|
||||||
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
|
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
|
||||||
const stageStartIndex = useMemo(() => {
|
const stageStartIndex = useMemo(() => {
|
||||||
@@ -941,10 +987,12 @@ function StagesColumn({
|
|||||||
let acc = 0;
|
let acc = 0;
|
||||||
for (const g of stageGroups) {
|
for (const g of stageGroups) {
|
||||||
out.push(acc);
|
out.push(acc);
|
||||||
acc += g.length;
|
acc += g.seats.length;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [stageGroups]);
|
}, [stageGroups]);
|
||||||
|
// 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。
|
||||||
|
const hasParallelStage = stageGroups.some((g) => g.seats.length > 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -1018,11 +1066,13 @@ function StagesColumn({
|
|||||||
group={group}
|
group={group}
|
||||||
stageCount={stageGroups.length}
|
stageCount={stageGroups.length}
|
||||||
startIndex={stageStartIndex[stageIdx]}
|
startIndex={stageStartIndex[stageIdx]}
|
||||||
|
hasParallelStage={hasParallelStage}
|
||||||
onRemoveStage={() => onRemoveStage(stageIdx)}
|
onRemoveStage={() => onRemoveStage(stageIdx)}
|
||||||
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
|
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
|
||||||
onRemoveSeat={onRemoveSeat}
|
onRemoveSeat={onRemoveSeat}
|
||||||
onMergeSeat={onMergeSeat}
|
onMergeSeat={onMergeSeat}
|
||||||
onSplitSeat={onSplitSeat}
|
onSplitSeat={onSplitSeat}
|
||||||
|
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
|
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
|
||||||
@@ -1040,27 +1090,33 @@ function StageSection({
|
|||||||
group,
|
group,
|
||||||
stageCount,
|
stageCount,
|
||||||
startIndex,
|
startIndex,
|
||||||
|
hasParallelStage,
|
||||||
onRemoveStage,
|
onRemoveStage,
|
||||||
onReorderStage,
|
onReorderStage,
|
||||||
onRemoveSeat,
|
onRemoveSeat,
|
||||||
onMergeSeat,
|
onMergeSeat,
|
||||||
onSplitSeat,
|
onSplitSeat,
|
||||||
|
onGoalChange,
|
||||||
}: {
|
}: {
|
||||||
stageIdx: number;
|
stageIdx: number;
|
||||||
group: ChainSeat[];
|
group: StageGroup;
|
||||||
stageCount: number;
|
stageCount: number;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
|
/** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */
|
||||||
|
hasParallelStage: boolean;
|
||||||
onRemoveStage: () => void;
|
onRemoveStage: () => void;
|
||||||
onReorderStage: (dir: -1 | 1) => void;
|
onReorderStage: (dir: -1 | 1) => void;
|
||||||
onRemoveSeat: (agentId: string) => void;
|
onRemoveSeat: (agentId: string) => void;
|
||||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||||
onSplitSeat: (agentId: string) => void;
|
onSplitSeat: (agentId: string) => void;
|
||||||
|
onGoalChange: (goal: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
|
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 isFirstStage = stageIdx === 0;
|
||||||
const isLastStage = stageIdx === stageCount - 1;
|
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 (
|
return (
|
||||||
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
|
<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",
|
: "border-border bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isParallel ? `并行 ${group.length}` : "串行"}
|
{isParallel ? `并行 ${seats.length}` : "串行"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
@@ -1131,18 +1187,45 @@ function StageSection({
|
|||||||
</div>
|
</div>
|
||||||
</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 内席位 */}
|
{/* Stage 内席位 */}
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
|
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)}>
|
<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。
|
空 Stage —— 把席位拖到此处,或删除该 Stage。
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
|
||||||
{group.map((seat, i) => (
|
{seats.map((seat, i) => (
|
||||||
<SeatStrip
|
<SeatStrip
|
||||||
key={seat.agent_id}
|
key={seat.agent_id}
|
||||||
seat={seat}
|
seat={seat}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class ChainResponse(BaseModel):
|
|||||||
# Optional layered orchestration: list of stages, each a list of agent_ids
|
# Optional layered orchestration: list of stages, each a list of agent_ids
|
||||||
# run in parallel (stages run serially). None/absent = linear chain.
|
# run in parallel (stages run serially). None/absent = linear chain.
|
||||||
stages: list[list[str]] | None = None
|
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 = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||||
@@ -69,6 +71,7 @@ class ChainCreateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=2048)
|
description: str | None = Field(default=None, max_length=2048)
|
||||||
seats: list[ChainSeatSchema] = []
|
seats: list[ChainSeatSchema] = []
|
||||||
stages: list[list[str]] | None = None
|
stages: list[list[str]] | None = None
|
||||||
|
stage_goals: list[str | None] | None = None
|
||||||
|
|
||||||
|
|
||||||
class ChainUpdateRequest(BaseModel):
|
class ChainUpdateRequest(BaseModel):
|
||||||
@@ -77,6 +80,7 @@ class ChainUpdateRequest(BaseModel):
|
|||||||
description: str | None = Field(default=None, max_length=2048)
|
description: str | None = Field(default=None, max_length=2048)
|
||||||
seats: list[ChainSeatSchema] | None = None
|
seats: list[ChainSeatSchema] | None = None
|
||||||
stages: list[list[str]] | None = None
|
stages: list[list[str]] | None = None
|
||||||
|
stage_goals: list[str | None] | None = None
|
||||||
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
|
||||||
is_public: bool | None = None
|
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 ───────────────────────────────────────────────────────────────
|
# ── routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -174,6 +210,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
|||||||
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
|
||||||
_validate_seats(body.seats)
|
_validate_seats(body.seats)
|
||||||
_validate_stages(body.seats, body.stages)
|
_validate_stages(body.seats, body.stages)
|
||||||
|
_validate_stage_goals(body.stages, body.stage_goals)
|
||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
row = await store.create_chain(
|
row = await store.create_chain(
|
||||||
@@ -184,6 +221,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
|
|||||||
"description": body.description,
|
"description": body.description,
|
||||||
"seats": [s.model_dump() for s in body.seats],
|
"seats": [s.model_dump() for s in body.seats],
|
||||||
"stages": body.stages,
|
"stages": body.stages,
|
||||||
|
"stage_goals": body.stage_goals,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return ChainResponse(**row)
|
return ChainResponse(**row)
|
||||||
@@ -209,6 +247,8 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
|
|||||||
if body.seats is None:
|
if body.seats is None:
|
||||||
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
|
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
|
||||||
_validate_stages(body.seats, body.stages)
|
_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)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
|
# 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
|
# (each seat its own stage). Drives Step 2 dag-mode orchestration; see
|
||||||
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
# frontend ``lib/dag-types.ts``. Stored as LONGTEXT-backed JSON string.
|
||||||
stages: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
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 = 仅本人可见。
|
# 发布开关:True = 公共(所有账号可在「公共」筛选里查看 / 选用);False = 仅本人可见。
|
||||||
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
# 编辑 / 删除始终只限 owner;发布只是放开「查看 + 选入研讨」。索引以支持「公共」列表筛选。
|
||||||
is_public: Mapped[bool] = mapped_column(
|
is_public: Mapped[bool] = mapped_column(
|
||||||
|
|||||||
+6
@@ -52,6 +52,8 @@ class RoundtableChainRepository:
|
|||||||
"seats": _loads(row.seats) or [],
|
"seats": _loads(row.seats) or [],
|
||||||
# None when absent → frontend reads it as "linear chain" (no layering).
|
# None when absent → frontend reads it as "linear chain" (no layering).
|
||||||
"stages": _loads(row.stages),
|
"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_public": bool(getattr(row, "is_public", False)),
|
||||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
"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),
|
description=(data.get("description") or None),
|
||||||
seats=_dumps(data.get("seats") or []),
|
seats=_dumps(data.get("seats") or []),
|
||||||
stages=_dumps(data.get("stages")),
|
stages=_dumps(data.get("stages")),
|
||||||
|
stage_goals=_dumps(data.get("stage_goals")),
|
||||||
is_public=bool(data.get("is_public") or False),
|
is_public=bool(data.get("is_public") or False),
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
@@ -121,6 +124,7 @@ class RoundtableChainRepository:
|
|||||||
description=_UNSET,
|
description=_UNSET,
|
||||||
seats=_UNSET,
|
seats=_UNSET,
|
||||||
stages=_UNSET,
|
stages=_UNSET,
|
||||||
|
stage_goals=_UNSET,
|
||||||
is_public=_UNSET,
|
is_public=_UNSET,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
@@ -135,6 +139,8 @@ class RoundtableChainRepository:
|
|||||||
row.seats = _dumps(seats)
|
row.seats = _dumps(seats)
|
||||||
if stages is not _UNSET:
|
if stages is not _UNSET:
|
||||||
row.stages = _dumps(stages)
|
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:
|
if is_public is not _UNSET and is_public is not None:
|
||||||
row.is_public = bool(is_public)
|
row.is_public = bool(is_public)
|
||||||
row.updated_at = datetime.now(UTC)
|
row.updated_at = datetime.now(UTC)
|
||||||
|
|||||||
Reference in new issue
Block a user