mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-16 23:52:40 -06:00
755eea5e97
* 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
597 lines
14 KiB
TypeScript
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}`;
|
|
}
|