feat(control-ui): render Mermaid code fences in webchat

This commit is contained in:
chembo.huang 2026-03-17 20:36:27 +08:00
parent 55e12bd236
commit 962fa68527
7 changed files with 129 additions and 3 deletions

View File

@ -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",

View File

@ -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";

View File

@ -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;
}

View File

@ -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<PropertyKey, unk
host as unknown as Parameters<typeof scheduleChatScroll>[0],
forcedByTab || forcedByLoad || streamJustStarted || !host.chatHasAutoScrolled,
);
scheduleMermaidRender();
}
if (
host.tab === "logs" &&

View File

@ -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("<img");

View File

@ -48,6 +48,7 @@ const allowedAttrs = [
"data-code",
"type",
"aria-label",
"role",
];
const sanitizeOptions = {
ALLOWED_TAGS: allowedTags,
@ -184,6 +185,7 @@ htmlEscapeRenderer.code = ({
lang?: string;
escaped?: boolean;
}) => {
const normalizedLang = lang?.trim().toLowerCase() ?? "";
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
const safeText = escaped ? text : escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
@ -196,10 +198,14 @@ htmlEscapeRenderer.code = ({
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button>`;
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
if (normalizedLang === "mermaid") {
return `<div class="mermaid-block"><div class="mermaid-block__render" aria-label="Mermaid diagram" role="img"></div><details class="mermaid-block__source"><summary>Mermaid source</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details></div>`;
}
const trimmed = text.trim();
const isJson =
lang === "json" ||
(!lang &&
normalizedLang === "json" ||
(!normalizedLang &&
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));

68
ui/src/ui/mermaid.ts Normal file
View File

@ -0,0 +1,68 @@
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
render: (id: string, definition: string) => Promise<{ svg: string }>;
};
let mermaidApiPromise: Promise<MermaidApi> | null = null;
let renderScheduled = false;
let renderCounter = 0;
async function loadMermaidApi(): Promise<MermaidApi> {
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<void> {
const blocks = Array.from(
root.querySelectorAll<HTMLElement>(".mermaid-block:not([data-mermaid-status])"),
);
if (blocks.length === 0) {
return;
}
const api = await loadMermaidApi();
for (const block of blocks) {
const definition =
block.querySelector<HTMLElement>("code.language-mermaid")?.textContent?.trim() ?? "";
const renderTarget = block.querySelector<HTMLElement>(".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";
}
}
}