fix(frontend): 流式消息逐字显示动画 + 侧边栏会话切换修复
This commit is contained in:
@@ -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 ${
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
const store = useSessionStore.getState();
|
||||||
|
const remaining = store.sessions.filter((s) => s.id !== id);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
// 切换到列表中的第一个会话
|
||||||
|
await setCurrentSessionId(remaining[0].id);
|
||||||
|
} else {
|
||||||
clearMessages();
|
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,
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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() }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user