feat(control-ui): render Mermaid code fences in webchat
This commit is contained in:
parent
55e12bd236
commit
962fa68527
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
35
ui/src/styles/chat/mermaid.css
Normal file
35
ui/src/styles/chat/mermaid.css
Normal 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;
|
||||
}
|
||||
@ -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" &&
|
||||
|
||||
@ -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("");
|
||||
expect(html).not.toContain("<img");
|
||||
|
||||
@ -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
68
ui/src/ui/mermaid.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user