[PR]chats & upload files

This commit is contained in:
Susie Shi 2025-04-07 20:29:16 -04:00 committed by GitHub
commit 3526fb2d60
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 650 additions and 152 deletions

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { fetchChats, deleteChat, createChatRecord } from '../../store/chat/chat.thunks'; import { fetchChats, deleteChat, createChatRecord, createConversation } from '../../store/chat/chat.thunks';
import { showNotification } from '../../store/notification.slice'; import { showNotification } from '../../store/notification.slice';
import ChatSidebar from './ChatSidebar'; import ChatSidebar from './ChatSidebar';
import NewChat from './NewChat'; import NewChat from './NewChat';
@ -51,18 +51,28 @@ export default function Chat() {
if (knowledgeBaseId && !chatId && status === 'succeeded' && !status.includes('loading')) { 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) => { const existingChat = chatHistory.find((chat) => {
// ID // datasets
if (chat.datasets && Array.isArray(chat.datasets)) { if (!chat.datasets || !Array.isArray(chat.datasets)) {
return chat.datasets.some((ds) => ds.id === knowledgeBaseId); return false;
} }
//
if (chat.dataset_id_list && Array.isArray(chat.dataset_id_list)) { // ID
return chat.dataset_id_list.includes(knowledgeBaseId.replace(/-/g, '')); const chatDatasetIds = chat.datasets.map((ds) => ds.id);
}
return false; //
//
return (
knowledgeBaseIds.length === chatDatasetIds.length &&
knowledgeBaseIds.every((id) => chatDatasetIds.includes(id))
);
}); });
console.log('Chat.jsx: existingChat', existingChat); console.log('Chat.jsx: existingChat', existingChat);
if (existingChat) { if (existingChat) {
@ -73,11 +83,10 @@ export default function Chat() {
navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`); navigate(`/chat/${knowledgeBaseId}/${existingChat.conversation_id}`);
} else { } else {
console.log('Chat.jsx: 创建新聊天...'); console.log('Chat.jsx: 创建新聊天...');
// // - 使API
dispatch( dispatch(
createChatRecord({ createConversation({
dataset_id_list: [knowledgeBaseId.replace(/-/g, '')], dataset_id_list: knowledgeBaseIds,
question: '选择当前知识库,创建聊天',
}) })
) )
.unwrap() .unwrap()

View File

@ -11,12 +11,10 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
navigate('/chat'); navigate('/chat');
}; };
const handleMouseEnter = (id) => { const handleToggleDropdown = (e, id) => {
setActiveDropdown(id); e.preventDefault();
}; e.stopPropagation();
setActiveDropdown(activeDropdown === id ? null : id);
const handleMouseLeave = () => {
setActiveDropdown(null);
}; };
const handleDeleteChat = (e, id) => { const handleDeleteChat = (e, id) => {
@ -88,7 +86,9 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
}`} }`}
> >
<Link <Link
to={`/chat/${chat.datasets?.[0]?.id || knowledgeBaseId}/${chat.conversation_id}`} to={`/chat/${chat.datasets?.map((ds) => ds.id).join(',') || knowledgeBaseId}/${
chat.conversation_id
}`}
className={`text-decoration-none d-flex align-items-center gap-2 py-2 text-dark ${ className={`text-decoration-none d-flex align-items-center gap-2 py-2 text-dark ${
chatId === chat.conversation_id ? 'fw-bold' : '' chatId === chat.conversation_id ? 'fw-bold' : ''
}`} }`}
@ -100,12 +100,13 @@ export default function ChatSidebar({ chatHistory = [], onDeleteChat, isLoading
</div> </div>
</Link> </Link>
<div <div
className='dropdown-hover-area position-absolute end-0 top-0 bottom-0 d-none' className='dropdown-area position-absolute end-0 top-0 bottom-0'
style={{ width: '40px' }} style={{ width: '40px' }}
onMouseEnter={() => handleMouseEnter(chat.conversation_id)}
onMouseLeave={handleMouseLeave}
> >
<button className='btn btn-sm position-absolute end-0 top-50 translate-middle-y me-2'> <button
className='btn btn-sm position-absolute end-0 top-50 translate-middle-y me-2'
onClick={(e) => handleToggleDropdown(e, chat.conversation_id)}
>
<SvgIcon className='more-dot' width='5' height='16' /> <SvgIcon className='more-dot' width='5' height='16' />
</button> </button>
{activeDropdown === chat.conversation_id && ( {activeDropdown === chat.conversation_id && (

View File

@ -14,6 +14,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const [inputMessage, setInputMessage] = useState(''); const [inputMessage, setInputMessage] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const hasLoadedDetailRef = useRef({}); // ref
// Redux store // Redux store
const messages = useSelector((state) => state.chat.messages.items); const messages = useSelector((state) => state.chat.messages.items);
@ -35,33 +36,87 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const conversationStatus = useSelector((state) => state.chat.currentChat.status); const conversationStatus = useSelector((state) => state.chat.currentChat.status);
const conversationError = useSelector((state) => state.chat.currentChat.error); const conversationError = useSelector((state) => state.chat.currentChat.error);
//
const createSessionStatus = useSelector((state) => state.chat.createSession?.status);
const createSessionId = useSelector((state) => state.chat.createSession?.sessionId);
// ID
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
// conversationknowledgeBaseIdselectedKnowledgeBaseIds
useEffect(() => {
// 使conversation
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
const datasetIds = conversation.datasets.map((ds) => ds.id);
console.log('从会话中获取知识库列表:', datasetIds);
setSelectedKnowledgeBaseIds(datasetIds);
}
// 使URLID
else if (knowledgeBaseId) {
// IDID
const ids = knowledgeBaseId.split(',').map((id) => id.trim());
console.log('从URL参数中获取知识库列表:', ids);
setSelectedKnowledgeBaseIds(ids);
}
}, [conversation, knowledgeBaseId]);
// //
useEffect(() => { useEffect(() => {
if (chatId) { if (!chatId) return;
setLoading(true);
dispatch(fetchConversationDetail(chatId)) // chatId
.unwrap() if (hasLoadedDetailRef.current[chatId]) {
.catch((error) => { console.log('跳过已加载过的会话详情:', chatId);
// API404 return;
if (error && error !== 'Error: Request failed with status code 404') {
dispatch(
showNotification({
message: `获取聊天详情失败: ${error || '未知错误'}`,
type: 'danger',
})
);
}
})
.finally(() => {
setLoading(false);
});
} }
//
const isNewlyCreatedChat = createSessionStatus === 'succeeded' && createSessionId === chatId;
//
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;
})
.catch((error) => {
console.error('获取会话详情失败:', error);
dispatch(
showNotification({
message: `获取聊天详情失败: ${error || '未知错误'}`,
type: 'danger',
})
);
})
.finally(() => {
setLoading(false);
});
// //
return () => { return () => {
dispatch(resetMessages()); dispatch(resetMessages());
// hasLoadedDetailRef
// hasLoadedDetailRef.current = {}; // ref
}; };
}, [chatId, dispatch]); }, [chatId, dispatch, createSessionStatus, createSessionId]);
// ref
useEffect(() => {
return () => {
hasLoadedDetailRef.current = {};
};
}, []);
// //
useEffect(() => { useEffect(() => {
@ -117,18 +172,31 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
if (!inputMessage.trim() || sendStatus === 'loading') return; if (!inputMessage.trim() || sendStatus === 'loading') return;
console.log('准备发送消息:', inputMessage);
console.log('当前会话ID:', chatId);
// ID // ID
let dataset_id_list = []; let dataset_id_list = [];
if (conversation && conversation.datasets) { // 使
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, '')); dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, ''));
console.log('使用会话中的知识库列表:', dataset_id_list);
} else if (knowledgeBaseId) { } else if (knowledgeBaseId) {
// 使 // 使
dataset_id_list = [knowledgeBaseId.replace(/-/g, '')]; // IDID
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) { } else if (availableDatasets.length > 0) {
// 使 // 使
dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')]; dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')];
console.log('使用可用知识库列表中的第一个:', dataset_id_list);
} }
if (dataset_id_list.length === 0) { if (dataset_id_list.length === 0) {
@ -141,6 +209,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
return; return;
} }
console.log('发送消息参数:', {
dataset_id_list,
question: inputMessage,
conversation_id: chatId,
});
// //
dispatch( dispatch(
createChatRecord({ createChatRecord({
@ -150,12 +224,13 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
}) })
) )
.unwrap() .unwrap()
.then(() => { .then((response) => {
// //
// URLID console.log('消息发送成功:', response);
}) })
.catch((error) => { .catch((error) => {
// //
console.error('消息发送失败:', error);
dispatch( dispatch(
showNotification({ showNotification({
message: `发送失败: ${error}`, message: `发送失败: ${error}`,
@ -264,7 +339,33 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
</div> </div>
</div> </div>
<div className='message-time small text-muted mt-1'> <div className='message-time small text-muted mt-1'>
{message.created_at && new Date(message.created_at).toLocaleTimeString()} {message.created_at &&
(() => {
const messageDate = new Date(message.created_at);
const today = new Date();
//
const isToday =
messageDate.getDate() === today.getDate() &&
messageDate.getMonth() === today.getMonth() &&
messageDate.getFullYear() === today.getFullYear();
//
if (isToday) {
return messageDate.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
});
} else {
return messageDate.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
})()}
{message.is_streaming && ' · 正在生成...'} {message.is_streaming && ' · 正在生成...'}
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { showNotification } from '../../store/notification.slice'; import { showNotification } from '../../store/notification.slice';
import { fetchAvailableDatasets, fetchChats, createChatRecord } from '../../store/chat/chat.thunks'; import { fetchAvailableDatasets, fetchChats, createConversation } from '../../store/chat/chat.thunks';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
export default function NewChat() { export default function NewChat() {
@ -103,34 +103,31 @@ export default function NewChat() {
if (existingChat) { if (existingChat) {
// //
// 使IDURL // 使IDURL
const primaryDatasetId = selectedDatasetIds[0]; const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
console.log(`找到现有聊天记录,直接导航到 /chat/${primaryDatasetId}/${existingChat.conversation_id}`); console.log(
navigate(`/chat/${primaryDatasetId}/${existingChat.conversation_id}`); `找到现有聊天记录,直接导航到 /chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`
);
navigate(`/chat/${knowledgeBaseIdsParam}/${existingChat.conversation_id}`);
} else { } else {
// //
console.log(`未找到现有聊天记录直接创建新的聊天选中的知识库ID: ${selectedDatasetIds.join(', ')}`); console.log(`未找到现有聊天记录创建新会话选中的知识库ID: ${selectedDatasetIds.join(', ')}`);
//
const formattedIds = selectedDatasetIds.map((id) => id.replace(/-/g, ''));
console.log('格式化后的知识库ID:', formattedIds);
try { try {
// // createConversation
const response = await dispatch( const response = await dispatch(
createChatRecord({ createConversation({
dataset_id_list: formattedIds, dataset_id_list: selectedDatasetIds,
question: '选择当前知识库,创建聊天',
}) })
).unwrap(); ).unwrap();
console.log('创建聊天响应:', response); console.log('创建会话响应:', response);
if (response && response.conversation_id) { if (response && response.conversation_id) {
// 使IDURL // 使IDURL
const primaryDatasetId = selectedDatasetIds[0]; const knowledgeBaseIdsParam = selectedDatasetIds.join(',');
console.log(`创建成功,导航到 /chat/${primaryDatasetId}/${response.conversation_id}`); console.log(`创建会话成功,导航到 /chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
navigate(`/chat/${primaryDatasetId}/${response.conversation_id}`); navigate(`/chat/${knowledgeBaseIdsParam}/${response.conversation_id}`);
} else { } else {
throw new Error('未能获取会话ID' + JSON.stringify(response)); throw new Error('未能获取会话ID' + JSON.stringify(response));
} }

