Files
vf_react/src/views/AgentChat/index.js.bak3

817 lines
31 KiB
Plaintext
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/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;