Compare commits

...

38 Commits

Author SHA1 Message Date
zdl
0f3219143f Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event
* 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react:
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
  agent功能开发增加MCP后端
2025-11-07 19:48:20 +08:00
zdl
00aabfacea feat: DynamicNewsDetailPanel 支持无头部模式和精简模式优化
新增功能:
- 添加 showHeader prop 控制头部显示/隐藏(默认 true)
- 无头部模式下显示 CompactMetaBar 精简信息栏(右上角浮动)
- 相关股票支持精简模式(使用 CompactStockItem + Wrap 布局)
- 添加 showModeToggle 和 simpleContent props 到相关股票模块

Bug 修复和优化:
- 修复 isStocksOpen 初始值依赖未就绪变量的问题(改为 false)
- 优化股票加载逻辑:PRO 和 MAX 会员都默认展开和自动加载
- 更新日志文案:从"PRO会员"改为"PRO/MAX会员"

导入调整:
- 添加 Wrap, WrapItem(用于精简模式布局)
- 添加 CompactMetaBar(无头部模式信息栏)
- 添加 CompactStockItem(精简模式股票卡片)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:48:08 +08:00
zdl
7b49062986 docs: 更新 Community 文档
- 补充精简/详细模式切换功能文档
- 添加无头部模式(showHeader)使用说明
- 更新 CollapsibleSection 和 DynamicNewsDetailPanel 的 API 参考
- 添加相关组件的使用示例

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:47:14 +08:00
zdl
52c3e25218 feat: HistoricalEvents UI 布局优化
- 从网格布局(SimpleGrid 3列)改为单列纵向布局(VStack)
- 卡片样式优化:添加顶部渐变条装饰(蓝-紫-粉渐变)
- 卡片内部从垂直布局改为横向布局(HStack)
- 优化间距和边距,提升视觉层次感
- 调整卡片padding和borderRadius

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:46:56 +08:00
zdl
4979293320 feat: RelatedConceptsSection 支持受控模式和优化
- 新增 isOpen, onToggle props 支持外部控制展开状态(受控模式)
- 添加 hasNoConcepts 判断,优化空数据处理逻辑
- 改进精简模式和详细模式的空状态显示
- 增强点击处理逻辑,支持受控/非受控两种模式

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-07 19:46:29 +08:00
463ca7cf60 agent功能开发增加MCP后端 2025-11-07 19:35:37 +08:00
zdl
b30cbd6c62 RelatedStocksSection 重构为纯详细模式组件 2025-11-07 19:32:36 +08:00
zdl
11789b5ec7 Commit 2: CollapsibleSection 支持精简/详细双模式 2025-11-07 19:32:10 +08:00
zdl
63fb8a3aa8 feat: 功能: │ │
│ │ - 新增 showModeToggle, currentMode, onModeToggle 等 props                                                                                      │ │
│ │ - 支持显示模式切换按钮("精简模式" / "查看详情")                                                                                              │ │
│ │ - 根据模式动态显示按钮文案和图标
2025-11-07 19:31:42 +08:00
7366769083 agent功能开发增加MCP后端 2025-11-07 19:30:51 +08:00
zdl
2da71a3c03 feat: 相关股票添加合规 2025-11-07 19:29:19 +08:00
a46247f81b agent功能开发增加MCP后端 2025-11-07 19:27:01 +08:00
zdl
44b8c64907 feat(community): 列表模式事件卡片高度自适应 2025-11-07 19:25:10 +08:00
315d606945 agent功能开发增加MCP后端 2025-11-07 19:11:58 +08:00
zdl
5ceffc53d6 feat: 事件中心详情面板Ui调整 2025-11-07 18:39:49 +08:00
446d8f0870 agent功能开发增加MCP后端 2025-11-07 18:15:41 +08:00
e7ba8c4c2d agent功能开发增加MCP后端 2025-11-07 18:11:29 +08:00
a1c76a257c agent功能开发增加MCP后端 2025-11-07 17:42:06 +08:00
zdl
3574f5391f feat: 动画调整 2025-11-07 15:17:57 +08:00
zdl
fef9087c47 feat: 调整事件详情滑动不触发外部页面滑动 2025-11-07 15:11:18 +08:00
zdl
b0b42e9d3d feat: 添加post postHog加上 2025-11-07 15:10:27 +08:00
zdl
09f15d2e03 feat: 添加本地通知测试 2025-11-07 15:09:07 +08:00
zdl
a6718e1be5 pref: 删除无效代码 2025-11-07 15:08:46 +08:00
zdl
e93e307ad8 feat: 添加权限通知文档 2025-11-07 15:08:29 +08:00
zdl
16d60ef773 feat: 更新md文档 2025-11-07 15:07:38 +08:00
zdl
4d389bcc10 feat: 配置调整; 2025-11-07 14:48:27 +08:00
zdl
c10af30ad4 feat: 删除不需要的组件 2025-11-07 14:31:50 +08:00
zdl
3c060b7aa5 feat: 事件详情添加浏览量点击机制 2025-11-07 14:16:11 +08:00
zdl
72e9456aba feat: Community 页面有了自己独立的技术文档 2025-11-07 14:01:24 +08:00
zdl
0e82c96c5a feat: CLAUDE.md **🌐 语言偏好** 2025-11-07 14:00:57 +08:00
zdl
9c93843f75 feat: 删除无用代码 2025-11-07 13:19:51 +08:00
zdl
184c26d323 feat: 添加通知组件调试信息 2025-11-07 12:34:05 +08:00
zdl
e80227840a feat: 补充md文档 2025-11-07 12:19:41 +08:00
zdl
e4490b54e0 feat: CLAUDE.md 文档已经完全中文化 2025-11-07 12:19:41 +08:00
83cd875690 事件中心UI优化 2025-11-07 11:20:45 +08:00
25d3bf4d95 事件中心UI优化 2025-11-07 11:08:06 +08:00
7adb4ea8af Merge branch 'feature_bugfix/251104_event' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251104_event 2025-11-07 10:56:21 +08:00
3eff0554f9 事件中心UI优化 2025-11-07 10:56:08 +08:00
59 changed files with 15886 additions and 2189 deletions

3156
CLAUDE.md

File diff suppressed because it is too large Load Diff

Binary file not shown.

381
docs/AGENT_DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,381 @@
# AI Agent 系统部署指南
## 🎯 系统架构
### 三阶段流程
```
用户输入
[阶段1: 计划制定 Planning]
- LLM 分析用户需求
- 确定需要哪些工具
- 制定执行计划steps
[阶段2: 工具执行 Execution]
- 按计划顺序调用 MCP 工具
- 收集数据
- 异常处理和重试
[阶段3: 结果总结 Summarization]
- LLM 综合分析所有数据
- 生成自然语言报告
输出给用户
```
## 📦 文件清单
### 后端文件
```
mcp_server.py # MCP 工具服务器(已有)
mcp_agent_system.py # Agent 系统核心逻辑(新增)
mcp_config.py # 配置文件(已有)
mcp_database.py # 数据库操作(已有)
```
### 前端文件
```
src/components/ChatBot/
├── ChatInterfaceV2.js # 新版聊天界面(漂亮)
├── PlanCard.js # 执行计划卡片
├── StepResultCard.js # 步骤结果卡片(可折叠)
├── ChatInterface.js # 旧版聊天界面(保留)
├── MessageBubble.js # 消息气泡组件(保留)
└── index.js # 统一导出
src/views/AgentChat/
└── index.js # Agent 聊天页面
```
## 🚀 部署步骤
### 1. 安装依赖
```bash
# 进入项目目录
cd /home/ubuntu/vf_react
# 安装 OpenAI SDK支持多个LLM提供商
pip install openai
```
### 2. 获取 LLM API Key
**推荐:通义千问(便宜且中文能力强)**
1. 访问 https://dashscope.console.aliyun.com/
2. 注册/登录阿里云账号
3. 开通 DashScope 服务
4. 创建 API Key
5. 复制 API Key格式`sk-xxx...`
**其他选择**
- DeepSeek: https://platform.deepseek.com/ (最便宜)
- OpenAI: https://platform.openai.com/ (需要翻墙)
### 3. 配置环境变量
```bash
# 编辑环境变量
sudo nano /etc/environment
# 添加以下内容(选择一个)
# 方式1: 通义千问(推荐)
DASHSCOPE_API_KEY="sk-your-key-here"
# 方式2: DeepSeek更便宜
DEEPSEEK_API_KEY="sk-your-key-here"
# 方式3: OpenAI
OPENAI_API_KEY="sk-your-key-here"
# 保存并退出,然后重新加载
source /etc/environment
# 验证环境变量
echo $DASHSCOPE_API_KEY
```
### 4. 修改 mcp_server.py
在文件末尾(`if __name__ == "__main__":` 之前)添加:
```python
# ==================== Agent 端点 ====================
from mcp_agent_system import MCPAgent, ChatRequest, AgentResponse
# 创建 Agent 实例
agent = MCPAgent(provider="qwen") # 或 "deepseek", "openai"
@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
```
### 5. 重启 MCP 服务
```bash
# 如果使用 systemd
sudo systemctl restart mcp-server
# 或者手动重启
pkill -f mcp_server
nohup uvicorn mcp_server:app --host 0.0.0.0 --port 8900 > mcp_server.log 2>&1 &
# 查看日志
tail -f mcp_server.log
```
### 6. 测试 Agent API
```bash
# 测试 Agent 端点
curl -X POST http://localhost:8900/agent/chat \
-H "Content-Type: application/json" \
-d '{
"message": "全面分析贵州茅台这只股票",
"conversation_history": []
}'
# 应该返回类似这样的JSON
# {
# "success": true,
# "message": "根据分析,贵州茅台...",
# "plan": {
# "goal": "全面分析贵州茅台",
# "steps": [...]
# },
# "step_results": [...],
# "metadata": {...}
# }
```
### 7. 部署前端
```bash
# 在本地构建
npm run build
# 上传到服务器
scp -r build/* ubuntu@your-server:/var/www/valuefrontier.cn/
# 或者在服务器上构建
cd /home/ubuntu/vf_react
npm run build
sudo cp -r build/* /var/www/valuefrontier.cn/
```
### 8. 重启 Nginx
```bash
sudo systemctl reload nginx
```
## ✅ 验证部署
### 1. 测试后端 API
```bash
# 测试工具列表
curl https://valuefrontier.cn/mcp/tools
# 测试 Agent
curl -X POST https://valuefrontier.cn/mcp/agent/chat \
-H "Content-Type: application/json" \
-d '{
"message": "今日涨停股票有哪些",
"conversation_history": []
}'
```
### 2. 测试前端
1. 访问 https://valuefrontier.cn/agent-chat
2. 输入问题:"全面分析贵州茅台这只股票"
3. 观察:
- ✓ 是否显示执行计划卡片
- ✓ 是否显示步骤执行过程
- ✓ 是否显示最终总结
- ✓ 步骤结果卡片是否可折叠
### 3. 测试用例
```
测试1: 简单查询
输入:查询贵州茅台的股票信息
预期:调用 get_stock_basic_info返回基本信息
测试2: 深度分析(推荐)
输入:全面分析贵州茅台这只股票
预期:
- 步骤1: get_stock_basic_info
- 步骤2: get_stock_financial_index
- 步骤3: get_stock_trade_data
- 步骤4: search_china_news
- 步骤5: summarize_with_llm
测试3: 市场热点
输入:今日涨停股票有哪些亮点
预期:
- 步骤1: search_limit_up_stocks
- 步骤2: get_concept_statistics
- 步骤3: summarize_with_llm
测试4: 概念分析
输入:新能源概念板块的投资机会
预期:
- 步骤1: search_concepts新能源
- 步骤2: search_china_news新能源
- 步骤3: summarize_with_llm
```
## 🐛 故障排查
### 问题1: Agent 返回 "Provider not configured"
**原因**: 环境变量未设置
**解决**:
```bash
# 检查环境变量
echo $DASHSCOPE_API_KEY
# 如果为空,重新设置
export DASHSCOPE_API_KEY="sk-xxx..."
# 重启服务
sudo systemctl restart mcp-server
```
### 问题2: Agent 返回 JSON 解析错误
**原因**: LLM 没有返回正确的 JSON 格式
**解决**: 在 `mcp_agent_system.py` 中已经处理了代码块标记清理,如果还有问题:
1. 检查 LLM 的 temperature 参数(建议 0.3
2. 检查 prompt 是否清晰
3. 尝试不同的 LLM 提供商
### 问题3: 前端显示 "查询失败"
**原因**: 后端 API 未正确配置或 Nginx 代理问题
**解决**:
```bash
# 1. 检查 MCP 服务是否运行
ps aux | grep mcp_server
# 2. 检查 Nginx 配置
sudo nginx -t
# 3. 查看错误日志
sudo tail -f /var/log/nginx/error.log
tail -f /home/ubuntu/vf_react/mcp_server.log
```
### 问题4: 执行步骤失败
**原因**: 某个 MCP 工具调用失败
**解决**: 查看步骤结果卡片中的错误信息,通常是:
- API 超时:增加 timeout
- 参数错误:检查工具定义
- 数据库连接失败:检查数据库连接
## 💰 成本估算
### 使用通义千问qwen-plus
**价格**: ¥0.004/1000 tokens
**典型对话消耗**:
- 简单查询1步: ~500 tokens = ¥0.002
- 深度分析5步: ~3000 tokens = ¥0.012
- 平均每次对话: ¥0.005
**月度成本**1000次深度分析:
- 1000次 × ¥0.012 = ¥12
**结论**: 非常便宜1000次深度分析只需要12元。
### 使用 DeepSeek更便宜
**价格**: ¥0.001/1000 tokens比通义千问便宜4倍
**月度成本**1000次深度分析:
- 1000次 × ¥0.003 = ¥3
## 📊 监控和优化
### 1. 添加日志监控
```bash
# 实时查看 Agent 日志
tail -f mcp_server.log | grep -E "\[Agent\]|\[Planning\]|\[Execution\]|\[Summary\]"
```
### 2. 性能优化建议
1. **缓存计划**: 相似的问题可以复用执行计划
2. **并行执行**: 独立的工具调用可以并行执行
3. **流式输出**: 使用 Server-Sent Events 实时返回进度
4. **结果缓存**: 相同的工具调用结果可以缓存
### 3. 添加统计分析
`mcp_server.py` 中添加:
```python
from datetime import datetime
import json
# 记录每次 Agent 调用
@app.post("/agent/chat")
async def agent_chat(request: ChatRequest):
start_time = datetime.now()
response = await agent.process_query(...)
duration = (datetime.now() - start_time).total_seconds()
# 记录到日志
logger.info(f"Agent query completed in {duration:.2f}s", extra={
"query": request.message,
"steps": len(response.plan.steps) if response.plan else 0,
"success": response.success,
"duration": duration,
})
return response
```
## 🎉 完成!
现在你的 AI Agent 系统已经部署完成!
访问 https://valuefrontier.cn/agent-chat 开始使用。
**特点**:
- ✅ 三阶段智能分析(计划-执行-总结)
- ✅ 漂亮的UI界面卡片式展示
- ✅ 步骤结果可折叠查看
- ✅ 实时进度反馈
- ✅ 异常处理和重试
- ✅ 成本低廉¥3-12/月)

1197
docs/Community.md Normal file

File diff suppressed because it is too large Load Diff

