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

💄 feat(playground): Enhance the fade-in animation for the chat

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

+ 10 - 3
web/src/components/common/markdown/MarkdownRenderer.js

@@ -315,6 +315,7 @@ function _MarkdownContent(props) {
     content,
     content,
     className,
     className,
     animated = false,
     animated = false,
+    previousContentLength = 0,
   } = props;
   } = props;
 
 
   const escapedContent = useMemo(() => {
   const escapedContent = useMemo(() => {
@@ -336,10 +337,10 @@ function _MarkdownContent(props) {
       ],
       ],
     ];
     ];
     if (animated) {
     if (animated) {
-      base.push(rehypeSplitWordsIntoSpans);
+      base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]);
     }
     }
     return base;
     return base;
-  }, [animated]);
+  }, [animated, previousContentLength]);
 
 
   return (
   return (
     <ReactMarkdown
     <ReactMarkdown
@@ -463,6 +464,7 @@ export function MarkdownRenderer(props) {
     className,
     className,
     style,
     style,
     animated = false,
     animated = false,
+    previousContentLength = 0,
     ...otherProps
     ...otherProps
   } = props;
   } = props;
 
 
@@ -498,7 +500,12 @@ export function MarkdownRenderer(props) {
           正在渲染...
           正在渲染...
         </div>
         </div>
       ) : (
       ) : (
-        <MarkdownContent content={content} className={className} animated={animated} />
+        <MarkdownContent
+          content={content}
+          className={className}
+          animated={animated}
+          previousContentLength={previousContentLength}
+        />
       )}
       )}
     </div>
     </div>
   );
   );

