💄 feat(playground): chat streaming animation

This commit is contained in:
Apple\Apple
2025-06-02 19:58:10 +08:00
parent d534d4575d
commit bafb0078e2
7 changed files with 97 additions and 16 deletions

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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