feat: 简化主组件 index.js - 使用组件组合方式重构

This commit is contained in:
zdl
2025-11-24 15:18:52 +08:00
parent f536d68753
commit 41f1bbab1b
4 changed files with 3573 additions and 1537 deletions

View File

@@ -0,0 +1,816 @@
// 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 (
<Box flex={1} bg="gray.900">
<Flex h="100%" overflow="hidden" position="relative">
{/* 背景渐变装饰 */}
<BackgroundEffects />
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
messagesEndRef={messagesEndRef}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
{/* 右侧栏 - 深色配置中心 */}
<AnimatePresence>
{isRightSidebarOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInRight}
>
<Box
w="320px"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderLeft="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="-4px 0 24px rgba(0, 0, 0, 0.3)"
>
<Box p={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack justify="space-between">
<HStack spacing={2}>
<Settings className="w-5 h-5" color="#C084FC" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, purple.300, pink.300)"
bgClip="text"
fontSize="md"
>
配置中心
</Text>
</HStack>
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronRight className="w-4 h-4" />}
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"
}}
/>
</motion.div>
</Tooltip>
</HStack>
</Box>
<Box flex={1} overflowY="auto">
<Tabs colorScheme="purple" variant="line">
<TabList px={4} borderBottom="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<Tab
color="gray.400"
_selected={{
color: "purple.400",
borderColor: "purple.500",
boxShadow: "0 2px 8px rgba(139, 92, 246, 0.3)"
}}
>
<HStack spacing={2}>
<Cpu className="w-4 h-4" />
<Text>模型</Text>
</HStack>
</Tab>
<Tab
color="gray.400"
_selected={{
color: "purple.400",
borderColor: "purple.500",
boxShadow: "0 2px 8px rgba(139, 92, 246, 0.3)"
}}
>
<HStack spacing={2}>
<Code className="w-4 h-4" />
<Text>工具</Text>
{selectedTools.length > 0 && (
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
borderRadius="full"
fontSize="xs"
px={2}
py={0.5}
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{selectedTools.length}
</Badge>
)}
</HStack>
</Tab>
<Tab
color="gray.400"
_selected={{
color: "purple.400",
borderColor: "purple.500",
boxShadow: "0 2px 8px rgba(139, 92, 246, 0.3)"
}}
>
<HStack spacing={2}>
<BarChart3 className="w-4 h-4" />
<Text>统计</Text>
</HStack>
</Tab>
</TabList>
<TabPanels>
{/* 模型选择 */}
<TabPanel p={4}>
<VStack spacing={3} align="stretch">
{AVAILABLE_MODELS.map((model, idx) => (
<motion.div
key={model.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: idx * 0.1 }}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
>
<Card
cursor="pointer"
onClick={() => 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"
>
<CardBody p={3}>
<HStack align="start" spacing={3}>
<Box
p={2}
borderRadius="lg"
bgGradient={selectedModel === model.id
? "linear(to-br, purple.500, pink.500)"
: "linear(to-br, rgba(139, 92, 246, 0.2), rgba(236, 72, 153, 0.2))"}
boxShadow={selectedModel === model.id
? "0 4px 12px rgba(139, 92, 246, 0.4)"
: "none"}
>
{model.icon}
</Box>
<Box flex={1}>
<Text fontWeight="semibold" fontSize="sm" color="gray.100">
{model.name}
</Text>
<Text fontSize="xs" color="gray.400" mt={1}>
{model.description}
</Text>
</Box>
{selectedModel === model.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
>
<Check className="w-5 h-5" color="#A78BFA" />
</motion.div>
)}
</HStack>
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</TabPanel>
{/* 工具选择 */}
<TabPanel p={4}>
<Accordion allowMultiple>
{Object.entries(TOOL_CATEGORIES).map(([category, tools], catIdx) => (
<motion.div
key={category}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: catIdx * 0.05 }}
>
<AccordionItem
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
mb={2}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
_hover={{
bg: 'rgba(255, 255, 255, 0.08)',
borderColor: 'rgba(255, 255, 255, 0.2)'
}}
>
<AccordionButton>
<HStack flex={1} justify="space-between" pr={2}>
<Text color="gray.100" fontSize="sm">{category}</Text>
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
variant="subtle"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length}
</Badge>
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
<AccordionPanel pb={4}>
<CheckboxGroup
value={selectedTools}
onChange={setSelectedTools}
>
<VStack align="stretch" spacing={2}>
{tools.map((tool) => (
<motion.div
key={tool.id}
whileHover={{ x: 4 }}
transition={{ type: "spring", stiffness: 300 }}
>
<Checkbox
value={tool.id}
colorScheme="purple"
p={2}
borderRadius="lg"
bg="rgba(255, 255, 255, 0.02)"
_hover={{ bg: 'rgba(255, 255, 255, 0.05)' }}
transition="background 0.2s"
>
<HStack spacing={2} align="start">
<Box color="purple.400" mt={0.5}>{tool.icon}</Box>
<Box>
<Text fontSize="sm" color="gray.200">{tool.name}</Text>
<Text fontSize="xs" color="gray.500">{tool.description}</Text>
</Box>
</HStack>
</Checkbox>
</motion.div>
))}
</VStack>
</CheckboxGroup>
</AccordionPanel>
</AccordionItem>
</motion.div>
))}
</Accordion>
<HStack mt={4} spacing={2}>
<motion.div flex={1} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={() => setSelectedTools(MCP_TOOLS.map(t => t.id))}
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
_hover={{
bgGradient: "linear(to-r, blue.600, purple.600)",
boxShadow: "0 4px 12px rgba(139, 92, 246, 0.4)"
}}
>
全选
</Button>
</motion.div>
<motion.div flex={1} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
w="full"
variant="ghost"
onClick={() => setSelectedTools([])}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: "rgba(255, 255, 255, 0.1)",
borderColor: "rgba(255, 255, 255, 0.2)"
}}
>
清空
</Button>
</motion.div>
</HStack>
</TabPanel>
{/* 统计信息 */}
<TabPanel p={4}>
<VStack spacing={4} align="stretch">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">对话数</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
>
{sessions.length}
</Text>
</Box>
<MessageSquare className="w-8 h-8" color="#60A5FA" style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">消息数</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, purple.400, pink.400)"
bgClip="text"
>
{messages.length}
</Text>
</Box>
<Activity className="w-8 h-8" color="#C084FC" style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody p={4}>
<Flex align="center" justify="space-between">
<Box>
<Text fontSize="xs" color="gray.400">已选工具</Text>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, green.400, teal.400)"
bgClip="text"
>
{selectedTools.length}
</Text>
</Box>
<Code className="w-8 h-8" color="#34D399" style={{ opacity: 0.5 }} />
</Flex>
</CardBody>
</Card>
</motion.div>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
</Flex>
</Box>
);
};
export default AgentChat;