Compare commits

...

2 Commits

Author SHA1 Message Date
6a654950a5 [dev]gmail side panel 2025-04-17 20:38:06 -04:00
2b89db7301 [dev]gmail funcs 2025-04-17 19:19:57 -04:00
16 changed files with 1420 additions and 1892 deletions

View File

@ -1,83 +0,0 @@
import React, { useState, useEffect } from 'react';
import { switchToMockApi, switchToRealApi, checkServerStatus } from '../services/api';
export default function ApiModeSwitch() {
const [isMockMode, setIsMockMode] = useState(false);
const [isChecking, setIsChecking] = useState(false);
const [showNotification, setShowNotification] = useState(false);
const [notification, setNotification] = useState({ message: '', type: 'info' });
//
useEffect(() => {
const checkStatus = async () => {
setIsChecking(true);
const isServerUp = await checkServerStatus();
setIsMockMode(!isServerUp);
setIsChecking(false);
};
checkStatus();
}, []);
// API
const handleToggleMode = async () => {
setIsChecking(true);
if (isMockMode) {
// API
const isServerUp = await switchToRealApi();
if (isServerUp) {
setIsMockMode(false);
showNotificationMessage('已切换到真实API模式', 'success');
} else {
showNotificationMessage('服务器连接失败,继续使用模拟数据', 'warning');
}
} else {
// API
switchToMockApi();
setIsMockMode(true);
showNotificationMessage('已切换到模拟API模式', 'info');
}
setIsChecking(false);
};
//
const showNotificationMessage = (message, type) => {
setNotification({ message, type });
setShowNotification(true);
// 3
setTimeout(() => {
setShowNotification(false);
}, 3000);
};
return (
<div className='api-mode-switch'>
<div className='d-flex align-items-center'>
<div className='form-check form-switch me-2'>
<input
className='form-check-input'
type='checkbox'
id='apiModeToggle'
checked={isMockMode}
onChange={handleToggleMode}
disabled={isChecking}
/>
<label className='form-check-label' htmlFor='apiModeToggle'>
{isChecking ? '检查中...' : isMockMode ? '模拟API模式' : '真实API模式'}
</label>
</div>
{isMockMode && <span className='badge bg-warning text-dark'>使用本地模拟数据</span>}
{!isMockMode && <span className='badge bg-success'>已连接到后端服务器</span>}
</div>
{showNotification && (
<div className={`alert alert-${notification.type} mt-2 py-2 px-3`} style={{ fontSize: '0.85rem' }}>
{notification.message}
</div>
)}
</div>
);
}

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, createConversation } from '../../store/chat/chat.thunks'; import { fetchChats, deleteChat, 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';
@ -17,12 +17,13 @@ export default function Chat() {
items: chatHistory, items: chatHistory,
status, status,
error, error,
} = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null }); } = useSelector((state) => state.chat.chats || { items: [], status: 'idle', error: null });
const operationStatus = useSelector((state) => state.chat.createSession?.status); const operationStatus = useSelector((state) => state.chat.chatOperation?.status);
const operationError = useSelector((state) => state.chat.createSession?.error); const operationError = useSelector((state) => state.chat.chatOperation?.error);
// //
useEffect(() => { useEffect(() => {
console.log(chatHistory);
dispatch(fetchChats({ page: 1, page_size: 20 })); dispatch(fetchChats({ page: 1, page_size: 20 }));
}, [dispatch]); }, [dispatch]);

View File