309
docs/MCP_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,309 @@
# MCP 架构说明
## 🎯 MCP 是什么?
**MCP (Model Context Protocol)** 是一个**工具调用协议**,它的核心职责是:
1.**定义工具接口**:告诉 LLM 有哪些工具可用,每个工具需要什么参数
2.**执行工具调用**:根据请求调用对应的后端 API
3.**返回结构化数据**:将 API 结果返回给调用方
**MCP 不负责**
- ❌ 自然语言理解NLU
- ❌ 意图识别
- ❌ 结果总结
- ❌ 对话管理
## 📊 当前架构
### 方案 1简单关键词匹配已实现
```
用户输入:"查询贵州茅台的股票信息"
前端 ChatInterface (关键词匹配)
MCP 工具层 (search_china_news)
返回 JSON 数据
前端显示原始数据
```
**问题**
- ✗ 只能识别简单关键词
- ✗ 无法理解复杂意图
- ✗ 返回的是原始 JSON用户体验差
### 方案 2集成 LLM推荐
```
用户输入:"查询贵州茅台的股票信息"
LLM (Claude/GPT-4/通义千问)
↓ 理解意图:需要查询股票代码 600519 的基本信息
↓ 选择工具get_stock_basic_info
↓ 提取参数:{"seccode": "600519"}
MCP 工具层
↓ 调用 API获取数据
返回结构化数据
LLM 总结结果
↓ "贵州茅台600519是中国知名的白酒生产企业
当前股价 1650.00 元,市值 2.07 万亿..."
前端显示自然语言回复
```
**优势**
- ✓ 理解复杂意图
- ✓ 自动选择合适的工具
- ✓ 自然语言总结,用户体验好
- ✓ 支持多轮对话
## 🔧 实现方案
### 选项 A前端集成 LLM快速实现
**适用场景**:快速原型、小规模应用
**优点**
- 实现简单
- 无需修改后端
**缺点**
- API Key 暴露在前端(安全风险)
- 每个用户都消耗 API 额度
- 无法统一管理和监控
**实现步骤**
1. 修改 `src/components/ChatBot/ChatInterface.js`
```javascript
import { llmService } from '../../services/llmService';
const handleSendMessage = async () => {
// ...
// 使用 LLM 服务替代简单的 mcpService.chat
const response = await llmService.chat(inputValue, messages);
// ...
};
```
2. 配置 API Key`.env.local`
```bash
REACT_APP_OPENAI_API_KEY=sk-xxx...
# 或者使用通义千问(更便宜)
REACT_APP_DASHSCOPE_API_KEY=sk-xxx...
```
### 选项 B后端集成 LLM生产推荐
**适用场景**:生产环境、需要安全和性能
**优点**
- ✓ API Key 安全(不暴露给前端)
- ✓ 统一管理和监控
- ✓ 可以做缓存优化
- ✓ 可以做速率限制
**缺点**
- 需要修改后端
- 增加服务器成本
**实现步骤**
#### 1. 安装依赖
```bash
pip install openai
```
#### 2. 修改 `mcp_server.py`,添加聊天端点
在文件末尾添加:
```python
from mcp_chat_endpoint import MCPChatAssistant, ChatRequest, ChatResponse
# 创建聊天助手实例
chat_assistant = MCPChatAssistant(provider="qwen") # 推荐使用通义千问
@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
```
#### 3. 配置环境变量
在服务器上设置:
```bash
# 方式1使用通义千问推荐价格便宜
export DASHSCOPE_API_KEY="sk-xxx..."
# 方式2使用 OpenAI
export OPENAI_API_KEY="sk-xxx..."
# 方式3使用 DeepSeek最便宜
export DEEPSEEK_API_KEY="sk-xxx..."
```
#### 4. 修改前端 `mcpService.js`
```javascript
/**
* 智能对话 - 使用后端LLM处理
*/
async chat(userMessage, conversationHistory = []) {
try {
const response = await this.client.post('/chat', {
message: userMessage,
conversation_history: conversationHistory,
});
return {
success: true,
data: response,
};
} catch (error) {
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
```
#### 5. 修改前端 `ChatInterface.js`
```javascript
const handleSendMessage = async () => {
// ...
try {
// 调用后端聊天API
const response = await mcpService.chat(inputValue, messages);
if (response.success) {
const botMessage = {
id: Date.now() + 1,
content: response.data.message, // LLM总结的自然语言
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
toolUsed: response.data.tool_used, // 可选:显示使用了哪个工具
rawData: response.data.raw_data, // 可选:原始数据(折叠显示)
};
setMessages((prev) => [...prev, botMessage]);
}
} catch (error) {
// ...
}
};
```
## 💰 LLM 选择和成本
### 推荐:通义千问(阿里云)
**优点**
- 价格便宜1000次对话约 ¥1-2
- 中文理解能力强
- 国内访问稳定
**价格**
- qwen-plus: ¥0.004/1000 tokens约 ¥0.001/次对话)
- qwen-turbo: ¥0.002/1000 tokens更便宜
**获取 API Key**
1. 访问 https://dashscope.console.aliyun.com/
2. 创建 API Key
3. 设置环境变量 `DASHSCOPE_API_KEY`
### 其他选择
| 提供商 | 模型 | 价格 | 优点 | 缺点 |
|--------|------|------|------|------|
| **通义千问** | qwen-plus | ¥0.001/次 | 便宜、中文好 | - |
| **DeepSeek** | deepseek-chat | ¥0.0005/次 | 最便宜 | 新公司 |
| **OpenAI** | gpt-4o-mini | $0.15/1M tokens | 能力强 | 贵、需翻墙 |
| **Claude** | claude-3-haiku | $0.25/1M tokens | 理解力强 | 贵、需翻墙 |
## 🚀 部署步骤
### 1. 后端部署
```bash
# 安装依赖
pip install openai
# 设置 API Key
export DASHSCOPE_API_KEY="sk-xxx..."
# 重启服务
sudo systemctl restart mcp-server
# 测试聊天端点
curl -X POST https://valuefrontier.cn/mcp/chat \
-H "Content-Type: application/json" \
-d '{"message": "查询贵州茅台的股票信息"}'
```
### 2. 前端部署
```bash
# 构建
npm run build
# 部署
scp -r build/* user@server:/var/www/valuefrontier.cn/
```
### 3. 验证
访问 https://valuefrontier.cn/agent-chat测试对话
**测试用例**
1. "查询贵州茅台的股票信息" → 应返回自然语言总结
2. "今日涨停的股票有哪些" → 应返回涨停股票列表并总结
3. "新能源概念板块表现如何" → 应搜索概念并分析
## 📊 对比总结
| 特性 | 简单匹配 | 前端LLM | 后端LLM ⭐ |
|------|---------|---------|-----------|
| 实现难度 | 简单 | 中等 | 中等 |
| 用户体验 | 差 | 好 | 好 |
| 安全性 | 高 | 低 | 高 |
| 成本 | 无 | 用户承担 | 服务器承担 |
| 可维护性 | 差 | 中 | 好 |
| **推荐指数** | ⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
## 🎯 最终推荐
**生产环境:后端集成 LLM (方案 B)**
- 使用通义千问qwen-plus
- 成本低(约 ¥50/月10000次对话
- 安全可靠
**快速原型:前端集成 LLM (方案 A)**
- 适合演示
- 快速验证可行性
- 后续再迁移到后端

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

361
kimi_integration.py Normal file
View File

@@ -0,0 +1,361 @@
"""
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()

470
mcp_agent_system.py Normal file
View File

@@ -0,0 +1,470 @@
"""
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
"""

295
mcp_chat_endpoint.py Normal file
View File

@@ -0,0 +1,295 @@
"""
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
"""

248
mcp_client_example.py Normal file
View File

@@ -0,0 +1,248 @@
"""
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()

108
mcp_config.py Normal file
View File

@@ -0,0 +1,108 @@
"""
MCP服务器配置文件
集中管理所有配置项
"""
from typing import Dict
from pydantic import BaseSettings
class Settings(BaseSettings):
"""应用配置"""
# 服务器配置
SERVER_HOST: str = "0.0.0.0"
SERVER_PORT: int = 8900
DEBUG: bool = True
# 后端API服务端点
NEWS_API_URL: str = "http://222.128.1.157:21891"
ROADSHOW_API_URL: str = "http://222.128.1.157:19800"
CONCEPT_API_URL: str = "http://222.128.1.157:16801"
STOCK_ANALYSIS_API_URL: str = "http://222.128.1.157:8811"
# HTTP客户端配置
HTTP_TIMEOUT: float = 60.0
HTTP_MAX_RETRIES: int = 3
# 日志配置
LOG_LEVEL: str = "INFO"
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# CORS配置
CORS_ORIGINS: list = ["*"]
CORS_CREDENTIALS: bool = True
CORS_METHODS: list = ["*"]
CORS_HEADERS: list = ["*"]
# LLM配置如果需要集成
LLM_PROVIDER: str = "openai" # openai, anthropic, etc.
LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4"
LLM_BASE_URL: str = ""
# 速率限制
RATE_LIMIT_ENABLED: bool = False
RATE_LIMIT_PER_MINUTE: int = 60
# 缓存配置
CACHE_ENABLED: bool = True
CACHE_TTL: int = 300 # 秒
class Config:
env_file = ".env"
case_sensitive = True
# 全局设置实例
settings = Settings()
# 工具类别映射(用于组织和展示)
TOOL_CATEGORIES: Dict[str, list] = {
"新闻搜索": [
"search_news",
"search_china_news",
"search_medical_news"
],
"公司研究": [
"search_roadshows",
"search_research_reports"
],
"概念板块": [
"search_concepts",
"get_concept_details",
"get_stock_concepts",
"get_concept_statistics"
],
"股票分析": [
"search_limit_up_stocks",
"get_daily_stock_analysis"
]
}
# 工具优先级用于LLM选择工具时的提示
TOOL_PRIORITIES: Dict[str, int] = {
"search_china_news": 10, # 最常用
"search_concepts": 9,
"search_limit_up_stocks": 8,
"search_research_reports": 8,
"get_stock_concepts": 7,
"search_news": 6,
"get_daily_stock_analysis": 5,
"get_concept_statistics": 5,
"search_medical_news": 4,
"search_roadshows": 4,
"get_concept_details": 3,
}
# 默认参数配置
DEFAULT_PARAMS = {
"top_k": 20,
"page_size": 20,
"size": 10,
"sort_by": "change_pct",
"mode": "hybrid",
"exact_match": False,
}

546
mcp_database.py Normal file
View File

@@ -0,0 +1,546 @@
"""
MySQL数据库查询模块
提供股票财务数据查询功能
"""
import aiomysql
import logging
from typing import Dict, List, Any, Optional
from datetime import datetime, date
from decimal import Decimal
import json
logger = logging.getLogger(__name__)
# MySQL连接配置
MYSQL_CONFIG = {
'host': '222.128.1.157',
'port': 33060,
'user': 'root',
'password': 'Zzl5588161!',
'db': 'stock',
'charset': 'utf8mb4',
'autocommit': True
}
# 全局连接池
_pool = None
class DateTimeEncoder(json.JSONEncoder):
"""JSON编码器处理datetime和Decimal类型"""
def default(self, obj):
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
async def get_pool():
"""获取MySQL连接池"""
global _pool
if _pool is None:
_pool = await aiomysql.create_pool(
host=MYSQL_CONFIG['host'],
port=MYSQL_CONFIG['port'],
user=MYSQL_CONFIG['user'],
password=MYSQL_CONFIG['password'],
db=MYSQL_CONFIG['db'],
charset=MYSQL_CONFIG['charset'],
autocommit=MYSQL_CONFIG['autocommit'],
minsize=1,
maxsize=10
)
logger.info("MySQL connection pool created")
return _pool
async def close_pool():
"""关闭MySQL连接池"""
global _pool
if _pool:
_pool.close()
await _pool.wait_closed()
_pool = None
logger.info("MySQL connection pool closed")
def convert_row(row: Dict) -> Dict:
"""转换数据库行,处理特殊类型"""
if not row:
return {}
result = {}
for key, value in row.items():
if isinstance(value, Decimal):
result[key] = float(value)
elif isinstance(value, (datetime, date)):
result[key] = value.isoformat()
else:
result[key] = value
return result
async def get_stock_basic_info(seccode: str) -> Optional[Dict[str, Any]]:
"""
获取股票基本信息
Args:
seccode: 股票代码
Returns:
股票基本信息字典
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ORGNAME,
F001V as english_name,
F003V as legal_representative,
F004V as registered_address,
F005V as office_address,
F010D as establishment_date,
F011V as website,
F012V as email,
F013V as phone,
F015V as main_business,
F016V as business_scope,
F017V as company_profile,
F030V as industry_level1,
F032V as industry_level2,
F034V as sw_industry_level1,
F036V as sw_industry_level2,
F026V as province,
F028V as city,
F041V as chairman,
F042V as general_manager,
UPDATE_DATE as update_date
FROM ea_baseinfo
WHERE SECCODE = %s
LIMIT 1
"""
await cursor.execute(query, (seccode,))
result = await cursor.fetchone()
if result:
return convert_row(result)
return None
async def get_stock_financial_index(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""
获取股票财务指标
Args:
seccode: 股票代码
start_date: 开始日期 YYYY-MM-DD
end_date: 结束日期 YYYY-MM-DD
limit: 返回条数
Returns:
财务指标列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# 构建查询
query = """
SELECT
SECCODE, SECNAME, ENDDATE, STARTDATE,
F069D as report_year,
F003N as eps, -- 每股收益
F004N as basic_eps,
F008N as bps, -- 每股净资产
F014N as roe, -- 净资产收益率
F016N as roa, -- 总资产报酬率
F017N as net_profit_margin, -- 净利润率
F022N as receivable_turnover, -- 应收账款周转率
F023N as inventory_turnover, -- 存货周转率
F025N as total_asset_turnover, -- 总资产周转率
F041N as debt_ratio, -- 资产负债率
F042N as current_ratio, -- 流动比率
F043N as quick_ratio, -- 速动比率
F052N as revenue_growth, -- 营业收入增长率
F053N as profit_growth, -- 净利润增长率
F089N as revenue, -- 营业收入
F090N as operating_cost, -- 营业成本
F101N as net_profit, -- 净利润
F102N as net_profit_parent -- 归母净利润
FROM ea_financialindex
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_trade_data(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 30
) -> List[Dict[str, Any]]:
"""
获取股票交易数据
Args:
seccode: 股票代码
start_date: 开始日期 YYYY-MM-DD
end_date: 结束日期 YYYY-MM-DD
limit: 返回条数
Returns:
交易数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, TRADEDATE,
F002N as prev_close, -- 昨日收盘价
F003N as open_price, -- 开盘价
F005N as high_price, -- 最高价
F006N as low_price, -- 最低价
F007N as close_price, -- 收盘价
F004N as volume, -- 成交量
F011N as turnover, -- 成交金额
F009N as change_amount, -- 涨跌额
F010N as change_pct, -- 涨跌幅
F012N as turnover_rate, -- 换手率
F013N as amplitude, -- 振幅
F026N as pe_ratio, -- 市盈率
F020N as total_shares, -- 总股本
F021N as circulating_shares -- 流通股本
FROM ea_trade
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND TRADEDATE >= %s"
params.append(start_date)
if end_date:
query += " AND TRADEDATE <= %s"
params.append(end_date)
query += " ORDER BY TRADEDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_balance_sheet(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 8
) -> List[Dict[str, Any]]:
"""
获取资产负债表数据
Args:
seccode: 股票代码
start_date: 开始日期
end_date: 结束日期
limit: 返回条数
Returns:
资产负债表数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ENDDATE,
F001D as report_year,
F006N as cash, -- 货币资金
F009N as receivables, -- 应收账款
F015N as inventory, -- 存货
F019N as current_assets, -- 流动资产合计
F023N as long_term_investment, -- 长期股权投资
F025N as fixed_assets, -- 固定资产
F037N as noncurrent_assets, -- 非流动资产合计
F038N as total_assets, -- 资产总计
F039N as short_term_loan, -- 短期借款
F042N as payables, -- 应付账款
F052N as current_liabilities, -- 流动负债合计
F053N as long_term_loan, -- 长期借款
F060N as noncurrent_liabilities, -- 非流动负债合计
F061N as total_liabilities, -- 负债合计
F062N as share_capital, -- 股本
F063N as capital_reserve, -- 资本公积
F065N as retained_earnings, -- 未分配利润
F070N as total_equity -- 所有者权益合计
FROM ea_asset
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_cashflow(
seccode: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
limit: int = 8
) -> List[Dict[str, Any]]:
"""
获取现金流量表数据
Args:
seccode: 股票代码
start_date: 开始日期
end_date: 结束日期
limit: 返回条数
Returns:
现金流量表数据列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT
SECCODE, SECNAME, ENDDATE, STARTDATE,
F001D as report_year,
F009N as operating_cash_inflow, -- 经营活动现金流入
F014N as operating_cash_outflow, -- 经营活动现金流出
F015N as net_operating_cashflow, -- 经营活动现金流量净额
F021N as investing_cash_inflow, -- 投资活动现金流入
F026N as investing_cash_outflow, -- 投资活动现金流出
F027N as net_investing_cashflow, -- 投资活动现金流量净额
F031N as financing_cash_inflow, -- 筹资活动现金流入
F035N as financing_cash_outflow, -- 筹资活动现金流出
F036N as net_financing_cashflow, -- 筹资活动现金流量净额
F039N as net_cash_increase, -- 现金及现金等价物净增加额
F044N as net_profit, -- 净利润
F046N as depreciation, -- 固定资产折旧
F060N as net_operating_cashflow_adjusted -- 经营活动现金流量净额(补充)
FROM ea_cashflow
WHERE SECCODE = %s
"""
params = [seccode]
if start_date:
query += " AND ENDDATE >= %s"
params.append(start_date)
if end_date:
query += " AND ENDDATE <= %s"
params.append(end_date)
query += " ORDER BY ENDDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def search_stocks_by_criteria(
industry: Optional[str] = None,
province: Optional[str] = None,
min_market_cap: Optional[float] = None,
max_market_cap: Optional[float] = None,
limit: int = 50
) -> List[Dict[str, Any]]:
"""
按条件搜索股票
Args:
industry: 行业名称
province: 省份
min_market_cap: 最小市值(亿元)
max_market_cap: 最大市值(亿元)
limit: 返回条数
Returns:
股票列表
"""
pool = await get_pool()
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
query = """
SELECT DISTINCT
b.SECCODE,
b.SECNAME,
b.F030V as industry_level1,
b.F032V as industry_level2,
b.F034V as sw_industry_level1,
b.F026V as province,
b.F028V as city,
b.F015V as main_business,
t.F007N as latest_price,
t.F010N as change_pct,
t.F026N as pe_ratio,
t.TRADEDATE as latest_trade_date
FROM ea_baseinfo b
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 1=1
"""
params = []
if industry:
query += " AND (b.F030V LIKE %s OR b.F032V LIKE %s OR b.F034V LIKE %s)"
pattern = f"%{industry}%"
params.extend([pattern, pattern, pattern])
if province:
query += " AND b.F026V = %s"
params.append(province)
if min_market_cap or max_market_cap:
# 市值 = 最新价 * 总股本 / 100000000转换为亿元
if min_market_cap:
query += " AND (t.F007N * t.F020N / 100000000) >= %s"
params.append(min_market_cap)
if max_market_cap:
query += " AND (t.F007N * t.F020N / 100000000) <= %s"
params.append(max_market_cap)
query += " ORDER BY t.TRADEDATE DESC LIMIT %s"
params.append(limit)
await cursor.execute(query, params)
results = await cursor.fetchall()
return [convert_row(row) for row in results]
async def get_stock_comparison(
seccodes: List[str],
metric: str = "financial"
) -> Dict[str, Any]:
"""
股票对比分析
Args:
seccodes: 股票代码列表
metric: 对比指标类型 (financial/trade)
Returns:
对比数据
"""
pool = await get_pool()
if not seccodes or len(seccodes) < 2:
return {"error": "至少需要2个股票代码进行对比"}
async with pool.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
placeholders = ','.join(['%s'] * len(seccodes))
if metric == "financial":
# 对比最新财务指标
query = f"""
SELECT
f.SECCODE, f.SECNAME, f.ENDDATE,
f.F003N as eps,
f.F008N as bps,
f.F014N as roe,
f.F017N as net_profit_margin,
f.F041N as debt_ratio,
f.F052N as revenue_growth,
f.F053N as profit_growth,
f.F089N as revenue,
f.F101N as net_profit
FROM ea_financialindex f
INNER JOIN (
SELECT SECCODE, MAX(ENDDATE) as max_date
FROM ea_financialindex
WHERE SECCODE IN ({placeholders})
GROUP BY SECCODE
) latest ON f.SECCODE = latest.SECCODE
AND f.ENDDATE = latest.max_date
"""
else: # trade
# 对比最新交易数据
query = f"""
SELECT
t.SECCODE, t.SECNAME, t.TRADEDATE,
t.F007N as close_price,
t.F010N as change_pct,
t.F012N as turnover_rate,
t.F026N as pe_ratio,
t.F020N as total_shares,
t.F021N as circulating_shares
FROM ea_trade t
INNER JOIN (
SELECT SECCODE, MAX(TRADEDATE) as max_date
FROM ea_trade
WHERE SECCODE IN ({placeholders})
GROUP BY SECCODE
) latest ON t.SECCODE = latest.SECCODE
AND t.TRADEDATE = latest.max_date
"""
await cursor.execute(query, seccodes)
results = await cursor.fetchall()
return {
"comparison_type": metric,
"stocks": [convert_row(row) for row in results]
}

1541
mcp_server.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,492 @@
"""
集成到 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

@@ -18,8 +18,6 @@
"@fullcalendar/daygrid": "^5.9.0",
"@fullcalendar/interaction": "^5.9.0",
"@fullcalendar/react": "^5.9.0",
"@react-three/drei": "^9.11.3",
"@react-three/fiber": "^8.0.27",
"@reduxjs/toolkit": "^2.9.2",
"@splidejs/react-splide": "^0.7.12",
"@tanstack/react-virtual": "^3.13.12",
@@ -39,7 +37,6 @@
"fullcalendar": "^5.9.0",
"globalize": "^1.7.0",
"history": "^5.3.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.540.0",
"match-sorter": "6.3.0",
"moment": "^2.29.1",
@@ -59,7 +56,6 @@
"react-input-pin-code": "^1.1.5",
"react-just-parallax": "^3.1.16",
"react-jvectormap": "0.0.16",
"react-leaflet": "^3.2.5",
"react-markdown": "^10.1.0",
"react-quill": "^2.0.0-beta.4",
"react-redux": "^9.2.0",
@@ -82,7 +78,6 @@
"styled-components": "^5.3.11",
"stylis": "^4.0.10",
"stylis-plugin-rtl": "^2.1.1",
"three": "^0.142.0",
"tsparticles-slim": "^2.12.0"
},
"resolutions": {
@@ -96,7 +91,9 @@
"scripts": {
"prestart": "kill-port 3000",
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
"prestart:real": "kill-port 3000",
"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\"",
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",

View File

@@ -0,0 +1,376 @@
// src/components/ChatBot/ChatInterface.js
// 聊天界面主组件
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Menu,
MenuButton,
MenuList,
MenuItem,
Button,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiSettings, FiDownload } from 'react-icons/fi';
import { ChevronDownIcon } from '@chakra-ui/icons';
import MessageBubble from './MessageBubble';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* 聊天界面组件
*/
export const ChatInterface = () => {
const [messages, setMessages] = useState([
{
id: 1,
content: '你好我是AI投资助手我可以帮你查询股票信息、新闻资讯、概念板块、涨停分析等。请问有什么可以帮到你的',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [availableTools, setAvailableTools] = useState([]);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const inputBg = useColorModeValue('gray.50', 'gray.700');
// 加载可用工具列表
useEffect(() => {
const loadTools = async () => {
const result = await mcpService.listTools();
if (result.success) {
setAvailableTools(result.data);
logger.info('ChatInterface', '已加载MCP工具', { count: result.data.length });
}
};
loadTools();
}, []);
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 发送消息
const handleSendMessage = async () => {
if (!inputValue.trim() || isLoading) return;
const userMessage = {
id: Date.now(),
content: inputValue,
isUser: true,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
try {
// 调用MCP服务
const response = await mcpService.chat(inputValue, messages);
let botMessage;
if (response.success) {
// 根据返回的数据类型构造消息
const data = response.data;
if (typeof data === 'string') {
botMessage = {
id: Date.now() + 1,
content: data,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
} else if (Array.isArray(data)) {
// 数据列表
botMessage = {
id: Date.now() + 1,
content: `找到 ${data.length} 条结果:`,
isUser: false,
type: 'data',
data: data,
timestamp: new Date().toISOString(),
};
} else if (typeof data === 'object') {
// 对象数据
botMessage = {
id: Date.now() + 1,
content: JSON.stringify(data, null, 2),
isUser: false,
type: 'markdown',
timestamp: new Date().toISOString(),
};
} else {
botMessage = {
id: Date.now() + 1,
content: '抱歉,我无法理解这个查询结果。',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
} else {
botMessage = {
id: Date.now() + 1,
content: `抱歉,查询失败:${response.error}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
}
setMessages((prev) => [...prev, botMessage]);
} catch (error) {
logger.error('ChatInterface', 'handleSendMessage', error);
const errorMessage = {
id: Date.now() + 1,
content: `抱歉,发生了错误:${error.message}`,
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
content: '对话已清空。有什么可以帮到你的?',
isUser: false,
type: 'text',
timestamp: new Date().toISOString(),
},
]);
};
// 复制消息
const handleCopyMessage = () => {
toast({
title: '已复制',
status: 'success',
duration: 2000,
isClosable: true,
});
};
// 反馈
const handleFeedback = (type) => {
logger.info('ChatInterface', 'Feedback', { type });
toast({
title: type === 'positive' ? '感谢反馈!' : '我们会改进',
status: 'info',
duration: 2000,
isClosable: true,
});
};
// 快捷问题
const quickQuestions = [
'查询贵州茅台的股票信息',
'搜索人工智能相关新闻',
'今日涨停股票有哪些',
'新能源概念板块分析',
];
const handleQuickQuestion = (question) => {
setInputValue(question);
inputRef.current?.focus();
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.map((msg) => `[${msg.isUser ? '用户' : 'AI'}] ${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);
};
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部工具栏 */}
<Flex
px={4}
py={3}
borderBottom="1px"
borderColor={borderColor}
align="center"
justify="space-between"
>
<HStack>
<Text fontWeight="bold" fontSize="lg">AI投资助手</Text>
<Badge colorScheme="green">在线</Badge>
{availableTools.length > 0 && (
<Badge colorScheme="blue">{availableTools.length} 个工具</Badge>
)}
</HStack>
<HStack>
<IconButton
icon={<FiRefreshCw />}
size="sm"
variant="ghost"
aria-label="清空对话"
onClick={handleClearChat}
/>
<IconButton
icon={<FiDownload />}
size="sm"
variant="ghost"
aria-label="导出对话"
onClick={handleExportChat}
/>
<Menu>
<MenuButton
as={IconButton}
icon={<FiSettings />}
size="sm"
variant="ghost"
aria-label="设置"
/>
<MenuList>
<MenuItem>模型设置</MenuItem>
<MenuItem>快捷指令</MenuItem>
<MenuItem>历史记录</MenuItem>
</MenuList>
</Menu>
</HStack>
</Flex>
{/* 消息列表 */}
<Box
flex="1"
overflowY="auto"
px={4}
py={4}
css={{
'&::-webkit-scrollbar': {
width: '8px',
},
'&::-webkit-scrollbar-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={0} align="stretch">
{messages.map((message) => (
<MessageBubble
key={message.id}
message={message}
isUser={message.isUser}
onCopy={handleCopyMessage}
onFeedback={handleFeedback}
/>
))}
{isLoading && (
<Flex justify="flex-start" mb={4}>
<Flex align="center" bg={inputBg} px={4} py={3} borderRadius="lg">
<Spinner size="sm" mr={2} />
<Text fontSize="sm">AI正在思考...</Text>
</Flex>
</Flex>
)}
<div ref={messagesEndRef} />
</VStack>
</Box>
{/* 快捷问题(仅在消息较少时显示) */}
{messages.length <= 2 && (
<Box px={4} py={2}>
<Text fontSize="xs" color="gray.500" mb={2}>快捷问题</Text>
<Flex wrap="wrap" gap={2}>
{quickQuestions.map((question, idx) => (
<Button
key={idx}
size="xs"
variant="outline"
onClick={() => handleQuickQuestion(question)}
>
{question}
</Button>
))}
</Flex>
</Box>
)}
<Divider />
{/* 输入框 */}
<Box px={4} py={3} borderTop="1px" borderColor={borderColor}>
<Flex>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入消息... (Shift+Enter换行Enter发送)"
bg={inputBg}
border="none"
_focus={{ boxShadow: 'none' }}
mr={2}
disabled={isLoading}
/>
<IconButton
icon={<FiSend />}
colorScheme="blue"
aria-label="发送"
onClick={handleSendMessage}
isLoading={isLoading}
disabled={!inputValue.trim()}
/>
</Flex>
</Box>
</Flex>
);
};
export default ChatInterface;

View File

@@ -0,0 +1,576 @@
// src/components/ChatBot/ChatInterfaceV2.js
// 重新设计的聊天界面 - 更漂亮、支持Agent模式
import React, { useState, useRef, useEffect } from 'react';
import {
Box,
Flex,
Input,
IconButton,
VStack,
HStack,
Text,
Spinner,
useColorModeValue,
useToast,
Divider,
Badge,
Button,
Avatar,
Heading,
Progress,
Fade,
} from '@chakra-ui/react';
import { FiSend, FiRefreshCw, FiDownload, FiCpu, FiUser, FiZap } from 'react-icons/fi';
import { PlanCard } from './PlanCard';
import { StepResultCard } from './StepResultCard';
import { mcpService } from '../../services/mcpService';
import { logger } from '../../utils/logger';
/**
* Agent消息类型
*/
const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
/**
* 聊天界面V2组件 - Agent模式
*/
export const ChatInterfaceV2 = () => {
const [messages, setMessages] = useState([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '你好我是AI投资研究助手。我会通过多步骤分析来帮助你深入了解金融市场。\n\n你可以问我\n• 全面分析某只股票\n• 某个行业的投资机会\n• 今日市场热点\n• 某个概念板块的表现',
timestamp: new Date().toISOString(),
},
]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
const [currentProgress, setCurrentProgress] = useState(0);
const messagesEndRef = useRef(null);
const inputRef = useRef(null);
const toast = useToast();
// 颜色主题
const bgColor = useColorModeValue('gray.50', 'gray.900');
const chatBg = useColorModeValue('white', 'gray.800');
const inputBg = useColorModeValue('white', 'gray.700');
const userBubbleBg = useColorModeValue('blue.500', 'blue.600');
const agentBubbleBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
// 自动滚动到底部
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// 添加消息
const addMessage = (message) => {
setMessages((prev) => [...prev, { ...message, id: Date.now() }]);
};
// 更新最后一条消息
const updateLastMessage = (updates) => {
setMessages((prev) => {
const newMessages = [...prev];
if (newMessages.length > 0) {
newMessages[newMessages.length - 1] = {
...newMessages[newMessages.length - 1],
...updates,
};
}
return newMessages;
});
};
// 发送消息Agent模式
const handleSendMessage = async () => {
if (!inputValue.trim() || isProcessing) return;
const userMessage = {
type: MessageTypes.USER,
content: inputValue,
timestamp: new Date().toISOString(),
};
addMessage(userMessage);
setInputValue('');
setIsProcessing(true);
setCurrentProgress(0);
try {
// 1. 显示思考状态
addMessage({
type: MessageTypes.AGENT_THINKING,
content: '正在分析你的问题...',
timestamp: new Date().toISOString(),
});
setCurrentProgress(10);
// 调用 Agent API
const response = await fetch(`${mcpService.baseURL.replace('/mcp', '')}/mcp/agent/chat`, {
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,
})),
}),
});
if (!response.ok) {
throw new Error('Agent请求失败');
}
const agentResponse = await response.json();
logger.info('Agent response', agentResponse);
// 移除思考消息
setMessages(prev => prev.filter(m => m.type !== MessageTypes.AGENT_THINKING));
if (!agentResponse.success) {
throw new Error(agentResponse.message || '处理失败');
}
setCurrentProgress(30);
// 2. 显示执行计划
if (agentResponse.plan) {
addMessage({
type: MessageTypes.AGENT_PLAN,
content: '已制定执行计划',
plan: agentResponse.plan,
timestamp: new Date().toISOString(),
});
}
setCurrentProgress(40);
// 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(),
});
// 模拟进度更新
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));
}
}
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);
// 移除思考/执行中消息
setMessages(prev => prev.filter(
m => m.type !== MessageTypes.AGENT_THINKING && m.type !== MessageTypes.AGENT_EXECUTING
));
addMessage({
type: MessageTypes.ERROR,
content: `处理失败:${error.message}`,
timestamp: new Date().toISOString(),
});
toast({
title: '处理失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
} finally {
setIsProcessing(false);
setCurrentProgress(0);
inputRef.current?.focus();
}
};
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
};
// 清空对话
const handleClearChat = () => {
setMessages([
{
id: 1,
type: MessageTypes.AGENT_RESPONSE,
content: '对话已清空。有什么可以帮到你的?',
timestamp: new Date().toISOString(),
},
]);
};
// 导出对话
const handleExportChat = () => {
const chatText = messages
.filter(m => m.type === MessageTypes.USER || m.type === MessageTypes.AGENT_RESPONSE)
.map((msg) => `[${msg.type === MessageTypes.USER ? '用户' : 'AI助手'}] ${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);
};
// 快捷问题
const quickQuestions = [
'全面分析贵州茅台这只股票',
'今日涨停股票有哪些亮点',
'新能源概念板块的投资机会',
'半导体行业最新动态',
];
return (
<Flex direction="column" h="100%" bg={bgColor}>
{/* 头部 */}
<Box
bg={chatBg}
borderBottom="1px"
borderColor={borderColor}
px={6}
py={4}
>
<HStack justify="space-between">
<HStack spacing={4}>
<Avatar
size="md"
bg="blue.500"
icon={<FiCpu fontSize="1.5rem" />}
/>
<VStack align="start" spacing={0}>
<Heading size="md">AI投资研究助手</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-track': {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb': {
background: '#CBD5E0',
borderRadius: '4px',
},
}}
>
<VStack spacing={4} align="stretch">
{messages.map((message) => (
<Fade in key={message.id}>
<MessageRenderer message={message} />
</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>
);
};
/**
* 消息渲染器
*/
const MessageRenderer = ({ message }) => {
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" bg="blue.500" 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 ChatInterfaceV2;

View File

@@ -0,0 +1,149 @@
// src/components/ChatBot/MessageBubble.js
// 聊天消息气泡组件
import React from 'react';
import {
Box,
Flex,
Text,
Avatar,
useColorModeValue,
IconButton,
HStack,
Code,
Badge,
VStack,
} from '@chakra-ui/react';
import { FiCopy, FiThumbsUp, FiThumbsDown } from 'react-icons/fi';
import ReactMarkdown from 'react-markdown';
/**
* 消息气泡组件
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {boolean} props.isUser - 是否是用户消息
* @param {Function} props.onCopy - 复制消息回调
* @param {Function} props.onFeedback - 反馈回调
*/
export const MessageBubble = ({ message, isUser, onCopy, onFeedback }) => {
const userBg = useColorModeValue('blue.500', 'blue.600');
const botBg = useColorModeValue('gray.100', 'gray.700');
const userColor = 'white';
const botColor = useColorModeValue('gray.800', 'white');
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
onCopy?.();
};
return (
<Flex
w="100%"
justify={isUser ? 'flex-end' : 'flex-start'}
mb={4}
>
<Flex
maxW="75%"
flexDirection={isUser ? 'row-reverse' : 'row'}
align="flex-start"
>
{/* 头像 */}
<Avatar
size="sm"
name={isUser ? '用户' : 'AI助手'}
bg={isUser ? 'blue.500' : 'green.500'}
color="white"
mx={3}
/>
{/* 消息内容 */}
<Box>
<Box
bg={isUser ? userBg : botBg}
color={isUser ? userColor : botColor}
px={4}
py={3}
borderRadius="lg"
boxShadow="sm"
>
{message.type === 'text' ? (
<Text fontSize="sm" whiteSpace="pre-wrap">
{message.content}
</Text>
) : message.type === 'markdown' ? (
<Box fontSize="sm" className="markdown-content">
<ReactMarkdown>{message.content}</ReactMarkdown>
</Box>
) : message.type === 'data' ? (
<VStack align="stretch" spacing={2}>
{message.data && Array.isArray(message.data) && message.data.slice(0, 5).map((item, idx) => (
<Box
key={idx}
p={3}
bg={useColorModeValue('white', 'gray.600')}
borderRadius="md"
fontSize="xs"
>
{Object.entries(item).map(([key, value]) => (
<Flex key={key} justify="space-between" mb={1}>
<Text fontWeight="bold" mr={2}>{key}:</Text>
<Text>{String(value)}</Text>
</Flex>
))}
</Box>
))}
{message.data && message.data.length > 5 && (
<Badge colorScheme="blue" alignSelf="center">
+{message.data.length - 5} 更多结果
</Badge>
)}
</VStack>
) : null}
</Box>
{/* 消息操作按钮仅AI消息 */}
{!isUser && (
<HStack mt={2} spacing={2}>
<IconButton
icon={<FiCopy />}
size="xs"
variant="ghost"
aria-label="复制"
onClick={handleCopy}
/>
<IconButton
icon={<FiThumbsUp />}
size="xs"
variant="ghost"
aria-label="赞"
onClick={() => onFeedback?.('positive')}
/>
<IconButton
icon={<FiThumbsDown />}
size="xs"
variant="ghost"
aria-label="踩"
onClick={() => onFeedback?.('negative')}
/>
</HStack>
)}
{/* 时间戳 */}
<Text
fontSize="xs"
color="gray.500"
mt={1}
textAlign={isUser ? 'right' : 'left'}
>
{message.timestamp ? new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
}) : ''}
</Text>
</Box>
</Flex>
</Flex>
);
};
export default MessageBubble;

View File

@@ -0,0 +1,145 @@
// src/components/ChatBot/PlanCard.js
// 执行计划展示卡片
import React from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Icon,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiTarget, FiCheckCircle, FiXCircle, FiClock, FiTool } from 'react-icons/fi';
/**
* 执行计划卡片组件
*/
export const PlanCard = ({ plan, stepResults }) => {
const cardBg = useColorModeValue('blue.50', 'blue.900');
const borderColor = useColorModeValue('blue.200', 'blue.700');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const pendingColor = useColorModeValue('gray.400', 'gray.500');
const getStepStatus = (stepIndex) => {
if (!stepResults || stepResults.length === 0) return 'pending';
const result = stepResults.find(r => r.step_index === stepIndex);
return result ? result.status : 'pending';
};
const getStepIcon = (status) => {
switch (status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStepColor = (status) => {
switch (status) {
case 'success':
return successColor;
case 'failed':
return errorColor;
default:
return pendingColor;
}
};
return (
<Box
bg={cardBg}
borderRadius="lg"
borderWidth="2px"
borderColor={borderColor}
p={4}
mb={4}
boxShadow="md"
>
<VStack align="stretch" spacing={3}>
{/* 目标 */}
<HStack>
<Icon as={FiTarget} color="blue.500" boxSize={5} />
<Text fontWeight="bold" fontSize="md">执行目标</Text>
</HStack>
<Text fontSize="sm" color="gray.600" pl={7}>
{plan.goal}
</Text>
<Divider />
{/* 规划思路 */}
{plan.reasoning && (
<>
<Text fontSize="sm" fontWeight="bold">规划思路</Text>
<Text fontSize="sm" color="gray.600">
{plan.reasoning}
</Text>
<Divider />
</>
)}
{/* 执行步骤 */}
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">执行步骤</Text>
<Badge colorScheme="blue">{plan.steps.length} </Badge>
</HStack>
<VStack align="stretch" spacing={2}>
{plan.steps.map((step, index) => {
const status = getStepStatus(index);
const StepIcon = getStepIcon(status);
const stepColor = getStepColor(status);
return (
<HStack
key={index}
p={2}
bg={useColorModeValue('white', 'gray.700')}
borderRadius="md"
borderWidth="1px"
borderColor={stepColor}
align="flex-start"
>
<Icon as={StepIcon} color={stepColor} boxSize={4} mt={1} />
<VStack align="stretch" flex={1} spacing={1}>
<HStack justify="space-between">
<Text fontSize="sm" fontWeight="bold">
步骤 {index + 1}: {step.tool}
</Text>
<Badge
colorScheme={
status === 'success' ? 'green' :
status === 'failed' ? 'red' : 'gray'
}
fontSize="xs"
>
{status === 'success' ? '✓ 完成' :
status === 'failed' ? '✗ 失败' : '⏳ 等待'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.600">
{step.reason}
</Text>
</VStack>
</HStack>
);
})}
</VStack>
</VStack>
</Box>
);
};
export default PlanCard;

View File

@@ -0,0 +1,186 @@
// src/components/ChatBot/StepResultCard.js
// 步骤结果展示卡片(可折叠)
import React, { useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Badge,
Collapse,
Icon,
IconButton,
Code,
useColorModeValue,
Divider,
} from '@chakra-ui/react';
import { FiChevronDown, FiChevronUp, FiCheckCircle, FiXCircle, FiClock, FiDatabase } from 'react-icons/fi';
/**
* 步骤结果卡片组件
*/
export const StepResultCard = ({ stepResult }) => {
const [isExpanded, setIsExpanded] = useState(false);
const cardBg = useColorModeValue('white', 'gray.700');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const successColor = useColorModeValue('green.500', 'green.300');
const errorColor = useColorModeValue('red.500', 'red.300');
const getStatusIcon = () => {
switch (stepResult.status) {
case 'success':
return FiCheckCircle;
case 'failed':
return FiXCircle;
default:
return FiClock;
}
};
const getStatusColor = () => {
switch (stepResult.status) {
case 'success':
return 'green';
case 'failed':
return 'red';
default:
return 'gray';
}
};
const StatusIcon = getStatusIcon();
const statusColorScheme = getStatusColor();
// 格式化数据以便展示
const formatResult = (data) => {
if (typeof data === 'string') return data;
if (Array.isArray(data)) {
return `找到 ${data.length} 条记录`;
}
if (typeof data === 'object') {
return JSON.stringify(data, null, 2);
}
return String(data);
};
return (
<Box
bg={cardBg}
borderRadius="md"
borderWidth="1px"
borderColor={borderColor}
overflow="hidden"
boxShadow="sm"
>
{/* 头部 - 始终可见 */}
<HStack
p={3}
justify="space-between"
cursor="pointer"
onClick={() => setIsExpanded(!isExpanded)}
_hover={{ bg: useColorModeValue('gray.50', 'gray.600') }}
>
<HStack flex={1}>
<Icon as={StatusIcon} color={`${statusColorScheme}.500`} boxSize={5} />
<VStack align="stretch" spacing={0} flex={1}>
<HStack>
<Text fontSize="sm" fontWeight="bold">
步骤 {stepResult.step_index + 1}: {stepResult.tool}
</Text>
<Badge colorScheme={statusColorScheme} fontSize="xs">
{stepResult.status === 'success' ? '成功' :
stepResult.status === 'failed' ? '失败' : '执行中'}
</Badge>
</HStack>
<Text fontSize="xs" color="gray.500">
耗时: {stepResult.execution_time?.toFixed(2)}s
</Text>
</VStack>
</HStack>
<IconButton
icon={<Icon as={isExpanded ? FiChevronUp : FiChevronDown} />}
size="sm"
variant="ghost"
aria-label={isExpanded ? "收起" : "展开"}
/>
</HStack>
{/* 内容 - 可折叠 */}
<Collapse in={isExpanded} animateOpacity>
<Box p={3} pt={0}>
<Divider mb={3} />
{/* 参数 */}
{stepResult.arguments && Object.keys(stepResult.arguments).length > 0 && (
<VStack align="stretch" spacing={2} mb={3}>
<HStack>
<Icon as={FiDatabase} color="blue.500" boxSize={4} />
<Text fontSize="xs" fontWeight="bold">请求参数:</Text>
</HStack>
<Code
p={2}
borderRadius="md"
fontSize="xs"
whiteSpace="pre-wrap"
wordBreak="break-word"
>
{JSON.stringify(stepResult.arguments, null, 2)}
</Code>
</VStack>
)}
{/* 结果或错误 */}
{stepResult.status === 'success' && stepResult.result && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold">执行结果:</Text>
<Box
maxH="300px"
overflowY="auto"
p={2}
bg={useColorModeValue('gray.50', 'gray.800')}
borderRadius="md"
fontSize="xs"
>
{typeof stepResult.result === 'string' ? (
<Text whiteSpace="pre-wrap">{stepResult.result}</Text>
) : Array.isArray(stepResult.result) ? (
<VStack align="stretch" spacing={2}>
<Text fontWeight="bold">找到 {stepResult.result.length} 条记录:</Text>
{stepResult.result.slice(0, 3).map((item, idx) => (
<Code key={idx} p={2} borderRadius="md" fontSize="xs">
{JSON.stringify(item, null, 2)}
</Code>
))}
{stepResult.result.length > 3 && (
<Text fontSize="xs" color="gray.500">
...还有 {stepResult.result.length - 3} 条记录
</Text>
)}
</VStack>
) : (
<Code whiteSpace="pre-wrap" wordBreak="break-word">
{JSON.stringify(stepResult.result, null, 2)}
</Code>
)}
</Box>
</VStack>
)}
{stepResult.status === 'failed' && stepResult.error && (
<VStack align="stretch" spacing={2}>
<Text fontSize="xs" fontWeight="bold" color="red.500">错误信息:</Text>
<Text fontSize="xs" color="red.600" p={2} bg="red.50" borderRadius="md">
{stepResult.error}
</Text>
</VStack>
)}
</Box>
</Collapse>
</Box>
);
};
export default StepResultCard;

View File

@@ -0,0 +1,11 @@
// src/components/ChatBot/index.js
// 聊天机器人组件统一导出
export { ChatInterface } from './ChatInterface';
export { ChatInterfaceV2 } from './ChatInterfaceV2';
export { MessageBubble } from './MessageBubble';
export { PlanCard } from './PlanCard';
export { StepResultCard } from './StepResultCard';
// 默认导出新版本
export { ChatInterfaceV2 as default } from './ChatInterfaceV2';

View File

@@ -1,3 +0,0 @@
.leaflet-container {
height: 300px;
}

View File

@@ -1,28 +0,0 @@
import { MapContainer, TileLayer, } from 'react-leaflet';
import "./Map.css";
function MapPlaceholder() {
return (
<p>
Map of London.{' '}
<noscript>You need to enable JavaScript to see this map.</noscript>
</p>
)
}
function Map() {
return (
<MapContainer
center={[51.505, -0.09]}
zoom={13}
scrollWheelZoom={true}
placeholder={<MapPlaceholder />}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
</MapContainer>
)
}
export default Map;

View File

@@ -243,6 +243,26 @@ const MobileDrawer = memo(({
<Box>
<Text fontWeight="bold" mb={2}>AGENT社群</Text>
<VStack spacing={2} align="stretch">
<Link
onClick={() => handleNavigate('/agent-chat')}
py={1}
px={3}
borderRadius="md"
_hover={{ bg: 'gray.100' }}
cursor="pointer"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<HStack justify="space-between">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="xs" colorScheme="green">AI</Badge>
<Badge size="xs" colorScheme="red">NEW</Badge>
</HStack>
</HStack>
</Link>
<Link
py={1}
px={3}

View File

@@ -199,6 +199,12 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}
@@ -207,10 +213,31 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</MenuButton>
<MenuList
minW="300px"
p={4}
p={2}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('AI聊天助手', 'dropdown', '/agent-chat');
navigate('/agent-chat');
agentCommunityMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
borderLeft={location.pathname.includes('/agent-chat') ? '3px solid' : 'none'}
borderColor="blue.600"
fontWeight={location.pathname.includes('/agent-chat') ? 'bold' : 'normal'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem
isDisabled
cursor="not-allowed"

View File

@@ -139,6 +139,22 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
{/* AGENT社群组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">AGENT社群</Text>
<MenuItem
onClick={() => {
moreMenu.onClose(); // 先关闭菜单
navigate('/agent-chat');
}}
borderRadius="md"
bg={location.pathname.includes('/agent-chat') ? 'blue.50' : 'transparent'}
>
<Flex justify="space-between" align="center" w="100%">
<Text fontSize="sm">AI聊天助手</Text>
<HStack spacing={1}>
<Badge size="sm" colorScheme="green">AI</Badge>
<Badge size="sm" colorScheme="red">NEW</Badge>
</HStack>
</Flex>
</MenuItem>
<MenuItem isDisabled cursor="not-allowed" color="gray.400">
<Text fontSize="sm" color="gray.400">今日热议</Text>
</MenuItem>

View File

@@ -4,6 +4,7 @@
import React from 'react';
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
import { getChangeColor } from '../utils/colorUtils';
/**
* 股票涨跌幅指标组件3分天下布局
@@ -23,19 +24,14 @@ const StockChangeIndicators = ({
const isComfortable = size === 'comfortable';
const isDefault = size === 'default';
// 根据涨跌幅获取数字颜色(统一颜色,不分级
// 根据涨跌幅获取数字颜色(动态深浅
const getNumberColor = (value) => {
if (value == null) {
return useColorModeValue('gray.700', 'gray.400');
}
// 0值使用中性灰色
if (value === 0) {
return 'gray.700';
}
// 统一颜色:上涨红色,下跌绿色
return value > 0 ? 'red.500' : 'green.500';
// 使用动态颜色函数
return getChangeColor(value);
};
// 根据涨跌幅获取背景色(永远比文字色浅)

View File

@@ -23,6 +23,8 @@ export const IMPORTANCE_LEVELS = {
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: WarningIcon,
label: '极高',
stampText: '极', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.800',
description: '重大事件,市场影响深远',
antdColor: '#cf1322',
@@ -37,6 +39,8 @@ export const IMPORTANCE_LEVELS = {
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: WarningTwoIcon,
label: '高',
stampText: '高', // 印章文字
stampFont: "'STXingkai', 'FangSong', 'STFangsong', cursive", // 行楷/草书
dotBg: 'red.600',
description: '重要事件,影响较大',
antdColor: '#ff4d4f',
@@ -51,6 +55,8 @@ export const IMPORTANCE_LEVELS = {
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: InfoIcon,
label: '中',
stampText: '中', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.500',
description: '普通事件,有一定影响',
antdColor: '#ff7875',
@@ -61,10 +67,12 @@ export const IMPORTANCE_LEVELS = {
bgColor: 'red.50',
borderColor: 'red.100',
colorScheme: 'red',
badgeBg: '#6b7280', // 圆形徽章背景色 - 灰色
badgeBg: '#10b981', // 圆形徽章背景色 - 青绿色(替代灰色
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
icon: CheckCircleIcon,
label: '低',
stampText: '低', // 印章文字
stampFont: "'STKaiti', 'KaiTi', 'SimKai', serif", // 楷体
dotBg: 'red.400',
description: '参考事件,影响有限',
antdColor: '#ffa39e',

View File

@@ -2,8 +2,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
// 本地引入 Leaflet 样式,替代不稳定的 CDN 外链
import 'leaflet/dist/leaflet.css';
// 导入 Tailwind CSS 和 Brainwave 样式
import './styles/brainwave.css';
import './styles/brainwave-colors.css';

View File

@@ -35,6 +35,9 @@ export const lazyComponents = {
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
};
/**
@@ -59,4 +62,5 @@ export const {
ForecastReport,
FinancialPanorama,
MarketDataView,
AgentChat,
} = lazyComponents;

View File

@@ -149,6 +149,18 @@ export const routeConfig = [
description: '实时市场数据'
}
},
// ==================== Agent模块 ====================
{
path: 'agent-chat',
component: lazyComponents.AgentChat,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: 'AI投资助手',
description: '基于MCP的智能投资顾问'
}
},
];
/**

278
src/services/llmService.js Normal file
View File

@@ -0,0 +1,278 @@
// src/services/llmService.js
// LLM服务层 - 集成AI模型进行对话和工具调用
import axios from 'axios';
import { mcpService } from './mcpService';
import { logger } from '../utils/logger';
/**
* LLM服务配置
*/
const LLM_CONFIG = {
// 可以使用 OpenAI、Claude、通义千问等
provider: 'openai', // 或 'claude', 'qwen'
apiKey: process.env.REACT_APP_OPENAI_API_KEY || '',
apiUrl: 'https://api.openai.com/v1/chat/completions',
model: 'gpt-4o-mini', // 更便宜的模型
};
/**
* LLM服务类
*/
class LLMService {
constructor() {
this.conversationHistory = [];
}
/**
* 构建系统提示词
*/
getSystemPrompt(availableTools) {
return `你是一个专业的金融投资助手。你可以使用以下工具来帮助用户查询信息:
${availableTools.map(tool => `
**${tool.name}**
描述:${tool.description}
参数:${JSON.stringify(tool.parameters, null, 2)}
`).join('\n')}
用户提问时,请按照以下步骤:
1. 理解用户的意图
2. 选择合适的工具(可以多个)
3. 提取工具需要的参数
4. 调用工具后,用自然语言总结结果
回复格式:
- 如果需要调用工具返回JSON格式{"tool": "工具名", "arguments": {...}}
- 如果不需要工具,直接回复自然语言
注意:
- 贵州茅台的股票代码是 600519
- 涨停是指股票当日涨幅达到10%
- 概念板块是指相同题材的股票分类`;
}
/**
* 智能对话 - 使用LLM理解意图并调用工具
*/
async chat(userMessage, conversationHistory = []) {
try {
// 1. 获取可用工具列表
const toolsResult = await mcpService.listTools();
if (!toolsResult.success) {
throw new Error('获取工具列表失败');
}
const availableTools = toolsResult.data;
// 2. 构建对话历史
const messages = [
{
role: 'system',
content: this.getSystemPrompt(availableTools),
},
...conversationHistory.map(msg => ({
role: msg.isUser ? 'user' : 'assistant',
content: msg.content,
})),
{
role: 'user',
content: userMessage,
},
];
// 3. 调用LLM
logger.info('LLMService', '调用LLM', { messageCount: messages.length });
// 注意这里需要配置API密钥
if (!LLM_CONFIG.apiKey) {
// 如果没有配置LLM使用简单的关键词匹配
logger.warn('LLMService', '未配置LLM API密钥使用简单匹配');
return await this.fallbackChat(userMessage);
}
const response = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: messages,
temperature: 0.7,
max_tokens: 1000,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const aiResponse = response.data.choices[0].message.content;
logger.info('LLMService', 'LLM响应', { response: aiResponse });
// 4. 解析LLM响应
// 如果LLM返回工具调用指令
try {
const toolCall = JSON.parse(aiResponse);
if (toolCall.tool && toolCall.arguments) {
// 调用MCP工具
const toolResult = await mcpService.callTool(toolCall.tool, toolCall.arguments);
if (!toolResult.success) {
return {
success: false,
error: toolResult.error,
};
}
// 5. 让LLM总结工具结果
const summaryMessages = [
...messages,
{
role: 'assistant',
content: aiResponse,
},
{
role: 'system',
content: `工具 ${toolCall.tool} 返回的数据:\n${JSON.stringify(toolResult.data, null, 2)}\n\n请用自然语言总结这些数据,给用户一个简洁清晰的回复。`,
},
];
const summaryResponse = await axios.post(
LLM_CONFIG.apiUrl,
{
model: LLM_CONFIG.model,
messages: summaryMessages,
temperature: 0.7,
max_tokens: 500,
},
{
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${LLM_CONFIG.apiKey}`,
},
timeout: 30000,
}
);
const summary = summaryResponse.data.choices[0].message.content;
return {
success: true,
data: {
message: summary,
rawData: toolResult.data,
toolUsed: toolCall.tool,
},
};
}
} catch (parseError) {
// 不是JSON格式说明是直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
}
// 默认返回LLM的直接回复
return {
success: true,
data: {
message: aiResponse,
},
};
} catch (error) {
logger.error('LLMService', 'chat error', error);
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 降级方案简单的关键词匹配当没有配置LLM时
*/
async fallbackChat(userMessage) {
logger.info('LLMService', '使用降级方案', { message: userMessage });
// 使用原有的简单匹配逻辑
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
const result = await mcpService.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
const result = await mcpService.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
return this.formatFallbackResponse(result, '概念搜索');
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
const result = await mcpService.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
return this.formatFallbackResponse(result, '涨停分析');
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
return this.formatFallbackResponse(result, '股票信息');
} else if (userMessage.includes('茅台') || userMessage.includes('贵州茅台')) {
// 特殊处理茅台
const result = await mcpService.callTool('get_stock_basic_info', {
seccode: '600519',
});
return this.formatFallbackResponse(result, '贵州茅台股票信息');
} else {
// 默认:搜索新闻
const result = await mcpService.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
return this.formatFallbackResponse(result, '新闻搜索');
}
}
/**
* 格式化降级响应
*/
formatFallbackResponse(result, action) {
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
data: {
message: `已为您完成${action},找到以下结果:`,
rawData: result.data,
},
};
}
/**
* 清除对话历史
*/
clearHistory() {
this.conversationHistory = [];
}
}
// 导出单例
export const llmService = new LLMService();
export default LLMService;

