697 lines
20 KiB
JavaScript
697 lines
20 KiB
JavaScript
// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
|
||
// 会议消息气泡组件 - V2: 支持工具调用展示和流式输出
|
||
|
||
import React, { useState } from 'react';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import {
|
||
Box,
|
||
Flex,
|
||
HStack,
|
||
VStack,
|
||
Text,
|
||
Avatar,
|
||
Badge,
|
||
IconButton,
|
||
Tooltip,
|
||
Card,
|
||
CardBody,
|
||
Spinner,
|
||
Code,
|
||
Collapse,
|
||
} from '@chakra-ui/react';
|
||
import {
|
||
TrendingUp,
|
||
TrendingDown,
|
||
BarChart2,
|
||
Users,
|
||
Crown,
|
||
Copy,
|
||
ThumbsUp,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
Database,
|
||
Check,
|
||
Wrench,
|
||
AlertCircle,
|
||
Brain,
|
||
} from 'lucide-react';
|
||
import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles';
|
||
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
|
||
|
||
/**
|
||
* 清理 DeepSeek 模型输出中的工具调用标记
|
||
* DeepSeek 有时会以文本形式输出工具调用,格式如:
|
||
* <|tool▁calls▁begin|><|tool▁call▁begin|>tool_name<|tool▁sep|>{"args": "value"}<|tool▁call▁end|><|tool▁calls▁end|>
|
||
*/
|
||
const cleanDeepseekToolMarkers = (content) => {
|
||
if (!content) return content;
|
||
|
||
// 清理 DeepSeek 工具调用标记(匹配整个块)
|
||
let cleaned = content.replace(/<|tool▁calls▁begin|>[\s\S]*?<|tool▁calls▁end|>/g, '');
|
||
|
||
// 清理可能残留的单个标记
|
||
const markers = [
|
||
'<|tool▁calls▁begin|>',
|
||
'<|tool▁calls▁end|>',
|
||
'<|tool▁call▁begin|>',
|
||
'<|tool▁call▁end|>',
|
||
'<|tool▁sep|>',
|
||
];
|
||
markers.forEach((marker) => {
|
||
cleaned = cleaned.split(marker).join('');
|
||
});
|
||
|
||
return cleaned.trim();
|
||
};
|
||
|
||
/**
|
||
* 解析 deepmoney 格式的内容
|
||
* 格式: <think>思考过程</think><answer>回答内容</answer>
|
||
*
|
||
* @param {string} content - 原始内容
|
||
* @returns {{ thinking: string | null, answer: string }} 解析后的内容
|
||
*/
|
||
const parseDeepmoneyContent = (content) => {
|
||
if (!content) return { thinking: null, answer: '' };
|
||
|
||
// 先清理 DeepSeek 工具调用标记
|
||
const cleanedContent = cleanDeepseekToolMarkers(content);
|
||
|
||
// 匹配 <think>...</think> 标签
|
||
const thinkMatch = cleanedContent.match(/<think>([\s\S]*?)<\/think>/i);
|
||
// 匹配 <answer>...</answer> 标签
|
||
const answerMatch = cleanedContent.match(/<answer>([\s\S]*?)<\/answer>/i);
|
||
|
||
// 如果有 answer 标签,提取内容
|
||
if (answerMatch) {
|
||
return {
|
||
thinking: thinkMatch ? thinkMatch[1].trim() : null,
|
||
answer: answerMatch[1].trim(),
|
||
};
|
||
}
|
||
|
||
// 如果只有 think 标签但没有 answer 标签,可能正在流式输出中
|
||
if (thinkMatch && !answerMatch) {
|
||
// 检查 think 后面是否有其他内容
|
||
const afterThink = cleanedContent.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
|
||
// 如果 think 后面有内容但不是 answer 标签包裹的,可能是部分输出
|
||
if (afterThink && !afterThink.startsWith('<answer>')) {
|
||
return {
|
||
thinking: thinkMatch[1].trim(),
|
||
answer: afterThink.replace(/<\/?answer>/gi, '').trim(),
|
||
};
|
||
}
|
||
return {
|
||
thinking: thinkMatch[1].trim(),
|
||
answer: '',
|
||
};
|
||
}
|
||
|
||
// 如果没有特殊标签,返回清理后的内容
|
||
return {
|
||
thinking: null,
|
||
answer: cleanedContent,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 获取角色图标
|
||
*/
|
||
const getRoleIcon = (roleType) => {
|
||
switch (roleType) {
|
||
case 'bull':
|
||
return <TrendingUp className="w-4 h-4" />;
|
||
case 'bear':
|
||
return <TrendingDown className="w-4 h-4" />;
|
||
case 'quant':
|
||
return <BarChart2 className="w-4 h-4" />;
|
||
case 'retail':
|
||
return <Users className="w-4 h-4" />;
|
||
case 'manager':
|
||
return <Crown className="w-4 h-4" />;
|
||
default:
|
||
return <Users className="w-4 h-4" />;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 工具名称映射
|
||
*/
|
||
const TOOL_NAME_MAP = {
|
||
search_china_news: '搜索新闻',
|
||
search_research_reports: '搜索研报',
|
||
get_stock_basic_info: '获取股票信息',
|
||
get_stock_financial_index: '获取财务指标',
|
||
get_stock_balance_sheet: '获取资产负债表',
|
||
get_stock_cashflow: '获取现金流量表',
|
||
get_stock_trade_data: '获取交易数据',
|
||
search_limit_up_stocks: '搜索涨停股',
|
||
get_concept_statistics: '获取概念统计',
|
||
};
|
||
|
||
/**
|
||
* 格式化结果数据用于显示
|
||
*/
|
||
const formatResultData = (data) => {
|
||
if (data === null || data === undefined) return null;
|
||
if (typeof data === 'string') return data;
|
||
try {
|
||
return JSON.stringify(data, null, 2);
|
||
} catch {
|
||
return String(data);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取结果数据的预览文本
|
||
*/
|
||
const getResultPreview = (result) => {
|
||
if (!result) return '无数据';
|
||
|
||
if (result.data) {
|
||
const data = result.data;
|
||
if (data.chart_data) {
|
||
return `图表数据: ${data.chart_data.labels?.length || 0} 项`;
|
||
}
|
||
if (data.sector_data) {
|
||
const sectorCount = Object.keys(data.sector_data).length;
|
||
return `${sectorCount} 个板块分析`;
|
||
}
|
||
if (data.stocks) {
|
||
return `${data.stocks.length} 只股票`;
|
||
}
|
||
if (Array.isArray(data)) {
|
||
return `${data.length} 条记录`;
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(result)) {
|
||
return `${result.length} 条记录`;
|
||
}
|
||
|
||
if (typeof result === 'object') {
|
||
const keys = Object.keys(result);
|
||
return `${keys.length} 个字段`;
|
||
}
|
||
|
||
return '查看详情';
|
||
};
|
||
|
||
/**
|
||
* 单个工具调用卡片
|
||
*/
|
||
const ToolCallCard = ({ toolCall, idx, roleColor }) => {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const hasResult = toolCall.result && (
|
||
typeof toolCall.result === 'object'
|
||
? Object.keys(toolCall.result).length > 0
|
||
: toolCall.result
|
||
);
|
||
|
||
const handleCopy = async (e) => {
|
||
e.stopPropagation();
|
||
try {
|
||
await navigator.clipboard.writeText(formatResultData(toolCall.result));
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
} catch (err) {
|
||
console.error('复制失败:', err);
|
||
}
|
||
};
|
||
|
||
const toolDisplayName = TOOL_NAME_MAP[toolCall.tool_name] || toolCall.tool_name;
|
||
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, x: -10 }}
|
||
animate={{ opacity: 1, x: 0 }}
|
||
transition={{ delay: idx * 0.05 }}
|
||
>
|
||
<Card
|
||
bg="rgba(255, 255, 255, 0.03)"
|
||
border="1px solid"
|
||
borderColor={isExpanded ? `${roleColor}40` : 'rgba(255, 255, 255, 0.1)'}
|
||
borderRadius="md"
|
||
transition="all 0.2s"
|
||
_hover={{
|
||
borderColor: `${roleColor}30`,
|
||
}}
|
||
size="sm"
|
||
>
|
||
<CardBody p={2}>
|
||
{/* 工具调用头部 */}
|
||
<Flex
|
||
align="center"
|
||
justify="space-between"
|
||
gap={2}
|
||
cursor={hasResult ? 'pointer' : 'default'}
|
||
onClick={() => hasResult && setIsExpanded(!isExpanded)}
|
||
>
|
||
<HStack flex={1} spacing={2}>
|
||
{toolCall.status === 'calling' ? (
|
||
<Spinner size="xs" color={roleColor} />
|
||
) : toolCall.status === 'success' ? (
|
||
<Box color="green.400">
|
||
<Check className="w-3 h-3" />
|
||
</Box>
|
||
) : (
|
||
<Box color="red.400">
|
||
<AlertCircle className="w-3 h-3" />
|
||
</Box>
|
||
)}
|
||
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
|
||
<Text fontSize="xs" fontWeight="medium" color="gray.300">
|
||
{toolDisplayName}
|
||
</Text>
|
||
{hasResult && (
|
||
<Box
|
||
color="gray.500"
|
||
transition="transform 0.2s"
|
||
transform={isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}
|
||
>
|
||
<ChevronRight className="w-3 h-3" />
|
||
</Box>
|
||
)}
|
||
</HStack>
|
||
|
||
<HStack spacing={2}>
|
||
{hasResult && (
|
||
<Tooltip label={copied ? '已复制' : '复制数据'} placement="top">
|
||
<IconButton
|
||
size="xs"
|
||
variant="ghost"
|
||
icon={copied ? <Check className="w-2 h-2" /> : <Copy className="w-2 h-2" />}
|
||
onClick={handleCopy}
|
||
color={copied ? 'green.400' : 'gray.500'}
|
||
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||
aria-label="复制"
|
||
minW="20px"
|
||
h="20px"
|
||
/>
|
||
</Tooltip>
|
||
)}
|
||
{toolCall.execution_time && (
|
||
<Text fontSize="10px" color="gray.500">
|
||
{toolCall.execution_time.toFixed(2)}s
|
||
</Text>
|
||
)}
|
||
</HStack>
|
||
</Flex>
|
||
|
||
{/* 展开的详细数据 */}
|
||
<Collapse in={isExpanded} animateOpacity>
|
||
{isExpanded && hasResult && (
|
||
<Box mt={2}>
|
||
<Code
|
||
display="block"
|
||
p={2}
|
||
borderRadius="sm"
|
||
fontSize="10px"
|
||
whiteSpace="pre-wrap"
|
||
bg="rgba(0, 0, 0, 0.3)"
|
||
color="gray.300"
|
||
maxH="200px"
|
||
overflowY="auto"
|
||
sx={{
|
||
'&::-webkit-scrollbar': { width: '4px' },
|
||
'&::-webkit-scrollbar-track': { bg: 'transparent' },
|
||
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
|
||
}}
|
||
>
|
||
{formatResultData(toolCall.result)}
|
||
</Code>
|
||
</Box>
|
||
)}
|
||
</Collapse>
|
||
</CardBody>
|
||
</Card>
|
||
</motion.div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 工具调用列表组件
|
||
*/
|
||
const ToolCallsList = ({ toolCalls, roleColor }) => {
|
||
if (!toolCalls || toolCalls.length === 0) return null;
|
||
|
||
return (
|
||
<Box mt={3} mb={2}>
|
||
<HStack spacing={2} mb={2}>
|
||
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
|
||
<Text fontSize="xs" color="gray.400">
|
||
工具调用 ({toolCalls.length})
|
||
</Text>
|
||
</HStack>
|
||
<VStack spacing={1} align="stretch">
|
||
{toolCalls.map((toolCall, idx) => (
|
||
<ToolCallCard
|
||
key={toolCall.tool_call_id || idx}
|
||
toolCall={toolCall}
|
||
idx={idx}
|
||
roleColor={roleColor}
|
||
/>
|
||
))}
|
||
</VStack>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 思考过程展示组件
|
||
* 用于显示 deepmoney 等模型的思考过程,默认折叠
|
||
*/
|
||
const ThinkingBlock = ({ thinking, roleColor }) => {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
if (!thinking) return null;
|
||
|
||
return (
|
||
<Box mb={3}>
|
||
<HStack
|
||
spacing={2}
|
||
cursor="pointer"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
p={2}
|
||
bg="rgba(255, 255, 255, 0.03)"
|
||
borderRadius="md"
|
||
border="1px solid"
|
||
borderColor="rgba(255, 255, 255, 0.1)"
|
||
_hover={{ borderColor: `${roleColor}30` }}
|
||
transition="all 0.2s"
|
||
>
|
||
<Brain className="w-3 h-3" style={{ color: roleColor }} />
|
||
<Text fontSize="xs" color="gray.400" flex={1}>
|
||
AI 思考过程
|
||
</Text>
|
||
<Box
|
||
color="gray.500"
|
||
transition="transform 0.2s"
|
||
transform={isExpanded ? 'rotate(180deg)' : 'rotate(0deg)'}
|
||
>
|
||
<ChevronDown className="w-3 h-3" />
|
||
</Box>
|
||
</HStack>
|
||
|
||
<Collapse in={isExpanded} animateOpacity>
|
||
<Box
|
||
mt={2}
|
||
p={3}
|
||
bg="rgba(0, 0, 0, 0.2)"
|
||
borderRadius="md"
|
||
borderLeft="2px solid"
|
||
borderColor={`${roleColor}50`}
|
||
maxH="200px"
|
||
overflowY="auto"
|
||
sx={{
|
||
'&::-webkit-scrollbar': { width: '4px' },
|
||
'&::-webkit-scrollbar-track': { bg: 'transparent' },
|
||
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
|
||
}}
|
||
>
|
||
<Text fontSize="xs" color="gray.400" whiteSpace="pre-wrap" lineHeight="tall">
|
||
{thinking}
|
||
</Text>
|
||
</Box>
|
||
</Collapse>
|
||
</Box>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* MeetingMessageBubble - 会议消息气泡组件
|
||
*
|
||
* @param {Object} props
|
||
* @param {Object} props.message - 消息对象
|
||
* @param {boolean} props.isLatest - 是否是最新消息
|
||
* @returns {JSX.Element}
|
||
*/
|
||
const MeetingMessageBubble = ({ message, isLatest }) => {
|
||
const roleConfig = getRoleConfig(message.role_id) || {
|
||
name: message.role_name,
|
||
nickname: message.nickname,
|
||
color: message.color,
|
||
roleType: 'retail',
|
||
};
|
||
|
||
const isUser = message.role_id === 'user';
|
||
const isManager = roleConfig.roleType === 'manager';
|
||
const isConclusion = message.is_conclusion;
|
||
const isStreaming = message.isStreaming;
|
||
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
|
||
|
||
// 复制到剪贴板
|
||
const handleCopy = () => {
|
||
navigator.clipboard.writeText(message.content);
|
||
};
|
||
|
||
return (
|
||
<Flex
|
||
direction="column"
|
||
align={isUser ? 'flex-end' : 'flex-start'}
|
||
w="100%"
|
||
>
|
||
{/* 消息头部:角色信息 */}
|
||
<HStack
|
||
spacing={2}
|
||
mb={2}
|
||
flexDirection={isUser ? 'row-reverse' : 'row'}
|
||
>
|
||
<motion.div
|
||
whileHover={{ scale: 1.1 }}
|
||
transition={{ type: 'spring', stiffness: 400 }}
|
||
>
|
||
<Avatar
|
||
size="sm"
|
||
src={roleConfig.avatar}
|
||
icon={getRoleIcon(roleConfig.roleType)}
|
||
bg={roleConfig.color}
|
||
boxShadow={`0 0 12px ${roleConfig.color}40`}
|
||
/>
|
||
</motion.div>
|
||
|
||
<VStack spacing={0} align={isUser ? 'flex-end' : 'flex-start'}>
|
||
<HStack spacing={2}>
|
||
<Text
|
||
fontSize="sm"
|
||
fontWeight="bold"
|
||
color={roleConfig.color}
|
||
>
|
||
{roleConfig.name}
|
||
</Text>
|
||
{roleConfig.nickname !== roleConfig.name && (
|
||
<Text fontSize="xs" color="gray.500">
|
||
@{roleConfig.nickname}
|
||
</Text>
|
||
)}
|
||
{isManager && (
|
||
<Badge
|
||
colorScheme="purple"
|
||
size="sm"
|
||
variant="subtle"
|
||
>
|
||
主持人
|
||
</Badge>
|
||
)}
|
||
{isStreaming && (
|
||
<Badge
|
||
colorScheme="blue"
|
||
size="sm"
|
||
variant="subtle"
|
||
display="flex"
|
||
alignItems="center"
|
||
gap={1}
|
||
>
|
||
<Spinner size="xs" />
|
||
发言中
|
||
</Badge>
|
||
)}
|
||
{isConclusion && (
|
||
<Badge
|
||
colorScheme="green"
|
||
size="sm"
|
||
variant="solid"
|
||
>
|
||
最终结论
|
||
</Badge>
|
||
)}
|
||
</HStack>
|
||
<Text fontSize="xs" color="gray.500">
|
||
第 {message.round_number} 轮 ·{' '}
|
||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
|
||
{/* 消息内容卡片 */}
|
||
<motion.div
|
||
initial={{ opacity: 0, scale: 0.95 }}
|
||
animate={{ opacity: 1, scale: 1 }}
|
||
transition={{ duration: 0.2 }}
|
||
style={{ maxWidth: isUser ? '70%' : '85%', width: '100%' }}
|
||
>
|
||
<Card
|
||
bg={
|
||
isUser
|
||
? `linear-gradient(135deg, ${roleConfig.color}20, ${roleConfig.color}10)`
|
||
: isConclusion
|
||
? 'linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.05))'
|
||
: 'rgba(255, 255, 255, 0.05)'
|
||
}
|
||
border="1px solid"
|
||
borderColor={
|
||
isConclusion
|
||
? 'purple.500'
|
||
: `${roleConfig.color}30`
|
||
}
|
||
borderRadius="xl"
|
||
overflow="hidden"
|
||
boxShadow={
|
||
isConclusion
|
||
? '0 0 20px rgba(139, 92, 246, 0.3)'
|
||
: isLatest
|
||
? `0 4px 20px ${roleConfig.color}20`
|
||
: 'none'
|
||
}
|
||
>
|
||
{/* 结论标题 */}
|
||
{isConclusion && (
|
||
<Box
|
||
bgGradient="linear(to-r, purple.600, violet.600)"
|
||
px={4}
|
||
py={2}
|
||
>
|
||
<HStack>
|
||
<Crown className="w-4 h-4" />
|
||
<Text fontWeight="bold" fontSize="sm" color="white">
|
||
基金经理投资建议
|
||
</Text>
|
||
</HStack>
|
||
</Box>
|
||
)}
|
||
|
||
<CardBody p={4}>
|
||
{/* 工具调用列表 */}
|
||
{hasToolCalls && (
|
||
<ToolCallsList
|
||
toolCalls={message.tool_calls}
|
||
roleColor={roleConfig.color}
|
||
/>
|
||
)}
|
||
|
||
{/* 解析 deepmoney 格式的内容 */}
|
||
{(() => {
|
||
const parsedContent = parseDeepmoneyContent(message.content);
|
||
return (
|
||
<>
|
||
{/* 思考过程(可折叠) */}
|
||
<ThinkingBlock
|
||
thinking={parsedContent.thinking}
|
||
roleColor={roleConfig.color}
|
||
/>
|
||
|
||
{/* 消息内容 */}
|
||
<Box
|
||
fontSize="sm"
|
||
color="gray.100"
|
||
lineHeight="tall"
|
||
sx={{
|
||
'& p': { mb: 2 },
|
||
'& h1, & h2, & h3': { color: 'gray.50', fontWeight: 'bold' },
|
||
'& ul, & ol': { pl: 4 },
|
||
'& li': { mb: 1 },
|
||
'& code': {
|
||
bg: 'rgba(255,255,255,0.1)',
|
||
px: 1,
|
||
borderRadius: 'sm',
|
||
},
|
||
'& blockquote': {
|
||
borderLeftWidth: '3px',
|
||
borderLeftColor: roleConfig.color,
|
||
pl: 3,
|
||
color: 'gray.300',
|
||
fontStyle: 'italic',
|
||
},
|
||
'& strong': { color: roleConfig.color },
|
||
}}
|
||
>
|
||
{parsedContent.answer ? (
|
||
<MarkdownWithCharts content={parsedContent.answer} variant="dark" />
|
||
) : isStreaming ? (
|
||
<HStack spacing={2} color="gray.500">
|
||
<Spinner size="sm" />
|
||
<Text>正在思考...</Text>
|
||
</HStack>
|
||
) : null}
|
||
|
||
{/* 流式输出时的光标 */}
|
||
{isStreaming && parsedContent.answer && (
|
||
<motion.span
|
||
animate={{ opacity: [1, 0, 1] }}
|
||
transition={{ duration: 0.8, repeat: Infinity }}
|
||
style={{ color: roleConfig.color }}
|
||
>
|
||
▌
|
||
</motion.span>
|
||
)}
|
||
</Box>
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
{/* 操作按钮 */}
|
||
<Flex mt={3} pt={3} borderTop="1px solid" borderColor="whiteAlpha.100">
|
||
<HStack spacing={2}>
|
||
<Tooltip label="复制">
|
||
<IconButton
|
||
size="xs"
|
||
variant="ghost"
|
||
icon={<Copy className="w-3 h-3" />}
|
||
onClick={handleCopy}
|
||
color="gray.500"
|
||
_hover={{ color: 'white', bg: 'whiteAlpha.100' }}
|
||
/>
|
||
</Tooltip>
|
||
<Tooltip label="有用">
|
||
<IconButton
|
||
size="xs"
|
||
variant="ghost"
|
||
icon={<ThumbsUp className="w-3 h-3" />}
|
||
color="gray.500"
|
||
_hover={{ color: 'green.400', bg: 'green.900' }}
|
||
/>
|
||
</Tooltip>
|
||
</HStack>
|
||
|
||
{/* 角色标签 */}
|
||
<Box ml="auto">
|
||
<Badge
|
||
bg={`${roleConfig.color}20`}
|
||
color={roleConfig.color}
|
||
fontSize="xs"
|
||
px={2}
|
||
py={0.5}
|
||
borderRadius="full"
|
||
>
|
||
{roleConfig.roleType === 'bull' && '📈 看多'}
|
||
{roleConfig.roleType === 'bear' && '📉 看空'}
|
||
{roleConfig.roleType === 'quant' && '📊 量化'}
|
||
{roleConfig.roleType === 'retail' && '🌱 散户'}
|
||
{roleConfig.roleType === 'manager' && '👔 决策'}
|
||
</Badge>
|
||
</Box>
|
||
</Flex>
|
||
</CardBody>
|
||
</Card>
|
||
</motion.div>
|
||
</Flex>
|
||
);
|
||
};
|
||
|
||
export default MeetingMessageBubble;
|