perf(ui): skip markdown parsing while chat streams

This commit is contained in:
Vincent Koc
2026-06-01 01:59:59 +01:00
parent fda5254e99
commit 732748c8c5
5 changed files with 76 additions and 12 deletions

View File

@@ -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%;

View File

@@ -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");

View File

@@ -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>
`;
}

View File

@@ -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 citeturn2view0\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");

View File

@@ -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}`);
}