From a73ad50a300dd77553a43c5b3ee2289b23113989 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Wed, 19 Aug 2026 14:33:08 +0800 Subject: [PATCH] =?UTF-8?q?perf(web):=20=E5=90=88=E5=B9=B6=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=20Markdown=20=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将高频 token 更新限制为每 50 毫秒最多触发一次 Markdown 解析。 - 流式结束时同步刷新完整文本,保留链接、任务列表与代码块行为。 - 最新主线上的 65 个定向测试通过,补丁经 range-diff 确认未漂移。 - Web 类型检查仅剩主线 electronPasskeys 测试中的两条既有错误。 --- .../src/components/ChatMarkdown.logic.test.ts | 28 ++++++++ apps/web/src/components/ChatMarkdown.logic.ts | 9 +++ apps/web/src/components/ChatMarkdown.tsx | 68 +++++++++++++++---- 3 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/ChatMarkdown.logic.test.ts create mode 100644 apps/web/src/components/ChatMarkdown.logic.ts diff --git a/apps/web/src/components/ChatMarkdown.logic.test.ts b/apps/web/src/components/ChatMarkdown.logic.test.ts new file mode 100644 index 000000000000..d4da8e8a44b7 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.logic.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + STREAMING_MARKDOWN_RENDER_INTERVAL_MS, + streamingMarkdownRenderDelay, +} from "./ChatMarkdown.logic"; + +describe("streamingMarkdownRenderDelay", () => { + it("coalesces updates inside the render interval", () => { + expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 1_020 })).toBe(30); + }); + + it("allows an immediate refresh once the interval elapsed", () => { + expect( + streamingMarkdownRenderDelay({ + lastRenderedAt: 1_000, + now: 1_000 + STREAMING_MARKDOWN_RENDER_INTERVAL_MS, + }), + ).toBe(0); + expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 2_000 })).toBe(0); + }); + + it("handles a clock moving backwards without exceeding the interval", () => { + expect(streamingMarkdownRenderDelay({ lastRenderedAt: 1_000, now: 900 })).toBe( + STREAMING_MARKDOWN_RENDER_INTERVAL_MS, + ); + }); +}); diff --git a/apps/web/src/components/ChatMarkdown.logic.ts b/apps/web/src/components/ChatMarkdown.logic.ts new file mode 100644 index 000000000000..a49195e32e26 --- /dev/null +++ b/apps/web/src/components/ChatMarkdown.logic.ts @@ -0,0 +1,9 @@ +export const STREAMING_MARKDOWN_RENDER_INTERVAL_MS = 50; + +export function streamingMarkdownRenderDelay(input: { + lastRenderedAt: number; + now: number; +}): number { + const elapsed = Math.max(0, input.now - input.lastRenderedAt); + return Math.max(0, STREAMING_MARKDOWN_RENDER_INTERVAL_MS - elapsed); +} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 75127ea124e8..0e9e4355b79b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -174,6 +174,7 @@ import { BrowserPreviewUnavailableError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; +import { streamingMarkdownRenderDelay } from "./ChatMarkdown.logic"; interface ChatMarkdownProps { text: string; @@ -317,6 +318,33 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); +function useStreamingMarkdownText(text: string, isStreaming: boolean): string { + const [renderedText, setRenderedText] = useState(text); + const lastRenderedAtRef = useRef(Date.now()); + + useEffect(() => { + if (!isStreaming) { + lastRenderedAtRef.current = Date.now(); + setRenderedText((current) => (current === text ? current : text)); + return; + } + + const delay = streamingMarkdownRenderDelay({ + lastRenderedAt: lastRenderedAtRef.current, + now: Date.now(), + }); + const timeout = setTimeout(() => { + lastRenderedAtRef.current = Date.now(); + setRenderedText(text); + }, delay); + return () => { + clearTimeout(timeout); + }; + }, [isStreaming, text]); + + return isStreaming ? renderedText : text; +} + function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); const firstLine = markdown.slice( @@ -1968,6 +1996,7 @@ function ChatMarkdown({ onImageExpand, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, }: ChatMarkdownProps) { + const renderedText = useStreamingMarkdownText(text, isStreaming); const { resolvedTheme } = useTheme(); const [localMediaPreview, setLocalMediaPreview] = useState(null); const expandMedia = onImageExpand ?? setLocalMediaPreview; @@ -2076,7 +2105,7 @@ function ChatMarkdown({ string, NonNullable> >(); - for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(text))) { + for (const href of extractMarkdownLinkHrefs(renderCodexFileCitationsAsMarkdown(renderedText))) { const normalizedHref = normalizeMarkdownLinkHrefKey(href); if (metaByHref.has(normalizedHref)) continue; const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd, imageBaseDir ?? cwd); @@ -2085,10 +2114,10 @@ function ChatMarkdown({ } } return metaByHref; - }, [cwd, imageBaseDir, text]); + }, [cwd, imageBaseDir, renderedText]); const inlineCodeFileLinkMetaByText = useMemo(() => { const metaByText = new Map(); - for (const span of extractInlineCodeSpans(text)) { + for (const span of extractInlineCodeSpans(renderedText)) { if (metaByText.has(span)) continue; const meta = resolveInlineCodeFileLinkMeta(span, cwd, imageBaseDir ?? cwd); if (meta) { @@ -2096,7 +2125,7 @@ function ChatMarkdown({ } } return metaByText; - }, [cwd, imageBaseDir, text]); + }, [cwd, imageBaseDir, renderedText]); const fileLinkParentSuffixByPath = useMemo(() => { const filePaths = [ ...[...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath), @@ -2386,7 +2415,9 @@ function ChatMarkdown({ li({ node, children, ...props }) { const listItemStart = node?.position?.start.offset; const markerOffset = - typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + typeof listItemStart === "number" + ? findTaskListMarkerOffset(renderedText, listItemStart) + : null; return (
  • {renderSkillInlineMarkdownChildren(children, skills)} @@ -2756,11 +2787,11 @@ function ChatMarkdown({ openMarkdownFileInPreview, preferredEditorMenuLabel, resolveThreadPullRequest, + renderedText, resolvedTheme, revealMarkdownFileInFileManager, revealInFileManagerLabel, skills, - text, threadRef, updateThreadPullRequestLink, ]); @@ -2774,6 +2805,21 @@ function ChatMarkdown({ [extraRemarkPlugins, lineBreaks], ); + const renderedMarkdown = useMemo( + () => ( + + {renderedText} + + ), + [markdownComponents, markdownUrlTransform, parseRawHtml, remarkPlugins, renderedText], + ); + // react-markdown converts unparsed HTML nodes to text when skipHtml is false. // Keep that behavior explicit because literal mode depends on escaping the // complete source token instead of dropping it from the rendered message. @@ -2785,15 +2831,7 @@ function ChatMarkdown({ )} onCopy={handleCopy} > - - {text} - + {renderedMarkdown} {localMediaPreview ? (