1570 lines
50 KiB
JavaScript
1570 lines
50 KiB
JavaScript
// 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>
|
||
);
|
||
};
|