Files
openclaw/ui/src/components/markdown.ts
T
Vincent Koc 755eea5e97 fix(ui): localize remaining control surfaces (#115730)
* feat(ui): add localization keys for control surfaces

* fix(ui): localize relative time and durations

* fix(ui): localize update and recovery notices

* fix(ui): localize chat controls and command results

* fix(ui): localize memory management views

* fix(ui): localize shared tool surfaces

* fix(ui): localize settings and action notices

* chore(ui): tighten localization raw-copy baseline

* fix(ui): preserve localized status copy

* fix(ui): preserve compact sidebar time label

* fix(ui): preserve near-future sidebar direction

* test(ui): align screenshot localization assertion
2026-07-29 16:34:35 +08:00

597 lines
14 KiB
TypeScript

// 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<string, string>();
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}`;
}