feat(roundtable): 圆桌 Step2 分层 DAG 编排(并行/沙箱双模式 + 流程图 + 按需取全文)
数据模型/编辑器:链条加 stages 分层(前后端 + alembic 20260607_03);BusinessChainEditorPage 竖列 Stage 编辑 + 底部只读流程图预览;lib/dag-types + chain-stages 纯逻辑(含单测)。 调度(runDagOrchestration,方案 A 总控驱动):每个 stage 由总控经 agent_orchestration 派活给计划钉死的席位,批内 Promise.all 并行、批间 barrier 串行;末尾交回 runOrchestration 收口。抑制 leader 预广播 + 干净席位任务,根治『席位模仿派活』。 执行子模式:快速(双列·产物走正文·禁写文件 seat_parallel)/ 沙箱(单列·写 .md·沙箱跟随预览 seat);统一禁 bash(防 .py 脚本),提示词禁 .py。 显示:头像条 + 两列/单列 + ref 全量累积 + 节流 flush(仅选中渲染);DAG 流程图弹窗(按 stage 分列,节点随执行点亮,只到共识);沙箱跟随当前席位(防错位 + 修第一个席位自动开沙箱)。 按需取全文:新增 harness 只读工具 read_peer_delivery(从 run context 取网关注入的前序席位完整交付),席位默认看摘要、需要时取全文。 修:宽表溢出、并行自动下滑、data-lov-id Fragment 警告等。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5164fd362c
commit
7b8c11852e
26 files changed
+3719
-216
No files matched your search
@@ -22,6 +22,8 @@ export interface ChainCreatePayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
seats: ChainSeat[];
|
||||
/** Optional layered orchestration (agent_id groups). null/absent = linear. */
|
||||
stages?: string[][] | null;
|
||||
}
|
||||
|
||||
/** Partial update — send only what changed. */
|
||||
@@ -29,6 +31,8 @@ export interface ChainUpdatePayload {
|
||||
title?: string;
|
||||
description?: string;
|
||||
seats?: ChainSeat[];
|
||||
/** Send `null` to clear layering back to linear; omit to leave unchanged. */
|
||||
stages?: string[][] | null;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
|
||||
@@ -50,6 +54,7 @@ interface ServerChain {
|
||||
title: string;
|
||||
description: string | null;
|
||||
seats: ChainSeat[];
|
||||
stages?: string[][] | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
@@ -60,6 +65,7 @@ function toChain(s: ServerChain): BusinessChain {
|
||||
title: s.title ?? "",
|
||||
description: s.description ?? undefined,
|
||||
seats: Array.isArray(s.seats) ? s.seats : [],
|
||||
stages: Array.isArray(s.stages) ? s.stages : null,
|
||||
createdAt: s.created_at ?? undefined,
|
||||
updatedAt: s.updated_at ?? undefined,
|
||||
};
|
||||
@@ -71,6 +77,7 @@ function fromCreate(p: ChainCreatePayload) {
|
||||
title: p.title,
|
||||
...(p.description !== undefined ? { description: p.description } : {}),
|
||||
seats: p.seats,
|
||||
...(p.stages !== undefined ? { stages: p.stages } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -79,6 +86,8 @@ function fromUpdate(p: ChainUpdatePayload) {
|
||||
if (p.title !== undefined) out.title = p.title;
|
||||
if (p.description !== undefined) out.description = p.description;
|
||||
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;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
||||
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
|
||||
|
||||
/**
|
||||
@@ -50,20 +51,33 @@ export interface DraftStep2Snapshot {
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
/**
|
||||
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
|
||||
* dispatches freely; chain = dispatch follows the saved business chain order.
|
||||
* Optional — drafts saved before this won't carry it (hydrate defaults to
|
||||
* "recommend").
|
||||
* dispatches freely; chain = dispatch follows the saved business chain order;
|
||||
* dag = layered Stage DAG (stage-internal parallel, stage-to-stage serial,
|
||||
* see `lib/dag-types.ts`). Optional — drafts saved before this won't carry it
|
||||
* (hydrate defaults to "recommend").
|
||||
*/
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||||
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
|
||||
chain?: { id: string; title: string } | null;
|
||||
/**
|
||||
* Layered DAG plan, when `orchestrationMode === "dag"`. Persisted so a reloaded
|
||||
* draft restores the编排. Optional — null/absent for non-dag drafts. Old `chain`
|
||||
* drafts are converted on read via `chainToDagPlan` (compat, original draft
|
||||
* untouched).
|
||||
*/
|
||||
orchestrationPlan?: OrchestrationPlan | null;
|
||||
/**
|
||||
* dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
|
||||
* 仅 `orchestrationMode === "dag"` 有意义;缺省 fast。
|
||||
*/
|
||||
dagExecMode?: "fast" | "sandbox";
|
||||
}
|
||||
|
||||
export interface DraftStep2RunSnapshot {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
source: "initial" | "recommend" | "chain" | "manual";
|
||||
source: "initial" | "recommend" | "chain" | "dag" | "manual";
|
||||
selectedAgents?: Array<{ agent_id: string; name: string }>;
|
||||
threadIds: ThreadIdMap | null;
|
||||
coordinatorName: string | null;
|
||||
@@ -73,8 +87,11 @@ export interface DraftStep2RunSnapshot {
|
||||
consensusPercentage: number;
|
||||
budgetLimit: number;
|
||||
seatStubMessages?: Record<string, unknown[]>;
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||||
chain?: { id: string; title: string } | null;
|
||||
orchestrationPlan?: OrchestrationPlan | null;
|
||||
/** dag 执行子模式:fast / sandbox(见 DraftStep2Snapshot.dagExecMode)。 */
|
||||
dagExecMode?: "fast" | "sandbox";
|
||||
step3?: DraftStep3Snapshot | null;
|
||||
jobId?: string | null;
|
||||
jobStatus?: JobStatus | null;
|
||||
|
||||
@@ -209,6 +209,24 @@ export interface StreamMultiAgentRequest {
|
||||
* 后端只给摘要,使总控上下文精简、响应快。
|
||||
*/
|
||||
synthesisMode?: boolean;
|
||||
/**
|
||||
* 仅 leader 用。置 true 抑制「把总控本轮输入广播给各席位 thread」的预广播。DAG「每 stage
|
||||
* 派活轮」置真——否则总控的派活 prompt 会进席位历史,弱模型据此模仿派活。后端缺省 false。
|
||||
*/
|
||||
suppressPreBroadcast?: boolean;
|
||||
/**
|
||||
* 仅 special 用。置 true 表示这是 DAG 并行批次里的席位:后端按 `seat_parallel`
|
||||
* run policy **禁用** write_file / bash / str_replace / present_files 等写文件 /
|
||||
* 沙箱工具(§4),避免同批多席位并发抢同一沙箱。产物以消息正文交付。后端缺失
|
||||
* 该字段时按 false 处理(普通 seat policy),旧前端不受影响。
|
||||
*/
|
||||
parallelNoFile?: boolean;
|
||||
/**
|
||||
* 仅 special 用。前序席位的**完整交付** `[{name, content}]`。后端注入 run context
|
||||
* (`roundtable_peer_deliveries`),席位用 `read_peer_delivery` 工具**按需**取全文
|
||||
* (平时只看广播摘要)。不进 prompt → 不调工具就不耗 token。
|
||||
*/
|
||||
peerDeliveries?: Array<{ name: string; content: string }>;
|
||||
signal?: AbortSignal;
|
||||
/** Fires for every non-status data frame the upstream emits. */
|
||||
onUpstreamFrame?: (frame: unknown) => void;
|
||||
@@ -423,6 +441,12 @@ export async function streamMultiAgent(
|
||||
skill_stop_names: req.skillStopNames ?? [],
|
||||
// 仅 leader 用;综合轮让后端注入各席位完整交付正文。省略 → 后端按 false 处理。
|
||||
synthesis_mode: req.synthesisMode ?? false,
|
||||
// 仅 leader 用;DAG 派活轮抑制预广播,避免总控派活 prompt 污染席位 thread。省略 → false。
|
||||
suppress_pre_broadcast: req.suppressPreBroadcast ?? false,
|
||||
// 仅 special 用;DAG 并行批次禁写文件(seat_parallel policy)。省略 → 后端按 false 处理。
|
||||
parallel_no_file: req.parallelNoFile ?? false,
|
||||
// 仅 special 用;前序席位完整交付,后端注入 context 供 read_peer_delivery 按需取全文。
|
||||
peer_deliveries: req.peerDeliveries ?? [],
|
||||
}),
|
||||
signal: req.signal,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
type BusinessChain,
|
||||
type SeatSelectionResult,
|
||||
} from "../lib/business-chain";
|
||||
import { buildPlanFromStages } from "../lib/dag-types";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
|
||||
export interface BusinessChainPickerProps {
|
||||
@@ -102,17 +103,34 @@ export function BusinessChainPicker({
|
||||
);
|
||||
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
|
||||
|
||||
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在
|
||||
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
|
||||
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);
|
||||
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null;
|
||||
}, [selectedChain, survivingSeats]);
|
||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
||||
|
||||
const handleUse = () => {
|
||||
if (!canUse || !selectedChain) return;
|
||||
const agents = survivingSeats.map((s) => ({
|
||||
agent_id: s.agent_id,
|
||||
name: nameById.get(s.agent_id) ?? s.name,
|
||||
}));
|
||||
onConfirm({
|
||||
agents,
|
||||
mode: "chain",
|
||||
chain: { id: selectedChain.id, title: selectedChain.title },
|
||||
});
|
||||
const chainMeta = { id: selectedChain.id, title: selectedChain.title };
|
||||
const seatNameById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.name]));
|
||||
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
|
||||
|
||||
// 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。
|
||||
if (dagPlan) {
|
||||
// dag:席位顺序按 stage 展平(与 plan 一致)。
|
||||
const agents = dagPlan.stages
|
||||
.flatMap((st) => st.agentIds)
|
||||
.map((id) => ({ agent_id: id, name: nameOf(id) }));
|
||||
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode });
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
|
||||
onConfirm({ agents, mode: "chain", chain: chainMeta });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -163,6 +181,40 @@ export function BusinessChainPicker({
|
||||
<SettingsIcon className="size-3.5" />
|
||||
配置链条
|
||||
</Button>
|
||||
{/* dag 分层并行 → 选执行子模式(快速双列禁写 / 沙箱单列可写)。 */}
|
||||
{dagPlan && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">执行方式</span>
|
||||
<div className="flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDagExecMode("fast")}
|
||||
title="同 stage 席位并行执行,主区双列同时显示两个;并行期间禁止写文件(产物走正文)"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition",
|
||||
dagExecMode === "fast"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
快速 · 双列禁写
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDagExecMode("sandbox")}
|
||||
title="同 stage 席位仍并行执行,但主区单列显示、允许写文件;点上方头像切换查看,沙箱跟随当前席位"
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1 text-xs font-medium transition",
|
||||
dagExecMode === "sandbox"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
沙箱 · 单列可写
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedChain && !canUse && (
|
||||
<span className="text-xs text-destructive">存活席位不足 {CHAIN_MIN_SEATS} 个</span>
|
||||
@@ -217,6 +269,11 @@ function ChainCard({
|
||||
<CheckIcon className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
{chain.stages && chain.stages.length > 0 && (
|
||||
<span className="shrink-0 rounded-full border border-amber-400/40 bg-amber-400/10 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300">
|
||||
分层并行
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
|
||||
{chain.seats.length} 席
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 分层 DAG 流程图(Phase 7,§7)。
|
||||
*
|
||||
* 把 `DispatchChainModal` 的**线性**流程图复刻成 **DAG 版**:
|
||||
* 总控 ─▶ ┌Stage1┐ ─▶ ┌Stage2┐ ─▶ … ─▶ 🤝共识 ─▶ 📊结果绘制
|
||||
* │情报● │ │方案✓ │
|
||||
* └风险● ┘ └──────┘
|
||||
* - 按 stage 分**列**,stage 内席位**纵向并列**(并行批次),stage 间用连接线(串行)。
|
||||
* - 节点状态复用 {@link ChainNodeState}(pending / active / done)+ 同款动画
|
||||
* (`animate-ping` 光环 / `Loader2Icon` 转圈 / `CheckIcon` 打勾)。
|
||||
* - 末尾接「共识 → 结果绘制」里程碑(沿用 `DispatchChainModal`)。
|
||||
*
|
||||
* 纯展示组件:所有状态由 props 传入。两种用途:
|
||||
* 1) **编辑器只读预览**(`BusinessChainEditorPage` 底部):不传 `seatStates` → 全 pending,
|
||||
* 仅展示编排结构。
|
||||
* 2) **执行态总览**(Step 2 顶部缩略):`runDagOrchestration` 维护的 `dagNodeStates`
|
||||
* 传进 `seatStates`,节点随执行实时 active/done。
|
||||
*
|
||||
* 节点视觉/连线 CSS 与 `DispatchChainModal` 同源(`rt-chain-connector` 在
|
||||
* `styles/roundtable-planning.css` 全局段);此处自带紧凑版节点渲染,不改动既有 modal。
|
||||
*/
|
||||
import { CheckIcon, ChevronRightIcon, Loader2Icon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { getAvatar } from "./Avatars";
|
||||
import { chainTagClasses } from "../lib/business-chain";
|
||||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||||
import type { ChainNodeState } from "../lib/job-types";
|
||||
|
||||
/** 单个席位的展示元数据(名称 + 头像配色)。 */
|
||||
export interface DagFlowSeatMeta {
|
||||
name: string;
|
||||
avatarType: string;
|
||||
}
|
||||
|
||||
export interface DagFlowChartProps {
|
||||
/** 分层计划的 stages(`OrchestrationStage[]` 同形,只取 agentIds)。 */
|
||||
stages: ReadonlyArray<{ id?: string; agentIds: string[] }>;
|
||||
/**
|
||||
* 把 `agentId`(小写)+ 全局序号解析成 {name, avatarType}。
|
||||
* 缺省回退:name=agentId、avatarType=getAvatarTypeFor(agentId, order)。
|
||||
*/
|
||||
resolveSeat?: (agentId: string, order: number) => DagFlowSeatMeta;
|
||||
/** `agentId`(小写)→ 节点状态。缺省 / 未命中 = pending(只读预览即全 pending)。 */
|
||||
seatStates?: Record<string, ChainNodeState>;
|
||||
/** 总控节点状态(默认 pending)。 */
|
||||
coordinatorState?: ChainNodeState;
|
||||
/** 共识里程碑状态(默认 pending)。 */
|
||||
consensusState?: ChainNodeState;
|
||||
/** 结果绘制里程碑状态(默认 pending)。 */
|
||||
reportState?: ChainNodeState;
|
||||
/** 是否显示末尾里程碑(共识 / 结果绘制)。默认 true。 */
|
||||
showMilestones?: boolean;
|
||||
/**
|
||||
* 是否显示「结果绘制」里程碑。`结果绘制` 属于 **Step 3**;Step 2 的流程图只到「共识」即可,
|
||||
* 故 Step 2 / 编辑器预览传 false,只展示 Step 2 流程(总控 → stages → 共识)。默认 true。
|
||||
*/
|
||||
showReportMilestone?: boolean;
|
||||
/** 紧凑模式(顶部缩略 / 底部预览用更小的节点)。默认 false。 */
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 一个 stage 的聚合状态:任一 active → active;全 done → done;否则 pending。 */
|
||||
function aggregateStageState(
|
||||
agentIds: string[],
|
||||
seatStates: Record<string, ChainNodeState> | undefined,
|
||||
): ChainNodeState {
|
||||
if (!seatStates || agentIds.length === 0) return "pending";
|
||||
let anyActive = false;
|
||||
let allDone = true;
|
||||
for (const id of agentIds) {
|
||||
const st = seatStates[id.toLowerCase()] ?? "pending";
|
||||
if (st === "active") anyActive = true;
|
||||
if (st !== "done") allDone = false;
|
||||
}
|
||||
if (anyActive) return "active";
|
||||
return allDone ? "done" : "pending";
|
||||
}
|
||||
|
||||
export function DagFlowChart({
|
||||
stages,
|
||||
resolveSeat,
|
||||
seatStates,
|
||||
coordinatorState = "pending",
|
||||
consensusState = "pending",
|
||||
reportState = "pending",
|
||||
showMilestones = true,
|
||||
showReportMilestone = true,
|
||||
compact = false,
|
||||
className,
|
||||
}: DagFlowChartProps) {
|
||||
const resolve = (agentId: string, order: number): DagFlowSeatMeta =>
|
||||
resolveSeat?.(agentId, order) ?? {
|
||||
name: agentId,
|
||||
avatarType: getAvatarTypeFor(agentId, order),
|
||||
};
|
||||
|
||||
// 全局序号:跨 stage 连续递增(getAvatarTypeFor 兜底配色 + 节点编号)。
|
||||
let runningOrder = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"custom-scrollbar overflow-x-auto overflow-y-hidden",
|
||||
compact ? "py-3" : "py-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex min-w-max items-center", compact ? "gap-0 px-2" : "gap-0 px-4")}>
|
||||
{/* 总控节点 */}
|
||||
<FlowNode label="总控协调" avatarType="human" state={coordinatorState} compact={compact} />
|
||||
|
||||
{stages.map((stage, stageIdx) => {
|
||||
const stageState = aggregateStageState(stage.agentIds, seatStates);
|
||||
const isParallel = stage.agentIds.length > 1;
|
||||
return (
|
||||
// 用 display:contents 的包裹 div(而非 <Fragment key>):dev 代码标注插件会给具名
|
||||
// <Fragment> 注入 data-lov-id,而 Fragment 只接受 key/children → React 报
|
||||
// 「Invalid prop data-lov-id supplied to React.Fragment」。div.contents 不生成盒子,
|
||||
// Connector / StageColumn 仍作为外层 flex 的直接子项,布局不变,且能正常接收 data-*。
|
||||
<div className="contents" key={stage.id ?? `stage-${stageIdx}`}>
|
||||
<Connector active={stageState === "active"} done={stageState === "done"} compact={compact} />
|
||||
<StageColumn
|
||||
index={stageIdx}
|
||||
isParallel={isParallel}
|
||||
compact={compact}
|
||||
>
|
||||
{stage.agentIds.map((agentId) => {
|
||||
const order = runningOrder++;
|
||||
const meta = resolve(agentId, order);
|
||||
const state = seatStates?.[agentId.toLowerCase()] ?? "pending";
|
||||
return (
|
||||
<FlowNode
|
||||
key={agentId}
|
||||
label={meta.name}
|
||||
avatarType={meta.avatarType}
|
||||
state={state}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StageColumn>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{showMilestones && (
|
||||
<>
|
||||
<Connector
|
||||
active={consensusState === "active"}
|
||||
done={consensusState === "done"}
|
||||
compact={compact}
|
||||
/>
|
||||
<MilestoneNode label="共识达成" emoji="🤝" state={consensusState} compact={compact} />
|
||||
{showReportMilestone && (
|
||||
<>
|
||||
<Connector
|
||||
active={reportState === "active"}
|
||||
done={reportState === "done"}
|
||||
compact={compact}
|
||||
/>
|
||||
<MilestoneNode label="结果绘制" emoji="📊" state={reportState} compact={compact} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stage 列(纵向并列若干席位)────────────────────────────────────────────
|
||||
|
||||
function StageColumn({
|
||||
index,
|
||||
isParallel,
|
||||
compact,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
isParallel: boolean;
|
||||
compact: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"select-none rounded-full px-2 py-0.5 font-semibold text-muted-foreground",
|
||||
compact ? "text-[10px]" : "text-[11px]",
|
||||
)}
|
||||
>
|
||||
Stage {index + 1}
|
||||
{isParallel && (
|
||||
<span className="ml-1 rounded bg-primary/10 px-1 text-primary">并行</span>
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center rounded-xl border border-dashed border-border/70 bg-muted/20",
|
||||
compact ? "gap-2 p-1.5" : "gap-3 p-2.5",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 席位 / 总控节点 ─────────────────────────────────────────────────────────
|
||||
|
||||
function FlowNode({
|
||||
label,
|
||||
avatarType,
|
||||
state,
|
||||
compact,
|
||||
}: {
|
||||
label: string;
|
||||
avatarType: string;
|
||||
state: ChainNodeState;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const tag = chainTagClasses(avatarType);
|
||||
const ringSize = compact ? "size-10" : "size-14";
|
||||
const avatarSize = compact ? "h-9 w-9" : "h-12 w-12";
|
||||
return (
|
||||
<div className={cn("flex shrink-0 flex-col items-center gap-1.5 text-center", compact ? "w-16" : "w-24")}>
|
||||
<div className={cn("relative flex items-center justify-center", ringSize)}>
|
||||
{state === "active" && (
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-full border-2 transition-all",
|
||||
ringSize,
|
||||
state === "active" && "border-primary shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||||
state === "done" && "border-emerald-400/70",
|
||||
state === "pending" && "border-border opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{getAvatar(avatarType, avatarSize)}
|
||||
{state === "done" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||||
<CheckIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{state === "active" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<Loader2Icon className="size-2.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-full truncate rounded border px-1.5 py-0.5 font-medium",
|
||||
compact ? "text-[10px]" : "text-[11px]",
|
||||
state === "pending" ? "border-border bg-muted text-muted-foreground" : tag,
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 里程碑节点(共识 / 结果绘制)────────────────────────────────────────────
|
||||
|
||||
function MilestoneNode({
|
||||
label,
|
||||
emoji,
|
||||
state,
|
||||
compact,
|
||||
}: {
|
||||
label: string;
|
||||
emoji: string;
|
||||
state: ChainNodeState;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const ringSize = compact ? "size-10" : "size-14";
|
||||
return (
|
||||
<div className={cn("flex shrink-0 flex-col items-center gap-1.5 text-center", compact ? "w-16" : "w-24")}>
|
||||
<div className={cn("relative flex items-center justify-center", ringSize)}>
|
||||
{state === "active" && (
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"relative flex items-center justify-center rounded-full border-2 transition-all",
|
||||
ringSize,
|
||||
compact ? "text-lg" : "text-2xl",
|
||||
state === "active" && "border-primary bg-primary/5 shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||||
state === "done" && "border-emerald-400/70 bg-emerald-500/5",
|
||||
state === "pending" && "border-border bg-muted opacity-45 grayscale",
|
||||
)}
|
||||
>
|
||||
{emoji}
|
||||
{state === "done" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||||
<CheckIcon className="size-2.5" />
|
||||
</span>
|
||||
)}
|
||||
{state === "active" && (
|
||||
<span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||||
<Loader2Icon className="size-2.5 animate-spin" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded border px-1.5 py-0.5 font-semibold",
|
||||
compact ? "text-[10px]" : "text-[11px]",
|
||||
state === "pending"
|
||||
? "border-border bg-muted text-muted-foreground"
|
||||
: state === "active"
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-emerald-400/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 连接线(带流动动画,复用 DispatchChainModal 的 rt-chain-connector CSS)────
|
||||
|
||||
function Connector({
|
||||
active,
|
||||
done,
|
||||
compact,
|
||||
}: {
|
||||
active: boolean;
|
||||
done: boolean;
|
||||
compact: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex shrink-0 items-center justify-center", compact ? "h-10 w-10" : "h-14 w-14")}>
|
||||
<div className="relative flex w-full items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"rt-chain-connector",
|
||||
active && "rt-chain-connector--active",
|
||||
done && !active && "rt-chain-connector--done",
|
||||
)}
|
||||
/>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"absolute -right-1 size-4 transition-colors",
|
||||
active ? "text-primary" : done ? "text-primary/70" : "text-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -167,8 +167,13 @@ export const sharedMarkdownComponents = {
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 宽表在并行两列 / 窄气泡里会超出容器(模型常输出多列表格,如「法律免责条款」表)。
|
||||
// 套一层 max-w-full + overflow-x-auto 的块级容器:表格保持自然宽度,超出时容器内**横向
|
||||
// 滚动**而不是撑破列宽 / 盖到相邻列。table 不设 max-w,否则单元格会被挤到错乱换行。
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<table className="roundtable-chat-message my-1 border-collapse">{children}</table>
|
||||
<div className="my-1 max-w-full overflow-x-auto">
|
||||
<table className="roundtable-chat-message border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 并行批次头像条(§5.2)。
|
||||
*
|
||||
* 并行 stage 执行时显示在消息列表上方、模型选择器左侧。展示当前批次的所有席位头像:
|
||||
* - 进行中(running)→ `animate-ping` 光环 + `Loader2` 角标;
|
||||
* - 已完成(done)→ ✓ 角标;失败(error)→ ✕ 角标;
|
||||
* - 选中(在主区两列里显示的那 ≤2 个)→ 高亮描边。
|
||||
* 点头像 = 切换该席位是否在主区显示(LRU,最多 2,见 hook `toggleParallelSeat`)。
|
||||
*
|
||||
* 复用 `Avatars.getAvatar` 的头像 SVG 与 `DispatchChainModal` 的 loading 动画思路。
|
||||
*/
|
||||
import { CheckIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||
import type { ParallelSeatView } from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
|
||||
export interface ParallelSeatStripProps {
|
||||
batch: ParallelSeatView[];
|
||||
/** 当前主区显示的席位 agentId(最多 2)。 */
|
||||
selectedSeats: string[];
|
||||
onToggleSeat: (agentId: string) => void;
|
||||
}
|
||||
|
||||
export function ParallelSeatStrip({ batch, selectedSeats, onToggleSeat }: ParallelSeatStripProps) {
|
||||
if (batch.length === 0) return null;
|
||||
const selected = new Set(selectedSeats);
|
||||
const runningCount = batch.filter((s) => s.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 overflow-x-auto py-1">
|
||||
<span className="shrink-0 text-[calc(10px*var(--ui-font-scale,1))] font-bold text-slate-500">
|
||||
并行 {batch.length} 席
|
||||
{runningCount > 0 && <span className="text-primary">({runningCount} 进行中)</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{batch.map((seat) => {
|
||||
const isSelected = selected.has(seat.agentId);
|
||||
const isRunning = seat.status === "running";
|
||||
return (
|
||||
<button
|
||||
key={seat.agentId}
|
||||
type="button"
|
||||
onClick={() => onToggleSeat(seat.agentId)}
|
||||
title={`${seat.displayName}${isSelected ? "(主区显示中)" : "(点击在主区显示)"}`}
|
||||
className={cn(
|
||||
"relative shrink-0 rounded-full transition",
|
||||
isSelected ? "ring-2 ring-primary ring-offset-1" : "ring-1 ring-transparent hover:ring-slate-300",
|
||||
)}
|
||||
>
|
||||
{/* 进行中光环 */}
|
||||
{isRunning && (
|
||||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" aria-hidden />
|
||||
)}
|
||||
<span className={cn("relative block", !isSelected && "opacity-80")}>
|
||||
{getAvatar(seat.avatarType, "h-7 w-7")}
|
||||
</span>
|
||||
{/* 状态角标 */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute -bottom-0.5 -right-0.5 flex size-3.5 items-center justify-center rounded-full border border-white shadow-sm",
|
||||
seat.status === "running" && "bg-primary text-white",
|
||||
seat.status === "done" && "bg-emerald-500 text-white",
|
||||
seat.status === "error" && "bg-rose-500 text-white",
|
||||
)}
|
||||
>
|
||||
{seat.status === "running" && <Loader2Icon className="size-2.5 animate-spin" />}
|
||||
{seat.status === "done" && <CheckIcon className="size-2.5" strokeWidth={3} />}
|
||||
{seat.status === "error" && <XIcon className="size-2.5" strokeWidth={3} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,17 +16,25 @@
|
||||
*
|
||||
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
|
||||
*/
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Cpu,
|
||||
FileIcon,
|
||||
HelpCircle,
|
||||
Loader2,
|
||||
Network,
|
||||
Pause,
|
||||
Play,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */
|
||||
const SHOW_BACKGROUND_SUSPEND = false;
|
||||
|
||||
@@ -42,13 +50,19 @@ import {
|
||||
StreamingCursor,
|
||||
} from "@/roundtable-planning/components/MessageBubble";
|
||||
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
|
||||
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
|
||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
||||
import type {
|
||||
AgentConfig,
|
||||
ParallelSeatView,
|
||||
Step2Dialogue,
|
||||
} from "@/roundtable-planning/hooks/useStep2Orchestration";
|
||||
import type {
|
||||
RoleEntry,
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
|
||||
import {
|
||||
getAvatarTypeFor,
|
||||
type RoleEntry,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
|
||||
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
|
||||
@@ -71,6 +85,18 @@ export interface Step2PanelProps {
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
roles: RoleEntry[];
|
||||
// ─── 并行显示(§5)────────────────────────────────────────────
|
||||
parallelBatch: ParallelSeatView[];
|
||||
selectedParallelSeats: string[];
|
||||
isParallelRunning: boolean;
|
||||
onToggleParallelSeat: (agentId: string) => void;
|
||||
// ─── Phase 7 DAG 流程图执行态总览(§7)─────────────────────────
|
||||
/** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */
|
||||
orchestrationMode: "recommend" | "chain" | "dag";
|
||||
/** dag 模式的分层计划(流程图按 stage 分列渲染)。 */
|
||||
orchestrationPlan: OrchestrationPlan | null;
|
||||
/** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */
|
||||
dagNodeStates: Record<string, ChainNodeState>;
|
||||
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
|
||||
agentNameToRole: Record<
|
||||
string,
|
||||
@@ -120,6 +146,13 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
roles,
|
||||
parallelBatch,
|
||||
selectedParallelSeats,
|
||||
isParallelRunning,
|
||||
onToggleParallelSeat,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagNodeStates,
|
||||
agentNameToRole,
|
||||
selectedModel,
|
||||
availableModels,
|
||||
@@ -147,12 +180,74 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||
|
||||
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
|
||||
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
|
||||
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
|
||||
useEffect(() => {
|
||||
scrollStep2ToBottom();
|
||||
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
|
||||
|
||||
const resolveAgentName = useCallback(
|
||||
(idOrName: string) =>
|
||||
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
|
||||
[agentNameToRole],
|
||||
);
|
||||
|
||||
// 并行两列:按 bubbleId 取席位流式气泡。固定 2 列(窄屏 1 列,见 CSS 断点)。
|
||||
const dialogueById = useMemo(() => {
|
||||
const m = new Map<number, Step2Dialogue>();
|
||||
for (const d of step2RoundtableDialogues) m.set(d.id, d);
|
||||
return m;
|
||||
}, [step2RoundtableDialogues]);
|
||||
// 主区两列要显示的席位:取被选中的(最多 2),回退到批次最先两个。
|
||||
const parallelColumns = useMemo(() => {
|
||||
if (!isParallelRunning) return [];
|
||||
const pick = selectedParallelSeats.length > 0
|
||||
? selectedParallelSeats
|
||||
: parallelBatch.slice(0, 2).map((s) => s.agentId);
|
||||
return pick
|
||||
.map((agentId) => parallelBatch.find((s) => s.agentId === agentId))
|
||||
.filter((s): s is ParallelSeatView => Boolean(s))
|
||||
.slice(0, 2);
|
||||
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
|
||||
|
||||
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
|
||||
const showDagFlow =
|
||||
orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0;
|
||||
const resolveDagSeat = useCallback(
|
||||
(agentId: string, order: number) => {
|
||||
const meta = agentNameToRole[agentId.toLowerCase()];
|
||||
return {
|
||||
name: meta?.displayName ?? agentId,
|
||||
avatarType: meta?.avatarType ?? getAvatarTypeFor(agentId, order),
|
||||
};
|
||||
},
|
||||
[agentNameToRole],
|
||||
);
|
||||
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
|
||||
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
|
||||
const states = Object.values(dagNodeStates);
|
||||
const anyActive = states.includes("active");
|
||||
const anyStarted = states.some((s) => s !== "pending");
|
||||
const allDone = states.length > 0 && states.every((s) => s === "done");
|
||||
const coordinator: ChainNodeState = anyActive
|
||||
? "done"
|
||||
: isOrchestrating
|
||||
? "active"
|
||||
: anyStarted
|
||||
? "done"
|
||||
: "pending";
|
||||
const consensus: ChainNodeState = hasConsensus
|
||||
? "done"
|
||||
: allDone
|
||||
? "active"
|
||||
: "pending";
|
||||
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
|
||||
}, [dagNodeStates, isOrchestrating, hasConsensus]);
|
||||
|
||||
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
|
||||
const [dagFlowOpen, setDagFlowOpen] = useState(false);
|
||||
|
||||
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
||||
const [pauseFlash, setPauseFlash] = useState(false);
|
||||
|
||||
@@ -249,6 +344,54 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
|
||||
{/* Conversation log */}
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
|
||||
{showDagFlow && orchestrationPlan && (
|
||||
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDagFlowOpen(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>
|
||||
</div>
|
||||
)}
|
||||
{showDagFlow && orchestrationPlan && (
|
||||
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
|
||||
<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">
|
||||
<DagFlowChart
|
||||
stages={orchestrationPlan.stages}
|
||||
resolveSeat={resolveDagSeat}
|
||||
seatStates={dagNodeStates}
|
||||
coordinatorState={dagCoordinatorState}
|
||||
consensusState={dagConsensusState}
|
||||
showReportMilestone={false}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
|
||||
{isParallelRunning && (
|
||||
<div className="shrink-0 px-6 py-2 bg-white border-t border-slate-150">
|
||||
<ParallelSeatStrip
|
||||
batch={parallelBatch}
|
||||
selectedSeats={selectedParallelSeats}
|
||||
onToggleSeat={onToggleParallelSeat}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={step2ScrollRef}
|
||||
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
|
||||
@@ -260,19 +403,57 @@ export function Step2Panel(props: Step2PanelProps) {
|
||||
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
|
||||
</div>
|
||||
)}
|
||||
{step2RoundtableDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isParallelRunning && parallelColumns.length > 0 ? (
|
||||
// 并行进行时:主区固定 2 列(窄屏降 1 列),每列渲染一个被选中席位的消息流。
|
||||
// 未选中席位的气泡仍在 step2RoundtableDialogues 内存里(只是此刻不渲染),
|
||||
// 切换头像即可立刻看到其全部输出(§6 全接收/惰性渲染的基础)。
|
||||
<div
|
||||
className={
|
||||
parallelColumns.length > 1
|
||||
? "grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||
: "grid grid-cols-1 gap-4"
|
||||
}
|
||||
>
|
||||
{parallelColumns.map((seat) => {
|
||||
const msg = dialogueById.get(seat.bubbleId);
|
||||
return (
|
||||
<div
|
||||
key={seat.agentId}
|
||||
className="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50/40 p-3"
|
||||
>
|
||||
{msg ? (
|
||||
<Step2Message
|
||||
msg={msg}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="py-6 text-center text-xs text-slate-400">
|
||||
{seat.displayName} · 等待输出…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
step2RoundtableDialogues.map((msg) => (
|
||||
<Step2Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
resolveAgentName={resolveAgentName}
|
||||
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||
onFileCardClick={(path) => {
|
||||
if (msg.agentThreadId) {
|
||||
openArtifactPreview(msg.agentThreadId, path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||
</div>
|
||||
|
||||
@@ -60,8 +60,11 @@ export const markdownComponents = {
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// 宽表套横向滚动容器,避免超出卡片宽度(同 MessageBubble.sharedMarkdownComponents)。
|
||||
table: ({ children }: any) => (
|
||||
<table className="roundtable-chat-message my-2 border-collapse">{children}</table>
|
||||
<div className="my-2 max-w-full overflow-x-auto">
|
||||
<table className="roundtable-chat-message border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: any) => (
|
||||
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
type StreamPhase,
|
||||
type ThreadIdMap,
|
||||
} from "@/roundtable-planning/api/multi-agent";
|
||||
import {
|
||||
buildDagStageLeaderTask,
|
||||
pickDagSeatTask,
|
||||
type OrchestrationPlan,
|
||||
} from "@/roundtable-planning/lib/dag-types";
|
||||
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
|
||||
import {
|
||||
DEFAULT_SELECTED_AGENTS,
|
||||
MAX_CYCLES,
|
||||
@@ -56,6 +62,20 @@ export type Step2Dialogue = {
|
||||
}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 并行批次里的一个席位视图(§5 并行显示)。`runDagOrchestration` 在真并行 stage
|
||||
* 期间发布整批;头像条 / 两列主区据此渲染,`bubbleId` 指向 `step2RoundtableDialogues`
|
||||
* 里该席位的流式气泡。
|
||||
*/
|
||||
export interface ParallelSeatView {
|
||||
agentId: string;
|
||||
displayName: string;
|
||||
avatarType: string;
|
||||
status: "running" | "done" | "error";
|
||||
/** 该席位流式气泡在 step2RoundtableDialogues 里的 id。 */
|
||||
bubbleId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext
|
||||
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
|
||||
@@ -110,9 +130,11 @@ export type Step2Snapshot = {
|
||||
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
|
||||
* step2 hook 的 hydrateFromDraft 不直接消费它。
|
||||
*/
|
||||
orchestrationMode?: "recommend" | "chain";
|
||||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||||
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
|
||||
chain?: { id: string; title: string } | null;
|
||||
/** dag 模式的分层编排计划(见 `lib/dag-types.ts`)。同样是主页面 state,hook 经 option 读取。 */
|
||||
orchestrationPlan?: OrchestrationPlan | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -191,11 +213,25 @@ export interface UseStep2OrchestrationOptions {
|
||||
/**
|
||||
* 编排模式:
|
||||
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
|
||||
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。
|
||||
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`);
|
||||
* - `dag` —— **分层 DAG**:前端直派,stage 内并行、stage 间串行(`runDagOrchestration`,
|
||||
* 需配合 `orchestrationPlan`)。
|
||||
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
|
||||
* `selectedAgents` 的顺序。
|
||||
*/
|
||||
orchestrationMode: "recommend" | "chain";
|
||||
orchestrationMode: "recommend" | "chain" | "dag";
|
||||
/**
|
||||
* dag 模式的分层编排计划。`orchestrationMode === "dag"` 且本计划非空时,
|
||||
* `runActiveOrchestration` 走 `runDagOrchestration`;否则回退到 recommend / chain。
|
||||
*/
|
||||
orchestrationPlan?: OrchestrationPlan | null;
|
||||
/**
|
||||
* dag 执行子模式(仅 `orchestrationMode === "dag"` 生效):
|
||||
* - `fast`(默认):同 stage 席位并行、主区**双列**、**禁写文件**(seat_parallel);
|
||||
* - `sandbox`:同 stage 席位仍并行,但主区**单列**、**允许写文件**(seat),沙箱跟随当前
|
||||
* 显示的席位(切头像 → 沙箱内容随之切换/关闭)。
|
||||
*/
|
||||
dagExecMode?: "fast" | "sandbox";
|
||||
/** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */
|
||||
buildInitialIntent: () => string;
|
||||
/** 主页面统一的 toast。 */
|
||||
@@ -228,6 +264,17 @@ export interface UseStep2OrchestrationReturn {
|
||||
budgetLimit: number;
|
||||
agentConfigs: Record<number, AgentConfig>;
|
||||
selectedAgentId: number | null;
|
||||
// ─── 并行显示(§5)────────────────────────────────────────────────────
|
||||
/** 当前并行批次(非空 = 正在并行 stage)。 */
|
||||
parallelBatch: ParallelSeatView[];
|
||||
/** 主区当前并排显示的席位 agentId(最多 2)。 */
|
||||
selectedParallelSeats: string[];
|
||||
/** 是否正在并行执行(左侧栏折叠 / 头像条 / 两列显示的总开关)。 */
|
||||
isParallelRunning: boolean;
|
||||
/** 点头像切换主区显示的席位(LRU,最多 2)。 */
|
||||
toggleParallelSeat: (agentId: string) => void;
|
||||
/** Phase 7 DAG 流程图执行态:agentId(小写) → pending/active/done。非 dag 模式为空。 */
|
||||
dagNodeStates: Record<string, ChainNodeState>;
|
||||
// ─── Derived ────────────────────────────────────────────────────────
|
||||
roleTemplates: ReadonlyArray<Omit<RoleEntry, "status">>;
|
||||
agentNameToRole: Record<
|
||||
@@ -297,6 +344,8 @@ export function useStep2Orchestration(
|
||||
currentStep,
|
||||
selectedModel,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagExecMode = "fast",
|
||||
buildInitialIntent,
|
||||
triggerToast,
|
||||
setRoles,
|
||||
@@ -364,6 +413,30 @@ export function useStep2Orchestration(
|
||||
DEFAULT_AGENT_CONFIGS,
|
||||
);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
|
||||
// 并行批次(§5):runDagOrchestration 在真并行 stage 期间发布;空 = 非并行。
|
||||
const [parallelBatch, setParallelBatch] = useState<ParallelSeatView[]>([]);
|
||||
// Phase 7 DAG 流程图执行态(§7):agentId(小写) → pending/active/done。
|
||||
// runDagOrchestration 跨 stage 维护;DagFlowChart 据此实时点亮节点。非 dag 模式 = 空。
|
||||
const [dagNodeStates, setDagNodeStates] = useState<Record<string, ChainNodeState>>({});
|
||||
// 主区当前并排显示的席位(最多 2,固定 2 列;窄屏只渲染第 1 个)。
|
||||
const [selectedParallelSeats, setSelectedParallelSeats] = useState<string[]>([]);
|
||||
// §6 流式高性能:所有并行席位的增量文本**全量累积进 ref**(O(1),不触发渲染);
|
||||
// 只有被选中的席位才在节流 flush 时把全文挂到 React state 渲染 Markdown。
|
||||
// key = bubbleId。
|
||||
const parallelStreamRef = useRef<Map<number, { text: string; charCount: number }>>(
|
||||
new Map(),
|
||||
);
|
||||
const parallelFlushTimerRef = useRef<number | null>(null);
|
||||
// flush 时要读「当前选中席位」与「批次 bubbleId↔agentId 映射」—— 打到 ref 避免
|
||||
// 把 flush 函数绑进会频繁变化的 state 依赖。
|
||||
const selectedParallelSeatsRef = useRef<string[]>([]);
|
||||
const parallelBatchRef = useRef<ParallelSeatView[]>([]);
|
||||
useEffect(() => {
|
||||
selectedParallelSeatsRef.current = selectedParallelSeats;
|
||||
}, [selectedParallelSeats]);
|
||||
useEffect(() => {
|
||||
parallelBatchRef.current = parallelBatch;
|
||||
}, [parallelBatch]);
|
||||
|
||||
// ─── Derived ──────────────────────────────────────────────────────
|
||||
const roleTemplates = useMemo(
|
||||
@@ -403,10 +476,29 @@ export function useStep2Orchestration(
|
||||
const isOrchestratingRef = useRef<boolean>(isOrchestrating);
|
||||
// chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。
|
||||
const orchestrationModeRef = useRef(orchestrationMode);
|
||||
const orchestrationPlanRef = useRef<OrchestrationPlan | null>(orchestrationPlan ?? null);
|
||||
// dag 执行子模式(fast/sandbox) + 沙箱模式下「当前允许显示沙箱的席位 thread」。
|
||||
// sandbox 模式单列:只有**当前显示**的席位才可自动打开沙箱;后台并行席位写文件不抢占
|
||||
// (见 openSeatVirtualArtifact 的 auto 守卫)。null = 非 sandbox 模式 / 无限制。
|
||||
const dagExecModeRef = useRef<"fast" | "sandbox">(dagExecMode);
|
||||
const sandboxFollowThreadIdRef = useRef<string | null>(null);
|
||||
// 「上次已 reconcile 的显示席位」键(`${mode}:${seat}`)。沙箱跟随 effect 只在**显示席位真正
|
||||
// 变化**时开/关沙箱,避免每次 render(deps 身份变化)都强制重开,把用户手动关闭的沙箱又弹回来。
|
||||
const lastReconciledSeatRef = useRef<string>("");
|
||||
useEffect(() => {
|
||||
dagExecModeRef.current = dagExecMode;
|
||||
}, [dagExecMode]);
|
||||
// dag 计划「已跑过一遍」闸门:DAG 编排只在初次执行一次计划;之后的用户干预 / 恢复 /
|
||||
// 澄清回复都改走 **leader 自由派活循环**(runOrchestration),由总控接着派活,而**不是**
|
||||
// 重新把整张 DAG 从头跑一遍(致命 bug 修复)。新任务 / 重新进入时由 resetInitGate 复位。
|
||||
const dagPlanExecutedRef = useRef<boolean>(false);
|
||||
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
|
||||
useEffect(() => {
|
||||
orchestrationModeRef.current = orchestrationMode;
|
||||
}, [orchestrationMode]);
|
||||
useEffect(() => {
|
||||
orchestrationPlanRef.current = orchestrationPlan ?? null;
|
||||
}, [orchestrationPlan]);
|
||||
useEffect(() => {
|
||||
selectedAgentsRef.current = selectedAgents;
|
||||
}, [selectedAgents]);
|
||||
@@ -504,6 +596,24 @@ export function useStep2Orchestration(
|
||||
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
|
||||
const flags = autoOpenFlagsRef.current;
|
||||
if (!flags.autoOpen || !flags.autoSelect) return;
|
||||
// 快速模式(双列)DAG 并行批次:**完全不**自动开沙箱 —— 双列只看正文,多席位并发写
|
||||
// 文件(现已放开 write_file)会让单沙箱预览反复串台。文件仍写盘,Step3 / 文件卡可查看。
|
||||
if (
|
||||
orchestrationModeRef.current === "dag" &&
|
||||
dagExecModeRef.current === "fast" &&
|
||||
parallelBatchRef.current.length > 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// sandbox 单列模式:并行的**后台**席位写文件时不要抢占沙箱 —— 只有当前显示的
|
||||
// 席位(sandboxFollowThreadIdRef)才允许自动打开,否则沙箱内容会和主区列表错位。
|
||||
if (
|
||||
dagExecModeRef.current === "sandbox" &&
|
||||
sandboxFollowThreadIdRef.current &&
|
||||
sandboxFollowThreadIdRef.current !== seatThreadId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
hasAutoOpenedRef.current.add(virtualUrl);
|
||||
}
|
||||
setActiveArtifactThreadId(seatThreadId);
|
||||
@@ -530,6 +640,32 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* sandbox 模式「沙箱跟随」用:打开某席位 thread **最新**写入文件的虚拟预览(强制 auto=false)。
|
||||
* 从该 thread 的 stub messages 里倒序找第一个 write_file / str_replace 的 path。
|
||||
* 无产物返回 false(调用方据此关闭沙箱)。
|
||||
*/
|
||||
const openSeatLatestArtifact = useCallback(
|
||||
(seatThreadId: string): boolean => {
|
||||
const stub = seatStubMessagesRef.current[seatThreadId]?.[0];
|
||||
if (!stub) return false;
|
||||
for (let i = stub.tool_calls.length - 1; i >= 0; i -= 1) {
|
||||
const tc = stub.tool_calls[i];
|
||||
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
|
||||
const path =
|
||||
typeof (tc.args as { path?: unknown })?.path === "string"
|
||||
? ((tc.args as { path?: string }).path as string)
|
||||
: "";
|
||||
if (!path) continue;
|
||||
const virtualUrl = buildWriteFileVirtualUrl(path, stub.id, tc.id);
|
||||
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[buildWriteFileVirtualUrl, openSeatVirtualArtifact],
|
||||
);
|
||||
|
||||
/**
|
||||
* 反查某个产出文件是由哪个席位 thread 写入的。
|
||||
*
|
||||
@@ -888,6 +1024,120 @@ export function useStep2Orchestration(
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** 整段替换某气泡正文(§6 flush:把 ref 里累积的全文一次性挂上去)。 */
|
||||
const setDialogueContent = useCallback((id: number, fullText: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, content: fullText } : d)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
|
||||
/**
|
||||
* 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到
|
||||
* React state 渲染 Markdown(≤2 个)。未选中席位只在 ref 里累积、不挂 DOM —— 6+
|
||||
* 并行也不卡。切到它时下拍即显全文(再叠加选中变化 effect 的即时挂载)。
|
||||
*/
|
||||
const flushParallelStreams = useCallback(() => {
|
||||
parallelFlushTimerRef.current = null;
|
||||
const store = parallelStreamRef.current;
|
||||
if (store.size === 0) return;
|
||||
const selected = new Set(selectedParallelSeatsRef.current);
|
||||
for (const seat of parallelBatchRef.current) {
|
||||
if (selected.has(seat.agentId)) {
|
||||
const entry = store.get(seat.bubbleId);
|
||||
if (entry) setDialogueContent(seat.bubbleId, entry.text);
|
||||
}
|
||||
}
|
||||
}, [setDialogueContent]);
|
||||
|
||||
const scheduleParallelFlush = useCallback(() => {
|
||||
if (parallelFlushTimerRef.current !== null) return;
|
||||
parallelFlushTimerRef.current = window.setTimeout(flushParallelStreams, 80);
|
||||
}, [flushParallelStreams]);
|
||||
|
||||
/** 并行席位的 onTextDelta:全量累积进 ref(O(1)),再调度一次节流 flush。 */
|
||||
const ingestParallelDelta = useCallback(
|
||||
(bubbleId: number, text: string) => {
|
||||
if (!text) return;
|
||||
const store = parallelStreamRef.current;
|
||||
const cur = store.get(bubbleId) ?? { text: "", charCount: 0 };
|
||||
cur.text += text;
|
||||
cur.charCount = cur.text.length;
|
||||
store.set(bubbleId, cur);
|
||||
scheduleParallelFlush();
|
||||
},
|
||||
[scheduleParallelFlush],
|
||||
);
|
||||
|
||||
/** 清空并行流式 store + 取消挂起的 flush(stage 结束 / 抢占 / 异常兜底)。 */
|
||||
const resetParallelStreams = useCallback(() => {
|
||||
parallelStreamRef.current = new Map();
|
||||
if (parallelFlushTimerRef.current !== null) {
|
||||
window.clearTimeout(parallelFlushTimerRef.current);
|
||||
parallelFlushTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 卸载兜底:清掉可能挂起的 flush 定时器。
|
||||
useEffect(() => resetParallelStreams, [resetParallelStreams]);
|
||||
|
||||
// 选中变化 → 立刻把 ref 里已累积的全文挂给**新选中**的席位(切头像即显全部输出,
|
||||
// 证明流没丢、只是之前没渲染,§6.5)。
|
||||
useEffect(() => {
|
||||
const store = parallelStreamRef.current;
|
||||
if (store.size === 0) return;
|
||||
const selected = new Set(selectedParallelSeats);
|
||||
for (const seat of parallelBatchRef.current) {
|
||||
if (selected.has(seat.agentId)) {
|
||||
const entry = store.get(seat.bubbleId);
|
||||
if (entry) setDialogueContent(seat.bubbleId, entry.text);
|
||||
}
|
||||
}
|
||||
}, [selectedParallelSeats, setDialogueContent]);
|
||||
|
||||
// sandbox 单列模式「沙箱跟随」(防 bug 重点,用户特别强调):切换当前显示的席位时,
|
||||
// 沙箱内容必须等于该席位的内容 —— 有产物则打开它的沙箱,无产物则关闭沙箱面板。
|
||||
// 并把「当前允许显示沙箱的席位 thread」记到 sandboxFollowThreadIdRef,供 openSeatVirtualArtifact
|
||||
// 的 auto 守卫拦掉后台并行席位的抢占(见该函数)。非 sandbox 模式 / 无选中时清空,不干预沙箱。
|
||||
useEffect(() => {
|
||||
const seat = dagExecMode === "sandbox" ? selectedParallelSeats[0] ?? "" : "";
|
||||
// 只在「显示席位真正变化」时 reconcile。否则:本 effect 的 deps(openSeatLatestArtifact /
|
||||
// setArtifactsOpen 等)身份每次 render 都可能变 → effect 反复跑 → 把用户刚手动关闭的沙箱
|
||||
// 又强制重开,表现为「关闭按钮关不掉」。用 key 守卫后,用户关闭(席位没变)时直接早退。
|
||||
const key = `${dagExecMode}:${seat}`;
|
||||
if (lastReconciledSeatRef.current === key) return;
|
||||
lastReconciledSeatRef.current = key;
|
||||
|
||||
if (dagExecMode !== "sandbox" || !seat) {
|
||||
sandboxFollowThreadIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
const ids = threadIdsRef.current;
|
||||
if (!ids) {
|
||||
sandboxFollowThreadIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
const tid = resolveSeatThreadId(ids, seat);
|
||||
sandboxFollowThreadIdRef.current = tid ?? null;
|
||||
if (!tid) return;
|
||||
const opened = openSeatLatestArtifact(tid);
|
||||
if (!opened) {
|
||||
// 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→
|
||||
// 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen);
|
||||
// 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false
|
||||
// (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的
|
||||
// `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。
|
||||
setActiveArtifactThreadId(null);
|
||||
deselectArtifact();
|
||||
}
|
||||
}, [
|
||||
selectedParallelSeats,
|
||||
dagExecMode,
|
||||
resolveSeatThreadId,
|
||||
openSeatLatestArtifact,
|
||||
deselectArtifact,
|
||||
]);
|
||||
|
||||
const finalizeDialogue = useCallback(
|
||||
(id: number, finalContent: string, confidence?: string) => {
|
||||
setStep2RoundtableDialogues((prev) =>
|
||||
@@ -975,6 +1225,43 @@ export function useStep2Orchestration(
|
||||
);
|
||||
}, [setRoles]);
|
||||
|
||||
/**
|
||||
* DAG 并行批次:把**一整批**席位同时标为「研讨中」(与 markSpeakerStreaming
|
||||
* 的"排他单发"不同 —— 并行就是要多个 sub 同时高亮)。其余历史残留的"研讨中"清成"在线"。
|
||||
* activeSpeakerSet 天然是 Set,这里放整批 speakerId。
|
||||
*/
|
||||
const markParallelSpeakers = useCallback(
|
||||
(speakerIds: number[]) => {
|
||||
const wanted = new Set(speakerIds);
|
||||
setActiveSpeakerSet(new Set(speakerIds));
|
||||
setRoles((prev) =>
|
||||
prev.map((item) => {
|
||||
if (wanted.has(item.id)) return { ...item, status: "研讨中" };
|
||||
if (item.status === "研讨中") return { ...item, status: "在线" };
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
},
|
||||
[setRoles],
|
||||
);
|
||||
|
||||
/**
|
||||
* 点头像切换主区显示的席位(§5.3):已选中→取消;未选中→加入,超过 2 个按 LRU
|
||||
* 踢掉最早选中的。被换下的席位停止渲染但流仍在内存累积(气泡始终在列表里)。
|
||||
*/
|
||||
const toggleParallelSeat = useCallback((agentId: string) => {
|
||||
setSelectedParallelSeats((prev) => {
|
||||
// sandbox 单列:点头像 = 切换到该席位(始终保留 1 个,点已选中的不取消到空)。
|
||||
if (dagExecModeRef.current === "sandbox") {
|
||||
return prev.length === 1 && prev[0] === agentId ? prev : [agentId];
|
||||
}
|
||||
// fast 双列:点已选中 → 取消;未选中 → 加入,超过 2 个按 LRU 踢最早的。
|
||||
if (prev.includes(agentId)) return prev.filter((id) => id !== agentId);
|
||||
const next = [...prev, agentId];
|
||||
return next.length > 2 ? next.slice(next.length - 2) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ─── Abort ────────────────────────────────────────────────────────
|
||||
const abortAllInFlight = useCallback(() => {
|
||||
activeAbortersRef.current.forEach((ctrl) => {
|
||||
@@ -1483,13 +1770,363 @@ export function useStep2Orchestration(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── DAG orchestration loop ───────────────────────────────────────
|
||||
// dag 模式(§3 + §3.2 方案 A,2026-06-07 改成「总控驱动」):分层执行 —— stage 内**并行**
|
||||
// (Promise.all)、stage 间**串行**(Promise.all 即 barrier)。每个 stage 的流程与串行同构:
|
||||
// 1) **总控派活轮**:由【总控】对本 stage 计划钉死的席位**真正派活**(buildDagStageLeaderTask
|
||||
// 约束它只派给这些席位、且每个都派到;不自由乱派、不收口)。总控本轮也"承上"——后端
|
||||
// _build_seat_status_block 走轻量 last-ai-message 把此前各席位真实交付(摘要)注入其输入
|
||||
// (§传摘要按需传全文);
|
||||
// 2) **批内并行**:本 stage 席位并行跑总控派给各自的 task(pickDagSeatTask 命中其 agent_name,
|
||||
// 漏派则回退总控正文/模板,不借用别席位 task);完成后交付经后端 detached 广播回总控;
|
||||
// 3) barrier 后进入下一 stage,总控再派下一批(它已能看到上一批交付摘要)。
|
||||
// 所有 stage 跑完后,把锁交回 runOrchestration 由总控综合 / 收口(此时 synthesisMode 才按需
|
||||
// 注入各席位**完整交付正文**——全文只在收口这一轮付出)。
|
||||
//
|
||||
// 与「前端直派(方案 B)」的区别:每 stage 都经总控决策派活(像串行/链条那样),计划只钉死
|
||||
// 「派给谁」、总控负责「派什么活 + 承上启下」。与 chain 模式同构,区别是一次钉死一批、批内并行。
|
||||
//
|
||||
// 与 runOrchestration / runChainOrchestration 的「不要 Promise.all」不变量不同 ——
|
||||
// DAG 并行就是要 Promise.all;N 路 token 级 setState 的卡顿由 Phase 6(全接收 /
|
||||
// 惰性渲染 / 节流)解决,且单 stage 并行度有 warning 上限(PARALLEL_DEGREE_WARN_THRESHOLD)。
|
||||
const runDagOrchestration = useCallback(
|
||||
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
|
||||
const plan = orchestrationPlanRef.current;
|
||||
if (!plan || plan.stages.length === 0) {
|
||||
triggerToast("⚠️ 没有可执行的 DAG 编排计划");
|
||||
return;
|
||||
}
|
||||
if (isOrchestratingRef.current) return;
|
||||
// 同步抢占编排锁(原因同 runOrchestration):堵住人工干预 / 恢复 toggle 在同一
|
||||
// tick 内的二次触发,避免并发双发。
|
||||
isOrchestratingRef.current = true;
|
||||
setIsOrchestrating(true);
|
||||
orchestrationCancelRef.current = false;
|
||||
const myEpoch = (orchestrationEpochRef.current += 1);
|
||||
const isStale = () =>
|
||||
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
|
||||
// 标记 DAG 计划已开始执行:此后任何 runActiveOrchestration(干预 / 恢复 / 澄清)
|
||||
// 都改走 leader 循环,不再重跑整张 DAG。
|
||||
dagPlanExecutedRef.current = true;
|
||||
|
||||
const leaderThreadId = resolveSeatThreadId(ids, coord);
|
||||
let finishedAllStages = true;
|
||||
// 跨 stage 累积的**完整交付** {name, content}[] —— 注入下一 stage 席位的 run context
|
||||
// (peerDeliveries),供 read_peer_delivery 工具按需取全文(§12.9 传摘要按需传全文)。
|
||||
const priorDeliveries: Array<{ name: string; content: string }> = [];
|
||||
|
||||
// Phase 7:初始化 DAG 流程图节点状态 —— 计划内所有席位置 pending,执行过程中逐个点亮。
|
||||
const markDagNode = (agentId: string, state: ChainNodeState) =>
|
||||
setDagNodeStates((prev) => ({ ...prev, [agentId.toLowerCase()]: state }));
|
||||
setDagNodeStates(
|
||||
Object.fromEntries(
|
||||
plan.stages.flatMap((st) => st.agentIds.map((id) => [id.toLowerCase(), "pending" as ChainNodeState])),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
for (let s = 0; s < plan.stages.length; s += 1) {
|
||||
if (isStale()) {
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
const stage = plan.stages[s];
|
||||
const isParallel = stage.agentIds.length > 1;
|
||||
// 本 stage 计划钉死的席位元数据(供派活轮 + 并行执行共用)。
|
||||
const stageSeats = stage.agentIds.map((agentId) => {
|
||||
const meta = agentNameToRoleRef.current[agentId.toLowerCase()];
|
||||
return { agentId, meta, name: meta?.displayName ?? agentId };
|
||||
});
|
||||
|
||||
// ── (a) 总控派活轮(§3.2 方案 A + 批内并行):由【总控】对本 stage 计划钉死的
|
||||
// 席位**真正派活**(约束只派给这些席位、且每个都派到),而不是前端直发。总控本轮
|
||||
// 也"承上":后端 _build_seat_status_block 已把此前各席位真实交付(摘要)注入其输入
|
||||
// (传摘要按需传全文)。它不自由乱派、不收口。与 chain 同构,区别是一次钉死一批、批内并行。
|
||||
highlightSpeaker(null);
|
||||
const leaderBubbleId = appendStreamingDialogue({
|
||||
sender: "总控协调",
|
||||
avatarType: "blue",
|
||||
initialContent: "",
|
||||
confidence: s === 0 ? "派活中" : "承上派活中",
|
||||
agentThreadId: leaderThreadId,
|
||||
});
|
||||
const leaderTask = buildDagStageLeaderTask({
|
||||
seedMessage,
|
||||
stageIndex: s,
|
||||
totalStages: plan.stages.length,
|
||||
seats: stageSeats.map((p) => ({ agentId: p.agentId, name: p.name })),
|
||||
isFirst: s === 0,
|
||||
});
|
||||
const leaderAborter = new AbortController();
|
||||
activeAbortersRef.current.add(leaderAborter);
|
||||
let leaderRes;
|
||||
try {
|
||||
leaderRes = await streamMultiAgent({
|
||||
agentType: "leader",
|
||||
dAgentThreadId: ids,
|
||||
agentName: coord,
|
||||
newMessage: leaderTask,
|
||||
// 派活轮:传 false,后端只注入各席位摘要;完整交付正文留给末尾收口的 synthesis 轮。
|
||||
synthesisMode: false,
|
||||
// 抑制预广播:别把总控的派活 prompt 播进席位 thread(否则弱模型从历史里捡到派活
|
||||
// 意图、跟着模仿派活 / 绕道 bash 调内部接口)。席位任务由 pickDagSeatTask 干净下发。
|
||||
suppressPreBroadcast: true,
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
setDialogueConfidence(leaderBubbleId, label);
|
||||
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
|
||||
});
|
||||
} catch (err) {
|
||||
activeAbortersRef.current.delete(leaderAborter);
|
||||
if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
activeAbortersRef.current.delete(leaderAborter);
|
||||
if (isStale()) {
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
// 派活轮也可能要求澄清 → 暂停 DAG(回复后走 leader 自由派活、不重跑 DAG)。
|
||||
if (leaderRes.clarification) {
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
|
||||
setPendingClarification(leaderRes.clarification);
|
||||
setClarificationDraft("");
|
||||
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
|
||||
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
|
||||
|
||||
// ── (b) 本 stage 席位【并行】执行总控下发的 task ──
|
||||
// 预备各席位气泡(在 Promise.all 之前同步建好,这样并行显示(§5)的头像条/两列
|
||||
// 能立刻拿到每个席位的 bubbleId 去渲染对应消息流)。
|
||||
const seatPrep = stageSeats.map(({ agentId, meta, name }) => {
|
||||
const avatarType = meta?.avatarType ?? "blue";
|
||||
const seatThreadId = resolveSeatThreadId(ids, agentId);
|
||||
const bubbleId = appendStreamingDialogue({
|
||||
sender: name,
|
||||
avatarType,
|
||||
initialContent: "",
|
||||
confidence: "调度中",
|
||||
agentThreadId: seatThreadId,
|
||||
});
|
||||
return { agentId, meta, seatName: name, avatarType, seatThreadId, bubbleId };
|
||||
});
|
||||
|
||||
// 整批高亮(并行)。单节点 stage 退化为高亮一个,行为与串行一致。
|
||||
markParallelSpeakers(
|
||||
seatPrep.map((p) => p.meta?.speakerId).filter((v): v is number => typeof v === "number"),
|
||||
);
|
||||
// Phase 7:本 stage 席位置 active(流程图点亮当前列)。
|
||||
for (const p of seatPrep) markDagNode(p.agentId, "active");
|
||||
|
||||
// 并行 stage(≥2 席位)才发布「并行批次」给前端头像条 + 主区显示。
|
||||
// fast(双列)默认选中最先两个;sandbox(单列)只选最先一个,点头像切换。
|
||||
if (isParallel) {
|
||||
setParallelBatch(
|
||||
seatPrep.map((p) => ({
|
||||
agentId: p.agentId,
|
||||
displayName: p.seatName,
|
||||
avatarType: p.avatarType,
|
||||
status: "running" as const,
|
||||
bubbleId: p.bubbleId,
|
||||
})),
|
||||
);
|
||||
const cols = dagExecModeRef.current === "sandbox" ? 1 : 2;
|
||||
setSelectedParallelSeats(seatPrep.slice(0, cols).map((p) => p.agentId));
|
||||
}
|
||||
const markBatchStatus = (agentId: string, status: ParallelSeatView["status"]) =>
|
||||
setParallelBatch((prev) =>
|
||||
prev.map((p) => (p.agentId === agentId ? { ...p, status } : p)),
|
||||
);
|
||||
|
||||
// ── 批内并行:对 stage 内每个席位发 special run,跑总控本轮派给它的 task ──
|
||||
// 每个席位独立 try/catch 兜底,保证**单席位失败不拖垮整 stage**(§9):
|
||||
// 任务函数永不 reject → Promise.all 不会因一个席位异常整体崩。
|
||||
const stageResults = await Promise.all(
|
||||
seatPrep.map(async ({ agentId, meta, seatName, seatThreadId, bubbleId }): Promise<{ name: string; content: string } | null> => {
|
||||
const perSeatModel = meta
|
||||
? agentConfigsRef.current[meta.speakerId]?.model
|
||||
: undefined;
|
||||
// 用总控本轮派给该席位的 task(命中其 agent_name);总控没用工具只写文字时,
|
||||
// pickDagSeatTask 给干净本职任务、**不**注入总控派活叙述(根治「席位模仿派活」)。
|
||||
// 带上 seedMessage:预广播已抑制,席位 thread 里没有用户原始消息,靠 task 补背景。
|
||||
const task = pickDagSeatTask(
|
||||
leaderRes.dispatched,
|
||||
agentId,
|
||||
seatName,
|
||||
seedMessage,
|
||||
priorDeliveries.length > 0,
|
||||
dagExecModeRef.current === "sandbox", // 仅沙箱模式要求写 .md 文件;快速模式产物走正文
|
||||
);
|
||||
|
||||
const subAborter = new AbortController();
|
||||
activeAbortersRef.current.add(subAborter);
|
||||
try {
|
||||
const specRes = await streamMultiAgent({
|
||||
agentType: "special",
|
||||
dAgentThreadId: ids,
|
||||
agentName: agentId,
|
||||
displayName: seatName,
|
||||
newMessage: task,
|
||||
// 写文件只在 **沙箱模式**(seat policy)开放;**快速模式一律禁写文件**(seat_parallel,
|
||||
// 产物走正文,§12.10 用户要求 fast 不生成文件)。与 stage 是否并行无关:fast 单节点
|
||||
// stage 也禁写(保持 fast=无文件的一致体验)。
|
||||
parallelNoFile: dagExecModeRef.current !== "sandbox",
|
||||
// 前序各阶段的完整交付 → 供本席位 read_peer_delivery 按需取全文(§12.9)。
|
||||
peerDeliveries: priorDeliveries,
|
||||
model: perSeatModel || selectedModelRef.current || undefined,
|
||||
signal: subAborter.signal,
|
||||
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
|
||||
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
|
||||
onTextDelta: (text) =>
|
||||
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
setDialogueConfidence(bubbleId, label);
|
||||
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args);
|
||||
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
|
||||
},
|
||||
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
|
||||
});
|
||||
if (isStale()) {
|
||||
finalizeDialogue(bubbleId, specRes.content || "(已停止)", "已停止");
|
||||
markBatchStatus(agentId, "done");
|
||||
return null;
|
||||
}
|
||||
finalizeDialogue(bubbleId, specRes.content, "子智能体交付");
|
||||
markBatchStatus(agentId, "done");
|
||||
return specRes.content?.trim() ? { name: seatName, content: specRes.content } : null;
|
||||
} catch (err) {
|
||||
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
|
||||
finalizeDialogue(bubbleId, "(已停止)", "已停止");
|
||||
markBatchStatus(agentId, "done");
|
||||
} else {
|
||||
console.error(`[dag-special:${agentId}] error:`, err);
|
||||
finalizeDialogue(bubbleId, `(错误) ${(err as Error).message}`, "调用失败");
|
||||
markBatchStatus(agentId, "error");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
activeAbortersRef.current.delete(subAborter);
|
||||
if (meta) markSpeakerIdle(meta.speakerId);
|
||||
// Phase 7:席位到终态(成功/失败/抢占)→ 流程图节点打勾。ChainNodeState 无
|
||||
// error 态,失败也归 done(节点已结束);失败细节体现在气泡正文。
|
||||
markDagNode(agentId, "done");
|
||||
// 终态后从 ref store 删除,节流 flush 不再覆盖已 finalize 的正文。
|
||||
parallelStreamRef.current.delete(bubbleId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// 本 stage 结束(barrier 到达):收起并行批次,回到普通流式列表 + 清流式 store。
|
||||
if (isParallel) {
|
||||
setParallelBatch([]);
|
||||
setSelectedParallelSeats([]);
|
||||
resetParallelStreams();
|
||||
}
|
||||
|
||||
// Promise.all 即 barrier:本 stage 全部终态后才进入下一 stage(批间串行)。
|
||||
// 下一 stage 的总控派活轮会"承上"(后端注入本批真实交付摘要),无需前端再拼摘要。
|
||||
if (isStale()) {
|
||||
finishedAllStages = false;
|
||||
break;
|
||||
}
|
||||
// 累积本 stage 的**完整交付**,供后续 stage 席位 read_peer_delivery 按需取全文(§12.9)。
|
||||
for (const r of stageResults) {
|
||||
if (r && r.content?.trim()) priorDeliveries.push(r);
|
||||
}
|
||||
setConsensusPercentage(
|
||||
Math.min(95, Math.round(((s + 1) / plan.stages.length) * 90)),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 计划阶段全部完成 → **交给总控(leader 自由派活循环)继续** ──
|
||||
// 关键修复:并行/各阶段输出完后,一定要回到总控,由它综合并**按需继续派活 / 收口**,
|
||||
// 而不是停在一次性 synthesis。这里把 DAG 的编排锁释放,委托给 runOrchestration
|
||||
// (它有自己的重入守卫 + epoch + 收敛逻辑)。后续用户干预 / 恢复同样走 leader
|
||||
// (见 runActiveOrchestration 的 dagPlanExecutedRef 闸门),不再重跑整张 DAG。
|
||||
if (!isStale() && finishedAllStages && plan.finalSynthesis) {
|
||||
highlightSpeaker(null);
|
||||
isOrchestratingRef.current = false; // 释放锁,让 runOrchestration 即刻接管
|
||||
setIsOrchestrating(false);
|
||||
await runOrchestration(
|
||||
"各阶段席位已按依赖编排完成交付。请你作为总控,综合各席位的交付:若仍需补充则继续派活,否则直接输出最终方案并收口。",
|
||||
ids,
|
||||
coord,
|
||||
);
|
||||
return;
|
||||
} else if (!isStale() && finishedAllStages) {
|
||||
// 不收口(plan.finalSynthesis=false):全部 stage 跑完即视为完成,不再叫总控。
|
||||
setConsensusPercentage(100);
|
||||
setHasConsensus(true);
|
||||
triggerToast("✅ 依赖编排各阶段已全部完成");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("dag orchestration error:", err);
|
||||
triggerToast(`❌ 依赖编排研讨异常: ${(err as Error).message}`);
|
||||
} finally {
|
||||
highlightSpeaker(null);
|
||||
clearAllStreamingSpeakers();
|
||||
// 兜底收起并行批次(正常路径已在 stage 结束时清空,异常 / 抢占时这里保证不残留)。
|
||||
setParallelBatch([]);
|
||||
setSelectedParallelSeats([]);
|
||||
resetParallelStreams();
|
||||
setIsOrchestrating(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
appendStreamingDialogue,
|
||||
appendToDialogue,
|
||||
appendStepIfNew,
|
||||
setDialogueConfidence,
|
||||
phaseLabel,
|
||||
finalizeDialogue,
|
||||
highlightSpeaker,
|
||||
markParallelSpeakers,
|
||||
markSpeakerIdle,
|
||||
clearAllStreamingSpeakers,
|
||||
triggerToast,
|
||||
handleArtifactToolPhase,
|
||||
handleUpstreamFrame,
|
||||
resolveSeatThreadId,
|
||||
ingestParallelDelta,
|
||||
resetParallelStreams,
|
||||
runOrchestration,
|
||||
],
|
||||
);
|
||||
|
||||
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
|
||||
const runActiveOrchestration = useCallback(
|
||||
(seedMessage: string, ids: ThreadIdMap, coord: string) =>
|
||||
orchestrationModeRef.current === "chain"
|
||||
? runChainOrchestration(seedMessage, ids, coord)
|
||||
: runOrchestration(seedMessage, ids, coord),
|
||||
[runOrchestration, runChainOrchestration],
|
||||
(seedMessage: string, ids: ThreadIdMap, coord: string) => {
|
||||
const mode = orchestrationModeRef.current;
|
||||
// dag:**只在计划尚未跑过时**执行整张 DAG;一旦跑过(用户干预 / 恢复 / 澄清回复),
|
||||
// 改走 leader 自由派活循环,由总控接着派活,而不是把 DAG 从头重跑一遍。
|
||||
if (
|
||||
mode === "dag" &&
|
||||
!dagPlanExecutedRef.current &&
|
||||
(orchestrationPlanRef.current?.stages.length ?? 0) > 0
|
||||
) {
|
||||
return runDagOrchestration(seedMessage, ids, coord);
|
||||
}
|
||||
if (mode === "chain") {
|
||||
return runChainOrchestration(seedMessage, ids, coord);
|
||||
}
|
||||
return runOrchestration(seedMessage, ids, coord);
|
||||
},
|
||||
[runOrchestration, runChainOrchestration, runDagOrchestration],
|
||||
);
|
||||
|
||||
// ─── Effects ─────────────────────────────────────────────────────
|
||||
@@ -1766,6 +2403,8 @@ export function useStep2Orchestration(
|
||||
|
||||
const resetInitGate = useCallback(() => {
|
||||
step2InitRef.current = false;
|
||||
// 新一轮研讨:允许 DAG 计划重新执行一遍。
|
||||
dagPlanExecutedRef.current = false;
|
||||
setInitNonce((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
@@ -1821,6 +2460,10 @@ export function useStep2Orchestration(
|
||||
setLastLeaderContent("");
|
||||
setHasConsensus(false);
|
||||
setConsensusPercentage(0);
|
||||
setParallelBatch([]);
|
||||
setSelectedParallelSeats([]);
|
||||
setDagNodeStates({});
|
||||
resetParallelStreams();
|
||||
setActiveArtifactThreadId(null);
|
||||
artifactPathsRef.current = [];
|
||||
setArtifacts([]);
|
||||
@@ -1828,9 +2471,10 @@ export function useStep2Orchestration(
|
||||
setSeatStubMessages({});
|
||||
hasAutoOpenedRef.current = new Set();
|
||||
pendingInjectPromptRef.current = null;
|
||||
dagPlanExecutedRef.current = false;
|
||||
deselectArtifact();
|
||||
step2InitRef.current = false;
|
||||
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
|
||||
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
|
||||
|
||||
return {
|
||||
selectedAgents,
|
||||
@@ -1850,6 +2494,11 @@ export function useStep2Orchestration(
|
||||
activeSpeakerSet,
|
||||
customAgentInput,
|
||||
isUpperPanelCollapsed,
|
||||
parallelBatch,
|
||||
selectedParallelSeats,
|
||||
isParallelRunning: parallelBatch.length > 0,
|
||||
toggleParallelSeat,
|
||||
dagNodeStates,
|
||||
budgetLimit,
|
||||
agentConfigs,
|
||||
selectedAgentId,
|
||||
|
||||
@@ -27,21 +27,43 @@ export interface BusinessChain {
|
||||
description?: string;
|
||||
/** 有序席位列表。 */
|
||||
seats: ChainSeat[];
|
||||
/**
|
||||
* 可选「分层编排」:把 `seats` 分成若干阶段(stage),每个阶段内的 agent_id
|
||||
* 并行、阶段之间串行。如 `[["a","b"],["c"]]` = (a‖b) → c。缺省 / null = 线性链
|
||||
* (每席位独立一层)。有分层时,选入 Step 2 走 `dag` 编排(见 `lib/dag-types.ts`)。
|
||||
*/
|
||||
stages?: string[][] | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。
|
||||
* 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。
|
||||
* 三种模式(智能推荐 / 业务链条线性 / 业务链条分层 DAG)最终都产出**有序**的
|
||||
* `agents` + 一个 `mode`。
|
||||
*/
|
||||
export interface SeatSelectionResult {
|
||||
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */
|
||||
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。dag 模式下按 stage 展平排序。 */
|
||||
agents: Array<{ agent_id: string; name: string }>;
|
||||
/** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */
|
||||
mode: "recommend" | "chain";
|
||||
/** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
|
||||
/**
|
||||
* 决定 Step 2 走哪种编排:
|
||||
* - `recommend` 总控自由派活;
|
||||
* - `chain` 按链条顺序逐位派活;
|
||||
* - `dag` 分层并行(来源链条带 `stages` 时)。
|
||||
*/
|
||||
mode: "recommend" | "chain" | "dag";
|
||||
/** chain / dag 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
|
||||
chain?: Pick<BusinessChain, "id" | "title">;
|
||||
/** dag 模式带上由链条 `stages` 构造的执行计划(见 `lib/dag-types.ts`)。 */
|
||||
plan?: import("./dag-types").OrchestrationPlan;
|
||||
/**
|
||||
* dag 模式的执行子模式(仅 `mode === "dag"` 有意义):
|
||||
* - `fast`(快速):同 stage 席位**并行**、主区**双列**显示、**禁写文件**(seat_parallel);
|
||||
* - `sandbox`(沙箱):同 stage 席位仍**并行**,但主区**单列**显示、**允许写文件**(seat),
|
||||
* 沙箱跟随当前显示的席位(点头像切换,沙箱内容随之切换/关闭)。
|
||||
* 缺省 `fast`。
|
||||
*/
|
||||
dagExecMode?: "fast" | "sandbox";
|
||||
}
|
||||
|
||||
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
addEmptyStage,
|
||||
addSeatAsNewStage,
|
||||
addSeatToStage,
|
||||
chainToStageGroups,
|
||||
countSeats,
|
||||
deleteStage,
|
||||
findStageIndexOfSeat,
|
||||
moveSeatToStage,
|
||||
moveStage,
|
||||
removeSeat,
|
||||
splitSeatToNewStage,
|
||||
stageGroupsToSeats,
|
||||
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));
|
||||
|
||||
// ── chainToStageGroups ────────────────────────────────────────────────────────
|
||||
|
||||
test("chainToStageGroups: linear chain (no stages) → one seat per stage", () => {
|
||||
const groups = chainToStageGroups({ seats: [seat("a"), seat("b"), seat("c")] });
|
||||
assert.deepEqual(ids(groups), [["a"], ["b"], ["c"]]);
|
||||
});
|
||||
|
||||
test("chainToStageGroups: with stages → grouped, mapping ids to seats", () => {
|
||||
const groups = chainToStageGroups({
|
||||
seats: [seat("a"), seat("b"), seat("c")],
|
||||
stages: [["a", "b"], ["c"]],
|
||||
});
|
||||
assert.deepEqual(ids(groups), [["a", "b"], ["c"]]);
|
||||
});
|
||||
|
||||
test("chainToStageGroups: drops stage ids with no matching seat, and empty groups", () => {
|
||||
const groups = chainToStageGroups({
|
||||
seats: [seat("a"), seat("c")],
|
||||
stages: [["a", "ghost"], ["c"], ["onlyghost"]],
|
||||
});
|
||||
assert.deepEqual(ids(groups), [["a"], ["c"]]);
|
||||
});
|
||||
|
||||
test("chainToStageGroups: stages that resolve to nothing → linear fallback", () => {
|
||||
const groups = chainToStageGroups({ seats: [seat("a"), seat("b")], stages: [["ghost"]] });
|
||||
assert.deepEqual(ids(groups), [["a"], ["b"]]);
|
||||
});
|
||||
|
||||
// ── serialization ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("stageGroupsToSeats flattens in order", () => {
|
||||
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
|
||||
assert.deepEqual(
|
||||
stageGroupsToSeats(groups).map((s) => s.agent_id),
|
||||
["a", "b", "c"],
|
||||
);
|
||||
});
|
||||
|
||||
test("stageGroupsToStagePayload returns null when all singletons (linear)", () => {
|
||||
const groups: StageGroups = [[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")]];
|
||||
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"], ["c"]]);
|
||||
});
|
||||
|
||||
test("stageGroupsToStagePayload ignores empty stages", () => {
|
||||
const groups: StageGroups = [[seat("a"), seat("b")], []];
|
||||
assert.deepEqual(stageGroupsToStagePayload(groups), [["a", "b"]]);
|
||||
});
|
||||
|
||||
// ── lookups ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("findStageIndexOfSeat / countSeats", () => {
|
||||
const groups: StageGroups = [[seat("a"), seat("b")], [seat("c")]];
|
||||
assert.equal(findStageIndexOfSeat(groups, "c"), 1);
|
||||
assert.equal(findStageIndexOfSeat(groups, "zzz"), -1);
|
||||
assert.equal(countSeats(groups), 3);
|
||||
});
|
||||
|
||||
// ── mutations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test("addSeatAsNewStage appends a singleton stage, dedupes", () => {
|
||||
let g: StageGroups = [[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);
|
||||
assert.deepEqual(ids(g), [["b", "a"], ["c"]]);
|
||||
});
|
||||
|
||||
test("removeSeat drops the seat and prunes an emptied stage", () => {
|
||||
const g = removeSeat([[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);
|
||||
assert.deepEqual(ids(g), [["b", "a"]]);
|
||||
});
|
||||
|
||||
test("moveSeatToStage within same stage reorders", () => {
|
||||
const g = moveSeatToStage([[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");
|
||||
assert.deepEqual(ids(g), [["a"], ["b"], ["c"]]);
|
||||
});
|
||||
|
||||
test("splitSeatToNewStage on an already-solo seat is a no-op", () => {
|
||||
const g = splitSeatToNewStage([[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);
|
||||
assert.deepEqual(ids(g), [["a"], ["c"], ["b"]]);
|
||||
});
|
||||
|
||||
test("addEmptyStage / deleteStage", () => {
|
||||
let g = addEmptyStage([[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")]];
|
||||
const seats = stageGroupsToSeats(groups);
|
||||
const stages = stageGroupsToStagePayload(groups);
|
||||
const restored = chainToStageGroups({ seats, stages });
|
||||
assert.deepEqual(ids(restored), [["a", "b"], ["c"]]);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 业务链条「分层编排」编辑器的纯数据操作。
|
||||
*
|
||||
* 编辑器内部用 `StageGroups`(`ChainSeat[][]`,每个内层数组 = 一个 stage,stage 内
|
||||
* 并行、stage 间串行)作为**单一数据源**:
|
||||
* - 展平得到落库的有序 `seats`({@link stageGroupsToSeats});
|
||||
* - 折叠得到落库的 `stages`({@link stageGroupsToStagePayload})。
|
||||
*
|
||||
* 这里只放纯函数(无 React / 无副作用),便于单测;组件只做渲染 + 调用这些函数。
|
||||
* 对应需求文档 `docs/roundtable-dag-orchestration.md` §2(分层编辑器,竖列分组)。
|
||||
*/
|
||||
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
|
||||
|
||||
/** 编辑器内部的分层结构:每个内层数组是一个并行 stage,外层顺序即串行顺序。 */
|
||||
export type StageGroups = ChainSeat[][];
|
||||
|
||||
/**
|
||||
* 从一条业务链条还原分层结构。
|
||||
* - 链条带 `stages`:按分层分组(agent_id → 对应 seat),丢弃找不到 seat 的 id 与空组;
|
||||
* 分层全空时回退到线性。
|
||||
* - 无 `stages`(老链条):线性 —— 每个 seat 独立成一层。
|
||||
*/
|
||||
export function chainToStageGroups(chain: Pick<BusinessChain, "seats" | "stages">): StageGroups {
|
||||
const seats = chain.seats ?? [];
|
||||
const byId = new Map(seats.map((s) => [s.agent_id, s]));
|
||||
if (chain.stages && chain.stages.length > 0) {
|
||||
const groups: StageGroups = [];
|
||||
for (const group of chain.stages) {
|
||||
const mapped = group.map((id) => byId.get(id)).filter((s): s is ChainSeat => Boolean(s));
|
||||
if (mapped.length > 0) groups.push(mapped);
|
||||
}
|
||||
if (groups.length > 0) return groups;
|
||||
}
|
||||
return seats.map((s) => [s]);
|
||||
}
|
||||
|
||||
/** 展平成落库 / 校验用的有序席位列表(stage 顺序 → stage 内顺序)。 */
|
||||
export function stageGroupsToSeats(groups: StageGroups): ChainSeat[] {
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* 折叠成落库的 `stages`。**全是单元素 stage(无并行)时返回 null** —— 语义上就是线性
|
||||
* 链,落库为 null 以便老行为不变(Step 2 仍走 chain 模式);只要有一个 stage ≥2 席位
|
||||
* 就返回显式分层(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);
|
||||
if (!hasParallel) return null;
|
||||
return nonEmpty.map((g) => g.map((s) => s.agent_id));
|
||||
}
|
||||
|
||||
/** 该席位所在 stage 下标;不存在返回 -1。 */
|
||||
export function findStageIndexOfSeat(groups: StageGroups, agentId: string): number {
|
||||
return groups.findIndex((g) => g.some((s) => s.agent_id === agentId));
|
||||
}
|
||||
|
||||
/** 席位是否已在任意 stage 中。 */
|
||||
export function seatExistsInGroups(groups: StageGroups, agentId: string): boolean {
|
||||
return findStageIndexOfSeat(groups, agentId) >= 0;
|
||||
}
|
||||
|
||||
/** 总席位数(用于 2–8 约束)。 */
|
||||
export function countSeats(groups: StageGroups): number {
|
||||
return groups.reduce((n, g) => n + g.length, 0);
|
||||
}
|
||||
|
||||
// ── 变换(均返回新结构,不可变)──────────────────────────────────────────────
|
||||
|
||||
/** 末尾追加一个**新单席位 stage**(线性追加,pool 点击加入默认行为)。 */
|
||||
export function addSeatAsNewStage(groups: StageGroups, seat: ChainSeat): StageGroups {
|
||||
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
||||
return [...groups, [seat]];
|
||||
}
|
||||
|
||||
/** 把席位加入指定 stage(可选插入位置);席位已存在则不动。 */
|
||||
export function addSeatToStage(
|
||||
groups: StageGroups,
|
||||
seat: ChainSeat,
|
||||
stageIdx: number,
|
||||
atIndex?: number,
|
||||
): StageGroups {
|
||||
if (seatExistsInGroups(groups, seat.agent_id)) return groups;
|
||||
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
||||
return groups.map((g, i) => {
|
||||
if (i !== stageIdx) return g;
|
||||
const next = [...g];
|
||||
const at = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
|
||||
next.splice(at, 0, seat);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/** 移除一个席位;该 stage 因此变空则连 stage 一起删掉。 */
|
||||
export function removeSeat(groups: StageGroups, agentId: string): StageGroups {
|
||||
return groups
|
||||
.map((g) => g.filter((s) => s.agent_id !== agentId))
|
||||
.filter((g) => g.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把席位移动到目标 stage(跨 stage 合并 / stage 内移动)。
|
||||
* 源 stage 变空则删除;删除会改变下标,这里用 agent_id 与目标 stage 的**身份**而非下标
|
||||
* 重新定位,保证语义稳定。`atIndex` 为目标 stage 内插入位置。
|
||||
*/
|
||||
export function moveSeatToStage(
|
||||
groups: StageGroups,
|
||||
agentId: string,
|
||||
targetStageIdx: number,
|
||||
atIndex?: number,
|
||||
): 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);
|
||||
if (!seat) return groups;
|
||||
// 先按身份记下目标 stage 引用,避免删空源 stage 后下标错位。
|
||||
const targetRef = groups[targetStageIdx];
|
||||
let next: StageGroups = groups.map((g) =>
|
||||
g === targetRef ? g : g.filter((s) => s.agent_id !== agentId),
|
||||
);
|
||||
next = next.map((g) => {
|
||||
if (g !== targetRef) return g;
|
||||
if (g.some((s) => s.agent_id === agentId)) {
|
||||
// stage 内移动:先抽出再按 atIndex 插回。
|
||||
const without = g.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;
|
||||
}
|
||||
const merged = [...g];
|
||||
const at = atIndex == null ? merged.length : Math.max(0, Math.min(atIndex, merged.length));
|
||||
merged.splice(at, 0, seat);
|
||||
return merged;
|
||||
});
|
||||
return next.filter((g) => g.length > 0);
|
||||
}
|
||||
|
||||
/** 把席位从当前 stage 抽出,独立成一个**新 stage**(插在原 stage 之后)。 */
|
||||
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);
|
||||
if (!seat) return groups;
|
||||
if (groups[from].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]);
|
||||
} else {
|
||||
out.push(g);
|
||||
}
|
||||
});
|
||||
return out.filter((g) => g.length > 0);
|
||||
}
|
||||
|
||||
/** 整段 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) {
|
||||
return groups;
|
||||
}
|
||||
const next = [...groups];
|
||||
[next[stageIdx], next[target]] = [next[target], next[stageIdx]];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 末尾追加一个空 stage(用户「新建 Stage」后再拖 / 加席位进去)。 */
|
||||
export function addEmptyStage(groups: StageGroups): StageGroups {
|
||||
return [...groups, []];
|
||||
}
|
||||
|
||||
/** 删除整个 stage(含其席位)。 */
|
||||
export function deleteStage(groups: StageGroups, stageIdx: number): StageGroups {
|
||||
if (stageIdx < 0 || stageIdx >= groups.length) return groups;
|
||||
return groups.filter((_, i) => i !== stageIdx);
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildDagStageLeaderTask,
|
||||
buildDagTransitTask,
|
||||
buildDependencySummary,
|
||||
buildPlanFromStages,
|
||||
buildStageTask,
|
||||
chainToDagPlan,
|
||||
emptyPlan,
|
||||
isParallelStage,
|
||||
PARALLEL_DEGREE_WARN_THRESHOLD,
|
||||
pickDagSeatTask,
|
||||
validatePlan,
|
||||
type OrchestrationPlan,
|
||||
} from "./dag-types";
|
||||
|
||||
// ── chainToDagPlan:链条 → 单节点-stage DAG(§1.3 兼容转换)────────────────────
|
||||
|
||||
test("chainToDagPlan turns a linear chain into one-node-per-stage DAG", () => {
|
||||
const plan = chainToDagPlan(["a", "b", "c"]);
|
||||
assert.equal(plan.mode, "dag");
|
||||
assert.equal(plan.finalSynthesis, true);
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.agentIds),
|
||||
[["a"], ["b"], ["c"]],
|
||||
);
|
||||
// stage id 稳定且唯一(diff / 拖拽重排用)。
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.id),
|
||||
["stage-0", "stage-1", "stage-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("chainToDagPlan preserves order and respects finalSynthesis=false", () => {
|
||||
const plan = chainToDagPlan(["x", "y"], false);
|
||||
assert.equal(plan.finalSynthesis, false);
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.agentIds[0]),
|
||||
["x", "y"],
|
||||
);
|
||||
});
|
||||
|
||||
test("chainToDagPlan on empty chain yields an empty plan", () => {
|
||||
const plan = chainToDagPlan([]);
|
||||
assert.deepEqual(plan.stages, []);
|
||||
});
|
||||
|
||||
test("emptyPlan is a valid empty dag plan", () => {
|
||||
assert.deepEqual(emptyPlan(), { mode: "dag", stages: [], finalSynthesis: true });
|
||||
});
|
||||
|
||||
// ── isParallelStage ──────────────────────────────────────────────────────────
|
||||
|
||||
test("isParallelStage is true only for ≥2 seats", () => {
|
||||
assert.equal(isParallelStage({ id: "s", agentIds: ["a"] }), false);
|
||||
assert.equal(isParallelStage({ id: "s", agentIds: ["a", "b"] }), true);
|
||||
});
|
||||
|
||||
// ── validatePlan ──────────────────────────────────────────────────────────────
|
||||
|
||||
const roster = ["a", "b", "c", "d"];
|
||||
|
||||
test("validatePlan accepts a valid mixed serial/parallel plan", () => {
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [
|
||||
{ id: "s0", agentIds: ["a"] },
|
||||
{ id: "s1", agentIds: ["b", "c"] },
|
||||
{ id: "s2", agentIds: ["d"] },
|
||||
],
|
||||
};
|
||||
const res = validatePlan(plan, roster);
|
||||
assert.equal(res.ok, true);
|
||||
assert.deepEqual(res.errors, []);
|
||||
assert.deepEqual(res.warnings, []);
|
||||
});
|
||||
|
||||
test("validatePlan rejects an empty plan", () => {
|
||||
const res = validatePlan(emptyPlan(), roster);
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.errors.length, 1);
|
||||
});
|
||||
|
||||
test("validatePlan rejects an empty stage", () => {
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [{ id: "s0", agentIds: ["a"] }, { id: "s1", agentIds: [] }],
|
||||
};
|
||||
const res = validatePlan(plan, roster);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.errors[0], /第 2 阶段为空/);
|
||||
});
|
||||
|
||||
test("validatePlan rejects a seat not in the roster", () => {
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [{ id: "s0", agentIds: ["a", "zzz"] }],
|
||||
};
|
||||
const res = validatePlan(plan, roster);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.errors[0], /未选定的席位/);
|
||||
});
|
||||
|
||||
test("validatePlan rejects the same seat across two stages", () => {
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [
|
||||
{ id: "s0", agentIds: ["a"] },
|
||||
{ id: "s1", agentIds: ["a"] },
|
||||
],
|
||||
};
|
||||
const res = validatePlan(plan, roster);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.errors[0], /只能跑一次/);
|
||||
});
|
||||
|
||||
test("validatePlan rejects a duplicate seat within one stage", () => {
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [{ id: "s0", agentIds: ["a", "a"] }],
|
||||
};
|
||||
const res = validatePlan(plan, roster);
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.errors[0], /重复/);
|
||||
});
|
||||
|
||||
test("validatePlan warns (but allows) when parallel degree exceeds the threshold", () => {
|
||||
const big = Array.from({ length: PARALLEL_DEGREE_WARN_THRESHOLD + 1 }, (_, i) => `p${i}`);
|
||||
const plan: OrchestrationPlan = {
|
||||
mode: "dag",
|
||||
finalSynthesis: true,
|
||||
stages: [{ id: "s0", agentIds: big }],
|
||||
};
|
||||
const res = validatePlan(plan, big);
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.errors.length, 0);
|
||||
assert.equal(res.warnings.length, 1);
|
||||
assert.match(res.warnings[0], /并发限流/);
|
||||
});
|
||||
|
||||
// chain→dag round-trip is always valid against its own roster.
|
||||
test("a chainToDagPlan validates clean against its own roster", () => {
|
||||
const chain = ["alpha", "beta", "gamma"];
|
||||
const res = validatePlan(chainToDagPlan(chain), chain);
|
||||
assert.equal(res.ok, true);
|
||||
});
|
||||
|
||||
// ── buildPlanFromStages (链条 stages → 执行计划) ──────────────────────────────
|
||||
|
||||
test("buildPlanFromStages maps groups to stages with stable ids", () => {
|
||||
const plan = buildPlanFromStages([["a", "b"], ["c"]]);
|
||||
assert.equal(plan.mode, "dag");
|
||||
assert.equal(plan.finalSynthesis, true);
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.agentIds),
|
||||
[["a", "b"], ["c"]],
|
||||
);
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.id),
|
||||
["stage-0", "stage-1"],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPlanFromStages filters out non-surviving ids and drops emptied stages", () => {
|
||||
// "b" deleted; stage that becomes empty is dropped.
|
||||
const plan = buildPlanFromStages([["a", "b"], ["x"]], ["a", "c"]);
|
||||
assert.deepEqual(
|
||||
plan.stages.map((s) => s.agentIds),
|
||||
[["a"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("buildPlanFromStages on all-filtered yields empty stages (caller falls back)", () => {
|
||||
const plan = buildPlanFromStages([["x"], ["y"]], ["a"]);
|
||||
assert.deepEqual(plan.stages, []);
|
||||
});
|
||||
|
||||
test("a chain plan validates clean against its surviving roster", () => {
|
||||
const plan = buildPlanFromStages([["a", "b"], ["c"]], ["a", "b", "c"]);
|
||||
const res = validatePlan(plan, ["a", "b", "c"]);
|
||||
assert.equal(res.ok, true);
|
||||
});
|
||||
|
||||
// ── buildDependencySummary (§3.1 依赖摘要注入) ─────────────────────────────────
|
||||
|
||||
test("buildDependencySummary is empty for no deliveries (first stage)", () => {
|
||||
assert.equal(buildDependencySummary([]), "");
|
||||
});
|
||||
|
||||
test("buildDependencySummary lists each seat's delivery with its name", () => {
|
||||
const out = buildDependencySummary([
|
||||
{ seatName: "情报收集", content: "情报正文" },
|
||||
{ seatName: "环境评估", content: "评估正文" },
|
||||
]);
|
||||
assert.match(out, /【情报收集】/);
|
||||
assert.match(out, /情报正文/);
|
||||
assert.match(out, /【环境评估】/);
|
||||
assert.match(out, /评估正文/);
|
||||
});
|
||||
|
||||
test("buildDependencySummary truncates long content", () => {
|
||||
const long = "x".repeat(3000);
|
||||
const out = buildDependencySummary([{ seatName: "A", content: long }], 100);
|
||||
assert.match(out, /…(已截断)/);
|
||||
assert.ok(out.length < 3000);
|
||||
});
|
||||
|
||||
test("buildDependencySummary handles empty content gracefully", () => {
|
||||
const out = buildDependencySummary([{ seatName: "A", content: " " }]);
|
||||
assert.match(out, /(无文本输出)/);
|
||||
});
|
||||
|
||||
// ── buildStageTask (§3.2 直派提示) ────────────────────────────────────────────
|
||||
|
||||
test("buildStageTask for first stage embeds the task background", () => {
|
||||
const task = buildStageTask({
|
||||
seedMessage: "做一个容灾方案",
|
||||
seatName: "情报收集",
|
||||
priorSummary: "",
|
||||
isParallel: false,
|
||||
});
|
||||
assert.match(task, /任务背景/);
|
||||
assert.match(task, /做一个容灾方案/);
|
||||
assert.match(task, /情报收集/);
|
||||
// serial node — no parallel no-file warning.
|
||||
assert.doesNotMatch(task, /并行研讨/);
|
||||
});
|
||||
|
||||
test("buildStageTask for a later stage embeds the prior summary, not the seed", () => {
|
||||
const task = buildStageTask({
|
||||
seedMessage: "原始任务",
|
||||
seatName: "方案设计",
|
||||
priorSummary: "【情报收集】的交付:\n情报正文",
|
||||
isParallel: false,
|
||||
});
|
||||
assert.match(task, /此前各阶段已完成席位的交付摘要/);
|
||||
assert.match(task, /情报正文/);
|
||||
assert.doesNotMatch(task, /原始任务/);
|
||||
});
|
||||
|
||||
test("buildStageTask in a parallel stage forbids writing files", () => {
|
||||
const task = buildStageTask({
|
||||
seedMessage: "原始任务",
|
||||
seatName: "方案设计",
|
||||
priorSummary: "",
|
||||
isParallel: true,
|
||||
});
|
||||
assert.match(task, /并行研讨/);
|
||||
assert.match(task, /不要写文件/);
|
||||
});
|
||||
|
||||
test("buildStageTask with priorIsBridge frames prior context as the leader's bridge", () => {
|
||||
const bridge = "总控综合:情报已收集,下一步聚焦架构设计";
|
||||
const task = buildStageTask({
|
||||
seedMessage: "原始任务",
|
||||
seatName: "方案设计",
|
||||
priorSummary: bridge,
|
||||
isParallel: false,
|
||||
priorIsBridge: true,
|
||||
});
|
||||
// 措辞切换到「承上启下」综合说明,而非机械「交付摘要」。
|
||||
assert.match(task, /承上启下/);
|
||||
assert.match(task, /综合说明/);
|
||||
assert.match(task, /总控综合/);
|
||||
assert.doesNotMatch(task, /已完成席位的交付摘要/);
|
||||
});
|
||||
|
||||
// ── buildDagTransitTask (§12.3 Stage 间总控中转 + 广播) ─────────────────────────
|
||||
|
||||
test("buildDagTransitTask names completed + next seats and forbids dispatch/closeout", () => {
|
||||
const task = buildDagTransitTask({
|
||||
completedSeatNames: ["情报收集", "环境评估"],
|
||||
nextSeatNames: ["方案设计", "风险审查"],
|
||||
});
|
||||
assert.match(task, /中转轮/);
|
||||
assert.match(task, /情报收集、环境评估/);
|
||||
assert.match(task, /方案设计、风险审查/);
|
||||
assert.match(task, /承上启下/);
|
||||
// 计划钉死下一批 → 总控不自由派活、不收口。
|
||||
assert.match(task, /不要.*派活/);
|
||||
assert.match(task, /不要现在收口/);
|
||||
});
|
||||
|
||||
test("buildDagTransitTask falls back gracefully when seat name lists are empty", () => {
|
||||
const task = buildDagTransitTask({ completedSeatNames: [], nextSeatNames: [] });
|
||||
assert.match(task, /本批席位/);
|
||||
assert.match(task, /下一阶段席位/);
|
||||
});
|
||||
|
||||
// ── buildDagStageLeaderTask (§3.2 方案 A:总控驱动每 stage 派活) ─────────────────
|
||||
|
||||
test("buildDagStageLeaderTask first stage embeds task background + pins seats", () => {
|
||||
const task = buildDagStageLeaderTask({
|
||||
seedMessage: "做一个容灾方案",
|
||||
stageIndex: 0,
|
||||
totalStages: 3,
|
||||
seats: [
|
||||
{ agentId: "intel", name: "情报收集" },
|
||||
{ agentId: "env", name: "环境评估" },
|
||||
],
|
||||
isFirst: true,
|
||||
});
|
||||
assert.match(task, /任务背景/);
|
||||
assert.match(task, /做一个容灾方案/);
|
||||
assert.match(task, /第 1\/3 个阶段/);
|
||||
// 每个钉死席位都要带 agent_name 让总控逐字派活。
|
||||
assert.match(task, /agent_name: intel/);
|
||||
assert.match(task, /agent_name: env/);
|
||||
assert.match(task, /情报收集/);
|
||||
assert.match(task, /环境评估/);
|
||||
// 约束:只派给清单内、不收口。
|
||||
assert.match(task, /不要派给清单外/);
|
||||
assert.match(task, /不要.*给出最终结论/);
|
||||
// 多席位 → 并行提示。
|
||||
assert.match(task, /并行/);
|
||||
});
|
||||
|
||||
test("buildDagStageLeaderTask later stage relies on injected prior deliveries, not seed", () => {
|
||||
const task = buildDagStageLeaderTask({
|
||||
seedMessage: "原始任务",
|
||||
stageIndex: 1,
|
||||
totalStages: 2,
|
||||
seats: [{ agentId: "plan", name: "方案设计" }],
|
||||
isFirst: false,
|
||||
});
|
||||
assert.match(task, /承上启下/);
|
||||
assert.match(task, /第 2\/2 个阶段/);
|
||||
assert.doesNotMatch(task, /原始任务/);
|
||||
// 单席位 stage → 不加并行提示。
|
||||
assert.doesNotMatch(task, /会\*\*并行\*\*执行/);
|
||||
});
|
||||
|
||||
// ── pickDagSeatTask (§3.2 方案 A:取总控派给某席位的 task,多席位不张冠李戴) ────────
|
||||
|
||||
test("pickDagSeatTask returns the exact dispatch matched by agent_name (+ role guard)", () => {
|
||||
const dispatched: [string, string][] = [
|
||||
["plan", "设计架构"],
|
||||
["risk", "审查风险"],
|
||||
];
|
||||
const riskTask = pickDagSeatTask(dispatched, "risk", "风险审查");
|
||||
assert.match(riskTask, /审查风险/);
|
||||
assert.doesNotMatch(riskTask, /设计架构/);
|
||||
const planTask = pickDagSeatTask(dispatched, "plan", "方案设计");
|
||||
assert.match(planTask, /设计架构/);
|
||||
// 角色边界护栏:禁止席位派活/调度。
|
||||
assert.match(riskTask, /执行席位/);
|
||||
assert.match(riskTask, /严禁.*派活|严禁.*调度/);
|
||||
});
|
||||
|
||||
test("pickDagSeatTask gives a CLEAN own-role task on miss — never injects the dispatch narrative", () => {
|
||||
const dispatched: [string, string][] = [["plan", "设计架构"]];
|
||||
// risk 没被派到 → 既不借 plan 的「设计架构」,也**不**注入总控派活叙述(防模仿派活)。
|
||||
const task = pickDagSeatTask(dispatched, "risk", "风险审查");
|
||||
assert.doesNotMatch(task, /设计架构/);
|
||||
assert.match(task, /风险审查/);
|
||||
assert.match(task, /独立完成/);
|
||||
// 角色边界护栏:禁止席位派活/调度/绕道脚本。
|
||||
assert.match(task, /执行席位/);
|
||||
assert.match(task, /严禁/);
|
||||
assert.match(task, /bash|脚本|内部接口/);
|
||||
});
|
||||
|
||||
test("pickDagSeatTask with empty dispatched falls back to clean own-role task", () => {
|
||||
const task = pickDagSeatTask([], "risk", "风险审查");
|
||||
assert.match(task, /风险审查/);
|
||||
assert.match(task, /本阶段/);
|
||||
assert.match(task, /执行席位/);
|
||||
});
|
||||
|
||||
test("pickDagSeatTask embeds the seed message as task background when provided", () => {
|
||||
const task = pickDagSeatTask([], "risk", "风险审查", "做一个容灾方案");
|
||||
assert.match(task, /任务背景/);
|
||||
assert.match(task, /做一个容灾方案/);
|
||||
// exact-match 分支也带背景。
|
||||
const exactTask = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "做一个容灾方案");
|
||||
assert.match(exactTask, /做一个容灾方案/);
|
||||
assert.match(exactTask, /审查风险/);
|
||||
});
|
||||
|
||||
test("pickDagSeatTask mentions read_peer_delivery only when peers exist (§12.9)", () => {
|
||||
const noPeers = pickDagSeatTask([], "risk", "风险审查", "种子", false);
|
||||
assert.doesNotMatch(noPeers, /read_peer_delivery/);
|
||||
const withPeers = pickDagSeatTask([], "risk", "风险审查", "种子", true);
|
||||
assert.match(withPeers, /read_peer_delivery/);
|
||||
assert.match(withPeers, /按需取全文|完整交付/);
|
||||
});
|
||||
|
||||
test("pickDagSeatTask (sandbox mode, allowFiles) asks for .md and forbids .py (§12.8)", () => {
|
||||
// allowFiles=true(沙箱模式):产出 md、禁 .py。
|
||||
const miss = pickDagSeatTask([], "risk", "风险审查", "", false, true);
|
||||
const hit = pickDagSeatTask([["risk", "审查风险"]], "risk", "风险审查", "", false, true);
|
||||
for (const t of [miss, hit]) {
|
||||
assert.match(t, /Markdown(\.md)文件|\.md/);
|
||||
assert.match(t, /不要写 \.py|不要 \.py/);
|
||||
assert.match(t, /产物要求/);
|
||||
}
|
||||
});
|
||||
|
||||
test("pickDagSeatTask (fast mode, default) keeps output in the message body — no file (§12.10)", () => {
|
||||
// allowFiles 默认 false(快速模式):产物走正文,不写文件、不写 md。
|
||||
const t = pickDagSeatTask([], "risk", "风险审查");
|
||||
assert.match(t, /直接写在回复正文|产物走正文|不要写文件/);
|
||||
assert.doesNotMatch(t, /Markdown(\.md)文件/);
|
||||
});
|
||||
|
||||
test("buildDagStageLeaderTask insists on actually calling the tool (not narrating)", () => {
|
||||
const task = buildDagStageLeaderTask({
|
||||
seedMessage: "x",
|
||||
stageIndex: 0,
|
||||
totalStages: 2,
|
||||
seats: [
|
||||
{ agentId: "a", name: "甲" },
|
||||
{ agentId: "b", name: "乙" },
|
||||
],
|
||||
isFirst: true,
|
||||
});
|
||||
assert.match(task, /真正调用 agent_orchestration 工具/);
|
||||
assert.match(task, /不要.*只用文字/);
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* 圆桌 DAG 编排(分层 Stage 模型)共享类型 + 纯函数。
|
||||
*
|
||||
* 对应 `docs/roundtable-dag-orchestration.md` §1「数据模型」。本文件只放纯类型 +
|
||||
* 可单测的纯函数(校验 / chain→dag 兼容转换),不含任何 React / 网络副作用,
|
||||
* 后续 Phase 2(编辑器)/ Phase 3(`runDagOrchestration`)会引用这些类型。
|
||||
*
|
||||
* 设计要点(§0.3 / §1.1):
|
||||
* - DAG 用「有序的 stage 列表」表示:stage 内并行、stage 间串行。
|
||||
* - 线性链是 DAG 的特例(每层 1 个节点),见 {@link chainToDagPlan}。
|
||||
* - 自由连线画布本期不做,仅在数据模型上预留 {@link DagGraph}(§1.2)避免返工。
|
||||
*/
|
||||
|
||||
// ── 分层(Stage)表示:本期主模型(§1.1)──────────────────────────────────────
|
||||
|
||||
/** 一个并行批次:stage 内所有席位并行执行。 */
|
||||
export interface OrchestrationStage {
|
||||
/** stage 稳定 id(拖拽重排 / diff 用)。 */
|
||||
id: string;
|
||||
/** 本 stage 并行执行的席位(agent_id 列表)。1 个 = 退化为串行节点。 */
|
||||
agentIds: string[];
|
||||
}
|
||||
|
||||
/** 用户编排出的执行计划。stages 顺序即串行顺序。 */
|
||||
export interface OrchestrationPlan {
|
||||
/** 编排模式标识,持久化进 step2。 */
|
||||
mode: "dag";
|
||||
/** 串行执行的批次;每批内并行。 */
|
||||
stages: OrchestrationStage[];
|
||||
/** 收口:是否在所有 stage 完成后由总控综合(默认 true)。 */
|
||||
finalSynthesis: boolean;
|
||||
}
|
||||
|
||||
// ── 通用 DAG 表示:自由连线时才需要(§1.2,本期仅预留)───────────────────────
|
||||
|
||||
/** from 完成后才能跑 to。 */
|
||||
export interface DagEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** 自由连线画布的图表示。执行前需 toposort → 分层落回 {@link OrchestrationStage}。 */
|
||||
export interface DagGraph {
|
||||
nodes: string[];
|
||||
edges: DagEdge[];
|
||||
}
|
||||
|
||||
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 单 stage 并行度软上限:超过给 warning(远程模型并发限流 + 沙箱压力,§1.4 / §9)。
|
||||
* 仅提示,不阻断保存。
|
||||
*/
|
||||
export const PARALLEL_DEGREE_WARN_THRESHOLD = 6;
|
||||
|
||||
// ── 校验(纯函数,可单测,§1.4)──────────────────────────────────────────────
|
||||
|
||||
/** {@link validatePlan} 的返回。`errors` 阻断保存,`warnings` 仅提示。 */
|
||||
export interface PlanValidation {
|
||||
ok: boolean;
|
||||
/** 面向用户的中文错误(非空 = 无法保存)。 */
|
||||
errors: string[];
|
||||
/** 面向用户的中文提示(不阻断保存)。 */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验一份编排计划是否可执行 / 可保存。
|
||||
*
|
||||
* 校验项(§1.4):
|
||||
* - 至少 1 个 stage。
|
||||
* - 不允许空 stage。
|
||||
* - 每个席位必须在本次选定席位 `selectedAgentIds` 内(防编排不存在的席位)。
|
||||
* - 同一席位不允许出现在多个 stage(一个席位只跑一次);同一 stage 内也不允许重复。
|
||||
* - (warning)单 stage 并行度 > {@link PARALLEL_DEGREE_WARN_THRESHOLD}。
|
||||
*
|
||||
* @param plan 待校验的计划。
|
||||
* @param selectedAgentIds 本次选定的席位 agent_id 全集(roster)。
|
||||
*/
|
||||
export function validatePlan(
|
||||
plan: OrchestrationPlan,
|
||||
selectedAgentIds: string[],
|
||||
): PlanValidation {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const roster = new Set(selectedAgentIds);
|
||||
|
||||
if (!plan.stages || plan.stages.length === 0) {
|
||||
errors.push("编排为空:请至少添加一个阶段(Stage)。");
|
||||
return { ok: false, errors, warnings };
|
||||
}
|
||||
|
||||
// 记录每个席位首次出现的 stage 序号,用来检测跨 stage 重复。
|
||||
const seenInStage = new Map<string, number>();
|
||||
|
||||
plan.stages.forEach((stage, idx) => {
|
||||
const human = idx + 1;
|
||||
|
||||
if (!stage.agentIds || stage.agentIds.length === 0) {
|
||||
errors.push(`第 ${human} 阶段为空:请至少放入一个席位,或删除该阶段。`);
|
||||
return;
|
||||
}
|
||||
|
||||
const seenInThisStage = new Set<string>();
|
||||
for (const agentId of stage.agentIds) {
|
||||
if (!roster.has(agentId)) {
|
||||
errors.push(`第 ${human} 阶段包含未选定的席位「${agentId}」,请先把它加入参与角色。`);
|
||||
continue;
|
||||
}
|
||||
if (seenInThisStage.has(agentId)) {
|
||||
errors.push(`第 ${human} 阶段内席位「${agentId}」重复,一个席位每阶段只能出现一次。`);
|
||||
continue;
|
||||
}
|
||||
seenInThisStage.add(agentId);
|
||||
|
||||
const firstStage = seenInStage.get(agentId);
|
||||
if (firstStage !== undefined) {
|
||||
errors.push(
|
||||
`席位「${agentId}」同时出现在第 ${firstStage + 1} 与第 ${human} 阶段,一个席位只能跑一次。`,
|
||||
);
|
||||
} else {
|
||||
seenInStage.set(agentId, idx);
|
||||
}
|
||||
}
|
||||
|
||||
if (stage.agentIds.length > PARALLEL_DEGREE_WARN_THRESHOLD) {
|
||||
warnings.push(
|
||||
`第 ${human} 阶段并行 ${stage.agentIds.length} 个席位,可能触发远程模型并发限流或沙箱压力,建议拆分。`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: errors.length === 0, errors, warnings };
|
||||
}
|
||||
|
||||
// ── 链条 → DAG 兼容转换(§1.3)───────────────────────────────────────────────
|
||||
|
||||
/** 生成稳定的 stage id。不依赖随机数,便于单测与 diff(用序号即可,stages 不重排前唯一)。 */
|
||||
function stageId(index: number): string {
|
||||
return `stage-${index}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把线性链条(有序席位数组)转换成「每层 1 个节点」的 DAG 计划。
|
||||
*
|
||||
* 老 `chain` 模式的数据只是有序的 `selectedAgents`(线性),读取时即时转换为
|
||||
* 单节点-stage 的 dag,不破坏老 draft(§1.3 兼容层)。
|
||||
*
|
||||
* @param orderedAgentIds 链条顺序的席位 agent_id。
|
||||
* @param finalSynthesis 是否收口综合,默认 true。
|
||||
*/
|
||||
export function chainToDagPlan(
|
||||
orderedAgentIds: string[],
|
||||
finalSynthesis = true,
|
||||
): OrchestrationPlan {
|
||||
return {
|
||||
mode: "dag",
|
||||
stages: orderedAgentIds.map((agentId, idx) => ({
|
||||
id: stageId(idx),
|
||||
agentIds: [agentId],
|
||||
})),
|
||||
finalSynthesis,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一个空计划(编辑器初始态 / 兜底)。
|
||||
*/
|
||||
export function emptyPlan(finalSynthesis = true): OrchestrationPlan {
|
||||
return { mode: "dag", stages: [], finalSynthesis };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把业务链条的 `stages`(agent_id 分组的 string[][])转成执行计划。
|
||||
*
|
||||
* - `allowedIds` 提供时,过滤掉不在其中的 agent_id(如链条里有席位被删除),
|
||||
* 并丢弃过滤后变空的 stage —— 保证选入 Step 2 的计划只含存活席位。
|
||||
* - 全部为空时返回空 stages(调用方据此判断退回 chain / recommend)。
|
||||
*
|
||||
* @param stages 链条分层(每组并行、组间串行)。
|
||||
* @param allowedIds 仅保留这些 agent_id(存活席位);省略则全保留。
|
||||
* @param finalSynthesis 是否收口综合,默认 true。
|
||||
*/
|
||||
export function buildPlanFromStages(
|
||||
stages: string[][],
|
||||
allowedIds?: Iterable<string>,
|
||||
finalSynthesis = true,
|
||||
): 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 });
|
||||
});
|
||||
return { mode: "dag", stages: cleaned, finalSynthesis };
|
||||
}
|
||||
|
||||
/** 该 stage 是否真并行(≥2 席位)—— Phase 3 决定是否禁写文件 / Phase 5 决定显示。 */
|
||||
export function isParallelStage(stage: OrchestrationStage): boolean {
|
||||
return stage.agentIds.length > 1;
|
||||
}
|
||||
|
||||
// ── 派活提示构造(纯函数,Phase 3 调度用,可单测,§3.1 依赖摘要注入)──────────────
|
||||
|
||||
/** 已完成席位的一条交付(喂给后续 stage 当依赖上下文)。 */
|
||||
export interface StageDelivery {
|
||||
/** 席位显示名(中文优先)。 */
|
||||
seatName: string;
|
||||
/** 该席位本轮交付正文。 */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把前面所有 stage 已交付席位的正文拼成「依赖摘要」,注入下一 stage 的派活提示。
|
||||
* 每条按 `maxCharsPerSeat` 截断,避免后续席位上下文过长(§3.1:stage 边界拉一次摘要)。
|
||||
* 无交付(首 stage)返回空串。
|
||||
*/
|
||||
export function buildDependencySummary(
|
||||
deliveries: StageDelivery[],
|
||||
maxCharsPerSeat = 1200,
|
||||
): string {
|
||||
if (deliveries.length === 0) return "";
|
||||
return deliveries
|
||||
.map((d) => {
|
||||
const body = (d.content ?? "").trim();
|
||||
const clipped =
|
||||
body.length > maxCharsPerSeat ? `${body.slice(0, maxCharsPerSeat)}…(已截断)` : body;
|
||||
return `【${d.seatName}】的交付:\n${clipped || "(无文本输出)"}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 为某 stage 内一个席位构造 special 派活提示(DAG 直派,无 leader,§3.2 方案 B)。
|
||||
*
|
||||
* - 首 stage(`priorSummary` 为空):带任务背景。
|
||||
* - 后续 stage:带前序交付摘要让其承接(§3.1 依赖可见性)。
|
||||
* - 并行 stage(`isParallel`):明确要求**产出写在正文、禁止写文件 / 沙箱命令**
|
||||
* (§4.3 / §9:并行禁写文件,降低模型困惑,与后端 `seat_parallel` policy 呼应)。
|
||||
*/
|
||||
export function buildStageTask(args: {
|
||||
seedMessage: string;
|
||||
seatName: string;
|
||||
/**
|
||||
* 上一阶段的上下文:
|
||||
* - 默认(`priorIsBridge` 假):{@link buildDependencySummary} 拼出的机械交付摘要;
|
||||
* - `priorIsBridge` 真:总控「承上启下」中转轮({@link buildDagTransitTask})的综合说明。
|
||||
* 首 stage 传空串。
|
||||
*/
|
||||
priorSummary: string;
|
||||
isParallel: boolean;
|
||||
/**
|
||||
* `priorSummary` 是否为总控中转轮(§12.3)产出的「承上启下」说明(而非机械交付摘要)。
|
||||
* 真时改用对应措辞,让席位知道这是总控综合后的指引。缺省 false(机械摘要)。
|
||||
*/
|
||||
priorIsBridge?: boolean;
|
||||
}): string {
|
||||
const { seedMessage, seatName, priorSummary, isParallel, priorIsBridge } = args;
|
||||
const lines: string[] = [];
|
||||
if (!priorSummary) {
|
||||
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
||||
} else if (priorIsBridge) {
|
||||
lines.push("分层研讨继续。以下是总控对此前各阶段的【承上启下】综合说明,请据此承接:", "", priorSummary, "");
|
||||
} else {
|
||||
lines.push("分层研讨继续。以下是此前各阶段已完成席位的交付摘要,供你承接:", "", priorSummary, "");
|
||||
}
|
||||
lines.push(`请你作为「${seatName}」,从你的专业职责出发,完成本阶段的分析与交付。`);
|
||||
if (isParallel) {
|
||||
lines.push(
|
||||
"⚠️ 本阶段为并行研讨:请把你的产出**直接写在回复正文里**,不要写文件、不要执行沙箱命令——并行期间禁止文件写入,以免与同批其他席位互相打架。需要落文件的产物会留到后续串行 / 收口阶段单独处理。",
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 总控「中转轮」派活提示(§12.3 Stage 间经总控中转 + 广播)。
|
||||
*
|
||||
* DAG 各 stage 之间插入一轮 leader:综合上一批并行席位的交付,产出一段「承上启下」
|
||||
* 说明,再作为下一 stage 席位 task 的上下文(见 {@link buildStageTask} 的
|
||||
* `priorIsBridge`)。下一批席位由编排计划**钉死**(已在 `nextSeatNames` 列出),
|
||||
* 故提示明确要求总控**不要自己派活、不要收口**——只做综合(前端照计划直派下一批)。
|
||||
*
|
||||
* 「传摘要按需传全文」:各席位真实交付由后端 `_collect_delivery_status`(轻量
|
||||
* `GET /last-ai-message?max_chars=N` 接口)实读后注入总控输入——中转轮默认只注入
|
||||
* 每席位 ~600 字**摘要**,完整交付正文留到最终收口的 synthesis 轮才按需读取。
|
||||
* 本提示因此不重复塞交付正文,保持总控上下文精简、响应快。
|
||||
*/
|
||||
export function buildDagTransitTask(args: {
|
||||
/** 刚完成的本批席位显示名(仅用于提示文案,真实交付由后端核对注入)。 */
|
||||
completedSeatNames: string[];
|
||||
/** 下一阶段由计划钉死、即将执行的席位显示名。 */
|
||||
nextSeatNames: string[];
|
||||
}): string {
|
||||
const { completedSeatNames, nextSeatNames } = args;
|
||||
const done = completedSeatNames.join("、") || "本批席位";
|
||||
const next = nextSeatNames.join("、") || "下一阶段席位";
|
||||
return [
|
||||
"【分层 DAG · 总控中转轮】本阶段并行席位已全部完成交付。",
|
||||
`已完成席位:${done}。系统已把各席位的真实交付(摘要)客观核对后注入到你本轮输入里,以此为准。`,
|
||||
"请你作为总控,综合这一批的交付,产出一段【承上启下】说明:提炼关键结论与未决问题,并给出对下一阶段的指引。",
|
||||
`下一阶段将由编排计划**钉死**执行的席位是:${next}。这些席位由系统按计划直接派活,` +
|
||||
"**你不需要、也不要**通过 agent_orchestration 自行派活,更不要现在收口或输出最终方案。",
|
||||
"只输出这段承上启下的综合说明即可——它会作为下一阶段席位承接前序结论的上下文。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
// ── 总控驱动的「每 stage 派活轮」(§3.2 方案 A + 批内并行,2026-06-07 改)─────────
|
||||
//
|
||||
// 把 DAG 执行从「前端直派」改成「总控驱动」:每个 stage 由总控**真正派活**给该 stage
|
||||
// 计划钉死的席位(约束总控只派给这些席位、且每个都派到),席位并行执行、完成后交付经
|
||||
// 后端广播 + `_collect_delivery_status` 回到总控,总控下一 stage 继续派活。与 chain 模式
|
||||
// 同构(`buildChainLeaderTask` 单席位),区别是一次钉死**一批**席位、批内并行。
|
||||
|
||||
/**
|
||||
* 总控「每 stage 派活轮」提示:约束总控只对本 stage 钉死的若干席位派活(§3.2 方案 A)。
|
||||
*
|
||||
* - 首 stage(`isFirst`):带任务背景;后续 stage:靠后端注入的此前交付摘要承上。
|
||||
* - `seats` 是本 stage 计划钉死的席位(≥2 即并行批次),要求总控对**每个**席位都
|
||||
* 通过 agent_orchestration 派一个聚焦其职责的 task,不要派给清单外席位、不要自答 / 收口。
|
||||
*
|
||||
* 「传摘要按需传全文」:此处不塞各席位交付正文——后端 `_build_seat_status_block` 已把
|
||||
* 此前各席位的真实交付(摘要)注入总控本轮输入;完整正文留到末尾收口的 synthesis 轮。
|
||||
*/
|
||||
export function buildDagStageLeaderTask(args: {
|
||||
seedMessage: string;
|
||||
/** 0-based stage 序号。 */
|
||||
stageIndex: number;
|
||||
totalStages: number;
|
||||
/** 本 stage 计划钉死的席位。 */
|
||||
seats: Array<{ agentId: string; name: string }>;
|
||||
isFirst: boolean;
|
||||
}): string {
|
||||
const { seedMessage, stageIndex, totalStages, seats, isFirst } = args;
|
||||
const ordinal = `${stageIndex + 1}/${totalStages}`;
|
||||
const seatList = seats
|
||||
.map((s) => `「${s.name}」(agent_name: ${s.agentId})`)
|
||||
.join("、");
|
||||
const isParallel = seats.length > 1;
|
||||
const lines: string[] = [];
|
||||
if (isFirst) {
|
||||
lines.push("这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seedMessage, "");
|
||||
} else {
|
||||
lines.push(
|
||||
"分层研讨继续。系统已把此前各阶段席位的真实交付(摘要)客观核对后注入到你本轮输入里,请据此承上启下。",
|
||||
"",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`本研讨严格按预设分层顺序进行,共 ${totalStages} 个阶段,现在是第 ${ordinal} 个阶段。`,
|
||||
`本阶段需要执行的席位(已由编排计划钉死)是:${seatList}。`,
|
||||
`请你作为总控,通过 agent_orchestration 技能**分别**向上述${isParallel ? `这 ${seats.length} 个席位` : "席位"}派活,结合任务背景与此前交付,为每个席位分配聚焦其职责的本阶段任务。`,
|
||||
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
|
||||
"⚠️ 必须**真正调用 agent_orchestration 工具**来派活(每个席位对应一次工具调用);**不要**只用文字罗列「席位1:…席位2:…」这样的任务清单——只写文字而不调用工具,系统无法把任务下发给席位。",
|
||||
);
|
||||
if (isParallel) {
|
||||
lines.push(
|
||||
`注意:本阶段这 ${seats.length} 个席位会**并行**执行,请确保给每个席位的 task 都能独立完成,不要让它们互相等待。`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从总控「每 stage 派活轮」的终态里,取出派给**某个**钉死席位的 task 文本:
|
||||
* 1) 命中 agent_name === seatAgentId 的结构化派活(总控真调了 agent_orchestration 工具)
|
||||
* → 用它的 task(正常路径);
|
||||
* 2) 总控**没用工具、只写了文字**(弱模型常见,dispatched 为空 / 没命中该席位)→ **绝不**把
|
||||
* 总控的整段派活叙述喂给席位 —— 否则席位会把「我来调度…席位1/2/3」当成自己的任务照抄、
|
||||
* 模仿派活,甚至绕道 bash/python 调内部接口去派活(已在生产观测到)。改给一个**干净的本职
|
||||
* 任务**:任务背景与此前各阶段交付已由后端广播进席位线程上下文,席位据此承接即可。
|
||||
*
|
||||
* 所有分支都追加「角色边界护栏」,明确席位**不是总控、不许派活**(席位本就被 run policy 禁了
|
||||
* agent_orchestration,但弱模型会空转叙述 / 绕道,故再从提示层钉死)。
|
||||
*/
|
||||
export function pickDagSeatTask(
|
||||
dispatched: ReadonlyArray<readonly [string, string]>,
|
||||
seatAgentId: string,
|
||||
seatName: string,
|
||||
seedMessage = "",
|
||||
/** 是否已有前序席位交付(决定是否提示 read_peer_delivery 工具按需取全文,§12.9)。 */
|
||||
hasPeers = false,
|
||||
/**
|
||||
* 是否允许写文件(§12.10):
|
||||
* - `true`(**沙箱模式**):要求把交付**写成 .md 文件** + 正文摘要,禁 .py;
|
||||
* - `false`(**快速模式**,默认):产物**直接写在正文**,不写文件(fast 模式产物走正文)。
|
||||
*/
|
||||
allowFiles = false,
|
||||
): string {
|
||||
const guard =
|
||||
`\n\n【角色边界·必读】你是**执行席位**「${seatName}」,不是总控协调者,本轮**只**对你自己的交付负责。**严禁**任何形式的派活 / 调度:不要调用 agent_orchestration 或 skill_list,不要用 python / 脚本去访问内部接口(如 /api/agents、agent_orchestration)来调度,不要罗列或给其他席位分配任务清单、不要复述「我来调度各智能体」这类总控口吻。`;
|
||||
// 产物要求(§12.8 / §12.10):沙箱模式产出 .md 文件(禁 .py);快速模式产物走正文、不写文件。
|
||||
const fileRule = allowFiles
|
||||
? `\n\n【产物要求】请把你的**完整交付写成一个 Markdown(.md)文件**(用 write_file 写到 outputs 目录,文件名体现你的职责),并在回复正文里给出要点摘要。**不要写 .py 或任何可执行脚本文件**(不要写脚本去生成产物);直接产出成品文档,.md / .html / .txt 等其它格式都可以,唯独不要 .py。`
|
||||
: `\n\n【产物要求】本轮为**快速并行**模式:请把你的产物**直接写在回复正文里**,**不要写文件、不要执行脚本/命令**(需要落文件的产物留给沙箱模式或后续阶段)。`;
|
||||
// 任务背景:DAG 派活轮抑制了 leader 预广播(防席位模仿派活),所以席位 thread 里可能没有
|
||||
// 用户原始消息 —— 把任务背景直接写进席位 task,确保它有上下文(此前各阶段交付仍由后端
|
||||
// 席位间 detached 广播注入,故只补背景)。
|
||||
const bg = seedMessage.trim() ? `本次研讨的任务背景:\n${seedMessage.trim()}\n\n` : "";
|
||||
// §12.9:此前各阶段席位的交付,你默认只看到**摘要**(广播进来的);若某个前序席位的摘要不够、
|
||||
// 你需要它的**完整交付**,调用 read_peer_delivery('<席位名>') 按需取全文(平时不必调,优先用摘要)。
|
||||
const peerRule = hasPeers
|
||||
? `\n\n【按需取全文】此前各阶段席位的交付你默认只看到**摘要**;若需要某个前序席位的**完整交付**,调用工具 read_peer_delivery('<该席位名称>') 取回全文(摘要够用就不必调)。`
|
||||
: "";
|
||||
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}`;
|
||||
}
|
||||
// dispatched 为空 / 未命中 → 干净本职任务,**不**注入总控派活叙述(根治「席位模仿派活」)。
|
||||
return (
|
||||
`${bg}请你作为「${seatName}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。` +
|
||||
`此前各阶段席位的交付已在你的会话上下文里,请据此承接。` +
|
||||
fileRule +
|
||||
peerRule +
|
||||
guard
|
||||
);
|
||||
}
|
||||
@@ -5,21 +5,18 @@
|
||||
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
|
||||
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
|
||||
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
|
||||
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、上/下移、
|
||||
* 删除单项;左栏拖入时按落点插入到对应位置。
|
||||
* - 右栏:**分层编排** —— 把席位分到若干 **Stage**(竖向分组):同一 Stage 内并行、
|
||||
* Stage 之间串行。可拖席位跨 Stage、整段 Stage 上下移、新建 / 删除 Stage;也提供
|
||||
* 按钮(并入上/下一阶段、独立成阶段、移除)做无拖拽兜底。全单席位 Stage = 线性链。
|
||||
*
|
||||
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
|
||||
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
|
||||
* 拖拽用 `@dnd-kit`:每个 Stage 是一个 droppable,Stage 内席位 `SortableContext`;
|
||||
* 左栏卡片 `useDraggable`(pool→某 Stage / 新建 Stage 区插入)。落点计算在 `onDragEnd`。
|
||||
*
|
||||
* 视觉:对齐设计稿 `yewuliantiao`(浅红 / 绛红风格)。统一走 app 语义 token
|
||||
* (`primary` 在浅色主题即设计稿的绛红 #c25555,深色主题自动切蓝),配规范小圆角
|
||||
* (输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页的
|
||||
* `roundtable-planning` scope。
|
||||
* 持久化走 `api/chains.ts`:`seats` = 各 Stage 展平的有序席位;`stages` = 分层(全单
|
||||
* 席位时存 null,即线性,老行为不变)。席位数约束 2 ≤ N ≤ 8、agent 去重。分层操作的
|
||||
* 纯逻辑在 `lib/chain-stages.ts`(可单测)。
|
||||
*
|
||||
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
|
||||
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
|
||||
*
|
||||
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
|
||||
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
@@ -30,11 +27,13 @@ import {
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
GitBranch,
|
||||
GripVertical,
|
||||
ListOrdered,
|
||||
Layers,
|
||||
Loader2,
|
||||
Plus,
|
||||
Save,
|
||||
SplitSquareVertical,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
X,
|
||||
@@ -53,7 +52,6 @@ import {
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
@@ -81,11 +79,30 @@ import {
|
||||
type BusinessChain,
|
||||
type ChainSeat,
|
||||
} from "@/roundtable-planning/lib/business-chain";
|
||||
import {
|
||||
addEmptyStage,
|
||||
addSeatAsNewStage,
|
||||
addSeatToStage,
|
||||
chainToStageGroups,
|
||||
countSeats,
|
||||
deleteStage as deleteStageGroup,
|
||||
findStageIndexOfSeat,
|
||||
moveSeatToStage,
|
||||
moveStage,
|
||||
removeSeat as removeSeatFromGroups,
|
||||
seatExistsInGroups,
|
||||
splitSeatToNewStage,
|
||||
stageGroupsToSeats,
|
||||
stageGroupsToStagePayload,
|
||||
type StageGroups,
|
||||
} from "@/roundtable-planning/lib/chain-stages";
|
||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||
|
||||
const POOL_PREFIX = "pool::";
|
||||
const SEAT_PREFIX = "seat::";
|
||||
const SEATS_DROPPABLE_ID = "seats-droppable";
|
||||
const STAGE_PREFIX = "stage::";
|
||||
const NEWSTAGE_DROPPABLE_ID = "newstage-droppable";
|
||||
|
||||
// 规范圆角(§4):输入/按钮/tag 2px,卡片/分栏 4px。app 默认 --radius=12px 偏大,
|
||||
// 这里用 arbitrary 值精确命中规范。
|
||||
@@ -115,7 +132,8 @@ export default function BusinessChainEditorPage() {
|
||||
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [seats, setSeats] = useState<ChainSeat[]>([]);
|
||||
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
|
||||
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||
@@ -130,7 +148,34 @@ export default function BusinessChainEditorPage() {
|
||||
for (const a of pool) m.set(a.agent_id, a);
|
||||
return m;
|
||||
}, [pool]);
|
||||
const seatIdSet = useMemo(() => new Set(seats.map((s) => s.agent_id)), [seats]);
|
||||
const seatIdSet = useMemo(
|
||||
() => new Set(stageGroups.flat().map((s) => s.agent_id)),
|
||||
[stageGroups],
|
||||
);
|
||||
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
|
||||
|
||||
// ─── Phase 7 只读流程图预览(§2.2 底部 / §7)──────────────────────────────
|
||||
// 把当前分层(去掉空 stage)映射成 DagFlowChart 的 stages,并提供席位名/配色解析。
|
||||
const previewStages = useMemo(
|
||||
() =>
|
||||
stageGroups
|
||||
.filter((g) => g.length > 0)
|
||||
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.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);
|
||||
return m;
|
||||
}, [stageGroups]);
|
||||
const resolvePreviewSeat = useCallback(
|
||||
(agentId: string, order: number) => ({
|
||||
name: seatNameById.get(agentId.toLowerCase()) ?? agentId,
|
||||
avatarType: getAvatarTypeFor(agentId, order),
|
||||
}),
|
||||
[seatNameById],
|
||||
);
|
||||
|
||||
// ─── 链条列表(一次性)─────────────────────────────────────────────────
|
||||
const refreshChains = useCallback(async () => {
|
||||
@@ -175,14 +220,14 @@ export default function BusinessChainEditorPage() {
|
||||
setCurrentChainId(chain.id);
|
||||
setTitle(chain.title);
|
||||
setDescription(chain.description ?? "");
|
||||
setSeats(chain.seats.map((s) => ({ ...s })));
|
||||
setStageGroups(chainToStageGroups(chain));
|
||||
}, []);
|
||||
|
||||
const startNewChain = useCallback(() => {
|
||||
setCurrentChainId(null);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setSeats([]);
|
||||
setStageGroups([]);
|
||||
}, []);
|
||||
|
||||
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
||||
@@ -198,43 +243,65 @@ export default function BusinessChainEditorPage() {
|
||||
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试
|
||||
}, [chains, searchParams, loadChain]);
|
||||
|
||||
// ─── 席位增删改 ───────────────────────────────────────────────────────
|
||||
const addAgentToSeats = useCallback(
|
||||
(agentId: string, atIndex?: number) => {
|
||||
// ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)────────────────────
|
||||
const toChainSeat = useCallback(
|
||||
(agentId: string): ChainSeat | null => {
|
||||
const agent = poolById.get(agentId);
|
||||
if (!agent) return;
|
||||
setSeats((prev) => {
|
||||
if (prev.some((s) => s.agent_id === agentId)) {
|
||||
toast.info(`「${agent.name}」已在链条中`);
|
||||
return prev;
|
||||
}
|
||||
if (prev.length >= CHAIN_MAX_SEATS) {
|
||||
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
|
||||
return prev;
|
||||
}
|
||||
const seat: ChainSeat = {
|
||||
agent_id: agent.agent_id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
};
|
||||
const next = [...prev];
|
||||
const i = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
|
||||
next.splice(i, 0, seat);
|
||||
return next;
|
||||
});
|
||||
if (!agent) return null;
|
||||
return { agent_id: agent.agent_id, name: agent.name, description: agent.description };
|
||||
},
|
||||
[poolById],
|
||||
);
|
||||
|
||||
/** pool 点击加入:末尾追加一个新单席位 Stage(线性追加,默认行为)。 */
|
||||
const addAgentAsNewStage = useCallback(
|
||||
(agentId: string) => {
|
||||
const seat = toChainSeat(agentId);
|
||||
if (!seat) return;
|
||||
setStageGroups((prev) => {
|
||||
if (seatExistsInGroups(prev, agentId)) {
|
||||
toast.info(`「${seat.name}」已在链条中`);
|
||||
return prev;
|
||||
}
|
||||
if (countSeats(prev) >= CHAIN_MAX_SEATS) {
|
||||
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
|
||||
return prev;
|
||||
}
|
||||
return addSeatAsNewStage(prev, seat);
|
||||
});
|
||||
},
|
||||
[toChainSeat],
|
||||
);
|
||||
|
||||
const removeSeat = useCallback((agentId: string) => {
|
||||
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
|
||||
setStageGroups((prev) => removeSeatFromGroups(prev, agentId));
|
||||
}, []);
|
||||
|
||||
const moveSeat = useCallback((index: number, dir: -1 | 1) => {
|
||||
setSeats((prev) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= prev.length) return prev;
|
||||
return arrayMove(prev, index, target);
|
||||
const mergeSeatToStage = useCallback((agentId: string, targetStageIdx: number) => {
|
||||
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
|
||||
}, []);
|
||||
|
||||
const splitSeat = useCallback((agentId: string) => {
|
||||
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
|
||||
}, []);
|
||||
|
||||
const reorderStage = useCallback((stageIdx: number, dir: -1 | 1) => {
|
||||
setStageGroups((prev) => moveStage(prev, stageIdx, dir));
|
||||
}, []);
|
||||
|
||||
const appendEmptyStage = useCallback(() => {
|
||||
setStageGroups((prev) => addEmptyStage(prev));
|
||||
}, []);
|
||||
|
||||
const removeStage = useCallback((stageIdx: number) => {
|
||||
setStageGroups((prev) => {
|
||||
const stage = prev[stageIdx];
|
||||
if (stage && stage.length > 0) {
|
||||
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.length} 个席位,确认?`)) {
|
||||
return prev;
|
||||
}
|
||||
}
|
||||
return deleteStageGroup(prev, stageIdx);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -248,11 +315,11 @@ export default function BusinessChainEditorPage() {
|
||||
setActiveDrag({ kind: "pool", agentId, name: a?.name ?? agentId });
|
||||
} else if (id.startsWith(SEAT_PREFIX)) {
|
||||
const agentId = id.slice(SEAT_PREFIX.length);
|
||||
const s = seats.find((x) => x.agent_id === agentId);
|
||||
setActiveDrag({ kind: "seat", agentId, name: s?.name ?? agentId });
|
||||
const a = poolById.get(agentId);
|
||||
setActiveDrag({ kind: "seat", agentId, name: a?.name ?? agentId });
|
||||
}
|
||||
},
|
||||
[poolById, seats],
|
||||
[poolById],
|
||||
);
|
||||
|
||||
const onDragEnd = useCallback(
|
||||
@@ -262,33 +329,57 @@ export default function BusinessChainEditorPage() {
|
||||
const overId = e.over ? String(e.over.id) : null;
|
||||
if (!overId) return;
|
||||
|
||||
const overSeatIndex = overId.startsWith(SEAT_PREFIX)
|
||||
? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId)
|
||||
: -1;
|
||||
// 解析落点 → 目标 Stage 下标 + 该 Stage 内插入位置;-1 表示「新建 Stage 区」。
|
||||
let targetStage = -1;
|
||||
let atIndex: number | undefined;
|
||||
if (overId === NEWSTAGE_DROPPABLE_ID) {
|
||||
targetStage = -1;
|
||||
} else if (overId.startsWith(STAGE_PREFIX)) {
|
||||
targetStage = Number(overId.slice(STAGE_PREFIX.length));
|
||||
} 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);
|
||||
}
|
||||
|
||||
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
|
||||
|
||||
if (activeId.startsWith(POOL_PREFIX)) {
|
||||
const agentId = activeId.slice(POOL_PREFIX.length);
|
||||
const insertAt = overSeatIndex >= 0 ? overSeatIndex : seats.length;
|
||||
addAgentToSeats(agentId, insertAt);
|
||||
if (seatIdSet.has(agentId)) return;
|
||||
const seat = toChainSeat(agentId);
|
||||
if (!seat) return;
|
||||
if (seatCount >= CHAIN_MAX_SEATS) {
|
||||
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
|
||||
return;
|
||||
}
|
||||
setStageGroups((prev) =>
|
||||
toNewStage || targetStage < 0
|
||||
? addSeatAsNewStage(prev, seat)
|
||||
: addSeatToStage(prev, seat, targetStage, atIndex),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeId.startsWith(SEAT_PREFIX)) {
|
||||
const fromIndex = seats.findIndex(
|
||||
(s) => SEAT_PREFIX + s.agent_id === activeId,
|
||||
);
|
||||
if (fromIndex < 0) return;
|
||||
const toIndex = overSeatIndex >= 0 ? overSeatIndex : seats.length - 1;
|
||||
if (fromIndex === toIndex) return;
|
||||
setSeats((prev) => arrayMove(prev, fromIndex, toIndex));
|
||||
const agentId = activeId.slice(SEAT_PREFIX.length);
|
||||
setStageGroups((prev) => {
|
||||
if (toNewStage) {
|
||||
// 拖到「新建 Stage」区 = 抽出独立成最后一个 Stage。
|
||||
const withEmpty = addEmptyStage(prev);
|
||||
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
|
||||
}
|
||||
if (targetStage < 0) return prev;
|
||||
return moveSeatToStage(prev, agentId, targetStage, atIndex);
|
||||
});
|
||||
}
|
||||
},
|
||||
[seats, addAgentToSeats],
|
||||
[stageGroups, seatIdSet, seatCount, toChainSeat],
|
||||
);
|
||||
|
||||
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
|
||||
const seatCountInvalid =
|
||||
seats.length < CHAIN_MIN_SEATS || seats.length > CHAIN_MAX_SEATS;
|
||||
const seatCountInvalid = seatCount < CHAIN_MIN_SEATS || seatCount > CHAIN_MAX_SEATS;
|
||||
const hasEmptyStage = stageGroups.some((g) => g.length === 0);
|
||||
const canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
@@ -302,16 +393,20 @@ export default function BusinessChainEditorPage() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payloadSeats = seats.map((s) => ({
|
||||
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。
|
||||
const flat = stageGroupsToSeats(stageGroups);
|
||||
const payloadSeats = flat.map((s) => ({
|
||||
agent_id: s.agent_id,
|
||||
name: poolById.get(s.agent_id)?.name ?? s.name,
|
||||
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
|
||||
}));
|
||||
const stages = stageGroupsToStagePayload(stageGroups);
|
||||
if (currentChainId) {
|
||||
const updated = await updateChain(currentChainId, {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
seats: payloadSeats,
|
||||
stages,
|
||||
});
|
||||
if (!updated) {
|
||||
toast.error("链条已不存在,可能被删除");
|
||||
@@ -327,6 +422,7 @@ export default function BusinessChainEditorPage() {
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
seats: payloadSeats,
|
||||
stages,
|
||||
});
|
||||
setCurrentChainId(created.id);
|
||||
toast.success("已创建业务链条");
|
||||
@@ -340,7 +436,7 @@ export default function BusinessChainEditorPage() {
|
||||
}, [
|
||||
title,
|
||||
description,
|
||||
seats,
|
||||
stageGroups,
|
||||
seatCountInvalid,
|
||||
currentChainId,
|
||||
poolById,
|
||||
@@ -367,7 +463,6 @@ export default function BusinessChainEditorPage() {
|
||||
}, [currentChainId, title, startNewChain, refreshChains]);
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────
|
||||
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
|
||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
||||
|
||||
return (
|
||||
@@ -399,7 +494,7 @@ export default function BusinessChainEditorPage() {
|
||||
<div className="leading-tight">
|
||||
<div className="text-base font-bold text-foreground">业务链条配置</div>
|
||||
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
|
||||
编排有序的智能体研讨链路
|
||||
分层编排:同层并行 · 层间串行
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -449,7 +544,7 @@ export default function BusinessChainEditorPage() {
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{c.title || "未命名链条"}({c.seats.length} 席)
|
||||
{c.title || "未命名链条"}({c.seats.length} 席{c.stages?.length ? " · 分层" : ""})
|
||||
</span>
|
||||
{c.id === currentChainId && (
|
||||
<span className={cn("ml-2 shrink-0 bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary", R_CTRL)}>
|
||||
@@ -620,20 +715,25 @@ export default function BusinessChainEditorPage() {
|
||||
key={a.agent_id}
|
||||
agent={a}
|
||||
inChain={seatIdSet.has(a.agent_id)}
|
||||
onAdd={() => addAgentToSeats(a.agent_id)}
|
||||
onAdd={() => addAgentAsNewStage(a.agent_id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 右栏:有序席位 */}
|
||||
<SeatsColumn
|
||||
seats={seats}
|
||||
seatItemIds={seatItemIds}
|
||||
{/* 右栏:分层 Stage */}
|
||||
<StagesColumn
|
||||
stageGroups={stageGroups}
|
||||
seatCount={seatCount}
|
||||
seatCountInvalid={seatCountInvalid}
|
||||
onRemove={removeSeat}
|
||||
onMove={moveSeat}
|
||||
hasEmptyStage={hasEmptyStage}
|
||||
onAddStage={appendEmptyStage}
|
||||
onRemoveStage={removeStage}
|
||||
onReorderStage={reorderStage}
|
||||
onRemoveSeat={removeSeat}
|
||||
onMergeSeat={mergeSeatToStage}
|
||||
onSplitSeat={splitSeat}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -652,9 +752,27 @@ export default function BusinessChainEditorPage() {
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。 */}
|
||||
{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">
|
||||
<span>编排预览</span>
|
||||
<span className="text-muted-foreground/50">
|
||||
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
|
||||
</span>
|
||||
</div>
|
||||
<DagFlowChart
|
||||
stages={previewStages}
|
||||
resolveSeat={resolvePreviewSeat}
|
||||
showReportMilestone={false}
|
||||
compact
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 页脚 */}
|
||||
<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 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
@@ -733,22 +851,42 @@ function PoolAgentCard({
|
||||
);
|
||||
}
|
||||
|
||||
// ── 右栏(droppable 容器 + sortable 列表)───────────────────────────────
|
||||
// ── 右栏(分层 Stage 容器)───────────────────────────────────────────────
|
||||
|
||||
function SeatsColumn({
|
||||
seats,
|
||||
seatItemIds,
|
||||
function StagesColumn({
|
||||
stageGroups,
|
||||
seatCount,
|
||||
seatCountInvalid,
|
||||
onRemove,
|
||||
onMove,
|
||||
hasEmptyStage,
|
||||
onAddStage,
|
||||
onRemoveStage,
|
||||
onReorderStage,
|
||||
onRemoveSeat,
|
||||
onMergeSeat,
|
||||
onSplitSeat,
|
||||
}: {
|
||||
seats: ChainSeat[];
|
||||
seatItemIds: string[];
|
||||
stageGroups: StageGroups;
|
||||
seatCount: number;
|
||||
seatCountInvalid: boolean;
|
||||
onRemove: (agentId: string) => void;
|
||||
onMove: (index: number, dir: -1 | 1) => void;
|
||||
hasEmptyStage: boolean;
|
||||
onAddStage: () => void;
|
||||
onRemoveStage: (stageIdx: number) => void;
|
||||
onReorderStage: (stageIdx: number, dir: -1 | 1) => void;
|
||||
onRemoveSeat: (agentId: string) => void;
|
||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID });
|
||||
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
|
||||
const stageStartIndex = useMemo(() => {
|
||||
const out: number[] = [];
|
||||
let acc = 0;
|
||||
for (const g of stageGroups) {
|
||||
out.push(acc);
|
||||
acc += g.length;
|
||||
}
|
||||
return out;
|
||||
}, [stageGroups]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -760,16 +898,26 @@ function SeatsColumn({
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
|
||||
<ListOrdered className="size-3.5 text-primary" />
|
||||
<Layers className="size-3.5 text-primary" />
|
||||
</span>
|
||||
<h2 className="text-sm font-bold tracking-wide text-foreground">链条席位</h2>
|
||||
<h2 className="text-sm font-bold tracking-wide text-foreground">链条编排</h2>
|
||||
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
|
||||
按顺序发言
|
||||
同层并行 · 层间串行
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex select-none items-center gap-1 text-xs">
|
||||
<span className="font-medium text-muted-foreground">配置席位</span>
|
||||
<div className="flex select-none items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddStage}
|
||||
className={cn(
|
||||
"flex items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-1 font-medium text-primary transition hover:bg-primary/10",
|
||||
R_CTRL,
|
||||
)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
新建 Stage
|
||||
</button>
|
||||
<span
|
||||
className={cn(
|
||||
"border px-2 py-0.5 font-bold tabular-nums",
|
||||
@@ -779,20 +927,14 @@ function SeatsColumn({
|
||||
: "border-primary/30 bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{seats.length} / {CHAIN_MAX_SEATS}
|
||||
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
|
||||
{seatCount} / {CHAIN_MAX_SEATS}
|
||||
{seatCount < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 space-y-3 overflow-y-auto p-5 transition-colors",
|
||||
isOver && "bg-primary/[0.03]",
|
||||
)}
|
||||
>
|
||||
{seats.length === 0 ? (
|
||||
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
|
||||
{seatCount === 0 && stageGroups.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"m-1 flex h-full flex-col items-center justify-center border border-dashed border-border bg-card/50 px-8 py-16 text-center",
|
||||
@@ -800,29 +942,166 @@ function SeatsColumn({
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ListOrdered className="size-6" />
|
||||
<Layers className="size-6" />
|
||||
</div>
|
||||
<h3 className="mb-1 text-sm font-bold text-foreground">暂无席位</h3>
|
||||
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
该业务链条当前为空。请从左侧「候选智能体」点击或拖拽,将其按顺序编排加入此处。
|
||||
从左侧「候选智能体」点击或拖拽加入。每次加入是一个串行 Stage;把席位拖到同一个
|
||||
Stage 里即可让它们并行。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{stageGroups.map((group, stageIdx) => (
|
||||
<StageSection
|
||||
key={stageIdx}
|
||||
stageIdx={stageIdx}
|
||||
group={group}
|
||||
stageCount={stageGroups.length}
|
||||
startIndex={stageStartIndex[stageIdx]}
|
||||
onRemoveStage={() => onRemoveStage(stageIdx)}
|
||||
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
|
||||
onRemoveSeat={onRemoveSeat}
|
||||
onMergeSeat={onMergeSeat}
|
||||
onSplitSeat={onSplitSeat}
|
||||
/>
|
||||
))}
|
||||
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单个 Stage(droppable 容器 + Stage 内 sortable 列表)──────────────────
|
||||
|
||||
function StageSection({
|
||||
stageIdx,
|
||||
group,
|
||||
stageCount,
|
||||
startIndex,
|
||||
onRemoveStage,
|
||||
onReorderStage,
|
||||
onRemoveSeat,
|
||||
onMergeSeat,
|
||||
onSplitSeat,
|
||||
}: {
|
||||
stageIdx: number;
|
||||
group: ChainSeat[];
|
||||
stageCount: number;
|
||||
startIndex: number;
|
||||
onRemoveStage: () => void;
|
||||
onReorderStage: (dir: -1 | 1) => void;
|
||||
onRemoveSeat: (agentId: string) => void;
|
||||
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
|
||||
onSplitSeat: (agentId: string) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
|
||||
const isParallel = group.length > 1;
|
||||
const isFirstStage = stageIdx === 0;
|
||||
const isLastStage = stageIdx === stageCount - 1;
|
||||
const seatItemIds = group.map((s) => SEAT_PREFIX + s.agent_id);
|
||||
|
||||
return (
|
||||
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
|
||||
{/* Stage 头 */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/40 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 border px-2 py-0.5 text-[11px] font-bold tabular-nums",
|
||||
R_CTRL,
|
||||
"border-primary/30 bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
<GitBranch className="size-3" />
|
||||
Stage {stageIdx + 1}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"border px-1.5 py-0.5 text-[10px] font-semibold",
|
||||
R_CTRL,
|
||||
isParallel
|
||||
? "border-amber-400/40 bg-amber-400/10 text-amber-600 dark:text-amber-300"
|
||||
: "border-border bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isParallel ? `并行 ${group.length}` : "串行"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReorderStage(-1)}
|
||||
disabled={isFirstStage}
|
||||
title="上移 Stage"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
isFirstStage
|
||||
? "pointer-events-none text-muted-foreground/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onReorderStage(1)}
|
||||
disabled={isLastStage}
|
||||
title="下移 Stage"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
isLastStage
|
||||
? "pointer-events-none text-muted-foreground/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemoveStage}
|
||||
title="删除 Stage"
|
||||
className={cn("ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary", R_CTRL)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stage 内席位 */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
|
||||
>
|
||||
{group.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}>
|
||||
{seats.map((seat, index) => (
|
||||
{group.map((seat, i) => (
|
||||
<SeatStrip
|
||||
key={seat.agent_id}
|
||||
seat={seat}
|
||||
index={index}
|
||||
total={seats.length}
|
||||
onRemove={() => onRemove(seat.agent_id)}
|
||||
onMove={(dir) => onMove(index, dir)}
|
||||
globalIndex={startIndex + i}
|
||||
stageIdx={stageIdx}
|
||||
isFirstStage={isFirstStage}
|
||||
isLastStage={isLastStage}
|
||||
canSplit={isParallel}
|
||||
onRemove={() => onRemoveSeat(seat.agent_id)}
|
||||
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
|
||||
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
|
||||
onSplit={() => onSplitSeat(seat.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -830,22 +1109,29 @@ function SeatsColumn({
|
||||
|
||||
function SeatStrip({
|
||||
seat,
|
||||
index,
|
||||
total,
|
||||
globalIndex,
|
||||
isFirstStage,
|
||||
isLastStage,
|
||||
canSplit,
|
||||
onRemove,
|
||||
onMove,
|
||||
onMergeUp,
|
||||
onMergeDown,
|
||||
onSplit,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
index: number;
|
||||
total: number;
|
||||
globalIndex: number;
|
||||
stageIdx: number;
|
||||
isFirstStage: boolean;
|
||||
isLastStage: boolean;
|
||||
canSplit: boolean;
|
||||
onRemove: () => void;
|
||||
onMove: (dir: -1 | 1) => void;
|
||||
onMergeUp: () => void;
|
||||
onMergeDown: () => void;
|
||||
onSplit: () => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: SEAT_PREFIX + seat.agent_id });
|
||||
const avatarType = getAvatarTypeFor(seat.agent_id, index);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === total - 1;
|
||||
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -863,7 +1149,7 @@ function SeatStrip({
|
||||
className="flex w-7 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title="拖动排序"
|
||||
title="拖动到其它 Stage / 排序"
|
||||
>
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
@@ -877,7 +1163,7 @@ function SeatStrip({
|
||||
chainTagClasses(avatarType),
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
{globalIndex + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -891,17 +1177,17 @@ function SeatStrip({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 控制:上移 / 下移 / 移除 */}
|
||||
{/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(-1)}
|
||||
disabled={isFirst}
|
||||
title="上移席位"
|
||||
onClick={onMergeUp}
|
||||
disabled={isFirstStage}
|
||||
title="并入上一阶段(与其并行)"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
isFirst
|
||||
isFirstStage
|
||||
? "pointer-events-none text-muted-foreground/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
@@ -910,13 +1196,28 @@ function SeatStrip({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMove(1)}
|
||||
disabled={isLast}
|
||||
title="下移席位"
|
||||
onClick={onSplit}
|
||||
disabled={!canSplit}
|
||||
title="从本阶段拆出,独立成新阶段"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
isLast
|
||||
!canSplit
|
||||
? "pointer-events-none text-muted-foreground/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<SplitSquareVertical className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMergeDown}
|
||||
disabled={isLastStage}
|
||||
title="并入下一阶段(与其并行)"
|
||||
className={cn(
|
||||
"p-1 transition",
|
||||
R_CTRL,
|
||||
isLastStage
|
||||
? "pointer-events-none text-muted-foreground/30"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
@@ -938,3 +1239,35 @@ function SeatStrip({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 「新建 Stage」落区(拖席位到此 = 独立成最后一个 Stage)──────────────────
|
||||
|
||||
function NewStageDropZone({
|
||||
hasEmptyStage,
|
||||
onAddStage,
|
||||
}: {
|
||||
hasEmptyStage: boolean;
|
||||
onAddStage: () => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={setNodeRef}
|
||||
onClick={onAddStage}
|
||||
disabled={hasEmptyStage}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center gap-1.5 border border-dashed px-3 py-3 text-xs font-medium transition",
|
||||
R_CARD,
|
||||
isOver
|
||||
? "border-primary bg-primary/[0.06] text-primary"
|
||||
: "border-border bg-card/40 text-muted-foreground hover:border-primary/40 hover:text-primary",
|
||||
hasEmptyStage && "cursor-not-allowed opacity-50 hover:border-border hover:text-muted-foreground",
|
||||
)}
|
||||
title={hasEmptyStage ? "已有空 Stage,先填充它" : "新建一个串在最后的 Stage"}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
新建 Stage(串在最后) · 或把席位拖到此处独立成阶段
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
|
||||
@@ -84,6 +85,7 @@ import {
|
||||
type SelectedAgent,
|
||||
} from "@/roundtable-planning/lib/roundtable-constants";
|
||||
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
|
||||
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
|
||||
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
|
||||
|
||||
export default function RoundtablePlanningPage() {
|
||||
@@ -104,13 +106,19 @@ export default function RoundtablePlanningPage() {
|
||||
// 「导入会商」弹窗(输入分享码把别人分享的会商复制到本账号)。
|
||||
const [isImportOpen, setIsImportOpen] = useState<boolean>(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
|
||||
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活;
|
||||
// dag = 分层 DAG(stage 内并行、stage 间串行,配合 orchestrationPlan)。
|
||||
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
|
||||
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">(
|
||||
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain" | "dag">(
|
||||
"recommend",
|
||||
);
|
||||
// chain 模式来源链条(仅展示/持久化用)。
|
||||
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
|
||||
// dag 模式的分层编排计划(Phase 2 编辑器产出;持久化进 step2.orchestrationPlan)。
|
||||
const [orchestrationPlan, setOrchestrationPlan] = useState<OrchestrationPlan | null>(null);
|
||||
// dag 执行子模式:fast(并行双列禁写)/ sandbox(并行单列可写、沙箱跟随)。
|
||||
// 由推荐弹窗的「执行方式」选择决定(仅 dag 有意义)。
|
||||
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
|
||||
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||
const [step2Runs, setStep2Runs] = useState<DraftStep2RunSnapshot[]>([]);
|
||||
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
|
||||
@@ -121,6 +129,9 @@ export default function RoundtablePlanningPage() {
|
||||
const [step3SavedTick, setStep3SavedTick] = useState<number>(0);
|
||||
const step2RunsRef = useRef<DraftStep2RunSnapshot[]>([]);
|
||||
const activeStep2RunIdRef = useRef<string | null>(null);
|
||||
// 并行执行时收起左侧栏 —— 全局工作区侧栏(最左)走 useSidebarSafe(与「打开沙箱」同一路径,
|
||||
// 见 artifacts/context.tsx 的 sidebar.setOpen(false)),圆桌「参与角色」栏走 step2SidebarExpanded。
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
||||
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
||||
@@ -194,6 +205,8 @@ export default function RoundtablePlanningPage() {
|
||||
currentStep,
|
||||
selectedModel,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagExecMode,
|
||||
buildInitialIntent,
|
||||
triggerToast,
|
||||
setRoles,
|
||||
@@ -243,10 +256,14 @@ export default function RoundtablePlanningPage() {
|
||||
seatStubMessages: step2.seatStubMessages,
|
||||
orchestrationMode,
|
||||
chain: chainMeta,
|
||||
orchestrationPlan,
|
||||
dagExecMode,
|
||||
}),
|
||||
[
|
||||
chainMeta,
|
||||
orchestrationMode,
|
||||
orchestrationPlan,
|
||||
dagExecMode,
|
||||
step2.budgetLimit,
|
||||
step2.consensusPercentage,
|
||||
step2.coordinatorName,
|
||||
@@ -279,6 +296,8 @@ export default function RoundtablePlanningPage() {
|
||||
seatStubMessages: snapshot.seatStubMessages ?? {},
|
||||
orchestrationMode: snapshot.orchestrationMode,
|
||||
chain: snapshot.chain ?? null,
|
||||
orchestrationPlan: snapshot.orchestrationPlan ?? null,
|
||||
dagExecMode: snapshot.dagExecMode,
|
||||
step3: step3Report,
|
||||
}),
|
||||
[step3Report],
|
||||
@@ -362,6 +381,8 @@ export default function RoundtablePlanningPage() {
|
||||
(run: DraftStep2RunSnapshot) => {
|
||||
setOrchestrationMode(run.orchestrationMode ?? "recommend");
|
||||
setChainMeta(run.chain ?? null);
|
||||
setOrchestrationPlan(run.orchestrationPlan ?? null);
|
||||
setDagExecMode(run.dagExecMode ?? "fast");
|
||||
setStep3Report(run.step3 ?? null);
|
||||
step2.hydrateFromDraft(run as unknown as Step2Snapshot);
|
||||
},
|
||||
@@ -421,6 +442,8 @@ export default function RoundtablePlanningPage() {
|
||||
}
|
||||
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
|
||||
setChainMeta(snapshot.chain ?? null);
|
||||
setOrchestrationPlan(snapshot.orchestrationPlan ?? null);
|
||||
setDagExecMode(snapshot.dagExecMode ?? "fast");
|
||||
step2.hydrateFromDraft(snapshot as unknown as Step2Snapshot);
|
||||
},
|
||||
cancelAllStreams: () => {
|
||||
@@ -504,6 +527,19 @@ export default function RoundtablePlanningPage() {
|
||||
drafts.loadedFromDraftRef,
|
||||
]);
|
||||
|
||||
// 并行执行 → 收起左侧栏腾出横向空间放两列(和「打开沙箱」一样的收起体验):
|
||||
// - 全局工作区侧栏(最左):sidebar.setOpen(false)(与 artifacts 打开沙箱同一路径);
|
||||
// - 圆桌「参与角色」栏:setStep2SidebarExpanded(false)。
|
||||
// **不自动复原**:并行结束后再进入单智能体回答时保持收起(用户需要时手动展开),
|
||||
// 与沙箱收起后不自动回弹一致。
|
||||
useEffect(() => {
|
||||
if (step2.isParallelRunning) {
|
||||
sidebar?.setOpen(false);
|
||||
setStep2SidebarExpanded(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step2.isParallelRunning]);
|
||||
|
||||
// ─── 步骤间转场 handler ───────────────────────────────────
|
||||
const handleConfirmAgents = useCallback(
|
||||
(result: SeatSelectionResult) => {
|
||||
@@ -537,7 +573,10 @@ export default function RoundtablePlanningPage() {
|
||||
budgetLimit: step2.budgetLimit,
|
||||
seatStubMessages: {},
|
||||
orchestrationMode: result.mode,
|
||||
chain: result.mode === "chain" ? result.chain ?? null : null,
|
||||
// chain / dag 都来自业务链条,保留来源链名;recommend 无。
|
||||
chain: result.mode === "recommend" ? null : result.chain ?? null,
|
||||
orchestrationPlan: result.mode === "dag" ? result.plan ?? null : null,
|
||||
dagExecMode: result.mode === "dag" ? result.dagExecMode ?? "fast" : undefined,
|
||||
step3: null,
|
||||
};
|
||||
setStep2Runs([...syncedRuns, newRun]);
|
||||
@@ -546,15 +585,20 @@ export default function RoundtablePlanningPage() {
|
||||
step2.resetStep2State();
|
||||
step2.setSelectedAgents(normalized);
|
||||
step2.resetInitGate();
|
||||
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。
|
||||
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活;
|
||||
// dag = 分层并行(来源链条带 stages,携带 result.plan)。
|
||||
setOrchestrationMode(result.mode);
|
||||
setChainMeta(result.mode === "chain" ? result.chain ?? null : null);
|
||||
setChainMeta(result.mode === "recommend" ? null : result.chain ?? null);
|
||||
setOrchestrationPlan(result.mode === "dag" ? result.plan ?? null : null);
|
||||
setDagExecMode(result.mode === "dag" ? result.dagExecMode ?? "fast" : "fast");
|
||||
setIsRecommendDialogOpen(false);
|
||||
setCurrentStep(2);
|
||||
triggerToast(
|
||||
result.mode === "chain"
|
||||
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨`
|
||||
: "成功确认!已切换至第二阶段圆桌研讨系统",
|
||||
result.mode === "dag"
|
||||
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」分层并行研讨`
|
||||
: result.mode === "chain"
|
||||
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨`
|
||||
: "成功确认!已切换至第二阶段圆桌研讨系统",
|
||||
);
|
||||
},
|
||||
[buildCurrentStep2Snapshot, mergeActiveRun, step2, step3Report, triggerToast],
|
||||
@@ -570,6 +614,8 @@ export default function RoundtablePlanningPage() {
|
||||
setActiveStep2RunId(null);
|
||||
setOrchestrationMode("recommend");
|
||||
setChainMeta(null);
|
||||
setOrchestrationPlan(null);
|
||||
setDagExecMode("fast");
|
||||
setStep3Report(null);
|
||||
step1.stopIntentStream();
|
||||
drafts.loadedFromDraftRef.current = false;
|
||||
@@ -609,7 +655,9 @@ export default function RoundtablePlanningPage() {
|
||||
intent: buildInitialIntent(),
|
||||
agents,
|
||||
model: selectedModel || undefined, // 模型沿用第二步选择
|
||||
orchestrationMode,
|
||||
// 后台作业引擎暂不支持 dag(分层 DAG 只在前端 runDagOrchestration 跑);
|
||||
// 挂起到后台时降级为 recommend(总控自由派活)。
|
||||
orchestrationMode: orchestrationMode === "dag" ? "recommend" : orchestrationMode,
|
||||
chain: chainMeta,
|
||||
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
|
||||
// (那些线程可能有活跃 run,复用会撞 409)。
|
||||
@@ -878,6 +926,13 @@ export default function RoundtablePlanningPage() {
|
||||
agentConfigs={step2.agentConfigs}
|
||||
selectedAgentId={step2.selectedAgentId}
|
||||
roles={roles}
|
||||
parallelBatch={step2.parallelBatch}
|
||||
selectedParallelSeats={step2.selectedParallelSeats}
|
||||
isParallelRunning={step2.isParallelRunning}
|
||||
onToggleParallelSeat={step2.toggleParallelSeat}
|
||||
orchestrationMode={orchestrationMode}
|
||||
orchestrationPlan={orchestrationPlan}
|
||||
dagNodeStates={step2.dagNodeStates}
|
||||
agentNameToRole={step2.agentNameToRole}
|
||||
selectedModel={selectedModel}
|
||||
availableModels={availableModels}
|
||||
|
||||
Reference in new issue
Block a user