diff --git a/package-lock.json b/package-lock.json index 8667f54..13d05ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "react-router-dom": "^7.2.0", "react-syntax-highlighter": "^15.6.1", "redux-persist": "^6.0.0", + "redux-thunk": "^3.1.0", "remark-gfm": "^4.0.1", "uuid": "^11.1.0" }, diff --git a/package.json b/package.json index 55f43c2..2eed04e 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-router-dom": "^7.2.0", "react-syntax-highlighter": "^15.6.1", "redux-persist": "^6.0.0", + "redux-thunk": "^3.1.0", "remark-gfm": "^4.0.1", "uuid": "^11.1.0" }, diff --git a/src/App.jsx b/src/App.jsx index 5c3f9c3..5b6d6d9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,9 +3,9 @@ import AppRouter from './router/router'; import { checkAuthThunk } from './store/auth/auth.thunk'; import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { login } from './store/auth/auth.slice'; import { initWebSocket, closeWebSocket } from './services/websocket'; import { setWebSocketConnected } from './store/notificationCenter/notificationCenter.slice'; +import NavigationGuard from './components/NavigationGuard'; function App() { const navigate = useNavigate(); @@ -55,7 +55,12 @@ function App() { } }; - return ; + return ( + <> + + + + ); } export default App; diff --git a/src/components/AccessRequestModal.jsx b/src/components/AccessRequestModal.jsx index 6a8654c..516ef7c 100644 --- a/src/components/AccessRequestModal.jsx +++ b/src/components/AccessRequestModal.jsx @@ -58,7 +58,7 @@ export default function AccessRequestModal({ ...prev, permissions: { can_read: true, // 只读权限始终为true - can_edit: permissionType === '编辑权限', + can_edit: permissionType === '共享', can_delete: false, // 管理权限暂时不开放 }, })); @@ -155,11 +155,11 @@ export default function AccessRequestModal({ diff --git a/src/components/NavigationGuard.jsx b/src/components/NavigationGuard.jsx new file mode 100644 index 0000000..2d9956b --- /dev/null +++ b/src/components/NavigationGuard.jsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; + +/** + * 导航守卫组件,用于在执行某些操作时防止用户离开页面 + */ +const NavigationGuard = () => { + const location = useLocation(); + const navigate = useNavigate(); + const isUploading = useSelector((state) => state.upload.isUploading); + const previousLocation = React.useRef(location); + + // 检查是否在知识库详情页面 + const isInKnowledgeBaseDetail = + location.pathname.includes('/knowledge-base/') && !location.pathname.endsWith('/knowledge-base/'); + + useEffect(() => { + // 如果不是导航到知识库详情页,且当前正在上传文件,且之前在知识库详情页 + if (!isInKnowledgeBaseDetail && isUploading && previousLocation.current.pathname.includes('/knowledge-base/')) { + // 询问用户是否确认离开 + const confirmLeave = window.confirm('正在上传文件,离开页面可能会中断上传。确定要离开吗?'); + + // 如果用户不确认,就回到之前的页面 + if (!confirmLeave) { + navigate(previousLocation.current.pathname); + } + } + + // 更新前一个位置 + previousLocation.current = location; + }, [location, isUploading]); + + return null; // 这个组件不渲染任何UI +}; + +export default NavigationGuard; diff --git a/src/components/ResourceList.jsx b/src/components/ResourceList.jsx new file mode 100644 index 0000000..2abc2b7 --- /dev/null +++ b/src/components/ResourceList.jsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import SvgIcon from './SvgIcon'; + +/** + * 资源列表组件 - 显示聊天回复中引用的资源 + * @param {Object} props - 组件属性 + * @param {Array} props.resources - 资源列表 + */ +const ResourceList = ({ resources = [] }) => { + const [expanded, setExpanded] = useState(false); + + if (!resources || resources.length === 0) { + return null; + } + + // 最多显示3个资源,超过3个时折叠 + const visibleResources = expanded ? resources : resources.slice(0, 3); + const hasMore = resources.length > 3; + + return ( +
+
+ + 资源引用 + + {resources.length} +
+
+ {visibleResources.map((resource, index) => ( +
+
+ {(resource.similarity * 100).toFixed(0)}% +
+
+
{resource.document_name}
+
{resource.dataset_name}
+
+
+ ))} + + {hasMore && !expanded && ( + + )} + + {expanded && ( + + )} +
+
+ ); +}; + +export default ResourceList; diff --git a/src/components/SafeMarkdown.jsx b/src/components/SafeMarkdown.jsx index 7eedb1b..569b405 100644 --- a/src/components/SafeMarkdown.jsx +++ b/src/components/SafeMarkdown.jsx @@ -1,50 +1,176 @@ -import React from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import ReactMarkdown from 'react-markdown'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import remarkGfm from 'remark-gfm'; +import './SafeMarkdown.scss'; import ErrorBoundary from './ErrorBoundary'; import CodeBlock from './CodeBlock'; +import SvgIcon from './SvgIcon'; /** * SafeMarkdown component that wraps ReactMarkdown with error handling * Displays raw content as fallback if markdown parsing fails */ -const SafeMarkdown = ({ content, className = 'markdown-content' }) => { +const SafeMarkdown = ({ content, isStreaming = false }) => { + const [copied, setCopied] = useState(false); + const [animatedContent, setAnimatedContent] = useState(''); + const [typingSpeed, setTypingSpeed] = useState(10); // 打字速度(毫秒) + const prevContentRef = useRef(''); + const isStreamingRef = useRef(isStreaming); + + // 复制代码按钮处理函数 + const handleCopy = (code) => { + navigator.clipboard.writeText(code).then( + () => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, + (err) => { + console.error('无法复制内容: ', err); + } + ); + }; + + // 获取随机打字速度 - 使打字效果看起来更自然 + const getRandomTypingSpeed = () => { + // 基础速度 + 随机变化 + const baseSpeed = 20; + const variance = Math.random() * 20; // 0-20ms的随机变化 + + // 如果是标点符号,稍微停顿长一点 + const nextChar = content.charAt(animatedContent.length); + const isPunctuation = ['.', ',', '!', '?', ';', ':', '\n'].includes(nextChar); + + return isPunctuation ? baseSpeed + variance + 30 : baseSpeed + variance; + }; + + // 打字机效果 + useEffect(() => { + // 更新流式状态引用 + isStreamingRef.current = isStreaming; + + // 如果内容是空,直接清空动画内容 + if (!content) { + setAnimatedContent(''); + prevContentRef.current = ''; + return; + } + + // 如果没有流式传输,或者内容已经完全相同,直接显示完整内容 + if (!isStreaming || content === animatedContent) { + setAnimatedContent(content); + prevContentRef.current = content; + return; + } + + // 如果内容比当前动画内容长,逐步添加 + if (content.length > animatedContent.length) { + // 计算打字速度 - 根据内容长度动态调整 + let speed = getRandomTypingSpeed(); + if (content.length > 500) { + speed = speed * 0.5; // 内容很长时加速 + } else if (content.length > 200) { + speed = speed * 0.7; // 内容较长时稍微加速 + } + + const timeout = setTimeout(() => { + // 根据内容长度,决定一次添加多少字符,提高长文本的打字速度 + let charsToAdd = 1; + if (content.length > 1000) { + charsToAdd = 3; // 对于非常长的内容,一次添加3个字符 + } else if (content.length > 500) { + charsToAdd = 2; // 对于较长的内容,一次添加2个字符 + } + + const endIndex = Math.min(animatedContent.length + charsToAdd, content.length); + const nextChars = content.substring(animatedContent.length, endIndex); + + setAnimatedContent((prev) => prev + nextChars); + }, speed); + + return () => clearTimeout(timeout); + } + + // 保存前一次的内容引用,用于检测内容变化 + prevContentRef.current = content; + }, [content, animatedContent, isStreaming]); + // Fallback UI that shows raw content when ReactMarkdown fails const renderFallback = (error) => { console.error('Markdown rendering error:', error); return ( -
-

- Error rendering markdown. Showing raw content: -

-
{content}
+
+
Markdown渲染错误
+
{content}
); }; - return ( - -
, - code({ node, inline, className: codeClassName, children, ...props }) { - const match = /language-(\w+)/.exec(codeClassName || ''); - return !inline && match ? ( - - ) : ( - - {children} - - ); - }, - }} - > - {content} - - - ); + try { + // 如果内容为空,直接返回空 + if (!content) return null; + + // 实际显示的内容 - 当流式传输时使用动画内容,否则使用完整内容 + const displayContent = isStreaming ? animatedContent : content; + + return ( + +
, + code({ node, inline, className, children, ...props }) { + const match = /language-(\w+)/.exec(className || ''); + const language = match && match[1] ? match[1] : ''; + + return !inline && match ? ( +
+
+ {language} + {copied ? ( + + + 已复制 + + ) : ( + + )} +
+ + {String(children).replace(/\n$/, '')} + +
+ ) : ( + + {children} + + ); + }, + }} + > + {displayContent} + + {isStreaming && animatedContent !== content && } + + ); + } catch (error) { + console.error('Markdown渲染错误:', error); + // 降级处理,纯文本显示 + return ( +
+
Markdown渲染错误
+
{content}
+
+ ); + } }; export default SafeMarkdown; diff --git a/src/components/SafeMarkdown.scss b/src/components/SafeMarkdown.scss new file mode 100644 index 0000000..92a3557 --- /dev/null +++ b/src/components/SafeMarkdown.scss @@ -0,0 +1,172 @@ +.markdown-content { + line-height: 1.6; + word-wrap: break-word; + + h1, h2, h3, h4, h5, h6 { + margin-top: 1.5rem; + margin-bottom: 1rem; + font-weight: 600; + } + + p { + margin-bottom: 1rem; + } + + a { + color: #0066cc; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + blockquote { + border-left: 4px solid #dfe2e5; + padding-left: 1rem; + margin-left: 0; + color: #6a737d; + } + + pre { + background-color: #f6f8fa; + border-radius: 3px; + padding: 16px; + overflow: auto; + } + + code { + background-color: rgba(0, 0, 0, 0.05); + border-radius: 3px; + padding: 0.2em 0.4em; + font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; + font-size: 85%; + } + + ul, ol { + padding-left: 2rem; + margin-bottom: 1rem; + } + + img { + max-width: 100%; + height: auto; + } + + table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; + + th, td { + border: 1px solid #dfe2e5; + padding: 8px 12px; + } + + th { + background-color: #f6f8fa; + } + } +} + +.code-block-container { + position: relative; + margin: 1rem 0; + border-radius: 6px; + overflow: hidden; + + .code-block-header { + display: flex; + justify-content: space-between; + align-items: center; + background-color: #343a40; + padding: 8px 16px; + color: #fff; + font-family: monospace; + + .code-language-badge { + background-color: rgba(255, 255, 255, 0.1); + border-radius: 4px; + padding: 2px 6px; + font-size: 12px; + } + + .copy-button { + display: flex; + align-items: center; + gap: 5px; + background: none; + border: none; + color: #fff; + cursor: pointer; + font-size: 12px; + opacity: 0.8; + transition: opacity 0.2s; + + &:hover { + opacity: 1; + } + } + + .copied-indicator { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: #4caf50; + } + } +} + +.markdown-fallback { + padding: 1rem; + border: 1px dashed #dc3545; + border-radius: 4px; + margin-bottom: 1rem; + + .text-danger { + color: #dc3545; + font-weight: bold; + margin-bottom: 0.5rem; + } + + pre { + background-color: #f8f9fa; + padding: 0.5rem; + border-radius: 4px; + white-space: pre-wrap; + } +} + +/* 打字光标动画 */ +.typing-cursor { + display: inline-block; + width: 2px; + height: 18px; + background-color: #333; + margin-left: 2px; + vertical-align: middle; + animation: cursor-blink 0.8s infinite; +} + +@keyframes cursor-blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* 暗黑模式支持 */ +@media (prefers-color-scheme: dark) { + .typing-cursor { + background-color: #eee; + } + + .markdown-content { + code { + background-color: rgba(255, 255, 255, 0.1); + } + } +} \ No newline at end of file diff --git a/src/components/UserSettingsModal.jsx b/src/components/UserSettingsModal.jsx index a2059e3..389553d 100644 --- a/src/components/UserSettingsModal.jsx +++ b/src/components/UserSettingsModal.jsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import '../styles/style.scss'; import { updateProfileThunk } from '../store/auth/auth.thunk'; // 部门和组别的映射关系 diff --git a/src/layouts/Mainlayout.jsx b/src/layouts/Mainlayout.jsx index 76ef448..a6b3751 100644 --- a/src/layouts/Mainlayout.jsx +++ b/src/layouts/Mainlayout.jsx @@ -1,7 +1,5 @@ import React from 'react'; import HeaderWithNav from './HeaderWithNav'; -import '../styles/style.scss'; -import NotificationSnackbar from '../components/NotificationSnackbar'; export default function Mainlayout({ children }) { return ( diff --git a/src/main.jsx b/src/main.jsx index 8203d0a..8e6984c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -8,6 +8,7 @@ import { Provider } from 'react-redux'; import store, { persistor } from './store/store.js'; import { PersistGate } from 'redux-persist/integration/react'; import Loading from './components/Loading.jsx'; +import './styles/style.scss'; createRoot(document.getElementById('root')).render( // diff --git a/src/pages/Chat/Chat.jsx b/src/pages/Chat/Chat.jsx index ae16ed3..dba40c9 100644 --- a/src/pages/Chat/Chat.jsx +++ b/src/pages/Chat/Chat.jsx @@ -17,13 +17,13 @@ export default function Chat() { items: chatHistory, status, error, - } = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null }); + } = useSelector((state) => state.chat.list || { items: [], status: 'idle', error: null }); const operationStatus = useSelector((state) => state.chat.createSession?.status); const operationError = useSelector((state) => state.chat.createSession?.error); // 获取聊天记录列表 useEffect(() => { - dispatch(fetchChats({ page: 1, page_size: 20 })); + dispatch(fetchChats()); }, [dispatch]); // 监听操作状态,显示通知 @@ -47,77 +47,48 @@ export default function Chat() { // If we have a knowledgeBaseId but no chatId, check if we have an existing chat or create a new one useEffect(() => { + console.log('Chat.jsx: chatHistory', chatHistory); + // 只有当 knowledgeBaseId 存在但 chatId 不存在,且聊天历史已加载完成时才执行 if (knowledgeBaseId && !chatId && status === 'succeeded' && !status.includes('loading')) { - console.log('Chat.jsx: 检查是否需要创建聊天...'); + console.log('Chat.jsx: 创建新聊天...'); // 处理可能的多个知识库ID (以逗号分隔) const knowledgeBaseIds = knowledgeBaseId.split(',').map((id) => id.trim()); console.log('Chat.jsx: 处理知识库ID列表:', knowledgeBaseIds); - // 检查是否存在包含所有选中知识库的聊天记录 - const existingChat = chatHistory.find((chat) => { - // 没有datasets属性或不是数组,跳过 - if (!chat.datasets || !Array.isArray(chat.datasets)) { - return false; - } - - // 获取当前聊天记录中的知识库ID列表 - const chatDatasetIds = chat.datasets.map((ds) => ds.id); - - // 检查所有选中的知识库是否都包含在这个聊天中 - // 并且聊天中的知识库数量要和选中的相同(完全匹配) - return ( - knowledgeBaseIds.length === chatDatasetIds.length && - knowledgeBaseIds.every((id) => chatDatasetIds.includes(id)) - ); - }); - - console.log('Chat.jsx: existingChat', existingChat); - - if (existingChat) { - console.log( - `Chat.jsx: 找到现有聊天记录,导航到 /chat/${knowledgeBaseId}/${existingChat.conversation_id}` - ); - // 找到现有聊天记录,导航到该聊天页面 - navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`); - } else { - console.log('Chat.jsx: 创建新聊天...'); - // 创建新聊天 - 使用新的API创建会话 - dispatch( - createConversation({ - dataset_id_list: knowledgeBaseIds, - }) - ) - .unwrap() - .then((response) => { - // 创建成功,使用返回的conversation_id导航 - if (response && response.conversation_id) { - console.log( - `Chat.jsx: 创建成功,导航到 /chat/${knowledgeBaseId}/${response.conversation_id}` - ); - navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`); - } else { - // 错误处理 - console.error('Chat.jsx: 创建失败,未能获取会话ID'); - dispatch( - showNotification({ - message: '创建聊天失败:未能获取会话ID', - type: 'danger', - }) - ); - } - }) - .catch((error) => { - console.error('Chat.jsx: 创建失败', error); + // 创建新聊天 - 使用新的API创建会话 + dispatch( + createConversation({ + dataset_id_list: knowledgeBaseIds, + }) + ) + .unwrap() + .then((response) => { + // 创建成功,使用返回的conversation_id导航 + if (response && response.conversation_id) { + console.log(`Chat.jsx: 创建成功,导航到 /chat/${knowledgeBaseId}/${response.conversation_id}`); + navigate(`/chat/${knowledgeBaseId}/${response.conversation_id}`); + } else { + // 错误处理 + console.error('Chat.jsx: 创建失败,未能获取会话ID'); dispatch( showNotification({ - message: `创建聊天失败: ${error}`, + message: '创建聊天失败:未能获取会话ID', type: 'danger', }) ); - }); - } + } + }) + .catch((error) => { + console.error('Chat.jsx: 创建失败', error); + dispatch( + showNotification({ + message: `创建聊天失败: ${error}`, + type: 'danger', + }) + ); + }); } }, [knowledgeBaseId, chatId, chatHistory, status, navigate, dispatch]); diff --git a/src/pages/Chat/ChatSidebar.jsx b/src/pages/Chat/ChatSidebar.jsx index 766482d..cacf6a4 100644 --- a/src/pages/Chat/ChatSidebar.jsx +++ b/src/pages/Chat/ChatSidebar.jsx @@ -93,8 +93,9 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading chatId === chat.conversation_id ? 'fw-bold' : '' }`} > -
-
+
+ {chat.title &&
{chat.title}
} +
{chat.datasets?.map((ds) => ds.name).join(', ') || '未命名知识库'}
diff --git a/src/pages/Chat/ChatWindow.jsx b/src/pages/Chat/ChatWindow.jsx index 82ecf2a..7168f26 100644 --- a/src/pages/Chat/ChatWindow.jsx +++ b/src/pages/Chat/ChatWindow.jsx @@ -1,12 +1,12 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { fetchMessages } from '../../store/chat/chat.messages.thunks'; import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice'; import { showNotification } from '../../store/notification.slice'; import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks'; import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks'; import SvgIcon from '../../components/SvgIcon'; import SafeMarkdown from '../../components/SafeMarkdown'; +import ResourceList from '../../components/ResourceList'; import { get } from '../../services/api'; export default function ChatWindow({ chatId, knowledgeBaseId }) { @@ -17,11 +17,17 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { const hasLoadedDetailRef = useRef({}); // 添加ref来跟踪已加载的会话 // 从 Redux store 获取消息 - const messages = useSelector((state) => state.chat.messages.items); - const messageStatus = useSelector((state) => state.chat.messages.status); - const messageError = useSelector((state) => state.chat.messages.error); + const chatList = useSelector((state) => state.chat.list.items); + const currentChatId = useSelector((state) => state.chat.currentChat.conversationId || chatId); + 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); + // 获取消息资源 + const resources = useSelector((state) => state.chat.resources); + // 使用新的Redux状态结构 const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []); const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId); @@ -43,6 +49,17 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 监听知识库ID变更,确保保存在组件状态中 const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]); + // 当chatId改变时设置当前会话ID + useEffect(() => { + if (chatId) { + // 通过设置currentChat.conversationId确保消息显示在正确的会话下 + dispatch({ + type: 'chat/setCurrentChat', + payload: { conversation_id: chatId }, + }); + } + }, [chatId, dispatch]); + // 当conversation或knowledgeBaseId更新时,更新selectedKnowledgeBaseIds useEffect(() => { // 优先使用conversation中的知识库列表 @@ -105,7 +122,8 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { // 组件卸载时清空消息 return () => { - dispatch(resetMessages()); + // Don't reset messages when switching chats + // dispatch(resetMessages()); // 不要清空hasLoadedDetailRef,否则会导致重复加载 // hasLoadedDetailRef.current = {}; // 清理ref缓存 }; @@ -253,19 +271,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
); - // 渲染错误状态 - const renderError = () => ( -
-

- 加载消息失败 -

-

{messageError}

- -
- ); - // 渲染空消息状态 const renderEmpty = () => { if (loading) return null; @@ -283,16 +288,11 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
{conversation && conversation.datasets ? ( <> -
{conversation.datasets.map((dataset) => dataset.name).join(', ')}
+
{conversation.title}
{conversation.datasets.length > 0 && conversation.datasets[0].type && ( 类型: {conversation.datasets[0].type} )} - ) : knowledgeBase ? ( - <> -
{knowledgeBase.name}
- {knowledgeBase.description} - ) : (
{loading || availableDatasetsLoading ? '加载中...' : '聊天'}
)} @@ -303,8 +303,6 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
{messageStatus === 'loading' ? renderLoading() - : messageStatus === 'failed' - ? renderError() : messages.length === 0 ? renderEmpty() : messages.map((message) => ( @@ -327,7 +325,10 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { {message.role === 'user' ? ( message.content ) : ( - + )} {message.is_streaming && ( @@ -336,6 +337,14 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) { )} + + {/* 只在AI回复消息下方显示资源列表 */} + {message.role === 'assistant' && + !message.is_streaming && + resources.messageId === message.id && + resources.items.length > 0 && ( + + )}
diff --git a/src/pages/Chat/NewChat.jsx b/src/pages/Chat/NewChat.jsx index 809d1de..e4f8d92 100644 --- a/src/pages/Chat/NewChat.jsx +++ b/src/pages/Chat/NewChat.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { showNotification } from '../../store/notification.slice'; @@ -17,14 +17,14 @@ export default function NewChat() { const error = useSelector((state) => state.chat.availableDatasets.error); // 获取聊天历史记录 - const chatHistory = useSelector((state) => state.chat.history.items || []); - const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading'); + const chatHistory = useSelector((state) => state.chat.list.items || []); + const chatHistoryLoading = useSelector((state) => state.chat.list.status === 'loading'); const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status); // 获取可用知识库列表和聊天历史 useEffect(() => { dispatch(fetchAvailableDatasets()); - dispatch(fetchChats({ page: 1, page_size: 50 })); + dispatch(fetchChats()); }, [dispatch]); // 监听错误状态 @@ -77,65 +77,31 @@ export default function NewChat() { // 打印调试信息 console.log('选中的知识库ID:', selectedDatasetIds); - // 检查是否已存在包含所有选中知识库的聊天记录 - // 注意:这里的逻辑简化了,实际可能需要更复杂的匹配算法 - const existingChat = chatHistory.find((chat) => { - // 检查聊天记录中的知识库是否完全匹配当前选择 - if (chat.datasets && Array.isArray(chat.datasets)) { - const chatDatasetIds = chat.datasets.map((ds) => ds.id); - return ( - chatDatasetIds.length === selectedDatasetIds.length && - selectedDatasetIds.every((id) => chatDatasetIds.includes(id)) - ); - } - - // 兼容旧格式 - if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) { - const formattedSelectedIds = selectedDatasetIds.map((id) => id.replace(/-/g, '')); - return ( - chat.dataset_id_list.length === formattedSelectedIds.length && - formattedSelectedIds.every((id) => chat.dataset_id_list.includes(id)) - ); - } - - return false; - }); - - if (existingChat) { - // 找到现有聊天记录,导航到该聊天页面 - // 使用所有知识库ID作为URL参数,以逗号分隔 - const knowledgeBaseIdsParam = selectedDatasetIds.join(','); - console.log( - `找到现有聊天记录,直接导航到 /chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}` - ); - navigate(`/chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`); - } else { - // 没有找到现有聊天记录,创建新的聊天 - console.log(`未找到现有聊天记录,创建新会话,选中的知识库ID: ${selectedDatasetIds.join(', ')}`); - - try { - // 调用createConversation创建新会话(不发送消息) - const response = await dispatch( - createConversation({ - dataset_id_list: selectedDatasetIds, - }) - ).unwrap(); - - console.log('创建会话响应:', response); - - if (response && response.conversation_id) { - // 使用所有知识库ID作为URL参数,以逗号分隔 - const knowledgeBaseIdsParam = selectedDatasetIds.join(','); - console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`); - navigate(`/chat/${knowledgeBaseIdsParam}/${response.conversation_id}`); - } else { - throw new Error('未能获取会话ID:' + JSON.stringify(response)); - } - } catch (apiError) { - // 专门处理API调用错误 - console.error('API调用失败:', apiError); - throw new Error(`API调用失败: ${apiError.message || '未知错误'}`); + // 创建新的聊天会话 + console.log(`创建新会话,选中的知识库ID: ${selectedDatasetIds.join(', ')}`); + + try { + // 调用createConversation创建新会话(不发送消息) + const response = await dispatch( + createConversation({ + dataset_id_list: selectedDatasetIds, + }) + ).unwrap(); + + console.log('创建会话响应:', response); + + if (response && response.conversation_id) { + // 使用所有知识库ID作为URL参数,以逗号分隔 + const knowledgeBaseIdsParam = selectedDatasetIds.join(','); + console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`); + navigate(`/chat/${knowledgeBaseIdsParam}/${response.conversation_id}`); + } else { + throw new Error('未能获取会话ID:' + JSON.stringify(response)); } + } catch (apiError) { + // 专门处理API调用错误 + console.error('API调用失败:', apiError); + throw new Error(`API调用失败: ${apiError.message || '未知错误'}`); } } catch (error) { console.error('导航或创建聊天失败:', error); @@ -157,6 +123,11 @@ export default function NewChat() { } }; + // Refresh chat history when a new chat is created + const refreshChatHistory = useCallback(() => { + dispatch(fetchChats()); + }, [dispatch]); + // 渲染加载状态 if (isLoading || chatHistoryLoading) { return ( diff --git a/src/pages/KnowledgeBase/Detail/DatasetTab.jsx b/src/pages/KnowledgeBase/Detail/DatasetTab.jsx index 4dc4fab..d9643ed 100644 --- a/src/pages/KnowledgeBase/Detail/DatasetTab.jsx +++ b/src/pages/KnowledgeBase/Detail/DatasetTab.jsx @@ -16,6 +16,7 @@ export default function DatasetTab({ knowledgeBase }) { const [selectAll, setSelectAll] = useState(false); const [showBatchDropdown, setShowBatchDropdown] = useState(false); const [showAddFileModal, setShowAddFileModal] = useState(false); + const [isUploadModalMinimized, setIsUploadModalMinimized] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [newFile, setNewFile] = useState({ name: '', @@ -34,14 +35,13 @@ export default function DatasetTab({ knowledgeBase }) { setDocuments(knowledgeBase.documents || []); }, [knowledgeBase]); - // 获取文档列表 - useEffect(() => { + // 获取文档列表 + useEffect(() => { if (knowledgeBase?.id) { dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBase.id })); } }, [dispatch, knowledgeBase?.id]); - // Handle click outside dropdown useEffect(() => { function handleClickOutside(event) { @@ -244,6 +244,8 @@ export default function DatasetTab({ knowledgeBase }) { }); setFileErrors({}); setShowAddFileModal(false); + // 同时重置最小化状态 + setIsUploadModalMinimized(false); }; // Handle delete document @@ -270,6 +272,28 @@ export default function DatasetTab({ knowledgeBase }) { (doc.description && doc.description.toLowerCase().includes(searchQuery.toLowerCase())) ); + // 处理上传按钮点击 + const handleAddFileClick = () => { + // 如果弹窗已经最小化,则恢复显示 + if (isUploadModalMinimized) { + setIsUploadModalMinimized(false); + setShowAddFileModal(true); + } else { + // 否则正常显示上传弹窗 + setShowAddFileModal(true); + } + }; + + // 处理上传弹窗最小化状态变化 + const handleUploadModalMinimizeChange = (isMinimized) => { + console.log('Upload modal minimize state changed:', isMinimized); + setIsUploadModalMinimized(isMinimized); + // 如果是最小化,则隐藏主弹窗但保持最小化状态显示 + if (isMinimized) { + setShowAddFileModal(false); + } + }; + return ( <> {/* Breadcrumb navigation */} @@ -281,7 +305,7 @@ export default function DatasetTab({ knowledgeBase }) {
{formatDateTime(doc.create_time || doc.created_at)} - {formatDateTime(doc.update_time || doc.updated_at)} + {doc.uploader_name || ''}
+
+ {/* Minimize button */} + + + {/* Close button */} + +
- + @@ -256,7 +256,7 @@ export default function UserPermissionDetails({ user, onClose, onSave }) { > - + diff --git a/src/pages/auth/Signup.jsx b/src/pages/auth/Signup.jsx index 603135b..3452332 100644 --- a/src/pages/auth/Signup.jsx +++ b/src/pages/auth/Signup.jsx @@ -244,9 +244,9 @@ export default function Signup() { name='role' value={formData.role} onChange={handleInputChange} - disabled={loading} + disabled > - + diff --git a/src/services/api.js b/src/services/api.js index d92e095..1d55c25 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -220,10 +220,30 @@ const streamRequest = async (url, data, onChunk, onError) => { if (isServerDown) { console.log(`[MOCK MODE] STREAM ${url}`); // 模拟流式响应 - setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"这是模拟的","conversation_id":"mock-1234"}}'), 300); - setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"流式","conversation_id":"mock-1234"}}'), 600); - setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"响应","conversation_id":"mock-1234"}}'), 900); - setTimeout(() => onChunk('{"code":200,"message":"partial","data":{"content":"数据","conversation_id":"mock-1234","is_end":true}}'), 1200); + setTimeout( + () => + onChunk( + '{"code":200,"message":"partial","data":{"content":"这是模拟的","conversation_id":"mock-1234"}}' + ), + 300 + ); + setTimeout( + () => + onChunk('{"code":200,"message":"partial","data":{"content":"流式","conversation_id":"mock-1234"}}'), + 600 + ); + setTimeout( + () => + onChunk('{"code":200,"message":"partial","data":{"content":"响应","conversation_id":"mock-1234"}}'), + 900 + ); + setTimeout( + () => + onChunk( + '{"code":200,"message":"partial","data":{"content":"数据","conversation_id":"mock-1234","is_end":true}}' + ), + 1200 + ); return { success: true, conversation_id: 'mock-1234' }; } @@ -239,7 +259,7 @@ const streamRequest = async (url, data, onChunk, onError) => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': token ? `Token ${token}` : '', + Authorization: token ? `Token ${token}` : '', }, body: JSON.stringify(data), }); @@ -248,50 +268,104 @@ const streamRequest = async (url, data, onChunk, onError) => { throw new Error(`HTTP error! Status: ${response.status}`); } - // 获取响应体的reader - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let conversationId = null; + // 检查是否为SSE (Server-Sent Events)格式 + const contentType = response.headers.get('Content-Type'); + const isSSE = contentType && contentType.includes('text/event-stream'); + console.log('响应内容类型:', contentType, '是否SSE:', isSSE); - // 处理流式数据 - while (true) { - const { done, value } = await reader.read(); - if (done) break; + // 处理SSE格式 + if (isSSE) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let conversationId = null; - // 解码并处理数据 - const chunk = decoder.decode(value, { stream: true }); - buffer += chunk; + // 处理流式数据 + while (true) { + const { done, value } = await reader.read(); + if (done) break; - // 按行分割并处理JSON - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后一行(可能不完整) + // 解码并处理数据 - 不使用stream选项以确保完整解码 + const chunk = decoder.decode(value, { stream: false }); + buffer += chunk; - for (const line of lines) { - if (!line.trim()) continue; - - try { - // 检查是否为SSE格式(data: {...}) - let jsonStr = line; - if (line.startsWith('data:')) { + // 按行分割并处理JSON + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // 保留最后一行(可能不完整) + + for (const line of lines) { + if (!line.trim()) continue; + + try { // 提取data:后面的JSON部分 - jsonStr = line.substring(5).trim(); - console.log('检测到SSE格式数据,提取JSON:', jsonStr); + let jsonStr = line; + if (line.startsWith('data:')) { + jsonStr = line.substring(5).trim(); + console.log('SSE数据块:', jsonStr); + } + + // 尝试解析JSON + const parsedData = JSON.parse(jsonStr); + if (parsedData.code === 200 && parsedData.data && parsedData.data.conversation_id) { + conversationId = parsedData.data.conversation_id; + } + + // 立即调用处理函数 + onChunk(jsonStr); + } catch (e) { + console.warn('解析JSON失败:', line, e); } - - // 尝试解析JSON - const data = JSON.parse(jsonStr); - if (data.code === 200 && data.data && data.data.conversation_id) { - conversationId = data.data.conversation_id; - } - onChunk(jsonStr); - } catch (e) { - console.warn('Failed to parse JSON:', line, e); } } - } - return { success: true, conversation_id: conversationId }; + return { success: true, conversation_id: conversationId }; + } + // 处理常规JSON响应 + else { + // 原始响应可能是单个JSON对象而不是流 + const responseData = await response.json(); + console.log('接收到非流式响应:', responseData); + + if (responseData.code === 200) { + // 模拟分段处理 + const content = responseData.data?.content || ''; + const conversationId = responseData.data?.conversation_id; + + // 每100个字符分段处理 + let offset = 0; + const chunkSize = 100; + + while (offset < content.length) { + const isLast = offset + chunkSize >= content.length; + const chunk = content.substring(offset, offset + chunkSize); + + // 构造类似流式传输的JSON + const chunkData = { + code: 200, + message: 'partial', + data: { + content: chunk, + conversation_id: conversationId, + is_end: isLast, + }, + }; + + // 调用处理函数 + onChunk(JSON.stringify(chunkData)); + + // 暂停一下让UI有时间更新 + await new Promise((resolve) => setTimeout(resolve, 50)); + + offset += chunkSize; + } + + return { success: true, conversation_id: conversationId }; + } + + // 如果不是成功响应,直接传递原始数据 + onChunk(JSON.stringify(responseData)); + return { success: responseData.code === 200, conversation_id: responseData.data?.conversation_id }; + } } catch (error) { console.error('Streaming request failed:', error); if (onError) { @@ -318,4 +392,21 @@ export const rejectPermission = (permissionId) => { return post(`/permissions/reject_permission/${permissionId}`); }; +/** + * 获取聊天回复相关资源列表 + * @param {Object} data - 请求参数 + * @param {Array} data.dataset_id_list - 知识库ID列表 + * @param {string} data.question - 用户问题 + * @returns {Promise} API响应 + */ +export const fetchChatResources = async (data) => { + try { + const response = await post('/chat-history/hit_test/', data); + return response; + } catch (error) { + console.error('获取聊天资源失败:', error); + throw error; + } +}; + export { get, post, put, del, upload, streamRequest }; diff --git a/src/services/permissionService.js b/src/services/permissionService.js index a77532e..ad2d3eb 100644 --- a/src/services/permissionService.js +++ b/src/services/permissionService.js @@ -35,7 +35,7 @@ export const calculateExpiresAt = (duration) => { * @deprecated 请使用Redux thunk版本 * @param {Object} requestData - 请求数据 * @param {string} requestData.id - 知识库ID - * @param {string} requestData.accessType - 访问类型,如 '只读访问', '编辑权限' + * @param {string} requestData.accessType - 访问类型,如 '只读访问', '共享' * @param {string} requestData.duration - 访问时长,如 '一周', '一个月' * @param {string} requestData.reason - 申请原因 * @returns {Promise} - API 请求的 Promise @@ -45,7 +45,7 @@ export const legacyRequestKnowledgeBaseAccess = async (requestData) => { knowledge_base: requestData.id, permissions: { can_read: true, - can_edit: requestData.accessType === '编辑权限', + can_edit: requestData.accessType === '共享', can_delete: false, }, reason: requestData.reason, diff --git a/src/services/websocket.js b/src/services/websocket.js index 2b551c8..1409a37 100644 --- a/src/services/websocket.js +++ b/src/services/websocket.js @@ -17,9 +17,11 @@ let socket = 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; // 单个会话中允许的总重连次数 /** * 初始化WebSocket连接 @@ -27,36 +29,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); + 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 +94,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 +110,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 +201,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 +233,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); } }; diff --git a/src/store/chat/chat.messages.thunks.js b/src/store/chat/chat.messages.thunks.js index 7beea0c..279958d 100644 --- a/src/store/chat/chat.messages.thunks.js +++ b/src/store/chat/chat.messages.thunks.js @@ -1,25 +1,6 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { get, post } from '../../services/api'; -/** - * 获取聊天消息 - * @param {string} chatId - 聊天ID - */ -export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (chatId, { rejectWithValue }) => { - try { - const response = await get(`/chat-history/${chatId}/messages/`); - - // 处理返回格式 - if (response && response.code === 200) { - return response.data.messages; - } - - return response.data?.messages || []; - } catch (error) { - return rejectWithValue(error.response?.data?.message || 'Failed to fetch messages'); - } -}); - /** * 发送聊天消息 * @param {Object} params - 消息参数 diff --git a/src/store/chat/chat.slice.js b/src/store/chat/chat.slice.js index 890acda..793a02c 100644 --- a/src/store/chat/chat.slice.js +++ b/src/store/chat/chat.slice.js @@ -9,28 +9,16 @@ import { fetchConversationDetail, createConversation, } from './chat.thunks'; -import { fetchMessages, sendMessage } from './chat.messages.thunks'; +import { sendMessage } from './chat.messages.thunks'; // 初始状态 const initialState = { - // Chat history state - history: { - items: [], - status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' - error: null, - }, // Chat session creation state createSession: { status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' error: null, sessionId: null, }, - // Chat messages state - messages: { - items: [], - status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' - error: null, - }, // Send message state sendMessage: { status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' @@ -47,20 +35,28 @@ const initialState = { status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' error: null, }, - // 兼容旧版本的state结构 + // 聊天列表状态 list: { items: [], total: 0, - page: 1, - page_size: 10, status: 'idle', error: null, + messageStatus: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' + messageError: null, }, // 当前聊天 currentChat: { data: null, status: 'idle', error: null, + conversationId: null, // 当前选择的会话ID + }, + // 聊天资源引用 + resources: { + messageId: null, + items: [], + status: 'idle', + error: null, }, }; @@ -80,19 +76,31 @@ const chatSlice = createSlice({ state.currentChat.data = null; state.currentChat.status = 'idle'; state.currentChat.error = null; + state.currentChat.conversationId = null; }, // 设置当前聊天 setCurrentChat: (state, action) => { state.currentChat.data = action.payload; state.currentChat.status = 'succeeded'; + if (action.payload && action.payload.conversation_id) { + state.currentChat.conversationId = action.payload.conversation_id; + } }, // 重置消息状态 resetMessages: (state) => { - state.messages.items = []; - state.messages.status = 'idle'; - state.messages.error = null; + if (state.currentChat.conversationId) { + const chatIndex = state.list.items.findIndex( + (chat) => chat.conversation_id === state.currentChat.conversationId + ); + if (chatIndex !== -1) { + // 只重置当前会话的消息 + state.list.items[chatIndex].messages = []; + } + } + state.list.messageStatus = 'idle'; + state.list.messageError = null; }, // 重置发送消息状态 @@ -103,64 +111,138 @@ const chatSlice = createSlice({ // 添加消息 addMessage: (state, action) => { - state.messages.items.push(action.payload); + const conversationId = state.currentChat.conversationId; + if (conversationId) { + const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId); + console.log(chatIndex, 'chatIndex'); + if (chatIndex !== -1) { + // 确保messages数组存在 + if (!state.list.items[chatIndex].messages) { + state.list.items[chatIndex].messages = []; + } + // 添加消息到对应会话 + state.list.items[chatIndex].messages.push(action.payload); + + // 更新最后一条消息和消息数量 + if (action.payload.role === 'user') { + state.list.items[chatIndex].last_message = action.payload.content; + if (state.list.items[chatIndex].message_count) { + state.list.items[chatIndex].message_count += 1; + } else { + state.list.items[chatIndex].message_count = 1; + } + } + } + } }, - // 更新消息(用于流式传输) + // 更新消息 updateMessage: (state, action) => { const { id, ...updates } = action.payload; - const messageIndex = state.messages.items.findIndex((msg) => msg.id === id); + const conversationId = state.currentChat.conversationId; - if (messageIndex !== -1) { - // 更新现有消息 - state.messages.items[messageIndex] = { - ...state.messages.items[messageIndex], - ...updates, - }; + if (conversationId) { + const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId); - // 如果流式传输结束,更新发送消息状态 - if (updates.is_streaming === false) { - state.sendMessage.status = 'succeeded'; + if (chatIndex !== -1 && state.list.items[chatIndex].messages) { + const messageIndex = state.list.items[chatIndex].messages.findIndex((msg) => msg.id === id); + + if (messageIndex !== -1) { + // 更新现有消息 + state.list.items[chatIndex].messages[messageIndex] = { + ...state.list.items[chatIndex].messages[messageIndex], + ...updates, + }; + + // 如果流式传输结束,更新发送消息状态 + if (updates.is_streaming === false) { + state.sendMessage.status = 'succeeded'; + } + } } } }, + + // 添加资源 + setMessageResources: (state, action) => { + const { messageId, resources } = action.payload; + state.resources = { + messageId, + items: resources, + status: 'succeeded', + error: null, + }; + }, + + // 清除资源 + clearMessageResources: (state) => { + state.resources = { + messageId: null, + items: [], + status: 'idle', + error: null, + }; + }, + + // 设置资源加载状态 + setResourcesLoading: (state, action) => { + state.resources.status = 'loading'; + state.resources.messageId = action.payload; + state.resources.error = null; + }, + + // 设置资源加载失败 + setResourcesError: (state, action) => { + state.resources.status = 'failed'; + state.resources.error = action.payload; + }, + + // 更新特定会话的消息 + updateChatMessages: (state, action) => { + const { conversationId, messages } = action.payload; + const chatIndex = state.list.items.findIndex((chat) => chat.conversation_id === conversationId); + + if (chatIndex !== -1) { + state.list.items[chatIndex].messages = messages; + // 同时更新当前会话ID + state.currentChat.conversationId = conversationId; + } + }, }, extraReducers: (builder) => { // 获取聊天列表 builder .addCase(fetchChats.pending, (state) => { state.list.status = 'loading'; - state.history.status = 'loading'; }) .addCase(fetchChats.fulfilled, (state, action) => { state.list.status = 'succeeded'; + console.log(action.payload, '当前list.items:', state.list.items); // 检查是否是追加模式 if (action.payload.append) { // 追加模式:将新结果添加到现有列表的前面 - state.list.items = [...action.payload.results, ...state.list.items]; - state.history.items = [...action.payload.results, ...state.history.items]; + // 确保每个聊天项都有messages数组 + const newResults = action.payload.results.map((chat) => ({ + ...chat, + messages: chat.messages || [], + })); + state.list.items = [...newResults, ...state.list.items]; } else { // 替换模式:使用新结果替换整个列表 - state.list.items = action.payload.results; + // 确保每个聊天项都有messages数组 + state.list.items = (action.payload.results || []).map((chat) => ({ + ...chat, + messages: chat.messages || [], + })); state.list.total = action.payload.total; - state.list.page = action.payload.page; - state.list.page_size = action.payload.page_size; - - // 同时更新新的状态结构 - state.history.items = action.payload.results; } - state.history.status = 'succeeded'; - state.history.error = null; + state.list.error = null; }) .addCase(fetchChats.rejected, (state, action) => { state.list.status = 'failed'; state.list.error = action.payload || action.error.message; - - // 同时更新新的状态结构 - state.history.status = 'failed'; - state.history.error = action.payload || action.error.message; }) // 创建聊天 @@ -185,16 +267,14 @@ const chatSlice = createSlice({ }) .addCase(deleteChat.fulfilled, (state, action) => { state.operations.status = 'succeeded'; - // 更新旧的状态结构 - state.list.items = state.list.items.filter((chat) => chat.id !== action.payload); - // 更新新的状态结构 - state.history.items = state.history.items.filter((chat) => chat.conversation_id !== action.payload); + // 更新聊天列表 + state.list.items = state.list.items.filter((chat) => chat.conversation_id !== action.payload); if (state.list.total > 0) { state.list.total -= 1; } - if (state.currentChat.data && state.currentChat.data.id === action.payload) { + if (state.currentChat.data && state.currentChat.data.conversation_id === action.payload) { state.currentChat.data = null; } }) @@ -222,20 +302,6 @@ const chatSlice = createSlice({ state.operations.error = action.payload || action.error.message; }) - // 获取聊天消息 - .addCase(fetchMessages.pending, (state) => { - state.messages.status = 'loading'; - state.messages.error = null; - }) - .addCase(fetchMessages.fulfilled, (state, action) => { - state.messages.status = 'succeeded'; - state.messages.items = action.payload; - }) - .addCase(fetchMessages.rejected, (state, action) => { - state.messages.status = 'failed'; - state.messages.error = action.error.message; - }) - // 发送聊天消息 .addCase(sendMessage.pending, (state) => { state.sendMessage.status = 'loading'; @@ -348,6 +414,11 @@ export const { resetSendMessageStatus, addMessage, updateMessage, + setMessageResources, + clearMessageResources, + setResourcesLoading, + setResourcesError, + updateChatMessages, } = chatSlice.actions; // 导出 reducer diff --git a/src/store/chat/chat.thunks.js b/src/store/chat/chat.thunks.js index 607c120..8e3def3 100644 --- a/src/store/chat/chat.thunks.js +++ b/src/store/chat/chat.thunks.js @@ -1,29 +1,32 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { get, post, put, del, streamRequest } from '../../services/api'; +import { get, post, put, del, streamRequest, fetchChatResources } from '../../services/api'; import { showNotification } from '../notification.slice'; -import { addMessage, updateMessage, setCurrentChat } from './chat.slice'; +import { + addMessage, + updateMessage, + setCurrentChat, + setMessageResources, + setResourcesLoading, + setResourcesError, +} from './chat.slice'; /** * 获取聊天列表 * @param {Object} params - 查询参数 - * @param {number} params.page - 页码 - * @param {number} params.page_size - 每页数量 */ -export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}, { rejectWithValue }) => { +export const fetchChats = createAsyncThunk('chat/fetchChats', async (_, { rejectWithValue }) => { try { - const response = await get('/chat-history/', { params }); + const response = await get('/chat-history/'); // 处理返回格式 if (response && response.code === 200) { return { - results: response.data.results, - total: response.data.total, - page: response.data.page || 1, - page_size: response.data.page_size || 10, + results: response.data, + total: response.data.length, }; } - return { results: [], total: 0, page: 1, page_size: 10 }; + return { results: [], total: 0 }; } catch (error) { console.error('Error fetching chats:', error); return rejectWithValue(error.response?.data?.message || 'Failed to fetch chats'); @@ -134,6 +137,15 @@ export const createChatRecord = createAsyncThunk( // 先添加用户消息到聊天窗口 const userMessageId = Date.now().toString(); + + // 先设置当前会话ID,这样addMessage可以找到正确的聊天项 + if (conversation_id) { + dispatch({ + type: 'chat/setCurrentChat', + payload: { conversation_id }, + }); + } + dispatch( addMessage({ id: userMessageId, @@ -158,6 +170,15 @@ export const createChatRecord = createAsyncThunk( let finalMessage = ''; let conversationId = conversation_id; + // 同时获取聊天资源 - 在后台发送请求 + dispatch( + getChatResources({ + dataset_id_list, + question, + messageId: assistantMessageId, + }) + ); + // 使用流式请求函数处理 const result = await streamRequest( '/chat-history/', @@ -168,11 +189,17 @@ export const createChatRecord = createAsyncThunk( const data = JSON.parse(chunkText); console.log('收到聊天数据块:', data); - if (data.code === 200) { + if (data.code === 200 || data.code === 201) { // 保存会话ID (无论消息类型,只要找到会话ID就保存) if (data.data && data.data.conversation_id && !conversationId) { conversationId = data.data.conversation_id; console.log('获取到会话ID:', conversationId); + + // 设置当前会话ID,使消息更新能找到正确的聊天项 + dispatch({ + type: 'chat/setCurrentChat', + payload: { conversation_id: conversationId }, + }); } // 处理各种可能的消息类型 @@ -190,6 +217,7 @@ export const createChatRecord = createAsyncThunk( updateMessage({ id: assistantMessageId, content: finalMessage, + is_streaming: true, }) ); } @@ -234,6 +262,7 @@ export const createChatRecord = createAsyncThunk( updateMessage({ id: assistantMessageId, content: finalMessage, + is_streaming: true, }) ); } @@ -277,7 +306,7 @@ export const createChatRecord = createAsyncThunk( // 获取知识库信息 const state = getState(); const availableDatasets = state.chat.availableDatasets.items || []; - const existingChats = state.chat.history.items || []; + const existingChats = state.chat.list.items || []; // 检查是否已存在此会话ID的记录 const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id); @@ -303,6 +332,7 @@ export const createChatRecord = createAsyncThunk( create_time: new Date().toISOString(), last_message: question, message_count: 2, // 用户问题和助手回复 + messages: [], // 确保有消息数组 }; // 更新当前聊天 @@ -375,6 +405,16 @@ export const fetchConversationDetail = createAsyncThunk( currentChat?.conversation_id === conversationId ) { console.log('使用新创建的会话数据,跳过详情请求:', conversationId); + + // 确保设置当前会话ID + dispatch({ + type: 'chat/setCurrentChat', + payload: { + ...currentChat, + conversation_id: conversationId, + }, + }); + return currentChat; } @@ -383,12 +423,25 @@ export const fetchConversationDetail = createAsyncThunk( }); if (response && response.code === 200) { - // 如果存在消息,更新Redux状态 + // 找到对应的chat item并添加消息 if (response.data.messages) { - dispatch({ - type: 'chat/fetchMessages/fulfilled', - payload: response.data.messages, - }); + const chatList = state.chat.list.items; + const chatIndex = chatList.findIndex((chat) => chat.conversation_id === conversationId); + console.log(chatIndex, 'chatIndex'); + + if (chatIndex !== -1) { + // 直接更新该聊天的消息 + dispatch({ + type: 'chat/updateChatMessages', + payload: { + conversationId, + messages: response.data.messages, + }, + }); + } else { + // 如果不存在该聊天,先通过fetchChats刷新列表 + await dispatch(fetchChats()); + } } return response.data; @@ -450,6 +503,7 @@ export const createConversation = createAsyncThunk( create_time: new Date().toISOString(), last_message: '', message_count: 0, + messages: [], // 确保有消息数组 }; // 更新聊天历史列表 @@ -489,3 +543,58 @@ export const createConversation = createAsyncThunk( } } ); + +/** + * 获取聊天回复的相关资源 + * @param {Object} params - 聊天参数 + * @param {string[]} params.dataset_id_list - 知识库ID列表 + * @param {string} params.question - 用户问题 + * @param {string} params.messageId - 消息ID,用于关联资源 + */ +export const getChatResources = createAsyncThunk( + 'chat/getChatResources', + async ({ dataset_id_list, question, messageId }, { dispatch }) => { + try { + // 设置资源加载状态 + dispatch(setResourcesLoading(messageId)); + + // 调用API获取资源 + const response = await fetchChatResources({ + dataset_id_list, + question, + }); + + // 处理响应 + if (response && response.code === 200 && response.data) { + const { matched_documents } = response.data; + + // 将资源添加到store + dispatch( + setMessageResources({ + messageId, + resources: matched_documents || [], + }) + ); + + return matched_documents; + } + + return []; + } catch (error) { + console.error('获取聊天资源失败:', error); + + // 设置错误状态 + dispatch(setResourcesError(error.message || '获取资源失败')); + return []; + } + } +); + +// 获取存在的聊天(如果存在) +export const getExistingChat = (conversationId) => (dispatch, getState) => { + const state = getState(); + const existingChats = state.chat.list.items; + + // 只通过会话ID查找聊天 + return existingChats.find((chat) => chat.conversation_id === conversationId); +}; diff --git a/src/store/store.js b/src/store/store.js index 8cdecb4..fa501d2 100644 --- a/src/store/store.js +++ b/src/store/store.js @@ -7,6 +7,7 @@ import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js'; import chatReducer from './chat/chat.slice.js'; import permissionsReducer from './permissions/permissions.slice.js'; import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js'; +import uploadReducer from './upload/upload.slice.js'; const rootRducer = combineReducers({ auth: authReducer, @@ -15,6 +16,7 @@ const rootRducer = combineReducers({ chat: chatReducer, permissions: permissionsReducer, notificationCenter: notificationCenterReducer, + upload: uploadReducer, }); const persistConfig = { @@ -26,13 +28,21 @@ const persistConfig = { // Persist configuration const persistedReducer = persistReducer(persistConfig, rootRducer); +// Create the store with middleware const store = configureStore({ reducer: persistedReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ - serializableCheck: false, // Disable serializable check for redux-persist + serializableCheck: { + // Ignore these action types + ignoredActions: ['persist/PERSIST'], + // Ignore these field paths in all actions + ignoredActionPaths: ['meta.arg', 'payload.timestamp'], + // Ignore these paths in the state + ignoredPaths: ['items.dates'], + }, }), - devTools: true, + devTools: process.env.NODE_ENV !== 'production', }); // Create the persistor to manage rehydrating the store diff --git a/src/store/upload/upload.slice.js b/src/store/upload/upload.slice.js new file mode 100644 index 0000000..bd20012 --- /dev/null +++ b/src/store/upload/upload.slice.js @@ -0,0 +1,19 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const initialState = { + isUploading: false, +}; + +const uploadSlice = createSlice({ + name: 'upload', + initialState, + reducers: { + setIsUploading: (state, action) => { + console.log('Setting isUploading to:', action.payload); + state.isUploading = action.payload; + }, + }, +}); + +export const { setIsUploading } = uploadSlice.actions; +export default uploadSlice.reducer; diff --git a/src/styles/style.scss b/src/styles/style.scss index 7cd7833..25fcb5c 100644 --- a/src/styles/style.scss +++ b/src/styles/style.scss @@ -444,3 +444,25 @@ padding-right: 0.75rem; } } + +// 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; + } +}