// Control UI module implements markdown behavior. import DOMPurify from "dompurify"; import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js"; import { routeIdFromPath } from "../app-route-paths.ts"; import { resolveControlUiBasePath } from "../app/browser.ts"; import { i18n, t } from "../i18n/index.ts"; import { truncateText } from "../lib/format.ts"; import { normalizeLowercaseStringOrEmpty } from "../lib/string-coerce.ts"; import { renderAssistantTranscriptPlainTextFallback } from "./markdown-assistant-transcript.ts"; import { renderMarkdownCodeBlock } from "./markdown-code-blocks.ts"; import { isHostLocalMarkdownFileHref } from "./markdown-file-links.ts"; import { createMarkdownParser } from "./markdown-parser.ts"; import { normalizeMarkdownRenderOptions, type MarkdownRenderEnv, type MarkdownRenderOptions, } from "./markdown-render-options.ts"; import { repairStreamingMarkdownTail, splitStableStreamingMarkdown } from "./markdown-streaming.ts"; import { escapeMarkdownHtml, isMarkdownBlockArtText, normalizeMarkdownLineBreaks, } from "./markdown-text.ts"; const allowedTags = [ "a", "b", "blockquote", "br", "button", "code", "del", "details", "div", "em", "h1", "h2", "h3", "h4", "hr", "i", "input", "li", "ol", "p", "pre", "s", "span", "strong", "summary", "table", "tbody", "td", "th", "thead", "tr", "ul", "img", ]; const allowedAttrs = [ "checked", "class", "disabled", "href", "open", "rel", "target", "title", "start", "src", "alt", "data-code", "data-code-encoding", "data-file-line", "data-file-path", "type", "aria-label", ]; const sanitizeOptions = { ALLOWED_TAGS: allowedTags, ALLOWED_ATTR: allowedAttrs, ADD_DATA_URI_TAGS: ["img"], }; let hooksInstalled = false; const MARKDOWN_CHAR_LIMIT = 140_000; const MARKDOWN_PARSE_LIMIT = 40_000; // Covers several message-heavy sessions during rapid switching. Only inputs // up to 50k characters enter this 500-entry LRU, keeping memory bounded. const MARKDOWN_CACHE_LIMIT = 500; const MARKDOWN_CACHE_MAX_CHARS = 50_000; const DOCS_ORIGIN = "https://docs.openclaw.ai"; const DOCS_ROOT_SEGMENTS = new Set([ "agent-runtime-architecture", "announcements", "auth-credential-semantics", "automation", "brave-search", "channels", "ci", "clawhub", "cli", "concepts", "date-time", "debug", "diagnostics", "gateway", "help", "index", "install", "logging", "maturity-scorecard", "network", "nodes", "openclaw-agent-runtime", "perplexity", "plan", "platforms", "plugins", "prose", "providers", "refactor", "reference", "security", "specs", "start", "tools", "tts", "vps", "web", ]); const DOCS_SHORTLINK_PATHS = new Set([ "/AGENTS.default", "/RELEASING", "/agent", "/agent-loop", "/agent-send", "/agent-workspace", "/android", "/anthropic", "/architecture", "/audio", "/auth-monitoring", "/azure", "/background-process", "/bash", "/bonjour", "/browser", "/browser-linux-troubleshooting", "/bun", "/camera", "/clawd", "/clawdhub", "/compaction", "/configuration", "/context", "/context-engine", "/control-ui", "/cron", "/cron-jobs", "/cron-vs-heartbeat", "/dashboard", "/device-models", "/discord", "/discovery", "/docker", "/doctor", "/duckduckgo-search", "/elevated", "/exa-search", "/experiments/plans/cron-add-hardening", "/experiments/plans/group-policy-hardening", "/faq", "/gateway-lock", "/gcp", "/gemini-search", "/getting-started", "/glm", "/gmail-pubsub", "/grammy", "/grok-search", "/group-messages", "/groups", "/health", "/heartbeat", "/hubs", "/images", "/imessage", "/ios", "/kimi-search", "/line", "/linux", "/location", "/location-command", "/lore", "/mac/bun", "/mac/canvas", "/mac/child-process", "/mac/dev-setup", "/mac/health", "/mac/icon", "/mac/logging", "/mac/menu-bar", "/mac/peekaboo", "/mac/permissions", "/mac/release", "/mac/remote", "/mac/signing", "/mac/skills", "/mac/voice-overlay", "/mac/voicewake", "/mac/webchat", "/mac/xpc", "/macos", "/mattermost", "/mcp", "/message", "/messages", "/minimax", "/mistral", "/model", "/model-failover", "/models", "/moonshot", "/multi-agent", "/nix", "/northflank", "/oauth", "/onboarding", "/openai", "/opencode", "/opencode-go", "/openrouter", "/pairing", "/pi", "/pi-dev", "/plugin", "/podman", "/poll", "/presence", "/provider-routing", "/qianfan", "/queue", "/quickstart", "/railway", "/remote", "/remote-gateway-readme", "/render", "/rpc", "/sandbox", "/sandboxing", "/session", "/session-tool", "/sessions", "/setup", "/showcase", "/signal", "/skill-workshop", "/skills", "/skills-config", "/slack", "/slash-commands", "/subagents", "/tailscale", "/talk", "/telegram", "/templates/AGENTS", "/templates/BOOT", "/templates/BOOTSTRAP", "/templates/HEARTBEAT", "/templates/IDENTITY", "/templates/SOUL", "/templates/TOOLS", "/templates/USER", "/test", "/thinking", "/timezone", "/troubleshooting", "/tui", "/typebox", "/updating", "/voicewake", "/web-fetch", "/webchat", "/webhook", "/whatsapp", "/windows", "/wizard", "/xiaomi", "/zai", ]); const APP_RESOURCE_ROOT_SEGMENTS = new Set([ "__openclaw", "__openclaw__", "_next", "api", "apple-touch-icon.png", "assets", "avatar", "favicon-32.png", "favicon.ico", "favicon.svg", "manifest.json", "manifest.webmanifest", "media", "res", "socket.io", "sw.js", "static", "ws", ]); const APP_RESOURCE_PATH_PREFIXES = [ ["plugins", "diffs"], ["plugins", "diffs-language-pack"], ]; const markdownCache = new Map(); const TAIL_LINK_BLUR_CLASS = "chat-link-tail-blur"; function getCachedMarkdown(key: string): string | null { const cached = markdownCache.get(key); if (cached === undefined) { return null; } markdownCache.delete(key); markdownCache.set(key, cached); return cached; } function setCachedMarkdown(key: string, value: string) { markdownCache.set(key, value); if (markdownCache.size <= MARKDOWN_CACHE_LIMIT) { return; } const oldest = markdownCache.keys().next().value; if (oldest) { markdownCache.delete(oldest); } } function isControlUiRoutePath(pathname: string): boolean { if (routeIdFromPath(pathname) !== null) { return true; } const basePath = currentControlUiBasePath(); if (!basePath) { return false; } if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) { return false; } return routeIdFromPath(pathname, basePath) !== null; } function currentControlUiBasePath(): string { if (typeof window === "undefined") { return ""; } return resolveControlUiBasePath(window.location.pathname); } function pathSegments(pathname: string): string[] { return pathname.split("/").filter(Boolean); } function stripCurrentControlUiBasePath(pathname: string): string[] { const segments = pathSegments(pathname); const baseSegments = pathSegments(currentControlUiBasePath()); if ( baseSegments.length === 0 || baseSegments.some((segment, index) => segments[index] !== segment) ) { return segments; } return segments.slice(baseSegments.length); } function segmentsStartWith(segments: string[], prefix: string[]): boolean { return prefix.every((segment, index) => segments[index] === segment); } function isControlUiResourcePath(segments: string[]): boolean { if (segments.includes("__openclaw__") || segments.includes("__openclaw")) { return true; } const segment = segments[0]; if (!segment || APP_RESOURCE_ROOT_SEGMENTS.has(segment)) { return true; } return APP_RESOURCE_PATH_PREFIXES.some((prefix) => segmentsStartWith(segments, prefix)); } function isDocsRootPath(normalizedPath: string, segments: string[]): boolean { if (DOCS_SHORTLINK_PATHS.has(normalizedPath)) { return true; } const segment = segments[0]; return segment ? DOCS_ROOT_SEGMENTS.has(segment) : false; } function normalizeDocsRootHref(href: string): string { const trimmed = href.trim(); if (!trimmed.startsWith("/") || trimmed.startsWith("//")) { return href; } try { const url = new URL(trimmed, DOCS_ORIGIN); if (url.origin !== DOCS_ORIGIN) { return href; } const normalizedPath = url.pathname.replace(/\/+$/, "") || "/"; if (isControlUiRoutePath(normalizedPath)) { return href; } const segments = pathSegments(normalizedPath); const resourceSegments = stripCurrentControlUiBasePath(normalizedPath); if (isControlUiResourcePath(resourceSegments)) { return href; } if (isDocsRootPath(normalizedPath, segments)) { return url.href; } return href; } catch { return href; } } function installHooks() { if (hooksInstalled) { return; } hooksInstalled = true; DOMPurify.addHook("afterSanitizeAttributes", (node) => { if (!(node instanceof HTMLAnchorElement)) { return; } const href = node.getAttribute("href"); if (!href) { return; } if (isHostLocalMarkdownFileHref(href)) { node.removeAttribute("href"); return; } const normalizedHref = normalizeDocsRootHref(href); if (normalizedHref !== href) { node.setAttribute("href", normalizedHref); } // Block dangerous URL schemes (javascript:, data:, vbscript:, etc.) try { const url = new URL(normalizedHref, window.location.href); if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") { node.removeAttribute("href"); return; } } catch { // Relative URLs are fine; malformed absolute URLs with dangerous schemes // will fail to parse and keep their href — but DOMPurify already strips // javascript: by default. This is defense-in-depth. } node.setAttribute("rel", "noreferrer noopener"); node.setAttribute("target", "_blank"); if (normalizeLowercaseStringOrEmpty(href).includes("tail")) { node.classList.add(TAIL_LINK_BLUR_CLASS); } }); } function formatTruncatedMarkdownInput(input: string): string { const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); return appendMarkdownTruncationNotice(truncated); } function appendMarkdownTruncationNotice(truncated: { text: string; truncated: boolean; total: number; }): string { const notice = truncated.truncated ? `\n\n${t("chat.markdown.truncated", { total: String(truncated.total), shown: String(truncated.text.length), })}` : ""; return `${truncated.text}${notice}`; } const markdownParser = createMarkdownParser(); // Uncached render core shared by the static and streaming paths. The streaming // tail changes on every delta, so routing it through here (instead of the cached // wrapper) keeps per-message churn out of the LRU cache. function renderSanitizedMarkdown(renderInput: string, renderOptions: MarkdownRenderEnv): string { installHooks(); const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT); const input = appendMarkdownTruncationNotice(truncated); if (isMarkdownBlockArtText(truncated.text)) { return DOMPurify.sanitize( renderMarkdownCodeBlock(input, "", renderOptions, { blockArt: true }), sanitizeOptions, ); } if (truncated.text.length > MARKDOWN_PARSE_LIMIT) { // Large plain-text replies should stay readable without inheriting the // capped code-block chrome, while still preserving whitespace for logs // and other structured text that commonly trips the parse guard. return DOMPurify.sanitize(toEscapedPlainTextHtml(input, renderOptions), sanitizeOptions); } let rendered: string; try { rendered = markdownParser.render(input, renderOptions); } catch (err) { // Fall back to escaped plain text when md.render() throws (#36213). console.warn("[markdown] md.render failed, falling back to plain text:", err); rendered = toEscapedPlainTextHtml(input, renderOptions); } return DOMPurify.sanitize(rendered, sanitizeOptions); } export function toSanitizedMarkdownHtml( markdownLocal: string, options: MarkdownRenderOptions = {}, ): string { const renderOptions = normalizeMarkdownRenderOptions(options); const rawInput = normalizeMarkdownLineBreaks( stripUnsupportedCitationControlMarkers(markdownLocal), ); const input = rawInput.trim(); if (!input) { return ""; } const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input; const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS; const cacheKey = `${i18n.getLocale()}\0${renderOptions.assistantTranscriptRoleHeaders}\0${renderOptions.codeBlockChrome}\0${renderOptions.fileLinks}\0${renderOptions.interactiveImages}\0${renderInput}`; if (cacheable) { const cached = getCachedMarkdown(cacheKey); if (cached !== null) { return cached; } } const sanitized = renderSanitizedMarkdown(renderInput, renderOptions); if (cacheable) { setCachedMarkdown(cacheKey, sanitized); } return sanitized; } function toEscapedPlainTextHtml(value: string, options: MarkdownRenderEnv): string { return renderAssistantTranscriptPlainTextFallback( normalizeMarkdownLineBreaks(value), options.assistantTranscriptRoleHeaders, () => t("sessionsView.assistant"), escapeMarkdownHtml, ); } export function toStreamingMarkdownHtml( markdownLocal: string, options: MarkdownRenderOptions = {}, ): string { const renderOptions = normalizeMarkdownRenderOptions(options); const rawInput = normalizeMarkdownLineBreaks( stripUnsupportedCitationControlMarkers(markdownLocal), ); if (isMarkdownBlockArtText(rawInput)) { return renderSanitizedMarkdown(rawInput, renderOptions); } const trimmedInput = rawInput.trim(); if (!trimmedInput) { return ""; } const input = formatTruncatedMarkdownInput(trimmedInput); const { boundary, tailRepairStart } = splitStableStreamingMarkdown(input); const stableMarkdown = input.slice(0, boundary); const streamingTail = input.slice(boundary); const stableHtml = boundary > 0 ? toSanitizedMarkdownHtml(stableMarkdown, options) : ""; if (!streamingTail.trim()) { return stableHtml; } const tailHtml = tailRepairStart === null ? renderSanitizedMarkdown(streamingTail, renderOptions) : renderSanitizedMarkdown( repairStreamingMarkdownTail(streamingTail, tailRepairStart - boundary), renderOptions, ); return `${stableHtml}${tailHtml}`; }