Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event

* 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react:
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
This commit is contained in:
zdl
2025-11-07 19:48:20 +08:00
26 changed files with 6904 additions and 1 deletions

View File

@@ -0,0 +1,376 @@
// src/components/ChatBot/ChatInterface.js
// 聊天界面主组件
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Menu,
MenuButton,
MenuList,
MenuItem,
Button,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
import { ChevronDownIcon } from '@chakra-ui/icons';
import MessageBubble from './MessageBubble';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* 聊天界面组件
*/
export const ChatInterface = () => {
const [messages, setMessages] = useState([
{
id: 1,
content: '你好我是AI投资助手我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [availableTools, setAvailableTools] = useState([]);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const inputBg = useColorModeValue('gray.50', 'gray.700');
// 加载可用工具列表
useEffect(() => {
const loadTools = async () => {
const result = await mcpService.listTools();
if (result.success) {
setAvailableTools(result.data);
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
}
};
loadTools();
}, []);
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage = {
id: Date.now(),
content: inputValue,
isUser: true,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
try {
// 调用MCP服务
const response = await mcpService.chat(inputValue, messages);
let botMessage;
if (response.success) {
// 根据返回的数据类型构造消息
const data = response.data;
if (typeof data === 'string') {
botMessage = {
id: Date.now() + 1,
content: data,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
} else if (Array.isArray(data)) {
// 数据列表
botMessage = {
id: Date.now() + 1,
content: `找到 ${data.length} 条结果:`,
isUser: false,
type: 'data',
data: data,
timestamp: new Date().toISOString(),
};
} else if (typeof data === 'object') {
// 对象数据
botMessage = {
id: Date.now() + 1,
content: JSON.stringify(data, null, 2),
isUser: false,
type: 'markdown',
timestamp: new Date().toISOString(),
};
} else {
botMessage = {
id: Date.now() + 1,
content: '抱歉,我无法理解这个查询结果。',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
} else {
botMessage = {
id: Date.now() + 1,
content: `抱歉,查询失败:${response.error}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
logger.error('ChatInterface', 'handleSendMessage', error);
const errorMessage = {
id: Date.now() + 1,
content: `抱歉,发生了错误:${error.message}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
content: '对话已清空。有什么可以帮到你的?',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
};
// 复制消息
const handleCopyMessage = () => {
toast({
title: '已复制',
status: 'success',
duration: 2000,
isClosable: true,
});
};
// 反馈
const handleFeedback = (type) => {
logger.info('ChatInterface', 'Feedback', { type });
toast({
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
status: 'info',
duration: 2000,
isClosable: true,
});
};
// 快捷问题
const quickQuestions = [
'查询贵州茅台的股票信息',
'搜索人工智能相关新闻',
'今日涨停股票有哪些',
'新能源概念板块分析',
];
const handleQuickQuestion = (question) => {
setInputValue(question);
inputRef.current?.focus();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${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);
};
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部工具栏 */}
<Flex
px={4}
py={3}
borderBottom="1px"
borderColor={borderColor}
align="center"
justify="space-between"
>
<HStack>
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
<Badge colorScheme="green">在线</Badge>
{availableTools.length > 0 && (
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
)}
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
<Menu>
<MenuButton
as={IconButton}
icon={<FiSettings />}
size="sm"
variant="ghost"
aria-label="设置"
/>
<MenuList>
<MenuItem>模型设置</MenuItem>
<MenuItem>快捷指令</MenuItem>
<MenuItem>历史记录</MenuItem>
</MenuList>
</Menu>
</HStack>
</Flex>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={4}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={0} align="stretch">
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isUser={message.isUser}
onCopy={handleCopyMessage}
onFeedback={handleFeedback}
/>
))}
{isLoading && (
<Flex justify="flex-start" mb={4}>
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
<Spinner size="sm" mr={2} />
<Text fontSize="sm">AI正在思考...</Text>
</Flex>
</Flex>
)}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题(仅在消息较少时显示) */}
{messages.length <= 2 && (
<Box px={4} py={2}>
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="xs"
variant="outline"
onClick={() => handleQuickQuestion(question)}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
<Divider />
{/* 输入框 */}
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入消息... (Shift+Enter换行Enter发送)"
bg={inputBg}
border="none"
_focus={{ boxShadow: 'none' }}
mr={2}
disabled={isLoading}
/>
<IconButton
icon={<FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isLoading}
disabled={!inputValue.trim()}
/>
</Flex>
</Box>
</Flex>
);
};
export default ChatInterface;

View File

@@ -0,0 +1,576 @@
// src/components/ChatBot/ChatInterfaceV2.js
// 重新设计的聊天界面 - 更漂亮、支持Agent模式
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Button,
Avatar,
Heading,
Progress,
Fade,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiDownload, FiCpu, FiUser, FiZap } from 'react-icons/fi';
import { PlanCard } from './PlanCard';
import { StepResultCard } from './StepResultCard';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* 聊天界面V2组件 - Agent模式
*/
export const ChatInterfaceV2 = () => {
const [messages, setMessages] = useState([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '你好我是AI投资研究助手。我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 更新最后一条消息
const updateLastMessage = (updates) => {
setMessages((prev) => {
const newMessages = [...prev];
if (newMessages.length > 0) {
newMessages[newMessages.length - 1] = {
...newMessages[newMessages.length - 1],
...updates,
};
}
return newMessages;
});
};
// 发送消息Agent模式
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 调用 Agent API
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputValue,
conversation_history: messages.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE).map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
});
if (!response.ok) {
throw new Error('Agent请求失败');
}
const agentResponse = await response.json();
logger.info('Agent response', agentResponse);
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
if (!agentResponse.success) {
throw new Error(agentResponse.message || '处理失败');
}
setCurrentProgress(30);
// 2. 显示执行计划
if (agentResponse.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: agentResponse.plan,
timestamp: new Date().toISOString(),
});
}
setCurrentProgress(40);
// 3. 显示执行过程
if (agentResponse.step_results && agentResponse.step_results.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
timestamp: new Date().toISOString(),
});
// 模拟进度更新
for (let i = 0; i < agentResponse.step_results.length; i++) {
setCurrentProgress(40 + (i + 1) / agentResponse.step_results.length * 50);
await new Promise(resolve => setTimeout(resolve, 100));
}
}
setCurrentProgress(100);
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 4. 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: agentResponse.message || agentResponse.final_summary,
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
metadata: agentResponse.metadata,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages(prev => prev.filter(
m => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
));
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${error.message}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '对话已清空。有什么可以帮到你的?',
timestamp: new Date().toISOString(),
},
]);
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : 'AI助手'}] ${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 quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部 */}
<Box
bg={chatBg}
borderBottom="1px"
borderColor={borderColor}
px={6}
py={4}
>
<HStack justify="space-between">
<HStack spacing={4}>
<Avatar
size="md"
bg="blue.500"
icon={<FiCpu fontSize="1.5rem" />}
/>
<VStack align="start" spacing={0}>
<Heading size="md">AI投资研究助手</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>💡 试试这些问题</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" bg="blue.500" icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<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%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<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%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex={1} spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {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 />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</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%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default ChatInterfaceV2;

