Compare commits

...

37 Commits

Author SHA1 Message Date
zdl
2bb8cb78e6 feat: 客服通知代码提交 2025-11-11 11:31:40 +08:00
zdl
8e5623d723 feat(customer-service): 集成 Bytedesk 客服系统并优化 Dify 机器人显示
## 主要变更

### 1. Dify 机器人优化
**文件**: public/index.html
-  恢复 Dify 机器人代码
-  添加显示控制逻辑:只在 /home 页面显示
-  使用 JavaScript 监听路由变化,动态控制显示/隐藏
-  保留所有样式配置

### 2. Bytedesk 客服系统集成
**文件**: src/bytedesk-integration/config/bytedesk.config.js
-  配置开发环境使用代理路径(/bytedesk-api)
-  修复 X-Frame-Options 跨域问题
-  优化 shouldShowCustomerService 逻辑:默认所有页面显示,只在 /login 隐藏
-  保留白名单模式代码作为备用方案

**文件**: src/components/GlobalComponents.js
-  集成 BytedeskWidget 组件
-  使用 shouldShowCustomerService 控制显示

### 3. 客服显示规则

**Dify 机器人**:
-  /home 页面 → 显示
-  其他页面 → 隐藏

**Bytedesk 客服**:
-  所有页面 → 显示
-  /login 页面 → 隐藏

## 已知问题

- ⚠️ Bytedesk 服务器配置 enabled: false,需要后端修改为 true
- ⚠️ 配置接口: /config/bytedesk/properties

## 测试建议

1. 访问 /home 页面,检查 Dify 机器人是否显示
2. 访问其他页面,检查 Dify 是否隐藏
3. 等待后端修改 enabled 后,测试 Bytedesk 客服功能

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-10 19:58:36 +08:00
zdl
57b4841b4c feat: 添加客服组件 2025-11-10 19:23:25 +08:00
zdl
9e23b370fe feat: 底部UI调整 2025-11-10 14:48:28 +08:00
zdl
34bc3d1d6f feat: 调整footer间距 2025-11-10 14:48:28 +08:00
7f2a4dd36a 事件中心不提示通知修复 2025-11-10 14:20:42 +08:00
45ff13f4d0 事件中心不提示通知修复 2025-11-10 13:46:34 +08:00
a00b8bb73d 事件中心ui 2025-11-10 12:45:34 +08:00
46ba421f42 事件中心ui 2025-11-10 12:32:14 +08:00
6cd300b5ae 事件中心ui 2025-11-10 12:22:21 +08:00
617300ac8f 事件中心不提示通知修复 2025-11-10 10:47:39 +08:00
25163789ca 事件中心不提示通知修复,增加开启/关闭通知按钮。修复edge或者opera浏览器登录扫码无跳转的问题 2025-11-10 10:36:29 +08:00
fbf6813615 事件中心有引用的相关详情样式调整 2025-11-10 10:18:55 +08:00
800151771c agent功能开发增加MCP后端 2025-11-10 08:14:53 +08:00
9a723f04f1 agent功能开发增加MCP后端 2025-11-10 07:56:52 +08:00
2756e6e379 agent功能开发增加MCP后端 2025-11-08 11:32:01 +08:00
87d8b25768 agent功能开发增加MCP后端 2025-11-08 10:58:16 +08:00
6228bef5ad agent功能开发增加MCP后端 2025-11-08 10:17:48 +08:00
dff37adbbc agent功能开发增加MCP后端 2025-11-08 08:58:30 +08:00
2a228c8d6c agent功能开发增加MCP后端 2025-11-08 00:11:36 +08:00
95eb86c06a agent功能开发增加MCP后端 2025-11-07 23:51:18 +08:00
6899b9d0d2 agent功能开发增加MCP后端 2025-11-07 23:18:20 +08:00
a8edb8bde3 agent功能开发增加MCP后端 2025-11-07 23:03:22 +08:00
d8dc79d32c agent功能开发增加MCP后端 2025-11-07 22:45:46 +08:00
e29f391f10 agent功能开发增加MCP后端 2025-11-07 22:31:07 +08:00
30788648af agent功能开发增加MCP后端 2025-11-07 22:12:23 +08:00
c886d78ff6 agent功能开发增加MCP后端 2025-11-07 22:02:21 +08:00
3a058fd805 agent功能开发增加MCP后端 2025-11-07 21:46:50 +08:00
d1d8d1a25d agent功能开发增加MCP后端 2025-11-07 21:03:24 +08:00
fc5d2058c4 agent功能开发增加MCP后端 2025-11-07 20:50:16 +08:00
322b1dd845 agent功能开发增加MCP后端 2025-11-07 20:23:54 +08:00
zdl
f01eff6eb7 feat: 优化股票卡片显示
d670b0a feat: 历史股票增加相关度数据
     02c03ab feat: 修改列表默认状态
     8bdc2aa feat: 处理mock数据
2025-11-07 20:05:14 +08:00
zdl
4860cac3ca feat: 历史股票增加相关度数据 2025-11-07 20:05:14 +08:00
zdl
207701bbde feat: 修改列表默认状态 2025-11-07 20:05:14 +08:00
zdl
033f29e90c feat: 处理mock数据 2025-11-07 20:05:14 +08:00
bd9fdefdea Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event 2025-11-07 19:55:16 +08:00
4dc27a35ff agent功能开发增加MCP后端 2025-11-07 19:55:05 +08:00
42 changed files with 11110 additions and 3738 deletions

1630
CLAUDE.md

File diff suppressed because it is too large Load Diff

5958
app_vx.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,15 +22,15 @@ openai_client = None
mysql_pool = None
# 配置
ES_HOST = 'http://192.168.1.58:9200'
OPENAI_BASE_URL = "http://192.168.1.58:8000/v1"
ES_HOST = 'http://127.0.0.1:9200'
OPENAI_BASE_URL = "http://127.0.0.1:8000/v1"
OPENAI_API_KEY = "dummy"
EMBEDDING_MODEL = "qwen3-embedding-8b"
INDEX_NAME = 'concept_library'
# MySQL配置
MYSQL_CONFIG = {
'host': '192.168.1.14',
'host': '192.168.1.8',
'user': 'root',
'password': 'Zzl5588161!',
'db': 'stock',
@@ -490,7 +490,7 @@ def build_hybrid_knn_query(
"field": "description_embedding",
"query_vector": embedding,
"k": k,
"num_candidates": min(k * 2, 500),
"num_candidates": max(k + 50, min(k * 2, 10000)), # 确保 num_candidates > k最大 10000
"boost": semantic_weight
}
}
@@ -591,7 +591,7 @@ async def search_concepts(request: SearchRequest):
"field": "description_embedding",
"query_vector": embedding,
"k": effective_search_size, # 使用有效搜索大小
"num_candidates": min(effective_search_size * 2, 1000)
"num_candidates": max(effective_search_size + 50, min(effective_search_size * 2, 10000)) # 确保 num_candidates > k
},
"size": effective_search_size
}
@@ -1045,7 +1045,16 @@ async def get_concept_price_timeseries(
):
"""获取概念在指定日期范围内的涨跌幅时间序列数据"""
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning(f"[PriceTimeseries] MySQL 连接不可用,返回空时间序列数据")
# 返回空时间序列而不是 503 错误
return PriceTimeSeriesResponse(
concept_id=concept_id,
concept_name=concept_id, # 无法查询名称,使用 ID
start_date=start_date,
end_date=end_date,
data_points=0,
timeseries=[]
)
if start_date > end_date:
raise HTTPException(status_code=400, detail="开始日期不能晚于结束日期")
@@ -1150,11 +1159,93 @@ async def get_concept_statistics(
min_stock_count: int = Query(3, ge=1, description="最少股票数量过滤")
):
"""获取概念板块统计数据 - 涨幅榜、跌幅榜、活跃榜、波动榜、连涨榜"""
from datetime import datetime, timedelta
# 如果 MySQL 不可用,直接返回示例数据(而不是返回 503
if not mysql_pool:
raise HTTPException(status_code=503, detail="数据库连接不可用")
logger.warning("[Statistics] MySQL 连接不可用,使用示例数据")
# 计算日期范围
if days is not None and (start_date is not None or end_date is not None):
pass # 参数冲突,但仍使用 days
if start_date is not None and end_date is not None:
pass # 使用提供的日期
elif days is not None:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
elif start_date is not None:
end_date = datetime.now().date()
elif end_date is not None:
start_date = end_date - timedelta(days=7)
else:
end_date = datetime.now().date()
start_date = end_date - timedelta(days=7)
# 返回示例数据(与 except 块中相同)
fallback_statistics = ConceptStatistics(
hot_concepts=[
ConceptStatItem(name="小米大模型", change_pct=12.45, stock_count=24, news_count=18),
ConceptStatItem(name="人工智能", change_pct=8.76, stock_count=45, news_count=12),
ConceptStatItem(name="新能源汽车", change_pct=6.54, stock_count=38, news_count=8),
ConceptStatItem(name="芯片概念", change_pct=5.43, stock_count=52, news_count=15),
ConceptStatItem(name="生物医药", change_pct=4.21, stock_count=28, news_count=6),
],
cold_concepts=[
ConceptStatItem(name="房地产", change_pct=-5.76, stock_count=33, news_count=5),
ConceptStatItem(name="煤炭开采", change_pct=-4.32, stock_count=25, news_count=3),
ConceptStatItem(name="钢铁冶炼", change_pct=-3.21, stock_count=28, news_count=4),
ConceptStatItem(name="传统零售", change_pct=-2.98, stock_count=19, news_count=2),
ConceptStatItem(name="纺织服装", change_pct=-2.45, stock_count=15, news_count=2),
],
active_concepts=[
ConceptStatItem(name="人工智能", news_count=45, report_count=15, total_mentions=60),
ConceptStatItem(name="芯片概念", news_count=42, report_count=12, total_mentions=54),
ConceptStatItem(name="新能源汽车", news_count=38, report_count=8, total_mentions=46),
ConceptStatItem(name="生物医药", news_count=28, report_count=6, total_mentions=34),
ConceptStatItem(name="量子科技", news_count=25, report_count=5, total_mentions=30),
],
volatile_concepts=[
ConceptStatItem(name="区块链", volatility=25.6, avg_change=2.1, max_change=15.2),
ConceptStatItem(name="元宇宙", volatility=23.8, avg_change=1.8, max_change=13.9),
ConceptStatItem(name="虚拟现实", volatility=21.2, avg_change=-0.5, max_change=10.1),
ConceptStatItem(name="游戏概念", volatility=19.7, avg_change=3.2, max_change=12.8),
ConceptStatItem(name="在线教育", volatility=18.3, avg_change=-1.1, max_change=8.1),
],
momentum_concepts=[
ConceptStatItem(name="数字经济", consecutive_days=6, total_change=19.2, avg_daily=3.2),
ConceptStatItem(name="云计算", consecutive_days=5, total_change=16.8, avg_daily=3.36),
ConceptStatItem(name="物联网", consecutive_days=4, total_change=13.1, avg_daily=3.28),
ConceptStatItem(name="大数据", consecutive_days=4, total_change=12.4, avg_daily=3.1),
ConceptStatItem(name="工业互联网", consecutive_days=3, total_change=9.6, avg_daily=3.2),
],
summary={
'total_concepts': 500,
'positive_count': 320,
'negative_count': 180,
'avg_change': 1.8,
'update_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'date_range': f"{start_date}{end_date}",
'days': (end_date - start_date).days + 1,
'start_date': str(start_date),
'end_date': str(end_date)
}
)
return ConceptStatisticsResponse(
success=True,
data=fallback_statistics,
params={
'days': (end_date - start_date).days + 1,
'min_stock_count': min_stock_count,
'start_date': str(start_date),
'end_date': str(end_date)
},
note="MySQL 连接不可用,使用示例数据"
)
try:
from datetime import datetime, timedelta
import random
# 参数验证和日期范围计算

View File

@@ -263,6 +263,13 @@ module.exports = {
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
'/bytedesk-api': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/bytedesk-api': '' },
},
},
}),
},

View File

@@ -1,361 +0,0 @@
"""
Kimi API 集成示例
演示如何将MCP工具与Kimi大模型结合使用
"""
from openai import OpenAI
import json
from typing import List, Dict, Any
from mcp_client_example import MCPClient
# Kimi API配置
KIMI_API_KEY = "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5"
KIMI_BASE_URL = "https://api.moonshot.cn/v1"
KIMI_MODEL = "kimi-k2-turbpreview"
# 初始化Kimi客户端
kimi_client = OpenAI(
api_key=KIMI_API_KEY,
base_url=KIMI_BASE_URL,
)
# 初始化MCP客户端
mcp_client = MCPClient()
def convert_mcp_tools_to_kimi_format() -> tuple[List[Dict], Dict]:
"""
将MCP工具转换为Kimi API的tools格式
Returns:
tools: Kimi格式的工具列表
tool_map: 工具名称到执行函数的映射
"""
# 获取所有MCP工具
mcp_tools_response = mcp_client.list_tools()
mcp_tools = mcp_tools_response["tools"]
# 转换为Kimi格式
kimi_tools = []
tool_map = {}
for tool in mcp_tools:
# Kimi工具格式
kimi_tool = {
"type": "function",
"function": {
"name": tool["name"],
"description": tool["description"],
"parameters": tool["parameters"]
}
}
kimi_tools.append(kimi_tool)
# 创建工具执行函数
tool_name = tool["name"]
tool_map[tool_name] = lambda args, name=tool_name: execute_mcp_tool(name, args)
return kimi_tools, tool_map
def execute_mcp_tool(tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
"""
执行MCP工具
Args:
tool_name: 工具名称
arguments: 工具参数
Returns:
工具执行结果
"""
print(f"[工具调用] {tool_name}")
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
result = mcp_client.call_tool(tool_name, arguments)
print(f"[结果] 成功: {result.get('success', False)}")
return result
def chat_with_kimi(user_message: str, verbose: bool = True) -> str:
"""
与Kimi进行对话支持工具调用
Args:
user_message: 用户消息
verbose: 是否打印详细信息
Returns:
Kimi的回复
"""
# 获取Kimi格式的工具
tools, tool_map = convert_mcp_tools_to_kimi_format()
if verbose:
print(f"\n{'='*60}")
print(f"加载了 {len(tools)} 个工具")
print(f"{'='*60}\n")
# 初始化对话
messages = [
{
"role": "system",
"content": """你是一个专业的金融数据分析助手,由 Moonshot AI 提供支持。
你可以使用各种工具来帮助用户查询和分析金融数据,包括:
- 新闻搜索(全球新闻、中国新闻、医疗新闻)
- 公司研究(路演信息、研究报告)
- 概念板块分析
- 股票分析(涨停分析、财务数据、交易数据)
- 财务报表(资产负债表、现金流量表)
请根据用户的问题,选择合适的工具来获取信息,并提供专业的分析和建议。"""
},
{
"role": "user",
"content": user_message
}
]
if verbose:
print(f"[用户]: {user_message}\n")
# 对话循环,处理工具调用
finish_reason = None
iteration = 0
max_iterations = 10 # 防止无限循环
while finish_reason is None or finish_reason == "tool_calls":
iteration += 1
if iteration > max_iterations:
print("[警告] 达到最大迭代次数")
break
if verbose and iteration > 1:
print(f"\n[轮次 {iteration}]")
# 调用Kimi API
completion = kimi_client.chat.completions.create(
model=KIMI_MODEL,
messages=messages,
temperature=0.6, # Kimi推荐的temperature值
tools=tools,
)
choice = completion.choices[0]
finish_reason = choice.finish_reason
if verbose:
print(f"[Kimi] finish_reason: {finish_reason}")
# 处理工具调用
if finish_reason == "tool_calls":
# 将Kimi的消息添加到上下文
messages.append(choice.message)
# 执行每个工具调用
for tool_call in choice.message.tool_calls:
tool_name = tool_call.function.name
tool_arguments = json.loads(tool_call.function.arguments)
# 执行工具
tool_result = tool_map[tool_name](tool_arguments)
# 将工具结果添加到消息中
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": tool_name,
"content": json.dumps(tool_result, ensure_ascii=False),
})
if verbose:
print() # 空行分隔
# 返回最终回复
final_response = choice.message.content
if verbose:
print(f"\n[Kimi]: {final_response}\n")
print(f"{'='*60}")
return final_response
def demo_simple_query():
"""演示1: 简单查询"""
print("\n" + "="*60)
print("演示1: 简单新闻查询")
print("="*60)
response = chat_with_kimi("帮我查找关于人工智能的最新新闻")
return response
def demo_stock_analysis():
"""演示2: 股票分析"""
print("\n" + "="*60)
print("演示2: 股票财务分析")
print("="*60)
response = chat_with_kimi("帮我分析贵州茅台600519的财务状况")
return response
def demo_concept_research():
"""演示3: 概念研究"""
print("\n" + "="*60)
print("演示3: 概念板块研究")
print("="*60)
response = chat_with_kimi("查找新能源汽车相关的概念板块,并告诉我涨幅最高的是哪些")
return response
def demo_industry_comparison():
"""演示4: 行业对比"""
print("\n" + "="*60)
print("演示4: 行业内股票对比")
print("="*60)
response = chat_with_kimi("帮我找出半导体行业的龙头股票,并对比它们的财务指标")
return response
def demo_comprehensive_analysis():
"""演示5: 综合分析"""
print("\n" + "="*60)
print("演示5: 综合分析")
print("="*60)
response = chat_with_kimi("""
我想投资白酒行业,请帮我:
1. 搜索白酒行业的主要上市公司
2. 对比贵州茅台和五粮液的财务数据
3. 查看最近的行业新闻
4. 给出投资建议
""")
return response
def interactive_chat():
"""交互式对话"""
print("\n" + "="*60)
print("Kimi 金融助手 - 交互模式")
print("="*60)
print("提示:输入 'quit''exit' 退出")
print("="*60 + "\n")
while True:
try:
user_input = input("你: ").strip()
if not user_input:
continue
if user_input.lower() in ['quit', 'exit', '退出']:
print("\n再见!")
break
response = chat_with_kimi(user_input)
except KeyboardInterrupt:
print("\n\n再见!")
break
except Exception as e:
print(f"\n[错误] {str(e)}\n")
def test_kimi_connection():
"""测试Kimi API连接"""
print("\n" + "="*60)
print("测试 Kimi API 连接")
print("="*60 + "\n")
try:
# 简单的测试请求
response = kimi_client.chat.completions.create(
model=KIMI_MODEL,
messages=[
{"role": "user", "content": "你好,请介绍一下你自己"}
],
temperature=0.6
)
print("[✓] 连接成功!")
print(f"[✓] 模型: {KIMI_MODEL}")
print(f"[✓] 回复: {response.choices[0].message.content}\n")
return True
except Exception as e:
print(f"[✗] 连接失败: {str(e)}\n")
return False
def show_available_tools():
"""显示所有可用工具"""
print("\n" + "="*60)
print("可用工具列表")
print("="*60 + "\n")
tools, _ = convert_mcp_tools_to_kimi_format()
for i, tool in enumerate(tools, 1):
func = tool["function"]
print(f"{i}. {func['name']}")
print(f" 描述: {func['description'][:80]}...")
print()
print(f"总计: {len(tools)} 个工具\n")
if __name__ == "__main__":
import sys
# 首先测试连接
if not test_kimi_connection():
print("请检查API Key和网络连接")
sys.exit(1)
# 显示可用工具
show_available_tools()
# 运行演示
print("\n选择运行模式:")
print("1. 简单查询演示")
print("2. 股票分析演示")
print("3. 概念研究演示")
print("4. 行业对比演示")
print("5. 综合分析演示")
print("6. 交互式对话")
print("7. 运行所有演示")
try:
choice = input("\n请选择 (1-7): ").strip()
if choice == "1":
demo_simple_query()
elif choice == "2":
demo_stock_analysis()
elif choice == "3":
demo_concept_research()
elif choice == "4":
demo_industry_comparison()
elif choice == "5":
demo_comprehensive_analysis()
elif choice == "6":
interactive_chat()
elif choice == "7":
demo_simple_query()
demo_stock_analysis()
demo_concept_research()
demo_industry_comparison()
demo_comprehensive_analysis()
else:
print("无效选择")
except KeyboardInterrupt:
print("\n\n程序已退出")
finally:
mcp_client.close()

