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 内容为空,无法生成图片"); }