Files
vf_react/src/views/AgentChat/index.js
2025-11-23 08:24:30 +08:00

2094 lines
76 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
/**
* Framer Motion 动画变体配置
*/
const animations = {
slideInLeft: {
initial: { x: -320, opacity: 0 },
animate: {
x: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 30,
},
},
exit: {
x: -320,
opacity: 0,
transition: { duration: 0.2 },
},
},
slideInRight: {
initial: { x: 320, opacity: 0 },
animate: {
x: 0,
opacity: 1,
transition: {
type: 'spring',
stiffness: 300,
damping: 30,
},
},
exit: {
x: 320,
opacity: 0,
transition: { duration: 0.2 },
},
},
fadeInUp: {
initial: { opacity: 0, y: 20 },
animate: {
opacity: 1,
y: 0,
transition: {
type: 'spring',
stiffness: 400,
damping: 25,
},
},
},
staggerItem: {
initial: { opacity: 0, y: 10 },
animate: { opacity: 1, y: 0 },
},
staggerContainer: {
animate: {
transition: {
staggerChildren: 0.05,
},
},
},
pressScale: {
whileTap: { scale: 0.95 },
whileHover: { scale: 1.05 },
},
};
/**
* 消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* 可用模型配置
*/
const AVAILABLE_MODELS = [
{
id: 'kimi-k2-thinking',
name: 'Kimi K2 Thinking',
description: '深度思考模型,适合复杂分析',
icon: <Brain className="w-5 h-5" />,
color: 'purple',
},
{
id: 'kimi-k2',
name: 'Kimi K2',
description: '快速响应模型,适合简单查询',
icon: <Zap className="w-5 h-5" />,
color: 'blue',
},
{
id: 'deepmoney',
name: 'DeepMoney',
description: '金融专业模型',
icon: <TrendingUp className="w-5 h-5" />,
color: 'green',
},
];
/**
* MCP 工具配置(完整列表)
*/
const MCP_TOOLS = [
// 新闻搜索类
{
id: 'search_news',
name: '全球新闻搜索',
icon: <Globe className="w-4 h-4" />,
category: '新闻资讯',
description: '搜索全球新闻,支持关键词和日期过滤'
},
{
id: 'search_china_news',
name: '中国新闻搜索',
icon: <Newspaper className="w-4 h-4" />,
category: '新闻资讯',
description: 'KNN语义搜索中国新闻'
},
{
id: 'search_medical_news',
name: '医疗健康新闻',
icon: <Activity className="w-4 h-4" />,
category: '新闻资讯',
description: '医药、医疗设备、生物技术新闻'
},
// 概念板块类
{
id: 'search_concepts',
name: '概念板块搜索',
icon: <PieChart className="w-4 h-4" />,
category: '概念板块',
description: '搜索股票概念板块及相关股票'
},
{
id: 'get_concept_details',
name: '概念详情',
icon: <FileText className="w-4 h-4" />,
category: '概念板块',
description: '获取概念板块详细信息'
},
{
id: 'get_stock_concepts',
name: '股票概念',
icon: <BarChart3 className="w-4 h-4" />,
category: '概念板块',
description: '查询股票相关概念板块'
},
{
id: 'get_concept_statistics',
name: '概念统计',
icon: <LineChart className="w-4 h-4" />,
category: '概念板块',
description: '涨幅榜、跌幅榜、活跃榜等'
},
// 涨停分析类
{
id: 'search_limit_up_stocks',
name: '涨停股票搜索',
icon: <TrendingUp className="w-4 h-4" />,
category: '涨停分析',
description: '搜索涨停股票,支持多条件筛选'
},
{
id: 'get_daily_stock_analysis',
name: '涨停日报',
icon: <Calendar className="w-4 h-4" />,
category: '涨停分析',
description: '每日涨停股票分析报告'
},
// 研报路演类
{
id: 'search_research_reports',
name: '研报搜索',
icon: <BookOpen className="w-4 h-4" />,
category: '研报路演',
description: '搜索研究报告,支持语义搜索'
},
{
id: 'search_roadshows',
name: '路演活动',
icon: <Briefcase className="w-4 h-4" />,
category: '研报路演',
description: '上市公司路演、投资者交流活动'
},
// 股票数据类
{
id: 'get_stock_basic_info',
name: '股票基本信息',
icon: <FileText className="w-4 h-4" />,
category: '股票数据',
description: '公司名称、行业、主营业务等'
},
{
id: 'get_stock_financial_index',
name: '财务指标',
icon: <DollarSign className="w-4 h-4" />,
category: '股票数据',
description: 'EPS、ROE、营收增长率等'
},
{
id: 'get_stock_trade_data',
name: '交易数据',
icon: <BarChart3 className="w-4 h-4" />,
category: '股票数据',
description: '价格、成交量、涨跌幅等'
},
{
id: 'get_stock_balance_sheet',
name: '资产负债表',
icon: <PieChart className="w-4 h-4" />,
category: '股票数据',
description: '资产、负债、所有者权益'
},
{
id: 'get_stock_cashflow',
name: '现金流量表',
icon: <LineChart className="w-4 h-4" />,
category: '股票数据',
description: '经营、投资、筹资现金流'
},
{
id: 'search_stocks_by_criteria',
name: '条件选股',
icon: <Search className="w-4 h-4" />,
category: '股票数据',
description: '按行业、地区、市值筛选'
},
{
id: 'get_stock_comparison',
name: '股票对比',
icon: <BarChart3 className="w-4 h-4" />,
category: '股票数据',
description: '多只股票财务指标对比'
},
// 用户数据类
{
id: 'get_user_watchlist',
name: '自选股列表',
icon: <Users className="w-4 h-4" />,
category: '用户数据',
description: '用户关注的股票及行情'
},
{
id: 'get_user_following_events',
name: '关注事件',
icon: <Activity className="w-4 h-4" />,
category: '用户数据',
description: '用户关注的重大事件'
},
];
// 按类别分组工具
const TOOL_CATEGORIES = {
'新闻资讯': MCP_TOOLS.filter(t => t.category === '新闻资讯'),
'概念板块': MCP_TOOLS.filter(t => t.category === '概念板块'),
'涨停分析': MCP_TOOLS.filter(t => t.category === '涨停分析'),
'研报路演': MCP_TOOLS.filter(t => t.category === '研报路演'),
'股票数据': MCP_TOOLS.filter(t => t.category === '股票数据'),
'用户数据': MCP_TOOLS.filter(t => t.category === '用户数据'),
};
/**
* Agent Chat - 主组件HeroUI v3 深色主题)
*/
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('kimi-k2-thinking');
const [selectedTools, setSelectedTools] = useState([
'search_news',
'search_china_news',
'search_concepts',
'search_limit_up_stocks',
'search_research_reports',
]);
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]);
// ==================== 按日期分组会话 ====================
const groupSessionsByDate = (sessions) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
const groups = {
today: [],
yesterday: [],
thisWeek: [],
older: [],
};
sessions.forEach(session => {
const sessionDate = new Date(session.created_at || session.timestamp);
const daysDiff = Math.floor((today - sessionDate) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) {
groups.today.push(session);
} else if (daysDiff === 1) {
groups.yesterday.push(session);
} else if (daysDiff <= 7) {
groups.thisWeek.push(session);
} else {
groups.older.push(session);
}
});
return groups;
};
const sessionGroups = groupSessionsByDate(sessions);
const filteredSessions = searchQuery
? sessions.filter((s) =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
)
: sessions;
const quickQuestions = [
{ text: '今日涨停板块分析', emoji: '🔥' },
{ text: '新能源概念机会', emoji: '⚡' },
{ text: '半导体行业动态', emoji: '💾' },
{ text: '本周热门研报', emoji: '📊' },
];
return (
<Box minH="100vh" position="relative" overflow="hidden" pt="80px" zIndex={0}>
{/* 背景渐变层 */}
<Box
position="absolute"
top="80px"
left={0}
right={0}
bottom={0}
bgGradient="linear(to-br, gray.900, gray.800, purple.900)"
zIndex={0}
/>
{/* 背景装饰光效 */}
<Box
position="absolute"
top="0"
right="-20%"
width="600px"
height="600px"
bgGradient="radial(circle, purple.600, transparent)"
opacity="0.15"
filter="blur(100px)"
pointerEvents="none"
zIndex={0}
/>
<Box
position="absolute"
bottom="-30%"
left="-10%"
width="500px"
height="500px"
bgGradient="radial(circle, blue.600, transparent)"
opacity="0.1"
filter="blur(100px)"
pointerEvents="none"
zIndex={0}
/>
<Flex h="calc(100vh - 80px)" overflow="hidden" position="relative" zIndex={1}>
{/* 左侧栏 - 深色毛玻璃 */}
<AnimatePresence>
{isLeftSidebarOpen && (
<motion.div
style={{ width: '320px', display: 'flex', flexDirection: 'column' }}
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInLeft}
>
<Box
w="320px"
display="flex"
flexDirection="column"
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderRight="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" mb={3}>
<HStack spacing={2}>
<MessageSquare className="w-5 h-5" color="#60A5FA" />
<Text
fontWeight="semibold"
bgGradient="linear(to-r, blue.300, purple.300)"
bgClip="text"
fontSize="md"
>
对话历史
</Text>
</HStack>
<HStack spacing={2}>
<Tooltip label="新建对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Plus className="w-4 h-4" />}
onClick={createNewSession}
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(59, 130, 246, 0.2)",
borderColor: "blue.400",
color: "blue.300",
boxShadow: "0 0 12px rgba(59, 130, 246, 0.3)"
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="收起侧边栏">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ChevronLeft className="w-4 h-4" />}
onClick={() => setIsLeftSidebarOpen(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>
</HStack>
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)" zIndex={1}>
<Search className="w-4 h-4" color="#9CA3AF" />
</Box>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
variant="outline"
pl={10}
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)"
}}
/>
</Box>
</Box>
<Box flex={1} p={3} overflowY="auto">
{/* 按日期分组显示会话 */}
{sessionGroups.today.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>今天</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.today.map((session, idx) => (
<motion.div
key={session.session_id}
custom={idx}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<SessionCard
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
</motion.div>
))}
</VStack>
</Box>
)}
{sessionGroups.yesterday.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>昨天</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.yesterday.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.thisWeek.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>本周</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.thisWeek.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.older.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>更早</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.older.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{isLoadingSessions && (
<Flex justify="center" p={4}>
<Spinner size="md" color="purple.500" emptyColor="gray.700" thickness="3px" speed="0.65s" />
</Flex>
)}
{sessions.length === 0 && !isLoadingSessions && (
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
<MessageSquare className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
<Text>还没有对话历史</Text>
<Text fontSize="xs">开始一个新对话吧</Text>
</VStack>
)}
</Box>
<Box p={4} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<HStack spacing={3}>
<Avatar
src={user?.avatar}
name={user?.nickname}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{user?.nickname || '未登录'}
</Text>
<Badge
bgGradient="linear(to-r, blue.500, purple.500)"
color="white"
px={2}
py={0.5}
borderRadius="full"
fontSize="xs"
fontWeight="semibold"
textTransform="none"
>
{user?.subscription_type || 'free'}
</Badge>
</Box>
</HStack>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 中间主聊天区域 */}
<Flex flex={1} direction="column">
{/* 顶部标题栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
<HStack spacing={4}>
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
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"
}}
/>
</motion.div>
)}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
价小前投研 AI
</Text>
<HStack spacing={2} mt={1}>
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
智能分析
</Badge>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name}
</Badge>
</HStack>
</Box>
</HStack>
<HStack spacing={2}>
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
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)"
}}
/>
</motion.div>
</Tooltip>
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
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"
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
{/* 消息列表 */}
<Box
flex={1}
p={6}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6} py={3}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text>快速开始</Text>
</HStack>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: "rgba(59, 130, 246, 0.15)",
borderColor: "blue.400",
boxShadow: "0 4px 12px rgba(59, 130, 246, 0.3)",
color: "white"
}}
onClick={() => {
setInputValue(question.text);
inputRef.current?.focus();
}}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 输入栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => removeFile(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
<HStack spacing={2}>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
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)"
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
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)"
}}
/>
</motion.div>
</Tooltip>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => 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)"
}}
/>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing && <Send className="w-5 h-5" />}
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)"
}}
/>
</motion.div>
</HStack>
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">Enter</Kbd>
<Text>发送</Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">Shift</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">Enter</Kbd>
<Text>换行</Text>
</HStack>
</HStack>
</Box>
</Box>
</Flex>
{/* 右侧栏 - 深色配置中心 */}
<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;
/**
* 会话卡片组件
*/
const SessionCard = ({ session, isActive, onPress }) => {
return (
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
<Card
cursor="pointer"
onClick={onPress}
bg={isActive
? 'rgba(139, 92, 246, 0.15)'
: 'rgba(255, 255, 255, 0.05)'}
backdropFilter="blur(12px)"
borderWidth={1}
borderColor={isActive
? 'purple.400'
: 'rgba(255, 255, 255, 0.1)'}
_hover={{
bg: isActive
? 'rgba(139, 92, 246, 0.2)'
: 'rgba(255, 255, 255, 0.08)',
borderColor: isActive
? 'purple.400'
: 'rgba(255, 255, 255, 0.2)',
boxShadow: isActive
? "0 12px 24px rgba(139, 92, 246, 0.4)"
: "0 4px 12px rgba(0, 0, 0, 0.3)"
}}
transition="all 0.3s"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{session.title || '新对话'}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
{new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Box>
{session.message_count && (
<Badge
bgGradient={isActive
? "linear(to-r, blue.500, purple.500)"
: "linear(to-r, gray.600, gray.700)"}
color={isActive ? 'white' : 'gray.400'}
variant="subtle"
boxShadow={isActive
? "0 2px 8px rgba(139, 92, 246, 0.3)"
: "none"}
>
{session.message_count}
</Badge>
)}
</Flex>
</CardBody>
</Card>
</motion.div>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="start" spacing={3} maxW="75%">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Card
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 8px 20px rgba(139, 92, 246, 0.4)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="white" whiteSpace="pre-wrap">
{message.content}
</Text>
{message.files && message.files.length > 0 && (
<HStack mt={2} flexWrap="wrap" spacing={2}>
{message.files.map((file, idx) => (
<Badge
key={idx}
bg="rgba(255, 255, 255, 0.2)"
color="white"
display="flex"
alignItems="center"
gap={1}
>
<File className="w-3 h-3" />
{file.name}
</Badge>
))}
</HStack>
)}
</CardBody>
</Card>
</motion.div>
<Avatar
src={userAvatar}
icon={<User className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
<HStack spacing={2}>
<Spinner size="sm" color="purple.500" emptyColor="gray.700" thickness="3px" speed="0.65s" />
<Text fontSize="sm" color="gray.300">{message.content}</Text>
</HStack>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="gray.100" whiteSpace="pre-wrap" lineHeight="relaxed">
{message.content}
</Text>
{message.stepResults && message.stepResults.length > 0 && (
<Box mt={3}>
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
</Box>
)}
<Flex align="center" gap={2} mt={3} pt={3} borderTop="1px solid" borderColor="rgba(255, 255, 255, 0.1)">
<Tooltip label="复制">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Copy className="w-4 h-4" />}
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)"
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="点赞">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsUp className="w-4 h-4" />}
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)"
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="点踩">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsDown className="w-4 h-4" />}
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)"
}}
/>
</motion.div>
</Tooltip>
<Text fontSize="xs" color="gray.500" ml="auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
>
<Card
bg="rgba(239, 68, 68, 0.1)"
backdropFilter="blur(16px)"
border="1px solid"
borderColor="rgba(239, 68, 68, 0.5)"
boxShadow="0 8px 32px 0 rgba(239, 68, 68, 0.37)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="red.400">{message.content}</Text>
</CardBody>
</Card>
</motion.div>
</Flex>
);
default:
return null;
}
};
/**
* 执行步骤显示组件
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
return (
<Accordion allowToggle>
<AccordionItem
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.2)'
}}
>
<AccordionButton px={4} py={2}>
<HStack flex={1} spacing={2}>
<Activity className="w-4 h-4" color="#C084FC" />
<Text color="gray.300" fontSize="sm">执行详情</Text>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
variant="subtle"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{steps.length} 步骤
</Badge>
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Text fontSize="xs" fontWeight="medium" color="gray.300">
步骤 {idx + 1}: {result.tool_name}
</Text>
<Badge
bgGradient={result.status === 'success'
? "linear(to-r, green.500, teal.500)"
: "linear(to-r, red.500, orange.500)"}
color="white"
variant="subtle"
boxShadow={result.status === 'success'
? "0 2px 8px rgba(16, 185, 129, 0.3)"
: "0 2px 8px rgba(239, 68, 68, 0.3)"}
>
{result.status}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time?.toFixed(2)}s
</Text>
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}> {result.error}</Text>
)}
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};