From 6b88f8149f41a8a09daf3ed1b118b1f1b2326f21 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 14 Jul 2026 17:47:36 +0800 Subject: [PATCH 1/2] fix(markdown): strengthen streaming tail fade --- .../src/components/Markdown/index.module.scss | 16 +++++++++++++++- projects/app/src/components/Markdown/index.tsx | 12 +++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/projects/app/src/components/Markdown/index.module.scss b/projects/app/src/components/Markdown/index.module.scss index c0b352a094b0..477918906349 100644 --- a/projects/app/src/components/Markdown/index.module.scss +++ b/projects/app/src/components/Markdown/index.module.scss @@ -1,7 +1,21 @@ .waitingAnimation { :global(.stream-tail) { display: inline; - will-change: opacity, filter, transform; + opacity: 0; + will-change: opacity, transform; + animation: streamTailFadeIn 280ms cubic-bezier(0.33, 0, 0.67, 1) both; + } +} + +@keyframes streamTailFadeIn { + from { + opacity: 0; + transform: translateY(1px); + } + + to { + opacity: 1; + transform: translateY(0); } } diff --git a/projects/app/src/components/Markdown/index.tsx b/projects/app/src/components/Markdown/index.tsx index 55b3d47663e7..d123a5b91223 100644 --- a/projects/app/src/components/Markdown/index.tsx +++ b/projects/app/src/components/Markdown/index.tsx @@ -26,6 +26,8 @@ const IframeHtmlCodeBlock = dynamic(() => import('./codeBlock/iframe-html'), { s const VideoBlock = dynamic(() => import('./codeBlock/Video'), { ssr: false }); 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 ChatGuide = dynamic(() => import('./chat/Guide'), { ssr: false }); const QuestionGuide = dynamic(() => import('./chat/QuestionGuide'), { ssr: false }); @@ -68,7 +70,7 @@ function MarkdownLinkRenderer(props: any) { ); } -/** 仅让最新流式文本批次执行淡入;不支持 Web Animations API 时直接展示内容。 */ +/** 仅让最新流式文本批次执行淡入;CSS 会为不支持 Web Animations API 的浏览器兜底。 */ function MarkdownStreamTailRenderer({ children }: any) { const ref = useRef(null); @@ -78,12 +80,12 @@ function MarkdownStreamTailRenderer({ children }: any) { const animation = element.animate( [ - { opacity: 0.25, filter: 'blur(1px)', transform: 'translateY(1px)' }, - { opacity: 1, filter: 'blur(0)', transform: 'translateY(0)' } + { opacity: 0, transform: 'translateY(1px)' }, + { opacity: 1, transform: 'translateY(0)' } ], { - duration: 120, - easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + duration: STREAM_TAIL_FADE_DURATION_MS, + easing: STREAM_TAIL_FADE_EASING, fill: 'both' } ); From 106bb4d9bb0773e3f271b275bbd04772c85fa2e8 Mon Sep 17 00:00:00 2001 From: Archer <545436317@qq.com> Date: Tue, 14 Jul 2026 20:59:10 +0800 Subject: [PATCH 2/2] 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))); + }); +});