agent功能开发增加MCP后端
This commit is contained in:
23
CLAUDE.md
23
CLAUDE.md
@@ -2958,6 +2958,29 @@ refactor(components): 将 EventCard 拆分为原子组件
|
||||
> - **Community 页面**: [docs/Community.md](./docs/Community.md) - 页面架构、组件结构、数据流、变更历史
|
||||
> - **其他页面**: 根据需要创建独立的页面文档
|
||||
|
||||
### 2025-10-30: EventList.js 组件化重构
|
||||
|
||||
**影响范围**: Community 页面核心组件
|
||||
|
||||
**重构成果**:
|
||||
- 将 1095 行的 `EventList.js` 拆分为 497 行主组件 + 10 个子组件
|
||||
- 代码行数减少 **54.6%** (598 行)
|
||||
- 创建了 7 个原子组件 (Atoms) 和 2 个组合组件 (Molecules)
|
||||
|
||||
**新增组件**:
|
||||
- `EventCard/` - 统一入口,智能路由紧凑/详细模式
|
||||
- `CompactEventCard.js` - 紧凑模式事件卡片
|
||||
- `DetailedEventCard.js` - 详细模式事件卡片
|
||||
- 7 个原子组件: EventTimeline, EventImportanceBadge, EventStats, EventFollowButton, EventPriceDisplay, EventDescription, EventHeader
|
||||
|
||||
**新增工具函数**:
|
||||
- `src/utils/priceFormatters.js` - 价格格式化工具 (getPriceChangeColor, formatPriceChange, PriceArrow)
|
||||
- `src/constants/animations.js` - 动画常量 (pulseAnimation, fadeIn, slideInUp)
|
||||
|
||||
**优势**: 提高了代码可维护性、可复用性、可测试性和性能
|
||||
|
||||
**详细文档**: 参见 [docs/Community.md](./docs/Community.md)
|
||||
|
||||
---
|
||||
|
||||
## 更新本文档
|
||||
|
||||
376
src/components/ChatBot/ChatInterface.js
Normal file
376
src/components/ChatBot/ChatInterface.js
Normal file
@@ -0,0 +1,376 @@
|
||||
// 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;
|
||||
149
src/components/ChatBot/MessageBubble.js
Normal file
149
src/components/ChatBot/MessageBubble.js
Normal file
@@ -0,0 +1,149 @@
|
||||
// src/components/ChatBot/MessageBubble.js
|
||||
// 聊天消息气泡组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Avatar,
|
||||
useColorModeValue,
|
||||
IconButton,
|
||||
HStack,
|
||||
Code,
|
||||
Badge,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
|
||||
/**
|
||||
* 消息气泡组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.message - 消息对象
|
||||
* @param {boolean} props.isUser - 是否是用户消息
|
||||
* @param {Function} props.onCopy - 复制消息回调
|
||||
* @param {Function} props.onFeedback - 反馈回调
|
||||
*/
|
||||
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
|
||||
const userBg = useColorModeValue('blue.500', 'blue.600');
|
||||
const botBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const userColor = 'white';
|
||||
const botColor = useColorModeValue('gray.800', 'white');
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
onCopy?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
w="100%"
|
||||
justify={isUser ? 'flex-end' : 'flex-start'}
|
||||
mb={4}
|
||||
>
|
||||
<Flex
|
||||
maxW="75%"
|
||||
flexDirection={isUser ? 'row-reverse' : 'row'}
|
||||
align="flex-start"
|
||||
>
|
||||
{/* 头像 */}
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={isUser ? '用户' : 'AI助手'}
|
||||
bg={isUser ? 'blue.500' : 'green.500'}
|
||||
color="white"
|
||||
mx={3}
|
||||
/>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Box>
|
||||
<Box
|
||||
bg={isUser ? userBg : botBg}
|
||||
color={isUser ? userColor : botColor}
|
||||
px={4}
|
||||
py={3}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
>
|
||||
{message.type === 'text' ? (
|
||||
<Text fontSize="sm" whiteSpace="pre-wrap">
|
||||
{message.content}
|
||||
</Text>
|
||||
) : message.type === 'markdown' ? (
|
||||
<Box fontSize="sm" className="markdown-content">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</Box>
|
||||
) : message.type === 'data' ? (
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={3}
|
||||
bg={useColorModeValue('white', 'gray.600')}
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
>
|
||||
{Object.entries(item).map(([key, value]) => (
|
||||
<Flex key={key} justify="space-between" mb={1}>
|
||||
<Text fontWeight="bold" mr={2}>{key}:</Text>
|
||||
<Text>{String(value)}</Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
{message.data && message.data.length > 5 && (
|
||||
<Badge colorScheme="blue" alignSelf="center">
|
||||
+{message.data.length - 5} 更多结果
|
||||
</Badge>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{/* 消息操作按钮(仅AI消息) */}
|
||||
{!isUser && (
|
||||
<HStack mt={2} spacing={2}>
|
||||
<IconButton
|
||||
icon={<FiCopy />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="复制"
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsUp />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="赞"
|
||||
onClick={() => onFeedback?.('positive')}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiThumbsDown />}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="踩"
|
||||
onClick={() => onFeedback?.('negative')}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* 时间戳 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={1}
|
||||
textAlign={isUser ? 'right' : 'left'}
|
||||
>
|
||||
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
7
src/components/ChatBot/index.js
Normal file
7
src/components/ChatBot/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// src/components/ChatBot/index.js
|
||||
// 聊天机器人组件统一导出
|
||||
|
||||
export { ChatInterface } from './ChatInterface';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
|
||||
export { ChatInterface as default } from './ChatInterface';
|
||||
@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Link
|
||||
onClick={() => handleNavigate('/agent-chat')}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="green">AI</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
py={1}
|
||||
px={3}
|
||||
|
||||
@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={4}
|
||||
p={2}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
|
||||
navigate('/agent-chat');
|
||||
agentCommunityMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
|
||||
@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
|
||||
{/* AGENT社群组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/agent-chat');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">AI聊天助手</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">AI</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
</MenuItem>
|
||||
|
||||
@@ -35,6 +35,9 @@ export const lazyComponents = {
|
||||
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
|
||||
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
|
||||
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
|
||||
|
||||
// Agent模块
|
||||
AgentChat: React.lazy(() => import('../views/AgentChat')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -59,4 +62,5 @@ export const {
|
||||
ForecastReport,
|
||||
FinancialPanorama,
|
||||
MarketDataView,
|
||||
AgentChat,
|
||||
} = lazyComponents;
|
||||
|
||||
@@ -149,6 +149,18 @@ export const routeConfig = [
|
||||
description: '实时市场数据'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Agent模块 ====================
|
||||
{
|
||||
path: 'agent-chat',
|
||||
component: lazyComponents.AgentChat,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: 'AI投资助手',
|
||||
description: '基于MCP的智能投资顾问'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
248
src/services/mcpService.js
Normal file
248
src/services/mcpService.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// src/services/mcpService.js
|
||||
// MCP (Model Context Protocol) 服务层
|
||||
// 用于与FastAPI后端的MCP工具进行交互
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '../utils/apiConfig';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* MCP API客户端
|
||||
*/
|
||||
class MCPService {
|
||||
constructor() {
|
||||
this.baseURL = `${getApiBase()}/mcp`;
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: 60000, // 60秒超时(MCP工具可能需要较长时间)
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
logger.debug('MCPService', 'Request', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Request Error', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.debug('MCPService', 'Response', {
|
||||
url: response.config.url,
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('MCPService', 'Response Error', {
|
||||
url: error.config?.url,
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有可用的MCP工具
|
||||
* @returns {Promise<Object>} 工具列表
|
||||
*/
|
||||
async listTools() {
|
||||
try {
|
||||
const response = await this.client.get('/tools');
|
||||
return {
|
||||
success: true,
|
||||
data: response.tools || [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具列表失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定工具的定义
|
||||
* @param {string} toolName - 工具名称
|
||||
* @returns {Promise<Object>} 工具定义
|
||||
*/
|
||||
async getTool(toolName) {
|
||||
try {
|
||||
const response = await this.client.get(`/tools/${toolName}`);
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '获取工具定义失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用MCP工具
|
||||
* @param {string} toolName - 工具名称
|
||||
* @param {Object} arguments - 工具参数
|
||||
* @returns {Promise<Object>} 工具执行结果
|
||||
*/
|
||||
async callTool(toolName, toolArguments) {
|
||||
try {
|
||||
const response = await this.client.post('/tools/call', {
|
||||
tool: toolName,
|
||||
arguments: toolArguments,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || error.message || '工具调用失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能对话 - 根据用户输入自动选择合适的工具
|
||||
* @param {string} userMessage - 用户消息
|
||||
* @param {Array} conversationHistory - 对话历史(可选)
|
||||
* @returns {Promise<Object>} AI响应
|
||||
*/
|
||||
async chat(userMessage, conversationHistory = []) {
|
||||
try {
|
||||
// 这里可以实现智能路由逻辑
|
||||
// 根据用户输入判断应该调用哪个工具
|
||||
|
||||
// 示例:关键词匹配
|
||||
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage.replace(/新闻|资讯/g, '').trim(),
|
||||
top_k: 5,
|
||||
});
|
||||
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
|
||||
const query = userMessage.replace(/概念|板块/g, '').trim();
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size: 5,
|
||||
sort_by: 'change_pct',
|
||||
});
|
||||
} else if (userMessage.includes('涨停')) {
|
||||
const query = userMessage.replace(/涨停/g, '').trim();
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode: 'hybrid',
|
||||
page_size: 5,
|
||||
});
|
||||
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
|
||||
// 6位数字 = 股票代码
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode: userMessage.trim(),
|
||||
});
|
||||
} else {
|
||||
// 默认:搜索新闻
|
||||
return await this.callTool('search_china_news', {
|
||||
query: userMessage,
|
||||
top_k: 5,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || '对话处理失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具类别枚举
|
||||
*/
|
||||
static TOOL_CATEGORIES = {
|
||||
NEWS: 'news', // 新闻搜索
|
||||
STOCK: 'stock', // 股票信息
|
||||
CONCEPT: 'concept', // 概念板块
|
||||
LIMIT_UP: 'limit_up', // 涨停分析
|
||||
RESEARCH: 'research', // 研报搜索
|
||||
ROADSHOW: 'roadshow', // 路演信息
|
||||
FINANCIAL: 'financial', // 财务数据
|
||||
TRADE: 'trade', // 交易数据
|
||||
};
|
||||
|
||||
/**
|
||||
* 常用工具快捷方式
|
||||
*/
|
||||
async searchNews(query, topK = 5, exactMatch = false) {
|
||||
return await this.callTool('search_china_news', {
|
||||
query,
|
||||
top_k: topK,
|
||||
exact_match: exactMatch,
|
||||
});
|
||||
}
|
||||
|
||||
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
|
||||
return await this.callTool('search_concepts', {
|
||||
query,
|
||||
size,
|
||||
sort_by: sortBy,
|
||||
});
|
||||
}
|
||||
|
||||
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
|
||||
return await this.callTool('search_limit_up_stocks', {
|
||||
query,
|
||||
mode,
|
||||
page_size: pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockInfo(seccode) {
|
||||
return await this.callTool('get_stock_basic_info', {
|
||||
seccode,
|
||||
});
|
||||
}
|
||||
|
||||
async getStockConcepts(stockCode, size = 10) {
|
||||
return await this.callTool('get_stock_concepts', {
|
||||
stock_code: stockCode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async searchResearchReports(query, mode = 'hybrid', size = 5) {
|
||||
return await this.callTool('search_research_reports', {
|
||||
query,
|
||||
mode,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
async getConceptStatistics(days = 7, minStockCount = 3) {
|
||||
return await this.callTool('get_concept_statistics', {
|
||||
days,
|
||||
min_stock_count: minStockCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const mcpService = new MCPService();
|
||||
|
||||
// 导出类(供测试使用)
|
||||
export default MCPService;
|
||||
53
src/views/AgentChat/index.js
Normal file
53
src/views/AgentChat/index.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// src/views/AgentChat/index.js
|
||||
// Agent聊天页面
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import ChatInterface from '../../components/ChatBot';
|
||||
|
||||
/**
|
||||
* Agent聊天页面
|
||||
* 提供基于MCP的AI助手对话功能
|
||||
*/
|
||||
const AgentChat = () => {
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
|
||||
return (
|
||||
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
|
||||
<Container maxW="container.xl" h="100%">
|
||||
<VStack spacing={6} align="stretch" h="100%">
|
||||
{/* 页面标题 */}
|
||||
<Box>
|
||||
<Heading size="lg" mb={2}>AI投资助手</Heading>
|
||||
<Text color="gray.600" fontSize="sm">
|
||||
基于MCP协议的智能投资顾问,支持股票查询、新闻搜索、概念分析等多种功能
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 聊天界面 */}
|
||||
<Box
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="xl"
|
||||
overflow="hidden"
|
||||
h="calc(100vh - 300px)"
|
||||
minH="600px"
|
||||
>
|
||||
<ChatInterface />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentChat;
|
||||
Reference in New Issue
Block a user