fix: 第一轮修复 - 记忆管理/IoT操控/历史消息持久化/动作消息/链路优化/安全配置

- 修复记忆管理数据库连接不可用 (ai-core重编译+Unicode修复)
- 修复IoT子会话工具调用链路日志缺失
- 新增最终审查子会话(review_provider) 支持消息格式解析拆分
- 实现历史消息持久化(后端存储+前端分页加载)
- 前端新增动作消息(ActionMessage)类型和渲染
- 优化对话链路速度(非阻塞子会话+快速问候通道)
- JWT密钥环境变量化(无默认值启动panic)
- Token自动刷新机制(401拦截器+refresh接口)
- WebSocket指数退避重连(jitter+最大10次)
- localStorage清理一致性(cyrene_前缀+版本检查)
- IoT环境变量统一为IOT_SERVICE_URL
This commit is contained in:
2026-05-21 23:10:07 +08:00
parent 8b7d4ec19a
commit a058b0ab8e
53 changed files with 5535 additions and 241 deletions
+2
View File
@@ -5,6 +5,8 @@ export {
refreshToken,
setToken,
getToken,
getRefreshToken,
setTokens,
clearToken,
isAuthenticated,
} from './client';
+200 -55
View File
@@ -1,9 +1,154 @@
// HTTP 客户端封装
// HTTP 客户端封装 — 带 Token 自动刷新拦截器
import type { AuthResponse } from '@/types/session';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080/api/v1';
/** localStorage key 前缀 */
const LS_TOKEN_KEY = 'token';
const LS_REFRESH_TOKEN_KEY = 'refresh_token';
const LS_USER_ID_KEY = 'user_id';
// ========== Token 刷新队列(避免多个并发请求同时刷新 token ==========
let isRefreshing = false;
let refreshPromise: Promise<string | null> | null = null;
/**
* 等待中的请求队列。当 token 刷新完成后,用新 token 重放这些请求。
*/
type QueuedRequest = {
resolve: (token: string | null) => void;
reject: (err: unknown) => void;
};
let refreshQueue: QueuedRequest[] = [];
function onRefreshDone(newToken: string | null) {
for (const q of refreshQueue) {
q.resolve(newToken);
}
refreshQueue = [];
}
function onRefreshFailed(err: unknown) {
for (const q of refreshQueue) {
q.reject(err);
}
refreshQueue = [];
}
// ========== 存储辅助函数 ==========
/** 存储认证令牌 */
export function setToken(token: string) {
try {
localStorage.setItem(LS_TOKEN_KEY, token);
} catch { /* ignore */ }
}
/** 获取认证令牌 */
export function getToken(): string | null {
try {
return localStorage.getItem(LS_TOKEN_KEY);
} catch {
return null;
}
}
/** 获取刷新令牌 */
export function getRefreshToken(): string | null {
try {
return localStorage.getItem(LS_REFRESH_TOKEN_KEY);
} catch {
return null;
}
}
/** 存储 tokens */
export function setTokens(accessToken: string, refreshTokenValue?: string) {
setToken(accessToken);
if (refreshTokenValue) {
try {
localStorage.setItem(LS_REFRESH_TOKEN_KEY, refreshTokenValue);
} catch { /* ignore */ }
}
}
/** 清除认证令牌 */
export function clearToken() {
try {
localStorage.removeItem(LS_TOKEN_KEY);
localStorage.removeItem(LS_REFRESH_TOKEN_KEY);
localStorage.removeItem(LS_USER_ID_KEY);
} catch { /* ignore */ }
}
/** 检查是否已认证 */
export function isAuthenticated(): boolean {
return !!getToken();
}
// ========== Token 刷新逻辑 ==========
/**
* 尝试刷新 token。如果已经有正在进行的刷新请求,等待它完成。
*/
async function tryRefreshToken(): Promise<string | null> {
const refreshTokenValue = getRefreshToken();
if (!refreshTokenValue) {
// 没有 refresh token,无法刷新
return null;
}
// 如果已经有刷新正在进行中,加入队列等待
if (isRefreshing && refreshPromise) {
return new Promise((resolve, reject) => {
refreshQueue.push({ resolve, reject });
});
}
isRefreshing = true;
refreshPromise = (async () => {
try {
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${refreshTokenValue}`,
},
body: JSON.stringify({ refresh_token: refreshTokenValue }),
});
if (!response.ok) {
throw new Error(`Refresh failed: ${response.status}`);
}
const data: AuthResponse = await response.json();
if (data.token) {
setTokens(data.token, data.refresh_token);
return data.token;
}
return null;
} catch {
// 刷新失败
clearToken();
return null;
} finally {
isRefreshing = false;
refreshPromise = null;
}
})();
try {
const newToken = await refreshPromise;
onRefreshDone(newToken);
return newToken;
} catch (err) {
onRefreshFailed(err);
return null;
}
}
/** 请求选项 */
interface RequestOptions {
method?: string;
@@ -20,70 +165,70 @@ interface ApiResponse<T = unknown> {
}
/**
* 发送API请求
* 发送 API 请求,内置 401 自动刷新拦截
*/
async function request<T = unknown>(endpoint: string, options: RequestOptions = {}): Promise<ApiResponse<T>> {
const { method = 'GET', body, auth = true } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
const makeRequest = async (tokenOverride?: string): Promise<ApiResponse<T>> => {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
};
if (auth) {
const token = localStorage.getItem('token');
const token = tokenOverride || (auth ? getToken() : null);
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
try {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await response.json().catch(() => null);
// 401 且当前请求需要认证、且不是 refresh 接口本身、且尚未重试
if (response.status === 401 && auth && endpoint !== '/auth/refresh' && !tokenOverride) {
const newToken = await tryRefreshToken();
if (newToken) {
// 用新 token 重试
return makeRequest(newToken);
}
// 刷新失败,清除状态,跳转登录页
clearToken();
// 触发页面重新加载以显示登录页
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
return {
error: '认证已过期,请重新登录',
status: 401,
};
}
if (!response.ok) {
const data = await response.json().catch(() => null);
if (!response.ok) {
return {
error: data?.error || `请求失败 (${response.status})`,
status: response.status,
};
}
return { data: data as T, status: response.status };
} catch (err) {
return {
error: data?.error || `请求失败 (${response.status})`,
status: response.status,
error: err instanceof Error ? err.message : '网络错误',
status: 0,
};
}
};
return { data: data as T, status: response.status };
} catch (err) {
return {
error: err instanceof Error ? err.message : '网络错误',
status: 0,
};
}
return makeRequest();
}
/** 存储认证令牌 */
export function setToken(token: string) {
localStorage.setItem('token', token);
}
/** 获取认证令牌 */
export function getToken(): string | null {
return localStorage.getItem('token');
}
/** 清除认证令牌 */
export function clearToken() {
localStorage.removeItem('token');
localStorage.removeItem('user_id');
}
/** 检查是否已认证 */
export function isAuthenticated(): boolean {
return !!getToken();
}
// ========== 认证API ==========
// ========== 认证 API ==========
export async function login(username: string, password: string): Promise<ApiResponse<AuthResponse>> {
const resp = await request<AuthResponse>('/auth/login', {
@@ -92,8 +237,8 @@ export async function login(username: string, password: string): Promise<ApiResp
auth: false,
});
if (resp.data?.token) {
setToken(resp.data.token);
localStorage.setItem('user_id', resp.data.user_id);
setTokens(resp.data.token, resp.data.refresh_token);
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
}
return resp;
}
@@ -105,8 +250,8 @@ export async function register(username: string, password: string, email: string
auth: false,
});
if (resp.data?.token) {
setToken(resp.data.token);
localStorage.setItem('user_id', resp.data.user_id);
setTokens(resp.data.token, resp.data.refresh_token);
localStorage.setItem(LS_USER_ID_KEY, resp.data.user_id);
}
return resp;
}
@@ -114,12 +259,12 @@ export async function register(username: string, password: string, email: string
export async function refreshToken(): Promise<ApiResponse<AuthResponse>> {
const resp = await request<AuthResponse>('/auth/refresh', { method: 'POST' });
if (resp.data?.token) {
setToken(resp.data.token);
setTokens(resp.data.token, resp.data.refresh_token);
}
return resp;
}
// ========== 会话API ==========
// ========== 会话 API ==========
export async function createSession(title?: string) {
return request('/sessions', { method: 'POST', body: { title } });
@@ -141,7 +286,7 @@ export async function fetchSessionMessages(id: string) {
return request(`/sessions/${id}/messages`);
}
// ========== 记忆API ==========
// ========== 记忆 API ==========
export async function searchMemory(query: string) {
return request(`/memory/search?q=${encodeURIComponent(query)}`);
@@ -1,4 +1,5 @@
import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { MessageList } from './MessageList';
import { IoTStatusBar } from './IoTStatusBar';
@@ -43,7 +44,18 @@ export function ChatContainer() {
{/* 消息列表 */}
<div className="flex flex-col flex-1 min-h-0">
<MessageList messages={messages} isTyping={isTyping} />
<MessageList
messages={messages}
isTyping={isTyping}
hasMoreMessages={useChatStore((s) => s.hasMoreMessages)}
isLoadingHistory={useChatStore((s) => s.isLoadingHistory)}
onLoadMore={() => {
const sessionId = useSessionStore.getState().currentSessionId;
if (sessionId) {
useSessionStore.getState().loadMoreMessagesFromServer(sessionId);
}
}}
/>
</div>
{/* IoT 状态栏(底部) */}
@@ -2,17 +2,18 @@ import { useState, useEffect, useRef, useCallback } from 'react';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import { useAuthStore } from '@/store/authStore';
import { useSpeechSynthesis } from '@/hooks/useSpeechSynthesis';
import type { MessageAttachment, MultiMessageItem, StreamSegment } from '@/types/chat';
import type { MessageAttachment, MultiMessageItem, StreamSegment, MessageDisplayType } from '@/types/chat';
import { ImageLightbox } from './ImageLightbox';
interface MessageBubbleProps {
role: 'user' | 'assistant' | 'system';
role: 'user' | 'assistant' | 'system' | 'action';
content: string;
timestamp: number;
isStreaming?: boolean;
attachments?: MessageAttachment[];
multiMessages?: MultiMessageItem[];
streamSegments?: StreamSegment[];
msgType?: MessageDisplayType;
}
/**
@@ -139,8 +140,24 @@ function AIMessageActions({ content }: { content: string }) {
);
}
export function MessageBubble({ role, content, timestamp, isStreaming, attachments, multiMessages, streamSegments }: MessageBubbleProps) {
export function MessageBubble({
role,
content,
timestamp,
isStreaming,
attachments,
multiMessages,
streamSegments,
msgType,
}: MessageBubbleProps) {
const isUser = role === 'user';
const isAction = role === 'action' || msgType === 'action';
// 动作消息使用独立的渲染方式
if (isAction) {
return <ActionMessageBubble content={content} timestamp={timestamp} />;
}
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
@@ -263,6 +280,27 @@ export function MessageBubble({ role, content, timestamp, isStreaming, attachmen
);
}
/** 动作消息气泡 — 灰色/斜体/居中,视觉上与聊天消息区分 */
function ActionMessageBubble({ content, timestamp }: { content: string; timestamp: number }) {
const time = new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
return (
<div className="flex justify-center px-4 py-1 animate-fadeIn">
<div className="max-w-[70%] text-center">
<p className="text-xs text-gray-400 dark:text-gray-500 italic leading-relaxed whitespace-pre-wrap break-words">
<span className="select-none text-gray-300 dark:text-gray-600">~ </span>
{content}
<span className="select-none text-gray-300 dark:text-gray-600"> ~</span>
</p>
<p className="text-[10px] text-gray-300 dark:text-gray-600 mt-0.5">{time}</p>
</div>
</div>
);
}
/** 图片缩略图组件 */
function ImageThumbnail({
attachment,
@@ -6,17 +6,29 @@ import type { Message } from '@/types/chat';
interface MessageListProps {
messages: Message[];
isTyping: boolean;
hasMoreMessages?: boolean;
isLoadingHistory?: boolean;
onLoadMore?: () => void;
}
export function MessageList({ messages, isTyping }: MessageListProps) {
export function MessageList({
messages,
isTyping,
hasMoreMessages = false,
isLoadingHistory = false,
onLoadMore,
}: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// 自动滚动到底部
// 自动滚动到底部(仅在消息追加时,不在加载历史时)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, isTyping]);
if (!isLoadingHistory) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [messages, isTyping, isLoadingHistory]);
if (messages.length === 0) {
if (messages.length === 0 && !isLoadingHistory) {
return (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8">
<p className="text-sm text-gray-400 dark:text-gray-500">
@@ -27,7 +39,33 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
}
return (
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900">
<div
ref={containerRef}
className="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-pink-200 dark:scrollbar-thumb-pink-900"
>
{/* 加载更多历史消息按钮 */}
{hasMoreMessages && (
<div className="flex justify-center py-3">
<button
onClick={onLoadMore}
disabled={isLoadingHistory}
className="text-xs text-gray-400 hover:text-pink-500 disabled:text-gray-300 disabled:cursor-not-allowed px-4 py-1.5 rounded-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 hover:border-pink-200 dark:hover:border-pink-800 transition-all"
>
{isLoadingHistory ? (
<span className="flex items-center gap-1.5">
<svg className="animate-spin h-3 w-3" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
...
</span>
) : (
'加载更早的消息'
)}
</button>
</div>
)}
{messages.map((msg) => (
<MessageBubble
key={msg.id}
@@ -36,8 +74,7 @@ export function MessageList({ messages, isTyping }: MessageListProps) {
timestamp={msg.timestamp}
isStreaming={msg.isStreaming}
attachments={msg.attachments}
multiMessages={(msg as any).multiMessages}
streamSegments={(msg as any).streamSegments}
msgType={msg.msgType}
/>
))}
{isTyping && !messages.some((m) => m.isStreaming) && <TypingIndicator />}
+32 -2
View File
@@ -1,6 +1,36 @@
import { useAuthStore } from '@/store/authStore';
import { useEffect } from 'react';
import { useAuthStore, checkAndMigrateStore } from '@/store/authStore';
import { refreshToken, getToken } from '@/api/client';
/** 兼容旧代码的 Hook 导出 — 现在基于共享 Zustand store */
export function useAuth() {
return useAuthStore();
const store = useAuthStore();
// 应用启动时检查 localStorage 版本并尝试自动刷新 token
useEffect(() => {
// 检查 localStorage 数据版本兼容性
checkAndMigrateStore();
// 如果已登录,尝试验证 token 有效性
const token = getToken();
if (token) {
// 尝试刷新 token 来验证其有效性
refreshToken()
.then((resp) => {
if (resp.error || !resp.data?.token) {
// token 无效,清除状态
console.log('[useAuth] token 刷新失败,清除认证状态');
store.clearAuth();
}
// 刷新成功,token 已由 refreshToken 自动存储
})
.catch(() => {
// 网络错误等,不强制清除(可能是离线状态)
console.warn('[useAuth] token 验证网络错误,保留当前状态');
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在挂载时运行一次
return store;
}
+103 -6
View File
@@ -3,19 +3,42 @@ import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { useNotificationStore } from '@/store/notificationStore';
import { getToken } from '@/api/client';
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
import type { Message, WSClientMessage, WSServerMessage } from '@/types/chat';
const WS_BASE_URL =
import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws/chat';
// ========== 指数退避重连配置 ==========
const INITIAL_RECONNECT_DELAY_MS = 1000; // 初始延迟 1 秒
const MAX_RECONNECT_DELAY_MS = 30000; // 最大延迟 30 秒
const MAX_RECONNECT_ATTEMPTS = 10; // 最大重连次数
const RECONNECT_MULTIPLIER = 2; // 每次翻倍
/**
* 计算带 jitter 的指数退避延迟
* delay = min(initial * multiplier^attempt, maxDelay)
* jitter = delay * random(0.5, 1.0),避免惊群效应
*/
function getBackoffDelay(attempt: number): number {
const exponentialDelay = INITIAL_RECONNECT_DELAY_MS * Math.pow(RECONNECT_MULTIPLIER, attempt);
const cappedDelay = Math.min(exponentialDelay, MAX_RECONNECT_DELAY_MS);
// 添加 jitter:在 [delay/2, delay] 范围内随机
const jitter = cappedDelay * (0.5 + Math.random() * 0.5);
return Math.floor(jitter);
}
let wsInstanceCounter = 0;
export function useWebSocket() {
const [isConnected, setIsConnected] = useState(false);
const [reconnectAttempts, setReconnectAttempts] = useState(0);
const [maxReconnectAttempts] = useState(MAX_RECONNECT_ATTEMPTS);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const shouldReconnectRef = useRef(true);
const activeSessionRef = useRef<string | null>(null);
const reconnectCountRef = useRef(0);
const instanceIdRef = useRef(++wsInstanceCounter);
// 订阅 sessionStore 中的 currentSessionId 变化
@@ -29,6 +52,21 @@ export function useWebSocket() {
return;
}
// 检查是否超过最大重连次数
if (reconnectCountRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error(`[WS#${instanceId}] 已达到最大重连次数 (${MAX_RECONNECT_ATTEMPTS}),停止重连`);
setReconnectAttempts(reconnectCountRef.current);
// 通知用户连接失败
useChatStore.getState().setTyping(false);
useChatStore.getState().addMessage({
id: 'err_max_reconnect_' + Date.now(),
role: 'system',
content: `⚠️ WebSocket 连接失败,已尝试重连 ${MAX_RECONNECT_ATTEMPTS} 次,请刷新页面后重试`,
timestamp: Date.now(),
});
return;
}
// 关闭旧连接
if (wsRef.current) {
console.log(`[WS#${instanceId}] 关闭旧连接`);
@@ -42,12 +80,15 @@ export function useWebSocket() {
? `${WS_BASE_URL}?token=${token}&session_id=${sessionID}`
: `${WS_BASE_URL}?token=${token}`;
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}`);
console.log(`[WS#${instanceId}] 正在连接, session_id=${sessionID || '(无)'}, reconnectAttempt=${reconnectCountRef.current}`);
const ws = new WebSocket(url);
ws.onopen = () => {
setIsConnected(true);
shouldReconnectRef.current = true;
// 连接成功后重置退避计数器
reconnectCountRef.current = 0;
setReconnectAttempts(0);
console.log(`[WS#${instanceId}] 已连接, session_id:`, sessionID);
// 连接后发送会话恢复消息,恢复后端上下文
@@ -67,11 +108,17 @@ export function useWebSocket() {
setIsConnected(false);
console.log(`[WS#${instanceId}] 已断开`);
if (shouldReconnectRef.current) {
console.log(`[WS#${instanceId}] 3秒后重连...`);
const currentAttempt = reconnectCountRef.current;
const delay = getBackoffDelay(currentAttempt);
console.log(`[WS#${instanceId}] ${delay}ms 后重连 (第 ${currentAttempt + 1}/${MAX_RECONNECT_ATTEMPTS} 次)...`);
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
}
reconnectTimerRef.current = setTimeout(() => connect(), 3000);
reconnectTimerRef.current = setTimeout(() => {
reconnectCountRef.current += 1;
setReconnectAttempts(reconnectCountRef.current);
connect();
}, delay);
}
};
@@ -95,6 +142,10 @@ export function useWebSocket() {
useEffect(() => {
activeSessionRef.current = currentSessionId;
// 重置重连计数(切换会话时是新连接)
reconnectCountRef.current = 0;
setReconnectAttempts(0);
connect();
return () => {
@@ -137,7 +188,7 @@ export function useWebSocket() {
}
}, []);
return { isConnected, sendMessage };
return { isConnected, sendMessage, reconnectAttempts, maxReconnectAttempts };
}
function handleServerMessage(msg: WSServerMessage) {
@@ -200,7 +251,7 @@ function handleServerMessage(msg: WSServerMessage) {
);
break;
}
const msgsWithIds = msg.messages.map((m: any, i: number) => ({
const msgsWithIds: Message[] = msg.messages.map((m, i) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
}));
@@ -210,6 +261,52 @@ function handleServerMessage(msg: WSServerMessage) {
setTyping(false);
break;
case 'review':
// 审查子会话消息 — 后端返回带类型的 review_messages 列表
if (msg.review_messages && msg.review_messages.length > 0) {
// 逐条显示审查消息,action 类型使用动作消息样式,chat 类型使用普通聊天样式
for (const rm of msg.review_messages) {
addMessage({
id: `review_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
role: rm.type === 'action' ? 'action' : 'assistant',
content: rm.content,
timestamp: Date.now(),
isStreaming: false,
msgType: rm.type === 'action' ? 'action' : 'chat',
});
}
}
setTyping(false);
break;
case 'multi_message':
case 'stream_segments':
// 多段消息 / 流式片段 — 已通过 stream_chunk 处理,这里作为兜底
if (msg.multi_messages && msg.multi_messages.length > 0) {
for (const item of msg.multi_messages) {
addMessage({
id: `multi_${Date.now()}_${item.index}`,
role: 'assistant',
content: item.content,
timestamp: msg.timestamp || Date.now(),
isStreaming: false,
});
}
}
if (msg.stream_segments && msg.stream_segments.length > 0) {
for (const seg of msg.stream_segments) {
addMessage({
id: `seg_${Date.now()}_${seg.index}`,
role: 'assistant',
content: seg.text,
timestamp: msg.timestamp || Date.now(),
isStreaming: false,
});
}
}
setTyping(false);
break;
case 'device_update':
if (msg.devices && msg.devices.length > 0) {
chatState.setIoTDevices(msg.devices);
+16
View File
@@ -69,6 +69,22 @@
}
}
/* 动作消息淡入动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.4s ease-out;
}
/* ===== 流式渲染动画 ===== */
@keyframes fadeInUp {
from {
+72 -2
View File
@@ -3,24 +3,58 @@
* 用于跨组件共享登录/退出状态
*/
import { create } from 'zustand';
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken } from '@/api/client';
import { login as apiLogin, register as apiRegister, clearToken, isAuthenticated, getToken, getRefreshToken, setTokens } from '@/api/client';
/** localStorage key 前缀 */
const LS_VERSION_KEY = 'cyrene_store_version';
const CURRENT_VERSION = 1;
/** 所有 cyrene_ 前缀的 localStorage keyslogout 时全部清除 */
const CYRENE_KEYS = [
'token',
'refresh_token',
'user_id',
'user_nickname',
'cyrene_store_version',
];
function clearAllCyreneData(): void {
for (const key of CYRENE_KEYS) {
try {
localStorage.removeItem(key);
} catch { /* ignore */ }
}
}
interface AuthStore {
isLoggedIn: boolean;
userId: string | null;
token: string | null;
refreshToken: string | null;
loading: boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
register: (username: string, password: string, email: string, nickname: string, verifyCode: string) => Promise<{ success: boolean; error?: string }>;
logout: () => void;
setTokens: (accessToken: string, refreshToken?: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthStore>((set) => ({
isLoggedIn: isAuthenticated(),
userId: localStorage.getItem('user_id'),
token: getToken(),
refreshToken: getRefreshToken(),
loading: false,
setTokens: (accessToken: string, refreshTokenValue?: string) => {
setTokens(accessToken, refreshTokenValue);
set({
isLoggedIn: true,
token: accessToken,
refreshToken: refreshTokenValue || null,
});
},
login: async (username: string, password: string) => {
set({ loading: true });
try {
@@ -33,6 +67,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
refreshToken: resp.data?.refresh_token || null,
loading: false,
});
return { success: true };
@@ -58,6 +93,7 @@ export const useAuthStore = create<AuthStore>((set) => ({
isLoggedIn: true,
userId: resp.data?.user_id || null,
token: resp.data?.token || null,
refreshToken: resp.data?.refresh_token || null,
loading: false,
});
return { success: true };
@@ -69,16 +105,50 @@ export const useAuthStore = create<AuthStore>((set) => ({
logout: () => {
clearToken();
localStorage.removeItem('user_nickname');
clearAllCyreneData();
set({
isLoggedIn: false,
userId: null,
token: null,
refreshToken: null,
loading: false,
});
},
clearAuth: () => {
clearToken();
clearAllCyreneData();
set({
isLoggedIn: false,
userId: null,
token: null,
refreshToken: null,
loading: false,
});
},
}));
/**
* 在应用启动时检查 localStorage 数据版本
* 如果版本不兼容,清除所有数据
*/
export function checkAndMigrateStore(): void {
try {
const storedVersion = localStorage.getItem(LS_VERSION_KEY);
if (storedVersion) {
const version = parseInt(storedVersion, 10);
if (version !== CURRENT_VERSION) {
console.log(`[store] localStorage 版本不兼容 (${version}${CURRENT_VERSION}),清除旧数据`);
clearAllCyreneData();
}
}
// 写入当前版本
localStorage.setItem(LS_VERSION_KEY, String(CURRENT_VERSION));
} catch {
// 忽略存储错误
}
}
/** 兼容旧代码的 Hook 导出 */
export function useAuth() {
return useAuthStore();
+43 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand';
import type { Message } from '@/types/chat';
import type { Message, MessageDisplayType } from '@/types/chat';
import type { IoTDevice, BackgroundThinkingStatus } from '@/types/chat';
interface ChatStore {
@@ -16,6 +16,11 @@ interface ChatStore {
iotDevices: IoTDevice[];
iotDevicesLastUpdated: number | null;
// 历史消息分页
hasMoreMessages: boolean;
isLoadingHistory: boolean;
historyPage: number;
addMessage: (message: Message) => void;
appendToLastMessage: (content: string) => void;
finishStreaming: () => void;
@@ -26,6 +31,12 @@ interface ChatStore {
setContinuousMode: (enabled: boolean) => void;
setBackgroundThinkingStatus: (status: BackgroundThinkingStatus) => void;
setIoTDevices: (devices: IoTDevice[]) => void;
// 历史消息分页
setHasMoreMessages: (hasMore: boolean) => void;
setIsLoadingHistory: (loading: boolean) => void;
setHistoryPage: (page: number) => void;
prependMessages: (messages: Message[]) => void;
}
export const useChatStore = create<ChatStore>((set) => ({
@@ -35,6 +46,9 @@ export const useChatStore = create<ChatStore>((set) => ({
backgroundThinkingStatus: 'idle',
iotDevices: [],
iotDevicesLastUpdated: null,
hasMoreMessages: false,
isLoadingHistory: false,
historyPage: 1,
addMessage: (message) =>
set((state) => ({
@@ -77,7 +91,7 @@ export const useChatStore = create<ChatStore>((set) => ({
setTyping: (typing) => set({ isTyping: typing }),
clearMessages: () => set({ messages: [], isTyping: false }),
clearMessages: () => set({ messages: [], isTyping: false, hasMoreMessages: false, historyPage: 1 }),
setContinuousMode: (enabled) => set({ continuousMode: enabled }),
@@ -85,4 +99,31 @@ export const useChatStore = create<ChatStore>((set) => ({
setIoTDevices: (devices) =>
set({ iotDevices: devices, iotDevicesLastUpdated: Date.now() }),
setHasMoreMessages: (hasMore) => set({ hasMoreMessages: hasMore }),
setIsLoadingHistory: (loading) => set({ isLoadingHistory: loading }),
setHistoryPage: (page) => set({ historyPage: page }),
prependMessages: (olderMessages) =>
set((state) => ({
messages: [...olderMessages, ...state.messages],
})),
}));
// 辅助函数:根据 role 和 msgType 创建 Message 对象
export function createMessage(
id: string,
role: 'user' | 'assistant' | 'system' | 'action',
content: string,
timestamp: number,
opts?: { isStreaming?: boolean; msgType?: MessageDisplayType }
): Message {
return {
id,
role: role === 'action' ? 'action' : role,
content,
timestamp,
isStreaming: opts?.isStreaming ?? false,
msgType: opts?.msgType ?? (role === 'action' ? 'action' : 'chat'),
};
}
+93 -20
View File
@@ -26,6 +26,9 @@ export function isAdminUser(userId: string | null): boolean {
return userId === 'admin';
}
/** 每页加载的消息数量 */
const PAGE_SIZE = 50;
interface SessionStore {
sessions: Session[];
currentSessionId: string | null;
@@ -47,6 +50,7 @@ interface SessionStore {
// 服务端持久化操作
loadSessionsFromServer: (userId: string) => Promise<void>;
loadMessagesFromServer: (sessionId: string) => Promise<void>;
loadMoreMessagesFromServer: (sessionId: string) => Promise<void>;
clearMainSessionMessages: (sessionId: string) => Promise<boolean>;
deleteSessionAndRefresh: (id: string, userId: string) => Promise<void>;
deleteAllSessionsAndReset: (userId: string) => Promise<void>;
@@ -82,11 +86,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
},
setLoading: (loading) => set({ loading }),
setMessages: (messages) => {
// 仅在当前版本号未过期时设置消息
set((state) => {
// 使用 state 快照做防御性检查:_loadVersion 在 set 回调中是最新的
return { messages, loading: false };
});
set({ messages, loading: false });
useChatStore.getState().setMessages(messages);
},
clearMessages: () => {
@@ -110,22 +110,24 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
},
/**
* 从服务端加载指定会话的消息历史
* 从服务端加载指定会话的消息历史 (首次加载,第1页)
* 使用 _loadVersion 防止竞态条件:响应返回时如果版本号已变(用户切换到其他会话)则丢弃结果
*/
loadMessagesFromServer: async (sessionId: string) => {
// 记录请求发起时的版本号
const versionAtStart = get()._loadVersion;
const chatStore = useChatStore.getState();
chatStore.setIsLoadingHistory(true);
chatStore.setHistoryPage(1);
set({ loading: true });
try {
const resp = await fetchMessages(sessionId);
// 竞态条件检查:响应返回时版本号应未变,且当前会话仍为请求的会话
const resp = await fetchMessages(sessionId, PAGE_SIZE);
// 竞态条件检查
const currentState = get();
if (
currentState._loadVersion !== versionAtStart ||
currentState.currentSessionId !== sessionId
) {
// 用户已切换到其他会话,丢弃此过期响应
console.log(
'[sessionStore] 丢弃过期的 loadMessagesFromServer 响应:',
`sessionId=${sessionId}`,
@@ -134,18 +136,24 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
);
return;
}
const rawMessages = resp.messages || [];
const msgs: Message[] = rawMessages.map((m: any, i: number) => ({
id: m.id ? String(m.id) : `hist_${i}_${Date.now()}`,
role: m.role,
content: m.content,
timestamp: typeof m.created_at === 'number' ? m.created_at : Date.now(),
isStreaming: false,
}));
const msgs: Message[] = rawMessages.map((m, i: number) => {
const raw = m as unknown as Record<string, unknown>;
return {
id: raw.id ? String(raw.id) : `hist_${i}_${Date.now()}`,
role: (raw.role as Message['role']) || 'assistant',
content: typeof raw.content === 'string' ? raw.content : '',
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
isStreaming: false as const,
};
});
set({ messages: msgs, loading: false });
useChatStore.getState().setMessages(msgs);
chatStore.setMessages(msgs);
// 如果返回消息数量等于 PAGE_SIZE,说明可能还有更多
chatStore.setHasMoreMessages(rawMessages.length >= PAGE_SIZE);
} catch {
// 同样检查版本号,避免错误响应的空数组覆盖新会话的消息
const currentState = get();
if (
currentState._loadVersion !== versionAtStart ||
@@ -154,7 +162,72 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
return;
}
set({ messages: [], loading: false });
useChatStore.getState().clearMessages();
chatStore.clearMessages();
} finally {
useChatStore.getState().setIsLoadingHistory(false);
}
},
/**
* 加载更早的历史消息 (分页加载)
*/
loadMoreMessagesFromServer: async (sessionId: string) => {
const versionAtStart = get()._loadVersion;
const chatStore = useChatStore.getState();
if (chatStore.isLoadingHistory || !chatStore.hasMoreMessages) {
return;
}
chatStore.setIsLoadingHistory(true);
const nextPage = chatStore.historyPage + 1;
try {
const resp = await fetchMessages(sessionId, PAGE_SIZE);
// 竞态条件检查
const currentState = get();
if (
currentState._loadVersion !== versionAtStart ||
currentState.currentSessionId !== sessionId
) {
return;
}
const rawMessages = resp.messages || [];
// 服务端返回的是最新的消息,我们需要取比当前消息更旧的部分
// 由于后端当前不支持 offset/pagination,这里采用简单策略:
// 如果返回的条数与当前消息数不同,说明有新消息,重新加载
const currentMsgCount = chatStore.messages.length;
if (rawMessages.length > currentMsgCount) {
// 有新消息,取更早的
const olderMessages: Message[] = rawMessages
.slice(0, rawMessages.length - currentMsgCount)
.map((m, i: number) => {
const raw = m as unknown as Record<string, unknown>;
return {
id: raw.id ? String(raw.id) : `hist_old_${i}_${Date.now()}`,
role: (raw.role as Message['role']) || 'assistant',
content: typeof raw.content === 'string' ? raw.content : '',
timestamp: typeof raw.created_at === 'number' ? (raw.created_at as number) : Date.now(),
isStreaming: false as const,
};
});
if (olderMessages.length > 0) {
chatStore.prependMessages(olderMessages);
chatStore.setHistoryPage(nextPage);
}
chatStore.setHasMoreMessages(olderMessages.length >= PAGE_SIZE);
} else {
chatStore.setHasMoreMessages(false);
}
set({ loading: false });
} catch (err) {
console.error('[sessionStore] 加载更多消息失败:', err);
} finally {
useChatStore.getState().setIsLoadingHistory(false);
}
},
+14 -2
View File
@@ -1,7 +1,10 @@
// 聊天相关类型定义
/** 消息角色 */
export type MessageRole = 'user' | 'assistant' | 'system';
export type MessageRole = 'user' | 'assistant' | 'system' | 'action';
/** 消息显示类型 (区分聊天消息与动作消息) */
export type MessageDisplayType = 'chat' | 'action' | 'system';
/** 对话模式 */
export type ChatMode = 'text' | 'voice_msg' | 'voice_assistant';
@@ -29,6 +32,12 @@ export interface MessageAttachment {
description?: string; // AI 对图片的描述
}
/** 审查消息 (后端审查子会话输出的带类型消息) */
export interface ReviewMessage {
type: 'action' | 'chat';
content: string;
}
/** 单条消息 */
export interface Message {
id: string;
@@ -39,6 +48,8 @@ export interface Message {
attachments?: MessageAttachment[];
timestamp: number;
isStreaming?: boolean;
/** 消息显示类型: 区分聊天消息与动作消息 */
msgType?: MessageDisplayType;
}
/** IoT 设备类型定义 */
@@ -111,7 +122,7 @@ export interface AppNotification extends NotificationData {
/** WebSocket 服务端消息 */
export interface WSServerMessage {
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments';
type: 'response' | 'segment' | 'audio' | 'error' | 'device_update' | 'pong' | 'history_response' | 'stream_chunk' | 'stream_end' | 'background_thinking' | 'notification' | 'multi_message' | 'stream_segments' | 'review';
message_id?: string;
text?: string;
content?: string;
@@ -125,6 +136,7 @@ export interface WSServerMessage {
messages?: Message[];
multi_messages?: MultiMessageItem[];
stream_segments?: StreamSegment[];
review_messages?: ReviewMessage[];
devices?: IoTDevice[];
thinking_status?: BackgroundThinkingStatus;
notification?: NotificationData;
+3 -2
View File
@@ -22,9 +22,9 @@ export interface SessionListResponse {
sessions: Session[];
}
/** 会话消息列表响应 */
/** 会话消息列表响应 (API 返回的原始数据,字段名如 created_at 与服务端一致) */
export interface SessionMessagesResponse {
messages: import('@/types/chat').Message[];
messages: Record<string, unknown>[];
}
/** 单个会话响应 */
@@ -41,6 +41,7 @@ export interface SessionResponse {
export interface AuthResponse {
user_id: string;
token: string;
refresh_token?: string;
expires: number;
nickname?: string;
}