From a584507d9b4f14b76b2ac61385cb236253723de8 Mon Sep 17 00:00:00 2001
From: Archer <545436317@qq.com>
Date: Tue, 14 Jul 2026 20:59:10 +0800
Subject: [PATCH] perf(markdown): cache completed streaming blocks
---
pnpm-lock.yaml | 6 ++
projects/app/package.json | 2 +
.../app/src/components/Markdown/index.tsx | 93 +++++++++++++------
.../Markdown/streamMarkdownBlocks.ts | 75 +++++++++++++++
.../Markdown/streamMarkdownBlocks.test.ts | 91 ++++++++++++++++++
5 files changed, 239 insertions(+), 28 deletions(-)
create mode 100644 projects/app/src/components/Markdown/streamMarkdownBlocks.ts
create mode 100644 projects/app/test/components/Markdown/streamMarkdownBlocks.test.ts
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 82d2c04e653b..66d394512f84 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1441,6 +1441,9 @@ importers:
react-markdown:
specifier: 'catalog:'
version: 9.1.0(@types/react@18.3.1)(react@18.3.1)
+ remark-parse:
+ specifier: ^11.0.0
+ version: 11.0.0
react-syntax-highlighter:
specifier: ^15.5.0
version: 15.6.1(react@18.3.1)
@@ -1474,6 +1477,9 @@ importers:
undici:
specifier: 'catalog:'
version: 7.28.0
+ unified:
+ specifier: ^11.0.5
+ version: 11.0.5
use-context-selector:
specifier: ^1.4.4
version: 1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.26.0)
diff --git a/projects/app/package.json b/projects/app/package.json
index 28b32602d0a8..e80a9d68f6de 100644
--- a/projects/app/package.json
+++ b/projects/app/package.json
@@ -87,6 +87,7 @@
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"react-markdown": "catalog:",
+ "remark-parse": "^11.0.0",
"react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.4",
"reactflow": "^11.7.4",
@@ -98,6 +99,7 @@
"remark-math": "^6.0.0",
"sass": "^1.58.3",
"undici": "catalog:",
+ "unified": "^11.0.5",
"use-context-selector": "^1.4.4",
"vaul": "catalog:",
"zod": "catalog:",
diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx
index d123a5b91223..de4831d0eb1f 100644
--- a/projects/app/src/components/Markdown/index.tsx
+++ b/projects/app/src/components/Markdown/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
+import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css';
import RemarkMath from 'remark-math'; // Math syntax
@@ -16,6 +16,7 @@ import type { AProps } from './A';
import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable';
import { MarkdownRendererRuntimeContext } from './runtimeContext';
import { getStreamingAppendLength, rehypeStreamAnimated } from './rehypeStreamAnimated';
+import { splitMarkdownBlocks } from './streamMarkdownBlocks';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
@@ -28,6 +29,9 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const STREAM_TAIL_FADE_DURATION_MS = 280;
const STREAM_TAIL_FADE_EASING = 'cubic-bezier(0.33, 0, 0.67, 1)';
+const markdownRemarkPlugins = [RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks];
+const markdownBaseRehypePlugins = [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]];
+const markdownUrlTransform = (val: string) => val;
const ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
@@ -109,6 +113,39 @@ const markdownComponents = {
'stream-tail': MarkdownStreamTailRenderer
};
+type MarkdownStreamBlockProps = {
+ source: string;
+ tailLength: number;
+};
+
+/**
+ * 缓存已完成 Markdown block 的 React 子树。
+ *
+ * source 和 tailLength 都保持不变时,父级流式内容更新不会重新进入 react-markdown。
+ * 只有最后一个正在增长的 block 会重新解析,并继续使用现有的尾部淡入插件。
+ */
+const MarkdownStreamBlock = React.memo(({ source, tailLength }: MarkdownStreamBlockProps) => {
+ const rehypePlugins = useMemo(
+ () =>
+ tailLength > 0
+ ? [...markdownBaseRehypePlugins, [rehypeStreamAnimated, { tailLength }]]
+ : markdownBaseRehypePlugins,
+ [tailLength]
+ );
+
+ return (
+
+ {source}
+
+ );
+});
+MarkdownStreamBlock.displayName = 'MarkdownStreamBlock';
+
type Props = {
source?: string;
showAnimation?: boolean;
@@ -186,37 +223,37 @@ const MarkdownRender = ({
previousFormatSourceRef.current = formatSource;
}, [formatSource, source]);
- const streamAnimatedRehypePlugin = useMemo(
- () => [rehypeStreamAnimated, { tailLength: streamingTailLength }],
- [streamingTailLength]
+ const markdownBlocks = useMemo(
+ () => (showAnimation ? splitMarkdownBlocks(formatSource) : []),
+ [formatSource, showAnimation]
);
- const rehypePlugins = useMemo(
- () =>
- showAnimation
- ? [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }], streamAnimatedRehypePlugin]
- : [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]],
- [showAnimation, streamAnimatedRehypePlugin]
- );
-
- const urlTransform = useCallback((val: string) => {
- return val;
- }, []);
+ const markdownClassName = `markdown ${styles.markdown}
+ ${className || ''}
+ ${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
+ `;
return (
-
-
- {formatSource}
-
+
+ {showAnimation ? (
+ markdownBlocks.map((block, index) => (
+
+ ))
+ ) : (
+
+ {formatSource}
+
+ )}
{isDisabled && (
)}
diff --git a/projects/app/src/components/Markdown/streamMarkdownBlocks.ts b/projects/app/src/components/Markdown/streamMarkdownBlocks.ts
new file mode 100644
index 000000000000..f54965347e60
--- /dev/null
+++ b/projects/app/src/components/Markdown/streamMarkdownBlocks.ts
@@ -0,0 +1,75 @@
+import RemarkMath from 'remark-math';
+import RemarkGfm from 'remark-gfm';
+import remarkParse from 'remark-parse';
+import { unified } from 'unified';
+
+type MarkdownNodePosition = {
+ start: { offset?: number };
+ end: { offset?: number };
+};
+
+type MarkdownRoot = {
+ children: Array<{
+ type?: string;
+ position?: MarkdownNodePosition;
+ }>;
+};
+
+export type MarkdownBlock = {
+ source: string;
+ startOffset: number;
+};
+
+// Reuse the parser across renders; only the current source still needs to be parsed.
+const markdownBlockParser = unified()
+ .use(remarkParse)
+ .use(RemarkMath)
+ .use(RemarkGfm, { singleTilde: false });
+
+/**
+ * 按 Markdown 根级 block 的源码范围切分流式内容。
+ *
+ * 根级节点的 position 可以保留代码块、表格、列表和引用的完整语法,避免用空行
+ * 切分破坏 Markdown 上下文。startOffset 用作 React key;追加输出时,已经完成的
+ * block 会保持稳定,只有最后一个仍在增长的 block 需要重新渲染。
+ */
+export const splitMarkdownBlocks = (source: string): MarkdownBlock[] => {
+ const root = markdownBlockParser.parse(source) as MarkdownRoot;
+
+ // Reference links and GFM footnotes resolve against the complete document. Splitting
+ // them into independent ReactMarkdown instances would make a definition invisible to
+ // a paragraph in another block, so keep these messages on the original full-document path.
+ if (
+ root.children.some((node) => node.type === 'definition' || node.type === 'footnoteDefinition')
+ ) {
+ return source ? [{ source, startOffset: 0 }] : [];
+ }
+
+ const blocks = root.children.flatMap((node) => {
+ const startOffset = node.position?.start.offset;
+ const endOffset = node.position?.end.offset;
+
+ if (
+ typeof startOffset !== 'number' ||
+ typeof endOffset !== 'number' ||
+ endOffset <= startOffset
+ ) {
+ return [];
+ }
+
+ return [
+ {
+ source: source.slice(startOffset, endOffset),
+ startOffset
+ }
+ ];
+ });
+
+ // A non-empty source can contain only whitespace, which the parser omits.
+ // Keep one fallback block so the renderer preserves the existing empty-state behavior.
+ if (blocks.length === 0 && source) {
+ return [{ source, startOffset: 0 }];
+ }
+
+ return blocks;
+};
diff --git a/projects/app/test/components/Markdown/streamMarkdownBlocks.test.ts b/projects/app/test/components/Markdown/streamMarkdownBlocks.test.ts
new file mode 100644
index 000000000000..058783e04a53
--- /dev/null
+++ b/projects/app/test/components/Markdown/streamMarkdownBlocks.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, it } from 'vitest';
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import ReactMarkdown from 'react-markdown';
+import RemarkBreaks from 'remark-breaks';
+import RemarkMath from 'remark-math';
+import RemarkGfm from 'remark-gfm';
+import RehypeExternalLinks from 'rehype-external-links';
+import RehypeKatex from 'rehype-katex';
+
+import { splitMarkdownBlocks } from '@/components/Markdown/streamMarkdownBlocks';
+
+describe('splitMarkdownBlocks', () => {
+ it('should return no blocks for empty source', () => {
+ expect(splitMarkdownBlocks('')).toEqual([]);
+ });
+
+ it('should keep root markdown constructs as complete blocks', () => {
+ const source =
+ '# title\n\nparagraph\n\n```ts\nconst value = 1;\n```\n\n| key | value |\n| --- | --- |\n| a | b |';
+
+ const blocks = splitMarkdownBlocks(source);
+
+ expect(blocks.map((block) => block.source)).toEqual([
+ '# title',
+ 'paragraph',
+ '```ts\nconst value = 1;\n```',
+ '| key | value |\n| --- | --- |\n| a | b |'
+ ]);
+ expect(blocks.map((block) => block.startOffset)).toEqual([
+ 0,
+ source.indexOf('paragraph'),
+ source.indexOf('```ts'),
+ source.indexOf('| key')
+ ]);
+ });
+
+ it('should keep lists, quotes, and math expressions in their parent blocks', () => {
+ const source = '> quote\n> continuation\n\n- first\n- second\n\n$$\nx^2\n$$\n\ntext';
+
+ expect(splitMarkdownBlocks(source).map((block) => block.source)).toEqual([
+ '> quote\n> continuation',
+ '- first\n- second',
+ '$$\nx^2\n$$',
+ 'text'
+ ]);
+ });
+
+ it('should keep reference definitions in one document block', () => {
+ const source = '[link][reference]\n\n[reference]: https://example.com';
+
+ expect(splitMarkdownBlocks(source)).toEqual([{ source, startOffset: 0 }]);
+ });
+
+ it('should keep GFM footnote definitions in one document block', () => {
+ const source = 'text[^1]\n\n[^1]: footnote';
+
+ expect(splitMarkdownBlocks(source)).toEqual([{ source, startOffset: 0 }]);
+ });
+
+ it('should preserve a non-empty whitespace-only source as a fallback block', () => {
+ expect(splitMarkdownBlocks(' \n')).toEqual([{ source: ' \n', startOffset: 0 }]);
+ });
+
+ it('should use JavaScript source offsets without splitting surrogate pairs', () => {
+ const source = '😀 first\n\nsecond';
+ const blocks = splitMarkdownBlocks(source);
+
+ expect(blocks[0]).toEqual({ source: '😀 first', startOffset: 0 });
+ expect(blocks[1]).toEqual({ source: 'second', startOffset: source.indexOf('second') });
+ });
+
+ it('should preserve rendered HTML when stable blocks are rendered independently', () => {
+ const source =
+ '# title\n\nparagraph with [link](https://example.com)\n\n```ts\nconst value = 1;\n```\n\n| key | value |\n| --- | --- |\n| a | b |';
+ const options = {
+ remarkPlugins: [RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks],
+ rehypePlugins: [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]]
+ };
+
+ const render = (value: string) =>
+ renderToStaticMarkup(React.createElement(ReactMarkdown, options as any, value));
+ const renderedByBlocks = splitMarkdownBlocks(source)
+ .map((block) => render(block.source))
+ .join('');
+
+ const normalizeRootWhitespace = (html: string) => html.replace(/>\s+<');
+
+ expect(normalizeRootWhitespace(renderedByBlocks)).toBe(normalizeRootWhitespace(render(source)));
+ });
+});