agent功能开发增加MCP后端
This commit is contained in:
576
src/components/ChatBot/ChatInterfaceV2.js
Normal file
576
src/components/ChatBot/ChatInterfaceV2.js
Normal 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;
|
||||
145
src/components/ChatBot/PlanCard.js
Normal file
145
src/components/ChatBot/PlanCard.js
Normal 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;
|
||||
186
src/components/ChatBot/StepResultCard.js
Normal file
186
src/components/ChatBot/StepResultCard.js
Normal 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;
|
||||
@@ -2,6 +2,10 @@
|
||||
// 聊天机器人组件统一导出
|
||||
|
||||
export { ChatInterface } from './ChatInterface';
|
||||
export { ChatInterfaceV2 } from './ChatInterfaceV2';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { PlanCard } from './PlanCard';
|
||||
export { StepResultCard } from './StepResultCard';
|
||||
|
||||
export { ChatInterface as default } from './ChatInterface';
|
||||
// 默认导出新版本
|
||||
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';
|
||||
|
||||
278
src/services/llmService.js
Normal file
278
src/services/llmService.js
Normal 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;
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
VStack,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import ChatInterface from '../../components/ChatBot';
|
||||
import { ChatInterfaceV2 } from '../../components/ChatBot';
|
||||
|
||||
/**
|
||||
* Agent聊天页面
|
||||
@@ -42,7 +42,7 @@ const AgentChat = () => {
|
||||
h="calc(100vh - 300px)"
|
||||
minH="600px"
|
||||
>
|
||||
<ChatInterface />
|
||||
<ChatInterfaceV2 />
|
||||
</Box>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user