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 ${
|
||||
|
||||
@@ -42,26 +42,34 @@ export function useSession() {
|
||||
if (resp.data) {
|
||||
const session = resp.data as Session;
|
||||
addSession(session);
|
||||
// 切换会话会触发历史消息加载
|
||||
setCurrentSessionId(session.id);
|
||||
clearMessages();
|
||||
// setCurrentSessionId 内部已处理消息清空和历史加载,无需额外 clearMessages
|
||||
await setCurrentSessionId(session.id);
|
||||
return session;
|
||||
}
|
||||
return null;
|
||||
}, [addSession, setCurrentSessionId, clearMessages]);
|
||||
}, [addSession, setCurrentSessionId]);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
await apiDeleteSession(id);
|
||||
removeSession(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) => {
|
||||
clearMessages();
|
||||
// setCurrentSessionId 内部已处理消息清空和历史加载
|
||||
setCurrentSessionId(id);
|
||||
}, [setCurrentSessionId, clearMessages]);
|
||||
}, [setCurrentSessionId]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
|
||||
@@ -10,18 +10,23 @@ const WS_BASE_URL =
|
||||
export function useWebSocket() {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
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 变化
|
||||
const currentSessionId = useSessionStore((s) => s.currentSessionId);
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = currentSessionId;
|
||||
}, [currentSessionId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
// 关闭旧连接
|
||||
if (wsRef.current) {
|
||||
shouldReconnectRef.current = false;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
const sessionID = useSessionStore.getState().currentSessionId || '';
|
||||
const url = sessionID
|
||||
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
|
||||
@@ -31,13 +36,32 @@ export function useWebSocket() {
|
||||
|
||||
ws.onopen = () => {
|
||||
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 = () => {
|
||||
setIsConnected(false);
|
||||
console.log('[WS] 已断开,3秒后重连...');
|
||||
setTimeout(() => connect(), 3000);
|
||||
console.log('[WS] 已断开');
|
||||
if (shouldReconnectRef.current) {
|
||||
console.log('[WS] 3秒后重连...');
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
reconnectTimerRef.current = setTimeout(() => connect(), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
@@ -56,17 +80,22 @@ export function useWebSocket() {
|
||||
wsRef.current = ws;
|
||||
}, []);
|
||||
|
||||
// 初始连接 + 会话切换时重连
|
||||
useEffect(() => {
|
||||
connect();
|
||||
return () => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
}
|
||||
shouldReconnectRef.current = false;
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
}, [connect, currentSessionId]);
|
||||
|
||||
const sendMessage = useCallback((msg: WSClientMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
// 自动附上 session_id
|
||||
const sessionID = sessionIdRef.current || useSessionStore.getState().currentSessionId;
|
||||
const sessionID = useSessionStore.getState().currentSessionId;
|
||||
wsRef.current.send(JSON.stringify({
|
||||
...msg,
|
||||
session_id: msg.session_id || sessionID || undefined,
|
||||
@@ -80,6 +109,7 @@ export function useWebSocket() {
|
||||
function handleServerMessage(msg: WSServerMessage) {
|
||||
const { addMessage, appendToLastMessage, finishStreaming, setTyping } = useChatStore.getState();
|
||||
const { setMessages } = useSessionStore.getState();
|
||||
const chatState = useChatStore.getState();
|
||||
|
||||
switch (msg.type) {
|
||||
case 'response':
|
||||
@@ -126,6 +156,22 @@ function handleServerMessage(msg: WSServerMessage) {
|
||||
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;
|
||||
|
||||
case 'error':
|
||||
|
||||
@@ -86,19 +86,11 @@
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 流式消息容器:每个新 chunk 有微淡入效果 */
|
||||
/* 流式消息容器:轻微淡入效果 */
|
||||
.message-streaming {
|
||||
animation: fadeInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* 流式消息尾部闪烁光标 */
|
||||
.message-streaming::after {
|
||||
content: '▊';
|
||||
animation: cursorBlink 1s step-end infinite;
|
||||
color: #7c3aed;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 独立的闪烁光标工具类,可用于显式 span 元素 */
|
||||
.animate-streaming-cursor {
|
||||
display: inline;
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Message } from '@/types/chat';
|
||||
import type { IoTDevice, BackgroundThinkingStatus } from '@/types/chat';
|
||||
|
||||
interface ChatStore {
|
||||
messages: Message[];
|
||||
isTyping: boolean;
|
||||
|
||||
// 连续对话模式
|
||||
continuousMode: boolean;
|
||||
|
||||
// 后台思考状态
|
||||
backgroundThinkingStatus: BackgroundThinkingStatus;
|
||||
|
||||
// IoT 设备状态
|
||||
iotDevices: IoTDevice[];
|
||||
iotDevicesLastUpdated: number | null;
|
||||
|
||||
addMessage: (message: Message) => void;
|
||||
appendToLastMessage: (content: string) => void;
|
||||
finishStreaming: () => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setTyping: (typing: boolean) => void;
|
||||
clearMessages: () => void;
|
||||
|
||||
setContinuousMode: (enabled: boolean) => void;
|
||||
setBackgroundThinkingStatus: (status: BackgroundThinkingStatus) => void;
|
||||
setIoTDevices: (devices: IoTDevice[]) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatStore>((set) => ({
|
||||
messages: [],
|
||||
isTyping: false,
|
||||
continuousMode: true,
|
||||
backgroundThinkingStatus: 'idle',
|
||||
iotDevices: [],
|
||||
iotDevicesLastUpdated: null,
|
||||
|
||||
addMessage: (message) =>
|
||||
set((state) => ({
|
||||
@@ -54,9 +73,16 @@ export const useChatStore = create<ChatStore>((set) => ({
|
||||
return { messages: msgs, isTyping: false };
|
||||
}),
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
setMessages: (messages) => set({ messages, isTyping: false }),
|
||||
|
||||
setTyping: (typing) => set({ isTyping: typing }),
|
||||
|
||||
clearMessages: () => set({ messages: [], isTyping: false }),
|
||||
|
||||
setContinuousMode: (enabled) => set({ continuousMode: enabled }),
|
||||
|
||||
setBackgroundThinkingStatus: (status) => set({ backgroundThinkingStatus: status }),
|
||||
|
||||
setIoTDevices: (devices) =>
|
||||
set({ iotDevices: devices, iotDevicesLastUpdated: Date.now() }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user