mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
UI: render half-block QR output in web chat (#93869)
Co-authored-by: MG <1900448+emg110@users.noreply.github.com>
This commit is contained in:
@@ -697,6 +697,7 @@
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
font-family: var(--mono);
|
||||
overflow-x: auto;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
@@ -705,14 +706,33 @@
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-markdown pre:has(> code.markdown-block-art) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sidebar-markdown :where(pre code) {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.sidebar-markdown :where(pre code.markdown-block-art) {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-variant-ligatures: none;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.86;
|
||||
overflow-wrap: normal;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* ── Blockquotes ── */
|
||||
|
||||
.sidebar-markdown :where(blockquote) {
|
||||
|
||||
@@ -112,11 +112,30 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-text :where(pre:has(> code.markdown-block-art)) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-text :where(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.chat-text :where(pre code.markdown-block-art) {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-variant-ligatures: none;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.86;
|
||||
overflow-wrap: normal;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.chat-text :where(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 12px;
|
||||
|
||||
@@ -471,6 +471,25 @@
|
||||
word-break: inherit;
|
||||
}
|
||||
|
||||
.chat-tool-card__block-content:has(> code.markdown-block-art) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.chat-tool-card__block-content code.markdown-block-art {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-variant-ligatures: none;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.86;
|
||||
overflow-wrap: normal;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.chat-tool-card__inline {
|
||||
margin-top: 10px;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -2210,13 +2210,35 @@
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre {
|
||||
margin: 0;
|
||||
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre:has(> code.markdown-block-art),
|
||||
.chat-text :where(pre:has(> code.markdown-block-art)),
|
||||
.sidebar-markdown :where(pre:has(> code.markdown-block-art)) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
:is(.code-block-wrapper, .chat-text, .sidebar-markdown) :where(pre code.markdown-block-art) {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
font-variant-ligatures: none;
|
||||
letter-spacing: 0;
|
||||
line-height: 0.86;
|
||||
overflow-wrap: normal;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const blockArtCopyPayloadPrefix = "openclaw:block-art-code:";
|
||||
export const blockArtCodeBlockCopyPayloadEncoding = "block-art-json";
|
||||
|
||||
export function encodeBlockArtCodeBlockCopyPayload(value: string): string {
|
||||
return `${blockArtCopyPayloadPrefix}${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
export function decodeCodeBlockCopyPayload(value: string, encoding?: string): string {
|
||||
if (
|
||||
encoding !== blockArtCodeBlockCopyPayloadEncoding ||
|
||||
!value.startsWith(blockArtCopyPayloadPrefix)
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(value.slice(blockArtCopyPayloadPrefix.length));
|
||||
return typeof decoded === "string" ? decoded : value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ vi.mock("../../local-storage.ts", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
isMarkdownBlockArtText: () => false,
|
||||
toSanitizedMarkdownHtml: markdownRenderMock,
|
||||
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
|
||||
toStreamingPlainTextHtml: streamingTextRenderMock,
|
||||
|
||||
@@ -252,6 +252,7 @@ describe("tool-cards", () => {
|
||||
expect(rawToggle!.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(rawBody!.hidden).toBe(false);
|
||||
expect(rawBody!.querySelector(".chat-tool-card__block-label")?.textContent).toBe("Tool output");
|
||||
expect(rawBody!.querySelector("code.markdown-block-art")).toBeNull();
|
||||
expect(JSON.parse(rawBody!.querySelector("code")?.textContent ?? "{}")).toEqual({
|
||||
kind: "canvas",
|
||||
presentation: {
|
||||
@@ -267,6 +268,36 @@ describe("tool-cards", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks expanded raw block-art output so QR whitespace uses block-art rendering", () => {
|
||||
const container = document.createElement("div");
|
||||
const blockArt = " ▄▄▄▄▄▄▄ \n █ ▄▄▄ █ \n █▄▄▄▄▄█ ";
|
||||
render(
|
||||
renderToolCard(
|
||||
{
|
||||
id: "msg:view:block-art",
|
||||
name: "canvas_render",
|
||||
outputText: blockArt,
|
||||
preview: {
|
||||
kind: "canvas",
|
||||
surface: "assistant_message",
|
||||
render: "url",
|
||||
viewId: "qr_preview",
|
||||
url: "/__openclaw__/canvas/documents/qr_preview/index.html",
|
||||
},
|
||||
},
|
||||
{ expanded: true, onToggleExpanded: vi.fn() },
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const rawToggle = container.querySelector<HTMLButtonElement>(".chat-tool-card__raw-toggle");
|
||||
rawToggle!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
|
||||
const code = container.querySelector("code.markdown-block-art");
|
||||
expect(code).not.toBeNull();
|
||||
expect(code?.textContent).toBe(blockArt);
|
||||
});
|
||||
|
||||
it("opens assistant-surface canvas payloads in the sidebar when explicitly requested", () => {
|
||||
const container = document.createElement("div");
|
||||
const onOpenSidebar = vi.fn();
|
||||
|
||||
@@ -6,6 +6,7 @@ import { t } from "../../i18n/index.ts";
|
||||
import { resolveCanvasIframeUrl } from "../canvas-url.ts";
|
||||
import { resolveEmbedSandbox, type EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { isMarkdownBlockArtText } from "../markdown.ts";
|
||||
import type { SidebarContent } from "../sidebar-content.ts";
|
||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display.ts";
|
||||
import type { ToolCard } from "../types/chat-types.ts";
|
||||
@@ -550,6 +551,7 @@ function renderToolDataBlock(params: {
|
||||
empty?: boolean;
|
||||
}) {
|
||||
const { label, text, expanded, empty } = params;
|
||||
const codeClass = isMarkdownBlockArtText(text) ? "markdown-block-art" : "";
|
||||
return html`
|
||||
<div class="chat-tool-card__block ${expanded ? "chat-tool-card__block--expanded" : ""}">
|
||||
<div class="chat-tool-card__block-header">
|
||||
@@ -559,7 +561,9 @@ function renderToolDataBlock(params: {
|
||||
${empty
|
||||
? html`<div class="chat-tool-card__block-empty muted">${text}</div>`
|
||||
: expanded
|
||||
? html`<pre class="chat-tool-card__block-content"><code>${text}</code></pre>`
|
||||
? html`<pre
|
||||
class="chat-tool-card__block-content"
|
||||
><code class=${codeClass}>${text}</code></pre>`
|
||||
: html`<div class="chat-tool-card__block-preview mono">
|
||||
${getTruncatedPreview(text)}
|
||||
</div>`}
|
||||
|
||||
@@ -60,6 +60,15 @@ describe("tool-helpers", () => {
|
||||
expect(result).toBe("This is plain text output");
|
||||
});
|
||||
|
||||
it("wraps block art output in a fence while preserving quiet-zone whitespace", () => {
|
||||
const input = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
expect(result).toBe(`\`\`\`
|
||||
${input}
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
it("returns as-is for invalid JSON starting with {", () => {
|
||||
const input = "{not valid json";
|
||||
const result = formatToolOutputForSidebar(input);
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
* Helper functions for tool card rendering.
|
||||
*/
|
||||
|
||||
import { isMarkdownBlockArtText } from "../markdown.ts";
|
||||
import { PREVIEW_MAX_CHARS, PREVIEW_MAX_LINES } from "./constants.ts";
|
||||
|
||||
/**
|
||||
* Format tool output content for display in the sidebar.
|
||||
* Detects JSON and wraps it in a code block with formatting.
|
||||
* Detects block art and JSON, wrapping content in code blocks when needed.
|
||||
*/
|
||||
export function formatToolOutputForSidebar(text: string): string {
|
||||
if (isMarkdownBlockArtText(text)) {
|
||||
return "```\n" + text + "\n```";
|
||||
}
|
||||
|
||||
const trimmed = text.trim();
|
||||
// Try to detect and format JSON
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
|
||||
+102
-3
@@ -2,6 +2,10 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import {
|
||||
blockArtCodeBlockCopyPayloadEncoding,
|
||||
decodeCodeBlockCopyPayload,
|
||||
} from "./chat/code-block-copy-payload.ts";
|
||||
import {
|
||||
md,
|
||||
toSanitizedMarkdownHtml,
|
||||
@@ -16,6 +20,15 @@ function htmlFragment(html: string): HTMLElement {
|
||||
return container;
|
||||
}
|
||||
|
||||
function escapedCodeBlockCopyAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function withControlUiBasePath<T>(basePath: string, fn: () => T): T {
|
||||
Object.defineProperty(window, "__OPENCLAW_CONTROL_UI_BASE_PATH__", {
|
||||
value: basePath,
|
||||
@@ -359,6 +372,8 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
});
|
||||
|
||||
describe("code blocks", () => {
|
||||
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
|
||||
|
||||
it("renders fenced code blocks", () => {
|
||||
const html = toSanitizedMarkdownHtml("```ts\nconsole.log(1)\n```");
|
||||
const fragment = htmlFragment(html);
|
||||
@@ -366,23 +381,42 @@ describe("toSanitizedMarkdownHtml", () => {
|
||||
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
|
||||
|
||||
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("ts");
|
||||
expect(copy?.dataset.code).toBe("console.log(1)");
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe("console.log(1)");
|
||||
expect(copy?.dataset.codeEncoding).toBeUndefined();
|
||||
expect(code?.classList.contains("language-ts")).toBe(true);
|
||||
expect(code?.textContent).toBe("console.log(1)\n");
|
||||
});
|
||||
|
||||
it("renders raw block art as a whitespace-preserving code block", () => {
|
||||
const html = toSanitizedMarkdownHtml(blockArt);
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("pre code.markdown-block-art");
|
||||
|
||||
expect(fragment.querySelector("p")).toBeNull();
|
||||
expect(code?.textContent).toBe(blockArt);
|
||||
});
|
||||
|
||||
it("marks fenced block art without syntax highlighting", () => {
|
||||
const html = toSanitizedMarkdownHtml(`\`\`\`\n${blockArt}\n\`\`\``);
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("pre code.markdown-block-art");
|
||||
|
||||
expect(code?.classList.contains("hljs")).toBe(false);
|
||||
expect(code?.textContent).toBe(`${blockArt}\n`);
|
||||
});
|
||||
|
||||
it("renders indented code blocks", () => {
|
||||
// markdown-it requires a blank line before indented code
|
||||
const html = toSanitizedMarkdownHtml("text\n\n indented code");
|
||||
expect(html).toBe(
|
||||
'<p>text</p>\n<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="indented code" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>indented code\n</code></pre></div>',
|
||||
`<p>text</p>\n<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="${escapedCodeBlockCopyAttribute("indented code")}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>indented code\n</code></pre></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
it("includes copy button", () => {
|
||||
const html = toSanitizedMarkdownHtml("```\ncode\n```");
|
||||
expect(html).toBe(
|
||||
'<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="code" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>code\n</code></pre></div>',
|
||||
`<div class="code-block-wrapper"><div class="code-block-header"><button type="button" class="code-block-copy" data-code="${escapedCodeBlockCopyAttribute("code")}" aria-label="Copy code"><span class="code-block-copy__idle">Copy</span><span class="code-block-copy__done">Copied!</span></button></div><pre><code>code\n</code></pre></div>`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -427,11 +461,41 @@ PY
|
||||
|
||||
expect(fragment.querySelector(".code-block-lang")?.textContent).toBe("js");
|
||||
expect(copy?.dataset.code).toBe(source.trimEnd());
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe(source.trimEnd());
|
||||
expect(copy?.dataset.codeEncoding).toBeUndefined();
|
||||
expect(code?.textContent).toBe(source);
|
||||
expect(code?.querySelector(".hljs-keyword")?.textContent).toBe("const");
|
||||
expect(code?.querySelector(".hljs-string")?.textContent).toBe('"yes"');
|
||||
});
|
||||
|
||||
it("keeps ordinary code blocks raw when they start with the block-art prefix", () => {
|
||||
const source = 'openclaw:block-art-code:"literal"\n';
|
||||
const html = toSanitizedMarkdownHtml(`\`\`\`txt\n${source}\`\`\``);
|
||||
const fragment = htmlFragment(html);
|
||||
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
|
||||
|
||||
expect(copy?.dataset.code).toBe(source.trimEnd());
|
||||
expect(copy?.dataset.codeEncoding).toBeUndefined();
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
|
||||
source.trimEnd(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps boundary spaces in encoded copy payloads after sanitization", () => {
|
||||
const source = " ▀▀▀▀ \n ▄▄▄▄ ";
|
||||
const html = toSanitizedMarkdownHtml(`\`\`\`\n${source}\n\`\`\``);
|
||||
const fragment = htmlFragment(html);
|
||||
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
|
||||
|
||||
expect(copy?.dataset.code).not.toMatch(/^\s|\s$/);
|
||||
expect(copy?.dataset.code).toContain("openclaw:block-art-code:");
|
||||
expect(copy?.dataset.codeEncoding).toBe(blockArtCodeBlockCopyPayloadEncoding);
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
|
||||
source,
|
||||
);
|
||||
expect(fragment.querySelector("pre code")?.textContent).toBe(`${source}\n`);
|
||||
});
|
||||
|
||||
it("highlights collapsed JSON code blocks", () => {
|
||||
const html = toSanitizedMarkdownHtml('```json\n{"ok": true}\n```');
|
||||
const fragment = htmlFragment(html);
|
||||
@@ -478,6 +542,9 @@ PY
|
||||
const chineseCopy = chineseFragment.querySelector<HTMLButtonElement>(".code-block-copy");
|
||||
|
||||
expect(englishCopy?.dataset.code).toBe("const localizedCopy = true;");
|
||||
expect(decodeCodeBlockCopyPayload(englishCopy?.dataset.code ?? "")).toBe(
|
||||
"const localizedCopy = true;",
|
||||
);
|
||||
expect(englishCopy?.getAttribute("aria-label")).toBe("Copy code");
|
||||
expect(englishCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("Copy");
|
||||
expect(englishCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("Copied!");
|
||||
@@ -486,6 +553,9 @@ PY
|
||||
);
|
||||
|
||||
expect(chineseCopy?.dataset.code).toBe("const localizedCopy = true;");
|
||||
expect(decodeCodeBlockCopyPayload(chineseCopy?.dataset.code ?? "")).toBe(
|
||||
"const localizedCopy = true;",
|
||||
);
|
||||
expect(chineseCopy?.getAttribute("aria-label")).toBe("复制代码");
|
||||
expect(chineseCopy?.querySelector(".code-block-copy__idle")?.textContent).toBe("复制");
|
||||
expect(chineseCopy?.querySelector(".code-block-copy__done")?.textContent).toBe("已复制!");
|
||||
@@ -507,6 +577,7 @@ PY
|
||||
expect(details?.querySelector("summary")?.textContent).toBe("JSON · 2 lines");
|
||||
expect(details?.querySelector(".code-block-lang")?.textContent).toBe("json");
|
||||
expect(copy?.dataset.code).toBe('{"key": "value"}');
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "")).toBe('{"key": "value"}');
|
||||
expect(code?.classList.contains("language-json")).toBe(true);
|
||||
expect(code?.textContent).toBe('{"key": "value"}\n');
|
||||
});
|
||||
@@ -739,6 +810,34 @@ describe("toStreamingPlainTextHtml", () => {
|
||||
});
|
||||
|
||||
describe("toStreamingMarkdownHtml", () => {
|
||||
it("renders streaming raw block art without collapsing quiet-zone spaces", () => {
|
||||
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
|
||||
const html = toStreamingMarkdownHtml(blockArt);
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("pre code.markdown-block-art");
|
||||
|
||||
expect(fragment.querySelector("p")).toBeNull();
|
||||
expect(code?.textContent).toBe(blockArt);
|
||||
});
|
||||
|
||||
it("truncates oversized streaming raw block art before rendering", () => {
|
||||
const line = " ▀▀▀▀ ";
|
||||
const blockArt = Array.from({ length: 20_000 }, () => line).join("\n");
|
||||
const html = toStreamingMarkdownHtml(blockArt);
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("pre code.markdown-block-art");
|
||||
const copy = fragment.querySelector<HTMLButtonElement>(".code-block-copy");
|
||||
|
||||
expect(code?.textContent).toContain("… truncated");
|
||||
expect(code?.textContent).toContain(`showing first 140000`);
|
||||
expect(code?.textContent?.length).toBeLessThan(blockArt.length);
|
||||
expect(copy?.dataset.code).toContain("openclaw:block-art-code:");
|
||||
expect(copy?.dataset.codeEncoding).toBe(blockArtCodeBlockCopyPayloadEncoding);
|
||||
expect(decodeCodeBlockCopyPayload(copy?.dataset.code ?? "", copy?.dataset.codeEncoding)).toBe(
|
||||
code?.textContent,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders completed block prefixes as markdown and keeps the open tail plain", () => {
|
||||
const html = toStreamingMarkdownHtml("## Done\n\nworking **tail");
|
||||
|
||||
|
||||
+130
-61
@@ -19,6 +19,10 @@ import MarkdownIt from "markdown-it";
|
||||
import markdownItTaskLists from "markdown-it-task-lists";
|
||||
import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js";
|
||||
import { i18n, t } from "../i18n/index.ts";
|
||||
import {
|
||||
blockArtCodeBlockCopyPayloadEncoding,
|
||||
encodeBlockArtCodeBlockCopyPayload,
|
||||
} from "./chat/code-block-copy-payload.ts";
|
||||
import { truncateText } from "./format.ts";
|
||||
import { inferBasePathFromPathname, normalizeBasePath, tabFromPath } from "./navigation.ts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts";
|
||||
@@ -71,6 +75,7 @@ const allowedAttrs = [
|
||||
"src",
|
||||
"alt",
|
||||
"data-code",
|
||||
"data-code-encoding",
|
||||
"type",
|
||||
"aria-label",
|
||||
];
|
||||
@@ -86,6 +91,8 @@ const MARKDOWN_PARSE_LIMIT = 40_000;
|
||||
const MARKDOWN_CACHE_LIMIT = 200;
|
||||
const MARKDOWN_CACHE_MAX_CHARS = 50_000;
|
||||
const INLINE_DATA_IMAGE_RE = /^data:image\/[a-z0-9.+-]+;base64,/i;
|
||||
const BLOCK_ART_LINE_RE = /^[\t \u00a0▀▄█]+$/u;
|
||||
const BLOCK_ART_GLYPH_RE = /[▀▄█]/u;
|
||||
const HOST_LOCAL_FILE_HREF_RE =
|
||||
/^(?:~\/|\/(?:Users|home|tmp|private\/tmp|var\/folders|private\/var\/folders)\/|\/[A-Za-z]:\/|[A-Za-z]:[\\/])/;
|
||||
const DOCS_ORIGIN = "https://docs.openclaw.ai";
|
||||
@@ -533,10 +540,37 @@ function normalizeMarkdownInput(markdownLocal: string): string {
|
||||
return "";
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
return appendMarkdownTruncationNotice(truncated).replace(/\r\n?/g, "\n");
|
||||
}
|
||||
|
||||
function appendMarkdownTruncationNotice(truncated: {
|
||||
text: string;
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
}): string {
|
||||
const notice = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
: "";
|
||||
return `${truncated.text}${suffix}`.replace(/\r\n?/g, "\n");
|
||||
return `${truncated.text}${notice}`;
|
||||
}
|
||||
|
||||
export function isMarkdownBlockArtText(value: string): boolean {
|
||||
const lines = value.replace(/\r\n?/g, "\n").split("\n");
|
||||
const artLines = lines.filter((line) => line.trim().length > 0);
|
||||
if (artLines.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// QR generators commonly use spaces plus upper/lower/full block glyphs.
|
||||
// Require multiple glyph-only lines so ordinary prose with a stray block character stays markdown.
|
||||
let glyphCount = 0;
|
||||
for (const line of artLines) {
|
||||
if (!BLOCK_ART_LINE_RE.test(line) || !BLOCK_ART_GLYPH_RE.test(line)) {
|
||||
return false;
|
||||
}
|
||||
glyphCount += Array.from(line).filter((char) => BLOCK_ART_GLYPH_RE.test(char)).length;
|
||||
}
|
||||
return glyphCount >= 8;
|
||||
}
|
||||
|
||||
function getFenceMarker(line: string): { marker: "`" | "~"; length: number } | null {
|
||||
@@ -693,6 +727,60 @@ function codeClassAttribute(lang: string, highlighted: string): string {
|
||||
return classes.length > 0 ? ` class="${escapeHtml(classes.join(" "))}"` : "";
|
||||
}
|
||||
|
||||
function renderCodeElement(
|
||||
text: string,
|
||||
lang: string,
|
||||
options: { blockArt?: boolean } = {},
|
||||
): string {
|
||||
if (options.blockArt || isMarkdownBlockArtText(text)) {
|
||||
return `<pre><code class="markdown-block-art">${escapeHtml(text)}</code></pre>`;
|
||||
}
|
||||
const highlighted = highlightCode(text, lang);
|
||||
const classAttr = codeClassAttribute(lang, highlighted);
|
||||
return `<pre><code${classAttr}>${highlighted}</code></pre>`;
|
||||
}
|
||||
|
||||
function renderCodeBlock(
|
||||
text: string,
|
||||
lang: string,
|
||||
env: unknown,
|
||||
options: { blockArt?: boolean; copyText?: string } = {},
|
||||
): string {
|
||||
const blockArt = options.blockArt || isMarkdownBlockArtText(text);
|
||||
const codeBlock = renderCodeElement(text, lang, { blockArt });
|
||||
if (!shouldRenderCodeBlockCopy(env)) {
|
||||
return codeBlock;
|
||||
}
|
||||
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
|
||||
const copyText = options.copyText ?? text;
|
||||
const copyPayload = blockArt ? encodeBlockArtCodeBlockCopyPayload(copyText) : copyText;
|
||||
const attrSafe = escapeHtml(copyPayload);
|
||||
const encodingAttr = blockArt
|
||||
? ` data-code-encoding="${blockArtCodeBlockCopyPayloadEncoding}"`
|
||||
: "";
|
||||
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}"${encodingAttr} aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
|
||||
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const isJson =
|
||||
lang === "json" ||
|
||||
(!lang &&
|
||||
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
|
||||
|
||||
if (isJson) {
|
||||
const lineCount = text.split("\n").length;
|
||||
const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON";
|
||||
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
|
||||
}
|
||||
|
||||
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
|
||||
}
|
||||
|
||||
function codeBlockCopyTextFromMarkdownToken(content: string): string {
|
||||
return content.endsWith("\n") ? content.slice(0, -1) : content;
|
||||
}
|
||||
|
||||
export const md = new MarkdownIt({
|
||||
html: true, // Enable HTML recognition so html_block/html_inline overrides can escape it
|
||||
breaks: true,
|
||||
@@ -964,60 +1052,17 @@ md.renderer.rules.fence = (tokens, idx, _options, env) => {
|
||||
// token.info contains the full fence info string (e.g., "json title=foo");
|
||||
// extract only the first whitespace-separated token as the language.
|
||||
const lang = token.info.trim().split(/\s+/)[0] || "";
|
||||
const text = token.content;
|
||||
const highlighted = highlightCode(text, lang);
|
||||
const classAttr = codeClassAttribute(lang, highlighted);
|
||||
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
|
||||
if (!shouldRenderCodeBlockCopy(env)) {
|
||||
return codeBlock;
|
||||
}
|
||||
const langLabel = lang ? `<span class="code-block-lang">${escapeHtml(lang)}</span>` : "";
|
||||
const attrSafe = escapeHtml(text);
|
||||
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
|
||||
const header = `<div class="code-block-header">${langLabel}${copyBtn}</div>`;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const isJson =
|
||||
lang === "json" ||
|
||||
(!lang &&
|
||||
((trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"))));
|
||||
|
||||
if (isJson) {
|
||||
const lineCount = text.split("\n").length;
|
||||
const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON";
|
||||
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
|
||||
}
|
||||
|
||||
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
|
||||
return renderCodeBlock(token.content, lang, env, {
|
||||
copyText: codeBlockCopyTextFromMarkdownToken(token.content),
|
||||
});
|
||||
};
|
||||
|
||||
// Override indented code blocks (code_block) with the same treatment as fence
|
||||
md.renderer.rules.code_block = (tokens, idx, _options, env) => {
|
||||
const token = tokens[idx];
|
||||
const text = token.content;
|
||||
const highlighted = highlightCode(text, "");
|
||||
const classAttr = codeClassAttribute("", highlighted);
|
||||
const codeBlock = `<pre><code${classAttr}>${highlighted}</code></pre>`;
|
||||
if (!shouldRenderCodeBlockCopy(env)) {
|
||||
return codeBlock;
|
||||
}
|
||||
const attrSafe = escapeHtml(text);
|
||||
const copyBtn = `<button type="button" class="code-block-copy" data-code="${attrSafe}" aria-label="${escapeHtml(t("common.copyCode"))}"><span class="code-block-copy__idle">${escapeHtml(t("common.copy"))}</span><span class="code-block-copy__done">${escapeHtml(t("common.copied"))}</span></button>`;
|
||||
const header = `<div class="code-block-header">${copyBtn}</div>`;
|
||||
|
||||
const trimmed = text.trim();
|
||||
const isJson =
|
||||
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
||||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
|
||||
|
||||
if (isJson) {
|
||||
const lineCount = text.split("\n").length;
|
||||
const label = lineCount > 1 ? `JSON · ${lineCount} lines` : "JSON";
|
||||
return `<details class="json-collapse"><summary>${label}</summary><div class="code-block-wrapper">${header}${codeBlock}</div></details>`;
|
||||
}
|
||||
|
||||
return `<div class="code-block-wrapper">${header}${codeBlock}</div>`;
|
||||
const content = tokens[idx].content;
|
||||
return renderCodeBlock(content, "", env, {
|
||||
copyText: codeBlockCopyTextFromMarkdownToken(content),
|
||||
});
|
||||
};
|
||||
|
||||
export function toSanitizedMarkdownHtml(
|
||||
@@ -1025,27 +1070,36 @@ export function toSanitizedMarkdownHtml(
|
||||
options: MarkdownRenderOptions = {},
|
||||
): string {
|
||||
const renderOptions = normalizeMarkdownRenderOptions(options);
|
||||
const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim();
|
||||
const rawInput = stripUnsupportedCitationControlMarkers(markdownLocal).replace(/\r\n?/g, "\n");
|
||||
const input = rawInput.trim();
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
installHooks();
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${input}`;
|
||||
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${renderInput}`;
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
const cached = getCachedMarkdown(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
: "";
|
||||
const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT);
|
||||
if (isMarkdownBlockArtText(truncated.text)) {
|
||||
const rendered = renderCodeBlock(appendMarkdownTruncationNotice(truncated), "", renderOptions, {
|
||||
blockArt: true,
|
||||
});
|
||||
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
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.
|
||||
const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
|
||||
const html = toEscapedPlainTextHtml(appendMarkdownTruncationNotice(truncated));
|
||||
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
@@ -1054,11 +1108,11 @@ export function toSanitizedMarkdownHtml(
|
||||
}
|
||||
let rendered: string;
|
||||
try {
|
||||
rendered = md.render(`${truncated.text}${suffix}`, renderOptions);
|
||||
rendered = md.render(appendMarkdownTruncationNotice(truncated), 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);
|
||||
const escaped = escapeHtml(`${truncated.text}${suffix}`);
|
||||
const escaped = escapeHtml(appendMarkdownTruncationNotice(truncated));
|
||||
rendered = `<pre class="code-block">${escaped}</pre>`;
|
||||
}
|
||||
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
|
||||
@@ -1084,6 +1138,21 @@ export function toStreamingMarkdownHtml(
|
||||
markdownLocal: string,
|
||||
options: MarkdownRenderOptions = {},
|
||||
): string {
|
||||
const rawInput = stripUnsupportedCitationControlMarkers(markdownLocal).replace(/\r\n?/g, "\n");
|
||||
if (isMarkdownBlockArtText(rawInput)) {
|
||||
const truncated = truncateText(rawInput, MARKDOWN_CHAR_LIMIT);
|
||||
installHooks();
|
||||
return DOMPurify.sanitize(
|
||||
renderCodeBlock(
|
||||
appendMarkdownTruncationNotice(truncated),
|
||||
"",
|
||||
normalizeMarkdownRenderOptions(options),
|
||||
{ blockArt: true },
|
||||
),
|
||||
sanitizeOptions,
|
||||
);
|
||||
}
|
||||
|
||||
const input = normalizeMarkdownInput(markdownLocal);
|
||||
if (!input) {
|
||||
return "";
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState } from "../chat/chat-welcome.ts";
|
||||
import {
|
||||
blockArtCodeBlockCopyPayloadEncoding,
|
||||
encodeBlockArtCodeBlockCopyPayload,
|
||||
} from "../chat/code-block-copy-payload.ts";
|
||||
import { renderChatSessionSelect } from "../chat/session-controls.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import type { GatewaySessionRow, ModelCatalogEntry, SessionsListResult } from "../types.ts";
|
||||
@@ -166,6 +170,7 @@ vi.mock("../chat/grouped-render.ts", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
isMarkdownBlockArtText: () => false,
|
||||
toSanitizedMarkdownHtml: (value: string) => value,
|
||||
}));
|
||||
|
||||
@@ -618,6 +623,62 @@ describe("chat compaction divider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat code-block copy", () => {
|
||||
it("copies decoded QR block-art boundary spaces from the delegated button handler", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const container = renderChatView();
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread");
|
||||
const payload = " ▀▀▀▀ \n ▄▄▄▄ ";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "code-block-copy";
|
||||
button.dataset.code = encodeBlockArtCodeBlockCopyPayload(payload);
|
||||
button.dataset.codeEncoding = blockArtCodeBlockCopyPayloadEncoding;
|
||||
thread.appendChild(button);
|
||||
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("keeps legacy raw data-code payloads copyable", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const container = renderChatView();
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread");
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "code-block-copy";
|
||||
button.dataset.code = "legacy text";
|
||||
thread.appendChild(button);
|
||||
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith("legacy text");
|
||||
});
|
||||
|
||||
it("does not decode unmarked raw data-code payloads that start with the block-art prefix", async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||
const container = renderChatView();
|
||||
const thread = requireElement(container, ".chat-thread", "chat thread");
|
||||
const payload = 'openclaw:block-art-code:"literal"';
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "code-block-copy";
|
||||
button.dataset.code = payload;
|
||||
thread.appendChild(button);
|
||||
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat history render window", () => {
|
||||
it("starts freshly loaded large histories with a small render window", () => {
|
||||
const messages = Array.from({ length: 80 }, (_, index) => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { renderChatQueue } from "../chat/chat-queue.ts";
|
||||
import { buildRawSidebarContent } from "../chat/chat-sidebar-raw.ts";
|
||||
import { renderWelcomeState, resolveAssistantDisplayAvatar } from "../chat/chat-welcome.ts";
|
||||
import { copyToClipboard } from "../chat/clipboard.ts";
|
||||
import { decodeCodeBlockCopyPayload } from "../chat/code-block-copy-payload.ts";
|
||||
import { renderContextNotice } from "../chat/context-notice.ts";
|
||||
import { DeletedMessages } from "../chat/deleted-messages.ts";
|
||||
import { exportChatMarkdown } from "../chat/export.ts";
|
||||
@@ -2072,7 +2073,8 @@ export function renderChat(props: ChatProps) {
|
||||
if (!btn) {
|
||||
return;
|
||||
}
|
||||
const code = (btn as HTMLElement).dataset.code ?? "";
|
||||
const button = btn as HTMLElement;
|
||||
const code = decodeCodeBlockCopyPayload(button.dataset.code ?? "", button.dataset.codeEncoding);
|
||||
void copyToClipboard(code).then((copied) => {
|
||||
if (!copied) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user