Files
vf_react/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
2025-11-28 16:51:28 +08:00

697 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;