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'; import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
interface MessageBubbleProps { interface MessageBubbleProps {
@@ -7,6 +8,69 @@ interface MessageBubbleProps {
isStreaming?: boolean; 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) { export function MessageBubble({ role, content, timestamp, isStreaming }: MessageBubbleProps) {
const isUser = role === 'user'; const isUser = role === 'user';
const time = new Date(timestamp).toLocaleTimeString('zh-CN', { const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
@@ -14,6 +78,11 @@ export function MessageBubble({ role, content, timestamp, isStreaming }: Message
minute: '2-digit', minute: '2-digit',
}); });
// 流式消息使用打字机逐字显示
const displayedContent = useTypewriter(content, !!isStreaming);
// 判断是否还有未显示完的字符
const hasMoreChars = isStreaming && displayedContent.length < content.length;
return ( return (
<div className={`flex px-4 py-2 gap-3 ${isUser ? 'flex-row-reverse' : ''}`}> <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' : ''} ${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 && ( {!isStreaming && (
<p <p
className={`text-xs mt-1 ${ className={`text-xs mt-1 ${
+17 -9
View File
@@ -42,26 +42,34 @@ export function useSession() {
if (resp.data) { if (resp.data) {
const session = resp.data as Session; const session = resp.data as Session;
addSession(session); addSession(session);
// 切换会话会触发历史消息加载 // setCurrentSessionId 内部已处理消息清空和历史加载,无需额外 clearMessages
setCurrentSessionId(session.id); await setCurrentSessionId(session.id);
clearMessages();
return session; return session;
} }
return null; return null;
}, [addSession, setCurrentSessionId, clearMessages]); }, [addSession, setCurrentSessionId]);
const deleteSession = useCallback(async (id: string) => { const deleteSession = useCallback(async (id: string) => {
await apiDeleteSession(id); await apiDeleteSession(id);
removeSession(id); // 如果删除的是当前活跃会话,先切换到其他会话
if (currentSessionId === id) { if (currentSessionId === id) {
clearMessages(); const store = useSessionStore.getState();
const remaining = store.sessions.filter((s) => s.id !== id);
if (remaining.length > 0) {
// 切换到列表中的第一个会话
await setCurrentSessionId(remaining[0].id);
} else {
clearMessages();
setCurrentSessionId(null);
}
} }
}, [removeSession, currentSessionId, clearMessages]); removeSession(id);
}, [removeSession, currentSessionId, clearMessages, setCurrentSessionId]);
const setCurrentSession = useCallback((id: string) => { const setCurrentSession = useCallback((id: string) => {
clearMessages(); // setCurrentSessionId 内部已处理消息清空和历史加载
setCurrentSessionId(id); setCurrentSessionId(id);
}, [setCurrentSessionId, clearMessages]); }, [setCurrentSessionId]);
return { return {
sessions, sessions,
+55 -9
View File
@@ -10,18 +10,23 @@ const WS_BASE_URL =
export function useWebSocket() { export function useWebSocket() {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const sessionIdRef = useRef<string | null>(null); const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
// 订阅 sessionStore 中的 currentSessionId 变化 // 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId); const currentSessionId = useSessionStore((s) => s.currentSessionId);
useEffect(() => {
sessionIdRef.current = currentSessionId;
}, [currentSessionId]);
const connect = useCallback(() => { const connect = useCallback(() => {
const token = getToken(); const token = getToken();
if (!token) return; if (!token) return;
// 关闭旧连接
if (wsRef.current) {
shouldReconnectRef.current = false;
wsRef.current.close();
wsRef.current = null;
}
const sessionID = useSessionStore.getState().currentSessionId || ''; const sessionID = useSessionStore.getState().currentSessionId || '';
const url = sessionID const url = sessionID
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}` ? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
@@ -31,13 +36,32 @@ export function useWebSocket() {
ws.onopen = () => { ws.onopen = () => {
setIsConnected(true); setIsConnected(true);
console.log('[WS] 已连接'); shouldReconnectRef.current = true;
console.log('[WS] 已连接, session_id:', sessionID);
// 连接后发送会话恢复消息,恢复历史上下文
const sid = useSessionStore.getState().currentSessionId;
if (sid) {
const resumeMsg: WSClientMessage = {
type: 'history',
session_id: sid,
timestamp: Date.now(),
};
ws.send(JSON.stringify(resumeMsg));
console.log('[WS] 已发送会话恢复请求, session_id:', sid);
}
}; };
ws.onclose = () => { ws.onclose = () => {
setIsConnected(false); setIsConnected(false);
console.log('[WS] 已断开3秒后重连...'); console.log('[WS] 已断开');
setTimeout(() => connect(), 3000); if (shouldReconnectRef.current) {
console.log('[WS] 3秒后重连...');
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimerRef.current = setTimeout(() => connect(), 3000);
}
}; };
ws.onerror = (err) => { ws.onerror = (err) => {
@@ -56,17 +80,22 @@ export function useWebSocket() {
wsRef.current = ws; wsRef.current = ws;
}, []); }, []);
// 初始连接 + 会话切换时重连
useEffect(() => { useEffect(() => {
connect(); connect();
return () => { return () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
shouldReconnectRef.current = false;
wsRef.current?.close(); wsRef.current?.close();
}; };
}, [connect]); }, [connect, currentSessionId]);
const sendMessage = useCallback((msg: WSClientMessage) => { const sendMessage = useCallback((msg: WSClientMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
// 自动附上 session_id // 自动附上 session_id
const sessionID = sessionIdRef.current || useSessionStore.getState().currentSessionId; const sessionID = useSessionStore.getState().currentSessionId;
wsRef.current.send(JSON.stringify({ wsRef.current.send(JSON.stringify({
...msg, ...msg,
session_id: msg.session_id || sessionID || undefined, session_id: msg.session_id || sessionID || undefined,
@@ -80,6 +109,7 @@ export function useWebSocket() {
function handleServerMessage(msg: WSServerMessage) { function handleServerMessage(msg: WSServerMessage) {
const { addMessage, appendToLastMessage, finishStreaming, setTyping } = useChatStore.getState(); const { addMessage, appendToLastMessage, finishStreaming, setTyping } = useChatStore.getState();
const { setMessages } = useSessionStore.getState(); const { setMessages } = useSessionStore.getState();
const chatState = useChatStore.getState();
switch (msg.type) { switch (msg.type) {
case 'response': case 'response':
@@ -126,6 +156,22 @@ function handleServerMessage(msg: WSServerMessage) {
setMessages(msg.messages); setMessages(msg.messages);
useChatStore.getState().setMessages(msg.messages); useChatStore.getState().setMessages(msg.messages);
} }
// 确保历史加载后 typing indicator 关闭
setTyping(false);
break;
case 'device_update':
// 处理 IoT 设备状态更新
if (msg.devices && msg.devices.length > 0) {
chatState.setIoTDevices(msg.devices);
}
break;
case 'background_thinking':
// 处理后端推送的后台思考状态
if (msg.thinking_status) {
chatState.setBackgroundThinkingStatus(msg.thinking_status);
}
break; break;
case 'error': case 'error':
+1 -9
View File
@@ -86,19 +86,11 @@
50% { opacity: 0; } 50% { opacity: 0; }
} }
/* 流式消息容器:每个新 chunk 有微淡入效果 */ /* 流式消息容器:微淡入效果 */
.message-streaming { .message-streaming {
animation: fadeInUp 0.3s ease-out; animation: fadeInUp 0.3s ease-out;
} }
/* 流式消息尾部闪烁光标 */
.message-streaming::after {
content: '▊';
animation: cursorBlink 1s step-end infinite;
color: #7c3aed;
margin-left: 2px;
}
/* 独立的闪烁光标工具类,可用于显式 span 元素 */ /* 独立的闪烁光标工具类,可用于显式 span 元素 */
.animate-streaming-cursor { .animate-streaming-cursor {
display: inline; display: inline;
+27 -1
View File
@@ -1,21 +1,40 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { Message } from '@/types/chat'; import type { Message } from '@/types/chat';
import type { IoTDevice, BackgroundThinkingStatus } from '@/types/chat';
interface ChatStore { interface ChatStore {
messages: Message[]; messages: Message[];
isTyping: boolean; isTyping: boolean;
// 连续对话模式
continuousMode: boolean;
// 后台思考状态
backgroundThinkingStatus: BackgroundThinkingStatus;
// IoT 设备状态
iotDevices: IoTDevice[];
iotDevicesLastUpdated: number | null;
addMessage: (message: Message) => void; addMessage: (message: Message) => void;
appendToLastMessage: (content: string) => void; appendToLastMessage: (content: string) => void;
finishStreaming: () => void; finishStreaming: () => void;
setMessages: (messages: Message[]) => void; setMessages: (messages: Message[]) => void;
setTyping: (typing: boolean) => void; setTyping: (typing: boolean) => void;
clearMessages: () => void; clearMessages: () => void;
setContinuousMode: (enabled: boolean) => void;
setBackgroundThinkingStatus: (status: BackgroundThinkingStatus) => void;
setIoTDevices: (devices: IoTDevice[]) => void;
} }
export const useChatStore = create<ChatStore>((set) => ({ export const useChatStore = create<ChatStore>((set) => ({
messages: [], messages: [],
isTyping: false, isTyping: false,
continuousMode: true,
backgroundThinkingStatus: 'idle',
iotDevices: [],
iotDevicesLastUpdated: null,
addMessage: (message) => addMessage: (message) =>
set((state) => ({ set((state) => ({
@@ -54,9 +73,16 @@ export const useChatStore = create<ChatStore>((set) => ({
return { messages: msgs, isTyping: false }; return { messages: msgs, isTyping: false };
}), }),
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages, isTyping: false }),
setTyping: (typing) => set({ isTyping: typing }), setTyping: (typing) => set({ isTyping: typing }),
clearMessages: () => set({ messages: [], isTyping: false }), clearMessages: () => set({ messages: [], isTyping: false }),
setContinuousMode: (enabled) => set({ continuousMode: enabled }),
setBackgroundThinkingStatus: (status) => set({ backgroundThinkingStatus: status }),
setIoTDevices: (devices) =>
set({ iotDevices: devices, iotDevicesLastUpdated: Date.now() }),
})); }));