💄 feat(playground): chat streaming animation
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -92,6 +92,7 @@ const ThinkingContent = ({
|
||||
<MarkdownRenderer
|
||||
content={finalExtractedThinkingContent}
|
||||
className=""
|
||||
animated={isThinkingStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
46
web/src/utils/rehypeSplitWordsIntoSpans.js
Normal file
46
web/src/utils/rehypeSplitWordsIntoSpans.js
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user