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后端
This commit is contained in:
51
CLAUDE.md
51
CLAUDE.md
@@ -2956,8 +2956,59 @@ refactor(components): 将 EventCard 拆分为原子组件
|
||||
|
||||
> **📝 页面级变更历史**: 特定页面的详细变更历史和技术文档已迁移到各自的文档中:
|
||||
> - **Community 页面**: [docs/Community.md](./docs/Community.md) - 页面架构、组件结构、数据流、变更历史
|
||||
> - **Agent 系统**: [AGENT_INTEGRATION_COMPLETE.md](./AGENT_INTEGRATION_COMPLETE.md) - Agent 系统集成完整说明
|
||||
> - **其他页面**: 根据需要创建独立的页面文档
|
||||
|
||||
### 2025-11-07: Agent 系统集成到 mcp_server.py
|
||||
|
||||
**影响范围**: 后端 MCP 服务器 + 前端 Agent 聊天功能
|
||||
|
||||
**集成成果**:
|
||||
- 将独立的 Agent 系统完全集成到 `mcp_server.py` 中
|
||||
- 使用 **Kimi (kimi-k2-thinking)** 进行计划制定和推理
|
||||
- 使用 **DeepMoney (本地部署)** 进行新闻总结
|
||||
- 实现三阶段智能分析流程(计划→执行→总结)
|
||||
- 前端使用 ChatInterfaceV2 + 可折叠卡片展示执行过程
|
||||
- **无需运行多个脚本**,所有功能集成在单一服务中
|
||||
|
||||
**技术要点**:
|
||||
- 新增 `MCPAgentIntegrated` 类(991-1367行)
|
||||
- 新增 `/agent/chat` API 端点
|
||||
- 新增特殊工具 `summarize_news`(使用 DeepMoney)
|
||||
- Kimi 使用 `reasoning_content` 字段记录思考过程
|
||||
- 自动替换占位符("前面的新闻数据" → 实际数据)
|
||||
|
||||
**前端组件**:
|
||||
- `ChatInterfaceV2.js` - 新版聊天界面
|
||||
- `PlanCard.js` - 执行计划展示
|
||||
- `StepResultCard.js` - 步骤结果卡片(可折叠)
|
||||
- 路由:`/agent-chat`
|
||||
|
||||
**详细文档**: 参见 [AGENT_INTEGRATION_COMPLETE.md](./AGENT_INTEGRATION_COMPLETE.md)
|
||||
|
||||
### 2025-10-30: EventList.js 组件化重构
|
||||
|
||||
**影响范围**: Community 页面核心组件
|
||||
|
||||
**重构成果**:
|
||||
- 将 1095 行的 `EventList.js` 拆分为 497 行主组件 + 10 个子组件
|
||||
- 代码行数减少 **54.6%** (598 行)
|
||||
- 创建了 7 个原子组件 (Atoms) 和 2 个组合组件 (Molecules)
|
||||
|
||||
**新增组件**:
|
||||
- `EventCard/` - 统一入口,智能路由紧凑/详细模式
|
||||
- `CompactEventCard.js` - 紧凑模式事件卡片
|
||||
- `DetailedEventCard.js` - 详细模式事件卡片
|
||||
- 7 个原子组件: EventTimeline, EventImportanceBadge, EventStats, EventFollowButton, EventPriceDisplay, EventDescription, EventHeader
|
||||
|
||||
**新增工具函数**:
|
||||
- `src/utils/priceFormatters.js` - 价格格式化工具 (getPriceChangeColor, formatPriceChange, PriceArrow)
|
||||
- `src/constants/animations.js` - 动画常量 (pulseAnimation, fadeIn, slideInUp)
|
||||
|
||||
**优势**: 提高了代码可维护性、可复用性、可测试性和性能
|
||||
|
||||
**详细文档**: 参见 [docs/Community.md](./docs/Community.md)
|
||||
|
||||
---
|
||||
|
||||
## 更新本文档
|
||||
|
||||
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
Binary file not shown.
381
docs/AGENT_DEPLOYMENT.md
Normal file
381
docs/AGENT_DEPLOYMENT.md
Normal 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/月)
|
||||
309
docs/MCP_ARCHITECTURE.md
Normal file
309
docs/MCP_ARCHITECTURE.md
Normal 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)**
|
||||
- 适合演示
|
||||
- 快速验证可行性
|
||||
- 后续再迁移到后端
|
||||
361
kimi_integration.py
Normal file
361
kimi_integration.py
Normal 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
470
mcp_agent_system.py
Normal 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
295
mcp_chat_endpoint.py
Normal 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
248
mcp_client_example.py
Normal 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
108
mcp_config.py
Normal 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
546
mcp_database.py
Normal 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
1541
mcp_server.py
Normal file
File diff suppressed because it is too large
Load Diff
492
mcp_server_agent_integration.py
Normal file
492
mcp_server_agent_integration.py
Normal 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
|
||||
"""
|
||||
376
src/components/ChatBot/ChatInterface.js
Normal file
376
src/components/ChatBot/ChatInterface.js
Normal 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;
|
||||
576
src/components/ChatBot/ChatInterfaceV2.js
Normal file
576
src/components/ChatBot/ChatInterfaceV2.js
Normal 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;
|
||||
149
src/components/ChatBot/MessageBubble.js
Normal file
149
src/components/ChatBot/MessageBubble.js
Normal 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;
|
||||
145
src/components/ChatBot/PlanCard.js
Normal file
145
src/components/ChatBot/PlanCard.js
Normal 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;
|
||||
186
src/components/ChatBot/StepResultCard.js
Normal file
186
src/components/ChatBot/StepResultCard.js
Normal 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;
|
||||
11
src/components/ChatBot/index.js
Normal file
11
src/components/ChatBot/index.js
Normal 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';
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
278
src/services/llmService.js
Normal 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
248
src/services/mcpService.js
Normal 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;
|
||||
53
src/views/AgentChat/index.js
Normal file
53
src/views/AgentChat/index.js
Normal 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;
|
||||
Reference in New Issue
Block a user