From 3a5013b87619cb5ab81c5f05a6d37af65cef7efb Mon Sep 17 00:00:00 2001 From: "Apple\\Apple" Date: Mon, 2 Jun 2025 20:15:00 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=84=20feat(playground):=20Enhance=20th?= =?UTF-8?q?e=20fade-in=20animation=20for=20the=20chat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/markdown/MarkdownRenderer.js | 13 +++++-- .../components/playground/MessageContent.js | 29 +++++++++++++- .../components/playground/ThinkingContent.js | 22 +++++++++++ web/src/utils/rehypeSplitWordsIntoSpans.js | 39 +++++++++++++++++-- 4 files changed, 95 insertions(+), 8 deletions(-) diff --git a/web/src/components/common/markdown/MarkdownRenderer.js b/web/src/components/common/markdown/MarkdownRenderer.js index b6320d63..870e0b6f 100644 --- a/web/src/components/common/markdown/MarkdownRenderer.js +++ b/web/src/components/common/markdown/MarkdownRenderer.js @@ -315,6 +315,7 @@ function _MarkdownContent(props) { content, className, animated = false, + previousContentLength = 0, } = props; const escapedContent = useMemo(() => { @@ -336,10 +337,10 @@ function _MarkdownContent(props) { ], ]; if (animated) { - base.push(rehypeSplitWordsIntoSpans); + base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]); } return base; - }, [animated]); + }, [animated, previousContentLength]); return ( ) : ( - + )} ); diff --git a/web/src/components/playground/MessageContent.js b/web/src/components/playground/MessageContent.js index 0eebaff4..2e8eb548 100644 --- a/web/src/components/playground/MessageContent.js +++ b/web/src/components/playground/MessageContent.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useEffect } from 'react'; import { Typography, TextArea, @@ -25,6 +25,8 @@ const MessageContent = ({ onEditValueChange }) => { const { t } = useTranslation(); + const previousContentLengthRef = useRef(0); + const lastContentRef = useRef(''); if (message.status === 'error') { let errorText; @@ -128,6 +130,14 @@ const MessageContent = ({ const finalExtractedThinkingContent = currentExtractedThinkingContent; const finalDisplayableFinalContent = currentDisplayableFinalContent; + // 流式状态结束时重置 + useEffect(() => { + if (!isThinkingStatus) { + previousContentLengthRef.current = 0; + lastContentRef.current = ''; + } + }, [isThinkingStatus]); + if (message.role === 'assistant' && isThinkingStatus && !finalExtractedThinkingContent && @@ -243,6 +253,7 @@ const MessageContent = ({ content={textContent.text} className={message.role === 'user' ? 'user-message' : ''} animated={false} + previousContentLength={0} /> )} @@ -253,12 +264,27 @@ const MessageContent = ({ if (typeof message.content === 'string') { if (message.role === 'assistant') { 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 (
); @@ -270,6 +296,7 @@ const MessageContent = ({ content={message.content} className={message.role === 'user' ? 'user-message' : ''} animated={false} + previousContentLength={0} /> ); diff --git a/web/src/components/playground/ThinkingContent.js b/web/src/components/playground/ThinkingContent.js index 2ffe066a..57b4f86d 100644 --- a/web/src/components/playground/ThinkingContent.js +++ b/web/src/components/playground/ThinkingContent.js @@ -13,6 +13,7 @@ const ThinkingContent = ({ }) => { const { t } = useTranslation(); const scrollRef = useRef(null); + const lastContentRef = useRef(''); if (!finalExtractedThinkingContent) return null; @@ -25,6 +26,26 @@ const ThinkingContent = ({ } }, [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 (
diff --git a/web/src/utils/rehypeSplitWordsIntoSpans.js b/web/src/utils/rehypeSplitWordsIntoSpans.js index 220d850b..a9bb6db3 100644 --- a/web/src/utils/rehypeSplitWordsIntoSpans.js +++ b/web/src/utils/rehypeSplitWordsIntoSpans.js @@ -4,8 +4,12 @@ import { visit } from 'unist-util-visit'; * rehype 插件:将段落等文本节点拆分为逐词 ,并添加淡入动画 class。 * 仅在流式渲染阶段使用,避免已渲染文字重复动画。 */ -export function rehypeSplitWordsIntoSpans() { +export function rehypeSplitWordsIntoSpans(options = {}) { + const { previousContentLength = 0 } = options; + return (tree) => { + let currentCharCount = 0; // 当前已处理的字符数 + visit(tree, 'element', (node) => { if ( ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'strong'].includes(node.tagName) && @@ -18,22 +22,49 @@ export function rehypeSplitWordsIntoSpans() { // 使用 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) => { + const wordStartPos = currentCharCount; + const wordEndPos = currentCharCount + word.length; + + // 判断这个词是否是新增的(在 previousContentLength 之后) + const isNewContent = wordStartPos >= previousContentLength; + newChildren.push({ type: 'element', tagName: 'span', properties: { - className: ['animate-fade-in'], + className: isNewContent ? ['animate-fade-in'] : [], }, children: [{ type: 'text', value: word }], }); + + currentCharCount = wordEndPos; }); } 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 { newChildren.push(child);