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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
+9
-1
@@ -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/...`),也不要改用宿主路径。
|
||||||
|
|||||||
Reference in new issue
Block a user