update pay function
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
|
||||
// 会议消息气泡组件
|
||||
// 会议消息气泡组件 - V2: 支持工具调用展示和流式输出
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
Tooltip,
|
||||
Card,
|
||||
CardBody,
|
||||
Spinner,
|
||||
Code,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
TrendingUp,
|
||||
@@ -24,6 +27,11 @@ import {
|
||||
Crown,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ChevronRight,
|
||||
Database,
|
||||
Check,
|
||||
Wrench,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles';
|
||||
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
|
||||
@@ -48,6 +56,231 @@ const getRoleIcon = (roleType) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 工具名称映射
|
||||
*/
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MeetingMessageBubble - 会议消息气泡组件
|
||||
*
|
||||
@@ -67,6 +300,8 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
||||
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 = () => {
|
||||
@@ -120,6 +355,19 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
||||
主持人
|
||||
</Badge>
|
||||
)}
|
||||
{isStreaming && (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
<Spinner size="xs" />
|
||||
发言中
|
||||
</Badge>
|
||||
)}
|
||||
{isConclusion && (
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
@@ -188,6 +436,15 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
||||
)}
|
||||
|
||||
<CardBody p={4}>
|
||||
{/* 工具调用列表 */}
|
||||
{hasToolCalls && (
|
||||
<ToolCallsList
|
||||
toolCalls={message.tool_calls}
|
||||
roleColor={roleConfig.color}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Box
|
||||
fontSize="sm"
|
||||
color="gray.100"
|
||||
@@ -212,7 +469,25 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
||||
'& strong': { color: roleConfig.color },
|
||||
}}
|
||||
>
|
||||
<MarkdownWithCharts content={message.content} variant="dark" />
|
||||
{message.content ? (
|
||||
<MarkdownWithCharts content={message.content} variant="dark" />
|
||||
) : isStreaming ? (
|
||||
<HStack spacing={2} color="gray.500">
|
||||
<Spinner size="sm" />
|
||||
<Text>正在思考...</Text>
|
||||
</HStack>
|
||||
) : null}
|
||||
|
||||
{/* 流式输出时的光标 */}
|
||||
{isStreaming && message.content && (
|
||||
<motion.span
|
||||
animate={{ opacity: [1, 0, 1] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity }}
|
||||
style={{ color: roleConfig.color }}
|
||||
>
|
||||
▌
|
||||
</motion.span>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
|
||||
@@ -91,18 +91,18 @@ const MeetingRoom = ({ user, onToast }) => {
|
||||
// 启动新会议
|
||||
startMeeting(inputValue.trim());
|
||||
setInputValue('');
|
||||
} else if (status === MeetingStatus.CONCLUDED) {
|
||||
// 如果已结论,开始新会议
|
||||
resetMeeting();
|
||||
startMeeting(inputValue.trim());
|
||||
setInputValue('');
|
||||
} else if (
|
||||
status === MeetingStatus.WAITING_INPUT ||
|
||||
status === MeetingStatus.CONCLUDED
|
||||
status === MeetingStatus.DISCUSSING ||
|
||||
status === MeetingStatus.SPEAKING
|
||||
) {
|
||||
// 用户插话或开始新话题
|
||||
if (isConcluded) {
|
||||
// 如果已结论,开始新会议
|
||||
resetMeeting();
|
||||
startMeeting(inputValue.trim());
|
||||
} else {
|
||||
sendUserMessage(inputValue.trim());
|
||||
}
|
||||
// 用户可以在任何时候插话(包括讨论中和发言中)
|
||||
sendUserMessage(inputValue.trim());
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
@@ -135,11 +135,15 @@ const MeetingRoom = ({ user, onToast }) => {
|
||||
if (status === MeetingStatus.IDLE) {
|
||||
return '输入投研议题,如:分析茅台最新财报...';
|
||||
} else if (status === MeetingStatus.WAITING_INPUT) {
|
||||
return '输入您的观点参与讨论,或等待继续...';
|
||||
return '输入您的观点参与讨论,或点击继续按钮...';
|
||||
} else if (status === MeetingStatus.CONCLUDED) {
|
||||
return '会议已结束,输入新议题开始新会议...';
|
||||
} else if (status === MeetingStatus.STARTING) {
|
||||
return '正在召集会议成员...';
|
||||
} else if (status === MeetingStatus.DISCUSSING || status === MeetingStatus.SPEAKING) {
|
||||
return '随时输入您的观点参与讨论...';
|
||||
}
|
||||
return '会议进行中...';
|
||||
return '输入您的观点...';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -352,11 +356,7 @@ const MeetingRoom = ({ user, onToast }) => {
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder={getPlaceholder()}
|
||||
isDisabled={
|
||||
isLoading ||
|
||||
status === MeetingStatus.DISCUSSING ||
|
||||
status === MeetingStatus.SPEAKING
|
||||
}
|
||||
isDisabled={status === MeetingStatus.STARTING}
|
||||
size="lg"
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
border="1px solid"
|
||||
@@ -378,13 +378,11 @@ const MeetingRoom = ({ user, onToast }) => {
|
||||
>
|
||||
<IconButton
|
||||
size="lg"
|
||||
icon={isLoading ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
||||
icon={isLoading && status === MeetingStatus.STARTING ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
||||
onClick={handleSend}
|
||||
isDisabled={
|
||||
!inputValue.trim() ||
|
||||
isLoading ||
|
||||
status === MeetingStatus.DISCUSSING ||
|
||||
status === MeetingStatus.SPEAKING
|
||||
status === MeetingStatus.STARTING
|
||||
}
|
||||
bgGradient="linear(to-r, orange.400, red.500)"
|
||||
color="white"
|
||||
@@ -427,9 +425,11 @@ const MeetingRoom = ({ user, onToast }) => {
|
||||
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{status === MeetingStatus.WAITING_INPUT && (
|
||||
{(status === MeetingStatus.WAITING_INPUT ||
|
||||
status === MeetingStatus.DISCUSSING ||
|
||||
status === MeetingStatus.SPEAKING) && (
|
||||
<Text color="orange.400">
|
||||
💡 您可以插话参与讨论,或点击继续按钮进行下一轮
|
||||
💡 随时输入观点参与讨论,您的发言会影响分析师的判断
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
@@ -39,6 +39,26 @@ export interface MeetingRoleConfig {
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具调用结果接口
|
||||
*/
|
||||
export interface ToolCallResult {
|
||||
/** 工具调用 ID */
|
||||
tool_call_id: string;
|
||||
/** 工具名称 */
|
||||
tool_name: string;
|
||||
/** 工具参数 */
|
||||
arguments?: Record<string, any>;
|
||||
/** 调用状态 */
|
||||
status: 'calling' | 'success' | 'error';
|
||||
/** 调用结果 */
|
||||
result?: any;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 执行时间(秒) */
|
||||
execution_time?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议消息接口
|
||||
*/
|
||||
@@ -63,6 +83,10 @@ export interface MeetingMessage {
|
||||
round_number: number;
|
||||
/** 是否为结论 */
|
||||
is_conclusion?: boolean;
|
||||
/** 工具调用列表 */
|
||||
tool_calls?: ToolCallResult[];
|
||||
/** 是否正在流式输出 */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,8 +233,12 @@ export type MeetingEventType =
|
||||
| 'session_start'
|
||||
| 'order_decided'
|
||||
| 'speaking_start'
|
||||
| 'message'
|
||||
| 'meeting_end';
|
||||
| 'tool_call_start'
|
||||
| 'tool_call_result'
|
||||
| 'content_delta'
|
||||
| 'message_complete'
|
||||
| 'round_end'
|
||||
| 'error';
|
||||
|
||||
/**
|
||||
* SSE 事件接口
|
||||
@@ -224,4 +252,15 @@ export interface MeetingEvent {
|
||||
message?: MeetingMessage;
|
||||
is_concluded?: boolean;
|
||||
round_number?: number;
|
||||
/** 工具调用相关 */
|
||||
tool_call_id?: string;
|
||||
tool_name?: string;
|
||||
arguments?: Record<string, any>;
|
||||
result?: any;
|
||||
status?: string;
|
||||
execution_time?: number;
|
||||
/** 流式内容 */
|
||||
content?: string;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/views/AgentChat/hooks/useInvestmentMeeting.ts
|
||||
// 投研会议室 Hook - 管理会议状态、发送消息、处理 SSE 流
|
||||
// V2: 支持流式输出、工具调用展示、用户中途发言
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
MeetingStatus,
|
||||
MeetingEvent,
|
||||
MeetingResponse,
|
||||
ToolCallResult,
|
||||
getRoleConfig,
|
||||
} from '../constants/meetingRoles';
|
||||
|
||||
@@ -129,7 +131,129 @@ export const useInvestmentMeeting = ({
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 启动会议(使用流式 SSE)
|
||||
* 更新消息内容(用于流式输出)
|
||||
*/
|
||||
const updateMessageContent = useCallback((roleId: string, content: string) => {
|
||||
setMessages((prev) => {
|
||||
const lastIndex = prev.findIndex(
|
||||
(m) => m.role_id === roleId && m.isStreaming
|
||||
);
|
||||
if (lastIndex >= 0) {
|
||||
const newMessages = [...prev];
|
||||
newMessages[lastIndex] = {
|
||||
...newMessages[lastIndex],
|
||||
content: newMessages[lastIndex].content + content,
|
||||
};
|
||||
return newMessages;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 添加工具调用到消息
|
||||
*/
|
||||
const addToolCallToMessage = useCallback(
|
||||
(roleId: string, toolCall: ToolCallResult) => {
|
||||
setMessages((prev) => {
|
||||
const lastIndex = prev.findIndex(
|
||||
(m) => m.role_id === roleId && m.isStreaming
|
||||
);
|
||||
if (lastIndex >= 0) {
|
||||
const newMessages = [...prev];
|
||||
const existingToolCalls = newMessages[lastIndex].tool_calls || [];
|
||||
newMessages[lastIndex] = {
|
||||
...newMessages[lastIndex],
|
||||
tool_calls: [...existingToolCalls, toolCall],
|
||||
};
|
||||
return newMessages;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 更新工具调用结果
|
||||
*/
|
||||
const updateToolCallResult = useCallback(
|
||||
(roleId: string, toolCallId: string, result: any, status: string, executionTime?: number) => {
|
||||
setMessages((prev) => {
|
||||
const lastIndex = prev.findIndex(
|
||||
(m) => m.role_id === roleId && m.isStreaming
|
||||
);
|
||||
if (lastIndex >= 0) {
|
||||
const newMessages = [...prev];
|
||||
const toolCalls = newMessages[lastIndex].tool_calls || [];
|
||||
const toolIndex = toolCalls.findIndex((t) => t.tool_call_id === toolCallId);
|
||||
if (toolIndex >= 0) {
|
||||
const newToolCalls = [...toolCalls];
|
||||
newToolCalls[toolIndex] = {
|
||||
...newToolCalls[toolIndex],
|
||||
result,
|
||||
status: status as 'success' | 'error',
|
||||
execution_time: executionTime,
|
||||
};
|
||||
newMessages[lastIndex] = {
|
||||
...newMessages[lastIndex],
|
||||
tool_calls: newToolCalls,
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 完成消息流式输出
|
||||
*/
|
||||
const finishStreamingMessage = useCallback((roleId: string, finalContent?: string) => {
|
||||
setMessages((prev) => {
|
||||
const lastIndex = prev.findIndex(
|
||||
(m) => m.role_id === roleId && m.isStreaming
|
||||
);
|
||||
if (lastIndex >= 0) {
|
||||
const newMessages = [...prev];
|
||||
newMessages[lastIndex] = {
|
||||
...newMessages[lastIndex],
|
||||
content: finalContent || newMessages[lastIndex].content,
|
||||
isStreaming: false,
|
||||
};
|
||||
return newMessages;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 创建流式消息占位
|
||||
*/
|
||||
const createStreamingMessage = useCallback(
|
||||
(roleId: string, roleName: string, roundNumber: number): MeetingMessage => {
|
||||
const roleConfig = getRoleConfig(roleId);
|
||||
return {
|
||||
id: `${roleId}-${Date.now()}`,
|
||||
role_id: roleId,
|
||||
role_name: roleName,
|
||||
nickname: roleConfig?.nickname || roleName,
|
||||
avatar: roleConfig?.avatar || '',
|
||||
color: roleConfig?.color || '#6366F1',
|
||||
content: '',
|
||||
timestamp: new Date().toISOString(),
|
||||
round_number: roundNumber,
|
||||
tool_calls: [],
|
||||
isStreaming: true,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 启动会议(使用 POST + fetch 流式 SSE)
|
||||
*/
|
||||
const startMeetingStream = useCallback(
|
||||
async (topic: string) => {
|
||||
@@ -137,156 +261,189 @@ export const useInvestmentMeeting = ({
|
||||
setStatus(MeetingStatus.STARTING);
|
||||
setIsLoading(true);
|
||||
setMessages([]);
|
||||
setCurrentRound(1);
|
||||
|
||||
try {
|
||||
// 使用 EventSource 进行 SSE 连接
|
||||
const params = new URLSearchParams({
|
||||
topic,
|
||||
user_id: userId,
|
||||
user_nickname: userNickname,
|
||||
});
|
||||
|
||||
const eventSource = new EventSource(
|
||||
`/mcp/agent/meeting/stream?${params.toString()}`
|
||||
);
|
||||
eventSourceRef.current = eventSource;
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: MeetingEvent = JSON.parse(event.data);
|
||||
|
||||
switch (data.type) {
|
||||
case 'session_start':
|
||||
setSessionId(data.session_id || null);
|
||||
setStatus(MeetingStatus.DISCUSSING);
|
||||
break;
|
||||
|
||||
case 'order_decided':
|
||||
// 发言顺序已决定
|
||||
break;
|
||||
|
||||
case 'speaking_start':
|
||||
setSpeakingRoleId(data.role_id || null);
|
||||
setStatus(MeetingStatus.SPEAKING);
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
if (data.message) {
|
||||
addMessage(data.message);
|
||||
setSpeakingRoleId(null);
|
||||
|
||||
// 检查是否是结论
|
||||
if (data.message.is_conclusion) {
|
||||
setConclusion(data.message);
|
||||
setIsConcluded(true);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'meeting_end':
|
||||
setCurrentRound(data.round_number || 1);
|
||||
setIsConcluded(data.is_concluded || false);
|
||||
setStatus(
|
||||
data.is_concluded
|
||||
? MeetingStatus.CONCLUDED
|
||||
: MeetingStatus.WAITING_INPUT
|
||||
);
|
||||
setIsLoading(false);
|
||||
eventSource.close();
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 事件失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE 连接错误:', error);
|
||||
eventSource.close();
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '连接失败',
|
||||
description: '会议连接中断,请重试',
|
||||
status: 'error',
|
||||
});
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('启动会议失败:', error);
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '启动会议失败',
|
||||
description: '请稍后重试',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[userId, userNickname, addMessage, onToast]
|
||||
);
|
||||
|
||||
/**
|
||||
* 启动会议(非流式,获取完整响应)
|
||||
*/
|
||||
const startMeeting = useCallback(
|
||||
async (topic: string) => {
|
||||
setCurrentTopic(topic);
|
||||
setStatus(MeetingStatus.STARTING);
|
||||
setIsLoading(true);
|
||||
setMessages([]);
|
||||
|
||||
try {
|
||||
const response = await axios.post<MeetingResponse>(
|
||||
'/mcp/agent/meeting/start',
|
||||
{
|
||||
// 使用 fetch 进行 POST 请求的 SSE
|
||||
const response = await fetch('/mcp/agent/meeting/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topic,
|
||||
user_id: userId,
|
||||
user_nickname: userNickname,
|
||||
conversation_history: [],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data: MeetingEvent = JSON.parse(line.slice(6));
|
||||
handleSSEEvent(data, 1);
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e, line);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (response.data.success) {
|
||||
const data = response.data;
|
||||
const handleSSEEvent = (data: MeetingEvent, roundNum: number) => {
|
||||
switch (data.type) {
|
||||
case 'session_start':
|
||||
setSessionId(data.session_id || null);
|
||||
setStatus(MeetingStatus.DISCUSSING);
|
||||
break;
|
||||
|
||||
setSessionId(data.session_id);
|
||||
setCurrentRound(data.round_number);
|
||||
setIsConcluded(data.is_concluded);
|
||||
case 'order_decided':
|
||||
// 发言顺序已决定,可以显示提示
|
||||
break;
|
||||
|
||||
// 添加所有消息
|
||||
data.messages.forEach((msg) => {
|
||||
addMessage(msg);
|
||||
});
|
||||
case 'speaking_start':
|
||||
setSpeakingRoleId(data.role_id || null);
|
||||
setStatus(MeetingStatus.SPEAKING);
|
||||
// 创建流式消息占位
|
||||
if (data.role_id && data.role_name) {
|
||||
const streamingMsg = createStreamingMessage(
|
||||
data.role_id,
|
||||
data.role_name,
|
||||
roundNum
|
||||
);
|
||||
addMessage(streamingMsg);
|
||||
}
|
||||
break;
|
||||
|
||||
// 设置结论
|
||||
if (data.conclusion) {
|
||||
setConclusion(data.conclusion);
|
||||
case 'tool_call_start':
|
||||
if (data.role_id && data.tool_call_id && data.tool_name) {
|
||||
const toolCall: ToolCallResult = {
|
||||
tool_call_id: data.tool_call_id,
|
||||
tool_name: data.tool_name,
|
||||
arguments: data.arguments,
|
||||
status: 'calling',
|
||||
};
|
||||
addToolCallToMessage(data.role_id, toolCall);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_call_result':
|
||||
if (data.role_id && data.tool_call_id) {
|
||||
updateToolCallResult(
|
||||
data.role_id,
|
||||
data.tool_call_id,
|
||||
data.result,
|
||||
data.status || 'success',
|
||||
data.execution_time
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content_delta':
|
||||
if (data.role_id && data.content) {
|
||||
updateMessageContent(data.role_id, data.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_complete':
|
||||
if (data.role_id) {
|
||||
finishStreamingMessage(data.role_id, data.content);
|
||||
setSpeakingRoleId(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'round_end':
|
||||
setCurrentRound(data.round_number || 1);
|
||||
setIsConcluded(data.is_concluded || false);
|
||||
setStatus(
|
||||
data.is_concluded
|
||||
? MeetingStatus.CONCLUDED
|
||||
: MeetingStatus.WAITING_INPUT
|
||||
);
|
||||
setIsLoading(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('会议错误:', data.error);
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '会议出错',
|
||||
description: data.error || '未知错误',
|
||||
status: 'error',
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
setStatus(
|
||||
data.is_concluded
|
||||
? MeetingStatus.CONCLUDED
|
||||
: MeetingStatus.WAITING_INPUT
|
||||
);
|
||||
} else {
|
||||
throw new Error('会议启动失败');
|
||||
// 读取流
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余 buffer
|
||||
if (buffer.trim()) {
|
||||
processLine(buffer);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('启动会议失败:', error);
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '启动会议失败',
|
||||
description: error.response?.data?.detail || error.message,
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[userId, userNickname, addMessage, onToast]
|
||||
[
|
||||
userId,
|
||||
userNickname,
|
||||
addMessage,
|
||||
createStreamingMessage,
|
||||
addToolCallToMessage,
|
||||
updateToolCallResult,
|
||||
updateMessageContent,
|
||||
finishStreamingMessage,
|
||||
onToast,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* 继续会议讨论
|
||||
* 启动会议(默认使用流式)
|
||||
*/
|
||||
const startMeeting = useCallback(
|
||||
async (topic: string) => {
|
||||
// 使用流式版本
|
||||
await startMeetingStream(topic);
|
||||
},
|
||||
[startMeetingStream]
|
||||
);
|
||||
|
||||
/**
|
||||
* 继续会议讨论(使用流式)
|
||||
*/
|
||||
const continueMeeting = useCallback(
|
||||
async (userMessage?: string) => {
|
||||
@@ -301,55 +458,184 @@ export const useInvestmentMeeting = ({
|
||||
|
||||
setStatus(MeetingStatus.DISCUSSING);
|
||||
setIsLoading(true);
|
||||
const nextRound = currentRound + 1;
|
||||
setCurrentRound(nextRound);
|
||||
|
||||
try {
|
||||
const response = await axios.post<MeetingResponse>(
|
||||
'/mcp/agent/meeting/continue',
|
||||
{
|
||||
// 构建会话历史(排除正在流式传输的消息)
|
||||
const historyMessages = messages
|
||||
.filter((m) => !m.isStreaming)
|
||||
.map((m) => ({
|
||||
role_id: m.role_id,
|
||||
role_name: m.role_name,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// 使用 fetch 进行 POST 请求的 SSE
|
||||
const response = await fetch('/mcp/agent/meeting/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
topic: currentTopic,
|
||||
user_id: userId,
|
||||
user_nickname: userNickname,
|
||||
session_id: sessionId,
|
||||
user_message: userMessage,
|
||||
conversation_history: messages,
|
||||
conversation_history: historyMessages,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data: MeetingEvent = JSON.parse(line.slice(6));
|
||||
handleSSEEvent(data);
|
||||
} catch (e) {
|
||||
console.error('解析 SSE 数据失败:', e, line);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (response.data.success) {
|
||||
const data = response.data;
|
||||
const handleSSEEvent = (data: MeetingEvent) => {
|
||||
switch (data.type) {
|
||||
case 'session_start':
|
||||
setSessionId(data.session_id || null);
|
||||
break;
|
||||
|
||||
setCurrentRound(data.round_number);
|
||||
setIsConcluded(data.is_concluded);
|
||||
case 'speaking_start':
|
||||
setSpeakingRoleId(data.role_id || null);
|
||||
setStatus(MeetingStatus.SPEAKING);
|
||||
if (data.role_id && data.role_name) {
|
||||
const streamingMsg = createStreamingMessage(
|
||||
data.role_id,
|
||||
data.role_name,
|
||||
nextRound
|
||||
);
|
||||
addMessage(streamingMsg);
|
||||
}
|
||||
break;
|
||||
|
||||
// 添加新的消息
|
||||
data.messages.forEach((msg) => {
|
||||
addMessage(msg);
|
||||
});
|
||||
case 'tool_call_start':
|
||||
if (data.role_id && data.tool_call_id && data.tool_name) {
|
||||
const toolCall: ToolCallResult = {
|
||||
tool_call_id: data.tool_call_id,
|
||||
tool_name: data.tool_name,
|
||||
arguments: data.arguments,
|
||||
status: 'calling',
|
||||
};
|
||||
addToolCallToMessage(data.role_id, toolCall);
|
||||
}
|
||||
break;
|
||||
|
||||
// 设置结论
|
||||
if (data.conclusion) {
|
||||
setConclusion(data.conclusion);
|
||||
case 'tool_call_result':
|
||||
if (data.role_id && data.tool_call_id) {
|
||||
updateToolCallResult(
|
||||
data.role_id,
|
||||
data.tool_call_id,
|
||||
data.result,
|
||||
data.status || 'success',
|
||||
data.execution_time
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'content_delta':
|
||||
if (data.role_id && data.content) {
|
||||
updateMessageContent(data.role_id, data.content);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_complete':
|
||||
if (data.role_id) {
|
||||
finishStreamingMessage(data.role_id, data.content);
|
||||
setSpeakingRoleId(null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'round_end':
|
||||
setCurrentRound(data.round_number || nextRound);
|
||||
setIsConcluded(data.is_concluded || false);
|
||||
setStatus(
|
||||
data.is_concluded
|
||||
? MeetingStatus.CONCLUDED
|
||||
: MeetingStatus.WAITING_INPUT
|
||||
);
|
||||
setIsLoading(false);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error('会议错误:', data.error);
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '会议出错',
|
||||
description: data.error || '未知错误',
|
||||
status: 'error',
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
setStatus(
|
||||
data.is_concluded
|
||||
? MeetingStatus.CONCLUDED
|
||||
: MeetingStatus.WAITING_INPUT
|
||||
);
|
||||
// 读取流
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
processLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理剩余 buffer
|
||||
if (buffer.trim()) {
|
||||
processLine(buffer);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('继续会议失败:', error);
|
||||
setStatus(MeetingStatus.ERROR);
|
||||
setIsLoading(false);
|
||||
onToast?.({
|
||||
title: '继续会议失败',
|
||||
description: error.response?.data?.detail || error.message,
|
||||
description: error.message || '请稍后重试',
|
||||
status: 'error',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[currentTopic, userId, userNickname, sessionId, messages, addMessage, onToast]
|
||||
[
|
||||
currentTopic,
|
||||
userId,
|
||||
userNickname,
|
||||
sessionId,
|
||||
messages,
|
||||
currentRound,
|
||||
addMessage,
|
||||
createStreamingMessage,
|
||||
addToolCallToMessage,
|
||||
updateToolCallResult,
|
||||
updateMessageContent,
|
||||
finishStreamingMessage,
|
||||
onToast,
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user