[dev]gmail side panel

This commit is contained in:
susie-laptop 2025-04-17 20:38:06 -04:00
parent 2b89db7301
commit 6a654950a5
13 changed files with 781 additions and 1931 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

@ -17,12 +17,13 @@ export default function Chat() {
items: chatHistory,
status,
error,
} = useSelector((state) => state.chat.history || { items: [], status: 'idle', error: null });
const operationStatus = useSelector((state) => state.chat.createSession?.status);
const operationError = useSelector((state) => state.chat.createSession?.error);
} = useSelector((state) => state.chat.chats || { items: [], status: 'idle', error: null });
const operationStatus = useSelector((state) => state.chat.chatOperation?.status);
const operationError = useSelector((state) => state.chat.chatOperation?.error);
//
useEffect(() => {
console.log(chatHistory);
dispatch(fetchChats({ page: 1, page_size: 20 }));
}, [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 { useDispatch, useSelector } from 'react-redux';
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 { createChatRecord, fetchAvailableDatasets, fetchConversationDetail } from '../../store/chat/chat.thunks';
import { fetchKnowledgeBases } from '../../store/knowledgeBase/knowledgeBase.thunks';
import SvgIcon from '../../components/SvgIcon';
import SafeMarkdown from '../../components/SafeMarkdown';
import ChatSidePanel from './ChatSidePanel';
import { get } from '../../services/api';
export default function ChatWindow({ chatId, knowledgeBaseId }) {
@ -20,11 +21,14 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const [noEmailsWarning, setNoEmailsWarning] = useState(null);
const [troubleshooting, setTroubleshooting] = useState(null);
// Redux store
const messages = useSelector((state) => state.chat.messages.items);
const messageStatus = useSelector((state) => state.chat.messages.status);
const messageError = useSelector((state) => state.chat.messages.error);
const { 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);
@ -40,14 +44,14 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []);
const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
//
const conversation = useSelector((state) => state.chat.currentChat.data);
const conversationStatus = useSelector((state) => state.chat.currentChat.status);
const conversationError = useSelector((state) => state.chat.currentChat.error);
// ID
const activeConversationId = useSelector((state) => state.chat.activeConversationId);
//
const createSessionStatus = useSelector((state) => state.chat.createSession?.status);
const createSessionId = useSelector((state) => state.chat.createSession?.sessionId);
//
const chatOperationStatus = useSelector((state) => state.chat.chatOperation.status);
//
const [talentEmail, setTalentEmail] = useState('');
// ID
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
@ -84,11 +88,11 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
}
}, [chatId, gmailNoEmailsWarning, gmailTroubleshooting]);
// conversationknowledgeBaseIdselectedKnowledgeBaseIds
// currentChatknowledgeBaseIdselectedKnowledgeBaseIds
useEffect(() => {
// 使conversation
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
const datasetIds = conversation.datasets.map((ds) => ds.id);
// 使currentChat
if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
const datasetIds = currentChat.datasets.map((ds) => ds.id);
console.log('从会话中获取知识库列表:', datasetIds);
setSelectedKnowledgeBaseIds(datasetIds);
}
@ -99,7 +103,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
console.log('从URL参数中获取知识库列表:', ids);
setSelectedKnowledgeBaseIds(ids);
}
}, [conversation, knowledgeBaseId]);
}, [currentChat, knowledgeBaseId]);
//
useEffect(() => {
@ -111,12 +115,12 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
return;
}
//
const isNewlyCreatedChat = createSessionStatus === 'succeeded' && createSessionId === chatId;
//
const existingChat = chats.find((chat) => chat.conversation_id === chatId);
//
if (isNewlyCreatedChat && conversation && conversation.conversation_id === chatId) {
console.log('跳过新创建会话的详情获取:', chatId);
//
if (existingChat && existingChat.messages && existingChat.messages.length > 0) {
console.log('聊天已存在且有消息,跳过详情获取:', chatId);
hasLoadedDetailRef.current[chatId] = true;
return;
}
@ -143,14 +147,7 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
.finally(() => {
setLoading(false);
});
//
return () => {
dispatch(resetMessages());
// hasLoadedDetailRef
// hasLoadedDetailRef.current = {}; // ref
};
}, [chatId, dispatch, createSessionStatus, createSessionId]);
}, [chatId, dispatch, chats]);
// ref
useEffect(() => {
@ -185,10 +182,13 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
// 使
dispatch(
addMessage({
id: 'gmail-warning-' + Date.now(),
role: 'assistant',
content: `⚠️ ${noEmailsMessage}\n\n您仍然可以在此聊天中提问但可能无法获得与邮件内容相关的回答。`,
created_at: new Date().toISOString(),
conversationId: chatId,
message: {
id: 'gmail-warning-' + Date.now(),
role: 'assistant',
content: `⚠️ ${noEmailsMessage}\n\n您仍然可以在此聊天中提问但可能无法获得与邮件内容相关的回答。`,
created_at: new Date().toISOString(),
},
})
);
@ -210,10 +210,13 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
dispatch(
addMessage({
id: 'welcome-' + Date.now(),
role: 'assistant',
content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`,
created_at: new Date().toISOString(),
conversationId: chatId,
message: {
id: 'welcome-' + Date.now(),
role: 'assistant',
content: `欢迎使用${selectedKb.name}。您可以向我提问任何相关问题。`,
created_at: new Date().toISOString(),
},
})
);
}
@ -222,16 +225,16 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
//
useEffect(() => {
if (sendStatus === 'failed' && sendError) {
if (messageStatus === 'failed' && messageError) {
dispatch(
showNotification({
message: `发送失败: ${sendError}`,
message: `发送失败: ${messageError}`,
type: 'danger',
})
);
dispatch(resetSendMessageStatus());
dispatch(resetMessageOperation());
}
}, [sendStatus, sendError, dispatch]);
}, [messageStatus, messageError, dispatch]);
//
useEffect(() => {
@ -240,8 +243,8 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
//
useEffect(() => {
// conversation使
if (conversation && conversation.datasets && conversation.datasets.length > 0) {
// currentChat使
if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
return;
}
@ -249,12 +252,36 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) {
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) => {
e.preventDefault();
if (!inputMessage.trim() || sendStatus === 'loading') return;
if (!inputMessage.trim() || messageStatus === 'loading') return;
console.log('准备发送消息:', inputMessage);
console.log('当前会话ID:', chatId);
@ -267,9 +294,9 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
// 使
dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, ''));
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);
} else if (knowledgeBaseId) {
// 使
@ -362,125 +389,136 @@ export default function ChatWindow({ chatId, knowledgeBaseId }) {
};
return (
<div className='chat-window d-flex flex-column h-100'>
{/* Chat header */}
<div className='p-3 border-bottom'>
{conversation && conversation.datasets ? (
<>
<h5 className='mb-0'>{conversation.datasets.map((dataset) => dataset.name).join(', ')}</h5>
{conversation.datasets.length > 0 && conversation.datasets[0].type && (
<small className='text-muted'>类型: {conversation.datasets[0].type}</small>
)}
</>
) : knowledgeBase ? (
<>
<h5 className='mb-0'>{knowledgeBase.name}</h5>
<small className='text-muted'>{knowledgeBase.description}</small>
</>
) : (
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
)}
</div>
<div className='d-flex h-100'>
{/* Main Chat Area */}
<div className='chat-window d-flex flex-column h-100 flex-grow-1'>
{/* Chat header */}
<div className='p-3 border-bottom'>
{currentChat && currentChat.datasets ? (
<>
<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>
<small className='text-muted'>{knowledgeBase.description}</small>
</>
) : (
<h5 className='mb-0'>{loading || availableDatasetsLoading ? '加载中...' : '聊天'}</h5>
)}
</div>
{/* Chat messages */}
<div className='flex-grow-1 p-3 overflow-auto'>
<div className='container'>
{messageStatus === 'loading'
? renderLoading()
: messageStatus === 'failed'
? renderError()
: messages.length === 0
? renderEmpty()
: messages.map((message) => (
<div
key={message.id}
className={`d-flex ${
message.role === 'user' ? 'align-items-end' : 'align-items-start'
} mb-3 flex-column`}
>
{/* Chat messages */}
<div className='flex-grow-1 p-3 overflow-auto'>
<div className='container'>
{messageStatus === 'loading' && !messages.length
? renderLoading()
: messageStatus === 'failed' && !messages.length
? renderError()
: messages.length === 0
? renderEmpty()
: messages.map((message) => (
<div
className={`chat-message p-3 rounded-3 ${
message.role === 'user' ? 'bg-dark text-white' : 'bg-light'
}`}
style={{
maxWidth: '75%',
position: 'relative',
}}
key={message.id}
className={`d-flex ${
message.role === 'user' ? 'align-items-end' : 'align-items-start'
} mb-3 flex-column`}
>
<div className='message-content'>
{message.role === 'user' ? (
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
className={`chat-message p-3 rounded-3 ${
message.role === 'user' ? 'bg-dark text-white' : 'bg-light'
}`}
style={{
maxWidth: '75%',
position: 'relative',
}}
>
<div className='message-content'>
{message.role === 'user' ? (
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 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();
<div ref={messagesEndRef} />
</div>
</div>
//
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 ref={messagesEndRef} />
{/* Chat input */}
<div className='p-3 border-top'>
<form onSubmit={handleSendMessage} className='d-flex gap-2'>
<input
type='text'
className='form-control'
placeholder='输入你的问题...'
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
disabled={messageStatus === 'loading'}
/>
<button
type='submit'
className='btn btn-dark d-flex align-items-center justify-content-center gap-2'
disabled={messageStatus === 'loading' || !inputMessage.trim()}
>
<SvgIcon className='send' color='#ffffff' />
<span className='ms-1' style={{ minWidth: 'fit-content' }}>
发送
</span>
</button>
</form>
</div>
</div>
{/* Chat input */}
<div className='p-3 border-top'>
<form onSubmit={handleSendMessage} className='d-flex gap-2'>
<input
type='text'
className='form-control'
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>
{/* Right Side Panel */}
<div
className='chat-side-panel-container border-start'
style={{ width: '350px', height: '100%', overflow: 'hidden' }}
>
<ChatSidePanel chatId={chatId} talentEmail={talentEmail} />
</div>
</div>
);

View File

@ -25,9 +25,9 @@ export default function NewChat() {
const error = useSelector((state) => state.chat.availableDatasets.error);
//
const chatHistory = useSelector((state) => state.chat.history.items || []);
const chatHistoryLoading = useSelector((state) => state.chat.history.status === 'loading');
const chatCreationStatus = useSelector((state) => state.chat.sendMessage?.status);
const chatHistory = useSelector((state) => state.chat.chats.items || []);
const chatHistoryLoading = useSelector((state) => state.chat.chats.status === 'loading');
const chatCreationStatus = useSelector((state) => state.chat.messageOperation?.status);
// Gmail
const gmailSetupStatus = useSelector((state) => state.gmailChat?.setup?.status);

View File

@ -15,7 +15,6 @@ import AccessRequestModal from '../../components/AccessRequestModal';
import CreateKnowledgeBaseModal from '../../components/CreateKnowledgeBaseModal';
import Pagination from '../../components/Pagination';
import SearchBar from '../../components/SearchBar';
import ApiModeSwitch from '../../components/ApiModeSwitch';
//
import KnowledgeBaseList from './components/KnowledgeBaseList';
@ -461,9 +460,6 @@ export default function KnowledgeBase() {
return (
<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'>
<SearchBar
searchKeyword={searchKeyword}

View File

@ -1,6 +1,5 @@
import axios from 'axios';
import CryptoJS from 'crypto-js';
import { mockGet, mockPost, mockPut, mockDelete } from './mockApi';
const secretKey = import.meta.env.VITE_SECRETKEY;
@ -42,13 +41,6 @@ api.interceptors.response.use(
return response;
},
(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
if (error.response) {
// 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;
}
};
// 初始检查服务器状态
checkServerStatus();
// Define common HTTP methods with fallback to mock API
// Define common HTTP methods
const get = async (url, params = {}) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] GET ${url}`);
return await mockGet(url, params);
}
const res = await api.get(url, { ...params });
return res.data;
} 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;
}
};
// Handle POST requests for JSON data with fallback to mock API
// Handle POST requests for JSON data
const post = async (url, data, isMultipart = false) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] POST ${url}`);
return await mockPost(url, data);
}
const headers = isMultipart
? { 'Content-Type': 'multipart/form-data' } // For file uploads
: { '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 });
return res.data;
} 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;
}
};
// Handle PUT requests with fallback to mock API
// Handle PUT requests
const put = async (url, data) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] PUT ${url}`);
return await mockPut(url, data);
}
const res = await api.put(url, data, {
headers: { 'Content-Type': 'application/json' },
});
return res.data;
} 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;
}
};
// Handle DELETE requests with fallback to mock API
// Handle DELETE requests
const del = async (url) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] DELETE ${url}`);
return await mockDelete(url);
}
const res = await api.delete(url);
return res.data;
} 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;
}
};
const upload = async (url, data) => {
try {
if (isServerDown) {
console.log(`[MOCK MODE] Upload ${url}`);
return await mockPost(url, data, true);
}
const axiosInstance = await axios.create({
baseURL: '/api',
headers: {
@ -191,41 +124,12 @@ const upload = async (url, data) => {
const res = await axiosInstance.post(url, data);
return res.data;
} 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;
}
};
// 手动切换到模拟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
const streamRequest = async (url, data, onChunk, onError) => {
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
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 {
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) {
return response.data.messages;
return response.data.messages || [];
}
return response.data?.messages || [];
return [];
} catch (error) {
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) {
return response.data;
return {
...response.data,
role: response.data.role || 'user', // 确保有角色字段
};
}
return response.data || {};

View File

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

View File

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

View File

@ -1,84 +1,84 @@
import { createSlice } from '@reduxjs/toolkit';
import { setUserGoal, getConversationSummary, getRecommendedReply } from './talentChat.thunks';
// 初始状态
const initialState = {
goal: {
userGoal: {
data: null,
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
data: {
id: null,
content: null,
created_at: null,
updated_at: null,
},
error: null
},
summary: {
conversationSummary: {
data: null,
status: 'idle',
error: null,
data: {
id: null,
talent_email: null,
conversation_id: null,
summary: null,
created_at: null,
updated_at: null,
},
error: null
},
recommendedReply: {
data: null,
status: 'idle',
error: null,
reply: null,
},
error: null
}
};
// 创建 slice
const talentChatSlice = createSlice({
name: 'talentChat',
initialState,
reducers: {},
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
// Handle setUserGoal
.addCase(setUserGoal.pending, (state) => {
state.goal.status = 'loading';
state.userGoal.status = 'loading';
state.userGoal.error = null;
})
.addCase(setUserGoal.fulfilled, (state, action) => {
state.goal.status = 'succeeded';
state.goal.data = action.payload.goal;
state.goal.error = null;
state.userGoal.status = 'succeeded';
state.userGoal.data = action.payload?.data || action.payload;
state.userGoal.error = null;
})
.addCase(setUserGoal.rejected, (state, action) => {
state.goal.status = 'failed';
state.goal.error = action.error.message;
state.userGoal.status = 'failed';
state.userGoal.error = action.payload || action.error.message;
})
// Handle getConversationSummary
// 获取会话摘要
.addCase(getConversationSummary.pending, (state) => {
state.summary.status = 'loading';
state.conversationSummary.status = 'loading';
state.conversationSummary.error = null;
})
.addCase(getConversationSummary.fulfilled, (state, action) => {
state.summary.status = 'succeeded';
state.summary.data = action.payload.summary;
state.summary.error = null;
state.conversationSummary.status = 'succeeded';
state.conversationSummary.data = action.payload || null;
state.conversationSummary.error = null;
})
.addCase(getConversationSummary.rejected, (state, action) => {
state.summary.status = 'failed';
state.summary.error = action.error.message;
state.conversationSummary.status = 'failed';
state.conversationSummary.error = action.payload || action.error.message;
})
// Handle getRecommendedReply
// 获取推荐回复
.addCase(getRecommendedReply.pending, (state) => {
state.recommendedReply.status = 'loading';
state.recommendedReply.error = null;
})
.addCase(getRecommendedReply.fulfilled, (state, action) => {
state.recommendedReply.status = 'succeeded';
state.recommendedReply.reply = action.payload.reply;
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.error.message;
state.recommendedReply.error = action.payload || action.error.message;
});
},
}
});
export const { resetTalentChat } = talentChatSlice.actions;
export default talentChatSlice.reducer;

View File

@ -23,8 +23,8 @@ export const getConversationSummary = createAsyncThunk(
talent_email: talentEmail,
};
const response = await get('/conversation-summary/', { params });
return response;
const { summary } = await get('/conversation-summary/', { params });
return summary;
} catch (error) {
return rejectWithValue(
error.response?.data?.message || error.message || 'Failed to get conversation summary'