+ 28 - 1
web/src/components/playground/MessageContent.js

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useRef, useEffect } from 'react';
 import {
 import {
   Typography,
   Typography,
   TextArea,
   TextArea,
@@ -25,6 +25,8 @@ const MessageContent = ({
   onEditValueChange
   onEditValueChange
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
+  const previousContentLengthRef = useRef(0);
+  const lastContentRef = useRef('');
 
 
   if (message.status === 'error') {
   if (message.status === 'error') {
     let errorText;
     let errorText;
@@ -128,6 +130,14 @@ const MessageContent = ({
   const finalExtractedThinkingContent = currentExtractedThinkingContent;
   const finalExtractedThinkingContent = currentExtractedThinkingContent;
   const finalDisplayableFinalContent = currentDisplayableFinalContent;
   const finalDisplayableFinalContent = currentDisplayableFinalContent;
 
 
+  // 流式状态结束时重置
+  useEffect(() => {
+    if (!isThinkingStatus) {
+      previousContentLengthRef.current = 0;
+      lastContentRef.current = '';
+    }
+  }, [isThinkingStatus]);
+
   if (message.role === 'assistant' &&
   if (message.role === 'assistant' &&
     isThinkingStatus &&
     isThinkingStatus &&
     !finalExtractedThinkingContent &&
     !finalExtractedThinkingContent &&
@@ -243,6 +253,7 @@ const MessageContent = ({
                       content={textContent.text}
                       content={textContent.text}
                       className={message.role === 'user' ? 'user-message' : ''}
                       className={message.role === 'user' ? 'user-message' : ''}
                       animated={false}
                       animated={false}
+                      previousContentLength={0}
                     />
                     />
                   </div>
                   </div>
                 )}
                 )}
@@ -253,12 +264,27 @@ const MessageContent = ({
           if (typeof message.content === 'string') {
           if (typeof message.content === 'string') {
             if (message.role === 'assistant') {
             if (message.role === 'assistant') {
               if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
               if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
+                // 获取上一次的内容长度
+                let prevLength = 0;
+                if (isThinkingStatus && lastContentRef.current) {
+                  // 只有当前内容包含上一次内容时,才使用上一次的长度
+                  if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
+                    prevLength = lastContentRef.current.length;
+                  }
+                }
+
+                // 更新最后内容的引用
+                if (isThinkingStatus) {
+                  lastContentRef.current = finalDisplayableFinalContent;
+                }
+
                 return (
                 return (
                   <div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
                   <div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
                     <MarkdownRenderer
                     <MarkdownRenderer
                       content={finalDisplayableFinalContent}
                       content={finalDisplayableFinalContent}
                       className=""
                       className=""
                       animated={isThinkingStatus}
                       animated={isThinkingStatus}
+                      previousContentLength={prevLength}
                     />
                     />
                   </div>
                   </div>
                 );
                 );
@@ -270,6 +296,7 @@ const MessageContent = ({
                     content={message.content}
                     content={message.content}
                     className={message.role === 'user' ? 'user-message' : ''}
                     className={message.role === 'user' ? 'user-message' : ''}
                     animated={false}
                     animated={false}
+                    previousContentLength={0}
                   />
                   />
                 </div>
                 </div>
               );
               );

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

@@ -13,6 +13,7 @@ const ThinkingContent = ({
 }) => {
 }) => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const scrollRef = useRef(null);
   const scrollRef = useRef(null);
+  const lastContentRef = useRef('');
 
 
   if (!finalExtractedThinkingContent) return null;
   if (!finalExtractedThinkingContent) return null;
 
 
@@ -25,6 +26,26 @@ const ThinkingContent = ({
     }
     }
   }, [finalExtractedThinkingContent, message.isReasoningExpanded]);
   }, [finalExtractedThinkingContent, message.isReasoningExpanded]);
 
 
+  // 流式状态结束时重置
+  useEffect(() => {
+    if (!isThinkingStatus) {
+      lastContentRef.current = '';
+    }
+  }, [isThinkingStatus]);
+
+  // 获取上一次的内容长度
+  let prevLength = 0;
+  if (isThinkingStatus && lastContentRef.current) {
+    if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
+      prevLength = lastContentRef.current.length;
+    }
+  }
+
+  // 更新最后内容的引用
+  if (isThinkingStatus) {
+    lastContentRef.current = finalExtractedThinkingContent;
+  }
+
   return (
   return (
     <div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
     <div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
       <div
       <div
@@ -93,6 +114,7 @@ const ThinkingContent = ({
                   content={finalExtractedThinkingContent}
                   content={finalExtractedThinkingContent}
                   className=""
                   className=""
                   animated={isThinkingStatus}
                   animated={isThinkingStatus}
+                  previousContentLength={prevLength}
                 />
                 />
               </div>
               </div>
             </div>
             </div>

+ 35 - 4
web/src/utils/rehypeSplitWordsIntoSpans.js

@@ -4,8 +4,12 @@ import { visit } from 'unist-util-visit';
  * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
  * rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
  * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
  * 仅在流式渲染阶段使用,避免已渲染文字重复动画。
  */
  */
-export function rehypeSplitWordsIntoSpans() {
+export function rehypeSplitWordsIntoSpans(options = {}) {
+  const { previousContentLength = 0 } = options;
+
   return (tree) => {
   return (tree) => {
+    let currentCharCount = 0; // 当前已处理的字符数
+
     visit(tree, 'element', (node) => {
     visit(tree, 'element', (node) => {
       if (
       if (
         ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) &&
         ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) &&
@@ -18,22 +22,49 @@ export function rehypeSplitWordsIntoSpans() {
               // 使用 Intl.Segmenter 精准拆分中英文及标点
               // 使用 Intl.Segmenter 精准拆分中英文及标点
               const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
               const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
               const segments = segmenter.segment(child.value);
               const segments = segmenter.segment(child.value);
+
               Array.from(segments)
               Array.from(segments)
                 .map((seg) => seg.segment)
                 .map((seg) => seg.segment)
                 .filter(Boolean)
                 .filter(Boolean)
                 .forEach((word) => {
                 .forEach((word) => {
+                  const wordStartPos = currentCharCount;
+                  const wordEndPos = currentCharCount + word.length;
+
+                  // 判断这个词是否是新增的(在 previousContentLength 之后)
+                  const isNewContent = wordStartPos >= previousContentLength;
+
                   newChildren.push({
                   newChildren.push({
                     type: 'element',
                     type: 'element',
                     tagName: 'span',
                     tagName: 'span',
                     properties: {
                     properties: {
-                      className: ['animate-fade-in'],
+                      className: isNewContent ? ['animate-fade-in'] : [],
                     },
                     },
                     children: [{ type: 'text', value: word }],
                     children: [{ type: 'text', value: word }],
                   });
                   });
+
+                  currentCharCount = wordEndPos;
                 });
                 });
             } catch (_) {
             } catch (_) {
-              // Fallback:如果浏览器不支持 Segmenter,直接输出原文本
-              newChildren.push(child);
+              // Fallback:如果浏览器不支持 Segmenter
+              const textStartPos = currentCharCount;
+              const isNewContent = textStartPos >= previousContentLength;
+
+              if (isNewContent) {
+                // 新内容,添加动画
+                newChildren.push({
+                  type: 'element',
+                  tagName: 'span',
+                  properties: {
+                    className: ['animate-fade-in'],
+                  },
+                  children: [{ type: 'text', value: child.value }],
+                });
+              } else {
+                // 旧内容,不添加动画
+                newChildren.push(child);
+              }
+
+              currentCharCount += child.value.length;
             }
             }
           } else {
           } else {
             newChildren.push(child);
             newChildren.push(child);