惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

小众软件
小众软件
博客园_首页
博客园 - 聂微东
T
Tailwind CSS Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
J
Java Code Geeks
The Cloudflare Blog
aimingoo的专栏
aimingoo的专栏
Martin Fowler
Martin Fowler
D
Docker
人人都是产品经理
人人都是产品经理
WordPress大学
WordPress大学
博客园 - 三生石上(FineUI控件)
Microsoft Azure Blog
Microsoft Azure Blog
Recent Announcements
Recent Announcements
Apple Machine Learning Research
Apple Machine Learning Research
阮一峰的网络日志
阮一峰的网络日志
B
Blog RSS Feed
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Microsoft Security Blog
Microsoft Security Blog
L
LangChain Blog
Jina AI
Jina AI
博客园 - Franky
D
DataBreaches.Net

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
perf(ui): skip markdown parsing while chat streams · open...
vincentkoc · 2026-06-01 · via Recent Commits to openclaw:main
Original file line numberDiff line numberDiff line change

@@ -29,6 +29,14 @@

2929

margin: 0;

3030

}

3131
32+

.markdown-plain-text-fallback {

33+

display: block;

34+

white-space: pre-wrap;

35+

overflow-wrap: anywhere;

36+

word-break: break-word;

37+

font: inherit;

38+

}

39+
3240

.chat-text :where(table) {

3341

display: block;

3442

max-width: 100%;

Original file line numberDiff line numberDiff line change

@@ -15,6 +15,9 @@ const localStorageValues = vi.hoisted(() => new Map<string, string>());

1515

const markdownRenderMock = vi.hoisted(() =>

1616

vi.fn((value: string, _options?: { codeBlockChrome?: "copy" | "none" }) => value),

1717

);

18+

const streamingTextRenderMock = vi.hoisted(() =>

19+

vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),

20+

);

1821
1922

