443 lines
14 KiB
JavaScript
443 lines
14 KiB
JavaScript
// src/views/AgentChat/components/MeetingRoom/index.js
|
||
// 投研会议室主组件
|
||
|
||
import React, { useRef, useEffect } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
VStack,
|
||
HStack,
|
||
Text,
|
||
Input,
|
||
IconButton,
|
||
Avatar,
|
||
Badge,
|
||
Spinner,
|
||
Tooltip,
|
||
Kbd,
|
||
useColorModeValue,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
Send,
|
||
Users,
|
||
RefreshCw,
|
||
MessageCircle,
|
||
CheckCircle,
|
||
AlertCircle,
|
||
} from 'lucide-react';
|
||
import {
|
||
MEETING_ROLES,
|
||
MeetingStatus,
|
||
getRoleConfig,
|
||
} from '../../constants/meetingRoles';
|
||
import { useInvestmentMeeting } from '../../hooks/useInvestmentMeeting';
|
||
import MeetingMessageBubble from './MeetingMessageBubble';
|
||
import MeetingRolePanel from './MeetingRolePanel';
|
||
import MeetingWelcome from './MeetingWelcome';
|
||
|
||
/**
|
||
* MeetingRoom - 投研会议室主组件
|
||
*
|
||
* @param {Object} props
|
||
* @param {Object} props.user - 当前用户信息
|
||
* @param {Function} props.onToast - Toast 通知函数
|
||
* @returns {JSX.Element}
|
||
*/
|
||
const MeetingRoom = ({ user, onToast }) => {
|
||
const inputRef = useRef(null);
|
||
const messagesEndRef = useRef(null);
|
||
|
||
// 使用投研会议 Hook
|
||
const {
|
||
messages,
|
||
status,
|
||
speakingRoleId,
|
||
currentRound,
|
||
isConcluded,
|
||
conclusion,
|
||
inputValue,
|
||
setInputValue,
|
||
startMeeting,
|
||
continueMeeting,
|
||
sendUserMessage,
|
||
resetMeeting,
|
||
currentTopic,
|
||
isLoading,
|
||
} = useInvestmentMeeting({
|
||
userId: user?.id ? String(user.id) : 'anonymous',
|
||
userNickname: user?.nickname || '匿名用户',
|
||
onToast,
|
||
});
|
||
|
||
// 自动滚动到底部
|
||
useEffect(() => {
|
||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||
}, [messages]);
|
||
|
||
// 处理键盘事件
|
||
const handleKeyPress = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
};
|
||
|
||
// 处理发送
|
||
const handleSend = () => {
|
||
if (!inputValue.trim()) return;
|
||
|
||
if (status === MeetingStatus.IDLE) {
|
||
// 启动新会议
|
||
startMeeting(inputValue.trim());
|
||
setInputValue('');
|
||
} else if (status === MeetingStatus.CONCLUDED) {
|
||
// 如果已结论,开始新会议
|
||
resetMeeting();
|
||
startMeeting(inputValue.trim());
|
||
setInputValue('');
|
||
} else if (
|
||
status === MeetingStatus.WAITING_INPUT ||
|
||
status === MeetingStatus.DISCUSSING ||
|
||
status === MeetingStatus.SPEAKING
|
||
) {
|
||
// 用户可以在任何时候插话(包括讨论中和发言中)
|
||
sendUserMessage(inputValue.trim());
|
||
setInputValue('');
|
||
}
|
||
};
|
||
|
||
// 获取状态提示文字
|
||
const getStatusText = () => {
|
||
switch (status) {
|
||
case MeetingStatus.IDLE:
|
||
return '请输入投研议题,开始会议讨论';
|
||
case MeetingStatus.STARTING:
|
||
return '正在召集会议成员...';
|
||
case MeetingStatus.DISCUSSING:
|
||
return `第 ${currentRound} 轮讨论进行中...`;
|
||
case MeetingStatus.SPEAKING:
|
||
const role = getRoleConfig(speakingRoleId);
|
||
return `${role?.name || '成员'} 正在发言...`;
|
||
case MeetingStatus.WAITING_INPUT:
|
||
return '讨论暂停,您可以插话或等待继续';
|
||
case MeetingStatus.CONCLUDED:
|
||
return '会议已结束,已得出投资建议';
|
||
case MeetingStatus.ERROR:
|
||
return '会议出现异常,请重试';
|
||
default:
|
||
return '';
|
||
}
|
||
};
|
||
|
||
// 获取输入框占位符
|
||
const getPlaceholder = () => {
|
||
if (status === MeetingStatus.IDLE) {
|
||
return '输入投研议题,如:分析茅台最新财报...';
|
||
} else if (status === MeetingStatus.WAITING_INPUT) {
|
||
return '输入您的观点参与讨论,或点击继续按钮...';
|
||
} else if (status === MeetingStatus.CONCLUDED) {
|
||
return '会议已结束,输入新议题开始新会议...';
|
||
} else if (status === MeetingStatus.STARTING) {
|
||
return '正在召集会议成员...';
|
||
} else if (status === MeetingStatus.DISCUSSING || status === MeetingStatus.SPEAKING) {
|
||
return '随时输入您的观点参与讨论...';
|
||
}
|
||
return '输入您的观点...';
|
||
};
|
||
|
||
return (
|
||
<Flex h="100%" direction="column" bg="gray.900">
|
||
{/* 顶部标题栏 */}
|
||
<Box
|
||
bg="rgba(17, 24, 39, 0.9)"
|
||
backdropFilter="blur(20px)"
|
||
borderBottom="1px solid"
|
||
borderColor="rgba(255, 255, 255, 0.1)"
|
||
px={6}
|
||
py={4}
|
||
>
|
||
<Flex align="center" justify="space-between">
|
||
<HStack spacing={4}>
|
||
<motion.div
|
||
animate={{ rotate: isLoading ? 360 : 0 }}
|
||
transition={{
|
||
duration: 2,
|
||
repeat: isLoading ? Infinity : 0,
|
||
ease: 'linear',
|
||
}}
|
||
>
|
||
<Avatar
|
||
icon={<Users className="w-6 h-6" />}
|
||
bgGradient="linear(to-br, orange.400, red.500)"
|
||
boxShadow="0 0 20px rgba(251, 146, 60, 0.5)"
|
||
/>
|
||
</motion.div>
|
||
|
||
<Box>
|
||
<Text
|
||
fontSize="xl"
|
||
fontWeight="bold"
|
||
bgGradient="linear(to-r, orange.400, red.400)"
|
||
bgClip="text"
|
||
letterSpacing="tight"
|
||
>
|
||
投研会议室
|
||
</Text>
|
||
<HStack spacing={2} mt={1}>
|
||
<Badge
|
||
bg={
|
||
status === MeetingStatus.CONCLUDED
|
||
? 'green.500'
|
||
: status === MeetingStatus.ERROR
|
||
? 'red.500'
|
||
: 'blue.500'
|
||
}
|
||
color="white"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="md"
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={1}
|
||
>
|
||
{status === MeetingStatus.CONCLUDED ? (
|
||
<CheckCircle className="w-3 h-3" />
|
||
) : status === MeetingStatus.ERROR ? (
|
||
<AlertCircle className="w-3 h-3" />
|
||
) : (
|
||
<MessageCircle className="w-3 h-3" />
|
||
)}
|
||
{getStatusText()}
|
||
</Badge>
|
||
{currentRound > 0 && (
|
||
<Badge
|
||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||
color="white"
|
||
px={2}
|
||
py={1}
|
||
borderRadius="md"
|
||
>
|
||
第 {currentRound} 轮
|
||
</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={resetMeeting}
|
||
bg="rgba(255, 255, 255, 0.05)"
|
||
color="gray.400"
|
||
_hover={{
|
||
bg: 'rgba(255, 255, 255, 0.1)',
|
||
color: 'white',
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
</Tooltip>
|
||
</HStack>
|
||
</Flex>
|
||
</Box>
|
||
|
||
{/* 主内容区 */}
|
||
<Flex flex={1} overflow="hidden">
|
||
{/* 角色面板(左侧) */}
|
||
<MeetingRolePanel
|
||
speakingRoleId={speakingRoleId}
|
||
status={status}
|
||
/>
|
||
|
||
{/* 消息区域(中间) */}
|
||
<Box
|
||
flex={1}
|
||
overflowY="auto"
|
||
bg="rgba(17, 24, 39, 0.5)"
|
||
p={4}
|
||
>
|
||
{messages.length === 0 && status === MeetingStatus.IDLE ? (
|
||
<MeetingWelcome
|
||
onTopicSelect={(topic) => {
|
||
setInputValue(topic);
|
||
inputRef.current?.focus();
|
||
}}
|
||
/>
|
||
) : (
|
||
<VStack spacing={4} align="stretch" maxW="800px" mx="auto">
|
||
{/* 当前议题展示 */}
|
||
{currentTopic && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -10 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
>
|
||
<Box
|
||
bg="rgba(251, 146, 60, 0.1)"
|
||
border="1px solid"
|
||
borderColor="rgba(251, 146, 60, 0.3)"
|
||
borderRadius="lg"
|
||
p={4}
|
||
mb={4}
|
||
>
|
||
<Text fontSize="sm" color="orange.300" fontWeight="medium">
|
||
📋 本次议题
|
||
</Text>
|
||
<Text color="white" mt={1}>
|
||
{currentTopic}
|
||
</Text>
|
||
</Box>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* 消息列表 */}
|
||
<AnimatePresence mode="popLayout">
|
||
{messages.map((message, index) => (
|
||
<motion.div
|
||
key={message.id || index}
|
||
initial={{ opacity: 0, y: 20 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
exit={{ opacity: 0, y: -20 }}
|
||
transition={{ duration: 0.3 }}
|
||
>
|
||
<MeetingMessageBubble
|
||
message={message}
|
||
isLatest={index === messages.length - 1}
|
||
/>
|
||
</motion.div>
|
||
))}
|
||
</AnimatePresence>
|
||
|
||
{/* 正在发言指示器 */}
|
||
{speakingRoleId && (
|
||
<motion.div
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
>
|
||
<HStack
|
||
spacing={3}
|
||
p={4}
|
||
bg="rgba(255, 255, 255, 0.05)"
|
||
borderRadius="lg"
|
||
>
|
||
<Spinner size="sm" color="purple.400" />
|
||
<Text color="gray.400" fontSize="sm">
|
||
{getRoleConfig(speakingRoleId)?.name} 正在思考...
|
||
</Text>
|
||
</HStack>
|
||
</motion.div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</VStack>
|
||
)}
|
||
</Box>
|
||
</Flex>
|
||
|
||
{/* 输入栏 */}
|
||
<Box
|
||
bg="rgba(17, 24, 39, 0.9)"
|
||
backdropFilter="blur(20px)"
|
||
borderTop="1px solid"
|
||
borderColor="rgba(255, 255, 255, 0.1)"
|
||
px={6}
|
||
py={4}
|
||
>
|
||
<Box maxW="800px" mx="auto">
|
||
<HStack spacing={3}>
|
||
<Input
|
||
ref={inputRef}
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyDown={handleKeyPress}
|
||
placeholder={getPlaceholder()}
|
||
isDisabled={status === MeetingStatus.STARTING}
|
||
size="lg"
|
||
bg="rgba(255, 255, 255, 0.05)"
|
||
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: 'orange.400',
|
||
boxShadow: '0 0 0 1px var(--chakra-colors-orange-400)',
|
||
}}
|
||
/>
|
||
|
||
<motion.div
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<IconButton
|
||
size="lg"
|
||
icon={isLoading && status === MeetingStatus.STARTING ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
||
onClick={handleSend}
|
||
isDisabled={
|
||
!inputValue.trim() ||
|
||
status === MeetingStatus.STARTING
|
||
}
|
||
bgGradient="linear(to-r, orange.400, red.500)"
|
||
color="white"
|
||
_hover={{
|
||
bgGradient: 'linear(to-r, orange.500, red.600)',
|
||
boxShadow: '0 8px 20px rgba(251, 146, 60, 0.4)',
|
||
}}
|
||
/>
|
||
</motion.div>
|
||
|
||
{/* 继续讨论按钮 */}
|
||
{status === MeetingStatus.WAITING_INPUT && !isConcluded && (
|
||
<motion.div
|
||
whileHover={{ scale: 1.05 }}
|
||
whileTap={{ scale: 0.95 }}
|
||
>
|
||
<Tooltip label="继续下一轮讨论">
|
||
<IconButton
|
||
size="lg"
|
||
icon={<MessageCircle className="w-5 h-5" />}
|
||
onClick={() => continueMeeting()}
|
||
isDisabled={isLoading}
|
||
bgGradient="linear(to-r, purple.400, blue.500)"
|
||
color="white"
|
||
_hover={{
|
||
bgGradient: 'linear(to-r, purple.500, blue.600)',
|
||
}}
|
||
/>
|
||
</Tooltip>
|
||
</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">
|
||
Enter
|
||
</Kbd>
|
||
<Text>
|
||
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
|
||
</Text>
|
||
</HStack>
|
||
{(status === MeetingStatus.WAITING_INPUT ||
|
||
status === MeetingStatus.DISCUSSING ||
|
||
status === MeetingStatus.SPEAKING) && (
|
||
<Text color="orange.400">
|
||
💡 随时输入观点参与讨论,您的发言会影响分析师的判断
|
||
</Text>
|
||
)}
|
||
</HStack>
|
||
</Box>
|
||
</Box>
|
||
</Flex>
|
||
);
|
||
};
|
||
|
||
export default MeetingRoom;
|