377 lines
10 KiB
JavaScript
377 lines
10 KiB
JavaScript
// src/components/ChatBot/ChatInterface.js
|
||
// 聊天界面主组件
|
||
|
||
import React, { useState, useRef, useEffect } from 'react';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Input,
|
||
IconButton,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
Spinner,
|
||
useColorModeValue,
|
||
useToast,
|
||
Divider,
|
||
Badge,
|
||
Menu,
|
||
MenuButton,
|
||
MenuList,
|
||
MenuItem,
|
||
Button,
|
||
} from '@chakra-ui/react';
|
||
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
|
||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||
import MessageBubble from './MessageBubble';
|
||
import { mcpService } from '../../services/mcpService';
|
||
import { logger } from '../../utils/logger';
|
||
|
||
/**
|
||
* 聊天界面组件
|
||
*/
|
||
export const ChatInterface = () => {
|
||
const [messages, setMessages] = useState([
|
||
{
|
||
id: 1,
|
||
content: '你好!我是AI投资助手,我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的?',
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
},
|
||
]);
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [availableTools, setAvailableTools] = useState([]);
|
||
|
||
const messagesEndRef = useRef(null);
|
||
const inputRef = useRef(null);
|
||
const toast = useToast();
|
||
|
||
// 颜色主题
|
||
const bgColor = useColorModeValue('white', 'gray.800');
|
||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||
const inputBg = useColorModeValue('gray.50', 'gray.700');
|
||
|
||
// 加载可用工具列表
|
||
useEffect(() => {
|
||
const loadTools = async () => {
|
||
const result = await mcpService.listTools();
|
||
if (result.success) {
|
||
setAvailableTools(result.data);
|
||
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
|
||
}
|
||
};
|
||
loadTools();
|
||
}, []);
|
||
|
||
// 自动滚动到底部
|
||
const scrollToBottom = () => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
};
|
||
|
||
useEffect(() => {
|
||
scrollToBottom();
|
||
}, [messages]);
|
||
|
||
// 发送消息
|
||
const handleSendMessage = async () => {
|
||
if (!inputValue.trim() || isLoading) return;
|
||
|
||
const userMessage = {
|
||
id: Date.now(),
|
||
content: inputValue,
|
||
isUser: true,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
|
||
setMessages((prev) => [...prev, userMessage]);
|
||
setInputValue('');
|
||
setIsLoading(true);
|
||
|
||
try {
|
||
// 调用MCP服务
|
||
const response = await mcpService.chat(inputValue, messages);
|
||
|
||
let botMessage;
|
||
if (response.success) {
|
||
// 根据返回的数据类型构造消息
|
||
const data = response.data;
|
||
|
||
if (typeof data === 'string') {
|
||
botMessage = {
|
||
id: Date.now() + 1,
|
||
content: data,
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
} else if (Array.isArray(data)) {
|
||
// 数据列表
|
||
botMessage = {
|
||
id: Date.now() + 1,
|
||
content: `找到 ${data.length} 条结果:`,
|
||
isUser: false,
|
||
type: 'data',
|
||
data: data,
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
} else if (typeof data === 'object') {
|
||
// 对象数据
|
||
botMessage = {
|
||
id: Date.now() + 1,
|
||
content: JSON.stringify(data, null, 2),
|
||
isUser: false,
|
||
type: 'markdown',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
} else {
|
||
botMessage = {
|
||
id: Date.now() + 1,
|
||
content: '抱歉,我无法理解这个查询结果。',
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
}
|
||
} else {
|
||
botMessage = {
|
||
id: Date.now() + 1,
|
||
content: `抱歉,查询失败:${response.error}`,
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
setMessages((prev) => [...prev, botMessage]);
|
||
} catch (error) {
|
||
logger.error('ChatInterface', 'handleSendMessage', error);
|
||
const errorMessage = {
|
||
id: Date.now() + 1,
|
||
content: `抱歉,发生了错误:${error.message}`,
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
setMessages((prev) => [...prev, errorMessage]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
inputRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
// 处理键盘事件
|
||
const handleKeyPress = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSendMessage();
|
||
}
|
||
};
|
||
|
||
// 清空对话
|
||
const handleClearChat = () => {
|
||
setMessages([
|
||
{
|
||
id: 1,
|
||
content: '对话已清空。有什么可以帮到你的?',
|
||
isUser: false,
|
||
type: 'text',
|
||
timestamp: new Date().toISOString(),
|
||
},
|
||
]);
|
||
};
|
||
|
||
// 复制消息
|
||
const handleCopyMessage = () => {
|
||
toast({
|
||
title: '已复制',
|
||
status: 'success',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
};
|
||
|
||
// 反馈
|
||
const handleFeedback = (type) => {
|
||
logger.info('ChatInterface', 'Feedback', { type });
|
||
toast({
|
||
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
|
||
status: 'info',
|
||
duration: 2000,
|
||
isClosable: true,
|
||
});
|
||
};
|
||
|
||
// 快捷问题
|
||
const quickQuestions = [
|
||
'查询贵州茅台的股票信息',
|
||
'搜索人工智能相关新闻',
|
||
'今日涨停股票有哪些',
|
||
'新能源概念板块分析',
|
||
];
|
||
|
||
const handleQuickQuestion = (question) => {
|
||
setInputValue(question);
|
||
inputRef.current?.focus();
|
||
};
|
||
|
||
// 导出对话
|
||
const handleExportChat = () => {
|
||
const chatText = messages
|
||
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${msg.content}`)
|
||
.join('\n\n');
|
||
|
||
const blob = new Blob([chatText], { type: 'text/plain' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
return (
|
||
<Flex direction="column" h="100%" bg={bgColor}>
|
||
{/* 头部工具栏 */}
|
||
<Flex
|
||
px={4}
|
||
py={3}
|
||
borderBottom="1px"
|
||
borderColor={borderColor}
|
||
align="center"
|
||
justify="space-between"
|
||
>
|
||
<HStack>
|
||
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
|
||
<Badge colorScheme="green">在线</Badge>
|
||
{availableTools.length > 0 && (
|
||
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
|
||
)}
|
||
</HStack>
|
||
<HStack>
|
||
<IconButton
|
||
icon={<FiRefreshCw />}
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label="清空对话"
|
||
onClick={handleClearChat}
|
||
/>
|
||
<IconButton
|
||
icon={<FiDownload />}
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label="导出对话"
|
||
onClick={handleExportChat}
|
||
/>
|
||
<Menu>
|
||
<MenuButton
|
||
as={IconButton}
|
||
icon={<FiSettings />}
|
||
size="sm"
|
||
variant="ghost"
|
||
aria-label="设置"
|
||
/>
|
||
<MenuList>
|
||
<MenuItem>模型设置</MenuItem>
|
||
<MenuItem>快捷指令</MenuItem>
|
||
<MenuItem>历史记录</MenuItem>
|
||
</MenuList>
|
||
</Menu>
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 消息列表 */}
|
||
<Box
|
||
flex="1"
|
||
overflowY="auto"
|
||
px={4}
|
||
py={4}
|
||
css={{
|
||
'&::-webkit-scrollbar': {
|
||
width: '8px',
|
||
},
|
||
'&::-webkit-scrollbar-track': {
|
||
background: 'transparent',
|
||
},
|
||
'&::-webkit-scrollbar-thumb': {
|
||
background: '#CBD5E0',
|
||
borderRadius: '4px',
|
||
},
|
||
}}
|
||
>
|
||
<VStack spacing={0} align="stretch">
|
||
{messages.map((message) => (
|
||
<MessageBubble
|
||
key={message.id}
|
||
message={message}
|
||
isUser={message.isUser}
|
||
onCopy={handleCopyMessage}
|
||
onFeedback={handleFeedback}
|
||
/>
|
||
))}
|
||
{isLoading && (
|
||
<Flex justify="flex-start" mb={4}>
|
||
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
|
||
<Spinner size="sm" mr={2} />
|
||
<Text fontSize="sm">AI正在思考...</Text>
|
||
</Flex>
|
||
</Flex>
|
||
)}
|
||
<div ref={messagesEndRef} />
|
||
</VStack>
|
||
</Box>
|
||
|
||
{/* 快捷问题(仅在消息较少时显示) */}
|
||
{messages.length <= 2 && (
|
||
<Box px={4} py={2}>
|
||
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题:</Text>
|
||
<Flex wrap="wrap" gap={2}>
|
||
{quickQuestions.map((question, idx) => (
|
||
<Button
|
||
key={idx}
|
||
size="xs"
|
||
variant="outline"
|
||
onClick={() => handleQuickQuestion(question)}
|
||
>
|
||
{question}
|
||
</Button>
|
||
))}
|
||
</Flex>
|
||
</Box>
|
||
)}
|
||
|
||
<Divider />
|
||
|
||
{/* 输入框 */}
|
||
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
|
||
<Flex>
|
||
<Input
|
||
ref={inputRef}
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="输入消息... (Shift+Enter换行,Enter发送)"
|
||
bg={inputBg}
|
||
border="none"
|
||
_focus={{ boxShadow: 'none' }}
|
||
mr={2}
|
||
disabled={isLoading}
|
||
/>
|
||
<IconButton
|
||
icon={<FiSend />}
|
||
colorScheme="blue"
|
||
aria-label="发送"
|
||
onClick={handleSendMessage}
|
||
isLoading={isLoading}
|
||
disabled={!inputValue.trim()}
|
||
/>
|
||
</Flex>
|
||
</Box>
|
||
</Flex>
|
||
);
|
||
};
|
||
|
||
export default ChatInterface;
|