update pay function

This commit is contained in:
2025-11-28 15:32:03 +08:00
parent 9b7a221315
commit 8cf2850660
6 changed files with 1278 additions and 666 deletions

View File

@@ -434,4 +434,157 @@ export const agentHandlers = [
conclusion: messages[messages.length - 1],
});
}),
// POST /mcp/agent/meeting/stream - 流式会议接口V2
http.post('/mcp/agent/meeting/stream', async ({ request }) => {
const body = await request.json();
const { topic, user_id } = body;
const sessionId = `meeting-${Date.now()}`;
// 定义会议角色和他们的消息
const roleMessages = [
{
role_id: 'buffett',
role_name: '巴菲特',
content: `关于「${topic}」,我认为这里存在显著的投资机会。从价值投资的角度看,我们应该关注以下几点:\n\n1. **长期价值**:该标的具有较强的护城河\n2. **盈利能力**ROE持续保持在较高水平\n3. **管理层质量**:管理团队稳定且执行力强\n\n我的观点是**看多**,建议逢低布局。`,
tools: [
{ name: 'search_china_news', result: { articles: [{ title: '相关新闻1' }, { title: '相关新闻2' }] } },
{ name: 'get_stock_basic_info', result: { pe: 25.6, pb: 3.2, roe: 18.5 } },
],
},
{
role_id: 'big_short',
role_name: '大空头',
content: `等等,让我泼点冷水。关于「${topic}」,市场似乎过于乐观了:\n\n⚠️ **风险提示**\n1. 当前估值处于历史高位,安全边际不足\n2. 行业竞争加剧,利润率面临压力\n3. 宏观环境不确定性增加\n\n建议投资者**保持谨慎**,不要追高。`,
tools: [
{ name: 'get_stock_financial_index', result: { debt_ratio: 45.2, current_ratio: 1.8 } },
],
},
{
role_id: 'simons',
role_name: '量化分析员',
content: `从量化角度分析「${topic}」:\n\n📊 **技术指标**\n- MACD金叉形态动能向上\n- RSI58处于中性区域\n- 均线5日>10日>20日多头排列\n\n📈 **资金面**\n- 主力资金近5日净流入2.3亿\n- 北向资金:持续加仓\n\n**结论**短期技术面偏多但需关注60日均线支撑。`,
tools: [
{ name: 'get_stock_trade_data', result: { volume: 1234567, turnover: 5.2 } },
{ name: 'get_concept_statistics', result: { concepts: ['AI概念', '半导体'], avg_change: 2.3 } },
],
},
{
role_id: 'leek',
role_name: '韭菜',
content: `哇!「${topic}」看起来要涨啊!\n\n🚀 我觉得必须满仓干!隔壁老王都赚翻了!\n\n不过话说回来...万一跌了怎么办?会不会套住?\n\n算了不管了,先冲一把再说!错过这村就没这店了!\n\n内心OS希望别当接盘侠...`,
tools: [], // 韭菜不用工具
},
{
role_id: 'fund_manager',
role_name: '基金经理',
content: `## 投资建议总结\n\n综合各方观点,对于「${topic}」,我的判断如下:\n\n### 综合评估\n多空双方都提出了有价值的观点。技术面短期偏多,但估值确实需要关注。\n\n### 关键观点\n- ✅ 基本面优质,长期价值明确\n- ⚠️ 短期估值偏高,需要耐心等待\n- 📊 技术面处于上升趋势\n\n### 风险提示\n注意仓位控制,避免追高\n\n### 操作建议\n**观望为主**,等待回调至支撑位再考虑建仓\n\n### 信心指数7/10`,
tools: [
{ name: 'search_research_reports', result: { reports: [{ title: '深度研报1' }] } },
],
is_conclusion: true,
},
];
// 创建 SSE 流
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 发送 session_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'session_start',
session_id: sessionId,
})}\n\n`));
await delay(300);
// 发送 order_decided
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'order_decided',
order: roleMessages.map(r => r.role_id),
})}\n\n`));
await delay(300);
// 依次发送每个角色的消息
for (const role of roleMessages) {
// speaking_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'speaking_start',
role_id: role.role_id,
role_name: role.role_name,
})}\n\n`));
await delay(200);
// 发送工具调用
for (const tool of role.tools) {
const toolCallId = `tc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// tool_call_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_call_start',
role_id: role.role_id,
tool_call_id: toolCallId,
tool_name: tool.name,
arguments: {},
})}\n\n`));
await delay(500);
// tool_call_result
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_call_result',
role_id: role.role_id,
tool_call_id: toolCallId,
result: tool.result,
status: 'success',
execution_time: 0.5 + Math.random() * 0.5,
})}\n\n`));
await delay(200);
}
// 流式发送内容
const chunks = role.content.match(/.{1,20}/g) || [];
for (const chunk of chunks) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'content_delta',
role_id: role.role_id,
content: chunk,
})}\n\n`));
await delay(30);
}
// message_complete
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'message_complete',
role_id: role.role_id,
content: role.content,
is_conclusion: role.is_conclusion || false,
})}\n\n`));
await delay(500);
}
// round_end
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'round_end',
round_number: 1,
is_concluded: false,
})}\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}),
];

View File

@@ -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>
{/* 操作按钮 */}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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,
]
);
/**