Просмотр исходного кода

💄 feat(playground): chat streaming animation

Apple\Apple 9 месяцев назад
Родитель
Сommit
bafb0078e2

+ 1 - 0
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": {

+ 30 - 14
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 (
     <ReactMarkdown
       remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
-      rehypePlugins={[
-        RehypeKatex,
-        [
-          RehypeHighlight,
-          {
-            detect: false,
-            ignoreMissing: true,
-          },
-        ],
-      ]}
+      rehypePlugins={rehypePluginsBase}
       components={{
         pre: PreCode,
         code: CustomCode,
@@ -447,6 +462,7 @@ export function MarkdownRenderer(props) {
     fontFamily = 'inherit',
     className,
     style,
+    animated = false,
     ...otherProps
   } = props;
 
@@ -482,7 +498,7 @@ export function MarkdownRenderer(props) {
           正在渲染...
         </div>
       ) : (
-        <MarkdownContent content={content} className={className} />
+        <MarkdownContent content={content} className={className} animated={animated} />
       )}
     </div>
   );

+ 14 - 0
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;
 }

+ 3 - 0
web/src/components/playground/MessageContent.js

@@ -242,6 +242,7 @@ const MessageContent = ({
                     <MarkdownRenderer
                       content={textContent.text}
                       className={message.role === 'user' ? 'user-message' : ''}
+                      animated={false}
                     />
                   </div>
                 )}
@@ -257,6 +258,7 @@ const MessageContent = ({
                     <MarkdownRenderer
                       content={finalDisplayableFinalContent}
                       className=""
+                      animated={isThinkingStatus}
                     />
                   </div>
                 );
@@ -267,6 +269,7 @@ const MessageContent = ({
                   <MarkdownRenderer
                     content={message.content}
                     className={message.role === 'user' ? 'user-message' : ''}
+                    animated={false}
                   />
                 </div>
               );

+ 1 - 0
web/src/components/playground/ThinkingContent.js

@@ -92,6 +92,7 @@ const ThinkingContent = ({
                 <MarkdownRenderer
                   content={finalExtractedThinkingContent}
                   className=""
+                  animated={isThinkingStatus}
                 />
               </div>
             </div>

+ 2 - 2
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(
-  <React.StrictMode>
+  // <React.StrictMode>
     <StatusProvider>
       <UserProvider>
         <BrowserRouter>
@@ -27,5 +27,5 @@ root.render(
         </BrowserRouter>
       </UserProvider>
     </StatusProvider>
-  </React.StrictMode>,
+  // </React.StrictMode>,
 );

+ 46 - 0
web/src/utils/rehypeSplitWordsIntoSpans.js

@@ -0,0 +1,46 @@
+import { visit } from 'unist-util-visit';
+
+/**
+ * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 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;
+      }
+    });
+  };
+}