From 7c15ca6710dd0635fe00b70ae7eb4426c7d62034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 09:05:29 +0800 Subject: [PATCH] fix: show sandbox flowcharts on Win7 Chrome and repair common Mermaid syntax Disable HTML labels so nodes use native SVG text, and rewrite bare edge labels plus invalid class statements before render. Co-authored-by: Cursor --- frontend-web/scripts/check-chrome109-css.mjs | 5 + .../workspace/messages/markdown-content.tsx | 1 + frontend-web/src/core/streamdown/plugins.ts | 5 + .../streamdown/remark-sanitize-mermaid.ts | 18 ++++ .../src/core/utils/mermaid-sanitize.test.ts | 71 ++++++++++++ frontend-web/src/core/utils/mermaid.ts | 101 +++++++++++++++++- 6 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts create mode 100644 frontend-web/src/core/utils/mermaid-sanitize.test.ts 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/messages/markdown-content.tsx b/frontend-web/src/components/workspace/messages/markdown-content.tsx index b83053f2..9288b3a2 100644 --- a/frontend-web/src/components/workspace/messages/markdown-content.tsx +++ b/frontend-web/src/components/workspace/messages/markdown-content.tsx @@ -143,6 +143,7 @@ export function MarkdownContent({ className={className} remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins} + mermaidConfig={streamdownPlugins.mermaidConfig} components={components} > {content} diff --git a/frontend-web/src/core/streamdown/plugins.ts b/frontend-web/src/core/streamdown/plugins.ts index ff8ab11b..48357fd2 100644 --- a/frontend-web/src/core/streamdown/plugins.ts +++ b/frontend-web/src/core/streamdown/plugins.ts @@ -5,22 +5,27 @@ import remarkMath from "remark-math"; import type { StreamdownProps } from "streamdown"; import { rehypeSplitWordsIntoSpans } from "../rehype"; +import { SANDBOX_MERMAID_CONFIG } from "../utils/mermaid"; +import { remarkSanitizeMermaid } from "./remark-sanitize-mermaid"; export const streamdownPlugins = { remarkPlugins: [ remarkGfm, [remarkMath, { singleDollarTextMath: true }], + remarkSanitizeMermaid, ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ rehypeRaw, [rehypeKatex, { output: "html" }], ] as StreamdownProps["rehypePlugins"], + mermaidConfig: SANDBOX_MERMAID_CONFIG, }; export const streamdownPluginsWithWordAnimation = { remarkPlugins: [ remarkGfm, [remarkMath, { singleDollarTextMath: true }], + remarkSanitizeMermaid, ] as StreamdownProps["remarkPlugins"], rehypePlugins: [ [rehypeKatex, { output: "html" }], diff --git a/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts b/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts new file mode 100644 index 00000000..aa7d9252 --- /dev/null +++ b/frontend-web/src/core/streamdown/remark-sanitize-mermaid.ts @@ -0,0 +1,18 @@ +import { visit } from "unist-util-visit"; + +import { sanitizeMermaidSource } from "../utils/mermaid"; + +type MermaidCodeNode = { + lang?: string | null; + value: string; +}; + +export function remarkSanitizeMermaid() { + return (tree: unknown) => { + visit(tree as never, "code", (node: MermaidCodeNode) => { + const lang = (node.lang ?? "").toLowerCase(); + if (lang !== "mermaid" && lang !== "mmd") return; + node.value = sanitizeMermaidSource(node.value); + }); + }; +} diff --git a/frontend-web/src/core/utils/mermaid-sanitize.test.ts b/frontend-web/src/core/utils/mermaid-sanitize.test.ts new file mode 100644 index 00000000..ea7099d2 --- /dev/null +++ b/frontend-web/src/core/utils/mermaid-sanitize.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sanitizeMermaidSource } from "./mermaid"; + +test("inserts a missing arrow before a bare edge label", () => { + const input = "E --> G|价值输出 V_s| H[PPO 损失]"; + assert.equal( + sanitizeMermaidSource(input), + "E --> G -->|价值输出 V_s| H[PPO 损失]", + ); +}); + +test("leaves already-valid labeled edges unchanged", () => { + const input = "E -->|价值输出 V_s| H[PPO 损失]\nG -->|状态| I"; + assert.equal(sanitizeMermaidSource(input), input); +}); + +test("fixes a label attached to a shaped node", () => { + const input = "E[Actor 网络]|价值输出 V_s| H[PPO 损失]"; + assert.equal( + sanitizeMermaidSource(input), + "E[Actor 网络] -->|价值输出 V_s| H[PPO 损失]", + ); +}); + +test("fixes several bare labels on one line", () => { + const input = "A|x| B|y| C"; + assert.equal(sanitizeMermaidSource(input), "A -->|x| B -->|y| C"); +}); + +test("inserts a space after class when the model starts the id list with a comma", () => { + const input = " class,E,F,G,H,I,J,K loss;"; + assert.equal( + sanitizeMermaidSource(input), + " class E,F,G,H,I,J,K loss;", + ); +}); + +test("splits zip-assigned class names onto one statement per class", () => { + const input = " class A,B,C,D input,module,module,module;"; + assert.equal( + sanitizeMermaidSource(input), + " class A input;\n class B,C,D module;", + ); +}); + +test("leaves a valid class assignment and classDef alone", () => { + const input = [ + " classDef input fill:#e3f2fd,stroke:#1565c0;", + " class A,B input;", + ].join("\n"); + assert.equal(sanitizeMermaidSource(input), input); +}); + +test("repairs the DAC-PPO architecture diagram class block", () => { + const input = `graph TD + A[智能体观测 o_t] --> B[个体 Encoder] + E --> G -->|价值输出 V_s| H[PPO 损失] + classDef input fill:#e3f2fd,stroke:#1565c0; + classDef module fill:#fff3e0,stroke:#e65100; + classDef loss fill:#e8f5e9,stroke:#2e7d32; + class A,B,C,D input,module,module,module; + class,E,F,G,H,I,J,K loss;`; + const output = sanitizeMermaidSource(input); + assert.match(output, /class A input;/); + assert.match(output, /class B,C,D module;/); + assert.match(output, /class E,F,G,H,I,J,K loss;/); + assert.doesNotMatch(output, /class,/); + assert.doesNotMatch(output, /input,module/); +}); diff --git a/frontend-web/src/core/utils/mermaid.ts b/frontend-web/src/core/utils/mermaid.ts index 01fa3e5f..9891b3ed 100644 --- a/frontend-web/src/core/utils/mermaid.ts +++ b/frontend-web/src/core/utils/mermaid.ts @@ -2,6 +2,100 @@ 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. + */ +export 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", + ], +}; + +/** + * Models often emit almost-Mermaid that the parser rejects: + * - `A --> B|label| C` instead of `A --> B -->|label| C` + * - `class,E,F loss;` (comma after `class`) instead of `class E,F loss;` + * - `class A,B input,module;` (one class name per node) instead of + * separate `class` statements + * + * Already-valid `-->|label|` links and single-class assignments are kept. + */ +export function sanitizeMermaidSource(source: string): string { + return sanitizeMermaidClassStatements( + source + .replace( + /(^|[\s;])([A-Za-z][\w-]*|[\u4e00-\u9fff]+)\|([^|\n]+)\|/gm, + "$1$2 -->|$3|", + ) + .replace(/(\])\|([^|\n]+)\|/g, "$1 -->|$2|"), + ); +} + +const CLASS_NODE_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/; +const CLASS_NAME_LIST = /[A-Za-z][\w-]*(?:\s*,\s*[A-Za-z][\w-]*)*/; + +function sanitizeMermaidClassStatements(source: string): string { + return source.replace( + /^([ \t]*)class(?!Def\b)[ \t]*,?[ \t]*(\S[^\n]*)$/gm, + (full, indent: string, rest: string) => { + const trimmed = rest.replace(/;+\s*$/, "").trim(); + const matched = trimmed.match( + new RegExp(`^(${CLASS_NODE_LIST.source})\\s+(${CLASS_NAME_LIST.source})$`), + ); + if (!matched) { + return full; + } + const nodeIds = matched[1].split(/\s*,\s*/).filter(Boolean); + const classNames = matched[2].split(/\s*,\s*/).filter(Boolean); + if (nodeIds.length === 0 || classNames.length === 0) { + return full; + } + if (classNames.length === 1) { + return `${indent}class ${nodeIds.join(",")} ${classNames[0]};`; + } + const groups: Array<{ name: string; ids: string[] }> = []; + for (let index = 0; index < nodeIds.length; index += 1) { + const name = classNames[index] ?? classNames[classNames.length - 1]; + const last = groups[groups.length - 1]; + if (last && last.name === name) { + last.ids.push(nodeIds[index]); + } else { + groups.push({ name, ids: [nodeIds[index]] }); + } + } + return groups + .map((group) => `${indent}class ${group.ids.join(",")} ${group.name};`) + .join("\n"); + }, + ); +} + export function isMermaidFile(filepath: string): boolean { return /\.mmd$/i.test(filepath); } @@ -14,17 +108,14 @@ 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; } export async function renderMermaidToSvg(source: string): Promise { - const text = source.trim(); + const text = sanitizeMermaidSource(source).trim(); if (!text) { throw new Error("Mermaid 内容为空,无法生成图片"); }