fix: 第二轮修复 — 数据库启动检查、会话持久化、URL路由、设备排序等

1. DevTools 启动前检查数据库状态,失败时自动尝试启动
2. ai-core 添加数据库断线重连机制 (30秒间隔)
3. Dashboard 添加数据库状态卡片 (启动/停止/重启)
4. Gateway 会话空闲超时管理 (30分钟标记空闲)
5. 会话/消息 PostgreSQL 持久化 (SessionStore + REST API)
6. 前端服务端会话持久化 + URL hash 路由 + 侧边栏管理
7. 管理员回到主对话按钮
8. IoT 设备卡片固定排序
9. 更新相关文档
This commit is contained in:
2026-05-17 17:18:02 +08:00
parent 745b1c6aad
commit e7b7eff0d8
21 changed files with 1735 additions and 284 deletions
+134 -2
View File
@@ -1,13 +1,37 @@
import { useState } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { AppLayout } from '@/components/layout/AppLayout';
import { ChatContainer } from '@/components/chat/ChatContainer';
import { ChatInput } from '@/components/chat/ChatInput';
import { useAuth } from '@/hooks/useAuth';
import { useChat } from '@/hooks/useChat';
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
import { useChatStore } from '@/store/chatStore';
import { fetchMessages } from '@/api/sessions';
/** URL Hash 工具 */
const SESSION_HASH_PREFIX = 'session=';
function getSessionIdFromHash(): string | null {
const hash = window.location.hash.slice(1); // 去掉 #
if (hash.startsWith(SESSION_HASH_PREFIX)) {
return hash.slice(SESSION_HASH_PREFIX.length);
}
return null;
}
function setHashSessionId(sessionId: string | null) {
if (sessionId) {
window.location.hash = SESSION_HASH_PREFIX + sessionId;
} else {
// 清除 hash
history.replaceState(null, '', window.location.pathname + window.location.search);
}
}
export default function App() {
const { isLoggedIn, login, register, loading: authLoading } = useAuth();
const { isLoggedIn, login, register, loading: authLoading, userId } = useAuth();
const { send } = useChat();
const { loadSessionsFromServer, ensureMainSession, setCurrentSessionId, setMessages, loadMessagesFromServer, sessions, currentSessionId } = useSessionStore();
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
@@ -17,6 +41,114 @@ export default function App() {
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const initializedRef = useRef(false);
// ========== URL Hash 路由 ==========
/** 根据 hash 恢复或选择初始会话 */
const initSession = useCallback(async () => {
if (!userId || initializedRef.current) return;
initializedRef.current = true;
const admin = isAdminUser(userId);
// 1. 从服务端加载会话列表
await loadSessionsFromServer(userId);
const currentSessions = useSessionStore.getState().sessions;
// 2. 检查 URL hash
const hashId = getSessionIdFromHash();
if (hashId) {
// 尝试加载 hash 指定的会话
const found = currentSessions.find((s) => s.id === hashId);
if (found) {
setCurrentSessionId(found.id);
await loadMessagesFromServer(found.id);
return;
}
// 会话可能已被删除,尝试从 API 获取消息(404 时 catch
try {
const resp = await fetchMessages(hashId);
if (resp.messages && resp.messages.length > 0) {
// 消息存在说明会话仍有效(虽然不在列表里,可能是刚创建的)
setCurrentSessionId(hashId);
const msgs = resp.messages.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,
}));
setMessages(msgs);
useChatStore.getState().setMessages(msgs);
return;
}
} catch {
// 会话不存在,回退
}
// 回退:清除 hash,加载最新/主对话
setHashSessionId(null);
}
// 3. 无 hash 或 hash 无效:加载最新会话
if (admin) {
// 管理员:确保主对话存在
const mainSession = await ensureMainSession(userId);
if (mainSession) {
setCurrentSessionId(mainSession.id);
setHashSessionId(mainSession.id);
await loadMessagesFromServer(mainSession.id);
return;
}
}
// 普通用户:选择最新会话
if (currentSessions.length > 0) {
const latest = currentSessions[0]; // 已按 updated_at DESC 排序
setCurrentSessionId(latest.id);
setHashSessionId(latest.id);
await loadMessagesFromServer(latest.id);
}
}, [userId, loadSessionsFromServer, ensureMainSession, setCurrentSessionId, setMessages, loadMessagesFromServer]);
// 登录后初始化
useEffect(() => {
if (isLoggedIn && userId) {
initSession();
}
}, [isLoggedIn, userId, initSession]);
// 监听 hashchange 事件 (浏览器前进/后退)
useEffect(() => {
if (!isLoggedIn) return;
const handleHashChange = async () => {
const hashId = getSessionIdFromHash();
const currentId = useSessionStore.getState().currentSessionId;
if (hashId && hashId !== currentId) {
// hash 变化,切换会话
setCurrentSessionId(hashId);
await loadMessagesFromServer(hashId);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [isLoggedIn, setCurrentSessionId, loadMessagesFromServer]);
// 当前会话变化时更新 URL hash(仅在登录后、非 hashchange 驱动时)
useEffect(() => {
if (!isLoggedIn || !currentSessionId) return;
const hashId = getSessionIdFromHash();
if (hashId !== currentSessionId) {
setHashSessionId(currentSessionId);
}
}, [isLoggedIn, currentSessionId]);
// ========== 认证相关 ==========
const handleLogin = async () => {
setError('');
const result = await login(username, password);
+110 -8
View File
@@ -1,8 +1,110 @@
// 会话API
export {
createSession,
listSessions,
getSession,
deleteSession,
fetchSessionMessages,
} from './client';
// 会话持久化 API — 对接 Gateway REST API
import { request } from './client';
import type { Session, SessionListResponse, SessionMessagesResponse, SessionResponse } from '@/types/session';
/**
* 获取用户的会话列表
* GET /api/v1/sessions?user_id={userId}
*/
export async function fetchSessions(userId: string): Promise<Session[]> {
const resp = await request<SessionListResponse>(
`/sessions?user_id=${encodeURIComponent(userId)}`
);
if (resp.error) {
console.error('[sessions] 获取会话列表失败:', resp.error);
return [];
}
// Gateway 返回 { sessions: [...] }
return (resp.data as SessionListResponse)?.sessions || [];
}
/**
* 获取会话的消息历史
* GET /api/v1/sessions/{sessionId}/messages?limit={limit}
*/
export async function fetchMessages(
sessionId: string,
limit: number = 50
): Promise<SessionMessagesResponse> {
const resp = await request<SessionMessagesResponse>(
`/sessions/${encodeURIComponent(sessionId)}/messages?limit=${limit}`
);
if (resp.error) {
console.error('[sessions] 获取消息失败:', resp.error);
return { messages: [] };
}
return (resp.data as SessionMessagesResponse) || { messages: [] };
}
/**
* 创建新会话
* POST /api/v1/sessions
*/
export async function createSession(
userId: string,
sessionId: string,
title: string = '新的对话',
isMain: boolean = false
): Promise<SessionResponse | null> {
const resp = await request<SessionResponse>('/sessions', {
method: 'POST',
body: {
user_id: userId,
session_id: sessionId,
title,
is_main: isMain,
},
});
if (resp.error) {
console.error('[sessions] 创建会话失败:', resp.error);
return null;
}
return resp.data as SessionResponse;
}
/**
* 删除单个会话 (不删除记忆)
* DELETE /api/v1/sessions/{sessionId}
*/
export async function deleteSession(sessionId: string): Promise<boolean> {
const resp = await request(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
if (resp.error) {
console.error('[sessions] 删除会话失败:', resp.error);
return false;
}
return true;
}
/**
* 删除用户所有会话 (不删除记忆)
* DELETE /api/v1/sessions?user_id={userId}
*/
export async function deleteAllSessions(userId: string): Promise<boolean> {
const resp = await request(`/sessions?user_id=${encodeURIComponent(userId)}`, {
method: 'DELETE',
});
if (resp.error) {
console.error('[sessions] 删除所有会话失败:', resp.error);
return false;
}
return true;
}
/**
* 清空会话消息但不删除会话本身
* DELETE /api/v1/sessions/{sessionId}/messages
*/
export async function clearSessionMessages(sessionId: string): Promise<boolean> {
const resp = await request(
`/sessions/${encodeURIComponent(sessionId)}/messages`,
{ method: 'DELETE' }
);
if (resp.error) {
console.error('[sessions] 清空消息失败:', resp.error);
return false;
}
return true;
}
+180 -42
View File
@@ -1,6 +1,8 @@
import { useState, useCallback } from 'react';
import { useSession } from '@/hooks/useSession';
import { useSessionStore } from '@/store/sessionStore';
import { CyreneAvatar } from '@/components/persona/CyreneAvatar';
import type { Session } from '@/types/session';
interface SidebarProps {
onClose?: () => void;
@@ -11,12 +13,25 @@ export function Sidebar({ onClose }: SidebarProps) {
sessions,
createSession,
deleteSession,
deleteAllSessions,
clearMainSession,
setCurrentSession,
isAdmin,
} = useSession();
const currentSessionId = useSessionStore((s) => s.currentSessionId);
const displaySessions = sessions;
const activeSessionId = currentSessionId;
// 确认弹窗状态
const [confirmAction, setConfirmAction] = useState<{
type: 'delete' | 'clearMain' | 'deleteAll';
sessionId?: string;
} | null>(null);
// 按 updated_at 降序排列
const displaySessions = [...sessions].sort((a, b) => {
const ta = typeof a.updated_at === 'string' ? parseInt(a.updated_at, 10) : (a.updated_at as unknown as number);
const tb = typeof b.updated_at === 'string' ? parseInt(b.updated_at, 10) : (b.updated_at as unknown as number);
return (tb || 0) - (ta || 0);
});
const handleNewChat = async () => {
const session = await createSession();
@@ -28,11 +43,43 @@ export function Sidebar({ onClose }: SidebarProps) {
if (onClose) onClose();
};
const handleDeleteSession = (e: { stopPropagation: () => void }, id: string) => {
e.stopPropagation();
deleteSession(id);
const handleMainSession = async () => {
// 找到主对话
const mainSession = displaySessions.find((s) => s.is_main);
if (mainSession) {
setCurrentSession(mainSession.id);
if (onClose) onClose();
}
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const session = displaySessions.find((s) => s.id === id);
// 管理员主对话不可删除
if (isAdmin && session?.is_main) return;
setConfirmAction({ type: 'delete', sessionId: id });
};
const handleConfirmAction = async () => {
if (!confirmAction) return;
switch (confirmAction.type) {
case 'delete':
if (confirmAction.sessionId) {
await deleteSession(confirmAction.sessionId);
}
break;
case 'clearMain':
await clearMainSession();
break;
case 'deleteAll':
await deleteAllSessions();
break;
}
setConfirmAction(null);
};
const cancelConfirm = () => setConfirmAction(null);
/** 格式化时间戳为可读字符串 */
const formatTime = (ts: string | number): string => {
if (!ts) return '';
@@ -52,7 +99,28 @@ export function Sidebar({ onClose }: SidebarProps) {
return (
<aside className="h-full bg-white/90 dark:bg-gray-900/90 border-r border-pink-100 dark:border-pink-900 flex flex-col">
{/* 侧边栏头部 */}
{/* 主对话按钮 (仅管理员可见) */}
{isAdmin && (
<div className="p-3 border-b border-pink-100 dark:border-pink-900 flex gap-2">
<button
onClick={handleMainSession}
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-amber-50 hover:bg-amber-100 dark:bg-amber-900/20 dark:hover:bg-amber-900/30 text-amber-600 dark:text-amber-400 rounded-xl text-sm font-medium transition-colors border border-amber-200 dark:border-amber-800"
title="回到主对话"
>
<span>🏠</span>
<span></span>
</button>
<button
onClick={() => setConfirmAction({ type: 'clearMain' })}
className="flex items-center justify-center px-2 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-400 hover:text-red-500 rounded-xl text-sm transition-colors border border-red-200 dark:border-red-800"
title="清空主对话消息"
>
<span>🗑</span>
</button>
</div>
)}
{/* 新对话按钮 */}
<div className="p-4 border-b border-pink-100 dark:border-pink-900">
<button
onClick={handleNewChat}
@@ -70,46 +138,80 @@ export function Sidebar({ onClose }: SidebarProps) {
</p>
) : (
displaySessions.map((session) => (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
${
activeSessionId === session.id
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
}
`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<CyreneAvatar size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{session.title || '新的对话'}</p>
<p className="text-xs text-gray-400 truncate">
{session.message_count != null ? `${session.message_count} 条消息` : ''}
{session.updated_at ? ` · ${formatTime(session.updated_at)}` : ''}
</p>
</div>
</div>
<button
onClick={(e) => handleDeleteSession(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
displaySessions.map((session) => {
const isMainSession = session.is_main;
const isActive = currentSessionId === session.id;
// 管理员主对话不可删除
const canDelete = !(isAdmin && isMainSession);
return (
<div
key={session.id}
onClick={() => handleSelectSession(session.id)}
className={`
group flex items-center justify-between px-4 py-2.5 mx-2 rounded-lg cursor-pointer transition-colors
${
isActive
? 'bg-pink-50 dark:bg-pink-900/30 text-pink-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-600 dark:text-gray-300'
}
`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))
<div className="flex items-center gap-2 min-w-0 flex-1">
<CyreneAvatar size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{isMainSession ? '🏠 ' : ''}
{session.title || '新的对话'}
</p>
<p className="text-xs text-gray-400 truncate">
{session.message_count != null
? `${session.message_count} 条消息`
: ''}
{session.updated_at
? `${session.message_count != null ? ' · ' : ''}${formatTime(session.updated_at)}`
: ''}
</p>
</div>
</div>
{canDelete && (
<button
onClick={(e) => handleDeleteClick(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 text-gray-400 hover:text-red-400 transition-all"
title="删除会话"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)}
</div>
);
})
)}
</div>
{/* 底部信息 */}
<div className="p-4 border-t border-pink-100 dark:border-pink-900">
<div className="flex items-center gap-2 text-xs text-gray-400">
{/* 底部:一键清空所有对话 */}
<div className="p-3 border-t border-pink-100 dark:border-pink-900">
<button
onClick={() => setConfirmAction({ type: 'deleteAll' })}
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30 text-red-400 hover:text-red-500 rounded-xl text-xs font-medium transition-colors border border-red-200 dark:border-red-800"
>
<span>🗑</span>
<span></span>
</button>
<div className="flex items-center gap-2 mt-3 text-xs text-gray-400">
<CyreneAvatar size="sm" />
<div>
<p className="font-medium text-pink-400"> AI</p>
@@ -117,6 +219,42 @@ export function Sidebar({ onClose }: SidebarProps) {
</div>
</div>
</div>
{/* 确认弹窗 */}
{confirmAction && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-6 m-4 max-w-sm w-full border border-pink-100 dark:border-pink-700">
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-2">
{confirmAction.type === 'delete'
? '删除会话'
: confirmAction.type === 'clearMain'
? '清空主对话'
: '清空所有对话'}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
{confirmAction.type === 'delete'
? '确定要删除这个会话吗?此操作不会删除记忆。'
: confirmAction.type === 'clearMain'
? '确定要清空主对话的所有消息吗?此操作不会删除记忆。'
: '确定要删除所有对话吗?此操作不会删除记忆。管理员将回到主对话,普通用户进入新对话。'}
</p>
<div className="flex justify-end gap-3">
<button
onClick={cancelConfirm}
className="px-4 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleConfirmAction}
className="px-4 py-2 rounded-lg text-sm bg-red-400 hover:bg-red-500 text-white font-medium transition-colors"
>
</button>
</div>
</div>
</div>
)}
</aside>
);
}
+97 -46
View File
@@ -1,9 +1,24 @@
import { useCallback, useEffect } from 'react';
import { useSessionStore } from '@/store/sessionStore';
import { useSessionStore, isAdminUser } from '@/store/sessionStore';
import { useChatStore } from '@/store/chatStore';
import { listSessions as apiListSessions, createSession as apiCreateSession, deleteSession as apiDeleteSession } from '@/api/client';
import { createSession as apiCreateSession } from '@/api/sessions';
import type { Session } from '@/types/session';
/** 生成简易随机ID */
function randomID(n: number = 12): string {
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < n; i++) {
result += letters.charAt(Math.floor(Math.random() * letters.length));
}
return `session_${result}`;
}
/** 获取当前用户ID */
function getUserId(): string {
return localStorage.getItem('user_id') || '';
}
export function useSession() {
const {
sessions,
@@ -14,69 +29,105 @@ export function useSession() {
removeSession,
setCurrentSessionId,
setLoading,
loadSessionsFromServer,
loadMessagesFromServer,
clearMainSessionMessages,
deleteSessionAndRefresh,
deleteAllSessionsAndReset,
ensureMainSession,
} = useSessionStore();
const { clearMessages } = useChatStore();
const userId = getUserId();
const isAdmin = isAdminUser(userId);
/** 从服务端加载会话列表 */
const loadSessions = useCallback(async () => {
setLoading(true);
try {
const resp = await apiListSessions();
if (resp.data) {
const data = resp.data as { sessions: Session[] };
setSessions(data.sessions || []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [setSessions, setLoading]);
if (!userId) return;
await loadSessionsFromServer(userId);
}, [userId, loadSessionsFromServer]);
// 初始加载
useEffect(() => {
loadSessions();
}, [loadSessions]);
const createSession = useCallback(async (title?: string) => {
const resp = await apiCreateSession(title);
if (resp.data) {
const session = resp.data as Session;
addSession(session);
// setCurrentSessionId 内部已处理消息清空和历史加载,无需额外 clearMessages
await setCurrentSessionId(session.id);
return session;
}
return null;
}, [addSession, setCurrentSessionId]);
const deleteSession = useCallback(async (id: string) => {
await apiDeleteSession(id);
// 如果删除的是当前活跃会话,先切换到其他会话
if (currentSessionId === id) {
const remaining = useSessionStore.getState().sessions.filter((s: Session) => s.id !== id);
if (remaining.length > 0) {
// 切换到列表中的第一个会话
await setCurrentSessionId(remaining[0].id);
} else {
clearMessages();
setCurrentSessionId(null);
/** 创建新会话 (isMain 默认为 false) */
const createSession = useCallback(
async (title?: string, isMain: boolean = false) => {
const sid = randomID();
const created = await apiCreateSession(
userId,
sid,
title || '新的对话',
isMain
);
if (created) {
const newSession: Session = {
id: created.id,
user_id: created.user_id,
title: created.title,
is_main: created.is_main,
created_at: String(created.created_at),
updated_at: String(created.updated_at),
message_count: 0,
};
addSession(newSession);
setCurrentSessionId(newSession.id);
return newSession;
}
}
removeSession(id);
}, [removeSession, currentSessionId, clearMessages, setCurrentSessionId]);
return null;
},
[userId, addSession, setCurrentSessionId]
);
const setCurrentSession = useCallback((id: string) => {
// setCurrentSessionId 内部已处理消息清空和历史加载
setCurrentSessionId(id);
}, [setCurrentSessionId]);
/** 清空主对话消息 */
const clearMainSession = useCallback(async () => {
const mainSession = useSessionStore
.getState()
.sessions.find((s: Session) => s.is_main);
if (!mainSession) {
// 尝试确保主对话存在
const ensured = await ensureMainSession(userId);
if (!ensured) return false;
return clearMainSessionMessages(ensured.id);
}
return clearMainSessionMessages(mainSession.id);
}, [userId, clearMainSessionMessages, ensureMainSession]);
/** 删除单个会话 */
const deleteSession = useCallback(
async (id: string) => {
await deleteSessionAndRefresh(id, userId);
},
[userId, deleteSessionAndRefresh]
);
/** 删除所有会话 */
const deleteAllSessions = useCallback(async () => {
await deleteAllSessionsAndReset(userId);
}, [userId, deleteAllSessionsAndReset]);
/** 切换当前会话 */
const setCurrentSession = useCallback(
async (id: string) => {
setCurrentSessionId(id);
// 加载该会话的消息历史
await loadMessagesFromServer(id);
},
[setCurrentSessionId, loadMessagesFromServer]
);
return {
sessions,
currentSessionId,
loading,
isAdmin,
userId,
loadSessions,
createSession,
clearMainSession,
deleteSession,
deleteAllSessions,
setCurrentSession,
};
}
+50 -22
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback, useState } from 'react';
import { useChatStore } from '@/store/chatStore';
import { useSessionStore } from '@/store/sessionStore';
import { getToken } from '@/api/client';
import { fetchMessages } from '@/api/sessions';
import type { WSClientMessage, WSServerMessage } from '@/types/chat';
const WS_BASE_URL =
@@ -12,7 +13,8 @@ export function useWebSocket() {
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 activeSessionRef = useRef<string | null>(null);
const loadingRef = useRef(false); // 防止重复加载消息
// 订阅 sessionStore 中的 currentSessionId 变化
const currentSessionId = useSessionStore((s) => s.currentSessionId);
@@ -40,7 +42,7 @@ export function useWebSocket() {
shouldReconnectRef.current = true;
console.log('[WS] 已连接, session_id:', sessionID);
// 连接后发送会话恢复消息,恢复历史上下文
// 连接后发送会话恢复消息,恢复后端上下文
const sid = useSessionStore.getState().currentSessionId;
if (sid) {
const resumeMsg: WSClientMessage = {
@@ -81,10 +83,39 @@ export function useWebSocket() {
wsRef.current = ws;
}, []);
// 初始连接 + 会话切换时重连
// 会话切换时:先通过 REST API 加载历史消息,再建立 WS 连接
useEffect(() => {
activeSessionRef.current = currentSessionId;
connect();
const loadAndConnect = async () => {
// 如果是从 URL 恢复的 session,先加载历史消息
if (currentSessionId && !loadingRef.current) {
loadingRef.current = true;
try {
const resp = await fetchMessages(currentSessionId);
const rawMessages = resp.messages || [];
const msgs = 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,
}));
useSessionStore.getState().setMessages(msgs);
useChatStore.getState().setMessages(msgs);
} catch {
// 加载失败不影响后续连接
} finally {
loadingRef.current = false;
}
}
connect();
};
loadAndConnect();
return () => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
@@ -96,12 +127,13 @@ export function useWebSocket() {
const sendMessage = useCallback((msg: WSClientMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
// 自动附上 session_id
const sessionID = useSessionStore.getState().currentSessionId;
wsRef.current.send(JSON.stringify({
...msg,
session_id: msg.session_id || sessionID || undefined,
}));
wsRef.current.send(
JSON.stringify({
...msg,
session_id: msg.session_id || sessionID || undefined,
})
);
}
}, []);
@@ -109,7 +141,8 @@ export function useWebSocket() {
}
function handleServerMessage(msg: WSServerMessage) {
const { addMessage, appendToLastMessage, finishStreaming, setTyping } = useChatStore.getState();
const { addMessage, appendToLastMessage, finishStreaming, setTyping } =
useChatStore.getState();
const { setMessages } = useSessionStore.getState();
const chatState = useChatStore.getState();
@@ -128,21 +161,22 @@ function handleServerMessage(msg: WSServerMessage) {
case 'stream_chunk':
if (msg.content) {
// 首个 chunk 到达时创建消息并隐藏 typing indicator
const { messages } = useChatStore.getState();
const lastMsg = messages[messages.length - 1];
if (!lastMsg || lastMsg.role !== 'assistant' || !lastMsg.isStreaming) {
// 创建新的流式消息
if (
!lastMsg ||
lastMsg.role !== 'assistant' ||
!lastMsg.isStreaming
) {
addMessage({
id: msg.message_id || ('msg_' + Date.now()),
id: msg.message_id || 'msg_' + Date.now(),
role: 'assistant',
content: msg.content,
timestamp: msg.timestamp,
isStreaming: true,
});
setTyping(false); // 首个 chunk 到达,隐藏 typing 指示器
setTyping(false);
} else {
// 追加到现有流式消息
appendToLastMessage(msg.content);
}
}
@@ -154,28 +188,23 @@ function handleServerMessage(msg: WSServerMessage) {
case 'history_response':
if (msg.messages) {
// 确保每条消息都有 id
const msgsWithIds = msg.messages.map((m: any, i: number) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
}));
// 同步历史消息到两个 store
setMessages(msgsWithIds);
useChatStore.getState().setMessages(msgsWithIds);
}
// 确保历史加载后 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);
}
@@ -187,7 +216,6 @@ function handleServerMessage(msg: WSServerMessage) {
break;
case 'pong':
// 忽略心跳响应
break;
default:
+160 -32
View File
@@ -1,15 +1,38 @@
import { create } from 'zustand';
import type { Session } from '@/types/session';
import type { Message } from '@/types/chat';
import { fetchSessionMessages as apiFetchMessages } from '@/api/client';
import {
fetchSessions,
fetchMessages,
createSession as apiCreateSession,
deleteSession as apiDeleteSession,
deleteAllSessions as apiDeleteAllSessions,
clearSessionMessages as apiClearMessages,
} from '@/api/sessions';
import { useChatStore } from '@/store/chatStore';
/** 生成简易随机ID */
function randomID(n: number = 12): string {
const letters = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < n; i++) {
result += letters.charAt(Math.floor(Math.random() * letters.length));
}
return `session_${result}`;
}
/** 判断是否为管理员用户 (user_id 以 "admin_" 开头) */
export function isAdminUser(userId: string | null): boolean {
return userId?.startsWith('admin_') ?? false;
}
interface SessionStore {
sessions: Session[];
currentSessionId: string | null;
loading: boolean;
messages: Message[];
// 基础操作
setSessions: (sessions: Session[]) => void;
addSession: (session: Session) => void;
removeSession: (id: string) => void;
@@ -17,9 +40,17 @@ interface SessionStore {
setLoading: (loading: boolean) => void;
setMessages: (messages: Message[]) => void;
clearMessages: () => void;
// 服务端持久化操作
loadSessionsFromServer: (userId: string) => Promise<void>;
loadMessagesFromServer: (sessionId: string) => Promise<void>;
clearMainSessionMessages: (sessionId: string) => Promise<boolean>;
deleteSessionAndRefresh: (id: string, userId: string) => Promise<void>;
deleteAllSessionsAndReset: (userId: string) => Promise<void>;
ensureMainSession: (userId: string) => Promise<Session | null>;
}
export const useSessionStore = create<SessionStore>((set) => ({
export const useSessionStore = create<SessionStore>((set, get) => ({
sessions: [],
currentSessionId: null,
loading: false,
@@ -34,46 +65,143 @@ export const useSessionStore = create<SessionStore>((set) => ({
currentSessionId: state.currentSessionId === id ? null : state.currentSessionId,
messages: state.currentSessionId === id ? [] : state.messages,
})),
setCurrentSessionId: async (id) => {
// 立即清除旧消息,防止闪旧数据
set({ currentSessionId: id, messages: [], loading: true });
useChatStore.getState().clearMessages();
// 清除旧消息(同时清 chatStore)
if (id === null) {
set({ messages: [], loading: false });
return;
}
// 从后端加载历史消息
try {
const resp = await apiFetchMessages(id);
if (resp.data) {
const data = resp.data as { messages: Message[] };
const msgs = (data.messages || []).map((m: Message, i: number) => ({
...m,
id: m.id || `hist_${i}_${Date.now()}`,
}));
set({ messages: msgs, loading: false });
// 同步到 chatStore 以便 ChatContainer 渲染
useChatStore.getState().setMessages(msgs);
} else {
set({ messages: [], loading: false });
useChatStore.getState().clearMessages();
}
} catch {
set({ messages: [], loading: false });
setCurrentSessionId: (id) => {
set({ currentSessionId: id });
// 切换会话时清空旧消息,等待加载
if (id !== get().currentSessionId) {
set({ messages: [], loading: true });
useChatStore.getState().clearMessages();
}
},
setLoading: (loading) => set({ loading }),
setMessages: (messages) => {
set({ messages });
// 同步到 chatStore
useChatStore.getState().setMessages(messages);
},
clearMessages: () => {
set({ messages: [] });
useChatStore.getState().clearMessages();
},
// ========== 服务端持久化操作 ==========
/**
* 从服务端加载会话列表
*/
loadSessionsFromServer: async (userId: string) => {
set({ loading: true });
try {
const sessions = await fetchSessions(userId);
set({ sessions, loading: false });
} catch {
set({ loading: false });
}
},
/**
* 从服务端加载指定会话的消息历史
*/
loadMessagesFromServer: async (sessionId: string) => {
set({ loading: true });
try {
const resp = await fetchMessages(sessionId);
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,
}));
set({ messages: msgs, loading: false });
useChatStore.getState().setMessages(msgs);
} catch {
set({ messages: [], loading: false });
useChatStore.getState().clearMessages();
}
},
/**
* 清空主对话的消息但保留会话本身
*/
clearMainSessionMessages: async (sessionId: string) => {
const ok = await apiClearMessages(sessionId);
if (ok) {
set({ messages: [] });
useChatStore.getState().clearMessages();
}
return ok;
},
/**
* 删除会话并自动切换到下一个可用会话
*/
deleteSessionAndRefresh: async (id: string, userId: string) => {
const ok = await apiDeleteSession(id);
if (!ok) return;
const state = get();
const remaining = state.sessions.filter((s) => s.id !== id);
const wasCurrent = state.currentSessionId === id;
// 更新本地列表
set({ sessions: remaining });
if (wasCurrent) {
if (remaining.length > 0) {
// 切换到列表中的第一个会话
const nextId = remaining[0].id;
set({ currentSessionId: nextId });
await get().loadMessagesFromServer(nextId);
} else {
// 没有会话了:管理员回到主对话,普通用户创建新对话
set({ currentSessionId: null, messages: [] });
useChatStore.getState().clearMessages();
}
}
},
/**
* 删除所有会话并重置状态
*/
deleteAllSessionsAndReset: async (userId: string) => {
const ok = await apiDeleteAllSessions(userId);
if (!ok) return;
set({ sessions: [], currentSessionId: null, messages: [] });
useChatStore.getState().clearMessages();
},
/**
* 确保主对话存在(管理员用户)。若不存在则创建并返回。
*/
ensureMainSession: async (userId: string) => {
const state = get();
// 先检查本地列表
const existing = state.sessions.find((s) => s.is_main);
if (existing) return existing;
// 本地没有,尝试从服务端加载
await get().loadSessionsFromServer(userId);
const refreshed = get().sessions.find((s) => s.is_main);
if (refreshed) return refreshed;
// 服务端也没有,创建主对话
const sid = randomID();
const created = await apiCreateSession(userId, sid, '主对话', true);
if (created) {
const newSession: Session = {
id: created.id,
user_id: created.user_id,
title: created.title,
is_main: created.is_main,
created_at: String(created.created_at),
updated_at: String(created.updated_at),
message_count: 0,
};
set((s) => ({ sessions: [newSession, ...s.sessions] }));
return newSession;
}
return null;
},
}));
+13 -2
View File
@@ -5,15 +5,16 @@ export interface Session {
id: string;
user_id: string;
title: string;
is_main: boolean;
created_at: string;
updated_at: string;
message_count: number;
is_active: boolean;
message_count?: number;
}
/** 创建会话参数 */
export interface CreateSessionParams {
title?: string;
is_main?: boolean;
}
/** 会话列表响应 */
@@ -26,6 +27,16 @@ export interface SessionMessagesResponse {
messages: import('@/types/chat').Message[];
}
/** 单个会话响应 */
export interface SessionResponse {
id: string;
user_id: string;
title: string;
is_main: boolean;
created_at: string;
updated_at: string;
}
/** 认证相关 */
export interface AuthResponse {
user_id: string;