From f5bf7897b9202e67e2b5427bbc689a7d2fbd3595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 2 Sep 2026 12:52:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20=E6=94=AF=E6=8C=81=20Roundtab?= =?UTF-8?q?le=20=E5=86=99=E6=96=87=E4=BB=B6=E6=B5=81=E5=BC=8F=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=8E=E6=B6=88=E6=81=AF=E6=B5=81=E8=81=9A=E5=90=88?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能说明:\n- 为写文件工具链增加流式预览状态模型(in-flight、chunk 归并、已完成)\n- 在消息面板中支持 write_file 流式 artifact 的动态插入/更新与完成态呈现\n- 优化 artifact 详情页的文件名与来源显示,兼容流式生成中文件路径\n- 在 Roundtable 工作流 step2 流程中注入与监听 write_file 事件,支持多阶段消息统一展示\n- 补充单测覆盖:streaming-write-preview、write-file-in-flight、streaming-artifacts 等关键路径 --- .../artifacts/artifact-file-detail.tsx | 24 ++-- .../workspace/messages/message-group.tsx | 14 +- .../artifacts/streaming-write-preview.test.ts | 8 ++ .../core/artifacts/streaming-write-preview.ts | 17 +++ .../artifacts/write-file-in-flight.test.ts | 28 ++++ .../core/artifacts/write-file-in-flight.ts | 4 + frontend-web/src/pages/AgentChatPage.tsx | 20 ++- .../roundtable-planning/api/multi-agent.ts | 14 +- .../hooks/useStep2Orchestration.ts | 132 ++++++++++++++---- .../lib/streaming-artifacts.test.ts | 18 +++ .../lib/streaming-artifacts.ts | 31 ++++ 11 files changed, 266 insertions(+), 44 deletions(-) diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index b876ee17..0b23eaf7 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -428,11 +428,15 @@ export function ArtifactFileDetail({ if (streamPerfEnabled && isLiveStreaming) return raw; return desensitize(raw); }, [disableDesensitize, displayContent, isLiveStreaming, streamPerfEnabled]); - const artifactContentPending = - isCodeFile && - !useWriteFileStreaming && - content === undefined && - isArtifactContentLoading; + const artifactContentPending = + isCodeFile && + !useWriteFileStreaming && + content === undefined && + isArtifactContentLoading; + // `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块 + // 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。 + const streamingWriteContentPending = + useWriteFileStreaming && writeFileInFlight && !content; const artifactContentUnavailable = isCodeFile && !useWriteFileStreaming && @@ -1766,10 +1770,10 @@ export function ArtifactFileDetail({ activeCursor={isApplyingEdit} /> )} - {artifactContentPending && viewMode === "code" && ( -
-
)} - {isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && ( + {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
{ assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true); assert.equal(isArtifactApiPath("/mnt/user-data/outputs/report.md"), true); }); + +test("isAutoPreviewableArtifactPath limits automatic previews to outputs and provisional drafts", () => { + assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/outputs/report.html"), true); + assert.equal(isAutoPreviewableArtifactPath("未命名文件.html"), true); + assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/scan_structure.py"), false); + assert.equal(isAutoPreviewableArtifactPath("/mnt/user-data/workspace/draft.html"), false); +}); diff --git a/frontend-web/src/core/artifacts/streaming-write-preview.ts b/frontend-web/src/core/artifacts/streaming-write-preview.ts index c2ec791f..4fc73091 100644 --- a/frontend-web/src/core/artifacts/streaming-write-preview.ts +++ b/frontend-web/src/core/artifacts/streaming-write-preview.ts @@ -59,6 +59,23 @@ export function isArtifactApiPath(path: string): boolean { ); } +/** + * Whether a streamed write is a user-facing deliverable that may open the + * artifact panel automatically. Agents often use ``workspace/`` for helper + * scripts and intermediate data; previewing those files is noisy and can show + * an empty sandbox before the real output exists. + */ +export function isAutoPreviewableArtifactPath(path: string): boolean { + if (isProvisionalStreamingArtifactPath(path)) { + return true; + } + const normalized = normalizeStreamingArtifactPath(path) + .split(/[?#]/, 1)[0] + ?.replace(/^\/+/, "") + .replace(/\\/g, "/"); + return normalized === "mnt/user-data/outputs" || normalized?.startsWith("mnt/user-data/outputs/"); +} + export function languageOfStreamingArtifact( path: string, ): StreamingArtifactLanguage | null { diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts index 827504e6..a16f5c82 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts @@ -208,6 +208,34 @@ test("normal mode keeps previewing pathless streaming drafts", () => { assert.match(preview?.artifactPath ?? "", /\.md/); }); +test("latestInFlightWriteFilePreview ignores workspace helper files", () => { + const preview = latestInFlightWriteFilePreview([ + { + id: "m1", + type: "ai", + tool_calls: [ + { + id: "w1", + name: "write_file", + args: { + path: "/mnt/user-data/workspace/scan_structure.py", + content: "print('scan')", + }, + }, + { + id: "w2", + name: "write_file", + args: { + path: "/mnt/user-data/workspace/draft.html", + content: "", + }, + }, + ], + }, + ]); + assert.equal(preview, null); +}); + test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => { const preview = latestInFlightWriteFilePreview([ { diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts index 477af28e..07cc111a 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts @@ -4,6 +4,7 @@ import { isImageFile } from "../utils/files"; import { buildWriteFileVirtualUrl, hasCompleteArtifactExtension, + isAutoPreviewableArtifactPath, languageOfStreamingArtifact, normalizeStreamingArtifactPath, provisionalWriteFilePath, @@ -140,6 +141,9 @@ export function latestInFlightWriteFilePreview( if (!languageOfStreamingArtifact(sourcePath)) { continue; } + if (!isAutoPreviewableArtifactPath(sourcePath)) { + continue; + } return { artifactPath: buildWriteFileVirtualUrl({ path: sourcePath, diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index 7121f32b..f6b02c5e 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -38,6 +38,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato import { Tooltip } from "@/components/workspace/tooltip"; import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { getAPIClient } from "@/core/api"; +import { useTheme } from "@/contexts/ThemeContext"; import { useI18n } from "@/core/i18n/hooks"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { useModels } from "@/core/models/hooks"; @@ -92,6 +93,8 @@ import type { Message } from "@langchain/langgraph-sdk"; /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; +/** 实验性页面研报智能体:发送当前界面明暗模式供其选择同色系 HTML 骨架。 */ +const PAGE_REPORT_DESIGNER_AGENT_ID = "page-report-designer"; const RWFX_SQ_REPORT_CONTEXT_MAX_CHARS = 12000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; @@ -226,6 +229,7 @@ export default function AgentChatPage({ } = {}) { const routeBase = useRouteBase(); const { t } = useI18n(); + const { baseMode } = useTheme(); const [showFollowups, setShowFollowups] = useState(false); const router = useRouter(); // 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar; @@ -677,13 +681,18 @@ export default function AgentChatPage({ settings.context.writing_mode && writingArtifactPath ? { writing_artifact_path: writingArtifactPath } : {}; + const pageReportThemeContext = + agent_id === PAGE_REPORT_DESIGNER_AGENT_ID + ? { presentation_theme: baseMode } + : {}; void sendMessage(threadId, message, { ...writingExtraContext, ...(extraContext ?? {}), agent_id, + ...pageReportThemeContext, }); }, - [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath], + [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode], ); // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 @@ -757,9 +766,14 @@ export default function AgentChatPage({ async (answer: string) => { setHasStartedConversation(true); await stopActiveThreadBeforeClarificationReply(threadId, thread); - await sendMessage(threadId, { text: answer, files: [] }, { agent_id }); + await sendMessage(threadId, { text: answer, files: [] }, { + agent_id, + ...(agent_id === PAGE_REPORT_DESIGNER_AGENT_ID + ? { presentation_theme: baseMode } + : {}), + }); }, - [sendMessage, thread, threadId, agent_id], + [sendMessage, thread, threadId, agent_id, baseMode], ); const handleStop = useCallback(async () => { diff --git a/frontend-web/src/roundtable-planning/api/multi-agent.ts b/frontend-web/src/roundtable-planning/api/multi-agent.ts index 2a574f35..5f3b309b 100644 --- a/frontend-web/src/roundtable-planning/api/multi-agent.ts +++ b/frontend-web/src/roundtable-planning/api/multi-agent.ts @@ -809,8 +809,20 @@ export async function streamMultiAgent( // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 ingestToolCallChunks(toolCallChunks); + // Some providers serialize `tool_calls` before their `args` have + // been decoded, leaving args as the raw JSON string. Treating that + // as a complete call suppresses the accumulated tool_call_chunks, + // so write_file reaches the sandbox with a path but no content. const hasCompleteToolCalls = - Array.isArray(toolCalls) && toolCalls.length > 0; + Array.isArray(toolCalls) && + toolCalls.length > 0 && + toolCalls.every( + (toolCall) => + !!toolCall && + typeof toolCall === "object" && + !!(toolCall as Record).args && + typeof (toolCall as Record).args === "object", + ); let synthetic: SyntheticToolCall[] = []; if (!hasCompleteToolCalls && toolCallAcc.size > 0) { synthetic = buildSyntheticToolCalls(); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts index 55212c66..dc6abc97 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts @@ -36,6 +36,7 @@ import { uploadFiles } from "@/core/uploads/api"; import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview"; import { collectRoundtableDeliverablePaths, + mergeRoundtableStreamingToolArgs, newestChangedRoundtableArtifact, resolveRoundtableStreamingWritePreview, roundtableArtifactDiskSnapshot, @@ -48,15 +49,21 @@ type RunFileRef = NonNullable[number]; type SeatDelivery = { name: string; content: string }; /** - * `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活, - * 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。 + * `agent_orchestration` / 写文件工具可在一轮内重复调用。用 tool call id 区分每一笔 + * 派活或写入,避免用户点击较早的步骤时误打开后来同名调用的文件。 */ function streamStepKey( phase: StreamPhase, toolName?: string, toolCallId?: string, ): string { - if (phase === "tool_calling" && toolName === "agent_orchestration" && toolCallId) { + if ( + phase === "tool_calling" && + (toolName === "agent_orchestration" || + toolName === "write_file" || + toolName === "str_replace") && + toolCallId + ) { return `${phase}:${toolName}:${toolCallId}`; } return `${phase}:${toolName ?? ""}`; @@ -912,7 +919,12 @@ export function useStep2Orchestration( sourceToolCallId: sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined), name: toolName, - args, + // 进度步骤只保存可见参数(通常只有 path),不能让一次手动点击把先前 + // 流进来的 content 覆盖掉;同理也要保住 content-first 帧随后补齐的 path。 + args: + idx >= 0 + ? mergeRoundtableStreamingToolArgs(baseMsg.tool_calls[idx].args, args) + : { ...args }, }; const nextToolCalls = idx >= 0 @@ -1067,12 +1079,16 @@ export function useStep2Orchestration( const findSeatArtifactSource = useCallback( ( rawPath: string, + preferredToolCallId?: string, ): { seatThreadId: string; messageId: string; toolCallId: string; /** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */ content?: string; + isWriteFile: boolean; + /** 当前 write_file 尚未收到对应 tool result,真实文件可能还没有落盘。 */ + inFlight: boolean; } | null => { const target = rawPath.trim(); if (!target) return null; @@ -1081,30 +1097,70 @@ export function useStep2Orchestration( const c = (tc.args as { content?: unknown })?.content; return typeof c === "string" ? c : undefined; }; + let exactMatch: + | { + seatThreadId: string; + messageId: string; + toolCallId: string; + content?: string; + isWriteFile: boolean; + inFlight: boolean; + } + | null = null; let basenameMatch: - | { seatThreadId: string; messageId: string; toolCallId: string; content?: string } + | { + seatThreadId: string; + messageId: string; + toolCallId: string; + content?: string; + isWriteFile: boolean; + inFlight: boolean; + } | 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) { + // 同一路径可能在同一轮被重写;倒序保证文件卡/步骤优先打开最后一次写入。 + for (let toolIndex = msg.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) { + const tc = msg.tool_calls[toolIndex]; 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; + const isWriteFile = tc.name === "write_file"; + const inFlight = + isWriteFile && + !msgs.some( + (message) => message.type === "tool" && message.tool_call_id === tc.id, + ); + const source = { + seatThreadId, + messageId: msg.id, + toolCallId: tc.id, + content: readContent(tc), + isWriteFile, + inFlight, + }; if (p === target) { - return { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) }; + if ( + preferredToolCallId && + (tc.id === preferredToolCallId || tc.sourceToolCallId === preferredToolCallId) + ) { + return source; + } + if (!exactMatch) exactMatch = source; + continue; } if (!basenameMatch && (p.split("/").pop() ?? p) === targetBase) { - basenameMatch = { seatThreadId, messageId: msg.id, toolCallId: tc.id, content: readContent(tc) }; + basenameMatch = source; } } } - return basenameMatch; + return exactMatch ?? basenameMatch; }, [], ); @@ -1119,11 +1175,15 @@ export function useStep2Orchestration( if (!normalized) return; // present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。 - // 先反查 stub:**仅当 stub 内存里确有内容**(流式期间 / 草稿恢复时捕获了 write_file content) - // 才走虚拟预览——关闭沙箱后再点也能显示,且不受总控 coordinator thread 在 artifacts 路由 - // not found 的影响。 + // 先反查 stub:write_file 尚未完成时即使正文还没有抵达,也必须留在虚拟预览。 + // 真实文件尚未写盘时错误走 HTTP 会造成“点文件 → 加载 → 找不到文件”;正文到达后 + // stub 的同一 tool call 会自动刷新预览。完成且无正文的旧草稿才降级走 HTTP。 const seatSource = findSeatArtifactSource(normalized); - if (seatSource && typeof seatSource.content === "string" && seatSource.content.length > 0) { + if ( + seatSource && + seatSource.isWriteFile && + (seatSource.inFlight || typeof seatSource.content === "string") + ) { const virtualUrl = buildWriteFileVirtualUrl({ path: normalized, messageId: seatSource.messageId, @@ -1270,18 +1330,41 @@ export function useStep2Orchestration( ) => { const seatThreadId = dialogue.agentThreadId; if (!seatThreadId) return; - const [, toolName] = step.key.split(":"); - if (toolName === "write_file" || toolName === "str_replace") { + const [, toolName, sourceToolCallId] = step.key.split(":"); + if (toolName === "write_file") { if (!step.args) return; const path = resolveRoundtableStreamingWritePreview(toolName, step.args); if (!path) return; - const { messageId, toolCallId } = upsertSeatToolCall( + // 点击步骤优先复用已由 upstream frame 建好的同一条 stub,绝不再次用 + // `step.args`(只有路径)写回它。否则会把正在接收的正文覆盖为空。 + const source = findSeatArtifactSource(path, sourceToolCallId); + if ( + source && + source.isWriteFile && + (source.inFlight || typeof source.content === "string") + ) { + const virtualUrl = buildWriteFileVirtualUrl({ + path, + messageId: source.messageId, + toolCallId: source.toolCallId, + }); + openSeatVirtualArtifact(source.seatThreadId, virtualUrl, false); + return; + } + // 极早期还没拿到 upstream frame 的兜底:先建虚拟调用,后续帧会合并内容。 + const { messageId, toolCallId } = upsertSeatToolCall(seatThreadId, toolName, step.args); + openSeatVirtualArtifact( seatThreadId, - toolName, - step.args, + buildWriteFileVirtualUrl({ path, messageId, toolCallId }), + false, ); - const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId }); - openSeatVirtualArtifact(seatThreadId, virtualUrl, false); + return; + } + // str_replace 没有完整 content,不适合虚拟 write_file 预览;它的文件已存在, + // 直接走真实产物读取,避免打开一个永远为空的虚拟编辑器。 + if (toolName === "str_replace") { + const path = resolveRoundtableStreamingWritePreview(toolName, step.args); + if (path) openArtifactPreview(seatThreadId, path); return; } // read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能 @@ -1298,6 +1381,7 @@ export function useStep2Orchestration( }, [ buildWriteFileVirtualUrl, + findSeatArtifactSource, openArtifactPreview, openSeatVirtualArtifact, upsertSeatToolCall, @@ -2192,7 +2276,7 @@ export function useStep2Orchestration( onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(a.bubbleId, label); - appendStepIfNew(a.bubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew(a.bubbleId, label, streamStepKey(phase, toolName, toolCallId), args); handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { @@ -2662,7 +2746,7 @@ export function useStep2Orchestration( onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(subBubbleId, label); - appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew(subBubbleId, label, streamStepKey(phase, toolName, toolCallId), args); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { @@ -3081,7 +3165,7 @@ export function useStep2Orchestration( onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(bubbleId, label); - appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { @@ -3293,7 +3377,7 @@ export function useStep2Orchestration( onPhaseChange: (phase, toolName, args, toolCallId) => { const label = phaseLabel(phase, "special", toolName); setDialogueConfidence(bubbleId, label); - appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); + appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); }, onUpstreamFrame: (frame) => { diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts index 7348ccfe..1ee83846 100644 --- a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts +++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.test.ts @@ -3,11 +3,29 @@ import test from "node:test"; import { collectRoundtableDeliverablePaths, + mergeRoundtableStreamingToolArgs, newestChangedRoundtableArtifact, resolveRoundtableStreamingWritePreview, roundtableArtifactDiskSnapshot, } from "./streaming-artifacts"; +test("roundtable keeps streamed file bytes when a compact progress step only carries path", () => { + assert.deepEqual( + mergeRoundtableStreamingToolArgs( + { path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" }, + { path: "/mnt/user-data/outputs/report.md" }, + ), + { path: "/mnt/user-data/outputs/report.md", content: "# 已接收的报告" }, + ); + assert.deepEqual( + mergeRoundtableStreamingToolArgs( + { content: "# 内容先到" }, + { path: "/mnt/user-data/outputs/report.md", content: "" }, + ), + { path: "/mnt/user-data/outputs/report.md", content: "# 内容先到" }, + ); +}); + test("roundtable opens a provisional Markdown preview when content arrives before path", () => { assert.equal( resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }), diff --git a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts index 2ea40a43..ff756ee1 100644 --- a/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts +++ b/frontend-web/src/roundtable-planning/lib/streaming-artifacts.ts @@ -42,6 +42,37 @@ export function resolveRoundtableStreamingWritePreview( return languageOfStreamingArtifact(path) ? path : null; } +/** + * Merge successive snapshots of one streaming tool call without losing bytes + * that arrived in an earlier SSE frame. + * + * LangChain's partial JSON snapshots frequently contain only `path` while the + * complete `content` has already been received (and the inverse can happen + * while the path key is still incomplete). A click on a progress step also + * carries the deliberately compact `{ path }` payload. Blindly replacing the + * prior args with either shape empties the virtual write_file preview. + */ +export function mergeRoundtableStreamingToolArgs( + previous: Record | undefined, + incoming: Record, +): Record { + if (!previous) return { ...incoming }; + + const merged = { ...previous, ...incoming }; + for (const key of ["path", "content"] as const) { + const prior = previous[key]; + const next = incoming[key]; + if ( + typeof prior === "string" && + prior.length > 0 && + !(typeof next === "string" && next.length > 0) + ) { + merged[key] = prior; + } + } + return merged; +} + /** Final artifact cards intentionally expose only user-facing deliverables. */ export function collectRoundtableDeliverablePaths(values: Iterable): string[] { const seen = new Set();