Files
deerflow-code/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
T
2026-06-12 20:27:34 +08:00

2618 lines
113 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Step 2(多智能体圆桌研讨)状态机与编排循环 hook。
*
* 包含:
* - 初始化 SSE(initMultiAgent)与 thread map 管理
* - 编排循环 `runOrchestration`(顺序派活、leader/special 切换、澄清暂停)
* - 对话气泡的三段式流式管理(append → append delta → finalize)
* - 圆桌可视化的高亮态、Persona 模型覆盖、用户干预、澄清回复
*
* 改这个 hook 前请先读 `frontend-web/docs/multi-agent-frontend-dev.md` §5.4。
* 关键不变量:
* 1) special 顺序触发,**不要**改回 `Promise.all`(N 路 token 级 setState 会卡顿)。
* 2) 三段式气泡操作都通过 `id` 匹配,绝不用数组下标。
* 3) MAX_CYCLES=8 是死循环兜底,放宽前先评估对话列表的虚拟滚动。
* 4) 离开 Step 2 **不**清空 threadIds/dialogues,允许返回继续看。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useArtifacts } from "@/components/workspace/artifacts";
import {
initMultiAgent,
streamMultiAgent,
type ClarificationPayload,
type LeaderDispatch,
type StreamPhase,
type StreamMultiAgentRequest,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { uploadFiles } from "@/core/uploads/api";
/** 干预消息携带的上传文件引用(与 streamMultiAgent 的 files 字段同形)。 */
type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
/** 读取并清空 ref(consume-once):首个 leader run 拿到文件后清空,后续轮次不再带。 */
function takeOnce<T>(ref: { current: T | null }): T | undefined {
const v = ref.current ?? undefined;
ref.current = null;
return v;
}
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,
buildAgentNameToRole,
buildRoleTemplates,
newCoordinatorName,
nowTime,
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
/** Step 2 对话流里的一条气泡(总控派活 / 子智能体交付 / 系统播报 / 用户插话…)。 */
export type Step2Dialogue = {
id: number;
sender: string;
avatarType: string;
content: string;
time: string;
confidence: string;
streaming?: boolean;
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
agentThreadId?: string;
steps?: Array<{
id: string;
key: string;
label: string;
ts: string;
args?: Record<string, unknown>;
}>;
};
/**
* 并行批次里的一个席位视图(§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
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
*/
export interface StubToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface StubAiMessage {
id: string;
type: "ai";
content: string;
tool_calls: StubToolCall[];
}
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
export type AgentConfig = {
model: string;
weight: number;
prompt: string;
temp: number;
};
/** 草稿持久化的 Step 2 快照。 */
export type Step2Snapshot = {
/**
* 本次研讨实际选中的席位 —— 加载草稿后据此重建左侧「参与角色」面板。
* 不存的话(2026-06 之前的旧草稿)hydrate 会回退到 DEFAULT_SELECTED_AGENTS,
* 导致 Step 2/3 左侧显示默认 6 个而非当时真正选的那几个。反序列化时
* 老草稿按 undefined 兜底(保留当前 selectedAgents,不强行覆盖)。
*/
selectedAgents?: SelectedAgent[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: Step2Dialogue[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
/**
* 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
* 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
* 老草稿可能没有这字段,反序列化时按空对象兜底。
*/
seatStubMessages: Record<string, StubAiMessage[]>;
/**
* 本次 Step 2 编排模式(2026-06 业务链条)。recommend = 总控自由派活;
* chain = 按业务链条顺序派活。老草稿无此字段 → 加载时默认 recommend。
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
* step2 hook 的 hydrateFromDraft 不直接消费它。
*/
orchestrationMode?: "recommend" | "chain" | "dag";
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
chain?: { id: string; title: string } | null;
/** dag 模式的分层编排计划(见 `lib/dag-types.ts`)。同样是主页面 state,hook 经 option 读取。 */
orchestrationPlan?: OrchestrationPlan | null;
};
/**
* 6 个默认席位的初始 Persona 配置。model 留空 → 运行时 `agentConfigs[seat]?.model
* || selectedModel || undefined`,即「席位覆盖优先,否则跟随全局模型」。
*/
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
2: { model: "", weight: 90, temp: 0.2, prompt: "你负责解析核心任务、资源极限和用户目标偏好,监督全局研商收敛。" },
3: { model: "", weight: 85, temp: 0.3, prompt: "你负责搜集电力、通信系统、历史极端部署条件情报,支持备灾架构体系。" },
4: { model: "", weight: 88, temp: 0.1, prompt: "你评估服务器机房热载荷、算力并发承载极限,预报运行瓶颈与风险时钟。" },
5: { model: "", weight: 92, temp: 0.4, prompt: "你负责双全栈主备建模设计,高内聚低耦合,规避不可预知停机崩溃。" },
6: { model: "", weight: 95, temp: 0.1, prompt: "你作为安全主管,严厉把控预算开销与合规通过率防线,识别计划漏洞。" },
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
/**
* chain 模式:为「链条第 i 个席位」构造**约束总控只能派给它**的 leader prompt。
* 后端不变 —— 靠 prompt 把派活目标钉死成链条下一席位,再由 `pickChainTask` 校验/纠偏。
*/
function buildChainLeaderTask(args: {
seedMessage: string;
position: number;
total: number;
seatAgentId: string;
seatName: string;
isFirst: boolean;
}): string {
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
const ordinal = `${position + 1}/${total}`;
if (isFirst) {
return [
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seedMessage,
"",
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
return [
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
/**
* 从 leader 终态里取出「派给当前链条席位」的任务文本,带纠偏:
* 1) 命中 agent_name === seatAgentId 的派活 → 用它的 task;
* 2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
* 3) 总控没派活只写了说明 → 用 leader 正文,再不行回退到模板任务。
* 无论哪种,调用方都强制对**链条席位**跑 special,确保游标严格推进。
*/
function pickChainTask(
dispatched: LeaderDispatch[],
leaderContent: string,
seatAgentId: string,
seatName: string,
): string {
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) return exact[1];
const any = dispatched.find(([, task]) => task?.trim());
if (any && any[1]?.trim()) return any[1];
if (leaderContent?.trim()) {
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
}
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
}
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`);
* - `dag` —— **分层 DAG**:前端直派,stage 内并行、stage 间串行(`runDagOrchestration`,
* 需配合 `orchestrationPlan`)。
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
* `selectedAgents` 的顺序。
*/
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。 */
triggerToast: (msg: string) => void;
/** Step 2 init / 编排循环里改 LeftSidebar 角色面板用。 */
setRoles: React.Dispatch<React.SetStateAction<RoleEntry[]>>;
/** 草稿 hook 共享的 ref;影响 init 期间是否跳过自动 SSE。 */
loadedFromDraftRef: React.MutableRefObject<boolean>;
}
export interface UseStep2OrchestrationReturn {
// ─── State ──────────────────────────────────────────────────────────
selectedAgents: SelectedAgent[];
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
isInitializing: boolean;
initError: string | null;
isOrchestrating: boolean;
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
step2RoundtableDialogues: Step2Dialogue[];
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
isRoundTableRunning: boolean;
activeSpeakerId: number | null;
activeSpeakerSet: Set<number>;
customAgentInput: string;
isUpperPanelCollapsed: boolean;
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<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
roundtableAgentIds: string[];
// ─── Setters ────────────────────────────────────────────────────────
setSelectedAgents: React.Dispatch<React.SetStateAction<SelectedAgent[]>>;
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
setIsUpperPanelCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
/** 推荐弹窗确认后清空 step2InitRef,让下一次进入重新走 init。 */
resetInitGate: () => void;
// ─── Handlers ───────────────────────────────────────────────────────
/** 中断当前编排循环 + 所有 in-flight SSE。 */
stopOrchestration: () => void;
/** 与 Step2Panel「暂停会商」按钮相同 —— 挂起编排并中止 in-flight SSE。 */
pauseRoundtable: () => void;
/** 与 Step2Panel「恢复会商」按钮相同 —— 恢复自动研讨(含待处理的干预指令)。 */
resumeRoundtable: () => void;
/** 记录干预指令(不暂停不恢复);通常由 Step2Panel 在 pauseRoundtable 之后调用。 */
recordInjectMessage: () => void;
/** 中断所有 in-flight SSE(给新建任务 / 加载草稿用)。 */
abortAllInFlight: () => void;
/**
* 一键干预:记录指令 + 自动恢复会商 + 触发新一轮编排(替代 pause + record + resume 组合)。
* `files`:可选的参考文件,发送前先上传到总控 thread,首轮 leader run 据此 read_file 读取。
*/
handleInjectToRoundtable: (files?: File[]) => void;
/** 总控澄清回复 —— 把回答拼到 prompt 里再起一轮编排。 */
submitClarification: (rawAnswer: string) => void;
/** 预算滑块(本地演示,非编排真实输入)。 */
handleBudgetChange: (newVal: number) => void;
/** Persona 抽屉的「保存」按钮:写入 agentConfigs,下一轮该席位生效。 */
handleSaveAgentAndRecalculate: (id: number) => void;
/** 从草稿加载;同时把 step2InitRef 置为「已 init」跳过自动 SSE。 */
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
resetStep2State: () => void;
/** 当前沙箱预览绑定的席位 thread id。 */
activeArtifactThreadId: string | null;
/**
* 各 special seat 的 stub messages —— 注入到沙箱 ThreadContext.thread.messages,
* 让 `useArtifactContent` 通过虚拟 URL 直接读 `args.content`(对齐主聊天)。
*/
seatStubMessages: Record<string, StubAiMessage[]>;
/** 从工具步骤或流式 write_file 打开沙箱预览。 */
openArtifactPreview: (
threadId: string,
path: string,
options?: { auto?: boolean },
) => void;
/** 点击气泡内工具步骤时打开沙箱。 */
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
}
export function useStep2Orchestration(
options: UseStep2OrchestrationOptions,
): UseStep2OrchestrationReturn {
const {
currentStep,
selectedModel,
orchestrationMode,
orchestrationPlan,
dagExecMode = "fast",
buildInitialIntent,
triggerToast,
setRoles,
loadedFromDraftRef,
} = options;
const {
artifacts,
setArtifacts,
select: selectArtifact,
setOpen: setArtifactsOpen,
deselect: deselectArtifact,
autoOpen,
autoSelect,
} = useArtifacts();
const artifactPathsRef = useRef<string[]>([]);
useEffect(() => {
artifactPathsRef.current = artifacts ?? [];
}, [artifacts]);
// 用户手动关掉沙箱后,autoOpen 会被 ArtifactsProvider 置 false —— 把它们打到 ref
// 上,让 openSeatVirtualArtifact 不用进 useCallback deps,避免每次 toggle 重建函数。
const autoOpenFlagsRef = useRef({ autoOpen, autoSelect });
useEffect(() => {
autoOpenFlagsRef.current = { autoOpen, autoSelect };
}, [autoOpen, autoSelect]);
/** 已自动打开过的 virtualUrl 集合 —— 流式期间一秒会回调几十次,防止重复 schedule 开沙箱。 */
const hasAutoOpenedRef = useRef<Set<string>>(new Set());
// ─── State ─────────────────────────────────────────────────────────
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
null,
);
const [seatStubMessages, setSeatStubMessages] = useState<
Record<string, StubAiMessage[]>
>({});
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
DEFAULT_SELECTED_AGENTS,
);
const [initNonce, setInitNonce] = useState(0);
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
const [coordinatorName, setCoordinatorName] = useState<string | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(false);
const [initError, setInitError] = useState<string | null>(null);
const [isOrchestrating, setIsOrchestrating] = useState<boolean>(false);
const [lastLeaderContent, setLastLeaderContent] = useState<string>("");
const [hasConsensus, setHasConsensus] = useState<boolean>(false);
const [consensusPercentage, setConsensusPercentage] = useState<number>(89.4);
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
Step2Dialogue[]
>([]);
const [pendingClarification, setPendingClarification] =
useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState<string>("");
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
const [activeSpeakerId, setActiveSpeakerId] = useState<number | null>(null);
const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(
new Set(),
);
const [customAgentInput, setCustomAgentInput] = useState<string>("");
const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] =
useState<boolean>(false);
const [budgetLimit, setBudgetLimit] = useState<number>(500);
const [agentConfigs, setAgentConfigs] = useState<Record<number, AgentConfig>>(
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(
() => buildRoleTemplates(selectedAgents),
[selectedAgents],
);
const agentNameToRole = useMemo(
() => buildAgentNameToRole(selectedAgents),
[selectedAgents],
);
const roundtableAgentIds = useMemo(
() => selectedAgents.map((a) => a.agent_id),
[selectedAgents],
);
// ─── Refs ────────────────────────────────────────────────────────
const orchestrationCancelRef = useRef<boolean>(false);
// 编排「世代号」。每次启动一轮编排(runOrchestration / runChainOrchestration)就 +1。
// 旧循环捕获自己的 epoch,在每个取消检查点比对 —— 一旦 epoch 变了(说明已有新循环
// 接管,例如人工干预),旧循环立即退出。这能根治「新循环把 orchestrationCancelRef
// 重置为 false 反而把正在退出的旧循环『反取消』,导致旧循环继续又发一个 leader run」
// 的并发双发 bug:epoch 是单调递增的,新循环无法把它『重置』回旧循环的值。
const orchestrationEpochRef = useRef<number>(0);
const step2InitRef = useRef<boolean>(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
const pendingInjectPromptRef = useRef<string | null>(null);
/**
* 干预消息携带的上传文件引用:由下一轮编排的**首个 leader run**消费一次后清空,
* 总控据此 read_file 读取文件再派活。consume-once 语义避免后续轮次重复带文件。
*/
const pendingInjectFilesRef = useRef<RunFileRef[] | null>(null);
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
// 改写都重建一遍循环函数 —— 之前没收窄时 useEffect 会因 deps 变化重复 init。
const selectedModelRef = useRef<string>(selectedModel);
const agentConfigsRef = useRef<Record<number, AgentConfig>>(agentConfigs);
const agentNameToRoleRef = useRef(agentNameToRole);
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
const coordinatorNameRef = useRef<string | null>(coordinatorName);
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]);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
useEffect(() => {
agentConfigsRef.current = agentConfigs;
}, [agentConfigs]);
useEffect(() => {
agentNameToRoleRef.current = agentNameToRole;
}, [agentNameToRole]);
useEffect(() => {
threadIdsRef.current = threadIds;
}, [threadIds]);
useEffect(() => {
coordinatorNameRef.current = coordinatorName;
}, [coordinatorName]);
useEffect(() => {
isOrchestratingRef.current = isOrchestrating;
}, [isOrchestrating]);
const resolveSeatThreadId = useCallback((ids: ThreadIdMap, agentName: string) => {
return ids[agentName] ?? ids[agentName.toLowerCase()];
}, []);
/**
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
* 返回的 `messageId`/`toolCallId` 用于拼出 `write-file:` 虚拟 URL,沙箱 useArtifactContent
* 据此从 thread.messages 取出 args.content,完全绕过 HTTP。
*/
const upsertSeatToolCall = useCallback(
(
seatThreadId: string,
toolName: string,
args: Record<string, unknown>,
): { messageId: string; toolCallId: string } => {
const path = typeof args.path === "string" ? args.path : "";
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
const existing = current[0];
const baseMsg: StubAiMessage = existing ?? {
id: `roundtable-stub-${seatThreadId}`,
type: "ai",
content: "",
tool_calls: [],
};
const idx = baseMsg.tool_calls.findIndex(
(tc) =>
tc.name === toolName &&
String((tc.args as { path?: unknown })?.path ?? "") === path,
);
const toolCallId =
idx >= 0
? baseMsg.tool_calls[idx].id
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
const nextToolCall: StubToolCall = {
id: toolCallId,
name: toolName,
args,
};
const nextToolCalls =
idx >= 0
? baseMsg.tool_calls.map((tc, i) =>
i === idx ? nextToolCall : tc,
)
: [...baseMsg.tool_calls, nextToolCall];
const nextAiMsg: StubAiMessage = {
...baseMsg,
tool_calls: nextToolCalls,
};
const nextMap = {
...seatStubMessagesRef.current,
[seatThreadId]: [nextAiMsg],
};
seatStubMessagesRef.current = nextMap;
setSeatStubMessages(nextMap);
return { messageId: nextAiMsg.id, toolCallId };
},
[],
);
/**
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
*
* auto=true 流式期间:
* - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule);
* - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。
* auto=false(用户点击步骤 / 文件卡)始终强制打开。
*/
const openSeatVirtualArtifact = useCallback(
(
seatThreadId: string,
virtualUrl: string,
auto: boolean,
opts?: { manual?: boolean },
) => {
// manual=true → 视为「用户手动接管」,会把全局 autoSelect 置 false(尊重用户后续不再
// 自动选/自动开)。默认沿用旧语义:auto=false 即手动点击(文件卡 / 步骤)。
// 但**系统程序化打开**(如沙箱跟随 openSeatLatestArtifact)必须传 manual:false ——
// 否则会误关 autoSelect,毒化随后实时 write_file 的 auto=true 自动打开,表现为
// 「单个智能体发言时沙箱不自动打开、只能手动开」(763eb4d 沙箱跟随引入的回归)。
const manual = opts?.manual ?? !auto;
if (auto) {
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);
const open = () => {
// select 的第 2 参 = autoSelect:true 表示「自动选,不毒化」;只有真正手动接管才传 false。
selectArtifact(virtualUrl, !manual);
setArtifactsOpen(true);
};
if (auto) {
window.setTimeout(open, 100);
} else {
open();
}
},
[selectArtifact, setArtifactsOpen],
);
const buildWriteFileVirtualUrl = useCallback(
(path: string, messageId: string, toolCallId: string) =>
new URL(
`write-file:${path}?message_id=${encodeURIComponent(
messageId,
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
).toString(),
[],
);
/**
* 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);
// manual:false —— 沙箱跟随是系统行为,强制打开但**不**关闭 autoSelect(否则毒化
// 后续实时自动打开,见 openSeatVirtualArtifact 注释)。
openSeatVirtualArtifact(seatThreadId, virtualUrl, false, { manual: false });
return true;
}
return false;
},
[buildWriteFileVirtualUrl, openSeatVirtualArtifact],
);
/**
* 反查某个产出文件是由哪个席位 thread 写入的。
*
* 总控(coordinator)常用 `present_files` 呈现「子智能体写的文件」,但文件物理上落在
* 子席位 thread 的 outputs 里,coordinator thread 并没有 —— 直接拿总控气泡的
* `agentThreadId`(=coordinator thread)走 HTTP 拉会 404(`Thread ... not found`)。
* 流式期间子席位 write_file 时,内容已缓存进 seatStubMessages(key=真实写入 thread)。
* 这里按 path 反查那条 stub,让关闭沙箱后再点文件卡也能复用虚拟预览(内存内容,
* 与流式期间体验一致,且不依赖后端 thread 是否仍可访问)。
*/
const findSeatArtifactSource = useCallback(
(
rawPath: string,
): { seatThreadId: string; messageId: string; toolCallId: string } | null => {
const target = rawPath.trim();
if (!target) return null;
const targetBase = target.split("/").pop() ?? target;
let basenameMatch:
| { seatThreadId: string; messageId: string; toolCallId: string }
| null = null;
for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current,
)) {
const msg = msgs?.[0];
if (!msg) continue;
for (const tc of msg.tool_calls) {
if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p =
typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string)
: "";
if (!p) continue;
if (p === target) {
return { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) {
basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id };
}
}
}
return basenameMatch;
},
[],
);
/**
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
*/
const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
// 先反查 stub 走虚拟预览(内存内容)—— 关闭沙箱后再点文件卡也能显示,且不受
// 总控 coordinator thread 在后端 artifacts 路由下 not found 的影响。
const seatSource = findSeatArtifactSource(normalized);
if (seatSource) {
const virtualUrl = buildWriteFileVirtualUrl(
normalized,
seatSource.messageId,
seatSource.toolCallId,
);
openSeatVirtualArtifact(
seatSource.seatThreadId,
virtualUrl,
options?.auto ?? false,
);
return;
}
if (!threadId) return;
setActiveArtifactThreadId(threadId);
const prev = artifactPathsRef.current;
if (!prev.includes(normalized)) {
const next = [...prev, normalized];
artifactPathsRef.current = next;
setArtifacts(next);
}
const openPanel = () => {
selectArtifact(normalized, options?.auto ?? false);
setArtifactsOpen(true);
};
if (options?.auto) {
window.setTimeout(openPanel, 100);
} else {
openPanel();
}
},
[
findSeatArtifactSource,
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
selectArtifact,
setArtifacts,
setArtifactsOpen,
],
);
const openArtifactFromStep = useCallback(
(
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => {
const seatThreadId = dialogue.agentThreadId;
if (!seatThreadId) return;
const [, toolName] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") {
const path = step.args?.path;
if (typeof path !== "string" || !step.args) return;
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName,
step.args,
);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
return;
}
if (toolName === "read_file") {
// 验证类「查看文件…」步骤:用 args.path 在沙箱预览该文件。
// openArtifactPreview 内部 findSeatArtifactSource 优先按 path 命中流式期间
// write_file 缓存的内容(虚拟预览);未命中再走 seat thread 的 outputs HTTP。
const path = step.args?.path;
if (typeof path === "string" && path) {
openArtifactPreview(seatThreadId, path);
}
return;
}
if (toolName === "present_files") {
const filepaths = step.args?.filepaths;
if (Array.isArray(filepaths)) {
const first = filepaths.find(
(p): p is string => typeof p === "string" && p.length > 0,
);
if (first) openArtifactPreview(seatThreadId, first);
}
}
},
[
buildWriteFileVirtualUrl,
openArtifactPreview,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
const handleArtifactToolPhase = useCallback(
(
phase: StreamPhase,
toolName: string | undefined,
args: Record<string, unknown> | undefined,
seatThreadId: string | undefined,
) => {
if (phase !== "tool_calling") return;
if (!seatThreadId) return;
const isArtifactTool =
toolName === "write_file" || toolName === "str_replace";
if (!isArtifactTool) return;
const path = args?.path;
if (typeof path !== "string" || !path || !args) {
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] tool_calling without path yet",
{ toolName, args },
);
}
return;
}
const { messageId, toolCallId } = upsertSeatToolCall(
seatThreadId,
toolName as string,
args,
);
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
if (typeof window !== "undefined") {
console.debug(
"[roundtable-sandbox] auto-open from phase",
{ toolName, path, seatThreadId, virtualUrl },
);
}
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
},
[
buildWriteFileVirtualUrl,
openSeatVirtualArtifact,
upsertSeatToolCall,
],
);
/**
* 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
* 与 `handleArtifactToolPhase` 相比:
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
*/
const handleUpstreamFrame = useCallback(
(frame: unknown, seatThreadId: string | undefined) => {
if (!seatThreadId) return;
if (!Array.isArray(frame) || frame.length === 0) return;
const chunk = frame[0];
if (!chunk || typeof chunk !== "object") return;
const obj = chunk as Record<string, unknown>;
const toolCalls = obj.tool_calls;
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
for (const tc of toolCalls) {
if (!tc || typeof tc !== "object") continue;
const tcObj = tc as Record<string, unknown>;
const name = typeof tcObj.name === "string" ? tcObj.name : "";
if (name !== "write_file" && name !== "str_replace") continue;
const tcArgs = tcObj.args;
if (!tcArgs || typeof tcArgs !== "object") continue;
const argsObj = tcArgs as Record<string, unknown>;
const path = argsObj.path;
if (typeof path !== "string" || !path) continue;
upsertSeatToolCall(seatThreadId, name, argsObj);
}
},
[upsertSeatToolCall],
);
// ─── Dialogue / Speaker helpers ────────────────────────────────────
const appendDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
content: string;
confidence?: string;
}) => {
setStep2RoundtableDialogues((prev) => [
...prev,
{
id: Date.now() + Math.random(),
sender: card.sender,
avatarType: card.avatarType,
content: card.content,
time: nowTime(),
confidence: card.confidence ?? "实时输出",
},
]);
},
[],
);
const phaseLabel = useCallback(
(
phase: StreamPhase,
agentType: "leader" | "special",
toolName?: string,
): string => {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "agent_orchestration") return "正在派活";
if (toolName === "ask_clarification") return "正在提问";
if (toolName === "present_files") return "正在交付文件";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return agentType === "leader" ? "正在派活说明" : "正在输出";
default:
return agentType === "leader" ? "调度中" : "正在输出…";
}
},
[],
);
const setDialogueConfidence = useCallback(
(id: number, confidence: string) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => (d.id === id ? { ...d, confidence } : d)),
);
},
[],
);
const appendStepIfNew = useCallback(
(
bubbleId: number,
label: string,
key: string,
args?: Record<string, unknown>,
) => {
setStep2RoundtableDialogues((prev) =>
prev.map((d) => {
if (d.id !== bubbleId) return d;
const steps: Step2Dialogue["steps"] = d.steps ?? [];
const last = steps[steps.length - 1];
if (last && last.key === key) {
// 同 phase:tool 重复回调 —— 流式累积期 args 会持续长大(present_files
// 多文件分批、write_file 内容增长),拿最新 args 覆盖,不要早早定死。
if (args && args !== last.args) {
const next = [...steps];
next[next.length - 1] = { ...last, args };
return { ...d, steps: next };
}
return d;
}
return {
...d,
steps: [
...steps,
{ id: `${bubbleId}-${steps.length}`, key, label, ts: nowTime(), args },
],
};
}),
);
},
[],
);
const appendStreamingDialogue = useCallback(
(card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
agentThreadId?: string;
}): number => {
const id = Date.now() + Math.random();
setStep2RoundtableDialogues((prev) => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: card.initialContent ?? "",
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
steps: [],
agentThreadId: card.agentThreadId,
},
]);
return id;
},
[],
);
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setStep2RoundtableDialogues((prev) =>
prev.map((d) =>
d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d,
),
);
}, []);
/** 整段替换某气泡正文(§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) =>
prev.map((d) => {
if (d.id !== id) return d;
const next =
finalContent && finalContent.trim().length > 0
? finalContent
: d.content || "(无文本输出)";
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
},
[],
);
const highlightSpeaker = useCallback(
(speakerId: number | null) => {
setActiveSpeakerId(speakerId);
setRoles((prev) =>
prev.map((item) => {
if (speakerId !== null && item.id === speakerId) {
return { ...item, status: "研讨中" };
}
// 发言结束(不再研讨中)的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") {
return { ...item, status: "已完成" };
}
return item;
}),
);
},
[setRoles],
);
const markSpeakerStreaming = useCallback(
(speakerId: number) => {
// 编排循环已经改成**顺序**派 special(详见 §5.4 / 文件顶部注释),
// 任意时刻只有一个子智能体在跑。因此这里既要把当前发言席位置为"研讨中",
// 又要把上一轮 / 历史残留下来的"研讨中"统一标记成"已完成",保证右侧"参与角色"
// 面板任何时刻**只有一个** sub agent 显示研讨中,与对话区实际在 stream 的
// 那一个一致。activeSpeakerSet 沿用 Set 形态(虽然实际只放 1 个)以兼容外部
// 消费方,真正的"高亮排他"靠 roles 的 status reset。
setActiveSpeakerSet(() => new Set([speakerId]));
setRoles((prev) =>
prev.map((item) => {
if (item.id === speakerId) return { ...item, status: "研讨中" };
// 上一轮发言已结束的席位标记为「已完成」,而非回退到「在线」。
if (item.status === "研讨中") return { ...item, status: "已完成" };
return item;
}),
);
},
[setRoles],
);
const markSpeakerIdle = useCallback(
(speakerId: number) => {
setActiveSpeakerSet((prev) => {
if (!prev.has(speakerId)) return prev;
const next = new Set(prev);
next.delete(speakerId);
return next;
});
setRoles((prev) =>
prev.map((item) =>
// 该席位本轮输出完成 —— 标记为「已完成」(带勾选样式),而非回退到「在线」。
item.id === speakerId && item.status === "研讨中"
? { ...item, status: "已完成" }
: item,
),
);
},
[setRoles],
);
const clearAllStreamingSpeakers = useCallback(() => {
setActiveSpeakerSet(new Set());
setRoles((prev) =>
prev.map((item) =>
// 研讨收尾:仍在研讨中的席位置为「已完成」,而非回退到「在线」。
item.status === "研讨中" ? { ...item, status: "已完成" } : item,
),
);
}, [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) => {
try {
ctrl.abort();
} catch {
/* noop */
}
});
activeAbortersRef.current.clear();
}, []);
const stopOrchestration = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
}, [abortAllInFlight]);
// ─── Orchestration loop ──────────────────────────────────────────
// 1) leader call → 2) 顺序派 special → 3) 综合 prompt 再 leader …
// 直到 leader.dispatched===[] (共识) 或循环达到 MAX_CYCLES。
// **不要并行 special**(详见文件顶部注释)。
const runOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// **同步**抢占编排锁。isOrchestratingRef 平时由 [isOrchestrating] 的 effect 异步
// 回写(见上方),滞后一拍;若只靠它,人工干预里 setIsRoundTableRunning(true) 触发的
// 「恢复/干预」effect 会在 ref 尚未变 true 的同一 tick 内再调一次本函数 —— 两个 leader
// run 并发打到同一总控 thread(rollback 抢占 → 截断的那个旧报 502、存活的那个重复出现)。
// 这里立刻把 ref 置 true,让重入守卫即时生效,堵住二次触发。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// 认领本轮 epoch。任何更晚启动的循环都会让 epoch 自增 → 本循环 isStale() 立即为真。
const myEpoch = (orchestrationEpochRef.current += 1);
// 单一「该退出了吗」判定:被显式取消,**或**已被更新的循环接管(epoch 变了)。
// 后者专治新循环重置 orchestrationCancelRef=false 把旧循环反取消的并发双发。
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
let nextLeaderTask = seedMessage;
let safetyCounter = 0;
try {
while (!isStale() && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
// Leader(协调智能体)正在派活,**不应该**让任何 sub agent 显示"研讨中"——
// 协调者本身不在右侧"参与角色"面板里(COMMANDER_SEAT_ID=1 是人类主持人,
// sub agent 从 id=2 起)。历史代码这里写的是 `highlightSpeaker(2)`,会
// 错把第一个 sub agent 误标为研讨中,叠加 special 派活时另一个 sub 也被
// markSpeakerStreaming,就出现"两个 sub 同时研讨中"的视觉 bug。
// 改成 null 后:leader 跑的阶段所有 sub 都是"在线",special 跑哪个就只
// 高亮哪个。activeSpeakerId 同步置空,圆桌可视化也不会高亮错的位置。
highlightSpeaker(null);
// 总控也可能直接 write_file —— 之前只接了 special 流,导致总控写文件时沙箱不自动打开。
// 这里把 leader thread id 绑到气泡 + artifact handler 上,与 special 同样的行为。
const leaderThreadId = resolveSeatThreadId(ids, coord);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: leaderThreadId,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
// 第 2 轮起(已有席位交付)才是「综合轮」:让后端注入各席位完整交付供总控收口。
// 首轮纯派活、无交付,传 false 保持上下文精简。
synthesisMode: safetyCounter > 1,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
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, "(已停止)", "已停止");
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) break;
if (leaderRes.clarification) {
finalizeDialogue(
leaderBubbleId,
leaderRes.content,
"等待用户澄清",
);
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
break;
}
finalizeDialogue(
leaderBubbleId,
leaderRes.content,
leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明",
);
if (leaderRes.content) {
setLastLeaderContent(leaderRes.content);
}
if (leaderRes.dispatched.length === 0) {
highlightSpeaker(null);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast(
"🎉 圆桌研讨论证会圆满落幕!已取得 100% 专家组共识",
);
break;
}
// **顺序**派活 —— 不要改回 Promise.all,详见 §5.4。
for (const [subName, task] of leaderRes.dispatched as LeaderDispatch[]) {
if (isStale()) break;
const meta = agentNameToRoleRef.current[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const seatThreadId = resolveSeatThreadId(ids, subName);
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
displayName: meta?.displayName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(
subBubbleId,
label,
`${phase}:${toolName ?? ""}`,
args,
);
handleArtifactToolPhase(
phase,
toolName,
args,
seatThreadId,
);
},
onUpstreamFrame: (frame) =>
handleUpstreamFrame(frame, seatThreadId),
});
if (isStale()) {
finalizeDialogue(
subBubbleId,
specRes.content || "(已停止)",
"已停止",
);
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (
subAborter.signal.aborted ||
(err as Error).name === "AbortError"
) {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[special:${subName}] error:`, err);
finalizeDialogue(
subBubbleId,
`(错误) ${(err as Error).message}`,
"调用失败",
);
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) break;
}
if (isStale()) break;
setConsensusPercentage((prev) =>
Math.min(95, parseFloat((prev + 8).toFixed(1))),
);
nextLeaderTask =
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;若信息已足够,请直接输出最终方案并停止派活。";
}
if (safetyCounter >= MAX_CYCLES) {
triggerToast("⏱️ 已达到最大研讨轮次,自动停止以避免循环");
}
} catch (err) {
console.error("orchestration error:", err);
triggerToast(`❌ 研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
resolveSeatThreadId,
],
);
// ─── Chain orchestration loop ─────────────────────────────────────
// chain 模式:总控**按 selectedAgents(=链条)顺序**逐位派活,而非自由派活。
// 每位:(a) 约束 leader 只派给该席位 → (b) 对该席位跑 special;走完整链后 (c)
// 由 leader 收口产出最终结论。后端零改动 —— 顺序与约束全在前端 prompt + 校验。
// **不要并行 special**(与 runOrchestration 同一不变量)。
const runChainOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
// 同步抢占编排锁(原因同 runOrchestration):堵住人工干预时 isRoundTableRunning
// toggle effect 在同一 tick 内的二次触发,避免并发双 leader run。
isOrchestratingRef.current = true;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
// epoch 接管:新循环启动即自增,旧链条循环据此退出(详见 runOrchestration / 声明处注释)。
const myEpoch = (orchestrationEpochRef.current += 1);
const isStale = () =>
orchestrationCancelRef.current || orchestrationEpochRef.current !== myEpoch;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
if (isStale()) {
finishedAllSeats = false;
break;
}
const seat = chainSeats[i];
const meta = agentNameToRoleRef.current[seat.agent_id];
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
// ── (a) 总控按链条顺序派活给当前席位 ──
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "链条派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildChainLeaderTask({
seedMessage,
position: i,
total,
seatAgentId: seat.agent_id,
seatName,
isFirst: i === 0,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
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, "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (isStale()) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
// 链条中途总控仍可能要求澄清 → 暂停链条,等用户回复后从头重跑(MVP)。
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
finishedAllSeats = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
const task = pickChainTask(
leaderRes.dispatched,
leaderRes.content,
seat.agent_id,
seatName,
);
// ── (b) 当前席位交付(special,顺序、不并行)──
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subBubbleId = appendStreamingDialogue({
sender: seatName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (isStale()) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) {
finishedAllSeats = false;
break;
}
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
if (!isStale() && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "汇总链条结论",
agentThreadId: leaderThreadId,
});
const closeoutAborter = new AbortController();
activeAbortersRef.current.add(closeoutAborter);
try {
const closeoutRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
// 链条收口就是综合轮:让后端注入各席位完整交付正文。
synthesisMode: true,
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("🎉 业务链条研讨完成!已生成全案结论");
} catch (err) {
if (closeoutAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
} else {
console.error("chain closeout error:", err);
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
}
}
} catch (err) {
console.error("chain orchestration error:", err);
triggerToast(`❌ 链条研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
resolveSeatThreadId,
],
);
// ─── 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,
goal: stage.goal, // 业务链条里为本阶段写的「本阶段目标」(可空)
coordinatorPrompt: plan.coordinatorPrompt, // 写给总控的整体派活指引(可空,每阶段都注入)
});
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,
// 用户随干预上传的参考文件(consume-once:仅首轮 leader 带,之后清空)。
files: takeOnce(pendingInjectFilesRef),
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 文件;快速模式产物走正文
stage.goal, // 兜底注入「本阶段目标」(总控没派好时席位仍据此对齐)
);
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) => {
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 ─────────────────────────────────────────────────────
// Step 2 init —— 首次进入时建协调 thread + 各席位 thread,然后触发第一轮编排。
// 离开 Step 2 **不**销毁 threadIds,保证回来还能继续。
useEffect(() => {
if (currentStep !== 2) {
orchestrationCancelRef.current = true;
abortAllInFlight();
setIsOrchestrating(false);
setPendingClarification(null);
setClarificationDraft("");
return;
}
if (step2InitRef.current) return;
step2InitRef.current = true;
let cancelled = false;
(async () => {
setIsInitializing(true);
setInitError(null);
setStep2RoundtableDialogues([]);
setConsensusPercentage(0);
setLastLeaderContent("");
setHasConsensus(false);
const coord = newCoordinatorName();
try {
const initRes = await initMultiAgent({
agentNames: roundtableAgentIds,
mainAgentName: coord,
model: selectedModelRef.current || undefined,
onSeatReady: ({ agentName }) => {
if (cancelled) return;
const id = agentName.toLowerCase();
setRoles((prev) =>
prev.map((r) => (r.agentId === id ? { ...r, status: "在线" } : r)),
);
},
onCoordinatorReady: () => {
if (cancelled) return;
},
});
if (cancelled) return;
setThreadIds(initRes.thread_ids);
setCoordinatorName(initRes.main_agent_name);
appendDialogue({
sender: "系统",
avatarType: "blue",
content: `✅ 圆桌已就绪:协调智能体 ${initRes.main_agent_name} 已上线,${Object.keys(initRes.thread_ids).length - 1} 个席位已分配独立线程。`,
confidence: "系统就绪",
});
triggerToast("✅ 多智能体圆桌初始化完成");
await runActiveOrchestration(
buildInitialIntent(),
initRes.thread_ids,
initRes.main_agent_name,
);
} catch (err) {
console.error("init multi-agent failed:", err);
if (cancelled) return;
setInitError((err as Error).message);
triggerToast(`❌ 初始化失败: ${(err as Error).message}`);
} finally {
if (!cancelled) setIsInitializing(false);
}
})();
return () => {
cancelled = true;
orchestrationCancelRef.current = true;
};
// 只关心 currentStep 切换 —— buildInitialIntent / runOrchestration / 等
// 都是 useCallback / ref 包装,变化不应触发重新 init。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep, initNonce]);
// 挂起 / 恢复:与上面的 init effect 独立。`isRoundTableRunning` toggle 时:
// - false → stopOrchestration
// - true 且 idle → 触发 leader cycle(若有干预指令则优先执行)
useEffect(() => {
if (currentStep !== 2) return;
if (!isRoundTableRunning) {
stopOrchestration();
return;
}
if (
isOrchestratingRef.current ||
isInitializing ||
!threadIdsRef.current ||
!coordinatorNameRef.current
)
return;
const pending = pendingInjectPromptRef.current;
if (pending) {
pendingInjectPromptRef.current = null;
void runActiveOrchestration(
pending,
threadIdsRef.current,
coordinatorNameRef.current,
);
return;
}
void runActiveOrchestration(
"用户已恢复研讨,请继续上一轮的协调工作。",
threadIdsRef.current,
coordinatorNameRef.current,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRoundTableRunning]);
const pauseRoundtable = useCallback(() => {
setIsRoundTableRunning(false);
orchestrationCancelRef.current = true;
stopOrchestration();
triggerToast("⏸️ 会商已暂停");
}, [stopOrchestration, triggerToast]);
const resumeRoundtable = useCallback(() => {
setIsRoundTableRunning(true);
triggerToast("▶️ 恢复大模型并行博弈与推理决策");
}, [triggerToast]);
const recordInjectMessage = useCallback(() => {
const userText = customAgentInput.trim();
if (!userText) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
setCustomAgentInput("");
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
pendingInjectPromptRef.current = `用户人工干预新指令:${userText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
triggerToast("干预指令已记录,会商已自动恢复执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(
async (files?: File[]) => {
const userText = customAgentInput.trim();
const hasFiles = !!(files && files.length > 0);
if (!userText && !hasFiles) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法记录干预指令");
return;
}
// 先把文件上传到总控 thread(与普通聊天同一上传接口/技能链路)。拿到 virtual_path
// 存进 consume-once ref,供下一轮首个 leader run 注入 additional_kwargs.files。
// 上传失败则中止本次干预,不打断当前会商。
if (hasFiles) {
const coordThreadId = resolveSeatThreadId(
threadIdsRef.current,
coordinatorNameRef.current,
);
try {
const up = await uploadFiles(coordThreadId, files!);
pendingInjectFilesRef.current = up.files.map((f) => ({
filename: f.filename,
size: f.size,
path: f.virtual_path,
status: "uploaded",
}));
} catch (err) {
triggerToast(`❌ 文件上传失败: ${(err as Error).message}`);
return;
}
}
// 中止当前编排(如有)
orchestrationCancelRef.current = true;
abortAllInFlight();
// 强制标记非编排中,使后续 runOrchestration 可立即启动
isOrchestratingRef.current = false;
setIsOrchestrating(false);
setCustomAgentInput("");
const fileNote = hasFiles ? `(附 ${files!.length} 个文件)` : "";
const shownText = userText || "(上传了参考文件)";
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${shownText}${fileNote}`,
confidence: "最高指令",
});
const promptText =
userText || "我上传了参考文件,请先阅读文件内容,再据此推进研讨。";
const prompt = `用户人工干预新指令:${promptText}\n\n请优先按这条新指令,通过 agent_orchestration 把活派给相关的真实席位去获取交付;若用户点名了某个席位(如"前端实现"),必须派给清单里对应的那个 agent_name。**不要**自己代答,也**不要**在没有真正看到该席位「完成…交付内容为:…」的情况下臆造交付或直接收口。只有当用户明确要求收口、或相关席位都已真实交付时,才综合输出结论。`;
// 确保处于运行状态(同步更新按钮 UI)
setIsRoundTableRunning(true);
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
},
[
customAgentInput,
appendDialogue,
abortAllInFlight,
runActiveOrchestration,
resolveSeatThreadId,
triggerToast,
],
);
const submitClarification = useCallback(
(rawAnswer: string) => {
const answer = rawAnswer.trim();
if (!answer) return;
if (!threadIdsRef.current || !coordinatorNameRef.current) {
triggerToast("⚠️ 圆桌尚未就绪,无法回复澄清");
return;
}
if (isOrchestratingRef.current) {
triggerToast("⏳ 当前轮次仍在进行中,请稍候");
return;
}
const question = pendingClarification?.question || "(澄清问题)";
appendDialogue({
sender: "用户 (澄清回复)",
avatarType: "human",
content: `🗣 回复总控提问:${answer}`,
confidence: "澄清回复",
});
setPendingClarification(null);
setClarificationDraft("");
void runActiveOrchestration(
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
threadIdsRef.current,
coordinatorNameRef.current,
);
},
[appendDialogue, pendingClarification, runActiveOrchestration, triggerToast],
);
// 预算滑块 —— 写死的演示分支,跟实际派活没有数据通路。删除或重构请同步
// 主页面对应的 UI(目前嵌在 Step 2 上半部分 isUpperPanelCollapsed 分支里,
// 默认 `false &&` 隐藏)。
const handleBudgetChange = useCallback(
(newVal: number) => {
setBudgetLimit(newVal);
triggerToast(`💰 研商经费调优约束变更为 ${newVal} 万!各大智能体启动资源重算...`);
setTimeout(() => {
const respTime = nowTime();
let agentOpinion = "";
let speakerType = "amber";
let senderName = "风险审查智能体";
if (newVal < 400) {
agentOpinion = `⚠️ [预算警报] 经费压缩至 ${newVal} 万元,自动触发紧急安全妥协防御策略!我们将不得不裁剪旁路冷备份服务器配置,降低静态演练深度,算力容灾等级重下降至 Tier I (低安全水位)。`;
} else if (newVal > 540) {
agentOpinion = `🔋 [方案容量上升] 预算上调至充足的 ${newVal} 万元,资装弹性充裕!我已为 A 套极速构建增加了 4 组外部超大异地区容灾节点支持,灾备冗余度成功突破 Tier III+ 指标,高防高内聚!`;
speakerType = "purple";
senderName = "情报收集与适配";
} else {
agentOpinion = `⚡ [资源调配优化] 总体经费设定为 ${newVal} 万元,空间适中。我们重新规划了轻量容器 K3s 集成开销(约 ${Math.round(newVal * 0.45)} 万)和安全审计费比,总体博弈过程保持稳定态收敛。`;
speakerType = "pink";
senderName = "执行规划智能体";
}
setStep2RoundtableDialogues((prev) => [
...prev,
{
id: Date.now() + 10,
sender: senderName,
avatarType: speakerType,
confidence: "资源级自适应重算",
content: agentOpinion,
time: respTime,
},
]);
}, 1500);
},
[triggerToast],
);
const handleSaveAgentAndRecalculate = useCallback(
(id: number) => {
// 通过 agentNameToRole 反查席位 displayName。id 是 seat id(2..N+1),
// 对应 selectedAgents 的索引 i = id - 2,直接拿其 displayName 更可靠。
const seatIdx = id - 2;
const seatAgent = selectedAgents[seatIdx];
const meta = seatAgent ? agentNameToRole[seatAgent.agent_id] : undefined;
const roleName = meta?.displayName ?? "专家智能体";
const cfg = agentConfigs[id];
const targetModel = cfg?.model || selectedModel || "默认模型";
const avatarColor = meta?.avatarType ?? "blue";
appendDialogue({
sender: `${roleName} (${targetModel})`,
avatarType: avatarColor,
content: `⚡ [模型偏好已保存] 本席位在下一轮研讨中将使用 ${targetModel} 进行推理。系统提示词与温度(${cfg?.temp ?? 0.2})当前仅在前端记录,等后端开放覆写后再同步生效。`,
confidence: "席位偏好",
});
setSelectedAgentId(null);
triggerToast(`⚙️ "${roleName}"已切换至 ${targetModel},下轮生效`);
},
[agentConfigs, agentNameToRole, appendDialogue, selectedAgents, selectedModel, triggerToast],
);
const resetInitGate = useCallback(() => {
step2InitRef.current = false;
// 新一轮研讨:允许 DAG 计划重新执行一遍。
dagPlanExecutedRef.current = false;
setInitNonce((n) => n + 1);
}, []);
const hydrateFromDraft = useCallback(
(snapshot: Step2Snapshot) => {
// 同步中断当前 in-flight + 清 runtime 标记。原页面的 handleLoadDraft 也是
// 同步置 false 而不是等编排 loop 的 finally —— 否则用户加载草稿后还能看到
// 「正在编排」的灰色态。abortAllInFlight 已经在 cancelAllStreams 里调过,
// 这里只重复设 cancel ref 与 UI 标记,确保从 Step 2 在编排中切到草稿时
// UI 立刻翻成静止态。
orchestrationCancelRef.current = true;
setIsOrchestrating(false);
setIsInitializing(false);
setPendingClarification(null);
setClarificationDraft("");
// 先恢复 selectedAgents —— roleTemplates / agentNameToRole 都由它派生,
// 主页面的「参与角色」面板据此重建,否则会停留在默认 6 个席位。
// 老草稿没有这字段时保持当前值不动(避免清空成空数组)。
if (snapshot.selectedAgents && snapshot.selectedAgents.length > 0) {
setSelectedAgents(snapshot.selectedAgents);
}
setThreadIds(snapshot.threadIds);
setCoordinatorName(snapshot.coordinatorName);
setStep2RoundtableDialogues(snapshot.step2RoundtableDialogues);
setLastLeaderContent(snapshot.lastLeaderContent);
setHasConsensus(snapshot.hasConsensus);
setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit);
const restoredStubs = snapshot.seatStubMessages ?? {};
seatStubMessagesRef.current = restoredStubs;
setSeatStubMessages(restoredStubs);
// 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。
hasAutoOpenedRef.current = new Set();
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
// 调用方需要的话再 resetInitGate()。
step2InitRef.current = true;
},
[],
);
const resetStep2State = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
setIsOrchestrating(false);
setIsInitializing(false);
setPendingClarification(null);
setClarificationDraft("");
setThreadIds(null);
setCoordinatorName(null);
setInitError(null);
setStep2RoundtableDialogues([]);
setLastLeaderContent("");
setHasConsensus(false);
setConsensusPercentage(0);
setParallelBatch([]);
setSelectedParallelSeats([]);
setDagNodeStates({});
resetParallelStreams();
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
seatStubMessagesRef.current = {};
setSeatStubMessages({});
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
dagPlanExecutedRef.current = false;
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts, resetParallelStreams]);
return {
selectedAgents,
threadIds,
coordinatorName,
isInitializing,
initError,
isOrchestrating,
lastLeaderContent,
hasConsensus,
consensusPercentage,
step2RoundtableDialogues,
pendingClarification,
clarificationDraft,
isRoundTableRunning,
activeSpeakerId,
activeSpeakerSet,
customAgentInput,
isUpperPanelCollapsed,
parallelBatch,
selectedParallelSeats,
isParallelRunning: parallelBatch.length > 0,
toggleParallelSeat,
dagNodeStates,
budgetLimit,
agentConfigs,
selectedAgentId,
roleTemplates,
agentNameToRole,
roundtableAgentIds,
setSelectedAgents,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
setSelectedAgentId,
setIsUpperPanelCollapsed,
setAgentConfigs,
resetInitGate,
stopOrchestration,
pauseRoundtable,
resumeRoundtable,
recordInjectMessage,
abortAllInFlight,
handleInjectToRoundtable,
submitClarification,
handleBudgetChange,
handleSaveAgentAndRecalculate,
hydrateFromDraft,
resetStep2State,
activeArtifactThreadId,
seatStubMessages,
openArtifactPreview,
openArtifactFromStep,
};
}