diff --git a/docs/webui.md b/docs/webui.md index ef2109586..d63e1671b 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -264,6 +264,28 @@ plain `
` view that shipped in slice 02:
for `cp file.js file.js.bak; copy in panel; paste back`) and never
leaks the gutter line numbers into the copied text.
+## Markdown rendering and Mermaid diagrams (slice 23)
+
+Assistant messages and Markdown file previews render through
+`webapp/lib/markdown.ts` (`marked`, already a workspace dependency — no
+CDN). A fenced code block whose language token is `mermaid` renders as a
+diagram instead of a code block. The behaviour is a contract, not an
+implementation accident:
+
+| Aspect | Contract | Backed by |
+| --- | --- | --- |
+| Fence language | The bare token after the fence opener must be `mermaid`, case-insensitive; trailing metadata (```` ```mermaid {theme: dark} ````) still matches | `webapp/lib/markdown.ts:133-136` |
+| Renderer seam | Fence languages dispatch through a language→renderer registry; the markdown main flow never branches on a language name. Any other fence language can be taken over the same way — one `registerLanguageRenderer(...)` call. That is the seam a future `minimax-code-plugin` renderer will install through | `webapp/lib/markdown.ts:54-113`, `webapp/lib/mermaid-renderer.ts:64-71` |
+| Theme | Diagrams re-render when the app switches light/dark: the host watches ``'s class and mermaid is re-initialised per theme | `components/markdown-html.tsx:52-66`, `components/mermaid-block.tsx:156-166` + `223-229` |
+| Failure state | A syntax error does not blank the page. The failing diagram shows its error text plus the **original source in a copyable ``** — the copy round-trips byte-exact, including `-->|label|` edge syntax — and the rest of the document renders normally. (If the chart library itself fails to load — offline, say — the same failure card appears with the source still copyable. If a language renderer throws, the fence falls back to the plain code block — same "never blank the document" rule at the parser level.) | `components/mermaid-block.tsx:285-307`, `components/markdown-html.tsx:183-230`, `webapp/lib/markdown.ts:149-165` |
+| Sizing | Diagrams scale to the column width; a diagram wider than its card scrolls inside the card | `webapp/styles/mermaid.css:62-80` |
+| CJK labels | Node and edge labels render through a font stack with PingFang SC / Microsoft YaHei / Noto Sans CJK SC fallbacks, so Chinese text does not come out as tofu | `components/mermaid-block.tsx:109` |
+| Loading | The chart library is several megabytes and is `import()`-ed when the **first** diagram of a page mounts; the chunk ships with a one-year immutable cache, so later page loads fetch it from the browser cache. A page with no mermaid fence never requests the chunk | `components/mermaid-block.tsx:54-66`, `server/lib/static.js:64-66` |
+| Outline | A diagram is never a heading: the fence emits a ``/`` placeholder pair, not `h1`–`h6`, so diagrams appear in no heading-derived outline (the webui itself renders no Markdown outline today) | `webapp/lib/mermaid-renderer.ts:44-57` |
+
+The `mermaid` dependency (11.12.1, MIT) is recorded in
+`release/dependency-licenses.json`.
+
## Persistence keys (client-side `localStorage` / `sessionStorage`)
| Key | Channel | Owner | Introduced by | Shape |
diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md
index 85ef62cc4..e43d65d55 100644
--- a/docs/webui.zh-CN.md
+++ b/docs/webui.zh-CN.md
@@ -252,6 +252,57 @@ slice 22 增强:
文件字节互为往返 ——`cp file.js file.js.bak; 在面板里复制; 粘回去`),
且绝不让行号槽混入复制文本。
+## Markdown 里的 Mermaid 图(slice 23)
+
+助手回复和 Markdown 文件预览共用一套渲染管线
+(`webapp/lib/markdown.ts`,`marked` 是工作区既有依赖,不走 CDN)。在
+代码围栏的语言位置写 `mermaid`,围栏内容就会被画成图,而不是显示为
+代码块:
+
+````markdown
+```mermaid
+flowchart LR
+ 需求 --> 开发 --> 验收
+```
+````
+
+**你会得到什么**
+
+- 只要围栏语言是 `mermaid` 就出图,大小写不敏感;围栏后跟的
+ `{...}` 参数不影响识别(`webapp/lib/markdown.ts:133-136`)。
+- 中文标签正常显示:节点和边上的文字走 PingFang SC / Microsoft
+ YaHei / Noto Sans CJK SC 字体栈,不会画成方块
+ (`components/mermaid-block.tsx:109`)。
+- 图跟随界面浅色/深色主题,切换主题时已渲染的图会重新画
+ (`components/markdown-html.tsx:52-66`)。
+- 图按列宽缩放;特别宽的图在卡片内横向滚动,不撑破版面
+ (`webapp/styles/mermaid.css:62-80`)。
+
+**图坏了会怎样**
+
+- **语法写错**:出错的那张图显示"Mermaid 渲染失败"卡片——错误原因
+ 加原始源码。源码可以原样选中复制,复制回来的内容和当初写的逐字节
+ 一致,包括 `-->|标签|` 这类箭头语法
+ (`components/mermaid-block.tsx:285-307`、
+ `components/markdown-html.tsx:183-230`)。文档其余部分照常渲染,
+ 一张图坏了不会让整篇白屏。
+- **图表库加载失败**(如断网):同样落入失败卡片,源码仍可复制,
+ 其余内容不受影响。
+
+**限制**
+
+- **首次遇到图需要加载**:图表库有几 MB,页面里第一张图出现时才从
+ 服务端加载(没有任何 mermaid 图的页面完全不请求它,
+ `components/mermaid-block.tsx:54-66`);**图表库文件**带一年期 immutable
+ 强缓存(`server/lib/static.js:64-66`),之后的页面加载直接用浏览
+ 器缓存,不重复下载。
+- **不进目录大纲**:图不产生标题。围栏渲染为 ``/`` 占位
+ 元素而不是 `h1`-`h6`(`webapp/lib/mermaid-renderer.ts:44-57`),
+ 因此图永远不会出现在按标题组织的大纲或导航里(webui 目前的
+ Markdown 渲染本身也不生成大纲)。
+
+依赖:`mermaid` 11.12.1(MIT),已登记于 `release/dependency-licenses.json`。
+
## 持久化键(客户端 `localStorage` / `sessionStorage`)
| 键 | 通道 | 归属 | 引入 ticket | 数据形态 |
diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md
index 01bde314a..f12815ee0 100644
--- a/packages/webui/docs/CAPABILITIES.md
+++ b/packages/webui/docs/CAPABILITIES.md
@@ -60,6 +60,7 @@ single index that satisfies the check.
| Long chat list virtualization (≥ 200 messages) | ✅ | `webapp/lib/transcript.ts` virtual-window branch (N ≥ 200) with scroll/resize rAF handler; covered by `webapp/test/transcript.test.ts`. |
| Markdown rendering (headings, lists, code) | ✅ | `webapp/lib/markdown.ts` wrapping the workspace `marked` package (`packages/tui` already depends on it; no CDN) |
| Syntax highlighting in code blocks | ✅ | `marked` code renderer (`webapp/lib/markdown.ts`) + CSS classes from `webapp/styles/official-utilities.css` |
+| Mermaid diagrams in markdown (slice 23) | ✅ | a fenced code block whose language token is `mermaid` renders as a diagram instead of a code block. Fence languages dispatch through the language→renderer registry (`webapp/lib/markdown.ts#registerLanguageRenderer`, lines 107-113); mermaid self-registers on import (`webapp/lib/mermaid-renderer.ts:64-71`), and a renderer for any other fence language plugs into the same seam — the markdown main flow never branches on a language name. The chart library is `import()`-ed on the first diagram of a page (`webapp/components/mermaid-block.tsx:62-66`) and its chunk is served with a one-year immutable cache (`server/lib/static.js:66`); a page without a mermaid fence never requests it. Diagrams follow the light/dark theme (`components/markdown-html.tsx:52-66` re-renders on the `` class flip; `components/mermaid-block.tsx:156-166,223-229` re-initialises mermaid per theme). A syntax error renders a legible failure card — error text plus the original source in a copyable `` (`components/mermaid-block.tsx:285-307`) — while the rest of the document renders normally. Limits: diagrams scale to the column width and scroll when wider (`webapp/styles/mermaid.css:62-80`); CJK labels render via the font stack at `components/mermaid-block.tsx:109`; a diagram is never a heading, so it appears in no heading-derived outline (the fence emits a ``/`` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. |
| Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. |
| Rewind / fork a message | ⚠ | The engine implements `session/fork` and `session/resume` (`MCODE_ACP_CAPABILITIES.fork / .resume = true`), but no webui route exposes them yet — see [§13](CAPABILITIES.md#13-what-mcode-would-need-to-add-to-enable-the--rows). |
| Edit a sent message and resend | ❌ | Not exposed by the acp protocol |
@@ -181,7 +182,7 @@ single index that satisfies the check.
| Custom CSS themes | ❌ | no theme loader; would need a CSS-vars system |
| User-defined hotkeys | ❌ | shortcuts are hard-coded |
| Four-column shell (sidebar · conversation · preview · tree) | ✅ | slice 17 + slice 21 (`webapp/components/workspace-columns.tsx`). Conversation column is fluid in `[280, 768]` px while at least one fixed column is visible; with **both** on-demand columns folded the conversation column lifts past 768 and takes the full remainder — measured at 1280 / 240-px chrome = 1040 px, at 1920 = 1680 px (`webapp/test/workspace-tabs-state.test.ts#computeColumnLayout — slice 21 idle state`). The preview and tree columns are **on demand** (slice 21): each appears when at least one matching-role tab is open and auto-closes when the last tab in that role closes. `syncColumnVisibility(tabStrip, layout)` re-derives the visibility flags on every change; the deserializer normalises a stale "column open but empty" payload to closed so a stale disk write cannot conjure an empty column. Surface kinds are split by `columnRoleForKind` — `file: \| browser` lives on the preview column, `files \| git \| tasks \| search \| plugins` on the tree column. The previously-shipped `search`, `alerts`, and `progress` `PanelKind` values were removed from the union (`webapp/lib/persist.ts#PanelKind`). The sidebar tree column's "搜索" surface is **real as of slice 19b** (`webapp/components/workspace-tree-column.tsx#SearchSurface`); the engine contract for plugins is not yet shipped, so the Plugins surface still renders an i18n "this is coming" card rather than a silent no-op. |
-| IDE-grade code preview (slice 22 — line gutter, per-language lazy syntax highlighting, byte-faithful copy) | ✅ | `webapp/components/code-view.tsx` is mounted inside `file-preview`. (1) **Line-number gutter** via `splitHighlightedLines` (`webapp/lib/code-highlight.ts`) — line numbers are aligned to code lines and independent of horizontal scroll; cross-line `` from highlight.js is balanced per-line so each row is hover-stable and copy-faithful. (2) **Per-language lazy highlighting** via `loadHljsLanguage` — only the open file's grammar is imported. The switch / if-ladder of literal `import("highlight.js/lib/languages/.js")` branches is what lets webpack code-split each grammar into its own chunk (a Record-driven dynamic import would have bundled all 191 grammars). Bounded work: 32 KiB / 1500 lines; larger files are truncated before highlight and the UI renders an honest `truncated` notice. Unknown languages fall through to a plain monospace view — the contract is total. (3) **Byte-faithful copy** — `endsWithNewline` is tracked across the highlight → split → copy chain so the clipboard text round-trips to the file bytes (`cp file.js file.js.bak; copy in panel; paste back`); line numbers never leak into the copied text. The server labels the file via `EXT_LANGUAGE` (`server/lib/fs-util.js#languageForExtension`); the view does not re-guess. Mermaid rendering in markdown (a candidate slice 23) is **not** implemented in `webapp/lib/markdown.ts` and is not documented as a capability — it is being built and has not landed. |
+| IDE-grade code preview (slice 22 — line gutter, per-language lazy syntax highlighting, byte-faithful copy) | ✅ | `webapp/components/code-view.tsx` is mounted inside `file-preview`. (1) **Line-number gutter** via `splitHighlightedLines` (`webapp/lib/code-highlight.ts`) — line numbers are aligned to code lines and independent of horizontal scroll; cross-line `` from highlight.js is balanced per-line so each row is hover-stable and copy-faithful. (2) **Per-language lazy highlighting** via `loadHljsLanguage` — only the open file's grammar is imported. The switch / if-ladder of literal `import("highlight.js/lib/languages/.js")` branches is what lets webpack code-split each grammar into its own chunk (a Record-driven dynamic import would have bundled all 191 grammars). Bounded work: 32 KiB / 1500 lines; larger files are truncated before highlight and the UI renders an honest `truncated` notice. Unknown languages fall through to a plain monospace view — the contract is total. (3) **Byte-faithful copy** — `endsWithNewline` is tracked across the highlight → split → copy chain so the clipboard text round-trips to the file bytes (`cp file.js file.js.bak; copy in panel; paste back`); line numbers never leak into the copied text. The server labels the file via `EXT_LANGUAGE` (`server/lib/fs-util.js#languageForExtension`); the view does not re-guess. |
## 11. Network & access control
diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md
index 7dbfe5c00..fadb99c4a 100644
--- a/packages/webui/docs/CAPABILITIES.zh-CN.md
+++ b/packages/webui/docs/CAPABILITIES.zh-CN.md
@@ -52,6 +52,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言
| 长聊天列表虚拟化(≥ 200 条消息) | ✅ | `webapp/lib/transcript.ts` 虚拟窗口分支(N ≥ 200),带滚动/缩放 rAF 处理器;由 `webapp/test/transcript.test.ts` 覆盖。 |
| Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) |
| 代码块语法高亮 | ✅ | highlight.js(本地副本) |
+| Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 ``/`` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 |
| 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 |
| 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 |
| 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 |
diff --git a/packages/webui/package.json b/packages/webui/package.json
index 0b8ade8d0..44fbbdf91 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -35,6 +35,7 @@
"autoprefixer": "10.6.1",
"highlight.js": "10.7.3",
"marked": "18.0.12",
+ "mermaid": "11.12.1",
"next": "14.2.35",
"postcss": "8.5.28",
"react": "18.3.1",
diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx
index 460a1982d..dfeab1b2a 100644
--- a/packages/webui/webapp/app/layout.tsx
+++ b/packages/webui/webapp/app/layout.tsx
@@ -10,6 +10,7 @@ import "../styles/official-utilities.css";
import "../styles/mavis-dropdown.css";
import "../styles/desktop-typography.css";
import "../styles/code-preview.css";
+import "../styles/mermaid.css";
export const metadata: Metadata = {
title: "MiniMax Code",
diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx
index d79ad03bb..27863a037 100644
--- a/packages/webui/webapp/components/chat.tsx
+++ b/packages/webui/webapp/components/chat.tsx
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "@/lib/api";
import { renderMarkdown } from "@/lib/markdown";
+import { MarkdownHtml } from "./markdown-html";
+import "../lib/mermaid-renderer"; // registers the mermaid language renderer
import { reportActionError } from "@/lib/action-errors";
import { findSubagentForBlock } from "@/lib/agent-team-lookup";
import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team";
@@ -531,12 +533,14 @@ function MarkdownBody({ text, streaming }: { text: string; streaming?: boolean }
const html = useMemo(() => renderMarkdown(text), [text]);
return (
-
+
+ {/* Slice 23 — MarkdownHtml walks the rendered DOM, finds the
+ mermaid-block placeholders, and mounts the lazy mermaid
+ component into each one. The parser seam
+ (`registerLanguageRenderer` in lib/markdown.ts) means
+ third-party diagram renderers can attach here too. */}
+
+
{streaming ? (
▍
) : null}
diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx
index c966934bb..98c4405e3 100644
--- a/packages/webui/webapp/components/file-preview.tsx
+++ b/packages/webui/webapp/components/file-preview.tsx
@@ -12,6 +12,8 @@ import {
type FsFilePayload,
} from "@/lib/api";
import { renderMarkdown } from "@/lib/markdown";
+import "@/lib/mermaid-renderer"; // registers the mermaid language renderer
+import { MarkdownHtml } from "@/components/markdown-html";
import { CodeView as IdeCodeView } from "@/components/code-view";
import {
basenameOf,
@@ -295,14 +297,19 @@ function MarkdownView({ content }: { content: string }) {
// lib/markdown.ts#sanitize) — the same policy used by chat.tsx for
// assistant output. Reusing it keeps the threat model and allow-list
// identical across surfaces.
+ //
+ // Slice 23 — the markdown is no longer injected directly. The
+ // MarkdownHtml component walks the rendered DOM, finds the
+ // mermaid-block placeholders, and mounts the lazy mermaid
+ // component into each one. The seam (the language → renderer
+ // registry in lib/markdown.ts) means the renderer used here is
+ // plugin-extensible without an `if (lang === "mermaid")` inside
+ // this component.
const html = useMemo(() => renderMarkdown(content), [content]);
return (
-
+
+
+
);
}
diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx
new file mode 100644
index 000000000..cbc41f5c8
--- /dev/null
+++ b/packages/webui/webapp/components/markdown-html.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+import {
+ Children,
+ createElement,
+ useEffect,
+ useState,
+ type ReactNode,
+} from "react";
+
+import { MermaidBlock } from "./mermaid-block";
+
+/**
+ * Render pre-sanitised HTML and mount mermaid blocks as real React
+ * components.
+ *
+ * Why this is a real React tree and not a `dangerouslySetInnerHTML`
+ * injection followed by a one-shot DOM walk + portal:
+ *
+ * - The previous design injected the sanitised HTML once after
+ * mount, then walked the DOM to find mermaid placeholders and
+ * replaced each placeholder with a `createPortal(,
+ * placeholder)`. That works on first commit but a later React
+ * re-commit of the SAME html re-applies `dangerouslySetInnerHTML`,
+ * which **replaces the inner DOM** (the placeholders are now
+ * fresh nodes). The previously-portalled `MermaidBlock` instances
+ * are attached to the OLD detached placeholders and become
+ * orphans — the diagram never mounts, and the placeholder stays
+ * at "渲染中…" forever. No console error fires; the failure is
+ * silent. (Acceptance run.)
+ *
+ * - The fix in this version parses the sanitised HTML on every
+ * render and converts it to a React element tree. Each mermaid
+ * placeholder pair is recognised by the walker and replaced with
+ * a `` element — a real React
+ * node, in the real React tree, that React re-reconciles on every
+ * commit. Re-commits are idempotent: the walker runs again, finds
+ * the placeholders in the FRESH input, and emits the same
+ * `` elements. No portals, no DOM walks, no
+ * orphaned subtrees.
+ *
+ * SSR fallback: when `DOMParser` is unavailable (the static-export
+ * prerender), the walker falls back to a single `dangerouslySetInnerHTML`
+ * element. The mermaid path is a no-op in SSR — there is nothing to
+ * render — and the live mermaid mount happens entirely client-side.
+ */
+export function MarkdownHtml({ html }: { html: string }) {
+ const [theme, setTheme] = useState<"light" | "dark">("light");
+
+ // Watch the element for the light/dark class flip so diagrams
+ // re-render when the user changes the theme.
+ useEffect(() => {
+ if (typeof document === "undefined") return;
+ const compute = (): "light" | "dark" => {
+ return document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light";
+ };
+ setTheme(compute());
+ const obs = new MutationObserver(() => setTheme(compute()));
+ obs.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ return () => obs.disconnect();
+ }, []);
+
+ // Convert the sanitised HTML to a React tree. The walker is pure —
+ // same input always yields the same tree — and mermaid blocks are
+ // emitted as `` directly. No
+ // portal, no second DOM pass.
+ const tree = htmlToReact(html, theme);
+
+ return (
+
+ {tree}
+
+ );
+}
+
+/**
+ * Walk a sanitised HTML string and convert it to a React tree.
+ *
+ * The walker:
+ *
+ * - emits one `ReactNode` per child node of the parsed body, in
+ * order (text nodes, element nodes);
+ * - recognises the mermaid placeholder pair `SOURCE
…` and
+ * replaces the `` with a `` —
+ * the `` is consumed (skipped) since its text content has
+ * been folded into the MermaidBlock prop;
+ * - passes through every other tag with the sanitiser's allowed
+ * attributes (`class`, `href`, `title`, `align`) so a markdown
+ * document looks the same as before — only the mermaid fences
+ * are upgraded from inert HTML to a live component.
+ *
+ * Returns a single `dangerouslySetInnerHTML` element from inside the
+ * tree on SSR (when `DOMParser` is undefined); the prerender still
+ * produces a non-empty HTML response.
+ */
+function htmlToReact(html: string, theme: "light" | "dark"): ReactNode {
+ if (typeof DOMParser === "undefined") {
+ return (
+
+ );
+ }
+
+ const doc = new DOMParser().parseFromString(
+ `${html}`,
+ "text/html",
+ );
+
+ // Keep a counter for stable React keys across the walk.
+ let key = 0;
+
+ const walkChildren = (parent: Element | Document): ReactNode[] => {
+ const out: ReactNode[] = [];
+ for (const child of [...parent.childNodes]) {
+ if (child.nodeType === 3 /* text */) {
+ const text = child.textContent ?? "";
+ if (text.length === 0) continue;
+ out.push(text);
+ continue;
+ }
+ if (child.nodeType !== 1 /* element */) continue;
+ const el = child as Element;
+ const tag = el.tagName.toLowerCase();
+
+ // mermaid placeholder pair: consume the preceding source
+ // (skipped) and replace the following
+ // with a real .
+ if (tag === "pre" && el.classList.contains("mermaid-source")) {
+ continue;
+ }
+ if (tag === "div" && el.classList.contains("mermaid-block")) {
+ const source = findMermaidSourceBefore(el);
+ out.push(
+ createElement(MermaidBlock, {
+ key: `mermaid-${key++}`,
+ source,
+ theme,
+ }),
+ );
+ continue;
+ }
+
+ const props: Record = { key: `n${key++}` };
+ for (const attr of el.attributes) {
+ const name = attr.name.toLowerCase();
+ if (name === "class") {
+ props.className = attr.value;
+ } else {
+ props[attr.name] = attr.value;
+ }
+ }
+ const children = walkChildren(el);
+ out.push(
+ createElement(tag, props, children.length > 0 ? children : undefined),
+ );
+ }
+ return out;
+ };
+
+ return walkChildren(doc.body);
+}
+
+/**
+ * Find the source text for a mermaid placeholder.
+ *
+ * The renderer (lib/mermaid-renderer.ts) emits a
+ * `SOURCE
` immediately
+ * before each ``. We walk the previous
+ * siblings of `placeholder` to find the source.
+ *
+ * IMPORTANT: read `pre.textContent`, not `pre.innerHTML`.
+ *
+ * The renderer escapes the source via `escapeHtml` (`&`→`&`,
+ * `<`→`<`, `>`→`>`, `"`→`"`, `'`→`'`) so a hostile
+ * fence cannot smuggle markup through the placeholder. By the time
+ * DOMParser has parsed the sanitised HTML, the entity references
+ * have already been decoded back into literal characters — that is
+ * what `textContent` returns.
+ *
+ * `pre.innerHTML`, by contrast, **re-serialises** the text content
+ * and re-emits entities (`<` → `<`). A naive `.replace(/|label|` edge syntax and
+ * corrupts the failure-state "copy source" button (the user copies
+ * `A -->|是| B`, not `A -->|是| B`).
+ *
+ * Exported under a test-only name so the markdown-html-render test
+ * suite can assert this contract without booting a full DOM (the
+ * suite has no jsdom / happy-dom and the walker lives in client
+ * code). The element contract is the small DOM Level 1 surface the
+ * function actually touches: `nodeType`, `tagName`, `classList`,
+ * `textContent`, `previousSibling`.
+ */
+export function findMermaidSourceBefore(placeholder: {
+ previousSibling: unknown;
+}): string {
+ let cur: unknown = placeholder.previousSibling;
+ while (cur) {
+ const node = cur as {
+ nodeType?: number;
+ tagName?: string;
+ classList?: { contains(c: string): boolean };
+ textContent?: string | null;
+ };
+ if (
+ node.nodeType === 1 &&
+ typeof node.tagName === "string" &&
+ node.tagName.toLowerCase() === "pre" &&
+ node.classList?.contains("mermaid-source")
+ ) {
+ return node.textContent ?? "";
+ }
+ cur = (cur as { previousSibling?: unknown }).previousSibling;
+ }
+ return "";
+}
+
+/**
+ * Count the number of `` instances in a rendered tree.
+ *
+ * Used for the `data-mermaid-count` attribute on the host div so a
+ * regression test can assert "the markdown produced N mermaid
+ * diagrams" without depending on internal walker details.
+ */
+function countMermaid(node: ReactNode): number {
+ let count = 0;
+ Children.forEach(node, (child) => {
+ if (!child || typeof child !== "object") return;
+ if (Array.isArray(child)) {
+ for (const c of child) count += countMermaid(c);
+ return;
+ }
+ const el = child as { type?: unknown; props?: { children?: ReactNode } };
+ if (el.type === MermaidBlock) count++;
+ if (el.props?.children) count += countMermaid(el.props.children);
+ });
+ return count;
+}
\ No newline at end of file
diff --git a/packages/webui/webapp/components/mermaid-block.tsx b/packages/webui/webapp/components/mermaid-block.tsx
new file mode 100644
index 000000000..375eb61fd
--- /dev/null
+++ b/packages/webui/webapp/components/mermaid-block.tsx
@@ -0,0 +1,545 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+
+/**
+ * Lazy mermaid block — slice 23 of webui-parity.
+ *
+ * The markdown pipeline emits a ``
+ * next to a `` placeholder for every mermaid
+ * fence it encounters. `MarkdownHtml` walks the parsed HTML and emits a
+ * real `` React element for each
+ * placeholder, so this component owns its own lifecycle — no portal,
+ * no second DOM pass, no orphan risk on React re-commits.
+ *
+ * Responsibilities:
+ *
+ * - **Lazy load.** `mermaid` is several megabytes and is `import()`-ed
+ * only when the FIRST placeholder in the document mounts; the
+ * `import()` lands the library in a single webpack chunk that is
+ * NOT in the initial bundle. A document with zero mermaid fences
+ * never asks for the chunk.
+ *
+ * - **Theme-aware render.** `mermaid.initialize` is called once per
+ * distinct config (see `_mermaidConfigKeyForTest`) so theme flips
+ * trigger a re-render without resetting the parser cache.
+ *
+ * - **CJK fallback.** The `fontFamily` stack passed to mermaid
+ * prefers the system CJK font (PingFang / Microsoft YaHei / Noto
+ * Sans CJK SC) so Chinese Gantt and flowchart labels render.
+ *
+ * - **Strict security + `%%{init}` hardening.** `securityLevel:
+ * "strict"` is the mermaid preset that disables click handlers
+ * and inline HTML. The `%%{init:...}` directive the user can put
+ * inside a mermaid source is stripped before reaching mermaid
+ * because it can override the security level (an `init: "loose"`
+ * would re-enable the XSS surface). The SVG output is then walked
+ * through the same allowlist the markdown pipeline uses — see
+ * `sanitiseSvg` below.
+ *
+ * - **Failure is legible.** A parse error from mermaid shows the
+ * error string and a copyable `` of the original source.
+ * A load failure (network / browser issue) shows a generic
+ * "diagram unavailable" with the source still copyable. Neither
+ * path blanks the document.
+ */
+
+// `mermaid` is dynamically imported (see `loadMermaid`); the type is
+// loaded eagerly so we can call `mermaid.render` and friends, but the
+// runtime payload is not.
+import type mermaidNs from "mermaid";
+
+type MermaidApi = typeof mermaidNs;
+
+let mermaidPromise: Promise | null = null;
+
+/**
+ * Dynamic import of mermaid. Cached so the second diagram does not pay
+ * the load cost again. The first call's promise is what the lazy-load
+ * evidence watches for; if it never resolves the document did not
+ * trigger the chunk.
+ */
+function loadMermaid(): Promise {
+ if (mermaidPromise) return mermaidPromise;
+ mermaidPromise = import("mermaid").then((mod) => mod.default ?? mod);
+ return mermaidPromise;
+}
+
+/** Reset for tests. */
+export function _resetMermaidForTest(): void {
+ mermaidPromise = null;
+}
+
+/**
+ * Tracks the most recent configuration we handed to mermaid so we can
+ * detect a theme flip without re-running the parser. The
+ * `MutationObserver` in the component fires on `` class changes
+ * and triggers a re-render when the value differs.
+ *
+ * The key MUST include every configuration value that affects the
+ * emitted SVG — adding a new flag to the `mermaid.initialize` call
+ * below without also bumping this key would silently leave a stale
+ * config in mermaid's internal state on a theme flip. The key
+ * previously read `${theme}|${source.length}|${theme}` (which had
+ * `theme` duplicated and ignored the htmlLabels/suppressErrorRendering
+ * overrides), so this version hashes the live config object.
+ */
+let lastMermaidConfigKey: string | null = null;
+
+interface MermaidBlockProps {
+ /** Raw mermaid source (the body of the ```mermaid fence). */
+ source: string;
+ /** Theme to render against; updated by the host when the theme flips. */
+ theme: "light" | "dark";
+ /** Test hook — overrides the dynamic import. */
+ _loadMermaid?: () => Promise;
+}
+
+export function _stripMermaidInitForTest(source: string): string {
+ return stripMermaidInit(source);
+}
+
+/**
+ * The font stack we hand to mermaid as `fontFamily`. CJK fallback —
+ * PingFang SC (macOS), Microsoft YaHei (Windows), Noto Sans CJK SC
+ * (Linux distros without the Apple/Microsoft fonts). Exported so the
+ * test suite can assert the stack survived a refactor of
+ * `mermaid.initialize`.
+ */
+export const _mermaidFontFamilyForTest = "-apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Noto Sans CJK SC, Source Han Sans SC, sans-serif";
+
+/**
+ * Build the `mermaid.initialize` argument for the production
+ * `MermaidBlock`. Exported so the test suite can assert every option
+ * the production code passes — a regression that drops
+ * `htmlLabels: false` or `suppressErrorRendering: true` would let
+ * foreignObject labels / bomb SVGs back into the DOM, and that
+ * regression must be caught by the unit harness even though the
+ * full mermaid path requires a browser.
+ *
+ * The four non-obvious options, each of which has caused a real
+ * acceptance failure and is tested in `markdown-html-render.test.ts`:
+ *
+ * - `htmlLabels: false` (top-level) + `flowchart: { htmlLabels:
+ * false }`. mermaid 11 ships `htmlLabels: true` as the global
+ * default, which puts node labels and edge labels inside
+ * `` blocks. The sanitiser has to drop
+ * `` wholesale (allowing it would re-introduce
+ * the same HTML-injection surface `securityLevel: "strict"` is
+ * supposed to close), so every flowchart / pie / class / state
+ * diagram rendered as an empty box until we disabled the
+ * html-label path here. The mermaid 11 labelHelper reads from
+ * TWO config slots — node labels read the top-level
+ * `htmlLabels`, edge labels read `flowchart.htmlLabels`. Both
+ * must be set to false; one alone leaves foreignObjects behind
+ * (verified against mermaid 11.12.1).
+ *
+ * - `suppressErrorRendering: true`. Stop mermaid from injecting
+ * the "Syntax error in text" bomb SVG into `document.body` on
+ * every parse failure. By default mermaid appends a 2400×512
+ * error SVG to the page even when the host caller (us) catches
+ * the thrown error and renders a legible failure UI; the bomb
+ * is then left orphaned at the bottom of the document, outside
+ * any mermaid card, × N where N is the number of bad fences in
+ * the markdown. With this flag, mermaid calls its internal
+ * `removeTempElements()` on every error path, so `document.body`
+ * is left clean.
+ *
+ * - `securityLevel: "strict"`. The mermaid preset that disables
+ * click handlers and inline HTML. Combined with the `%%{init}`
+ * stripper and the sanitiser, this is the third wall that keeps
+ * a hostile fence from triggering an external request beacon.
+ *
+ * - The CJK font fallback on `fontFamily` so Chinese Gantt and
+ * flowchart labels render on a freshly installed system.
+ */
+export function _mermaidInitializeOptionsForTest(theme: "light" | "dark"): Record {
+ return {
+ startOnLoad: false,
+ securityLevel: "strict",
+ theme: theme === "dark" ? "dark" : "default",
+ htmlLabels: false,
+ flowchart: { htmlLabels: false },
+ suppressErrorRendering: true,
+ fontFamily: _mermaidFontFamilyForTest,
+ };
+}
+
+/**
+ * Build the config-change key the component compares against
+ * `lastMermaidConfigKey` to decide whether `mermaid.initialize` must
+ * re-run. Exported so the test suite can pin the key's contract.
+ *
+ * The contract the tests pin (and a historical bug made necessary):
+ *
+ * - The key MUST track the **content** of the initialize options. An
+ * earlier implementation keyed on `${theme}|${source.length}`, so
+ * an options change behind an unchanged source length produced the
+ * SAME key and the new `mermaid.initialize` silently never ran —
+ * acceptance flipped an option in place and nothing re-initialised,
+ * with the entire unit suite still green (nothing asserted the
+ * key). Hashing the live options object makes that drift
+ * impossible: content changes, key changes.
+ *
+ * - `source` deliberately does NOT participate in the key. `source`
+ * is consumed by the `mermaid.render` call, not `initialize`; two
+ * different diagrams sharing one config must NOT re-initialise
+ * mermaid between them.
+ *
+ * - Identical inputs must produce the identical key — the guard
+ * exists to skip no-op re-initialisation.
+ */
+export function _mermaidConfigKeyForTest(
+ theme: "light" | "dark",
+ source: string,
+ options: Record,
+): string {
+ return `${theme}|${JSON.stringify(options)}`;
+}
+
+export function MermaidBlock({ source, theme, _loadMermaid }: MermaidBlockProps) {
+ const [svg, setSvg] = useState(null);
+ const [error, setError] = useState(null);
+ const genRef = useRef(0);
+
+ useEffect(() => {
+ // Last-write-wins across rapid prop updates.
+ const gen = ++genRef.current;
+ let cancelled = false;
+ setError(null);
+ setSvg(null);
+ const loader = _loadMermaid ?? loadMermaid;
+ (async () => {
+ try {
+ const mermaid = await loader();
+ if (cancelled || gen !== genRef.current) return;
+ // The configKey includes EVERY value passed to mermaid.initialize
+ // (theme, htmlLabels, suppressErrorRendering, fontFamily). Adding a
+ // new option to the shared `_mermaidInitializeOptionsForTest` builder
+ // above without also extending this key would silently leave mermaid
+ // running with a stale config across a theme flip. Hashing the actual
+ // options (rather than a brittle human-typed signature) makes that
+ // drift impossible: if the options shape changes, the hash changes,
+ // and mermaid re-initialises.
+ const options = _mermaidInitializeOptionsForTest(theme);
+ const configKey = _mermaidConfigKeyForTest(theme, source, options);
+ if (lastMermaidConfigKey !== configKey) {
+ mermaid.initialize(options);
+ lastMermaidConfigKey = configKey;
+ }
+ // Hardening: strip `%%{init:{...}}` directives before passing
+ // the source to mermaid. Mermaid honours these on the parsed
+ // diagram and they can override the security level — a hostile
+ // `%%{init:{"securityLevel":"loose"}}` would re-enable
+ // htmlLabels +
and turn the diagram into a request
+ // beacon. The acceptance run reproduced this; the source-side
+ // filter is the fix.
+ const hardened = stripMermaidInit(source);
+ // mermaid.render returns { svg } (a string); older versions
+ // returned the SVG directly. The union is the documented
+ // contract — handle both shapes.
+ const id = `mermaid-${gen}-${Math.random().toString(36).slice(2, 8)}`;
+ const result = await mermaid.render(id, hardened);
+ if (cancelled || gen !== genRef.current) return;
+ const raw = typeof result === "string" ? result : result.svg;
+ // Sanitise the SVG before injection. Mermaid is configured
+ // strictly (no clicks, no HTML labels) and the %%{init} is
+ // stripped above, but the sanitiser is the third wall: anything
+ // mermaid emits that is not on the markdown allowlist (script,
+ // onload, foreignObject, …) is dropped here.
+ const clean = sanitiseSvg(raw);
+ setSvg(clean);
+ } catch (cause) {
+ if (cancelled || gen !== genRef.current) return;
+ // Parse error or load failure. The failure-state UI shows the
+ // source so the user can copy it; this never blanks the page.
+ setError(cause instanceof Error ? cause.message : String(cause));
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [source, theme, _loadMermaid]);
+
+ if (error) {
+ return ;
+ }
+ if (svg) {
+ return (
+
+ );
+ }
+ return (
+
+ 渲染中…
+
+ );
+}
+
+function FailureView({ source, error }: { source: string; error: string }) {
+ return (
+
+ Mermaid 渲染失败
+
+ {error}
+
+
+ {source}
+
+
+ 源代码已显示在上方,可复制。文档其余部分保持正常渲染。
+
+
+ );
+}
+
+/**
+ * Strip a `%%{init:{...}}` directive from a mermaid source.
+ *
+ * Mermaid applies `%%{init:{...}}` blocks at parse time and they
+ * override every other configuration source — including the
+ * `mermaid.initialize({securityLevel: "strict"})` call we make in
+ * this component. A hostile input carrying
+ * `%%{init:{"securityLevel":"loose"}}` therefore re-enables the
+ * htmlLabels +
+ surface we explicitly
+ * disabled, and the browser will fetch whatever remote URL the
+ * diagram embeds. The acceptance run reproduced this as a request
+ * beacon. The strip happens before the source reaches mermaid so
+ * the directive never has a chance to take effect.
+ *
+ * The match uses a brace-counting walk, not a regex. A regex
+ * `[^}]*` is wrong here because mermaid init bodies can contain
+ * nested JSON braces (`{"flowchart":{"htmlLabels":true}}`); the
+ * naive regex stops at the first `}` and leaves the outer `}}` plus
+ * the rest of the source in a state mermaid's parser then chokes on.
+ * The walker counts opening / closing braces from the position
+ * right after `init:` and stops at the matching close — every
+ * `%%{init:...}` is removed wholesale, but nothing else.
+ */
+function stripMermaidInit(source: string): string {
+ let out = "";
+ let cursor = 0;
+ while (cursor < source.length) {
+ // Find the next `%%` marker. Whitespace between `%%` and `{` is
+ // tolerated — mermaid's own parser is lenient.
+ const pctStart = source.indexOf("%%", cursor);
+ if (pctStart < 0) {
+ out += source.slice(cursor);
+ break;
+ }
+ // Scan past whitespace for the `{`.
+ let braceIdx = pctStart + 2;
+ while (braceIdx < source.length && /\s/.test(source.charAt(braceIdx))) braceIdx++;
+ if (source.charAt(braceIdx) !== "{") {
+ // Not a `%%{...}` form — emit and continue past the `%%`.
+ out += source.slice(cursor, pctStart + 2);
+ cursor = pctStart + 2;
+ continue;
+ }
+ // From here on we are inside `%%{...}`.
+ const directiveStart = braceIdx;
+ const inner = source.slice(directiveStart + 1);
+ if (!/^\s*init\s*:/i.test(inner)) {
+ // Different `%%{...}` form — keep the marker and continue
+ // past the next `}` (no brace-counting needed since we are
+ // not stripping this directive, only moving the cursor).
+ const closeIdx = source.indexOf("}", directiveStart + 1);
+ out += source.slice(cursor, closeIdx >= 0 ? closeIdx + 1 : directiveStart + 1);
+ cursor = closeIdx >= 0 ? closeIdx + 1 : directiveStart + 1;
+ continue;
+ }
+ // Walk forward, counting braces, to find the directive's close.
+ const colonOffset = inner.search(":");
+ if (colonOffset < 0) {
+ out += source.slice(cursor);
+ break;
+ }
+ const bodyStart = directiveStart + 1 + colonOffset + 1;
+ let depth = 1;
+ let i = bodyStart;
+ while (i < source.length && depth > 0) {
+ const ch = source[i];
+ if (ch === "{") depth++;
+ else if (ch === "}") depth--;
+ i++;
+ }
+ if (depth !== 0) {
+ // Unterminated directive — bail and keep the rest verbatim.
+ out += source.slice(cursor);
+ break;
+ }
+ out += source.slice(cursor, pctStart);
+ // i is one past the matching `}`. Consume a trailing newline.
+ cursor = i;
+ if (source[cursor] === "\n") cursor++;
+ }
+ return out;
+}
+
+/**
+ * Strip dangerous tags/attributes from a mermaid SVG before injection.
+ *
+ * Three rules that proved necessary in acceptance:
+ *
+ * 1. **Use `documentElement`, not `body`.** SVG parsed with
+ * `image/svg+xml` is an `XMLDocument` — `doc.body` is `null`
+ * on an XML document. Reading `body.innerHTML` returns "Cannot
+ * read properties of null" on every successful mermaid render
+ * and falls into the failure path. The root element is
+ * `doc.documentElement` (the `