diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index e1c5325a..2f32b8ed 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -31,6 +31,11 @@ const requiredLegacyFallbacks = [ pattern: //, 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) { diff --git a/frontend-web/src/components/workspace/chats/chat-box.tsx b/frontend-web/src/components/workspace/chats/chat-box.tsx index e2a6c51a..6985e5d5 100644 --- a/frontend-web/src/components/workspace/chats/chat-box.tsx +++ b/frontend-web/src/components/workspace/chats/chat-box.tsx @@ -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; diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index 41a81777..996f9675 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -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 ( diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts index 68686482..3638d77f 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.test.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.test.ts @@ -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( diff --git a/frontend-web/src/core/artifacts/write-file-in-flight.ts b/frontend-web/src/core/artifacts/write-file-in-flight.ts index 3aae5083..3d1fefef 100644 --- a/frontend-web/src/core/artifacts/write-file-in-flight.ts +++ b/frontend-web/src/core/artifacts/write-file-in-flight.ts @@ -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; +} diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts index 01fa3e5f..f5f71a7a 100644 --- a/frontend-web/src/core/utils/mermaid.ts +++ b/frontend-web/src/core/utils/mermaid.ts @@ -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; diff --git a/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md index 80b01f1b..7f3a5fe1 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md +++ b/offline-backend-20260512/backend/app/gateway/routers/_agentfx_seed_assets/agentfx-analyst/SOUL.md @@ -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": "", "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/...`),也不要改用宿主路径。