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">/,
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+9
-1
@@ -14,7 +14,14 @@
|
||||
|
||||
### 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/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` 转义。
|
||||
- 任务 id 一律取开场信息或隐藏任务上下文里的「任务id(task_id)」,不要自造、改动,**更不要向用户询问**。
|
||||
- **文件读写一律使用 `/mnt/user-data/...` 虚拟路径**,即使工具输出或报错信息里出现了宿主机真实路径(如 `F:\...` 或 `.deer-flow/users/...`),也不要改用宿主路径。
|
||||
|
||||
Reference in new issue
Block a user