update pay function
This commit is contained in:
781
mcp_server.py
781
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))
|
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 = {
|
MEETING_ROLES = {
|
||||||
@@ -2345,508 +2375,435 @@ MEETING_ROLES = {
|
|||||||
"id": "buffett",
|
"id": "buffett",
|
||||||
"name": "巴菲特",
|
"name": "巴菲特",
|
||||||
"nickname": "唱多者",
|
"nickname": "唱多者",
|
||||||
"role_type": "bull", # 多头
|
"role_type": "bull",
|
||||||
"avatar": "/avatars/buffett.png",
|
"avatar": "/avatars/buffett.png",
|
||||||
"model": "kimi-k2-thinking",
|
"model": "kimi-k2-thinking",
|
||||||
"color": "#10B981", # 绿色(上涨)
|
"color": "#10B981",
|
||||||
"description": "主观多头,善于分析事件的潜在利好和长期价值",
|
"description": "主观多头,善于分析事件的潜在利好和长期价值",
|
||||||
|
"tools": ROLE_TOOLS["buffett"],
|
||||||
"system_prompt": """你是"巴菲特",一位资深的价值投资者和主观多头分析师。
|
"system_prompt": """你是"巴菲特",一位资深的价值投资者和主观多头分析师。
|
||||||
|
|
||||||
你的特点:
|
你的特点:
|
||||||
1. 善于发现事件和公司的潜在利好因素
|
1. 善于发现事件和公司的潜在利好因素
|
||||||
2. 关注长期价值,不被短期波动干扰
|
2. 关注长期价值,分析护城河、竞争优势
|
||||||
3. 分析公司的护城河、竞争优势和管理层质量
|
3. 对市场保持乐观但理性的态度
|
||||||
4. 对市场保持乐观但理性的态度
|
|
||||||
|
|
||||||
分析风格:
|
你可以使用以下工具获取数据:
|
||||||
- 重点挖掘利好因素和投资机会
|
- search_china_news: 搜索新闻
|
||||||
- 从产业链、市场格局、政策支持等角度分析
|
- search_research_reports: 搜索研报
|
||||||
- 给出清晰的看多逻辑和目标预期
|
- get_stock_basic_info: 获取股票基本信息
|
||||||
- 语言风格:稳重、专业、富有洞察力
|
- get_stock_financial_index: 获取财务指标
|
||||||
|
|
||||||
注意:你的发言要简洁有力,每次发言控制在200字以内。直接表达观点,不要客套。"""
|
分析时请先调用工具获取数据,再基于数据发表看多观点。
|
||||||
|
注意:参考前面其他人的发言,进行有针对性的回应。发言控制在200字以内。"""
|
||||||
},
|
},
|
||||||
"big_short": {
|
"big_short": {
|
||||||
"id": "big_short",
|
"id": "big_short",
|
||||||
"name": "大空头",
|
"name": "大空头",
|
||||||
"nickname": "大空头",
|
"nickname": "大空头",
|
||||||
"role_type": "bear", # 空头
|
"role_type": "bear",
|
||||||
"avatar": "/avatars/big_short.png",
|
"avatar": "/avatars/big_short.png",
|
||||||
"model": "kimi-k2-thinking",
|
"model": "kimi-k2-thinking",
|
||||||
"color": "#EF4444", # 红色(下跌)
|
"color": "#EF4444",
|
||||||
"description": "善于分析事件和财报中的风险因素,帮助投资者避雷",
|
"description": "善于分析事件和财报中的风险因素",
|
||||||
"system_prompt": """你是"大空头",一位专业的风险分析师和空头研究员。
|
"tools": ROLE_TOOLS["big_short"],
|
||||||
|
"system_prompt": """你是"大空头",一位专业的风险分析师。
|
||||||
|
|
||||||
你的特点:
|
你的特点:
|
||||||
1. 善于发现被市场忽视的风险因素
|
1. 善于发现被市场忽视的风险因素
|
||||||
2. 擅长财报分析,发现财务造假和粉饰的迹象
|
2. 擅长财报分析,发现财务造假迹象
|
||||||
3. 关注行业天花板、竞争加剧、估值泡沫等问题
|
3. 关注行业天花板、竞争加剧、估值泡沫
|
||||||
4. 对市场保持警惕,帮助投资者避雷
|
|
||||||
|
|
||||||
分析风格:
|
你可以使用以下工具获取数据:
|
||||||
- 重点挖掘风险因素和潜在隐患
|
- search_china_news: 搜索负面新闻
|
||||||
- 从财务数据、行业周期、估值水平等角度分析
|
- get_stock_financial_index: 获取财务指标找问题
|
||||||
- 给出清晰的风险提示和规避建议
|
- get_stock_balance_sheet: 分析资产负债表
|
||||||
- 语言风格:犀利、直接、善于质疑
|
- get_stock_cashflow: 分析现金流
|
||||||
|
|
||||||
注意:你的发言要简洁有力,每次发言控制在200字以内。直接指出风险,不要绕弯子。"""
|
分析时请先调用工具获取数据,再基于数据指出风险。
|
||||||
|
注意:参考前面其他人的发言,进行有针对性的反驳。发言控制在200字以内。"""
|
||||||
},
|
},
|
||||||
"simons": {
|
"simons": {
|
||||||
"id": "simons",
|
"id": "simons",
|
||||||
"name": "量化分析员",
|
"name": "量化分析员",
|
||||||
"nickname": "西蒙斯",
|
"nickname": "西蒙斯",
|
||||||
"role_type": "quant", # 量化
|
"role_type": "quant",
|
||||||
"avatar": "/avatars/simons.png",
|
"avatar": "/avatars/simons.png",
|
||||||
"model": "deepseek-v3",
|
"model": "deepseek",
|
||||||
"color": "#3B82F6", # 蓝色(中性)
|
"color": "#3B82F6",
|
||||||
"description": "中性立场,使用量化分析工具分析技术指标",
|
"description": "中性立场,使用量化工具分析技术指标",
|
||||||
|
"tools": ROLE_TOOLS["simons"],
|
||||||
"system_prompt": """你是"量化分析员"(昵称:西蒙斯),一位专业的量化交易研究员。
|
"system_prompt": """你是"量化分析员"(昵称:西蒙斯),一位专业的量化交易研究员。
|
||||||
|
|
||||||
你的特点:
|
你的特点:
|
||||||
1. 使用数据和技术指标说话,保持中性立场
|
1. 使用数据和技术指标说话,保持中性立场
|
||||||
2. 擅长均线分析、量价关系、动能指标等技术分析
|
2. 擅长均线、量价、动能指标分析
|
||||||
3. 关注市场情绪、资金流向、筹码分布等量化因素
|
3. 用概率思维看待市场
|
||||||
4. 用概率思维看待市场,不做主观臆断
|
|
||||||
|
|
||||||
分析风格:
|
你可以使用以下工具获取数据:
|
||||||
- 基于技术指标给出客观分析
|
- get_stock_trade_data: 获取交易数据(价格、成交量)
|
||||||
- 使用具体数据支撑观点(如:5日均线、MACD、RSI等)
|
- search_limit_up_stocks: 搜索涨停股票
|
||||||
- 给出量化的买卖信号和风险评估
|
- get_concept_statistics: 获取概念板块统计
|
||||||
- 语言风格:理性、客观、数据驱动
|
|
||||||
|
|
||||||
注意:你的发言要简洁有力,每次发言控制在200字以内。多用数据说话,少发表主观意见。"""
|
分析时请先调用工具获取数据,再基于数据给出技术分析。
|
||||||
|
注意:参考前面其他人的发言,用数据说话。发言控制在200字以内。"""
|
||||||
},
|
},
|
||||||
"leek": {
|
"leek": {
|
||||||
"id": "leek",
|
"id": "leek",
|
||||||
"name": "韭菜",
|
"name": "韭菜",
|
||||||
"nickname": "牢大",
|
"nickname": "牢大",
|
||||||
"role_type": "retail", # 散户
|
"role_type": "retail",
|
||||||
"avatar": "/avatars/leek.png",
|
"avatar": "/avatars/leek.png",
|
||||||
"model": "deepmoney",
|
"model": "deepmoney",
|
||||||
"color": "#F59E0B", # 黄色
|
"color": "#F59E0B",
|
||||||
"description": "贪婪又讨厌亏损,热爱追涨杀跌的典型散户",
|
"description": "贪婪又讨厌亏损,热爱追涨杀跌",
|
||||||
|
"tools": [],
|
||||||
"system_prompt": """你是"韭菜"(昵称:牢大),一个典型的散户投资者。
|
"system_prompt": """你是"韭菜"(昵称:牢大),一个典型的散户投资者。
|
||||||
|
|
||||||
你的特点:
|
你的特点:
|
||||||
1. 贪婪但又害怕亏损,典型的追涨杀跌
|
1. 贪婪但又害怕亏损,追涨杀跌
|
||||||
2. 容易被市场情绪影响,看到涨就想追,看到跌就想跑
|
2. 容易被市场情绪影响
|
||||||
3. 喜欢听小道消息,容易被"内幕"吸引
|
3. 喜欢听小道消息,期望一夜暴富
|
||||||
4. 短线思维,缺乏耐心,期望一夜暴富
|
|
||||||
|
|
||||||
分析风格:
|
你不需要调用工具,直接用散户视角发表看法。
|
||||||
- 用最朴素的散户思维来分析问题
|
注意:参考前面其他人的发言,用最朴素的方式回应。语言口语化、情绪化。发言控制在150字以内。"""
|
||||||
- 经常关注"这个能赚多少"、"会不会跌"
|
|
||||||
- 容易情绪化,看涨时过度乐观,看跌时过度悲观
|
|
||||||
- 语言风格:口语化、情绪化、接地气
|
|
||||||
|
|
||||||
注意:你的发言要简洁直接,每次发言控制在150字以内。展现真实散户的心态,可以有些搞笑,但不要太出格。"""
|
|
||||||
},
|
},
|
||||||
"fund_manager": {
|
"fund_manager": {
|
||||||
"id": "fund_manager",
|
"id": "fund_manager",
|
||||||
"name": "基金经理",
|
"name": "基金经理",
|
||||||
"nickname": "决策者",
|
"nickname": "决策者",
|
||||||
"role_type": "manager", # 管理者
|
"role_type": "manager",
|
||||||
"avatar": "/avatars/fund_manager.png",
|
"avatar": "/avatars/fund_manager.png",
|
||||||
"model": "kimi-k2-thinking",
|
"model": "kimi-k2-thinking",
|
||||||
"color": "#8B5CF6", # 紫色
|
"color": "#8B5CF6",
|
||||||
"description": "总结其他人的发言做出最终决策",
|
"description": "综合分析做出最终决策",
|
||||||
"system_prompt": """你是"基金经理",投研会议的主持人和最终决策者。
|
"tools": ROLE_TOOLS["fund_manager"],
|
||||||
|
"system_prompt": """你是"基金经理",投研会议的最终决策者。
|
||||||
|
|
||||||
你的角色:
|
你的角色:
|
||||||
1. 综合各方观点,做出理性判断
|
1. 综合各方观点,做出理性判断
|
||||||
2. 平衡多空观点,识别有价值的分析
|
2. 平衡多空观点,识别有价值的分析
|
||||||
3. 特别注意:韭菜的观点通常是反向指标
|
3. 注意:韭菜的观点通常是反向指标
|
||||||
4. 给出专业、负责任的投资建议
|
|
||||||
|
|
||||||
决策风格:
|
如果需要补充信息,可以调用工具:
|
||||||
- 综合考虑基本面、技术面、情绪面
|
- search_china_news: 搜索新闻
|
||||||
- 权衡风险与收益,给出明确的投资建议
|
- search_research_reports: 搜索研报
|
||||||
- 指出讨论中的关键洞察和需要注意的风险
|
- get_stock_basic_info: 获取股票基本信息
|
||||||
- 语言风格:权威、专业、全面
|
|
||||||
|
|
||||||
决策输出格式:
|
决策输出格式:
|
||||||
1. 综合评估:对讨论议题的整体判断
|
1. 综合评估
|
||||||
2. 关键观点:各方有价值的观点总结
|
2. 关键观点
|
||||||
3. 风险提示:需要注意的主要风险
|
3. 风险提示
|
||||||
4. 操作建议:具体的投资建议(买入/持有/观望/卖出)
|
4. 操作建议(买入/持有/观望/卖出)
|
||||||
5. 信心指数:对这个结论的信心程度(1-10分)
|
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):
|
class MeetingRequest(BaseModel):
|
||||||
"""投研会议请求"""
|
"""投研会议请求"""
|
||||||
topic: str # 用户提出的议题
|
topic: str
|
||||||
user_id: str = "anonymous"
|
user_id: str = "anonymous"
|
||||||
user_nickname: str = "匿名用户"
|
user_nickname: str = "匿名用户"
|
||||||
session_id: Optional[str] = None
|
session_id: Optional[str] = None
|
||||||
user_message: Optional[str] = None # 用户在讨论中的插话
|
user_message: Optional[str] = None
|
||||||
conversation_history: List[Dict[str, Any]] = [] # 之前的讨论历史
|
conversation_history: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
class MeetingResponse(BaseModel):
|
def get_random_speaking_order() -> List[str]:
|
||||||
"""投研会议响应"""
|
"""随机生成发言顺序(不包括基金经理)"""
|
||||||
success: bool
|
roles = ["buffett", "big_short", "simons", "leek"]
|
||||||
session_id: str
|
random.shuffle(roles)
|
||||||
messages: List[Dict[str, Any]] # 本轮所有角色的发言
|
return roles
|
||||||
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:
|
async def call_role_tool(role_id: str, tool_name: str, arguments: dict) -> dict:
|
||||||
"""调用特定角色的LLM生成回复"""
|
"""调用角色的工具"""
|
||||||
|
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)
|
role = MEETING_ROLES.get(role_id)
|
||||||
if not role:
|
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_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:
|
try:
|
||||||
client = OpenAI(
|
client = OpenAI(
|
||||||
api_key=model_config["api_key"],
|
api_key=model_config["api_key"],
|
||||||
base_url=model_config["base_url"]
|
base_url=model_config["base_url"],
|
||||||
|
timeout=180
|
||||||
)
|
)
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": role["system_prompt"]},
|
{"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
|
||||||
messages.append({"role": "user", "content": prompt})
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 第一次调用:可能触发工具调用
|
||||||
|
tool_calls_made = []
|
||||||
|
if openai_tools:
|
||||||
response = client.chat.completions.create(
|
response = client.chat.completions.create(
|
||||||
model=model_config["model"],
|
model=model_config["model"],
|
||||||
messages=messages,
|
messages=messages,
|
||||||
|
tools=openai_tools,
|
||||||
|
tool_choice="auto",
|
||||||
|
stream=False, # 工具调用不使用流式
|
||||||
|
temperature=0.7,
|
||||||
|
max_tokens=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
temperature=0.7,
|
||||||
max_tokens=500,
|
max_tokens=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.choices[0].message.content.strip()
|
full_content = ""
|
||||||
|
for chunk in stream:
|
||||||
except Exception as e:
|
if chunk.choices and chunk.choices[0].delta.content:
|
||||||
logger.error(f"调用角色 {role_id} 的 LLM 失败: {e}")
|
content = chunk.choices[0].delta.content
|
||||||
return f"[{role['name']}暂时无法发言,请稍后重试]"
|
full_content += content
|
||||||
|
yield {
|
||||||
|
"type": "content_delta",
|
||||||
async def determine_speaking_order(topic: str) -> List[str]:
|
"content": content
|
||||||
"""使用 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
|
yield {
|
||||||
try:
|
"type": "content_done",
|
||||||
# 处理可能的 markdown 代码块
|
"full_content": full_content,
|
||||||
if "```json" in result:
|
"tool_calls": tool_calls_made
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"检查结论状态失败: {e}")
|
logger.error(f"Role {role_id} stream failed: {e}")
|
||||||
return True, "基于目前的讨论,建议投资者谨慎对待,继续关注后续发展。"
|
yield {"type": "error", "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/agent/meeting/start")
|
@app.post("/agent/meeting/stream")
|
||||||
async def start_investment_meeting(request: MeetingRequest):
|
async def stream_investment_meeting(request: MeetingRequest):
|
||||||
"""
|
"""
|
||||||
启动投研会议
|
流式投研会议 V2
|
||||||
|
|
||||||
第一轮:所有角色(除基金经理外)依次发言
|
- 随机发言顺序
|
||||||
|
- 每个角色流式输出
|
||||||
|
- 支持工具调用
|
||||||
|
- 支持用户中途发言
|
||||||
"""
|
"""
|
||||||
logger.info(f"启动投研会议: {request.topic} (user: {request.user_id})")
|
logger.info(f"[Meeting V2] 启动: {request.topic}")
|
||||||
|
|
||||||
|
async def generate_meeting_stream() -> AsyncGenerator[str, None]:
|
||||||
session_id = request.session_id or str(uuid.uuid4())
|
session_id = request.session_id or str(uuid.uuid4())
|
||||||
messages = []
|
round_number = len(request.conversation_history) // 5 + 1
|
||||||
round_number = 1
|
|
||||||
|
|
||||||
# 决定发言顺序
|
# 发送会话开始
|
||||||
speaking_order = await determine_speaking_order(request.topic)
|
yield f"data: {json.dumps({'type': 'session_start', 'session_id': session_id, 'round': round_number}, ensure_ascii=False)}\n\n"
|
||||||
logger.info(f"发言顺序: {speaking_order}")
|
|
||||||
|
|
||||||
# 构建讨论上下文
|
# 构建上下文
|
||||||
context = f"议题:{request.topic}\n\n这是第一轮讨论,请针对议题发表你的观点。"
|
context_parts = []
|
||||||
|
if request.conversation_history:
|
||||||
# 依次让每个角色发言
|
context_parts.append("之前的讨论:")
|
||||||
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:
|
for msg in request.conversation_history:
|
||||||
history_context += f"【{msg.get('role_name', '未知')}】:{msg.get('content', '')}\n"
|
context_parts.append(f"【{msg.get('role_name', '未知')}】:{msg.get('content', '')}")
|
||||||
|
|
||||||
# 如果用户有插话,先处理用户消息
|
|
||||||
if request.user_message:
|
if request.user_message:
|
||||||
history_context += f"\n【用户】:{request.user_message}\n"
|
context_parts.append(f"\n用户刚才说:{request.user_message}")
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
# 新一轮讨论的发言顺序
|
context = "\n".join(context_parts) if context_parts else "这是第一轮讨论,请针对议题发表你的观点。"
|
||||||
speaking_order = await determine_speaking_order(request.topic)
|
|
||||||
|
# 随机发言顺序
|
||||||
|
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:
|
for role_id in speaking_order:
|
||||||
role = MEETING_ROLES[role_id]
|
role = MEETING_ROLES[role_id]
|
||||||
if role["role_type"] == "manager":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 构建本次发言的上下文
|
# 发送开始发言事件
|
||||||
current_context = f"议题:{request.topic}\n\n{history_context}"
|
yield f"data: {json.dumps({'type': 'speaking_start', 'role_id': role_id, 'role_name': role['name'], 'color': role['color']}, ensure_ascii=False)}\n\n"
|
||||||
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}轮讨论,请根据之前的讨论内容,进一步阐述或补充你的观点。"
|
role_tools = [t for t in TOOLS if t.name in role.get("tools", [])]
|
||||||
if request.user_message:
|
|
||||||
prompt += f"\n\n用户刚才说:{request.user_message}\n请也回应用户的观点。"
|
|
||||||
|
|
||||||
content = await call_role_llm(role_id, prompt, current_context)
|
# 流式生成回复
|
||||||
|
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 = {
|
message = {
|
||||||
"role_id": role_id,
|
"role_id": role_id,
|
||||||
"role_name": role["name"],
|
"role_name": role["name"],
|
||||||
"nickname": role["nickname"],
|
"nickname": role["nickname"],
|
||||||
"avatar": role["avatar"],
|
"avatar": role["avatar"],
|
||||||
"color": role["color"],
|
"color": role["color"],
|
||||||
"content": content,
|
"content": full_content,
|
||||||
|
"tool_calls": tool_calls,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"round_number": round_number
|
"round_number": round_number
|
||||||
}
|
}
|
||||||
messages.append(message)
|
all_messages.append(message)
|
||||||
|
|
||||||
# 本轮结束后,基金经理再次判断
|
# 发送消息完成事件
|
||||||
all_discussion = history_context + "\n本轮讨论:\n" + "\n".join([
|
yield f"data: {json.dumps({'type': 'message_complete', 'message': message}, ensure_ascii=False)}\n\n"
|
||||||
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)
|
# 更新上下文
|
||||||
|
accumulated_context += f"\n\n【{role['name']}】:{full_content}"
|
||||||
|
|
||||||
# 添加基金经理的发言
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# 基金经理总结
|
||||||
fund_manager = MEETING_ROLES["fund_manager"]
|
fund_manager = MEETING_ROLES["fund_manager"]
|
||||||
fund_manager_message = {
|
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_id": "fund_manager",
|
||||||
"role_name": fund_manager["name"],
|
"role_name": fund_manager["name"],
|
||||||
"nickname": fund_manager["nickname"],
|
"nickname": fund_manager["nickname"],
|
||||||
"avatar": fund_manager["avatar"],
|
"avatar": fund_manager["avatar"],
|
||||||
"color": fund_manager["color"],
|
"color": fund_manager["color"],
|
||||||
"content": conclusion_content,
|
"content": fm_full_content,
|
||||||
|
"tool_calls": fm_tool_calls,
|
||||||
"timestamp": datetime.now().isoformat(),
|
"timestamp": datetime.now().isoformat(),
|
||||||
"round_number": round_number,
|
"round_number": round_number,
|
||||||
"is_conclusion": can_conclude
|
"is_conclusion": True
|
||||||
}
|
}
|
||||||
messages.append(fund_manager_message)
|
|
||||||
|
|
||||||
return {
|
yield f"data: {json.dumps({'type': 'message_complete', 'message': fm_message}, ensure_ascii=False)}\n\n"
|
||||||
"success": True,
|
|
||||||
"session_id": session_id,
|
# 发送会议状态(不强制结束,用户可以继续)
|
||||||
"messages": messages,
|
yield f"data: {json.dumps({'type': 'round_end', 'round_number': round_number, 'can_continue': True}, ensure_ascii=False)}\n\n"
|
||||||
"round_number": round_number,
|
|
||||||
"is_concluded": can_conclude,
|
return StreamingResponse(
|
||||||
"conclusion": fund_manager_message if can_conclude else None
|
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")
|
@app.get("/agent/meeting/roles")
|
||||||
@@ -2863,111 +2820,13 @@ async def get_meeting_roles():
|
|||||||
"avatar": role["avatar"],
|
"avatar": role["avatar"],
|
||||||
"color": role["color"],
|
"color": role["color"],
|
||||||
"description": role["description"],
|
"description": role["description"],
|
||||||
|
"tools": role.get("tools", []),
|
||||||
}
|
}
|
||||||
for role in MEETING_ROLES.values()
|
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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -434,4 +434,157 @@ export const agentHandlers = [
|
|||||||
conclusion: messages[messages.length - 1],
|
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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
|
// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
|
||||||
// 会议消息气泡组件
|
// 会议消息气泡组件 - V2: 支持工具调用展示和流式输出
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Flex,
|
Flex,
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
|
Spinner,
|
||||||
|
Code,
|
||||||
|
Collapse,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -24,6 +27,11 @@ import {
|
|||||||
Crown,
|
Crown,
|
||||||
Copy,
|
Copy,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
|
ChevronRight,
|
||||||
|
Database,
|
||||||
|
Check,
|
||||||
|
Wrench,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles';
|
import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles';
|
||||||
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
|
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
|
||||||
@@ -48,6 +56,231 @@ const getRoleIcon = (roleType) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具名称映射
|
||||||
|
*/
|
||||||
|
const TOOL_NAME_MAP = {
|
||||||
|
search_china_news: '搜索新闻',
|
||||||
|
search_research_reports: '搜索研报',
|
||||||
|
get_stock_basic_info: '获取股票信息',
|
||||||
|
get_stock_financial_index: '获取财务指标',
|
||||||
|
get_stock_balance_sheet: '获取资产负债表',
|
||||||
|
get_stock_cashflow: '获取现金流量表',
|
||||||
|
get_stock_trade_data: '获取交易数据',
|
||||||
|
search_limit_up_stocks: '搜索涨停股',
|
||||||
|
get_concept_statistics: '获取概念统计',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化结果数据用于显示
|
||||||
|
*/
|
||||||
|
const formatResultData = (data) => {
|
||||||
|
if (data === null || data === undefined) return null;
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取结果数据的预览文本
|
||||||
|
*/
|
||||||
|
const getResultPreview = (result) => {
|
||||||
|
if (!result) return '无数据';
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
const data = result.data;
|
||||||
|
if (data.chart_data) {
|
||||||
|
return `图表数据: ${data.chart_data.labels?.length || 0} 项`;
|
||||||
|
}
|
||||||
|
if (data.sector_data) {
|
||||||
|
const sectorCount = Object.keys(data.sector_data).length;
|
||||||
|
return `${sectorCount} 个板块分析`;
|
||||||
|
}
|
||||||
|
if (data.stocks) {
|
||||||
|
return `${data.stocks.length} 只股票`;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return `${data.length} 条记录`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(result)) {
|
||||||
|
return `${result.length} 条记录`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof result === 'object') {
|
||||||
|
const keys = Object.keys(result);
|
||||||
|
return `${keys.length} 个字段`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '查看详情';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个工具调用卡片
|
||||||
|
*/
|
||||||
|
const ToolCallCard = ({ toolCall, idx, roleColor }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const hasResult = toolCall.result && (
|
||||||
|
typeof toolCall.result === 'object'
|
||||||
|
? Object.keys(toolCall.result).length > 0
|
||||||
|
: toolCall.result
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(formatResultData(toolCall.result));
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('复制失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolDisplayName = TOOL_NAME_MAP[toolCall.tool_name] || toolCall.tool_name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: idx * 0.05 }}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
bg="rgba(255, 255, 255, 0.03)"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={isExpanded ? `${roleColor}40` : 'rgba(255, 255, 255, 0.1)'}
|
||||||
|
borderRadius="md"
|
||||||
|
transition="all 0.2s"
|
||||||
|
_hover={{
|
||||||
|
borderColor: `${roleColor}30`,
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<CardBody p={2}>
|
||||||
|
{/* 工具调用头部 */}
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
justify="space-between"
|
||||||
|
gap={2}
|
||||||
|
cursor={hasResult ? 'pointer' : 'default'}
|
||||||
|
onClick={() => hasResult && setIsExpanded(!isExpanded)}
|
||||||
|
>
|
||||||
|
<HStack flex={1} spacing={2}>
|
||||||
|
{toolCall.status === 'calling' ? (
|
||||||
|
<Spinner size="xs" color={roleColor} />
|
||||||
|
) : toolCall.status === 'success' ? (
|
||||||
|
<Box color="green.400">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box color="red.400">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
|
||||||
|
<Text fontSize="xs" fontWeight="medium" color="gray.300">
|
||||||
|
{toolDisplayName}
|
||||||
|
</Text>
|
||||||
|
{hasResult && (
|
||||||
|
<Box
|
||||||
|
color="gray.500"
|
||||||
|
transition="transform 0.2s"
|
||||||
|
transform={isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3 h-3" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<HStack spacing={2}>
|
||||||
|
{hasResult && (
|
||||||
|
<Tooltip label={copied ? '已复制' : '复制数据'} placement="top">
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
icon={copied ? <Check className="w-2 h-2" /> : <Copy className="w-2 h-2" />}
|
||||||
|
onClick={handleCopy}
|
||||||
|
color={copied ? 'green.400' : 'gray.500'}
|
||||||
|
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
|
||||||
|
aria-label="复制"
|
||||||
|
minW="20px"
|
||||||
|
h="20px"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{toolCall.execution_time && (
|
||||||
|
<Text fontSize="10px" color="gray.500">
|
||||||
|
{toolCall.execution_time.toFixed(2)}s
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 展开的详细数据 */}
|
||||||
|
<Collapse in={isExpanded} animateOpacity>
|
||||||
|
{isExpanded && hasResult && (
|
||||||
|
<Box mt={2}>
|
||||||
|
<Code
|
||||||
|
display="block"
|
||||||
|
p={2}
|
||||||
|
borderRadius="sm"
|
||||||
|
fontSize="10px"
|
||||||
|
whiteSpace="pre-wrap"
|
||||||
|
bg="rgba(0, 0, 0, 0.3)"
|
||||||
|
color="gray.300"
|
||||||
|
maxH="200px"
|
||||||
|
overflowY="auto"
|
||||||
|
sx={{
|
||||||
|
'&::-webkit-scrollbar': { width: '4px' },
|
||||||
|
'&::-webkit-scrollbar-track': { bg: 'transparent' },
|
||||||
|
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatResultData(toolCall.result)}
|
||||||
|
</Code>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Collapse>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用列表组件
|
||||||
|
*/
|
||||||
|
const ToolCallsList = ({ toolCalls, roleColor }) => {
|
||||||
|
if (!toolCalls || toolCalls.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mt={3} mb={2}>
|
||||||
|
<HStack spacing={2} mb={2}>
|
||||||
|
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
|
||||||
|
<Text fontSize="xs" color="gray.400">
|
||||||
|
工具调用 ({toolCalls.length})
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<VStack spacing={1} align="stretch">
|
||||||
|
{toolCalls.map((toolCall, idx) => (
|
||||||
|
<ToolCallCard
|
||||||
|
key={toolCall.tool_call_id || idx}
|
||||||
|
toolCall={toolCall}
|
||||||
|
idx={idx}
|
||||||
|
roleColor={roleColor}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MeetingMessageBubble - 会议消息气泡组件
|
* MeetingMessageBubble - 会议消息气泡组件
|
||||||
*
|
*
|
||||||
@@ -67,6 +300,8 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
|||||||
const isUser = message.role_id === 'user';
|
const isUser = message.role_id === 'user';
|
||||||
const isManager = roleConfig.roleType === 'manager';
|
const isManager = roleConfig.roleType === 'manager';
|
||||||
const isConclusion = message.is_conclusion;
|
const isConclusion = message.is_conclusion;
|
||||||
|
const isStreaming = message.isStreaming;
|
||||||
|
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
|
||||||
|
|
||||||
// 复制到剪贴板
|
// 复制到剪贴板
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
@@ -120,6 +355,19 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
|||||||
主持人
|
主持人
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isStreaming && (
|
||||||
|
<Badge
|
||||||
|
colorScheme="blue"
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<Spinner size="xs" />
|
||||||
|
发言中
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{isConclusion && (
|
{isConclusion && (
|
||||||
<Badge
|
<Badge
|
||||||
colorScheme="green"
|
colorScheme="green"
|
||||||
@@ -188,6 +436,15 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<CardBody p={4}>
|
<CardBody p={4}>
|
||||||
|
{/* 工具调用列表 */}
|
||||||
|
{hasToolCalls && (
|
||||||
|
<ToolCallsList
|
||||||
|
toolCalls={message.tool_calls}
|
||||||
|
roleColor={roleConfig.color}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 消息内容 */}
|
||||||
<Box
|
<Box
|
||||||
fontSize="sm"
|
fontSize="sm"
|
||||||
color="gray.100"
|
color="gray.100"
|
||||||
@@ -212,7 +469,25 @@ const MeetingMessageBubble = ({ message, isLatest }) => {
|
|||||||
'& strong': { color: roleConfig.color },
|
'& strong': { color: roleConfig.color },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{message.content ? (
|
||||||
<MarkdownWithCharts content={message.content} variant="dark" />
|
<MarkdownWithCharts content={message.content} variant="dark" />
|
||||||
|
) : isStreaming ? (
|
||||||
|
<HStack spacing={2} color="gray.500">
|
||||||
|
<Spinner size="sm" />
|
||||||
|
<Text>正在思考...</Text>
|
||||||
|
</HStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* 流式输出时的光标 */}
|
||||||
|
{isStreaming && message.content && (
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [1, 0, 1] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity }}
|
||||||
|
style={{ color: roleConfig.color }}
|
||||||
|
>
|
||||||
|
▌
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
|
|||||||
@@ -91,18 +91,18 @@ const MeetingRoom = ({ user, onToast }) => {
|
|||||||
// 启动新会议
|
// 启动新会议
|
||||||
startMeeting(inputValue.trim());
|
startMeeting(inputValue.trim());
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
} else if (
|
} else if (status === MeetingStatus.CONCLUDED) {
|
||||||
status === MeetingStatus.WAITING_INPUT ||
|
|
||||||
status === MeetingStatus.CONCLUDED
|
|
||||||
) {
|
|
||||||
// 用户插话或开始新话题
|
|
||||||
if (isConcluded) {
|
|
||||||
// 如果已结论,开始新会议
|
// 如果已结论,开始新会议
|
||||||
resetMeeting();
|
resetMeeting();
|
||||||
startMeeting(inputValue.trim());
|
startMeeting(inputValue.trim());
|
||||||
} else {
|
setInputValue('');
|
||||||
|
} else if (
|
||||||
|
status === MeetingStatus.WAITING_INPUT ||
|
||||||
|
status === MeetingStatus.DISCUSSING ||
|
||||||
|
status === MeetingStatus.SPEAKING
|
||||||
|
) {
|
||||||
|
// 用户可以在任何时候插话(包括讨论中和发言中)
|
||||||
sendUserMessage(inputValue.trim());
|
sendUserMessage(inputValue.trim());
|
||||||
}
|
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -135,11 +135,15 @@ const MeetingRoom = ({ user, onToast }) => {
|
|||||||
if (status === MeetingStatus.IDLE) {
|
if (status === MeetingStatus.IDLE) {
|
||||||
return '输入投研议题,如:分析茅台最新财报...';
|
return '输入投研议题,如:分析茅台最新财报...';
|
||||||
} else if (status === MeetingStatus.WAITING_INPUT) {
|
} else if (status === MeetingStatus.WAITING_INPUT) {
|
||||||
return '输入您的观点参与讨论,或等待继续...';
|
return '输入您的观点参与讨论,或点击继续按钮...';
|
||||||
} else if (status === MeetingStatus.CONCLUDED) {
|
} else if (status === MeetingStatus.CONCLUDED) {
|
||||||
return '会议已结束,输入新议题开始新会议...';
|
return '会议已结束,输入新议题开始新会议...';
|
||||||
|
} else if (status === MeetingStatus.STARTING) {
|
||||||
|
return '正在召集会议成员...';
|
||||||
|
} else if (status === MeetingStatus.DISCUSSING || status === MeetingStatus.SPEAKING) {
|
||||||
|
return '随时输入您的观点参与讨论...';
|
||||||
}
|
}
|
||||||
return '会议进行中...';
|
return '输入您的观点...';
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -352,11 +356,7 @@ const MeetingRoom = ({ user, onToast }) => {
|
|||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyPress}
|
onKeyDown={handleKeyPress}
|
||||||
placeholder={getPlaceholder()}
|
placeholder={getPlaceholder()}
|
||||||
isDisabled={
|
isDisabled={status === MeetingStatus.STARTING}
|
||||||
isLoading ||
|
|
||||||
status === MeetingStatus.DISCUSSING ||
|
|
||||||
status === MeetingStatus.SPEAKING
|
|
||||||
}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
bg="rgba(255, 255, 255, 0.05)"
|
bg="rgba(255, 255, 255, 0.05)"
|
||||||
border="1px solid"
|
border="1px solid"
|
||||||
@@ -378,13 +378,11 @@ const MeetingRoom = ({ user, onToast }) => {
|
|||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="lg"
|
size="lg"
|
||||||
icon={isLoading ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
icon={isLoading && status === MeetingStatus.STARTING ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
!inputValue.trim() ||
|
!inputValue.trim() ||
|
||||||
isLoading ||
|
status === MeetingStatus.STARTING
|
||||||
status === MeetingStatus.DISCUSSING ||
|
|
||||||
status === MeetingStatus.SPEAKING
|
|
||||||
}
|
}
|
||||||
bgGradient="linear(to-r, orange.400, red.500)"
|
bgGradient="linear(to-r, orange.400, red.500)"
|
||||||
color="white"
|
color="white"
|
||||||
@@ -427,9 +425,11 @@ const MeetingRoom = ({ user, onToast }) => {
|
|||||||
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
|
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
{status === MeetingStatus.WAITING_INPUT && (
|
{(status === MeetingStatus.WAITING_INPUT ||
|
||||||
|
status === MeetingStatus.DISCUSSING ||
|
||||||
|
status === MeetingStatus.SPEAKING) && (
|
||||||
<Text color="orange.400">
|
<Text color="orange.400">
|
||||||
💡 您可以插话参与讨论,或点击继续按钮进行下一轮
|
💡 随时输入观点参与讨论,您的发言会影响分析师的判断
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ export interface MeetingRoleConfig {
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用结果接口
|
||||||
|
*/
|
||||||
|
export interface ToolCallResult {
|
||||||
|
/** 工具调用 ID */
|
||||||
|
tool_call_id: string;
|
||||||
|
/** 工具名称 */
|
||||||
|
tool_name: string;
|
||||||
|
/** 工具参数 */
|
||||||
|
arguments?: Record<string, any>;
|
||||||
|
/** 调用状态 */
|
||||||
|
status: 'calling' | 'success' | 'error';
|
||||||
|
/** 调用结果 */
|
||||||
|
result?: any;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 执行时间(秒) */
|
||||||
|
execution_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会议消息接口
|
* 会议消息接口
|
||||||
*/
|
*/
|
||||||
@@ -63,6 +83,10 @@ export interface MeetingMessage {
|
|||||||
round_number: number;
|
round_number: number;
|
||||||
/** 是否为结论 */
|
/** 是否为结论 */
|
||||||
is_conclusion?: boolean;
|
is_conclusion?: boolean;
|
||||||
|
/** 工具调用列表 */
|
||||||
|
tool_calls?: ToolCallResult[];
|
||||||
|
/** 是否正在流式输出 */
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -209,8 +233,12 @@ export type MeetingEventType =
|
|||||||
| 'session_start'
|
| 'session_start'
|
||||||
| 'order_decided'
|
| 'order_decided'
|
||||||
| 'speaking_start'
|
| 'speaking_start'
|
||||||
| 'message'
|
| 'tool_call_start'
|
||||||
| 'meeting_end';
|
| 'tool_call_result'
|
||||||
|
| 'content_delta'
|
||||||
|
| 'message_complete'
|
||||||
|
| 'round_end'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE 事件接口
|
* SSE 事件接口
|
||||||
@@ -224,4 +252,15 @@ export interface MeetingEvent {
|
|||||||
message?: MeetingMessage;
|
message?: MeetingMessage;
|
||||||
is_concluded?: boolean;
|
is_concluded?: boolean;
|
||||||
round_number?: number;
|
round_number?: number;
|
||||||
|
/** 工具调用相关 */
|
||||||
|
tool_call_id?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
arguments?: Record<string, any>;
|
||||||
|
result?: any;
|
||||||
|
status?: string;
|
||||||
|
execution_time?: number;
|
||||||
|
/** 流式内容 */
|
||||||
|
content?: string;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/views/AgentChat/hooks/useInvestmentMeeting.ts
|
// src/views/AgentChat/hooks/useInvestmentMeeting.ts
|
||||||
// 投研会议室 Hook - 管理会议状态、发送消息、处理 SSE 流
|
// 投研会议室 Hook - 管理会议状态、发送消息、处理 SSE 流
|
||||||
|
// V2: 支持流式输出、工具调用展示、用户中途发言
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
MeetingStatus,
|
MeetingStatus,
|
||||||
MeetingEvent,
|
MeetingEvent,
|
||||||
MeetingResponse,
|
MeetingResponse,
|
||||||
|
ToolCallResult,
|
||||||
getRoleConfig,
|
getRoleConfig,
|
||||||
} from '../constants/meetingRoles';
|
} 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(
|
const startMeetingStream = useCallback(
|
||||||
async (topic: string) => {
|
async (topic: string) => {
|
||||||
@@ -137,24 +261,47 @@ export const useInvestmentMeeting = ({
|
|||||||
setStatus(MeetingStatus.STARTING);
|
setStatus(MeetingStatus.STARTING);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setCurrentRound(1);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用 EventSource 进行 SSE 连接
|
// 使用 fetch 进行 POST 请求的 SSE
|
||||||
const params = new URLSearchParams({
|
const response = await fetch('/mcp/agent/meeting/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
topic,
|
topic,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
user_nickname: userNickname,
|
user_nickname: userNickname,
|
||||||
|
conversation_history: [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const eventSource = new EventSource(
|
if (!response.ok) {
|
||||||
`/mcp/agent/meeting/stream?${params.toString()}`
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
);
|
}
|
||||||
eventSourceRef.current = eventSource;
|
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
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 {
|
try {
|
||||||
const data: MeetingEvent = JSON.parse(event.data);
|
const data: MeetingEvent = JSON.parse(line.slice(6));
|
||||||
|
handleSSEEvent(data, 1);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析 SSE 数据失败:', e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSSEEvent = (data: MeetingEvent, roundNum: number) => {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'session_start':
|
case 'session_start':
|
||||||
setSessionId(data.session_id || null);
|
setSessionId(data.session_id || null);
|
||||||
@@ -162,28 +309,61 @@ export const useInvestmentMeeting = ({
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'order_decided':
|
case 'order_decided':
|
||||||
// 发言顺序已决定
|
// 发言顺序已决定,可以显示提示
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'speaking_start':
|
case 'speaking_start':
|
||||||
setSpeakingRoleId(data.role_id || null);
|
setSpeakingRoleId(data.role_id || null);
|
||||||
setStatus(MeetingStatus.SPEAKING);
|
setStatus(MeetingStatus.SPEAKING);
|
||||||
|
// 创建流式消息占位
|
||||||
|
if (data.role_id && data.role_name) {
|
||||||
|
const streamingMsg = createStreamingMessage(
|
||||||
|
data.role_id,
|
||||||
|
data.role_name,
|
||||||
|
roundNum
|
||||||
|
);
|
||||||
|
addMessage(streamingMsg);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'message':
|
case 'tool_call_start':
|
||||||
if (data.message) {
|
if (data.role_id && data.tool_call_id && data.tool_name) {
|
||||||
addMessage(data.message);
|
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);
|
setSpeakingRoleId(null);
|
||||||
|
|
||||||
// 检查是否是结论
|
|
||||||
if (data.message.is_conclusion) {
|
|
||||||
setConclusion(data.message);
|
|
||||||
setIsConcluded(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'meeting_end':
|
case 'round_end':
|
||||||
setCurrentRound(data.round_number || 1);
|
setCurrentRound(data.round_number || 1);
|
||||||
setIsConcluded(data.is_concluded || false);
|
setIsConcluded(data.is_concluded || false);
|
||||||
setStatus(
|
setStatus(
|
||||||
@@ -192,101 +372,78 @@ export const useInvestmentMeeting = ({
|
|||||||
: MeetingStatus.WAITING_INPUT
|
: MeetingStatus.WAITING_INPUT
|
||||||
);
|
);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
eventSource.close();
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('会议错误:', data.error);
|
||||||
|
setStatus(MeetingStatus.ERROR);
|
||||||
|
setIsLoading(false);
|
||||||
|
onToast?.({
|
||||||
|
title: '会议出错',
|
||||||
|
description: data.error || '未知错误',
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
|
||||||
console.error('解析 SSE 事件失败:', e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.onerror = (error) => {
|
// 读取流
|
||||||
console.error('SSE 连接错误:', error);
|
while (true) {
|
||||||
eventSource.close();
|
const { done, value } = await reader.read();
|
||||||
setStatus(MeetingStatus.ERROR);
|
if (done) break;
|
||||||
setIsLoading(false);
|
|
||||||
onToast?.({
|
buffer += decoder.decode(value, { stream: true });
|
||||||
title: '连接失败',
|
const lines = buffer.split('\n');
|
||||||
description: '会议连接中断,请重试',
|
buffer = lines.pop() || '';
|
||||||
status: 'error',
|
|
||||||
});
|
for (const line of lines) {
|
||||||
};
|
if (line.trim()) {
|
||||||
} catch (error) {
|
processLine(line);
|
||||||
console.error('启动会议失败:', error);
|
|
||||||
setStatus(MeetingStatus.ERROR);
|
|
||||||
setIsLoading(false);
|
|
||||||
onToast?.({
|
|
||||||
title: '启动会议失败',
|
|
||||||
description: '请稍后重试',
|
|
||||||
status: 'error',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
[userId, userNickname, addMessage, onToast]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动会议(非流式,获取完整响应)
|
|
||||||
*/
|
|
||||||
const startMeeting = useCallback(
|
|
||||||
async (topic: string) => {
|
|
||||||
setCurrentTopic(topic);
|
|
||||||
setStatus(MeetingStatus.STARTING);
|
|
||||||
setIsLoading(true);
|
|
||||||
setMessages([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.post<MeetingResponse>(
|
|
||||||
'/mcp/agent/meeting/start',
|
|
||||||
{
|
|
||||||
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(
|
// 处理剩余 buffer
|
||||||
data.is_concluded
|
if (buffer.trim()) {
|
||||||
? MeetingStatus.CONCLUDED
|
processLine(buffer);
|
||||||
: MeetingStatus.WAITING_INPUT
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw new Error('会议启动失败');
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('启动会议失败:', error);
|
console.error('启动会议失败:', error);
|
||||||
setStatus(MeetingStatus.ERROR);
|
setStatus(MeetingStatus.ERROR);
|
||||||
|
setIsLoading(false);
|
||||||
onToast?.({
|
onToast?.({
|
||||||
title: '启动会议失败',
|
title: '启动会议失败',
|
||||||
description: error.response?.data?.detail || error.message,
|
description: error.message || '请稍后重试',
|
||||||
status: 'error',
|
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(
|
const continueMeeting = useCallback(
|
||||||
async (userMessage?: string) => {
|
async (userMessage?: string) => {
|
||||||
@@ -301,55 +458,184 @@ export const useInvestmentMeeting = ({
|
|||||||
|
|
||||||
setStatus(MeetingStatus.DISCUSSING);
|
setStatus(MeetingStatus.DISCUSSING);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
const nextRound = currentRound + 1;
|
||||||
|
setCurrentRound(nextRound);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post<MeetingResponse>(
|
// 构建会话历史(排除正在流式传输的消息)
|
||||||
'/mcp/agent/meeting/continue',
|
const historyMessages = messages
|
||||||
{
|
.filter((m) => !m.isStreaming)
|
||||||
|
.map((m) => ({
|
||||||
|
role_id: m.role_id,
|
||||||
|
role_name: m.role_name,
|
||||||
|
content: m.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 使用 fetch 进行 POST 请求的 SSE
|
||||||
|
const response = await fetch('/mcp/agent/meeting/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
topic: currentTopic,
|
topic: currentTopic,
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
user_nickname: userNickname,
|
user_nickname: userNickname,
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
user_message: userMessage,
|
user_message: userMessage,
|
||||||
conversation_history: messages,
|
conversation_history: historyMessages,
|
||||||
}
|
}),
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
const data = response.data;
|
|
||||||
|
|
||||||
setCurrentRound(data.round_number);
|
|
||||||
setIsConcluded(data.is_concluded);
|
|
||||||
|
|
||||||
// 添加新的消息
|
|
||||||
data.messages.forEach((msg) => {
|
|
||||||
addMessage(msg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 设置结论
|
if (!response.ok) {
|
||||||
if (data.conclusion) {
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
setConclusion(data.conclusion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSSEEvent = (data: MeetingEvent) => {
|
||||||
|
switch (data.type) {
|
||||||
|
case 'session_start':
|
||||||
|
setSessionId(data.session_id || null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 || nextRound);
|
||||||
|
setIsConcluded(data.is_concluded || false);
|
||||||
setStatus(
|
setStatus(
|
||||||
data.is_concluded
|
data.is_concluded
|
||||||
? MeetingStatus.CONCLUDED
|
? MeetingStatus.CONCLUDED
|
||||||
: MeetingStatus.WAITING_INPUT
|
: 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 读取流
|
||||||
|
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) {
|
} catch (error: any) {
|
||||||
console.error('继续会议失败:', error);
|
console.error('继续会议失败:', error);
|
||||||
setStatus(MeetingStatus.ERROR);
|
setStatus(MeetingStatus.ERROR);
|
||||||
|
setIsLoading(false);
|
||||||
onToast?.({
|
onToast?.({
|
||||||
title: '继续会议失败',
|
title: '继续会议失败',
|
||||||
description: error.response?.data?.detail || error.message,
|
description: error.message || '请稍后重试',
|
||||||
status: 'error',
|
status: 'error',
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentTopic, userId, userNickname, sessionId, messages, addMessage, onToast]
|
[
|
||||||
|
currentTopic,
|
||||||
|
userId,
|
||||||
|
userNickname,
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
currentRound,
|
||||||
|
addMessage,
|
||||||
|
createStreamingMessage,
|
||||||
|
addToolCallToMessage,
|
||||||
|
updateToolCallResult,
|
||||||
|
updateMessageContent,
|
||||||
|
finishStreamingMessage,
|
||||||
|
onToast,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user