feat(control-ui): harden Mermaid rendering
This commit is contained in:
parent
962fa68527
commit
c11dbc797f
@ -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",
|
||||
|
||||
@ -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"]);
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -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>` : "";
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user