View File

@ -316,7 +316,7 @@ export default function DatasetTab({ knowledgeBase }) {
</div> </div>
)} )}
</div> </div>
<div className='w-25'> <div className='w-25 d-none'>
<input <input
type='text' type='text'
className='form-control' className='form-control'
@ -338,7 +338,7 @@ export default function DatasetTab({ knowledgeBase }) {
onDeleteDocument={handleDeleteDocument} onDeleteDocument={handleDeleteDocument}
/> />
{/* Pagination */} {/* Pagination */}
<div className='d-flex justify-content-between align-items-center mt-3'> {/* <div className='d-flex justify-content-between align-items-center mt-3'>
<div> <div>
每页行数: 每页行数:
<select className='form-select form-select d-inline-block ms-2' style={{ width: '70px' }}> <select className='form-select form-select d-inline-block ms-2' style={{ width: '70px' }}>
@ -364,7 +364,7 @@ export default function DatasetTab({ knowledgeBase }) {
</ul> </ul>
</nav> </nav>
</div> </div>
</div> </div> */}
{/* File upload modal */} {/* File upload modal */}
<FileUploadModal <FileUploadModal

View File

@ -1,4 +1,4 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { formatDate } from '../../../../utils/dateUtils'; import { formatDate } from '../../../../utils/dateUtils';
import { deleteKnowledgeBaseDocument } from '../../../../store/knowledgeBase/knowledgeBase.thunks'; import { deleteKnowledgeBaseDocument } from '../../../../store/knowledgeBase/knowledgeBase.thunks';
@ -9,10 +9,67 @@ import DocumentPreviewModal from './DocumentPreviewModal';
*/ */
const DocumentList = ({ knowledgeBaseId }) => { const DocumentList = ({ knowledgeBaseId }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { items, loading, pagination } = useSelector((state) => state.knowledgeBase.documents); const { items, loading, error } = useSelector((state) => state.knowledgeBase.documents);
const [previewModalVisible, setPreviewModalVisible] = useState(false); const [previewModalVisible, setPreviewModalVisible] = useState(false);
const [selectedDocumentId, setSelectedDocumentId] = useState(null); const [selectedDocumentId, setSelectedDocumentId] = useState(null);
//
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [displayedItems, setDisplayedItems] = useState([]);
const [totalPages, setTotalPages] = useState(1);
//
useEffect(() => {
if (!items || items.length === 0) {
setDisplayedItems([]);
setTotalPages(1);
return;
}
//
const total = Math.ceil(items.length / pageSize);
setTotalPages(total);
//
let page = currentPage;
if (page > total) {
page = total;
setCurrentPage(page);
}
//
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, items.length);
setDisplayedItems(items.slice(startIndex, endIndex));
console.log(`前端分页: 总项目 ${items.length}, 当前页 ${page}/${total}, 显示 ${startIndex + 1}-${endIndex}`);
}, [items, currentPage, pageSize]);
//
const handlePageChange = (page) => {
setCurrentPage(page);
};
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
};
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
};
//
useEffect(() => {
console.log('DocumentList 渲染 - 知识库ID:', knowledgeBaseId);
console.log('文档列表状态:', { items, loading, error });
console.log('文档列表项数:', items ? items.length : 0);
}, [knowledgeBaseId, items, loading, error]);
const handleDeleteDocument = (documentId) => { const handleDeleteDocument = (documentId) => {
if (window.confirm('确定要删除此文档吗?')) { if (window.confirm('确定要删除此文档吗?')) {
dispatch( dispatch(
@ -35,6 +92,7 @@ const DocumentList = ({ knowledgeBaseId }) => {
}; };
if (loading) { if (loading) {
console.log('DocumentList - 加载中...');
return ( return (
<div className='text-center py-4'> <div className='text-center py-4'>
<div className='spinner-border text-primary' role='status'> <div className='spinner-border text-primary' role='status'>
@ -44,7 +102,8 @@ const DocumentList = ({ knowledgeBaseId }) => {
); );
} }
if (items.length === 0) { if (!items || items.length === 0) {
console.log('DocumentList - 暂无文档');
return ( return (
<div className='text-center py-4 text-muted'> <div className='text-center py-4 text-muted'>
<p>暂无文档请上传文档</p> <p>暂无文档请上传文档</p>
@ -52,6 +111,7 @@ const DocumentList = ({ knowledgeBaseId }) => {
); );
} }
console.log('DocumentList - 渲染文档列表', displayedItems);
return ( return (
<> <>
<div className='table-responsive'> <div className='table-responsive'>
@ -65,22 +125,30 @@ const DocumentList = ({ knowledgeBaseId }) => {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((doc) => ( {displayedItems.map((doc) => (
<tr key={doc.id}> <tr key={doc.id || doc.document_id}>
<td>{doc.document_name}</td> <td>
<td>{formatDateTime(doc.create_time)}</td> <div
<td>{formatDateTime(doc.update_time)}</td> className='text-truncate'
style={{ maxWidth: '250px' }}
title={doc.document_name || doc.name}
>
{doc.document_name || doc.name}
</div>
</td>
<td className='text-nowrap'>{formatDateTime(doc.create_time || doc.created_at)}</td>
<td className='text-nowrap'>{formatDateTime(doc.update_time || doc.updated_at)}</td>
<td> <td>
<div className='btn-group' role='group'> <div className='btn-group' role='group'>
<button <button
className='btn btn-sm btn-outline-dark me-2' className='btn btn-sm btn-outline-dark me-2'
onClick={() => handlePreviewDocument(doc.document_id)} onClick={() => handlePreviewDocument(doc.document_id || doc.id)}
> >
预览 预览
</button> </button>
<button <button
className='btn btn-sm btn-outline-danger' className='btn btn-sm btn-outline-danger'
onClick={() => handleDeleteDocument(doc.document_id)} onClick={() => handleDeleteDocument(doc.document_id || doc.id)}
> >
删除 删除
</button> </button>
@ -91,9 +159,33 @@ const DocumentList = ({ knowledgeBaseId }) => {
</tbody> </tbody>
</table> </table>
{pagination.total > 0 && ( {/* 分页控件 */}
{items.length > 0 && (
<div className='d-flex justify-content-between align-items-center mt-3'> <div className='d-flex justify-content-between align-items-center mt-3'>
<p className='text-muted mb-0'> {pagination.total} 条记录</p> <div className='text-muted mb-0'>
{items.length} 条记录 {currentPage}/{totalPages}
</div>
<nav aria-label='文档列表分页'>
<ul className='pagination dark-pagination pagination-sm mb-0'>
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className='page-link' onClick={handlePrevPage}>
上一页
</button>
</li>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<li key={page} className={`page-item ${page === currentPage ? 'active' : ''}`}>
<button className='page-link' onClick={() => handlePageChange(page)}>
{page}
</button>
</li>
))}
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className='page-link' onClick={handleNextPage}>
下一页
</button>
</li>
</ul>
</nav>
</div> </div>
)} )}
</div> </div>

View File

@ -34,6 +34,8 @@ const DocumentPreviewModal = ({ show, documentId, knowledgeBaseId, onClose }) =>
// //
setDocumentContent(response); setDocumentContent(response);
} }
console.log(documentContent);
} catch (error) { } catch (error) {
console.error('获取文档内容失败:', error); console.error('获取文档内容失败:', error);
dispatch( dispatch(
@ -88,8 +90,8 @@ const DocumentPreviewModal = ({ show, documentId, knowledgeBaseId, onClose }) =>
</div> </div>
) : documentContent ? ( ) : documentContent ? (
<div className='document-content'> <div className='document-content'>
{documentContent.content && {documentContent?.paragraphs.length > 0 &&
documentContent.content.map((section, index) => { documentContent.paragraphs.map((section, index) => {
let contentDisplay; let contentDisplay;
try { try {
// JSON // JSON

View File

@ -9,9 +9,10 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const fileInputRef = useRef(null); const fileInputRef = useRef(null);
const modalRef = useRef(null); const modalRef = useRef(null);
const [selectedFile, setSelectedFile] = useState(null); const [selectedFiles, setSelectedFiles] = useState([]);
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [fileError, setFileError] = useState(''); const [fileError, setFileError] = useState('');
const [uploadResults, setUploadResults] = useState(null);
// //
const handleUploadAreaClick = () => { const handleUploadAreaClick = () => {
@ -29,59 +30,68 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
e.stopPropagation(); e.stopPropagation();
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileSelected(e.dataTransfer.files[0]); handleFilesSelected(e.dataTransfer.files);
} }
}; };
const handleFileChange = (e) => { const handleFileChange = (e) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
handleFileSelected(e.target.files[0]); handleFilesSelected(e.target.files);
} }
}; };
const handleFileSelected = (file) => { const handleFilesSelected = (files) => {
setFileError(''); setFileError('');
setSelectedFile(file); // FileList
const filesArray = Array.from(files);
setSelectedFiles((prev) => [...prev, ...filesArray]);
};
const removeFile = (index) => {
setSelectedFiles((prev) => prev.filter((_, i) => i !== index));
}; };
const resetFileInput = () => { const resetFileInput = () => {
setSelectedFile(null); setSelectedFiles([]);
setFileError(''); setFileError('');
setUploadResults(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
}; };
const handleUpload = async () => { const handleUpload = async () => {
if (!selectedFile) { if (selectedFiles.length === 0) {
setFileError('请选择要上传的文件'); setFileError('请至少选择一个要上传的文件');
return; return;
} }
setIsUploading(true); setIsUploading(true);
setUploadResults(null);
try { try {
await dispatch( const result = await dispatch(
uploadDocument({ uploadDocument({
knowledge_base_id: knowledgeBaseId, knowledge_base_id: knowledgeBaseId,
file: selectedFile, files: selectedFiles,
}) })
).unwrap(); ).unwrap();
// //
dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBaseId })); dispatch(getKnowledgeBaseDocuments({ knowledge_base_id: knowledgeBaseId }));
// Reset the file input //
resetFileInput(); setUploadResults(result);
// // 3
onClose(); if (result.failed_count === 0) {
setTimeout(() => {
handleClose();
}, 3000);
}
} catch (error) { } catch (error) {
console.error('Upload failed:', error); console.error('Upload failed:', error);
setFileError('文件上传失败: ' + (error?.message || '未知错误')); setFileError('文件上传失败: ' + (error?.message || '未知错误'));
//
resetFileInput();
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
@ -130,9 +140,11 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
<div <div
className='modal-content bg-white rounded shadow' className='modal-content bg-white rounded shadow'
style={{ style={{
width: '500px', width: '600px',
maxWidth: '90%', maxWidth: '90%',
padding: '20px', padding: '20px',
maxHeight: '90vh',
overflowY: 'auto',
}} }}
> >
<div className='modal-header d-flex justify-content-between align-items-center mb-3'> <div className='modal-header d-flex justify-content-between align-items-center mb-3'>
@ -162,22 +174,92 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
onChange={handleFileChange} onChange={handleFileChange}
accept='.pdf,.doc,.docx,.txt,.md,.csv,.xlsx,.xls' accept='.pdf,.doc,.docx,.txt,.md,.csv,.xlsx,.xls'
disabled={isUploading} disabled={isUploading}
multiple
/> />
{selectedFile ? ( <div>
<div> <p className='mb-1'>点击或拖拽文件到此处上传</p>
<p className='mb-1'>已选择文件</p> <p className='text-muted small mb-0'>支持 PDF, Word, Excel, TXT, Markdown, CSV 等格式</p>
<p className='fw-bold mb-0'>{selectedFile.name}</p> </div>
</div>
) : (
<div>
<p className='mb-1'>点击或拖拽文件到此处上传</p>
<p className='text-muted small mb-0'>
支持 PDF, Word, Excel, TXT, Markdown, CSV 等格式
</p>
</div>
)}
{fileError && <div className='text-danger mt-2'>{fileError}</div>} {fileError && <div className='text-danger mt-2'>{fileError}</div>}
</div> </div>
{/* 选择的文件列表 */}
{selectedFiles.length > 0 && (
<div className='mb-3'>
<p className='fw-bold mb-2'>已选择 {selectedFiles.length} 个文件</p>
<ul className='list-group'>
{selectedFiles.map((file, index) => (
<li
key={index}
className='list-group-item d-flex justify-content-between align-items-center'
>
<div
className='d-flex align-items-center'
style={{ maxWidth: 'calc(100% - 70px)' }}
>
<div className='text-truncate'>{file.name}</div>
<div className='text-nowrap ms-2 text-muted' style={{ flexShrink: 0 }}>
({(file.size / 1024).toFixed(0)} KB)
</div>
</div>
{!isUploading && (
<button
className='btn btn-sm btn-outline-danger'
onClick={() => removeFile(index)}
style={{ minWidth: '60px' }}
>
移除
</button>
)}
</li>
))}
</ul>
</div>
)}
{/* 上传结果显示 */}
{uploadResults && (
<div className='mt-3 border rounded p-3'>
<h6 className='mb-2'>上传结果</h6>
<p className='mb-2'>
总文件: {uploadResults.total_files}, 成功: {uploadResults.uploaded_count}, 失败:{' '}
{uploadResults.failed_count}
</p>
{uploadResults.documents && uploadResults.documents.length > 0 && (
<>
<p className='mb-1 fw-bold text-success'>上传成功:</p>
<ul className='list-group mb-2'>
{uploadResults.documents.map((doc) => (
<li key={doc.id} className='list-group-item py-2 d-flex align-items-center'>
<span className='badge bg-success me-2'></span>
<span className='text-truncate'>{doc.name}</span>
</li>
))}
</ul>
</>
)}
{uploadResults.failed_documents && uploadResults.failed_documents.length > 0 && (
<>
<p className='mb-1 fw-bold text-danger'>上传失败:</p>
<ul className='list-group'>
{uploadResults.failed_documents.map((doc, index) => (
<li key={index} className='list-group-item py-2 d-flex align-items-center'>
<span className='badge bg-danger me-2'></span>
<div className='text-truncate'>
{doc.name}
{doc.reason && (
<small className='ms-2 text-danger'>({doc.reason})</small>
)}
</div>
</li>
))}
</ul>
</>
)}
</div>
)}
</div> </div>
<div className='modal-footer gap-2'> <div className='modal-footer gap-2'>
<button type='button' className='btn btn-secondary' onClick={handleClose} disabled={isUploading}> <button type='button' className='btn btn-secondary' onClick={handleClose} disabled={isUploading}>
@ -187,7 +269,7 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
type='button' type='button'
className='btn btn-dark' className='btn btn-dark'
onClick={handleUpload} onClick={handleUpload}
disabled={!selectedFile || isUploading} disabled={selectedFiles.length === 0 || isUploading}
> >
{isUploading ? ( {isUploading ? (
<> <>
@ -199,7 +281,7 @@ const FileUploadModal = ({ show, knowledgeBaseId, onClose }) => {
上传中... 上传中...
</> </>
) : ( ) : (
'上传文档' `上传文档${selectedFiles.length > 0 ? ` (${selectedFiles.length})` : ''}`
)} )}
</button> </button>
</div> </div>

View File

@ -885,6 +885,25 @@ export const mockPost = async (url, data, isMultipart = false) => {
}; };
} }
// 创建会话 (不发送消息)
if (url === '/chat-history/create_conversation') {
const { dataset_id_list } = data;
// 生成新的会话ID
const conversation_id = `conv-${uuidv4()}`;
console.log(`[MOCK API] 创建新会话: ${conversation_id}, 知识库: ${dataset_id_list.join(', ')}`);
return {
code: 200,
message: '会话创建成功',
data: {
conversation_id: conversation_id,
dataset_id_list: dataset_id_list,
},
};
}
// 拒绝权限申请 // 拒绝权限申请
if (url === '/permissions/reject/') { if (url === '/permissions/reject/') {
const { id, responseMessage } = data; const { id, responseMessage } = data;
@ -1026,7 +1045,47 @@ export const mockDelete = async (url) => {
return { success: true }; return { success: true };
} }
// Delete chat // Delete chat (new endpoint)
if (url.match(/^\/chat-history\/delete_conversation/)) {
const params = new URLSearchParams(url.split('?')[1]);
const conversationId = params.get('conversation_id');
if (!conversationId) {
throw { response: { status: 400, data: { message: 'Missing conversation_id parameter' } } };
}
console.log(`[MOCK API] Deleting conversation: ${conversationId}`);
// 查找并删除会话
const index = mockChatHistory.findIndex(
(chat) => chat.id === conversationId || chat.conversation_id === conversationId
);
if (index === -1) {
// 即使找不到也返回成功保持与API一致的行为
console.log(`[MOCK API] Conversation not found: ${conversationId}, but returning success`);
return {
code: 200,
message: '会话删除成功',
data: {},
};
}
mockChatHistory.splice(index, 1);
// 清除会话消息
if (chatMessages[conversationId]) {
delete chatMessages[conversationId];
}
return {
code: 200,
message: '会话删除成功',
data: {},
};
}
// Delete chat (old endpoint - keeping for backward compatibility)
if (url.match(/^\/chat-history\/[^/]+\/$/)) { if (url.match(/^\/chat-history\/[^/]+\/$/)) {
const id = url.split('/')[2]; const id = url.split('/')[2];
return { data: mockDeleteChat(id) }; return { data: mockDeleteChat(id) };

View File

@ -7,6 +7,7 @@ import {
deleteChat, deleteChat,
createChatRecord, createChatRecord,
fetchConversationDetail, fetchConversationDetail,
createConversation,
} from './chat.thunks'; } from './chat.thunks';
import { fetchMessages, sendMessage } from './chat.messages.thunks'; import { fetchMessages, sendMessage } from './chat.messages.thunks';
@ -276,6 +277,32 @@ const chatSlice = createSlice({
state.sendMessage.error = action.error.message; state.sendMessage.error = action.error.message;
}) })
// 处理创建会话
.addCase(createConversation.pending, (state) => {
state.createSession.status = 'loading';
state.createSession.error = null;
})
.addCase(createConversation.fulfilled, (state, action) => {
state.createSession.status = 'succeeded';
state.createSession.sessionId = action.payload.conversation_id;
// 当前聊天设置 - 使用与fetchConversationDetail相同的数据结构
state.currentChat.data = {
conversation_id: action.payload.conversation_id,
datasets: action.payload.datasets || [],
// 添加其他必要的字段确保与fetchConversationDetail返回的数据结构兼容
messages: [],
create_time: new Date().toISOString(),
update_time: new Date().toISOString(),
};
state.currentChat.status = 'succeeded';
state.currentChat.error = null;
})
.addCase(createConversation.rejected, (state, action) => {
state.createSession.status = 'failed';
state.createSession.error = action.payload || action.error.message;
})
// 处理获取可用知识库 // 处理获取可用知识库
.addCase(fetchAvailableDatasets.pending, (state) => { .addCase(fetchAvailableDatasets.pending, (state) => {
state.availableDatasets.status = 'loading'; state.availableDatasets.status = 'loading';

View File

@ -78,7 +78,7 @@ export const updateChat = createAsyncThunk('chat/updateChat', async ({ id, data
*/ */
export const deleteChat = createAsyncThunk('chat/deleteChat', async (conversationId, { rejectWithValue }) => { export const deleteChat = createAsyncThunk('chat/deleteChat', async (conversationId, { rejectWithValue }) => {
try { try {
const response = await del(`/chat-history/conversation/${conversationId}/`); const response = await del(`/chat-history/delete_conversation?conversation_id=${conversationId}`);
// 处理返回格式 // 处理返回格式
if (response && response.code === 200) { if (response && response.code === 200) {
@ -128,13 +128,8 @@ export const createChatRecord = createAsyncThunk(
const requestBody = { const requestBody = {
question, question,
dataset_id_list, dataset_id_list,
conversation_id,
}; };
// 如果存在对话 ID添加到请求中
if (conversation_id) {
requestBody.conversation_id = conversation_id;
}
console.log('准备发送聊天请求:', requestBody); console.log('准备发送聊天请求:', requestBody);
// 先添加用户消息到聊天窗口 // 先添加用户消息到聊天窗口
@ -366,8 +361,23 @@ export const createChatRecord = createAsyncThunk(
*/ */
export const fetchConversationDetail = createAsyncThunk( export const fetchConversationDetail = createAsyncThunk(
'chat/fetchConversationDetail', 'chat/fetchConversationDetail',
async (conversationId, { rejectWithValue, dispatch }) => { async (conversationId, { rejectWithValue, dispatch, getState }) => {
try { try {
// 先检查是否是刚创建的会话
const state = getState();
const createSession = state.chat.createSession || {};
const currentChat = state.chat.currentChat.data;
// 如果是刚创建成功的会话且会话ID匹配则直接返回现有会话数据
if (
createSession.status === 'succeeded' &&
createSession.sessionId === conversationId &&
currentChat?.conversation_id === conversationId
) {
console.log('使用新创建的会话数据,跳过详情请求:', conversationId);
return currentChat;
}
const response = await get('/chat-history/conversation_detail', { const response = await get('/chat-history/conversation_detail', {
params: { conversation_id: conversationId }, params: { conversation_id: conversationId },
}); });
@ -386,8 +396,11 @@ export const fetchConversationDetail = createAsyncThunk(
return rejectWithValue('获取会话详情失败'); return rejectWithValue('获取会话详情失败');
} catch (error) { } catch (error) {
// 如果是新聊天API会返回404此时不返回错误 // 明确检查是否是404错误
if (error.response && error.response.status === 404) { const is404Error = error.response && error.response.status === 404;
if (is404Error) {
console.log('会话未找到,可能是新创建的会话:', conversationId);
return null; return null;
} }
@ -396,3 +409,83 @@ export const fetchConversationDetail = createAsyncThunk(
} }
} }
); );
/**
* 创建新会话仅获取会话ID不发送消息
* @param {Object} params - 参数
* @param {string[]} params.dataset_id_list - 知识库ID列表
*/
export const createConversation = createAsyncThunk(
'chat/createConversation',
async ({ dataset_id_list }, { dispatch, getState, rejectWithValue }) => {
try {
console.log('创建新会话知识库ID列表:', dataset_id_list);
const params = {
dataset_id_list: dataset_id_list,
};
const response = await post('/chat-history/create_conversation/', params);
if (response && response.code === 200) {
const conversationData = response.data;
console.log('会话创建成功:', conversationData);
// 获取知识库信息
const state = getState();
const availableDatasets = state.chat.availableDatasets.items || [];
// 创建一个新的聊天记录对象添加到历史列表
const newChatEntry = {
conversation_id: conversationData.conversation_id,
datasets: dataset_id_list.map((id) => {
// 尝试查找知识库名称
const formattedId = id.includes('-')
? id
: id.replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, '$1-$2-$3-$4-$5');
const dataset = availableDatasets.find((ds) => ds.id === formattedId);
return {
id: formattedId,
name: dataset?.name || '新知识库对话',
};
}),
create_time: new Date().toISOString(),
last_message: '',
message_count: 0,
};
// 更新聊天历史列表
dispatch({
type: 'chat/fetchChats/fulfilled',
payload: {
results: [newChatEntry],
total: 1,
append: true, // 标记为追加,而不是替换
},
});
// 设置为当前聊天
dispatch(
setCurrentChat({
conversation_id: conversationData.conversation_id,
datasets: newChatEntry.datasets,
})
);
return conversationData;
}
return rejectWithValue('创建会话失败');
} catch (error) {
console.error('创建会话失败:', error);
// 显示错误通知
dispatch(
showNotification({
message: `创建会话失败: ${error.message || '未知错误'}`,
type: 'danger',
})
);
return rejectWithValue(error.message || '创建会话失败');
}
}
);

View File

@ -218,9 +218,13 @@ const knowledgeBaseSlice = createSlice({
state.documents.items = action.payload.items || []; state.documents.items = action.payload.items || [];
state.documents.pagination = { state.documents.pagination = {
total: action.payload.total || 0, total: action.payload.total || 0,
page: action.payload.page || 1, page: 1,
page_size: action.payload.page_size || 10, page_size: 10,
}; };
console.log('文档数据已更新到store:', {
itemsCount: state.documents.items.length,
items: state.documents.items,
});
}) })
.addCase(getKnowledgeBaseDocuments.rejected, (state, action) => { .addCase(getKnowledgeBaseDocuments.rejected, (state, action) => {
state.documents.loading = false; state.documents.loading = false;

View File

@ -200,32 +200,54 @@ export const requestKnowledgeBaseAccess = createAsyncThunk(
); );
/** /**
* Upload a document to a knowledge base * Upload documents to a knowledge base
* @param {Object} params - Upload parameters * @param {Object} params - Upload parameters
* @param {string} params.knowledge_base_id - Knowledge base ID * @param {string} params.knowledge_base_id - Knowledge base ID
* @param {File} params.file - File to upload * @param {File[]} params.files - Files to upload
*/ */
export const uploadDocument = createAsyncThunk( export const uploadDocument = createAsyncThunk(
'knowledgeBase/uploadDocument', 'knowledgeBase/uploadDocument',
async ({ knowledge_base_id, file }, { rejectWithValue, dispatch }) => { async ({ knowledge_base_id, files }, { rejectWithValue, dispatch }) => {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file);
// 支持单文件和多文件上传
if (Array.isArray(files)) {
// 多文件上传
files.forEach(file => {
formData.append('files', file);
});
} else {
// 单文件上传(向后兼容)
formData.append('files', files);
}
const response = await post(`/knowledge-bases/${knowledge_base_id}/upload_document/`, formData, true); const response = await post(`/knowledge-bases/${knowledge_base_id}/upload_document/`, formData, true);
// 处理新的返回格式
if (response.data && response.data.code === 200) {
const result = response.data.data;
// 使用API返回的消息作为通知
dispatch(
showNotification({
type: 'success',
message: response.data.message || `文档上传完成,成功: ${result.uploaded_count},失败: ${result.failed_count}`,
})
);
return result;
}
dispatch( dispatch(
showNotification({ showNotification({
type: 'success', type: 'success',
message: `文档 ${file.name} 上传成功`, message: Array.isArray(files)
? `${files.length} 个文档上传成功`
: `文档 ${files.name} 上传成功`,
}) })
); );
// 处理新的返回格式
if (response.data && response.data.code === 200) {
return response.data.data;
}
return response.data; return response.data;
} catch (error) { } catch (error) {
const errorMessage = error.response?.data?.message || error.message || '文档上传失败'; const errorMessage = error.response?.data?.message || error.message || '文档上传失败';
@ -244,24 +266,33 @@ export const uploadDocument = createAsyncThunk(
* Get documents list for a knowledge base * Get documents list for a knowledge base
* @param {Object} params - Parameters * @param {Object} params - Parameters
* @param {string} params.knowledge_base_id - Knowledge base ID * @param {string} params.knowledge_base_id - Knowledge base ID
* @param {number} params.page - Page number (default: 1)
* @param {number} params.page_size - Page size (default: 10)
*/ */
export const getKnowledgeBaseDocuments = createAsyncThunk( export const getKnowledgeBaseDocuments = createAsyncThunk(
'knowledgeBase/getDocuments', 'knowledgeBase/getDocuments',
async ({ knowledge_base_id, page = 1, page_size = 10 }, { rejectWithValue }) => { async ({ knowledge_base_id }, { rejectWithValue }) => {
try { try {
const response = await get(`/knowledge-bases/${knowledge_base_id}/documents/`, { console.log('获取知识库文档列表:', knowledge_base_id);
params: { page, page_size }, const { data, code } = await get(`/knowledge-bases/${knowledge_base_id}/documents`);
}); console.log('文档列表API响应:', { data, code });
// 处理返回格式 // 处理返回格式
if (response.data && response.data.code === 200) { if (code === 200) {
return response.data.data; console.log('API返回数据:', data);
return {
items: data || [],
total: (data || []).length
};
} else {
// 未知格式,尝试提取数据
const items = data?.items || data || [];
console.log('未识别格式,提取数据:', items);
return {
items: items,
total: items.length
};
} }
return response.data;
} catch (error) { } catch (error) {
console.error('获取知识库文档失败:', error);
return rejectWithValue(error.response?.data?.message || '获取文档列表失败'); return rejectWithValue(error.response?.data?.message || '获取文档列表失败');
} }
} }