mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 21:04:07 +00:00
perf(ui): skip markdown parsing while chat streams
This commit is contained in:
@@ -29,6 +29,14 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown-plain-text-fallback {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-text :where(table) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -15,6 +15,9 @@ const localStorageValues = vi.hoisted(() => new Map<string, string>());
|
||||
const markdownRenderMock = vi.hoisted(() =>
|
||||
vi.fn((value: string, _options?: { codeBlockChrome?: "copy" | "none" }) => value),
|
||||
);
|
||||
const streamingTextRenderMock = vi.hoisted(() =>
|
||||
vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),
|
||||
);
|
||||
|
||||
vi.mock("../../local-storage.ts", () => ({
|
||||
getSafeLocalStorage: () => ({
|
||||
@@ -26,6 +29,7 @@ vi.mock("../../local-storage.ts", () => ({
|
||||
|
||||
vi.mock("../markdown.ts", () => ({
|
||||
toSanitizedMarkdownHtml: markdownRenderMock,
|
||||
toStreamingPlainTextHtml: streamingTextRenderMock,
|
||||
}));
|
||||
|
||||
vi.mock("../icons.ts", () => ({
|
||||
@@ -887,6 +891,19 @@ describe("grouped chat rendering", () => {
|
||||
expect(bubble?.classList.contains("streaming")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders streaming text without markdown sanitization", () => {
|
||||
const container = document.createElement("div");
|
||||
markdownRenderMock.mockClear();
|
||||
streamingTextRenderMock.mockClear();
|
||||
|
||||
render(renderStreamingGroup("**live**\nreply", 1), container);
|
||||
|
||||
expect(markdownRenderMock).not.toHaveBeenCalled();
|
||||
expect(streamingTextRenderMock).toHaveBeenCalledWith("**live**\nreply");
|
||||
const text = container.querySelector(".markdown-plain-text-fallback");
|
||||
expect(text?.textContent).toBe("**live**\nreply");
|
||||
});
|
||||
|
||||
it("renders configured local user names", () => {
|
||||
const renderUser = (opts: Partial<RenderMessageGroupOptions>) => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
import { toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "../markdown.ts";
|
||||
import { openExternalUrlSafe } from "../open-external-url.ts";
|
||||
import type { SidebarContent } from "../sidebar-content.ts";
|
||||
import { detectTextDirection } from "../text-direction.ts";
|
||||
@@ -1813,11 +1813,7 @@ function renderGroupedMessage(
|
||||
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
|
||||
</details>`
|
||||
: markdown
|
||||
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(
|
||||
toSanitizedMarkdownHtml(markdown, markdownRenderOptions),
|
||||
)}
|
||||
</div>`
|
||||
? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
|
||||
: nothing}
|
||||
${hasToolCards
|
||||
? singleToolCard && !markdown && !hasImages
|
||||
@@ -1880,9 +1876,7 @@ function renderGroupedMessage(
|
||||
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
|
||||
</details>`
|
||||
: markdown
|
||||
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}
|
||||
</div>`
|
||||
? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
|
||||
: nothing}
|
||||
${hasToolCards
|
||||
? renderInlineToolCards(toolCards, {
|
||||
@@ -1910,3 +1904,22 @@ function renderGroupedMessage(
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMarkdownText(
|
||||
markdown: string,
|
||||
isStreaming: boolean,
|
||||
markdownRenderOptions?: { codeBlockChrome: "copy" | "none" },
|
||||
) {
|
||||
if (isStreaming) {
|
||||
return html`
|
||||
<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(toStreamingPlainTextHtml(markdown))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="chat-text" dir="${detectTextDirection(markdown)}">
|
||||
${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { render } from "lit";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "../i18n/index.ts";
|
||||
import { md, toSanitizedMarkdownHtml } from "./markdown.ts";
|
||||
import { md, toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "./markdown.ts";
|
||||
import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts";
|
||||
|
||||
function htmlFragment(html: string): HTMLElement {
|
||||
@@ -685,6 +685,20 @@ PY
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStreamingPlainTextHtml", () => {
|
||||
it("strips unsupported citation control markers before escaping streaming text", () => {
|
||||
const html = toStreamingPlainTextHtml(
|
||||
"v2026.5.20 release note citeturn2view0\n\nStill readable.",
|
||||
);
|
||||
|
||||
expect(html).toBe(
|
||||
'<div class="markdown-plain-text-fallback">v2026.5.20 release note\n\nStill readable.</div>',
|
||||
);
|
||||
expect(html).not.toContain("cite");
|
||||
expect(html).not.toContain("turn2view0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderMarkdownSidebar", () => {
|
||||
it("renders sanitized markdown content", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
@@ -634,7 +634,7 @@ export function toSanitizedMarkdownHtml(
|
||||
// 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 = renderEscapedPlainTextHtml(`${truncated.text}${suffix}`);
|
||||
const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
|
||||
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
@@ -657,6 +657,18 @@ export function toSanitizedMarkdownHtml(
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
function renderEscapedPlainTextHtml(value: string): string {
|
||||
export function toEscapedPlainTextHtml(value: string): string {
|
||||
return `<div class="markdown-plain-text-fallback">${escapeHtml(value.replace(/\r\n?/g, "\n"))}</div>`;
|
||||
}
|
||||
|
||||
export function toStreamingPlainTextHtml(markdownLocal: string): string {
|
||||
const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim();
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
const suffix = truncated.truncated
|
||||
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
|
||||
: "";
|
||||
return toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user