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:
1 parent
553f72604a
commit
f5bf7897b9
11 files changed
+254
-32
No files matched your search
@@ -433,6 +433,10 @@ export function ArtifactFileDetail({
|
||||
!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" && (
|
||||
{(artifactContentPending || streamingWriteContentPending) && viewMode === "code" && (
|
||||
<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" />
|
||||
正在加载文档内容…
|
||||
{streamingWriteContentPending ? "正在接收文件内容…" : "正在加载文档内容…"}
|
||||
</div>
|
||||
)}
|
||||
{artifactContentUnavailable && viewMode === "code" && (
|
||||
@@ -1787,7 +1791,7 @@ export function ArtifactFileDetail({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !artifactContentUnavailable && (
|
||||
{isCodeFile && viewMode === "code" && !artifactContentPending && !streamingWriteContentPending && !artifactContentUnavailable && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
import {
|
||||
buildWriteFileVirtualUrl,
|
||||
hasCompleteArtifactExtension,
|
||||
isAutoPreviewableArtifactPath,
|
||||
languageOfStreamingArtifact,
|
||||
normalizeStreamingArtifactPath,
|
||||
provisionalWriteFilePath,
|
||||
@@ -804,7 +805,8 @@ function ArtifactWriteToolCall({
|
||||
!isLoading ||
|
||||
result !== undefined ||
|
||||
!sourcePath ||
|
||||
!languageOfStreamingArtifact(sourcePath)
|
||||
!languageOfStreamingArtifact(sourcePath) ||
|
||||
!isAutoPreviewableArtifactPath(sourcePath)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
isAutoPreviewableArtifactPath,
|
||||
isArtifactApiPath,
|
||||
isProvisionalStreamingArtifactPath,
|
||||
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);
|
||||
});
|
||||
|
||||
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(
|
||||
path: string,
|
||||
): StreamingArtifactLanguage | null {
|
||||
|
||||
@@ -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: "<!doctype html><html></html>",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.equal(preview, null);
|
||||
});
|
||||
|
||||
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
|
||||
const preview = latestInFlightWriteFilePreview([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<string, unknown>).args &&
|
||||
typeof (toolCall as Record<string, unknown>).args === "object",
|
||||
);
|
||||
let synthetic: SyntheticToolCall[] = [];
|
||||
if (!hasCompleteToolCalls && toolCallAcc.size > 0) {
|
||||
synthetic = buildSyntheticToolCalls();
|
||||
|
||||
@@ -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<StreamMultiAgentRequest["files"]>[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) => {
|
||||
|
||||
@@ -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正文" }),
|
||||
|
||||
@@ -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<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. */
|
||||
export function collectRoundtableDeliverablePaths(values: Iterable<unknown>): string[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
Reference in new issue
Block a user