diff --git a/mcp_server.py b/mcp_server.py index a04bb43d..51814890 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -2337,6 +2337,637 @@ async def search_chat_history(user_id: str, query: str, top_k: int = 10): raise HTTPException(status_code=500, detail=str(e)) +# ==================== 投研会议室系统 ==================== + +# 投研会议室角色配置 +MEETING_ROLES = { + "buffett": { + "id": "buffett", + "name": "巴菲特", + "nickname": "唱多者", + "role_type": "bull", # 多头 + "avatar": "/avatars/buffett.png", + "model": "kimi-k2-thinking", + "color": "#10B981", # 绿色(上涨) + "description": "主观多头,善于分析事件的潜在利好和长期价值", + "system_prompt": """你是"巴菲特",一位资深的价值投资者和主观多头分析师。 + +你的特点: +1. 善于发现事件和公司的潜在利好因素 +2. 关注长期价值,不被短期波动干扰 +3. 分析公司的护城河、竞争优势和管理层质量 +4. 对市场保持乐观但理性的态度 + +分析风格: +- 重点挖掘利好因素和投资机会 +- 从产业链、市场格局、政策支持等角度分析 +- 给出清晰的看多逻辑和目标预期 +- 语言风格:稳重、专业、富有洞察力 + +注意:你的发言要简洁有力,每次发言控制在200字以内。直接表达观点,不要客套。""" + }, + "big_short": { + "id": "big_short", + "name": "大空头", + "nickname": "大空头", + "role_type": "bear", # 空头 + "avatar": "/avatars/big_short.png", + "model": "kimi-k2-thinking", + "color": "#EF4444", # 红色(下跌) + "description": "善于分析事件和财报中的风险因素,帮助投资者避雷", + "system_prompt": """你是"大空头",一位专业的风险分析师和空头研究员。 + +你的特点: +1. 善于发现被市场忽视的风险因素 +2. 擅长财报分析,发现财务造假和粉饰的迹象 +3. 关注行业天花板、竞争加剧、估值泡沫等问题 +4. 对市场保持警惕,帮助投资者避雷 + +分析风格: +- 重点挖掘风险因素和潜在隐患 +- 从财务数据、行业周期、估值水平等角度分析 +- 给出清晰的风险提示和规避建议 +- 语言风格:犀利、直接、善于质疑 + +注意:你的发言要简洁有力,每次发言控制在200字以内。直接指出风险,不要绕弯子。""" + }, + "simons": { + "id": "simons", + "name": "量化分析员", + "nickname": "西蒙斯", + "role_type": "quant", # 量化 + "avatar": "/avatars/simons.png", + "model": "deepseek-v3", + "color": "#3B82F6", # 蓝色(中性) + "description": "中性立场,使用量化分析工具分析技术指标", + "system_prompt": """你是"量化分析员"(昵称:西蒙斯),一位专业的量化交易研究员。 + +你的特点: +1. 使用数据和技术指标说话,保持中性立场 +2. 擅长均线分析、量价关系、动能指标等技术分析 +3. 关注市场情绪、资金流向、筹码分布等量化因素 +4. 用概率思维看待市场,不做主观臆断 + +分析风格: +- 基于技术指标给出客观分析 +- 使用具体数据支撑观点(如:5日均线、MACD、RSI等) +- 给出量化的买卖信号和风险评估 +- 语言风格:理性、客观、数据驱动 + +注意:你的发言要简洁有力,每次发言控制在200字以内。多用数据说话,少发表主观意见。""" + }, + "leek": { + "id": "leek", + "name": "韭菜", + "nickname": "牢大", + "role_type": "retail", # 散户 + "avatar": "/avatars/leek.png", + "model": "deepmoney", + "color": "#F59E0B", # 黄色 + "description": "贪婪又讨厌亏损,热爱追涨杀跌的典型散户", + "system_prompt": """你是"韭菜"(昵称:牢大),一个典型的散户投资者。 + +你的特点: +1. 贪婪但又害怕亏损,典型的追涨杀跌 +2. 容易被市场情绪影响,看到涨就想追,看到跌就想跑 +3. 喜欢听小道消息,容易被"内幕"吸引 +4. 短线思维,缺乏耐心,期望一夜暴富 + +分析风格: +- 用最朴素的散户思维来分析问题 +- 经常关注"这个能赚多少"、"会不会跌" +- 容易情绪化,看涨时过度乐观,看跌时过度悲观 +- 语言风格:口语化、情绪化、接地气 + +注意:你的发言要简洁直接,每次发言控制在150字以内。展现真实散户的心态,可以有些搞笑,但不要太出格。""" + }, + "fund_manager": { + "id": "fund_manager", + "name": "基金经理", + "nickname": "决策者", + "role_type": "manager", # 管理者 + "avatar": "/avatars/fund_manager.png", + "model": "kimi-k2-thinking", + "color": "#8B5CF6", # 紫色 + "description": "总结其他人的发言做出最终决策", + "system_prompt": """你是"基金经理",投研会议的主持人和最终决策者。 + +你的角色: +1. 综合各方观点,做出理性判断 +2. 平衡多空观点,识别有价值的分析 +3. 特别注意:韭菜的观点通常是反向指标 +4. 给出专业、负责任的投资建议 + +决策风格: +- 综合考虑基本面、技术面、情绪面 +- 权衡风险与收益,给出明确的投资建议 +- 指出讨论中的关键洞察和需要注意的风险 +- 语言风格:权威、专业、全面 + +决策输出格式: +1. 综合评估:对讨论议题的整体判断 +2. 关键观点:各方有价值的观点总结 +3. 风险提示:需要注意的主要风险 +4. 操作建议:具体的投资建议(买入/持有/观望/卖出) +5. 信心指数:对这个结论的信心程度(1-10分) + +注意:如果讨论还不够充分,你可以要求继续讨论。每次发言控制在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 # 用户提出的议题 + user_id: str = "anonymous" + user_nickname: str = "匿名用户" + session_id: Optional[str] = None + 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 # 基金经理的结论(如果有) + + +async def call_role_llm(role_id: str, prompt: str, context: str = "") -> str: + """调用特定角色的LLM生成回复""" + role = MEETING_ROLES.get(role_id) + if not role: + raise ValueError(f"Unknown role: {role_id}") + + model_name = role["model"] + model_config = MEETING_MODEL_CONFIGS.get(model_name, MODEL_CONFIGS["kimi-k2-thinking"]) + + try: + client = OpenAI( + api_key=model_config["api_key"], + base_url=model_config["base_url"] + ) + + messages = [ + {"role": "system", "content": role["system_prompt"]}, + ] + + if context: + messages.append({"role": "user", "content": f"当前讨论背景:\n{context}"}) + + messages.append({"role": "user", "content": prompt}) + + response = client.chat.completions.create( + model=model_config["model"], + messages=messages, + 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": "如果可以结论,这里是你的完整决策;如果不能,这里是需要继续讨论的方向" +}}""" + } + ], + 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 + + except Exception as e: + logger.error(f"检查结论状态失败: {e}") + return True, "基于目前的讨论,建议投资者谨慎对待,继续关注后续发展。" + + +@app.post("/agent/meeting/start") +async def start_investment_meeting(request: MeetingRequest): + """ + 启动投研会议 + + 第一轮:所有角色(除基金经理外)依次发言 + """ + logger.info(f"启动投研会议: {request.topic} (user: {request.user_id})") + + session_id = request.session_id or str(uuid.uuid4()) + messages = [] + round_number = 1 + + # 决定发言顺序 + speaking_order = await determine_speaking_order(request.topic) + logger.info(f"发言顺序: {speaking_order}") + + # 构建讨论上下文 + context = f"议题:{request.topic}\n\n这是第一轮讨论,请针对议题发表你的观点。" + + # 依次让每个角色发言 + 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请也回应用户的观点。" + + content = await call_role_llm(role_id, prompt, current_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) + + # 本轮结束后,基金经理再次判断 + all_discussion = history_context + "\n本轮讨论:\n" + "\n".join([ + f"【{msg['role_name']}】:{msg['content']}" + for msg in messages if msg["role_id"] != "user" + ]) + + can_conclude, conclusion_content = await check_conclusion_ready(all_discussion, 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.get("/agent/meeting/roles") +async def get_meeting_roles(): + """获取所有会议角色配置""" + return { + "success": True, + "roles": [ + { + "id": role["id"], + "name": role["name"], + "nickname": role["nickname"], + "role_type": role["role_type"], + "avatar": role["avatar"], + "color": role["color"], + "description": role["description"], + } + 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 5bac63e1..2e1462cd 100644 --- a/src/mocks/handlers/agent.js +++ b/src/mocks/handlers/agent.js @@ -230,4 +230,208 @@ export const agentHandlers = [ count: history.length, }); }), + + // ==================== 投研会议室 API Handlers ==================== + + // GET /mcp/agent/meeting/roles - 获取会议角色配置 + http.get('/mcp/agent/meeting/roles', async () => { + await delay(200); + + return HttpResponse.json({ + success: true, + roles: [ + { + id: 'buffett', + name: '巴菲特', + nickname: '唱多者', + role_type: 'bull', + avatar: '/avatars/buffett.png', + color: '#10B981', + description: '主观多头,善于分析事件的潜在利好和长期价值', + }, + { + id: 'big_short', + name: '大空头', + nickname: '大空头', + role_type: 'bear', + avatar: '/avatars/big_short.png', + color: '#EF4444', + description: '善于分析事件和财报中的风险因素,帮助投资者避雷', + }, + { + id: 'simons', + name: '量化分析员', + nickname: '西蒙斯', + role_type: 'quant', + avatar: '/avatars/simons.png', + color: '#3B82F6', + description: '中性立场,使用量化分析工具分析技术指标', + }, + { + id: 'leek', + name: '韭菜', + nickname: '牢大', + role_type: 'retail', + avatar: '/avatars/leek.png', + color: '#F59E0B', + description: '贪婪又讨厌亏损,热爱追涨杀跌的典型散户', + }, + { + id: 'fund_manager', + name: '基金经理', + nickname: '决策者', + role_type: 'manager', + avatar: '/avatars/fund_manager.png', + color: '#8B5CF6', + description: '总结其他人的发言做出最终决策', + }, + ], + }); + }), + + // POST /mcp/agent/meeting/start - 启动投研会议 + http.post('/mcp/agent/meeting/start', async ({ request }) => { + await delay(2000); // 模拟多角色讨论耗时 + + const body = await request.json(); + const { topic, user_id } = body; + + const sessionId = `meeting-${Date.now()}`; + const timestamp = new Date().toISOString(); + + // 生成模拟的多角色讨论消息 + const messages = [ + { + role_id: 'buffett', + role_name: '巴菲特', + nickname: '唱多者', + avatar: '/avatars/buffett.png', + color: '#10B981', + content: `关于「${topic}」,我认为这里存在显著的投资机会。从价值投资的角度看,我们应该关注以下几点:\n\n1. **长期价值**:该标的具有较强的护城河\n2. **盈利能力**:ROE持续保持在较高水平\n3. **管理层质量**:管理团队稳定且执行力强\n\n我的观点是**看多**,建议逢低布局。`, + timestamp, + round_number: 1, + }, + { + role_id: 'big_short', + role_name: '大空头', + nickname: '大空头', + avatar: '/avatars/big_short.png', + color: '#EF4444', + content: `等等,让我泼点冷水。关于「${topic}」,市场似乎过于乐观了:\n\n⚠️ **风险提示**:\n1. 当前估值处于历史高位,安全边际不足\n2. 行业竞争加剧,利润率面临压力\n3. 宏观环境不确定性增加\n\n建议投资者**保持谨慎**,不要追高。`, + timestamp: new Date(Date.now() + 1000).toISOString(), + round_number: 1, + }, + { + role_id: 'simons', + role_name: '量化分析员', + nickname: '西蒙斯', + avatar: '/avatars/simons.png', + color: '#3B82F6', + content: `从量化角度分析「${topic}」:\n\n📊 **技术指标**:\n- MACD:金叉形态,动能向上\n- RSI:58,处于中性区域\n- 均线:5日>10日>20日,多头排列\n\n📈 **资金面**:\n- 主力资金:近5日净流入2.3亿\n- 北向资金:持续加仓\n\n**结论**:短期技术面偏多,但需关注60日均线支撑。`, + timestamp: new Date(Date.now() + 2000).toISOString(), + round_number: 1, + }, + { + role_id: 'leek', + role_name: '韭菜', + nickname: '牢大', + avatar: '/avatars/leek.png', + color: '#F59E0B', + content: `哇!「${topic}」看起来要涨啊!\n\n🚀 我觉得必须满仓干!隔壁老王都赚翻了!\n\n不过话说回来...万一跌了怎么办?会不会套住?\n\n算了不管了,先冲一把再说!错过这村就没这店了!\n\n(内心OS:希望别当接盘侠...)`, + timestamp: new Date(Date.now() + 3000).toISOString(), + round_number: 1, + }, + { + role_id: 'fund_manager', + role_name: '基金经理', + nickname: '决策者', + avatar: '/avatars/fund_manager.png', + color: '#8B5CF6', + content: `## 投资建议总结\n\n综合各方观点,对于「${topic}」,我的判断如下:\n\n### 综合评估\n多空双方都提出了有价值的观点。技术面短期偏多,但估值确实需要关注。\n\n### 关键观点\n- ✅ 基本面优质,长期价值明确\n- ⚠️ 短期估值偏高,需要耐心等待\n- 📊 技术面处于上升趋势\n\n### 风险提示\n注意仓位控制,避免追高\n\n### 操作建议\n**观望为主**,等待回调至支撑位再考虑建仓\n\n### 信心指数:7/10`, + timestamp: new Date(Date.now() + 4000).toISOString(), + round_number: 1, + is_conclusion: true, + }, + ]; + + return HttpResponse.json({ + success: true, + session_id: sessionId, + messages, + round_number: 1, + is_concluded: true, + conclusion: messages[messages.length - 1], + }); + }), + + // POST /mcp/agent/meeting/continue - 继续会议讨论 + http.post('/mcp/agent/meeting/continue', async ({ request }) => { + await delay(1500); + + const body = await request.json(); + const { topic, user_message, conversation_history } = body; + + const roundNumber = Math.floor(conversation_history.length / 5) + 2; + const timestamp = new Date().toISOString(); + + const messages = []; + + // 如果用户有插话,添加用户消息 + if (user_message) { + messages.push({ + role_id: 'user', + role_name: '用户', + nickname: '你', + avatar: '', + color: '#6366F1', + content: user_message, + timestamp, + round_number: roundNumber, + }); + } + + // 生成新一轮讨论 + messages.push( + { + role_id: 'buffett', + role_name: '巴菲特', + nickname: '唱多者', + avatar: '/avatars/buffett.png', + color: '#10B981', + content: `感谢用户的补充。${user_message ? `关于"${user_message}",` : ''}我依然坚持看多的观点。从更长远的角度看,短期波动不影响长期价值。`, + timestamp: new Date(Date.now() + 1000).toISOString(), + round_number: roundNumber, + }, + { + role_id: 'big_short', + role_name: '大空头', + nickname: '大空头', + avatar: '/avatars/big_short.png', + color: '#EF4444', + content: `用户提出了很好的问题。我要再次强调风险控制的重要性。当前市场情绪过热,建议保持警惕。`, + timestamp: new Date(Date.now() + 2000).toISOString(), + round_number: roundNumber, + }, + { + role_id: 'fund_manager', + role_name: '基金经理', + nickname: '决策者', + avatar: '/avatars/fund_manager.png', + color: '#8B5CF6', + content: `## 第${roundNumber}轮讨论总结\n\n经过进一步讨论,我维持之前的判断:\n\n- 短期观望为主\n- 中长期可以考虑分批建仓\n- 严格控制仓位,设好止损\n\n**信心指数:7.5/10**\n\n会议到此结束,感谢各位的参与!`, + timestamp: new Date(Date.now() + 3000).toISOString(), + round_number: roundNumber, + is_conclusion: true, + } + ); + + return HttpResponse.json({ + success: true, + session_id: body.session_id, + messages, + round_number: roundNumber, + is_concluded: true, + conclusion: messages[messages.length - 1], + }); + }), ]; diff --git a/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js new file mode 100644 index 00000000..e4df9fc0 --- /dev/null +++ b/src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js @@ -0,0 +1,267 @@ +// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js +// 会议消息气泡组件 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { + Box, + Flex, + HStack, + VStack, + Text, + Avatar, + Badge, + IconButton, + Tooltip, + Card, + CardBody, +} from '@chakra-ui/react'; +import { + TrendingUp, + TrendingDown, + BarChart2, + Users, + Crown, + Copy, + ThumbsUp, +} from 'lucide-react'; +import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles'; +import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts'; + +/** + * 获取角色图标 + */ +const getRoleIcon = (roleType) => { + switch (roleType) { + case 'bull': + return ; + case 'bear': + return ; + case 'quant': + return ; + case 'retail': + return ; + case 'manager': + return ; + default: + return ; + } +}; + +/** + * MeetingMessageBubble - 会议消息气泡组件 + * + * @param {Object} props + * @param {Object} props.message - 消息对象 + * @param {boolean} props.isLatest - 是否是最新消息 + * @returns {JSX.Element} + */ +const MeetingMessageBubble = ({ message, isLatest }) => { + const roleConfig = getRoleConfig(message.role_id) || { + name: message.role_name, + nickname: message.nickname, + color: message.color, + roleType: 'retail', + }; + + const isUser = message.role_id === 'user'; + const isManager = roleConfig.roleType === 'manager'; + const isConclusion = message.is_conclusion; + + // 复制到剪贴板 + const handleCopy = () => { + navigator.clipboard.writeText(message.content); + }; + + return ( + + {/* 消息头部:角色信息 */} + + + + + + + + + {roleConfig.name} + + {roleConfig.nickname !== roleConfig.name && ( + + @{roleConfig.nickname} + + )} + {isManager && ( + + 主持人 + + )} + {isConclusion && ( + + 最终结论 + + )} + + + 第 {message.round_number} 轮 ·{' '} + {new Date(message.timestamp).toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + })} + + + + + {/* 消息内容卡片 */} + + + {/* 结论标题 */} + {isConclusion && ( + + + + + 基金经理投资建议 + + + + )} + + + + + + + {/* 操作按钮 */} + + + + } + onClick={handleCopy} + color="gray.500" + _hover={{ color: 'white', bg: 'whiteAlpha.100' }} + /> + + + } + color="gray.500" + _hover={{ color: 'green.400', bg: 'green.900' }} + /> + + + + {/* 角色标签 */} + + + {roleConfig.roleType === 'bull' && '📈 看多'} + {roleConfig.roleType === 'bear' && '📉 看空'} + {roleConfig.roleType === 'quant' && '📊 量化'} + {roleConfig.roleType === 'retail' && '🌱 散户'} + {roleConfig.roleType === 'manager' && '👔 决策'} + + + + + + + + ); +}; + +export default MeetingMessageBubble; diff --git a/src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js b/src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js new file mode 100644 index 00000000..05975046 --- /dev/null +++ b/src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js @@ -0,0 +1,301 @@ +// src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js +// 会议角色面板组件 - 显示所有参会角色状态 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { + Box, + VStack, + HStack, + Text, + Avatar, + Badge, + Tooltip, +} from '@chakra-ui/react'; +import { + TrendingUp, + TrendingDown, + BarChart2, + Users, + Crown, + Mic, + MicOff, +} from 'lucide-react'; +import { MEETING_ROLES, MeetingStatus } from '../../constants/meetingRoles'; + +/** + * 获取角色图标 + */ +const getRoleIcon = (roleType) => { + switch (roleType) { + case 'bull': + return ; + case 'bear': + return ; + case 'quant': + return ; + case 'retail': + return ; + case 'manager': + return ; + default: + return ; + } +}; + +/** + * RoleCard - 单个角色卡片 + */ +const RoleCard = ({ role, isSpeaking }) => { + return ( + + + + + {/* 头像 */} + + + {isSpeaking && ( + + + + + + )} + + + {/* 角色信息 */} + + + {role.name} + + + @{role.nickname} + + + + {/* 状态指示 */} + + {role.roleType === 'bull' && '多头'} + {role.roleType === 'bear' && '空头'} + {role.roleType === 'quant' && '量化'} + {role.roleType === 'retail' && '散户'} + {role.roleType === 'manager' && '主持'} + + + + + + ); +}; + +/** + * MeetingRolePanel - 会议角色面板 + * + * @param {Object} props + * @param {string|null} props.speakingRoleId - 正在发言的角色 ID + * @param {MeetingStatus} props.status - 会议状态 + * @returns {JSX.Element} + */ +const MeetingRolePanel = ({ speakingRoleId, status }) => { + // 将角色按类型分组 + const analysts = Object.values(MEETING_ROLES).filter( + (r) => r.roleType !== 'manager' + ); + const manager = MEETING_ROLES.fund_manager; + + return ( + + {/* 标题 */} + + 参会成员 + + + + {/* 分析师组 */} + + 分析团队 + + {analysts.map((role) => ( + + ))} + + {/* 分隔线 */} + + + {/* 基金经理 */} + + 决策层 + + + + + {/* 会议状态指示 */} + + + 会议状态 + + + + + {status === MeetingStatus.IDLE && '等待开始'} + {status === MeetingStatus.STARTING && '召集中...'} + {status === MeetingStatus.DISCUSSING && '讨论中'} + {status === MeetingStatus.SPEAKING && '发言中'} + {status === MeetingStatus.WAITING_INPUT && '等待输入'} + {status === MeetingStatus.CONCLUDED && '已结束'} + {status === MeetingStatus.ERROR && '异常'} + + + + + {/* 角色说明 */} + + + 💡 角色说明 + + + + 📈 多头:挖掘利好因素 + + + 📉 空头:发现风险隐患 + + + 📊 量化:技术指标分析 + + + 🌱 散户:反向指标参考 + + + 👔 主持:综合判断决策 + + + + + ); +}; + +export default MeetingRolePanel; diff --git a/src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js b/src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js new file mode 100644 index 00000000..4e6fbea8 --- /dev/null +++ b/src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js @@ -0,0 +1,294 @@ +// src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js +// 会议欢迎界面 - 显示议题建议 + +import React from 'react'; +import { motion } from 'framer-motion'; +import { + Box, + VStack, + HStack, + Text, + SimpleGrid, + Card, + CardBody, + Icon, +} from '@chakra-ui/react'; +import { + TrendingUp, + FileText, + AlertTriangle, + LineChart, + Briefcase, + Building, + Zap, + Target, +} from 'lucide-react'; + +/** + * 议题建议列表 + */ +const TOPIC_SUGGESTIONS = [ + { + id: 1, + icon: FileText, + color: 'blue.400', + title: '财报分析', + example: '分析贵州茅台2024年三季报,评估投资价值', + }, + { + id: 2, + icon: AlertTriangle, + color: 'red.400', + title: '风险评估', + example: '分析宁德时代面临的主要风险和挑战', + }, + { + id: 3, + icon: TrendingUp, + color: 'green.400', + title: '趋势判断', + example: '当前AI概念股还能不能追?', + }, + { + id: 4, + icon: LineChart, + color: 'purple.400', + title: '技术分析', + example: '从技术面分析上证指数短期走势', + }, + { + id: 5, + icon: Building, + color: 'orange.400', + title: '行业研究', + example: '新能源汽车行业2025年投资机会分析', + }, + { + id: 6, + icon: Target, + color: 'cyan.400', + title: '事件驱动', + example: '美联储降息对A股的影响分析', + }, +]; + +/** + * TopicCard - 议题建议卡片 + */ +const TopicCard = ({ suggestion, onClick }) => { + const IconComponent = suggestion.icon; + + return ( + + onClick(suggestion.example)} + transition="all 0.2s" + _hover={{ + bg: 'rgba(255, 255, 255, 0.05)', + borderColor: suggestion.color, + boxShadow: `0 8px 30px ${suggestion.color}20`, + }} + > + + + + + + + + {suggestion.title} + + + + {suggestion.example} + + + + + + ); +}; + +/** + * MeetingWelcome - 会议欢迎界面 + * + * @param {Object} props + * @param {Function} props.onTopicSelect - 选择议题回调 + * @returns {JSX.Element} + */ +const MeetingWelcome = ({ onTopicSelect }) => { + return ( + + + {/* 标题区域 */} + + + + + + + + + + 欢迎来到投研会议室 + + + + 多位 AI 分析师将从不同角度分析您的投资议题, + 包括多头、空头、量化分析师和散户视角, + 最终由基金经理给出投资建议。 + + + + + {/* 特点说明 */} + + + {[ + { icon: '📈', text: '多空对决' }, + { icon: '📊', text: '量化分析' }, + { icon: '🎯', text: '投资建议' }, + { icon: '💬', text: '实时参与' }, + ].map((item, index) => ( + + {item.icon} + + {item.text} + + + ))} + + + + {/* 议题建议 */} + + + + + + 点击下方议题快速开始,或输入自定义议题 + + + + + {TOPIC_SUGGESTIONS.map((suggestion) => ( + + ))} + + + + + {/* 使用提示 */} + + + + 💡 提示:会议进行中您可以随时插话参与讨论, + 您的观点会影响分析师的判断。 + 讨论结束后,基金经理会给出最终的投资建议。 + + + + + + ); +}; + +export default MeetingWelcome; diff --git a/src/views/AgentChat/components/MeetingRoom/index.js b/src/views/AgentChat/components/MeetingRoom/index.js new file mode 100644 index 00000000..74405b4f --- /dev/null +++ b/src/views/AgentChat/components/MeetingRoom/index.js @@ -0,0 +1,442 @@ +// src/views/AgentChat/components/MeetingRoom/index.js +// 投研会议室主组件 + +import React, { useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + Box, + Flex, + VStack, + HStack, + Text, + Input, + IconButton, + Avatar, + Badge, + Spinner, + Tooltip, + Kbd, + useColorModeValue, +} from '@chakra-ui/react'; +import { + Send, + Users, + RefreshCw, + MessageCircle, + CheckCircle, + AlertCircle, +} from 'lucide-react'; +import { + MEETING_ROLES, + MeetingStatus, + getRoleConfig, +} from '../../constants/meetingRoles'; +import { useInvestmentMeeting } from '../../hooks/useInvestmentMeeting'; +import MeetingMessageBubble from './MeetingMessageBubble'; +import MeetingRolePanel from './MeetingRolePanel'; +import MeetingWelcome from './MeetingWelcome'; + +/** + * MeetingRoom - 投研会议室主组件 + * + * @param {Object} props + * @param {Object} props.user - 当前用户信息 + * @param {Function} props.onToast - Toast 通知函数 + * @returns {JSX.Element} + */ +const MeetingRoom = ({ user, onToast }) => { + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + + // 使用投研会议 Hook + const { + messages, + status, + speakingRoleId, + currentRound, + isConcluded, + conclusion, + inputValue, + setInputValue, + startMeeting, + continueMeeting, + sendUserMessage, + resetMeeting, + currentTopic, + isLoading, + } = useInvestmentMeeting({ + userId: user?.id ? String(user.id) : 'anonymous', + userNickname: user?.nickname || '匿名用户', + onToast, + }); + + // 自动滚动到底部 + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // 处理键盘事件 + const handleKeyPress = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + // 处理发送 + const handleSend = () => { + if (!inputValue.trim()) return; + + if (status === MeetingStatus.IDLE) { + // 启动新会议 + startMeeting(inputValue.trim()); + setInputValue(''); + } else if ( + status === MeetingStatus.WAITING_INPUT || + status === MeetingStatus.CONCLUDED + ) { + // 用户插话或开始新话题 + if (isConcluded) { + // 如果已结论,开始新会议 + resetMeeting(); + startMeeting(inputValue.trim()); + } else { + sendUserMessage(inputValue.trim()); + } + setInputValue(''); + } + }; + + // 获取状态提示文字 + const getStatusText = () => { + switch (status) { + case MeetingStatus.IDLE: + return '请输入投研议题,开始会议讨论'; + case MeetingStatus.STARTING: + return '正在召集会议成员...'; + case MeetingStatus.DISCUSSING: + return `第 ${currentRound} 轮讨论进行中...`; + case MeetingStatus.SPEAKING: + const role = getRoleConfig(speakingRoleId); + return `${role?.name || '成员'} 正在发言...`; + case MeetingStatus.WAITING_INPUT: + return '讨论暂停,您可以插话或等待继续'; + case MeetingStatus.CONCLUDED: + return '会议已结束,已得出投资建议'; + case MeetingStatus.ERROR: + return '会议出现异常,请重试'; + default: + return ''; + } + }; + + // 获取输入框占位符 + const getPlaceholder = () => { + if (status === MeetingStatus.IDLE) { + return '输入投研议题,如:分析茅台最新财报...'; + } else if (status === MeetingStatus.WAITING_INPUT) { + return '输入您的观点参与讨论,或等待继续...'; + } else if (status === MeetingStatus.CONCLUDED) { + return '会议已结束,输入新议题开始新会议...'; + } + return '会议进行中...'; + }; + + return ( + + {/* 顶部标题栏 */} + + + + + } + bgGradient="linear(to-br, orange.400, red.500)" + boxShadow="0 0 20px rgba(251, 146, 60, 0.5)" + /> + + + + + 投研会议室 + + + + {status === MeetingStatus.CONCLUDED ? ( + + ) : status === MeetingStatus.ERROR ? ( + + ) : ( + + )} + {getStatusText()} + + {currentRound > 0 && ( + + 第 {currentRound} 轮 + + )} + + + + + + + + } + onClick={resetMeeting} + bg="rgba(255, 255, 255, 0.05)" + color="gray.400" + _hover={{ + bg: 'rgba(255, 255, 255, 0.1)', + color: 'white', + }} + /> + + + + + + + {/* 主内容区 */} + + {/* 角色面板(左侧) */} + + + {/* 消息区域(中间) */} + + {messages.length === 0 && status === MeetingStatus.IDLE ? ( + { + setInputValue(topic); + inputRef.current?.focus(); + }} + /> + ) : ( + + {/* 当前议题展示 */} + {currentTopic && ( + + + + 📋 本次议题 + + + {currentTopic} + + + + )} + + {/* 消息列表 */} + + {messages.map((message, index) => ( + + + + ))} + + + {/* 正在发言指示器 */} + {speakingRoleId && ( + + + + + {getRoleConfig(speakingRoleId)?.name} 正在思考... + + + + )} + +
+ + )} + + + + {/* 输入栏 */} + + + + setInputValue(e.target.value)} + onKeyDown={handleKeyPress} + placeholder={getPlaceholder()} + isDisabled={ + isLoading || + status === MeetingStatus.DISCUSSING || + status === MeetingStatus.SPEAKING + } + size="lg" + bg="rgba(255, 255, 255, 0.05)" + border="1px solid" + borderColor="rgba(255, 255, 255, 0.1)" + color="white" + _placeholder={{ color: 'gray.500' }} + _hover={{ + borderColor: 'rgba(255, 255, 255, 0.2)', + }} + _focus={{ + borderColor: 'orange.400', + boxShadow: '0 0 0 1px var(--chakra-colors-orange-400)', + }} + /> + + + : } + onClick={handleSend} + isDisabled={ + !inputValue.trim() || + isLoading || + status === MeetingStatus.DISCUSSING || + status === MeetingStatus.SPEAKING + } + bgGradient="linear(to-r, orange.400, red.500)" + color="white" + _hover={{ + bgGradient: 'linear(to-r, orange.500, red.600)', + boxShadow: '0 8px 20px rgba(251, 146, 60, 0.4)', + }} + /> + + + {/* 继续讨论按钮 */} + {status === MeetingStatus.WAITING_INPUT && !isConcluded && ( + + + } + onClick={() => continueMeeting()} + isDisabled={isLoading} + bgGradient="linear(to-r, purple.400, blue.500)" + color="white" + _hover={{ + bgGradient: 'linear(to-r, purple.500, blue.600)', + }} + /> + + + )} + + + + + + Enter + + + {status === MeetingStatus.IDLE ? '开始会议' : '发送消息'} + + + {status === MeetingStatus.WAITING_INPUT && ( + + 💡 您可以插话参与讨论,或点击继续按钮进行下一轮 + + )} + + + + + ); +}; + +export default MeetingRoom; diff --git a/src/views/AgentChat/constants/index.ts b/src/views/AgentChat/constants/index.ts index 35c32e62..43bbf4fe 100644 --- a/src/views/AgentChat/constants/index.ts +++ b/src/views/AgentChat/constants/index.ts @@ -20,3 +20,4 @@ export * from './messageTypes'; export * from './models'; export * from './tools'; export * from './quickQuestions'; +export * from './meetingRoles'; diff --git a/src/views/AgentChat/constants/meetingRoles.ts b/src/views/AgentChat/constants/meetingRoles.ts new file mode 100644 index 00000000..e6c7c8aa --- /dev/null +++ b/src/views/AgentChat/constants/meetingRoles.ts @@ -0,0 +1,227 @@ +// src/views/AgentChat/constants/meetingRoles.ts +// 投研会议室角色配置 + +import * as React from 'react'; +import { + TrendingUp, + TrendingDown, + BarChart2, + Users, + Crown, +} from 'lucide-react'; + +/** + * 角色类型枚举 + */ +export type MeetingRoleType = 'bull' | 'bear' | 'quant' | 'retail' | 'manager'; + +/** + * 会议角色配置接口 + */ +export interface MeetingRoleConfig { + /** 角色唯一标识 */ + id: string; + /** 角色名称 */ + name: string; + /** 角色昵称 */ + nickname: string; + /** 角色类型 */ + roleType: MeetingRoleType; + /** 头像路径 */ + avatar: string; + /** 主题颜色 */ + color: string; + /** 渐变背景 */ + gradient: string; + /** 角色描述 */ + description: string; + /** 图标 */ + icon: React.ReactNode; +} + +/** + * 会议消息接口 + */ +export interface MeetingMessage { + /** 消息 ID */ + id?: string | number; + /** 角色 ID */ + role_id: string; + /** 角色名称 */ + role_name: string; + /** 角色昵称 */ + nickname: string; + /** 头像 */ + avatar: string; + /** 颜色 */ + color: string; + /** 消息内容 */ + content: string; + /** 时间戳 */ + timestamp: string; + /** 轮次 */ + round_number: number; + /** 是否为结论 */ + is_conclusion?: boolean; +} + +/** + * 会议响应接口 + */ +export interface MeetingResponse { + success: boolean; + session_id: string; + messages: MeetingMessage[]; + round_number: number; + is_concluded: boolean; + conclusion?: MeetingMessage | null; +} + +/** + * 会议请求接口 + */ +export interface MeetingRequest { + topic: string; + user_id?: string; + user_nickname?: string; + session_id?: string; + user_message?: string; + conversation_history?: MeetingMessage[]; +} + +/** + * 投研会议室角色配置 + */ +export const MEETING_ROLES: Record = { + buffett: { + id: 'buffett', + name: '巴菲特', + nickname: '唱多者', + roleType: 'bull', + avatar: '/avatars/buffett.png', + color: '#10B981', + gradient: 'linear(to-br, green.400, emerald.600)', + description: '主观多头,善于分析事件的潜在利好和长期价值', + icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }), + }, + big_short: { + id: 'big_short', + name: '大空头', + nickname: '大空头', + roleType: 'bear', + avatar: '/avatars/big_short.png', + color: '#EF4444', + gradient: 'linear(to-br, red.400, rose.600)', + description: '善于分析事件和财报中的风险因素,帮助投资者避雷', + icon: React.createElement(TrendingDown, { className: 'w-5 h-5' }), + }, + simons: { + id: 'simons', + name: '量化分析员', + nickname: '西蒙斯', + roleType: 'quant', + avatar: '/avatars/simons.png', + color: '#3B82F6', + gradient: 'linear(to-br, blue.400, cyan.600)', + description: '中性立场,使用量化分析工具分析技术指标', + icon: React.createElement(BarChart2, { className: 'w-5 h-5' }), + }, + leek: { + id: 'leek', + name: '韭菜', + nickname: '牢大', + roleType: 'retail', + avatar: '/avatars/leek.png', + color: '#F59E0B', + gradient: 'linear(to-br, amber.400, yellow.600)', + description: '贪婪又讨厌亏损,热爱追涨杀跌的典型散户', + icon: React.createElement(Users, { className: 'w-5 h-5' }), + }, + fund_manager: { + id: 'fund_manager', + name: '基金经理', + nickname: '决策者', + roleType: 'manager', + avatar: '/avatars/fund_manager.png', + color: '#8B5CF6', + gradient: 'linear(to-br, purple.400, violet.600)', + description: '总结其他人的发言做出最终决策', + icon: React.createElement(Crown, { className: 'w-5 h-5' }), + }, +}; + +/** + * 用户角色配置(用于用户插话) + */ +export const USER_ROLE: MeetingRoleConfig = { + id: 'user', + name: '用户', + nickname: '你', + roleType: 'retail', + avatar: '', + color: '#6366F1', + gradient: 'linear(to-br, indigo.400, purple.600)', + description: '参与讨论的用户', + icon: React.createElement(Users, { className: 'w-5 h-5' }), +}; + +/** + * 获取角色配置 + */ +export const getRoleConfig = (roleId: string): MeetingRoleConfig | undefined => { + if (roleId === 'user') return USER_ROLE; + return MEETING_ROLES[roleId]; +}; + +/** + * 获取所有非管理者角色(用于发言顺序) + */ +export const getSpeakingRoles = (): MeetingRoleConfig[] => { + return Object.values(MEETING_ROLES).filter( + (role) => role.roleType !== 'manager' + ); +}; + +/** + * 会议状态枚举 + */ +export enum MeetingStatus { + /** 空闲,等待用户输入议题 */ + IDLE = 'idle', + /** 正在开始会议 */ + STARTING = 'starting', + /** 正在讨论中 */ + DISCUSSING = 'discussing', + /** 某个角色正在发言 */ + SPEAKING = 'speaking', + /** 等待用户输入(可以插话或继续) */ + WAITING_INPUT = 'waiting_input', + /** 会议已结束,得出结论 */ + CONCLUDED = 'concluded', + /** 发生错误 */ + ERROR = 'error', +} + +/** + * SSE 事件类型 + */ +export type MeetingEventType = + | 'session_start' + | 'order_decided' + | 'speaking_start' + | 'message' + | 'meeting_end'; + +/** + * SSE 事件接口 + */ +export interface MeetingEvent { + type: MeetingEventType; + session_id?: string; + order?: string[]; + role_id?: string; + role_name?: string; + message?: MeetingMessage; + is_concluded?: boolean; + round_number?: number; +} diff --git a/src/views/AgentChat/hooks/index.ts b/src/views/AgentChat/hooks/index.ts index bb28170e..b94d45bd 100644 --- a/src/views/AgentChat/hooks/index.ts +++ b/src/views/AgentChat/hooks/index.ts @@ -32,3 +32,9 @@ export type { UseAgentChatParams, UseAgentChatReturn, } from './useAgentChat'; + +export { useInvestmentMeeting } from './useInvestmentMeeting'; +export type { + UseInvestmentMeetingParams, + UseInvestmentMeetingReturn, +} from './useInvestmentMeeting'; diff --git a/src/views/AgentChat/hooks/useInvestmentMeeting.ts b/src/views/AgentChat/hooks/useInvestmentMeeting.ts new file mode 100644 index 00000000..e9a9d36f --- /dev/null +++ b/src/views/AgentChat/hooks/useInvestmentMeeting.ts @@ -0,0 +1,403 @@ +// src/views/AgentChat/hooks/useInvestmentMeeting.ts +// 投研会议室 Hook - 管理会议状态、发送消息、处理 SSE 流 + +import { useState, useCallback, useRef } from 'react'; +import axios from 'axios'; +import { + MeetingMessage, + MeetingStatus, + MeetingEvent, + MeetingResponse, + getRoleConfig, +} from '../constants/meetingRoles'; + +/** + * useInvestmentMeeting Hook 参数 + */ +export interface UseInvestmentMeetingParams { + /** 当前用户 ID */ + userId?: string; + /** 当前用户昵称 */ + userNickname?: string; + /** Toast 通知函数 */ + onToast?: (options: { + title: string; + description?: string; + status: 'success' | 'error' | 'warning' | 'info'; + }) => void; +} + +/** + * useInvestmentMeeting Hook 返回值 + */ +export interface UseInvestmentMeetingReturn { + /** 会议消息列表 */ + messages: MeetingMessage[]; + /** 会议状态 */ + status: MeetingStatus; + /** 当前正在发言的角色 ID */ + speakingRoleId: string | null; + /** 当前会话 ID */ + sessionId: string | null; + /** 当前轮次 */ + currentRound: number; + /** 是否已得出结论 */ + isConcluded: boolean; + /** 结论消息 */ + conclusion: MeetingMessage | null; + /** 输入框内容 */ + inputValue: string; + /** 设置输入框内容 */ + setInputValue: (value: string) => void; + /** 开始会议(用户提出议题) */ + startMeeting: (topic: string) => Promise; + /** 继续会议(下一轮讨论) */ + continueMeeting: (userMessage?: string) => Promise; + /** 用户插话 */ + sendUserMessage: (message: string) => Promise; + /** 重置会议 */ + resetMeeting: () => void; + /** 当前议题 */ + currentTopic: string; + /** 是否正在加载 */ + isLoading: boolean; +} + +/** + * 投研会议室 Hook + * + * 管理投研会议的完整生命周期: + * 1. 启动会议(用户提出议题) + * 2. 处理角色发言(支持流式和非流式) + * 3. 用户插话 + * 4. 继续讨论 + * 5. 得出结论 + */ +export const useInvestmentMeeting = ({ + userId = 'anonymous', + userNickname = '匿名用户', + onToast, +}: UseInvestmentMeetingParams = {}): UseInvestmentMeetingReturn => { + // 会议状态 + const [messages, setMessages] = useState([]); + const [status, setStatus] = useState(MeetingStatus.IDLE); + const [speakingRoleId, setSpeakingRoleId] = useState(null); + const [sessionId, setSessionId] = useState(null); + const [currentRound, setCurrentRound] = useState(0); + const [isConcluded, setIsConcluded] = useState(false); + const [conclusion, setConclusion] = useState(null); + const [currentTopic, setCurrentTopic] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [inputValue, setInputValue] = useState(''); + + // 用于取消 SSE 连接 + const eventSourceRef = useRef(null); + + /** + * 添加消息到列表 + */ + const addMessage = useCallback((message: MeetingMessage) => { + setMessages((prev) => [ + ...prev, + { + ...message, + id: message.id || Date.now() + Math.random(), + }, + ]); + }, []); + + /** + * 重置会议状态 + */ + const resetMeeting = useCallback(() => { + // 关闭 SSE 连接 + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + + setMessages([]); + setStatus(MeetingStatus.IDLE); + setSpeakingRoleId(null); + setSessionId(null); + setCurrentRound(0); + setIsConcluded(false); + setConclusion(null); + setCurrentTopic(''); + setIsLoading(false); + setInputValue(''); + }, []); + + /** + * 启动会议(使用流式 SSE) + */ + const startMeetingStream = useCallback( + async (topic: string) => { + setCurrentTopic(topic); + setStatus(MeetingStatus.STARTING); + setIsLoading(true); + setMessages([]); + + 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', + { + topic, + user_id: userId, + user_nickname: userNickname, + } + ); + + if (response.data.success) { + const data = response.data; + + setSessionId(data.session_id); + setCurrentRound(data.round_number); + setIsConcluded(data.is_concluded); + + // 添加所有消息 + data.messages.forEach((msg) => { + addMessage(msg); + }); + + // 设置结论 + if (data.conclusion) { + setConclusion(data.conclusion); + } + + setStatus( + data.is_concluded + ? MeetingStatus.CONCLUDED + : MeetingStatus.WAITING_INPUT + ); + } else { + throw new Error('会议启动失败'); + } + } catch (error: any) { + console.error('启动会议失败:', error); + setStatus(MeetingStatus.ERROR); + onToast?.({ + title: '启动会议失败', + description: error.response?.data?.detail || error.message, + status: 'error', + }); + } finally { + setIsLoading(false); + } + }, + [userId, userNickname, addMessage, onToast] + ); + + /** + * 继续会议讨论 + */ + const continueMeeting = useCallback( + async (userMessage?: string) => { + if (!currentTopic) { + onToast?.({ + title: '无法继续', + description: '请先启动会议', + status: 'warning', + }); + return; + } + + setStatus(MeetingStatus.DISCUSSING); + setIsLoading(true); + + try { + const response = await axios.post( + '/mcp/agent/meeting/continue', + { + topic: currentTopic, + user_id: userId, + user_nickname: userNickname, + session_id: sessionId, + user_message: userMessage, + conversation_history: messages, + } + ); + + if (response.data.success) { + const data = response.data; + + setCurrentRound(data.round_number); + setIsConcluded(data.is_concluded); + + // 添加新的消息 + data.messages.forEach((msg) => { + addMessage(msg); + }); + + // 设置结论 + if (data.conclusion) { + setConclusion(data.conclusion); + } + + setStatus( + data.is_concluded + ? MeetingStatus.CONCLUDED + : MeetingStatus.WAITING_INPUT + ); + } + } catch (error: any) { + console.error('继续会议失败:', error); + setStatus(MeetingStatus.ERROR); + onToast?.({ + title: '继续会议失败', + description: error.response?.data?.detail || error.message, + status: 'error', + }); + } finally { + setIsLoading(false); + } + }, + [currentTopic, userId, userNickname, sessionId, messages, addMessage, onToast] + ); + + /** + * 用户发送消息(插话) + */ + const sendUserMessage = useCallback( + async (message: string) => { + if (!message.trim()) return; + + // 先添加用户消息到列表 + const userRole = getRoleConfig('user'); + addMessage({ + role_id: 'user', + role_name: '用户', + nickname: userNickname, + avatar: userRole?.avatar || '', + color: userRole?.color || '#6366F1', + content: message, + timestamp: new Date().toISOString(), + round_number: currentRound, + }); + + // 清空输入框 + setInputValue(''); + + // 继续会议,带上用户消息 + await continueMeeting(message); + }, + [userNickname, currentRound, addMessage, continueMeeting] + ); + + return { + messages, + status, + speakingRoleId, + sessionId, + currentRound, + isConcluded, + conclusion, + inputValue, + setInputValue, + startMeeting, + continueMeeting, + sendUserMessage, + resetMeeting, + currentTopic, + isLoading, + }; +}; + +export default useInvestmentMeeting; diff --git a/src/views/AgentChat/index.js b/src/views/AgentChat/index.js index 7651d6bd..ef7494f4 100644 --- a/src/views/AgentChat/index.js +++ b/src/views/AgentChat/index.js @@ -1,9 +1,12 @@ // src/views/AgentChat/index.js // 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本 // 使用 Framer Motion 物理动画引擎 + 毛玻璃效果 +// 支持两种模式:单一聊天模式 & 投研会议室模式 import React, { useState } from 'react'; -import { Box, Flex, useToast } from '@chakra-ui/react'; +import { Box, Flex, useToast, HStack, Button, Tooltip } from '@chakra-ui/react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { MessageSquare, Users } from 'lucide-react'; import { useAuth } from '@contexts/AuthContext'; // 常量配置 - 从 TypeScript 模块导入 @@ -14,10 +17,19 @@ import { DEFAULT_SELECTED_TOOLS } from './constants/tools'; import LeftSidebar from './components/LeftSidebar'; import ChatArea from './components/ChatArea'; import RightSidebar from './components/RightSidebar'; +import MeetingRoom from './components/MeetingRoom'; // 自定义 Hooks import { useAgentSessions, useAgentChat, useFileUpload } from './hooks'; +/** + * 聊天模式枚举 + */ +const ChatMode = { + SINGLE: 'single', // 单一聊天模式 + MEETING: 'meeting', // 投研会议室模式 +}; + /** * Agent Chat - 主组件(HeroUI v3 深色主题) * @@ -28,13 +40,19 @@ import { useAgentSessions, useAgentChat, useFileUpload } from './hooks'; * * 主组件职责: * 1. 组合各个自定义 Hooks - * 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择) + * 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择、聊天模式) * 3. 组合渲染子组件 + * + * 新增功能(2024-11): + * - 投研会议室模式:多 AI 角色协作讨论投资议题 */ const AgentChat = () => { const { user } = useAuth(); const toast = useToast(); + // ==================== 聊天模式状态 ==================== + const [chatMode, setChatMode] = useState(ChatMode.SINGLE); + // ==================== UI 状态(主组件管理)==================== const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID); const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS); @@ -88,52 +106,129 @@ const AgentChat = () => { // ==================== 渲染组件 ==================== return ( - - {/* 左侧栏 */} - setIsLeftSidebarOpen(false)} - sessions={sessions} - currentSessionId={currentSessionId} - onSessionSwitch={switchSession} - onNewSession={createNewSession} - isLoadingSessions={isLoadingSessions} - user={user} - /> + + {/* 模式切换栏 */} + + + + + + + - {/* 中间聊天区 */} - setIsLeftSidebarOpen(true)} - onToggleRightSidebar={() => setIsRightSidebarOpen(true)} - onNewSession={createNewSession} - userAvatar={user?.avatar} - inputRef={inputRef} - fileInputRef={fileInputRef} - /> + + + + + + + - {/* 右侧栏 */} - setIsRightSidebarOpen(false)} - selectedModel={selectedModel} - onModelChange={setSelectedModel} - selectedTools={selectedTools} - onToolsChange={setSelectedTools} - sessionsCount={sessions.length} - messagesCount={messages.length} - /> + {/* 主内容区 */} + + + {chatMode === ChatMode.SINGLE ? ( + + {/* 左侧栏 */} + setIsLeftSidebarOpen(false)} + sessions={sessions} + currentSessionId={currentSessionId} + onSessionSwitch={switchSession} + onNewSession={createNewSession} + isLoadingSessions={isLoadingSessions} + user={user} + /> + + {/* 中间聊天区 */} + setIsLeftSidebarOpen(true)} + onToggleRightSidebar={() => setIsRightSidebarOpen(true)} + onNewSession={createNewSession} + userAvatar={user?.avatar} + inputRef={inputRef} + fileInputRef={fileInputRef} + /> + + {/* 右侧栏 */} + setIsRightSidebarOpen(false)} + selectedModel={selectedModel} + onModelChange={setSelectedModel} + selectedTools={selectedTools} + onToolsChange={setSelectedTools} + sessionsCount={sessions.length} + messagesCount={messages.length} + /> + + ) : ( + + {/* 投研会议室 */} + + + )} + + ); };