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
+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);
}
},