1638 lines
53 KiB
JavaScript
1638 lines
53 KiB
JavaScript
// 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;
|