diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index d4509999..9c0df9b4 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -290,12 +290,14 @@ export interface StreamMultiAgentRequest { * show progressive status messages during the inevitable first-token wait. * `name` is the tool name when phase is `tool_calling` (or empty otherwise). * `args` 是 tool_calling 阶段的工具入参(取自上游 LangGraph messages-tuple - * 帧的 tool_calls[0].args),供 UI 渲染路径 chip / 代码块等子节点。 + * 帧中当前 tool call 的 args),供 UI 渲染路径 chip / 代码块等子节点。 + * `toolCallId` 可将同名工具的连续调用与同一次调用的增量参数区分开。 */ onPhaseChange?: ( phase: StreamPhase, name?: string, args?: Record, + toolCallId?: string, ) => void; /** * 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个 @@ -619,27 +621,32 @@ export async function streamMultiAgent( let currentPhase: StreamPhase = "connecting"; let currentPhaseName = ""; let currentPhaseArgsSignature = ""; + let currentPhaseToolCallId = ""; req.onPhaseChange?.("connecting"); const setPhase = ( next: StreamPhase, name?: string, args?: Record, + toolCallId?: string, ) => { const nextName = name ?? ""; const nextArgsSignature = phaseArgsSignature(args); + const nextToolCallId = toolCallId ?? ""; // 同 phase、同工具、同身份入参的帧只是流式重复帧,不通知 UI。保留 path/filepaths // 等身份变化,确保工具参数从不完整变完整时仍可自动打开沙箱。 if ( next === currentPhase && nextName === currentPhaseName && - nextArgsSignature === currentPhaseArgsSignature + nextArgsSignature === currentPhaseArgsSignature && + nextToolCallId === currentPhaseToolCallId ) { return; } currentPhase = next; currentPhaseName = nextName; currentPhaseArgsSignature = nextArgsSignature; - req.onPhaseChange?.(next, name, args); + currentPhaseToolCallId = nextToolCallId; + req.onPhaseChange?.(next, name, args, toolCallId); }; /** @@ -811,24 +818,29 @@ export async function streamMultiAgent( if (chunkType === "tool") { setPhase("tool_result"); } else if (hasToolCall) { - // Pick the most informative name + args we have so far. + // Pick the active (latest) call's name + args. A leader can dispatch + // several seats with the same `agent_orchestration` tool in one run; + // choosing the first accumulated call would leave the UI stuck on an + // earlier assignment until the final response arrives. // 优先用累积出的 / 完整的 tool_calls(含可读 args 对象);若都还不到火候, // 回退到 tool_call_chunks 拿名字(args 还是 string,不透传)。 - const firstCall = + const activeCall = effectiveToolCalls && effectiveToolCalls.length > 0 - ? (effectiveToolCalls[0] as Record) + ? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record) : Array.isArray(toolCallChunks) && toolCallChunks.length > 0 - ? (toolCallChunks[0] as Record) + ? (toolCallChunks[toolCallChunks.length - 1] as Record) : null; const toolName = - firstCall && typeof firstCall.name === "string" ? firstCall.name : ""; + activeCall && typeof activeCall.name === "string" ? activeCall.name : ""; const args = - firstCall && - typeof firstCall.args === "object" && - firstCall.args !== null - ? (firstCall.args as Record) + activeCall && + typeof activeCall.args === "object" && + activeCall.args !== null + ? (activeCall.args as Record) : undefined; - setPhase("tool_calling", toolName, args); + const toolCallId = + activeCall && typeof activeCall.id === "string" ? activeCall.id : undefined; + setPhase("tool_calling", toolName, args, toolCallId); } else if ( (chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") && ((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0)) diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 42ec7d56..5b70d648 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -16,7 +16,10 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useArtifacts } from "@/components/workspace/artifacts"; +import { + artifactPreviewKey, + useArtifacts, +} from "@/components/workspace/artifacts"; import { initMultiAgent, streamMultiAgent, @@ -37,6 +40,21 @@ type RunFileRef = NonNullable[number]; type SeatDelivery = { name: string; content: string }; +/** + * `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活, + * 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。 + */ +function streamStepKey( + phase: StreamPhase, + toolName?: string, + toolCallId?: string, +): string { + if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) { + return `${phase}:${toolName}:${toolCallId}`; + } + return `${phase}:${toolName ?? ""}`; +} + /** In-memory cursor for a business-chain human-validation pause. */ type HumanValidationContinuation = | { mode: "chain"; nextSeatIndex: number } @@ -179,7 +197,7 @@ export interface ParallelSeatView { } /** - * Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext + * Stub thread message —— 用于把 special seat SSE 拿到的工具调用(及其合成 tool result)注入到 ThreadContext * 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL * `write-file:?message_id=...&tool_call_id=...` 直接读 `args.content`, * 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。 @@ -191,9 +209,59 @@ export interface StubToolCall { } export interface StubAiMessage { id: string; - type: "ai"; + type: "ai" | "tool"; content: string; tool_calls: StubToolCall[]; + /** tool 类型消息对应的原始 tool call;供沙箱判定 write_file 已完成。 */ + tool_call_id?: string; +} + +/** + * 圆桌流为了实时预览会合成一条带 write_file 的 ai 消息。主聊天真实流在工具执行结束后 + * 还会收到对应的 tool 消息;圆桌 special 流此前漏了这一步,导致沙箱永久认为文件仍在写。 + * + * 这里只补齐已经存在的 write_file 调用,绝不根据整条会商/其它并行流的 running 状态推断, + * 因而每个席位、报告、流程图和大屏预览的完成态彼此独立。 + */ +export function completeStubWriteFile( + messages: StubAiMessage[], + toolCallId: string, +): StubAiMessage[] { + if (!toolCallId || messages.some((message) => message.type === "tool" && message.tool_call_id === toolCallId)) { + return messages; + } + const source = messages.find( + (message) => + message.type === "ai" && + message.tool_calls.some( + (toolCall) => toolCall.id === toolCallId && toolCall.name === "write_file", + ), + ); + if (!source) return messages; + return [ + ...messages, + { + id: `roundtable-stub-tool-result-${source.id}-${toolCallId}`, + type: "tool", + content: "", + tool_calls: [], + tool_call_id: toolCallId, + }, + ]; +} + +/** 补齐一段已正常收尾的圆桌 stub 中全部 write_file 的工具返回。 */ +export function completeAllStubWriteFiles(messages: StubAiMessage[]): StubAiMessage[] { + let next = messages; + for (const message of messages) { + if (message.type !== "ai") continue; + for (const toolCall of message.tool_calls) { + if (toolCall.name === "write_file") { + next = completeStubWriteFile(next, toolCall.id); + } + } + } + return next; } /** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */ @@ -515,19 +583,12 @@ export function useStep2Orchestration( select: selectArtifact, setOpen: setArtifactsOpen, deselect: deselectArtifact, - autoOpen, - autoSelect, + openStreamedArtifact, } = useArtifacts(); const artifactPathsRef = useRef([]); 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>(new Set()); @@ -562,6 +623,9 @@ export function useStep2Orchestration( const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState< Step2Dialogue[] >([]); + // 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file, + // 不会误影响仍在并行运行的其它席位。 + const dialogueThreadIdRef = useRef>({}); const [pendingClarification, setPendingClarification] = useState(null); const [clarificationDraft, setClarificationDraft] = useState(""); @@ -837,13 +901,42 @@ export function useStep2Orchestration( [scheduleSeatStubMessagesFlush], ); + /** 将某次真实 tool 返回同步到对应的虚拟 write_file 预览。 */ + const completeSeatStubWriteFile = useCallback( + (seatThreadId: string, toolCallId: string) => { + const current = seatStubMessagesRef.current[seatThreadId] ?? []; + const nextMessages = completeStubWriteFile(current, toolCallId); + if (nextMessages === current) return; + const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages }; + seatStubMessagesRef.current = nextMap; + pendingSeatStubMessagesRef.current = nextMap; + scheduleSeatStubMessagesFlush(); + }, + [scheduleSeatStubMessagesFlush], + ); + + /** special 流正常收尾的兜底:补齐该席位尚未透传出来的 write_file tool 返回。 */ + const completeSeatStubWriteFiles = useCallback( + (seatThreadId: string | undefined) => { + if (!seatThreadId) return; + const current = seatStubMessagesRef.current[seatThreadId] ?? []; + const nextMessages = completeAllStubWriteFiles(current); + if (nextMessages === current) return; + const nextMap = { ...seatStubMessagesRef.current, [seatThreadId]: nextMessages }; + seatStubMessagesRef.current = nextMap; + pendingSeatStubMessagesRef.current = nextMap; + scheduleSeatStubMessagesFlush(); + }, + [scheduleSeatStubMessagesFlush], + ); + /** * 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content, * 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。 * * auto=true 流式期间: * - 已自动打开过该 virtualUrl 就直接跳过(防止 onPhaseChange / onUpstreamFrame 每帧重复 schedule); - * - 用户手动关闭过沙箱(`autoOpen` 已被 ArtifactsProvider 置 false)也尊重决定,不再自动开。 + * - 用户手动关闭过**当前文件**时不反复弹开;后续新的 write_file 调用仍允许自动预览。 * auto=false(用户点击步骤 / 文件卡)始终强制打开。 */ const openSeatVirtualArtifact = useCallback( @@ -861,8 +954,6 @@ export function useStep2Orchestration( 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 ( @@ -882,6 +973,11 @@ export function useStep2Orchestration( return; } hasAutoOpenedRef.current.add(virtualUrl); + setActiveArtifactThreadId(seatThreadId); + // 由 ArtifactsProvider 以 tool-call 维度管理「手动关闭后不再弹同一份文件」: + // 当前文件被关闭过 → false;下一次 write_file 的 previewKey 不同 → 仍会自动打开。 + openStreamedArtifact(virtualUrl, artifactPreviewKey(virtualUrl)); + return; } setActiveArtifactThreadId(seatThreadId); const open = () => { @@ -889,13 +985,9 @@ export function useStep2Orchestration( selectArtifact(virtualUrl, !manual); setArtifactsOpen(true); }; - if (auto) { - window.setTimeout(open, 100); - } else { - open(); - } + open(); }, - [selectArtifact, setArtifactsOpen], + [openStreamedArtifact, selectArtifact, setArtifactsOpen], ); const buildWriteFileVirtualUrl = useCallback( @@ -1156,6 +1248,13 @@ export function useStep2Orchestration( const chunk = frame[0]; if (!chunk || typeof chunk !== "object") return; const obj = chunk as Record; + // special 流透传真实工具返回时,必须同步收口虚拟 write_file;否则右侧沙箱会把 + // 已完成的文件永久当成「正在生成」。 + if (String(obj.type ?? "").toLowerCase() === "tool") { + const toolCallId = typeof obj.tool_call_id === "string" ? obj.tool_call_id : ""; + if (toolCallId) completeSeatStubWriteFile(seatThreadId, toolCallId); + return; + } const toolCalls = obj.tool_calls; if (!Array.isArray(toolCalls) || toolCalls.length === 0) return; for (const tc of toolCalls) { @@ -1171,7 +1270,7 @@ export function useStep2Orchestration( upsertSeatToolCall(seatThreadId, name, argsObj); } }, - [upsertSeatToolCall], + [completeSeatStubWriteFile, upsertSeatToolCall], ); // ── 「多智能体问答」子智能体任务卡片(参照主聊天 message-list 的 task 识别)────────── @@ -1402,15 +1501,28 @@ export function useStep2Orchestration( let nextDialogue: Step2Dialogue; if (last && last.key === key) { - // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 - // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 - // content chunk 更新对话 state。 - const hasVisibleArgs = (value?: Record) => - !!value && Object.keys(value).some((argKey) => argKey !== "content"); - if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; - const nextSteps = [...steps]; - nextSteps[nextSteps.length - 1] = { ...last, args }; - nextDialogue = { ...dialogue, steps: nextSteps }; + // 总控派活的 task 是 tool_call JSON 的流式增量。不能沿用普通工具的 + // 「首次可见参数即停」策略,否则只显示 agent_name,整段任务会在收尾时 + // 才被最终消息一次性补上。 + if (key.startsWith("tool_calling:agent_orchestration:") && args) { + const mergedArgs = { ...(last.args ?? {}), ...args }; + if (JSON.stringify(last.args ?? {}) === JSON.stringify(mergedArgs)) { + return prev; + } + const nextSteps = [...steps]; + nextSteps[nextSteps.length - 1] = { ...last, args: mergedArgs }; + nextDialogue = { ...dialogue, steps: nextSteps }; + } else { + // 同一个工具调用在流式期间会反复带来增量入参。步骤卡只需在「首次拿到 + // 可见参数」时补一次;文件正文由 upstream frame 同步给沙箱,不能随每个 + // content chunk 更新对话 state。 + const hasVisibleArgs = (value?: Record) => + !!value && Object.keys(value).some((argKey) => argKey !== "content"); + if (hasVisibleArgs(last.args) || !hasVisibleArgs(args)) return prev; + const nextSteps = [...steps]; + nextSteps[nextSteps.length - 1] = { ...last, args }; + nextDialogue = { ...dialogue, steps: nextSteps }; + } } else { nextDialogue = { ...dialogue, @@ -1438,6 +1550,9 @@ export function useStep2Orchestration( agentThreadId?: string; }): number => { const id = Date.now() + Math.random(); + if (card.agentThreadId) { + dialogueThreadIdRef.current[id] = card.agentThreadId; + } setStep2RoundtableDialogues((prev) => [ ...prev, { @@ -1641,10 +1756,8 @@ export function useStep2Orchestration( const opened = openSeatLatestArtifact(tid); if (!opened) { // 当前显示席位**暂时**还没有沙箱产物(常见:并行批次第一个席位刚选中、还没写文件)→ - // 关闭沙箱面板,但**只用 deselect**(它走 raw setOpen(false),**不**关掉 autoOpen); - // 千万别再调 setArtifactsOpen(false) —— 那个 wrapped setter 会把 autoOpen 置 false - // (context.tsx:73),导致该席位**随后 live 写文件时 openSeatVirtualArtifact 的 - // `!autoOpen` 守卫拦掉自动打开**(正是「第一个席位不自动开沙箱、要手动开」的 bug)。 + // 用 deselect 收起并清空旧预览,不把别的席位当前文件标记为「手动关闭」。这样该席位 + // 随后的新 write_file 仍能按自己的 previewKey 自动打开。 setActiveArtifactThreadId(null); deselectArtifact(); } @@ -1658,6 +1771,9 @@ export function useStep2Orchestration( const finalizeDialogue = useCallback( (id: number, finalContent: string, confidence?: string) => { + // 本气泡对应的 special 流已收尾:为该席位(仅该席位)补齐 write_file tool result。 + // 这是对少数没有把 tool 帧透传到 onUpstreamFrame 的后端流的安全兜底。 + completeSeatStubWriteFiles(dialogueThreadIdRef.current[id]); // 最终结果已经是完整文本,丢弃尚未 flush 的 token 缓冲,避免收尾后旧增量反写。 directStreamDeltaRef.current.delete(id); if ( @@ -1693,7 +1809,7 @@ export function useStep2Orchestration( return changed ? next : prev; }); }, - [], + [completeSeatStubWriteFiles], ); const highlightSpeaker = useCallback( @@ -1991,13 +2107,13 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); appendStepIfNew( leaderBubbleId, label, - `${phase}:${toolName ?? ""}`, + streamStepKey(phase, toolName, toolCallId), args, ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); @@ -2274,10 +2390,15 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); - appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + leaderBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -2460,10 +2581,15 @@ export function useStep2Orchestration( signal: closeoutAborter.signal, onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false), onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(closeoutBubbleId, label); - appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + closeoutBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -2639,10 +2765,15 @@ export function useStep2Orchestration( signal: leaderAborter.signal, onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false), onTextDelta: (text) => appendToDialogue(leaderBubbleId, text), - onPhaseChange: (phase, toolName, args) => { + onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "leader", toolName); setDialogueConfidence(leaderBubbleId, label); - appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew( + leaderBubbleId, + label, + streamStepKey(phase, toolName, toolCallId), + args, + ); handleArtifactToolPhase(phase, toolName, args, leaderThreadId); }, onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId), @@ -3431,8 +3562,20 @@ export function useStep2Orchestration( setHasConsensus(snapshot.hasConsensus); setConsensusPercentage(snapshot.consensusPercentage); setBudgetLimit(snapshot.budgetLimit); - const restoredStubs = snapshot.seatStubMessages ?? {}; + // 历史草稿是静态完成记录;旧版本未持久化 tool result 的 write_file 也必须收口, + // 否则从「最终方案展示」回看时会错误显示仍在生成。 + const restoredStubs = Object.fromEntries( + Object.entries(snapshot.seatStubMessages ?? {}).map(([threadId, messages]) => [ + threadId, + completeAllStubWriteFiles(messages), + ]), + ); replaceSeatStubMessages(restoredStubs); + dialogueThreadIdRef.current = Object.fromEntries( + (snapshot.step2RoundtableDialogues ?? []) + .filter((dialogue) => Boolean(dialogue.agentThreadId)) + .map((dialogue) => [dialogue.id, dialogue.agentThreadId!]), + ); // 草稿里历史 write_file 的虚拟 URL 与当前会话无关 —— 清空守卫,允许新 run 触发自动打开。 hasAutoOpenedRef.current = new Set(); // 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上, @@ -3499,6 +3642,7 @@ export function useStep2Orchestration( setCoordinatorName(null); setInitError(null); setStep2RoundtableDialogues([]); + dialogueThreadIdRef.current = {}; setLastLeaderContent(""); setHasConsensus(false); setConsensusPercentage(0); diff --git a/frontend-web/src/roundtable-planning/lib/step-display.tsx b/frontend-web/src/roundtable-planning/lib/step-display.tsx index 36ffa9dd..e30310dd 100644 --- a/frontend-web/src/roundtable-planning/lib/step-display.tsx +++ b/frontend-web/src/roundtable-planning/lib/step-display.tsx @@ -29,7 +29,7 @@ import { MarkdownContent } from "@/roundtable-planning/components/MessageBubble" /** * 根据工具名 + args 渲染 ChainOfThoughtStep 的子节点(路径 chip / 命令代码块等)。 - * 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧的 tool_calls[0].args。 + * 数据来自 streamMultiAgent 解析 LangGraph messages-tuple 帧中当前工具调用的 args。 * * 主聊天的 `ToolCall` 还会消费 tool result(走 `findToolCallResult`),我们这边 * 拿不到 result(只走 tool_calls 增量帧),所以只渲染 args。