From 962fa685274791bf1fbfe07e103a2a97dbb1545c Mon Sep 17 00:00:00 2001 From: "chembo.huang" Date: Tue, 17 Mar 2026 20:36:27 +0800 Subject: [PATCH] feat(control-ui): render Mermaid code fences in webchat --- ui/package.json | 6 ++- ui/src/styles/chat.css | 1 + ui/src/styles/chat/mermaid.css | 35 +++++++++++++++++ ui/src/ui/app-lifecycle.ts | 2 + ui/src/ui/markdown.test.ts | 10 +++++ ui/src/ui/markdown.ts | 10 ++++- ui/src/ui/mermaid.ts | 68 ++++++++++++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 ui/src/styles/chat/mermaid.css create mode 100644 ui/src/ui/mermaid.ts diff --git a/ui/package.json b/ui/package.json index 5d514f671cd..3ce3f9183c6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,11 @@ "@noble/ed25519": "3.0.1", "dompurify": "^3.3.3", "lit": "^3.3.2", - "marked": "^17.0.4" + "marked": "^17.0.4", + "mermaid": "11.13.0", + "signal-polyfill": "^0.2.2", + "signal-utils": "^0.21.1", + "vite": "8.0.0" }, "devDependencies": { "@vitest/browser-playwright": "4.1.0", diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css index 07d3b644a63..d1a69c1c784 100644 --- a/ui/src/styles/chat.css +++ b/ui/src/styles/chat.css @@ -1,5 +1,6 @@ @import "./chat/layout.css"; @import "./chat/text.css"; +@import "./chat/mermaid.css"; @import "./chat/grouped.css"; @import "./chat/tool-cards.css"; @import "./chat/sidebar.css"; diff --git a/ui/src/styles/chat/mermaid.css b/ui/src/styles/chat/mermaid.css new file mode 100644 index 00000000000..f24852e393f --- /dev/null +++ b/ui/src/styles/chat/mermaid.css @@ -0,0 +1,35 @@ +.chat-text :where(.mermaid-block) { + margin-top: 0.75em; + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; + background: color-mix(in srgb, var(--secondary) 70%, transparent); +} + +.chat-text :where(.mermaid-block__render) { + overflow-x: auto; + padding: 12px; +} + +.chat-text :where(.mermaid-block__render svg) { + display: block; + max-width: 100%; + height: auto; +} + +.chat-text :where(.mermaid-block__source) { + border-top: 1px solid var(--border); +} + +.chat-text :where(.mermaid-block__source > summary) { + cursor: pointer; + list-style: none; + user-select: none; + padding: 8px 12px; + font-size: 12px; + color: var(--muted); +} + +.chat-text :where(.mermaid-block__source > summary::-webkit-details-marker) { + display: none; +} diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index ae816a0bdb9..a986d2b6b64 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -17,6 +17,7 @@ import { syncThemeWithSettings, } from "./app-settings.ts"; import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; +import { scheduleMermaidRender } from "./mermaid.ts"; import type { Tab } from "./navigation.ts"; type LifecycleHost = { @@ -109,6 +110,7 @@ export function handleUpdated(host: LifecycleHost, changed: Map[0], forcedByTab || forcedByLoad || streamJustStarted || !host.chatHasAutoScrolled, ); + scheduleMermaidRender(); } if ( host.tab === "logs" && diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 8c2f37cbea4..fb626009065 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -30,6 +30,16 @@ describe("toSanitizedMarkdownHtml", () => { expect(html).toContain("console.log(1)"); }); + it("renders mermaid fences into renderable mermaid blocks", () => { + const html = toSanitizedMarkdownHtml( + ["```mermaid", "flowchart TD", "A --> B", "```"].join("\n"), + ); + expect(html).toContain('class="mermaid-block"'); + expect(html).toContain('class="mermaid-block__render"'); + expect(html).toContain('class="language-mermaid"'); + expect(html).toContain("Mermaid source"); + }); + it("flattens remote markdown images into alt text", () => { const html = toSanitizedMarkdownHtml("![Alt text](https://example.com/image.png)"); expect(html).not.toContain(" { + const normalizedLang = lang?.trim().toLowerCase() ?? ""; const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : ""; const safeText = escaped ? text : escapeHtml(text); const codeBlock = `
${safeText}
`; @@ -196,10 +198,14 @@ htmlEscapeRenderer.code = ({ const copyBtn = ``; const header = `
${langLabel}${copyBtn}
`; + if (normalizedLang === "mermaid") { + return `
Mermaid source
${header}${codeBlock}
`; + } + const trimmed = text.trim(); const isJson = - lang === "json" || - (!lang && + normalizedLang === "json" || + (!normalizedLang && ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))); diff --git a/ui/src/ui/mermaid.ts b/ui/src/ui/mermaid.ts new file mode 100644 index 00000000000..1271ce88667 --- /dev/null +++ b/ui/src/ui/mermaid.ts @@ -0,0 +1,68 @@ +type MermaidApi = { + initialize: (config: Record) => void; + render: (id: string, definition: string) => Promise<{ svg: string }>; +}; + +let mermaidApiPromise: Promise | null = null; +let renderScheduled = false; +let renderCounter = 0; + +async function loadMermaidApi(): Promise { + if (!mermaidApiPromise) { + mermaidApiPromise = import("mermaid").then((mod) => { + const api = (mod.default ?? mod) as MermaidApi; + api.initialize({ + startOnLoad: false, + securityLevel: "strict", + }); + return api; + }); + } + return mermaidApiPromise; +} + +export function scheduleMermaidRender(root: ParentNode = document): void { + if (renderScheduled) { + return; + } + renderScheduled = true; + queueMicrotask(() => { + requestAnimationFrame(() => { + renderScheduled = false; + void renderMermaidBlocks(root); + }); + }); +} + +async function renderMermaidBlocks(root: ParentNode): Promise { + const blocks = Array.from( + root.querySelectorAll(".mermaid-block:not([data-mermaid-status])"), + ); + if (blocks.length === 0) { + return; + } + + const api = await loadMermaidApi(); + + for (const block of blocks) { + const definition = + block.querySelector("code.language-mermaid")?.textContent?.trim() ?? ""; + const renderTarget = block.querySelector(".mermaid-block__render"); + if (!definition || !renderTarget) { + block.dataset.mermaidStatus = "error"; + continue; + } + + block.dataset.mermaidStatus = "rendering"; + try { + const id = `openclaw-mermaid-${++renderCounter}`; + const { svg } = await api.render(id, definition); + renderTarget.innerHTML = svg; + block.dataset.mermaidStatus = "ready"; + } catch (err) { + console.warn("[markdown] mermaid render failed", err); + renderTarget.textContent = "Mermaid render failed. Expand source to inspect diagram text."; + block.dataset.mermaidStatus = "error"; + } + } +}