fix(frontend): 流式消息逐字显示动画 + 侧边栏会话切换修复
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
@@ -7,6 +8,69 @@ interface MessageBubbleProps {
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打字机逐字显示 Hook
|
||||
* 当 isStreaming 为 true 时,逐字显示 content;当流式结束时一次性显示全部
|
||||
*/
|
||||
function useTypewriter(content: string, isStreaming: boolean): string {
|
||||
const [displayed, setDisplayed] = useState('');
|
||||
const prevContentRef = useRef('');
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
// 流式结束,直接显示全部内容
|
||||
setDisplayed(content);
|
||||
prevContentRef.current = content;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果 content 重置了(新消息),从头开始
|
||||
if (content.length < prevContentRef.current.length && content.length < displayed.length) {
|
||||
setDisplayed('');
|
||||
prevContentRef.current = '';
|
||||
}
|
||||
|
||||
// 启动逐字显示定时器
|
||||
const tick = () => {
|
||||
setDisplayed((prev) => {
|
||||
if (prev.length >= content.length) {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
// 每次显示一个字符,遇到中文字符时多等一帧
|
||||
return content.slice(0, prev.length + 1);
|
||||
});
|
||||
};
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
|
||||
// 根据内容长度动态调整速度:短内容快,长内容稍慢
|
||||
const speed = content.length > 200 ? 15 : content.length > 100 ? 20 : 30;
|
||||
timerRef.current = setInterval(tick, speed);
|
||||
|
||||
prevContentRef.current = content;
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [content, isStreaming]);
|
||||
|
||||
return displayed;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = role === 'user';
|
||||
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
@@ -14,6 +78,11 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
// 流式消息使用打字机逐字显示
|
||||
const displayedContent = useTypewriter(content, !!isStreaming);
|
||||
// 判断是否还有未显示完的字符
|
||||
const hasMoreChars = isStreaming && displayedContent.length < content.length;
|
||||
|
||||
return (
|
||||
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}>
|
||||
{/* 头像 */}
|
||||
@@ -33,7 +102,13 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
|
||||
${isStreaming ? 'message-streaming' : ''}
|
||||
`}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{content}</p>
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{isStreaming ? displayedContent : content}
|
||||
{/* 流式消息末尾闪烁光标 — 用独立 span 避免 ::after 在隐藏字符后错位 */}
|
||||
{hasMoreChars && (
|
||||
<span className="animate-streaming-cursor" />
|
||||
)}
|
||||
</p>
|
||||
{!isStreaming && (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
|
||||
Reference in New Issue
Block a user