475 lines
16 KiB
JavaScript
475 lines
16 KiB
JavaScript
// 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;
|