fix: 改进流式 write_file 沙箱预览与 Mermaid 旧浏览器兼容

同轮次非末尾 write_file 也能自动打开沙箱并跳过 JSON 大文件预览;Mermaid 改用 SVG 标签;agentfx 强制先写 md 再写 json。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-08-28 16:21:39 +08:00
1 parent 875001a8c0
commit 832e180c52
7 files changed
+236 -10

No files matched your search

@@ -18,6 +18,7 @@ import {
listThreadArtifacts,
mergeArtifactPaths,
} from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
@@ -174,6 +175,7 @@ const ChatBox: React.FC<{
deselect,
selectedArtifact,
selectedArtifactKey,
openStreamedArtifact,
} = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -424,6 +426,26 @@ const ChatBox: React.FC<{
disableArtifacts,
]);
// Live sandbox: open as soon as any in-flight write_file has a path or
// the first content bytes — not only when that tool is the last CoT step.
// Agentfx (and other agents) often emit write_file + present_files/bash in
// the same turn; the collapsed earlier write would otherwise never auto-open.
const streamingWritePreview = useMemo(
() =>
disableArtifacts || !thread.isLoading
? null
: latestInFlightWriteFilePreview(thread.messages ?? []),
[disableArtifacts, thread.isLoading, thread.messages],
);
useEffect(() => {
if (!streamingWritePreview) return;
openStreamedArtifact(
streamingWritePreview.artifactPath,
streamingWritePreview.previewKey,
);
setSideTab("document");
}, [openStreamedArtifact, streamingWritePreview]);
const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current;
@@ -42,6 +42,7 @@ import {
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview";
@@ -754,7 +755,6 @@ function ArtifactWriteToolCall({
args,
result,
description,
isLast,
isLoading,
}: {
id?: string;
@@ -793,19 +793,20 @@ function ArtifactWriteToolCall({
!artifactPath ||
!previewKey ||
!isLoading ||
!isLast ||
result !== undefined
result !== undefined ||
!sourcePath ||
!languageOfStreamingArtifact(sourcePath)
) {
return;
}
openStreamedArtifact(artifactPath, previewKey);
}, [
artifactPath,
isLast,
isLoading,
openStreamedArtifact,
previewKey,
result,
sourcePath,
]);
return (
@@ -1,7 +1,99 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isWriteFileInFlight, resolveWriteFilePath } from "./write-file-in-flight";
import {
isWriteFileInFlight,
latestInFlightWriteFilePreview,
resolveWriteFilePath,
} from "./write-file-in-flight";
test("latestInFlightWriteFilePreview opens a write_file that is not the last tool call", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{ id: "p1", name: "present_files", args: { paths: ["/mnt/user-data/outputs/report.md"] } },
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /write-file:/);
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview keeps markdown when a later json write is in flight", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/report.md",
content: "# 标题\n",
},
},
{
id: "w2",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{",
},
},
],
},
]);
assert.ok(preview);
assert.equal(preview?.previewKey, "m1:w1");
assert.match(preview?.artifactPath ?? "", /report\.md/);
});
test("latestInFlightWriteFilePreview does not auto-open a json-only write", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{
id: "w1",
name: "write_file",
args: {
path: "/mnt/user-data/outputs/task-reports.json",
content: "[{}]",
},
},
],
},
]);
assert.equal(preview, null);
});
test("latestInFlightWriteFilePreview skips write_file that already has a tool result", () => {
const preview = latestInFlightWriteFilePreview([
{
id: "m1",
type: "ai",
tool_calls: [
{ id: "w1", name: "write_file", args: { path: "/mnt/user-data/outputs/report.md" } },
],
},
{ type: "tool", tool_call_id: "w1" },
]);
assert.equal(preview, null);
});
test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal(
@@ -1,5 +1,13 @@
import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) {
return false;
@@ -27,6 +35,7 @@ export function resolveWriteFilePath(filepath: string): string {
type WriteFileThreadLike = {
messages: Array<{
id?: string;
type?: string;
tool_call_id?: string;
tool_calls?: Array<{
@@ -75,3 +84,60 @@ export function isWriteFileInFlight(
}
return false;
}
/**
* Latest in-flight `write_file` that the sandbox can live-preview.
* Scans all messages (not just the last CoT step) so a write that is
* followed by `present_files` / bash in the same turn still opens the panel
* while arguments stream in — same idea as position-roundtable's
* `latestStreamingWriteFile`.
*/
export function latestInFlightWriteFilePreview(
messages: WriteFileThreadLike["messages"],
): { artifactPath: string; previewKey: string } | null {
const thread = { messages };
for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) {
const message = messages[messageIndex];
if (message?.type !== "ai" || !message.id || !message.tool_calls) {
continue;
}
for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) {
const toolCall = message.tool_calls[toolIndex];
if (toolCall?.name !== "write_file" || !toolCall.id) {
continue;
}
if (hasToolResult(thread, toolCall.id)) {
continue;
}
const args = toolCall.args as { path?: unknown; content?: unknown } | undefined;
const rawPath = typeof args?.path === "string" ? args.path : "";
const content = typeof args?.content === "string" ? args.content : "";
const normalizedPath = normalizeStreamingArtifactPath(rawPath);
const hasUsablePath =
Boolean(normalizedPath) && hasCompleteArtifactExtension(normalizedPath);
const sourcePath = hasUsablePath
? normalizedPath
: content.trim()
? provisionalWriteFilePath(content)
: null;
if (!sourcePath) {
continue;
}
// JSON (e.g. agentfx task-reports.json) is too large to live-preview:
// switching the sandbox onto it freezes the main thread, then the rest
// of the turn appears to dump at once. Keep showing markdown instead.
if (!languageOfStreamingArtifact(sourcePath)) {
continue;
}
return {
artifactPath: buildWriteFileVirtualUrl({
path: sourcePath,
messageId: message.id,
toolCallId: toolCall.id,
}),
previewKey: `${message.id}:${toolCall.id}`,
};
}
}
return null;
}
+36 -4
View File
@@ -2,6 +2,41 @@ import { getFileName } from "./files";
let mermaidInitialized = false;
/**
* Keep sandbox diagrams on the SVG-only Mermaid rendering path.
*
* Mermaid's default HTML labels are implemented with SVG `foreignObject`.
* Although that looks a little richer in current browsers, many Windows 7
* Chromium shells either do not support it or calculate its dimensions
* incorrectly, leaving nodes blank or overlapping. Native SVG text works in
* the Chrome 100 baseline used by this application and in older Win7 browser
* shells as well.
*
* `htmlLabels` is marked secure so a diagram's front matter/directive cannot
* switch this preview back to `foreignObject` rendering.
*/
const SANDBOX_MERMAID_CONFIG = {
startOnLoad: false,
securityLevel: "strict" as const,
htmlLabels: false,
fontFamily: '"Microsoft YaHei", "Microsoft YaHei UI", SimSun, Arial, sans-serif',
flowchart: {
// A simple path is more reliable than the spline renderer in older
// Chromium SVG implementations and is easier to read at small sizes.
curve: "linear" as const,
useMaxWidth: false,
},
secure: [
"secure",
"securityLevel",
"startOnLoad",
"maxTextSize",
"suppressErrorRendering",
"maxEdges",
"htmlLabels",
],
};
export function isMermaidFile(filepath: string): boolean {
return /\.mmd$/i.test(filepath);
}
@@ -14,10 +49,7 @@ async function getMermaidModule() {
const mod = await import("mermaid");
const mermaid = mod.default;
if (!mermaidInitialized) {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
});
mermaid.initialize(SANDBOX_MERMAID_CONFIG);
mermaidInitialized = true;
}
return mermaid;