View File

@@ -0,0 +1,149 @@
// src/components/ChatBot/MessageBubble.js
// 聊天消息气泡组件
import React from 'react';
import {
Box,
Flex,
Text,
Avatar,
useColorModeValue,
IconButton,
HStack,
Code,
Badge,
VStack,
} from '@chakra-ui/react';
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
import ReactMarkdown from 'react-markdown';
/**
* 消息气泡组件
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {boolean} props.isUser - 是否是用户消息
* @param {Function} props.onCopy - 复制消息回调
* @param {Function} props.onFeedback - 反馈回调
*/
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
const userBg = useColorModeValue('blue.500', 'blue.600');
const botBg = useColorModeValue('gray.100', 'gray.700');
const userColor = 'white';
const botColor = useColorModeValue('gray.800', 'white');
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
onCopy?.();
};
return (
<Flex
w="100%"
justify={isUser ? 'flex-end' : 'flex-start'}
mb={4}
>
<Flex
maxW="75%"
flexDirection={isUser ? 'row-reverse' : 'row'}
align="flex-start"
>
{/* 头像 */}
<Avatar
size="sm"
name={isUser ? '用户' : 'AI助手'}
bg={isUser ? 'blue.500' : 'green.500'}
color="white"
mx={3}
/>
{/* 消息内容 */}
<Box>
<Box
bg={isUser ? userBg : botBg}
color={isUser ? userColor : botColor}
px={4}
py={3}
borderRadius="lg"
boxShadow="sm"
>
{message.type === 'text' ? (
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
) : message.type === 'markdown' ? (
<Box fontSize="sm" className="markdown-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</Box>
) : message.type === 'data' ? (
<VStack align="stretch" spacing={2}>
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
<Box
key={idx}
p={3}
bg={useColorModeValue('white', 'gray.600')}
borderRadius="md"
fontSize="xs"
>
{Object.entries(item).map(([key, value]) => (
<Flex key={key} justify="space-between" mb={1}>
<Text fontWeight="bold" mr={2}>{key}:</Text>
<Text>{String(value)}</Text>
</Flex>
))}
</Box>
))}
{message.data && message.data.length > 5 && (
<Badge colorScheme="blue" alignSelf="center">
+{message.data.length - 5} 更多结果
</Badge>
)}
</VStack>
) : null}
</Box>
{/* 消息操作按钮仅AI消息 */}
{!isUser && (
<HStack mt={2} spacing={2}>
<IconButton
icon={<FiCopy />}
size="xs"
variant="ghost"
aria-label="复制"
onClick={handleCopy}
/>
<IconButton
icon={<FiThumbsUp />}
size="xs"
variant="ghost"
aria-label="赞"
onClick={() => onFeedback?.('positive')}
/>
<IconButton
icon={<FiThumbsDown />}
size="xs"
variant="ghost"
aria-label="踩"
onClick={() => onFeedback?.('negative')}
/>
</HStack>
)}
{/* 时间戳 */}
<Text
fontSize="xs"
color="gray.500"
mt={1}
textAlign={isUser ? 'right' : 'left'}
>
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}) : ''}
</Text>
</Box>
</Flex>
</Flex>
);
};
export default MessageBubble;

