From e7ba8c4c2d3fa1340e63bf26a56f391e2eb72e4c Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 7 Nov 2025 18:11:29 +0800 Subject: [PATCH] =?UTF-8?q?agent=E5=8A=9F=E8=83=BD=E5=BC=80=E5=8F=91?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0MCP=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 23 ++ src/components/ChatBot/ChatInterface.js | 376 ++++++++++++++++++ src/components/ChatBot/MessageBubble.js | 149 +++++++ src/components/ChatBot/index.js | 7 + .../components/MobileDrawer/MobileDrawer.js | 20 + .../components/Navigation/DesktopNav.js | 29 +- .../Navbars/components/Navigation/MoreMenu.js | 16 + src/routes/lazy-components.js | 4 + src/routes/routeConfig.js | 12 + src/services/mcpService.js | 248 ++++++++++++ src/views/AgentChat/index.js | 53 +++ 11 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 src/components/ChatBot/ChatInterface.js create mode 100644 src/components/ChatBot/MessageBubble.js create mode 100644 src/components/ChatBot/index.js create mode 100644 src/services/mcpService.js create mode 100644 src/views/AgentChat/index.js diff --git a/CLAUDE.md b/CLAUDE.md index b1460323..07e104f4 100644 --- a/CLAUDE.md +++ b/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) + --- ## 更新本文档 diff --git a/src/components/ChatBot/ChatInterface.js b/src/components/ChatBot/ChatInterface.js new file mode 100644 index 00000000..35984432 --- /dev/null +++ b/src/components/ChatBot/ChatInterface.js @@ -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 ( + + {/* 头部工具栏 */} + + + AI投资助手 + 在线 + {availableTools.length > 0 && ( + {availableTools.length} 个工具 + )} + + + } + size="sm" + variant="ghost" + aria-label="清空对话" + onClick={handleClearChat} + /> + } + size="sm" + variant="ghost" + aria-label="导出对话" + onClick={handleExportChat} + /> + + } + size="sm" + variant="ghost" + aria-label="设置" + /> + + 模型设置 + 快捷指令 + 历史记录 + + + + + + {/* 消息列表 */} + + + {messages.map((message) => ( + + ))} + {isLoading && ( + + + + AI正在思考... + + + )} +
+ + + + {/* 快捷问题(仅在消息较少时显示) */} + {messages.length <= 2 && ( + + 快捷问题: + + {quickQuestions.map((question, idx) => ( + + ))} + + + )} + + + + {/* 输入框 */} + + + setInputValue(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="输入消息... (Shift+Enter换行,Enter发送)" + bg={inputBg} + border="none" + _focus={{ boxShadow: 'none' }} + mr={2} + disabled={isLoading} + /> + } + colorScheme="blue" + aria-label="发送" + onClick={handleSendMessage} + isLoading={isLoading} + disabled={!inputValue.trim()} + /> + + + + ); +}; + +export default ChatInterface; diff --git a/src/components/ChatBot/MessageBubble.js b/src/components/ChatBot/MessageBubble.js new file mode 100644 index 00000000..ce491702 --- /dev/null +++ b/src/components/ChatBot/MessageBubble.js @@ -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 ( + + + {/* 头像 */} + + + {/* 消息内容 */} + + + {message.type === 'text' ? ( + + {message.content} + + ) : message.type === 'markdown' ? ( + + {message.content} + + ) : message.type === 'data' ? ( + + {message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => ( + + {Object.entries(item).map(([key, value]) => ( + + {key}: + {String(value)} + + ))} + + ))} + {message.data && message.data.length > 5 && ( + + +{message.data.length - 5} 更多结果 + + )} + + ) : null} + + + {/* 消息操作按钮(仅AI消息) */} + {!isUser && ( + + } + size="xs" + variant="ghost" + aria-label="复制" + onClick={handleCopy} + /> + } + size="xs" + variant="ghost" + aria-label="赞" + onClick={() => onFeedback?.('positive')} + /> + } + size="xs" + variant="ghost" + aria-label="踩" + onClick={() => onFeedback?.('negative')} + /> + + )} + + {/* 时间戳 */} + + {message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }) : ''} + + + + + ); +}; + +export default MessageBubble; diff --git a/src/components/ChatBot/index.js b/src/components/ChatBot/index.js new file mode 100644 index 00000000..59a0a5f6 --- /dev/null +++ b/src/components/ChatBot/index.js @@ -0,0 +1,7 @@ +// src/components/ChatBot/index.js +// 聊天机器人组件统一导出 + +export { ChatInterface } from './ChatInterface'; +export { MessageBubble } from './MessageBubble'; + +export { ChatInterface as default } from './ChatInterface'; diff --git a/src/components/Navbars/components/MobileDrawer/MobileDrawer.js b/src/components/Navbars/components/MobileDrawer/MobileDrawer.js index 1649deff..89d7d7ad 100644 --- a/src/components/Navbars/components/MobileDrawer/MobileDrawer.js +++ b/src/components/Navbars/components/MobileDrawer/MobileDrawer.js @@ -243,6 +243,26 @@ const MobileDrawer = memo(({ AGENT社群 + 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'} + > + + AI聊天助手 + + AI + NEW + + + { as={Button} variant="ghost" rightIcon={} + 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 }) => { + { + // 🎯 追踪菜单项点击 + 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'} + > + + AI聊天助手 + + AI + NEW + + + { {/* AGENT社群组 */} AGENT社群 + { + moreMenu.onClose(); // 先关闭菜单 + navigate('/agent-chat'); + }} + borderRadius="md" + bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'} + > + + AI聊天助手 + + AI + NEW + + + 今日热议 diff --git a/src/routes/lazy-components.js b/src/routes/lazy-components.js index 6659d6e1..2fcaa578 100644 --- a/src/routes/lazy-components.js +++ b/src/routes/lazy-components.js @@ -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; diff --git a/src/routes/routeConfig.js b/src/routes/routeConfig.js index 64dd9c7d..6d632416 100644 --- a/src/routes/routeConfig.js +++ b/src/routes/routeConfig.js @@ -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的智能投资顾问' + } + }, ]; /** diff --git a/src/services/mcpService.js b/src/services/mcpService.js new file mode 100644 index 00000000..b6d011d0 --- /dev/null +++ b/src/services/mcpService.js @@ -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} 工具列表 + */ + 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} 工具定义 + */ + 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} 工具执行结果 + */ + 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} 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; diff --git a/src/views/AgentChat/index.js b/src/views/AgentChat/index.js new file mode 100644 index 00000000..1d2fe93e --- /dev/null +++ b/src/views/AgentChat/index.js @@ -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 ( + + + + {/* 页面标题 */} + + AI投资助手 + + 基于MCP协议的智能投资顾问,支持股票查询、新闻搜索、概念分析等多种功能 + + + + {/* 聊天界面 */} + + + + + + + ); +}; + +export default AgentChat;