View File

@@ -1,470 +0,0 @@
"""
MCP Agent System - 基于 DeepResearch 逻辑的智能代理系统
三阶段流程:计划制定 → 工具执行 → 结果总结
"""
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Literal
from datetime import datetime
import json
import logging
from openai import OpenAI
import asyncio
import os
logger = logging.getLogger(__name__)
# ==================== 数据模型 ====================
class ToolCall(BaseModel):
"""工具调用"""
tool: str
arguments: Dict[str, Any]
reason: str # 为什么要调用这个工具
class ExecutionPlan(BaseModel):
"""执行计划"""
goal: str # 用户的目标
steps: List[ToolCall] # 执行步骤
reasoning: str # 规划reasoning
class StepResult(BaseModel):
"""单步执行结果"""
step_index: int
tool: str
arguments: Dict[str, Any]
status: Literal["success", "failed", "skipped"]
result: Optional[Any] = None
error: Optional[str] = None
execution_time: float = 0
class AgentResponse(BaseModel):
"""Agent响应"""
success: bool
message: str # 自然语言总结
plan: Optional[ExecutionPlan] = None # 执行计划
step_results: List[StepResult] = [] # 每步的结果
final_summary: Optional[str] = None # 最终总结
metadata: Optional[Dict[str, Any]] = None
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
stream: bool = False # 是否流式输出
# ==================== Agent 系统 ====================
class MCPAgent:
"""MCP 智能代理 - 三阶段执行"""
def __init__(self, provider: str = "qwen"):
self.provider = provider
# LLM 配置
config = {
"qwen": {
"api_key": os.getenv("DASHSCOPE_API_KEY", ""),
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
},
"deepseek": {
"api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
},
"openai": {
"api_key": os.getenv("OPENAI_API_KEY", ""),
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
},
}.get(provider)
if not config or not config["api_key"]:
raise ValueError(f"Provider '{provider}' not configured. Please set API key.")
self.client = OpenAI(
api_key=config["api_key"],
base_url=config["base_url"],
)
self.model = config["model"]
# ==================== 阶段 1: 计划制定 ====================
def get_planning_prompt(self, tools: List[dict]) -> str:
"""获取计划制定的系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n"
f"描述:{tool['description']}\n"
f"参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融研究助手。你需要根据用户的问题,制定一个详细的执行计划。
## 可用工具
{tools_desc}
## 重要知识
- 贵州茅台股票代码: 600519
- 涨停: 股价单日涨幅约10%
- 概念板块: 相同题材的股票分类
## 特殊工具说明
- **summarize_with_llm**: 这是一个特殊工具,用于让你总结和分析收集到的数据
- 当需要对多个数据源进行综合分析时使用
- 当需要生成研究报告时使用
- 参数: {{"data": "要分析的数据", "task": "分析任务描述"}}
## 任务
分析用户问题,制定执行计划。返回 JSON 格式:
```json
{{
"goal": "用户的目标(一句话概括)",
"reasoning": "你的分析思路(为什么这样规划)",
"steps": [
{{
"tool": "工具名称",
"arguments": {{"参数名": "参数值"}},
"reason": "为什么要执行这一步"
}}
]
}}
```
## 规划原则
1. **从简到繁**: 先获取基础信息,再深入分析
2. **数据先行**: 先收集数据,再总结分析
3. **合理组合**: 可以调用多个工具但不要超过5个
4. **包含总结**: 最后一步通常是 summarize_with_llm
## 示例
用户:"帮我全面分析一下贵州茅台这只股票"
你的计划:
```json
{{
"goal": "全面分析贵州茅台股票",
"reasoning": "需要获取基本信息、财务指标、交易数据,然后综合分析",
"steps": [
{{
"tool": "get_stock_basic_info",
"arguments": {{"seccode": "600519"}},
"reason": "获取股票基本信息(公司名称、行业、市值等)"
}},
{{
"tool": "get_stock_financial_index",
"arguments": {{"seccode": "600519", "limit": 5}},
"reason": "获取最近5期财务指标营收、利润、ROE等"
}},
{{
"tool": "get_stock_trade_data",
"arguments": {{"seccode": "600519", "limit": 30}},
"reason": "获取最近30天交易数据价格走势、成交量"
}},
{{
"tool": "search_china_news",
"arguments": {{"query": "贵州茅台", "top_k": 5}},
"reason": "获取最新新闻,了解市场动态"
}},
{{
"tool": "summarize_with_llm",
"arguments": {{
"data": "前面收集的所有数据",
"task": "综合分析贵州茅台的投资价值,包括基本面、财务状况、股价走势、市场情绪"
}},
"reason": "综合所有数据,生成投资分析报告"
}}
]
}}
```
只返回JSON不要额外解释。"""
async def create_plan(self, user_query: str, tools: List[dict]) -> ExecutionPlan:
"""阶段1: 创建执行计划"""
logger.info(f"[Planning] Creating plan for: {user_query}")
messages = [
{"role": "system", "content": self.get_planning_prompt(tools)},
{"role": "user", "content": user_query},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.3,
max_tokens=1500,
)
plan_json = response.choices[0].message.content.strip()
logger.info(f"[Planning] Raw response: {plan_json}")
# 清理可能的代码块标记
if "```json" in plan_json:
plan_json = plan_json.split("```json")[1].split("```")[0].strip()
elif "```" in plan_json:
plan_json = plan_json.split("```")[1].split("```")[0].strip()
plan_data = json.loads(plan_json)
plan = ExecutionPlan(
goal=plan_data["goal"],
reasoning=plan_data.get("reasoning", ""),
steps=[
ToolCall(**step) for step in plan_data["steps"]
],
)
logger.info(f"[Planning] Plan created: {len(plan.steps)} steps")
return plan
# ==================== 阶段 2: 工具执行 ====================
async def execute_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
tool_handlers: Dict[str, Any],
) -> Dict[str, Any]:
"""执行单个工具"""
# 特殊处理summarize_with_llm
if tool_name == "summarize_with_llm":
return await self.summarize_with_llm(
data=arguments.get("data", ""),
task=arguments.get("task", "总结数据"),
)
# 调用 MCP 工具
handler = tool_handlers.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
result = await handler(arguments)
return result
async def execute_plan(
self,
plan: ExecutionPlan,
tool_handlers: Dict[str, Any],
) -> List[StepResult]:
"""阶段2: 执行计划中的所有步骤"""
logger.info(f"[Execution] Starting execution: {len(plan.steps)} steps")
results = []
collected_data = {} # 收集的数据,供后续步骤使用
for i, step in enumerate(plan.steps):
logger.info(f"[Execution] Step {i+1}/{len(plan.steps)}: {step.tool}")
start_time = datetime.now()
try:
# 替换 arguments 中的占位符
arguments = step.arguments.copy()
if step.tool == "summarize_with_llm" and arguments.get("data") == "前面收集的所有数据":
# 将收集的数据传递给总结工具
arguments["data"] = json.dumps(collected_data, ensure_ascii=False, indent=2)
# 执行工具
result = await self.execute_tool(step.tool, arguments, tool_handlers)
execution_time = (datetime.now() - start_time).total_seconds()
# 保存结果
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=arguments,
status="success",
result=result,
execution_time=execution_time,
)
results.append(step_result)
# 收集数据
collected_data[f"step_{i+1}_{step.tool}"] = result
logger.info(f"[Execution] Step {i+1} completed in {execution_time:.2f}s")
except Exception as e:
logger.error(f"[Execution] Step {i+1} failed: {str(e)}")
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=step.arguments,
status="failed",
error=str(e),
execution_time=execution_time,
)
results.append(step_result)
# 根据错误类型决定是否继续
if "not found" in str(e).lower():
logger.warning(f"[Execution] Stopping due to critical error")
break
else:
logger.warning(f"[Execution] Continuing despite error")
continue
logger.info(f"[Execution] Execution completed: {len(results)} steps")
return results
async def summarize_with_llm(self, data: str, task: str) -> str:
"""特殊工具:使用 LLM 总结数据"""
logger.info(f"[LLM Summary] Task: {task}")
messages = [
{
"role": "system",
"content": "你是一个专业的金融分析师。根据提供的数据,完成指定的分析任务。"
},
{
"role": "user",
"content": f"## 任务\n{task}\n\n## 数据\n{data}\n\n请根据数据完成分析任务,用专业且易懂的语言呈现。"
},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
max_tokens=2000,
)
summary = response.choices[0].message.content
return summary
# ==================== 阶段 3: 结果总结 ====================
async def generate_final_summary(
self,
user_query: str,
plan: ExecutionPlan,
step_results: List[StepResult],
) -> str:
"""阶段3: 生成最终总结"""
logger.info("[Summary] Generating final summary")
# 收集所有成功的结果
successful_results = [r for r in step_results if r.status == "success"]
if not successful_results:
return "很抱歉,所有步骤都执行失败,无法生成分析报告。"
# 构建总结提示
results_text = "\n\n".join([
f"**步骤 {r.step_index + 1}: {r.tool}**\n"
f"结果: {json.dumps(r.result, ensure_ascii=False, indent=2)[:1000]}..."
for r in successful_results
])
messages = [
{
"role": "system",
"content": "你是一个专业的金融研究助手。根据执行结果,生成一份简洁清晰的报告。"
},
{
"role": "user",
"content": f"""
用户问题:{user_query}
执行计划:{plan.goal}
执行结果:
{results_text}
请根据以上信息生成一份专业的分析报告300字以内
"""
},
]
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info("[Summary] Final summary generated")
return summary
# ==================== 主流程 ====================
async def process_query(
self,
user_query: str,
tools: List[dict],
tool_handlers: Dict[str, Any],
) -> AgentResponse:
"""主流程:处理用户查询"""
logger.info(f"[Agent] Processing query: {user_query}")
try:
# 阶段 1: 创建计划
plan = await self.create_plan(user_query, tools)
# 阶段 2: 执行计划
step_results = await self.execute_plan(plan, tool_handlers)
# 阶段 3: 生成总结
final_summary = await self.generate_final_summary(
user_query, plan, step_results
)
return AgentResponse(
success=True,
message=final_summary,
plan=plan,
step_results=step_results,
final_summary=final_summary,
metadata={
"total_steps": len(plan.steps),
"successful_steps": len([r for r in step_results if r.status == "success"]),
"failed_steps": len([r for r in step_results if r.status == "failed"]),
"total_execution_time": sum(r.execution_time for r in step_results),
},
)
except Exception as e:
logger.error(f"[Agent] Error: {str(e)}", exc_info=True)
return AgentResponse(
success=False,
message=f"处理失败: {str(e)}",
)
# ==================== FastAPI 端点 ====================
"""
在 mcp_server.py 中添加:
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
# 创建 Agent 实例
agent = MCPAgent(provider="qwen")
@app.post("/agent/chat", response_model=AgentResponse)
async def agent_chat(request: ChatRequest):
\"\"\"智能代理对话端点\"\"\"
logger.info(f"Agent chat: {request.message}")
# 获取工具列表和处理器
tools = [tool.dict() for tool in TOOLS]
# 处理查询
response = await agent.process_query(
user_query=request.message,
tools=tools,
tool_handlers=TOOL_HANDLERS,
)
return response
"""

View File