vi.mock("../../local-storage.ts", () => ({

2023

getSafeLocalStorage: () => ({

@@ -26,6 +29,7 @@ vi.mock("../../local-storage.ts", () => ({

2629
2730

vi.mock("../markdown.ts", () => ({

2831

toSanitizedMarkdownHtml: markdownRenderMock,

32+

toStreamingPlainTextHtml: streamingTextRenderMock,

2933

}));

3034
3135

vi.mock("../icons.ts", () => ({

@@ -887,6 +891,19 @@ describe("grouped chat rendering", () => {

887891

expect(bubble?.classList.contains("streaming")).toBe(false);

888892

});

889893
894+

it("renders streaming text without markdown sanitization", () => {

895+

const container = document.createElement("div");

896+

markdownRenderMock.mockClear();

897+

streamingTextRenderMock.mockClear();

898+
899+

render(renderStreamingGroup("**live**\nreply", 1), container);

900+
901+

expect(markdownRenderMock).not.toHaveBeenCalled();

902+

expect(streamingTextRenderMock).toHaveBeenCalledWith("**live**\nreply");

903+

const text = container.querySelector(".markdown-plain-text-fallback");

904+

expect(text?.textContent).toBe("**live**\nreply");

905+

});

906+
890907

it("renders configured local user names", () => {

891908

const renderUser = (opts: Partial<RenderMessageGroupOptions>) => {

892909

const container = document.createElement("div");

Original file line numberDiff line numberDiff line change

@@ -5,7 +5,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";

55

import type { AssistantIdentity } from "../assistant-identity.ts";

66

import type { EmbedSandboxMode } from "../embed-sandbox.ts";

77

import { icons } from "../icons.ts";

8-

import { toSanitizedMarkdownHtml } from "../markdown.ts";

8+

import { toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "../markdown.ts";

99

import { openExternalUrlSafe } from "../open-external-url.ts";

1010

import type { SidebarContent } from "../sidebar-content.ts";

1111

import { detectTextDirection } from "../text-direction.ts";

@@ -1813,11 +1813,7 @@ function renderGroupedMessage(

18131813

<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>

18141814

</details>`

18151815

: markdown

1816-

? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">

1817-

${unsafeHTML(

1818-

toSanitizedMarkdownHtml(markdown, markdownRenderOptions),

1819-

)}

1820-

</div>`

1816+

? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)

18211817

: nothing}

18221818

${hasToolCards

18231819

? singleToolCard && !markdown && !hasImages

@@ -1880,9 +1876,7 @@ function renderGroupedMessage(

18801876

<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>

18811877

</details>`

18821878

: markdown

1883-

? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">

1884-

${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}

1885-

</div>`

1879+

? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)

18861880

: nothing}

18871881

${hasToolCards

18881882

? renderInlineToolCards(toolCards, {

@@ -1910,3 +1904,22 @@ function renderGroupedMessage(

19101904

</div>

19111905

`;

19121906

}

1907+
1908+

function renderMarkdownText(

1909+

markdown: string,

1910+

isStreaming: boolean,

1911+

markdownRenderOptions?: { codeBlockChrome: "copy" | "none" },

1912+

) {

1913+

if (isStreaming) {

1914+

return html`

1915+

<div class="chat-text" dir="${detectTextDirection(markdown)}">

1916+

${unsafeHTML(toStreamingPlainTextHtml(markdown))}

1917+

</div>

1918+

`;

1919+

}

1920+

return html`

1921+

<div class="chat-text" dir="${detectTextDirection(markdown)}">

1922+

${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}

1923+

</div>

1924+

`;

1925+

}

Original file line numberDiff line numberDiff line change

@@ -1,7 +1,7 @@

11

import { render } from "lit";

22

import { describe, expect, it, vi } from "vitest";

33

import { i18n } from "../i18n/index.ts";

4-

import { md, toSanitizedMarkdownHtml } from "./markdown.ts";

4+

import { md, toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "./markdown.ts";

55

import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts";

66
77

function htmlFragment(html: string): HTMLElement {

@@ -685,6 +685,20 @@ PY

685685

});

686686

});

687687
688+

describe("toStreamingPlainTextHtml", () => {

689+

it("strips unsupported citation control markers before escaping streaming text", () => {

690+

const html = toStreamingPlainTextHtml(

691+

"v2026.5.20 release note citeturn2view0\n\nStill readable.",

692+

);

693+
694+

expect(html).toBe(

695+

'<div class="markdown-plain-text-fallback">v2026.5.20 release note\n\nStill readable.</div>',

696+

);

697+

expect(html).not.toContain("cite");

698+

expect(html).not.toContain("turn2view0");

699+

});

700+

});

701+
688702

describe("renderMarkdownSidebar", () => {

689703

it("renders sanitized markdown content", () => {

690704

const container = document.createElement("div");

Original file line numberDiff line numberDiff line change

@@ -634,7 +634,7 @@ export function toSanitizedMarkdownHtml(

634634

// Large plain-text replies should stay readable without inheriting the

635635

// capped code-block chrome, while still preserving whitespace for logs

636636

// and other structured text that commonly trips the parse guard.

637-

const html = renderEscapedPlainTextHtml(`${truncated.text}${suffix}`);

637+

const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`);

638638

const sanitized = DOMPurify.sanitize(html, sanitizeOptions);

639639

if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {

640640

setCachedMarkdown(cacheKey, sanitized);

@@ -657,6 +657,18 @@ export function toSanitizedMarkdownHtml(

657657

return sanitized;

658658

}

659659
660-

function renderEscapedPlainTextHtml(value: string): string {

660+

export function toEscapedPlainTextHtml(value: string): string {

661661

return `<div class="markdown-plain-text-fallback">${escapeHtml(value.replace(/\r\n?/g, "\n"))}</div>`;

662662

}

663+
664+

export function toStreamingPlainTextHtml(markdownLocal: string): string {

665+

const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim();

666+

if (!input) {

667+

return "";

668+

}

669+

const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);

670+

const suffix = truncated.truncated

671+

? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`

672+

: "";

673+

return toEscapedPlainTextHtml(`${truncated.text}${suffix}`);

674+

}