2618 lines
113 KiB
TypeScript
2618 lines
113 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 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,
|
||
};
|
||
}
|