Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions projects/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:",
Expand Down
16 changes: 15 additions & 1 deletion projects/app/src/components/Markdown/index.module.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}

Expand Down
105 changes: 72 additions & 33 deletions projects/app/src/components/Markdown/index.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 });
Expand All @@ -26,6 +27,11 @@ 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 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 });
Expand Down Expand Up @@ -68,7 +74,7 @@ function MarkdownLinkRenderer(props: any) {
);
}

/** 仅让最新流式文本批次执行淡入;不支持 Web Animations API 时直接展示内容。 */
/** 仅让最新流式文本批次执行淡入;CSS 会为不支持 Web Animations API 的浏览器兜底。 */
function MarkdownStreamTailRenderer({ children }: any) {
const ref = useRef<HTMLSpanElement>(null);

Expand All @@ -78,12 +84,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'
}
);
Expand All @@ -107,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 (
<ReactMarkdown
remarkPlugins={markdownRemarkPlugins as any}
rehypePlugins={rehypePlugins as any}
components={markdownComponents as any}
urlTransform={markdownUrlTransform}
>
{source}
</ReactMarkdown>
);
});
MarkdownStreamBlock.displayName = 'MarkdownStreamBlock';

type Props = {
source?: string;
showAnimation?: boolean;
Expand Down Expand Up @@ -184,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 (
<MarkdownRendererRuntimeContext.Provider value={renderContextValue}>
<Box position={'relative'}>
<ReactMarkdown
className={`markdown ${styles.markdown}
${className || ''}
${showAnimation ? `${formatSource ? styles.waitingAnimation : styles.animation}` : ''}
`}
remarkPlugins={[RemarkMath, [RemarkGfm, { singleTilde: false }], RemarkBreaks]}
rehypePlugins={rehypePlugins as any}
components={markdownComponents as any}
urlTransform={urlTransform}
>
{formatSource}
</ReactMarkdown>
<Box position={'relative'} className={showAnimation ? markdownClassName : undefined}>
{showAnimation ? (
markdownBlocks.map((block, index) => (
<MarkdownStreamBlock
key={block.startOffset}
source={block.source}
tailLength={index === markdownBlocks.length - 1 ? streamingTailLength : 0}
/>
))
) : (
<ReactMarkdown
className={markdownClassName}
remarkPlugins={markdownRemarkPlugins as any}
rehypePlugins={markdownBaseRehypePlugins as any}
components={markdownComponents as any}
urlTransform={markdownUrlTransform}
>
{formatSource}
</ReactMarkdown>
)}
{isDisabled && (
<Box position={'absolute'} top={0} right={0} left={0} bottom={0} zIndex={1} />
)}
Expand Down
75 changes: 75 additions & 0 deletions projects/app/src/components/Markdown/streamMarkdownBlocks.ts
Original file line number Diff line number Diff line change
@@ -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;
};
91 changes: 91 additions & 0 deletions projects/app/test/components/Markdown/streamMarkdownBlocks.test.ts
Original file line number Diff line number Diff line change
@@ -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+</g, '><');

expect(normalizeRootWhitespace(renderedByBlocks)).toBe(normalizeRootWhitespace(render(source)));
});
});
Loading