feat: 创建 ChatArea 组件(含 MessageRenderer、ExecutionStepsDisplay 子组件)

This commit is contained in:
zdl
2025-11-24 15:18:32 +08:00
parent 9475027c0d
commit f536d68753
3 changed files with 839 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
// 执行步骤显示组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Card,
CardBody,
Badge,
HStack,
VStack,
Flex,
Text,
} from '@chakra-ui/react';
import { Activity } from 'lucide-react';
/**
* ExecutionStepsDisplay - 执行步骤显示组件
*
* @param {Object} props
* @param {Array} props.steps - 执行步骤列表
* @param {Object} props.plan - 执行计划(可选)
* @returns {JSX.Element}
*/
const ExecutionStepsDisplay = ({ steps, plan }) => {
return (
<Accordion allowToggle>
<AccordionItem
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="lg"
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
>
<AccordionButton px={4} py={2}>
<HStack flex={1} spacing={2}>
<Activity className="w-4 h-4" color="#C084FC" />
<Text color="gray.300" fontSize="sm">
执行详情
</Text>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
variant="subtle"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{steps.length} 步骤
</Badge>
</HStack>
<AccordionIcon color="gray.400" />
</AccordionButton>
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Text fontSize="xs" fontWeight="medium" color="gray.300">
步骤 {idx + 1}: {result.tool_name}
</Text>
<Badge
bgGradient={
result.status === 'success'
? 'linear(to-r, green.500, teal.500)'
: 'linear(to-r, red.500, orange.500)'
}
color="white"
variant="subtle"
boxShadow={
result.status === 'success'
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: '0 2px 8px rgba(239, 68, 68, 0.3)'
}
>
{result.status}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time?.toFixed(2)}s
</Text>
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}>
{result.error}
</Text>
)}
</CardBody>
</Card>
</motion.div>
))}
</VStack>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default ExecutionStepsDisplay;

View File

@@ -0,0 +1,248 @@
// src/views/AgentChat/components/ChatArea/MessageRenderer.js
// 消息渲染器组件
import React from 'react';
import { motion } from 'framer-motion';
import {
Card,
CardBody,
Avatar,
Badge,
Spinner,
Tooltip,
IconButton,
HStack,
Flex,
Text,
Box,
} from '@chakra-ui/react';
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay';
/**
* MessageRenderer - 消息渲染器组件
*
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {string} props.userAvatar - 用户头像 URL
* @returns {JSX.Element|null}
*/
const MessageRenderer = ({ message, userAvatar }) => {
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="start" spacing={3} maxW="75%">
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<Card
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 8px 20px rgba(139, 92, 246, 0.4)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="white" whiteSpace="pre-wrap">
{message.content}
</Text>
{message.files && message.files.length > 0 && (
<HStack mt={2} flexWrap="wrap" spacing={2}>
{message.files.map((file, idx) => (
<Badge
key={idx}
bg="rgba(255, 255, 255, 0.2)"
color="white"
display="flex"
alignItems="center"
gap={1}
>
<File className="w-3 h-3" />
{file.name}
</Badge>
))}
</HStack>
)}
</CardBody>
</Card>
</motion.div>
<Avatar
src={userAvatar}
icon={<User className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, blue.500, purple.600)"
boxShadow="0 0 12px rgba(139, 92, 246, 0.4)"
/>
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }}>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
<HStack spacing={2}>
<Spinner
size="sm"
color="purple.500"
emptyColor="gray.700"
thickness="3px"
speed="0.65s"
/>
<Text fontSize="sm" color="gray.300">
{message.content}
</Text>
</HStack>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(16px) saturate(180%)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="gray.100" whiteSpace="pre-wrap" lineHeight="relaxed">
{message.content}
</Text>
{message.stepResults && message.stepResults.length > 0 && (
<Box mt={3}>
<ExecutionStepsDisplay steps={message.stepResults} plan={message.plan} />
</Box>
)}
<Flex
align="center"
gap={2}
mt={3}
pt={3}
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<Tooltip label="复制">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Copy className="w-4 h-4" />}
onClick={() => navigator.clipboard.writeText(message.content)}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'white',
bg: 'rgba(255, 255, 255, 0.1)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="点赞">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsUp className="w-4 h-4" />}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'green.400',
bg: 'rgba(16, 185, 129, 0.1)',
boxShadow: '0 0 12px rgba(16, 185, 129, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="点踩">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<ThumbsDown className="w-4 h-4" />}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
color: 'red.400',
bg: 'rgba(239, 68, 68, 0.1)',
boxShadow: '0 0 12px rgba(239, 68, 68, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Text fontSize="xs" color="gray.500" ml="auto">
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</Flex>
</CardBody>
</Card>
</motion.div>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="center">
<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }}>
<Card
bg="rgba(239, 68, 68, 0.1)"
backdropFilter="blur(16px)"
border="1px solid"
borderColor="rgba(239, 68, 68, 0.5)"
boxShadow="0 8px 32px 0 rgba(239, 68, 68, 0.37)"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="red.400">
{message.content}
</Text>
</CardBody>
</Card>
</motion.div>
</Flex>
);
default:
return null;
}
};
export default MessageRenderer;