@ -0,0 +1,252 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { setUserGoal, getConversationSummary, getRecommendedReply } from '../../store/talentChat/talentChat.thunks';
import { showNotification } from '../../store/notification.slice';
import SafeMarkdown from '../../components/SafeMarkdown';
export default function ChatSidePanel({ chatId, talentEmail }) {
const dispatch = useDispatch();
const [goal, setGoal] = useState('');
const [loading, setLoading] = useState({
goal: false,
summary: false,
reply: false,
});
const [copySuccess, setCopySuccess] = useState(false);
// Get data from Redux store
const userGoal = useSelector((state) => state.talentChat?.userGoal?.data);
const conversationSummary = useSelector((state) => state.talentChat?.conversationSummary?.data);
const recommendedReply = useSelector((state) => state.talentChat?.recommendedReply?.data);
// Load conversation summary and recommended reply when component mounts
useEffect(() => {
if (talentEmail && chatId) {
handleGetSummary();
handleGetRecommendedReply();
}
}, [chatId, talentEmail]);
// Reset copy success state after 2 seconds
useEffect(() => {
if (copySuccess) {
const timer = setTimeout(() => {
setCopySuccess(false);
}, 2000);
return () => clearTimeout(timer);
}
}, [copySuccess]);
const handleGoalSubmit = async (e) => {
e.preventDefault();
if (!goal.trim()) return;
setLoading((prev) => ({ ...prev, goal: true }));
try {
await dispatch(setUserGoal(goal)).unwrap();
dispatch(
showNotification({
message: '聊天目标设置成功',
type: 'success',
})
);
} catch (error) {
dispatch(
showNotification({
message: `设置目标失败: ${error}`,
type: 'danger',
})
);
} finally {
setLoading((prev) => ({ ...prev, goal: false }));
}
};
const handleGetSummary = async () => {
if (!talentEmail) return;
setLoading((prev) => ({ ...prev, summary: true }));
try {
await dispatch(getConversationSummary(talentEmail)).unwrap();
} catch (error) {
dispatch(
showNotification({
message: `获取聊天总结失败: ${error}`,
type: 'danger',
})
);
} finally {
setLoading((prev) => ({ ...prev, summary: false }));
}
};
const handleGetRecommendedReply = async () => {
if (!chatId || !talentEmail) return;
setLoading((prev) => ({ ...prev, reply: true }));
try {
await dispatch(
getRecommendedReply({
conversation_id: chatId,
talent_email: talentEmail,
})
).unwrap();
} catch (error) {
dispatch(
showNotification({
message: `获取推荐话术失败: ${error}`,
type: 'danger',
})
);
} finally {
setLoading((prev) => ({ ...prev, reply: false }));
}
};
const handleCopyReply = () => {
// Get reply text from the appropriate property path
const replyText = recommendedReply?.reply || '';
if (replyText) {
navigator.clipboard
.writeText(replyText)
.then(() => {
setCopySuccess(true);
dispatch(
showNotification({
message: '已复制到剪贴板',
type: 'success',
})
);
})
.catch((err) => {
console.error('复制失败:', err);
dispatch(
showNotification({
message: '复制失败,请手动复制',
type: 'danger',
})
);
});
}
};
return (
<div className='chat-side-panel d-flex flex-column h-100'>
{/* 聊天目标 */}
<div className='panel-section p-3 border-bottom' style={{ flex: '0 0 auto' }}>
<h5 className='section-title mb-3'>
<span className='bg-warning bg-opacity-25 px-2 py-1 rounded'>聊天目标</span>
</h5>
<form onSubmit={handleGoalSubmit}>
<textarea
className='form-control mb-2'
rows='3'
placeholder='请输入聊天目标...'
value={goal}
onChange={(e) => setGoal(e.target.value)}
></textarea>
<button type='submit' className='btn btn-sm btn-primary' disabled={loading.goal || !goal.trim()}>
{loading.goal ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
提交中...
</>
) : (
'设置目标'
)}
</button>
</form>
{userGoal && (
<div className='mt-2 p-2 bg-light rounded'>
<small className='fw-bold'>当前目标</small>
<p className='mb-0 small'>
{userGoal.content ||
(typeof userGoal === 'string' ? userGoal : userGoal?.data?.content || '')}
</p>
</div>
)}
</div>
{/* 历史聊天记录总结 */}
<div className='panel-section p-3 border-bottom' style={{ flex: '1 0 50%', overflowY: 'auto' }}>
<h5 className='section-title mb-3'>
<span className='bg-warning bg-opacity-25 px-2 py-1 rounded'>历史聊天记录总结</span>
<button
className='btn btn-sm btn-outline-secondary ms-2'
onClick={handleGetSummary}
disabled={loading.summary || !talentEmail}
>
{loading.summary ? (
<span className='spinner-border spinner-border-sm' role='status' aria-hidden='true'></span>
) : (
'刷新'
)}
</button>
</h5>
{loading.summary ? (
<div className='text-center py-3'>
<div className='spinner-border text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<p className='mt-2 text-muted'>获取聊天总结...</p>
</div>
) : conversationSummary?.summary ? (
<div className='summary-content'>
<SafeMarkdown content={conversationSummary.summary} />
</div>
) : (
<p className='text-muted'>暂无聊天总结</p>
)}
</div>
{/* 推荐聊天话术 */}
<div className='panel-section p-3' style={{ flex: '1 0 50%', overflowY: 'auto' }}>
<h5 className='section-title mb-3'>
<span className='bg-warning bg-opacity-25 px-2 py-1 rounded'>推荐聊天话术</span>
<button
className='btn btn-sm btn-outline-secondary ms-2'
onClick={handleGetRecommendedReply}
disabled={loading.reply || !chatId || !talentEmail}
>
{loading.reply ? (
<span className='spinner-border spinner-border-sm' role='status' aria-hidden='true'></span>
) : (
'刷新'
)}
</button>
</h5>
{loading.reply ? (
<div className='text-center py-3'>
<div className='spinner-border text-secondary' role='status'>
<span className='visually-hidden'>加载中...</span>
</div>
<p className='mt-2 text-muted'>生成话术中...</p>
</div>
) : recommendedReply?.reply || recommendedReply?.status === 'success' ? (
<div
className='recommended-reply bg-light p-3 rounded position-relative'
style={{ whiteSpace: 'pre-line', fontFamily: 'Arial, sans-serif' }}
>
<SafeMarkdown content={recommendedReply.reply} />
<div className='position-absolute top-0 end-0 m-2'>
<button
className='btn btn-sm btn-outline-primary'
onClick={handleCopyReply}
title='复制话术内容'
>
{copySuccess ? '已复制 ✓' : '复制'}
</button>
</div>
</div>
) : (
<p className='text-muted'>暂无推荐话术</p>
)}
</div>
</div>
);
}

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { fetchMessages } from '../../store/chat/chat.messages.thunks'; import { fetchMessages } from '../../store/chat/chat.messages.thunks';
import { resetMessages, resetSendMessageStatus, addMessage } from '../../store/chat/chat.slice'; import { resetMessageOperation, addMessage } from '../../store/chat/chat.slice';
import { showNotification } from '../../store/notification.slice'; import { showNotification } from '../../store/notification.slice';
import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks'; import { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks'; import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
import SafeMarkdown from '../../components/SafeMarkdown'; import SafeMarkdown from '../../components/SafeMarkdown';
import ChatSidePanel from './ChatSidePanel';
import { get } from '../../services/api'; import { get } from '../../services/api';
export default function ChatWindow({ chatId, knowledgeBaseId }) { export default function ChatWindow({ chatId, knowledgeBaseId }) {
@ -16,11 +17,23 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const messagesEndRef = useRef(null); const messagesEndRef = useRef(null);
const hasLoadedDetailRef = useRef({}); // ref const hasLoadedDetailRef = useRef({}); // ref
// Redux store // Gmail
const messages = useSelector((state) => state.chat.messages.items); const [noEmailsWarning, setNoEmailsWarning] = useState(null);
const messageStatus = useSelector((state) => state.chat.messages.status); const [troubleshooting, setTroubleshooting] = useState(null);
const messageError = useSelector((state) => state.chat.messages.error);
const { status: sendStatus, error: sendError } = useSelector((state) => state.chat.sendMessage); // Redux store
const chats = useSelector((state) => state.chat.chats.items);
const currentChat = chats.find((chat) => chat.conversation_id === chatId);
const messages = currentChat?.messages || [];
//
const messageStatus = useSelector((state) => state.chat.messageOperation.status);
const messageError = useSelector((state) => state.chat.messageOperation.error);
// Gmail
const gmailSetupStatus = useSelector((state) => state.gmailChat?.setup?.status);
const gmailNoEmailsWarning = useSelector((state) => state.gmailChat?.setup?.noEmailsWarning);
const gmailTroubleshooting = useSelector((state) => state.gmailChat?.setup?.troubleshooting);
// 使Redux // 使Redux
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []); const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
@ -31,23 +44,55 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []); const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []);
const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading'); const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
// // ID
const conversation = useSelector((state) => state.chat.currentChat.data); const activeConversationId = useSelector((state) => state.chat.activeConversationId);
const conversationStatus = useSelector((state) => state.chat.currentChat.status);
const conversationError = useSelector((state) => state.chat.currentChat.error);
// //
const createSessionStatus = useSelector((state) => state.chat.createSession?.status); const chatOperationStatus = useSelector((state) => state.chat.chatOperation.status);
const createSessionId = useSelector((state) => state.chat.createSession?.sessionId);
//
const [talentEmail, setTalentEmail] = useState('');
// ID // ID
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]); const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
// conversationknowledgeBaseIdselectedKnowledgeBaseIds // Gmail
useEffect(() => { useEffect(() => {
// 使conversation // 使Redux
if (conversation && conversation.datasets && conversation.datasets.length > 0) { if (gmailNoEmailsWarning) {
const datasetIds = conversation.datasets.map((ds) => ds.id); setNoEmailsWarning(gmailNoEmailsWarning);
setTroubleshooting(gmailTroubleshooting);
return;
}
// localStorage
try {
const savedWarning = localStorage.getItem('gmailNoEmailsWarning');
if (savedWarning) {
const warningData = JSON.parse(savedWarning);
// 24
const isCurrentChat = warningData.chatId === chatId;
const isStillValid = Date.now() - warningData.timestamp < 24 * 60 * 60 * 1000;
if (isCurrentChat && isStillValid) {
setNoEmailsWarning(warningData.message);
setTroubleshooting(warningData.troubleshooting);
} else if (!isStillValid) {
//
localStorage.removeItem('gmailNoEmailsWarning');
}
}
} catch (error) {
console.error('解析Gmail警告信息失败:', error);
localStorage.removeItem('gmailNoEmailsWarning');
}
}, [chatId, gmailNoEmailsWarning, gmailTroubleshooting]);
// currentChatknowledgeBaseIdselectedKnowledgeBaseIds
useEffect(() => {
// 使currentChat
if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
const datasetIds = currentChat.datasets.map((ds) => ds.id);
console.log('从会话中获取知识库列表:', datasetIds); console.log('从会话中获取知识库列表:', datasetIds);
setSelectedKnowledgeBaseIds(datasetIds); setSelectedKnowledgeBaseIds(datasetIds);
} }
@ -58,7 +103,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
console.log('从URL参数中获取知识库列表:', ids); console.log('从URL参数中获取知识库列表:', ids);
setSelectedKnowledgeBaseIds(ids); setSelectedKnowledgeBaseIds(ids);
} }
}, [conversation, knowledgeBaseId]); }, [currentChat, knowledgeBaseId]);
// //
useEffect(() => { useEffect(() => {
@ -70,12 +115,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
return; return;
} }
// //
const isNewlyCreatedChat = createSessionStatus === 'succeeded' && createSessionId === chatId; const existingChat = chats.find((chat) => chat.conversation_id === chatId);
// //
if (isNewlyCreatedChat && conversation && conversation.conversation_id === chatId) { if (existingChat && existingChat.messages && existingChat.messages.length > 0) {
console.log('跳过新创建会话的详情获取:', chatId); console.log('聊天已存在且有消息,跳过详情获取:', chatId);
hasLoadedDetailRef.current[chatId] = true; hasLoadedDetailRef.current[chatId] = true;
return; return;
} }
@ -102,14 +147,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
.finally(() => { .finally(() => {
setLoading(false); setLoading(false);
}); });
}, [chatId, dispatch, chats]);
//
return () => {
dispatch(resetMessages());
// hasLoadedDetailRef
// hasLoadedDetailRef.current = {}; // ref
};
}, [chatId, dispatch, createSessionStatus, createSessionId]);
// ref // ref
useEffect(() => { useEffect(() => {
@ -122,32 +160,81 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
useEffect(() => { useEffect(() => {
// //
if (chatId && messages.length === 0 && !loading && messageStatus !== 'loading') { if (chatId && messages.length === 0 && !loading && messageStatus !== 'loading') {
const selectedKb = knowledgeBase || //
availableDatasets.find((ds) => ds.id === knowledgeBaseId) || { name: '知识库' }; let isNoEmailsFirstMessage = false;
let noEmailsMessage = null;
dispatch( try {
addMessage({ const savedWarning = localStorage.getItem('gmailNoEmailsWarning');
id: 'welcome-' + Date.now(), if (savedWarning) {
role: 'assistant', const warningData = JSON.parse(savedWarning);
content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`, //
created_at: new Date().toISOString(), if (warningData.chatId === chatId && warningData.isFirstMessage) {
}) isNoEmailsFirstMessage = true;
); noEmailsMessage = warningData.message;
}
}
} catch (error) {
console.error('解析Gmail警告信息失败:', error);
}
if (isNoEmailsFirstMessage && noEmailsMessage) {
// 使
dispatch(
addMessage({
conversationId: chatId,
message: {
id: 'gmail-warning-' + Date.now(),
role: 'assistant',
content: `⚠️ ${noEmailsMessage}\n\n您仍然可以在此聊天中提问但可能无法获得与邮件内容相关的回答。`,
created_at: new Date().toISOString(),
},
})
);
// isFirstMessage
try {
const savedWarning = localStorage.getItem('gmailNoEmailsWarning');
if (savedWarning) {
const warningData = JSON.parse(savedWarning);
warningData.isFirstMessage = false;
localStorage.setItem('gmailNoEmailsWarning', JSON.stringify(warningData));
}
} catch (error) {
console.error('更新Gmail警告信息失败:', error);
}
} else {
// 使
const selectedKb = knowledgeBase ||
availableDatasets.find((ds) => ds.id === knowledgeBaseId) || { name: '知识库' };
dispatch(
addMessage({
conversationId: chatId,
message: {
id: 'welcome-' + Date.now(),
role: 'assistant',
content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`,
created_at: new Date().toISOString(),
},
})
);
}
} }
}, [chatId, messages.length, loading, messageStatus, knowledgeBase, knowledgeBaseId, availableDatasets, dispatch]); }, [chatId, messages.length, loading, messageStatus, knowledgeBase, knowledgeBaseId, availableDatasets, dispatch]);
// //
useEffect(() => { useEffect(() => {
if (sendStatus === 'failed' && sendError) { if (messageStatus === 'failed' && messageError) {
dispatch( dispatch(
showNotification({ showNotification({
message: `发送失败: ${sendError}`, message: `发送失败: ${messageError}`,
type: 'danger', type: 'danger',
}) })
); );
dispatch(resetSendMessageStatus()); dispatch(resetMessageOperation());
} }
}, [sendStatus, sendError, dispatch]); }, [messageStatus, messageError, dispatch]);
// //
useEffect(() => { useEffect(() => {
@ -156,8 +243,8 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
// //
useEffect(() => { useEffect(() => {
// conversation使 // currentChat使
if (conversation && conversation.datasets && conversation.datasets.length > 0) { if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
return; return;
} }
@ -165,12 +252,36 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) { if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) {
dispatch(fetchAvailableDatasets()); dispatch(fetchAvailableDatasets());
} }
}, [dispatch, knowledgeBaseId, knowledgeBases, conversation, availableDatasets]); }, [dispatch, knowledgeBaseId, knowledgeBases, currentChat, availableDatasets]);
useEffect(() => {
//
if (currentChat?.talent_email) {
setTalentEmail(currentChat.talent_email);
} else if (currentChat?.datasets?.[0]?.talent_email) {
setTalentEmail(currentChat.datasets[0].talent_email);
} else if (currentChat?.datasets?.[0]?.name && currentChat?.datasets[0]?.name.includes('@')) {
//
const emailMatch = currentChat.datasets[0].name.match(/[\w.-]+@[\w.-]+\.\w+/);
if (emailMatch) {
setTalentEmail(emailMatch[0]);
}
} else if (messages.length > 0) {
//
for (const message of messages) {
const emailMatch = message.content?.match(/[\w.-]+@[\w.-]+\.\w+/);
if (emailMatch) {
setTalentEmail(emailMatch[0]);
break;
}
}
}
}, [currentChat, messages]);
const handleSendMessage = (e) => { const handleSendMessage = (e) => {
e.preventDefault(); e.preventDefault();
if (!inputMessage.trim() || sendStatus === 'loading') return; if (!inputMessage.trim() || messageStatus === 'loading') return;
console.log('准备发送消息:', inputMessage); console.log('准备发送消息:', inputMessage);
console.log('当前会话ID:', chatId); console.log('当前会话ID:', chatId);
@ -183,9 +294,9 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
// 使 // 使
dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, '')); dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, ''));
console.log('使用组件状态中的知识库列表:', dataset_id_list); console.log('使用组件状态中的知识库列表:', dataset_id_list);
} else if (conversation && conversation.datasets && conversation.datasets.length > 0) { } else if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
// 使 // 使
dataset_id_list = conversation.datasets.map((ds) => ds.id.replace(/-/g, '')); dataset_id_list = currentChat.datasets.map((ds) => ds.id.replace(/-/g, ''));
console.log('使用会话中的知识库列表:', dataset_id_list); console.log('使用会话中的知识库列表:', dataset_id_list);
} else if (knowledgeBaseId) { } else if (knowledgeBaseId) {
// 使 // 使
@ -278,125 +389,136 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
}; };
return ( return (
<div className='chat-window d-flex flex-column h-100'> <div className='d-flex h-100'>
{/* Chat header */} {/* Main Chat Area */}
<div className='p-3 border-bottom'> <div className='chat-window d-flex flex-column h-100 flex-grow-1'>
{conversation && conversation.datasets ? ( {/* Chat header */}
<> <div className='p-3 border-bottom'>
<h5 className='mb-0'>{conversation.datasets.map((dataset) => dataset.name).join(', ')}</h5> {currentChat && currentChat.datasets ? (
{conversation.datasets.length > 0 && conversation.datasets[0].type && ( <>
<small className='text-muted'>类型: {conversation.datasets[0].type}</small> <h5 className='mb-0'>{currentChat.datasets.map((dataset) => dataset.name).join(', ')}</h5>
)} {currentChat.datasets.length > 0 && currentChat.datasets[0].type && (
</> <small className='text-muted'>类型: {currentChat.datasets[0].type}</small>
) : knowledgeBase ? ( )}
<> </>
<h5 className='mb-0'>{knowledgeBase.name}</h5> ) : knowledgeBase ? (
<small className='text-muted'>{knowledgeBase.description}</small> <>
</> <h5 className='mb-0'>{knowledgeBase.name}</h5>
) : ( <small className='text-muted'>{knowledgeBase.description}</small>
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5> </>
)} ) : (
</div> <h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
)}
</div>
{/* Chat messages */} {/* Chat messages */}
<div className='flex-grow-1 p-3 overflow-auto'> <div className='flex-grow-1 p-3 overflow-auto'>
<div className='container'> <div className='container'>
{messageStatus === 'loading' {messageStatus === 'loading' && !messages.length
? renderLoading() ? renderLoading()
: messageStatus === 'failed' : messageStatus === 'failed' && !messages.length
? renderError() ? renderError()
: messages.length === 0 : messages.length === 0
? renderEmpty() ? renderEmpty()
: messages.map((message) => ( : messages.map((message) => (
<div
key={message.id}
className={`d-flex ${
message.role === 'user' ? 'align-items-end' : 'align-items-start'
} mb-3 flex-column`}
>
<div <div
className={`chat-message p-3 rounded-3 ${ key={message.id}
message.role === 'user' ? 'bg-dark text-white' : 'bg-light' className={`d-flex ${
}`} message.role === 'user' ? 'align-items-end' : 'align-items-start'
style={{ } mb-3 flex-column`}
maxWidth: '75%',
position: 'relative',
}}
> >
<div className='message-content'> <div
{message.role === 'user' ? ( className={`chat-message p-3 rounded-3 ${
message.content message.role === 'user' ? 'bg-dark text-white' : 'bg-light'
) : ( }`}
<SafeMarkdown content={message.content} /> style={{
)} maxWidth: '75%',
{message.is_streaming && ( position: 'relative',
<span className='streaming-indicator'> }}
<span className='dot dot1'></span> >
<span className='dot dot2'></span> <div className='message-content'>
<span className='dot dot3'></span> {message.role === 'user' ? (
</span> message.content
)} ) : (
<SafeMarkdown content={message.content} />
)}
{message.is_streaming && (
<span className='streaming-indicator'>
<span className='dot dot1'></span>
<span className='dot dot2'></span>
<span className='dot dot3'></span>
</span>
)}
</div>
</div>
<div className='message-time small text-muted mt-1'>
{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 && ' · 正在生成...'}
</div> </div>
</div> </div>
<div className='message-time small text-muted mt-1'> ))}
{message.created_at &&
(() => {
const messageDate = new Date(message.created_at);
const today = new Date();
// <div ref={messagesEndRef} />
const isToday = </div>
messageDate.getDate() === today.getDate() && </div>
messageDate.getMonth() === today.getMonth() &&
messageDate.getFullYear() === today.getFullYear();
// {/* Chat input */}
if (isToday) { <div className='p-3 border-top'>
return messageDate.toLocaleTimeString('zh-CN', { <form onSubmit={handleSendMessage} className='d-flex gap-2'>
hour: '2-digit', <input
minute: '2-digit', type='text'
}); className='form-control'
} else { placeholder='输入你的问题...'
return messageDate.toLocaleString('zh-CN', { value={inputMessage}
year: 'numeric', onChange={(e) => setInputMessage(e.target.value)}
month: '2-digit', disabled={messageStatus === 'loading'}
day: '2-digit', />
hour: '2-digit', <button
minute: '2-digit', type='submit'
}); className='btn btn-dark d-flex align-items-center justify-content-center gap-2'
} disabled={messageStatus === 'loading' || !inputMessage.trim()}
})()} >
{message.is_streaming && ' · 正在生成...'} <SvgIcon className='send' color='#ffffff' />
</div> <span className='ms-1' style={{ minWidth: 'fit-content' }}>
</div> 发送
))} </span>
</button>
<div ref={messagesEndRef} /> </form>
</div> </div>
</div> </div>
{/* Chat input */} {/* Right Side Panel */}
<div className='p-3 border-top'> <div
<form onSubmit={handleSendMessage} className='d-flex gap-2'> className='chat-side-panel-container border-start'
<input style={{ width: '350px', height: '100%', overflow: 'hidden' }}
type='text' >
className='form-control' <ChatSidePanel chatId={chatId} talentEmail={talentEmail} />
placeholder='输入你的问题...'
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
disabled={sendStatus === 'loading'}
/>
<button
type='submit'
className='btn btn-dark d-flex align-items-center justify-content-center gap-2'
disabled={sendStatus === 'loading' || !inputMessage.trim()}
>
<SvgIcon className='send' color='#ffffff' />
<span className='ms-1' style={{ minWidth: 'fit-content' }}>
发送
</span>
</button>
</form>
</div> </div>
</div> </div>
); );

