Files
vf_react/src/views/AgentChat/index.js
2025-11-22 06:54:16 +08:00

1638 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/views/AgentChat/index_v4.js
// Agent聊天页面 V4 - 黑金毛玻璃设计,带模型选择和工具选择
import React, { useState, useEffect, useRef } from 'react';
import { Global, css } from '@emotion/react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Button,
Avatar,
Heading,
Divider,
Spinner,
Badge,
useToast,
Progress,
Fade,
Collapse,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
Tooltip,
Select,
Checkbox,
CheckboxGroup,
Stack,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
useDisclosure,
} from '@chakra-ui/react';
import {
FiSend,
FiSearch,
FiPlus,
FiMessageSquare,
FiTrash2,
FiMoreVertical,
FiRefreshCw,
FiDownload,
FiCpu,
FiUser,
FiZap,
FiClock,
FiSettings,
FiCheckCircle,
FiChevronRight,
FiTool,
} from 'react-icons/fi';
import { useAuth } from '@contexts/AuthContext';
import { PlanCard } from '@components/ChatBot/PlanCard';
import { StepResultCard } from '@components/ChatBot/StepResultCard';
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
import { logger } from '@utils/logger';
import axios from 'axios';
import HomeNavbar from '@components/Navbars/HomeNavbar';
/**
* Agent消息类型
*/
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',
name: 'Kimi K2',
description: '快速响应,适合日常对话',
icon: '🚀',
provider: 'Moonshot',
},
{
id: 'kimi-k2-thinking',
name: 'Kimi K2 Thinking',
description: '深度思考,提供详细推理过程',
icon: '🧠',
provider: 'Moonshot',
recommended: true,
},
{
id: 'glm-4.6',
name: 'GLM 4.6',
description: '智谱AI最新模型性能强大',
icon: '⚡',
provider: 'ZhipuAI',
},
{
id: 'deepmoney',
name: 'DeepMoney',
description: '金融专业模型,擅长财经分析',
icon: '💰',
provider: 'Custom',
},
{
id: 'gemini-3',
name: 'Gemini 3',
description: 'Google最新多模态模型',
icon: '✨',
provider: 'Google',
},
];
/**
* MCP工具分类配置
*/
const MCP_TOOL_CATEGORIES = [
{
name: '新闻搜索',
icon: '📰',
tools: [
{ id: 'search_news', name: '全球新闻搜索', description: '搜索国际新闻、行业动态' },
{ id: 'search_china_news', name: '中国新闻搜索', description: 'KNN语义搜索中国新闻' },
{ id: 'search_medical_news', name: '医疗新闻搜索', description: '医药、医疗设备、生物技术' },
],
},
{
name: '股票分析',
icon: '📈',
tools: [
{ id: 'search_limit_up_stocks', name: '涨停股票搜索', description: '搜索涨停股票及原因分析' },
{ id: 'get_stock_analysis', name: '个股分析', description: '获取股票深度分析报告' },
{ id: 'get_stock_concepts', name: '股票概念查询', description: '查询股票相关概念板块' },
],
},
{
name: '概念板块',
icon: '🏢',
tools: [
{ id: 'search_concepts', name: '概念搜索', description: '搜索股票概念板块' },
{ id: 'get_concept_details', name: '概念详情', description: '获取概念板块详细信息' },
{ id: 'get_concept_statistics', name: '概念统计', description: '涨幅榜、活跃榜、连涨榜' },
],
},
{
name: '公司信息',
icon: '🏭',
tools: [
{ id: 'search_roadshows', name: '路演搜索', description: '搜索上市公司路演活动' },
{ id: 'get_company_info', name: '公司信息', description: '获取公司基本面信息' },
],
},
{
name: '数据分析',
icon: '📊',
tools: [
{ id: 'query_database', name: '数据库查询', description: 'SQL查询金融数据' },
{ id: 'get_market_overview', name: '市场概况', description: '获取市场整体行情' },
],
},
];
/**
* Agent聊天页面 V4 - 黑金毛玻璃设计
*/
const AgentChatV4 = () => {
const { user } = useAuth();
const toast = useToast();
// 确保组件总是返回有效的 React 元素
if (!user) {
return (
<Flex w="100vw" h="100vh" align="center" justify="center" bg="#1a1d23">
<VStack spacing={4}>
<Spinner size="xl" color="#FFD700" />
<Text color="#E8E8E8">正在加载...</Text>
</VStack>
</Flex>
);
}
// 会话相关状态
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
// 消息相关状态
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
// 模型和工具配置状态
const [selectedModel, setSelectedModel] = useState('kimi-k2-thinking');
const [selectedTools, setSelectedTools] = useState(() => {
// 默认全选所有工具
const allToolIds = MCP_TOOL_CATEGORIES.flatMap(cat => cat.tools.map(t => t.id));
return allToolIds;
});
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
// 检测是否为移动设备(屏幕宽度小于 768px
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// 根据设备类型设置侧边栏默认状态(移动端默认收起)
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({
defaultIsOpen: !isMobile
});
const { isOpen: isRightPanelOpen, onToggle: toggleRightPanel } = useDisclosure({
defaultIsOpen: !isMobile
});
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// 毛玻璃深灰金配色主题(类似编程工具的深色主题)
const glassBg = 'rgba(30, 35, 40, 0.85)'; // 深灰色毛玻璃
const glassHoverBg = 'rgba(40, 45, 50, 0.9)';
const goldAccent = '#FFD700';
const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
const darkBg = '#1a1d23'; // VS Code 风格的深灰背景
const borderGold = 'rgba(255, 215, 0, 0.3)';
const textGold = '#FFD700';
const textWhite = '#E8E8E8'; // 柔和的白色
const textGray = '#9BA1A6'; // 柔和的灰色
const cardBg = 'rgba(40, 45, 50, 0.6)'; // 卡片背景(深灰毛玻璃)
// ==================== 会话管理函数 ====================
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: String(user.id), limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
logger.info('会话列表加载成功', response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
toast({
title: '加载失败',
description: '无法加载会话列表',
status: 'error',
duration: 3000,
});
} 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);
logger.info('会话历史加载成功', formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
toast({
title: '加载失败',
description: '无法加载会话历史',
status: 'error',
duration: 3000,
});
}
};
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}我是价小前北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
timestamp: new Date().toISOString(),
},
]);
};
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
const deleteSession = async (sessionId) => {
toast({
title: '删除会话',
description: '此功能尚未实现',
status: 'info',
duration: 2000,
});
};
// ==================== 消息处理函数 ====================
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 监听窗口大小变化,动态更新移动端状态
useEffect(() => {
const handleResize = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 在 AgentChat 页面隐藏 Bytedesk 客服插件(避免遮挡页面)
useEffect(() => {
// 隐藏 Bytedesk - 更安全的方式
const hideBytedeskElements = () => {
try {
// 查找所有 Bytedesk 相关元素
const bytedeskElements = document.querySelectorAll(
'[class*="bytedesk"], [id*="bytedesk"], [class*="BytedeskWeb"], .bytedesk-widget'
);
// 保存原始 display 值
const originalDisplays = new Map();
bytedeskElements.forEach(el => {
if (el && el.style) {
originalDisplays.set(el, el.style.display);
el.style.display = 'none';
}
});
// 返回清理函数
return () => {
bytedeskElements.forEach(el => {
if (el && el.style) {
const originalDisplay = originalDisplays.get(el);
if (originalDisplay !== undefined) {
el.style.display = originalDisplay;
} else {
el.style.display = '';
}
}
});
};
} catch (error) {
console.warn('Failed to hide Bytedesk elements:', error);
return () => {}; // 返回空清理函数
}
};
const cleanup = hideBytedeskElements();
// 组件卸载时恢复显示
return cleanup;
}, []);
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
const hasAccess = user?.subscription_type === 'max';
if (!hasAccess) {
logger.warn('AgentChat', '权限检查失败', {
userId: user?.id,
subscription_type: user?.subscription_type,
});
toast({
title: '订阅升级',
description: '「价小前投研」功能需要 Max 订阅。请前往设置页面升级您的订阅。',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
let currentPlan = null;
let stepResults = [];
let executingMessageId = null;
try {
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
const requestBody = {
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 ? String(user.id) : 'anonymous',
user_nickname: user?.nickname || user?.username || '匿名用户',
user_avatar: user?.avatar || '',
subscription_type: user?.subscription_type || 'free',
session_id: currentSessionId,
model: selectedModel, // 传递选中的模型
tools: selectedTools, // 传递选中的工具
};
const response = await fetch('/mcp/agent/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let thinkingMessageId = null;
let thinkingContent = '';
let summaryMessageId = null;
let summaryContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
let currentEvent = null;
for (const line of lines) {
if (!line.trim() || line.startsWith(':')) continue;
if (line.startsWith('event:')) {
currentEvent = line.substring(6).trim();
continue;
}
if (line.startsWith('data:')) {
try {
const data = JSON.parse(line.substring(5).trim());
if (currentEvent === 'thinking') {
if (!thinkingMessageId) {
thinkingMessageId = Date.now();
thinkingContent = '';
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
id: thinkingMessageId,
type: MessageTypes.AGENT_THINKING,
content: '',
timestamp: new Date().toISOString(),
});
}
thinkingContent += data.content;
setMessages((prev) =>
prev.map((m) =>
m.id === thinkingMessageId
? { ...m, content: thinkingContent }
: m
)
);
} else if (currentEvent === 'plan') {
currentPlan = data;
thinkingMessageId = null;
thinkingContent = '';
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
} else if (currentEvent === 'step_complete') {
const stepResult = {
step_index: data.step_index,
tool: data.tool,
status: data.status,
result: data.result,
error: data.error,
execution_time: data.execution_time,
};
stepResults.push(stepResult);
setMessages((prev) =>
prev.map((m) =>
m.id === executingMessageId
? { ...m, stepResults: [...stepResults] }
: m
)
);
const progress = 40 + (stepResults.length / (currentPlan?.steps?.length || 5)) * 40;
setCurrentProgress(Math.min(progress, 80));
} else if (currentEvent === 'summary_chunk') {
if (!summaryMessageId) {
summaryMessageId = Date.now();
summaryContent = '';
setMessages((prev) =>
prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING)
);
addMessage({
id: summaryMessageId,
type: MessageTypes.AGENT_RESPONSE,
content: '',
plan: currentPlan,
stepResults: stepResults,
isStreaming: true,
timestamp: new Date().toISOString(),
});
setCurrentProgress(85);
}
summaryContent += data.content;
setMessages((prev) =>
prev.map((m) =>
m.id === summaryMessageId
? { ...m, content: summaryContent }
: m
)
);
} else if (currentEvent === 'summary') {
if (summaryMessageId) {
setMessages((prev) =>
prev.map((m) =>
m.id === summaryMessageId
? {
...m,
content: data.content || summaryContent,
metadata: data.metadata,
isStreaming: false,
}
: m
)
);
} else {
setMessages((prev) =>
prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING)
);
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.content,
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
isStreaming: false,
timestamp: new Date().toISOString(),
});
}
setCurrentProgress(100);
} else if (currentEvent === 'status') {
if (data.stage === 'planning') {
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
type: MessageTypes.AGENT_THINKING,
content: data.message,
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
} else if (data.stage === 'executing') {
const msgId = Date.now();
executingMessageId = msgId;
addMessage({
id: msgId,
type: MessageTypes.AGENT_EXECUTING,
content: data.message,
plan: currentPlan,
stepResults: [],
timestamp: new Date().toISOString(),
});
setCurrentProgress(40);
} else if (data.stage === 'summarizing') {
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
addMessage({
type: MessageTypes.AGENT_THINKING,
content: data.message,
timestamp: new Date().toISOString(),
});
setCurrentProgress(80);
}
}
} catch (e) {
logger.error('解析 SSE 数据失败', e);
}
}
}
}
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?.detail || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
const handleClearChat = () => {
createNewSession();
};
const handleExportChat = () => {
const chatText = messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 工具选择处理 ====================
const handleToolToggle = (toolId, isChecked) => {
if (isChecked) {
setSelectedTools((prev) => [...prev, toolId]);
} else {
setSelectedTools((prev) => prev.filter((id) => id !== toolId));
}
};
const handleCategoryToggle = (categoryTools, isAllSelected) => {
const toolIds = categoryTools.map((t) => t.id);
if (isAllSelected) {
// 全部取消选中
setSelectedTools((prev) => prev.filter((id) => !toolIds.includes(id)));
} else {
// 全部选中
setSelectedTools((prev) => {
const newTools = [...prev];
toolIds.forEach((id) => {
if (!newTools.includes(id)) {
newTools.push(id);
}
});
return newTools;
});
}
};
// ==================== 初始化 ====================
useEffect(() => {
if (user) {
loadSessions();
createNewSession();
}
}, [user]);
// ==================== 渲染 ====================
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
const filteredSessions = sessions.filter(
(session) =>
!searchQuery ||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Box w="100%" h="100vh" bg={darkBg} display="flex" flexDirection="column">
{/* 顶部导航栏 */}
<HomeNavbar />
{/* 全局样式:确保页面正确显示 */}
<Global
styles={css`
/* 隐藏可能干扰的全局元素 */
.chakra-portal {
z-index: 9999;
}
`}
/>
{/* 主内容区域 */}
<Flex
flex="1"
w="100%"
bg={darkBg}
overflow="hidden"
position="relative"
flexDirection="row"
>
<Box
position="absolute"
top="-50%"
right="-20%"
width="600px"
height="600px"
bg={goldGradient}
opacity="0.05"
filter="blur(100px)"
borderRadius="full"
pointerEvents="none"
/>
<Box
position="absolute"
bottom="-30%"
left="-10%"
width="500px"
height="500px"
bg={goldGradient}
opacity="0.03"
filter="blur(100px)"
borderRadius="full"
pointerEvents="none"
/>
{/* 左侧会话列表 */}
<Collapse in={isSidebarOpen} animateOpacity>
<Box
w="280px"
bg={glassBg}
backdropFilter="blur(20px)"
borderRight="1px solid"
borderColor={borderGold}
h="100%"
display="flex"
flexDirection="column"
boxShadow="0 8px 32px rgba(255, 215, 0, 0.1)"
>
{/* 侧边栏头部 */}
<Box p={4} borderBottom="1px solid" borderColor={borderGold}>
<Button
leftIcon={<FiPlus />}
bg={goldGradient}
color={darkBg}
w="100%"
onClick={createNewSession}
size="sm"
_hover={{
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(255, 215, 0, 0.4)',
}}
transition="all 0.3s"
fontWeight="bold"
>
新建对话
</Button>
<InputGroup mt={3} size="sm">
<InputLeftElement pointerEvents="none">
<FiSearch color={textGold} />
</InputLeftElement>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor={borderGold}
color={textWhite}
_placeholder={{ color: textGray }}
_hover={{ borderColor: goldAccent }}
_focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }}
/>
</InputGroup>
</Box>
{/* 会话列表 */}
<VStack
flex="1"
overflowY="auto"
spacing={0}
align="stretch"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: borderGold,
borderRadius: '3px',
},
}}
>
{isLoadingSessions ? (
<Flex justify="center" align="center" h="200px">
<Spinner color={goldAccent} />
</Flex>
) : filteredSessions.length === 0 ? (
<Flex justify="center" align="center" h="200px" direction="column">
<FiMessageSquare size={32} color={textGray} />
<Text mt={2} fontSize="sm" color={textGray}>
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
</Text>
</Flex>
) : (
filteredSessions.map((session) => (
<Box
key={session.session_id}
p={3}
cursor="pointer"
bg={currentSessionId === session.session_id ? 'rgba(255, 215, 0, 0.1)' : 'transparent'}
_hover={{ bg: 'rgba(255, 215, 0, 0.05)' }}
borderBottom="1px solid"
borderColor="rgba(255, 215, 0, 0.1)"
onClick={() => switchSession(session.session_id)}
transition="all 0.2s"
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="medium" noOfLines={2} color={textWhite}>
{session.last_message || '新对话'}
</Text>
<HStack spacing={2} fontSize="xs" color={textGray}>
<FiClock size={12} />
<Text>
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
<Badge
bg="rgba(255, 215, 0, 0.2)"
color={goldAccent}
fontSize="xx-small"
borderRadius="full"
px={2}
>
{session.message_count}
</Badge>
</HStack>
</VStack>
<Menu>
<MenuButton
as={IconButton}
icon={<FiMoreVertical />}
size="xs"
variant="ghost"
color={textGray}
_hover={{ color: goldAccent }}
onClick={(e) => e.stopPropagation()}
/>
<MenuList bg={glassBg} backdropFilter="blur(20px)" borderColor={borderGold}>
<MenuItem
icon={<FiTrash2 />}
color="red.400"
bg="transparent"
_hover={{ bg: 'rgba(255, 0, 0, 0.1)' }}
onClick={(e) => {
e.stopPropagation();
deleteSession(session.session_id);
}}
>
删除对话
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))
)}
</VStack>
{/* 用户信息 */}
<Box p={4} borderTop="1px solid" borderColor={borderGold}>
<HStack spacing={3}>
<Avatar
size="sm"
name={user?.nickname}
src={user?.avatar}
border="2px solid"
borderColor={goldAccent}
/>
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="medium" color={textWhite}>
{user?.nickname || '未登录'}
</Text>
<Badge
bg="rgba(255, 215, 0, 0.2)"
color={goldAccent}
fontSize="xs"
borderRadius="full"
px={2}
>
MAX 订阅
</Badge>
</VStack>
</HStack>
</Box>
</Box>
</Collapse>
{/* 主聊天区域 */}
<Flex flex="1" direction="column" h="100%" position="relative">
{/* 聊天头部 */}
<Box
bg={glassBg}
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor={borderGold}
px={{ base: 2, md: 4 }}
py={{ base: 2, md: 2 }}
minH={{ base: "56px", md: "64px" }}
boxShadow="0 4px 16px rgba(255, 215, 0, 0.1)"
flexShrink={0}
zIndex={10}
>
<Flex justify="space-between" align="center" h="full">
<HStack spacing={{ base: 2, md: 3 }} align="center" flex={1} minW={0}>
<Box
w={{ base: "24px", md: "32px" }}
h={{ base: "24px", md: "32px" }}
borderRadius="lg"
bg={goldGradient}
display="flex"
alignItems="center"
justifyContent="center"
boxShadow="0 4px 12px rgba(255, 215, 0, 0.3)"
flexShrink={0}
>
<FiCpu fontSize={{ base: "0.9rem", md: "1.2rem" }} color={darkBg} />
</Box>
<VStack align="start" spacing={0} flex={1} minW={0}>
<HStack spacing={2} align="baseline">
<Heading
size={{ base: "xs", md: "sm" }}
color={textWhite}
noOfLines={1}
>
价小前投研
</Heading>
<Badge
bg="rgba(255, 215, 0, 0.2)"
color={goldAccent}
fontSize={{ base: "xx-small", md: "xs" }}
borderRadius="full"
px={{ base: 1, md: 2 }}
whiteSpace="nowrap"
display="inline-flex"
alignItems="center"
gap={1}
>
<Box as={FiZap} display="inline-block" width="10px" height="10px" />
<Text as="span" fontSize="inherit">AI 深度分析</Text>
</Badge>
</HStack>
<Text
fontSize={{ base: "xx-small", md: "xs" }}
color={textGray}
display={{ base: "none", lg: "block" }}
noOfLines={1}
>
{AVAILABLE_MODELS.find(m => m.id === selectedModel)?.name || '智能模型'}
</Text>
</VStack>
</HStack>
<HStack spacing={{ base: 1, md: 2 }} flexShrink={0}>
<IconButton
icon={<FiMessageSquare />}
size="sm"
variant="ghost"
color={goldAccent}
_hover={{ bg: 'rgba(255, 215, 0, 0.1)' }}
aria-label="历史对话"
onClick={toggleSidebar}
/>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
color={textGray}
_hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }}
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
color={textGray}
_hover={{ color: goldAccent, bg: 'rgba(255, 215, 0, 0.1)' }}
aria-label="导出对话"
onClick={handleExportChat}
display={{ base: "none", sm: "inline-flex" }}
/>
<IconButton
icon={<FiSettings />}
size="sm"
variant="ghost"
color={goldAccent}
_hover={{ bg: 'rgba(255, 215, 0, 0.1)' }}
aria-label="设置"
onClick={toggleRightPanel}
/>
</HStack>
</Flex>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
mt={3}
borderRadius="full"
bg="rgba(255, 255, 255, 0.1)"
sx={{
'& > div': {
background: goldGradient,
},
}}
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={{ base: 3, md: 6 }}
py={{ base: 4, md: 6 }}
css={{
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-thumb': {
background: borderGold,
borderRadius: '4px',
},
}}
>
<VStack spacing={{ base: 4, md: 5 }} align="stretch" maxW="1200px" mx="auto">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={{ base: 3, md: 6 }} py={{ base: 3, md: 4 }} bg={glassBg} backdropFilter="blur(20px)" borderTop="1px solid" borderColor={borderGold}>
<Text fontSize="xs" color={textGray} mb={2}>
💡 试试这些问题
</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
borderColor={borderGold}
color={textGold}
fontSize="xs"
_hover={{
bg: 'rgba(255, 215, 0, 0.1)',
borderColor: goldAccent,
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={{ base: 3, md: 6 }} py={{ base: 3, md: 4 }} bg={glassBg} backdropFilter="blur(20px)" borderTop="1px solid" borderColor={borderGold}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder={isMobile ? "输入问题..." : "输入你的问题,我会进行深度分析..."}
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor={borderGold}
color={textWhite}
_placeholder={{ color: textGray }}
_focus={{ borderColor: goldAccent, boxShadow: `0 0 0 1px ${goldAccent}` }}
mr={2}
disabled={isProcessing}
size={{ base: "md", md: "lg" }}
/>
<Button
bg={goldGradient}
color={darkBg}
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size={{ base: "md", md: "lg" }}
minW={{ base: "60px", md: "100px" }}
_hover={{
transform: 'translateY(-2px)',
boxShadow: '0 4px 20px rgba(255, 215, 0, 0.4)',
}}
transition="all 0.3s"
fontWeight="bold"
>
{isProcessing ? <Spinner size="sm" /> : (
isMobile ? <FiSend /> : <HStack spacing={2}><FiSend /><Text>发送</Text></HStack>
)}
</Button>
</Flex>
</Box>
</Flex>
{/* 右侧配置面板 */}
<Collapse in={isRightPanelOpen} animateOpacity>
<Box
w="320px"
bg={glassBg}
backdropFilter="blur(20px)"
borderLeft="1px solid"
borderColor={borderGold}
h="100%"
overflowY="auto"
boxShadow="0 -8px 32px rgba(255, 215, 0, 0.1)"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: borderGold,
borderRadius: '3px',
},
}}
>
<VStack align="stretch" spacing={4} p={4}>
{/* 模型选择 */}
<Box>
<HStack mb={3}>
<FiCpu color={goldAccent} />
<Text fontSize="sm" fontWeight="bold" color={textWhite}>
选择模型
</Text>
</HStack>
<VStack align="stretch" spacing={2}>
{AVAILABLE_MODELS.map((model) => (
<Box
key={model.id}
p={3}
bg={selectedModel === model.id ? 'rgba(255, 215, 0, 0.15)' : 'rgba(255, 255, 255, 0.03)'}
borderRadius="lg"
border="1px solid"
borderColor={selectedModel === model.id ? goldAccent : 'rgba(255, 215, 0, 0.2)'}
cursor="pointer"
onClick={() => setSelectedModel(model.id)}
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 215, 0, 0.1)',
borderColor: goldAccent,
transform: 'translateX(4px)',
}}
position="relative"
>
{model.recommended && (
<Badge
position="absolute"
top="-8px"
right="8px"
bg={goldGradient}
color={darkBg}
fontSize="xx-small"
px={2}
borderRadius="full"
fontWeight="bold"
>
推荐
</Badge>
)}
<HStack spacing={3}>
<Text fontSize="xl">{model.icon}</Text>
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="bold" color={textWhite}>
{model.name}
</Text>
<Text fontSize="xs" color={textGray} noOfLines={2}>
{model.description}
</Text>
</VStack>
{selectedModel === model.id && (
<FiCheckCircle color={goldAccent} />
)}
</HStack>
</Box>
))}
</VStack>
</Box>
<Divider borderColor={borderGold} />
{/* 工具选择 */}
<Box>
<HStack mb={3} justify="space-between">
<HStack>
<FiTool color={goldAccent} />
<Text fontSize="sm" fontWeight="bold" color={textWhite}>
MCP 工具
</Text>
</HStack>
<Badge
bg="rgba(255, 215, 0, 0.2)"
color={goldAccent}
fontSize="xs"
borderRadius="full"
px={2}
>
{selectedTools.length} 个已选
</Badge>
</HStack>
<Accordion allowMultiple defaultIndex={[]}>
{MCP_TOOL_CATEGORIES.map((category, catIdx) => {
const categoryToolIds = category.tools.map((t) => t.id);
const selectedInCategory = categoryToolIds.filter((id) => selectedTools.includes(id));
const isAllSelected = selectedInCategory.length === categoryToolIds.length;
const isSomeSelected = selectedInCategory.length > 0 && !isAllSelected;
return (
<AccordionItem
key={catIdx}
border="none"
bg="rgba(255, 255, 255, 0.02)"
borderRadius="lg"
mb={2}
>
<AccordionButton
_hover={{ bg: 'rgba(255, 215, 0, 0.05)' }}
borderRadius="lg"
p={3}
>
<HStack flex="1" spacing={3}>
<Text fontSize="lg">{category.icon}</Text>
<Text fontSize="sm" fontWeight="medium" color={textWhite}>
{category.name}
</Text>
<Badge
bg="rgba(255, 215, 0, 0.2)"
color={goldAccent}
fontSize="xx-small"
borderRadius="full"
>
{selectedInCategory.length}/{category.tools.length}
</Badge>
</HStack>
<AccordionIcon color={goldAccent} />
</AccordionButton>
<AccordionPanel pb={3} pt={2}>
<VStack align="stretch" spacing={2}>
{/* 全选按钮 */}
<Button
size="xs"
variant="ghost"
color={isAllSelected ? goldAccent : textGray}
onClick={() => handleCategoryToggle(category.tools, isAllSelected)}
_hover={{ bg: 'rgba(255, 215, 0, 0.1)' }}
>
{isAllSelected ? '取消全选' : '全选'}
</Button>
{category.tools.map((tool) => (
<Checkbox
key={tool.id}
isChecked={selectedTools.includes(tool.id)}
onChange={(e) => handleToolToggle(tool.id, e.target.checked)}
colorScheme="yellow"
size="sm"
sx={{
'.chakra-checkbox__control': {
borderColor: borderGold,
bg: 'rgba(255, 255, 255, 0.05)',
_checked: {
bg: goldGradient,
borderColor: goldAccent,
},
},
}}
>
<VStack align="start" spacing={0} ml={2}>
<Text fontSize="xs" fontWeight="medium" color={textWhite}>
{tool.name}
</Text>
<Text fontSize="xx-small" color={textGray}>
{tool.description}
</Text>
</VStack>
</Checkbox>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
);
})}
</Accordion>
</Box>
</VStack>
</Box>
</Collapse>
</Flex>
</Box>
);
};
/**
* 消息渲染器(深灰毛玻璃风格)
*/
const MessageRenderer = ({ message, userAvatar }) => {
const glassBg = 'rgba(30, 35, 40, 0.85)'; // 深灰色毛玻璃
const cardBg = 'rgba(40, 45, 50, 0.6)'; // 卡片背景
const goldAccent = '#FFD700';
const goldGradient = 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
const darkBg = '#1a1d23';
const borderGold = 'rgba(255, 215, 0, 0.3)';
const textWhite = '#E8E8E8'; // 柔和的白色
const textGray = '#9BA1A6'; // 柔和的灰色
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={goldGradient}
color={darkBg}
px={4}
py={3}
borderRadius="lg"
boxShadow="0 4px 16px rgba(255, 215, 0, 0.3)"
backdropFilter="blur(10px)"
>
<Text fontSize="sm" whiteSpace="pre-wrap" fontWeight="medium">
{message.content}
</Text>
</Box>
<Avatar
size="sm"
src={userAvatar}
icon={<FiUser fontSize="1rem" />}
border="2px solid"
borderColor={goldAccent}
/>
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Box
w="32px"
h="32px"
borderRadius="lg"
bg="rgba(138, 43, 226, 0.2)"
border="2px solid"
borderColor="purple.400"
display="flex"
alignItems="center"
justifyContent="center"
>
<FiCpu fontSize="1rem" color="purple" />
</Box>
<Box
bg={glassBg}
backdropFilter="blur(20px)"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="rgba(138, 43, 226, 0.3)"
boxShadow="0 4px 16px rgba(138, 43, 226, 0.2)"
>
<HStack>
<Spinner size="sm" color="purple.400" />
<Text fontSize="sm" color="purple.300">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Box
w="32px"
h="32px"
borderRadius="lg"
bg="rgba(59, 130, 246, 0.2)"
border="2px solid"
borderColor="blue.400"
display="flex"
alignItems="center"
justifyContent="center"
>
<FiCpu fontSize="1rem" color="blue" />
</Box>
<VStack align="stretch" flex="1">
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Box
w="32px"
h="32px"
borderRadius="lg"
bg="rgba(249, 115, 22, 0.2)"
border="2px solid"
borderColor="orange.400"
display="flex"
alignItems="center"
justifyContent="center"
>
<FiCpu fontSize="1rem" color="orange" />
</Box>
<VStack align="stretch" flex="1" spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Box
w="32px"
h="32px"
borderRadius="lg"
bg={goldGradient}
display="flex"
alignItems="center"
justifyContent="center"
boxShadow="0 2px 8px rgba(255, 215, 0, 0.3)"
>
<FiCpu fontSize="1rem" color={darkBg} />
</Box>
<VStack align="stretch" flex="1" spacing={3}>
{/* 最终总结 */}
<Box
bg={glassBg}
backdropFilter="blur(20px)"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderGold}
boxShadow="0 4px 16px rgba(255, 215, 0, 0.1)"
>
{message.isStreaming ? (
<Text fontSize="sm" whiteSpace="pre-wrap" color={textWhite}>
{message.content}
</Text>
) : (
<MarkdownWithCharts content={message.content} />
)}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color={textGray}>
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text color="green.400"> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text color="red.400"> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider borderColor={borderGold} />
<Text fontSize="xs" fontWeight="bold" color={textGray}>
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Box
w="32px"
h="32px"
borderRadius="lg"
bg="rgba(239, 68, 68, 0.2)"
border="2px solid"
borderColor="red.400"
display="flex"
alignItems="center"
justifyContent="center"
>
<FiCpu fontSize="1rem" color="red" />
</Box>
<Box
bg="rgba(239, 68, 68, 0.1)"
backdropFilter="blur(20px)"
color="red.300"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="rgba(239, 68, 68, 0.3)"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default AgentChatV4;