248
src/services/mcpService.js Normal file
View File

@@ -0,0 +1,248 @@
// src/services/mcpService.js
// MCP (Model Context Protocol) 服务层
// 用于与FastAPI后端的MCP工具进行交互
import axios from 'axios';
import { getApiBase } from '../utils/apiConfig';
import { logger } from '../utils/logger';
/**
* MCP API客户端
*/
class MCPService {
constructor() {
this.baseURL = `${getApiBase()}/mcp`;
this.client = axios.create({
baseURL: this.baseURL,
timeout: 60000, // 60秒超时MCP工具可能需要较长时间
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
this.client.interceptors.request.use(
(config) => {
logger.debug('MCPService', 'Request', {
url: config.url,
method: config.method,
data: config.data,
});
return config;
},
(error) => {
logger.error('MCPService', 'Request Error', error);
return Promise.reject(error);
}
);
// 响应拦截器
this.client.interceptors.response.use(
(response) => {
logger.debug('MCPService', 'Response', {
url: response.config.url,
status: response.status,
data: response.data,
});
return response.data;
},
(error) => {
logger.error('MCPService', 'Response Error', {
url: error.config?.url,
status: error.response?.status,
message: error.message,
});
return Promise.reject(error);
}
);
}
/**
* 列出所有可用的MCP工具
* @returns {Promise<Object>} 工具列表
*/
async listTools() {
try {
const response = await this.client.get('/tools');
return {
success: true,
data: response.tools || [],
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具列表失败',
};
}
}
/**
* 获取特定工具的定义
* @param {string} toolName - 工具名称
* @returns {Promise<Object>} 工具定义
*/
async getTool(toolName) {
try {
const response = await this.client.get(`/tools/${toolName}`);
return {
success: true,
data: response,
};
} catch (error) {
return {
success: false,
error: error.message || '获取工具定义失败',
};
}
}
/**
* 调用MCP工具
* @param {string} toolName - 工具名称
* @param {Object} arguments - 工具参数
* @returns {Promise<Object>} 工具执行结果
*/
async callTool(toolName, toolArguments) {
try {
const response = await this.client.post('/tools/call', {
tool: toolName,
arguments: toolArguments,
});
return {
success: true,
data: response.data || response,
};
} catch (error) {
return {
success: false,
error: error.response?.data?.detail || error.message || '工具调用失败',
};
}
}
/**
* 智能对话 - 根据用户输入自动选择合适的工具
* @param {string} userMessage - 用户消息
* @param {Array} conversationHistory - 对话历史(可选)
* @returns {Promise<Object>} AI响应
*/
async chat(userMessage, conversationHistory = []) {
try {
// 这里可以实现智能路由逻辑
// 根据用户输入判断应该调用哪个工具
// 示例:关键词匹配
if (userMessage.includes('新闻') || userMessage.includes('资讯')) {
return await this.callTool('search_china_news', {
query: userMessage.replace(/新闻|资讯/g, '').trim(),
top_k: 5,
});
} else if (userMessage.includes('概念') || userMessage.includes('板块')) {
const query = userMessage.replace(/概念|板块/g, '').trim();
return await this.callTool('search_concepts', {
query,
size: 5,
sort_by: 'change_pct',
});
} else if (userMessage.includes('涨停')) {
const query = userMessage.replace(/涨停/g, '').trim();
return await this.callTool('search_limit_up_stocks', {
query,
mode: 'hybrid',
page_size: 5,
});
} else if (/^[0-9]{6}$/.test(userMessage.trim())) {
// 6位数字 = 股票代码
return await this.callTool('get_stock_basic_info', {
seccode: userMessage.trim(),
});
} else {
// 默认:搜索新闻
return await this.callTool('search_china_news', {
query: userMessage,
top_k: 5,
});
}
} catch (error) {
return {
success: false,
error: error.message || '对话处理失败',
};
}
}
/**
* 工具类别枚举
*/
static TOOL_CATEGORIES = {
NEWS: 'news', // 新闻搜索
STOCK: 'stock', // 股票信息
CONCEPT: 'concept', // 概念板块
LIMIT_UP: 'limit_up', // 涨停分析
RESEARCH: 'research', // 研报搜索
ROADSHOW: 'roadshow', // 路演信息
FINANCIAL: 'financial', // 财务数据
TRADE: 'trade', // 交易数据
};
/**
* 常用工具快捷方式
*/
async searchNews(query, topK = 5, exactMatch = false) {
return await this.callTool('search_china_news', {
query,
top_k: topK,
exact_match: exactMatch,
});
}
async searchConcepts(query, size = 10, sortBy = 'change_pct') {
return await this.callTool('search_concepts', {
query,
size,
sort_by: sortBy,
});
}
async searchLimitUpStocks(query, mode = 'hybrid', pageSize = 10) {
return await this.callTool('search_limit_up_stocks', {
query,
mode,
page_size: pageSize,
});
}
async getStockInfo(seccode) {
return await this.callTool('get_stock_basic_info', {
seccode,
});
}
async getStockConcepts(stockCode, size = 10) {
return await this.callTool('get_stock_concepts', {
stock_code: stockCode,
size,
});
}
async searchResearchReports(query, mode = 'hybrid', size = 5) {
return await this.callTool('search_research_reports', {
query,
mode,
size,
});
}
async getConceptStatistics(days = 7, minStockCount = 3) {
return await this.callTool('get_concept_statistics', {
days,
min_stock_count: minStockCount,
});
}
}
// 导出单例实例
export const mcpService = new MCPService();
// 导出类(供测试使用)
export default MCPService;

View File

@@ -310,6 +310,7 @@ class MockSocketService {
this.reconnectAttempts = 0;
this.customReconnectTimer = null;
this.failConnection = false; // 是否模拟连接失败
this.pushPaused = false; // 新增:暂停推送标志(保持连接)
}
/**
@@ -414,6 +415,7 @@ class MockSocketService {
// 清除所有定时器
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
const wasConnected = this.connected;
this.connected = false;
@@ -613,6 +615,12 @@ class MockSocketService {
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
const pushInterval = setInterval(() => {
// 检查是否暂停推送
if (this.pushPaused) {
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
return;
}
// 随机选择 1-burstCount 条消息
const count = Math.floor(Math.random() * burstCount) + 1;
@@ -642,22 +650,56 @@ class MockSocketService {
stopMockPush() {
this.intervals.forEach(interval => clearInterval(interval));
this.intervals = [];
this.pushPaused = false; // 重置暂停状态
logger.info('mockSocketService', 'Mock push stopped');
}
/**
* 暂停自动推送(保持连接和定时器运行)
*/
pausePush() {
this.pushPaused = true;
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
}
/**
* 恢复自动推送
*/
resumePush() {
this.pushPaused = false;
logger.info('mockSocketService', '▶️ Mock push resumed');
}
/**
* 查询推送暂停状态
* @returns {boolean} 是否已暂停
*/
isPushPaused() {
return this.pushPaused;
}
/**
* 手动触发一条测试消息
* @param {object} customData - 自定义消息数据(可选)
*/
sendTestNotification(customData = null) {
const notification = customData || {
type: 'trade_alert',
severity: 'info',
title: '测试消息',
message: '这是一条手动触发的测试消息',
timestamp: Date.now(),
autoClose: 5000,
// 如果传入自定义数据,直接使用(向后兼容)
if (customData) {
this.emit('new_event', customData);
logger.info('mockSocketService', 'Custom test notification sent', customData);
return;
}
// 默认发送新格式的测试通知(符合当前通知系统规范)
const notification = {
type: 'announcement', // 公告通知类型
priority: 'important', // 重要优先级30秒自动关闭
title: '🧪 测试通知',
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
id: `test_${Date.now()}`,
clickable: false,
};
this.emit('new_event', notification);
@@ -836,6 +878,27 @@ if (process.env.NODE_ENV === 'development') {
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
return attempts;
},
// 暂停自动推送(保持连接)
pausePush: () => {
mockSocketService.pausePush();
logger.info('mockSocketService', '⏸️ Auto push paused');
return true;
},
// 恢复自动推送
resumePush: () => {
mockSocketService.resumePush();
logger.info('mockSocketService', '▶️ Auto push resumed');
return true;
},
// 查看推送暂停状态
isPushPaused: () => {
const paused = mockSocketService.isPushPaused();
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
return paused;
},
};
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
@@ -845,6 +908,9 @@ if (process.env.NODE_ENV === 'development') {
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
}
export default mockSocketService;

View File

@@ -78,10 +78,93 @@ if (typeof window !== 'undefined') {
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent,
url: window.location.href,
env: {
NODE_ENV: process.env.NODE_ENV,
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
REACT_APP_ENV: process.env.REACT_APP_ENV,
},
};
console.log('[Socket Diagnostics]', diagnostics);
return diagnostics;
}
},
// 手动订阅事件
subscribe: (options = {}) => {
const { eventType = 'all', importance = 'all' } = options;
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType,
importance,
onNewEvent: (event) => {
console.log('[Socket Debug] ✅ New event received:', event);
},
onSubscribed: (data) => {
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
},
});
} else {
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
}
},
// 测试连接质量
testConnection: () => {
console.log('[Socket Debug] Testing connection...');
const start = Date.now();
if (socket.emit) {
socket.emit('ping', { timestamp: start }, (response) => {
const latency = Date.now() - start;
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
});
} else {
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
}
},
// 检查配置是否正确
checkConfig: () => {
const config = {
socketType: SOCKET_TYPE,
useMock,
envVars: {
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
NODE_ENV: process.env.NODE_ENV,
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
},
socketMethods: {
connect: typeof socket.connect,
disconnect: typeof socket.disconnect,
on: typeof socket.on,
emit: typeof socket.emit,
subscribeToEvents: typeof socket.subscribeToEvents,
},
};
console.log('[Socket Debug] Configuration Check:', config);
// 检查潜在问题
const issues = [];
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
issues.push('⚠️ WARNING: Using MOCK socket in production!');
}
if (!socket.subscribeToEvents) {
issues.push('❌ ERROR: subscribeToEvents method missing');
}
if (issues.length > 0) {
console.warn('[Socket Debug] Issues found:', issues);
} else {
console.log('[Socket Debug] ✅ No issues found');
}
return { config, issues };
},
};
console.log(
@@ -92,6 +175,190 @@ if (typeof window !== 'undefined') {
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
'color: #2196F3;'
);
console.log(
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
'color: #2196F3;'
);
// ========== 通知系统专用调试 API ==========
window.__NOTIFY_DEBUG__ = {
// 完整检查(配置+连接+订阅状态)
checkAll: () => {
console.log('\n==========【通知系统诊断】==========');
// 1. 检查 Socket 配置
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
console.log('\n✓ Socket 配置检查完成');
// 2. 检查连接状态
const status = window.__SOCKET_DEBUG__.getStatus();
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
// 3. 检查环境变量
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
// 4. 检查浏览器通知权限
const browserPermission = Notification?.permission || 'unsupported';
console.log('\n✓ 浏览器通知权限:', browserPermission);
// 5. 汇总报告
const report = {
timestamp: new Date().toISOString(),
socket: {
type: SOCKET_TYPE,
connected: status.connected,
reconnectAttempts: status.reconnectAttempts,
},
env: socketCheck.config.envVars,
browserNotification: browserPermission,
issues: socketCheck.issues,
};
console.log('\n========== 诊断报告 ==========');
console.table(report);
if (report.issues.length > 0) {
console.warn('\n⚠ 发现问题:', report.issues);
} else {
console.log('\n✅ 系统正常,未发现问题');
}
// 提供修复建议
if (!status.connected) {
console.log('\n💡 修复建议:');
console.log(' 1. 检查网络连接');
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
console.log(' 3. 检查后端服务是否运行');
}
if (browserPermission === 'denied') {
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
}
console.log('\n====================================\n');
return report;
},
// 手动订阅事件(简化版)
subscribe: (eventType = 'all', importance = 'all') => {
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
},
// 模拟接收通知用于测试UI
testNotify: (type = 'announcement') => {
console.log('\n[通知调试] 模拟通知:', type);
const mockNotifications = {
announcement: {
id: `test_${Date.now()}`,
type: 'announcement',
priority: 'important',
title: '🧪 测试公告通知',
content: '这是一条测试消息,用于验证通知系统是否正常工作',
publishTime: Date.now(),
pushTime: Date.now(),
},
stock_alert: {
id: `test_${Date.now()}`,
type: 'stock_alert',
priority: 'urgent',
title: '🧪 测试股票预警',
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
publishTime: Date.now(),
pushTime: Date.now(),
},
event_alert: {
id: `test_${Date.now()}`,
type: 'event_alert',
priority: 'important',
title: '🧪 测试事件动向',
content: 'AI大模型新政策发布影响科技板块',
publishTime: Date.now(),
pushTime: Date.now(),
},
analysis_report: {
id: `test_${Date.now()}`,
type: 'analysis_report',
priority: 'normal',
title: '🧪 测试分析报告',
content: '2024年Q1市场策略报告已发布',
publishTime: Date.now(),
pushTime: Date.now(),
},
};
const notification = mockNotifications[type] || mockNotifications.announcement;
// 触发 new_event 事件
if (socket.emit) {
// 对于真实 Socket模拟服务端推送实际上客户端无法这样做仅用于Mock模式
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
}
// 直接触发事件监听器(如果是 Mock 模式)
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
socket.emit('new_event', notification);
console.log('✅ 已触发 Mock 通知事件');
}
console.log('通知数据:', notification);
return notification;
},
// 导出完整诊断报告
exportReport: () => {
const report = window.__NOTIFY_DEBUG__.checkAll();
// 生成可下载的 JSON
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `notification-debug-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
console.log('✅ 诊断报告已导出');
return report;
},
// 快捷帮助
help: () => {
console.log('\n========== 通知系统调试 API ==========');
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
console.log('\n========== Socket 调试 API ==========');
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
console.log('====================================\n');
},
};
console.log(
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
'color: #FF9800; font-weight: bold;'
);
console.log(
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
'color: #FF9800;'
);
console.log(
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
'color: #FF9800;'
);
}
export default socket;

98
src/utils/colorUtils.js Normal file
View File

@@ -0,0 +1,98 @@
// src/utils/colorUtils.js
// 颜色工具函数 - 根据涨跌幅动态计算颜色深浅
/**
* 根据涨跌幅获取颜色(深浅动态变化)
* @param {number} change - 涨跌幅百分比
* @returns {string} Chakra UI 颜色值
*/
export const getChangeColor = (change) => {
if (change === null || change === undefined || isNaN(change)) {
return 'gray.500';
}
const absChange = Math.abs(change);
if (change > 0) {
// 涨:红色系,根据涨幅深浅
if (absChange >= 9) return 'red.900'; // 涨停或接近涨停
if (absChange >= 7) return 'red.800';
if (absChange >= 5) return 'red.700';
if (absChange >= 3) return 'red.600';
if (absChange >= 2) return 'red.500';
if (absChange >= 1) return 'red.400';
return 'red.300'; // 微涨
} else if (change < 0) {
// 跌:绿色系,根据跌幅深浅
if (absChange >= 9) return 'green.900'; // 跌停或接近跌停
if (absChange >= 7) return 'green.800';
if (absChange >= 5) return 'green.700';
if (absChange >= 3) return 'green.600';
if (absChange >= 2) return 'green.500';
if (absChange >= 1) return 'green.400';
return 'green.300'; // 微跌
}
return 'gray.500'; // 平盘
};
/**
* 获取涨跌幅背景渐变色(用于精简模式卡片)
* @param {number} change - 涨跌幅百分比
* @param {boolean} useDark - 是否使用深色模式
* @returns {string} Chakra UI bgGradient 值
*/
export const getChangeBackgroundGradient = (change, useDark = false) => {
if (change === null || change === undefined || isNaN(change)) {
return 'linear(to-br, gray.50, gray.100)';
}
const absChange = Math.abs(change);
if (change > 0) {
// 涨:红色渐变背景
if (absChange >= 9) return 'linear(to-br, red.100, red.200)';
if (absChange >= 7) return 'linear(to-br, red.50, red.150)';
if (absChange >= 5) return 'linear(to-br, red.50, red.100)';
if (absChange >= 3) return 'linear(to-br, red.50, red.100)';
return 'linear(to-br, red.50, red.50)';
} else if (change < 0) {
// 跌:绿色渐变背景
if (absChange >= 9) return 'linear(to-br, green.100, green.200)';
if (absChange >= 7) return 'linear(to-br, green.50, green.150)';
if (absChange >= 5) return 'linear(to-br, green.50, green.100)';
if (absChange >= 3) return 'linear(to-br, green.50, green.100)';
return 'linear(to-br, green.50, green.50)';
}
return 'linear(to-br, gray.50, gray.100)';
};
/**
* 获取涨跌幅边框颜色
* @param {number} change - 涨跌幅百分比
* @returns {string} Chakra UI 颜色值
*/
export const getChangeBorderColor = (change) => {
if (change === null || change === undefined || isNaN(change)) {
return 'gray.300';
}
const absChange = Math.abs(change);
if (change > 0) {
if (absChange >= 9) return 'red.700';
if (absChange >= 7) return 'red.600';
if (absChange >= 5) return 'red.500';
if (absChange >= 3) return 'red.400';
return 'red.300';
} else if (change < 0) {
if (absChange >= 9) return 'green.700';
if (absChange >= 7) return 'green.600';
if (absChange >= 5) return 'green.500';
if (absChange >= 3) return 'green.400';
return 'green.300';
}
return 'gray.300';
};

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

@@ -54,6 +54,7 @@ let dynamicNewsCardRenderCount = 0;
* @param {Function} onSearchFocus - 搜索框获得焦点回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} trackingFunctions - PostHog 追踪函数集合
* @param {Object} ref - 用于滚动的ref
*/
const DynamicNewsCard = forwardRef(({
@@ -64,6 +65,7 @@ const DynamicNewsCard = forwardRef(({
onSearchFocus,
onEventClick,
onViewDetail,
trackingFunctions = {},
...rest
}, ref) => {
const dispatch = useDispatch();
@@ -205,9 +207,22 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 四排模式的事件点击处理(打开弹窗)
const handleFourRowEventClick = useCallback((event) => {
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
// 🎯 追踪事件详情打开
if (trackingFunctions.trackNewsDetailOpened) {
trackingFunctions.trackNewsDetailOpened({
eventId: event.id,
eventTitle: event.title,
importance: event.importance,
source: 'four_row_mode',
displayMode: 'modal',
timestamp: new Date().toISOString(),
});
}
setModalEvent(event);
onModalOpen();
}, [onModalOpen]);
}, [onModalOpen, trackingFunctions]);
// 初始加载 - 只在组件首次挂载且对应模式数据为空时执行
useEffect(() => {
@@ -302,6 +317,18 @@ const [currentMode, setCurrentMode] = useState('vertical');
console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;');
hasAutoSelectedFirstEvent.current = true;
setSelectedEvent(currentPageEvents[0]);
// 🎯 追踪事件点击(首次自动选中)
if (trackingFunctions.trackNewsArticleClicked) {
trackingFunctions.trackNewsArticleClicked({
eventId: currentPageEvents[0].id,
eventTitle: currentPageEvents[0].title,
importance: currentPageEvents[0].importance,
source: 'auto_select_first',
displayMode: mode,
timestamp: new Date().toISOString(),
});
}
return;
}
@@ -310,7 +337,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
e => e.id === selectedEvent?.id
);
}
}, [currentPageEvents, selectedEvent?.id, mode]);
}, [currentPageEvents, selectedEvent?.id, mode, trackingFunctions]);
// 组件卸载时清理选中状态
useEffect(() => {
@@ -364,7 +391,9 @@ const [currentMode, setCurrentMode] = useState('vertical');
if (!cardHeaderElement || !cardBodyElement) return;
let ticking = false;
const TRIGGER_OFFSET = 100; // 提前 100px 触发
const TRIGGER_OFFSET = 150; // 提前 150px 触发(进入固定模式)
const EXIT_OFFSET = 200; // 提前 200px 退出(退出比进入更容易)
const EXIT_THRESHOLD = 30; // 接近顶部 30px 内即可退出
// 外部滚动监听:触发固定模式
const handleExternalScroll = () => {
@@ -375,7 +404,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
const rect = cardHeaderElement.getBoundingClientRect();
const elementTop = rect.top;
// 计算触发点:总导航高度 + 100px 偏移量
// 计算触发点:总导航高度 + 150px 偏移量
const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET;
// 向上滑动:元素顶部到达触发点 → 激活固定模式
@@ -402,25 +431,31 @@ const [currentMode, setCurrentMode] = useState('vertical');
// 检测向上滚动deltaY < 0
if (e.deltaY < 0) {
// 查找所有滚动容器
const scrollContainers = cardBodyElement.querySelectorAll('[data-scroll-container]');
window.requestAnimationFrame(() => {
// 🎯 检查 1CardHeader 位置(主要条件)
const rect = cardHeaderElement.getBoundingClientRect();
const elementTop = rect.top;
const exitPoint = TOTAL_NAV_HEIGHT + EXIT_OFFSET;
if (scrollContainers.length === 0) {
// 如果没有找到标记的容器,查找所有可滚动元素
const allScrollable = cardBodyElement.querySelectorAll('[style*="overflow"]');
scrollContainers = allScrollable;
}
// 🎯 检查 2左侧事件列表滚动位置辅助条件
const eventListContainers = cardBodyElement.querySelectorAll('[data-event-list-container]');
const allNearTop = eventListContainers.length === 0 ||
Array.from(eventListContainers).every(
container => container.scrollTop <= EXIT_THRESHOLD
);
// 检查是否所有滚动容器都在顶部
const allAtTop = scrollContainers.length === 0 ||
Array.from(scrollContainers).every(
container => container.scrollTop === 0
);
if (allAtTop) {
setIsFixedMode(false);
console.log('🔓 恢复正常文档流模式(内部滚动到顶部)');
}
// 🎯 退出条件CardHeader 超过退出点 OR 左侧列表接近顶部
if (elementTop > exitPoint || allNearTop) {
setIsFixedMode(false);
console.log('🔓 恢复正常文档流模式', {
elementTop,
exitPoint,
listNearTop: allNearTop,
exitThreshold: EXIT_THRESHOLD,
reason: elementTop > exitPoint ? 'CardHeader位置' : '左侧列表滚动'
});
}
});
}
};
@@ -490,6 +525,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
filters={filters}
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
/>
</Box>
</CardHeader>

View File

@@ -13,21 +13,31 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail';
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
* @param {string} detailMode - 详情模式:'full' | 'no-header'(默认 'full'
* @param {boolean} showHeader - 是否显示头部(可选,优先级高于 detailMode
*/
const EventDetailScrollPanel = ({
selectedEvent,
scrollbarTrackBg,
scrollbarThumbBg,
scrollbarThumbHoverBg,
detailMode = 'full',
showHeader,
}) => {
// 计算是否显示头部showHeader 显式指定时优先,否则根据 detailMode 判断
const shouldShowHeader = showHeader !== undefined
? showHeader
: detailMode === 'full';
return (
<Box
pl={2}
position="relative"
data-detail-panel-container="true"
sx={{
height: '100%',
overflowY: 'auto',
overflowX: 'hidden',
overscrollBehavior: 'contain',
'&::-webkit-scrollbar': {
width: '3px',
},
@@ -45,7 +55,7 @@ const EventDetailScrollPanel = ({
}}
>
{selectedEvent ? (
<DynamicNewsDetailPanel event={selectedEvent} />
<DynamicNewsDetailPanel event={selectedEvent} showHeader={shouldShowHeader} />
) : (
<Center h="100%" minH="400px">
<VStack spacing={4}>

View File

@@ -75,7 +75,7 @@ const VerticalModeLayout = ({
minWidth={0}
overflowY="auto"
h="100%"
data-scroll-container="true"
data-event-list-container="true"
css={{
overscrollBehavior: 'contain',
'&::-webkit-scrollbar': {
@@ -112,6 +112,7 @@ const VerticalModeLayout = ({
timelineStyle={getTimelineBoxStyle()}
borderColor={borderColor}
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
layout="vertical"
/>
))}
</VStack>
@@ -161,6 +162,7 @@ const VerticalModeLayout = ({
{/* 详情面板 */}
<EventDetailScrollPanel
key={detailPanelKey}
detailMode="no-header"
selectedEvent={selectedEvent}
/>
</Box>

View File

@@ -9,6 +9,7 @@ import {
Heading,
Badge,
IconButton,
Button,
useColorModeValue,
} from '@chakra-ui/react';
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
@@ -21,22 +22,53 @@ import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
* @param {Function} props.onToggle - 切换展开/收起的回调
* @param {number} props.count - 可选的数量徽章
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.currentMode - 当前模式:'detailed' | 'simple'
* @param {Function} props.onModeToggle - 模式切换回调
* @param {boolean} props.isLocked - 是否锁定(不可展开)
*/
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscriptionBadge = null }) => {
const CollapsibleHeader = ({
title,
isOpen,
onToggle,
count = null,
subscriptionBadge = null,
showModeToggle = false,
currentMode = 'detailed',
onModeToggle = null,
isLocked = false
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
const hoverBg = useColorModeValue('gray.100', 'gray.700');
const headingColor = useColorModeValue('gray.700', 'gray.200');
// 获取按钮文案
const getButtonText = () => {
if (currentMode === 'simple') {
return '查看详情'; // 简单模式时,按钮显示"查看详情"
}
return '精简模式'; // 详细模式时,按钮显示"精简模式"
};
// 获取按钮图标
const getButtonIcon = () => {
if (currentMode === 'simple') {
return null; // 简单模式不显示图标
}
// 详细模式:展开显示向上箭头,收起显示向下箭头
return isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />;
};
return (
<Flex
justify="space-between"
align="center"
cursor="pointer"
onClick={onToggle}
cursor={showModeToggle ? 'default' : 'pointer'}
onClick={showModeToggle ? undefined : onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={{ bg: hoverBg }}
_hover={showModeToggle ? {} : { bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
@@ -54,12 +86,32 @@ const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscription
</Badge>
)}
</HStack>
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
{showModeToggle && onModeToggle && (
<Button
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={getButtonIcon()}
onClick={(e) => {
e.stopPropagation();
onModeToggle(e);
}}
>
{getButtonText()}
</Button>
)}
{/* showModeToggle=false 时显示原有的 IconButton */}
{!showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
/>
)}
</Flex>
);
};

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// 通用可折叠区块组件
import React from 'react';
import React, { useState } from 'react';
import {
Box,
Collapse,
@@ -19,7 +19,10 @@ import CollapsibleHeader from './CollapsibleHeader';
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
* @param {boolean} props.isLocked - 是否锁定(不可展开)
* @param {Function} props.onLockedClick - 锁定时点击的回调
* @param {React.ReactNode} props.children - 内容
* @param {React.ReactNode} props.children - 详细内容
* @param {React.ReactNode} props.simpleContent - 精简模式的内容(可选)
* @param {boolean} props.showModeToggle - 是否显示模式切换按钮(默认 false
* @param {string} props.defaultMode - 默认模式:'detailed' | 'simple'(默认 'detailed'
*/
const CollapsibleSection = ({
title,
@@ -29,10 +32,16 @@ const CollapsibleSection = ({
subscriptionBadge = null,
isLocked = false,
onLockedClick = null,
children
children,
simpleContent = null,
showModeToggle = false,
defaultMode = 'detailed'
}) => {
const sectionBg = useColorModeValue('gray.50', 'gray.750');
// 模式状态:'detailed' | 'simple'
const [displayMode, setDisplayMode] = useState(defaultMode);
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
const handleToggle = () => {
if (isLocked && onLockedClick) {
@@ -42,15 +51,43 @@ const CollapsibleSection = ({
}
};
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={handleToggle}
count={count}
subscriptionBadge={subscriptionBadge}
/>
// 处理模式切换
const handleModeToggle = (e) => {
e.stopPropagation(); // 阻止冒泡到标题栏的 onToggle
if (isLocked && onLockedClick) {
// 如果被锁定,触发付费弹窗
onLockedClick();
return;
}
if (displayMode === 'detailed') {
// 从详细模式切换到精简模式
setDisplayMode('simple');
} else {
// 从精简模式切换回详细模式
setDisplayMode('detailed');
// 切换回详细模式时,如果未展开则自动展开
if (!isOpen && onToggle) {
onToggle();
}
}
};
// 渲染精简模式
const renderSimpleMode = () => {
if (!simpleContent) return null;
return (
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
{simpleContent}
</Box>
);
};
// 渲染详细模式
const renderDetailedMode = () => {
return (
<Collapse
in={isOpen && !isLocked}
animateOpacity
@@ -61,6 +98,25 @@ const CollapsibleSection = ({
{children}
</Box>
</Collapse>
);
};
return (
<Box>
<CollapsibleHeader
title={title}
isOpen={isOpen}
onToggle={handleToggle}
count={count}
subscriptionBadge={subscriptionBadge}
showModeToggle={showModeToggle}
currentMode={displayMode}
onModeToggle={handleModeToggle}
isLocked={isLocked}
/>
{/* 根据当前模式渲染对应内容 */}
{displayMode === 'simple' ? renderSimpleMode() : renderDetailedMode()}
</Box>
);
};

View File

@@ -0,0 +1,100 @@
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
// 精简信息栏组件(无头部模式下右上角显示)
import React from 'react';
import {
HStack,
Badge,
Text,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import EventFollowButton from '../EventCard/EventFollowButton';
/**
* 精简信息栏组件
* 在无头部模式下,显示在 CardBody 右上角
* 包含:重要性徽章、浏览次数、关注按钮
*
* @param {Object} props
* @param {Object} props.event - 事件对象
* @param {Object} props.importance - 重要性配置对象(包含 level, icon 等)
* @param {boolean} props.isFollowing - 是否已关注
* @param {number} props.followerCount - 关注数
* @param {Function} props.onToggleFollow - 切换关注回调
*/
const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
const viewCountBg = useColorModeValue('white', 'gray.700');
const viewCountTextColor = useColorModeValue('gray.600', 'gray.300');
// 获取重要性文本
const getImportanceText = () => {
const levelMap = {
'S': '极高',
'A': '高',
'B': '中',
'C': '低'
};
return levelMap[importance.level] || '中';
};
return (
<HStack
position="absolute"
top={3}
right={3}
spacing={3}
zIndex={1}
>
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
<Badge
px={3}
py={1.5}
borderRadius="full"
fontSize="sm"
fontWeight="bold"
bgGradient={
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
'linear(to-r, gray.500, gray.700)'
}
color="white"
boxShadow="lg"
display="flex"
alignItems="center"
gap={1}
>
<Icon as={importance.icon} boxSize={4} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
{/* 浏览次数 - 添加容器背景以提高可读性 */}
<HStack
spacing={1}
bg={viewCountBg}
px={2}
py={1}
borderRadius="md"
boxShadow="sm"
>
<ViewIcon color="gray.400" boxSize={4} />
<Text fontSize="sm" color={viewCountTextColor} whiteSpace="nowrap">
{(event.view_count || 0).toLocaleString()}
</Text>
</HStack>
{/* 关注按钮 */}
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={onToggleFollow}
size="sm"
showCount={true}
/>
</HStack>
);
};
export default CompactMetaBar;

View File

@@ -8,6 +8,7 @@ import {
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils';
/**
* 精简模式股票卡片组件
@@ -30,31 +31,6 @@ const CompactStockItem = ({ stock, quote = null }) => {
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色(涨红跌绿)
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 获取背景渐变色(涨红跌绿)
const getBackgroundGradient = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) {
return 'linear(to-br, gray.50, gray.100)';
}
return num > 0
? 'linear(to-br, red.50, red.100)'
: 'linear(to-br, green.50, green.100)';
};
// 获取边框颜色
const getBorderColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.300';
return num > 0 ? 'red.300' : 'green.300';
};
// 获取涨跌幅数据(优先使用 quotefallback 到 stock
const change = quote?.change ?? stock.daily_change ?? null;
@@ -68,9 +44,9 @@ const CompactStockItem = ({ stock, quote = null }) => {
fontSize="xs"
>
<Box
bgGradient={getBackgroundGradient(change)}
bgGradient={getChangeBackgroundGradient(change)}
borderWidth="3px"
borderColor={getBorderColor(change)}
borderColor={getChangeBorderColor(change)}
borderRadius="2xl"
p={4}
onClick={handleViewDetail}
@@ -85,7 +61,7 @@ const CompactStockItem = ({ stock, quote = null }) => {
left: 0,
right: 0,
height: '4px',
bg: getBorderColor(change),
bg: getChangeBorderColor(change),
}}
_hover={{
boxShadow: '2xl',

View File

@@ -10,6 +10,8 @@ import {
Text,
Spinner,
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
} from '@chakra-ui/react';
@@ -19,9 +21,11 @@ import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
@@ -32,8 +36,9 @@ import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgrade
* 动态新闻详情面板主组件
* @param {Object} props
* @param {Object} props.event - 事件对象(包含详情数据)
* @param {boolean} props.showHeader - 是否显示头部信息(默认 true
*/
const DynamicNewsDetailPanel = ({ event }) => {
const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const dispatch = useDispatch();
const { user } = useAuth();
const cardBg = useColorModeValue('white', 'gray.800');
@@ -49,6 +54,10 @@ const DynamicNewsDetailPanel = ({ event }) => {
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
// 🎯 浏览量机制:存储从 API 获取的完整事件详情(包含最新 view_count
const [fullEventDetail, setFullEventDetail] = useState(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// 权限判断函数
const hasAccess = useCallback((requiredTier) => {
const tierLevel = { free: 0, pro: 1, max: 2 };
@@ -76,6 +85,30 @@ const DynamicNewsDetailPanel = ({ event }) => {
loadChainAnalysis
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
if (!event?.id) return;
setLoadingDetail(true);
try {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
eventId: event?.id
});
} finally {
setLoadingDetail(false);
}
}, [event?.id]);
// 相关股票、相关概念、历史事件和传导链的权限
const canAccessStocks = hasAccess('pro');
const canAccessConcepts = hasAccess('pro');
@@ -83,8 +116,8 @@ const DynamicNewsDetailPanel = ({ event }) => {
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// PRO 会员的相关股票默认展开
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro');
// 初始值为 false由 useEffect 根据权限动态设置
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
@@ -168,14 +201,21 @@ const DynamicNewsDetailPanel = ({ event }) => {
useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
// PRO 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks && userTier === 'pro';
// 🎯 加载事件详情(增加浏览量)
loadEventDetail();
// PRO 和 MAX 会员的相关股票默认展开,其他情况收起
const shouldOpenStocks = canAccessStocks;
setIsStocksOpen(shouldOpenStocks);
setHasLoadedStocks(false);
// PRO 会员默认展开时,自动加载股票数据
if (shouldOpenStocks) {
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
// PRO 和 MAX 会员自动加载股票数据(无论是否展开)
const shouldLoadStocks = canAccessStocks; // PRO 或 MAX 都有权限
if (shouldLoadStocks) {
console.log('%c📊 [PRO/MAX会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
userTier
});
loadStocksData();
setHasLoadedStocks(true);
}
@@ -185,7 +225,7 @@ const DynamicNewsDetailPanel = ({ event }) => {
setHasLoadedHistorical(false);
setIsTransmissionOpen(false);
setHasLoadedTransmission(false);
}, [event?.id, canAccessStocks, userTier, loadStocksData]);
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
@@ -247,21 +287,34 @@ const DynamicNewsDetailPanel = ({ event }) => {
return (
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
<CardBody>
<VStack align="stretch" spacing={3}>
{/* 头部信息区 */}
<EventHeaderInfo
event={event}
<CardBody position="relative">
{/* 无头部模式:显示右上角精简信息栏 */}
{!showHeader && (
<CompactMetaBar
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
<VStack align="stretch" spacing={3}>
{/* 头部信息区 - 优先使用完整详情数据(包含最新浏览量) - 可配置显示/隐藏 */}
{showHeader && (
<EventHeaderInfo
event={fullEventDetail || event}
importance={importance}
isFollowing={isFollowing}
followerCount={followerCount}
onToggleFollow={handleToggleFollow}
/>
)}
{/* 事件描述 */}
<EventDescriptionSection description={event.description} />
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 */}
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
@@ -275,6 +328,27 @@ const DynamicNewsDetailPanel = ({ event }) => {
})()}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
defaultMode="detailed"
simpleContent={
loading.stocks || loading.quotes ? (
<Center py={2}>
<Spinner size="sm" color="blue.500" />
<Text ml={2} color={textColor} fontSize="sm">加载股票数据中...</Text>
</Center>
) : (
<Wrap spacing={4}>
{stocks?.map((stock, index) => (
<WrapItem key={index}>
<CompactStockItem
stock={stock}
quote={quotes[stock.stock_code]}
/>
</WrapItem>
))}
</Wrap>
)
}
>
{loading.stocks || loading.quotes ? (
<Center py={4}>

View File

@@ -38,9 +38,13 @@ const RelatedConceptsSection = ({
eventTime,
subscriptionBadge = null,
isLocked = false,
onLockedClick = null
onLockedClick = null,
isOpen = undefined, // 新增:受控模式(外部控制展开状态)
onToggle = undefined // 新增:受控模式(外部控制展开回调)
}) => {
const [isExpanded, setIsExpanded] = useState(false);
// 使用外部 isOpen如果没有则使用内部 useState
const [internalExpanded, setInternalExpanded] = useState(false);
const isExpanded = onToggle !== undefined ? isOpen : internalExpanded;
const [concepts, setConcepts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -179,10 +183,8 @@ const RelatedConceptsSection = ({
);
}
// 如果没有概念,不渲染
if (!concepts || concepts.length === 0) {
return null;
}
// 判断是否有数据
const hasNoConcepts = !concepts || concepts.length === 0;
/**
* 根据相关度获取颜色(浅色背景 + 深色文字)
@@ -232,9 +234,12 @@ const RelatedConceptsSection = ({
// 如果被锁定且有回调函数,触发付费弹窗
if (isLocked && onLockedClick) {
onLockedClick();
} else if (onToggle !== undefined) {
// 受控模式:调用外部回调
onToggle();
} else {
// 否则正常展开/收起
setIsExpanded(!isExpanded);
// 非受控模式:使用内部状态
setInternalExpanded(!internalExpanded);
}
}}
>
@@ -249,30 +254,49 @@ const RelatedConceptsSection = ({
</Box>
{/* 简单模式:横向卡片列表(总是显示) */}
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{concepts.map((concept, index) => (
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</Flex>
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{/* 详细概念卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{hasNoConcepts ? (
<Box mb={isExpanded ? 3 : 0}>
{error ? (
<Text color="red.500" fontSize="sm">{error}</Text>
) : (
<Text color={textColor} fontSize="sm">暂无相关概念数据</Text>
)}
</Box>
) : (
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
{concepts.map((concept, index) => (
<DetailedConceptCard
<SimpleConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
getRelevanceColor={getRelevanceColor}
/>
))}
</SimpleGrid>
</Flex>
)}
{/* 详细模式:卡片网格(可折叠) */}
<Collapse in={isExpanded} animateOpacity>
{hasNoConcepts ? (
<Box py={4}>
{error ? (
<Text color="red.500" fontSize="sm" textAlign="center">{error}</Text>
) : (
<Text color={textColor} fontSize="sm" textAlign="center">暂无详细数据</Text>
)}
</Box>
) : (
/* 详细概念卡片网格 */
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
{concepts.map((concept, index) => (
<DetailedConceptCard
key={index}
concept={concept}
onClick={handleConceptClick}
/>
))}
</SimpleGrid>
)}
</Collapse>
</Box>
);

View File

@@ -1,21 +1,13 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React, { useState } from 'react';
import {
VStack,
Flex,
Button,
ButtonGroup,
Wrap,
WrapItem,
} from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import React from 'react';
import { VStack } from '@chakra-ui/react';
import StockListItem from './StockListItem';
import CompactStockItem from './CompactStockItem';
/**
* 相关股票列表区组件(纯内容部分)
* 只负责渲染详细的股票列表,精简模式由外层 CollapsibleSection 的 simpleContent 提供
* @param {Object} props
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
@@ -30,67 +22,23 @@ const RelatedStocksSection = ({
watchlistSet = new Set(),
onWatchlistToggle
}) => {
// 显示模式:'detail' 详情模式, 'compact' 精简模式
const [viewMode, setViewMode] = useState('detail');
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<VStack align="stretch" spacing={4}>
{/* 模式切换按钮 */}
<Flex justify="flex-end">
<ButtonGroup size="sm" isAttached variant="outline">
<Button
leftIcon={<ViewIcon />}
colorScheme={viewMode === 'detail' ? 'blue' : 'gray'}
variant={viewMode === 'detail' ? 'solid' : 'outline'}
onClick={() => setViewMode('detail')}
>
详情模式
</Button>
<Button
leftIcon={<ViewOffIcon />}
colorScheme={viewMode === 'compact' ? 'blue' : 'gray'}
variant={viewMode === 'compact' ? 'solid' : 'outline'}
onClick={() => setViewMode('compact')}
>
精简模式
</Button>
</ButtonGroup>
</Flex>
{/* 详情模式 */}
{viewMode === 'detail' && (
<VStack align="stretch" spacing={3}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</VStack>
)}
{/* 精简模式 */}
{viewMode === 'compact' && (
<Wrap spacing={4}>
{stocks.map((stock, index) => (
<WrapItem key={index}>
<CompactStockItem
stock={stock}
quote={quotes[stock.stock_code]}
/>
</WrapItem>
))}
</Wrap>
)}
<VStack align="stretch" spacing={3}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</VStack>
);
};

View File

@@ -12,12 +12,15 @@ import {
IconButton,
Collapse,
Tooltip,
Badge,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import StockChartModal from '../../../../components/StockChart/StockChartModal';
import CitedContent from '../../../../components/Citation/CitedContent';
import { getChangeColor } from '../../../../utils/colorUtils';
/**
* 股票卡片组件
@@ -66,12 +69,7 @@ const StockListItem = ({
return `${prefix}${parseFloat(value).toFixed(2)}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
const num = parseFloat(value);
if (isNaN(num) || num === 0) return 'gray.500';
return num > 0 ? 'red.500' : 'green.500';
};
// 使用工具函数获取涨跌幅颜色(已从 colorUtils 导入)
// 获取涨跌幅数据(优先使用 quotefallback 到 stock
const change = quote?.change ?? stock.daily_change ?? null;
@@ -108,7 +106,7 @@ const StockListItem = ({
borderRadius="lg"
p={3}
position="relative"
overflow="hidden"
overflow="visible"
_before={{
content: '""',
position: 'absolute',
@@ -117,6 +115,8 @@ const StockListItem = ({
right: 0,
height: '3px',
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
}}
_hover={{
boxShadow: 'lg',
@@ -245,55 +245,73 @@ const StockListItem = ({
/>
</Box>
{/* 关联描述(单行显示,点击展开)- 占据更多空间 */}
{relationText && relationText !== '--' && (
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
placement="top"
hasArrow
bg="gray.600"
color="white"
fontSize="xs"
>
<Box
flex={1}
minW={0}
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"
>
{/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={40}>
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
{/* 关联描述 - 升级和降级处理 */}
{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"
>
{relationText}
</Text>
</Collapse>
{isDescExpanded && (
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
>
AI生成仅供参考
</Text>
)}
</Box>
</Tooltip>
{/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={40}>
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
>
{relationText}
</Text>
</Collapse>
{/* 提示信息 */}
{isDescExpanded && (
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
>
AI生成仅供参考
</Text>
)}
</Box>
</Tooltip>
)}
</Box>
)}
</HStack>
</Box>

View File

@@ -13,9 +13,10 @@ import {
} from '@chakra-ui/react';
import moment from 'moment';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { getChangeColor } from '../../../../utils/colorUtils';
// 导入子组件
import ImportanceBadge from './ImportanceBadge';
import ImportanceStamp from './ImportanceStamp';
import EventFollowButton from './EventFollowButton';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
@@ -153,43 +154,37 @@ const DynamicNewsEventCard = ({
};
/**
* 根据平均涨幅计算背景色(分级策略)
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
* @param {number} avgChange - 平均涨跌幅
* @returns {string} Chakra UI 颜色值
*/
const getChangeBasedBgColor = (avgChange) => {
// 转换为数字类型(处理可能的字符串类型数据)
const numChange = Number(avgChange);
// 🔍 调试日志:排查背景色计算问题
console.log('📊 [背景色计算]', {
rawValue: avgChange,
numValue: numChange,
type: typeof avgChange,
isNull: avgChange == null,
isNaN: isNaN(numChange),
title: event.title.substring(0, 30) + '...'
});
// 如果没有涨跌幅数据或转换失败,使用默认的奇偶行背景
// 如果没有涨跌幅数据,使用半透明背景
if (avgChange == null || isNaN(numChange)) {
return index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750');
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
}
// 根据涨跌幅分级返回背景色
if (numChange >= 5) {
return useColorModeValue('red.100', 'red.900');
} else if (numChange >= 3) {
return useColorModeValue('red.100', 'red.800');
} else if (numChange > 0) {
return useColorModeValue('red.50', 'red.700');
} else if (numChange > -3) {
return useColorModeValue('green.50', 'green.700');
} else if (numChange > -5) {
return useColorModeValue('green.100', 'green.800');
} else {
return useColorModeValue('green.100', 'green.900');
// 根据涨跌幅分级返回半透明背景色(毛玻璃效果)
const absChange = Math.abs(numChange);
if (numChange > 0) {
// 涨:红色系半透明
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
} else if (numChange < 0) {
// 跌:绿色系半透明
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
}
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
};
// 获取当前事件的交易时段、样式和文字标签
@@ -198,8 +193,11 @@ const DynamicNewsEventCard = ({
const periodLabel = getPeriodLabel(tradingPeriod);
return (
<VStack align="stretch" spacing={2} w="100%" pt={3}>
<VStack align="stretch" spacing={2} w="100%" pt={8} position="relative">
{/* 右上角:重要性印章(放在卡片外层) */}
<Box position="absolute" top={-4} right={4} zIndex={10}>
<ImportanceStamp importance={event.importance} />
</Box>
{/* 事件卡片 */}
<Card
@@ -208,17 +206,18 @@ const DynamicNewsEventCard = ({
? useColorModeValue('blue.50', 'blue.900')
: getChangeBasedBgColor(event.related_avg_chg)
}
backdropFilter="blur(10px)" // 毛玻璃效果
borderWidth={isSelected ? "2px" : "1px"}
borderColor={isSelected
? useColorModeValue('blue.500', 'blue.400')
: borderColor
}
borderRadius="md"
boxShadow={isSelected ? "lg" : "sm"}
// overflow="hidden"
borderRadius="lg"
boxShadow={isSelected ? "xl" : "md"}
overflow="visible"
_hover={{
boxShadow: 'xl',
transform: 'translateY(-2px)',
boxShadow: '2xl',
transform: 'translateY(-4px)',
borderColor: isSelected ? 'blue.600' : importance.color,
}}
transition="all 0.3s ease"
@@ -226,8 +225,6 @@ const DynamicNewsEventCard = ({
onClick={() => onEventClick?.(event)}
>
<CardBody p={3}>
{/* 左上角:重要性标签 */}
<ImportanceBadge importance={event.importance} position={{ top:-1, left: 0}} />
{/* 时间标签 - 在卡片上方,宽度自适应,左对齐 */}
<Box

View File

@@ -15,7 +15,7 @@ import {
import { getImportanceConfig } from '../../../../constants/importanceLevels';
// 导入子组件
import ImportanceBadge from './ImportanceBadge';
import ImportanceStamp from './ImportanceStamp';
import EventTimeline from './EventTimeline';
import EventFollowButton from './EventFollowButton';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
@@ -34,6 +34,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
* @param {Object} props.timelineStyle - 时间轴样式配置
* @param {string} props.borderColor - 边框颜色
* @param {string} props.indicatorSize - 涨幅指标尺寸 ('default' | 'comfortable' | 'large')
* @param {string} props.layout - 布局模式 ('vertical' | 'four-row'),影响时间轴竖线高度
*/
const HorizontalDynamicNewsEventCard = ({
event,
@@ -47,6 +48,7 @@ const HorizontalDynamicNewsEventCard = ({
timelineStyle,
borderColor,
indicatorSize = 'comfortable',
layout = 'vertical',
}) => {
const importance = getImportanceConfig(event.importance);
@@ -57,6 +59,40 @@ const HorizontalDynamicNewsEventCard = ({
const selectedBorderColor = useColorModeValue('blue.500', 'blue.400');
const linkColor = useColorModeValue('blue.600', 'blue.400');
/**
* 根据平均涨幅计算背景色(分级策略)- 使用毛玻璃效果
* @param {number} avgChange - 平均涨跌幅
* @returns {string} Chakra UI 颜色值
*/
const getChangeBasedBgColor = (avgChange) => {
const numChange = Number(avgChange);
// 如果没有涨跌幅数据,使用半透明背景
if (avgChange == null || isNaN(numChange)) {
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
}
// 根据涨跌幅分级返回半透明背景色(毛玻璃效果)
const absChange = Math.abs(numChange);
if (numChange > 0) {
// 涨:红色系半透明
if (absChange >= 9) return useColorModeValue('rgba(254, 202, 202, 0.9)', 'rgba(127, 29, 29, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(254, 202, 202, 0.8)', 'rgba(153, 27, 27, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(254, 226, 226, 0.8)', 'rgba(185, 28, 28, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(254, 226, 226, 0.7)', 'rgba(220, 38, 38, 0.7)');
return useColorModeValue('rgba(254, 242, 242, 0.7)', 'rgba(239, 68, 68, 0.7)');
} else if (numChange < 0) {
// 跌:绿色系半透明
if (absChange >= 9) return useColorModeValue('rgba(187, 247, 208, 0.9)', 'rgba(20, 83, 45, 0.9)');
if (absChange >= 7) return useColorModeValue('rgba(187, 247, 208, 0.8)', 'rgba(22, 101, 52, 0.8)');
if (absChange >= 5) return useColorModeValue('rgba(209, 250, 229, 0.8)', 'rgba(21, 128, 61, 0.8)');
if (absChange >= 3) return useColorModeValue('rgba(209, 250, 229, 0.7)', 'rgba(22, 163, 74, 0.7)');
return useColorModeValue('rgba(240, 253, 244, 0.7)', 'rgba(34, 197, 94, 0.7)');
}
return useColorModeValue('rgba(255, 255, 255, 0.8)', 'rgba(26, 32, 44, 0.8)');
};
return (
<HStack align="stretch" spacing={3} w="full">
{/* 左侧时间轴 */}
@@ -64,91 +100,96 @@ const HorizontalDynamicNewsEventCard = ({
createdAt={event.created_at}
timelineStyle={timelineStyle}
borderColor={borderColor}
minHeight="60px"
minHeight={layout === 'four-row' ? '60px' : 0}
/>
{/* 右侧事件卡片 */}
<Card
flex="1"
position="relative"
bg={isSelected
? selectedBg
: (index % 2 === 0 ? cardBg : cardBgAlt)
}
borderWidth={isSelected ? "2px" : "1px"}
borderColor={isSelected
? selectedBorderColor
: borderColor
}
borderRadius="md"
boxShadow={isSelected ? "lg" : "sm"}
overflow="hidden"
_hover={{
boxShadow: 'xl',
transform: 'translateY(-2px)',
borderColor: isSelected ? 'blue.600' : importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick?.(event)}
>
<CardBody p={3} pb={2}>
{/* 左上角:重要性标签 */}
<ImportanceBadge importance={event.importance} />
{/* 右侧事件卡片容器(带印章) */}
<Box flex="1" position="relative" pt={6}>
{/* 右上角:重要性印章(放在卡片外层) */}
<Box position="absolute" top={-2} right={4} zIndex={10}>
<ImportanceStamp importance={event.importance} size="sm" />
</Box>
{/* 右上角:关注按钮 */}
<Box position="absolute" top={2} right={2} zIndex={1}>
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={() => onToggleFollow?.(event.id)}
size="xs"
showCount={false}
/>
</Box>
{/* 事件卡片 */}
<Card
position="relative"
bg={isSelected
? selectedBg
: getChangeBasedBgColor(event.related_avg_chg)
}
backdropFilter="blur(10px)" // 毛玻璃效果
borderWidth={isSelected ? "2px" : "1px"}
borderColor={isSelected
? selectedBorderColor
: borderColor
}
borderRadius="lg"
boxShadow={isSelected ? "xl" : "md"}
overflow="visible"
_hover={{
boxShadow: '2xl',
transform: 'translateY(-2px)',
borderColor: isSelected ? 'blue.600' : importance.color,
}}
transition="all 0.3s ease"
cursor="pointer"
onClick={() => onEventClick?.(event)}
>
<CardBody p={3} pb={2}>
{/* 右上角:关注按钮 */}
<Box position="absolute" top={2} right={2} zIndex={1}>
<EventFollowButton
isFollowing={isFollowing}
followerCount={followerCount}
onToggle={() => onToggleFollow?.(event.id)}
size="xs"
showCount={false}
/>
</Box>
<VStack align="stretch" spacing={1.5}>
{/* 标题 - 最多两行hover 显示完整内容 */}
<Tooltip
label={event.title}
placement="top"
hasArrow
bg="gray.700"
color="white"
fontSize="sm"
p={2}
borderRadius="md"
isDisabled={event.title.length < 40}
>
<Box
cursor="pointer"
onClick={(e) => onTitleClick?.(e, event)}
mt={1}
paddingRight="10px"
<VStack align="stretch" spacing={1.5}>
{/* 标题 - 最多两行hover 显示完整内容 */}
<Tooltip
label={event.title}
placement="top"
hasArrow
bg="gray.700"
color="white"
fontSize="sm"
p={2}
borderRadius="md"
isDisabled={event.title.length < 40}
>
<Text
fontSize="md"
fontWeight="semibold"
color={linkColor}
lineHeight="1.4"
noOfLines={2}
_hover={{ textDecoration: 'underline' }}
<Box
cursor="pointer"
onClick={(e) => onTitleClick?.(e, event)}
mt={1}
paddingRight="10px"
>
{event.title}
</Text>
</Box>
</Tooltip>
<Text
fontSize="md"
fontWeight="semibold"
color={linkColor}
lineHeight="1.4"
noOfLines={2}
_hover={{ textDecoration: 'underline' }}
>
{event.title}
</Text>
</Box>
</Tooltip>
{/* 第二行:涨跌幅数据 */}
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
size={indicatorSize}
/>
</VStack>
</CardBody>
</Card>
{/* 第二行:涨跌幅数据 */}
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
size={indicatorSize}
/>
</VStack>
</CardBody>
</Card>
</Box>
</HStack>
);
};

View File

@@ -0,0 +1,86 @@
// src/views/Community/components/EventCard/ImportanceStamp.js
// 重要性印章组件
import React from 'react';
import {
Box,
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
/**
* 重要性印章组件(模拟盖章效果)
* @param {Object} props
* @param {string} props.importance - 重要性等级 (S/A/B/C)
* @param {string} props.size - 印章尺寸 ('sm' | 'md' | 'lg')
*/
const ImportanceStamp = ({ importance, size = 'md' }) => {
const config = getImportanceConfig(importance);
// 印章颜色
const stampColor = useColorModeValue(config.badgeBg, config.badgeBg);
// 尺寸配置
const sizeConfig = {
sm: { outer: '40px', inner: '34px', fontSize: 'md', borderOuter: '2px', borderInner: '1.5px' },
md: { outer: '50px', inner: '42px', fontSize: 'xl', borderOuter: '3px', borderInner: '2px' },
lg: { outer: '60px', inner: '52px', fontSize: '2xl', borderOuter: '4px', borderInner: '2.5px' },
};
const currentSize = sizeConfig[size];
return (
<Box
position="relative"
display="inline-block"
>
{/* 外层圆形边框(双圈) */}
<Box
position="relative"
w={currentSize.outer}
h={currentSize.outer}
borderRadius="50%"
borderWidth={currentSize.borderOuter}
borderColor={stampColor}
display="flex"
alignItems="center"
justifyContent="center"
transform="rotate(-15deg)"
opacity={0.9}
boxShadow="0 3px 12px rgba(0,0,0,0.2)"
bg={useColorModeValue('white', 'gray.800')}
_hover={{
opacity: 1,
transform: "rotate(-15deg) scale(1.15)",
boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
}}
transition="all 0.3s ease"
>
{/* 内层圆形边框 */}
<Box
position="absolute"
w={currentSize.inner}
h={currentSize.inner}
borderRadius="50%"
borderWidth={currentSize.borderInner}
borderColor={stampColor}
/>
{/* 印章文字 */}
<Text
fontSize={currentSize.fontSize}
fontWeight="black"
color={stampColor}
fontFamily={config.stampFont}
letterSpacing="2px"
textShadow="0 0 2px currentColor"
>
{config.stampText}
</Text>
</Box>
</Box>
);
};
export default ImportanceStamp;

View File

@@ -1,63 +0,0 @@
// src/views/Community/components/EventModals.js
// 事件弹窗组合组件包含详情Modal和股票Drawer
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton
} from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import StockDetailPanel from './StockDetailPanel';
/**
* 事件弹窗组合组件
* @param {Object} eventModalState - 事件详情Modal状态
* @param {boolean} eventModalState.isOpen - 是否打开
* @param {Function} eventModalState.onClose - 关闭回调
* @param {Object} eventModalState.event - 事件对象
* @param {Function} eventModalState.onEventClose - 事件关闭回调(清除状态)
* @param {Object} stockDrawerState - 股票详情Drawer状态
* @param {boolean} stockDrawerState.visible - 是否显示
* @param {Object} stockDrawerState.event - 事件对象
* @param {Function} stockDrawerState.onClose - 关闭回调
*/
const EventModals = ({
eventModalState,
stockDrawerState
}) => {
return (
<>
{/* 事件详情模态框 - 使用Chakra UI Modal */}
<Modal
isOpen={eventModalState.isOpen}
onClose={eventModalState.onClose}
size="xl"
>
<ModalOverlay />
<ModalContent>
<ModalHeader>事件详情</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
<EventDetailModal
event={eventModalState.event}
onClose={eventModalState.onEventClose}
/>
</ModalBody>
</ModalContent>
</Modal>
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer */}
<StockDetailPanel
visible={stockDrawerState.visible}
event={stockDrawerState.event}
onClose={stockDrawerState.onClose}
/>
</>
);
};
export default EventModals;

View File

@@ -1,83 +0,0 @@
// src/views/Community/components/EventTimelineCard.js
// 事件时间轴卡片组件整合Header + Search + List
import React, { forwardRef } from 'react';
import {
Card,
CardHeader,
CardBody,
Box,
useColorModeValue
} from '@chakra-ui/react';
import EventTimelineHeader from './EventTimelineHeader';
import UnifiedSearchBox from './UnifiedSearchBox';
import EventListSection from './EventListSection';
/**
* 事件时间轴卡片组件
* @param {Array} events - 事件列表
* @param {boolean} loading - 加载状态
* @param {Object} pagination - 分页信息
* @param {Object} filters - 筛选条件
* @param {Array} popularKeywords - 热门关键词
* @param {Date} lastUpdateTime - 最后更新时间
* @param {Function} onSearch - 搜索回调
* @param {Function} onSearchFocus - 搜索框获得焦点回调
* @param {Function} onPageChange - 分页变化回调
* @param {Function} onEventClick - 事件点击回调
* @param {Function} onViewDetail - 查看详情回调
* @param {Object} ref - 用于滚动的ref
*/
const EventTimelineCard = forwardRef(({
events,
loading,
pagination,
filters,
popularKeywords,
lastUpdateTime,
onSearch,
onSearchFocus,
onPageChange,
onEventClick,
onViewDetail,
...rest
}, ref) => {
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
{/* 标题部分 */}
<CardHeader>
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
</CardHeader>
{/* 主体内容 */}
<CardBody>
{/* 统一搜索组件(整合了话题、股票、行业、日期、排序、重要性、热门概念、筛选标签) */}
<Box mb={4}>
<UnifiedSearchBox
onSearch={onSearch}
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
/>
</Box>
{/* 事件列表包含Loading、Empty、List三种状态 */}
<EventListSection
loading={loading}
events={events}
pagination={pagination}
onPageChange={onPageChange}
onEventClick={onEventClick}
onViewDetail={onViewDetail}
/>
</CardBody>
</Card>
);
});
EventTimelineCard.displayName = 'EventTimelineCard';
export default EventTimelineCard;

View File

@@ -35,7 +35,7 @@ const CustomArrow = ({ className, style, onClick, direction }) => {
);
};
const HotEvents = ({ events, onPageChange }) => {
const HotEvents = ({ events, onPageChange, onEventClick }) => {
const [currentSlide, setCurrentSlide] = useState(0);
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
const [modalEvent, setModalEvent] = useState(null);
@@ -67,6 +67,17 @@ const HotEvents = ({ events, onPageChange }) => {
};
const handleCardClick = (event) => {
// 🎯 追踪热点事件点击
if (onEventClick) {
onEventClick({
eventId: event.id,
eventTitle: event.title,
importance: event.importance,
source: 'hot_events_section',
timestamp: new Date().toISOString(),
});
}
setModalEvent(event);
onModalOpen();
};

View File

@@ -16,8 +16,9 @@ import HotEvents from './HotEvents';
/**
* 热点事件区域组件
* @param {Array} events - 热点事件列表
* @param {Function} onEventClick - 事件点击追踪回调
*/
const HotEventsSection = ({ events }) => {
const HotEventsSection = ({ events, onEventClick }) => {
const cardBg = useColorModeValue('white', 'gray.800');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
@@ -55,7 +56,11 @@ const HotEventsSection = ({ events }) => {
)}
</CardHeader>
<CardBody py={0} px={4}>
<HotEvents events={events} onPageChange={handlePageChange} />
<HotEvents
events={events}
onPageChange={handlePageChange}
onEventClick={onEventClick}
/>
</CardBody>
</Card>
);

View File

@@ -24,7 +24,8 @@ const UnifiedSearchBox = ({
popularKeywords = [],
filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize // 每页显示数量
pageSize, // 每页显示数量
trackingFunctions = {} // PostHog 追踪函数集合
}) => {
// 其他状态
@@ -259,6 +260,16 @@ const UnifiedSearchBox = ({
name: stockInfo.name
});
// 🎯 追踪股票点击
if (trackingFunctions.trackRelatedStockClicked) {
trackingFunctions.trackRelatedStockClicked({
stockCode: stockInfo.code,
stockName: stockInfo.name,
source: 'search_box_autocomplete',
timestamp: new Date().toISOString(),
});
}
// 更新输入框显示
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
@@ -289,6 +300,15 @@ const UnifiedSearchBox = ({
// 转换为逗号分隔字符串传给后端(空数组表示"全部"
const importanceStr = value.length === 0 ? 'all' : value.join(',');
// 🎯 追踪筛选操作
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'importance',
filterValue: importanceStr,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ importance: importanceStr });
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
@@ -309,6 +329,15 @@ const UnifiedSearchBox = ({
debouncedSearchRef.current.cancel();
}
// 🎯 追踪排序操作
if (trackingFunctions.trackNewsSorted) {
trackingFunctions.trackNewsSorted({
sortBy: value,
previousSortBy: sort,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
@@ -328,6 +357,15 @@ const UnifiedSearchBox = ({
debouncedSearchRef.current.cancel();
}
// 🎯 追踪行业筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'industry',
filterValue: value?.[value.length - 1] || '',
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
@@ -347,6 +385,15 @@ const UnifiedSearchBox = ({
debouncedSearchRef.current.cancel();
}
// 🎯 追踪热门关键词点击
if (trackingFunctions.trackNewsSearched) {
trackingFunctions.trackNewsSearched({
searchQuery: keyword,
searchType: 'popular_keyword',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
q: keyword,
industry_code: ''
@@ -363,6 +410,16 @@ const UnifiedSearchBox = ({
if (!timeConfig) {
// 清空筛选
setTradingTimeRange(null);
// 🎯 追踪时间筛选清空
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: 'cleared',
timestamp: new Date().toISOString(),
});
}
const params = buildFilterParams({
start_date: '',
end_date: '',
@@ -389,6 +446,16 @@ const UnifiedSearchBox = ({
setTradingTimeRange({ ...params, label, key });
// 🎯 追踪时间筛选
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'time_range',
filterValue: label,
timeRangeType: type,
timestamp: new Date().toISOString(),
});
}
// 立即触发搜索
const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
@@ -411,6 +478,16 @@ const UnifiedSearchBox = ({
industry_code: ''
});
// 🎯 追踪搜索操作
if (trackingFunctions.trackNewsSearched && inputValue) {
trackingFunctions.trackNewsSearched({
searchQuery: inputValue,
searchType: 'main_search',
filters: params,
timestamp: new Date().toISOString(),
});
}
logger.debug('UnifiedSearchBox', '主搜索触发', {
inputValue,
params
@@ -513,6 +590,15 @@ const UnifiedSearchBox = ({
setImportance([]); // 改为空数组
setTradingTimeRange(null); // 清空交易时段筛选
// 🎯 追踪筛选重置
if (trackingFunctions.trackNewsFilterApplied) {
trackingFunctions.trackNewsFilterApplied({
filterType: 'reset',
filterValue: 'all_filters_cleared',
timestamp: new Date().toISOString(),
});
}
// 输出重置后的完整参数
const resetParams = {
q: '',
@@ -580,7 +666,8 @@ const UnifiedSearchBox = ({
// 重要性标签(多选合并显示为单个标签)
if (importance && importance.length > 0) {
const importanceLabel = importance.map(imp => `${imp}`).join(', ');
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
}
@@ -681,10 +768,10 @@ const UnifiedSearchBox = ({
placeholder="全部"
maxTagCount={3}
>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
<Option value="S">极高</Option>
<Option value="A"></Option>
<Option value="B"></Option>
<Option value="C"></Option>
</AntSelect>
</Space>

View File

@@ -1,11 +1,10 @@
// src/views/Community/index.js
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import React, { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
import {
fetchPopularKeywords,
fetchHotEvents,
fetchDynamicNews
fetchHotEvents
} from '../../store/slices/communityDataSlice';
import {
Box,
@@ -14,10 +13,8 @@ import {
} from '@chakra-ui/react';
// 导入组件
import EventTimelineCard from './components/EventTimelineCard';
import DynamicNewsCard from './components/DynamicNewsCard';
import HotEventsSection from './components/HotEventsSection';
import EventModals from './components/EventModals';
// 导入自定义 Hooks
import { useEventData } from './hooks/useEventData';
@@ -26,15 +23,12 @@ import { useCommunityEvents } from './hooks/useCommunityEvents';
import { logger } from '../../utils/logger';
import { useNotification } from '../../contexts/NotificationContext';
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../lib/constants';
// 导航栏已由 MainLayout 提供,无需在此导入
const Community = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const { track } = usePostHogTrack(); // PostHog 追踪(保留用于兼容)
// Redux状态
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
@@ -42,26 +36,18 @@ const Community = () => {
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
// Ref用于滚动到实时事件时间轴
const eventTimelineRef = useRef(null);
const hasScrolledRef = useRef(false); // 标记是否已滚动
const containerRef = useRef(null); // 用于首次滚动到内容区域
// Ref用于首次滚动到内容区域
const containerRef = useRef(null);
// ⚡ 通知权限引导
const { showCommunityGuide } = useNotification();
// Modal/Drawer状态
const [selectedEvent, setSelectedEvent] = useState(null);
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
// 自定义 Hooks
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
navigate,
onEventClick: (event) => setSelectedEventForStock(event),
eventTimelineRef
navigate
});
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
@@ -72,28 +58,6 @@ const Community = () => {
dispatch(fetchHotEvents());
}, [dispatch]);
// 每5分钟刷新一次动态新闻使用 prependMode 追加到头部)
useEffect(() => {
const interval = setInterval(() => {
dispatch(fetchDynamicNews({
page: 1,
per_page: 10, // 获取最新的10条
prependMode: true // 追加到头部,不清空缓存
}));
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [dispatch]);
// 🎯 PostHog 追踪:页面浏览
// useEffect(() => {
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
// timestamp: new Date().toISOString(),
// has_hot_events: hotEvents && hotEvents.length > 0,
// has_keywords: popularKeywords && popularKeywords.length > 0,
// });
// }, [track]); // 只在组件挂载时执行一次
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
useEffect(() => {
if (events && events.length > 0 && !loading) {
@@ -136,25 +100,15 @@ const Community = () => {
return () => clearTimeout(timer);
}, []); // 空依赖数组,只在组件挂载时执行一次
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
const scrollToTimeline = useCallback(() => {
if (!hasScrolledRef.current && eventTimelineRef.current) {
eventTimelineRef.current.scrollIntoView({
behavior: 'smooth', // 平滑滚动动画
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
inline: 'nearest' // 水平方向最小滚动
});
hasScrolledRef.current = true; // 标记已滚动
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
}
}, []);
return (
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container ref={containerRef} maxW="1600px" pt={6} pb={8}>
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />
<HotEventsSection
events={hotEvents}
onEventClick={communityEvents.trackNewsArticleClicked}
/>
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard
@@ -163,43 +117,18 @@ const Community = () => {
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}
onSearch={updateFilters}
onSearchFocus={scrollToTimeline}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
trackingFunctions={{
trackNewsArticleClicked: communityEvents.trackNewsArticleClicked,
trackNewsDetailOpened: communityEvents.trackNewsDetailOpened,
trackNewsFilterApplied: communityEvents.trackNewsFilterApplied,
trackNewsSorted: communityEvents.trackNewsSorted,
trackNewsSearched: communityEvents.trackNewsSearched,
trackRelatedStockClicked: communityEvents.trackRelatedStockClicked,
}}
/>
{/* 实时事件 - 原纵向列表 */}
{/* <EventTimelineCard
ref={eventTimelineRef}
mt={6}
events={events}
loading={loading}
pagination={pagination}
filters={filters}
popularKeywords={popularKeywords}
lastUpdateTime={lastUpdateTime}
onSearch={updateFilters}
onSearchFocus={scrollToTimeline}
onPageChange={handlePageChange}
onEventClick={handleEventClick}
onViewDetail={handleViewDetail}
/> */}
</Container>
{/* 事件弹窗 */}
{/* <EventModals
eventModalState={{
isOpen: !!selectedEvent,
onClose: () => setSelectedEvent(null),
event: selectedEvent,
onEventClose: () => setSelectedEvent(null)
}}
stockDrawerState={{
visible: !!selectedEventForStock,
event: selectedEventForStock,
onClose: () => setSelectedEventForStock(null)
}}
/> */}
</Box>
);
};

View File

@@ -31,6 +31,7 @@ import {
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import CitedContent from '../../../components/Citation/CitedContent';
const HistoricalEvents = ({
events = [],
@@ -224,8 +225,8 @@ const HistoricalEvents = ({
</Box>
)}
{/* 历史事件卡片网格 */}
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{/* 历史事件卡片列表 - 混合布局 */}
<VStack spacing={3} align="stretch">
{events.map((event) => {
const importanceColor = getImportanceColor(event.importance);
@@ -235,92 +236,126 @@ const HistoricalEvents = ({
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
borderRadius="lg"
position="relative"
overflow="visible"
cursor="pointer"
onClick={() => handleCardClick(event)}
_before={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
}}
_hover={{
boxShadow: 'lg',
borderColor: 'blue.400',
transform: 'translateY(-2px)',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 事件名称 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
noOfLines={2}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Text>
{/* 日期 + Badges */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.relevance && (
<Badge colorScheme="blue" size="sm">
相关度: {event.relevance}
</Badge>
)}
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
<VStack align="stretch" spacing={2} p={3}>
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
<HStack align="flex-start" spacing={3}>
{/* 左侧:标题 + 时间信息(允许折行) */}
<VStack flex="1" align="flex-start" spacing={1}>
{/* 标题 */}
<Text
fontSize="md"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.title || '未命名事件'}
</Text>
{/* 时间 + Badges允许折行 */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
({getRelativeTime(getEventDate(event))})
</Text>
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
</HStack>
</VStack>
{/* 右侧:相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
flexShrink={0}
>
相关股票
</Button>
</HStack>
{/* 事件描述 */}
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={4}
>
{getEventContent(event) ? `${getEventContent(event)}AI合成` : '暂无内容'}
</Text>
{/* 相关股票按钮 */}
<Button
size="sm"
leftIcon={<Icon as={FaChartLine} />}
onClick={(e) => {
e.stopPropagation();
handleViewStocks(event);
}}
colorScheme="blue"
variant="outline"
width="full"
>
相关股票
</Button>
{/* 底部:描述(独占整行)- 升级和降级处理 */}
<Box>
{(() => {
const content = getEventContent(event);
// 检查是否有 data 结构(升级版本)
if (content && typeof content === 'object' && content.data) {
return (
<CitedContent
data={content}
title=""
showAIBadge={true}
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
padding: '0',
}}
/>
);
}
// 降级版本:纯文本
return (
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
noOfLines={2}
>
{content ? `${content}AI合成` : '暂无内容'}
</Text>
);
})()}
</Box>
</VStack>
</Box>
);
})}
</SimpleGrid>
</VStack>
{/* 相关股票 Modal - 条件渲染 */}
{stocksModalOpen && (