View File

@@ -0,0 +1,474 @@
// src/views/AgentChat/components/ChatArea/index.js
// 中间聊天区域组件
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Input,
Avatar,
Badge,
Tooltip,
IconButton,
Kbd,
HStack,
VStack,
Flex,
Text,
Tag,
TagLabel,
TagCloseButton,
} from '@chakra-ui/react';
import {
Send,
Menu,
RefreshCw,
Settings,
Cpu,
Zap,
Sparkles,
Paperclip,
Image as ImageIcon,
} from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
/**
* ChatArea - 中间聊天区域组件
*
* @param {Object} props
* @param {Array} props.messages - 消息列表
* @param {string} props.inputValue - 输入框内容
* @param {Function} props.onInputChange - 输入框变化回调
* @param {boolean} props.isProcessing - 处理中状态
* @param {Function} props.onSendMessage - 发送消息回调
* @param {Function} props.onKeyPress - 键盘事件回调
* @param {Array} props.uploadedFiles - 已上传文件列表
* @param {Function} props.onFileSelect - 文件选择回调
* @param {Function} props.onFileRemove - 文件删除回调
* @param {string} props.selectedModel - 当前选中的模型 ID
* @param {boolean} props.isLeftSidebarOpen - 左侧栏是否展开
* @param {boolean} props.isRightSidebarOpen - 右侧栏是否展开
* @param {Function} props.onToggleLeftSidebar - 切换左侧栏回调
* @param {Function} props.onToggleRightSidebar - 切换右侧栏回调
* @param {Function} props.onNewSession - 新建会话回调
* @param {string} props.userAvatar - 用户头像 URL
* @param {RefObject} props.messagesEndRef - 消息列表底部引用
* @param {RefObject} props.inputRef - 输入框引用
* @param {RefObject} props.fileInputRef - 文件上传输入引用
* @returns {JSX.Element}
*/
const ChatArea = ({
messages,
inputValue,
onInputChange,
isProcessing,
onSendMessage,
onKeyPress,
uploadedFiles,
onFileSelect,
onFileRemove,
selectedModel,
isLeftSidebarOpen,
isRightSidebarOpen,
onToggleLeftSidebar,
onToggleRightSidebar,
onNewSession,
userAvatar,
messagesEndRef,
inputRef,
fileInputRef,
}) => {
return (
<Flex flex={1} direction="column">
{/* 顶部标题栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
<HStack spacing={4}>
{!isLeftSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Menu className="w-4 h-4" />}
onClick={onToggleLeftSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 3, repeat: Infinity, ease: 'linear' }}
>
<Avatar
icon={<Cpu className="w-6 h-6" />}
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 20px rgba(236, 72, 153, 0.5)"
/>
</motion.div>
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.400, purple.400)"
bgClip="text"
letterSpacing="tight"
>
价小前投研 AI
</Text>
<HStack spacing={2} mt={1}>
<Badge
bgGradient="linear(to-r, green.500, teal.500)"
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
boxShadow="0 2px 8px rgba(16, 185, 129, 0.3)"
>
<Zap className="w-3 h-3" />
智能分析
</Badge>
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
boxShadow="0 2px 8px rgba(139, 92, 246, 0.3)"
>
{AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.name}
</Badge>
</HStack>
</Box>
</HStack>
<HStack spacing={2}>
<Tooltip label="清空对话">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={onNewSession}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
borderColor: 'purple.400',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
{!isRightSidebarOpen && (
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<Settings className="w-4 h-4" />}
onClick={onToggleRightSidebar}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
)}
</HStack>
</Flex>
</Box>
{/* 消息列表 */}
<Box
flex={1}
p={6}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
<motion.div
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6} py={3}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text>快速开始</Text>
</HStack>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => {
onInputChange(question.text);
inputRef.current?.focus();
}}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
</motion.div>
)}
</AnimatePresence>
{/* 输入栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">
{/* 已上传文件预览 */}
{uploadedFiles.length > 0 && (
<HStack mb={3} flexWrap="wrap" spacing={2}>
{uploadedFiles.map((file, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
>
<Tag
size="md"
variant="subtle"
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
borderColor="rgba(255, 255, 255, 0.1)"
borderWidth={1}
>
<TagLabel color="gray.300">{file.name}</TagLabel>
<TagCloseButton onClick={() => onFileRemove(idx)} color="gray.400" />
</Tag>
</motion.div>
))}
</HStack>
)}
<HStack spacing={2}>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt"
onChange={onFileSelect}
style={{ display: 'none' }}
/>
<Tooltip label="上传文件">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<Paperclip className="w-5 h-5" />}
onClick={() => fileInputRef.current?.click()}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Tooltip label="上传图片">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<IconButton
variant="ghost"
size="lg"
icon={<ImageIcon className="w-5 h-5" />}
onClick={() => {
fileInputRef.current?.setAttribute('accept', 'image/*');
fileInputRef.current?.click();
}}
bg="rgba(255, 255, 255, 0.05)"
color="gray.300"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
borderColor: 'purple.400',
color: 'white',
boxShadow: '0 0 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</Tooltip>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyPress}
placeholder="输入你的问题... (Enter 发送, Shift+Enter 换行)"
isDisabled={isProcessing}
size="lg"
variant="outline"
borderWidth={2}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'purple.400',
boxShadow:
'0 0 0 1px var(--chakra-colors-purple-400), 0 0 12px rgba(139, 92, 246, 0.3)',
bg: 'rgba(255, 255, 255, 0.08)',
}}
/>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<IconButton
size="lg"
icon={!isProcessing && <Send className="w-5 h-5" />}
onClick={onSendMessage}
isLoading={isProcessing}
isDisabled={!inputValue.trim() || isProcessing}
bgGradient="linear(to-r, blue.500, purple.600)"
color="white"
_hover={{
bgGradient: 'linear(to-r, blue.600, purple.700)',
boxShadow: '0 8px 20px rgba(139, 92, 246, 0.4)',
}}
_active={{
transform: 'translateY(0)',
boxShadow: '0 4px 12px rgba(139, 92, 246, 0.3)',
}}
/>
</motion.div>
</HStack>
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>发送</Text>
</HStack>
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Shift
</Kbd>
<Text>+</Text>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400" borderColor="rgba(255, 255, 255, 0.1)">
Enter
</Kbd>
<Text>换行</Text>
</HStack>
</HStack>
</Box>
</Box>
</Flex>
);
};
export default ChatArea;