💄 feat(playground): Enhance the fade-in animation for the chat
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user