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:
@@ -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 keys,logout 时全部清除 */
|
||||
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();
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user