feat(control-ui): harden Mermaid rendering

This commit is contained in:
chembo.huang 2026-03-18 12:49:37 +08:00
parent 962fa68527
commit c11dbc797f
4 changed files with 68 additions and 65 deletions

View File

@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildDiscordInboundAccessContext } from "../../../../extensions/discord/src/monitor/inbound-context.js";
import type { ResolvedSlackAccount } from "../../../../extensions/slack/src/accounts.js";
import type { SlackMessageEvent } from "../../../../extensions/slack/src/types.js";
import type { MsgContext } from "../../../auto-reply/templating.js";
@ -73,14 +72,6 @@ vi.mock("../../../../extensions/whatsapp/src/auto-reply/deliver-reply.js", () =>
deliverWebReply: vi.fn(async () => {}),
}));
const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js");
const { prepareSlackMessage } =
await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js");
const { createInboundSlackTestContext } =
await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js");
const { buildTelegramMessageContextForTest } =
await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js");
function createSlackAccount(config: ResolvedSlackAccount["config"] = {}): ResolvedSlackAccount {
return {
accountId: "default",
@ -112,45 +103,25 @@ describe("channel inbound contract", () => {
dispatchInboundMessageMock.mockClear();
});
it("keeps Discord inbound context finalized", () => {
const { groupSystemPrompt, ownerAllowFrom, untrustedContext } =
buildDiscordInboundAccessContext({
channelConfig: null,
guildInfo: null,
sender: { id: "U1", name: "Alice", tag: "alice" },
isGuild: false,
});
const ctx = finalizeInboundContext({
Body: "hi",
BodyForAgent: "hi",
RawBody: "hi",
CommandBody: "hi",
From: "discord:U1",
To: "user:U1",
SessionKey: "agent:main:discord:direct:u1",
AccountId: "default",
ChatType: "direct",
ConversationLabel: "Alice",
SenderName: "Alice",
SenderId: "U1",
SenderUsername: "alice",
GroupSystemPrompt: groupSystemPrompt,
OwnerAllowFrom: ownerAllowFrom,
UntrustedContext: untrustedContext,
Provider: "discord",
Surface: "discord",
WasMentioned: false,
MessageSid: "m1",
CommandAuthorized: true,
OriginatingChannel: "discord",
OriginatingTo: "user:U1",
it("keeps Discord inbound context finalized", async () => {
const { processDiscordMessage } =
await import("../../../../extensions/discord/src/monitor/message-handler.process.js");
const { createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
await import("../../../../extensions/discord/src/monitor/message-handler.test-harness.js");
const messageCtx = await createBaseDiscordMessageContext({
cfg: { messages: {} },
ackReactionScope: "direct",
...createDiscordDirectMessageContextOverrides(),
});
expectChannelInboundContextContract(ctx);
await processDiscordMessage(messageCtx);
expect(inboundCtxCapture.ctx).toBeTruthy();
expectChannelInboundContextContract(inboundCtxCapture.ctx!);
});
it("keeps Signal inbound context finalized", async () => {
const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js");
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",
@ -178,6 +149,10 @@ describe("channel inbound contract", () => {
});
it("keeps Slack inbound context finalized", async () => {
const { prepareSlackMessage } =
await import("../../../../extensions/slack/src/monitor/message-handler/prepare.js");
const { createInboundSlackTestContext } =
await import("../../../../extensions/slack/src/monitor/message-handler/prepare.test-helpers.js");
const ctx = createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true } },
@ -198,6 +173,8 @@ describe("channel inbound contract", () => {
});
it("keeps Telegram inbound context finalized", async () => {
const { buildTelegramMessageContextForTest } =
await import("../../../../extensions/telegram/src/bot-message-context.test-harness.js");
const context = await buildTelegramMessageContextForTest({
cfg: {
agents: {
@ -232,6 +209,7 @@ describe("channel inbound contract", () => {
});
it("keeps WhatsApp inbound context finalized", async () => {
const { finalizeInboundContext } = await import("../../../auto-reply/reply/inbound-context.js");
const ctx = finalizeInboundContext({
Body: "Alice: hi",
BodyForAgent: "hi",

View File

@ -196,7 +196,8 @@ bundledChannelRuntimeSetters.setTelegramRuntime({
channel: {
telegram: {
messageActions: {
describeMessageTool: telegramDescribeMessageToolMock,
listActions: telegramListActionsMock,
getCapabilities: telegramGetCapabilitiesMock,
},
},
},
@ -206,7 +207,8 @@ bundledChannelRuntimeSetters.setDiscordRuntime({
channel: {
discord: {
messageActions: {
describeMessageTool: discordDescribeMessageToolMock,
listActions: discordListActionsMock,
getCapabilities: discordGetCapabilitiesMock,
},
},
},
@ -405,11 +407,10 @@ export const actionContractRegistry: ActionsContractEntry[] = [
expectedActions: ["send", "poll", "react"],
expectedCapabilities: ["interactive", "buttons"],
beforeTest: () => {
telegramDescribeMessageToolMock.mockReset();
telegramDescribeMessageToolMock.mockReturnValue({
actions: ["send", "poll", "react"],
capabilities: ["interactive", "buttons"],
});
telegramListActionsMock.mockReset();
telegramGetCapabilitiesMock.mockReset();
telegramListActionsMock.mockReturnValue(["send", "poll", "react"]);
telegramGetCapabilitiesMock.mockReturnValue(["interactive", "buttons"]);
},
},
],
@ -424,11 +425,10 @@ export const actionContractRegistry: ActionsContractEntry[] = [
expectedActions: ["send", "react", "poll"],
expectedCapabilities: ["interactive", "components"],
beforeTest: () => {
discordDescribeMessageToolMock.mockReset();
discordDescribeMessageToolMock.mockReturnValue({
actions: ["send", "react", "poll"],
capabilities: ["interactive", "components"],
});
discordListActionsMock.mockReset();
discordGetCapabilitiesMock.mockReset();
discordListActionsMock.mockReturnValue(["send", "react", "poll"]);
discordGetCapabilitiesMock.mockReturnValue(["interactive", "components"]);
},
},
],

View File

@ -186,7 +186,7 @@ htmlEscapeRenderer.code = ({
escaped?: boolean;
}) => {
const normalizedLang = lang?.trim().toLowerCase() ?? "";
const langClass = lang ? ` class="language-${escapeHtml(lang)}"` : "";
const langClass = normalizedLang ? ` class="language-${escapeHtml(normalizedLang)}"` : "";
const safeText = escaped ? text : escapeHtml(text);
const codeBlock = `<pre><code${langClass}>${safeText}</code></pre>`;
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";

View File

@ -1,3 +1,5 @@
import DOMPurify from "dompurify";
type MermaidApi = {
initialize: (config: Record<string, unknown>) => void;
render: (id: string, definition: string) => Promise<{ svg: string }>;
@ -9,14 +11,21 @@ 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",
mermaidApiPromise = import("mermaid")
.then((mod) => {
const api = (mod.default ?? mod) as MermaidApi;
api.initialize({
startOnLoad: false,
securityLevel: "strict",
});
return api;
})
.catch((err) => {
// If the dynamic import fails (e.g. chunk mismatch after deploy),
// allow subsequent render attempts to retry without a full reload.
mermaidApiPromise = null;
throw err;
});
return api;
});
}
return mermaidApiPromise;
}
@ -42,7 +51,21 @@ async function renderMermaidBlocks(root: ParentNode): Promise<void> {
return;
}
const api = await loadMermaidApi();
let api: MermaidApi;
try {
api = await loadMermaidApi();
} catch (err) {
console.warn("[markdown] mermaid module load failed", err);
for (const block of blocks) {
const renderTarget = block.querySelector<HTMLElement>(".mermaid-block__render");
if (renderTarget) {
renderTarget.textContent =
"Mermaid render failed to load. Reload the page or expand source to inspect diagram text.";
}
block.dataset.mermaidStatus = "error";
}
return;
}
for (const block of blocks) {
const definition =
@ -57,7 +80,9 @@ async function renderMermaidBlocks(root: ParentNode): Promise<void> {
try {
const id = `openclaw-mermaid-${++renderCounter}`;
const { svg } = await api.render(id, definition);
renderTarget.innerHTML = svg;
renderTarget.innerHTML = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
});
block.dataset.mermaidStatus = "ready";
} catch (err) {
console.warn("[markdown] mermaid render failed", err);