Commit f60c4591 by Archer Committed by GitHub

perf(markdown): cache completed streaming blocks (#7306)

parent df6bc232
...@@ -1441,6 +1441,9 @@ importers: ...@@ -1441,6 +1441,9 @@ importers:
react-markdown: react-markdown:
specifier: 'catalog:' specifier: 'catalog:'
version: 9.1.0(@types/react@18.3.1)(react@18.3.1) 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: react-syntax-highlighter:
specifier: ^15.5.0 specifier: ^15.5.0
version: 15.6.1(react@18.3.1) version: 15.6.1(react@18.3.1)
...@@ -1474,6 +1477,9 @@ importers: ...@@ -1474,6 +1477,9 @@ importers:
undici: undici:
specifier: 'catalog:' specifier: 'catalog:'
version: 7.28.0 version: 7.28.0
unified:
specifier: ^11.0.5
version: 11.0.5
use-context-selector: use-context-selector:
specifier: ^1.4.4 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) version: 1.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(scheduler@0.26.0)
...@@ -87,6 +87,7 @@ ...@@ -87,6 +87,7 @@
"react-hook-form": "catalog:", "react-hook-form": "catalog:",
"react-i18next": "catalog:", "react-i18next": "catalog:",
"react-markdown": "catalog:", "react-markdown": "catalog:",
"remark-parse": "^11.0.0",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"react-textarea-autosize": "^8.5.4", "react-textarea-autosize": "^8.5.4",
"reactflow": "^11.7.4", "reactflow": "^11.7.4",
...@@ -98,6 +99,7 @@ ...@@ -98,6 +99,7 @@
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"sass": "^1.58.3", "sass": "^1.58.3",
"undici": "catalog:", "undici": "catalog:",
"unified": "^11.0.5",
"use-context-selector": "^1.4.4", "use-context-selector": "^1.4.4",
"vaul": "catalog:", "vaul": "catalog:",
"zod": "catalog:", "zod": "catalog:",
......
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 ReactMarkdown from 'react-markdown';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import RemarkMath from 'remark-math'; // Math syntax import RemarkMath from 'remark-math'; // Math syntax
...@@ -16,6 +16,7 @@ import type { AProps } from './A'; ...@@ -16,6 +16,7 @@ import type { AProps } from './A';
import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable'; import MarkdownTable from '@fastgpt/web/components/common/Markdown/MarkdownTable';
import { MarkdownRendererRuntimeContext } from './runtimeContext'; import { MarkdownRendererRuntimeContext } from './runtimeContext';
import { getStreamingAppendLength, rehypeStreamAnimated } from './rehypeStreamAnimated'; import { getStreamingAppendLength, rehypeStreamAnimated } from './rehypeStreamAnimated';
import { splitMarkdownBlocks } from './streamMarkdownBlocks';
const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false }); const CodeLight = dynamic(() => import('./codeBlock/CodeLight'), { ssr: false });
const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false }); const MermaidCodeBlock = dynamic(() => import('./img/MermaidCodeBlock'), { ssr: false });
...@@ -28,6 +29,9 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false }); ...@@ -28,6 +29,9 @@ const AudioBlock = dynamic(() => import('./codeBlock/Audio'), { ssr: false });
const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const STREAM_TAIL_FADE_DURATION_MS = 280; const STREAM_TAIL_FADE_DURATION_MS = 280;
const STREAM_TAIL_FADE_EASING = 'cubic-bezier(0.33, 0, 0.67, 1)'; 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 ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false });
const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false }); const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false });
...@@ -109,6 +113,39 @@ const markdownComponents = { ...@@ -109,6 +113,39 @@ const markdownComponents = {
'stream-tail': MarkdownStreamTailRenderer '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 (
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins as any}
rehypePlugins={rehypePlugins as any}
components={markdownComponents as any}
urlTransform={markdownUrlTransform}
>
{source}
</ReactMarkdown>
);
});
MarkdownStreamBlock.displayName = 'MarkdownStreamBlock';
type Props = { type Props = {
source?: string; source?: string;
showAnimation?: boolean; showAnimation?: boolean;
...@@ -186,37 +223,37 @@ const MarkdownRender = ({ ...@@ -186,37 +223,37 @@ const MarkdownRender = ({
previousFormatSourceRef.current = formatSource; previousFormatSourceRef.current = formatSource;
}, [formatSource, source]); }, [formatSource, source]);
const streamAnimatedRehypePlugin = useMemo( const markdownBlocks = useMemo(
() => [rehypeStreamAnimated, { tailLength: streamingTailLength }], () => (showAnimation ? splitMarkdownBlocks(formatSource) : []),
[streamingTailLength] [formatSource, showAnimation]
); );
const rehypePlugins = useMemo( const markdownClassName = `markdown ${styles.markdown}
() => ${className || ''}
showAnimation ${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
? [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }], streamAnimatedRehypePlugin] `;
: [RehypeKatex, [RehypeExternalLinks, { target: '_blank' }]],
[showAnimation, streamAnimatedRehypePlugin]
);
const urlTransform = useCallback((val: string) => {
return val;
}, []);
return ( return (
<MarkdownRendererRuntimeContext.Provider value={renderContextValue}> <MarkdownRendererRuntimeContext.Provider value={renderContextValue}>
<Box position={'relative'}> <Box position={'relative'} className={showAnimation ? markdownClassName : undefined}>
<ReactMarkdown {showAnimation ? (
className={`markdown ${styles.markdown} markdownBlocks.map((block, index) => (
${className || ''} <MarkdownStreamBlock
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''} key={block.startOffset}
`} source={block.source}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]} tailLength={index === markdownBlocks.length - 1 ? streamingTailLength : 0}
rehypePlugins={rehypePlugins as any} />
components={markdownComponents as any} ))
urlTransform={urlTransform} ) : (
> <ReactMarkdown
{formatSource} className={markdownClassName}
</ReactMarkdown> remarkPlugins={markdownRemarkPlugins as any}
rehypePlugins={markdownBaseRehypePlugins as any}
components={markdownComponents as any}
urlTransform={markdownUrlTransform}
>
{formatSource}
</ReactMarkdown>
)}
{isDisabled && ( {isDisabled && (
<Box position={'absolute'} top={0} right={0} left={0} bottom={0} zIndex={1} /> <Box position={'absolute'} top={0} right={0} left={0} bottom={0} zIndex={1} />
)} )}
......
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;
};
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+</g, '><');
expect(normalizeRootWhitespace(renderedByBlocks)).toBe(normalizeRootWhitespace(render(source)));
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment