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.
* `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<string, unknown>,
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<string, unknown>,
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<string, unknown>)
? (effectiveToolCalls[effectiveToolCalls.length - 1] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
? (toolCallChunks[toolCallChunks.length - 1] as Record<string, unknown>)
: 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<string, unknown>)
activeCall &&
typeof activeCall.args === "object" &&
activeCall.args !== null
? (activeCall.args as Record<string, unknown>)
: 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))
@@ -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<StreamMultiAgentRequest["files"]>[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:<path>?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<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());
@@ -562,6 +623,9 @@ export function useStep2Orchestration(
const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState<
Step2Dialogue[]
>([]);
// 由气泡 id 反查真实席位线程。完成一条气泡时只收口该线程的虚拟 write_file,
// 不会误影响仍在并行运行的其它席位。
const dialogueThreadIdRef = useRef<Record<number, string>>({});
const [pendingClarification, setPendingClarification] =
useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState<string>("");
@@ -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<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;
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<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 };
// 总控派活的 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<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 {
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);
@@ -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。