update pay function
This commit is contained in:
631
mcp_server.py
631
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")
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 <TrendingUp className="w-4 h-4" />;
|
||||
case 'bear':
|
||||
return <TrendingDown className="w-4 h-4" />;
|
||||
case 'quant':
|
||||
return <BarChart2 className="w-4 h-4" />;
|
||||
case 'retail':
|
||||
return <Users className="w-4 h-4" />;
|
||||
case 'manager':
|
||||
return <Crown className="w-4 h-4" />;
|
||||
default:
|
||||
return <Users className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Flex
|
||||
direction="column"
|
||||
align={isUser ? 'flex-end' : 'flex-start'}
|
||||
w="100%"
|
||||
>
|
||||
{/* 消息头部:角色信息 */}
|
||||
<HStack
|
||||
spacing={2}
|
||||
mb={2}
|
||||
flexDirection={isUser ? 'row-reverse' : 'row'}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
transition={{ type: 'spring', stiffness: 400 }}
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
icon={getRoleIcon(roleConfig.roleType)}
|
||||
bg={roleConfig.color}
|
||||
boxShadow={`0 0 12px ${roleConfig.color}40`}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<VStack spacing={0} align={isUser ? 'flex-end' : 'flex-start'}>
|
||||
<HStack spacing={2}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={roleConfig.color}
|
||||
>
|
||||
{roleConfig.name}
|
||||
</Text>
|
||||
{roleConfig.nickname !== roleConfig.name && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
@{roleConfig.nickname}
|
||||
</Text>
|
||||
)}
|
||||
{isManager && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
主持人
|
||||
</Badge>
|
||||
)}
|
||||
{isConclusion && (
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
>
|
||||
最终结论
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
第 {message.round_number} 轮 ·{' '}
|
||||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 消息内容卡片 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ maxWidth: isUser ? '70%' : '85%', width: '100%' }}
|
||||
>
|
||||
<Card
|
||||
bg={
|
||||
isUser
|
||||
? `linear-gradient(135deg, ${roleConfig.color}20, ${roleConfig.color}10)`
|
||||
: isConclusion
|
||||
? 'linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.05))'
|
||||
: 'rgba(255, 255, 255, 0.05)'
|
||||
}
|
||||
border="1px solid"
|
||||
borderColor={
|
||||
isConclusion
|
||||
? 'purple.500'
|
||||
: `${roleConfig.color}30`
|
||||
}
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
boxShadow={
|
||||
isConclusion
|
||||
? '0 0 20px rgba(139, 92, 246, 0.3)'
|
||||
: isLatest
|
||||
? `0 4px 20px ${roleConfig.color}20`
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
{/* 结论标题 */}
|
||||
{isConclusion && (
|
||||
<Box
|
||||
bgGradient="linear(to-r, purple.600, violet.600)"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<HStack>
|
||||
<Crown className="w-4 h-4" />
|
||||
<Text fontWeight="bold" fontSize="sm" color="white">
|
||||
基金经理投资建议
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardBody p={4}>
|
||||
<Box
|
||||
fontSize="sm"
|
||||
color="gray.100"
|
||||
lineHeight="tall"
|
||||
sx={{
|
||||
'& p': { mb: 2 },
|
||||
'& h1, & h2, & h3': { color: 'gray.50', fontWeight: 'bold' },
|
||||
'& ul, & ol': { pl: 4 },
|
||||
'& li': { mb: 1 },
|
||||
'& code': {
|
||||
bg: 'rgba(255,255,255,0.1)',
|
||||
px: 1,
|
||||
borderRadius: 'sm',
|
||||
},
|
||||
'& blockquote': {
|
||||
borderLeftWidth: '3px',
|
||||
borderLeftColor: roleConfig.color,
|
||||
pl: 3,
|
||||
color: 'gray.300',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'& strong': { color: roleConfig.color },
|
||||
}}
|
||||
>
|
||||
<MarkdownWithCharts content={message.content} variant="dark" />
|
||||
</Box>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<Flex mt={3} pt={3} borderTop="1px solid" borderColor="whiteAlpha.100">
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="复制">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon={<Copy className="w-3 h-3" />}
|
||||
onClick={handleCopy}
|
||||
color="gray.500"
|
||||
_hover={{ color: 'white', bg: 'whiteAlpha.100' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip label="有用">
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
icon={<ThumbsUp className="w-3 h-3" />}
|
||||
color="gray.500"
|
||||
_hover={{ color: 'green.400', bg: 'green.900' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
{/* 角色标签 */}
|
||||
<Box ml="auto">
|
||||
<Badge
|
||||
bg={`${roleConfig.color}20`}
|
||||
color={roleConfig.color}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="full"
|
||||
>
|
||||
{roleConfig.roleType === 'bull' && '📈 看多'}
|
||||
{roleConfig.roleType === 'bear' && '📉 看空'}
|
||||
{roleConfig.roleType === 'quant' && '📊 量化'}
|
||||
{roleConfig.roleType === 'retail' && '🌱 散户'}
|
||||
{roleConfig.roleType === 'manager' && '👔 决策'}
|
||||
</Badge>
|
||||
</Box>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingMessageBubble;
|
||||
301
src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js
Normal file
301
src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js
Normal file
@@ -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 <TrendingUp className="w-4 h-4" />;
|
||||
case 'bear':
|
||||
return <TrendingDown className="w-4 h-4" />;
|
||||
case 'quant':
|
||||
return <BarChart2 className="w-4 h-4" />;
|
||||
case 'retail':
|
||||
return <Users className="w-4 h-4" />;
|
||||
case 'manager':
|
||||
return <Crown className="w-4 h-4" />;
|
||||
default:
|
||||
return <Users className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* RoleCard - 单个角色卡片
|
||||
*/
|
||||
const RoleCard = ({ role, isSpeaking }) => {
|
||||
return (
|
||||
<Tooltip label={role.description} placement="right" hasArrow>
|
||||
<motion.div
|
||||
animate={
|
||||
isSpeaking
|
||||
? {
|
||||
scale: [1, 1.02, 1],
|
||||
boxShadow: [
|
||||
`0 0 0px ${role.color}`,
|
||||
`0 0 20px ${role.color}`,
|
||||
`0 0 0px ${role.color}`,
|
||||
],
|
||||
}
|
||||
: {}
|
||||
}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: isSpeaking ? Infinity : 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
p={3}
|
||||
bg={isSpeaking ? `${role.color}15` : 'rgba(255, 255, 255, 0.03)'}
|
||||
border="1px solid"
|
||||
borderColor={isSpeaking ? role.color : 'rgba(255, 255, 255, 0.1)'}
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
borderColor: `${role.color}50`,
|
||||
}}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
{/* 头像 */}
|
||||
<Box position="relative">
|
||||
<Avatar
|
||||
size="sm"
|
||||
icon={getRoleIcon(role.roleType)}
|
||||
bg={role.color}
|
||||
boxShadow={isSpeaking ? `0 0 12px ${role.color}` : 'none'}
|
||||
/>
|
||||
{isSpeaking && (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 0.5, repeat: Infinity }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bg="green.500"
|
||||
borderRadius="full"
|
||||
p={1}
|
||||
boxShadow="0 0 8px rgba(34, 197, 94, 0.6)"
|
||||
>
|
||||
<Mic className="w-2 h-2" />
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 角色信息 */}
|
||||
<VStack spacing={0} align="start" flex={1}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={isSpeaking ? role.color : 'gray.200'}
|
||||
>
|
||||
{role.name}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
@{role.nickname}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 状态指示 */}
|
||||
<Badge
|
||||
size="sm"
|
||||
colorScheme={
|
||||
role.roleType === 'bull'
|
||||
? 'green'
|
||||
: role.roleType === 'bear'
|
||||
? 'red'
|
||||
: role.roleType === 'quant'
|
||||
? 'blue'
|
||||
: role.roleType === 'manager'
|
||||
? 'purple'
|
||||
: 'yellow'
|
||||
}
|
||||
variant="subtle"
|
||||
fontSize="10px"
|
||||
>
|
||||
{role.roleType === 'bull' && '多头'}
|
||||
{role.roleType === 'bear' && '空头'}
|
||||
{role.roleType === 'quant' && '量化'}
|
||||
{role.roleType === 'retail' && '散户'}
|
||||
{role.roleType === 'manager' && '主持'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Box
|
||||
w="220px"
|
||||
bg="rgba(17, 24, 39, 0.8)"
|
||||
borderRight="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
p={4}
|
||||
overflowY="auto"
|
||||
>
|
||||
{/* 标题 */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color="gray.500"
|
||||
textTransform="uppercase"
|
||||
letterSpacing="wider"
|
||||
mb={3}
|
||||
>
|
||||
参会成员
|
||||
</Text>
|
||||
|
||||
<VStack spacing={2} align="stretch">
|
||||
{/* 分析师组 */}
|
||||
<Text fontSize="xs" color="gray.600" mt={2} mb={1}>
|
||||
分析团队
|
||||
</Text>
|
||||
{analysts.map((role) => (
|
||||
<RoleCard
|
||||
key={role.id}
|
||||
role={role}
|
||||
isSpeaking={speakingRoleId === role.id}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box
|
||||
h="1px"
|
||||
bg="rgba(255, 255, 255, 0.1)"
|
||||
my={2}
|
||||
/>
|
||||
|
||||
{/* 基金经理 */}
|
||||
<Text fontSize="xs" color="gray.600" mb={1}>
|
||||
决策层
|
||||
</Text>
|
||||
<RoleCard
|
||||
role={manager}
|
||||
isSpeaking={speakingRoleId === 'fund_manager'}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 会议状态指示 */}
|
||||
<Box
|
||||
mt={4}
|
||||
p={3}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
会议状态
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Box
|
||||
w={2}
|
||||
h={2}
|
||||
borderRadius="full"
|
||||
bg={
|
||||
status === MeetingStatus.IDLE
|
||||
? 'gray.500'
|
||||
: status === MeetingStatus.CONCLUDED
|
||||
? 'green.500'
|
||||
: status === MeetingStatus.ERROR
|
||||
? 'red.500'
|
||||
: 'blue.500'
|
||||
}
|
||||
boxShadow={
|
||||
status !== MeetingStatus.IDLE
|
||||
? `0 0 8px ${
|
||||
status === MeetingStatus.CONCLUDED
|
||||
? 'rgba(34, 197, 94, 0.6)'
|
||||
: status === MeetingStatus.ERROR
|
||||
? 'rgba(239, 68, 68, 0.6)'
|
||||
: 'rgba(59, 130, 246, 0.6)'
|
||||
}`
|
||||
: 'none'
|
||||
}
|
||||
/>
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{status === MeetingStatus.IDLE && '等待开始'}
|
||||
{status === MeetingStatus.STARTING && '召集中...'}
|
||||
{status === MeetingStatus.DISCUSSING && '讨论中'}
|
||||
{status === MeetingStatus.SPEAKING && '发言中'}
|
||||
{status === MeetingStatus.WAITING_INPUT && '等待输入'}
|
||||
{status === MeetingStatus.CONCLUDED && '已结束'}
|
||||
{status === MeetingStatus.ERROR && '异常'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 角色说明 */}
|
||||
<Box mt={4}>
|
||||
<Text fontSize="xs" color="gray.600" mb={2}>
|
||||
💡 角色说明
|
||||
</Text>
|
||||
<VStack spacing={1} align="start">
|
||||
<Text fontSize="10px" color="gray.500">
|
||||
📈 多头:挖掘利好因素
|
||||
</Text>
|
||||
<Text fontSize="10px" color="gray.500">
|
||||
📉 空头:发现风险隐患
|
||||
</Text>
|
||||
<Text fontSize="10px" color="gray.500">
|
||||
📊 量化:技术指标分析
|
||||
</Text>
|
||||
<Text fontSize="10px" color="gray.500">
|
||||
🌱 散户:反向指标参考
|
||||
</Text>
|
||||
<Text fontSize="10px" color="gray.500">
|
||||
👔 主持:综合判断决策
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingRolePanel;
|
||||
294
src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js
Normal file
294
src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js
Normal file
@@ -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 (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02, y: -4 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<Card
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
borderRadius="xl"
|
||||
cursor="pointer"
|
||||
onClick={() => 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`,
|
||||
}}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack align="start" spacing={3}>
|
||||
<HStack spacing={3}>
|
||||
<Box
|
||||
p={2}
|
||||
bg={`${suggestion.color}15`}
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Icon
|
||||
as={IconComponent}
|
||||
color={suggestion.color}
|
||||
boxSize={5}
|
||||
/>
|
||||
</Box>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color="gray.200"
|
||||
>
|
||||
{suggestion.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.400"
|
||||
lineHeight="tall"
|
||||
>
|
||||
{suggestion.example}
|
||||
</Text>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MeetingWelcome - 会议欢迎界面
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Function} props.onTopicSelect - 选择议题回调
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MeetingWelcome = ({ onTopicSelect }) => {
|
||||
return (
|
||||
<Box
|
||||
flex={1}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
p={8}
|
||||
>
|
||||
<VStack spacing={8} maxW="800px" w="100%">
|
||||
{/* 标题区域 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: [0, 5, -5, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
p={4}
|
||||
bgGradient="linear(to-br, orange.400, red.500)"
|
||||
borderRadius="2xl"
|
||||
boxShadow="0 0 40px rgba(251, 146, 60, 0.4)"
|
||||
>
|
||||
<Briefcase className="w-10 h-10" />
|
||||
</Box>
|
||||
</motion.div>
|
||||
|
||||
<Text
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
bgGradient="linear(to-r, orange.300, red.300)"
|
||||
bgClip="text"
|
||||
textAlign="center"
|
||||
>
|
||||
欢迎来到投研会议室
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color="gray.400"
|
||||
textAlign="center"
|
||||
maxW="500px"
|
||||
>
|
||||
多位 AI 分析师将从不同角度分析您的投资议题,
|
||||
包括多头、空头、量化分析师和散户视角,
|
||||
最终由基金经理给出投资建议。
|
||||
</Text>
|
||||
</VStack>
|
||||
</motion.div>
|
||||
|
||||
{/* 特点说明 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<HStack
|
||||
spacing={6}
|
||||
flexWrap="wrap"
|
||||
justify="center"
|
||||
>
|
||||
{[
|
||||
{ icon: '📈', text: '多空对决' },
|
||||
{ icon: '📊', text: '量化分析' },
|
||||
{ icon: '🎯', text: '投资建议' },
|
||||
{ icon: '💬', text: '实时参与' },
|
||||
].map((item, index) => (
|
||||
<HStack
|
||||
key={index}
|
||||
spacing={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
>
|
||||
<Text fontSize="lg">{item.icon}</Text>
|
||||
<Text fontSize="sm" color="gray.300">
|
||||
{item.text}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</HStack>
|
||||
</motion.div>
|
||||
|
||||
{/* 议题建议 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<VStack spacing={4} w="100%">
|
||||
<HStack spacing={2}>
|
||||
<Zap className="w-4 h-4" style={{ color: '#F59E0B' }} />
|
||||
<Text fontSize="sm" color="gray.400">
|
||||
点击下方议题快速开始,或输入自定义议题
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4} w="100%">
|
||||
{TOPIC_SUGGESTIONS.map((suggestion) => (
|
||||
<TopicCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onClick={onTopicSelect}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</motion.div>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<Box
|
||||
bg="rgba(251, 146, 60, 0.1)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(251, 146, 60, 0.2)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
maxW="500px"
|
||||
>
|
||||
<Text fontSize="xs" color="orange.300">
|
||||
💡 提示:会议进行中您可以随时插话参与讨论,
|
||||
您的观点会影响分析师的判断。
|
||||
讨论结束后,基金经理会给出最终的投资建议。
|
||||
</Text>
|
||||
</Box>
|
||||
</motion.div>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingWelcome;
|
||||
442
src/views/AgentChat/components/MeetingRoom/index.js
Normal file
442
src/views/AgentChat/components/MeetingRoom/index.js
Normal file
@@ -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 (
|
||||
<Flex h="100%" direction="column" bg="gray.900">
|
||||
{/* 顶部标题栏 */}
|
||||
<Box
|
||||
bg="rgba(17, 24, 39, 0.9)"
|
||||
backdropFilter="blur(20px)"
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
px={6}
|
||||
py={4}
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={4}>
|
||||
<motion.div
|
||||
animate={{ rotate: isLoading ? 360 : 0 }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: isLoading ? Infinity : 0,
|
||||
ease: 'linear',
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
icon={<Users className="w-6 h-6" />}
|
||||
bgGradient="linear(to-br, orange.400, red.500)"
|
||||
boxShadow="0 0 20px rgba(251, 146, 60, 0.5)"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<Box>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontWeight="bold"
|
||||
bgGradient="linear(to-r, orange.400, red.400)"
|
||||
bgClip="text"
|
||||
letterSpacing="tight"
|
||||
>
|
||||
投研会议室
|
||||
</Text>
|
||||
<HStack spacing={2} mt={1}>
|
||||
<Badge
|
||||
bg={
|
||||
status === MeetingStatus.CONCLUDED
|
||||
? 'green.500'
|
||||
: status === MeetingStatus.ERROR
|
||||
? 'red.500'
|
||||
: 'blue.500'
|
||||
}
|
||||
color="white"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
>
|
||||
{status === MeetingStatus.CONCLUDED ? (
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
) : status === MeetingStatus.ERROR ? (
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
) : (
|
||||
<MessageCircle className="w-3 h-3" />
|
||||
)}
|
||||
{getStatusText()}
|
||||
</Badge>
|
||||
{currentRound > 0 && (
|
||||
<Badge
|
||||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||||
color="white"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
第 {currentRound} 轮
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="重置会议">
|
||||
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<RefreshCw className="w-4 h-4" />}
|
||||
onClick={resetMeeting}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
color="gray.400"
|
||||
_hover={{
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: 'white',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Flex flex={1} overflow="hidden">
|
||||
{/* 角色面板(左侧) */}
|
||||
<MeetingRolePanel
|
||||
speakingRoleId={speakingRoleId}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
{/* 消息区域(中间) */}
|
||||
<Box
|
||||
flex={1}
|
||||
overflowY="auto"
|
||||
bg="rgba(17, 24, 39, 0.5)"
|
||||
p={4}
|
||||
>
|
||||
{messages.length === 0 && status === MeetingStatus.IDLE ? (
|
||||
<MeetingWelcome
|
||||
onTopicSelect={(topic) => {
|
||||
setInputValue(topic);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<VStack spacing={4} align="stretch" maxW="800px" mx="auto">
|
||||
{/* 当前议题展示 */}
|
||||
{currentTopic && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Box
|
||||
bg="rgba(251, 146, 60, 0.1)"
|
||||
border="1px solid"
|
||||
borderColor="rgba(251, 146, 60, 0.3)"
|
||||
borderRadius="lg"
|
||||
p={4}
|
||||
mb={4}
|
||||
>
|
||||
<Text fontSize="sm" color="orange.300" fontWeight="medium">
|
||||
📋 本次议题
|
||||
</Text>
|
||||
<Text color="white" mt={1}>
|
||||
{currentTopic}
|
||||
</Text>
|
||||
</Box>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 消息列表 */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={message.id || index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MeetingMessageBubble
|
||||
message={message}
|
||||
isLatest={index === messages.length - 1}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 正在发言指示器 */}
|
||||
{speakingRoleId && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<HStack
|
||||
spacing={3}
|
||||
p={4}
|
||||
bg="rgba(255, 255, 255, 0.05)"
|
||||
borderRadius="lg"
|
||||
>
|
||||
<Spinner size="sm" color="purple.400" />
|
||||
<Text color="gray.400" fontSize="sm">
|
||||
{getRoleConfig(speakingRoleId)?.name} 正在思考...
|
||||
</Text>
|
||||
</HStack>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 输入栏 */}
|
||||
<Box
|
||||
bg="rgba(17, 24, 39, 0.9)"
|
||||
backdropFilter="blur(20px)"
|
||||
borderTop="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
px={6}
|
||||
py={4}
|
||||
>
|
||||
<Box maxW="800px" mx="auto">
|
||||
<HStack spacing={3}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<IconButton
|
||||
size="lg"
|
||||
icon={isLoading ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
|
||||
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)',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 继续讨论按钮 */}
|
||||
{status === MeetingStatus.WAITING_INPUT && !isConcluded && (
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Tooltip label="继续下一轮讨论">
|
||||
<IconButton
|
||||
size="lg"
|
||||
icon={<MessageCircle className="w-5 h-5" />}
|
||||
onClick={() => continueMeeting()}
|
||||
isDisabled={isLoading}
|
||||
bgGradient="linear(to-r, purple.400, blue.500)"
|
||||
color="white"
|
||||
_hover={{
|
||||
bgGradient: 'linear(to-r, purple.500, blue.600)',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
|
||||
<HStack spacing={1}>
|
||||
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400">
|
||||
Enter
|
||||
</Kbd>
|
||||
<Text>
|
||||
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
|
||||
</Text>
|
||||
</HStack>
|
||||
{status === MeetingStatus.WAITING_INPUT && (
|
||||
<Text color="orange.400">
|
||||
💡 您可以插话参与讨论,或点击继续按钮进行下一轮
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MeetingRoom;
|
||||
@@ -20,3 +20,4 @@ export * from './messageTypes';
|
||||
export * from './models';
|
||||
export * from './tools';
|
||||
export * from './quickQuestions';
|
||||
export * from './meetingRoles';
|
||||
|
||||
227
src/views/AgentChat/constants/meetingRoles.ts
Normal file
227
src/views/AgentChat/constants/meetingRoles.ts
Normal file
@@ -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<string, MeetingRoleConfig> = {
|
||||
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;
|
||||
}
|
||||
@@ -32,3 +32,9 @@ export type {
|
||||
UseAgentChatParams,
|
||||
UseAgentChatReturn,
|
||||
} from './useAgentChat';
|
||||
|
||||
export { useInvestmentMeeting } from './useInvestmentMeeting';
|
||||
export type {
|
||||
UseInvestmentMeetingParams,
|
||||
UseInvestmentMeetingReturn,
|
||||
} from './useInvestmentMeeting';
|
||||
|
||||
403
src/views/AgentChat/hooks/useInvestmentMeeting.ts
Normal file
403
src/views/AgentChat/hooks/useInvestmentMeeting.ts
Normal file
@@ -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<void>;
|
||||
/** 继续会议(下一轮讨论) */
|
||||
continueMeeting: (userMessage?: string) => Promise<void>;
|
||||
/** 用户插话 */
|
||||
sendUserMessage: (message: string) => Promise<void>;
|
||||
/** 重置会议 */
|
||||
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<MeetingMessage[]>([]);
|
||||
const [status, setStatus] = useState<MeetingStatus>(MeetingStatus.IDLE);
|
||||
const [speakingRoleId, setSpeakingRoleId] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [currentRound, setCurrentRound] = useState(0);
|
||||
const [isConcluded, setIsConcluded] = useState(false);
|
||||
const [conclusion, setConclusion] = useState<MeetingMessage | null>(null);
|
||||
const [currentTopic, setCurrentTopic] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 用于取消 SSE 连接
|
||||
const eventSourceRef = useRef<EventSource | null>(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<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(
|
||||
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<MeetingResponse>(
|
||||
'/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;
|
||||
@@ -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,7 +106,68 @@ const AgentChat = () => {
|
||||
|
||||
// ==================== 渲染组件 ====================
|
||||
return (
|
||||
<Flex h="100%" position="relative" bg="gray.900">
|
||||
<Flex h="100%" position="relative" bg="gray.900" direction="column">
|
||||
{/* 模式切换栏 */}
|
||||
<Box
|
||||
bg="rgba(17, 24, 39, 0.95)"
|
||||
borderBottom="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<HStack spacing={2} justify="center">
|
||||
<Tooltip label="单一聊天模式:与 AI 助手一对一对话">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<MessageSquare className="w-4 h-4" />}
|
||||
variant={chatMode === ChatMode.SINGLE ? 'solid' : 'ghost'}
|
||||
colorScheme={chatMode === ChatMode.SINGLE ? 'purple' : 'gray'}
|
||||
onClick={() => setChatMode(ChatMode.SINGLE)}
|
||||
bg={chatMode === ChatMode.SINGLE ? 'purple.500' : 'transparent'}
|
||||
color={chatMode === ChatMode.SINGLE ? 'white' : 'gray.400'}
|
||||
_hover={{
|
||||
bg: chatMode === ChatMode.SINGLE ? 'purple.600' : 'whiteAlpha.100',
|
||||
}}
|
||||
>
|
||||
智能助手
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="投研会议室:多位 AI 分析师协作讨论投资议题">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Users className="w-4 h-4" />}
|
||||
variant={chatMode === ChatMode.MEETING ? 'solid' : 'ghost'}
|
||||
colorScheme={chatMode === ChatMode.MEETING ? 'orange' : 'gray'}
|
||||
onClick={() => setChatMode(ChatMode.MEETING)}
|
||||
bg={chatMode === ChatMode.MEETING ? 'orange.500' : 'transparent'}
|
||||
color={chatMode === ChatMode.MEETING ? 'white' : 'gray.400'}
|
||||
_hover={{
|
||||
bg: chatMode === ChatMode.MEETING ? 'orange.600' : 'whiteAlpha.100',
|
||||
}}
|
||||
>
|
||||
投研会议室
|
||||
</Button>
|
||||
</motion.div>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 主内容区 */}
|
||||
<Flex flex={1} overflow="hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
{chatMode === ChatMode.SINGLE ? (
|
||||
<motion.div
|
||||
key="single-chat"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ display: 'flex', flex: 1, height: '100%' }}
|
||||
>
|
||||
{/* 左侧栏 */}
|
||||
<LeftSidebar
|
||||
isOpen={isLeftSidebarOpen}
|
||||
@@ -134,6 +213,22 @@ const AgentChat = () => {
|
||||
sessionsCount={sessions.length}
|
||||
messagesCount={messages.length}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="meeting-room"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ flex: 1, height: '100%' }}
|
||||
>
|
||||
{/* 投研会议室 */}
|
||||
<MeetingRoom user={user} onToast={toast} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user