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