feat(frontend): 支持 Roundtable 写文件流式预览与消息流聚合展示

功能说明:\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 等关键路径
This commit is contained in:
董宏凯 committed 2026-09-02 12:52:33 +08:00
1 parent 553f72604a
commit f5bf7897b9
11 files changed
+254 -32

No files matched your search

@@ -433,6 +433,10 @@ export function ArtifactFileDetail({
!useWriteFileStreaming && !useWriteFileStreaming &&
content === undefined && content === undefined &&
isArtifactContentLoading; isArtifactContentLoading;
// `write_file` 的 path 会先于正文抵达。它仍是正常的流式过程,不能显示成一块
// 空白编辑器,更不能误导用户去点击重试真实文件;下一帧内容到达后会自然切换为代码视图。
const streamingWriteContentPending =
useWriteFileStreaming && writeFileInFlight && !content;
const artifactContentUnavailable = const artifactContentUnavailable =
isCodeFile && isCodeFile &&
!useWriteFileStreaming && !useWriteFileStreaming &&
@@ -1766,10 +1770,10 @@ export function ArtifactFileDetail({
activeCursor={isApplyingEdit} activeCursor={isApplyingEdit}
/> />
)} )}
{artifactContentPending && viewMode === "code" && ( {(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
<div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm"> <div className="text-muted-foreground flex size-full flex-col items-center justify-center gap-3 text-sm">
<LoaderIcon aria-hidden="true" className="size-5 animate-spin" /> <LoaderIcon aria-hidden="true" className="size-5 animate-spin" />
正在加载文档内容… {streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
</div> </div>
)} )}
{artifactContentUnavailable && viewMode === "code" && ( {artifactContentUnavailable && viewMode === "code" && (
@@ -1787,7 +1791,7 @@ export function ArtifactFileDetail({
</button> </button>
</div> </div>
)} )}
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && ( {isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
<div className="size-full"> <div className="size-full">
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none bg-transparent" className="size-full resize-none rounded-none border-none bg-transparent"
@@ -42,6 +42,7 @@ import {
import { import {
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
hasCompleteArtifactExtension, hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact, languageOfStreamingArtifact,
normalizeStreamingArtifactPath, normalizeStreamingArtifactPath,
provisionalWriteFilePath, provisionalWriteFilePath,
@@ -804,7 +805,8 @@ function ArtifactWriteToolCall({
!isLoading || !isLoading ||
result !== undefined || result !== undefined ||
!sourcePath || !sourcePath ||
!languageOfStreamingArtifact(sourcePath) !languageOfStreamingArtifact(sourcePath) ||
!isAutoPreviewableArtifactPath(sourcePath)
) { ) {
return; return;
} }
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
isAutoPreviewableArtifactPath,
isArtifactApiPath, isArtifactApiPath,
isProvisionalStreamingArtifactPath, isProvisionalStreamingArtifactPath,
provisionalWriteFilePath, provisionalWriteFilePath,
@@ -30,3 +31,10 @@ test("isArtifactApiPath rejects write-file URLs and placeholder names", () => {
assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true); assert.equal(isArtifactApiPath("mnt/user-data/outputs/report.md"), true);
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);
});
@@ -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( export function languageOfStreamingArtifact(
path: string, path: string,
): StreamingArtifactLanguage | null { ): StreamingArtifactLanguage | null {
@@ -208,6 +208,34 @@ test("normal mode keeps previewing pathless streaming drafts", () => {
assert.match(preview?.artifactPath ?? "", /\.md/); 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: "<!doctype html><html></html>",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => { test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([ const preview = latestInFlightWriteFilePreview([
{ {
@@ -4,6 +4,7 @@ import { isImageFile } from "../utils/files";
import { import {
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
hasCompleteArtifactExtension, hasCompleteArtifactExtension,
isAutoPreviewableArtifactPath,
languageOfStreamingArtifact, languageOfStreamingArtifact,
normalizeStreamingArtifactPath, normalizeStreamingArtifactPath,
provisionalWriteFilePath, provisionalWriteFilePath,
@@ -140,6 +141,9 @@ export function latestInFlightWriteFilePreview(
if (!languageOfStreamingArtifact(sourcePath)) { if (!languageOfStreamingArtifact(sourcePath)) {
continue; continue;
} }
if (!isAutoPreviewableArtifactPath(sourcePath)) {
continue;
}
return { return {
artifactPath: buildWriteFileVirtualUrl({ artifactPath: buildWriteFileVirtualUrl({
path: sourcePath, path: sourcePath,
+17 -3
View File
@@ -38,6 +38,7 @@ import { TokenUsageIndicator } from "@/components/workspace/token-usage-indicato
import { Tooltip } from "@/components/workspace/tooltip"; import { Tooltip } from "@/components/workspace/tooltip";
import { normalizeAgentQuestions, useAgent } from "@/core/agents"; import { normalizeAgentQuestions, useAgent } from "@/core/agents";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useTheme } from "@/contexts/ThemeContext";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit"; import { queueLlmWikiConversationDeposit } from "@/core/llmwiki/conversation-deposit";
import { useModels } from "@/core/models/hooks"; import { useModels } from "@/core/models/hooks";
@@ -92,6 +93,8 @@ import type { Message } from "@langchain/langgraph-sdk";
/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ /** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */
const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; 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_MAX_CHARS = 12000;
const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000; const RWFX_SQ_REPORT_CONTEXT_TAIL_CHARS = 2000;
@@ -226,6 +229,7 @@ export default function AgentChatPage({
} = {}) { } = {}) {
const routeBase = useRouteBase(); const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const { baseMode } = useTheme();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const router = useRouter(); const router = useRouter();
// 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar; // 独立智能体页的左列是 AgentChatSidebar,而不是外层 WorkspaceSidebar;
@@ -677,13 +681,18 @@ export default function AgentChatPage({
settings.context.writing_mode && writingArtifactPath settings.context.writing_mode && writingArtifactPath
? { writing_artifact_path: writingArtifactPath } ? { writing_artifact_path: writingArtifactPath }
: {}; : {};
const pageReportThemeContext =
agent_id === PAGE_REPORT_DESIGNER_AGENT_ID
? { presentation_theme: baseMode }
: {};
void sendMessage(threadId, message, { void sendMessage(threadId, message, {
...writingExtraContext, ...writingExtraContext,
...(extraContext ?? {}), ...(extraContext ?? {}),
agent_id, agent_id,
...pageReportThemeContext,
}); });
}, },
[sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath], [sendMessage, threadId, agent_id, settings.context.writing_mode, writingArtifactPath, baseMode],
); );
// 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。 // 任务流程:切换 / 新建会话时,停掉「上一会话服务端在跑」的 run(老对话要停止)。
@@ -757,9 +766,14 @@ export default function AgentChatPage({
async (answer: string) => { async (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
await stopActiveThreadBeforeClarificationReply(threadId, thread); 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 () => { const handleStop = useCallback(async () => {
@@ -809,8 +809,20 @@ export async function streamMultiAgent(
// 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。 // 1) 累积本帧的 tool_call_chunks delta(若有),再合成可能可读的 tool_calls。
// 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。 // 主聊天 useStream 在 SDK 层做了这件事,我们在自定义 SSE 里手动补上。
ingestToolCallChunks(toolCallChunks); 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 = const hasCompleteToolCalls =
Array.isArray(toolCalls) && toolCalls.length > 0; Array.isArray(toolCalls) &&
toolCalls.length > 0 &&
toolCalls.every(
(toolCall) =>
!!toolCall &&
typeof toolCall === "object" &&
!!(toolCall as Record<string, unknown>).args &&
typeof (toolCall as Record<string, unknown>).args === "object",
);
let synthetic: SyntheticToolCall[] = []; let synthetic: SyntheticToolCall[] = [];
if (!hasCompleteToolCalls && toolCallAcc.size > 0) { if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
synthetic = buildSyntheticToolCalls(); synthetic = buildSyntheticToolCalls();
@@ -36,6 +36,7 @@ import { uploadFiles } from "@/core/uploads/api";
import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview"; import { buildWriteFileVirtualUrl } from "@/core/artifacts/streaming-write-preview";
import { import {
collectRoundtableDeliverablePaths, collectRoundtableDeliverablePaths,
mergeRoundtableStreamingToolArgs,
newestChangedRoundtableArtifact, newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview, resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot, roundtableArtifactDiskSnapshot,
@@ -48,15 +49,21 @@ type RunFileRef = NonNullable<StreamMultiAgentRequest["files"]>[number];
type SeatDelivery = { name: string; content: string }; type SeatDelivery = { name: string; content: string };
/** /**
* `agent_orchestration` 可在一轮总控中重复调用。用 tool call id 区分每一笔派活, * `agent_orchestration` / 写文件工具可在一轮内重复调用。用 tool call id 区分每一笔
* 同一 id 的 task 参数则持续覆盖更新,供步骤卡实时显示。 * 派活或写入,避免用户点击较早的步骤时误打开后来同名调用的文件。
*/ */
function streamStepKey( function streamStepKey(
phase: StreamPhase, phase: StreamPhase,
toolName?: string, toolName?: string,
toolCallId?: string, toolCallId?: string,
): 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}:${toolCallId}`;
} }
return `${phase}:${toolName ?? ""}`; return `${phase}:${toolName ?? ""}`;
@@ -912,7 +919,12 @@ export function useStep2Orchestration(
sourceToolCallId: sourceToolCallId:
sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined), sourceToolCallId ?? (idx >= 0 ? baseMsg.tool_calls[idx].sourceToolCallId : undefined),
name: toolName, name: toolName,
args, // 进度步骤只保存可见参数(通常只有 path),不能让一次手动点击把先前
// 流进来的 content 覆盖掉;同理也要保住 content-first 帧随后补齐的 path。
args:
idx >= 0
? mergeRoundtableStreamingToolArgs(baseMsg.tool_calls[idx].args, args)
: { ...args },
}; };
const nextToolCalls = const nextToolCalls =
idx >= 0 idx >= 0
@@ -1067,12 +1079,16 @@ export function useStep2Orchestration(
const findSeatArtifactSource = useCallback( const findSeatArtifactSource = useCallback(
( (
rawPath: string, rawPath: string,
preferredToolCallId?: string,
): { ): {
seatThreadId: string; seatThreadId: string;
messageId: string; messageId: string;
toolCallId: string; toolCallId: string;
/** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */ /** stub 里捕获的文件内容(仅 write_file 有;str_replace / 跨账号草稿恢复时常为 undefined)。 */
content?: string; content?: string;
isWriteFile: boolean;
/** 当前 write_file 尚未收到对应 tool result,真实文件可能还没有落盘。 */
inFlight: boolean;
} | null => { } | null => {
const target = rawPath.trim(); const target = rawPath.trim();
if (!target) return null; if (!target) return null;
@@ -1081,30 +1097,70 @@ export function useStep2Orchestration(
const c = (tc.args as { content?: unknown })?.content; const c = (tc.args as { content?: unknown })?.content;
return typeof c === "string" ? c : undefined; return typeof c === "string" ? c : undefined;
}; };
let exactMatch:
| {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
isWriteFile: boolean;
inFlight: boolean;
}
| null = null;
let basenameMatch: let basenameMatch:
| { seatThreadId: string; messageId: string; toolCallId: string; content?: string } | {
seatThreadId: string;
messageId: string;
toolCallId: string;
content?: string;
isWriteFile: boolean;
inFlight: boolean;
}
| null = null; | null = null;
for (const [seatThreadId, msgs] of Object.entries( for (const [seatThreadId, msgs] of Object.entries(
seatStubMessagesRef.current, seatStubMessagesRef.current,
)) { )) {
const msg = msgs?.[0]; const msg = msgs?.[0];
if (!msg) continue; 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; if (tc.name !== "write_file" && tc.name !== "str_replace") continue;
const p = const p =
typeof (tc.args as { path?: unknown })?.path === "string" typeof (tc.args as { path?: unknown })?.path === "string"
? ((tc.args as { path?: string }).path as string) ? ((tc.args as { path?: string }).path as string)
: ""; : "";
if (!p) continue; 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) { 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) { 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; if (!normalized) return;
// present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。 // present_files 呈现的文件多半是子智能体 write_file 写入的:真实内容/thread 在 stub 里。
// 先反查 stub:**仅当 stub 内存里确有内容**(流式期间 / 草稿恢复时捕获了 write_file content) // 先反查 stub:write_file 尚未完成时即使正文还没有抵达,也必须留在虚拟预览。
// 才走虚拟预览——关闭沙箱后再点也能显示,且不受总控 coordinator thread 在 artifacts 路由 // 真实文件尚未写盘时错误走 HTTP 会造成“点文件 → 加载 → 找不到文件”;正文到达后
// not found 的影响。 // stub 的同一 tool call 会自动刷新预览。完成且无正文的旧草稿才降级走 HTTP。
const seatSource = findSeatArtifactSource(normalized); 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({ const virtualUrl = buildWriteFileVirtualUrl({
path: normalized, path: normalized,
messageId: seatSource.messageId, messageId: seatSource.messageId,
@@ -1270,18 +1330,41 @@ export function useStep2Orchestration(
) => { ) => {
const seatThreadId = dialogue.agentThreadId; const seatThreadId = dialogue.agentThreadId;
if (!seatThreadId) return; if (!seatThreadId) return;
const [, toolName] = step.key.split(":"); const [, toolName, sourceToolCallId] = step.key.split(":");
if (toolName === "write_file" || toolName === "str_replace") { if (toolName === "write_file") {
if (!step.args) return; if (!step.args) return;
const path = resolveRoundtableStreamingWritePreview(toolName, step.args); const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
if (!path) return; 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, seatThreadId,
toolName, buildWriteFileVirtualUrl({ path, messageId, toolCallId }),
step.args, false,
); );
const virtualUrl = buildWriteFileVirtualUrl({ path, messageId, toolCallId }); return;
openSeatVirtualArtifact(seatThreadId, virtualUrl, false); }
// str_replace 没有完整 content,不适合虚拟 write_file 预览;它的文件已存在,
// 直接走真实产物读取,避免打开一个永远为空的虚拟编辑器。
if (toolName === "str_replace") {
const path = resolveRoundtableStreamingWritePreview(toolName, step.args);
if (path) openArtifactPreview(seatThreadId, path);
return; return;
} }
// read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能 // read_file 不再可点(见 MessageStepsCard 的 isArtifactStep):智能体调用技能
@@ -1298,6 +1381,7 @@ export function useStep2Orchestration(
}, },
[ [
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
findSeatArtifactSource,
openArtifactPreview, openArtifactPreview,
openSeatVirtualArtifact, openSeatVirtualArtifact,
upsertSeatToolCall, upsertSeatToolCall,
@@ -2192,7 +2276,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(a.bubbleId, label); 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); handleArtifactToolPhase(phase, toolName, args, a.seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
@@ -2662,7 +2746,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label); setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(subBubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
@@ -3081,7 +3165,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
@@ -3293,7 +3377,7 @@ export function useStep2Orchestration(
onPhaseChange: (phase, toolName, args, toolCallId) => { onPhaseChange: (phase, toolName, args, toolCallId) => {
const label = phaseLabel(phase, "special", toolName); const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(bubbleId, label); setDialogueConfidence(bubbleId, label);
appendStepIfNew(bubbleId, label, `${phase}:${toolName ?? ""}`, args); appendStepIfNew(bubbleId, label, streamStepKey(phase, toolName, toolCallId), args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId); handleArtifactToolPhase(phase, toolName, args, seatThreadId, toolCallId);
}, },
onUpstreamFrame: (frame) => { onUpstreamFrame: (frame) => {
@@ -3,11 +3,29 @@ import test from "node:test";
import { import {
collectRoundtableDeliverablePaths, collectRoundtableDeliverablePaths,
mergeRoundtableStreamingToolArgs,
newestChangedRoundtableArtifact, newestChangedRoundtableArtifact,
resolveRoundtableStreamingWritePreview, resolveRoundtableStreamingWritePreview,
roundtableArtifactDiskSnapshot, roundtableArtifactDiskSnapshot,
} from "./streaming-artifacts"; } 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", () => { test("roundtable opens a provisional Markdown preview when content arrives before path", () => {
assert.equal( assert.equal(
resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }), resolveRoundtableStreamingWritePreview("write_file", { content: "# 报告\n正文" }),
@@ -42,6 +42,37 @@ export function resolveRoundtableStreamingWritePreview(
return languageOfStreamingArtifact(path) ? path : null; 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<string, unknown> | undefined,
incoming: Record<string, unknown>,
): Record<string, unknown> {
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. */ /** Final artifact cards intentionally expose only user-facing deliverables. */
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] { export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
const seen = new Set<string>(); const seen = new Set<string>();