From 89d42b9e10ebb8453f214024685f3d3629448218 Mon Sep 17 00:00:00 2001 From: susie-laptop Date: Sun, 27 Apr 2025 19:52:26 -0400 Subject: [PATCH] [dev]resources & chat --- package-lock.json | 8 +- package.json | 2 +- src/App.jsx | 4 +- src/pages/Chat/ChatWindow.jsx | 154 +++++++++---- src/services/websocket.js | 395 +++++++++++++++++++++++++++++----- src/store/chat/chat.slice.js | 16 +- src/styles/style.scss | 22 ++ 7 files changed, 498 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61af9e7..c43e4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^2.6.0", "axios": "^1.8.1", - "bootstrap": "^5.3.3", + "bootstrap": "^5.3.5", "crypto-js": "^4.2.0", "lodash": "^4.17.21", "react": "^19.0.0", @@ -1656,9 +1656,9 @@ } }, "node_modules/bootstrap": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", - "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", + "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 448c853..02ef75d 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@popperjs/core": "^2.11.8", "@reduxjs/toolkit": "^2.6.0", "axios": "^1.8.1", - "bootstrap": "^5.3.3", + "bootstrap": "^5.3.5", "crypto-js": "^4.2.0", "lodash": "^4.17.21", "react": "^19.0.0", diff --git a/src/App.jsx b/src/App.jsx index 58515d9..f08b053 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,8 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import AppRouter from './router/router'; import { checkAuthThunk } from './store/auth/auth.thunk'; -import { login } from './store/auth/auth.slice'; -import { initWebSocket, closeWebSocket } from './services/websocket'; +import { initWebSocket, closeWebSocket, initChatWebSocket } from './services/websocket'; import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice'; function App() { @@ -24,6 +23,7 @@ function App() { if (!isConnected) { // 初始化WebSocket连接 initWebSocket(dispatch); + initChatWebSocket() } } else { if (isConnected) { diff --git a/src/pages/Chat/ChatWindow.jsx b/src/pages/Chat/ChatWindow.jsx index 010c274..78c5138 100644 --- a/src/pages/Chat/ChatWindow.jsx +++ b/src/pages/Chat/ChatWindow.jsx @@ -1,11 +1,12 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice'; +import { resetSendMessageStatus, addMessage, updateMessage } from '../../store/chat/chat.slice'; import { showNotification } from '../../store/notification.slice'; -import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks'; +import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail, getChatResources } from '../../store/chat/chat.thunks'; import SvgIcon from '../../components/SvgIcon'; import SafeMarkdown from '../../components/SafeMarkdown'; import ResourceList from '../../components/ResourceList'; +import { sendChatMessageViaWebSocket } from '../../services/websocket'; export default function ChatWindow({ chatId, knowledgeBaseId }) { const dispatch = useDispatch(); @@ -20,7 +21,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { const currentChat = chatList.find((chat) => chat.conversation_id === currentChatId); const messages = currentChat?.messages || []; const messageStatus = useSelector((state) => state.chat.list.messageStatus); - const messageError = useSelector((state) => state.chat.list.messageError); const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage); // 获取消息资源 @@ -29,7 +29,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 使用新的Redux状态结构 const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []); const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId); - const isLoadingKnowledgeBases = useSelector((state) => state.knowledgeBase.loading); // 获取可用数据集列表 const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []); @@ -63,14 +62,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 优先使用conversation中的知识库列表 if (conversation && conversation.datasets && conversation.datasets.length > 0) { const datasetIds = conversation.datasets.map((ds) => ds.id); - console.log('从会话中获取知识库列表:', datasetIds); setSelectedKnowledgeBaseIds(datasetIds); } // 其次使用URL中传入的知识库ID else if (knowledgeBaseId) { // 可能是单个ID或以逗号分隔的多个ID const ids = knowledgeBaseId.split(',').map((id) => id.trim()); - console.log('从URL参数中获取知识库列表:', ids); setSelectedKnowledgeBaseIds(ids); } }, [conversation, knowledgeBaseId]); @@ -81,7 +78,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 如果已经加载过这个chatId的详情,不再重复加载 if (hasLoadedDetailRef.current[chatId]) { - console.log('跳过已加载过的会话详情:', chatId); return; } @@ -90,18 +86,15 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 如果是新创建的会话且已经有会话数据,则跳过详情获取 if (isNewlyCreatedChat && conversation && conversation.conversation_id === chatId) { - console.log('跳过新创建会话的详情获取:', chatId); hasLoadedDetailRef.current[chatId] = true; return; } - console.log('获取会话详情:', chatId); setLoading(true); dispatch(fetchConversationDetail(chatId)) .unwrap() .then((response) => { - console.log('获取会话详情成功:', response); // 标记为已加载 hasLoadedDetailRef.current[chatId] = true; }) @@ -198,21 +191,17 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { if (selectedKnowledgeBaseIds.length > 0) { // 使用已保存的知识库列表 dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, '')); - console.log('使用组件状态中的知识库列表:', dataset_id_list); } else if (conversation && conversation.datasets && conversation.datasets.length > 0) { // 如果已有会话,使用会话中的知识库 dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, '')); - console.log('使用会话中的知识库列表:', dataset_id_list); } else if (knowledgeBaseId) { // 如果是新会话,使用当前选择的知识库 // 可能是单个ID或以逗号分隔的多个ID const ids = knowledgeBaseId.split(',').map((id) => id.trim().replace(/-/g, '')); dataset_id_list = ids; - console.log('使用URL参数中的知识库:', dataset_id_list); } else if (availableDatasets.length > 0) { // 如果都没有,尝试使用可用知识库列表中的第一个 dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')]; - console.log('使用可用知识库列表中的第一个:', dataset_id_list); } if (dataset_id_list.length === 0) { @@ -225,35 +214,125 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { return; } - console.log('发送消息参数:', { - dataset_id_list, + const requestBody = { + dataset_id_list: dataset_id_list, question: inputMessage, conversation_id: chatId, - }); + }; - // 发送消息到服务器 + console.log('发送消息参数:', requestBody); + + // 创建WebSocket测试的AI回复消息ID - 必须先初始化 + const wsMessageId = `ws-${Date.now()}`; + let wsMessageContent = ''; + + // 添加用户消息 + const userMessageId = `user-${Date.now()}`; dispatch( - createChatRecord({ - dataset_id_list: dataset_id_list, + addMessage({ + id: userMessageId, + role: 'user', + content: inputMessage, + created_at: new Date().toISOString(), + }) + ); + + // 添加一个空白的临时消息用于WebSocket流式回复 + dispatch( + addMessage({ + id: wsMessageId, + role: 'assistant', + content: '', + created_at: new Date().toISOString(), + is_streaming: true, + is_websocket: true, + }) + ); + + // 获取聊天资源 - 在用户消息发送后立即请求资源 + dispatch( + getChatResources({ + dataset_id_list, question: inputMessage, - conversation_id: chatId, + messageId: wsMessageId, // 将资源关联到助手消息 }) - ) - .unwrap() - .then((response) => { - // 成功发送后,可以执行任何需要的操作 - console.log('消息发送成功:', response); - }) - .catch((error) => { - // 发送失败,显示错误信息 - console.error('消息发送失败:', error); + ); + + sendChatMessageViaWebSocket(requestBody, (data) => { + try { + // 根据消息类型处理不同的响应 + // 处理部分内容 + if (data.message === 'partial') { + const newText = data.data.content || ''; + wsMessageContent += newText; + + // 更新消息内容 - 使用完全替换而不是追加,确保React检测到变化 + dispatch( + updateMessage({ + id: wsMessageId, + content: wsMessageContent, + is_streaming: true, + updated_at: new Date().toISOString(), // 添加时间戳强制更新 + }) + ); + + // 触发重新渲染 + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + } + + // 处理流结束 + if (data.message === '完成') { + console.log('WebSocket流式传输结束,最终内容:', data.data.content); + + // 使用完整内容替换消息 + dispatch( + updateMessage({ + id: wsMessageId, + content: data.data.content || wsMessageContent, + is_streaming: false, + updated_at: new Date().toISOString(), // 添加时间戳强制更新 + }) + ); + + // 触发重新渲染和滚动 + setTimeout(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, 50); + } + + // 处理错误 + if (data.code !== 200 && data.code !== 201) { + console.error('WebSocket错误:', data.message); + dispatch( + showNotification({ + message: `WebSocket错误: ${data.message}`, + type: 'warning', + }) + ); + + // 更新消息显示错误 + dispatch( + updateMessage({ + id: wsMessageId, + content: `Error: ${data.message}`, + is_streaming: false, + }) + ); + } + } catch (error) { + console.error('处理WebSocket响应失败:', error); + // 更新消息显示错误 dispatch( - showNotification({ - message: `发送失败: ${error}`, - type: 'danger', + updateMessage({ + id: wsMessageId, + content: `处理WebSocket响应失败: ${error.message}`, + is_streaming: false, }) ); - }); + } + }); // 清空输入框 setInputMessage(''); @@ -279,7 +358,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { ); }; - + return (
{/* Chat header */} @@ -335,11 +414,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { )} - - {/* 只在AI回复消息下方显示资源列表 */} + + {/* 只在AI回复消息下方显示资源列表,仅当有内容时显示 */} {message.role === 'assistant' && !message.is_streaming && resources.messageId === message.id && + resources.status === 'succeeded' && resources.items.length > 0 && ( )} diff --git a/src/services/websocket.js b/src/services/websocket.js index 9aa8e5a..21c2a0f 100644 --- a/src/services/websocket.js +++ b/src/services/websocket.js @@ -12,14 +12,21 @@ const secretKey = import.meta.env.VITE_SECRETKEY; const API_URL = import.meta.env.VITE_API_URL || 'http://81.69.223.133:8008'; // 将 HTTP URL 转换为 WebSocket URL const WS_BASE_URL = API_URL.replace(/^http/, 'ws').replace(/\/api\/?$/, ''); +const WS_CHAT_URL = 'ws://81.69.223.133:8008/ws/chat/stream/'; let socket = null; +let chatSocket = null; let reconnectTimer = null; let pingInterval = null; let reconnectAttempts = 0; // 添加重连尝试计数器 +let globalReconnectAttempts = 0; // 添加全局重连计数器 const RECONNECT_DELAY = 5000; // 5秒后尝试重连 const PING_INTERVAL = 30000; // 30秒发送一次ping const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数 +const MAX_GLOBAL_RECONNECT_ATTEMPTS = 3; // 单个会话中允许的总重连次数 + +// 添加的聊天消息回调处理 +let chatMessageCallback = null; /** * 初始化WebSocket连接 @@ -27,36 +34,63 @@ const MAX_RECONNECT_ATTEMPTS = 3; // 最大重连尝试次数 */ export const initWebSocket = () => { return new Promise((resolve, reject) => { + // 检查全局重连次数 + if (globalReconnectAttempts >= MAX_GLOBAL_RECONNECT_ATTEMPTS) { + console.warn(`已达到全局最大重连次数(${MAX_GLOBAL_RECONNECT_ATTEMPTS}),不再尝试重连`); + reject(new Error('Maximum global reconnection attempts reached')); + return; + } + // 如果已经有一个连接,先关闭它 if (socket && socket.readyState !== WebSocket.CLOSED) { - socket.close(); + console.log('关闭已有WebSocket连接'); + socket.close(1000, 'Normal closure, reconnecting'); } // 清除之前的定时器 - if (reconnectTimer) clearTimeout(reconnectTimer); - if (pingInterval) clearInterval(pingInterval); + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } try { // 从sessionStorage获取token const encryptedToken = sessionStorage.getItem('token'); - let token = ''; if (!encryptedToken) { console.error('No token found, cannot connect to notification service'); store.dispatch(setWebSocketConnected(false)); reject(new Error('No token found')); return; } - if (encryptedToken) { + + let token = ''; + try { token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8); + if (!token) { + throw new Error('Token decryption resulted in empty string'); + } + } catch (e) { + console.error('Failed to decrypt token:', e); + store.dispatch(setWebSocketConnected(false)); + reject(new Error('Invalid token')); + return; } - const wsUrl = `${WS_BASE_URL}/ws/notifications/?token=${token}`; - console.log('WebSocket URL:', wsUrl); + + const wsUrl = `${WS_BASE_URL}/ws/notifications?token=${token}`; + console.log('正在连接WebSocket...', wsUrl.substring(0, wsUrl.indexOf('?'))); + socket = new WebSocket(wsUrl); // 连接建立时的处理 socket.onopen = () => { - console.log('WebSocket connection established'); - reconnectAttempts = 0; // 连接成功后重置重连计数器 + console.log('WebSocket 连接成功!'); + reconnectAttempts = 0; // 连接成功后重置当前重连计数器 + // 不重置全局重连计数器,确保总重连次数限制 // 更新Redux中的连接状态 store.dispatch(setWebSocketConnected(true)); @@ -65,8 +99,9 @@ export const initWebSocket = () => { subscribeToNotifications(); // 设置定时发送ping消息 + if (pingInterval) clearInterval(pingInterval); pingInterval = setInterval(() => { - if (socket.readyState === WebSocket.OPEN) { + if (socket && socket.readyState === WebSocket.OPEN) { sendPing(); } }, PING_INTERVAL); @@ -80,47 +115,68 @@ export const initWebSocket = () => { const data = JSON.parse(event.data); handleWebSocketMessage(data); } catch (error) { - console.error('Error parsing WebSocket message:', error); + console.error('解析WebSocket消息失败:', error, 'Raw message:', event.data); } }; // 错误处理 socket.onerror = (error) => { - console.error('WebSocket error:', error); - // 更新Redux中的连接状态 - store.dispatch(setWebSocketConnected(false)); - reject(error); + console.error('WebSocket连接错误:', error); + // 不立即更新Redux状态,让onclose处理 }; // 连接关闭时的处理 socket.onclose = (event) => { - console.log(`WebSocket connection closed: ${event.code} ${event.reason}`); + console.log( + `WebSocket连接关闭: 代码=${event.code} 原因="${event.reason || '未知'}" 是否干净=${event.wasClean}` + ); + + // 清除ping定时器 + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } // 更新Redux中的连接状态 store.dispatch(setWebSocketConnected(false)); - // 清除ping定时器 - if (pingInterval) clearInterval(pingInterval); - // 如果不是正常关闭,尝试重连 - if (event.code !== 1000) { - if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { + if (!event.wasClean && event.code !== 1000 && event.code !== 1001) { + // 检查是否已达到最大重连次数 + if ( + reconnectAttempts < MAX_RECONNECT_ATTEMPTS && + globalReconnectAttempts < MAX_GLOBAL_RECONNECT_ATTEMPTS + ) { reconnectAttempts++; - console.log(`WebSocket reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`); + globalReconnectAttempts++; + + const delay = Math.min(RECONNECT_DELAY * reconnectAttempts, 15000); // 指数退避,但最大15秒 + console.log( + `WebSocket将在${ + delay / 1000 + }秒后尝试重连 (第${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}次, 总计${globalReconnectAttempts}/${MAX_GLOBAL_RECONNECT_ATTEMPTS}次)` + ); reconnectTimer = setTimeout(() => { - console.log('Attempting to reconnect WebSocket...'); - initWebSocket().catch((err) => { - console.error('Failed to reconnect WebSocket:', err); - // 重连失败时更新Redux中的连接状态 - store.dispatch(setWebSocketConnected(false)); - }); - }, RECONNECT_DELAY); + console.log('正在尝试重新连接WebSocket...'); + initWebSocket() + .then(() => { + console.log('WebSocket重连成功'); + }) + .catch((err) => { + console.error('WebSocket重连失败:', err); + // 重连失败时更新Redux中的连接状态 + store.dispatch(setWebSocketConnected(false)); + }); + }, delay); } else { - console.log('Maximum reconnection attempts reached. Giving up.'); + const msg = `已达到最大重连次数 (当前${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}, 总计${globalReconnectAttempts}/${MAX_GLOBAL_RECONNECT_ATTEMPTS})`; + console.warn(msg); // 达到最大重连次数时更新Redux中的连接状态 store.dispatch(setWebSocketConnected(false)); } + } else { + console.log('正常关闭,不会尝试重连'); } }; } catch (error) { @@ -150,10 +206,16 @@ export const subscribeToNotifications = () => { */ export const sendPing = () => { if (socket && socket.readyState === WebSocket.OPEN) { - const pingMessage = { - type: 'ping', - }; - socket.send(JSON.stringify(pingMessage)); + try { + const pingMessage = { + type: 'ping', + timestamp: new Date().toISOString(), + }; + socket.send(JSON.stringify(pingMessage)); + console.debug('已发送 ping 消息'); + } catch (error) { + console.error('发送 ping 消息失败:', error); + } } }; @@ -176,53 +238,91 @@ export const acknowledgeNotification = (notificationId) => { /** * 关闭WebSocket连接 + * @param {boolean} [permanent=false] 是否永久关闭不再重连 */ -export const closeWebSocket = () => { - if (socket) { - socket.close(1000, 'Normal closure'); - socket = null; - } - +export const closeWebSocket = (permanent = false) => { + // 停止重连尝试 if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + // 停止ping if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } + // 如果是永久关闭,重置重连计数器 + if (permanent) { + globalReconnectAttempts = MAX_GLOBAL_RECONNECT_ATTEMPTS; // 设置为最大值阻止重连 + } + + // 关闭连接 + if (socket) { + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + console.log(`手动关闭WebSocket连接${permanent ? '(永久)' : ''}`); + socket.close(1000, '用户主动关闭'); + } + socket = null; + } + // 更新Redux中的连接状态 store.dispatch(setWebSocketConnected(false)); }; +/** + * 重置WebSocket连接状态,允许重新尝试连接 + */ +export const resetWebSocketState = () => { + closeWebSocket(true); // 先关闭当前连接 + + // 重置所有计数器和状态 + reconnectAttempts = 0; + globalReconnectAttempts = 0; + + console.log('WebSocket连接状态已重置,可以重新尝试连接'); +}; + /** * 处理接收到的WebSocket消息 * @param {Object} data 解析后的消息数据 */ const handleWebSocketMessage = (data) => { - switch (data.type) { - case 'connection_established': - console.log(`Connection established for user: ${data.user_id}`); - break; + if (!data || typeof data !== 'object') { + console.warn('收到无效的WebSocket消息:', data); + return; + } - case 'notification': - console.log('Received notification:', data); - // 将通知添加到Redux store - store.dispatch(addNotification(processNotification(data))); - break; + try { + switch (data.type) { + case 'connection_established': + console.log(`WebSocket连接已建立,用户ID: ${data.user_id}`); + break; - case 'pong': - console.log(`Received pong at ${data.timestamp}`); - break; + case 'notification': + if (!data.data) { + console.warn('收到无效的通知数据:', data); + return; + } + console.log('收到新通知:', data.data.title); + // 将通知添加到Redux store + store.dispatch(addNotification(processNotification(data))); + break; - case 'error': - console.error(`WebSocket error: ${data.code} - ${data.message}`); - break; + case 'pong': + console.debug(`收到pong响应,时间戳: ${data.timestamp}`); + break; - default: - console.log('Received unknown message type:', data); + case 'error': + console.error(`WebSocket错误: ${data.code} - ${data.message}`); + break; + + default: + console.log('收到未知类型的消息:', data.type, data); + } + } catch (error) { + console.error('处理WebSocket消息时发生错误:', error, 'Message:', data); } }; @@ -257,7 +357,7 @@ const processNotification = (data) => { } else { timeDisplay = `${diffDays}天前`; } - + return { id: notificationData.id, type: notificationData.category, @@ -271,3 +371,182 @@ const processNotification = (data) => { metadata: notificationData.metadata || {}, }; }; + +/** + * 初始化聊天WebSocket连接 + * @returns {Promise} WebSocket连接实例 + */ +export const initChatWebSocket = () => { + return new Promise((resolve, reject) => { + // 如果已经有一个连接,先关闭它 + if (chatSocket && chatSocket.readyState !== WebSocket.CLOSED) { + console.log('关闭已有Chat WebSocket连接'); + chatSocket.close(1000, 'Normal closure, reconnecting'); + } + + try { + // 从sessionStorage获取token + const encryptedToken = sessionStorage.getItem('token'); + if (!encryptedToken) { + console.error('No token found, cannot connect to chat service'); + reject(new Error('No token found')); + return; + } + + let token = ''; + try { + token = CryptoJS.AES.decrypt(encryptedToken, secretKey).toString(CryptoJS.enc.Utf8); + if (!token) { + throw new Error('Token decryption resulted in empty string'); + } + } catch (e) { + console.error('Failed to decrypt token:', e); + reject(new Error('Invalid token')); + return; + } + + // 构建WebSocket URL + const wsUrl = `${WS_CHAT_URL}?token=${token}`; + console.log('正在连接Chat WebSocket...', wsUrl.substring(0, wsUrl.indexOf('?'))); + + // 创建WebSocket连接 + chatSocket = new WebSocket(wsUrl); + + // 设置超时 + const connectionTimeout = setTimeout(() => { + if (chatSocket.readyState !== WebSocket.OPEN) { + console.error('Chat WebSocket连接超时'); + chatSocket.close(); + reject(new Error('Connection timeout')); + } + }, 10000); // 10秒超时 + + // 连接建立时的处理 + chatSocket.onopen = () => { + console.log('Chat WebSocket 连接成功!'); + clearTimeout(connectionTimeout); + resolve(chatSocket); + }; + + // 接收消息的处理 + chatSocket.onmessage = (event) => { + try { + // 解析消息并传递给回调 + const message = JSON.parse(event.data); + + if (chatMessageCallback) { + chatMessageCallback(message); + } + } catch (error) { + console.error('处理Chat WebSocket消息失败:', error, 'Raw message:', event.data); + } + }; + + // 错误处理 + chatSocket.onerror = (error) => { + console.error('Chat WebSocket连接错误:', error); + clearTimeout(connectionTimeout); + reject(error); + }; + + // 连接关闭时的处理 + chatSocket.onclose = (event) => { + console.log( + `Chat WebSocket连接关闭: 代码=${event.code} 原因="${event.reason || '未知'}" 是否干净=${ + event.wasClean + }` + ); + clearTimeout(connectionTimeout); + }; + } catch (error) { + console.error('Error initializing Chat WebSocket:', error); + reject(error); + } + }); +}; + +/** + * 通过WebSocket发送聊天消息 + * @param {Object} message 聊天消息 + * @param {Function} callback 接收消息的回调函数 + */ +export const sendChatMessageViaWebSocket = (message, callback) => { + // 保存回调函数 + chatMessageCallback = callback; + + // 确保WebSocket连接已建立 + if (!chatSocket || chatSocket.readyState !== WebSocket.OPEN) { + console.log('WebSocket未连接,正在初始化连接...'); + return initChatWebSocket() + .then(() => { + console.log('WebSocket连接已建立,发送消息:', message); + chatSocket.send(JSON.stringify(message)); + }) + .catch((error) => { + console.error('Chat WebSocket连接失败:', error); + // 如果连接失败,调用回调传递错误 + if (callback) { + callback({ + code: 500, + message: `WebSocket连接失败: ${error.message}`, + data: { + content: `连接失败: ${error.message}`, + is_end: true, + }, + }); + } + }); + } else { + // 如果连接已建立,直接发送消息 + console.log('WebSocket已连接,直接发送消息:', message); + chatSocket.send(JSON.stringify(message)); + } +}; + +/** + * 处理聊天WebSocket消息 + * 流式内容结构类似于: + * { + * "code": 200, + * "message": "开始流式传输" | "partial" | "完成", + * "data": { + * "content": "消息内容", + * "is_end": true/false + * } + * } + * @param {string} data 原始消息数据 + * @param {Function} callback 处理消息的回调函数 + */ +export const processChatWebSocketMessage = (data, callback) => { + try { + const message = JSON.parse(data); + + // 调用回调处理消息 + if (callback) { + callback(message); + } + + return message; + } catch (error) { + console.error('解析Chat WebSocket消息失败:', error); + } +}; + +/** + * 关闭聊天WebSocket连接 + */ +export const closeChatWebSocket = () => { + // 清除回调 + chatMessageCallback = null; + + // 关闭连接 + if (chatSocket) { + if (chatSocket.readyState === WebSocket.OPEN || chatSocket.readyState === WebSocket.CONNECTING) { + console.log('手动关闭Chat WebSocket连接'); + chatSocket.close(1000, '用户主动关闭'); + } + chatSocket = null; + } + + console.log('Chat WebSocket连接已关闭'); +}; diff --git a/src/store/chat/chat.slice.js b/src/store/chat/chat.slice.js index 793a02c..6d03350 100644 --- a/src/store/chat/chat.slice.js +++ b/src/store/chat/chat.slice.js @@ -148,7 +148,12 @@ const chatSlice = createSlice({ const messageIndex = state.list.items[chatIndex].messages.findIndex((msg) => msg.id === id); if (messageIndex !== -1) { - // 更新现有消息 + // 更新现有消息 - 确保完全替换内容以触发React更新 + if (updates.content !== undefined) { + state.list.items[chatIndex].messages[messageIndex].content = updates.content; + } + + // 更新其他字段 state.list.items[chatIndex].messages[messageIndex] = { ...state.list.items[chatIndex].messages[messageIndex], ...updates, @@ -158,6 +163,15 @@ const chatSlice = createSlice({ if (updates.is_streaming === false) { state.sendMessage.status = 'succeeded'; } + + // 如果是最后一条消息且是助手消息,更新会话的last_message + if (state.list.items[chatIndex].messages[messageIndex].role === 'assistant') { + state.list.items[chatIndex].last_message = + updates.content || state.list.items[chatIndex].messages[messageIndex].content; + } + } else { + // 如果找不到消息,尝试创建一个新消息 + console.warn(`消息 ID ${id} 不存在,无法更新`); } } } diff --git a/src/styles/style.scss b/src/styles/style.scss index 43bb867..d31a591 100644 --- a/src/styles/style.scss +++ b/src/styles/style.scss @@ -590,3 +590,25 @@ } } } + +// ResourceList 组件样式 +.resource-list { + font-size: 0.9rem; + + .resource-item { + transition: background-color 0.2s ease; + + &:hover { + background-color: #f0f0f0 !important; + } + } + + .resource-title { + font-size: 0.9rem; + line-height: 1.2; + } + + .resource-source { + font-size: 0.8rem; + } +}