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

This commit is contained in:
Apple\Apple
2025-06-02 20:15:00 +08:00
parent bafb0078e2
commit 3a5013b876
4 changed files with 95 additions and 8 deletions

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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,直接输出原文本 // Fallback如果浏览器不支持 Segmenter
newChildren.push(child); 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);