From bafb0078e266d5687e2d0915e96ea6c0c02c2086 Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Mon, 2 Jun 2025 19:58:10 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20feat(playground):=20chat=20strea?= =?UTF-8?q?ming=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package.json | 1 + .../common/markdown/MarkdownRenderer.js | 44 ++++++++++++------ .../components/common/markdown/markdown.css | 14 ++++++ .../components/playground/MessageContent.js | 3 ++ .../components/playground/ThinkingContent.js | 1 + web/src/index.js | 4 +- web/src/utils/rehypeSplitWordsIntoSpans.js | 46 +++++++++++++++++++ 7 files changed, 97 insertions(+), 16 deletions(-) create mode 100644 web/src/utils/rehypeSplitWordsIntoSpans.js diff --git a/web/package.json b/web/package.json index a09a58af..010dd626 100644 --- a/web/package.json +++ b/web/package.json @@ -40,6 +40,7 @@ "semantic-ui-offline": "^2.5.0", "semantic-ui-react": "^2.1.3", "sse": "https://github.com/mpetazzoni/sse.js", + "unist-util-visit": "^5.0.0", "use-debounce": "^10.0.4" }, "scripts": { diff --git a/web/src/components/common/markdown/MarkdownRenderer.js b/web/src/components/common/markdown/MarkdownRenderer.js index 5368b078..b6320d63 100644 --- a/web/src/components/common/markdown/MarkdownRenderer.js +++ b/web/src/components/common/markdown/MarkdownRenderer.js @@ -16,6 +16,7 @@ import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; import { copy } from '../../../helpers/utils'; import { IconCopy } from '@douyinfe/semi-icons'; import { useTranslation } from 'react-i18next'; +import { rehypeSplitWordsIntoSpans } from '../../../utils/rehypeSplitWordsIntoSpans'; mermaid.initialize({ startOnLoad: false, @@ -310,26 +311,40 @@ function tryWrapHtmlCode(text) { } function _MarkdownContent(props) { + const { + content, + className, + animated = false, + } = props; + const escapedContent = useMemo(() => { - return tryWrapHtmlCode(escapeBrackets(props.content)); - }, [props.content]); + return tryWrapHtmlCode(escapeBrackets(content)); + }, [content]); // 判断是否为用户消息 - const isUserMessage = props.className && props.className.includes('user-message'); + const isUserMessage = className && className.includes('user-message'); + + const rehypePluginsBase = useMemo(() => { + const base = [ + RehypeKatex, + [ + RehypeHighlight, + { + detect: false, + ignoreMissing: true, + }, + ], + ]; + if (animated) { + base.push(rehypeSplitWordsIntoSpans); + } + return base; + }, [animated]); return ( ) : ( - + )} ); diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css index e40295d7..815b1d18 100644 --- a/web/src/components/common/markdown/markdown.css +++ b/web/src/components/common/markdown/markdown.css @@ -419,4 +419,18 @@ pre:hover .copy-code-button { padding: 12px 16px; margin: 12px 0; border-radius: 0 6px 6px 0; +} + +@keyframes fade-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.animate-fade-in { + animation: fade-in 0.4s ease-in-out both; } \ No newline at end of file diff --git a/web/src/components/playground/MessageContent.js b/web/src/components/playground/MessageContent.js index 7a07aa0b..0eebaff4 100644 --- a/web/src/components/playground/MessageContent.js +++ b/web/src/components/playground/MessageContent.js @@ -242,6 +242,7 @@ const MessageContent = ({ )} @@ -257,6 +258,7 @@ const MessageContent = ({ ); @@ -267,6 +269,7 @@ const MessageContent = ({ ); diff --git a/web/src/components/playground/ThinkingContent.js b/web/src/components/playground/ThinkingContent.js index 6cd91dd8..2ffe066a 100644 --- a/web/src/components/playground/ThinkingContent.js +++ b/web/src/components/playground/ThinkingContent.js @@ -92,6 +92,7 @@ const ThinkingContent = ({ diff --git a/web/src/index.js b/web/src/index.js index 1f180bbd..978f39d5 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -15,7 +15,7 @@ import './i18n/i18n.js'; const root = ReactDOM.createRoot(document.getElementById('root')); const { Sider, Content, Header, Footer } = Layout; root.render( - + // @@ -27,5 +27,5 @@ root.render( - , + // , ); diff --git a/web/src/utils/rehypeSplitWordsIntoSpans.js b/web/src/utils/rehypeSplitWordsIntoSpans.js new file mode 100644 index 00000000..220d850b --- /dev/null +++ b/web/src/utils/rehypeSplitWordsIntoSpans.js @@ -0,0 +1,46 @@ +import { visit } from 'unist-util-visit'; + +/** + * rehype 插件:将段落等文本节点拆分为逐词 ,并添加淡入动画 class。 + * 仅在流式渲染阶段使用,避免已渲染文字重复动画。 + */ +export function rehypeSplitWordsIntoSpans() { + return (tree) => { + visit(tree, 'element', (node) => { + if ( + ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) && + node.children + ) { + const newChildren = []; + node.children.forEach((child) => { + if (child.type === 'text') { + try { + // 使用 Intl.Segmenter 精准拆分中英文及标点 + const segmenter = new Intl.Segmenter('zh', { granularity: 'word' }); + const segments = segmenter.segment(child.value); + Array.from(segments) + .map((seg) => seg.segment) + .filter(Boolean) + .forEach((word) => { + newChildren.push({ + type: 'element', + tagName: 'span', + properties: { + className: ['animate-fade-in'], + }, + children: [{ type: 'text', value: word }], + }); + }); + } catch (_) { + // Fallback:如果浏览器不支持 Segmenter,直接输出原文本 + newChildren.push(child); + } + } else { + newChildren.push(child); + } + }); + node.children = newChildren; + } + }); + }; +} \ No newline at end of file