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

@@ -31,6 +31,11 @@ const requiredLegacyFallbacks = [
pattern: /<InputGroup className="h-auto min-h-16">/, pattern: /<InputGroup className="h-auto min-h-16">/,
description: "Prompt textarea must not rely on :has() for its parent height", description: "Prompt textarea must not rely on :has() for its parent height",
}, },
{
file: "src/core/utils/mermaid.ts",
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
},
]; ];
for (const file of cssFiles) { for (const file of cssFiles) {
@@ -18,6 +18,7 @@ import {
listThreadArtifacts, listThreadArtifacts,
mergeArtifactPaths, mergeArtifactPaths,
} from "@/core/artifacts/listing"; } from "@/core/artifacts/listing";
import { latestInFlightWriteFilePreview } from "@/core/artifacts/write-file-in-flight";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings";
import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag"; import { isSingleRoundRagMessage } from "@/core/threads/use-single-round-rag";
@@ -174,6 +175,7 @@ const ChatBox: React.FC<{
deselect, deselect,
selectedArtifact, selectedArtifact,
selectedArtifactKey, selectedArtifactKey,
openStreamedArtifact,
} = useArtifacts(); } = useArtifacts();
const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true); const [autoSelectFirstArtifact, setAutoSelectFirstArtifact] = useState(true);
@@ -424,6 +426,26 @@ const ChatBox: React.FC<{
disableArtifacts, 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); const directoryModeWasEnabledRef = useRef(artifactDirectoryModeEnabled);
useEffect(() => { useEffect(() => {
const wasEnabled = directoryModeWasEnabledRef.current; const wasEnabled = directoryModeWasEnabledRef.current;
@@ -42,6 +42,7 @@ import {
import { import {
buildWriteFileVirtualUrl, buildWriteFileVirtualUrl,
hasCompleteArtifactExtension, hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath, normalizeStreamingArtifactPath,
provisionalWriteFilePath, provisionalWriteFilePath,
} from "@/core/artifacts/streaming-write-preview"; } from "@/core/artifacts/streaming-write-preview";
@@ -754,7 +755,6 @@ function ArtifactWriteToolCall({
args, args,
result, result,
description, description,
isLast,
isLoading, isLoading,
}: { }: {
id?: string; id?: string;
@@ -793,19 +793,20 @@ function ArtifactWriteToolCall({
!artifactPath || !artifactPath ||
!previewKey || !previewKey ||
!isLoading || !isLoading ||
!isLast || result !== undefined ||
result !== undefined !sourcePath ||
!languageOfStreamingArtifact(sourcePath)
) { ) {
return; return;
} }
openStreamedArtifact(artifactPath, previewKey); openStreamedArtifact(artifactPath, previewKey);
}, [ }, [
artifactPath, artifactPath,
isLast,
isLoading, isLoading,
openStreamedArtifact, openStreamedArtifact,
previewKey, previewKey,
result, result,
sourcePath,
]); ]);
return ( return (
@@ -1,7 +1,99 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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", () => { test("resolveWriteFilePath reads the virtual URL pathname", () => {
assert.equal( assert.equal(
@@ -1,5 +1,13 @@
import { isImageFile } from "../utils/files"; import { isImageFile } from "../utils/files";
import {
buildWriteFileVirtualUrl,
hasCompleteArtifactExtension,
languageOfStreamingArtifact,
normalizeStreamingArtifactPath,
provisionalWriteFilePath,
} from "./streaming-write-preview";
export function isWriteFileStreamingUrl(filepath: string): boolean { export function isWriteFileStreamingUrl(filepath: string): boolean {
if (!filepath.startsWith("write-file:")) { if (!filepath.startsWith("write-file:")) {
return false; return false;
@@ -27,6 +35,7 @@ export function resolveWriteFilePath(filepath: string): string {
type WriteFileThreadLike = { type WriteFileThreadLike = {
messages: Array<{ messages: Array<{
id?: string;
type?: string; type?: string;
tool_call_id?: string; tool_call_id?: string;
tool_calls?: Array<{ tool_calls?: Array<{
@@ -75,3 +84,60 @@ export function isWriteFileInFlight(
} }
return false; 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; 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 { export function isMermaidFile(filepath: string): boolean {
return /\.mmd$/i.test(filepath); return /\.mmd$/i.test(filepath);
} }
@@ -14,10 +49,7 @@ async function getMermaidModule() {
const mod = await import("mermaid"); const mod = await import("mermaid");
const mermaid = mod.default; const mermaid = mod.default;
if (!mermaidInitialized) { if (!mermaidInitialized) {
mermaid.initialize({ mermaid.initialize(SANDBOX_MERMAID_CONFIG);
startOnLoad: false,
securityLevel: "strict",
});
mermaidInitialized = true; mermaidInitialized = true;
} }
return mermaid; return mermaid;
@@ -14,7 +14,14 @@
### 2. 生成报告文件 ### 2. 生成报告文件
信息足够后,用 `write_file` 一次性写两份文件(路径固定,覆盖旧版): 信息足够后,**必须用 `write_file` 分两次写文件**(路径固定,覆盖旧版)。目的是让用户在沙箱里**边写边看**,而不是等两份文件全部生成完才一次性弹出。
**顺序(违反即失败):**
1. **先**只调用一次 `write_file` 写 `/mnt/user-data/outputs/report.md`。这一次模型回复里禁止再夹带第二个 `write_file`、禁止夹带 `bash`。工具返回 OK 后立刻 `present_files`,参数只含这一份 md。
2. 等第一步工具都返回后,再单独一次 `write_file` 写 `/mnt/user-data/outputs/task-reports.json`。写完后再 `present_files` 两份文件。
3. **禁止**用 `bash` / `python -c` / 重定向 / `echo` / `cat <<` 创建或覆盖这两份交付文件。`bash` 只允许在入库步骤跑 `task_report_import.py`,或做只读校验(不得写入 `/mnt/user-data/outputs/`)。
4. **禁止**在同一次模型回复里同时发出两个 `write_file`。
- `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。 - `/mnt/user-data/outputs/report.md` —— Markdown 研判报告,章节按下方 14 个分类组织,每章给出要点与分析。
- `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "<JSON 字符串>", "categoryType": "<分类名>"}`。 - `/mnt/user-data/outputs/task-reports.json` —— 结构化 JSON 数组,**14 条记录一个不落**,每条形如 `{"contentJson": "<JSON 字符串>", "categoryType": "<分类名>"}`。
@@ -68,6 +75,7 @@ python /mnt/skills/public/task-report-import/scripts/task_report_import.py --tas
## 硬性约束 ## 硬性约束
- **先检索后报告**:没有真实检索材料的报告不允许生成。 - **先检索后报告**:没有真实检索材料的报告不允许生成。
- **先 md 后 json、禁止并行两个 write_file**:第一份报告必须先 `write_file` + `present_files` 让沙箱实时打开;json 放在下一轮工具调用。交付文件禁止用 bash/python 写盘。
- `task-reports.json` 必须是合法 JSON 数组:14 个分类一条不落、`categoryType` 逐字匹配、内容全部为非空中文文字、无 `\uXXXX` 转义。 - `task-reports.json` 必须是合法 JSON 数组:14 个分类一条不落、`categoryType` 逐字匹配、内容全部为非空中文文字、无 `\uXXXX` 转义。
- 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。 - 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。
- **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。 - **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。