Files
vf_react/src/views/AgentChat/index.js
2025-11-22 15:30:59 +08:00

1570 lines
50 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 投研助手 - Hero UI 深色模式版本
// 使用 Framer Motion 物理动画引擎
import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Button,
Card,
CardHeader,
CardBody,
CardFooter,
Input,
Avatar,
Chip,
Divider,
Spinner,
Tooltip,
Badge,
Checkbox,
CheckboxGroup,
Tabs,
Tab,
ScrollShadow,
Kbd,
Accordion,
AccordionItem,
} from '@heroui/react';
import { useAuth } from '@contexts/AuthContext';
import { logger } from '@utils/logger';
import axios from 'axios';
import { useToast } from '@chakra-ui/react';
// 图标 - 使用 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: 'secondary',
},
{
id: 'kimi-k2',
name: 'Kimi K2',
description: '快速响应模型,适合简单查询',
icon: <Zap className="w-5 h-5" />,
color: 'primary',
},
{
id: 'deepmoney',
name: 'DeepMoney',
description: '金融专业模型',
icon: <TrendingUp className="w-5 h-5" />,
color: 'success',
},
];
/**
* 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 === '用户数据'),
};
/**
* Hero Agent Chat - 主组件(深色模式)
*/
const AgentChat = () => {
const { user } = useAuth();
const toast = useToast();
// 会话管理
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 页面强制启用深色模式
document.documentElement.classList.add('dark');
return () => {
// 组件卸载时不移除,让其他页面自己控制
// document.documentElement.classList.remove('dark');
};
}, []);
// ==================== 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 (
<div className="flex h-[calc(100vh-80px)] bg-gradient-to-br from-gray-900 via-slate-900 to-gray-900 overflow-hidden">
{/* 左侧栏 - 深色毛玻璃 */}
{isLeftSidebarOpen && (
<motion.div
className="w-80 flex flex-col bg-gray-900/95 backdrop-blur-xl border-r border-gray-800 shadow-2xl"
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInLeft}
>
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-400" />
<span className="font-semibold text-gray-100">对话历史</span>
</div>
<div className="flex items-center gap-2">
<Tooltip content="新建对话">
<Button
isIconOnly
size="sm"
variant="flat"
color="primary"
onPress={createNewSession}
>
<Plus className="w-4 h-4" />
</Button>
</Tooltip>
<Tooltip content="收起侧边栏">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setIsLeftSidebarOpen(false)}
>
<ChevronLeft className="w-4 h-4" />
</Button>
</Tooltip>
</div>
</div>
<Input
placeholder="搜索对话..."
value={searchQuery}
onValueChange={setSearchQuery}
startContent={<Search className="w-4 h-4 text-gray-500" />}
size="sm"
variant="bordered"
classNames={{
input: 'text-sm text-gray-100',
inputWrapper: 'border-gray-700 bg-gray-800/50 hover:border-gray-600',
}}
/>
</div>
<ScrollShadow className="flex-1 p-3">
{/* 按日期分组显示会话 */}
{sessionGroups.today.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-gray-400 mb-2 px-2">今天</p>
<div className="space-y-2">
{sessionGroups.today.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</div>
</div>
)}
{sessionGroups.yesterday.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-gray-400 mb-2 px-2">昨天</p>
<div className="space-y-2">
{sessionGroups.yesterday.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</div>
</div>
)}
{sessionGroups.thisWeek.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-gray-400 mb-2 px-2">本周</p>
<div className="space-y-2">
{sessionGroups.thisWeek.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</div>
</div>
)}
{sessionGroups.older.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-gray-400 mb-2 px-2">更早</p>
<div className="space-y-2">
{sessionGroups.older.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => switchSession(session.session_id)}
/>
))}
</div>
</div>
)}
{isLoadingSessions && (
<div className="flex justify-center p-4">
<Spinner size="sm" color="primary" />
</div>
)}
{sessions.length === 0 && !isLoadingSessions && (
<div className="text-center py-8 text-gray-500 text-sm">
<MessageSquare className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>还没有对话历史</p>
<p className="text-xs mt-1">开始一个新对话吧</p>
</div>
)}
</ScrollShadow>
<div className="p-4 border-t border-gray-800">
<div className="flex items-center gap-3">
<Avatar
src={user?.avatar}
name={user?.nickname}
size="sm"
classNames={{
base: 'bg-gradient-to-br from-blue-500 to-purple-600',
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-gray-100">{user?.nickname || '未登录'}</p>
<p className="text-xs text-gray-500 truncate">{user?.subscription_type || 'free'} 用户</p>
</div>
</div>
</div>
</motion.div>
)}
{/* 中间主聊天区域 */}
<div className="flex-1 flex flex-col">
{/* 顶部标题栏 - 深色 */}
<div className="bg-gray-900/95 backdrop-blur-xl border-b border-gray-800 px-6 py-4 shadow-lg">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{!isLeftSidebarOpen && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setIsLeftSidebarOpen(true)}
>
<Menu className="w-4 h-4 text-gray-400" />
</Button>
)}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
classNames={{
base: 'bg-gradient-to-br from-purple-500 to-pink-500',
}}
/>
</motion.div>
<div>
<h1 className="text-xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
价小前投研 AI
</h1>
<div className="flex items-center gap-2 mt-1">
<Chip
size="sm"
variant="flat"
color="success"
startContent={<Zap className="w-3 h-3" />}
classNames={{
base: 'bg-green-500/20',
content: 'text-green-400',
}}
>
智能分析
</Chip>
<Chip
size="sm"
variant="flat"
color="secondary"
classNames={{
base: 'bg-purple-500/20',
content: 'text-purple-400',
}}
>
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name}
</Chip>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Tooltip content="清空对话">
<Button
isIconOnly
size="sm"
variant="light"
onPress={createNewSession}
className="text-gray-400 hover:text-gray-200"
>
<RefreshCw className="w-4 h-4" />
</Button>
</Tooltip>
{!isRightSidebarOpen && (
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setIsRightSidebarOpen(true)}
className="text-gray-400 hover:text-gray-200"
>
<Settings className="w-4 h-4" />
</Button>
)}
</div>
</div>
</div>
{/* 消息列表 */}
<ScrollShadow className="flex-1 p-6 bg-gradient-to-b from-gray-900/50 to-gray-900/30">
<motion.div
className="max-w-4xl mx-auto space-y-4"
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<AnimatePresence mode="popLayout">
{messages.map((message, index) => (
<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} />
</motion.div>
</ScrollShadow>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
<motion.div
className="px-6 py-3"
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<div className="max-w-4xl mx-auto">
<p className="text-xs text-gray-400 mb-2 font-medium flex items-center gap-1">
<Sparkles className="w-3 h-3" />
快速开始
</p>
<div className="grid grid-cols-2 gap-2">
{quickQuestions.map((question, idx) => (
<motion.div key={idx} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
variant="bordered"
color="primary"
className="w-full justify-start h-auto py-3 border-gray-700 hover:border-blue-500 hover:bg-blue-500/10"
onPress={() => {
setInputValue(question.text);
inputRef.current?.focus();
}}
>
<span className="mr-2">{question.emoji}</span>
<span className="text-gray-200">{question.text}</span>
</Button>
</motion.div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 输入栏 - 深色 */}
<div className="bg-gray-900/95 backdrop-blur-xl border-t border-gray-800 px-6 py-4 shadow-2xl">
<div className="max-w-4xl mx-auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<div className="mb-3 flex gap-2 flex-wrap">
{uploadedFiles.map((file, idx) => (
<Chip
key={idx}
onClose={() => removeFile(idx)}
variant="flat"
classNames={{
base: 'bg-gray-800 border border-gray-700',
content: 'text-gray-300',
}}
>
{file.name}
</Chip>
))}
</div>
)}
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={handleFileSelect}
className="hidden"
/>
<Tooltip content="上传文件">
<Button
isIconOnly
variant="flat"
size="lg"
onPress={() => fileInputRef.current?.click()}
className="bg-gray-800 hover:bg-gray-700 text-gray-300"
>
<Paperclip className="w-5 h-5" />
</Button>
</Tooltip>
<Tooltip content="上传图片">
<Button
isIconOnly
variant="flat"
size="lg"
onPress={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
className="bg-gray-800 hover:bg-gray-700 text-gray-300"
>
<ImageIcon className="w-5 h-5" />
</Button>
</Tooltip>
<Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onKeyDown={handleKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
disabled={isProcessing}
size="lg"
variant="bordered"
classNames={{
input: 'text-base text-gray-100',
inputWrapper: 'border-2 border-gray-700 bg-gray-800/50 hover:border-blue-500 focus-within:border-blue-500',
}}
/>
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
isIconOnly
color="primary"
variant="shadow"
size="lg"
onPress={handleSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500"
>
{!isProcessing && <Send className="w-5 h-5" />}
</Button>
</motion.div>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Kbd keys={['enter']} className="bg-gray-800 text-gray-400">Enter</Kbd>
<span>发送</span>
</div>
<div className="flex items-center gap-1">
<Kbd keys={['shift']} className="bg-gray-800 text-gray-400">Shift</Kbd>
<span>+</span>
<Kbd keys={['enter']} className="bg-gray-800 text-gray-400">Enter</Kbd>
<span>换行</span>
</div>
</div>
</div>
</div>
</div>
{/* 右侧栏 - 深色配置中心 */}
{isRightSidebarOpen && (
<motion.div
className="w-80 flex flex-col bg-gray-900/95 backdrop-blur-xl border-l border-gray-800 shadow-2xl"
initial="initial"
animate="animate"
exit="exit"
variants={animations.slideInRight}
>
<div className="p-4 border-b border-gray-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="w-5 h-5 text-purple-400" />
<span className="font-semibold text-gray-100">配置中心</span>
</div>
<Tooltip content="收起侧边栏">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => setIsRightSidebarOpen(false)}
className="text-gray-400 hover:text-gray-200"
>
<ChevronRight className="w-4 h-4" />
</Button>
</Tooltip>
</div>
</div>
<ScrollShadow className="flex-1">
<Tabs
aria-label="配置选项"
color="primary"
variant="underlined"
classNames={{
tabList: 'gap-6 w-full px-4 border-b border-gray-800',
cursor: 'bg-blue-500',
tab: 'text-gray-400 data-[selected=true]:text-blue-400',
tabContent: 'group-data-[selected=true]:text-blue-400',
}}
>
{/* 模型选择 */}
<Tab
key="model"
title={
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4" />
<span>模型</span>
</div>
}
>
<div className="p-4 space-y-3">
{AVAILABLE_MODELS.map((model) => (
<Card
key={model.id}
isPressable
isHoverable
onPress={() => setSelectedModel(model.id)}
className={`transition-all ${
selectedModel === model.id
? 'bg-blue-500/20 border-2 border-blue-500'
: 'bg-gray-800/50 border-2 border-gray-700 hover:border-gray-600'
}`}
>
<CardBody className="p-3">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-gradient-to-br from-blue-500/20 to-purple-500/20">
{model.icon}
</div>
<div className="flex-1">
<p className="font-semibold text-sm text-gray-100">{model.name}</p>
<p className="text-xs text-gray-400 mt-1">{model.description}</p>
</div>
{selectedModel === model.id && (
<Check className="w-5 h-5 text-blue-400" />
)}
</div>
</CardBody>
</Card>
))}
</div>
</Tab>
{/* 工具选择 - 按分类显示 */}
<Tab
key="tools"
title={
<div className="flex items-center gap-2">
<Code className="w-4 h-4" />
<span>工具</span>
<Badge
content={selectedTools.length}
color="primary"
size="sm"
classNames={{
badge: 'bg-blue-500',
}}
/>
</div>
}
>
<div className="p-4">
<Accordion
variant="splitted"
classNames={{
base: 'gap-2',
trigger: 'bg-gray-800/50 hover:bg-gray-800 border border-gray-700 rounded-lg',
title: 'text-gray-100 text-sm',
content: 'text-gray-300',
}}
>
{Object.entries(TOOL_CATEGORIES).map(([category, tools]) => (
<AccordionItem
key={category}
aria-label={category}
title={
<div className="flex items-center justify-between w-full pr-2">
<span>{category}</span>
<Chip
size="sm"
variant="flat"
classNames={{
base: 'bg-blue-500/20',
content: 'text-blue-400',
}}
>
{tools.filter(t => selectedTools.includes(t.id)).length}/{tools.length}
</Chip>
</div>
}
>
<CheckboxGroup
value={selectedTools}
onValueChange={setSelectedTools}
classNames={{
base: 'gap-2',
}}
>
{tools.map((tool) => (
<Checkbox
key={tool.id}
value={tool.id}
classNames={{
base: 'p-2 rounded-lg hover:bg-gray-800/50 transition-colors',
label: 'text-gray-200 text-sm',
wrapper: 'after:bg-blue-500',
}}
>
<div className="flex items-center gap-2">
<div className="text-blue-400">{tool.icon}</div>
<div>
<p className="text-sm">{tool.name}</p>
<p className="text-xs text-gray-500">{tool.description}</p>
</div>
</div>
</Checkbox>
))}
</CheckboxGroup>
</AccordionItem>
))}
</Accordion>
<div className="mt-4 flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => setSelectedTools(MCP_TOOLS.map(t => t.id))}
className="flex-1 bg-blue-500/20 text-blue-400 hover:bg-blue-500/30"
>
全选
</Button>
<Button
size="sm"
variant="flat"
onPress={() => setSelectedTools([])}
className="flex-1 bg-gray-800 text-gray-300 hover:bg-gray-700"
>
清空
</Button>
</div>
</div>
</Tab>
{/* 统计信息 */}
<Tab
key="stats"
title={
<div className="flex items-center gap-2">
<BarChart3 className="w-4 h-4" />
<span>统计</span>
</div>
}
>
<div className="p-4 space-y-4">
<Card className="bg-gray-800/50 border border-gray-700">
<CardBody className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400">对话数</p>
<p className="text-2xl font-bold text-gray-100">{sessions.length}</p>
</div>
<MessageSquare className="w-8 h-8 text-blue-400 opacity-50" />
</div>
</CardBody>
</Card>
<Card className="bg-gray-800/50 border border-gray-700">
<CardBody className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400">消息数</p>
<p className="text-2xl font-bold text-gray-100">{messages.length}</p>
</div>
<Activity className="w-8 h-8 text-purple-400 opacity-50" />
</div>
</CardBody>
</Card>
<Card className="bg-gray-800/50 border border-gray-700">
<CardBody className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-400">已选工具</p>
<p className="text-2xl font-bold text-gray-100">{selectedTools.length}</p>
</div>
<Code className="w-8 h-8 text-green-400 opacity-50" />
</div>
</CardBody>
</Card>
</div>
</Tab>
</Tabs>
</ScrollShadow>
</motion.div>
)}
</div>
);
};
export default AgentChat;
/**
* 会话卡片组件
*/
const SessionCard = ({ session, isActive, onPress }) => {
return (
<Card
isPressable
isHoverable
onPress={onPress}
className={`transition-all ${
isActive
? 'bg-blue-500/20 border border-blue-500'
: 'bg-gray-800/30 border border-gray-800 hover:bg-gray-800/50 hover:border-gray-700'
}`}
>
<CardBody className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-gray-100">
{session.title || '新对话'}
</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
{session.message_count && (
<Chip
size="sm"
variant="flat"
classNames={{
base: isActive ? 'bg-blue-500/30' : 'bg-gray-700/50',
content: isActive ? 'text-blue-300' : 'text-gray-400',
}}
>
{session.message_count}
</Chip>
)}
</div>
</CardBody>
</Card>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
switch (message.type) {
case MessageTypes.USER:
return (
<div className="flex justify-end">
<div className="flex items-start gap-3 max-w-[75%]">
<Card className="bg-gradient-to-br from-blue-600 to-purple-600 shadow-lg shadow-blue-500/20">
<CardBody className="px-5 py-3">
<p className="text-sm text-white whitespace-pre-wrap">{message.content}</p>
{message.files && message.files.length > 0 && (
<div className="mt-2 flex gap-2 flex-wrap">
{message.files.map((file, idx) => (
<Chip key={idx} size="sm" variant="flat" className="bg-white/20 text-white">
<File className="w-3 h-3 mr-1" />
{file.name}
</Chip>
))}
</div>
)}
</CardBody>
</Card>
<Avatar
src={userAvatar}
icon={<User className="w-4 h-4" />}
size="sm"
classNames={{
base: 'bg-gradient-to-br from-blue-500 to-purple-600',
}}
/>
</div>
</div>
);
case MessageTypes.AGENT_THINKING:
return (
<div className="flex justify-start">
<div className="flex items-start gap-3 max-w-[75%]">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
classNames={{
base: 'bg-gradient-to-br from-purple-500 to-pink-500',
}}
/>
<Card className="bg-gray-800/50 border border-gray-700 backdrop-blur-sm">
<CardBody className="px-5 py-3 flex items-center gap-2">
<Spinner size="sm" color="primary" />
<p className="text-sm text-gray-300">{message.content}</p>
</CardBody>
</Card>
</div>
</div>
);
case MessageTypes.AGENT_RESPONSE:
return (
<div className="flex justify-start">
<div className="flex items-start gap-3 max-w-[75%]">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
classNames={{
base: 'bg-gradient-to-br from-purple-500 to-pink-500',
}}
/>
<Card className="bg-gray-800/80 border border-gray-700 backdrop-blur-sm shadow-lg">
<CardBody className="px-5 py-3">
<p className="text-sm text-gray-100 whitespace-pre-wrap leading-relaxed">
{message.content}
</p>
{message.stepResults && message.stepResults.length > 0 && (
<div className="mt-3">
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
</div>
)}
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-gray-700">
<Tooltip content="复制">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => navigator.clipboard.writeText(message.content)}
className="text-gray-400 hover:text-gray-200"
>
<Copy className="w-4 h-4" />
</Button>
</Tooltip>
<Tooltip content="点赞">
<Button
isIconOnly
size="sm"
variant="light"
className="text-gray-400 hover:text-green-400"
>
<ThumbsUp className="w-4 h-4" />
</Button>
</Tooltip>
<Tooltip content="点踩">
<Button
isIconOnly
size="sm"
variant="light"
className="text-gray-400 hover:text-red-400"
>
<ThumbsDown className="w-4 h-4" />
</Button>
</Tooltip>
<span className="text-xs text-gray-500 ml-auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</CardBody>
</Card>
</div>
</div>
);
case MessageTypes.ERROR:
return (
<div className="flex justify-center">
<Card className="bg-red-500/10 border border-red-500/50 backdrop-blur-sm">
<CardBody className="px-5 py-3">
<p className="text-sm text-red-400">{message.content}</p>
</CardBody>
</Card>
</div>
);
default:
return null;
}
};
/**
* 执行步骤显示组件
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
return (
<Accordion
variant="splitted"
classNames={{
base: 'px-0',
trigger: 'bg-gray-900/50 hover:bg-gray-900 border border-gray-700 rounded-lg px-4 py-2',
title: 'text-gray-300 text-sm',
content: 'text-gray-400',
}}
>
<AccordionItem
key="1"
aria-label="执行详情"
title={
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-purple-400" />
<span>执行详情</span>
<Chip
size="sm"
variant="flat"
classNames={{
base: 'bg-purple-500/20',
content: 'text-purple-400',
}}
>
{steps.length} 步骤
</Chip>
</div>
}
>
<div className="space-y-2">
{steps.map((result, idx) => (
<Card key={idx} className="bg-gray-900/30 border border-gray-700">
<CardBody className="p-3">
<div className="flex items-start justify-between gap-2">
<p className="text-xs font-medium text-gray-300">
步骤 {idx + 1}: {result.tool_name}
</p>
<Chip
size="sm"
color={result.status === 'success' ? 'success' : 'danger'}
variant="flat"
classNames={{
base: result.status === 'success' ? 'bg-green-500/20' : 'bg-red-500/20',
content: result.status === 'success' ? 'text-green-400' : 'text-red-400',
}}
>
{result.status}
</Chip>
</div>
<p className="text-xs text-gray-500 mt-1">{result.execution_time?.toFixed(2)}s</p>
{result.error && <p className="text-xs text-red-400 mt-1"> {result.error}</p>}
</CardBody>
</Card>
))}
</div>
</AccordionItem>
</Accordion>
);
};