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:
@@ -5,6 +5,8 @@ export {
|
||||
refreshToken,
|
||||
setToken,
|
||||
getToken,
|
||||
getRefreshToken,
|
||||
setTokens,
|
||||
clearToken,
|
||||
isAuthenticated,
|
||||
} from './client';
|
||||
|
||||
+200
-55
@@ -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 />}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user