From 8cf2850660b54b75d037a240c0c93d97de30b3be Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Fri, 28 Nov 2025 15:32:03 +0800 Subject: [PATCH] update pay function --- mcp_server.py | 833 ++++++++---------- src/mocks/handlers/agent.js | 153 ++++ .../MeetingRoom/MeetingMessageBubble.js | 283 +++++- .../AgentChat/components/MeetingRoom/index.js | 44 +- src/views/AgentChat/constants/meetingRoles.ts | 43 +- .../AgentChat/hooks/useInvestmentMeeting.ts | 588 +++++++++---- 6 files changed, 1278 insertions(+), 666 deletions(-) diff --git a/mcp_server.py b/mcp_server.py index 51814890..d1a30147 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -2337,7 +2337,37 @@ async def search_chat_history(user_id: str, query: str, top_k: int = 10): raise HTTPException(status_code=500, detail=str(e)) -# ==================== 投研会议室系统 ==================== +# ==================== 投研会议室系统 (V2 - 流式+工具调用) ==================== + +import random + +# 投研会议室专用模型配置 +MEETING_MODEL_CONFIGS = { + "kimi-k2-thinking": { + "api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5", + "base_url": "https://api.moonshot.cn/v1", + "model": "kimi-k2-thinking", + }, + "deepseek": { + "api_key": "sk-7363bdb28d7d4bf0aa68eb9449f8f063", + "base_url": "https://api.deepseek.com", + "model": "deepseek-chat", + }, + "deepmoney": { + "api_key": "", + "base_url": "http://111.62.35.50:8000/v1", + "model": "deepmoney", + }, +} + +# 每个角色可用的工具列表 +ROLE_TOOLS = { + "buffett": ["search_china_news", "search_research_reports", "get_stock_basic_info", "get_stock_financial_index"], + "big_short": ["search_china_news", "get_stock_financial_index", "get_stock_balance_sheet", "get_stock_cashflow"], + "simons": ["get_stock_trade_data", "search_limit_up_stocks", "get_concept_statistics"], + "leek": [], # 韭菜不用工具 + "fund_manager": ["search_china_news", "search_research_reports", "get_stock_basic_info"], +} # 投研会议室角色配置 MEETING_ROLES = { @@ -2345,508 +2375,435 @@ MEETING_ROLES = { "id": "buffett", "name": "巴菲特", "nickname": "唱多者", - "role_type": "bull", # 多头 + "role_type": "bull", "avatar": "/avatars/buffett.png", "model": "kimi-k2-thinking", - "color": "#10B981", # 绿色(上涨) + "color": "#10B981", "description": "主观多头,善于分析事件的潜在利好和长期价值", + "tools": ROLE_TOOLS["buffett"], "system_prompt": """你是"巴菲特",一位资深的价值投资者和主观多头分析师。 你的特点: 1. 善于发现事件和公司的潜在利好因素 -2. 关注长期价值,不被短期波动干扰 -3. 分析公司的护城河、竞争优势和管理层质量 -4. 对市场保持乐观但理性的态度 +2. 关注长期价值,分析护城河、竞争优势 +3. 对市场保持乐观但理性的态度 -分析风格: -- 重点挖掘利好因素和投资机会 -- 从产业链、市场格局、政策支持等角度分析 -- 给出清晰的看多逻辑和目标预期 -- 语言风格:稳重、专业、富有洞察力 +你可以使用以下工具获取数据: +- search_china_news: 搜索新闻 +- search_research_reports: 搜索研报 +- get_stock_basic_info: 获取股票基本信息 +- get_stock_financial_index: 获取财务指标 -注意:你的发言要简洁有力,每次发言控制在200字以内。直接表达观点,不要客套。""" +分析时请先调用工具获取数据,再基于数据发表看多观点。 +注意:参考前面其他人的发言,进行有针对性的回应。发言控制在200字以内。""" }, "big_short": { "id": "big_short", "name": "大空头", "nickname": "大空头", - "role_type": "bear", # 空头 + "role_type": "bear", "avatar": "/avatars/big_short.png", "model": "kimi-k2-thinking", - "color": "#EF4444", # 红色(下跌) - "description": "善于分析事件和财报中的风险因素,帮助投资者避雷", - "system_prompt": """你是"大空头",一位专业的风险分析师和空头研究员。 + "color": "#EF4444", + "description": "善于分析事件和财报中的风险因素", + "tools": ROLE_TOOLS["big_short"], + "system_prompt": """你是"大空头",一位专业的风险分析师。 你的特点: 1. 善于发现被市场忽视的风险因素 -2. 擅长财报分析,发现财务造假和粉饰的迹象 -3. 关注行业天花板、竞争加剧、估值泡沫等问题 -4. 对市场保持警惕,帮助投资者避雷 +2. 擅长财报分析,发现财务造假迹象 +3. 关注行业天花板、竞争加剧、估值泡沫 -分析风格: -- 重点挖掘风险因素和潜在隐患 -- 从财务数据、行业周期、估值水平等角度分析 -- 给出清晰的风险提示和规避建议 -- 语言风格:犀利、直接、善于质疑 +你可以使用以下工具获取数据: +- search_china_news: 搜索负面新闻 +- get_stock_financial_index: 获取财务指标找问题 +- get_stock_balance_sheet: 分析资产负债表 +- get_stock_cashflow: 分析现金流 -注意:你的发言要简洁有力,每次发言控制在200字以内。直接指出风险,不要绕弯子。""" +分析时请先调用工具获取数据,再基于数据指出风险。 +注意:参考前面其他人的发言,进行有针对性的反驳。发言控制在200字以内。""" }, "simons": { "id": "simons", "name": "量化分析员", "nickname": "西蒙斯", - "role_type": "quant", # 量化 + "role_type": "quant", "avatar": "/avatars/simons.png", - "model": "deepseek-v3", - "color": "#3B82F6", # 蓝色(中性) - "description": "中性立场,使用量化分析工具分析技术指标", + "model": "deepseek", + "color": "#3B82F6", + "description": "中性立场,使用量化工具分析技术指标", + "tools": ROLE_TOOLS["simons"], "system_prompt": """你是"量化分析员"(昵称:西蒙斯),一位专业的量化交易研究员。 你的特点: 1. 使用数据和技术指标说话,保持中性立场 -2. 擅长均线分析、量价关系、动能指标等技术分析 -3. 关注市场情绪、资金流向、筹码分布等量化因素 -4. 用概率思维看待市场,不做主观臆断 +2. 擅长均线、量价、动能指标分析 +3. 用概率思维看待市场 -分析风格: -- 基于技术指标给出客观分析 -- 使用具体数据支撑观点(如:5日均线、MACD、RSI等) -- 给出量化的买卖信号和风险评估 -- 语言风格:理性、客观、数据驱动 +你可以使用以下工具获取数据: +- get_stock_trade_data: 获取交易数据(价格、成交量) +- search_limit_up_stocks: 搜索涨停股票 +- get_concept_statistics: 获取概念板块统计 -注意:你的发言要简洁有力,每次发言控制在200字以内。多用数据说话,少发表主观意见。""" +分析时请先调用工具获取数据,再基于数据给出技术分析。 +注意:参考前面其他人的发言,用数据说话。发言控制在200字以内。""" }, "leek": { "id": "leek", "name": "韭菜", "nickname": "牢大", - "role_type": "retail", # 散户 + "role_type": "retail", "avatar": "/avatars/leek.png", "model": "deepmoney", - "color": "#F59E0B", # 黄色 - "description": "贪婪又讨厌亏损,热爱追涨杀跌的典型散户", + "color": "#F59E0B", + "description": "贪婪又讨厌亏损,热爱追涨杀跌", + "tools": [], "system_prompt": """你是"韭菜"(昵称:牢大),一个典型的散户投资者。 你的特点: -1. 贪婪但又害怕亏损,典型的追涨杀跌 -2. 容易被市场情绪影响,看到涨就想追,看到跌就想跑 -3. 喜欢听小道消息,容易被"内幕"吸引 -4. 短线思维,缺乏耐心,期望一夜暴富 +1. 贪婪但又害怕亏损,追涨杀跌 +2. 容易被市场情绪影响 +3. 喜欢听小道消息,期望一夜暴富 -分析风格: -- 用最朴素的散户思维来分析问题 -- 经常关注"这个能赚多少"、"会不会跌" -- 容易情绪化,看涨时过度乐观,看跌时过度悲观 -- 语言风格:口语化、情绪化、接地气 - -注意:你的发言要简洁直接,每次发言控制在150字以内。展现真实散户的心态,可以有些搞笑,但不要太出格。""" +你不需要调用工具,直接用散户视角发表看法。 +注意:参考前面其他人的发言,用最朴素的方式回应。语言口语化、情绪化。发言控制在150字以内。""" }, "fund_manager": { "id": "fund_manager", "name": "基金经理", "nickname": "决策者", - "role_type": "manager", # 管理者 + "role_type": "manager", "avatar": "/avatars/fund_manager.png", "model": "kimi-k2-thinking", - "color": "#8B5CF6", # 紫色 - "description": "总结其他人的发言做出最终决策", - "system_prompt": """你是"基金经理",投研会议的主持人和最终决策者。 + "color": "#8B5CF6", + "description": "综合分析做出最终决策", + "tools": ROLE_TOOLS["fund_manager"], + "system_prompt": """你是"基金经理",投研会议的最终决策者。 你的角色: 1. 综合各方观点,做出理性判断 2. 平衡多空观点,识别有价值的分析 -3. 特别注意:韭菜的观点通常是反向指标 -4. 给出专业、负责任的投资建议 +3. 注意:韭菜的观点通常是反向指标 -决策风格: -- 综合考虑基本面、技术面、情绪面 -- 权衡风险与收益,给出明确的投资建议 -- 指出讨论中的关键洞察和需要注意的风险 -- 语言风格:权威、专业、全面 +如果需要补充信息,可以调用工具: +- search_china_news: 搜索新闻 +- search_research_reports: 搜索研报 +- get_stock_basic_info: 获取股票基本信息 决策输出格式: -1. 综合评估:对讨论议题的整体判断 -2. 关键观点:各方有价值的观点总结 -3. 风险提示:需要注意的主要风险 -4. 操作建议:具体的投资建议(买入/持有/观望/卖出) -5. 信心指数:对这个结论的信心程度(1-10分) +1. 综合评估 +2. 关键观点 +3. 风险提示 +4. 操作建议(买入/持有/观望/卖出) +5. 信心指数(1-10分) -注意:如果讨论还不够充分,你可以要求继续讨论。每次发言控制在300字以内。""" +参考前面所有人的发言,给出综合判断。发言控制在300字以内。""" } } -# 投研会议室专用模型配置(扩展现有配置) -MEETING_MODEL_CONFIGS = { - **MODEL_CONFIGS, - "deepseek-v3": { - "api_key": "sk-1cf3dfadf7244a8680cd0a60da6f1efd", - "base_url": "https://api.deepseek.com/v1", - "model": "deepseek-chat", - } -} - - -class MeetingRoleMessage(BaseModel): - """会议角色消息""" - role_id: str - role_name: str - nickname: str - avatar: str - color: str - content: str - timestamp: str - round_number: int # 第几轮讨论 - class MeetingRequest(BaseModel): """投研会议请求""" - topic: str # 用户提出的议题 + topic: str user_id: str = "anonymous" user_nickname: str = "匿名用户" session_id: Optional[str] = None - user_message: Optional[str] = None # 用户在讨论中的插话 - conversation_history: List[Dict[str, Any]] = [] # 之前的讨论历史 + user_message: Optional[str] = None + conversation_history: List[Dict[str, Any]] = [] -class MeetingResponse(BaseModel): - """投研会议响应""" - success: bool - session_id: str - messages: List[Dict[str, Any]] # 本轮所有角色的发言 - round_number: int # 当前轮次 - is_concluded: bool # 是否已得出结论 - conclusion: Optional[Dict[str, Any]] = None # 基金经理的结论(如果有) +def get_random_speaking_order() -> List[str]: + """随机生成发言顺序(不包括基金经理)""" + roles = ["buffett", "big_short", "simons", "leek"] + random.shuffle(roles) + return roles -async def call_role_llm(role_id: str, prompt: str, context: str = "") -> str: - """调用特定角色的LLM生成回复""" +async def call_role_tool(role_id: str, tool_name: str, arguments: dict) -> dict: + """调用角色的工具""" + handler = TOOL_HANDLERS.get(tool_name) + if not handler: + return {"success": False, "error": f"Unknown tool: {tool_name}"} + + try: + result = await handler(arguments) + return {"success": True, "tool": tool_name, "result": result} + except Exception as e: + logger.error(f"Tool {tool_name} failed: {e}") + return {"success": False, "tool": tool_name, "error": str(e)} + + +async def stream_role_response( + role_id: str, + topic: str, + context: str, + tools: List[dict] +) -> AsyncGenerator[dict, None]: + """流式生成角色回复,支持工具调用""" role = MEETING_ROLES.get(role_id) if not role: - raise ValueError(f"Unknown role: {role_id}") + yield {"type": "error", "error": f"Unknown role: {role_id}"} + return model_name = role["model"] - model_config = MEETING_MODEL_CONFIGS.get(model_name, MODEL_CONFIGS["kimi-k2-thinking"]) + model_config = MEETING_MODEL_CONFIGS.get(model_name) + if not model_config: + yield {"type": "error", "error": f"Unknown model: {model_name}"} + return try: client = OpenAI( api_key=model_config["api_key"], - base_url=model_config["base_url"] + base_url=model_config["base_url"], + timeout=180 ) messages = [ {"role": "system", "content": role["system_prompt"]}, + {"role": "user", "content": f"议题:{topic}\n\n{context}"} ] - if context: - messages.append({"role": "user", "content": f"当前讨论背景:\n{context}"}) + # 准备工具定义(如果该角色有工具) + role_tool_names = role.get("tools", []) + openai_tools = None + if role_tool_names: + openai_tools = [] + for tool in TOOLS: + if tool.name in role_tool_names: + openai_tools.append({ + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters + } + }) - messages.append({"role": "user", "content": prompt}) + # 第一次调用:可能触发工具调用 + tool_calls_made = [] + if openai_tools: + response = client.chat.completions.create( + model=model_config["model"], + messages=messages, + tools=openai_tools, + tool_choice="auto", + stream=False, # 工具调用不使用流式 + temperature=0.7, + max_tokens=1000, + ) - response = client.chat.completions.create( + assistant_message = response.choices[0].message + + # 处理工具调用 + if assistant_message.tool_calls: + messages.append(assistant_message) + + for tool_call in assistant_message.tool_calls: + tool_name = tool_call.function.name + try: + arguments = json.loads(tool_call.function.arguments) + except: + arguments = {} + + # 发送工具调用开始事件 + yield { + "type": "tool_call_start", + "tool": tool_name, + "arguments": arguments + } + + # 执行工具调用 + result = await call_role_tool(role_id, tool_name, arguments) + tool_calls_made.append(result) + + # 发送工具调用结果事件 + yield { + "type": "tool_call_result", + "tool": tool_name, + "result": result + } + + # 添加工具结果到消息 + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "content": json.dumps(result, ensure_ascii=False) + }) + + # 流式生成最终回复 + stream = client.chat.completions.create( model=model_config["model"], messages=messages, + stream=True, temperature=0.7, max_tokens=500, ) - return response.choices[0].message.content.strip() - - except Exception as e: - logger.error(f"调用角色 {role_id} 的 LLM 失败: {e}") - return f"[{role['name']}暂时无法发言,请稍后重试]" - - -async def determine_speaking_order(topic: str) -> List[str]: - """使用 K2 模型决定发言顺序""" - try: - client = OpenAI( - api_key=MODEL_CONFIGS["kimi-k2-thinking"]["api_key"], - base_url=MODEL_CONFIGS["kimi-k2-thinking"]["base_url"] - ) - - response = client.chat.completions.create( - model=MODEL_CONFIGS["kimi-k2-thinking"]["model"], - messages=[ - { - "role": "system", - "content": """你是一个会议主持助手。根据用户提出的议题,决定投研会议中各角色的最佳发言顺序。 - -可用角色(不包括基金经理,他最后总结): -- buffett: 巴菲特(主观多头,分析利好) -- big_short: 大空头(风险分析师) -- simons: 量化分析员(技术分析) -- leek: 韭菜(散户视角) - -根据议题性质,安排最合适的发言顺序。比如: -- 如果是分析某公司/事件,建议先让多头分析利好,再让空头分析风险 -- 如果是技术走势问题,可以先让量化分析 -- 韭菜可以随时插入,提供散户视角 - -只需要返回角色ID列表,用逗号分隔,例如:buffett,simons,big_short,leek""" - }, - {"role": "user", "content": f"议题:{topic}"} - ], - temperature=0.3, - max_tokens=100, - ) - - order_str = response.choices[0].message.content.strip() - # 解析返回的顺序 - order = [r.strip() for r in order_str.split(",") if r.strip() in MEETING_ROLES] - - # 确保所有非管理者角色都在列表中 - for role_id, role in MEETING_ROLES.items(): - if role["role_type"] != "manager" and role_id not in order: - order.append(role_id) - - return order - - except Exception as e: - logger.error(f"决定发言顺序失败: {e}") - # 返回默认顺序 - return ["buffett", "big_short", "simons", "leek"] - - -async def check_conclusion_ready(discussion_history: str, topic: str) -> tuple[bool, str]: - """基金经理判断是否可以得出结论""" - try: - client = OpenAI( - api_key=MODEL_CONFIGS["kimi-k2-thinking"]["api_key"], - base_url=MODEL_CONFIGS["kimi-k2-thinking"]["base_url"] - ) - - response = client.chat.completions.create( - model=MODEL_CONFIGS["kimi-k2-thinking"]["model"], - messages=[ - { - "role": "system", - "content": MEETING_ROLES["fund_manager"]["system_prompt"] - }, - { - "role": "user", - "content": f"""议题:{topic} - -目前的讨论内容: -{discussion_history} - -请判断: -1. 目前的讨论是否足够充分,可以得出最终结论? -2. 如果可以,请给出你的最终决策。 -3. 如果不可以,请说明还需要讨论什么,并要求继续讨论。 - -请以JSON格式回复: -{{ - "can_conclude": true/false, - "reasoning": "判断理由", - "conclusion": "如果可以结论,这里是你的完整决策;如果不能,这里是需要继续讨论的方向" -}}""" + full_content = "" + for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + content = chunk.choices[0].delta.content + full_content += content + yield { + "type": "content_delta", + "content": content } - ], - temperature=0.5, - max_tokens=800, - ) - result = response.choices[0].message.content.strip() - # 尝试解析JSON - try: - # 处理可能的 markdown 代码块 - if "```json" in result: - result = result.split("```json")[1].split("```")[0].strip() - elif "```" in result: - result = result.split("```")[1].split("```")[0].strip() - - data = json.loads(result) - return data.get("can_conclude", False), data.get("conclusion", result) - except json.JSONDecodeError: - # 如果JSON解析失败,直接返回内容 - return True, result + # 发送完成事件 + yield { + "type": "content_done", + "full_content": full_content, + "tool_calls": tool_calls_made + } except Exception as e: - logger.error(f"检查结论状态失败: {e}") - return True, "基于目前的讨论,建议投资者谨慎对待,继续关注后续发展。" + logger.error(f"Role {role_id} stream failed: {e}") + yield {"type": "error", "error": str(e)} -@app.post("/agent/meeting/start") -async def start_investment_meeting(request: MeetingRequest): +@app.post("/agent/meeting/stream") +async def stream_investment_meeting(request: MeetingRequest): """ - 启动投研会议 + 流式投研会议 V2 - 第一轮:所有角色(除基金经理外)依次发言 + - 随机发言顺序 + - 每个角色流式输出 + - 支持工具调用 + - 支持用户中途发言 """ - logger.info(f"启动投研会议: {request.topic} (user: {request.user_id})") + logger.info(f"[Meeting V2] 启动: {request.topic}") - session_id = request.session_id or str(uuid.uuid4()) - messages = [] - round_number = 1 + async def generate_meeting_stream() -> AsyncGenerator[str, None]: + session_id = request.session_id or str(uuid.uuid4()) + round_number = len(request.conversation_history) // 5 + 1 - # 决定发言顺序 - speaking_order = await determine_speaking_order(request.topic) - logger.info(f"发言顺序: {speaking_order}") + # 发送会话开始 + yield f"data: {json.dumps({'type': 'session_start', 'session_id': session_id, 'round': round_number}, ensure_ascii=False)}\n\n" - # 构建讨论上下文 - context = f"议题:{request.topic}\n\n这是第一轮讨论,请针对议题发表你的观点。" + # 构建上下文 + context_parts = [] + if request.conversation_history: + context_parts.append("之前的讨论:") + for msg in request.conversation_history: + context_parts.append(f"【{msg.get('role_name', '未知')}】:{msg.get('content', '')}") - # 依次让每个角色发言 - for role_id in speaking_order: - role = MEETING_ROLES[role_id] - if role["role_type"] == "manager": - continue # 基金经理不在第一轮发言 - - # 加入之前角色的发言作为上下文 - prev_context = context - if messages: - prev_context += "\n\n其他人的观点:\n" - for msg in messages: - prev_context += f"- {msg['role_name']}:{msg['content']}\n" - - # 调用LLM生成发言 - content = await call_role_llm(role_id, request.topic, prev_context) - - message = { - "role_id": role_id, - "role_name": role["name"], - "nickname": role["nickname"], - "avatar": role["avatar"], - "color": role["color"], - "content": content, - "timestamp": datetime.now().isoformat(), - "round_number": round_number - } - messages.append(message) - - # 第一轮结束后,基金经理判断是否可以得出结论 - discussion_summary = "\n".join([ - f"【{msg['role_name']}】:{msg['content']}" - for msg in messages - ]) - - can_conclude, conclusion_content = await check_conclusion_ready(discussion_summary, request.topic) - - # 添加基金经理的发言 - fund_manager = MEETING_ROLES["fund_manager"] - fund_manager_message = { - "role_id": "fund_manager", - "role_name": fund_manager["name"], - "nickname": fund_manager["nickname"], - "avatar": fund_manager["avatar"], - "color": fund_manager["color"], - "content": conclusion_content, - "timestamp": datetime.now().isoformat(), - "round_number": round_number, - "is_conclusion": can_conclude - } - messages.append(fund_manager_message) - - return { - "success": True, - "session_id": session_id, - "messages": messages, - "round_number": round_number, - "is_concluded": can_conclude, - "conclusion": fund_manager_message if can_conclude else None - } - - -@app.post("/agent/meeting/continue") -async def continue_investment_meeting(request: MeetingRequest): - """ - 继续投研会议讨论 - - 根据之前的讨论历史,继续新一轮讨论 - 支持用户在讨论中插话 - """ - logger.info(f"继续投研会议: {request.topic} (round: {len(request.conversation_history) // 5 + 1})") - - session_id = request.session_id or str(uuid.uuid4()) - messages = [] - round_number = len(request.conversation_history) // 5 + 2 # 估算轮次 - - # 构建历史讨论上下文 - history_context = "历史讨论:\n" - for msg in request.conversation_history: - history_context += f"【{msg.get('role_name', '未知')}】:{msg.get('content', '')}\n" - - # 如果用户有插话,先处理用户消息 - if request.user_message: - history_context += f"\n【用户】:{request.user_message}\n" - messages.append({ - "role_id": "user", - "role_name": "用户", - "nickname": request.user_nickname, - "avatar": "", - "color": "#6366F1", - "content": request.user_message, - "timestamp": datetime.now().isoformat(), - "round_number": round_number - }) - - # 新一轮讨论的发言顺序 - speaking_order = await determine_speaking_order(request.topic) - - # 依次让每个角色发言 - for role_id in speaking_order: - role = MEETING_ROLES[role_id] - if role["role_type"] == "manager": - continue - - # 构建本次发言的上下文 - current_context = f"议题:{request.topic}\n\n{history_context}" - if messages: - current_context += "\n本轮讨论:\n" - for msg in messages: - if msg["role_id"] != "user": - current_context += f"- {msg['role_name']}:{msg['content']}\n" - - # 调用LLM - prompt = f"这是第{round_number}轮讨论,请根据之前的讨论内容,进一步阐述或补充你的观点。" if request.user_message: - prompt += f"\n\n用户刚才说:{request.user_message}\n请也回应用户的观点。" + context_parts.append(f"\n用户刚才说:{request.user_message}") - content = await call_role_llm(role_id, prompt, current_context) + context = "\n".join(context_parts) if context_parts else "这是第一轮讨论,请针对议题发表你的观点。" - message = { - "role_id": role_id, - "role_name": role["name"], - "nickname": role["nickname"], - "avatar": role["avatar"], - "color": role["color"], - "content": content, + # 随机发言顺序 + speaking_order = get_random_speaking_order() + yield f"data: {json.dumps({'type': 'order_decided', 'order': speaking_order}, ensure_ascii=False)}\n\n" + + all_messages = [] + accumulated_context = context + + # 依次让每个角色发言 + for role_id in speaking_order: + role = MEETING_ROLES[role_id] + + # 发送开始发言事件 + yield f"data: {json.dumps({'type': 'speaking_start', 'role_id': role_id, 'role_name': role['name'], 'color': role['color']}, ensure_ascii=False)}\n\n" + + # 准备工具列表 + role_tools = [t for t in TOOLS if t.name in role.get("tools", [])] + + # 流式生成回复 + full_content = "" + tool_calls = [] + + async for event in stream_role_response(role_id, request.topic, accumulated_context, role_tools): + if event["type"] == "tool_call_start": + yield f"data: {json.dumps({'type': 'tool_call_start', 'role_id': role_id, 'tool': event['tool'], 'arguments': event['arguments']}, ensure_ascii=False)}\n\n" + + elif event["type"] == "tool_call_result": + yield f"data: {json.dumps({'type': 'tool_call_result', 'role_id': role_id, 'tool': event['tool'], 'result': event['result']}, ensure_ascii=False)}\n\n" + tool_calls.append(event["result"]) + + elif event["type"] == "content_delta": + yield f"data: {json.dumps({'type': 'content_delta', 'role_id': role_id, 'content': event['content']}, ensure_ascii=False)}\n\n" + full_content += event["content"] + + elif event["type"] == "content_done": + full_content = event["full_content"] + tool_calls = event.get("tool_calls", []) + + elif event["type"] == "error": + yield f"data: {json.dumps({'type': 'error', 'role_id': role_id, 'error': event['error']}, ensure_ascii=False)}\n\n" + full_content = f"[{role['name']}暂时无法发言]" + + # 构建完整消息 + message = { + "role_id": role_id, + "role_name": role["name"], + "nickname": role["nickname"], + "avatar": role["avatar"], + "color": role["color"], + "content": full_content, + "tool_calls": tool_calls, + "timestamp": datetime.now().isoformat(), + "round_number": round_number + } + all_messages.append(message) + + # 发送消息完成事件 + yield f"data: {json.dumps({'type': 'message_complete', 'message': message}, ensure_ascii=False)}\n\n" + + # 更新上下文 + accumulated_context += f"\n\n【{role['name']}】:{full_content}" + + await asyncio.sleep(0.3) + + # 基金经理总结 + fund_manager = MEETING_ROLES["fund_manager"] + yield f"data: {json.dumps({'type': 'speaking_start', 'role_id': 'fund_manager', 'role_name': fund_manager['name'], 'color': fund_manager['color']}, ensure_ascii=False)}\n\n" + + fm_full_content = "" + fm_tool_calls = [] + fm_tools = [t for t in TOOLS if t.name in fund_manager.get("tools", [])] + + async for event in stream_role_response("fund_manager", request.topic, accumulated_context, fm_tools): + if event["type"] == "tool_call_start": + yield f"data: {json.dumps({'type': 'tool_call_start', 'role_id': 'fund_manager', 'tool': event['tool'], 'arguments': event['arguments']}, ensure_ascii=False)}\n\n" + elif event["type"] == "tool_call_result": + yield f"data: {json.dumps({'type': 'tool_call_result', 'role_id': 'fund_manager', 'tool': event['tool'], 'result': event['result']}, ensure_ascii=False)}\n\n" + fm_tool_calls.append(event["result"]) + elif event["type"] == "content_delta": + yield f"data: {json.dumps({'type': 'content_delta', 'role_id': 'fund_manager', 'content': event['content']}, ensure_ascii=False)}\n\n" + fm_full_content += event["content"] + elif event["type"] == "content_done": + fm_full_content = event["full_content"] + elif event["type"] == "error": + fm_full_content = "[基金经理暂时无法发言]" + + fm_message = { + "role_id": "fund_manager", + "role_name": fund_manager["name"], + "nickname": fund_manager["nickname"], + "avatar": fund_manager["avatar"], + "color": fund_manager["color"], + "content": fm_full_content, + "tool_calls": fm_tool_calls, "timestamp": datetime.now().isoformat(), - "round_number": round_number + "round_number": round_number, + "is_conclusion": True } - messages.append(message) - # 本轮结束后,基金经理再次判断 - all_discussion = history_context + "\n本轮讨论:\n" + "\n".join([ - f"【{msg['role_name']}】:{msg['content']}" - for msg in messages if msg["role_id"] != "user" - ]) + yield f"data: {json.dumps({'type': 'message_complete', 'message': fm_message}, ensure_ascii=False)}\n\n" - can_conclude, conclusion_content = await check_conclusion_ready(all_discussion, request.topic) + # 发送会议状态(不强制结束,用户可以继续) + yield f"data: {json.dumps({'type': 'round_end', 'round_number': round_number, 'can_continue': True}, ensure_ascii=False)}\n\n" - # 添加基金经理的发言 - fund_manager = MEETING_ROLES["fund_manager"] - fund_manager_message = { - "role_id": "fund_manager", - "role_name": fund_manager["name"], - "nickname": fund_manager["nickname"], - "avatar": fund_manager["avatar"], - "color": fund_manager["color"], - "content": conclusion_content, - "timestamp": datetime.now().isoformat(), - "round_number": round_number, - "is_conclusion": can_conclude - } - messages.append(fund_manager_message) - - return { - "success": True, - "session_id": session_id, - "messages": messages, - "round_number": round_number, - "is_concluded": can_conclude, - "conclusion": fund_manager_message if can_conclude else None - } + return StreamingResponse( + generate_meeting_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) @app.get("/agent/meeting/roles") @@ -2863,111 +2820,13 @@ async def get_meeting_roles(): "avatar": role["avatar"], "color": role["color"], "description": role["description"], + "tools": role.get("tools", []), } for role in MEETING_ROLES.values() ] } -@app.post("/agent/meeting/stream") -async def stream_investment_meeting(request: MeetingRequest): - """ - 流式投研会议 - - 以 SSE 方式逐个角色流式返回发言 - """ - logger.info(f"流式投研会议: {request.topic} (user: {request.user_id})") - - async def generate_meeting_stream() -> AsyncGenerator[str, None]: - session_id = request.session_id or str(uuid.uuid4()) - round_number = 1 - all_messages = [] - - # 发送会话开始事件 - yield f"data: {json.dumps({'type': 'session_start', 'session_id': session_id}, ensure_ascii=False)}\n\n" - - # 决定发言顺序 - speaking_order = await determine_speaking_order(request.topic) - - yield f"data: {json.dumps({'type': 'order_decided', 'order': speaking_order}, ensure_ascii=False)}\n\n" - - context = f"议题:{request.topic}\n\n这是第一轮讨论,请针对议题发表你的观点。" - - # 依次让每个角色发言 - for role_id in speaking_order: - role = MEETING_ROLES[role_id] - if role["role_type"] == "manager": - continue - - # 发送"正在发言"状态 - yield f"data: {json.dumps({'type': 'speaking_start', 'role_id': role_id, 'role_name': role['name']}, ensure_ascii=False)}\n\n" - - # 构建上下文 - prev_context = context - if all_messages: - prev_context += "\n\n其他人的观点:\n" - for msg in all_messages: - prev_context += f"- {msg['role_name']}:{msg['content']}\n" - - # 调用LLM生成发言 - content = await call_role_llm(role_id, request.topic, prev_context) - - message = { - "role_id": role_id, - "role_name": role["name"], - "nickname": role["nickname"], - "avatar": role["avatar"], - "color": role["color"], - "content": content, - "timestamp": datetime.now().isoformat(), - "round_number": round_number - } - all_messages.append(message) - - # 发送完整发言 - yield f"data: {json.dumps({'type': 'message', 'message': message}, ensure_ascii=False)}\n\n" - - # 短暂延迟,让前端有时间处理 - await asyncio.sleep(0.5) - - # 基金经理总结 - fund_manager = MEETING_ROLES["fund_manager"] - yield f"data: {json.dumps({'type': 'speaking_start', 'role_id': 'fund_manager', 'role_name': fund_manager['name']}, ensure_ascii=False)}\n\n" - - discussion_summary = "\n".join([ - f"【{msg['role_name']}】:{msg['content']}" - for msg in all_messages - ]) - can_conclude, conclusion_content = await check_conclusion_ready(discussion_summary, request.topic) - - fund_manager_message = { - "role_id": "fund_manager", - "role_name": fund_manager["name"], - "nickname": fund_manager["nickname"], - "avatar": fund_manager["avatar"], - "color": fund_manager["color"], - "content": conclusion_content, - "timestamp": datetime.now().isoformat(), - "round_number": round_number, - "is_conclusion": can_conclude - } - - yield f"data: {json.dumps({'type': 'message', 'message': fund_manager_message}, ensure_ascii=False)}\n\n" - - # 发送会议结束事件 - yield f"data: {json.dumps({'type': 'meeting_end', 'is_concluded': can_conclude, 'round_number': round_number}, ensure_ascii=False)}\n\n" - - return StreamingResponse( - generate_meeting_stream(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - # ==================== 健康检查 ==================== @app.get("/health") diff --git a/src/mocks/handlers/agent.js b/src/mocks/handlers/agent.js index 2e1462cd..2fce2a8c 100644 --- a/src/mocks/handlers/agent.js +++ b/src/mocks/handlers/agent.js @@ -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- RSI:58,处于中性区域\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', + }, + }); + }), ]; diff --git a/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js index e4df9fc0..bad737de 100644 --- a/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js +++ b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js @@ -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 ( + + + + {/* 工具调用头部 */} + 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) => ( + + ))} + + + ); +}; + /** * 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 }) => { 主持人 )} + {isStreaming && ( + + + 发言中 + + )} {isConclusion && ( { )} + {/* 工具调用列表 */} + {hasToolCalls && ( + + )} + + {/* 消息内容 */} { '& strong': { color: roleConfig.color }, }} > - + {message.content ? ( + + ) : isStreaming ? ( + + + 正在思考... + + ) : null} + + {/* 流式输出时的光标 */} + {isStreaming && message.content && ( + + ▌ + + )} {/* 操作按钮 */} diff --git a/src/views/AgentChat/components/MeetingRoom/index.js b/src/views/AgentChat/components/MeetingRoom/index.js index 74405b4f..31755a90 100644 --- a/src/views/AgentChat/components/MeetingRoom/index.js +++ b/src/views/AgentChat/components/MeetingRoom/index.js @@ -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 }) => { > : } + icon={isLoading && status === MeetingStatus.STARTING ? : } 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 ? '开始会议' : '发送消息'} - {status === MeetingStatus.WAITING_INPUT && ( + {(status === MeetingStatus.WAITING_INPUT || + status === MeetingStatus.DISCUSSING || + status === MeetingStatus.SPEAKING) && ( - 💡 您可以插话参与讨论,或点击继续按钮进行下一轮 + 💡 随时输入观点参与讨论,您的发言会影响分析师的判断 )} diff --git a/src/views/AgentChat/constants/meetingRoles.ts b/src/views/AgentChat/constants/meetingRoles.ts index e6c7c8aa..5bf8bc5d 100644 --- a/src/views/AgentChat/constants/meetingRoles.ts +++ b/src/views/AgentChat/constants/meetingRoles.ts @@ -39,6 +39,26 @@ export interface MeetingRoleConfig { icon: React.ReactNode; } +/** + * 工具调用结果接口 + */ +export interface ToolCallResult { + /** 工具调用 ID */ + tool_call_id: string; + /** 工具名称 */ + tool_name: string; + /** 工具参数 */ + arguments?: Record; + /** 调用状态 */ + 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; + result?: any; + status?: string; + execution_time?: number; + /** 流式内容 */ + content?: string; + /** 错误信息 */ + error?: string; } diff --git a/src/views/AgentChat/hooks/useInvestmentMeeting.ts b/src/views/AgentChat/hooks/useInvestmentMeeting.ts index e9a9d36f..bf082a95 100644 --- a/src/views/AgentChat/hooks/useInvestmentMeeting.ts +++ b/src/views/AgentChat/hooks/useInvestmentMeeting.ts @@ -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( - '/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( - '/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, + ] ); /**