fix(frontend): 流式消息逐字显示动画 + 侧边栏会话切换修复

This commit is contained in:
2026-05-16 21:25:03 +08:00
parent 02a5067f8c
commit 15a22737a2
5 changed files with 176 additions and 29 deletions
@@ -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 ${