Files
vf_react/src/components/ChatBot/ChatInterface.js

377 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;