fix: 改进流式 write_file 沙箱预览与 Mermaid 旧浏览器兼容
同轮次非末尾 write_file 也能自动打开沙箱并跳过 JSON 大文件预览;Mermaid 改用 SVG 标签;agentfx 强制先写 md 再写 json。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user