@@ -1,295 +0,0 @@
"""
MCP Chat Endpoint - 添加到 mcp_server.py
集成LLM实现智能对话自动调用MCP工具并总结结果
"""
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
import os
import json
from openai import OpenAI
import logging
logger = logging.getLogger(__name__)
# ==================== LLM配置 ====================
# 支持多种LLM提供商
LLM_PROVIDERS = {
"openai": {
"api_key": os.getenv("OPENAI_API_KEY", ""),
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o-mini", # 便宜且快速
},
"qwen": {
"api_key": os.getenv("DASHSCOPE_API_KEY", ""),
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"model": "qwen-plus",
},
"deepseek": {
"api_key": os.getenv("DEEPSEEK_API_KEY", ""),
"base_url": "https://api.deepseek.com/v1",
"model": "deepseek-chat",
},
}
# 默认使用的LLM提供商
DEFAULT_PROVIDER = "qwen" # 推荐使用通义千问,价格便宜
# ==================== 数据模型 ====================
class Message(BaseModel):
"""消息"""
role: str # system, user, assistant
content: str
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
provider: Optional[str] = DEFAULT_PROVIDER
class ChatResponse(BaseModel):
"""聊天响应"""
success: bool
message: str
tool_used: Optional[str] = None
raw_data: Optional[Any] = None
error: Optional[str] = None
# ==================== LLM助手类 ====================
class MCPChatAssistant:
"""MCP聊天助手 - 集成LLM和工具调用"""
def __init__(self, provider: str = DEFAULT_PROVIDER):
self.provider = provider
config = LLM_PROVIDERS.get(provider)
if not config or not config["api_key"]:
logger.warning(f"LLM provider '{provider}' not configured, using fallback mode")
self.client = None
else:
self.client = OpenAI(
api_key=config["api_key"],
base_url=config["base_url"],
)
self.model = config["model"]
def get_system_prompt(self, tools: List[dict]) -> str:
"""构建系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n描述:{tool['description']}\n参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
{tools_desc}
## 工作流程
1. **理解用户意图**:分析用户问题,确定需要什么信息
2. **选择工具**:从上面的工具中选择最合适的一个或多个
3. **提取参数**:从用户输入中提取工具需要的参数
4. **返回工具调用指令**JSON格式
{{"tool": "工具名", "arguments": {{...}}}}
## 重要规则
- 贵州茅台的股票代码是 **600519**
- 如果用户提到股票名称,尝试推断股票代码
- 如果不确定需要什么信息,使用 search_china_news 搜索相关新闻
- 涨停是指股票当日涨幅达到10%左右
- 只返回工具调用指令,不要额外解释
## 示例
用户:"查询贵州茅台的股票信息"
你:{{"tool": "get_stock_basic_info", "arguments": {{"seccode": "600519"}}}}
用户:"今日涨停的股票有哪些"
你:{{"tool": "search_limit_up_stocks", "arguments": {{"query": "", "mode": "hybrid", "page_size": 10}}}}
用户:"新能源概念板块表现如何"
你:{{"tool": "search_concepts", "arguments": {{"query": "新能源", "size": 10, "sort_by": "change_pct"}}}}
"""
async def chat(self, user_message: str, conversation_history: List[Dict[str, str]], tools: List[dict]) -> ChatResponse:
"""智能对话"""
try:
if not self.client:
# 降级到简单匹配
return await self.fallback_chat(user_message)
# 1. 构建消息历史
messages = [
{"role": "system", "content": self.get_system_prompt(tools)},
]
# 添加历史对话最多保留最近10轮
for msg in conversation_history[-20:]:
messages.append({
"role": "user" if msg.get("isUser") else "assistant",
"content": msg.get("content", ""),
})
messages.append({"role": "user", "content": user_message})
# 2. 调用LLM获取工具调用指令
logger.info(f"Calling LLM with {len(messages)} messages")
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.3, # 低温度,更确定性
max_tokens=500,
)
tool_call_instruction = response.choices[0].message.content.strip()
logger.info(f"LLM response: {tool_call_instruction}")
# 3. 解析工具调用指令
try:
tool_call = json.loads(tool_call_instruction)
tool_name = tool_call.get("tool")
tool_args = tool_call.get("arguments", {})
if not tool_name:
raise ValueError("No tool specified")
# 4. 调用工具(这里需要导入 mcp_server 的工具处理器)
from mcp_server import TOOL_HANDLERS
handler = TOOL_HANDLERS.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
tool_result = await handler(tool_args)
# 5. 让LLM总结结果
summary_messages = messages + [
{"role": "assistant", "content": tool_call_instruction},
{"role": "system", "content": f"工具 {tool_name} 返回的数据:\n{json.dumps(tool_result, ensure_ascii=False, indent=2)}\n\n请用自然语言总结这些数据给用户一个简洁清晰的回复不超过200字"}
]
summary_response = self.client.chat.completions.create(
model=self.model,
messages=summary_messages,
temperature=0.7,
max_tokens=300,
)
summary = summary_response.choices[0].message.content
return ChatResponse(
success=True,
message=summary,
tool_used=tool_name,
raw_data=tool_result,
)
except json.JSONDecodeError:
# LLM没有返回JSON格式直接返回其回复
return ChatResponse(
success=True,
message=tool_call_instruction,
)
except Exception as tool_error:
logger.error(f"Tool execution error: {str(tool_error)}")
return ChatResponse(
success=False,
message="工具调用失败",
error=str(tool_error),
)
except Exception as e:
logger.error(f"Chat error: {str(e)}", exc_info=True)
return ChatResponse(
success=False,
message="对话处理失败",
error=str(e),
)
async def fallback_chat(self, user_message: str) -> ChatResponse:
"""降级方案:简单关键词匹配"""
from mcp_server import TOOL_HANDLERS
try:
# 茅台特殊处理
if "茅台" in user_message or "贵州茅台" in user_message:
handler = TOOL_HANDLERS.get("get_stock_basic_info")
result = await handler({"seccode": "600519"})
return ChatResponse(
success=True,
message="已为您查询贵州茅台(600519)的股票信息:",
tool_used="get_stock_basic_info",
raw_data=result,
)
# 涨停分析
elif "涨停" in user_message:
handler = TOOL_HANDLERS.get("search_limit_up_stocks")
query = user_message.replace("涨停", "").strip()
result = await handler({"query": query, "mode": "hybrid", "page_size": 10})
return ChatResponse(
success=True,
message="已为您查询涨停股票信息:",
tool_used="search_limit_up_stocks",
raw_data=result,
)
# 概念板块
elif "概念" in user_message or "板块" in user_message:
handler = TOOL_HANDLERS.get("search_concepts")
query = user_message.replace("概念", "").replace("板块", "").strip()
result = await handler({"query": query, "size": 10, "sort_by": "change_pct"})
return ChatResponse(
success=True,
message=f"已为您查询'{query}'相关概念板块:",
tool_used="search_concepts",
raw_data=result,
)
# 默认:搜索新闻
else:
handler = TOOL_HANDLERS.get("search_china_news")
result = await handler({"query": user_message, "top_k": 5})
return ChatResponse(
success=True,
message="已为您搜索相关新闻:",
tool_used="search_china_news",
raw_data=result,
)
except Exception as e:
logger.error(f"Fallback chat error: {str(e)}")
return ChatResponse(
success=False,
message="查询失败",
error=str(e),
)
# ==================== FastAPI端点 ====================
# 在 mcp_server.py 中添加以下代码:
"""
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
# 创建聊天助手实例
chat_assistant = MCPChatAssistant(provider="qwen") # 或 "openai", "deepseek"
@app.post("/chat", response_model=ChatResponse)
async def chat_endpoint(request: ChatRequest):
\"\"\"智能对话端点 - 使用LLM理解意图并调用工具\"\"\"
logger.info(f"Chat request: {request.message}")
# 获取可用工具列表
tools = [tool.dict() for tool in TOOLS]
# 调用聊天助手
response = await chat_assistant.chat(
user_message=request.message,
conversation_history=request.conversation_history,
tools=tools,
)
return response
"""

View File

@@ -1,248 +0,0 @@
"""
MCP客户端使用示例
演示如何调用MCP服务器的各种工具
"""
import httpx
import json
from typing import Dict, Any
class MCPClient:
"""MCP客户端"""
def __init__(self, base_url: str = "http://localhost:8900"):
self.base_url = base_url
self.client = httpx.Client(timeout=60.0)
def list_tools(self):
"""列出所有可用工具"""
response = self.client.get(f"{self.base_url}/tools")
response.raise_for_status()
return response.json()
def get_tool(self, tool_name: str):
"""获取特定工具的定义"""
response = self.client.get(f"{self.base_url}/tools/{tool_name}")
response.raise_for_status()
return response.json()
def call_tool(self, tool_name: str, arguments: Dict[str, Any]):
"""调用工具"""
payload = {
"tool": tool_name,
"arguments": arguments
}
response = self.client.post(f"{self.base_url}/tools/call", json=payload)
response.raise_for_status()
return response.json()
def close(self):
"""关闭客户端"""
self.client.close()
def print_result(title: str, result: Dict[str, Any]):
"""打印结果"""
print(f"\n{'=' * 60}")
print(f"{title}")
print(f"{'=' * 60}")
print(json.dumps(result, ensure_ascii=False, indent=2))
def main():
"""主函数 - 演示各种工具的使用"""
client = MCPClient()
try:
# 1. 列出所有工具
print("\n示例1: 列出所有可用工具")
tools = client.list_tools()
print(f"可用工具数量: {len(tools['tools'])}")
for tool in tools['tools']:
print(f" - {tool['name']}: {tool['description'][:50]}...")
# 2. 搜索中国新闻
print("\n示例2: 搜索中国新闻(关键词:人工智能)")
result = client.call_tool(
"search_china_news",
{
"query": "人工智能",
"top_k": 5
}
)
if result['success']:
print_result("中国新闻搜索结果", result['data'])
# 3. 搜索概念板块(按涨跌幅排序)
print("\n示例3: 搜索概念板块(关键词:新能源,按涨跌幅排序)")
result = client.call_tool(
"search_concepts",
{
"query": "新能源",
"size": 5,
"sort_by": "change_pct"
}
)
if result['success']:
print_result("概念搜索结果", result['data'])
# 4. 获取股票的相关概念
print("\n示例4: 获取股票相关概念股票代码600519")
result = client.call_tool(
"get_stock_concepts",
{
"stock_code": "600519",
"size": 10
}
)
if result['success']:
print_result("股票概念结果", result['data'])
# 5. 搜索涨停股票
print("\n示例5: 搜索涨停股票(关键词:锂电池)")
result = client.call_tool(
"search_limit_up_stocks",
{
"query": "锂电池",
"mode": "hybrid",
"page_size": 5
}
)
if result['success']:
print_result("涨停股票搜索结果", result['data'])
# 6. 搜索研究报告
print("\n示例6: 搜索研究报告(关键词:投资策略)")
result = client.call_tool(
"search_research_reports",
{
"query": "投资策略",
"mode": "hybrid",
"size": 3
}
)
if result['success']:
print_result("研究报告搜索结果", result['data'])
# 7. 获取概念统计数据
print("\n示例7: 获取概念统计最近7天")
result = client.call_tool(
"get_concept_statistics",
{
"days": 7,
"min_stock_count": 3
}
)
if result['success']:
print_result("概念统计结果", result['data'])
# 8. 搜索路演信息
print("\n示例8: 搜索路演信息(关键词:业绩)")
result = client.call_tool(
"search_roadshows",
{
"query": "业绩",
"size": 3
}
)
if result['success']:
print_result("路演搜索结果", result['data'])
# 9. 获取股票基本信息
print("\n示例9: 获取股票基本信息股票600519")
result = client.call_tool(
"get_stock_basic_info",
{
"seccode": "600519"
}
)
if result['success']:
print_result("股票基本信息", result['data'])
# 10. 获取股票财务指标
print("\n示例10: 获取股票财务指标股票600519最近5期")
result = client.call_tool(
"get_stock_financial_index",
{
"seccode": "600519",
"limit": 5
}
)
if result['success']:
print_result("财务指标", result['data'])
# 11. 获取股票交易数据
print("\n示例11: 获取股票交易数据股票600519最近10天")
result = client.call_tool(
"get_stock_trade_data",
{
"seccode": "600519",
"limit": 10
}
)
if result['success']:
print_result("交易数据", result['data'])
# 12. 按行业搜索股票
print("\n示例12: 按行业搜索股票(行业:半导体)")
result = client.call_tool(
"search_stocks_by_criteria",
{
"industry": "半导体",
"limit": 10
}
)
if result['success']:
print_result("行业股票", result['data'])
# 13. 股票对比分析
print("\n示例13: 股票对比分析600519 vs 000858")
result = client.call_tool(
"get_stock_comparison",
{
"seccodes": ["600519", "000858"],
"metric": "financial"
}
)
if result['success']:
print_result("股票对比", result['data'])
except Exception as e:
print(f"\n错误: {str(e)}")
finally:
client.close()
def test_single_tool():
"""测试单个工具(用于快速测试)"""
client = MCPClient()
try:
# 修改这里来测试不同的工具
result = client.call_tool(
"search_china_news",
{
"query": "芯片",
"exact_match": True,
"top_k": 3
}
)
print_result("测试结果", result)
except Exception as e:
print(f"错误: {str(e)}")
finally:
client.close()
if __name__ == "__main__":
# 运行完整示例
main()
# 或者测试单个工具
# test_single_tool()

View File

@@ -544,3 +544,240 @@ async def get_stock_comparison(
"comparison_type": metric,
"stocks": [convert_row(row) for row in results]
}
async def get_user_favorite_stocks(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选股列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选股列表(包含最新行情数据)
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选股(假设有 user_favorites 表)
# 如果没有此表,可以根据实际情况调整
query = """
SELECT
f.user_id,
f.stock_code,
b.SECNAME as stock_name,
b.F030V as industry,
t.F007N as current_price,
t.F010N as change_pct,
t.F012N as turnover_rate,
t.F026N as pe_ratio,
t.TRADEDATE as latest_trade_date,
f.created_at as favorite_time
FROM user_favorites f
INNER JOIN ea_baseinfo b ON f.stock_code = b.SECCODE
LEFT JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
GROUP BY SECCODE
) latest ON b.SECCODE = latest.SECCODE
LEFT JOIN ea_trade t ON b.SECCODE = t.SECCODE
AND t.TRADEDATE = latest.max_date
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY f.created_at DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_user_favorite_events(user_id: str, limit: int = 100) -> List[Dict[str, Any]]:
"""
获取用户自选事件列表
Args:
user_id: 用户ID
limit: 返回条数
Returns:
自选事件列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 查询用户自选事件(假设有 user_event_favorites 表)
query = """
SELECT
f.user_id,
f.event_id,
e.title,
e.description,
e.event_date,
e.importance,
e.related_stocks,
e.category,
f.created_at as favorite_time
FROM user_event_favorites f
INNER JOIN events e ON f.event_id = e.id
WHERE f.user_id = %s AND f.is_deleted = 0
ORDER BY e.event_date DESC
LIMIT %s
"""
await cursor.execute(query, [user_id, limit])
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def add_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
添加自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_favorites
WHERE user_id = %s AND stock_code = %s
"""
await cursor.execute(check_query, [user_id, stock_code])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选股"}
else:
return {"success": False, "message": "该股票已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_favorites (user_id, stock_code, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, stock_code])
return {"success": True, "message": "添加自选股成功"}
async def remove_favorite_stock(user_id: str, stock_code: str) -> Dict[str, Any]:
"""
删除自选股
Args:
user_id: 用户ID
stock_code: 股票代码
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND stock_code = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, stock_code])
if result > 0:
return {"success": True, "message": "删除自选股成功"}
else:
return {"success": False, "message": "未找到该自选股"}
async def add_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
添加自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 检查是否已存在
check_query = """
SELECT id, is_deleted
FROM user_event_favorites
WHERE user_id = %s AND event_id = %s
"""
await cursor.execute(check_query, [user_id, event_id])
existing = await cursor.fetchone()
if existing:
if existing['is_deleted'] == 1:
# 恢复已删除的记录
update_query = """
UPDATE user_event_favorites
SET is_deleted = 0, updated_at = NOW()
WHERE id = %s
"""
await cursor.execute(update_query, [existing['id']])
return {"success": True, "message": "已恢复自选事件"}
else:
return {"success": False, "message": "该事件已在自选中"}
# 插入新记录
insert_query = """
INSERT INTO user_event_favorites (user_id, event_id, created_at, updated_at, is_deleted)
VALUES (%s, %s, NOW(), NOW(), 0)
"""
await cursor.execute(insert_query, [user_id, event_id])
return {"success": True, "message": "添加自选事件成功"}
async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
"""
删除自选事件
Args:
user_id: 用户ID
event_id: 事件ID
Returns:
操作结果
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
UPDATE user_event_favorites
SET is_deleted = 1, updated_at = NOW()
WHERE user_id = %s AND event_id = %s AND is_deleted = 0
"""
result = await cursor.execute(query, [user_id, event_id])
if result > 0:
return {"success": True, "message": "删除自选事件成功"}
else:
return {"success": False, "message": "未找到该自选事件"}

320
mcp_elasticsearch.py Normal file
View File

