💄 feat(playground): chat streaming animation
This commit is contained in:
@@ -40,6 +40,7 @@
|
|||||||
"semantic-ui-offline": "^2.5.0",
|
"semantic-ui-offline": "^2.5.0",
|
||||||
"semantic-ui-react": "^2.1.3",
|
"semantic-ui-react": "^2.1.3",
|
||||||
"sse": "https://github.com/mpetazzoni/sse.js",
|
"sse": "https://github.com/mpetazzoni/sse.js",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"use-debounce": "^10.0.4"
|
"use-debounce": "^10.0.4"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
|||||||
import { copy } from '../../../helpers/utils';
|
import { copy } from '../../../helpers/utils';
|
||||||
import { IconCopy } from '@douyinfe/semi-icons';
|
import { IconCopy } from '@douyinfe/semi-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { rehypeSplitWordsIntoSpans } from '../../../utils/rehypeSplitWordsIntoSpans';
|
||||||
|
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
startOnLoad: false,
|
startOnLoad: false,
|
||||||
@@ -310,26 +311,40 @@ function tryWrapHtmlCode(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _MarkdownContent(props) {
|
function _MarkdownContent(props) {
|
||||||
|
const {
|
||||||
|
content,
|
||||||
|
className,
|
||||||
|
animated = false,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const escapedContent = useMemo(() => {
|
const escapedContent = useMemo(() => {
|
||||||
return tryWrapHtmlCode(escapeBrackets(props.content));
|
return tryWrapHtmlCode(escapeBrackets(content));
|
||||||
}, [props.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 (
|
return (
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
remarkPlugins={[RemarkMath, RemarkGfm, RemarkBreaks]}
|
||||||
rehypePlugins={[
|
rehypePlugins={rehypePluginsBase}
|
||||||
RehypeKatex,
|
|
||||||
[
|
|
||||||
RehypeHighlight,
|
|
||||||
{
|
|
||||||
detect: false,
|
|
||||||
ignoreMissing: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
components={{
|
components={{
|
||||||
pre: PreCode,
|
pre: PreCode,
|
||||||
code: CustomCode,
|
code: CustomCode,
|
||||||
@@ -447,6 +462,7 @@ export function MarkdownRenderer(props) {
|
|||||||
fontFamily = 'inherit',
|
fontFamily = 'inherit',
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
|
animated = false,
|
||||||
...otherProps
|
...otherProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
@@ -482,7 +498,7 @@ export function MarkdownRenderer(props) {
|
|||||||
正在渲染...
|
正在渲染...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownContent content={content} className={className} />
|
<MarkdownContent content={content} className={className} animated={animated} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -420,3 +420,17 @@ pre:hover .copy-code-button {
|
|||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
border-radius: 0 6px 6px 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
|
<MarkdownRenderer
|
||||||
content={textContent.text}
|
content={textContent.text}
|
||||||
className={message.role === 'user' ? 'user-message' : ''}
|
className={message.role === 'user' ? 'user-message' : ''}
|
||||||
|
animated={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -257,6 +258,7 @@ const MessageContent = ({
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
content={finalDisplayableFinalContent}
|
content={finalDisplayableFinalContent}
|
||||||
className=""
|
className=""
|
||||||
|
animated={isThinkingStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -267,6 +269,7 @@ const MessageContent = ({
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
content={message.content}
|
content={message.content}
|
||||||
className={message.role === 'user' ? 'user-message' : ''}
|
className={message.role === 'user' ? 'user-message' : ''}
|
||||||
|
animated={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ const ThinkingContent = ({
|
|||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
content={finalExtractedThinkingContent}
|
content={finalExtractedThinkingContent}
|
||||||
className=""
|
className=""
|
||||||
|
animated={isThinkingStatus}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import './i18n/i18n.js';
|
|||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
const { Sider, Content, Header, Footer } = Layout;
|
const { Sider, Content, Header, Footer } = Layout;
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
// <React.StrictMode>
|
||||||
<StatusProvider>
|
<StatusProvider>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
@@ -27,5 +27,5 @@ root.render(
|
|||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</StatusProvider>
|
</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