682 lines
20 KiB
JavaScript
682 lines
20 KiB
JavaScript
// 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);
|
||
const userInput = inputValue; // 保存输入值
|
||
setInputValue('');
|
||
setIsProcessing(true);
|
||
setCurrentProgress(0);
|
||
|
||
// 用于存储步骤结果
|
||
let currentPlan = null;
|
||
let stepResults = [];
|
||
let executingMessageId = null;
|
||
|
||
try {
|
||
// 1. 显示思考状态
|
||
addMessage({
|
||
type: MessageTypes.AGENT_THINKING,
|
||
content: '正在分析你的问题...',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
setCurrentProgress(10);
|
||
|
||
// 使用 EventSource 接收流式数据
|
||
const eventSource = new EventSource(
|
||
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
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,
|
||
})),
|
||
}),
|
||
}
|
||
);
|
||
|
||
// 由于 EventSource 不支持 POST,我们使用 fetch + ReadableStream
|
||
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
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,
|
||
})),
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Agent请求失败');
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = '';
|
||
|
||
// 读取流式数据
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const lines = buffer.split('\n\n');
|
||
buffer = lines.pop(); // 保留不完整的行
|
||
|
||
for (const line of lines) {
|
||
if (!line.trim()) continue;
|
||
|
||
// 解析 SSE 消息
|
||
const eventMatch = line.match(/^event: (.+)$/m);
|
||
const dataMatch = line.match(/^data: (.+)$/m);
|
||
|
||
if (!eventMatch || !dataMatch) continue;
|
||
|
||
const event = eventMatch[1];
|
||
const data = JSON.parse(dataMatch[1]);
|
||
|
||
logger.info(`SSE Event: ${event}`, data);
|
||
|
||
// 处理不同类型的事件
|
||
switch (event) {
|
||
case '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(20);
|
||
} else if (data.stage === 'executing') {
|
||
setCurrentProgress(30);
|
||
} else if (data.stage === 'summarizing') {
|
||
setCurrentProgress(90);
|
||
}
|
||
break;
|
||
|
||
case 'plan':
|
||
// 移除思考消息
|
||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
|
||
|
||
// 显示执行计划
|
||
currentPlan = data;
|
||
addMessage({
|
||
type: MessageTypes.AGENT_PLAN,
|
||
content: '已制定执行计划',
|
||
plan: data,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
setCurrentProgress(30);
|
||
break;
|
||
|
||
case 'step_start':
|
||
// 如果还没有执行中消息,创建一个
|
||
if (!executingMessageId) {
|
||
const executingMsg = {
|
||
type: MessageTypes.AGENT_EXECUTING,
|
||
content: '正在执行步骤...',
|
||
plan: currentPlan,
|
||
stepResults: [],
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
addMessage(executingMsg);
|
||
executingMessageId = Date.now();
|
||
}
|
||
break;
|
||
|
||
case 'step_complete':
|
||
// 添加步骤结果
|
||
stepResults.push({
|
||
step_index: data.step_index,
|
||
tool: data.tool,
|
||
status: data.status,
|
||
result: data.result,
|
||
error: data.error,
|
||
execution_time: data.execution_time,
|
||
arguments: data.arguments,
|
||
});
|
||
|
||
// 更新执行中消息
|
||
setMessages(prev =>
|
||
prev.map(msg =>
|
||
msg.type === MessageTypes.AGENT_EXECUTING
|
||
? { ...msg, stepResults: [...stepResults] }
|
||
: msg
|
||
)
|
||
);
|
||
|
||
// 更新进度
|
||
if (currentPlan) {
|
||
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
|
||
setCurrentProgress(progress);
|
||
}
|
||
break;
|
||
|
||
case 'summary':
|
||
// 移除执行中消息
|
||
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
|
||
|
||
// 显示最终结果
|
||
addMessage({
|
||
type: MessageTypes.AGENT_RESPONSE,
|
||
content: data.content,
|
||
plan: currentPlan,
|
||
stepResults: stepResults,
|
||
metadata: data.metadata,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
setCurrentProgress(100);
|
||
break;
|
||
|
||
case 'error':
|
||
throw new Error(data.message);
|
||
|
||
case 'done':
|
||
logger.info('Stream完成');
|
||
break;
|
||
|
||
default:
|
||
logger.warn('未知事件类型:', event);
|
||
}
|
||
}
|
||
}
|
||
|
||
} 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;
|