@@ -0,0 +1,320 @@
"""
Elasticsearch 连接和工具模块
用于聊天记录存储和向量搜索
"""
from elasticsearch import Elasticsearch, helpers
from datetime import datetime
from typing import List, Dict, Any, Optional
import logging
import json
import openai
logger = logging.getLogger(__name__)
# ==================== 配置 ====================
# ES 配置
ES_CONFIG = {
"host": "http://222.128.1.157:19200",
"index_chat_history": "agent_chat_history", # 聊天记录索引
}
# Embedding 配置
EMBEDDING_CONFIG = {
"api_key": "dummy",
"base_url": "http://222.128.1.157:18008/v1",
"model": "qwen3-embedding-8b",
"dims": 4096, # 向量维度
}
# ==================== ES 客户端 ====================
class ESClient:
"""Elasticsearch 客户端封装"""
def __init__(self):
self.es = Elasticsearch([ES_CONFIG["host"]], request_timeout=60)
self.chat_index = ES_CONFIG["index_chat_history"]
# 初始化 OpenAI 客户端用于 embedding
self.embedding_client = openai.OpenAI(
api_key=EMBEDDING_CONFIG["api_key"],
base_url=EMBEDDING_CONFIG["base_url"],
)
self.embedding_model = EMBEDDING_CONFIG["model"]
# 初始化索引
self.create_chat_history_index()
def create_chat_history_index(self):
"""创建聊天记录索引"""
if self.es.indices.exists(index=self.chat_index):
logger.info(f"索引 {self.chat_index} 已存在")
return
mappings = {
"properties": {
"session_id": {"type": "keyword"}, # 会话ID
"user_id": {"type": "keyword"}, # 用户ID
"user_nickname": {"type": "text"}, # 用户昵称
"user_avatar": {"type": "keyword"}, # 用户头像URL
"message_type": {"type": "keyword"}, # user / assistant
"message": {"type": "text"}, # 消息内容
"message_embedding": { # 消息向量
"type": "dense_vector",
"dims": EMBEDDING_CONFIG["dims"],
"index": True,
"similarity": "cosine"
},
"plan": {"type": "text"}, # 执行计划(仅 assistant
"steps": {"type": "text"}, # 执行步骤(仅 assistant
"timestamp": {"type": "date"}, # 时间戳
"created_at": {"type": "date"}, # 创建时间
}
}
self.es.indices.create(index=self.chat_index, body={"mappings": mappings})
logger.info(f"创建索引: {self.chat_index}")
def generate_embedding(self, text: str) -> List[float]:
"""生成文本向量"""
try:
if not text or len(text.strip()) == 0:
return []
# 截断过长文本
text = text[:16000] if len(text) > 16000 else text
response = self.embedding_client.embeddings.create(
model=self.embedding_model,
input=[text]
)
return response.data[0].embedding
except Exception as e:
logger.error(f"Embedding 生成失败: {e}")
return []
def save_chat_message(
self,
session_id: str,
user_id: str,
user_nickname: str,
user_avatar: str,
message_type: str, # "user" or "assistant"
message: str,
plan: Optional[str] = None,
steps: Optional[str] = None,
) -> str:
"""
保存聊天消息
Args:
session_id: 会话ID
user_id: 用户ID
user_nickname: 用户昵称
user_avatar: 用户头像URL
message_type: 消息类型 (user/assistant)
message: 消息内容
plan: 执行计划(可选)
steps: 执行步骤(可选)
Returns:
文档ID
"""
try:
# 生成向量
embedding = self.generate_embedding(message)
doc = {
"session_id": session_id,
"user_id": user_id,
"user_nickname": user_nickname,
"user_avatar": user_avatar,
"message_type": message_type,
"message": message,
"message_embedding": embedding if embedding else None,
"plan": plan,
"steps": steps,
"timestamp": datetime.now(),
"created_at": datetime.now(),
}
result = self.es.index(index=self.chat_index, body=doc)
logger.info(f"保存聊天记录: {result['_id']}")
return result["_id"]
except Exception as e:
logger.error(f"保存聊天记录失败: {e}")
raise
def get_chat_sessions(self, user_id: str, limit: int = 50) -> List[Dict[str, Any]]:
"""
获取用户的聊天会话列表
Args:
user_id: 用户ID
limit: 返回数量
Returns:
会话列表每个会话包含session_id, last_message, last_timestamp
"""
try:
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
query = {
"query": {
"term": {"user_id": user_id}
},
"aggs": {
"sessions": {
"terms": {
"field": "session_id",
"size": limit,
"order": {"last_message": "desc"}
},
"aggs": {
"last_message": {
"max": {"field": "timestamp"}
},
"last_message_content": {
"top_hits": {
"size": 1,
"sort": [{"timestamp": {"order": "desc"}}],
"_source": ["message", "timestamp", "message_type"]
}
}
}
}
},
"size": 0
}
result = self.es.search(index=self.chat_index, body=query)
sessions = []
for bucket in result["aggregations"]["sessions"]["buckets"]:
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
sessions.append({
"session_id": bucket["key"],
"last_message": session_data["message"],
"last_timestamp": session_data["timestamp"],
"message_count": bucket["doc_count"],
})
return sessions
except Exception as e:
logger.error(f"获取会话列表失败: {e}")
return []
def get_chat_history(
self,
session_id: str,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
获取指定会话的聊天历史
Args:
session_id: 会话ID
limit: 返回数量
Returns:
聊天记录列表
"""
try:
query = {
"query": {
"term": {"session_id": session_id}
},
"sort": [{"timestamp": {"order": "asc"}}],
"size": limit
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"message_type": doc["message_type"],
"message": doc["message"],
"plan": doc.get("plan"),
"steps": doc.get("steps"),
"timestamp": doc["timestamp"],
})
return messages
except Exception as e:
logger.error(f"获取聊天历史失败: {e}")
return []
def search_chat_history(
self,
user_id: str,
query_text: str,
top_k: int = 10
) -> List[Dict[str, Any]]:
"""
向量搜索聊天历史
Args:
user_id: 用户ID
query_text: 查询文本
top_k: 返回数量
Returns:
相关聊天记录列表
"""
try:
# 生成查询向量
query_embedding = self.generate_embedding(query_text)
if not query_embedding:
return []
# 向量搜索
query = {
"query": {
"bool": {
"must": [
{"term": {"user_id": user_id}},
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'message_embedding') + 1.0",
"params": {"query_vector": query_embedding}
}
}
}
]
}
},
"size": top_k
}
result = self.es.search(index=self.chat_index, body=query)
messages = []
for hit in result["hits"]["hits"]:
doc = hit["_source"]
messages.append({
"session_id": doc["session_id"],
"message_type": doc["message_type"],
"message": doc["message"],
"timestamp": doc["timestamp"],
"score": hit["_score"],
})
return messages
except Exception as e:
logger.error(f"向量搜索失败: {e}")
return []
# ==================== 全局实例 ====================
# 创建全局 ES 客户端
es_client = ESClient()

File diff suppressed because it is too large Load Diff

View File

@@ -1,492 +0,0 @@
"""
集成到 mcp_server.py 的 Agent 系统
使用 Kimi (kimi-k2-thinking) 和 DeepMoney 两个模型
"""
from openai import OpenAI
from pydantic import BaseModel
from typing import List, Dict, Any, Optional, Literal
from datetime import datetime
import json
import logging
logger = logging.getLogger(__name__)
# ==================== 模型配置 ====================
# Kimi 配置 - 用于计划制定和深度推理
KIMI_CONFIG = {
"api_key": "sk-TzB4VYJfCoXGcGrGMiewukVRzjuDsbVCkaZXi2LvkS8s60E5",
"base_url": "https://api.moonshot.cn/v1",
"model": "kimi-k2-thinking", # 思考模型
}
# DeepMoney 配置 - 用于新闻总结
DEEPMONEY_CONFIG = {
"api_key": "", # 空值
"base_url": "http://111.62.35.50:8000/v1",
"model": "deepmoney",
}
# ==================== 数据模型 ====================
class ToolCall(BaseModel):
"""工具调用"""
tool: str
arguments: Dict[str, Any]
reason: str
class ExecutionPlan(BaseModel):
"""执行计划"""
goal: str
steps: List[ToolCall]
reasoning: str
class StepResult(BaseModel):
"""单步执行结果"""
step_index: int
tool: str
arguments: Dict[str, Any]
status: Literal["success", "failed", "skipped"]
result: Optional[Any] = None
error: Optional[str] = None
execution_time: float = 0
class AgentResponse(BaseModel):
"""Agent响应"""
success: bool
message: str
plan: Optional[ExecutionPlan] = None
step_results: List[StepResult] = []
final_summary: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class ChatRequest(BaseModel):
"""聊天请求"""
message: str
conversation_history: List[Dict[str, str]] = []
# ==================== Agent 系统 ====================
class MCPAgentIntegrated:
"""集成版 MCP Agent - 使用 Kimi 和 DeepMoney"""
def __init__(self):
# 初始化 Kimi 客户端(计划制定)
self.kimi_client = OpenAI(
api_key=KIMI_CONFIG["api_key"],
base_url=KIMI_CONFIG["base_url"],
)
self.kimi_model = KIMI_CONFIG["model"]
# 初始化 DeepMoney 客户端(新闻总结)
self.deepmoney_client = OpenAI(
api_key=DEEPMONEY_CONFIG["api_key"],
base_url=DEEPMONEY_CONFIG["base_url"],
)
self.deepmoney_model = DEEPMONEY_CONFIG["model"]
def get_planning_prompt(self, tools: List[dict]) -> str:
"""获取计划制定的系统提示词"""
tools_desc = "\n\n".join([
f"**{tool['name']}**\n"
f"描述:{tool['description']}\n"
f"参数:{json.dumps(tool['parameters'], ensure_ascii=False, indent=2)}"
for tool in tools
])
return f"""你是一个专业的金融研究助手。根据用户问题,制定详细的执行计划。
## 可用工具
{tools_desc}
## 特殊工具
- **summarize_news**: 使用 DeepMoney 模型总结新闻数据
- 参数: {{"data": "新闻列表JSON", "focus": "关注点"}}
- 适用场景: 当需要总结新闻、研报等文本数据时
## 重要知识
- 贵州茅台: 600519
- 涨停: 涨幅约10%
- 概念板块: 相同题材股票分类
## 任务
分析用户问题,制定执行计划。返回 JSON
```json
{{
"goal": "用户目标",
"reasoning": "分析思路",
"steps": [
{{
"tool": "工具名",
"arguments": {{"参数": ""}},
"reason": "原因"
}}
]
}}
```
## 规划原则
1. 先收集数据,再分析总结
2. 使用 summarize_news 总结新闻类数据
3. 不超过5个步骤
4. 最后一步通常是总结
## 示例
用户:"贵州茅台最近有什么新闻"
计划:
```json
{{
"goal": "查询并总结贵州茅台最新新闻",
"reasoning": "先搜索新闻,再用 DeepMoney 总结",
"steps": [
{{
"tool": "search_china_news",
"arguments": {{"query": "贵州茅台", "top_k": 10}},
"reason": "搜索贵州茅台相关新闻"
}},
{{
"tool": "summarize_news",
"arguments": {{
"data": "前面的新闻数据",
"focus": "贵州茅台的重要动态和市场影响"
}},
"reason": "使用DeepMoney总结新闻要点"
}}
]
}}
```
只返回JSON不要其他内容。"""
async def create_plan(self, user_query: str, tools: List[dict]) -> ExecutionPlan:
"""阶段1: 使用 Kimi 创建执行计划(带思考过程)"""
logger.info(f"[Planning] Kimi开始制定计划: {user_query}")
messages = [
{"role": "system", "content": self.get_planning_prompt(tools)},
{"role": "user", "content": user_query},
]
# 使用 Kimi 思考模型
response = self.kimi_client.chat.completions.create(
model=self.kimi_model,
messages=messages,
temperature=1.0, # Kimi 推荐
max_tokens=16000, # 足够容纳 reasoning_content
)
choice = response.choices[0]
message = choice.message
# 提取思考过程
reasoning_content = ""
if hasattr(message, "reasoning_content"):
reasoning_content = getattr(message, "reasoning_content")
logger.info(f"[Planning] Kimi思考过程: {reasoning_content[:200]}...")
# 提取计划内容
plan_json = message.content.strip()
# 清理可能的代码块标记
if "```json" in plan_json:
plan_json = plan_json.split("```json")[1].split("```")[0].strip()
elif "```" in plan_json:
plan_json = plan_json.split("```")[1].split("```")[0].strip()
plan_data = json.loads(plan_json)
plan = ExecutionPlan(
goal=plan_data["goal"],
reasoning=plan_data.get("reasoning", "") + "\n\n" + (reasoning_content[:500] if reasoning_content else ""),
steps=[ToolCall(**step) for step in plan_data["steps"]],
)
logger.info(f"[Planning] 计划制定完成: {len(plan.steps)}")
return plan
async def execute_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
tool_handlers: Dict[str, Any],
) -> Dict[str, Any]:
"""执行单个工具"""
# 特殊工具summarize_news使用 DeepMoney
if tool_name == "summarize_news":
return await self.summarize_news_with_deepmoney(
data=arguments.get("data", ""),
focus=arguments.get("focus", "关键信息"),
)
# 调用 MCP 工具
handler = tool_handlers.get(tool_name)
if not handler:
raise ValueError(f"Tool '{tool_name}' not found")
result = await handler(arguments)
return result
async def summarize_news_with_deepmoney(self, data: str, focus: str) -> str:
"""使用 DeepMoney 模型总结新闻"""
logger.info(f"[DeepMoney] 总结新闻,关注点: {focus}")
messages = [
{
"role": "system",
"content": "你是一个专业的金融新闻分析师,擅长提取关键信息并进行总结。"
},
{
"role": "user",
"content": f"请总结以下新闻数据,关注点:{focus}\n\n数据:\n{data[:3000]}"
},
]
try:
response = self.deepmoney_client.chat.completions.create(
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info(f"[DeepMoney] 总结完成")
return summary
except Exception as e:
logger.error(f"[DeepMoney] 总结失败: {str(e)}")
# 降级:返回简化摘要
return f"新闻总结失败,原始数据:{data[:500]}..."
async def execute_plan(
self,
plan: ExecutionPlan,
tool_handlers: Dict[str, Any],
) -> List[StepResult]:
"""阶段2: 执行计划"""
logger.info(f"[Execution] 开始执行: {len(plan.steps)}")
results = []
collected_data = {}
for i, step in enumerate(plan.steps):
logger.info(f"[Execution] 步骤 {i+1}/{len(plan.steps)}: {step.tool}")
start_time = datetime.now()
try:
# 替换占位符
arguments = step.arguments.copy()
# 如果参数值是 "前面的新闻数据" 或 "前面收集的所有数据"
if step.tool == "summarize_news":
if arguments.get("data") in ["前面的新闻数据", "前面收集的所有数据"]:
# 将收集的数据传递
arguments["data"] = json.dumps(collected_data, ensure_ascii=False, indent=2)
# 执行工具
result = await self.execute_tool(step.tool, arguments, tool_handlers)
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=arguments,
status="success",
result=result,
execution_time=execution_time,
)
results.append(step_result)
# 收集数据
collected_data[f"step_{i+1}_{step.tool}"] = result
logger.info(f"[Execution] 步骤 {i+1} 完成: {execution_time:.2f}s")
except Exception as e:
logger.error(f"[Execution] 步骤 {i+1} 失败: {str(e)}")
execution_time = (datetime.now() - start_time).total_seconds()
step_result = StepResult(
step_index=i,
tool=step.tool,
arguments=step.arguments,
status="failed",
error=str(e),
execution_time=execution_time,
)
results.append(step_result)
# 继续执行其他步骤
continue
logger.info(f"[Execution] 执行完成")
return results
async def generate_final_summary(
self,
user_query: str,
plan: ExecutionPlan,
step_results: List[StepResult],
) -> str:
"""阶段3: 使用 Kimi 生成最终总结"""
logger.info("[Summary] Kimi生成最终总结")
# 收集成功的结果
successful_results = [r for r in step_results if r.status == "success"]
if not successful_results:
return "很抱歉,所有步骤都执行失败,无法生成分析报告。"
# 构建结果文本(精简版)
results_text = "\n\n".join([
f"**步骤 {r.step_index + 1}: {r.tool}**\n"
f"结果: {str(r.result)[:800]}..."
for r in successful_results[:3] # 只取前3个避免超长
])
messages = [
{
"role": "system",
"content": "你是专业的金融研究助手。根据执行结果,生成简洁清晰的报告。"
},
{
"role": "user",
"content": f"""用户问题:{user_query}
执行计划:{plan.goal}
执行结果:
{results_text}
请生成专业的分析报告300字以内"""
},
]
try:
response = self.kimi_client.chat.completions.create(
model="kimi-k2-turbpreview", # 使用非思考模型,更快
messages=messages,
temperature=0.7,
max_tokens=1000,
)
summary = response.choices[0].message.content
logger.info("[Summary] 总结完成")
return summary
except Exception as e:
logger.error(f"[Summary] 总结失败: {str(e)}")
# 降级:返回最后一步的结果
if successful_results:
last_result = successful_results[-1]
if isinstance(last_result.result, str):
return last_result.result
else:
return json.dumps(last_result.result, ensure_ascii=False, indent=2)
return "总结生成失败"
async def process_query(
self,
user_query: str,
tools: List[dict],
tool_handlers: Dict[str, Any],
) -> AgentResponse:
"""主流程"""
logger.info(f"[Agent] 处理查询: {user_query}")
try:
# 阶段1: Kimi 制定计划
plan = await self.create_plan(user_query, tools)
# 阶段2: 执行工具
step_results = await self.execute_plan(plan, tool_handlers)
# 阶段3: Kimi 生成总结
final_summary = await self.generate_final_summary(
user_query, plan, step_results
)
return AgentResponse(
success=True,
message=final_summary,
plan=plan,
step_results=step_results,
final_summary=final_summary,
metadata={
"total_steps": len(plan.steps),
"successful_steps": len([r for r in step_results if r.status == "success"]),
"failed_steps": len([r for r in step_results if r.status == "failed"]),
"total_execution_time": sum(r.execution_time for r in step_results),
"model_used": {
"planning": self.kimi_model,
"summarization": "kimi-k2-turbpreview",
"news_summary": self.deepmoney_model,
},
},
)
except Exception as e:
logger.error(f"[Agent] 错误: {str(e)}", exc_info=True)
return AgentResponse(
success=False,
message=f"处理失败: {str(e)}",
)
# ==================== 添加到 mcp_server.py ====================
"""
在 mcp_server.py 中添加以下代码:
# 导入 Agent 系统
from mcp_server_agent_integration import MCPAgentIntegrated, ChatRequest, AgentResponse
# 创建 Agent 实例(全局)
agent = MCPAgentIntegrated()
# 添加端点
@app.post("/agent/chat", response_model=AgentResponse)
async def agent_chat(request: ChatRequest):
\"\"\"智能代理对话端点\"\"\"
logger.info(f"Agent chat: {request.message}")
# 获取工具列表
tools = [tool.dict() for tool in TOOLS]
# 添加特殊工具summarize_news
tools.append({
"name": "summarize_news",
"description": "使用 DeepMoney 模型总结新闻数据,提取关键信息",
"parameters": {
"type": "object",
"properties": {
"data": {
"type": "string",
"description": "要总结的新闻数据JSON格式"
},
"focus": {
"type": "string",
"description": "关注点,例如:'市场影响''投资机会'"
}
},
"required": ["data"]
}
})
# 处理查询
response = await agent.process_query(
user_query=request.message,
tools=tools,
tool_handlers=TOOL_HANDLERS,
)
return response
"""

View File

@@ -95,10 +95,10 @@
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
"prestart:dev": "kill-port 3000",
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"start:test": "concurrently \"python app.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
"dev": "npm start",
"backend": "python app_2.py",
"backend": "python app.py",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",

View File

@@ -65,6 +65,9 @@
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<!-- ============================================
Dify 机器人配置 - 只在 /home 页面显示
============================================ -->
<script>
window.difyChatbotConfig = {
token: 'DwN8qAKtYFQtWskM',
@@ -85,6 +88,44 @@
},
}
</script>
<!-- Dify 机器人显示控制脚本 -->
<script>
// 控制 Dify 机器人只在 /home 页面显示
function controlDifyVisibility() {
const currentPath = window.location.pathname;
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
if (difyChatButton) {
// 只在 /home 页面显示
if (currentPath === '/home') {
difyChatButton.style.display = 'none';
console.log('[Dify] 显示机器人(当前路径: /home');
} else {
difyChatButton.style.display = 'none';
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, '');
}
}
}
// 页面加载完成后执行
window.addEventListener('load', function() {
console.log('[Dify] 初始化显示控制');
// 初始检查(延迟执行,等待 Dify 按钮渲染)
setTimeout(controlDifyVisibility, 500);
setTimeout(controlDifyVisibility, 1500);
// 监听路由变化React Router 使用 pushState
const observer = setInterval(controlDifyVisibility, 1000);
// 清理函数(可选)
window.addEventListener('beforeunload', function() {
clearInterval(observer);
});
});
</script>
<script
src="https://app.valuefrontier.cn/embed.min.js"
id="DwN8qAKtYFQtWskM"
@@ -166,7 +207,7 @@
bottom: 80px !important;
left: 10px !important;
}
#dify-chatbot-bubble-button {
width: 56px !important;
height: 56px !important;

View File

@@ -0,0 +1,156 @@
################################################################################
# Bytedesk客服系统环境变量配置示例
#
# 使用方法:
# 1. 复制本文件到vf_react项目根目录与package.json同级
# cp bytedesk-integration/.env.bytedesk.example .env.local
#
# 2. 根据实际部署环境修改配置值
#
# 3. 重启开发服务器使配置生效
# npm start
#
# 注意事项:
# - .env.local文件不应提交到Git已在.gitignore中
# - 开发环境和生产环境应使用不同的配置文件
# - 所有以REACT_APP_开头的变量会被打包到前端代码中
################################################################################
# ============================================================================
# Bytedesk服务器配置必需
# ============================================================================
# Bytedesk后端服务地址生产环境
# 格式: http://IP地址 或 https://域名
# 示例: http://43.143.189.195 或 https://kefu.yourdomain.com
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# ============================================================================
# Bytedesk组织和工作组配置必需
# ============================================================================
# 组织IDOrganization UID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 复制UID
# 示例: df_org_uid
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组IDWorkgroup SID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 复制工作组ID
# 示例: df_wg_aftersales (售后服务组)
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ============================================================================
# 可选配置
# ============================================================================
# 客服类型
# 2 = 人工客服(默认)
# 1 = 机器人客服
# REACT_APP_BYTEDESK_TYPE=2
# 语言设置
# zh-cn = 简体中文(默认)
# en = 英语
# ja = 日语
# ko = 韩语
# REACT_APP_BYTEDESK_LOCALE=zh-cn
# 客服图标位置
# bottom-right = 右下角(默认)
# bottom-left = 左下角
# top-right = 右上角
# top-left = 左上角
# REACT_APP_BYTEDESK_PLACEMENT=bottom-right
# 客服图标边距(像素)
# REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
# REACT_APP_BYTEDESK_MARGIN_SIDE=20
# 主题模式
# system = 跟随系统(默认)
# light = 亮色模式
# dark = 暗色模式
# REACT_APP_BYTEDESK_THEME_MODE=system
# 主题色(十六进制颜色)
# REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
# 是否自动弹出客服窗口(不推荐)
# true = 页面加载后自动弹出
# false = 需用户点击图标弹出(默认)
# REACT_APP_BYTEDESK_AUTO_POPUP=false
# ============================================================================
# 开发环境专用配置
# ============================================================================
# 开发环境可以使用不同的服务器地址
# 取消注释以下行使用本地或测试服务器
# REACT_APP_BYTEDESK_API_URL_DEV=http://localhost:9003
# ============================================================================
# 配置示例 - 不同部署场景
# ============================================================================
# ---------- 示例1: 生产环境(域名访问) ----------
# REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
# REACT_APP_BYTEDESK_ORG=prod_org_12345
# REACT_APP_BYTEDESK_SID=prod_wg_sales
# ---------- 示例2: 测试环境IP访问 ----------
# REACT_APP_BYTEDESK_API_URL=http://192.168.1.100
# REACT_APP_BYTEDESK_ORG=test_org_abc
# REACT_APP_BYTEDESK_SID=test_wg_support
# ---------- 示例3: 本地开发环境 ----------
# REACT_APP_BYTEDESK_API_URL=http://localhost:9003
# REACT_APP_BYTEDESK_ORG=dev_org_local
# REACT_APP_BYTEDESK_SID=dev_wg_test
# ============================================================================
# 故障排查
# ============================================================================
# 问题1: 客服图标不显示
# 解决方案:
# - 检查REACT_APP_BYTEDESK_API_URL是否可访问
# - 确认.env文件在项目根目录
# - 重启开发服务器npm start
# - 查看浏览器控制台是否有错误
# 问题2: 连接不上后端服务
# 解决方案:
# - 确认后端服务已启动docker ps查看bytedesk-prod容器
# - 检查CORS配置后端.env.production中的BYTEDESK_CORS_ALLOWED_ORIGINS
# - 确认防火墙未阻止80/443端口
# 问题3: ORG或SID配置错误
# 解决方案:
# - 登录管理后台http://43.143.189.195/admin/
# - 导航到"设置" -> "组织信息"获取ORG
# - 导航到"客服管理" -> "工作组"获取SID
# - 确保复制的ID没有多余空格
# 问题4: 多工作组场景
# 解决方案:
# - 可以为不同页面配置不同的SID
# - 在bytedesk.config.js中使用条件判断
# - 示例: 售后页面用售后组SID销售页面用销售组SID
# ============================================================================
# 安全提示
# ============================================================================
# 1. 不要在代码中硬编码API地址和ID
# 2. .env.local文件不应提交到Git仓库
# 3. 生产环境建议使用HTTPS
# 4. 定期更新后端服务器的安全补丁
# 5. 不要在公开的代码库中暴露组织ID和工作组ID
# ============================================================================
# 更多信息
# ============================================================================
# Bytedesk官方文档: https://docs.bytedesk.com
# 技术支持: 访问http://43.143.189.195/chat/联系在线客服
# GitHub: https://github.com/Bytedesk/bytedesk

View File

@@ -0,0 +1,237 @@
/**
* vf_react App.jsx集成示例
*
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
*
* 集成步骤:
* 1. 将bytedesk-integration文件夹复制到src/目录
* 2. 在App.jsx中导入BytedeskWidget和配置
* 3. 添加BytedeskWidget组件代码如下
* 4. 配置.env文件参考.env.bytedesk.example
*/
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom'; // 如果使用react-router
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
// ============================================================================
// 方案一: 全局集成(推荐)
// 适用场景: 客服系统需要在所有页面显示
// ============================================================================
function App() {
// ========== vf_react原有代码保持不变 ==========
// 这里是您原有的App.jsx代码
// 例如: const [user, setUser] = useState(null);
// 例如: const [theme, setTheme] = useState('light');
// ... 保持原有逻辑不变 ...
// ========== Bytedesk集成代码开始 ==========
const location = useLocation(); // 获取当前路径
const [showBytedesk, setShowBytedesk] = useState(false);
// 根据页面路径决定是否显示客服
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 获取Bytedesk配置
const bytedeskConfig = getBytedeskConfig();
// 客服加载成功回调
const handleBytedeskLoad = (bytedesk) => {
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
};
// 客服加载失败回调
const handleBytedeskError = (error) => {
console.error('[App] Bytedesk客服系统加载失败', error);
};
// ========== Bytedesk集成代码结束 ==========
return (
<div className="App">
{/* ========== vf_react原有内容保持不变 ========== */}
{/* 这里是您原有的App.jsx JSX代码 */}
{/* 例如: <Header /> */}
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
{/* ... 保持原有结构不变 ... */}
{/* ========== Bytedesk客服Widget ========== */}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
onLoad={handleBytedeskLoad}
onError={handleBytedeskError}
/>
)}
</div>
);
}
export default App;
// ============================================================================
// 方案二: 带用户信息集成
// 适用场景: 需要将登录用户信息传递给客服端
// ============================================================================
/*
import React, { useState, useEffect, useContext } from 'react';
import { useLocation } from 'react-router-dom';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
function App() {
// 获取登录用户信息
const { user } = useContext(AuthContext);
const location = useLocation();
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
const shouldShow = shouldShowCustomerService(location.pathname);
setShowBytedesk(shouldShow);
}, [location.pathname]);
// 根据用户信息生成配置
const bytedeskConfig = user
? getBytedeskConfigWithUser(user)
: getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案三: 条件性加载
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
// ============================================================================
/*
import React, { useState, useEffect } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [user, setUser] = useState(null);
const [showBytedesk, setShowBytedesk] = useState(false);
useEffect(() => {
// 只有在用户登录且为普通用户时显示客服
if (user && user.role === 'customer') {
setShowBytedesk(true);
} else {
setShowBytedesk(false);
}
}, [user]);
const bytedeskConfig = getBytedeskConfig();
return (
<div className="App">
// ... 您的原有代码 ...
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 方案四: 动态控制显示/隐藏
// 适用场景: 需要通过按钮或其他交互控制客服显示
// ============================================================================
/*
import React, { useState } from 'react';
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
function App() {
const [showBytedesk, setShowBytedesk] = useState(false);
const bytedeskConfig = getBytedeskConfig();
const toggleBytedesk = () => {
setShowBytedesk(prev => !prev);
};
return (
<div className="App">
// ... 您的原有代码 ...
{/* 自定义客服按钮 *\/}
<button onClick={toggleBytedesk} className="custom-service-button">
{showBytedesk ? '关闭客服' : '联系客服'}
</button>
{/* 客服Widget *\/}
{showBytedesk && (
<BytedeskWidget
config={bytedeskConfig}
autoLoad={true}
/>
)}
</div>
);
}
export default App;
*/
// ============================================================================
// 重要提示
// ============================================================================
/**
* 1. CSS样式兼容性
* - Bytedesk Widget使用Shadow DOM不会影响您的全局样式
* - Widget的样式可通过config中的theme配置调整
*
* 2. 性能优化
* - Widget脚本采用异步加载不会阻塞页面渲染
* - 建议在非关键页面(如登录、支付页)隐藏客服
*
* 3. 错误处理
* - 如果客服脚本加载失败,不会影响主应用
* - 建议添加onError回调进行错误监控
*
* 4. 调试模式
* - 查看浏览器控制台的[Bytedesk]前缀日志
* - 检查Network面板确认脚本加载成功
*
* 5. 生产部署
* - 确保.env文件配置正确特别是REACT_APP_BYTEDESK_API_URL
* - 确保CORS已在后端配置允许您的前端域名
* - 在管理后台配置正确的工作组IDsid
*/

View File

@@ -0,0 +1,140 @@
/**
* Bytedesk客服Widget组件
* 用于vf_react项目集成
*
* 使用方法:
* import BytedeskWidget from './components/BytedeskWidget';
* import { getBytedeskConfig } from './config/bytedesk.config';
*
* <BytedeskWidget
* config={getBytedeskConfig()}
* autoLoad={true}
* />
*/
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
const BytedeskWidget = ({
config,
autoLoad = true,
onLoad,
onError
}) => {
const scriptRef = useRef(null);
const widgetRef = useRef(null);
useEffect(() => {
// 如果不自动加载或配置未设置,跳过
if (!autoLoad || !config) {
if (!config) {
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
}
return;
}
console.log('[Bytedesk] 开始加载客服Widget...', config);
// 加载Bytedesk Widget脚本
const script = document.createElement('script');
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
script.async = true;
script.id = 'bytedesk-web-script';
script.onload = () => {
console.log('[Bytedesk] Widget脚本加载成功');
try {
if (window.BytedeskWeb) {
console.log('[Bytedesk] 初始化Widget');
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
widgetRef.current = bytedesk;
console.log('[Bytedesk] Widget初始化成功');
if (onLoad) {
onLoad(bytedesk);
}
} else {
throw new Error('BytedeskWeb对象未定义');
}
} catch (error) {
console.error('[Bytedesk] Widget初始化失败:', error);
if (onError) {
onError(error);
}
}
};
script.onerror = (error) => {
console.error('[Bytedesk] Widget脚本加载失败:', error);
if (onError) {
onError(error);
}
};
// 添加脚本到页面
document.body.appendChild(script);
scriptRef.current = script;
// 清理函数
return () => {
console.log('[Bytedesk] 清理Widget');
// 移除脚本
if (scriptRef.current && document.body.contains(scriptRef.current)) {
document.body.removeChild(scriptRef.current);
}
// 移除Widget DOM元素
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
widgetElements.forEach(el => {
if (el && el.parentNode) {
el.parentNode.removeChild(el);
}
});
// 清理全局对象
if (window.BytedeskWeb) {
delete window.BytedeskWeb;
}
};
}, [config, autoLoad, onLoad, onError]);
// 不渲染任何可见元素Widget会自动插入到body
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
};
BytedeskWidget.propTypes = {
config: PropTypes.shape({
apiUrl: PropTypes.string.isRequired,
htmlUrl: PropTypes.string.isRequired,
placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']),
marginBottom: PropTypes.number,
marginSide: PropTypes.number,
autoPopup: PropTypes.bool,
locale: PropTypes.string,
bubbleConfig: PropTypes.shape({
show: PropTypes.bool,
icon: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
}),
theme: PropTypes.shape({
mode: PropTypes.oneOf(['light', 'dark', 'system']),
backgroundColor: PropTypes.string,
textColor: PropTypes.string,
}),
chatConfig: PropTypes.shape({
org: PropTypes.string.isRequired,
t: PropTypes.string.isRequired,
sid: PropTypes.string.isRequired,
}).isRequired,
}),
autoLoad: PropTypes.bool,
onLoad: PropTypes.func,
onError: PropTypes.func,
};
export default BytedeskWidget;

View File

@@ -0,0 +1,148 @@
/**
* Bytedesk客服配置文件
* 指向43.143.189.195服务器
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_aftersales
*/
// 从环境变量读取配置
const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_aftersales';
/**
* Bytedesk客服基础配置
*/
export const bytedeskConfig = {
// API服务地址
apiUrl: BYTEDESK_API_URL,
// 聊天页面地址
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
// SDK 资源基础路径(用于加载内部模块 sdk.js, index.js 等)
baseUrl: 'https://www.weiyuai.cn',
// 客服图标位置
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
// 边距设置(像素)
marginBottom: 20,
marginSide: 20,
// 自动弹出(不推荐)
autoPopup: false,
// 语言设置
locale: 'zh-cn', // zh-cn | en | ja | ko
// 客服图标配置
bubbleConfig: {
show: true, // 是否显示客服图标
icon: '💬', // 图标emoji或图片URL
title: '在线客服', // 鼠标悬停标题
subtitle: '点击咨询', // 副标题
},
// 主题配置
theme: {
mode: 'system', // light | dark | system
backgroundColor: '#0066FF', // 主题色
textColor: '#ffffff', // 文字颜色
},
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
t: '2', // 类型: 2=客服, 1=机器人
sid: BYTEDESK_SID, // 工作组ID
},
};
/**
* 获取Bytedesk配置根据环境自动切换
*
* @returns {Object} Bytedesk配置对象
*/
export const getBytedeskConfig = () => {
// 所有环境都使用公网地址(不使用代理)
return bytedeskConfig;
};
/**
* 获取带用户信息的配置
* 用于已登录用户,自动传递用户信息到客服端
*
* @param {Object} user - 用户对象
* @param {string} user.id - 用户ID
* @param {string} user.name - 用户名
* @param {string} user.email - 用户邮箱
* @param {string} user.mobile - 用户手机号
* @returns {Object} 带用户信息的Bytedesk配置
*/
export const getBytedeskConfigWithUser = (user) => {
const config = getBytedeskConfig();
if (user && user.id) {
return {
...config,
chatConfig: {
...config.chatConfig,
// 传递用户信息(可选)
customParams: {
userId: user.id,
userName: user.name || 'Guest',
userEmail: user.email || '',
userMobile: user.mobile || '',
source: 'web', // 来源标识
},
},
};
}
return config;
};
/**
* 根据页面路径判断是否显示客服
*
* @param {string} pathname - 当前页面路径
* @returns {boolean} 是否显示客服
*/
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
];
// 检查是否在黑名单
if (blockedPages.some(page => pathname.startsWith(page))) {
return false;
}
// 默认所有页面都显示客服
return true;
/* ============================================
白名单模式(备用,需要时取消注释)
============================================
const allowedPages = [
'/', // 首页
'/home', // 主页
'/products', // 产品页
'/pricing', // 价格页
'/contact', // 联系我们
];
// 只在白名单页面显示客服
return allowedPages.some(page => pathname.startsWith(page));
============================================ */
};
export default {
bytedeskConfig,
getBytedeskConfig,
getBytedeskConfigWithUser,
shouldShowCustomerService,
};

View File

@@ -508,19 +508,19 @@ export default function WechatRegister() {
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
scrolling="no"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation"
allow="clipboard-write"
style={{
border: 'none',
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transform: 'scale(0.77) translateY(-35px)',
transformOrigin: 'top left',
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
pointerEvents: 'auto',
overflow: 'hidden',
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
onWheel={(e) => e.preventDefault()}
onTouchMove={(e) => e.preventDefault()}
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -95,7 +95,7 @@ export const ChatInterfaceV2 = () => {
});
};
// 发送消息Agent模式
// 发送消息Agent模式 - 流式
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
@@ -106,10 +106,16 @@ export const ChatInterfaceV2 = () => {
};
addMessage(userMessage);
const userInput = inputValue; // 保存输入值
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
// 用于存储步骤结果
let currentPlan = null;
let stepResults = [];
let executingMessageId = null;
try {
// 1. 显示思考状态
addMessage({
@@ -120,18 +126,40 @@ export const ChatInterfaceV2 = () => {
setCurrentProgress(10);
// Agent API
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat`, {
// 使EventSource 接收流式数据
const eventSource = new EventSource(
`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
}
);
// 由于 EventSource 不支持 POST我们使用 fetch + ReadableStream
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: inputValue,
conversation_history: messages.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE).map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
message: userInput,
conversation_history: messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map(m => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
}),
});
@@ -139,62 +167,139 @@ export const ChatInterfaceV2 = () => {
throw new Error('Agent请求失败');
}
const agentResponse = await response.json();
logger.info('Agent response', agentResponse);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 读取流式数据
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!agentResponse.success) {
throw new Error(agentResponse.message || '处理失败');
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop(); // 保留不完整的行
setCurrentProgress(30);
for (const line of lines) {
if (!line.trim()) continue;
// 2. 显示执行计划
if (agentResponse.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: agentResponse.plan,
timestamp: new Date().toISOString(),
});
}
// 解析 SSE 消息
const eventMatch = line.match(/^event: (.+)$/m);
const dataMatch = line.match(/^data: (.+)$/m);
setCurrentProgress(40);
if (!eventMatch || !dataMatch) continue;
// 3. 显示执行过程
if (agentResponse.step_results && agentResponse.step_results.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
timestamp: new Date().toISOString(),
});
const event = eventMatch[1];
const data = JSON.parse(dataMatch[1]);
// 模拟进度更新
for (let i = 0; i < agentResponse.step_results.length; i++) {
setCurrentProgress(40 + (i + 1) / agentResponse.step_results.length * 50);
await new Promise(resolve => setTimeout(resolve, 100));
logger.info(`SSE Event: ${event}`, data);
// 处理不同类型的事件
switch (event) {
case 'status':
if (data.stage === 'planning') {
// 移除思考消息,显示规划中
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
addMessage({
type: MessageTypes.AGENT_THINKING,
content: data.message,
timestamp: new Date().toISOString(),
});
setCurrentProgress(20);
} else if (data.stage === 'executing') {
setCurrentProgress(30);
} else if (data.stage === 'summarizing') {
setCurrentProgress(90);
}
break;
case 'plan':
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
// 显示执行计划
currentPlan = data;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
break;
case 'step_start':
// 如果还没有执行中消息,创建一个
if (!executingMessageId) {
const executingMsg = {
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: [],
timestamp: new Date().toISOString(),
};
addMessage(executingMsg);
executingMessageId = Date.now();
}
break;
case 'step_complete':
// 添加步骤结果
stepResults.push({
step_index: data.step_index,
tool: data.tool,
status: data.status,
result: data.result,
error: data.error,
execution_time: data.execution_time,
arguments: data.arguments,
});
// 更新执行中消息
setMessages(prev =>
prev.map(msg =>
msg.type === MessageTypes.AGENT_EXECUTING
? { ...msg, stepResults: [...stepResults] }
: msg
)
);
// 更新进度
if (currentPlan) {
const progress = 30 + ((data.step_index + 1) / currentPlan.steps.length) * 60;
setCurrentProgress(progress);
}
break;
case 'summary':
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.content,
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
break;
case 'error':
throw new Error(data.message);
case 'done':
logger.info('Stream完成');
break;
default:
logger.warn('未知事件类型:', event);
}
}
}
setCurrentProgress(100);
// 移除执行中消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_EXECUTING));
// 4. 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: agentResponse.message || agentResponse.final_summary,
plan: agentResponse.plan,
stepResults: agentResponse.step_results,
metadata: agentResponse.metadata,
timestamp: new Date().toISOString(),
});
} catch (error) {
logger.error('Agent chat error', error);

View File

@@ -0,0 +1,72 @@
// src/components/ChatBot/EChartsRenderer.js
// ECharts 图表渲染组件
import React, { useEffect, useRef } from 'react';
import { Box, useColorModeValue } from '@chakra-ui/react';
import * as echarts from 'echarts';
/**
* ECharts 图表渲染组件
* @param {Object} option - ECharts 配置对象
* @param {number} height - 图表高度(默认 400px
*/
export const EChartsRenderer = ({ option, height = 400 }) => {
const chartRef = useRef(null);
const chartInstance = useRef(null);
const bgColor = useColorModeValue('white', 'gray.800');
useEffect(() => {
if (!chartRef.current || !option) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
// 设置默认主题配置
const defaultOption = {
backgroundColor: 'transparent',
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...option,
};
// 设置图表配置
chartInstance.current.setOption(defaultOption, true);
// 响应式调整大小
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
};
}, [option]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
return (
<Box
ref={chartRef}
width="100%"
height={`${height}px`}
bg={bgColor}
borderRadius="md"
boxShadow="sm"
/>
);
};

View File

@@ -0,0 +1,189 @@
// src/components/ChatBot/MarkdownWithCharts.js
// 支持 ECharts 图表的 Markdown 渲染组件
import React from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import { EChartsRenderer } from './EChartsRenderer';
import { logger } from '@utils/logger';
/**
* 解析 Markdown 内容,提取 ECharts 代码块
* @param {string} markdown - Markdown 文本
* @returns {Array} - 包含文本和图表的数组
*/
const parseMarkdownWithCharts = (markdown) => {
if (!markdown) return [];
const parts = [];
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = echartsRegex.exec(markdown)) !== null) {
// 添加代码块前的文本
if (match.index > lastIndex) {
const textBefore = markdown.substring(lastIndex, match.index).trim();
if (textBefore) {
parts.push({ type: 'text', content: textBefore });
}
}
// 添加 ECharts 配置
const chartConfig = match[1].trim();
parts.push({ type: 'chart', content: chartConfig });
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < markdown.length) {
const textAfter = markdown.substring(lastIndex).trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
}
}
// 如果没有找到图表,返回整个 markdown 作为文本
if (parts.length === 0) {
parts.push({ type: 'text', content: markdown });
}
return parts;
};
/**
* 支持 ECharts 图表的 Markdown 渲染组件
* @param {string} content - Markdown 文本
*/
export const MarkdownWithCharts = ({ content }) => {
const parts = parseMarkdownWithCharts(content);
return (
<VStack align="stretch" spacing={4}>
{parts.map((part, index) => {
if (part.type === 'text') {
// 渲染普通 Markdown
return (
<Box key={index}>
<ReactMarkdown
components={{
// 自定义渲染样式
p: ({ children }) => (
<Text mb={2} fontSize="sm">
{children}
</Text>
),
h1: ({ children }) => (
<Text fontSize="xl" fontWeight="bold" mb={3}>
{children}
</Text>
),
h2: ({ children }) => (
<Text fontSize="lg" fontWeight="bold" mb={2}>
{children}
</Text>
),
h3: ({ children }) => (
<Text fontSize="md" fontWeight="bold" mb={2}>
{children}
</Text>
),
ul: ({ children }) => (
<Box as="ul" pl={4} mb={2}>
{children}
</Box>
),
ol: ({ children }) => (
<Box as="ol" pl={4} mb={2}>
{children}
</Box>
),
li: ({ children }) => (
<Box as="li" fontSize="sm" mb={1}>
{children}
</Box>
),
code: ({ inline, children }) =>
inline ? (
<Code fontSize="sm" px={1}>
{children}
</Code>
) : (
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
{children}
</Code>
),
blockquote: ({ children }) => (
<Box
borderLeftWidth="4px"
borderLeftColor="blue.500"
pl={4}
py={2}
fontStyle="italic"
color="gray.600"
>
{children}
</Box>
),
}}
>
{part.content}
</ReactMarkdown>
</Box>
);
} else if (part.type === 'chart') {
// 渲染 ECharts 图表
try {
// 清理可能的 Markdown 残留符号
let cleanContent = part.content.trim();
// 移除可能的前后空白和不可见字符
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
// 尝试解析 JSON
const chartOption = JSON.parse(cleanContent);
// 验证是否是有效的 ECharts 配置
if (!chartOption || typeof chartOption !== 'object') {
throw new Error('Invalid chart configuration: not an object');
}
return (
<Box key={index}>
<EChartsRenderer option={chartOption} height={350} />
</Box>
);
} catch (error) {
// 记录详细的错误信息
logger.error('解析 ECharts 配置失败', {
error: error.message,
contentLength: part.content.length,
contentPreview: part.content.substring(0, 200),
errorStack: error.stack
});
return (
<Alert status="warning" key={index} borderRadius="md">
<AlertIcon />
<VStack align="flex-start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="bold">
图表配置解析失败
</Text>
<Text fontSize="xs" color="gray.600">
错误: {error.message}
</Text>
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
{part.content.substring(0, 300)}
{part.content.length > 300 ? '...' : ''}
</Code>
</VStack>
</Alert>
);
}
}
return null;
})}
</VStack>
);
};

View File

@@ -2,6 +2,7 @@
// 集中管理应用的全局组件
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useNotification } from '../contexts/NotificationContext';
import { logger } from '../utils/logger';
@@ -12,6 +13,10 @@ import NotificationTestTool from './NotificationTestTool';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以在这里包装
@@ -67,8 +72,12 @@ function ConnectionStatusBarWrapper() {
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - NotificationTestTool: 通知测试工具 (仅开发环境)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
*/
export function GlobalComponents() {
const location = useLocation();
const showBytedesk = shouldShowCustomerService(location.pathname);
return (
<>
{/* Socket 连接状态条 */}
@@ -85,6 +94,14 @@ export function GlobalComponents() {
{/* 通知测试工具 (仅开发环境) */}
<NotificationTestTool />
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
{showBytedesk && (
<BytedeskWidget
config={getBytedeskConfig()}
autoLoad={true}
/>
)}
</>
);
}

View File

@@ -292,8 +292,13 @@ export const NotificationProvider = ({ children }) => {
* 发送浏览器通知
*/
const sendBrowserNotification = useCallback((notificationData) => {
console.log('[NotificationContext] 🔔 sendBrowserNotification 被调用');
console.log('[NotificationContext] 通知数据:', notificationData);
console.log('[NotificationContext] 当前浏览器权限:', browserPermission);
if (browserPermission !== 'granted') {
logger.warn('NotificationContext', 'Browser permission not granted');
console.warn('[NotificationContext] ❌ 浏览器权限未授予,无法发送通知');
return;
}
@@ -305,6 +310,14 @@ export const NotificationProvider = ({ children }) => {
// 判断是否需要用户交互(紧急通知不自动关闭)
const requireInteraction = priority === PRIORITY_LEVELS.URGENT;
console.log('[NotificationContext] ✅ 准备发送浏览器通知:', {
title,
body: content,
tag,
requireInteraction,
link
});
// 发送浏览器通知
const notification = browserNotificationService.sendNotification({
title: title || '新通知',
@@ -315,17 +328,24 @@ export const NotificationProvider = ({ children }) => {
autoClose: requireInteraction ? 0 : 8000,
});
// 设置点击处理(聚焦窗口并跳转)
if (notification && link) {
notification.onclick = () => {
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
if (notification) {
console.log('[NotificationContext] ✅ 通知对象创建成功:', notification);
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
// 设置点击处理(聚焦窗口并跳转)
if (link) {
notification.onclick = () => {
console.log('[NotificationContext] 通知被点击,跳转到:', link);
window.focus();
// 使用 window.location 跳转(不需要 React Router
window.location.hash = link;
notification.close();
};
}
logger.info('NotificationContext', 'Browser notification sent', { title, tag });
} else {
console.error('[NotificationContext] ❌ 通知对象创建失败!');
}
}, [browserPermission]);
/**
@@ -610,6 +630,24 @@ export const NotificationProvider = ({ children }) => {
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
socket.startMockPush(interval, maxBatch);
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
} else {
// ✅ 真实模式下,订阅事件推送
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[NotificationContext] 订阅确认:', data);
logger.info('NotificationContext', 'Events subscribed', data);
},
// ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听
});
} else {
console.warn('[NotificationContext] ⚠️ socket.subscribeToEvents 方法不可用');
}
}
});
@@ -646,6 +684,15 @@ export const NotificationProvider = ({ children }) => {
// 监听新事件推送(统一事件名)
socket.on('new_event', (data) => {
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
console.log('%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
console.log('[NotificationContext] 原始事件数据:', data);
console.log('[NotificationContext] 事件 ID:', data?.id);
console.log('[NotificationContext] 事件标题:', data?.title);
console.log('[NotificationContext] 事件类型:', data?.event_type || data?.type);
console.log('[NotificationContext] 事件重要性:', data?.importance);
logger.info('NotificationContext', 'Received new event', data);
// ========== Socket层去重检查 ==========
@@ -653,11 +700,14 @@ export const NotificationProvider = ({ children }) => {
if (processedEventIds.current.has(eventId)) {
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
return; // 重复事件,直接忽略
}
// 记录已处理的事件ID
processedEventIds.current.add(eventId);
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
// 限制Set大小避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
@@ -670,8 +720,14 @@ export const NotificationProvider = ({ children }) => {
// ========== Socket层去重检查结束 ==========
// 使用适配器转换事件格式
console.log('[NotificationContext] 正在转换事件格式...');
const notification = adaptEventToNotification(data);
console.log('[NotificationContext] 转换后的通知对象:', notification);
console.log('[NotificationContext] 准备添加通知到队列...');
addNotification(notification);
console.log('[NotificationContext] ✅ 通知已添加到队列');
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
});
// 保留系统通知监听(兼容性)

View File

@@ -9,6 +9,10 @@ import './styles/brainwave-colors.css';
// Import the main App component
import App from './App';
// 导入通知服务并挂载到 window用于调试
import { browserNotificationService } from './services/browserNotificationService';
window.browserNotificationService = browserNotificationService;
// 注册 Service Worker用于支持浏览器通知
function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)

View File

@@ -8,14 +8,14 @@ import RiskDisclaimer from '../components/RiskDisclaimer';
*/
const AppFooter = () => {
return (
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={2}>
<Container maxW="container.xl">
<VStack spacing={2}>
<VStack spacing={1}>
<RiskDisclaimer />
<Text color="gray.500" fontSize="sm">
© 2024 价值前沿. 保留所有权利.
</Text>
<HStack spacing={4} fontSize="xs" color="gray.400">
<HStack spacing={1} fontSize="xs" color="gray.400">
<Link
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
isExternal

View File

@@ -1080,14 +1080,65 @@ export const eventHandlers = [
date.setDate(date.getDate() - daysAgo);
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
const title = eventTitles[i % eventTitles.length];
// 带引用来源的研报数据
const researchReports = [
{
author: '中信证券',
report_title: `${title}深度研究报告`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 10) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '国泰君安',
report_title: `行业专题:${title}影响分析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 15) * 24 * 60 * 60 * 1000).toISOString()
},
{
author: '华泰证券',
report_title: `${title}投资机会深度解析`,
declare_date: new Date(date.getTime() - Math.floor(Math.random() * 20) * 24 * 60 * 60 * 1000).toISOString()
}
];
// 生成带引用标记的contentdata结构
const contentWithCitations = {
data: [
{
query_part: `${title}的详细描述。该事件对相关产业链产生重要影响【1】市场关注度高相关概念股表现活跃。`,
sentences: `根据券商研报分析,${title}将推动相关产业链快速发展【2】。预计未来${Math.floor(Math.random() * 2 + 1)}年内,相关企业营收增速有望达到${Math.floor(Math.random() * 30 + 20)}%以上【3】。该事件的影响范围广泛涉及多个细分领域投资机会显著。`,
match_score: importance >= 4 ? '好' : (importance >= 2 ? '中' : '一般'),
author: researchReports[0].author,
declare_date: researchReports[0].declare_date,
report_title: researchReports[0].report_title
},
{
query_part: `市场分析师认为该事件将带动产业链上下游企业协同发展【2】形成良性循环。`,
sentences: `从产业趋势来看,相关板块估值仍处于合理区间,具备较高的安全边际。机构投资者持续加仓相关标的,显示出对长期发展前景的看好。`,
match_score: importance >= 3 ? '好' : '中',
author: researchReports[1].author,
declare_date: researchReports[1].declare_date,
report_title: researchReports[1].report_title
},
{
query_part: `根据行业数据显示,受此事件影响,相关企业订单量同比增长${Math.floor(Math.random() * 40 + 30)}%【3】。`,
sentences: `行业景气度持续提升,龙头企业凭借技术优势和规模效应,市场份额有望进一步扩大。建议关注产业链核心环节的投资机会。`,
match_score: '好',
author: researchReports[2].author,
declare_date: researchReports[2].declare_date,
report_title: researchReports[2].report_title
}
]
};
events.push({
id: `hist_event_${i + 1}`,
title: eventTitles[i % eventTitles.length],
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
title: title,
content: contentWithCitations, // 升级版本带引用来源的data结构
description: `${title}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`, // 降级兼容
date: date.toISOString().split('T')[0],
importance: importance,
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
similarity: Math.floor(Math.random() * 10) + 1, // 1-10
impact_sectors: [
['半导体', '芯片设计', 'EDA'],
['新能源汽车', '锂电池', '充电桩'],

View File

@@ -157,8 +157,8 @@ export const routeConfig = [
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: 'AI投资助手',
description: '基于MCP的智能投资顾问'
title: '价小前投研',
description: '北京价值前沿科技公司的AI投研聊天助手'
}
},
];

View File

@@ -30,6 +30,14 @@ class BrowserNotificationService {
return Notification.permission;
}
/**
* 检查是否有通知权限
* @returns {boolean}
*/
hasPermission() {
return this.isSupported() && Notification.permission === 'granted';
}
/**
* 请求通知权限
* @returns {Promise<string>} 权限状态
@@ -77,57 +85,99 @@ class BrowserNotificationService {
data = {},
autoClose = 0,
}) {
// 详细日志:检查支持状态
if (!this.isSupported()) {
logger.warn('browserNotificationService', 'Notifications not supported');
console.warn('[browserNotificationService] ❌ 浏览器不支持通知 API');
return null;
}
if (this.permission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted');
// 详细日志:检查权限状态
const currentPermission = Notification.permission;
console.log('[browserNotificationService] 当前权限状态:', currentPermission);
if (currentPermission !== 'granted') {
logger.warn('browserNotificationService', 'Permission not granted', { permission: currentPermission });
console.warn(`[browserNotificationService] ❌ 权限未授予: ${currentPermission}`);
return null;
}
console.log('[browserNotificationService] ✅ 准备发送通知:', { title, body, tag, requireInteraction, autoClose });
try {
// 关闭相同 tag 的旧通知
if (tag && this.activeNotifications.has(tag)) {
const oldNotification = this.activeNotifications.get(tag);
oldNotification.close();
console.log('[browserNotificationService] 关闭旧通知:', tag);
}
// 创建通知
const finalTag = tag || `notification_${Date.now()}`;
console.log('[browserNotificationService] 创建通知对象...');
const notification = new Notification(title, {
body,
icon,
badge: '/badge.png',
tag: tag || `notification_${Date.now()}`,
tag: finalTag,
requireInteraction,
data,
silent: false, // 允许声音
});
console.log('[browserNotificationService] ✅ 通知对象已创建:', notification);
// 存储通知引用
if (tag) {
this.activeNotifications.set(tag, notification);
console.log('[browserNotificationService] 通知已存储到活跃列表');
}
// 自动关闭
if (autoClose > 0 && !requireInteraction) {
console.log(`[browserNotificationService] 设置自动关闭: ${autoClose}ms`);
setTimeout(() => {
notification.close();
console.log('[browserNotificationService] 通知已自动关闭');
}, autoClose);
}
// 通知关闭时清理引用
notification.onclose = () => {
console.log('[browserNotificationService] 通知被关闭:', finalTag);
if (tag) {
this.activeNotifications.delete(tag);
}
};
logger.info('browserNotificationService', 'Notification sent', { title, tag });
// 通知点击事件
notification.onclick = (event) => {
console.log('[browserNotificationService] 通知被点击:', finalTag, data);
};
// 通知显示事件
notification.onshow = () => {
console.log('[browserNotificationService] ✅ 通知已显示:', finalTag);
};
// 通知错误事件
notification.onerror = (error) => {
console.error('[browserNotificationService] ❌ 通知显示错误:', error);
};
logger.info('browserNotificationService', 'Notification sent', { title, tag: finalTag });
console.log('[browserNotificationService] ✅ 通知发送成功!');
return notification;
} catch (error) {
logger.error('browserNotificationService', 'sendNotification', error);
console.error('[browserNotificationService] ❌ 发送通知时发生错误:', error);
console.error('[browserNotificationService] 错误详情:', {
name: error.name,
message: error.message,
stack: error.stack
});
return null;
}
}

View File

@@ -64,6 +64,12 @@ class SocketService {
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
});
// 监听断开连接
@@ -142,11 +148,20 @@ class SocketService {
on(event, callback) {
if (!this.socket) {
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
console.warn(`[socketService] ❌ 无法监听事件 ${event}: Socket 未初始化`);
return;
}
this.socket.on(event, callback);
// 包装回调函数,添加日志
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
logger.info('socketService', `Event listener added: ${event}`);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
}
/**
@@ -337,13 +352,14 @@ class SocketService {
});
// 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// 先移除之前的监听器(避免重复)
this.socket.off('new_event');
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
// 添加新的监听器
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
@@ -355,7 +371,7 @@ class SocketService {
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
@@ -403,14 +419,26 @@ class SocketService {
/**
* 快捷方法:订阅所有类型的事件
* @param {Function} onNewEvent - 收到新事件时的回调函数
* @param {Function} onNewEvent - 收到新事件时的回调函数(可选)
* @returns {Function} 取消订阅的函数
*/
subscribeToAllEvents(onNewEvent) {
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
// 如果没有提供回调,添加一个默认的日志回调
const defaultCallback = (event) => {
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 事件数据:', event);
};
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent,
onNewEvent: onNewEvent || defaultCallback,
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
});
// 返回取消订阅的清理函数

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
// src/views/AgentChat/index.js
// Agent聊天页面
import React from 'react';
import {
Box,
Container,
Heading,
Text,
VStack,
useColorModeValue,
} from '@chakra-ui/react';
import { ChatInterfaceV2 } from '../../components/ChatBot';
/**
* Agent聊天页面
* 提供基于MCP的AI助手对话功能
*/
const AgentChat = () => {
const bgColor = useColorModeValue('gray.50', 'gray.900');
const cardBg = useColorModeValue('white', 'gray.800');
return (
<Box minH="calc(100vh - 200px)" bg={bgColor} py={8}>
<Container maxW="container.xl" h="100%">
<VStack spacing={6} align="stretch" h="100%">
{/* 页面标题 */}
<Box>
<Heading size="lg" mb={2}>AI投资助手</Heading>
<Text color="gray.600" fontSize="sm">
基于MCP协议的智能投资顾问支持股票查询新闻搜索概念分析等多种功能
</Text>
</Box>
{/* 聊天界面 */}
<Box
flex="1"
bg={cardBg}
borderRadius="xl"
boxShadow="xl"
overflow="hidden"
h="calc(100vh - 300px)"
minH="600px"
>
<ChatInterfaceV2 />
</Box>
</VStack>
</Container>
</Box>
);
};
export default AgentChat;

View File

@@ -0,0 +1,857 @@
// src/views/AgentChat/index_v3.js
// Agent聊天页面 V3 - 带左侧会话列表和用户信息集成
import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Button,
Avatar,
Heading,
Divider,
Spinner,
Badge,
useColorModeValue,
useToast,
Progress,
Fade,
Collapse,
useDisclosure,
InputGroup,
InputLeftElement,
Menu,
MenuButton,
MenuList,
MenuItem,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
} from '@chakra-ui/react';
import {
FiSend,
FiSearch,
FiPlus,
FiMessageSquare,
FiTrash2,
FiMoreVertical,
FiRefreshCw,
FiDownload,
FiCpu,
FiUser,
FiZap,
FiClock,
} from 'react-icons/fi';
import { useAuth } from '@contexts/AuthContext';
import { PlanCard } from '@components/ChatBot/PlanCard';
import { StepResultCard } from '@components/ChatBot/StepResultCard';
import { logger } from '@utils/logger';
import axios from 'axios';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* Agent聊天页面 V3
*/
const AgentChatV3 = () => {
const { user } = useAuth(); // 获取当前用户信息
const toast = useToast();
// 会话相关状态
const [sessions, setSessions] = useState([]);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [isLoadingSessions, setIsLoadingSessions] = useState(true);
// 消息相关状态
const [messages, setMessages] = useState([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
// UI 状态
const [searchQuery, setSearchQuery] = useState('');
const { isOpen: isSidebarOpen, onToggle: toggleSidebar } = useDisclosure({ defaultIsOpen: true });
// Refs
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const sidebarBg = useColorModeValue('white', 'gray.800');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const activeBg = useColorModeValue('blue.50', 'blue.900');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
// ==================== 会话管理函数 ====================
// 加载会话列表
const loadSessions = async () => {
if (!user?.id) return;
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
});
if (response.data.success) {
setSessions(response.data.data);
logger.info('会话列表加载成功', response.data.data);
}
} catch (error) {
logger.error('加载会话列表失败', error);
toast({
title: '加载失败',
description: '无法加载会话列表',
status: 'error',
duration: 3000,
});
} finally {
setIsLoadingSessions(false);
}
};
// 加载会话历史
const loadSessionHistory = async (sessionId) => {
if (!sessionId) return;
try {
const response = await axios.get(`/mcp/agent/history/${sessionId}`, {
params: { limit: 100 },
});
if (response.data.success) {
const history = response.data.data;
// 将历史记录转换为消息格式
const formattedMessages = history.map((msg, idx) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
timestamp: msg.timestamp,
}));
setMessages(formattedMessages);
logger.info('会话历史加载成功', formattedMessages);
}
} catch (error) {
logger.error('加载会话历史失败', error);
toast({
title: '加载失败',
description: '无法加载会话历史',
status: 'error',
duration: 3000,
});
}
};
// 创建新会话
const createNewSession = () => {
setCurrentSessionId(null);
setMessages([
{
id: Date.now(),
type: MessageTypes.AGENT_RESPONSE,
content: `你好${user?.nickname || ''}我是价小前北京价值前沿科技公司的AI投研助手。\n\n我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我:\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现`,
timestamp: new Date().toISOString(),
},
]);
};
// 切换会话
const switchSession = (sessionId) => {
setCurrentSessionId(sessionId);
loadSessionHistory(sessionId);
};
// 删除会话需要后端API支持
const deleteSession = async (sessionId) => {
// TODO: 实现删除会话的后端API
toast({
title: '删除会话',
description: '此功能尚未实现',
status: 'info',
duration: 2000,
});
};
// ==================== 消息处理函数 ====================
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
// 权限检查
if (user?.id !== 'max') {
toast({
title: '权限不足',
description: '「价小前投研」功能目前仅对特定用户开放。如需使用,请联系管理员。',
status: 'warning',
duration: 5000,
isClosable: true,
});
return;
}
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
const userInput = inputValue;
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
let currentPlan = null;
let stepResults = [];
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 2. 调用后端API非流式
const response = await axios.post('/mcp/agent/chat', {
message: userInput,
conversation_history: messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((m) => ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
session_id: currentSessionId,
});
// 移除思考消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_THINKING));
if (response.data.success) {
const data = response.data;
// 更新 session_id如果是新会话
if (data.session_id && !currentSessionId) {
setCurrentSessionId(data.session_id);
}
// 显示执行计划
if (data.plan) {
currentPlan = data.plan;
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: data.plan,
timestamp: new Date().toISOString(),
});
setCurrentProgress(30);
}
// 显示执行步骤
if (data.steps && data.steps.length > 0) {
stepResults = data.steps;
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: currentPlan,
stepResults: stepResults,
timestamp: new Date().toISOString(),
});
setCurrentProgress(70);
}
// 移除执行中消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终结果
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
plan: currentPlan,
stepResults: stepResults,
metadata: data.metadata,
timestamp: new Date().toISOString(),
});
setCurrentProgress(100);
// 重新加载会话列表
loadSessions();
}
} catch (error) {
logger.error('Agent chat error', error);
// 移除思考/执行中消息
setMessages((prev) =>
prev.filter(
(m) => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
)
);
const errorMessage = error.response?.data?.error || error.message || '处理失败';
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${errorMessage}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: errorMessage,
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
createNewSession();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter((m) => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : '价小前'}] ${msg.content}`)
.join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat_${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
// ==================== 初始化 ====================
useEffect(() => {
if (user) {
loadSessions();
createNewSession();
}
}, [user]);
// ==================== 渲染 ====================
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
// 筛选会话
const filteredSessions = sessions.filter(
(session) =>
!searchQuery ||
session.last_message?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<Flex h="calc(100vh - 80px)" bg={bgColor}>
{/* 左侧会话列表 */}
<Collapse in={isSidebarOpen} animateOpacity>
<Box
w="300px"
bg={sidebarBg}
borderRight="1px"
borderColor={borderColor}
h="100%"
display="flex"
flexDirection="column"
>
{/* 侧边栏头部 */}
<Box p={4} borderBottom="1px" borderColor={borderColor}>
<Button
leftIcon={<FiPlus />}
colorScheme="blue"
w="100%"
onClick={createNewSession}
size="sm"
>
新建对话
</Button>
{/* 搜索框 */}
<InputGroup mt={3} size="sm">
<InputLeftElement pointerEvents="none">
<FiSearch color="gray.300" />
</InputLeftElement>
<Input
placeholder="搜索对话..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</InputGroup>
</Box>
{/* 会话列表 */}
<VStack
flex="1"
overflowY="auto"
spacing={0}
align="stretch"
css={{
'&::-webkit-scrollbar': { width: '6px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '3px',
},
}}
>
{isLoadingSessions ? (
<Flex justify="center" align="center" h="200px">
<Spinner />
</Flex>
) : filteredSessions.length === 0 ? (
<Flex justify="center" align="center" h="200px" direction="column">
<FiMessageSquare size={32} color="gray" />
<Text mt={2} fontSize="sm" color="gray.500">
{searchQuery ? '没有找到匹配的对话' : '暂无对话记录'}
</Text>
</Flex>
) : (
filteredSessions.map((session) => (
<Box
key={session.session_id}
p={3}
cursor="pointer"
bg={currentSessionId === session.session_id ? activeBg : 'transparent'}
_hover={{ bg: hoverBg }}
borderBottom="1px"
borderColor={borderColor}
onClick={() => switchSession(session.session_id)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={1} flex="1">
<Text fontSize="sm" fontWeight="medium" noOfLines={2}>
{session.last_message || '新对话'}
</Text>
<HStack spacing={2} fontSize="xs" color="gray.500">
<FiClock size={12} />
<Text>
{new Date(session.last_timestamp).toLocaleDateString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
})}
</Text>
<Badge colorScheme="blue" fontSize="xx-small">
{session.message_count}
</Badge>
</HStack>
</VStack>
<Menu>
<MenuButton
as={IconButton}
icon={<FiMoreVertical />}
size="xs"
variant="ghost"
onClick={(e) => e.stopPropagation()}
/>
<MenuList>
<MenuItem
icon={<FiTrash2 />}
color="red.500"
onClick={(e) => {
e.stopPropagation();
deleteSession(session.session_id);
}}
>
删除对话
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))
)}
</VStack>
{/* 用户信息 */}
<Box p={4} borderTop="1px" borderColor={borderColor}>
<HStack spacing={3}>
<Avatar size="sm" name={user?.nickname} src={user?.avatar} />
<VStack align="start" spacing={0} flex="1">
<Text fontSize="sm" fontWeight="medium">
{user?.nickname || '未登录'}
</Text>
<Text fontSize="xs" color="gray.500">
{user?.id || 'anonymous'}
</Text>
</VStack>
</HStack>
</Box>
</Box>
</Collapse>
{/* 主聊天区域 */}
<Flex flex="1" direction="column" h="100%">
{/* 聊天头部 */}
<Box bg={chatBg} borderBottom="1px" borderColor={borderColor} px={6} py={4}>
<HStack justify="space-between">
<HStack spacing={4}>
<IconButton
icon={<FiMessageSquare />}
size="sm"
variant="ghost"
aria-label="切换侧边栏"
onClick={toggleSidebar}
/>
<Avatar size="md" bg="blue.500" icon={<FiCpu fontSize="1.5rem" />} />
<VStack align="start" spacing={0}>
<Heading size="md">价小前投研</Heading>
<HStack>
<Badge colorScheme="green" fontSize="xs">
<HStack spacing={1}>
<FiZap size={10} />
<span>智能分析</span>
</HStack>
</Badge>
<Text fontSize="xs" color="gray.500">
多步骤深度研究
</Text>
</HStack>
</VStack>
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
</HStack>
</HStack>
{/* 进度条 */}
{isProcessing && (
<Progress
value={currentProgress}
size="xs"
colorScheme="blue"
mt={3}
borderRadius="full"
isAnimated
/>
)}
</Box>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={6}
py={4}
css={{
'&::-webkit-scrollbar': { width: '8px' },
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} userAvatar={user?.avatar} />
</Fade>
))}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题 */}
{messages.length <= 2 && !isProcessing && (
<Box px={6} py={3} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Text fontSize="xs" color="gray.500" mb={2}>
💡 试试这些问题
</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
fontSize="xs"
onClick={() => {
setInputValue(question);
inputRef.current?.focus();
}}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
{/* 输入框 */}
<Box px={6} py={4} bg={chatBg} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入你的问题,我会进行深度分析..."
bg={inputBg}
border="1px"
borderColor={borderColor}
_focus={{ borderColor: 'blue.500', boxShadow: '0 0 0 1px #3182CE' }}
mr={2}
disabled={isProcessing}
size="lg"
/>
<IconButton
icon={isProcessing ? <Spinner size="sm" /> : <FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isProcessing}
disabled={!inputValue.trim() || isProcessing}
size="lg"
/>
</Flex>
</Box>
</Flex>
</Flex>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message, userAvatar }) => {
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
switch (message.type) {
case MessageTypes.USER:
return (
<Flex justify="flex-end">
<HStack align="flex-start" maxW="75%">
<Box
bg={userBubbleBg}
color="white"
px={4}
py={3}
borderRadius="lg"
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
</Box>
<Avatar size="sm" src={userAvatar} icon={<FiUser fontSize="1rem" />} />
</HStack>
</Flex>
);
case MessageTypes.AGENT_THINKING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="purple.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="sm"
>
<HStack>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="purple.600">
{message.content}
</Text>
</HStack>
</Box>
</HStack>
</Flex>
);
case MessageTypes.AGENT_PLAN:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="blue.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1">
<PlanCard plan={message.plan} stepResults={[]} />
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_EXECUTING:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="orange.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
<PlanCard plan={message.plan} stepResults={message.stepResults} />
{message.stepResults?.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="85%">
<Avatar size="sm" bg="green.500" icon={<FiCpu fontSize="1rem" />} />
<VStack align="stretch" flex="1" spacing={3}>
{/* 最终总结 */}
<Box
bg={agentBubbleBg}
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
boxShadow="md"
>
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
{/* 元数据 */}
{message.metadata && (
<HStack mt={3} spacing={4} fontSize="xs" color="gray.500">
<Text>总步骤: {message.metadata.total_steps}</Text>
<Text> {message.metadata.successful_steps}</Text>
{message.metadata.failed_steps > 0 && (
<Text> {message.metadata.failed_steps}</Text>
)}
<Text>耗时: {message.metadata.total_execution_time?.toFixed(1)}s</Text>
</HStack>
)}
</Box>
{/* 执行详情(可选) */}
{message.plan && message.stepResults && message.stepResults.length > 0 && (
<VStack align="stretch" spacing={2}>
<Divider />
<Text fontSize="xs" fontWeight="bold" color="gray.500">
📊 执行详情点击展开查看
</Text>
{message.stepResults.map((result, idx) => (
<StepResultCard key={idx} stepResult={result} />
))}
</VStack>
)}
</VStack>
</HStack>
</Flex>
);
case MessageTypes.ERROR:
return (
<Flex justify="flex-start">
<HStack align="flex-start" maxW="75%">
<Avatar size="sm" bg="red.500" icon={<FiCpu fontSize="1rem" />} />
<Box
bg="red.50"
color="red.700"
px={4}
py={3}
borderRadius="lg"
borderWidth="1px"
borderColor="red.200"
>
<Text fontSize="sm">{message.content}</Text>
</Box>
</HStack>
</Flex>
);
default:
return null;
}
};
export default AgentChatV3;

View File

@@ -25,8 +25,12 @@ import {
useColorModeValue,
useToast,
useDisclosure,
Switch,
Tooltip,
Icon,
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import { TimeIcon, BellIcon } from '@chakra-ui/icons';
import { useNotification } from '../../../contexts/NotificationContext';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
import PaginationControl from './DynamicNewsCard/PaginationControl';
@@ -73,6 +77,9 @@ const DynamicNewsCard = forwardRef(({
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
// 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification();
// 固定模式状态
const [isFixedMode, setIsFixedMode] = useState(false);
const [headerHeight, setHeaderHeight] = useState(0);
@@ -82,7 +89,7 @@ const DynamicNewsCard = forwardRef(({
// 导航栏和页脚固定高度
const NAVBAR_HEIGHT = 64; // 主导航高度
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
const FOOTER_HEIGHT = 120; // 页脚高度(预留
const FOOTER_HEIGHT = 80; // 页脚高度(优化后
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
// 从 Redux 读取关注状态
@@ -146,6 +153,23 @@ const [currentMode, setCurrentMode] = useState('vertical');
dispatch(toggleEventFollow(eventId));
}, [dispatch]);
// 通知开关处理
const handleNotificationToggle = useCallback(async () => {
if (browserPermission === 'granted') {
// 已授权,提示用户去浏览器设置中关闭
toast({
title: '已开启通知',
description: '要关闭通知,请在浏览器地址栏左侧点击锁图标,找到"通知"选项进行设置',
status: 'info',
duration: 5000,
isClosable: true,
});
} else {
// 未授权,请求权限
await requestBrowserPermission();
}
}, [browserPermission, requestBrowserPermission, toast]);
// 本地状态
const [selectedEvent, setSelectedEvent] = useState(null);
@@ -511,9 +535,66 @@ const [currentMode, setCurrentMode] = useState('vertical');
<Badge colorScheme="blue">快讯</Badge>
</HStack>
</VStack>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
<VStack align="end" spacing={2}>
{/* 通知开关 */}
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启,新事件将实时推送'
: '开启后可接收实时事件推送通知'}
placement="left"
hasArrow
>
<HStack
spacing={2}
px={3}
py={2}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
>
<Icon
as={BellIcon}
boxSize={4}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
>
{browserPermission === 'granted' ? '通知已开启' : '开启通知'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
/>
</HStack>
</Tooltip>
<Text fontSize="xs" color="gray.500">
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
</Text>
</VStack>
</Flex>
{/* 搜索和筛选组件 */}

View File

@@ -33,7 +33,7 @@ const VerticalModeLayout = ({
borderColor,
}) => {
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
const [layoutMode, setLayoutMode] = useState('list');
const [layoutMode, setLayoutMode] = useState('detail');
// 详情面板重置 key切换到 list 模式时改变,强制重新渲染)
const [detailPanelKey, setDetailPanelKey] = useState(0);

View File

@@ -73,7 +73,7 @@ const VirtualizedFourRowGrid = ({
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
*
* 工作原理:
* 1. 向下滚动到 60% 位置时,触发 loadNextPage()
* 1. 向下滚动到 90% 位置时,触发 loadNextPage()
* - 调用 usePagination.loadNextPage()
* - 内部执行 handlePageChange(currentPage + 1)
* - dispatch(fetchDynamicNews({ page: nextPage }))
@@ -87,7 +87,7 @@ const VirtualizedFourRowGrid = ({
* - 与5分钟定时刷新协同工作
*
* 设计要点:
* - 60% 触发点:提前加载,避免滚动到底部时才出现加载状态
* - 90% 触发点:接近底部才加载,避免过早触发影响用户体验
* - 防抖机制isLoadingMore.current 防止重复触发
* - 两层缓存:
* - Redux 缓存HTTP层fourRowEvents 数组存储已加载数据,避免重复请求
@@ -107,9 +107,9 @@ const VirtualizedFourRowGrid = ({
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
// 向下滚动:滚动到 60% 时开始加载下一页
if (loadNextPage && hasMore && scrollPercentage > 0.6) {
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
// 向下滚动:滚动到 90% 时开始加载下一页(更接近底部,避免过早触发)
if (loadNextPage && hasMore && scrollPercentage > 0.9) {
console.log('%c📜 [无限滚动] 接近底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
isLoadingMore.current = true;
await loadNextPage();
isLoadingMore.current = false;

View File

@@ -162,7 +162,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
<div
style={{
width: '100%',
height: 30,
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}

View File

@@ -125,13 +125,13 @@ const StockListItem = ({
transition="all 0.2s"
>
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
<HStack spacing={3} align="stretch">
{/* 左侧:股票名称 + 涨跌幅(垂直排列) - 收窄 */}
<HStack spacing={3} align="center" flexWrap="wrap">
{/* 左侧:股票代码 + 名称 + 涨跌幅(垂直排列) */}
<VStack
align="stretch"
spacing={1}
minW="100px"
maxW="120px"
minW="110px"
maxW="130px"
justify="center"
flexShrink={0}
>
@@ -143,17 +143,29 @@ const StockListItem = ({
color="white"
fontSize="xs"
>
<Text
fontSize="sm"
fontWeight="bold"
color={codeColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_name}
</Text>
<VStack spacing={0} align="stretch">
<Text
fontSize="xs"
color={codeColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text
fontSize="sm"
fontWeight="bold"
color={nameColor}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_name}
</Text>
</VStack>
</Tooltip>
<HStack spacing={1} align="center">
<Text
@@ -177,89 +189,137 @@ const StockListItem = ({
</HStack>
</VStack>
{/* 分时图 - 固定宽度 */}
<Box
w="160px"
{/* 分时图 - 紧凑高度 */}
<VStack
minW="150px"
maxW="180px"
flex="1"
borderWidth="1px"
borderColor={useColorModeValue('blue.100', 'blue.700')}
borderRadius="md"
p={2}
px={2}
py={1}
bg={useColorModeValue('blue.50', 'blue.900')}
onClick={(e) => {
e.stopPropagation();
setIsModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
flexShrink={1}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: useColorModeValue('blue.300', 'blue.500'),
boxShadow: 'sm'
boxShadow: 'md',
transform: 'translateY(-1px)'
}}
transition="all 0.2s"
>
<Text
fontSize="xs"
color={useColorModeValue('blue.700', 'blue.200')}
mb={1}
fontWeight="semibold"
whiteSpace="nowrap"
mb={0.5}
>
📈 分时
</Text>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
<Box h="35px">
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
</VStack>
{/* K线图 - 固定宽度 */}
<Box
w="160px"
{/* K线图 - 紧凑高度 */}
<VStack
minW="150px"
maxW="180px"
flex="1"
borderWidth="1px"
borderColor={useColorModeValue('purple.100', 'purple.700')}
borderRadius="md"
p={2}
px={2}
py={1}
bg={useColorModeValue('purple.50', 'purple.900')}
onClick={(e) => {
e.stopPropagation();
setIsModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
flexShrink={1}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: useColorModeValue('purple.300', 'purple.500'),
boxShadow: 'sm'
boxShadow: 'md',
transform: 'translateY(-1px)'
}}
transition="all 0.2s"
>
<Text
fontSize="xs"
color={useColorModeValue('purple.700', 'purple.200')}
mb={1}
fontWeight="semibold"
whiteSpace="nowrap"
mb={0.5}
>
📊 日线
</Text>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
<Box h="35px">
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
/>
</Box>
</VStack>
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0}>
{stock.relation_desc?.data ? (
// 升级:带引用来源的版本
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
// 升级:带引用来源的版本 - 添加折叠功能
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
placement="top"
hasArrow
bg="gray.600"
color="white"
fontSize="xs"
>
<Box
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={useColorModeValue('gray.50', 'gray.700')}
borderRadius="md"
_hover={{
bg: useColorModeValue('gray.100', 'gray.600'),
}}
transition="background 0.2s"
position="relative"
>
<Collapse in={isDescExpanded} startingHeight={40}>
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: 'transparent',
borderRadius: '0',
padding: '0',
}}
/>
</Collapse>
</Box>
</Tooltip>
) : (
// 降级:纯文本版本(保留展开/收起功能)
<Tooltip

View File

@@ -166,7 +166,8 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
<div
style={{
width: '100%',
height: 30,
height: '100%',
minHeight: '35px',
cursor: onClick ? 'pointer' : 'default'
}}
onClick={onClick}

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import {
@@ -10,6 +10,15 @@ import {
Box,
Container,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
} from '@chakra-ui/react';
// 导入组件
@@ -40,7 +49,10 @@ const Community = () => {
const containerRef = useRef(null);
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
const { browserPermission, requestBrowserPermission } = useNotification();
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
@@ -71,17 +83,38 @@ const Community = () => {
}
}, [events, loading, pagination, filters]);
// ⚡ 首次访问社区时,延迟显示权限引导
// ⚡ 检查通知权限状态,显示横幅提示
useEffect(() => {
if (showCommunityGuide) {
const timer = setTimeout(() => {
logger.info('Community', '显示社区权限引导');
showCommunityGuide();
}, 5000); // 延迟 5 秒,让用户先浏览页面
// 延迟3秒显示让用户先浏览页面
const timer = setTimeout(() => {
// 如果未授权或未请求过权限,显示横幅
if (browserPermission !== 'granted') {
const hasClosedBanner = localStorage.getItem('notification_banner_closed');
if (!hasClosedBanner) {
setShowNotificationBanner(true);
logger.info('Community', '显示通知权限横幅');
}
}
}, 3000);
return () => clearTimeout(timer);
return () => clearTimeout(timer);
}, [browserPermission]);
// 处理开启通知
const handleEnableNotifications = async () => {
const permission = await requestBrowserPermission();
if (permission === 'granted') {
setShowNotificationBanner(false);
logger.info('Community', '通知权限已授予');
}
}, [showCommunityGuide]); // 只在组件挂载时执行一次
};
// 处理关闭横幅
const handleCloseBanner = () => {
setShowNotificationBanner(false);
localStorage.setItem('notification_banner_closed', 'true');
logger.info('Community', '通知横幅已关闭');
};
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
useEffect(() => {
@@ -104,6 +137,42 @@ const Community = () => {
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container ref={containerRef} maxW="1600px" pt={6} pb={8}>
{/* 通知权限提示横幅 */}
{showNotificationBanner && (
<Alert
status="info"
variant="subtle"
borderRadius="lg"
mb={4}
boxShadow="md"
bg={useColorModeValue('blue.50', 'blue.900')}
borderWidth="1px"
borderColor={useColorModeValue('blue.200', 'blue.700')}
>
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="md" mb={1}>
开启桌面通知不错过重要事件
</AlertTitle>
<AlertDescription fontSize="sm">
即使浏览器最小化也能第一时间接收新事件推送通知
</AlertDescription>
</Box>
<HStack spacing={2} ml={4}>
<Button
size="sm"
colorScheme="blue"
onClick={handleEnableNotifications}
>
立即开启
</Button>
<CloseButton
onClick={handleCloseBanner}
position="relative"
/>
</HStack>
</Alert>
)}
{/* 热点事件区域 */}
<HotEventsSection
events={hotEvents}
@@ -112,7 +181,6 @@ const Community = () => {
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
mt={6}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}

View File

@@ -126,6 +126,14 @@ const HistoricalEvents = ({
return 'green';
};
// 获取相关度颜色1-10
const getSimilarityColor = (similarity) => {
if (similarity >= 8) return 'green';
if (similarity >= 6) return 'blue';
if (similarity >= 4) return 'orange';
return 'gray';
};
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '日期未知';
@@ -300,6 +308,11 @@ const HistoricalEvents = ({
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.similarity !== undefined && event.similarity !== null && (
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm">
相关度: {event.similarity}
</Badge>
)}
</HStack>
</VStack>