Files
vf_react/src/components/ChatBot/ChatInterfaceV2.js

577 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// src/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;