mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-14 07:46:03 +00:00
feat(ui): render streaming chat markdown live instead of plain text (#106062)
Streaming assistant replies in the Control UI now render markdown live: the in-flight tail is repaired with remend (zero-dep, Apache-2.0, extracted from Vercel's Streamdown) before the existing markdown-it + DOMPurify pipeline, and open code fences stream as live-highlighted code blocks. Deletes the plain-text streaming tail path (toStreamingPlainTextHtml) and extracts an uncached render core so tail deltas stop churning the markdown LRU cache. Closes #106053
This commit is contained in:
committed by
GitHub
parent
0633afe6a5
commit
fcb45b4400
68
pnpm-lock.yaml
generated
68
pnpm-lock.yaml
generated
@@ -596,19 +596,6 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/workspaces:
|
||||
dependencies:
|
||||
typebox:
|
||||
specifier: 1.3.3
|
||||
version: 1.3.3
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/crabbox:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
@@ -1845,6 +1832,19 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/workspaces:
|
||||
dependencies:
|
||||
typebox:
|
||||
specifier: 1.3.3
|
||||
version: 1.3.3
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/xai:
|
||||
dependencies:
|
||||
typebox:
|
||||
@@ -2009,12 +2009,12 @@ importers:
|
||||
|
||||
packages/normalization-core: {}
|
||||
|
||||
packages/retry: {}
|
||||
|
||||
packages/plugin-package-contract: {}
|
||||
|
||||
packages/plugin-sdk: {}
|
||||
|
||||
packages/retry: {}
|
||||
|
||||
packages/sdk:
|
||||
dependencies:
|
||||
'@openclaw/gateway-client':
|
||||
@@ -2115,6 +2115,9 @@ importers:
|
||||
marked:
|
||||
specifier: 18.0.5
|
||||
version: 18.0.5
|
||||
remend:
|
||||
specifier: 1.3.0
|
||||
version: 1.3.0
|
||||
devDependencies:
|
||||
'@types/markdown-it':
|
||||
specifier: 14.1.2
|
||||
@@ -7081,6 +7084,9 @@ packages:
|
||||
remark-rehype@11.1.2:
|
||||
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
|
||||
|
||||
remend@1.3.0:
|
||||
resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==}
|
||||
|
||||
repeat-string@1.6.1:
|
||||
resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -7291,14 +7297,14 @@ packages:
|
||||
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
smol-toml@1.7.0:
|
||||
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
smart-buffer@4.2.0:
|
||||
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
|
||||
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
smol-toml@1.7.0:
|
||||
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
socks-proxy-agent@8.0.5:
|
||||
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -9454,6 +9460,15 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
'@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.14(hono@4.12.25)
|
||||
@@ -9476,15 +9491,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
|
||||
'@standard-schema/spec': 1.1.0
|
||||
zod: 4.4.3
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@mozilla/readability@0.6.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
|
||||
@@ -13588,6 +13594,8 @@ snapshots:
|
||||
unified: 11.0.5
|
||||
vfile: 6.0.3
|
||||
|
||||
remend@1.3.0: {}
|
||||
|
||||
repeat-string@1.6.1: {}
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
@@ -13845,10 +13853,10 @@ snapshots:
|
||||
is-fullwidth-code-point: 5.1.0
|
||||
optional: true
|
||||
|
||||
smol-toml@1.7.0: {}
|
||||
|
||||
smart-buffer@4.2.0: {}
|
||||
|
||||
smol-toml@1.7.0: {}
|
||||
|
||||
socks-proxy-agent@8.0.5:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
|
||||
@@ -4566,7 +4566,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
|
||||
"ui/src/components/markdown.ts: encodeBlockArtCodeBlockCopyPayload",
|
||||
"ui/src/components/markdown.ts: highlightCode",
|
||||
"ui/src/components/markdown.ts: md",
|
||||
"ui/src/components/markdown.ts: toStreamingPlainTextHtml",
|
||||
"ui/src/components/mcp-app-view.ts: buildMcpAppHostCapabilities",
|
||||
"ui/src/components/mcp-app-view.ts: McpAppView",
|
||||
"ui/src/components/mcp-app-view.ts: resolveMcpAppSandboxUrl",
|
||||
|
||||
@@ -1181,7 +1181,7 @@
|
||||
"ui/src/components/github-link-hovercard.ts": 582,
|
||||
"ui/src/components/icons.ts": 701,
|
||||
"ui/src/components/lobster-pet.ts": 1790,
|
||||
"ui/src/components/markdown.ts": 1493,
|
||||
"ui/src/components/markdown.ts": 1489,
|
||||
"ui/src/components/terminal/terminal-panel.ts": 1140,
|
||||
"ui/src/components/tooltip.ts": 502,
|
||||
"ui/src/lib/agents/display.ts": 661,
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"lit": "3.3.3",
|
||||
"markdown-it": "14.3.0",
|
||||
"markdown-it-task-lists": "2.1.1",
|
||||
"marked": "18.0.5"
|
||||
"marked": "18.0.5",
|
||||
"remend": "1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "14.1.2",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
md,
|
||||
toSanitizedMarkdownHtml,
|
||||
toStreamingMarkdownHtml,
|
||||
toStreamingPlainTextHtml,
|
||||
} from "./markdown.ts";
|
||||
|
||||
function htmlFragment(html: string): HTMLElement {
|
||||
@@ -943,28 +942,6 @@ 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");
|
||||
});
|
||||
|
||||
it("normalizes Unicode and CR line breaks before escaping streaming text", () => {
|
||||
const html = toStreamingPlainTextHtml("first\u2028second\u2029third\rfourth\r\nfifth");
|
||||
|
||||
expect(html).toBe(
|
||||
'<div class="markdown-plain-text-fallback">first\nsecond\nthird\nfourth\nfifth</div>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStreamingMarkdownHtml", () => {
|
||||
it("renders streaming raw block art without collapsing quiet-zone spaces", () => {
|
||||
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
|
||||
@@ -994,40 +971,43 @@ describe("toStreamingMarkdownHtml", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders completed block prefixes as markdown and keeps the open tail plain", () => {
|
||||
it("renders completed block prefixes as markdown and closes the streaming tail", () => {
|
||||
const html = toStreamingMarkdownHtml("## Done\n\nworking **tail");
|
||||
|
||||
expect(html).toBe(
|
||||
'<h2>Done</h2>\n<div class="markdown-plain-text-fallback">working **tail</div>',
|
||||
);
|
||||
expect(html).toBe("<h2>Done</h2>\n<p>working <strong>tail</strong></p>\n");
|
||||
});
|
||||
|
||||
it("uses Unicode separators as stable markdown boundaries", () => {
|
||||
const html = toStreamingMarkdownHtml("## Done\u2028\u2028working **tail");
|
||||
|
||||
expect(html).toBe(
|
||||
'<h2>Done</h2>\n<div class="markdown-plain-text-fallback">working **tail</div>',
|
||||
);
|
||||
expect(html).toBe("<h2>Done</h2>\n<p>working <strong>tail</strong></p>\n");
|
||||
});
|
||||
|
||||
it("keeps a single open paragraph as escaped text", () => {
|
||||
it("renders a single open paragraph as markdown with closed formatting", () => {
|
||||
const html = toStreamingMarkdownHtml("**still streaming");
|
||||
|
||||
expect(html).toBe('<div class="markdown-plain-text-fallback">**still streaming</div>');
|
||||
expect(html).toBe("<p><strong>still streaming</strong></p>\n");
|
||||
});
|
||||
|
||||
it("does not invoke the markdown parser before a stable block boundary exists", () => {
|
||||
const renderSpy = vi.spyOn(md, "render");
|
||||
try {
|
||||
const html = toStreamingMarkdownHtml("**still streaming parser sentinel");
|
||||
it("renders half-written links as text only while streaming", () => {
|
||||
const html = toStreamingMarkdownHtml("see [Streamdown](https://strea");
|
||||
|
||||
expect(html).toBe(
|
||||
'<div class="markdown-plain-text-fallback">**still streaming parser sentinel</div>',
|
||||
);
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
renderSpy.mockRestore();
|
||||
}
|
||||
expect(html).toBe("<p>see Streamdown</p>\n");
|
||||
});
|
||||
|
||||
it("streams tables as markdown before the closing row arrives", () => {
|
||||
const html = toStreamingMarkdownHtml("| left | right |\n| --- | --- |\n| 1 | 2");
|
||||
const fragment = htmlFragment(html);
|
||||
|
||||
expect(fragment.querySelector("table")).not.toBeNull();
|
||||
expect(fragment.querySelector("th")?.textContent).toBe("left");
|
||||
expect(html).not.toContain("markdown-plain-text-fallback");
|
||||
});
|
||||
|
||||
it("leaves dollar amounts alone while streaming", () => {
|
||||
const html = toStreamingMarkdownHtml("prices are $$50 and");
|
||||
|
||||
expect(html).toBe("<p>prices are $$50 and</p>\n");
|
||||
});
|
||||
|
||||
it("reuses the rendered stable prefix while only the streaming tail changes", () => {
|
||||
@@ -1037,35 +1017,58 @@ describe("toStreamingMarkdownHtml", () => {
|
||||
const second = toStreamingMarkdownHtml("## Streaming cache sentinel\n\nsecond **tail");
|
||||
|
||||
expect(first).toContain("<h2>Streaming cache sentinel</h2>");
|
||||
expect(first).toContain("<p>first <strong>tail</strong></p>");
|
||||
expect(second).toContain("<h2>Streaming cache sentinel</h2>");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
expect(second).toContain("<p>second <strong>tail</strong></p>");
|
||||
// Stable prefix parses once (second call hits the cache); each tail parses fresh.
|
||||
const stableParses = renderSpy.mock.calls.filter(
|
||||
([input]) => input === "## Streaming cache sentinel",
|
||||
);
|
||||
expect(stableParses).toHaveLength(1);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
renderSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not parse an open code fence while streaming", () => {
|
||||
it("streams an open code fence as a live-highlighted code block", () => {
|
||||
const html = toStreamingMarkdownHtml("Intro\n\n```ts\nconst x = 1 < 2");
|
||||
const fragment = htmlFragment(html);
|
||||
|
||||
expect(html).toBe(
|
||||
'<p>Intro</p>\n<div class="markdown-plain-text-fallback">```ts\nconst x = 1 < 2</div>',
|
||||
);
|
||||
expect(fragment.querySelector("p")?.textContent).toBe("Intro");
|
||||
expect(fragment.querySelector("code.language-ts")?.textContent).toContain("const x = 1 < 2");
|
||||
expect(html).not.toContain("markdown-plain-text-fallback");
|
||||
});
|
||||
|
||||
it("keeps an open list code fence streaming through blank lines", () => {
|
||||
it("streams an open list code fence through blank lines", () => {
|
||||
const html = toStreamingMarkdownHtml("- ```ts\n const x = 1;\n\n const y = 2;");
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("li code");
|
||||
|
||||
expect(html).toBe(
|
||||
'<div class="markdown-plain-text-fallback">- ```ts\n const x = 1;\n\n const y = 2;</div>',
|
||||
);
|
||||
expect(code?.textContent).toContain("const x = 1;");
|
||||
expect(code?.textContent).toContain("const y = 2;");
|
||||
expect(html).not.toContain("markdown-plain-text-fallback");
|
||||
});
|
||||
|
||||
it("keeps an open blockquote code fence streaming through blank lines", () => {
|
||||
const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;");
|
||||
it("keeps completed tilde-fence code out of the remend tail", () => {
|
||||
// remend only understands ``` fences; a closed ~~~ block must land in the
|
||||
// stable prefix so its raw markers are never "completed" as inline markdown.
|
||||
const html = toStreamingMarkdownHtml('~~~ts\nconst s = "**open";\n~~~\ncontinuing **bold');
|
||||
const fragment = htmlFragment(html);
|
||||
|
||||
expect(html).toBe(
|
||||
'<div class="markdown-plain-text-fallback">> ```ts\n> const x = 1;\n>\n> const y = 2;</div>',
|
||||
);
|
||||
expect(fragment.querySelector("code")?.textContent).toContain('const s = "**open";');
|
||||
expect(fragment.querySelector("code strong")).toBeNull();
|
||||
expect(fragment.querySelector("p strong")?.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("streams an open blockquote code fence through blank lines", () => {
|
||||
const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;");
|
||||
const fragment = htmlFragment(html);
|
||||
const code = fragment.querySelector("blockquote code");
|
||||
|
||||
expect(code?.textContent).toContain("const x = 1;");
|
||||
expect(code?.textContent).toContain("const y = 2;");
|
||||
expect(html).not.toContain("markdown-plain-text-fallback");
|
||||
});
|
||||
|
||||
it("renders a completed code fence once the closing fence arrives", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import xml from "highlight.js/lib/languages/xml";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import markdownItTaskLists from "markdown-it-task-lists";
|
||||
import remend, { type RemendOptions } from "remend";
|
||||
import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js";
|
||||
import { routeIdFromPath } from "../app-route-paths.ts";
|
||||
import { resolveControlUiBasePath } from "../app/browser.ts";
|
||||
@@ -689,16 +690,6 @@ function normalizeMarkdownLineBreaks(value: string): string {
|
||||
return value.replace(/\r\n?|[\u2028\u2029]/g, "\n");
|
||||
}
|
||||
|
||||
function normalizeMarkdownInput(markdownLocal: string): string {
|
||||
const input = normalizeMarkdownLineBreaks(
|
||||
stripUnsupportedCitationControlMarkers(markdownLocal),
|
||||
).trim();
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
return formatTruncatedMarkdownInput(input);
|
||||
}
|
||||
|
||||
function formatTruncatedMarkdownInput(input: string): string {
|
||||
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
|
||||
return appendMarkdownTruncationNotice(truncated);
|
||||
@@ -776,7 +767,14 @@ function isFenceClose(line: string, fence: { marker: "`" | "~"; length: number }
|
||||
return trimmed.slice(match[0].length).trim() === "";
|
||||
}
|
||||
|
||||
function findStableStreamingMarkdownBoundary(markdownLocal: string): number {
|
||||
type StreamingMarkdownSplit = {
|
||||
/** Offset just past the last blank line outside a code fence; the prefix is block-stable. */
|
||||
boundary: number;
|
||||
/** True when the text after the boundary contains a code fence that has not closed yet. */
|
||||
tailHasOpenFence: boolean;
|
||||
};
|
||||
|
||||
function splitStableStreamingMarkdown(markdownLocal: string): StreamingMarkdownSplit {
|
||||
let boundary = 0;
|
||||
let index = 0;
|
||||
let openFence: { marker: "`" | "~"; length: number } | null = null;
|
||||
@@ -808,7 +806,7 @@ function findStableStreamingMarkdownBoundary(markdownLocal: string): number {
|
||||
index = lineEnd;
|
||||
}
|
||||
|
||||
return boundary;
|
||||
return { boundary, tailHasOpenFence: openFence !== null };
|
||||
}
|
||||
|
||||
for (const [language, definition, aliases] of [
|
||||
@@ -1380,6 +1378,36 @@ md.renderer.rules.code_block = (tokens, idx, _options, env) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 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(
|
||||
renderCodeBlock(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), sanitizeOptions);
|
||||
}
|
||||
let rendered: string;
|
||||
try {
|
||||
rendered = md.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 = `<pre class="code-block">${escapeHtml(input)}</pre>`;
|
||||
}
|
||||
return DOMPurify.sanitize(rendered, sanitizeOptions);
|
||||
}
|
||||
|
||||
export function toSanitizedMarkdownHtml(
|
||||
markdownLocal: string,
|
||||
options: MarkdownRenderOptions = {},
|
||||
@@ -1392,48 +1420,17 @@ export function toSanitizedMarkdownHtml(
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
installHooks();
|
||||
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
|
||||
const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS;
|
||||
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${renderOptions.fileLinks}\0${renderInput}`;
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
if (cacheable) {
|
||||
const cached = getCachedMarkdown(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
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(appendMarkdownTruncationNotice(truncated));
|
||||
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
let rendered: string;
|
||||
try {
|
||||
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(appendMarkdownTruncationNotice(truncated));
|
||||
rendered = `<pre class="code-block">${escaped}</pre>`;
|
||||
}
|
||||
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
|
||||
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
|
||||
const sanitized = renderSanitizedMarkdown(renderInput, renderOptions);
|
||||
if (cacheable) {
|
||||
setCachedMarkdown(cacheKey, sanitized);
|
||||
}
|
||||
return sanitized;
|
||||
@@ -1443,33 +1440,33 @@ function toEscapedPlainTextHtml(value: string): string {
|
||||
return `<div class="markdown-plain-text-fallback">${escapeHtml(normalizeMarkdownLineBreaks(value))}</div>`;
|
||||
}
|
||||
|
||||
export function toStreamingPlainTextHtml(markdownLocal: string): string {
|
||||
const input = normalizeMarkdownInput(markdownLocal);
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
return toEscapedPlainTextHtml(input);
|
||||
// Streaming-tail repair config: math is not rendered by this pipeline, so
|
||||
// completing `$$` would inject visible characters into ordinary prose.
|
||||
const streamingRemendOptions = { katex: false, linkMode: "text-only" } satisfies RemendOptions;
|
||||
|
||||
// Renders the in-flight block live. remend closes/strips unterminated inline
|
||||
// constructs (`**bold`, half links, …) so partially streamed markup styles
|
||||
// immediately instead of flashing raw markers. Inside an open code fence the
|
||||
// tail is code, not prose: skip remend (it only understands top-level ```
|
||||
// fences) and let markdown-it auto-close the fence at end of input (CommonMark
|
||||
// allows unterminated fences), so code streams with live highlighting.
|
||||
// Invariant: the tail never contains a *closed* fence — the split boundary
|
||||
// advances past every fence close — so remend (which cannot see ~~~ fences)
|
||||
// never runs across completed fenced code.
|
||||
function toStreamingTailHtml(tail: string, renderOptions: MarkdownRenderEnv): string {
|
||||
return renderSanitizedMarkdown(remend(tail, streamingRemendOptions), renderOptions);
|
||||
}
|
||||
|
||||
export function toStreamingMarkdownHtml(
|
||||
markdownLocal: string,
|
||||
options: MarkdownRenderOptions = {},
|
||||
): string {
|
||||
const renderOptions = normalizeMarkdownRenderOptions(options);
|
||||
const rawInput = normalizeMarkdownLineBreaks(
|
||||
stripUnsupportedCitationControlMarkers(markdownLocal),
|
||||
);
|
||||
if (isMarkdownBlockArtText(rawInput)) {
|
||||
const truncated = truncateText(rawInput, MARKDOWN_CHAR_LIMIT);
|
||||
installHooks();
|
||||
return DOMPurify.sanitize(
|
||||
renderCodeBlock(
|
||||
appendMarkdownTruncationNotice(truncated),
|
||||
"",
|
||||
normalizeMarkdownRenderOptions(options),
|
||||
{ blockArt: true },
|
||||
),
|
||||
sanitizeOptions,
|
||||
);
|
||||
return renderSanitizedMarkdown(rawInput, renderOptions);
|
||||
}
|
||||
|
||||
const trimmedInput = rawInput.trim();
|
||||
@@ -1478,16 +1475,15 @@ export function toStreamingMarkdownHtml(
|
||||
}
|
||||
const input = formatTruncatedMarkdownInput(trimmedInput);
|
||||
|
||||
const boundary = findStableStreamingMarkdownBoundary(input);
|
||||
if (boundary <= 0) {
|
||||
return toEscapedPlainTextHtml(input);
|
||||
}
|
||||
|
||||
const { boundary, tailHasOpenFence } = splitStableStreamingMarkdown(input);
|
||||
const stableMarkdown = input.slice(0, boundary);
|
||||
const streamingTail = input.slice(boundary);
|
||||
const stableHtml = toSanitizedMarkdownHtml(stableMarkdown, options);
|
||||
const stableHtml = boundary > 0 ? toSanitizedMarkdownHtml(stableMarkdown, options) : "";
|
||||
if (!streamingTail.trim()) {
|
||||
return stableHtml;
|
||||
}
|
||||
return `${stableHtml}${toEscapedPlainTextHtml(streamingTail)}`;
|
||||
const tailHtml = tailHasOpenFence
|
||||
? renderSanitizedMarkdown(streamingTail, renderOptions)
|
||||
: toStreamingTailHtml(streamingTail, renderOptions);
|
||||
return `${stableHtml}${tailHtml}`;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,6 @@ const markdownRenderMock = vi.hoisted(() =>
|
||||
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => value,
|
||||
),
|
||||
);
|
||||
const streamingTextRenderMock = vi.hoisted(() =>
|
||||
vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),
|
||||
);
|
||||
const streamingMarkdownRenderMock = vi.hoisted(() =>
|
||||
vi.fn(
|
||||
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) =>
|
||||
@@ -43,7 +40,6 @@ vi.mock("../../../components/markdown.ts", async (importOriginal) => {
|
||||
...actual,
|
||||
toSanitizedMarkdownHtml: markdownRenderMock,
|
||||
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
|
||||
toStreamingPlainTextHtml: streamingTextRenderMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1005,7 +1001,6 @@ describe("grouped chat rendering", () => {
|
||||
const container = document.createElement("div");
|
||||
markdownRenderMock.mockClear();
|
||||
streamingMarkdownRenderMock.mockClear();
|
||||
streamingTextRenderMock.mockClear();
|
||||
|
||||
render(
|
||||
renderStreamGroup([
|
||||
@@ -1021,7 +1016,6 @@ describe("grouped chat rendering", () => {
|
||||
);
|
||||
|
||||
expect(markdownRenderMock).not.toHaveBeenCalled();
|
||||
expect(streamingTextRenderMock).not.toHaveBeenCalled();
|
||||
expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", {
|
||||
codeBlockChrome: "copy",
|
||||
fileLinks: true,
|
||||
|
||||
Reference in New Issue
Block a user