1383 lines
50 KiB
TypeScript
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,
|
|
};
|
|
}
|