fix(会商第二步): 恢复总控派活实时展示并修正沙箱预览状态

- 流式解析改为跟踪当前 tool call id,连续多次 agent_orchestration 派活时前端即时展示最新席位和任务,不再等模型完整回复后一次性出现。

 - 总控派活步骤按 tool call 独立归档并合并增量参数,避免同名工具调用互相覆盖或只显示半截任务。

 - 沙箱自动打开改为按单个产物预览键管理:用户关闭当前文件后不反复弹出,但后续新文件仍可自动预览。

 - 为圆桌虚拟 write_file 补齐工具完成结果,并在席位收尾和历史草稿恢复时兜底收口,防止完成文件长期显示“正在生成”。
This commit is contained in:
董宏凯 committed 2026-08-30 21:24:30 +08:00
1 parent 74fc849241
commit 2254841822
3 files changed
+214 -58

No files matched your search

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