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

1383 lines
50 KiB
TypeScript

/**
* 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 ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
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>;
}>;
};
/**
* 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 = {
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[]>;
};
/** 席位 Persona 配置;初始为空,用户在 Persona 抽屉保存后写入。 */
const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {};
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/** 进入 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;
// ─── 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;
/** @deprecated 请用 pauseRoundtable + recordInjectMessage;保留兼容组合调用。 */
handleInjectToRoundtable: () => 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,
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[]>([]);
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);
// ─── 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);
const step2InitRef = useRef<boolean>(false);
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
/** 干预研审:恢复会商后待执行的 leader prompt。 */
const pendingInjectPromptRef = useRef<string | 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);
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) => {
if (auto) {
if (hasAutoOpenedRef.current.has(virtualUrl)) return;
const flags = autoOpenFlagsRef.current;
if (!flags.autoOpen || !flags.autoSelect) return;
hasAutoOpenedRef.current.add(virtualUrl);
}
setActiveArtifactThreadId(seatThreadId);
const open = () => {
selectArtifact(virtualUrl, auto);
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(),
[],
);
/**
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
*/
const openArtifactPreview = useCallback(
(threadId: string, path: string, options?: { auto?: boolean }) => {
const normalized = path.trim();
if (!normalized || !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();
}
},
[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 === "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,
),
);
}, []);
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) => {
setActiveSpeakerSet((prev) => {
if (prev.has(speakerId)) return prev;
const next = new Set(prev);
next.add(speakerId);
return next;
});
setRoles((prev) =>
prev.map((item) =>
item.id === speakerId ? { ...item, status: "研讨中" } : 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]);
// ─── 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;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
let nextLeaderTask = seedMessage;
let safetyCounter = 0;
try {
while (
!orchestrationCancelRef.current &&
safetyCounter < MAX_CYCLES
) {
safetyCounter += 1;
highlightSpeaker(2);
// 总控也可能直接 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,
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 (orchestrationCancelRef.current) 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 (orchestrationCancelRef.current) 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 (orchestrationCancelRef.current) {
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 (orchestrationCancelRef.current) 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,
],
);
// ─── 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 runOrchestration(
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]);
// 挂起 / 恢复:与上面的 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 runOrchestration(
pending,
threadIdsRef.current,
coordinatorNameRef.current,
);
return;
}
void runOrchestration(
"用户已恢复研讨,请继续上一轮的协调工作。",
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请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
triggerToast("干预指令已记录,点击「恢复会商」后执行");
}, [appendDialogue, customAgentInput, triggerToast]);
const handleInjectToRoundtable = useCallback(() => {
pauseRoundtable();
recordInjectMessage();
}, [pauseRoundtable, recordInjectMessage]);
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 runOrchestration(
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
threadIdsRef.current,
coordinatorNameRef.current,
);
},
[appendDialogue, pendingClarification, runOrchestration, 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;
}, []);
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("");
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);
setActiveArtifactThreadId(null);
artifactPathsRef.current = [];
setArtifacts([]);
seatStubMessagesRef.current = {};
setSeatStubMessages({});
hasAutoOpenedRef.current = new Set();
pendingInjectPromptRef.current = null;
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
return {
selectedAgents,
threadIds,
coordinatorName,
isInitializing,
initError,
isOrchestrating,
lastLeaderContent,
hasConsensus,
consensusPercentage,
step2RoundtableDialogues,
pendingClarification,
clarificationDraft,
isRoundTableRunning,
activeSpeakerId,
activeSpeakerSet,
customAgentInput,
isUpperPanelCollapsed,
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,
};
}