View File

@@ -0,0 +1,145 @@
// src/components/ChatBot/PlanCard.js
// 执行计划展示卡片
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Icon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons/fi';
/**
* 执行计划卡片组件
*/
export const PlanCard = ({ plan, stepResults }) => {
const cardBg = useColorModeValue('blue.50', 'blue.900');
const borderColor = useColorModeValue('blue.200', 'blue.700');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const pendingColor = useColorModeValue('gray.400', 'gray.500');
const getStepStatus = (stepIndex) => {
if (!stepResults || stepResults.length === 0) return 'pending';
const result = stepResults.find(r => r.step_index === stepIndex);
return result ? result.status : 'pending';
};
const getStepIcon = (status) => {
switch (status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStepColor = (status) => {
switch (status) {
case 'success':
return successColor;
case 'failed':
return errorColor;
default:
return pendingColor;
}
};
return (
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="2px"
borderColor={borderColor}
p={4}
mb={4}
boxShadow="md"
>
<VStack align="stretch" spacing={3}>
{/* 目标 */}
<HStack>
<Icon as={FiTarget} color="blue.500" boxSize={5} />
<Text fontWeight="bold" fontSize="md">执行目标</Text>
</HStack>
<Text fontSize="sm" color="gray.600" pl={7}>
{plan.goal}
</Text>
<Divider />
{/* 规划思路 */}
{plan.reasoning && (
<>
<Text fontSize="sm" fontWeight="bold">规划思路</Text>
<Text fontSize="sm" color="gray.600">
{plan.reasoning}
</Text>
<Divider />
</>
)}
{/* 执行步骤 */}
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">执行步骤</Text>
<Badge colorScheme="blue">{plan.steps.length} </Badge>
</HStack>
<VStack align="stretch" spacing={2}>
{plan.steps.map((step, index) => {
const status = getStepStatus(index);
const StepIcon = getStepIcon(status);
const stepColor = getStepColor(status);
return (
<HStack
key={index}
p={2}
bg={useColorModeValue('white', 'gray.700')}
borderRadius="md"
borderWidth="1px"
borderColor={stepColor}
align="flex-start"
>
<Icon as={StepIcon} color={stepColor} boxSize={4} mt={1} />
<VStack align="stretch" flex={1} spacing={1}>
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">
步骤 {index + 1}: {step.tool}
</Text>
<Badge
colorScheme={
status === 'success' ? 'green' :
status === 'failed' ? 'red' : 'gray'
}
fontSize="xs"
>
{status === 'success' ? '✓ 完成' :
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{step.reason}
</Text>
</VStack>
</HStack>
);
})}
</VStack>
</VStack>
</Box>
);
};
export default PlanCard;

View File

@@ -0,0 +1,186 @@
// src/components/ChatBot/StepResultCard.js
// 步骤结果展示卡片(可折叠)
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Collapse,
Icon,
IconButton,
Code,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDatabase } from 'react-icons/fi';
/**
* 步骤结果卡片组件
*/
export const StepResultCard = ({ stepResult }) => {
const [isExpanded, setIsExpanded] = useState(false);
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const getStatusIcon = () => {
switch (stepResult.status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStatusColor = () => {
switch (stepResult.status) {
case 'success':
return 'green';
case 'failed':
return 'red';
default:
return 'gray';
}
};
const StatusIcon = getStatusIcon();
const statusColorScheme = getStatusColor();
// 格式化数据以便展示
const formatResult = (data) => {
if (typeof data === 'string') return data;
if (Array.isArray(data)) {
return `找到 ${data.length} 条记录`;
}
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
}
return String(data);
};
return (
<Box
bg={cardBg}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
boxShadow="sm"
>
{/* 头部 - 始终可见 */}
<HStack
p={3}
justify="space-between"
cursor="pointer"
onClick={() => setIsExpanded(!isExpanded)}
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
>
<HStack flex={1}>
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
<VStack align="stretch" spacing={0} flex={1}>
<HStack>
<Text fontSize="sm" fontWeight="bold">
步骤 {stepResult.step_index + 1}: {stepResult.tool}
</Text>
<Badge colorScheme={statusColorScheme} fontSize="xs">
{stepResult.status === 'success' ? '成功' :
stepResult.status === 'failed' ? '失败' : '执行中'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.500">
耗时: {stepResult.execution_time?.toFixed(2)}s
</Text>
</VStack>
</HStack>
<IconButton
icon={<Icon as={isExpanded ? FiChevronUp : FiChevronDown} />}
size="sm"
variant="ghost"
aria-label={isExpanded ? "收起" : "展开"}
/>
</HStack>
{/* 内容 - 可折叠 */}
<Collapse in={isExpanded} animateOpacity>
<Box p={3} pt={0}>
<Divider mb={3} />
{/* 参数 */}
{stepResult.arguments && Object.keys(stepResult.arguments).length > 0 && (
<VStack align="stretch" spacing={2} mb={3}>
<HStack>
<Icon as={FiDatabase} color="blue.500" boxSize={4} />
<Text fontSize="xs" fontWeight="bold">请求参数:</Text>
</HStack>
<Code
p={2}
borderRadius="md"
fontSize="xs"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{JSON.stringify(stepResult.arguments, null, 2)}
</Code>
</VStack>
)}
{/* 结果或错误 */}
{stepResult.status === 'success' && stepResult.result && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold">执行结果:</Text>
<Box
maxH="300px"
overflowY="auto"
p={2}
bg={useColorModeValue('gray.50', 'gray.800')}
borderRadius="md"
fontSize="xs"
>
{typeof stepResult.result === 'string' ? (
<Text whiteSpace="pre-wrap">{stepResult.result}</Text>
) : Array.isArray(stepResult.result) ? (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold">找到 {stepResult.result.length} 条记录:</Text>
{stepResult.result.slice(0, 3).map((item, idx) => (
<Code key={idx} p={2} borderRadius="md" fontSize="xs">
{JSON.stringify(item, null, 2)}
</Code>
))}
{stepResult.result.length > 3 && (
<Text fontSize="xs" color="gray.500">
...还有 {stepResult.result.length - 3} 条记录
</Text>
)}
</VStack>
) : (
<Code whiteSpace="pre-wrap" wordBreak="break-word">
{JSON.stringify(stepResult.result, null, 2)}
</Code>
)}
</Box>
</VStack>
)}
{stepResult.status === 'failed' && stepResult.error && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
{stepResult.error}
</Text>
</VStack>
)}
</Box>
</Collapse>
</Box>
);
};
export default StepResultCard;

View File

@@ -0,0 +1,11 @@
// src/components/ChatBot/index.js
// 聊天机器人组件统一导出
export { ChatInterface } from './ChatInterface';
export { ChatInterfaceV2 } from './ChatInterfaceV2';
export { MessageBubble } from './MessageBubble';
export { PlanCard } from './PlanCard';
export { StepResultCard } from './StepResultCard';
// 默认导出新版本
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';

View File

@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
<Box>
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
<VStack spacing={2} align="stretch">
<Link
onClick={() => handleNavigate('/agent-chat')}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">AI</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link>
<Link
py={1}
px={3}

View File

@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</MenuButton>
<MenuList
minW="300px"
p={4}
p={2}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
navigate('/agent-chat');
agentCommunityMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"

View File

@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
{/* AGENT社群组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
<MenuItem
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/agent-chat');
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
</MenuItem>

View File

@@ -35,6 +35,9 @@ export const lazyComponents = {
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
};
/**
@@ -59,4 +62,5 @@ export const {
ForecastReport,
FinancialPanorama,
MarketDataView,
AgentChat,
} = lazyComponents;

View File

@@ -149,6 +149,18 @@ export const routeConfig = [
description: '实时市场数据'
}
},
// ==================== Agent模块 ====================
{
path: 'agent-chat',
component: lazyComponents.AgentChat,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: 'AI投资助手',
description: '基于MCP的智能投资顾问'
}
},
];
/**

278
src/services/llmService.js Normal file
View File

@@ -0,0 +1,278 @@
// src/services/llmService.js
// LLM服务层 - 集成AI模型进行对话和工具调用
import axios from 'axios';
import { mcpService } from './mcpService';
import { logger } from '../utils/logger';
/**
* LLM服务配置
*/
const LLM_CONFIG = {
// 可以使用 OpenAI、Claude、通义千问等
provider: 'openai', // 或 'claude', 'qwen'
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini', // 更便宜的模型
};
/**
* LLM服务类
*/
class LLMService {
constructor() {
this.conversationHistory = [];
}
/**
* 构建系统提示词
*/
getSystemPrompt(availableTools) {
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
${availableTools.map(tool => `
**${tool.name}**
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`).join('\n')}
用户提问时,请按照以下步骤:
1. 理解用户的意图
2. 选择合适的工具(可以多个)
3. 提取工具需要的参数
4. 调用工具后,用自然语言总结结果
回复格式:
- 如果需要调用工具返回JSON格式{"tool": "工具名", "arguments": {...}}
- 如果不需要工具,直接回复自然语言
注意:
- 贵州茅台的股票代码是 600519
- 涨停是指股票当日涨幅达到10%
- 概念板块是指相同题材的股票分类`;
}
/**
* 智能对话 - 使用LLM理解意图并调用工具
*/
async chat(userMessage, conversationHistory = []) {
try {
// 1. 获取可用工具列表
const toolsResult = await mcpService.listTools();
if (!toolsResult.success) {
throw new Error('获取工具列表失败');
}
const availableTools = toolsResult.data;
// 2. 构建对话历史
const messages = [
{
role: 'system',
content: this.getSystemPrompt(availableTools),
},
...conversationHistory.map(msg => ({
role: msg.isUser ? 'user' : 'assistant',
content: msg.content,
})),
{
role: 'user',
content: userMessage,
},
];
// 3. 调用LLM
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
// 注意这里需要配置API密钥
if (!LLM_CONFIG.apiKey) {
// 如果没有配置LLM使用简单的关键词匹配
logger.warn('LLMService', '未配置LLM API密钥使用简单匹配');
return await this.fallbackChat(userMessage);
}
const response = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: messages,
temperature: 0.7,
max_tokens: 1000,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const aiResponse = response.data.choices[0].message.content;
logger.info('LLMService', 'LLM响应', { response: aiResponse });
// 4. 解析LLM响应
// 如果LLM返回工具调用指令
try {
const toolCall = JSON.parse(aiResponse);
if (toolCall.tool && toolCall.arguments) {
// 调用MCP工具
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
if (!toolResult.success) {
return {
success: false,
error: toolResult.error,
};
}
// 5. 让LLM总结工具结果
const summaryMessages = [
...messages,
{
role: 'assistant',
content: aiResponse,
},
{
role: 'system',
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
},
];
const summaryResponse = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: summaryMessages,
temperature: 0.7,
max_tokens: 500,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const summary = summaryResponse.data.choices[0].message.content;
return {
success: true,
data: {
message: summary,
rawData: toolResult.data,
toolUsed: toolCall.tool,
},
};
}
} catch (parseError) {
// 不是JSON格式说明是直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
}
// 默认返回LLM的直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
} catch (error) {
logger.error('LLMService', 'chat error', error);
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 降级方案简单的关键词匹配当没有配置LLM时
*/
async fallbackChat(userMessage) {
logger.info('LLMService', '使用降级方案', { message: userMessage });
// 使用原有的简单匹配逻辑
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
const result = await mcpService.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
const result = await mcpService.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
return this.formatFallbackResponse(result, '概念搜索');
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
const result = await mcpService.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
return this.formatFallbackResponse(result, '涨停分析');
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
return this.formatFallbackResponse(result, '股票信息');
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
// 特殊处理茅台
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: '600519',
});
return this.formatFallbackResponse(result, '贵州茅台股票信息');
} else {
// 默认:搜索新闻
const result = await mcpService.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
}
}
/**
* 格式化降级响应
*/
formatFallbackResponse(result, action) {
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
data: {
message: `已为您完成${action},找到以下结果:`,
rawData: result.data,
},
};
}
/**
* 清除对话历史
*/
clearHistory() {
this.conversationHistory = [];
}
}
// 导出单例
export const llmService = new LLMService();
export default LLMService;

248
src/services/mcpService.js Normal file
View File

@@ -0,0 +1,248 @@
// src/services/mcpService.js
// MCP (Model Context Protocol) 服务层
// 用于与FastAPI后端的MCP工具进行交互
import axios from 'axios';
import { getApiBase } from '../utils/apiConfig';
import { logger } from '../utils/logger';
/**
* MCP API客户端
*/
class MCPService {
constructor() {
this.baseURL = `${getApiBase()}/mcp`;
this.client = axios.create({
baseURL: this.baseURL,
timeout: 60000, // 60秒超时MCP工具可能需要较长时间
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
logger.debug('MCPService', 'Request', {
url: config.url,
method: config.method,
data: config.data,
});
return config;
},
(error) => {
logger.error('MCPService', 'Request Error', error);
return Promise.reject(error);
}
);
// 响应拦截器
this.client.interceptors.response.use(
(response) => {
logger.debug('MCPService', 'Response', {
url: response.config.url,
status: response.status,
data: response.data,
});
return response.data;
},
(error) => {
logger.error('MCPService', 'Response Error', {
url: error.config?.url,
status: error.response?.status,
message: error.message,
});
return Promise.reject(error);
}
);
}
/**
* 列出所有可用的MCP工具
* @returns {Promise<Object>} 工具列表
*/
async listTools() {
try {
const response = await this.client.get('/tools');
return {
success: true,
data: response.tools || [],
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具列表失败',
};
}
}
/**
* 获取特定工具的定义
* @param {string} toolName - 工具名称
* @returns {Promise<Object>} 工具定义
*/
async getTool(toolName) {
try {
const response = await this.client.get(`/tools/${toolName}`);
return {
success: true,
data: response,
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具定义失败',
};
}
}
/**
* 调用MCP工具
* @param {string} toolName - 工具名称
* @param {Object} arguments - 工具参数
* @returns {Promise<Object>} 工具执行结果
*/
async callTool(toolName, toolArguments) {
try {
const response = await this.client.post('/tools/call', {
tool: toolName,
arguments: toolArguments,
});
return {
success: true,
data: response.data || response,
};
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || error.message || '工具调用失败',
};
}
}
/**
* 智能对话 - 根据用户输入自动选择合适的工具
* @param {string} userMessage - 用户消息
* @param {Array} conversationHistory - 对话历史(可选)
* @returns {Promise<Object>} AI响应
*/
async chat(userMessage, conversationHistory = []) {
try {
// 这里可以实现智能路由逻辑
// 根据用户输入判断应该调用哪个工具
// 示例:关键词匹配
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
return await this.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
return await this.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
return await this.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
return await this.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
} else {
// 默认:搜索新闻
return await this.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
}
} catch (error) {
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 工具类别枚举
*/
static TOOL_CATEGORIES = {
NEWS: 'news', // 新闻搜索
STOCK: 'stock', // 股票信息
CONCEPT: 'concept', // 概念板块
LIMIT_UP: 'limit_up', // 涨停分析
RESEARCH: 'research', // 研报搜索
ROADSHOW: 'roadshow', // 路演信息
FINANCIAL: 'financial', // 财务数据
TRADE: 'trade', // 交易数据
};
/**
* 常用工具快捷方式
*/
async searchNews(query, topK = 5, exactMatch = false) {
return await this.callTool('search_china_news', {
query,
top_k: topK,
exact_match: exactMatch,
});
}
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
return await this.callTool('search_concepts', {
query,
size,
sort_by: sortBy,
});
}
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
return await this.callTool('search_limit_up_stocks', {
query,
mode,
page_size: pageSize,
});
}
async getStockInfo(seccode) {
return await this.callTool('get_stock_basic_info', {
seccode,
});
}
async getStockConcepts(stockCode, size = 10) {
return await this.callTool('get_stock_concepts', {
stock_code: stockCode,
size,
});
}
async searchResearchReports(query, mode = 'hybrid', size = 5) {
return await this.callTool('search_research_reports', {
query,
mode,
size,
});
}
async getConceptStatistics(days = 7, minStockCount = 3) {
return await this.callTool('get_concept_statistics', {
days,
min_stock_count: minStockCount,
});
}
}
// 导出单例实例
export const mcpService = new MCPService();
// 导出类(供测试使用)
export default MCPService;

View File

@@ -0,0 +1,53 @@
// src/views/AgentChat/index.js
// Agent聊天页面
import React from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { ChatInterfaceV2 } from '../../components/ChatBot';
/**
* Agent聊天页面
* 提供基于MCP的AI助手对话功能
*/
const AgentChat = () => {
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
return (
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
<Container maxW="container.xl" h="100%">
<VStack spacing={6} align="stretch" h="100%">
{/* 页面标题 */}
<Box>
<Heading size="lg" mb={2}>AI投资助手</Heading>
<Text color="gray.600" fontSize="sm">
基于MCP协议的智能投资顾问支持股票查询新闻搜索概念分析等多种功能
</Text>
</Box>
{/* 聊天界面 */}
<Box
flex="1"
bg={cardBg}
borderRadius="xl"
boxShadow="xl"
overflow="hidden"
h="calc(100vh - 300px)"
minH="600px"
>
<ChatInterfaceV2 />
</Box>
</VStack>
</Container>
</Box>
);
};
export default AgentChat;