mirror of
https://github.com/Funkoala14/knowledgebase_influencer.git
synced 2025-06-08 03:08:14 +08:00
526 lines
24 KiB
JavaScript
526 lines
24 KiB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { useDispatch, useSelector } from 'react-redux';
|
||
import { fetchMessages } from '../../store/chat/chat.messages.thunks';
|
||
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 }) {
|
||
const dispatch = useDispatch();
|
||
const [inputMessage, setInputMessage] = useState('');
|
||
const [loading, setLoading] = useState(false);
|
||
const messagesEndRef = useRef(null);
|
||
const hasLoadedDetailRef = useRef({}); // 添加ref来跟踪已加载的会话
|
||
|
||
// Gmail无邮件警告状态
|
||
const [noEmailsWarning, setNoEmailsWarning] = useState(null);
|
||
const [troubleshooting, setTroubleshooting] = useState(null);
|
||
|
||
// 从 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状态结构
|
||
const knowledgeBases = useSelector((state) => state.knowledgeBase.knowledgeBases || []);
|
||
const knowledgeBase = knowledgeBases.find((kb) => kb.id === knowledgeBaseId);
|
||
const isLoadingKnowledgeBases = useSelector((state) => state.knowledgeBase.loading);
|
||
|
||
// 获取可用数据集列表
|
||
const availableDatasets = useSelector((state) => state.chat.availableDatasets.items || []);
|
||
const availableDatasetsLoading = useSelector((state) => state.chat.availableDatasets.status === 'loading');
|
||
|
||
// 获取当前活跃聊天ID
|
||
const activeConversationId = useSelector((state) => state.chat.activeConversationId);
|
||
|
||
// 聊天操作状态
|
||
const chatOperationStatus = useSelector((state) => state.chat.chatOperation.status);
|
||
|
||
// 提取达人邮箱信息(用于侧边栏功能)
|
||
const [talentEmail, setTalentEmail] = useState('');
|
||
|
||
// 监听知识库ID变更,确保保存在组件状态中
|
||
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState([]);
|
||
|
||
// 检查是否存在Gmail无邮件警告
|
||
useEffect(() => {
|
||
// 优先使用Redux状态中的警告
|
||
if (gmailNoEmailsWarning) {
|
||
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]);
|
||
|
||
// 当currentChat或knowledgeBaseId更新时,更新selectedKnowledgeBaseIds
|
||
useEffect(() => {
|
||
// 优先使用currentChat中的知识库列表
|
||
if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
|
||
const datasetIds = currentChat.datasets.map((ds) => ds.id);
|
||
console.log('从会话中获取知识库列表:', datasetIds);
|
||
setSelectedKnowledgeBaseIds(datasetIds);
|
||
}
|
||
// 其次使用URL中传入的知识库ID
|
||
else if (knowledgeBaseId) {
|
||
// 可能是单个ID或以逗号分隔的多个ID
|
||
const ids = knowledgeBaseId.split(',').map((id) => id.trim());
|
||
console.log('从URL参数中获取知识库列表:', ids);
|
||
setSelectedKnowledgeBaseIds(ids);
|
||
}
|
||
}, [currentChat, knowledgeBaseId]);
|
||
|
||
// 获取聊天详情
|
||
useEffect(() => {
|
||
if (!chatId) return;
|
||
|
||
// 如果已经加载过这个chatId的详情,不再重复加载
|
||
if (hasLoadedDetailRef.current[chatId]) {
|
||
console.log('跳过已加载过的会话详情:', chatId);
|
||
return;
|
||
}
|
||
|
||
// 检查是否是已存在的聊天
|
||
const existingChat = chats.find((chat) => chat.conversation_id === chatId);
|
||
|
||
// 如果已经有这个聊天的消息,则不需要获取详情
|
||
if (existingChat && existingChat.messages && existingChat.messages.length > 0) {
|
||
console.log('聊天已存在且有消息,跳过详情获取:', chatId);
|
||
hasLoadedDetailRef.current[chatId] = true;
|
||
return;
|
||
}
|
||
|
||
console.log('获取会话详情:', chatId);
|
||
setLoading(true);
|
||
|
||
dispatch(fetchConversationDetail(chatId))
|
||
.unwrap()
|
||
.then((response) => {
|
||
console.log('获取会话详情成功:', response);
|
||
// 标记为已加载
|
||
hasLoadedDetailRef.current[chatId] = true;
|
||
})
|
||
.catch((error) => {
|
||
console.error('获取会话详情失败:', error);
|
||
dispatch(
|
||
showNotification({
|
||
message: `获取聊天详情失败: ${error || '未知错误'}`,
|
||
type: 'danger',
|
||
})
|
||
);
|
||
})
|
||
.finally(() => {
|
||
setLoading(false);
|
||
});
|
||
}, [chatId, dispatch, chats]);
|
||
|
||
// 组件销毁时完全清空ref缓存
|
||
useEffect(() => {
|
||
return () => {
|
||
hasLoadedDetailRef.current = {};
|
||
};
|
||
}, []);
|
||
|
||
// 新会话自动添加欢迎消息
|
||
useEffect(() => {
|
||
// 如果是新聊天且没有任何消息,添加一条系统欢迎消息
|
||
if (chatId && messages.length === 0 && !loading && messageStatus !== 'loading') {
|
||
// 检查是否有无邮件警告作为首条消息
|
||
let isNoEmailsFirstMessage = false;
|
||
let noEmailsMessage = null;
|
||
|
||
try {
|
||
const savedWarning = localStorage.getItem('gmailNoEmailsWarning');
|
||
if (savedWarning) {
|
||
const warningData = JSON.parse(savedWarning);
|
||
// 检查是否是当前会话的警告、是否是首条消息
|
||
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]);
|
||
|
||
// 监听发送消息状态
|
||
useEffect(() => {
|
||
if (messageStatus === 'failed' && messageError) {
|
||
dispatch(
|
||
showNotification({
|
||
message: `发送失败: ${messageError}`,
|
||
type: 'danger',
|
||
})
|
||
);
|
||
dispatch(resetMessageOperation());
|
||
}
|
||
}, [messageStatus, messageError, dispatch]);
|
||
|
||
// 滚动到底部
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
// 获取当前会话的知识库信息
|
||
useEffect(() => {
|
||
// 如果currentChat有数据集信息,优先使用它
|
||
if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
|
||
return;
|
||
}
|
||
|
||
// 如果没有会话数据集信息,但有knowledgeBaseId,尝试从知识库列表中查找
|
||
if (knowledgeBaseId && knowledgeBases.length === 0 && !availableDatasets.length) {
|
||
dispatch(fetchAvailableDatasets());
|
||
}
|
||
}, [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() || messageStatus === 'loading') return;
|
||
|
||
console.log('准备发送消息:', inputMessage);
|
||
console.log('当前会话ID:', chatId);
|
||
|
||
// 获取知识库ID列表
|
||
let dataset_id_list = [];
|
||
|
||
// 优先使用组件状态中保存的知识库列表
|
||
if (selectedKnowledgeBaseIds.length > 0) {
|
||
// 使用已保存的知识库列表
|
||
dataset_id_list = selectedKnowledgeBaseIds.map((id) => id.replace(/-/g, ''));
|
||
console.log('使用组件状态中的知识库列表:', dataset_id_list);
|
||
} else if (currentChat && currentChat.datasets && currentChat.datasets.length > 0) {
|
||
// 如果已有会话,使用会话中的知识库
|
||
dataset_id_list = currentChat.datasets.map((ds) => ds.id.replace(/-/g, ''));
|
||
console.log('使用会话中的知识库列表:', dataset_id_list);
|
||
} else if (knowledgeBaseId) {
|
||
// 如果是新会话,使用当前选择的知识库
|
||
// 可能是单个ID或以逗号分隔的多个ID
|
||
const ids = knowledgeBaseId.split(',').map((id) => id.trim().replace(/-/g, ''));
|
||
dataset_id_list = ids;
|
||
console.log('使用URL参数中的知识库:', dataset_id_list);
|
||
} else if (availableDatasets.length > 0) {
|
||
// 如果都没有,尝试使用可用知识库列表中的第一个
|
||
dataset_id_list = [availableDatasets[0].id.replace(/-/g, '')];
|
||
console.log('使用可用知识库列表中的第一个:', dataset_id_list);
|
||
}
|
||
|
||
if (dataset_id_list.length === 0) {
|
||
dispatch(
|
||
showNotification({
|
||
message: '发送失败:未选择知识库',
|
||
type: 'danger',
|
||
})
|
||
);
|
||
return;
|
||
}
|
||
|
||
console.log('发送消息参数:', {
|
||
dataset_id_list,
|
||
question: inputMessage,
|
||
conversation_id: chatId,
|
||
});
|
||
|
||
// 发送消息到服务器
|
||
dispatch(
|
||
createChatRecord({
|
||
dataset_id_list: dataset_id_list,
|
||
question: inputMessage,
|
||
conversation_id: chatId,
|
||
})
|
||
)
|
||
.unwrap()
|
||
.then((response) => {
|
||
// 成功发送后,可以执行任何需要的操作
|
||
console.log('消息发送成功:', response);
|
||
})
|
||
.catch((error) => {
|
||
// 发送失败,显示错误信息
|
||
console.error('消息发送失败:', error);
|
||
dispatch(
|
||
showNotification({
|
||
message: `发送失败: ${error}`,
|
||
type: 'danger',
|
||
})
|
||
);
|
||
});
|
||
|
||
// 清空输入框
|
||
setInputMessage('');
|
||
};
|
||
|
||
// 渲染加载状态
|
||
const renderLoading = () => (
|
||
<div className='p-5 text-center'>
|
||
<div className='spinner-border text-secondary' role='status'>
|
||
<span className='visually-hidden'>加载中...</span>
|
||
</div>
|
||
<div className='mt-3 text-muted'>加载聊天记录...</div>
|
||
</div>
|
||
);
|
||
|
||
// 渲染错误状态
|
||
const renderError = () => (
|
||
<div className='alert alert-danger'>
|
||
<p className='mb-0'>
|
||
<strong>加载消息失败</strong>
|
||
</p>
|
||
<p className='mb-0 small'>{messageError}</p>
|
||
<button className='btn btn-outline-secondary mt-3' onClick={() => dispatch(fetchMessages(chatId))}>
|
||
重试
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
// 渲染空消息状态
|
||
const renderEmpty = () => {
|
||
if (loading) return null;
|
||
|
||
return (
|
||
<div className='text-center my-5'>
|
||
<p className='text-muted'>暂无消息,开始发送第一条消息吧</p>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<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' && !messages.length
|
||
? renderLoading()
|
||
: messageStatus === 'failed' && !messages.length
|
||
? 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`}
|
||
>
|
||
<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 ref={messagesEndRef} />
|
||
</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={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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|