View File

@ -3,6 +3,7 @@ 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, createConversation } from '../../store/chat/chat.thunks'; import { fetchAvailableDatasets, fetchChats, createConversation } from '../../store/chat/chat.thunks';
import { setupGmailChat } from '../../store/gmailChat/gmailChat.thunks';
import SvgIcon from '../../components/SvgIcon'; import SvgIcon from '../../components/SvgIcon';
export default function NewChat() { export default function NewChat() {
@ -11,15 +12,26 @@ export default function NewChat() {
const [selectedDatasetIds, setSelectedDatasetIds] = useState([]); const [selectedDatasetIds, setSelectedDatasetIds] = useState([]);
const [isNavigating, setIsNavigating] = useState(false); const [isNavigating, setIsNavigating] = useState(false);
// Gmail
const [talentEmail, setTalentEmail] = useState('');
const [showAuthCodeInput, setShowAuthCodeInput] = useState(false);
const [authCode, setAuthCode] = useState('');
const [authUrl, setAuthUrl] = useState('');
const [isGmailLoading, setIsGmailLoading] = useState(false);
// Redux store // Redux store
const datasets = useSelector((state) => state.chat.availableDatasets.items || []); const datasets = useSelector((state) => state.chat.availableDatasets.items || []);
const isLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading'); const isLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
const error = useSelector((state) => state.chat.availableDatasets.error); const error = useSelector((state) => state.chat.availableDatasets.error);
// //
const chatHistory = useSelector((state) => state.chat.history.items || []); const chatHistory = useSelector((state) => state.chat.chats.items || []);
const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading'); const chatHistoryLoading = useSelector((state) => state.chat.chats.status === 'loading');
const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status); const chatCreationStatus = useSelector((state) => state.chat.messageOperation?.status);
// Gmail
const gmailSetupStatus = useSelector((state) => state.gmailChat?.setup?.status);
const gmailSetupError = useSelector((state) => state.gmailChat?.setup?.error);
// //
useEffect(() => { useEffect(() => {
@ -57,6 +69,157 @@ export default function NewChat() {
}); });
}; };
// Gmail
const handleGmailIntegration = async (e) => {
e.preventDefault();
if (!talentEmail) {
dispatch(
showNotification({
message: '请输入达人邮箱',
type: 'warning',
})
);
return;
}
setIsGmailLoading(true);
try {
// GmailAPI
const response = await dispatch(setupGmailChat(talentEmail)).unwrap();
//
if (response.message && response.message.includes('没有找到') && response.knowledge_base_id) {
dispatch(
showNotification({
message: response.message,
type: 'warning',
})
);
// 使ID
try {
const conversationResponse = await dispatch(
createConversation({
dataset_id_list: [response.knowledge_base_id],
})
).unwrap();
console.log('使用Gmail知识库创建会话成功:', conversationResponse);
if (conversationResponse && conversationResponse.conversation_id) {
// localStorage
localStorage.setItem(
'gmailNoEmailsWarning',
JSON.stringify({
message: response.message,
troubleshooting: response.troubleshooting || null,
chatId: conversationResponse.conversation_id,
timestamp: Date.now(),
isFirstMessage: true, //
})
);
//
navigate(`/chat/${response.knowledge_base_id}/${conversationResponse.conversation_id}`);
} else {
throw new Error('未能获取会话ID');
}
} catch (convError) {
console.error('创建会话失败:', convError);
dispatch(
showNotification({
message: `创建会话失败: ${convError.message || '请重试'}`,
type: 'danger',
})
);
}
}
// IDID
else if (response && response.data && response.data.knowledge_base_id && response.data.conversation_id) {
dispatch(
showNotification({
message: response.message || 'Gmail集成成功',
type: 'success',
})
);
//
navigate(`/chat/${response.data.knowledge_base_id}/${response.data.conversation_id}`);
} else if (response && response.status === 'authorization_required' && response.auth_url) {
//
setAuthUrl(response.auth_url);
setShowAuthCodeInput(true);
// URL
window.open(response.auth_url, '_blank');
dispatch(
showNotification({
message: '需要Gmail授权请在新打开的窗口中完成授权并获取授权码',
type: 'info',
})
);
}
} catch (error) {
console.error('Gmail集成失败:', error);
dispatch(
showNotification({
message: `Gmail集成失败: ${error.message || '请重试'}`,
type: 'danger',
})
);
} finally {
setIsGmailLoading(false);
}
};
//
const handleAuthCodeSubmit = async (e) => {
e.preventDefault();
if (!authCode) {
dispatch(
showNotification({
message: '请输入授权码',
type: 'warning',
})
);
return;
}
setIsGmailLoading(true);
try {
// 使GmailAPI
const response = await dispatch(
setupGmailChat({
talent_gmail: talentEmail,
auth_code: authCode,
})
).unwrap();
if (response && response.data && response.data.knowledge_base_id && response.data.conversation_id) {
dispatch(
showNotification({
message: response.message || 'Gmail集成成功',
type: 'success',
})
);
//
navigate(`/chat/${response.data.knowledge_base_id}/${response.data.conversation_id}`);
} else {
throw new Error('未能获取会话信息');
}
} catch (error) {
console.error('授权码提交失败:', error);
dispatch(
showNotification({
message: `授权码提交失败: ${error.message || '请重试'}`,
type: 'danger',
})
);
} finally {
setIsGmailLoading(false);
}
};
// //
const handleStartChat = async () => { const handleStartChat = async () => {
if (selectedDatasetIds.length === 0) { if (selectedDatasetIds.length === 0) {
@ -188,6 +351,121 @@ export default function NewChat() {
</div> </div>
)} )}
{/* Gmail集成模块 */}
<div className='card mb-5 shadow-sm'>
<div className='card-body'>
<h4 className='card-title mb-4'>Gmail邮件集成</h4>
{!showAuthCodeInput ? (
<form onSubmit={handleGmailIntegration} className='mb-3'>
<div className='mb-3'>
<label htmlFor='talentEmail' className='form-label'>
达人邮箱地址
</label>
<div className='input-group'>
<input
type='email'
className='form-control'
id='talentEmail'
placeholder='请输入正在联络的达人邮箱'
value={talentEmail}
onChange={(e) => setTalentEmail(e.target.value)}
disabled={isGmailLoading}
required
/>
<button
className='btn btn-outline-dark'
type='submit'
disabled={isGmailLoading || !talentEmail}
>
{isGmailLoading ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
'创建邮件会话'
)}
</button>
</div>
<div className='form-text'>将创建一个包含您与该达人Gmail邮件沟通记录的知识库及会话</div>
</div>
</form>
) : (
<form onSubmit={handleAuthCodeSubmit} className='mb-3'>
<div className='alert alert-info mb-3'>
<p className='mb-2'>
<strong>需要Gmail授权</strong>
</p>
<p className='mb-2'>系统已在新窗口打开Gmail授权页面请完成授权并获取授权码</p>
<p className='mb-0'>
<a
href={authUrl}
target='_blank'
rel='noopener noreferrer'
className='btn btn-sm btn-outline-primary'
>
重新打开授权页面
</a>
</p>
</div>
<div className='mb-3'>
<label htmlFor='authCode' className='form-label'>
授权码
</label>
<div className='input-group'>
<input
type='text'
className='form-control'
id='authCode'
placeholder='请输入Gmail授权页面提供的授权码'
value={authCode}
onChange={(e) => setAuthCode(e.target.value)}
disabled={isGmailLoading}
required
/>
<button
className='btn btn-outline-success'
type='submit'
disabled={isGmailLoading || !authCode}
>
{isGmailLoading ? (
<>
<span
className='spinner-border spinner-border-sm me-2'
role='status'
aria-hidden='true'
></span>
处理中...
</>
) : (
'提交授权码'
)}
</button>
</div>
</div>
<button
type='button'
className='btn btn-link ps-0'
onClick={() => {
setShowAuthCodeInput(false);
setAuthCode('');
setAuthUrl('');
}}
>
&laquo; 返回输入达人邮箱
</button>
</form>
)}
</div>
</div>
<div className='d-flex justify-content-between align-items-center mb-4'> <div className='d-flex justify-content-between align-items-center mb-4'>
<h4 className='m-0'>选择知识库开始聊天</h4> <h4 className='m-0'>选择知识库开始聊天</h4>
<button <button

View File

@ -15,7 +15,6 @@ import AccessRequestModal from '../../components/AccessRequestModal';
import CreateKnowledgeBaseModal from '../../components/CreateKnowledgeBaseModal'; import CreateKnowledgeBaseModal from '../../components/CreateKnowledgeBaseModal';
import Pagination from '../../components/Pagination'; import Pagination from '../../components/Pagination';
import SearchBar from '../../components/SearchBar'; import SearchBar from '../../components/SearchBar';
import ApiModeSwitch from '../../components/ApiModeSwitch';
// //
import KnowledgeBaseList from './components/KnowledgeBaseList'; import KnowledgeBaseList from './components/KnowledgeBaseList';
@ -461,9 +460,6 @@ export default function KnowledgeBase() {
return ( return (
<div className='knowledge-base container my-4'> <div className='knowledge-base container my-4'>
{/* <div className='api-mode-control mb-3'>
<ApiModeSwitch />
</div> */}
<div className='d-flex justify-content-between align-items-center mb-3'> <div className='d-flex justify-content-between align-items-center mb-3'>
<SearchBar <SearchBar
searchKeyword={searchKeyword} searchKeyword={searchKeyword}

View File

@ -1,6 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import CryptoJS from 'crypto-js'; import CryptoJS from 'crypto-js';
import { mockGet, mockPost, mockPut, mockDelete } from './mockApi';
const secretKey = import.meta.env.VITE_SECRETKEY; const secretKey = import.meta.env.VITE_SECRETKEY;
@ -42,13 +41,6 @@ api.interceptors.response.use(
return response; return response;
}, },
(error) => { (error) => {
// 处理服务器无法连接的情况
if (!error.response || error.code === 'ECONNABORTED' || error.message.includes('Network Error')) {
console.error('Server appears to be down. Switching to mock data.');
isServerDown = true;
hasCheckedServer = true;
}
// Handle errors in the response // Handle errors in the response
if (error.response) { if (error.response) {
// monitor /verify // monitor /verify
@ -74,52 +66,20 @@ api.interceptors.response.use(
} }
); );
// 检查服务器状态
export const checkServerStatus = async () => {
try {
// await api.get('/health-check', { timeout: 3000 });
isServerDown = false;
hasCheckedServer = true;
console.log('Server connection established');
return true;
} catch (error) {
isServerDown = true;
hasCheckedServer = true;
console.error('Server connection failed, using mock data');
return false;
}
};
// 初始检查服务器状态 // Define common HTTP methods
checkServerStatus();
// Define common HTTP methods with fallback to mock API
const get = async (url, params = {}) => { const get = async (url, params = {}) => {
try { try {
if (isServerDown) {
console.log(`[MOCK MODE] GET ${url}`);
return await mockGet(url, params);
}
const res = await api.get(url, { ...params }); const res = await api.get(url, { ...params });
return res.data; return res.data;
} catch (error) { } catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for GET ${url}`);
return await mockGet(url, params);
}
throw error; throw error;
} }
}; };
// Handle POST requests for JSON data with fallback to mock API // Handle POST requests for JSON data
const post = async (url, data, isMultipart = false) => { const post = async (url, data, isMultipart = false) => {
try { try {
if (isServerDown) {
console.log(`[MOCK MODE] POST ${url}`);
return await mockPost(url, data);
}
const headers = isMultipart const headers = isMultipart
? { 'Content-Type': 'multipart/form-data' } // For file uploads ? { 'Content-Type': 'multipart/form-data' } // For file uploads
: { 'Content-Type': 'application/json' }; // For JSON data : { 'Content-Type': 'application/json' }; // For JSON data
@ -127,61 +87,34 @@ const post = async (url, data, isMultipart = false) => {
const res = await api.post(url, data, { headers }); const res = await api.post(url, data, { headers });
return res.data; return res.data;
} catch (error) { } catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for POST ${url}`);
return await mockPost(url, data);
}
throw error; throw error;
} }
}; };
// Handle PUT requests with fallback to mock API // Handle PUT requests
const put = async (url, data) => { const put = async (url, data) => {
try { try {
if (isServerDown) {
console.log(`[MOCK MODE] PUT ${url}`);
return await mockPut(url, data);
}
const res = await api.put(url, data, { const res = await api.put(url, data, {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
return res.data; return res.data;
} catch (error) { } catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for PUT ${url}`);
return await mockPut(url, data);
}
throw error; throw error;
} }
}; };
// Handle DELETE requests with fallback to mock API // Handle DELETE requests
const del = async (url) => { const del = async (url) => {
try { try {
if (isServerDown) {
console.log(`[MOCK MODE] DELETE ${url}`);
return await mockDelete(url);
}
const res = await api.delete(url); const res = await api.delete(url);
return res.data; return res.data;
} catch (error) { } catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for DELETE ${url}`);
return await mockDelete(url);
}
throw error; throw error;
} }
}; };
const upload = async (url, data) => { const upload = async (url, data) => {
try { try {
if (isServerDown) {
console.log(`[MOCK MODE] Upload ${url}`);
return await mockPost(url, data, true);
}
const axiosInstance = await axios.create({ const axiosInstance = await axios.create({
baseURL: '/api', baseURL: '/api',
headers: { headers: {
@ -191,41 +124,12 @@ const upload = async (url, data) => {
const res = await axiosInstance.post(url, data); const res = await axiosInstance.post(url, data);
return res.data; return res.data;
} catch (error) { } catch (error) {
if (!hasCheckedServer || (error.request && !error.response)) {
console.log(`Failed to connect to server. Falling back to mock API for Upload ${url}`);
return await mockPost(url, data, true);
}
throw error; throw error;
} }
}; };
// 手动切换到模拟API为调试目的
export const switchToMockApi = () => {
isServerDown = true;
hasCheckedServer = true;
console.log('Manually switched to mock API');
};
// 手动切换回真实API
export const switchToRealApi = async () => {
// 重新检查服务器状态
const isServerUp = await checkServerStatus();
console.log(isServerUp ? 'Switched back to real API' : 'Server still down, continuing with mock API');
return isServerUp;
};
// Handle streaming requests // Handle streaming requests
const streamRequest = async (url, data, onChunk, onError) => { const streamRequest = async (url, data, onChunk, onError) => {
try { try {
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);
return { success: true, conversation_id: 'mock-1234' };
}
// 获取认证Token // 获取认证Token
const encryptedToken = sessionStorage.getItem('token') || ''; const encryptedToken = sessionStorage.getItem('token') || '';

File diff suppressed because it is too large Load Diff

View File

@ -3,18 +3,20 @@ import { get, post } from '../../services/api';
/** /**
* 获取聊天消息 * 获取聊天消息
* @param {string} chatId - 聊天ID * @param {string} conversationId - 会话ID
*/ */
export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (chatId, { rejectWithValue }) => { export const fetchMessages = createAsyncThunk('chat/fetchMessages', async (conversationId, { rejectWithValue }) => {
try { try {
const response = await get(`/chat-history/${chatId}/messages/`); const response = await get(`/chat-history/conversation_detail`, {
params: { conversation_id: conversationId },
});
// 处理返回格式 // 处理返回格式
if (response && response.code === 200) { if (response && response.code === 200) {
return response.data.messages; return response.data.messages || [];
} }
return response.data?.messages || []; return [];
} catch (error) { } catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to fetch messages'); return rejectWithValue(error.response?.data?.message || 'Failed to fetch messages');
} }
@ -35,7 +37,10 @@ export const sendMessage = createAsyncThunk('chat/sendMessage', async ({ chatId,
// 处理返回格式 // 处理返回格式
if (response && response.code === 200) { if (response && response.code === 200) {
return response.data; return {
...response.data,
role: response.data.role || 'user', // 确保有角色字段
};
} }
return response.data || {}; return response.data || {};

View File

@ -2,7 +2,6 @@ import { createSlice } from '@reduxjs/toolkit';
import { import {
fetchAvailableDatasets, fetchAvailableDatasets,
fetchChats, fetchChats,
createChat,
updateChat, updateChat,
deleteChat, deleteChat,
createChatRecord, createChatRecord,
@ -13,28 +12,24 @@ import { fetchMessages, sendMessage } from './chat.messages.thunks';
// 初始状态 // 初始状态
const initialState = { const initialState = {
// Chat history state // 聊天列表,包含所有聊天及其消息
history: { chats: {
items: [], items: [], // 每个chat对象包含conversation_id, datasets, create_time, messages等
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null, error: null,
pagination: {
total: 0,
page: 1,
page_size: 10,
},
}, },
// Chat session creation state // 当前活跃聊天的ID
createSession: { activeConversationId: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' // 消息发送状态
error: null, messageOperation: {
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' status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null, error: null,
streamingMessageId: null, // 当前正在流式传输的消息ID如果有
}, },
// 可用于聊天的知识库列表 // 可用于聊天的知识库列表
availableDatasets: { availableDatasets: {
@ -42,26 +37,16 @@ const initialState = {
status: 'idle', status: 'idle',
error: null, error: null,
}, },
// 操作状态(创建、更新、删除) // 聊天操作状态(创建、更新、删除)
operations: { chatOperation: {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed' status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null, error: null,
}, },
// 兼容旧版本的state结构 };
list: {
items: [], // 辅助函数:查找聊天索引
total: 0, const findChatIndex = (state, conversationId) => {
page: 1, return state.chats.items.findIndex((chat) => chat.conversation_id === conversationId);
page_size: 10,
status: 'idle',
error: null,
},
// 当前聊天
currentChat: {
data: null,
status: 'idle',
error: null,
},
}; };
// 创建 slice // 创建 slice
@ -70,57 +55,98 @@ const chatSlice = createSlice({
initialState, initialState,
reducers: { reducers: {
// 重置操作状态 // 重置操作状态
resetOperationStatus: (state) => { resetChatOperation: (state) => {
state.operations.status = 'idle'; state.chatOperation.status = 'idle';
state.operations.error = null; state.chatOperation.error = null;
}, },
// 重置当前聊天 // 设置当前活跃聊天
resetCurrentChat: (state) => { setActiveChat: (state, action) => {
state.currentChat.data = null; state.activeConversationId = action.payload;
state.currentChat.status = 'idle';
state.currentChat.error = null;
}, },
// 设置当前聊天 // 重置消息操作状态
setCurrentChat: (state, action) => { resetMessageOperation: (state) => {
state.currentChat.data = action.payload; state.messageOperation.status = 'idle';
state.currentChat.status = 'succeeded'; state.messageOperation.error = null;
state.messageOperation.streamingMessageId = null;
}, },
// 重置消息状态 // 添加消息到特定聊天
resetMessages: (state) => {
state.messages.items = [];
state.messages.status = 'idle';
state.messages.error = null;
},
// 重置发送消息状态
resetSendMessageStatus: (state) => {
state.sendMessage.status = 'idle';
state.sendMessage.error = null;
},
// 添加消息
addMessage: (state, action) => { addMessage: (state, action) => {
state.messages.items.push(action.payload); const { conversationId, message } = action.payload;
const chatIndex = findChatIndex(state, conversationId);
if (chatIndex !== -1) {
// 确保chat有messages数组
if (!state.chats.items[chatIndex].messages) {
state.chats.items[chatIndex].messages = [];
}
// 添加消息
state.chats.items[chatIndex].messages.push(message);
// 更新最后一条消息和消息计数
state.chats.items[chatIndex].last_message = message.content;
state.chats.items[chatIndex].message_count = (state.chats.items[chatIndex].message_count || 0) + 1;
// 如果是助手消息且正在流式传输记录ID
if (message.role === 'assistant' && message.is_streaming) {
state.messageOperation.streamingMessageId = message.id;
state.messageOperation.status = 'loading';
}
}
}, },
// 更新消息(用于流式传输) // 更新消息(用于流式传输)
updateMessage: (state, action) => { updateMessage: (state, action) => {
const { id, ...updates } = action.payload; const { conversationId, messageId, updates, serverMessageId } = action.payload;
const messageIndex = state.messages.items.findIndex((msg) => msg.id === id); const chatIndex = findChatIndex(state, conversationId);
if (messageIndex !== -1) { if (chatIndex !== -1 && state.chats.items[chatIndex].messages) {
// 更新现有消息 // 首先尝试使用服务器返回的ID找到消息
state.messages.items[messageIndex] = { let messageIndex = -1;
...state.messages.items[messageIndex],
...updates,
};
// 如果流式传输结束,更新发送消息状态 if (serverMessageId) {
if (updates.is_streaming === false) { // 如果提供了服务器ID优先使用它查找消息
state.sendMessage.status = 'succeeded'; messageIndex = state.chats.items[chatIndex].messages.findIndex(
(msg) => msg.id === serverMessageId || msg.server_id === serverMessageId
);
}
// 如果没找到或没提供服务器ID则使用客户端生成的ID
if (messageIndex === -1) {
messageIndex = state.chats.items[chatIndex].messages.findIndex((msg) => msg.id === messageId);
}
if (messageIndex !== -1) {
// 更新现有消息
const updatedMessage = {
...state.chats.items[chatIndex].messages[messageIndex],
...updates,
};
// 如果收到了服务器ID且消息没有server_id字段添加它
if (serverMessageId && !updatedMessage.server_id) {
updatedMessage.server_id = serverMessageId;
}
state.chats.items[chatIndex].messages[messageIndex] = updatedMessage;
// 如果流式传输结束,更新状态
if (
updates.is_streaming === false &&
(messageId === state.messageOperation.streamingMessageId ||
serverMessageId === state.messageOperation.streamingMessageId)
) {
state.messageOperation.status = 'succeeded';
state.messageOperation.streamingMessageId = null;
}
// 如果更新了内容,更新最后一条消息
if (updates.content) {
state.chats.items[chatIndex].last_message = updates.content;
}
} }
} }
}, },
@ -129,189 +155,161 @@ const chatSlice = createSlice({
// 获取聊天列表 // 获取聊天列表
builder builder
.addCase(fetchChats.pending, (state) => { .addCase(fetchChats.pending, (state) => {
state.list.status = 'loading'; state.chats.status = 'loading';
state.history.status = 'loading';
}) })
.addCase(fetchChats.fulfilled, (state, action) => { .addCase(fetchChats.fulfilled, (state, action) => {
state.list.status = 'succeeded'; state.chats.status = 'succeeded';
// 检查是否是追加模式 // 检查是否是追加模式
if (action.payload.append) { if (action.payload.append) {
// 追加模式:将新结果添加到现有列表的前面 // 追加模式:将新结果添加到现有列表的前面
state.list.items = [...action.payload.results, ...state.list.items]; state.chats.items = [...action.payload.results, ...state.chats.items];
state.history.items = [...action.payload.results, ...state.history.items];
} else { } else {
// 替换模式:使用新结果替换整个列表 // 替换模式:使用新结果替换整个列表
state.list.items = action.payload.results; state.chats.items = action.payload.results;
state.list.total = action.payload.total; state.chats.pagination.total = action.payload.total;
state.list.page = action.payload.page; state.chats.pagination.page = action.payload.page;
state.list.page_size = action.payload.page_size; state.chats.pagination.page_size = action.payload.page_size;
// 同时更新新的状态结构
state.history.items = action.payload.results;
} }
state.history.status = 'succeeded';
state.history.error = null;
}) })
.addCase(fetchChats.rejected, (state, action) => { .addCase(fetchChats.rejected, (state, action) => {
state.list.status = 'failed'; state.chats.status = 'failed';
state.list.error = action.payload || action.error.message; state.chats.error = action.payload || action.error.message;
// 同时更新新的状态结构
state.history.status = 'failed';
state.history.error = action.payload || action.error.message;
})
// 创建聊天
.addCase(createChat.pending, (state) => {
state.operations.status = 'loading';
})
.addCase(createChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded';
state.list.items.unshift(action.payload);
state.list.total += 1;
state.currentChat.data = action.payload;
state.currentChat.status = 'succeeded';
})
.addCase(createChat.rejected, (state, action) => {
state.operations.status = 'failed';
state.operations.error = action.payload || action.error.message;
}) })
// 删除聊天 // 删除聊天
.addCase(deleteChat.pending, (state) => { .addCase(deleteChat.pending, (state) => {
state.operations.status = 'loading'; state.chatOperation.status = 'loading';
}) })
.addCase(deleteChat.fulfilled, (state, action) => { .addCase(deleteChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded'; state.chatOperation.status = 'succeeded';
// 更新旧的状态结构 // 删除聊天
state.list.items = state.list.items.filter((chat) => chat.id !== action.payload); state.chats.items = state.chats.items.filter((chat) => chat.conversation_id !== action.payload);
// 更新新的状态结构
state.history.items = state.history.items.filter((chat) => chat.conversation_id !== action.payload);
if (state.list.total > 0) { if (state.chats.pagination.total > 0) {
state.list.total -= 1; state.chats.pagination.total -= 1;
} }
if (state.currentChat.data && state.currentChat.data.id === action.payload) { // 如果删除的是当前活跃聊天重置activeConversationId
state.currentChat.data = null; if (state.activeConversationId === action.payload) {
state.activeConversationId = null;
} }
}) })
.addCase(deleteChat.rejected, (state, action) => { .addCase(deleteChat.rejected, (state, action) => {
state.operations.status = 'failed'; state.chatOperation.status = 'failed';
state.operations.error = action.payload || action.error.message; state.chatOperation.error = action.payload || action.error.message;
}) })
// 更新聊天 // 更新聊天
.addCase(updateChat.pending, (state) => { .addCase(updateChat.pending, (state) => {
state.operations.status = 'loading'; state.chatOperation.status = 'loading';
}) })
.addCase(updateChat.fulfilled, (state, action) => { .addCase(updateChat.fulfilled, (state, action) => {
state.operations.status = 'succeeded'; state.chatOperation.status = 'succeeded';
const index = state.list.items.findIndex((chat) => chat.id === action.payload.id); const chatIndex = findChatIndex(state, action.payload.conversation_id);
if (index !== -1) { if (chatIndex !== -1) {
state.list.items[index] = action.payload; // 保留messages字段避免覆盖
} const existingMessages = state.chats.items[chatIndex].messages;
if (state.currentChat.data && state.currentChat.data.id === action.payload.id) { state.chats.items[chatIndex] = {
state.currentChat.data = action.payload; ...action.payload,
messages: existingMessages || [],
};
} }
}) })
.addCase(updateChat.rejected, (state, action) => { .addCase(updateChat.rejected, (state, action) => {
state.operations.status = 'failed'; state.chatOperation.status = 'failed';
state.operations.error = action.payload || action.error.message; state.chatOperation.error = action.payload || action.error.message;
}) })
// 获取聊天消息 // 获取聊天消息
.addCase(fetchMessages.pending, (state) => { .addCase(fetchMessages.pending, (state) => {
state.messages.status = 'loading'; state.messageOperation.status = 'loading';
state.messages.error = null;
}) })
.addCase(fetchMessages.fulfilled, (state, action) => { .addCase(fetchMessages.fulfilled, (state, action) => {
state.messages.status = 'succeeded'; state.messageOperation.status = 'succeeded';
state.messages.items = action.payload; // 假设action.meta.arg是conversationId
const conversationId = action.meta.arg;
const chatIndex = findChatIndex(state, conversationId);
if (chatIndex !== -1) {
state.chats.items[chatIndex].messages = action.payload;
}
}) })
.addCase(fetchMessages.rejected, (state, action) => { .addCase(fetchMessages.rejected, (state, action) => {
state.messages.status = 'failed'; state.messageOperation.status = 'failed';
state.messages.error = action.error.message; state.messageOperation.error = action.error.message;
}) })
// 发送聊天消息 // 发送聊天消息
.addCase(sendMessage.pending, (state) => { .addCase(sendMessage.pending, (state) => {
state.sendMessage.status = 'loading'; state.messageOperation.status = 'loading';
state.sendMessage.error = null;
}) })
.addCase(sendMessage.fulfilled, (state, action) => { .addCase(sendMessage.fulfilled, (state, action) => {
state.sendMessage.status = 'succeeded'; state.messageOperation.status = 'succeeded';
// 更新消息列表 // 假设action.meta.arg包含chatId
const index = state.messages.items.findIndex( const { chatId } = action.meta.arg;
(msg) => msg.content === action.payload.content && msg.sender === action.payload.sender const chatIndex = findChatIndex(state, chatId);
);
if (index === -1) { if (chatIndex !== -1) {
state.messages.items.push(action.payload); // 确保chat有messages数组
if (!state.chats.items[chatIndex].messages) {
state.chats.items[chatIndex].messages = [];
}
// 检查消息是否已存在
const messageExists = state.chats.items[chatIndex].messages.some(
(msg) => msg.content === action.payload.content && msg.role === action.payload.role
);
if (!messageExists) {
state.chats.items[chatIndex].messages.push(action.payload);
state.chats.items[chatIndex].last_message = action.payload.content;
state.chats.items[chatIndex].message_count =
(state.chats.items[chatIndex].message_count || 0) + 1;
}
} }
}) })
.addCase(sendMessage.rejected, (state, action) => { .addCase(sendMessage.rejected, (state, action) => {
state.sendMessage.status = 'failed'; state.messageOperation.status = 'failed';
state.sendMessage.error = action.error.message; state.messageOperation.error = action.error.message;
}) })
// 处理创建聊天记录 // 处理创建聊天记录
.addCase(createChatRecord.pending, (state) => { .addCase(createChatRecord.pending, (state) => {
state.sendMessage.status = 'loading'; state.messageOperation.status = 'loading';
state.sendMessage.error = null;
}) })
.addCase(createChatRecord.fulfilled, (state, action) => { .addCase(createChatRecord.fulfilled, (state, action) => {
// 更新状态以反映聊天已创建 // 聊天创建成功但消息状态由addMessage和updateMessage处理
if (action.payload.conversation_id && !state.currentChat.data) { state.activeConversationId = action.payload.conversation_id;
// 设置当前聊天的会话ID
state.currentChat.data = {
conversation_id: action.payload.conversation_id,
// 其他信息将由流式更新填充
};
}
// 不再在这里添加消息因为消息已经在thunk函数中添加
}) })
.addCase(createChatRecord.rejected, (state, action) => { .addCase(createChatRecord.rejected, (state, action) => {
state.sendMessage.status = 'failed'; state.messageOperation.status = 'failed';
state.sendMessage.error = action.error.message; state.messageOperation.error = action.error.message;
}) })
// 处理创建会话 // 处理创建会话
.addCase(createConversation.pending, (state) => { .addCase(createConversation.pending, (state) => {
state.createSession.status = 'loading'; state.chatOperation.status = 'loading';
state.createSession.error = null;
}) })
.addCase(createConversation.fulfilled, (state, action) => { .addCase(createConversation.fulfilled, (state, action) => {
state.createSession.status = 'succeeded'; state.chatOperation.status = 'succeeded';
state.createSession.sessionId = action.payload.conversation_id; state.activeConversationId = action.payload.conversation_id;
// 当前聊天设置 - 使用与fetchConversationDetail相同的数据结构 // 在执行createConversation时已经通过dispatch添加了新聊天到列表
state.currentChat.data = { // 所以这里只需确保当前激活的聊天ID已设置
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) => { .addCase(createConversation.rejected, (state, action) => {
state.createSession.status = 'failed'; state.chatOperation.status = 'failed';
state.createSession.error = action.payload || action.error.message; state.chatOperation.error = action.payload || action.error.message;
}) })
// 处理获取可用知识库 // 处理获取可用知识库
.addCase(fetchAvailableDatasets.pending, (state) => { .addCase(fetchAvailableDatasets.pending, (state) => {
state.availableDatasets.status = 'loading'; state.availableDatasets.status = 'loading';
state.availableDatasets.error = null;
}) })
.addCase(fetchAvailableDatasets.fulfilled, (state, action) => { .addCase(fetchAvailableDatasets.fulfilled, (state, action) => {
state.availableDatasets.status = 'succeeded'; state.availableDatasets.status = 'succeeded';
state.availableDatasets.items = action.payload || []; state.availableDatasets.items = action.payload || [];
state.availableDatasets.error = null;
}) })
.addCase(fetchAvailableDatasets.rejected, (state, action) => { .addCase(fetchAvailableDatasets.rejected, (state, action) => {
state.availableDatasets.status = 'failed'; state.availableDatasets.status = 'failed';
@ -320,35 +318,38 @@ const chatSlice = createSlice({
// 获取会话详情 // 获取会话详情
.addCase(fetchConversationDetail.pending, (state) => { .addCase(fetchConversationDetail.pending, (state) => {
state.currentChat.status = 'loading'; // 设置加载状态
state.currentChat.error = null;
}) })
.addCase(fetchConversationDetail.fulfilled, (state, action) => { .addCase(fetchConversationDetail.fulfilled, (state, action) => {
// 如果有返回数据
if (action.payload) { if (action.payload) {
state.currentChat.status = 'succeeded'; const conversationId = action.payload.conversation_id;
state.currentChat.data = action.payload; const chatIndex = findChatIndex(state, conversationId);
} else {
state.currentChat.status = 'idle'; if (chatIndex !== -1) {
state.currentChat.data = null; // 更新现有聊天
state.chats.items[chatIndex] = {
...state.chats.items[chatIndex],
...action.payload,
};
} else {
// 添加新聊天
state.chats.items.push(action.payload);
}
// 设置为当前活跃聊天
state.activeConversationId = conversationId;
} }
}) })
.addCase(fetchConversationDetail.rejected, (state, action) => { .addCase(fetchConversationDetail.rejected, (state, action) => {
state.currentChat.status = 'failed'; // 仅在操作失败时设置错误状态
state.currentChat.error = action.payload || action.error.message;
}); });
}, },
}); });
// 导出 actions // 导出 actions
export const { export const { resetChatOperation, setActiveChat, resetMessageOperation, addMessage, updateMessage } =
resetOperationStatus, chatSlice.actions;
resetCurrentChat,
setCurrentChat,
resetMessages,
resetSendMessageStatus,
addMessage,
updateMessage,
} = chatSlice.actions;
// 导出 reducer // 导出 reducer
export default chatSlice.reducer; export default chatSlice.reducer;

View File

@ -1,7 +1,7 @@
import { createAsyncThunk } from '@reduxjs/toolkit'; import { createAsyncThunk } from '@reduxjs/toolkit';
import { get, post, put, del, streamRequest } from '../../services/api'; import { get, post, put, del, streamRequest } from '../../services/api';
import { showNotification } from '../notification.slice'; import { showNotification } from '../notification.slice';
import { addMessage, updateMessage, setCurrentChat } from './chat.slice'; import { addMessage, updateMessage, setActiveChat } from './chat.slice';
/** /**
* 获取聊天列表 * 获取聊天列表
@ -30,29 +30,9 @@ export const fetchChats = createAsyncThunk('chat/fetchChats', async (params = {}
} }
}); });
/**
* 创建新聊天
* @param {Object} chatData - 聊天数据
* @param {string} chatData.knowledge_base_id - 知识库ID
* @param {string} chatData.title - 聊天标题
*/
export const createChat = createAsyncThunk('chat/createChat', async (chatData, { rejectWithValue }) => {
try {
const response = await post('/chat-history/', chatData);
// 处理返回格式
if (response && response.code === 200) {
return response.data.chat;
}
return response.data?.chat || {};
} catch (error) {
return rejectWithValue(error.response?.data?.message || 'Failed to create chat');
}
});
/** /**
* 更新聊天 * 更新聊天
* 更新已经发送出去的聊天获得新的回复相当于编辑以往的聊天记录暂时未使用
* @param {Object} params - 更新参数 * @param {Object} params - 更新参数
* @param {string} params.id - 聊天ID * @param {string} params.id - 聊天ID
* @param {Object} params.data - 更新数据 * @param {Object} params.data - 更新数据
@ -114,7 +94,8 @@ export const fetchAvailableDatasets = createAsyncThunk(
); );
/** /**
* 创建聊天记录 * 创建/继续聊天
* 创建新会话或者继续一个已有的会话根据conversation_id来更新
* @param {Object} params - 聊天参数 * @param {Object} params - 聊天参数
* @param {string[]} params.dataset_id_list - 知识库ID列表 * @param {string[]} params.dataset_id_list - 知识库ID列表
* @param {string} params.question - 用户问题 * @param {string} params.question - 用户问题
@ -134,24 +115,36 @@ export const createChatRecord = createAsyncThunk(
// 先添加用户消息到聊天窗口 // 先添加用户消息到聊天窗口
const userMessageId = Date.now().toString(); const userMessageId = Date.now().toString();
const userMessage = {
id: userMessageId,
role: 'user',
content: question,
created_at: new Date().toISOString(),
};
// 添加用户消息
dispatch( dispatch(
addMessage({ addMessage({
id: userMessageId, conversationId: conversation_id,
role: 'user', message: userMessage,
content: question,
created_at: new Date().toISOString(),
}) })
); );
// 添加临时的助手消息(流式传输期间显示) // 添加临时的助手消息(流式传输期间显示)
const assistantMessageId = (Date.now() + 1).toString(); const assistantMessageId = (Date.now() + 1).toString();
const assistantMessage = {
id: assistantMessageId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
is_streaming: true,
};
// 添加助手消息
dispatch( dispatch(
addMessage({ addMessage({
id: assistantMessageId, conversationId: conversation_id,
role: 'assistant', message: assistantMessage,
content: '',
created_at: new Date().toISOString(),
is_streaming: true,
}) })
); );
@ -185,11 +178,16 @@ export const createChatRecord = createAsyncThunk(
finalMessage += data.data.content; finalMessage += data.data.content;
console.log('累加内容:', finalMessage); console.log('累加内容:', finalMessage);
// 获取服务器消息ID (如果存在)
const serverMessageId = data.data.id;
// 更新消息内容 // 更新消息内容
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
content: finalMessage, messageId: assistantMessageId,
serverMessageId: serverMessageId,
updates: { content: finalMessage },
}) })
); );
} }
@ -197,10 +195,16 @@ export const createChatRecord = createAsyncThunk(
// 处理结束标志 // 处理结束标志
if (data.data.is_end) { if (data.data.is_end) {
console.log('检测到消息结束标志'); console.log('检测到消息结束标志');
// 获取服务器消息ID (如果存在)
const serverMessageId = data.data.id;
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
is_streaming: false, messageId: assistantMessageId,
serverMessageId: serverMessageId,
updates: { is_streaming: false },
}) })
); );
} }
@ -217,10 +221,15 @@ export const createChatRecord = createAsyncThunk(
messageType === '结束流式传输' messageType === '结束流式传输'
) { ) {
console.log('收到完成消息'); console.log('收到完成消息');
// 获取服务器消息ID (如果存在)
const serverMessageId = data.data?.id;
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
is_streaming: false, messageId: assistantMessageId,
serverMessageId: serverMessageId,
updates: { is_streaming: false },
}) })
); );
} }
@ -230,10 +239,15 @@ export const createChatRecord = createAsyncThunk(
// 如果有content字段也尝试更新 // 如果有content字段也尝试更新
if (data.data && data.data.content !== undefined) { if (data.data && data.data.content !== undefined) {
finalMessage += data.data.content; finalMessage += data.data.content;
// 获取服务器消息ID (如果存在)
const serverMessageId = data.data.id;
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
content: finalMessage, messageId: assistantMessageId,
serverMessageId: serverMessageId,
updates: { content: finalMessage },
}) })
); );
} }
@ -250,9 +264,12 @@ export const createChatRecord = createAsyncThunk(
console.error('流式请求错误:', error); console.error('流式请求错误:', error);
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
content: `错误: ${error.message || '请求失败'}`, messageId: assistantMessageId,
is_streaming: false, updates: {
content: `错误: ${error.message || '请求失败'}`,
is_streaming: false,
},
}) })
); );
} }
@ -261,8 +278,9 @@ export const createChatRecord = createAsyncThunk(
// 确保流式传输结束后标记消息已完成 // 确保流式传输结束后标记消息已完成
dispatch( dispatch(
updateMessage({ updateMessage({
id: assistantMessageId, conversationId: conversationId,
is_streaming: false, messageId: assistantMessageId,
updates: { is_streaming: false },
}) })
); );
@ -277,7 +295,7 @@ export const createChatRecord = createAsyncThunk(
// 获取知识库信息 // 获取知识库信息
const state = getState(); const state = getState();
const availableDatasets = state.chat.availableDatasets.items || []; const availableDatasets = state.chat.availableDatasets.items || [];
const existingChats = state.chat.history.items || []; const existingChats = state.chat.chats.items || [];
// 检查是否已存在此会话ID的记录 // 检查是否已存在此会话ID的记录
const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id); const existingChat = existingChats.find((chat) => chat.conversation_id === chatInfo.conversation_id);
@ -303,6 +321,7 @@ export const createChatRecord = createAsyncThunk(
create_time: new Date().toISOString(), create_time: new Date().toISOString(),
last_message: question, last_message: question,
message_count: 2, // 用户问题和助手回复 message_count: 2, // 用户问题和助手回复
messages: [userMessage, assistantMessage], // 添加消息到聊天记录
}; };
// 更新当前聊天 // 更新当前聊天
@ -319,23 +338,7 @@ export const createChatRecord = createAsyncThunk(
} }
// 设置为当前聊天 // 设置为当前聊天
dispatch( dispatch(setActiveChat(chatInfo.conversation_id));
setCurrentChat({
conversation_id: chatInfo.conversation_id,
datasets: existingChat
? existingChat.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 || '新知识库对话',
};
}),
})
);
} }
return chatInfo; return chatInfo;
@ -363,34 +366,11 @@ export const fetchConversationDetail = createAsyncThunk(
'chat/fetchConversationDetail', 'chat/fetchConversationDetail',
async (conversationId, { rejectWithValue, dispatch, getState }) => { 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 },
}); });
if (response && response.code === 200) { if (response && response.code === 200) {
// 如果存在消息更新Redux状态
if (response.data.messages) {
dispatch({
type: 'chat/fetchMessages/fulfilled',
payload: response.data.messages,
});
}
return response.data; return response.data;
} }
@ -411,7 +391,7 @@ export const fetchConversationDetail = createAsyncThunk(
); );
/** /**
* 创建新会话仅获取会话ID不发送消息 * 创建新会话仅获取会话ID相当于一个会话凭证不发送消息
* @param {Object} params - 参数 * @param {Object} params - 参数
* @param {string[]} params.dataset_id_list - 知识库ID列表 * @param {string[]} params.dataset_id_list - 知识库ID列表
*/ */
@ -450,6 +430,7 @@ export const createConversation = createAsyncThunk(
create_time: new Date().toISOString(), create_time: new Date().toISOString(),
last_message: '', last_message: '',
message_count: 0, message_count: 0,
messages: [], // 初始化空消息数组
}; };
// 更新聊天历史列表 // 更新聊天历史列表
@ -463,12 +444,7 @@ export const createConversation = createAsyncThunk(
}); });
// 设置为当前聊天 // 设置为当前聊天
dispatch( dispatch(setActiveChat(conversationData.conversation_id));
setCurrentChat({
conversation_id: conversationData.conversation_id,
datasets: newChatEntry.datasets,
})
);
return conversationData; return conversationData;
} }

View File

@ -0,0 +1,101 @@
import { createSlice } from '@reduxjs/toolkit';
import { refreshGmailWatch, setupGmailChat, checkGmailAuth } from './gmailChat.thunks';
const initialState = {
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
data: {
historyId: null,
expiration: null,
wasExpired: null,
gmailEmail: null,
},
setup: {
status: 'idle',
error: null,
knowledgeBaseId: null,
conversationId: null,
message: null,
noEmailsWarning: null,
troubleshooting: null,
},
auth: {
status: 'idle',
error: null,
isAuthenticated: false,
needsSetup: false,
watchExpired: false,
lastHistoryId: null,
watchExpiration: null,
},
};
const gmailChatSlice = createSlice({
name: 'gmailChat',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(refreshGmailWatch.pending, (state) => {
state.status = 'loading';
})
.addCase(refreshGmailWatch.fulfilled, (state, action) => {
state.status = 'succeeded';
state.data = {
historyId: action.payload.data.history_id,
expiration: action.payload.data.expiration,
wasExpired: action.payload.data.was_expired,
gmailEmail: action.payload.data.gmail_email,
};
state.error = null;
})
.addCase(refreshGmailWatch.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(setupGmailChat.pending, (state) => {
state.setup.status = 'loading';
state.setup.noEmailsWarning = null;
state.setup.troubleshooting = null;
})
.addCase(setupGmailChat.fulfilled, (state, action) => {
state.setup.status = 'succeeded';
if (action.payload.message && action.payload.message.includes('没有找到')) {
state.setup.noEmailsWarning = action.payload.message;
state.setup.troubleshooting = action.payload.troubleshooting || null;
} else {
state.setup.noEmailsWarning = null;
state.setup.troubleshooting = null;
}
state.setup.knowledgeBaseId =
action.payload.knowledge_base_id || action.payload.data?.knowledge_base_id;
state.setup.conversationId = action.payload.conversation_id || action.payload.data?.conversation_id;
state.setup.message = action.payload.message;
state.setup.error = null;
})
.addCase(setupGmailChat.rejected, (state, action) => {
state.setup.status = 'failed';
state.setup.error = action.error.message;
})
.addCase(checkGmailAuth.pending, (state) => {
state.auth.status = 'loading';
})
.addCase(checkGmailAuth.fulfilled, (state, action) => {
state.auth.status = 'succeeded';
state.auth.isAuthenticated = action.payload.data.authenticated;
state.auth.needsSetup = action.payload.data.needs_setup;
state.auth.watchExpired = action.payload.data.watch_expired;
state.auth.lastHistoryId = action.payload.data.last_history_id;
state.auth.watchExpiration = action.payload.data.watch_expiration;
state.auth.error = null;
})
.addCase(checkGmailAuth.rejected, (state, action) => {
state.auth.status = 'failed';
state.auth.error = action.error.message;
});
},
});
export default gmailChatSlice.reducer;

View File

@ -0,0 +1,76 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { post } from '../../services/api';
export const refreshGmailWatch = createAsyncThunk('gmailChat/refreshGmailWatch', async (_, { rejectWithValue }) => {
try {
const requestData = {
use_proxy: true,
proxy_url: 'http://127.0.0.1:7890',
};
const response = await post('/gmail/refresh-watch/', requestData);
return response;
} catch (error) {
const errorMessage = error.response?.data?.message || 'Failed to refresh Gmail watch';
return rejectWithValue(errorMessage);
}
});
export const setupGmailChat = createAsyncThunk('gmailChat/setupGmailChat', async (payload, { rejectWithValue }) => {
try {
let requestData;
// 处理两种不同的调用情况:只传入邮箱字符串 或 传入包含auth_code的对象
if (typeof payload === 'string') {
// 第一次调用,只传入达人邮箱
requestData = {
client_secret_json: {
installed: {
client_id: '266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com',
project_id: 'knowledge-454905',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_secret: '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',
redirect_uris: ['http://localhost'],
},
},
talent_gmail: payload,
auth_code: "",
};
} else {
// 第二次调用,包含授权码
requestData = {
client_secret_json: {
installed: {
client_id: '266164728215-v84lngbp3vgr4ulql01sqkg5vaigf4a5.apps.googleusercontent.com',
project_id: 'knowledge-454905',
auth_uri: 'https://accounts.google.com/o/oauth2/auth',
token_uri: 'https://oauth2.googleapis.com/token',
auth_provider_x509_cert_url: 'https://www.googleapis.com/oauth2/v1/certs',
client_secret: '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',
redirect_uris: ['http://localhost'],
},
},
talent_gmail: payload.talent_gmail,
auth_code: payload.auth_code,
};
}
const response = await post('/gmail/setup/', requestData);
return response;
} catch (error) {
return rejectWithValue(error.response?.data?.message || error.message || 'Failed to setup Gmail chat');
}
});
export const checkGmailAuth = createAsyncThunk('gmailChat/checkAuth', async (_, { rejectWithValue }) => {
try {
const response = await axios.get('/gmail/check-auth/');
return response.data;
} catch (error) {
const errorMessage = error.response?.data?.message || 'Failed to check Gmail authentication';
return rejectWithValue(errorMessage);
}
});

View File

@ -7,6 +7,8 @@ import knowledgeBaseReducer from './knowledgeBase/knowledgeBase.slice.js';
import chatReducer from './chat/chat.slice.js'; import chatReducer from './chat/chat.slice.js';
import permissionsReducer from './permissions/permissions.slice.js'; import permissionsReducer from './permissions/permissions.slice.js';
import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js'; import notificationCenterReducer from './notificationCenter/notificationCenter.slice.js';
import gmailChatReducer from './gmailChat/gmailChat.slice.js';
import talentChatReducer from './talentChat/talentChat.slice.js';
const rootRducer = combineReducers({ const rootRducer = combineReducers({
auth: authReducer, auth: authReducer,
@ -15,6 +17,8 @@ const rootRducer = combineReducers({
chat: chatReducer, chat: chatReducer,
permissions: permissionsReducer, permissions: permissionsReducer,
notificationCenter: notificationCenterReducer, notificationCenter: notificationCenterReducer,
gmailChat: gmailChatReducer,
talentChat: talentChatReducer,
}); });
const persistConfig = { const persistConfig = {

View File

@ -0,0 +1,84 @@
import { createSlice } from '@reduxjs/toolkit';
import { setUserGoal, getConversationSummary, getRecommendedReply } from './talentChat.thunks';
// 初始状态
const initialState = {
userGoal: {
data: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null
},
conversationSummary: {
data: null,
status: 'idle',
error: null
},
recommendedReply: {
data: null,
status: 'idle',
error: null
}
};
// 创建 slice
const talentChatSlice = createSlice({
name: 'talentChat',
initialState,
reducers: {
resetTalentChat: (state) => {
state.userGoal = { data: null, status: 'idle', error: null };
state.conversationSummary = { data: null, status: 'idle', error: null };
state.recommendedReply = { data: null, status: 'idle', error: null };
}
},
extraReducers: (builder) => {
// 设置用户目标
builder
.addCase(setUserGoal.pending, (state) => {
state.userGoal.status = 'loading';
state.userGoal.error = null;
})
.addCase(setUserGoal.fulfilled, (state, action) => {
state.userGoal.status = 'succeeded';
state.userGoal.data = action.payload?.data || action.payload;
state.userGoal.error = null;
})
.addCase(setUserGoal.rejected, (state, action) => {
state.userGoal.status = 'failed';
state.userGoal.error = action.payload || action.error.message;
})
// 获取会话摘要
.addCase(getConversationSummary.pending, (state) => {
state.conversationSummary.status = 'loading';
state.conversationSummary.error = null;
})
.addCase(getConversationSummary.fulfilled, (state, action) => {
state.conversationSummary.status = 'succeeded';
state.conversationSummary.data = action.payload || null;
state.conversationSummary.error = null;
})
.addCase(getConversationSummary.rejected, (state, action) => {
state.conversationSummary.status = 'failed';
state.conversationSummary.error = action.payload || action.error.message;
})
// 获取推荐回复
.addCase(getRecommendedReply.pending, (state) => {
state.recommendedReply.status = 'loading';
state.recommendedReply.error = null;
})
.addCase(getRecommendedReply.fulfilled, (state, action) => {
state.recommendedReply.status = 'succeeded';
state.recommendedReply.data = action.payload?.data || action.payload;
state.recommendedReply.error = null;
})
.addCase(getRecommendedReply.rejected, (state, action) => {
state.recommendedReply.status = 'failed';
state.recommendedReply.error = action.payload || action.error.message;
});
}
});
export const { resetTalentChat } = talentChatSlice.actions;
export default talentChatSlice.reducer;

View File

@ -0,0 +1,51 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';
import { post, get } from '../../services/api';
export const setUserGoal = createAsyncThunk('talentChat/setUserGoal', async (content, { rejectWithValue }) => {
try {
const requestData = {
content,
};
const response = await post('/user-goal/', requestData);
return response;
} catch (error) {
return rejectWithValue(error.response?.data?.message || error.message || 'Failed to set user goal');
}
});
export const getConversationSummary = createAsyncThunk(
'talentChat/getConversationSummary',
async (talentEmail, { rejectWithValue }) => {
try {
const params = {
talent_email: talentEmail,
};
const { summary } = await get('/conversation-summary/', { params });
return summary;
} catch (error) {
return rejectWithValue(
error.response?.data?.message || error.message || 'Failed to get conversation summary'
);
}
}
);
export const getRecommendedReply = createAsyncThunk(
'talentChat/getRecommendedReply',
async ({ conversation_id, talent_email }, { rejectWithValue }) => {
try {
const requestData = {
conversation_id,
talent_email,
};
const response = await post('/recommended-reply/', requestData);
return response;
} catch (error) {
return rejectWithValue(error.response?.data?.message || error.message || 'Failed to get recommended reply');
}
}
);