// src/views/AgentChat/index.js // 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 // 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 import React, { useState, useEffect, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Box, Button, Input, Avatar, Badge, Divider, Spinner, Tooltip, Checkbox, CheckboxGroup, Kbd, Accordion, AccordionItem, AccordionButton, AccordionPanel, AccordionIcon, Tabs, TabList, TabPanels, Tab, TabPanel, useToast, VStack, HStack, Text, Flex, IconButton, useColorMode, Card, CardBody, Tag, TagLabel, TagCloseButton, } from '@chakra-ui/react'; import { useAuth } from '@contexts/AuthContext'; import { logger } from '@utils/logger'; import axios from 'axios'; // 图标 - 使用 Lucide Icons import { Send, Plus, Search, MessageSquare, Trash2, MoreVertical, RefreshCw, Download, Cpu, User, Zap, Clock, Settings, ChevronLeft, ChevronRight, Activity, Code, Database, TrendingUp, FileText, BookOpen, Menu, X, Check, Circle, Maximize2, Minimize2, Copy, ThumbsUp, ThumbsDown, Sparkles, Brain, Rocket, Paperclip, Image as ImageIcon, File, Calendar, Globe, DollarSign, Newspaper, BarChart3, PieChart, LineChart, Briefcase, Users, } from 'lucide-react'; // 常量配置 - 从 TypeScript 模块导入 import { MessageTypes } from './constants/messageTypes'; import { DEFAULT_MODEL_ID } from './constants/models'; import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; // 拆分后的子组件 import BackgroundEffects from './components/BackgroundEffects'; import LeftSidebar from './components/LeftSidebar'; import ChatArea from './components/ChatArea'; import RightSidebar from './components/RightSidebar'; /** * Agent Chat - 主组件(HeroUI v3 深色主题) * * 注意:所有常量配置已提取到 constants/ 目录: * - animations: constants/animations.ts * - MessageTypes: constants/messageTypes.ts * - AVAILABLE_MODELS: constants/models.ts * - MCP_TOOLS, TOOL_CATEGORIES: constants/tools.ts * - quickQuestions: constants/quickQuestions.ts */ const AgentChat = () => { const { user } = useAuth(); const toast = useToast(); const { setColorMode } = useColorMode(); // 会话管理 const [sessions, setSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [isLoadingSessions, setIsLoadingSessions] = useState(false); // 消息管理 const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isProcessing, setIsProcessing] = useState(false); // UI 状态 const [searchQuery, setSearchQuery] = useState(''); const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true); const [isRightSidebarOpen, setIsRightSidebarOpen] = useState(true); // 文件上传 const [uploadedFiles, setUploadedFiles] = useState([]); const fileInputRef = useRef(null); // Refs const messagesEndRef = useRef(null); const inputRef = useRef(null); // ==================== 启用深色模式 ==================== useEffect(() => { // 为 AgentChat 页面强制启用深色模式 setColorMode('dark'); document.documentElement.classList.add('dark'); return () => { // 组件卸载时不移除,让其他页面自己控制 // document.documentElement.classList.remove('dark'); }; }, [setColorMode]); // ==================== API 调用函数 ==================== const loadSessions = async () => { if (!user?.id) return; setIsLoadingSessions(true); try { const response = await axios.get('/mcp/agent/sessions', { params: { user_id: user.id, limit: 50 }, }); if (response.data.success) { setSessions(response.data.data); } } catch (error) { logger.error('加载会话列表失败', error); } finally { setIsLoadingSessions(false); } }; const loadSessionHistory = async (sessionId) => { if (!sessionId) return; try { const response = await axios.get(`/mcp/agent/history/${sessionId}`, { params: { limit: 100 }, }); if (response.data.success) { const history = response.data.data; const formattedMessages = history.map((msg, idx) => ({ id: `${sessionId}-${idx}`, type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE, content: msg.message, plan: msg.plan ? JSON.parse(msg.plan) : null, stepResults: msg.steps ? JSON.parse(msg.steps) : null, timestamp: msg.timestamp, })); setMessages(formattedMessages); } } catch (error) { logger.error('加载会话历史失败', error); } }; const createNewSession = () => { setCurrentSessionId(null); setMessages([ { id: Date.now(), type: MessageTypes.AGENT_RESPONSE, content: `你好${user?.nickname || ''}!👋\n\n我是**价小前**,你的 AI 投研助手。\n\n**我能做什么?**\n• 📊 全面分析股票基本面和技术面\n• 🔥 追踪市场热点和涨停板块\n• 📈 研究行业趋势和投资机会\n• 📰 汇总最新财经新闻和研报\n\n直接输入你的问题开始探索!`, timestamp: new Date().toISOString(), }, ]); }; const switchSession = (sessionId) => { setCurrentSessionId(sessionId); loadSessionHistory(sessionId); }; const handleSendMessage = async () => { if (!inputValue.trim() || isProcessing) return; const userMessage = { type: MessageTypes.USER, content: inputValue, timestamp: new Date().toISOString(), files: uploadedFiles.length > 0 ? uploadedFiles : undefined, }; addMessage(userMessage); const userInput = inputValue; setInputValue(''); setUploadedFiles([]); setIsProcessing(true); try { addMessage({ type: MessageTypes.AGENT_THINKING, content: '正在分析你的问题...', timestamp: new Date().toISOString(), }); const response = await axios.post('/mcp/agent/chat', { message: userInput, conversation_history: messages .filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE) .map((m) => ({ isUser: m.type === MessageTypes.USER, content: m.content, })), user_id: user?.id || 'anonymous', user_nickname: user?.nickname || '匿名用户', user_avatar: user?.avatar || '', subscription_type: user?.subscription_type || 'free', session_id: currentSessionId, model: selectedModel, tools: selectedTools, files: uploadedFiles.length > 0 ? uploadedFiles : undefined, }); setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING)); if (response.data.success) { const data = response.data; if (data.session_id && !currentSessionId) { setCurrentSessionId(data.session_id); } if (data.plan) { addMessage({ type: MessageTypes.AGENT_PLAN, content: '已制定执行计划', plan: data.plan, timestamp: new Date().toISOString(), }); } if (data.steps && data.steps.length > 0) { addMessage({ type: MessageTypes.AGENT_EXECUTING, content: '正在执行步骤...', plan: data.plan, stepResults: data.steps, timestamp: new Date().toISOString(), }); } setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING)); addMessage({ type: MessageTypes.AGENT_RESPONSE, content: data.final_answer || data.message || '处理完成', plan: data.plan, stepResults: data.steps, metadata: data.metadata, timestamp: new Date().toISOString(), }); loadSessions(); } } catch (error) { logger.error('Agent chat error', error); setMessages((prev) => prev.filter( (m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING ) ); const errorMessage = error.response?.data?.error || error.message || '处理失败'; addMessage({ type: MessageTypes.ERROR, content: `处理失败:${errorMessage}`, timestamp: new Date().toISOString(), }); toast({ title: '处理失败', description: errorMessage, status: 'error', duration: 5000, }); } finally { setIsProcessing(false); } }; // 文件上传处理 const handleFileSelect = (event) => { const files = Array.from(event.target.files || []); const fileData = files.map(file => ({ name: file.name, size: file.size, type: file.type, // 实际上传时需要转换为 base64 或上传到服务器 url: URL.createObjectURL(file), })); setUploadedFiles(prev => [...prev, ...fileData]); }; const removeFile = (index) => { setUploadedFiles(prev => prev.filter((_, i) => i !== index)); }; const addMessage = (message) => { setMessages((prev) => [ ...prev, { id: Date.now() + Math.random(), ...message, }, ]); }; const handleKeyPress = (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage(); } }; useEffect(() => { loadSessions(); createNewSession(); }, [user]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); return ( {/* 背景渐变装饰 */} {/* 左侧栏 */} setIsLeftSidebarOpen(false)} sessions={sessions} currentSessionId={currentSessionId} onSessionSwitch={switchSession} onNewSession={createNewSession} isLoadingSessions={isLoadingSessions} user={user} /> {/* 中间主聊天区域 */} {/* 顶部标题栏 - 深色毛玻璃 */} {!isLeftSidebarOpen && ( } onClick={() => setIsLeftSidebarOpen(true)} bg="rgba(255, 255, 255, 0.05)" color="gray.400" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", color: "white" }} /> )} } bgGradient="linear(to-br, purple.500, pink.500)" boxShadow="0 0 20px rgba(236, 72, 153, 0.5)" /> 价小前投研 AI 智能分析 {AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name} } onClick={createNewSession} bg="rgba(255, 255, 255, 0.05)" color="gray.400" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", color: "white", borderColor: "purple.400", boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" }} /> {!isRightSidebarOpen && ( } onClick={() => setIsRightSidebarOpen(true)} bg="rgba(255, 255, 255, 0.05)" color="gray.400" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", color: "white" }} /> )} {/* 消息列表 */} {messages.map((message) => ( ))}
{/* 快捷问题 */} {messages.length <= 2 && !isProcessing && ( 快速开始 {quickQuestions.map((question, idx) => ( ))} )} {/* 输入栏 - 深色毛玻璃 */} {/* 已上传文件预览 */} {uploadedFiles.length > 0 && ( {uploadedFiles.map((file, idx) => ( {file.name} removeFile(idx)} color="gray.400" /> ))} )} } onClick={() => fileInputRef.current?.click()} bg="rgba(255, 255, 255, 0.05)" color="gray.300" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", borderColor: "purple.400", color: "white", boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" }} /> } onClick={() => { fileInputRef.current?.setAttribute('accept', 'image/*'); fileInputRef.current?.click(); }} bg="rgba(255, 255, 255, 0.05)" color="gray.300" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", borderColor: "purple.400", color: "white", boxShadow: "0 0 12px rgba(139, 92, 246, 0.3)" }} /> setInputValue(e.target.value)} onKeyDown={handleKeyPress} placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)" isDisabled={isProcessing} size="lg" variant="outline" borderWidth={2} bg="rgba(255, 255, 255, 0.05)" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" color="white" _placeholder={{ color: "gray.500" }} _hover={{ borderColor: "rgba(255, 255, 255, 0.2)" }} _focus={{ borderColor: "purple.400", boxShadow: "0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)", bg: "rgba(255, 255, 255, 0.08)" }} /> } onClick={handleSendMessage} isLoading={isProcessing} isDisabled={!inputValue.trim() || isProcessing} bgGradient="linear(to-r, blue.500, purple.600)" color="white" _hover={{ bgGradient: "linear(to-r, blue.600, purple.700)", boxShadow: "0 8px 20px rgba(139, 92, 246, 0.4)" }} _active={{ transform: "translateY(0)", boxShadow: "0 4px 12px rgba(139, 92, 246, 0.3)" }} /> Enter 发送 Shift + Enter 换行 {/* 右侧栏 - 深色配置中心 */} {isRightSidebarOpen && ( 配置中心 } onClick={() => setIsRightSidebarOpen(false)} bg="rgba(255, 255, 255, 0.05)" color="gray.300" backdropFilter="blur(10px)" border="1px solid" borderColor="rgba(255, 255, 255, 0.1)" _hover={{ bg: "rgba(255, 255, 255, 0.1)", borderColor: "purple.400", color: "white" }} /> 模型 工具 {selectedTools.length > 0 && ( {selectedTools.length} )} 统计 {/* 模型选择 */} {AVAILABLE_MODELS.map((model, idx) => ( setSelectedModel(model.id)} bg={selectedModel === model.id ? 'rgba(139, 92, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)'} backdropFilter="blur(12px)" borderWidth={2} borderColor={selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.1)'} _hover={{ borderColor: selectedModel === model.id ? 'purple.400' : 'rgba(255, 255, 255, 0.2)', boxShadow: selectedModel === model.id ? "0 8px 20px rgba(139, 92, 246, 0.4)" : "0 4px 12px rgba(0, 0, 0, 0.3)" }} transition="all 0.3s" > {model.icon} {model.name} {model.description} {selectedModel === model.id && ( )} ))} {/* 工具选择 */} {Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => ( {category} {tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length} {tools.map((tool) => ( {tool.icon} {tool.name} {tool.description} ))} ))} {/* 统计信息 */} 对话数 {sessions.length} 消息数 {messages.length} 已选工具 {selectedTools.length} )} ); }; export default AgentChat; return ( {session.title || '新对话'} {new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', })} {session.message_count && ( {session.message_count} )} ); }; /** * 消息渲染器 */ const MessageRenderer = ({ message, userAvatar }) => { switch (message.type) { case MessageTypes.USER: return ( {message.content} {message.files && message.files.length > 0 && ( {message.files.map((file, idx) => ( {file.name} ))} )} } size="sm" bgGradient="linear(to-br, blue.500, purple.600)" boxShadow="0 0 12px rgba(139, 92, 246, 0.4)" /> ); case MessageTypes.AGENT_THINKING: return ( } size="sm" bgGradient="linear(to-br, purple.500, pink.500)" boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" /> {message.content} ); case MessageTypes.AGENT_RESPONSE: return ( } size="sm" bgGradient="linear(to-br, purple.500, pink.500)" boxShadow="0 0 12px rgba(236, 72, 153, 0.4)" /> {message.content} {message.stepResults && message.stepResults.length > 0 && ( )} } onClick={() => navigator.clipboard.writeText(message.content)} bg="rgba(255, 255, 255, 0.05)" color="gray.400" _hover={{ color: "white", bg: "rgba(255, 255, 255, 0.1)" }} /> } bg="rgba(255, 255, 255, 0.05)" color="gray.400" _hover={{ color: "green.400", bg: "rgba(16, 185, 129, 0.1)", boxShadow: "0 0 12px rgba(16, 185, 129, 0.3)" }} /> } bg="rgba(255, 255, 255, 0.05)" color="gray.400" _hover={{ color: "red.400", bg: "rgba(239, 68, 68, 0.1)", boxShadow: "0 0 12px rgba(239, 68, 68, 0.3)" }} /> {new Date(message.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', })} ); case MessageTypes.ERROR: return ( {message.content} ); default: return null; } }; /** * 执行步骤显示组件 */ const ExecutionStepsDisplay = ({ steps, plan }) => { return ( 执行详情 {steps.length} 步骤 {steps.map((result, idx) => ( 步骤 {idx + 1}: {result.tool_name} {result.status} {result.execution_time?.toFixed(2)}s {result.error && ( ⚠️ {result.error} )} ))} ); };