// 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 格式的内容 * 格式: 思考过程回答内容 * * @param {string} content - 原始内容 * @returns {{ thinking: string | null, answer: string }} 解析后的内容 */ const parseDeepmoneyContent = (content) => { if (!content) return { thinking: null, answer: '' }; // 先清理 DeepSeek 工具调用标记 const cleanedContent = cleanDeepseekToolMarkers(content); // 匹配 ... 标签 const thinkMatch = cleanedContent.match(/([\s\S]*?)<\/think>/i); // 匹配 ... 标签 const answerMatch = cleanedContent.match(/([\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(/[\s\S]*?<\/think>/i, '').trim(); // 如果 think 后面有内容但不是 answer 标签包裹的,可能是部分输出 if (afterThink && !afterThink.startsWith('')) { 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 ; case 'bear': return ; case 'quant': return ; case 'retail': return ; case 'manager': return ; default: return ; } }; /** * 工具名称映射 */ 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 ( {/* 工具调用头部 */} hasResult && setIsExpanded(!isExpanded)} > {toolCall.status === 'calling' ? ( ) : toolCall.status === 'success' ? ( ) : ( )} {toolDisplayName} {hasResult && ( )} {hasResult && ( : } onClick={handleCopy} color={copied ? 'green.400' : 'gray.500'} _hover={{ bg: 'rgba(255, 255, 255, 0.1)' }} aria-label="复制" minW="20px" h="20px" /> )} {toolCall.execution_time && ( {toolCall.execution_time.toFixed(2)}s )} {/* 展开的详细数据 */} {isExpanded && hasResult && ( {formatResultData(toolCall.result)} )} ); }; /** * 工具调用列表组件 */ const ToolCallsList = ({ toolCalls, roleColor }) => { if (!toolCalls || toolCalls.length === 0) return null; return ( 工具调用 ({toolCalls.length}) {toolCalls.map((toolCall, idx) => ( ))} ); }; /** * 思考过程展示组件 * 用于显示 deepmoney 等模型的思考过程,默认折叠 */ const ThinkingBlock = ({ thinking, roleColor }) => { const [isExpanded, setIsExpanded] = useState(false); if (!thinking) return null; return ( 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" > AI 思考过程 {thinking} ); }; /** * 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 ( {/* 消息头部:角色信息 */} {roleConfig.name} {roleConfig.nickname !== roleConfig.name && ( @{roleConfig.nickname} )} {isManager && ( 主持人 )} {isStreaming && ( 发言中 )} {isConclusion && ( 最终结论 )} 第 {message.round_number} 轮 ·{' '} {new Date(message.timestamp).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', })} {/* 消息内容卡片 */} {/* 结论标题 */} {isConclusion && ( 基金经理投资建议 )} {/* 工具调用列表 */} {hasToolCalls && ( )} {/* 解析 deepmoney 格式的内容 */} {(() => { const parsedContent = parseDeepmoneyContent(message.content); return ( <> {/* 思考过程(可折叠) */} {/* 消息内容 */} {parsedContent.answer ? ( ) : isStreaming ? ( 正在思考... ) : null} {/* 流式输出时的光标 */} {isStreaming && parsedContent.answer && ( )} ); })()} {/* 操作按钮 */} } onClick={handleCopy} color="gray.500" _hover={{ color: 'white', bg: 'whiteAlpha.100' }} /> } color="gray.500" _hover={{ color: 'green.400', bg: 'green.900' }} /> {/* 角色标签 */} {roleConfig.roleType === 'bull' && '📈 看多'} {roleConfig.roleType === 'bear' && '📉 看空'} {roleConfig.roleType === 'quant' && '📊 量化'} {roleConfig.roleType === 'retail' && '🌱 散户'} {roleConfig.roleType === 'manager' && '👔 决策'} ); }; export default MeetingMessageBubble;