Compare commits
74
Commits
394a7786e0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25f3edac0a | ||
|
|
7b8c3ba2dd | ||
|
|
8f30a588fb | ||
|
|
1ba81ba0d1 | ||
|
|
66f696c1e6 | ||
|
|
09440c2577 | ||
|
|
8a9c2e4868 | ||
|
|
39fcdbf6da | ||
|
|
910394423d | ||
|
|
4a2920e637 | ||
|
|
6f192a89cf | ||
|
|
8f57956904 | ||
|
|
552a962123 | ||
|
|
ab11283d92 | ||
|
|
f975672b0e | ||
|
|
9cf4378bf5 | ||
|
|
1c0dc59842 | ||
|
|
51d8df6e6a | ||
|
|
e284d43cc7 | ||
|
|
46e9f1e1e6 | ||
|
|
894eeff65a | ||
|
|
d2d133f22a | ||
|
|
54b444b651 | ||
|
|
75be24ce9c | ||
|
|
fd84fec5b1 | ||
|
|
fa38d2c57a | ||
|
|
679f23ff93 | ||
|
|
420e0f6603 | ||
|
|
4cf910ee3e | ||
|
|
45b6939ab3 | ||
|
|
53bbbf3195 | ||
|
|
e58c37ea17 | ||
|
|
b415a0b62e | ||
|
|
e50d006ef4 | ||
|
|
7e002ac2b5 | ||
|
|
cee260d4bd | ||
|
|
bc96a087dd | ||
|
|
56eb9f1634 | ||
|
|
0820d408eb | ||
|
|
84d89ab95a | ||
|
|
6e89a37ff7 | ||
|
|
6d5c2cf2c0 | ||
|
|
048671870a | ||
|
|
c1f8b164bb | ||
|
|
33d0f9c32e | ||
|
|
04a0641202 | ||
|
|
180409d317 | ||
|
|
037c965c26 | ||
|
|
34648e4810 | ||
|
|
f4e18bafe4 | ||
|
|
29a1ce2e54 | ||
|
|
9a43ee244d | ||
|
|
96aabc31f6 | ||
|
|
a9d8642db5 | ||
|
|
88b9a232ce | ||
|
|
1633d5c33e | ||
|
|
96be3fdeb3 | ||
|
|
52f1770515 | ||
|
|
89ce7d80dc | ||
|
|
65440d91ee | ||
|
|
1e0bd6f7a2 | ||
|
|
a336ac283d | ||
|
|
cf8c955d45 | ||
|
|
245484a0c5 | ||
|
|
2068421d41 | ||
|
|
7b1941fdaf | ||
|
|
f9c8bda6e0 | ||
|
|
ec7de645f4 | ||
|
|
efa63f9d25 | ||
|
|
c624d8f37c | ||
|
|
bb32884ecb | ||
|
|
df67a298be | ||
|
|
e1e50e884e | ||
|
|
a2504bce34 |
No files matched your search
+416
@@ -0,0 +1,416 @@
|
||||
# 七虎AI PaaS 平台 — 架构设计与实施方案 v3.0
|
||||
|
||||
> **最后更新**: 2026-08-12
|
||||
> **版本**: v3.0
|
||||
> **用途**: 平台竞争力、护城河、技术框架、功能架构、业务逻辑完整文档
|
||||
> **维护**: 随系统迭代实时更新
|
||||
> **仓库**: http://git.qihuyun.com/qyyfw/qihuaiflow
|
||||
|
||||
---
|
||||
|
||||
## 一、平台护城河 (Moat)
|
||||
|
||||
### 1.1 核心竞争壁垒
|
||||
|
||||
| 壁垒维度 | 具体实现 | 竞争门槛 |
|
||||
|---------|---------|---------|
|
||||
| **模型聚合网关** | LiteLLM + STORE_MODEL_IN_DB,4+ 厂商统一 OpenAI 协议,管理员 UI 一键增删模型自动同步 | 竞品需手动配置模型路由,无 admin UI 驱动 |
|
||||
| **双 Claw 单引擎** | Web SSE + IM Bot (微信) 共享 `EngineCore._send_message_core`,能力发现、MCP 调用、LLM 路由只实现一次 | 竞品 Web/IM 两套代码,同步成本高 |
|
||||
| **技能市场闭环** | Skills 发现-安装-搜索(Meilisearch)-执行全生命周期,支持 97+ 技能、14 类 capability | 需自建市场 + 搜索引擎 + 沙箱执行 |
|
||||
| **ngnix 安全纵深** | 四层防护: 限流(API 30r/s/Admin 5r/s/Login 1r/s) + 扫描器拦截(14类path/20类UA) + 自动IP黑名单(5min cron/24h TTL) + SSL终止 | 竞品大多只做限流 |
|
||||
| **模型双轨制已消除** | #965 平台→LiteLLM REST API 同步,管理员 UI 操作即生效,不再 SSH 手动改 yaml | 多数平台仍手动配置网关 |
|
||||
| **设计语言系统** | 完整 `qihu-design-system` (53 modules, CSS 55KB, 品牌色 #5B5FED + 渐变 + 玻璃/辉光 + 暗态) | 需专业设计师 + 前端团队长期维护 |
|
||||
| **可观测性** | Prometheus+Grafana+Alloy OTel 双 Claw 标签看板 + 日志审计 | 中小企业平台通常缺监控 |
|
||||
|
||||
### 1.2 竞争策略
|
||||
|
||||
| 策略 | 我方 | 竞品(AI PaaS) | 竞品(垂直SaaS) |
|
||||
|------|------|-------------|-------------|
|
||||
| 模型数量 | 14+ 实时可增删 | 3-5 个 | 1 个 |
|
||||
| 技能市场 | 97 个,自建生态 | 0-20 个 | 无 |
|
||||
| IM Bot | 微信 + 企业微信扩展 | 无 | 自己开发 |
|
||||
| 多租户 | PG RLS 行级安全 | 应用层隔离 | 单租户 |
|
||||
| 部署 | Docker Compose 一行启动 | Kubernetes 复杂 | 无 Docker |
|
||||
| 定价 | 免费层 + 会员订阅 | 仅付费 | 固定价 |
|
||||
|
||||
---
|
||||
|
||||
## 二、部署架构
|
||||
|
||||
### 2.1 物理拓扑
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ 宝塔 Nginx :443 │ SSL 终止 + 反向代理
|
||||
│ root: /www/dk_ │
|
||||
│ project/wwwroot/ │
|
||||
│ ai.qihuyun.com │
|
||||
└─────────┬─────────┘
|
||||
│ :12888
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
|
||||
│ Nginx │ │ uvicorn │ │ 静态资源 │
|
||||
│ :8080 │ │ :18080 │ │ dist/ │
|
||||
│ proxy_pass │ FastAPI │ │ React SPA│
|
||||
└──────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
┌─────────────┼─────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│Postgres │ │ Redis │ │LiteLLM │
|
||||
│PG18 │ │ :6379 │ │ :4000 │
|
||||
│+pgvector│ └────────┘ │ LLM 网关 │
|
||||
└─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 2.2 容器清单 (Docker Compose)
|
||||
|
||||
| 容器 | 镜像 | 端口 | 用途 |
|
||||
|------|------|------|------|
|
||||
| **qihu-aiflow** | qihuaiflow:with-deps | 12888/18080/8080 | 主应用: Nginx + FastAPI + React dist |
|
||||
| **qihu-aiflow-litellm** | berriai/litellm:main-stable | 14000→4000 | LLM 网关 + DB 模式模型管理 |
|
||||
| **qihu-aiflow-pg18** | pgvector/pgvector:pg16 | 5432 | PostgreSQL 主库 + 向量引擎 |
|
||||
| **qihu-aiflow-redis** | redis:7-alpine | 6379 | 缓存/队列/会话 |
|
||||
| **qihu-aiflow-casdoor** | casdoor | 8000 | 统一认证 OAuth2/OIDC |
|
||||
| **qihu-aiflow-meilisearch** | meilisearch:v1.11 | 17700 | 技能市场搜索引擎 |
|
||||
| **qihu-aiflow-prometheus** | prom/prometheus | 9090 | 指标采集 |
|
||||
| **qihu-aiflow-grafana** | grafana/grafana | 3000 | 可视化看板 |
|
||||
| **qihu-aiflow-alloy** | grafana/alloy | - | 日志采集 Agent |
|
||||
| **qihu-aiflow-node-exporter** | prom/node-exporter | 9100 | 宿主机指标 |
|
||||
|
||||
### 2.3 网络与域名
|
||||
|
||||
| 域名 | 用途 |
|
||||
|------|------|
|
||||
| ai.qihuyun.com | 主站 (React SPA + FastAPI API) |
|
||||
| docs.qihuyun.com | 文档站 |
|
||||
| bot.qihuyun.com | Bot 回调地址 |
|
||||
|
||||
---
|
||||
|
||||
## 三、技术栈矩阵
|
||||
|
||||
| 层级 | 技术 | 版本 | 选型理由 |
|
||||
|------|------|------|---------|
|
||||
| **后端框架** | FastAPI (Python 3.11) | 0.115+ | 异步原生、SSE 流式、Pydantic 验证、自动 OpenAPI |
|
||||
| **ASGI 服务器** | uvicorn | 0.30+ | 单进程事件循环、生产级 |
|
||||
| **前端框架** | React + Vite | 18.3 + 5.4 | 组件化、HMR、TypeScript 5.6 严格模式 |
|
||||
| **前端构建** | Tailwind CSS v4.3 | @tailwindcss/vite | utility-first, design token 驱动 |
|
||||
| **状态管理** | Zustand 5 | persist | 轻量级,三态主题 (light/dark/system) |
|
||||
| **关系数据库** | PostgreSQL 18 | pgvector 0.8.4 | JSONB、向量原生命支持 |
|
||||
| **向量引擎** | pgvector | 0.8.4 | 与主库同集群、混合检索 BM25+语义 |
|
||||
| **缓存/队列** | Redis 7 | - | 会话、工具列表缓存、事件总线 |
|
||||
| **认证** | Casdoor | latest | OAuth2/OIDC、自建身份提供商 |
|
||||
| **ORM** | SQLAlchemy 2.0 | 2.0.36 | 成熟生态、async 支持、psycopg |
|
||||
| **LLM 客户端** | openai Python SDK | 1.x | 统一 OpenAI 协议接入 |
|
||||
| **LLM 网关** | LiteLLM | main-stable | 模型路由、协议转换、费用追踪、key 管理 |
|
||||
| **MCP 客户端** | mcp Python SDK | 0.28.1 | Anthropic 官方 SDK、streamable_http/SSE |
|
||||
| **搜索引擎** | Meilisearch | v1.11 | BM25 + jieba 中文分词、sub-50ms 响应 |
|
||||
| **代理** | Nginx | 1.25+ | 反向代理、静态文件、SSL、限流、扫描器拦截 |
|
||||
| **可观测** | Prometheus + Grafana | latest | 指标仪表盘、双 Claw OTel 标签 |
|
||||
| **容器** | Docker Compose | v2 | 单机部署、一行启动 |
|
||||
|
||||
---
|
||||
|
||||
## 四、功能架构
|
||||
|
||||
### 4.1 系统分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 表现层 (Frontend) │
|
||||
│ React SPA | Tailwind CSS | Zustand State │
|
||||
│ AppShell (NavRail+抽屉) | 三态主题 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 网关层 (Gateway) │
|
||||
│ Nginx | SSL 终止 | 限流 | 扫描器拦截 | IP 黑名单 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API 层 (FastAPI) │
|
||||
│ auth | chat | admin | market | agents | mcp │
|
||||
│ skills | storage | billing | im_llm │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 业务逻辑层 (Core) │
|
||||
│ EngineCore | LLM Router | Node Registry │
|
||||
│ Claw Adapters (SSE/Buffer) | OTel │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 协议网关层 │
|
||||
│ LiteLLM (model routing) | MCP Protocol │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 数据层 (Storage) │
|
||||
│ PostgreSQL+pgvector | Redis | Meilisearch │
|
||||
│ Qiniu/OSS Object Storage | Casdoor OAuth │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 核心功能模块
|
||||
|
||||
#### 前台 (Frontend Pages)
|
||||
|
||||
| 模块 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| 落地页 | `/` | 平台介绍 + Hero + 能力展示 |
|
||||
| AI 对话 | `/chat` | SSE 流式对话、代码块渲染、Markdown、媒体预览 |
|
||||
| 知识库 | `/knowledge` | 文档上传、RAG 检索、向量索引管理 |
|
||||
| 技能市场 | `/market` | Meilisearch 搜索、技能安装、上架审核 |
|
||||
| Token 驾驶舱 | `/token` | 实时用量、消耗趋势、预警告警 |
|
||||
| 会员/定价 | `/pricing` | 套餐对比、微信支付订阅、增值付费 |
|
||||
| 个人中心 | `/profile` | 用户信息、主题设置、API Key 管理 |
|
||||
| 通知中心 | `/notifications` | 系统消息、订单通知、告警 |
|
||||
| Agent 画布 | `/agent` | 可视化编排、DAG 工作流编辑 |
|
||||
| App 服务 | `/apps` | 第三方应用集成(小说、视频等) |
|
||||
|
||||
#### 后台 (Admin Pages)
|
||||
|
||||
| 模块 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| 平台驾驶舱 | `/admin` | 用户数/模型数/调用量/营收概览 |
|
||||
| 大模型库 | `/admin/models` | 供应商管理、模型 CRUD、连接测试、能力默认值配置、**自动同步 LiteLLM** |
|
||||
| Agent 管理 | `/admin/agents` | Agent 创建/编辑/状态 |
|
||||
| 技能管理 | `/admin/skills` | 技能审批、上架/下架、分类 |
|
||||
| MCP 管理 | `/admin/mcp` | MCP Server 注册/授权 |
|
||||
| 用户管理 | `/admin/users` | 用户 CRUD、角色分配 |
|
||||
| 会员管理 | `/admin/membership` | 套餐配置、续费管理 |
|
||||
| 推广系统 | `/admin/promotion` | 邀请链接、佣金结算 |
|
||||
| 计费管理 | `/admin/billing` | 充值记录、订单、退款 |
|
||||
| 应用市场 | `/admin/market` | 应用审核、产品定价、webhook |
|
||||
| 系统设置 | `/admin/settings` | 站点配置、SMS、存储 |
|
||||
| 日志审计 | `/admin/logs` | 操作日志、不可删除 |
|
||||
| Wechat Bot | `/admin/imbots` | 微信连接状态、消息历史 |
|
||||
|
||||
### 4.3 模型能力体系
|
||||
|
||||
| 能力分类 | 英文标识 | 代表模型 | 用途 |
|
||||
|---------|---------|---------|------|
|
||||
| 文本对话 | text | DeepSeek-V4-Flash, MiMo-v2.5 | 通用聊天、文档生成 |
|
||||
| 推理思考 | thinking | MiMo-v2.5 | 复杂推理、逻辑分析 |
|
||||
| 视觉理解 | vision | MiMo-v2.5 | 图片识别、OCR |
|
||||
| 工具调用 | tool | MiMo-v2.5 | Function Calling |
|
||||
| 图像生成 | image | AGNES-Image-2.0-Flash | AI 绘图 |
|
||||
| 视频生成 | video | AGNES-Video-v2.0 | AI 视频生成 |
|
||||
| 语音合成 | audio | MOSS-TTSD | TTS 文字转语音 |
|
||||
| 语音识别 | asr | TeleSpeechASR | 语音转文字 |
|
||||
| OCR 识别 | ocr | DeepSeek-OCR | 文档 OCR |
|
||||
| 嵌入向量 | embedding | BAAI/bge-m3 | 文本向量化 |
|
||||
| 重排序 | rerank | BAAI/bge-reranker-v2-m3 | 检索结果重排 |
|
||||
|
||||
---
|
||||
|
||||
## 五、核心业务逻辑
|
||||
|
||||
### 5.1 AI 对话处理流 (chat.py)
|
||||
|
||||
```
|
||||
用户消息 → chat.py _send_message_core
|
||||
→ capability_discovery (发现可用 MCP/Skills/Models)
|
||||
→ System Prompt 组装 (知识库 + 能力清单 + 对话历史)
|
||||
→ LiteLLM /v1/chat/completions (master key 认证)
|
||||
→ SSE 流式返回 → BufferAdapter 缓冲 → SseAdapter 推送
|
||||
→ 图片生成: AGNES API → async thread → media relay → 微信推送
|
||||
→ 日志: Token 用量 + 审计
|
||||
```
|
||||
|
||||
### 5.2 双 Claw 单引擎架构 (Dual Claw)
|
||||
|
||||
```
|
||||
Web Claw (SSE) IM Bot Claw (微信)
|
||||
│ │
|
||||
▼ ▼
|
||||
SseAdapter BufferAdapter
|
||||
│ │
|
||||
└──────────┬───────────────────┘
|
||||
▼
|
||||
EngineCore._send_message_core (唯一引擎)
|
||||
│
|
||||
┌────────┼────────┐
|
||||
▼ ▼ ▼
|
||||
LLM Router MCP SDK Capability Discovery
|
||||
```
|
||||
|
||||
**关键设计原则**:
|
||||
- 能力发现 `capability_discovery.py` Web/IM 共用
|
||||
- MCP 命名 `mcp__{safe(name,30)}__{safe(tool,50)}__{idx}` 统一
|
||||
- `tests/test_dual_claw.py` R-DUAL 集成哨兵
|
||||
|
||||
### 5.3 模型同步机制 (#965 LiteLLM Sync)
|
||||
|
||||
```
|
||||
管理员 UI "添加模型" → models.py create_model()
|
||||
→ PostgreSQL aiflow.AIModel 写入 ✅
|
||||
→ threading.Thread → LiteLLM REST API /model/new ✅
|
||||
→ LiteLLM litellm DB 热加载 ✅
|
||||
→ 用户立即可用 ✅
|
||||
|
||||
STORE_MODEL_IN_DB=True (docker-compose)
|
||||
```
|
||||
|
||||
### 5.4 媒体生成与中转 (#967 media_relay)
|
||||
|
||||
```
|
||||
用户请求 "生成图片" → chat.py 检测 intent
|
||||
→ AGNES API /v1/images/generations
|
||||
→ SiliconFlow S3 存储 → 临时 URL (24h)
|
||||
→ media/relay (httpx stream + domain whitelist)
|
||||
→ WeChat / Web SSE 内联渲染
|
||||
```
|
||||
|
||||
### 5.5 安全纵深防御
|
||||
|
||||
```
|
||||
第一层: Nginx 限流
|
||||
- API: 30 r/s | Admin: 5 r/s | Login: 1 r/s
|
||||
第二层: 扫描器拦截
|
||||
- Path 14 类 (.git/.env/wp-login/etc) → 444
|
||||
- UA 20 类 (zgrab/nmap/gobuster/etc) → 444
|
||||
- Client SDK (curl/wget/python-requests) → 444
|
||||
第三层: 自动黑名单
|
||||
- /app/backend/nginx/block_ip.py
|
||||
- 5min cron + 24h TTL
|
||||
- 基于 4xx 错误率和 UA 模式
|
||||
第四层: JWT + Casdoor 认证
|
||||
- JWT Bearer Token
|
||||
- role-based 路由 (super_admin/admin/user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、设计系统 (qihu-design-system)
|
||||
|
||||
### 6.1 设计理念
|
||||
|
||||
**温暖科技感 + 极简主义** (Warm Tech Minimalism)。护眼雾白底,靛蓝 `#5B5FED` 为唯一品牌色。
|
||||
|
||||
### 6.2 技术实现
|
||||
|
||||
| 维度 | 值 |
|
||||
|------|-----|
|
||||
| 主品牌色 | `#5B5FED` (indigo) |
|
||||
| 暗态品牌色 | `#8289F5` |
|
||||
| 签名渐变 | indigo → violet(#8B5CF6) → fuchsia(#D946EF) |
|
||||
| 字体 | Inter (正文/标题) + JetBrains Mono (代码) |
|
||||
| CSS 框架 | Tailwind v4.3 (@tailwindcss/vite) |
|
||||
| 主题 | light / dark / system 三态,Zustand persist + matchMedia |
|
||||
| 组件库 | 53 modules,CSS 55KB / JS 244KB / 1.36s build |
|
||||
|
||||
### 6.3 组件体系
|
||||
|
||||
```yaml
|
||||
ui: Button(6 variants) | Badge(6v) | Input | Card(6v+cardSurface)
|
||||
canvas: GenUiCard(7 themes) | StatusRing(6 states) | ChatComposer(glass)
|
||||
layout: AppShell(NavRail+drawer+skip-link) | ThemeToggle | ReaderTabs
|
||||
```
|
||||
|
||||
### 6.4 设计铁律
|
||||
|
||||
- ❌ 禁硬编码 `bg-blue-*`/`bg-sky-*`/`bg-indigo-600`
|
||||
- ✅ GenUi 走 primary 语义 token
|
||||
- ✅ 变体映射必须 `Record<Variant, string>` + `cn()`,禁 `bg-primary-${w}`
|
||||
- ✅ 暗态 `.dark` 双写;玻璃/辉光统一 `.glass`/`.glow`
|
||||
- ✅ 动效尊重 `prefers-reduced-motion`
|
||||
- ✅ 条纹: `--chat-accent` / `--r-sm|md|lg` / `--chat-ease cubic-bezier(0.2,0.8,0.2,1)` / `--chat-duration 200ms`
|
||||
|
||||
---
|
||||
|
||||
## 七、运维与可靠性
|
||||
|
||||
### 7.1 Safe-Align 工作流
|
||||
|
||||
```
|
||||
① 备份: docker commit → backup 镜像 + git tag + bundle + PG dump
|
||||
② 拷贝: 仅 git-tracked 源文件 容器→宿主 git
|
||||
③ 提交: git add + commit (不 push)
|
||||
④ 校验: docker restart + 验证 + docker commit → with-deps
|
||||
```
|
||||
|
||||
**真相源**: 容器 (image := container := git)
|
||||
|
||||
### 7.2 监控
|
||||
|
||||
- Grafana 双 Claw 对比看板 `uid: qihuaiflow-dual-claw-876`
|
||||
- OTel traces + metrics (alloy → Prometheus)
|
||||
- 日志级别: INFO (生产) / DEBUG (开发)
|
||||
|
||||
### 7.3 备份策略
|
||||
|
||||
- 每 6h 自动备份 (cron)
|
||||
- PG dump (不用 --ssl=0 否则 0 bytes)
|
||||
- Docker image snapshot: `qihuaiflow:backup-*{TS}`
|
||||
- nginx logrotate 每日轮转
|
||||
|
||||
---
|
||||
|
||||
## 八、迭代路线
|
||||
|
||||
### 8.1 已完成 (2026-08-11)
|
||||
|
||||
| 编号 | 描述 | commit |
|
||||
|------|------|--------|
|
||||
| #957 | Nginx server 级 root 修复 | 53bbbf3 |
|
||||
| #959 | Index.html no-store cache | 45b6939 |
|
||||
| #961 | React hydration error fix | 4cf910e |
|
||||
| #962 | Global ErrorBoundary | 420e0f6 |
|
||||
| #963 | safeLazy chunk load fallback | 679f23f |
|
||||
| #964 | React hooks rules fix (#321) | fa38d2c |
|
||||
| #965 | LiteLLM platform-model sync | fd84fec |
|
||||
| #966 | Container restart loop fix | 75be24c |
|
||||
| #967 | media_relay endpoint impl | 54b444b |
|
||||
|
||||
### 8.2 下一步候选
|
||||
|
||||
| 优先级 | 项目 | 工作量 |
|
||||
|--------|------|--------|
|
||||
| 🟢 P0 | 企业微信官方 Bot API 接入 | 3 天 |
|
||||
| 🟢 P1 | 后端核心模块单测补全 (165→300+) | 3 天 |
|
||||
| 🟡 P1 | Alloy traces 配置 bake to image | 1 天 |
|
||||
| 🟡 P1 | 多租户 PG RLS 隔离 | 5 天 |
|
||||
| ⚪ P2 | 语音/视频流式响应 | 5 天 |
|
||||
| ⚪ P2 | Agent 画布可视化编排 | 7 天 |
|
||||
|
||||
### 8.3 长期方向
|
||||
|
||||
1. **模型 → 智能体**: 从简单的模型调用升级到自主 Agent + 多步推理
|
||||
2. **单机 → 集群**: Docker Compose → Kubernetes + Helm
|
||||
3. **中国市场 → 国际化**: 多语言 + 多区域部署
|
||||
4. **技能市场 → 开放生态**: 第三方开发者平台 + 收入分成
|
||||
5. **个人微信 → 企业微信**: 合规化 IM Bot 渠道
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 服务器信息
|
||||
|
||||
| 项 | 值 |
|
||||
|------|-----|
|
||||
| IP | 210.16.161.218 |
|
||||
| OS | Ubuntu 24.04 |
|
||||
| 公网地址 | https://ai.qihuyun.com |
|
||||
| 后台地址 | https://ai.qihuyun.com/admin |
|
||||
| 默认账户 | admin / admin123 |
|
||||
| Git | http://git.qihuyun.com/qyyfw/qihuaiflow |
|
||||
|
||||
### 9.2 关键文件路径
|
||||
|
||||
| 文件 | 容器路径 |
|
||||
|------|---------|
|
||||
| 后端入口 | `/app/backend/python/main.py` |
|
||||
| AI 对话 | `/app/backend/python/app/api/chat.py` |
|
||||
| LLM 网关 | `/app/backend/python/app/core/llm_router.py` |
|
||||
| 双 Claw | `/app/backend/python/app/core/claw_adapters.py` |
|
||||
| 同步服务 | `/app/backend/python/app/core/litellm_sync.py` |
|
||||
| 前端入口 | `/app/frontend/src/main.tsx` |
|
||||
| Nginx 配置 | `/etc/nginx/conf.d/aiflow.conf` |
|
||||
| LiteLLM 配置 | `/app/config.yaml` (litellm 容器) |
|
||||
|
||||
### 9.3 调试经验
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|---------|
|
||||
| 容器重启循环 | docker stop → docker cp 修复文件 → docker start |
|
||||
| React #321 hooks error | useState 必须在组件函数内,不能模块顶层 |
|
||||
| Chrome 缓存旧 HTML | index.html 加 Cache-Control: no-store |
|
||||
| nginx 调测 444 | curl 用 -A 'Mozilla/5.0' 避免被 P1-2 拦截 |
|
||||
| Docker tag 短ID 报错 | 必须用完整 sha256: 格式 ID |
|
||||
| SSH paramiko 掉线 | get_ssh() 3次重试 + 指数退避 |
|
||||
@@ -1,546 +1,217 @@
|
||||
# Qihu-AIFlow 智能自动化PaaS平台
|
||||
# 七虎AI — 智能自动化 PaaS 平台
|
||||
|
||||
## 项目概述
|
||||
> **私有化部署 | 零代码 AI SOP | 多模型聚合 | 技能市场 | IM Bot**
|
||||
|
||||
Qihu-AIFlow 是一套**私有化部署、面向个人及小微企业的AI自动化SOP一站式PaaS平台**。用户无需编码即可落地全流程AI自动化业务,平台提供AI对话、知识管理、技能市场、Token计费、会员订阅等完整闭环体验。
|
||||
|
||||
**版本**: v2.0.1
|
||||
**构建日期**: 2026-06-02
|
||||
**仓库地址**: http://git.qihuyun.com/qyyfw/qihuaiflow
|
||||
**版本**: v3.0 | **构建日期**: 2026-08-12 | **公网**: https://ai.qihuyun.com | **后台**: `admin / admin123`
|
||||
|
||||
---
|
||||
|
||||
## 技术环境
|
||||
|
||||
### 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端框架 | React 18 + TypeScript | 现代化响应式UI,HashRouter前端路由 |
|
||||
| 构建工具 | Vite 5 | 快速HMR开发服务器,高效生产构建 |
|
||||
| UI样式 | CSS Modules | 组件级样式隔离 |
|
||||
| 后端框架 | Python 3.11 + FastAPI | 高性能异步Web框架,自动生成OpenAPI文档 |
|
||||
| ORM | SQLAlchemy 2.0 | 全功能Python ORM,支持MySQL方言 |
|
||||
| 数据库 | MySQL 8.0 | 关系型数据库,InnoDB引擎,utf8mb4编码 |
|
||||
| 缓存 | Redis 7 | 内存缓存,支持持久化(AOF),事件总线 |
|
||||
| Web服务器 | Nginx | 反向代理 + 静态资源服务 |
|
||||
| 容器化 | Docker + Docker Compose | 一键构建部署,环境隔离 |
|
||||
| API认证 | JWT (python-jose) | 无状态Bearer Token认证 |
|
||||
| 密码加密 | bcrypt | 安全的密码哈希存储 |
|
||||
| 实时通信 | SSE (Server-Sent Events) | AI对话流式输出 |
|
||||
| IM平台接入 | 平台适配器模式 | 飞书/微信/钉钉/WhatsApp/Telegram/Slack统一抽象 |
|
||||
| 微信集成 | ClawBot API | 微信个人号扫码绑定,官方API对接 |
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 版本 | 用途 |
|
||||
|------|------|------|
|
||||
| MySQL | 8.0 | 用户数据、聊天记录、知识库、订单、配置等持久化存储 |
|
||||
| Redis | 7-alpine | 会话缓存、事件总线(发布/订阅)、API限流 |
|
||||
| Nginx | latest | 前端静态资源服务(:8080)、后端API反向代理(:18080) |
|
||||
| Python | 3.11-slim | 后端API服务运行环境 |
|
||||
| Node.js | LTS | Docker镜像内前端构建(Dockerfile中安装) |
|
||||
|
||||
---
|
||||
|
||||
## 系统架构
|
||||
|
||||
### 架构设计图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 用户浏览器 │
|
||||
│ https://bot.qihuyun.com │
|
||||
└──────────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Docker: qihu-aiflow 容器 │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Nginx (:8080) │ │
|
||||
│ │ / → frontend/dist (静态SPA) │ │
|
||||
│ │ /app/ → proxy localhost:18080/app/ │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ FastAPI + Uvicorn (:18080) │ │
|
||||
│ │ ┌───────────┐ ┌──────────┐ ┌───────────┐ │ │
|
||||
│ │ │ auth │ │ chat │ │ channels │ │ │
|
||||
│ │ │ knowledge │ │ market │ │ token │ │ │
|
||||
│ │ │ payment │ │ profile │ │ assets │ │ │
|
||||
│ │ │ evolution │ │ admin/* │ │ webhook │ │ │
|
||||
│ │ └───────────┘ └──────────┘ └───────────┘ │ │
|
||||
│ │ ┌──────────────────────────────────────┐ │ │
|
||||
│ │ │ 平台适配器层 │ │ │
|
||||
│ │ │ feishu│wechat│dingtalk│slack│... │ │ │
|
||||
│ │ └──────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
└────────────────────────┼─────────────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────┴──────────────┐
|
||||
▼ ▼
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ MySQL 8.0 │ │ Redis 7 │
|
||||
│ :3306 │ │ :6379 │
|
||||
│ 容器: qihu- │ │ 容器: qihu- │
|
||||
│ aiflow-mysql │ │ aiflow-redis │
|
||||
└──────────────────┘ └──────────────────┘
|
||||
|
||||
━━━━━━━━━━━━━━━━ 外部集成 ━━━━━━━━━━━━━━━━
|
||||
|
||||
ClawBot API AI模型供应商 微信/支付宝
|
||||
(微信IM绑定) (OpenAI兼容) (支付网关)
|
||||
```
|
||||
|
||||
### 请求流程
|
||||
|
||||
```
|
||||
用户请求 → Nginx(:8080) → /app/* → Uvicorn(:18080) → FastAPI路由
|
||||
↓
|
||||
JWT认证中间件
|
||||
↓
|
||||
SQLAlchemy ORM
|
||||
↙ ↘
|
||||
MySQL 8.0 Redis 7
|
||||
```
|
||||
|
||||
### 数据隔离
|
||||
|
||||
- 所有用户数据查询强制带 `user_id` 过滤,实现物理级数据隔离
|
||||
- 前后台权限分离:`role: admin` 可访问 `/admin/*` 路由
|
||||
- API密钥独立管理,支持颁发/撤销
|
||||
|
||||
---
|
||||
|
||||
## 功能模块
|
||||
|
||||
### 前台功能(10大模块)
|
||||
|
||||
#### 1. AI聊天
|
||||
- 多模型对话:支持切换不同AI模型供应商和模型
|
||||
- 流式输出(SSE):AI回复实时逐字展示
|
||||
- 知识库关联:选择知识库让AI基于专属知识回答
|
||||
- Agent/技能调用:Tool-call机制自动调用关联技能
|
||||
- 会话管理:创建/归档/置顶/搜索会话
|
||||
- 对话历史:完整的用户-AI对话记录
|
||||
|
||||
#### 2. 频道(IM接入)
|
||||
- 多平台支持:飞书、微信、钉钉、WhatsApp、Telegram、Slack
|
||||
- **微信**: 对接ClawBot官方API,扫码绑定微信个人号
|
||||
- 调用 `POST /api/v1/wechat/qrcode` 获取官方二维码
|
||||
- 轮询 `POST /api/v1/wechat/qrcode/status` 等待扫码确认
|
||||
- 自动保存 bot_token/ilink_bot_id/baseurl 等凭据
|
||||
- 支持 `POST /api/v1/wechat/channel_reset` 重置通道
|
||||
- 统一Webhook端点:`POST /app/api/channels/webhook/{platform}` 接收各平台消息
|
||||
- AI行为配置:Bot前缀、流式输出、私聊/群聊策略、白名单
|
||||
- 平台适配器模式:签名验证 + 消息解析 + 统一格式
|
||||
- 频道成员管理:邀请/移除成员
|
||||
|
||||
#### 3. 知识库
|
||||
- 文档上传管理:PDF/Word/TXT等多格式支持
|
||||
- 目录组织:多级目录结构管理文档
|
||||
- AI知识图谱:LLM自动提取实体和关系
|
||||
- Canvas可视化:节点拖拽、悬停高亮、点击详情
|
||||
- Pipeline自动构建:知识库变更触发自动更新
|
||||
|
||||
#### 4. 应用市场
|
||||
- 应用浏览:分类展示,详情查看
|
||||
- 搜索去重:智能搜索,防止重复
|
||||
- 购买安装:付费后一键安装
|
||||
- 卸载管理:支持卸载后重新安装(状态重置)
|
||||
- 后台审核:应用上架审核流程
|
||||
|
||||
#### 5. Token驾驶舱
|
||||
- 消耗概览:今日/累计/日均Token统计
|
||||
- 用量图表:按模型、日期维度可视化
|
||||
- CSV导出:消耗明细数据导出下载
|
||||
- 一键充值:快捷跳转付费中心
|
||||
- 服务器时间:上海时区(UTC+8)实时同步
|
||||
|
||||
#### 6. 付费中心
|
||||
- 会员订阅:多等级会员体系(基础/高级/企业)
|
||||
- 增值服务:单项服务购买
|
||||
- 实名认证:身份信息验证
|
||||
- 支付集成:微信支付 + 支付宝双渠道
|
||||
- 自动续费:会员到期自动续订
|
||||
|
||||
#### 7. 资产中心
|
||||
- 文件上传:支持多种格式
|
||||
- 分类管理:按文件类型自动归类
|
||||
- 在线预览:Office文档(Word/Excel/PPT) + PDF
|
||||
- AI图像生成:广告图/配图AI生成
|
||||
- 去重存储:按ID去重节省空间
|
||||
|
||||
#### 8. AI进化
|
||||
- 进化Pipeline:自动化知识图谱构建流程
|
||||
- 配置管理:启动/停止/参数调整
|
||||
- 监控面板:Pipeline状态、进度实时展示
|
||||
- 自动恢复:系统重启后自动恢复运行中的Pipeline
|
||||
|
||||
#### 9. 个人中心
|
||||
- 用户资料:修改头像、邮箱、手机号
|
||||
- 密码修改:安全的密码更新
|
||||
- 会员信息:等级、到期时间查看
|
||||
|
||||
#### 10. 消息通知
|
||||
- 系统通知:平台公告、功能更新
|
||||
- 应用通知:安装/更新提醒
|
||||
- 付费通知:订单状态变更
|
||||
|
||||
### 后台管理(13大模块)
|
||||
|
||||
#### 1. 平台驾驶舱
|
||||
- 核心指标:用户总数、活跃模型数、今日订单、Token消耗
|
||||
- Token统计面板:今日/累计/日均消耗,活跃模型数
|
||||
- 快捷入口:大模型库、网站设置、支付配置、增值服务等
|
||||
- 操作日志:最近管理员操作记录
|
||||
- 审计日志:安全审计追踪
|
||||
|
||||
#### 2. 用户管理
|
||||
- 用户列表:搜索、筛选、分页
|
||||
- 角色管理:admin / user 角色分配
|
||||
- 状态管理:激活 / 封禁用户
|
||||
- 用户详情:基本信息、会员状态
|
||||
|
||||
#### 3. 大模型库管理
|
||||
- 模型供应商:添加/编辑AI模型供应商
|
||||
- 模型配置:模型名称、API地址、API密钥
|
||||
- 默认模型:设置全局默认使用的模型
|
||||
|
||||
#### 4. 供应商管理
|
||||
- 供应商配置:管理第三方AI服务提供商
|
||||
- API密钥管理:密钥加密存储
|
||||
|
||||
#### 5. 网站基础设置
|
||||
- 站点信息:网站名称、Logo、描述
|
||||
- 邮件配置:SMTP服务器 + 测试发送
|
||||
- 短信配置:短信服务配置
|
||||
- AI内容生成:AI辅助生成网站文案
|
||||
|
||||
#### 6. 支付渠道配置
|
||||
- 微信支付:商户号、API密钥配置
|
||||
- 支付宝:AppID、商户私钥配置
|
||||
|
||||
#### 7. 增值服务管理
|
||||
- 服务列表:所有增值服务CRUD
|
||||
- 定价管理:服务价格设置
|
||||
- 上下架:控制服务可用状态
|
||||
|
||||
#### 8. 会员订阅体系
|
||||
- 会员等级:多等级定义和权限
|
||||
- 订阅计划:时长、价格配置
|
||||
- 自动化规则:会员自动升级/降级
|
||||
|
||||
#### 9. 广告位管理
|
||||
- Banner管理:图片上传、排序
|
||||
- AI图像生成:AI自动生成广告图
|
||||
- 投放配置:位置、展示频率
|
||||
|
||||
#### 10. 应用市场审核
|
||||
- 应用审核:审核上架应用
|
||||
- 优惠券管理:创建/发放优惠券
|
||||
|
||||
#### 11. 频道管理
|
||||
- 频道监控:查看所有频道状态
|
||||
- 平台配置:各平台接入凭证和AI行为配置
|
||||
|
||||
#### 12. 知识库管理
|
||||
- 知识库概览:查看系统所有知识库
|
||||
- 文档管理:文档列表和状态
|
||||
|
||||
#### 13. 日志与风控
|
||||
- 操作日志:管理员操作审计
|
||||
- API调用日志:接口调用统计
|
||||
- 付费日志:订单和交易记录
|
||||
|
||||
---
|
||||
|
||||
## Docker 部署手册
|
||||
|
||||
### 前置条件
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose v2+
|
||||
- 可用磁盘空间 ≥ 10GB
|
||||
- 宿主机端口:12888(前端)、18080(后端)、3306(MySQL)、6379(Redis)
|
||||
|
||||
### 方式一:从 Git 仓库部署
|
||||
## 🚀 快速开始
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone http://git.qihuyun.com/qyyfw/qihuaiflow.git
|
||||
cd qihuaiflow
|
||||
|
||||
# 2. 构建并启动所有服务(MySQL + Redis + AIFlow)
|
||||
docker compose up -d --build
|
||||
# 2. 配置环境
|
||||
cp .env.example .env # 编辑 API keys
|
||||
|
||||
# 3. 等待服务就绪(首次构建约3-5分钟)
|
||||
# 查看日志确认启动完成
|
||||
docker compose logs -f aiflow
|
||||
# 3. 一行启动
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 访问系统
|
||||
# 前端: http://<宿主机IP>:12888
|
||||
# 默认账号: admin / admin123
|
||||
# 4. 访问
|
||||
open https://ai.qihuyun.com # 前台
|
||||
open https://ai.qihuyun.com/admin # 后台
|
||||
```
|
||||
|
||||
### 方式二:从程序包部署
|
||||
**前置要求**: Docker Compose v2 + 8GB RAM
|
||||
|
||||
```bash
|
||||
# 1. 解压程序包
|
||||
tar -xzf Qihu-aiflow.tar.gz
|
||||
cd <解压目录>
|
||||
---
|
||||
|
||||
# 2. 修改环境变量(可选,编辑 .env 或 docker-compose.yml)
|
||||
# vim .env
|
||||
## ✨ 核心能力
|
||||
|
||||
# 3. 构建并启动
|
||||
docker compose up -d --build
|
||||
|
||||
# 4. 验证
|
||||
curl http://localhost:12888/ # 前端
|
||||
curl http://localhost:18080/app/api/health # 后端
|
||||
```
|
||||
|
||||
### Docker Compose 自动执行流程
|
||||
|
||||
`docker compose up -d --build` 启动后会自动:
|
||||
|
||||
1. **拉取基础镜像**: python:3.11-slim, mysql:8.0, redis:7-alpine
|
||||
2. **构建AIFlow镜像**:
|
||||
- 安装系统依赖 (nginx, mysql-client, redis-tools, build-essential)
|
||||
- 安装 Python 依赖 (requirements.txt, 使用清华镜像源)
|
||||
- 安装 Node.js + npm,构建前端 `npm install && npm run build`
|
||||
- 配置 Nginx 反向代理
|
||||
3. **启动MySQL**: 等待健康检查通过 (mysqladmin ping)
|
||||
4. **启动Redis**: 密码认证 (requirepass pwd2026)
|
||||
5. **初始化数据库**:
|
||||
- 创建数据库 `aiflow` (utf8mb4)
|
||||
- 按序执行 migrations/ 下 15 个 SQL 迁移脚本
|
||||
- 智能检测 admin 密码哈希有效性,仅在无效时重置
|
||||
6. **启动应用**: Nginx(:8080) + Uvicorn(:18080)
|
||||
|
||||
### 端口说明
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
| 能力 | 描述 | 状态 |
|
||||
|------|------|------|
|
||||
| 12888 | Nginx (前端) | 用户浏览器访问端口 |
|
||||
| 18080 | Uvicorn (后端API) | API服务端口 |
|
||||
| 3306 | MySQL | 数据库端口 |
|
||||
| 6379 | Redis | 缓存端口 |
|
||||
| 🤖 **AI 对话** | SSE 流式对话、多模型自动路由、Markdown 渲染、代码高亮 | ✅ 生产 |
|
||||
| 📚 **知识库 RAG** | 文档上传 → 切片 → pgvector 向量化 → BM25+语义检索 | ✅ 生产 |
|
||||
| 🛒 **技能市场** | 97+ Skill 发现-安装-执行,Meilisearch 中文搜索,14 类 capability | ✅ 生产 |
|
||||
| 🎨 **媒体生成** | 图片/视频 AI 生成,media relay 中转,微信内联渲染 | ✅ 生产 |
|
||||
| 📊 **Token 驾驶舱** | 实时用量监控、趋势图表、预警告警、计费追踪 | ✅ 生产 |
|
||||
| 💰 **会员订阅** | 多套餐对比、微信支付、增值单点付费 | ✅ 生产 |
|
||||
| 🏪 **应用市场** | 第三方应用集成,产品定价,webhook 回调 | ✅ 生产 |
|
||||
| 🤖 **Agent 编排** | DAG 图引擎 + Supervisor 模式 | 🔨 迭代中 |
|
||||
| 📱 **IM Bot** | 微信扫码连接,AI 对话 + 图片生成 | ✅ 生产 |
|
||||
| 🛡️ **安全纵深** | 四层防护: 限流+扫描器拦截+IP黑名单+JWT认证 | ✅ 生产 |
|
||||
|
||||
**注意**: 前端映射到宿主机 12888 端口(而非 8080),用户访问: `http://<IP>:12888`
|
||||
---
|
||||
|
||||
### 环境变量配置
|
||||
## 🏗️ 架构总览
|
||||
|
||||
Docker Compose 环境变量在 `docker-compose.yml` 的 `environment` 段配置:
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ React SPA (Vite 5 + Tailwind 4) │
|
||||
│ Landing | Chat | Knowledge | Market | Agent │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ Nginx (限流 + 扫描器 + SSL) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ FastAPI uvicorn (Python 3.11) │
|
||||
│ EngineCore (双 Claw 单引擎) | LLM Router │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ LiteLLM │ MCP SDK │ Casdoor OAuth │
|
||||
│ (模型网关) │ (工具协议) │ (统一认证) │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ PostgreSQL+pgvector │ Redis │ Meilisearch │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 变量 | 说明 | Docker默认值 | 本地默认值 |
|
||||
|------|------|-------------|-----------|
|
||||
| `MYSQL_HOST` | MySQL地址 | mysql (容器名) | localhost |
|
||||
| `MYSQL_PORT` | MySQL端口 | 3306 | 3306 |
|
||||
| `MYSQL_USER` | MySQL用户名 | root | root |
|
||||
| `MYSQL_PASSWORD` | MySQL密码 | pwd2026 | pwd2026 |
|
||||
| `MYSQL_DATABASE` | 数据库名 | aiflow | aiflow |
|
||||
| `REDIS_HOST` | Redis地址 | redis (容器名) | localhost |
|
||||
| `REDIS_PORT` | Redis端口 | 6379 | 6379 |
|
||||
| `REDIS_PASSWORD` | Redis密码 | pwd2026 | pwd2026 |
|
||||
| `SECRET_KEY` | JWT签名密钥 | (固定值) | (固定值) |
|
||||
| `APP_ACCESS_URL` | 外网访问地址 | https://bot.qihuyun.com | https://bot.qihuyun.com |
|
||||
| `DEBUG` | 调试模式 | false | false |
|
||||
详见 [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
|
||||
**重要**: 生产环境请修改 `SECRET_KEY`、`MYSQL_PASSWORD`、`REDIS_PASSWORD`。
|
||||
---
|
||||
|
||||
### 管理员账号
|
||||
## 📦 技术栈
|
||||
|
||||
部署后默认管理员账号:
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 前端 | React 18 + TypeScript 5.6 + Vite 5 + Tailwind CSS 4 + Zustand 5 |
|
||||
| 后端 | Python 3.11 + FastAPI + SQLAlchemy 2.0 + Pydantic |
|
||||
| 数据库 | PostgreSQL 18 + pgvector (向量) + Redis 7 (缓存) |
|
||||
| 认证 | Casdoor (OAuth2/OIDC) + JWT |
|
||||
| LLM 网关 | LiteLLM (模型路由 + 费用追踪 + 协议转换) |
|
||||
| 搜索引擎 | Meilisearch v1.11 (BM25 + jieba 中文分词) |
|
||||
| 可观测 | Prometheus + Grafana + Alloy (OTel) |
|
||||
| 部署 | Docker Compose (一行启动,自包含) |
|
||||
| 设计 | qihu-design-system (53 modules, indigo #5B5FED) |
|
||||
|
||||
| 用户名 | 密码 | 角色 |
|
||||
|--------|------|------|
|
||||
| admin | admin123 | 超级管理员 |
|
||||
---
|
||||
|
||||
> 首次登录后请立即修改密码:进入「个人中心」页面进行修改。
|
||||
> run.sh 会智能检测密码哈希有效性——如果用户已修改密码(有效bcrypt哈希),则保持不变。
|
||||
## 🗂️ 项目结构
|
||||
|
||||
### 常用命令
|
||||
```
|
||||
qihuaiflow/
|
||||
├── backend/python/app/ # FastAPI 后端
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── admin/ # 后台管理 API (models/skills/agents/users/...)
|
||||
│ │ ├── chat.py # AI 对话核心 (SSE流式 + 媒体生成)
|
||||
│ │ ├── auth.py # JWT 认证
|
||||
│ │ └── im_llm.py # IM Bot LLM 薄壳
|
||||
│ ├── core/ # 核心引擎
|
||||
│ │ ├── llm_router.py # LLM 路由 + 熔断
|
||||
│ │ ├── claw_adapters.py # 双 Claw 适配器 (SSE/Buffer)
|
||||
│ │ ├── litellm_sync.py # LiteLLM 模型同步服务
|
||||
│ │ ├── node_registry.py # Agent 节点注册
|
||||
│ │ └── capability_discovery.py # 能力发现
|
||||
│ └── models/ # 数据库模型
|
||||
├── frontend/ # React 前端
|
||||
│ └── src/
|
||||
│ ├── pages/admin/ # 后台页面 (39 modules)
|
||||
│ ├── pages/front/ # 前台页面
|
||||
│ ├── components/ # 设计系统组件
|
||||
│ │ ├── ui/ # Button/Badge/Input/Card
|
||||
│ │ ├── canvas/ # GenUiCard/StatusRing/ChatComposer
|
||||
│ │ └── layout/ # AppShell/ThemeToggle
|
||||
│ └── styles/ # 全局 CSS + chat 样式
|
||||
├── nginx/ # Nginx 配置文件
|
||||
├── config/ # LiteLLM + 平台配置
|
||||
├── migrations/ # 数据库迁移 SQL
|
||||
├── docker-compose.yml # Docker Compose 编排
|
||||
├── Dockerfile # 主应用镜像
|
||||
├── ARCHITECTURE.md # 架构设计文档
|
||||
└── DESIGN.md # 设计系统规范
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Docker Compose v2
|
||||
- Python 3.11+ (本地开发)
|
||||
- Node.js 22+ (前端开发)
|
||||
- Git
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
# 查看所有容器状态
|
||||
docker compose ps
|
||||
# 后端
|
||||
cd backend/python
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 18080
|
||||
|
||||
# 查看应用日志
|
||||
docker compose logs -f aiflow
|
||||
# 前端
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # Vite dev server :5173
|
||||
```
|
||||
|
||||
# 查看MySQL日志
|
||||
docker compose logs -f mysql
|
||||
### 部署
|
||||
|
||||
# 重启应用
|
||||
docker compose restart aiflow
|
||||
```bash
|
||||
# 生产部署
|
||||
docker-compose up -d --build
|
||||
|
||||
# 停止所有服务
|
||||
docker compose down
|
||||
# 查看状态
|
||||
docker-compose ps
|
||||
docker logs qihu-aiflow --tail 50
|
||||
|
||||
# 停止并清除数据(危险!)
|
||||
docker compose down -v
|
||||
# 重启
|
||||
docker restart qihu-aiflow
|
||||
```
|
||||
|
||||
# 重新构建并启动
|
||||
docker compose up -d --build
|
||||
### 测试
|
||||
|
||||
# 进入应用容器
|
||||
docker exec -it qihu-aiflow bash
|
||||
```bash
|
||||
# 后端测试
|
||||
docker exec qihu-aiflow bash -c "cd /app/backend/python && .venv/bin/python -m pytest"
|
||||
# 当前: 165 passed / 2 skipped
|
||||
|
||||
# 进入MySQL
|
||||
docker exec -it qihu-aiflow-mysql mysql -u root -ppwd2026 aiflow
|
||||
|
||||
# 进入Redis
|
||||
docker exec -it qihu-aiflow-redis redis-cli -a pwd2026
|
||||
# 前端测试
|
||||
docker exec qihu-aiflow bash -c "cd /app/frontend && npx vitest run"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目文件结构
|
||||
## 📊 平台数据
|
||||
|
||||
```
|
||||
/
|
||||
├── frontend/ # 前端项目 (React + TypeScript)
|
||||
│ ├── dist/ # 构建产物(Docker构建时生成)
|
||||
│ ├── public/ # 静态资源
|
||||
│ ├── src/ # TypeScript源码
|
||||
│ │ ├── api/ # API客户端 (index.ts)
|
||||
│ │ ├── components/ # 通用组件 (layout/)
|
||||
│ │ ├── contexts/ # React上下文
|
||||
│ │ ├── hooks/ # 自定义Hooks
|
||||
│ │ ├── mock/ # Mock数据(开发用)
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ │ ├── admin/ # 后台管理页面
|
||||
│ │ │ └── front/ # 前台页面
|
||||
│ │ ├── styles/ # 全局样式
|
||||
│ │ └── types/ # TypeScript类型定义
|
||||
│ ├── package.json # 前端依赖配置
|
||||
│ ├── package-lock.json # 依赖锁文件
|
||||
│ └── vite.config.ts # Vite构建配置
|
||||
├── backend/python/ # 后端项目 (Python FastAPI)
|
||||
│ ├── main.py # 应用入口 (FastAPI app)
|
||||
│ ├── requirements.txt # Python依赖列表
|
||||
│ └── app/ # 应用核心代码
|
||||
│ ├── api/ # API路由模块
|
||||
│ │ ├── auth.py # 认证 (login/register)
|
||||
│ │ ├── chat.py # AI聊天 (SSE流式)
|
||||
│ │ ├── channels.py # IM频道管理 (ClawBot对接)
|
||||
│ │ ├── knowledge.py # 知识库管理
|
||||
│ │ ├── market.py # 应用市场
|
||||
│ │ ├── token_usage.py # Token统计
|
||||
│ │ ├── payment.py # 付费中心
|
||||
│ │ ├── profile.py # 个人中心
|
||||
│ │ ├── assets.py # 资产管理
|
||||
│ │ ├── evolution.py # AI进化
|
||||
│ │ ├── notifications.py # 消息通知
|
||||
│ │ └── admin/ # 后台管理API
|
||||
│ │ ├── dashboard.py # 驾驶舱
|
||||
│ │ ├── users.py # 用户管理
|
||||
│ │ ├── models.py # 模型管理
|
||||
│ │ ├── settings.py # 网站设置
|
||||
│ │ ├── payment_admin.py # 支付配置
|
||||
│ │ ├── services.py # 增值服务
|
||||
│ │ ├── membership_admin.py # 会员管理
|
||||
│ │ ├── ads.py # 广告管理
|
||||
│ │ ├── market_admin.py # 应用审核
|
||||
│ │ └── logs.py # 日志风控
|
||||
│ ├── models.py # SQLAlchemy数据模型 (全部表定义)
|
||||
│ ├── database.py # 数据库连接配置
|
||||
│ ├── config.py # 应用配置
|
||||
│ ├── auth.py # JWT认证 & 权限控制
|
||||
│ ├── middleware/ # 中间件
|
||||
│ │ ├── log_middleware.py # API追踪中间件
|
||||
│ │ └── log_service.py # 操作日志服务
|
||||
│ ├── services/ # 业务服务
|
||||
│ │ └── evolution_service.py # 进化Pipeline服务
|
||||
│ ├── platform_adapter.py # 平台适配器基类
|
||||
│ └── platform_adapters/ # 各平台适配器实现
|
||||
│ ├── feishu_adapter.py
|
||||
│ ├── wecom_adapter.py
|
||||
│ ├── dingtalk_adapter.py
|
||||
│ ├── whatsapp_adapter.py
|
||||
│ ├── telegram_adapter.py
|
||||
│ └── slack_adapter.py
|
||||
├── migrations/ # 数据库迁移脚本 (001-015)
|
||||
│ ├── 001_init.sql # 初始化表结构
|
||||
│ ├── 002_init_data.sql # 初始数据
|
||||
│ ├── ...
|
||||
│ └── 015_production_fixes.sql # 生产修复
|
||||
├── docs/ # 项目文档
|
||||
│ ├── PRD.md # 产品需求文档
|
||||
│ ├── PRD-self-evolution.md # AI自进化PRD
|
||||
│ ├── PRD-self-evolution-v2.md # AI自进化PRD v2
|
||||
│ └── frontend-dev-record.md # 前端开发记录
|
||||
├── Dockerfile # Docker镜像构建文件
|
||||
├── docker-compose.yml # Docker Compose编排配置
|
||||
├── nginx.conf # Nginx反向代理配置
|
||||
├── run.sh # 一键启动脚本
|
||||
├── .env # 环境变量配置
|
||||
├── .gitignore # Git忽略配置
|
||||
├── .dockerignore # Docker构建忽略配置
|
||||
├── VERSION # 版本信息
|
||||
├── CHANGELOG.md # 变更日志
|
||||
└── README.md # 项目文档(本文件)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API接口概览
|
||||
|
||||
所有接口以 `/app/api/` 为前缀,完整OpenAPI文档访问: `http://<host>:18080/docs`
|
||||
|
||||
| 模块 | 基础路径 | 主要接口 |
|
||||
|------|---------|---------|
|
||||
| 认证 | `/app/api/auth` | login, register, logout, me |
|
||||
| AI聊天 | `/app/api/chat` | conversations CRUD, messages, SSE流式 |
|
||||
| 频道 | `/app/api/channels` | channels CRUD, webhook, 扫码授权, 状态轮询 |
|
||||
| 知识库 | `/app/api/knowledge` | bases CRUD, documents, directories, graph |
|
||||
| 应用市场 | `/app/api/market` | apps, install, uninstall |
|
||||
| Token | `/app/api/token` | overview, stats, export CSV |
|
||||
| 付费 | `/app/api/payment` | plans, orders, realname verification |
|
||||
| 资产 | `/app/api/assets` | files CRUD, upload, preview |
|
||||
| 进化 | `/app/api/evolution` | configs, pipeline status |
|
||||
| 后台-仪表盘 | `/app/api/admin/dashboard` | stats, charts |
|
||||
| 后台-用户 | `/app/api/admin/users` | users CRUD |
|
||||
| 后台-模型 | `/app/api/admin/models` | providers, models CRUD |
|
||||
| 后台-设置 | `/app/api/admin/settings` | site, email, sms config |
|
||||
| 后台-支付 | `/app/api/admin/payment` | payment channel config |
|
||||
| 后台-日志 | `/app/api/admin/logs` | operation, api, payment logs |
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
| 编号 | 说明 |
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 001 | 初始化表结构(users, conversations, messages, channels等) |
|
||||
| 002 | 初始数据(admin账号, 默认模型供应商, 种子数据) |
|
||||
| 003 | 增值服务表 |
|
||||
| 004 | OAuth状态字段 |
|
||||
| 005 | 频道扩展字段(account_name, features) |
|
||||
| 006 | API调用统计表 |
|
||||
| 007 | 进化系统基础表 |
|
||||
| 008 | 进化系统v2(EvolutionWatcher状态管理) |
|
||||
| 009 | 支付实名认证字段 |
|
||||
| 010 | 默认模型字段 |
|
||||
| 011 | 知识库存储与资产表 |
|
||||
| 012 | 生产环境列同步(幂等补全) |
|
||||
| 013 | 知识图谱表(节点/边/构建记录) |
|
||||
| 014 | 测试数据清理 |
|
||||
| 015 | 生产修复(content列 + AppPurchase状态重置) |
|
||||
| LLM 模型数 | 14 (含 OCR/Rerank/Embedding/TTS/ASR) |
|
||||
| 技能数 | 97 |
|
||||
| MCP 工具数 | 26 |
|
||||
| 后台管理页面 | 39 |
|
||||
| API 端点 | 300+ |
|
||||
| 容器数 | 12 |
|
||||
| 代码行数 | 50,000+ |
|
||||
|
||||
---
|
||||
|
||||
## 更新日志
|
||||
## 🌐 线上环境
|
||||
|
||||
详见 `VERSION` 和 `CHANGELOG.md`。
|
||||
| 资源 | 地址 |
|
||||
|------|------|
|
||||
| 前台 | https://ai.qihuyun.com |
|
||||
| 后台 | https://ai.qihuyun.com/admin |
|
||||
| Grafana | http://210.16.161.218:3000 |
|
||||
| Prometheus | http://210.16.161.218:9090 |
|
||||
| Casdoor | http://210.16.161.218:8000 |
|
||||
| Git 仓库 | http://git.qihuyun.com/qyyfw/qihuaiflow |
|
||||
|
||||
### v2.0.1 (2026-06-02)
|
||||
---
|
||||
|
||||
- 微信ClawBot官方API标准重构(频道扫码绑定)
|
||||
- 6项生产缺陷修复(二维码、知识库、tool-call、应用安装、时区、邮件)
|
||||
- 完整项目文档(README.md)
|
||||
- Gitea仓库发布
|
||||
## 📝 更新日志
|
||||
|
||||
详见 [CHANGELOG.md](CHANGELOG.md) 和 [RELEASES.md](RELEASES.md)
|
||||
|
||||
**v3.0 重点更新 (2026-08-12)**:
|
||||
- ✅ 修复 `/admin/models` 空白页 (React hooks + 缓存问题)
|
||||
- ✅ 实现 LiteLLM 模型同步 (管理员 UI → LiteLLM REST API)
|
||||
- ✅ 实现 media_relay 端点 (微信 Bot 图片中转)
|
||||
- ✅ 设计系统全量收口 + ErrorBoundary + safeLazy
|
||||
- ✅ Docker Compose STORE_MODEL_IN_DB 模式
|
||||
- ✅ Nginx 限流 + 扫描器拦截 + IP 黑名单
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
私有项目。© 2026 七虎AI. All rights reserved.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Grafana Alloy with baked-in config
|
||||
# Purpose: 替代 bind-mount /tmp/alloy → /etc/alloy,让 alloy 容器自包含配置,
|
||||
# 防止 /tmp 临时目录被清空导致 alloy 启动失败。
|
||||
# 构建: cd /www/wwwroot/qihuaiflow/alloy && ./build.sh
|
||||
# 切换: 修改 docker-compose.yml,alloy 服务的 image: 改为 qihuaiflow-alloy:verified-{TS}
|
||||
#
|
||||
# ⚠️ 注意:此 Dockerfile 假定 config.alloy 已存在于 ./config.alloy(同目录)
|
||||
|
||||
FROM grafana/alloy:latest
|
||||
|
||||
# 自定义配置:覆盖默认空配置
|
||||
COPY config.alloy /etc/alloy/config.alloy
|
||||
|
||||
# alloy 镜像默认 ENTRYPOINT 是 /bin/alloy,run 是默认参数
|
||||
# 无需覆盖 CMD
|
||||
|
||||
# 健康检查(可选):用 alloy 自身 /-/ready 端点
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:12345/-/ready || exit 1
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# alloy 镜像本地化
|
||||
|
||||
## 背景
|
||||
- 当前 alloy 容器(qihu-aiflow-alloy)用 `grafana/alloy:latest` + bind mount `/tmp/alloy → /etc/alloy:ro` 注入配置。
|
||||
- 这种方式**风险**:`/tmp` 在重启时可能被清空,或运维误删 `/tmp/alloy` 导致 alloy 启动失败。
|
||||
- bake 进镜像:让 alloy 容器自包含配置,符合容器 immutable 原则。
|
||||
|
||||
## 目录
|
||||
- `Dockerfile`:基于 `grafana/alloy:latest`,COPY 本目录 `config.alloy` 到 `/etc/alloy/config.alloy`
|
||||
- `config.alloy`:与宿主的 `/tmp/alloy/config.alloy` 同步(本目录是真相源,跑 build.sh 之前需更新)
|
||||
- `build.sh`:自动 build + tag
|
||||
|
||||
## Bake 步骤
|
||||
1. `cd /www/wwwroot/qihuaiflow/alloy`
|
||||
2. `./build.sh` → 产物 `qihuaiflow-alloy:verified-{TS}`
|
||||
3. 修改 `docker-compose.yml` alloy 服务:
|
||||
```yaml
|
||||
services:
|
||||
alloy:
|
||||
image: qihuaiflow-alloy:verified-20260810-154500
|
||||
# 移除 volumes 里的 /tmp/alloy mount
|
||||
```
|
||||
4. `docker compose up -d alloy`
|
||||
5. 验证: `docker exec qihu-aiflow-alloy wget -q -O- http://localhost:12345/-/ready`
|
||||
|
||||
## ⚠️ 风险与回滚
|
||||
- **风险**:重建期间 metrics 采集会中断 1-3 分钟
|
||||
- **回滚**:docker compose 用 `qihuaiflow-alloy:latest`(原 grafana/alloy 基础 + bind mount)切回
|
||||
- **建议执行时机**:低峰期(凌晨 1-5 点)
|
||||
|
||||
## 当前状态
|
||||
- 配置:已同步(2026-08-10),包含 OTLP receiver(4317/4318) + traces 接收(暂不导出)
|
||||
- 镜像构建:本目录就绪,等待"低峰 bake 任务"执行 build.sh
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# alloy/build.sh - 重建并 tag 镜像
|
||||
# 用法: ./build.sh
|
||||
set -euo pipefail
|
||||
TS=$(date +%Y%m%d-%H%M%S)
|
||||
IMAGE="qihuaiflow-alloy"
|
||||
TAG_BAK="backup-${TS}"
|
||||
TAG_NEW="verified-${TS}"
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
echo ">>> Build ${IMAGE}:${TAG_NEW}"
|
||||
docker build -t "${IMAGE}:${TAG_NEW}" .
|
||||
echo ">>> Tag previous as backup"
|
||||
# 拿当前运行镜像的 digest 作 backup
|
||||
docker tag "${IMAGE}:latest" "${IMAGE}:${TAG_BAK}" 2>/dev/null || true
|
||||
echo ">>> Done. New image: ${IMAGE}:${TAG_NEW}"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1) 修改 docker-compose.yml alloy 服务的 image:"
|
||||
echo " image: ${IMAGE}:${TAG_NEW}"
|
||||
echo " 2) docker compose up -d alloy"
|
||||
echo " 3) 验证 alloy 健康: docker exec qihu-aiflow-alloy wget -q -O- http://localhost:12345/-/ready"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Grafana Alloy — OTLP receiver bridging to Prometheus (metrics)
|
||||
// + OTLP traces receiver logging to stdout (Phase 1.I traces pipeline).
|
||||
// 2026-08-10: 加 traces pipeline (otelcol.exporter.logging) 修复应用 trace
|
||||
// `unknown service opentelemetry.proto.collector.trace.v1.TraceService`
|
||||
// 报错,确保 Grafana 双 Claw 时延看板数据可信。
|
||||
// 未来要接 Tempo / Grafana Cloud:把 otelcol.exporter.logging "default"
|
||||
// 换成 otelcol.exporter.otlp "<name>",endpoint 指向 Tempo:4317。
|
||||
|
||||
otelcol.receiver.otlp "default" {
|
||||
grpc {
|
||||
endpoint = "0.0.0.0:4317"
|
||||
}
|
||||
http {
|
||||
endpoint = "0.0.0.0:4318"
|
||||
}
|
||||
|
||||
output {
|
||||
metrics = [otelcol.exporter.prometheus.default.input]
|
||||
// traces/logs 在 receiver 实现 TraceService proto,但本平台未配 Tempo/Loki。
|
||||
// 故意不接 output → alloy 接收后丢弃,应用 OTel 不再报 UNIMPLEMENTED。
|
||||
// 后续接入 Tempo 时,取消下面注释:
|
||||
// traces = [otelcol.exporter.otlp.tempo.input]
|
||||
// logs = [otelcol.exporter.otlp.loki.input]
|
||||
}
|
||||
}
|
||||
|
||||
otelcol.exporter.prometheus "default" {
|
||||
add_metric_suffixes = false
|
||||
forward_to = [prometheus.remote_write.default.receiver]
|
||||
}
|
||||
|
||||
prometheus.remote_write "default" {
|
||||
endpoint {
|
||||
url = "http://qihu-aiflow-prometheus:9090/api/v1/write"
|
||||
}
|
||||
}
|
||||
|
||||
// === Traces/Logs 出口 (Phase 1.I) ===
|
||||
// 当前实现:暂时不导出(等待 Tempo/Loki/Grafana Cloud 接入)。
|
||||
// 接收器仍实现 TraceService proto,应用 OTel SDK 发到 alloy:4317
|
||||
// 不会再收到 UNIMPLEMENTED,只是 trace 被 alloy 丢弃。
|
||||
// 后续接入:Tempo (otlp) / Loki (otlp) / Grafana Cloud,加对应 exporter 并在
|
||||
// otelcol.receiver.otlp.output 里指定。
|
||||
@@ -284,7 +284,7 @@ async def claim_achievement_reward(
|
||||
|
||||
# Mark reward as claimed
|
||||
_execute_raw(db,
|
||||
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
|
||||
"UPDATE user_achievements SET reward_claimed = true, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
|
||||
{"uid": user_id, "aid": ach_id})
|
||||
|
||||
return {"code": 0, "message": "奖励领取成功"}
|
||||
|
||||
@@ -316,7 +316,7 @@ async def create_dashboard(
|
||||
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
|
||||
|
||||
if is_default:
|
||||
db.execute(text("UPDATE analytics_dashboards SET is_default=0"))
|
||||
db.execute(text("UPDATE analytics_dashboards SET is_default=false"))
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
|
||||
@@ -332,7 +332,7 @@ async def create_dashboard(
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
"""删除仪表盘"""
|
||||
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id})
|
||||
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=false"), {"id": dashboard_id})
|
||||
db.commit()
|
||||
return {"code": 200, "data": {"message": "仪表盘已删除"}}
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_
|
||||
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
|
||||
FROM billing_transactions
|
||||
WHERE transaction_type='recharge' AND status='completed'
|
||||
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
AND created_at >= NOW() - INTERVAL '7 DAY'
|
||||
GROUP BY DATE(created_at) ORDER BY d
|
||||
""")).fetchall()
|
||||
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
|
||||
|
||||
@@ -103,7 +103,7 @@ async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_curre
|
||||
try:
|
||||
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
|
||||
today = _execute_raw(db,
|
||||
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"]
|
||||
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURRENT_DATE")[0]["cnt"]
|
||||
success_count = _execute_raw(db,
|
||||
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
|
||||
failed_count = _execute_raw(db,
|
||||
|
||||
@@ -703,7 +703,7 @@ echo "Installation complete!"
|
||||
{d.get('version', '1.0.0')}
|
||||
|
||||
## Installation
|
||||
Run `bash install.sh` to install.
|
||||
Run \"bash install.sh\" to install.
|
||||
|
||||
## License
|
||||
A valid license key is required. Contact your administrator for details.
|
||||
|
||||
@@ -14,7 +14,15 @@ import hmac
|
||||
import time
|
||||
import threading
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# #976 全平台统一使用北京时间(容器 UTC,前端按 +08:00 展示)
|
||||
_TZ_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _beijing_now():
|
||||
"""返回带时区的北京时间(aware datetime,存 PG timestamptz 不误解)。"""
|
||||
return datetime.now(_TZ_BEIJING)
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
@@ -169,7 +177,7 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
|
||||
today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0}
|
||||
|
||||
# Total routing rules
|
||||
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt']
|
||||
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=true"))[0]._mapping['cnt']
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -202,7 +210,7 @@ async def get_dashboard(user=Depends(get_current_user)):
|
||||
SUM(messages_out) as msg_out,
|
||||
SUM(unique_users) as users
|
||||
FROM im_bot_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
WHERE stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
|
||||
GROUP BY stat_date
|
||||
ORDER BY stat_date
|
||||
"""))
|
||||
@@ -217,7 +225,7 @@ async def get_dashboard(user=Depends(get_current_user)):
|
||||
LEFT JOIN (
|
||||
SELECT channel_id, SUM(messages_in + messages_out) as msg_count
|
||||
FROM im_bot_daily_stats
|
||||
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
WHERE stat_date >= CURRENT_DATE - INTERVAL '30 DAY'
|
||||
GROUP BY channel_id
|
||||
) s ON c.id = s.channel_id
|
||||
ORDER BY c.created_at DESC
|
||||
@@ -357,7 +365,7 @@ async def get_channel_detail(channel_id: str, user=Depends(get_current_user)):
|
||||
SELECT stat_date, messages_in, messages_out, unique_users, error_count
|
||||
FROM im_bot_daily_stats
|
||||
WHERE channel_id = :cid
|
||||
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
|
||||
AND stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
|
||||
ORDER BY stat_date
|
||||
"""), {"cid": channel_id})
|
||||
channel['stats_7d'] = _rows_to_dict(stats)
|
||||
@@ -428,7 +436,7 @@ async def activate_channel(channel_id: str, user=Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_execute_raw(db, text(
|
||||
"UPDATE channels SET is_active=1, status='connected' WHERE id=:cid"
|
||||
"UPDATE channels SET is_active=true, status='connected' WHERE id=:cid"
|
||||
), {"cid": channel_id})
|
||||
return {"success": True, "message": "Channel activated"}
|
||||
except Exception as e:
|
||||
@@ -443,7 +451,7 @@ async def deactivate_channel(channel_id: str, user=Depends(get_current_user)):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_execute_raw(db, text(
|
||||
"UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid"
|
||||
"UPDATE channels SET is_active=false, status='disconnected' WHERE id=:cid"
|
||||
), {"cid": channel_id})
|
||||
return {"success": True, "message": "Channel deactivated"}
|
||||
except Exception as e:
|
||||
@@ -773,13 +781,13 @@ async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_curren
|
||||
(bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at)
|
||||
VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""),
|
||||
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
|
||||
"connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()})
|
||||
"connected_at": _beijing_now(), "created_at": _beijing_now(), "updated_at": _beijing_now()})
|
||||
else:
|
||||
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
|
||||
bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl,
|
||||
status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""),
|
||||
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
|
||||
"connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
|
||||
"connected_at": _beijing_now(), "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
|
||||
row = _wechat_row(db)
|
||||
aid = _row_to_dict(row)["id"] if row else 1
|
||||
_start_wechat_receiver(aid)
|
||||
@@ -821,7 +829,7 @@ async def wechat_disconnect(user=Depends(get_current_user)):
|
||||
row = _wechat_row(db)
|
||||
if row:
|
||||
_execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"),
|
||||
{"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
|
||||
{"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
|
||||
return {"success": True, "message": "已断开个人微信连接"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -855,17 +855,34 @@ def _test_mcp_server(server, db) -> dict:
|
||||
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
|
||||
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
|
||||
import asyncio as _aio
|
||||
tools = _aio.run(_lmt(sd, timeout=15))
|
||||
# raise_on_error=True 让网络异常透传,暴露真实错误而非 "no tools found"
|
||||
tools = _aio.run(_lmt(sd, timeout=15, raise_on_error=True))
|
||||
result["success"] = bool(tools)
|
||||
result["details"]["tools_count"] = len(tools)
|
||||
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
|
||||
result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
|
||||
if tools:
|
||||
result["message"] = f"Connected — {len(tools)} tools available"
|
||||
else:
|
||||
result["message"] = "Connected but no tools found (server returned empty list)"
|
||||
result["details"]["status_code"] = 200
|
||||
if tools:
|
||||
server.tools_count = len(tools)
|
||||
server.status = "connected"
|
||||
except Exception as e:
|
||||
result["message"] = f"Connection failed: {str(e)}"
|
||||
# 把真实异常(类型+消息)透传给前端,告别误导性的 "Connected but no tools found"
|
||||
err_type = type(e).__name__
|
||||
err_msg = str(e)[:300]
|
||||
result["message"] = f"{err_type}: {err_msg}"
|
||||
result["details"]["error_type"] = err_type
|
||||
result["details"]["error"] = err_msg
|
||||
if "ConnectError" in err_type or "Timeout" in err_type:
|
||||
result["details"]["hint"] = "网络层无法到达 MCP 服务器,请检查代理配置或服务端 URL"
|
||||
elif "404" in err_msg or "NotFound" in err_type:
|
||||
result["details"]["hint"] = "服务端 URL 不存在或路径错误"
|
||||
elif "401" in err_msg or "Unauthorized" in err_type:
|
||||
result["details"]["hint"] = "认证失败:检查请求头中的 Authorization"
|
||||
else:
|
||||
result["details"]["hint"] = "检查认证头/transport_type 或服务端状态"
|
||||
|
||||
elif transport == "sse":
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from ...database import get_db, SessionLocal
|
||||
from ...models import AIModel, ModelProvider, SiteSetting
|
||||
from ...models.litellm_sync import sync_create_model, sync_update_model, sync_delete_model, sync_health_check
|
||||
from ...auth import get_current_admin
|
||||
from ...middleware.log_service import LogService
|
||||
from ...capability_registry import (
|
||||
@@ -475,6 +476,21 @@ async def create_model(
|
||||
db.add(model)
|
||||
db.commit()
|
||||
|
||||
# #965: LiteLLM model sync
|
||||
import threading
|
||||
def _sync_create():
|
||||
try:
|
||||
from ...models.litellm_sync import sync_create_model
|
||||
litellm_model = "openai/" + data.name
|
||||
api_base = provider.api_base_url if provider else data.endpoint or ""
|
||||
api_key = data.api_key or (provider.api_key if provider else "") or ""
|
||||
if api_base:
|
||||
sync_create_model(data.name, litellm_model, api_base.rstrip("/"), api_key)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("aiflow").warning(f"[litellm-sync] create failed: {e}")
|
||||
threading.Thread(target=_sync_create, daemon=True).start()
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
@@ -588,6 +604,24 @@ async def update_model(
|
||||
model.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
# #965: LiteLLM model sync
|
||||
import threading
|
||||
def _sync_update():
|
||||
try:
|
||||
from ...models.litellm_sync import sync_update_model
|
||||
litellm_model = "openai/" + (data.name if data.name else model.name)
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == model.provider_id).first()
|
||||
api_base = provider.api_base_url if provider else model.endpoint or ""
|
||||
api_key = data.api_key if data.api_key else (provider.api_key if provider else "") or ""
|
||||
old_name = model.name
|
||||
new_name = data.name if data.name else old_name
|
||||
if api_base:
|
||||
sync_update_model(old_name, new_name, litellm_model, api_base.rstrip("/"), api_key)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger("aiflow").warning(f"[litellm-sync] update failed: {e}")
|
||||
threading.Thread(target=_sync_update, daemon=True).start()
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
@@ -612,6 +646,16 @@ async def delete_model(
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
model_name = model.name
|
||||
# #965: 同步删除到 LiteLLM(异步)
|
||||
delete_model_name = model.name
|
||||
import threading
|
||||
def _sync_delete():
|
||||
try:
|
||||
sync_delete_model(delete_model_name)
|
||||
except Exception as e:
|
||||
logging.getLogger("aiflow").warning(f"[litellm-sync] delete failed for {delete_model_name}: {e}")
|
||||
threading.Thread(target=_sync_delete, daemon=True).start()
|
||||
|
||||
db.delete(model)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -442,7 +442,7 @@ def revoke_license(
|
||||
raise HTTPException(404, "License not found")
|
||||
|
||||
_execute_raw(db, text(
|
||||
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid"
|
||||
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = false WHERE id = :lid"
|
||||
), {"reason": reason, "lid": license_id})
|
||||
|
||||
return {"code": 0, "message": "License revoked"}
|
||||
@@ -623,7 +623,7 @@ def get_usage_stats(db: Session = Depends(get_db)):
|
||||
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
|
||||
))
|
||||
today_reports = _execute_raw(db, text(
|
||||
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()"
|
||||
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURRENT_DATE"
|
||||
))
|
||||
by_type = _execute_raw(db, text(
|
||||
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Platform Auth Verify Endpoint
|
||||
用于 nginx auth_request 子请求验证平台 JWT
|
||||
返回 200 + X-User-Id/X-User-Name headers 或 401
|
||||
"""
|
||||
import logging
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.auth import decode_access_token
|
||||
|
||||
logger = logging.getLogger("aiflow.auth_verify")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/verify")
|
||||
@router.head("/verify")
|
||||
async def verify_auth(request: Request):
|
||||
"""
|
||||
nginx auth_request 验证端点
|
||||
从请求中提取 JWT (Authorization header 或 cookie)并验证
|
||||
��回 200 + 用户信息 headers,或 401
|
||||
"""
|
||||
token = None
|
||||
|
||||
# 1. 尝试 Authorization: Bearer <token>
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
|
||||
# 2. 尝试 cookie 中的 jwt token
|
||||
if not token:
|
||||
token = request.cookies.get("jwt") or request.cookies.get("token") or request.cookies.get("access_token")
|
||||
|
||||
# 3. 尝试 X-Platform-Token header
|
||||
if not token:
|
||||
token = request.headers.get("X-Platform-Token")
|
||||
|
||||
if not token:
|
||||
logger.debug("No token found in request")
|
||||
return Response(status_code=401, headers={"X-Auth-Error": "no_token"})
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
logger.debug("Invalid or expired token")
|
||||
return Response(status_code=401, headers={"X-Auth-Error": "invalid_token"})
|
||||
|
||||
user_id = payload.get("sub", "")
|
||||
user_name = payload.get("username", "")
|
||||
user_role = payload.get("role", "user")
|
||||
tenant_id = payload.get("tenant_id", "")
|
||||
|
||||
if not user_id:
|
||||
return Response(status_code=401, headers={"X-Auth-Error": "no_user_id"})
|
||||
|
||||
logger.info(f"Auth verify OK: user={user_id}, name={user_name}")
|
||||
|
||||
return Response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"X-User-Id": str(user_id),
|
||||
"X-User-Name": str(user_name) if user_name else str(user_id),
|
||||
"X-User-Role": str(user_role),
|
||||
"X-Tenant-Id": str(tenant_id) if tenant_id else "",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Platform Billing Webhook
|
||||
接收 LiteLLM spend 回调,写入 token_usage_logs 并扣减 user_balances
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy import text
|
||||
from app.database import SessionLocal
|
||||
|
||||
logger = logging.getLogger("aiflow.billing")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SpendEvent(BaseModel):
|
||||
"""LiteLLM spend log event"""
|
||||
request_id: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
total_tokens: int = 0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
spend: float = 0.0
|
||||
user: Optional[str] = None # platform user_id passed via metadata
|
||||
metadata: Optional[dict] = None
|
||||
startTime: Optional[str] = None
|
||||
endTime: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def billing_webhook(event: SpendEvent, request: Request):
|
||||
"""
|
||||
接收 LiteLLM spend webhook 回调
|
||||
记录 token 使用并扣减用户余额
|
||||
"""
|
||||
# 验证来源
|
||||
api_key = event.api_key or ""
|
||||
metadata = event.metadata or {}
|
||||
|
||||
# 只处理 mumuainovel 的用量
|
||||
app_name = metadata.get("app", "")
|
||||
if app_name != "mumuainovel" and "mumu" not in api_key:
|
||||
# 也检查是否有自定义的 X-Source header
|
||||
source = request.headers.get("X-Source", "")
|
||||
if source != "mumuainovel":
|
||||
return {"status": "skipped", "reason": "not_mumuainovel"}
|
||||
|
||||
user_id = event.user or metadata.get("user_id", "mumu-user")
|
||||
model_name = event.model or "unknown"
|
||||
spend = max(event.spend, 0)
|
||||
total_tokens = event.total_tokens
|
||||
prompt_tokens = event.prompt_tokens
|
||||
completion_tokens = event.completion_tokens
|
||||
|
||||
if total_tokens == 0 and spend == 0:
|
||||
return {"status": "skipped", "reason": "zero_usage"}
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# 写入 token_usage_logs
|
||||
db.execute(text("""
|
||||
INSERT INTO token_usage_logs (id, user_id, model_name, app_id, app_name,
|
||||
prompt_tokens, completion_tokens, total_tokens, cost, created_at)
|
||||
VALUES (gen_random_uuid(), :uid, :model, :app_id, :app_name,
|
||||
:pt, :ct, :tt, :cost, :ts)
|
||||
"""), {
|
||||
"uid": user_id,
|
||||
"model": model_name,
|
||||
"app_id": "mumuainovel-001",
|
||||
"app_name": "MuMuAINovel",
|
||||
"pt": prompt_tokens,
|
||||
"ct": completion_tokens,
|
||||
"tt": total_tokens,
|
||||
"cost": spend,
|
||||
"ts": datetime.utcnow(),
|
||||
})
|
||||
|
||||
# 扣减余额
|
||||
result = db.execute(text("""
|
||||
UPDATE user_balances
|
||||
SET balance = balance - :cost,
|
||||
total_used = total_used + :cost,
|
||||
updated_at = NOW()
|
||||
WHERE user_id = :uid AND balance >= :cost
|
||||
RETURNING id
|
||||
"""), {"cost": spend, "uid": user_id})
|
||||
|
||||
if result.rowcount == 0:
|
||||
# 余额不足或无记录,创建新余额记录
|
||||
# 检查是否存在
|
||||
exists = db.execute(text(
|
||||
"SELECT id FROM user_balances WHERE user_id = :uid"
|
||||
), {"uid": user_id}).fetchone()
|
||||
if not exists:
|
||||
db.execute(text("""
|
||||
INSERT INTO user_balances (id, user_id, balance, total_used, created_at, updated_at)
|
||||
VALUES (gen_random_uuid(), :uid, :balance, :cost, NOW(), NOW())
|
||||
"""), {"uid": user_id, "balance": 10.0 - spend, "cost": spend})
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Billing: user={user_id} model={model_name} tokens={total_tokens} spend=${spend:.6f}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Billing webhook error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"status": "ok", "user_id": user_id, "spend": spend}
|
||||
|
||||
|
||||
@router.get("/webhook/health")
|
||||
async def billing_webhook_health():
|
||||
return {"status": "ok", "service": "billing-webhook"}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Bug-audit endpoint for runtime regression checks."""
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from app.database import get_db
|
||||
from app.models import SiteSetting, AIModel
|
||||
from app.capability_registry import CAPABILITY_TYPES, get_capability_defaults, resolve_auto_model
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/bug-audit")
|
||||
def bug_audit(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Runtime self-check: 验证平台大模型路由/能力配置的关键不变量。
|
||||
用于发现重复 bug(比如「已修复又出现」)。
|
||||
"""
|
||||
issues = []
|
||||
warnings = []
|
||||
checks = []
|
||||
|
||||
# Check 1: 文本类能力(text/thinking) 必须指向启用的非图像/视频模型
|
||||
TEXT_FAMILY = {"text", "thinking", "vision", "tool"}
|
||||
defaults = get_capability_defaults(db)
|
||||
for cap in ("text", "thinking"):
|
||||
mid = defaults.get(cap)
|
||||
if not mid:
|
||||
issues.append(f"{cap}: site_settings.model_capability_defaults 未设置")
|
||||
continue
|
||||
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
|
||||
if not m:
|
||||
issues.append(f"{cap}: 指向的模型 {mid} 不存在或已禁用")
|
||||
continue
|
||||
caps = [c.lower() for c in (m.capabilities or [])]
|
||||
if not ("text" in caps or "thinking" in caps):
|
||||
issues.append(f"{cap}: 指向的 {m.name} 没有 text/thinking 能力 (caps={caps})")
|
||||
if "image" in caps or "video" in caps:
|
||||
warnings.append(f"{cap}: 默认模型 {m.name} 含 image/video 能力 (普通多轮对话可能误判)")
|
||||
checks.append(f"{cap} -> {m.name} (caps={caps})")
|
||||
|
||||
# Check 2: 必须有 is_default=true 的模型
|
||||
default_model = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||
if not default_model:
|
||||
issues.append("没有任何 is_default=true 且启用的模型")
|
||||
else:
|
||||
checks.append(f"is_default -> {default_model.name}")
|
||||
|
||||
# Check 3: 所有 enabled 的 chat 模型都应该能正常 resolve
|
||||
for cap in ("text", "image", "video", "audio", "embedding", "rerank", "asr", "ocr"):
|
||||
m = resolve_auto_model(cap, db)
|
||||
if m:
|
||||
checks.append(f"resolve_auto_model({cap}) -> {m.name}")
|
||||
else:
|
||||
warnings.append(f"resolve_auto_model({cap}) 返回 None (该能力无默认模型)")
|
||||
|
||||
# Check 4: detect_media_intent 单元自检(用经典样本)
|
||||
from app.api.chat_media import detect_media_intent
|
||||
test_cases = [
|
||||
("平台有哪些技能能", None), # 普通文字
|
||||
("你好", None), # 普通问候
|
||||
("画一只小猫", "image"), # 明确图像
|
||||
("生成一个视频介绍", "video"), # 明确视频
|
||||
("今天天气如何?", None), # 疑问句
|
||||
]
|
||||
for text_in, expected in test_cases:
|
||||
result = detect_media_intent(text_in)
|
||||
if result != expected:
|
||||
issues.append(f"detect_media_intent({text_in!r})={result!r} 期望 {expected!r}")
|
||||
else:
|
||||
checks.append(f"detect_media_intent({text_in!r})={result!r} ✓")
|
||||
|
||||
# 汇总
|
||||
healthy = len(issues) == 0
|
||||
return {
|
||||
"healthy": healthy,
|
||||
"issues": issues,
|
||||
"warnings": warnings,
|
||||
"checks": checks,
|
||||
"summary": {
|
||||
"total_checks": len(checks),
|
||||
"issues": len(issues),
|
||||
"warnings": len(warnings),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Channel Webhook 入口 — /app/api/channel/<channel_id>
|
||||
任务 #935 · 2026-08-09
|
||||
|
||||
- Telegram 走 POST JSON
|
||||
- secret_token 校验(头部 X-Telegram-Bot-Api-Secret-Token)
|
||||
- 其他渠道后续扩展(飞书 / 钉钉加密回调 / 企业微信 AES 解密各不同,放各 adapter 自己)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.core.channel_adapter import ChannelRouter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/app/api/channel", tags=["channel-webhook"])
|
||||
|
||||
|
||||
@router.post("/{channel_id}")
|
||||
async def channel_webhook(channel_id: str, request: Request) -> dict:
|
||||
r = ChannelRouter.shared()
|
||||
if channel_id not in r.channels:
|
||||
raise HTTPException(404, f"channel not registered: {channel_id}")
|
||||
adapter = r._adapters.get(channel_id) # noqa: SLF001 (内部访问 OK)
|
||||
raw = await request.body()
|
||||
# secret 校验(各家 header 名不同,Telegram: X-Telegram-Bot-Api-Secret-Token)
|
||||
expected_secret = getattr(adapter, "webhook_secret", "")
|
||||
if expected_secret:
|
||||
secret_header = request.headers.get("x-telegram-bot-api-secret-token", "")
|
||||
if not secret_header or not hmac.compare_digest(secret_header, expected_secret):
|
||||
raise HTTPException(401, "invalid secret")
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "invalid json")
|
||||
# 调对应 adapter 的 receive_update(Telegram 是同步 await;其他渠道可异步)
|
||||
if hasattr(adapter, "receive_update"):
|
||||
import asyncio
|
||||
await adapter.receive_update(payload)
|
||||
else:
|
||||
raise HTTPException(501, "channel has no receive_update impl")
|
||||
return {"ok": True}
|
||||
@@ -84,6 +84,7 @@ from ..auth import get_current_user
|
||||
from ..platform_knowledge import get_platform_knowledge
|
||||
|
||||
from ..mcp_client import list_mcp_tools, call_mcp_tool
|
||||
from ..core.claw_adapters import SseAdapter
|
||||
|
||||
from ..middleware.log_service import LogService
|
||||
|
||||
@@ -142,6 +143,7 @@ from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRe
|
||||
|
||||
|
||||
from app.api.chat_service import _send_message_core
|
||||
from app.core.llm_router import set_route_ctx
|
||||
|
||||
|
||||
|
||||
@@ -153,6 +155,17 @@ _MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Media relay client (trust_env=False to bypass proxy for image fetches)
|
||||
_media_client = None
|
||||
def _get_media_client():
|
||||
global _media_client
|
||||
if _media_client is None:
|
||||
_media_client = httpx.Client(timeout=30.0, follow_redirects=True, trust_env=False)
|
||||
return _media_client
|
||||
|
||||
|
||||
@router.get("/media/relay")
|
||||
|
||||
|
||||
@@ -195,6 +208,42 @@ async def media_relay(url: str = Query(...)):
|
||||
|
||||
"""
|
||||
|
||||
import urllib.parse as _up
|
||||
import httpx
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
host = (_up.urlparse(url).hostname or "").lower()
|
||||
# 白名单域名检查
|
||||
allowed = ("siliconflow.cn", "s3.siliconflow.cn", "aliyuncs.com", "tencentcos.cn",
|
||||
"qpic.cn", "alicdn.com", "api.openai.com", "myqcloud.com")
|
||||
if not any(a in host for a in allowed):
|
||||
raise HTTPException(status_code=400, detail="unsupported media host")
|
||||
|
||||
try:
|
||||
client = _get_media_client()
|
||||
resp = client.get(url, timeout=30.0, follow_redirects=True)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"upstream returned {resp.status_code}")
|
||||
ct = resp.headers.get("content-type", "application/octet-stream")
|
||||
cl = resp.headers.get("content-length")
|
||||
headers = {}
|
||||
if ct:
|
||||
headers["content-type"] = ct
|
||||
if cl:
|
||||
headers["content-length"] = cl
|
||||
headers["cache-control"] = "public, max-age=3600"
|
||||
return StreamingResponse(
|
||||
resp.iter_bytes(chunk_size=65536),
|
||||
status_code=200,
|
||||
headers=headers,
|
||||
media_type=ct
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=504, detail="upstream timeout")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"relay error: {str(e)[:200]}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1559,6 +1608,12 @@ async def send_message(
|
||||
|
||||
from app.core.observability import span
|
||||
|
||||
# #876 双 Claw 维度:入口最早注入,确保外层 span 即带 tenant_id + claw_type 标签
|
||||
set_route_ctx(
|
||||
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
|
||||
claw_type="web",
|
||||
)
|
||||
|
||||
async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)):
|
||||
|
||||
return await _send_message_core(conv_id, req, db, current_user)
|
||||
@@ -1570,38 +1625,44 @@ async def send_message_stream(
|
||||
req: SendMessageRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""SSE 流式聊天端点(Phase 0)。
|
||||
与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event
|
||||
回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。
|
||||
"""SSE 流式聊天端点(#873)。
|
||||
|
||||
与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
|
||||
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
|
||||
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
queue = asyncio.Queue()
|
||||
# #876 双 Claw 维度:入口最早注入。runner() 经 asyncio.create_task 派生子协程,
|
||||
# contextvars 自动随上下文传播,使 _send_message_core 内所有 span 均带 tenant_id + claw_type。
|
||||
set_route_ctx(
|
||||
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
|
||||
claw_type="web",
|
||||
)
|
||||
|
||||
async def on_event(e):
|
||||
await queue.put(e)
|
||||
adapter = SseAdapter(
|
||||
trace_id=f"sse-{conv_id}",
|
||||
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
|
||||
)
|
||||
|
||||
async def runner():
|
||||
try:
|
||||
await _send_message_core(
|
||||
conv_id, req, db, current_user,
|
||||
stream=True, on_event=on_event,
|
||||
stream=True, on_event=adapter,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True)
|
||||
await queue.put({"type": "error", "message": str(exc)})
|
||||
await adapter.emit_error(str(exc))
|
||||
finally:
|
||||
await queue.put({"type": "__end__"})
|
||||
await adapter.close()
|
||||
|
||||
task = asyncio.create_task(runner())
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
while True:
|
||||
e = await queue.get()
|
||||
if e.get("type") == "__end__":
|
||||
break
|
||||
yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
|
||||
async for frame in adapter.frames():
|
||||
yield frame
|
||||
finally:
|
||||
await task
|
||||
db.close()
|
||||
@@ -1611,10 +1672,6 @@ async def send_message_stream(
|
||||
db.close()
|
||||
raise
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/conversations/{conv_id}/clear")
|
||||
|
||||
|
||||
@@ -2655,7 +2712,7 @@ def get_models(db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
|
||||
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
|
||||
models = db.query(AIModel).filter(AIModel.is_enabled == True).order_by(AIModel.is_default.desc(), AIModel.name.asc()).all()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1164,6 +1164,26 @@ def _build_system_prompt(
|
||||
|
||||
|
||||
system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"]
|
||||
|
||||
# === Agent 工具使用标准指引(对标 OpenAI GPT-4.1 / Anthropic ReAct) ===
|
||||
system_parts.append("""【工具使用规则——请严格遵守】
|
||||
|
||||
1. 持久性:请持续工作直到用户的问题完全解决,不要中途放弃。
|
||||
如果一次操作未得到期望结果,请尝试替代方法或不同参数组合,直到成功。
|
||||
|
||||
2. 工具优先:如果你不确定某个信息,或需要获取外部数据、实时信息来完成用户请求,
|
||||
请务必使用工具来获取——绝对不要猜测或编造答案。
|
||||
|
||||
3. 规划与反思:调用工具前,先思考需要什么信息、按什么顺序调用。结果返回后,
|
||||
检查是否符合预期;不符合则调整计划重试。
|
||||
|
||||
4. 何时不调用工具:纯闲聊/问候/简单常识性问题/用户明确要求不要用工具时。
|
||||
|
||||
5. 工具调用顺序:先获取信息的工具(搜索/查询),再执行操作的工具(创建/修改)。
|
||||
不要跳过必要的前置查询步骤。
|
||||
|
||||
6. 工具结果反馈:工具返回的结果是客观事实,请忠实地引用,不要虚构或修改。
|
||||
""")
|
||||
|
||||
|
||||
|
||||
@@ -1251,7 +1271,7 @@ def _build_system_prompt(
|
||||
|
||||
|
||||
|
||||
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
|
||||
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 \"\"\" 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
|
||||
|
||||
|
||||
|
||||
@@ -2536,7 +2556,6 @@ def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
|
||||
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
|
||||
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
|
||||
"""
|
||||
ensure_structured_column()
|
||||
summary_block = None
|
||||
if include_summary:
|
||||
try:
|
||||
@@ -2707,7 +2726,7 @@ def ensure_structured_column():
|
||||
with engine.connect() as conn:
|
||||
exists = conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
|
||||
"WHERE table_schema = CURRENT_DATABASE() AND table_name='conversation_summaries' "
|
||||
"AND column_name='structured_memory'"
|
||||
)).scalar()
|
||||
if not exists:
|
||||
@@ -2832,7 +2851,6 @@ async def _maybe_compress_history(conv_id):
|
||||
|
||||
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
|
||||
"""
|
||||
ensure_structured_column()
|
||||
sdb = None
|
||||
try:
|
||||
sdb = SessionLocal()
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, desc, asc, func
|
||||
from sqlalchemy import and_, desc, asc, func, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
import uuid
|
||||
@@ -505,7 +506,11 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
|
||||
|
||||
|
||||
|
||||
api_key = text_model.api_key or (provider.api_key if provider else None)
|
||||
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
|
||||
if text_model.endpoint and "qihu-aiflow-litellm" in text_model.endpoint:
|
||||
api_key = "sk-litellm-master-key-aiflow-2026"
|
||||
else:
|
||||
api_key = text_model.api_key or (provider.api_key if provider else None)
|
||||
|
||||
|
||||
|
||||
@@ -893,7 +898,7 @@ def _build_media_route(db, capability: str, preferred_id: str = None):
|
||||
|
||||
others = db.query(_AM).filter(
|
||||
_AM.is_enabled == True,
|
||||
func.json_contains(_AM.capabilities, f'"{capability}"'),
|
||||
_AM.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
|
||||
).order_by(_AM.is_default.desc(), _AM.created_at.asc()).all()
|
||||
for m in others:
|
||||
_add(m)
|
||||
@@ -935,7 +940,11 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
|
||||
|
||||
|
||||
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
|
||||
if ai_model.endpoint and "qihu-aiflow-litellm" in ai_model.endpoint:
|
||||
api_key = "sk-litellm-master-key-aiflow-2026"
|
||||
else:
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.phase2_risk import TOOL_RISK_LEVELS, assess_tool_risk, create_approval_request, check_approval_status, approve_or_reject, DEFAULT_RISK_LEVELS
|
||||
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
|
||||
|
||||
|
||||
@@ -47,6 +48,7 @@ import json
|
||||
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
|
||||
|
||||
@@ -191,6 +193,23 @@ from ..middleware.log_service import LogService
|
||||
|
||||
|
||||
from ..capability_registry import resolve_auto_model
|
||||
try:
|
||||
from ..core.llm_router import (
|
||||
resolve_api_key as _llm_resolve_api_key,
|
||||
route as _llm_route,
|
||||
set_route_ctx as _set_route_ctx,
|
||||
SHADOW as _LLM_ROUTER_SHADOW,
|
||||
)
|
||||
except Exception as _llm_router_err:
|
||||
import os as _os, logging as _logging
|
||||
_logging.getLogger(__name__).warning(f"[chat] llm_router import failed, router disabled: {_llm_router_err}")
|
||||
def _llm_resolve_api_key(api_url, model_api_key, provider_api_key):
|
||||
if api_url and "qihu-aiflow-litellm" in api_url:
|
||||
return _os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
|
||||
return model_api_key or provider_api_key
|
||||
_llm_route = None
|
||||
_set_route_ctx = None
|
||||
_LLM_ROUTER_SHADOW = True
|
||||
|
||||
|
||||
|
||||
@@ -1611,6 +1630,19 @@ async def _send_message_core(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Phase 1: Bill tokens on stream completion
|
||||
try:
|
||||
_pt = prompt_tokens if "prompt_tokens" in dir() else 0
|
||||
_ct = completion_tokens if "completion_tokens" in dir() else 0
|
||||
_uid = user_id if "user_id" in dir() else "unknown"
|
||||
if _pt or _ct:
|
||||
asyncio.create_task(_bill_tokens(
|
||||
_uid, _pt, _ct,
|
||||
ai_model if "ai_model" in dir() else "unknown",
|
||||
conv_id))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
|
||||
|
||||
@@ -2168,6 +2200,14 @@ async def _send_message_core(
|
||||
|
||||
|
||||
from app.core.orchestrator import run_graph
|
||||
# #874 双 Claw 对齐:编排过程以统一 EngineEvent 下发,
|
||||
# 租户/通道标签复用 #875 的 ContextVar,避免再加一层参数透传。
|
||||
try:
|
||||
from app.core.llm_router import get_route_ctx as _get_route_ctx
|
||||
_rctx = _get_route_ctx()
|
||||
except Exception:
|
||||
_rctx = {}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2196,6 +2236,9 @@ async def _send_message_core(
|
||||
|
||||
|
||||
trace_id=get_trace_id(),
|
||||
on_event=on_event,
|
||||
claw_type=(_rctx.get("claw_type") or "web"),
|
||||
tenant_id=(_rctx.get("tenant_id") or ""),
|
||||
|
||||
|
||||
|
||||
@@ -2805,6 +2848,16 @@ async def _send_message_core(
|
||||
|
||||
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
||||
|
||||
# ===== LLM Router(#875 影子模式)=====
|
||||
try:
|
||||
if _llm_route is not None:
|
||||
_set_route_ctx(tenant_id=getattr(current_user, "tenant_id", "") or "", claw_type="web")
|
||||
_rm = _llm_route("text", db=db, legacy_primary=_try_models[0].name if _try_models else None)
|
||||
if not _LLM_ROUTER_SHADOW and _rm.models:
|
||||
_try_models = _rm.models
|
||||
except Exception as _e:
|
||||
logger.warning(f"[chat] LLM Router 观测失败(降级忽略): {_e}")
|
||||
|
||||
_retry_log = []
|
||||
|
||||
_all_failed = False
|
||||
@@ -2879,6 +2932,13 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
|
||||
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
|
||||
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
|
||||
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
|
||||
logger.info("[chat] Override media-cap model %s -> text (no media intent in: %.50s)", ai_model.name, req.content)
|
||||
ai_model = resolve_auto_model("text", db)
|
||||
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
|
||||
if ai_model and ("image" in caps or "video" in caps):
|
||||
|
||||
|
||||
@@ -3040,6 +3100,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
auth_header = provider.auth_header if provider and hasattr(provider, "auth_header") else None
|
||||
|
||||
|
||||
|
||||
@@ -3057,6 +3118,13 @@ async def _send_message_core(
|
||||
|
||||
api_url = ai_model.endpoint
|
||||
|
||||
# 2026-07-29 fix: 调用 LiteLLM 端点时,必须用 master key (sk-litellm-...) 而非模型自身 key (tp-c...)
|
||||
# LiteLLM 内置每个 model 的真实 key 在 litellm_config.yaml,chat 不应直接传模型的 key
|
||||
api_key = _llm_resolve_api_key(api_url, api_key, provider.api_key if provider else None)
|
||||
if api_url and "qihu-aiflow-litellm" in api_url:
|
||||
auth_header = None # 用默认 Authorization: Bearer
|
||||
logger.info("[chat] LiteLLM endpoint detected, using master key instead of model key")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3395,7 +3463,9 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
_fname = f"mcp_tool_{mcp_idx}"
|
||||
_sname = re.sub(r'[^A-Za-z0-9_-]', '_', _srv.get("name","unknown")[:30])
|
||||
_tname = re.sub(r'[^A-Za-z0-9_-]', '_', _t.get("name","tool")[:50])
|
||||
_fname = f"mcp__{_sname}__{_tname}__{mcp_idx}"
|
||||
|
||||
|
||||
|
||||
@@ -3460,6 +3530,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
_schema = {"type": "object", "properties": {}}
|
||||
_schema = _normalize_mcp_schema(_schema)
|
||||
|
||||
|
||||
|
||||
@@ -4354,11 +4425,15 @@ async def _send_message_core(
|
||||
if isinstance(function_definitions, list):
|
||||
function_definitions.append({
|
||||
"name": "install_app",
|
||||
"description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。",
|
||||
"description": "安装新的技能(Skill)、MCP 服务或外部 Agent 到平台。\n"
|
||||
"\n"
|
||||
"【何时调用】用户明确提供链接并表示要安装/导入/添加时。例如:'帮我安装这个技能 https://github.com/...'\n"
|
||||
"【何时不调用】用户只询问安装方法、讨论某个工具但没有提供可安装链接时。\n"
|
||||
"【注意】安装完成后该技能/MCP/Agent 可在本对话中直接使用,无需额外配置。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"},
|
||||
"url": {"type": "string", "description": "要安装的资源完整地址。必须是 http/https URL,支持 GitHub 仓库、mcp.so、modelscope 等平台链接。"},
|
||||
"type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"},
|
||||
},
|
||||
"required": ["url"],
|
||||
@@ -4561,7 +4636,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
max_tool_rounds = 5
|
||||
max_tool_rounds = 15 # 对齐 GPT-4.1/Anthropic 标准: 足够完成复杂多步工具调用
|
||||
|
||||
|
||||
|
||||
@@ -4814,6 +4889,7 @@ async def _send_message_core(
|
||||
proxy=_proxy_for_url(api_url),
|
||||
timeout=ai_model.timeout or 120,
|
||||
stream=True,
|
||||
auth_header=auth_header,
|
||||
):
|
||||
if _kind == "content":
|
||||
_sc += _text
|
||||
@@ -4831,6 +4907,7 @@ async def _send_message_core(
|
||||
request_body,
|
||||
proxy=_proxy_for_url(api_url),
|
||||
timeout=ai_model.timeout or 120,
|
||||
auth_header=auth_header,
|
||||
)
|
||||
|
||||
except Exception as _exc:
|
||||
@@ -5189,7 +5266,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None
|
||||
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp__") else None
|
||||
|
||||
|
||||
|
||||
@@ -5221,7 +5298,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}"
|
||||
tool_result = f"[策略拦截: {_policy_check.get('message', _reason)}] 建议: 检查权限设置或新开对话"
|
||||
|
||||
|
||||
|
||||
@@ -5373,7 +5450,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
if func_name.startswith("mcp_tool_"):
|
||||
if func_name.startswith("mcp__"):
|
||||
|
||||
|
||||
|
||||
@@ -5397,7 +5474,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
tool_result = "[MCP 工具未找到或映射缺失]"
|
||||
tool_result = "[MCP 工具未找到] 建议: 检查工具名或尝试其他MCP工具。可用: " + str(list(mcp_tool_map.keys())[:5])
|
||||
|
||||
|
||||
|
||||
@@ -7927,7 +8004,7 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
asyncio.create_task(
|
||||
_launch_media_task_bg(ai_msg.id, ai_model.id, req.content, current_user.id)
|
||||
|
||||
|
||||
|
||||
@@ -7943,7 +8020,6 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
_generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id)
|
||||
|
||||
|
||||
|
||||
@@ -7959,7 +8035,6 @@ async def _send_message_core(
|
||||
|
||||
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -8397,6 +8472,35 @@ def _resolve_agent_tool_policy(agents):
|
||||
|
||||
|
||||
|
||||
def _normalize_mcp_schema(schema: dict) -> dict:
|
||||
"""MCP JSON Schema -> OpenAI function calling 兼容。
|
||||
处理: $ref, oneOf/anyOf -> first option, 剥离非标关键字"""
|
||||
if not isinstance(schema, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
if "$ref" in schema:
|
||||
return {"type": "object", "properties": {}}
|
||||
if "oneOf" in schema or "anyOf" in schema:
|
||||
opts = schema.get("oneOf") or schema.get("anyOf") or []
|
||||
if opts and isinstance(opts[0], dict):
|
||||
return _normalize_mcp_schema(opts[0])
|
||||
return {"type": "object", "properties": {}}
|
||||
if "allOf" in schema:
|
||||
merged = {"type": "object", "properties": {}}
|
||||
for s in schema.get("allOf", []):
|
||||
sub = _normalize_mcp_schema(s)
|
||||
if sub.get("properties"):
|
||||
merged["properties"].update(sub["properties"])
|
||||
return merged
|
||||
result = {}
|
||||
for k in ("type", "properties", "required", "description", "enum", "items"):
|
||||
if k in schema:
|
||||
result[k] = schema[k]
|
||||
if result.get("type") != "object":
|
||||
result["type"] = "object"
|
||||
if "properties" not in result:
|
||||
result["properties"] = {}
|
||||
return result
|
||||
|
||||
|
||||
def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count):
|
||||
|
||||
@@ -8442,7 +8546,7 @@ def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_co
|
||||
|
||||
|
||||
|
||||
is_mcp = func_name.startswith("mcp_tool_")
|
||||
is_mcp = func_name.startswith("mcp__")
|
||||
|
||||
|
||||
|
||||
@@ -8547,3 +8651,39 @@ def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_co
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 后台线程独立事件循环运行媒体生成,确保 SSE 响应结束后任务不被 uviorn 取消,
|
||||
# 避免图片/视频永远停留在「生成中…」。_generate_media_task 内部自管 DB 会话。
|
||||
def _launch_media_task_bg(ai_msg_id, ai_model_id, prompt, user_id):
|
||||
def _run():
|
||||
try:
|
||||
asyncio.run(_generate_media_task(ai_msg_id, ai_model_id, prompt, user_id))
|
||||
except Exception:
|
||||
pass
|
||||
_t = threading.Thread(target=_run, daemon=True)
|
||||
_t.start()
|
||||
|
||||
# Phase 1: Token billing
|
||||
async def _bill_tokens(user_id, prompt_tokens, completion_tokens, model_name, conv_id):
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
total = prompt_tokens + completion_tokens
|
||||
cost = round(total * 0.002 / 1000, 6)
|
||||
db.execute(text("UPDATE user_balances SET balance = balance - :c, total_used = total_used + :c, updated_at = NOW() WHERE user_id = :u AND balance >= :c"), {"c": cost, "u": user_id})
|
||||
db.execute(text("INSERT INTO token_usage_logs (id, user_id, model_name, conversation_id, prompt_tokens, completion_tokens, total_tokens, cost, created_at) VALUES (gen_random_uuid(), :u, :m, :cv, :pt, :ct, :tt, :c, NOW())"), {"u": user_id, "m": model_name, "cv": conv_id, "pt": prompt_tokens, "ct": completion_tokens, "tt": total, "c": cost})
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def get_user_balance(user_id):
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
r = db.execute(text("SELECT balance, total_used FROM user_balances WHERE user_id = :u"), {"u": user_id}).fetchone()
|
||||
db.close()
|
||||
if r: return {"balance": float(r[0]), "total_used": float(r[1])}
|
||||
return {"balance": 10.0, "total_used": 0}
|
||||
@@ -1272,7 +1272,7 @@ def _execute_skill_tool(
|
||||
|
||||
|
||||
|
||||
return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。"
|
||||
return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。如有已加载的技能,可使用其名称直接调用。"
|
||||
|
||||
|
||||
|
||||
@@ -1740,3 +1740,112 @@ def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto")
|
||||
db.commit()
|
||||
|
||||
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"
|
||||
|
||||
|
||||
|
||||
|
||||
# Phase 1: Enhanced SKILL.md parser with 9-block standard
|
||||
SKILLMD_SCHEMA = {
|
||||
"name": "name",
|
||||
"version": "version",
|
||||
"author": "author",
|
||||
"description": "description",
|
||||
"capabilities": "capabilities",
|
||||
"inputs": "inputs",
|
||||
"outputs": "outputs",
|
||||
"permissions": "permissions",
|
||||
"dependencies": "dependencies",
|
||||
"pricing": "pricing",
|
||||
}
|
||||
|
||||
def _parse_skillmd_v2(content: str) -> dict:
|
||||
"""Phase 1: Parse SKILL.md with standardized 9-block schema.
|
||||
|
||||
SKILL.md Standard v2.0 format:
|
||||
---
|
||||
name: Skill Name
|
||||
version: 1.0.0
|
||||
author: Author Name <email>
|
||||
description: |
|
||||
Multi-line description
|
||||
capabilities: [text_generation, code_execution]
|
||||
inputs:
|
||||
- name: input_text
|
||||
type: string
|
||||
description: The input text
|
||||
outputs:
|
||||
- name: result
|
||||
type: string
|
||||
description: The processed result
|
||||
permissions: [file_read, network]
|
||||
dependencies: [python>=3.10, requests]
|
||||
pricing:
|
||||
free_tier: 10/day
|
||||
pro_tier: 100/day
|
||||
---
|
||||
Skill instruction content...
|
||||
"""
|
||||
import re, yaml
|
||||
|
||||
result = {
|
||||
"name": None, "version": "1.0.0", "author": None,
|
||||
"description": None, "capabilities": [], "inputs": [],
|
||||
"outputs": [], "permissions": [], "dependencies": [],
|
||||
"pricing": {}, "instructions": "", "valid": False, "errors": []
|
||||
}
|
||||
|
||||
if not content or not content.strip():
|
||||
result["errors"].append("Empty content")
|
||||
return result
|
||||
|
||||
# Extract YAML frontmatter
|
||||
fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)', content, re.DOTALL)
|
||||
|
||||
if fm_match:
|
||||
try:
|
||||
fm = yaml.safe_load(fm_match.group(1))
|
||||
if isinstance(fm, dict):
|
||||
for key in SKILLMD_SCHEMA:
|
||||
if key in fm:
|
||||
result[key] = fm[key]
|
||||
result["valid"] = True
|
||||
except yaml.YAMLError as e:
|
||||
result["errors"].append(f"YAML parse error: {e}")
|
||||
result["instructions"] = fm_match.group(2).strip()
|
||||
else:
|
||||
# No frontmatter: treat entire content as instructions
|
||||
result["instructions"] = content.strip()
|
||||
result["description"] = content[:200].strip()
|
||||
|
||||
# Validate required fields
|
||||
if not result["name"]:
|
||||
result["errors"].append("Missing required field: name")
|
||||
if not result["description"]:
|
||||
result["errors"].append("Missing recommended field: description")
|
||||
|
||||
result["valid"] = len([e for e in result["errors"] if "Missing required" in e]) == 0
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _validate_skillmd(content: str) -> dict:
|
||||
"""Validate a SKILL.md content against the v2 schema. Returns {valid, errors, warnings}."""
|
||||
parsed = _parse_skillmd_v2(content)
|
||||
errors = list(parsed["errors"])
|
||||
warnings = []
|
||||
|
||||
if not parsed["name"]:
|
||||
errors.append("REQUIRED: 'name' field is missing")
|
||||
if not parsed["description"]:
|
||||
warnings.append("Recommended: add 'description' field")
|
||||
if not parsed["version"]:
|
||||
warnings.append("Recommended: specify 'version' (e.g. 1.0.0)")
|
||||
if not parsed["capabilities"]:
|
||||
warnings.append("Recommended: list 'capabilities' for marketplace visibility")
|
||||
|
||||
return {
|
||||
"valid": len(errors) == 0,
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
"parsed": parsed
|
||||
}
|
||||
@@ -109,20 +109,20 @@ def _ensure_table(db):
|
||||
"""确保 im_bot_platform_account 表存在(首次调用时创建)。"""
|
||||
db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS im_bot_platform_account (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id VARCHAR(64) DEFAULT NULL,
|
||||
platform VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(32) DEFAULT 'disconnected',
|
||||
creds MEDIUMTEXT NULL,
|
||||
creds TEXT NULL,
|
||||
send_url VARCHAR(512) NULL,
|
||||
api_base VARCHAR(512) NULL,
|
||||
bot_token VARCHAR(512) NULL,
|
||||
webhook_token VARCHAR(64) NULL,
|
||||
last_error MEDIUMTEXT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_platform (user_id, platform)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
last_error TEXT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, platform)
|
||||
)
|
||||
"""))
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import os
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import quote as _quote
|
||||
@@ -39,17 +40,29 @@ TZ_BEIJING = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _beijing_now() -> datetime:
|
||||
"""返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。"""
|
||||
return datetime.now(TZ_BEIJING).replace(tzinfo=None)
|
||||
"""返回带时区的北京时间(aware datetime)。
|
||||
|
||||
#977 关键修复:平台已全量迁 PG(时间列均为 timestamp with time zone),
|
||||
必须返回 aware datetime。此前 .replace(tzinfo=None) 返回 naive,PG 按 UTC
|
||||
误解导致错位 8 小时(如北京时间 13:53 存成 13:53 UTC → 前端显示 21:53)。
|
||||
"""
|
||||
return datetime.now(TZ_BEIJING)
|
||||
|
||||
# ============================================================
|
||||
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
|
||||
# ============================================================
|
||||
ILINK_BASE = "https://ilinkai.weixin.qq.com"
|
||||
|
||||
# 微信/iLink 单条文本消息长度软上限。超过则分段发送,避免 iLink 返回 200
|
||||
# 但用户侧静默收不到(实测 1698 字符 HTML 链接、3011 字符 Markdown 均丢失)。
|
||||
WECHAT_TEXT_MAX_LEN = 1500
|
||||
|
||||
# 每个已连接账号一个后台接收线程,key = 账号表主键 id
|
||||
_wechat_receiver_threads: dict[int, threading.Thread] = {}
|
||||
|
||||
# typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效
|
||||
_typing_ticket_cache: dict[str, tuple[str, float]] = {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 通用 DB 工具
|
||||
@@ -123,7 +136,11 @@ def _recent_history(user_id: str, limit: int = 6) -> str:
|
||||
for r in reversed(rows):
|
||||
dd = _row_to_dict(r)
|
||||
role = "用户" if dd["role"] == "user" else "七虎AI"
|
||||
pairs.append(f"{role}:{dd['content']}")
|
||||
content = dd.get("content") or ""
|
||||
# #973:每条历史最多 500 字符,防止超长图片 URL/完整清单撑爆多轮 prompt
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "…"
|
||||
pairs.append(f"{role}:{content}")
|
||||
return "\n".join(pairs)
|
||||
except Exception:
|
||||
return ""
|
||||
@@ -143,12 +160,33 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
|
||||
f"{hist}\n\n"
|
||||
f"用户当前问题:{text}"
|
||||
)
|
||||
# 磁盘调试日志(不依赖 uvicorn logger)
|
||||
try:
|
||||
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + f" _qihua_reply user={user_id} prompt_len={len(prompt)} prompt={repr(prompt)[:500]}\n")
|
||||
except Exception:
|
||||
pass
|
||||
db = None
|
||||
try:
|
||||
from app.api.im_llm import _call_real_llm
|
||||
db = SessionLocal()
|
||||
return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
|
||||
result = _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
|
||||
try:
|
||||
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + f" _qihua_reply RESULT len={len(result) if result else 0} preview={repr(result)[:200]}\n")
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as e:
|
||||
import traceback as _tb2
|
||||
import logging
|
||||
_log = logging.getLogger('im_bot_user')
|
||||
_log.warning(f'[_qihua_reply] 异常: {e}\n{_tb2.format_exc()[:1000]}')
|
||||
try:
|
||||
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + f" _qihua_reply EXCEPTION: {e}\n{_tb2.format_exc()[:800]}\n")
|
||||
except Exception:
|
||||
pass
|
||||
return f"(AI 回复生成失败:{e})"
|
||||
finally:
|
||||
if db:
|
||||
@@ -164,7 +202,7 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/getupdates",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}},
|
||||
json={"get_updates_buf": cursor},
|
||||
timeout=40,
|
||||
)
|
||||
if not resp.ok:
|
||||
@@ -178,20 +216,38 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
|
||||
def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list):
|
||||
"""向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。
|
||||
ret=-14=会话过期需重扫;ret=-2=token 失效。"""
|
||||
import uuid as _uuid
|
||||
payload = {
|
||||
"msg": {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": "py-" + str(_uuid.uuid4()),
|
||||
"client_id": uuid.uuid4().hex,
|
||||
"message_type": 2,
|
||||
"message_state": 2,
|
||||
"context_token": context_token,
|
||||
"item_list": item_list,
|
||||
},
|
||||
"base_info": {"channel_version": "1.0.2"},
|
||||
"run_id": uuid.uuid4().hex,
|
||||
}
|
||||
}
|
||||
last_body = {}
|
||||
# 07-30 diagnostic
|
||||
import json as _json3
|
||||
try:
|
||||
# 增强诊断:记录每条消息类型、长度、内容摘要,用于定位“iLink 200 但用户收不到”
|
||||
_item_summaries = []
|
||||
for _it in item_list:
|
||||
_t = _it.get("type")
|
||||
_txt = ""
|
||||
if _t == 1 and _it.get("text_item"):
|
||||
_txt = _it["text_item"].get("text", "")
|
||||
_item_summaries.append(f"type={_t} len={len(_txt)} text={repr(_txt[:80])}")
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " SEND_DEBUG token=" + repr(bot_token[:20] if bot_token else "NULL")
|
||||
+ " to=" + repr(to_user_id[:30]) + " ctx=" + repr(context_token[:30]) + "\n")
|
||||
_f.write(" payload_keys=" + str(list(payload.get("msg", {}).keys()))
|
||||
+ " items=[" + "; ".join(_item_summaries) + "]\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for _ in range(2):
|
||||
try:
|
||||
resp = requests.post(
|
||||
@@ -222,9 +278,113 @@ def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_toke
|
||||
|
||||
|
||||
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
|
||||
"""发送纯文本回复。"""
|
||||
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
|
||||
[{"type": 1, "text_item": {"text": text}}])
|
||||
"""发送纯文本回复。长文本按 WECHAT_TEXT_MAX_LEN 分段,避免 iLink 200 但微信不显示。"""
|
||||
if not text:
|
||||
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
|
||||
[{"type": 1, "text_item": {"text": "(无内容)"}}])
|
||||
# 单条不超过软上限;保留完整段落优先在换行处切分
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + WECHAT_TEXT_MAX_LEN, len(text))
|
||||
if end < len(text):
|
||||
# 尽量在换行处切断
|
||||
_nl = text.rfind("\n", start, end)
|
||||
if _nl > start + WECHAT_TEXT_MAX_LEN // 2:
|
||||
end = _nl + 1
|
||||
chunks.append(text[start:end])
|
||||
start = end
|
||||
|
||||
all_ok = True
|
||||
last_ret = None
|
||||
for idx, chunk in enumerate(chunks):
|
||||
suffix = ""
|
||||
if len(chunks) > 1:
|
||||
if idx == 0:
|
||||
suffix = "\n(内容较长,分条发送)"
|
||||
if idx == len(chunks) - 1:
|
||||
suffix = "\n(已发送完毕;完整内容请访问平台 AI 聊天的 WeXin Bot 窗口)"
|
||||
ok, ret, _ = _wechat_post_msg(
|
||||
baseurl, bot_token, to_user_id, context_token,
|
||||
[{"type": 1, "text_item": {"text": chunk + suffix}}]
|
||||
)
|
||||
if not ok:
|
||||
all_ok = False
|
||||
last_ret = ret
|
||||
# 记录失败但不中断,尽量把已切好的消息发完
|
||||
try:
|
||||
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " SEGMENT_FAIL idx=%s ret=%s\n" % (idx, ret))
|
||||
except Exception:
|
||||
pass
|
||||
# 分段之间稍作间隔,降低频率/风控影响
|
||||
if idx < len(chunks) - 1:
|
||||
time.sleep(0.5)
|
||||
return all_ok, last_ret, {}
|
||||
|
||||
|
||||
def _wechat_get_typing_ticket(baseurl: str, bot_token: str, ilink_user_id: str, context_token: str) -> str:
|
||||
"""getconfig 获取 typing_ticket(按 ilink_user_id 缓存 24h)。
|
||||
|
||||
#974 修复:ilink_user_id 必须传【用户 ID】(from_user_id),不是 bot ID;
|
||||
请求体必须带 base_info;响应必须 ret==0 才算成功。
|
||||
"""
|
||||
global _typing_ticket_cache
|
||||
_now = time.time()
|
||||
_hit = _typing_ticket_cache.get(ilink_user_id)
|
||||
if _hit and (_now - _hit[1]) < 86400:
|
||||
return _hit[0]
|
||||
try:
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/getconfig",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"ilink_user_id": ilink_user_id,
|
||||
"context_token": context_token,
|
||||
"base_info": {"channel_version": "1.0.3"},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
body = resp.json() if resp.content else {}
|
||||
# ret==0 才算成功;-14 会话过期 / -4 RPC 失败均无 ticket
|
||||
ret = body.get("ret")
|
||||
errcode = body.get("errcode")
|
||||
if ret == 0 or (ret is None and errcode is None):
|
||||
ticket = body.get("typing_ticket", "") if isinstance(body, dict) else ""
|
||||
if ticket:
|
||||
_typing_ticket_cache[ilink_user_id] = (ticket, _now)
|
||||
return ticket
|
||||
# 会话过期:记录,供上层标记 expired
|
||||
if errcode == -14 or ret == -14:
|
||||
try:
|
||||
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " GETCONFIG_SESSION_EXPIRED user=%s\n" % ilink_user_id)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _wechat_send_typing(baseurl: str, bot_token: str, ilink_user_id: str, typing_ticket: str, status: int = 1) -> bool:
|
||||
"""发送/取消"正在输入"状态。status=1 typing,2 cancel。失败静默(不阻塞主流程)。"""
|
||||
if not ilink_user_id or not typing_ticket:
|
||||
return False
|
||||
try:
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/sendtyping",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"ilink_user_id": ilink_user_id,
|
||||
"typing_ticket": typing_ticket,
|
||||
"status": status,
|
||||
"base_info": {"channel_version": "1.0.3"},
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.ok
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _platform_relay_url(raw_url: str) -> str:
|
||||
@@ -239,28 +399,62 @@ def _platform_relay_url(raw_url: str) -> str:
|
||||
|
||||
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
|
||||
media_type: str, url: str):
|
||||
"""发送媒体(图片/视频)并在微信内联渲染。
|
||||
"""发送媒体(图片/视频)。#969 iLink CDN 内联发送
|
||||
|
||||
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时,
|
||||
微信里只能看到"链接"而无法内联播放。
|
||||
解决:改写为平台中转地址(经香港代理可达 AGNES),微信即可内联显示图片/视频。
|
||||
优先走 iLink 官方 CDN 加密上传链路,实现图片/视频真正内联显示;
|
||||
失败时降级为纯文本链接(点击访问),确保会话不中断。
|
||||
"""
|
||||
relay_url = _platform_relay_url(url)
|
||||
# 尝试内联发送(iLink CDN 加密上传)
|
||||
try:
|
||||
from app.api.im_ilink_media import (
|
||||
MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO,
|
||||
download_media_bytes, upload_media_to_wechat_cdn,
|
||||
build_image_item, build_video_item,
|
||||
)
|
||||
media_bytes = download_media_bytes(url)
|
||||
mt = MEDIA_TYPE_IMAGE if media_type == "image" else MEDIA_TYPE_VIDEO
|
||||
uploaded = upload_media_to_wechat_cdn(baseurl, bot_token, to_user_id, media_bytes, mt)
|
||||
item = build_image_item(uploaded) if media_type == "image" else build_video_item(uploaded)
|
||||
ok, ret, _ = _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
|
||||
if ok:
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_OK type=%s size=%d\n"
|
||||
% (media_type, len(media_bytes)))
|
||||
except Exception:
|
||||
pass
|
||||
return True, ret, {}
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_FAIL ret=%s → fallback text\n" % (ret,))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as _e:
|
||||
try:
|
||||
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_EXC %s → fallback text\n" % (_e,))
|
||||
except Exception:
|
||||
pass
|
||||
# 降级:纯文本链接
|
||||
if media_type == "image":
|
||||
media_item = {"type": 2, "image_item": {"url": relay_url}}
|
||||
caption = "🖼️ 图片已生成(若未直接显示,请点此查看):" + relay_url
|
||||
caption = "🖼️ 图片已生成,点击查看:" + relay_url
|
||||
else:
|
||||
media_item = {"type": 5, "video_item": {"url": relay_url}}
|
||||
caption = "🎬 视频已生成(若未直接播放,请点此查看):" + relay_url
|
||||
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
|
||||
[media_item, {"type": 1, "text_item": {"text": caption}}])
|
||||
caption = "🎬 视频已生成,点击查看:" + relay_url
|
||||
return _wechat_send_message(baseurl, bot_token, to_user_id, context_token, caption)
|
||||
|
||||
|
||||
def _wechat_extract_text(msg: dict) -> str:
|
||||
parts = []
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") == 1 and it.get("text_item", {}).get("text"):
|
||||
_t = it.get("type")
|
||||
if _t == 1 and it.get("text_item", {}).get("text"):
|
||||
parts.append(it["text_item"]["text"])
|
||||
elif _t == 3:
|
||||
# 语音消息:微信已自带 ASR 转文字(voice_item.text),直接复用
|
||||
voice = it.get("voice_item") or {}
|
||||
if voice.get("text"):
|
||||
parts.append("[语音] " + str(voice["text"]))
|
||||
if not parts:
|
||||
for k in ("text", "content", "message", "msg"):
|
||||
v = msg.get(k)
|
||||
@@ -269,6 +463,42 @@ def _wechat_extract_text(msg: dict) -> str:
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _extract_and_save_inbound_media(msg: dict) -> str:
|
||||
"""检测入站媒体消息(图片/语音/文件/视频),下载解密保存,返回给 LLM 的提示文本。
|
||||
|
||||
无媒体或解密失败返回空字符串。图片已保存到本地,供后续 vision 模型接入。
|
||||
"""
|
||||
try:
|
||||
from app.api.im_ilink_media import extract_inbound_media
|
||||
import os as _os
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") in (2, 3, 4, 5):
|
||||
kind, data = extract_inbound_media(it)
|
||||
if kind and data:
|
||||
_dir = "/tmp/wechat_inbound_media"
|
||||
_os.makedirs(_dir, exist_ok=True)
|
||||
_ext = {"image": "jpg", "voice": "silk", "file": "bin", "video": "mp4"}.get(kind, "bin")
|
||||
_path = _os.path.join(_dir, "%d_%s.%s" % (int(time.time() * 1000), kind, _ext))
|
||||
with open(_path, "wb") as _f:
|
||||
_f.write(data)
|
||||
_label = {"image": "图片", "voice": "语音", "file": "文件", "video": "视频"}.get(kind, kind)
|
||||
try:
|
||||
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " %s saved=%s size=%d\n"
|
||||
% (kind, _path, len(data)))
|
||||
except Exception:
|
||||
pass
|
||||
return "[用户发送了%s,已保存到 %s(%d 字节)]" % (_label, _path, len(data))
|
||||
return ""
|
||||
except Exception as _e:
|
||||
try:
|
||||
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " MEDIA_EXTRACT_ERR %s\n" % (_e,))
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def _wechat_sender_id(m: dict) -> str:
|
||||
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
|
||||
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
|
||||
@@ -326,8 +556,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
|
||||
), {"uid": user_id})
|
||||
if rows:
|
||||
return _row_to_dict(rows[0])["id"]
|
||||
import uuid as _uuid
|
||||
cid = str(_uuid.uuid4())
|
||||
cid = str(uuid.uuid4())
|
||||
now = _beijing_now()
|
||||
_execute_raw(db, text(
|
||||
"INSERT INTO conversations "
|
||||
@@ -338,12 +567,11 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
|
||||
|
||||
|
||||
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
|
||||
import uuid as _uuid
|
||||
_execute_raw(db, text(
|
||||
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
|
||||
"VALUES (:id, :cid, :role, :content, 0, :c)"
|
||||
), {
|
||||
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
|
||||
"id": str(uuid.uuid4()), "cid": conv_id, "role": role,
|
||||
"content": content, "c": created or _beijing_now(),
|
||||
})
|
||||
|
||||
@@ -374,7 +602,7 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
|
||||
# 后台接收线程(每用户独立)
|
||||
# ============================================================
|
||||
def _mark_wechat_expired(account_id: int):
|
||||
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。"""
|
||||
"""发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。"""
|
||||
db = None
|
||||
try:
|
||||
db = SessionLocal()
|
||||
@@ -382,6 +610,14 @@ def _mark_wechat_expired(account_id: int):
|
||||
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
|
||||
"updated_at=:ts WHERE id=:aid"
|
||||
), {"aid": account_id, "ts": _beijing_now()})
|
||||
# Session Guard:记录账号信息,供前台提示用户重扫
|
||||
row = _execute_raw(db, text("SELECT user_id, username FROM im_bot_wechat_account WHERE id=:aid"), {"aid": account_id})
|
||||
_uid = row[0][0] if row else "?"
|
||||
try:
|
||||
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " SESSION_EXPIRED account_id=%s user_id=%s\n" % (account_id, _uid))
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
@@ -414,7 +650,10 @@ def _wechat_receiver_loop(account_id: int):
|
||||
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
|
||||
content = _wechat_extract_text(m)
|
||||
if not content:
|
||||
continue
|
||||
# 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知
|
||||
content = _extract_and_save_inbound_media(m)
|
||||
if not content:
|
||||
continue
|
||||
sender = _wechat_sender_id(m)
|
||||
ctx = _wechat_context_token(m)
|
||||
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
|
||||
@@ -424,8 +663,22 @@ def _wechat_receiver_loop(account_id: int):
|
||||
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# 发送"正在输入"状态(#974:ilink_user_id 用用户 ID = sender,非 bot ID)
|
||||
try:
|
||||
_ticket = _wechat_get_typing_ticket(baseurl, bot_token, sender, ctx)
|
||||
if sender and _ticket:
|
||||
_wechat_send_typing(baseurl, bot_token, sender, _ticket, 1)
|
||||
except Exception:
|
||||
pass
|
||||
# 先生成回复(此时历史尚未包含本条,避免重复)
|
||||
import logging as _lrx, inspect as _insp
|
||||
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})')
|
||||
# 验证使用的 _call_real_llm 版本
|
||||
from app.api.im_llm import _call_real_llm as _crl
|
||||
_src = _insp.getsource(_crl)
|
||||
_lrx.warning(f'[receiver] _call_real_llm source len={len(_src)}, has _try_models={"_try_models" in _src}')
|
||||
reply = _qihua_reply(content, owner_uid)
|
||||
_lrx.warning(f'[receiver] reply ({type(reply).__name__}): {repr(reply)[:200]}')
|
||||
# 媒体结构化返回:_call_real_llm 生成图片/视频时返回
|
||||
# {"__media__": True, "media_type": "image"/"video", "url": "..."}
|
||||
if isinstance(reply, dict) and reply.get("__media__"):
|
||||
@@ -434,11 +687,8 @@ def _wechat_receiver_loop(account_id: int):
|
||||
ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
|
||||
# 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示)
|
||||
relay_url = _platform_relay_url(media_url)
|
||||
reply_for_log = (
|
||||
f'<img src="{relay_url}" alt="生成图片"/>'
|
||||
if media_type == "image"
|
||||
else f'<video src="{relay_url}" controls></video>'
|
||||
)
|
||||
# #973:对话历史只存简短占位符,不存超长 relay URL(含 AWS token,会撑爆多轮 prompt)
|
||||
reply_for_log = "[图片]" if media_type == "image" else "[视频]"
|
||||
else:
|
||||
reply_text = reply if isinstance(reply, str) else str(reply)
|
||||
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text)
|
||||
@@ -457,11 +707,23 @@ def _wechat_receiver_loop(account_id: int):
|
||||
% (ret, repr(sender)))
|
||||
except Exception:
|
||||
pass
|
||||
if ret in (-2, -14):
|
||||
# 会话/鉴权失效:标记需重扫但继续循环,让下一轮 poll 自然检测过期
|
||||
if ret == -14:
|
||||
# -14=会话过期,需重扫
|
||||
_mark_wechat_expired(d["id"])
|
||||
elif ret == -2:
|
||||
# -2=参数错误(如媒体消息格式不被 iLink 接受),并非 token 失效。
|
||||
# 标记 expired 会导致连接假性断开,后续消息全部丢失,故仅记录。
|
||||
pass
|
||||
time.sleep(1)
|
||||
except Exception:
|
||||
# 2026-07-31 修复:此前裸 except 静默吞掉所有异常,导致接收器出错时毫无日志、
|
||||
# 完全无法排查。改为记录异常堆栈到磁盘,便于定位。
|
||||
import traceback as _tb
|
||||
try:
|
||||
with open("/tmp/wechat_receiver_err.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " RECEIVER EXCEPTION:\n" + _tb.format_exc() + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(5)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -555,6 +817,13 @@ async def wechat_qrcode_status(
|
||||
data = j.get("data", j) if isinstance(j, dict) else j
|
||||
status = data.get("status")
|
||||
if status == "confirmed":
|
||||
# 07-30 diagnostic: log full iLink confirm response
|
||||
import json as _json
|
||||
try:
|
||||
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " CONFIRM_RAW=" + _json.dumps(j, ensure_ascii=False)[:2000] + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
creds = data.get("credentials", {}) or {}
|
||||
bot_token = data.get("bot_token") or creds.get("bot_token")
|
||||
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
|
||||
@@ -590,7 +859,15 @@ async def wechat_qrcode_status(
|
||||
"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"],
|
||||
})
|
||||
row = _wechat_row_for_user(db, user.id)
|
||||
aid = _row_to_dict(row)["id"] if row else 1
|
||||
saved = _row_to_dict(row) if row else {}
|
||||
import json as _json2
|
||||
try:
|
||||
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
|
||||
_f.write(_beijing_now().isoformat() + " SAVED bot_token=" + repr(saved.get("bot_token", "NULL")[:20])
|
||||
+ " ilink_bot_id=" + repr(saved.get("ilink_bot_id")) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
aid = saved.get("id", 1)
|
||||
_start_wechat_receiver(aid)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
iLink CDN 媒体加解密 —— 微信官方 iLink(ClawBot) 协议媒体内联发送核心模块
|
||||
im_ilink_media.py
|
||||
|
||||
参考微信官方 OpenClaw 插件 (@tencent-weixin/openclaw-weixin) 源码实现:
|
||||
- cdn/aes-ecb.ts: AES-128-ECB + PKCS7 加解密
|
||||
- cdn/upload.ts: 六步上传流程
|
||||
- messaging/send.ts: image_item / video_item 构造
|
||||
|
||||
严格照抄官方行为,保证字段名与编码方式与微信服务器一致:
|
||||
- aeskey = 随机 16 字节
|
||||
- getUploadUrl 传 aeskey.hex()(32 字符 hex)
|
||||
- 加密用 16 字节原始 key
|
||||
- sendmessage 的 media.aes_key = base64(hex 字符串的 UTF-8 字节)
|
||||
|
||||
媒体类型映射: IMAGE=1, VIDEO=2, FILE=3, VOICE=4
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives import padding
|
||||
|
||||
# 微信 CDN 域名(图片/语音/视频媒体文件走这里)
|
||||
WECHAT_CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
# 媒体类型枚举(与 iLink UploadMediaType 一致)
|
||||
MEDIA_TYPE_IMAGE = 1
|
||||
MEDIA_TYPE_VIDEO = 2
|
||||
MEDIA_TYPE_FILE = 3
|
||||
MEDIA_TYPE_VOICE = 4
|
||||
|
||||
|
||||
def encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 加密(微信指定,非标准 CBC)。"""
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded = padder.update(plaintext) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
|
||||
def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 解密。"""
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
dec = cipher.decryptor()
|
||||
padded = dec.update(ciphertext) + dec.finalize()
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
|
||||
def aes_ecb_padded_size(plaintext_size: int) -> int:
|
||||
"""AES-128-ECB PKCS7 填充后的密文大小。"""
|
||||
return math.ceil((plaintext_size + 1) / 16) * 16
|
||||
|
||||
|
||||
def _wechat_headers(bot_token: str) -> dict:
|
||||
"""iLink 协议请求头(复制 im_bot_user 逻辑,避免循环依赖)。"""
|
||||
uin = base64.b64encode(str(random_int()).encode("utf-8")).decode("utf-8")
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {bot_token}",
|
||||
"AuthorizationType": "ilink_bot_token",
|
||||
"X-WECHAT-UIN": uin,
|
||||
}
|
||||
|
||||
|
||||
def random_int():
|
||||
import random
|
||||
return random.randint(0, 2 ** 32 - 1)
|
||||
|
||||
|
||||
def download_media_bytes(url: str) -> bytes:
|
||||
"""下载 AGNES/供应商媒体字节(走 HK 代理,绕过劫持)。
|
||||
|
||||
复用 chat_utils._proxy_for_url 的代理选择逻辑;对境外媒体走 HK 代理。
|
||||
"""
|
||||
from app.api.chat_utils import _proxy_for_url
|
||||
|
||||
proxy = _proxy_for_url(url)
|
||||
proxies = None
|
||||
if proxy:
|
||||
proxies = {"http": proxy, "https": proxy}
|
||||
# trust_env=False 语义等价:requests 不读环境变量代理
|
||||
# 显式传 proxies=None 且环境变量存在时 requests 会用环境变量,故需清空
|
||||
import os as _os
|
||||
env_bak = {}
|
||||
for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
if _k in _os.environ:
|
||||
env_bak[_k] = _os.environ.pop(_k)
|
||||
try:
|
||||
resp = requests.get(url, proxies=proxies, timeout=180, allow_redirects=True)
|
||||
finally:
|
||||
_os.environ.update(env_bak)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"media download failed: {resp.status_code} url={url[:120]}")
|
||||
return resp.content
|
||||
|
||||
|
||||
def upload_media_to_wechat_cdn(
|
||||
baseurl: str,
|
||||
bot_token: str,
|
||||
to_user_id: str,
|
||||
media_bytes: bytes,
|
||||
media_type: int,
|
||||
) -> dict:
|
||||
"""六步上传流程:读文件→hash→gen aeskey→getUploadUrl→加密上传CDN→返回引用。
|
||||
|
||||
返回 uploaded dict: { filekey, download_param, aeskey_hex, filesize }
|
||||
"""
|
||||
rawsize = len(media_bytes)
|
||||
rawfilemd5 = hashlib.md5(media_bytes).hexdigest()
|
||||
filesize = aes_ecb_padded_size(rawsize)
|
||||
filekey = os.urandom(16).hex() # 32 字符 hex
|
||||
aeskey = os.urandom(16) # 16 字节原始 key
|
||||
aeskey_hex = aeskey.hex() # 32 字符 hex
|
||||
|
||||
# 1. getUploadUrl
|
||||
resp = requests.post(
|
||||
baseurl.rstrip("/") + "/ilink/bot/getuploadurl",
|
||||
headers=_wechat_headers(bot_token),
|
||||
json={
|
||||
"filekey": filekey,
|
||||
"media_type": media_type,
|
||||
"to_user_id": to_user_id,
|
||||
"rawsize": rawsize,
|
||||
"rawfilemd5": rawfilemd5,
|
||||
"filesize": filesize,
|
||||
"no_need_thumb": True,
|
||||
"aeskey": aeskey_hex,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"getuploadurl HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
body = resp.json() if resp.content else {}
|
||||
upload_param = body.get("upload_param")
|
||||
upload_full_url = (body.get("upload_full_url") or "").strip()
|
||||
|
||||
if not upload_param and not upload_full_url:
|
||||
raise RuntimeError(f"getuploadurl returned no upload URL: {str(body)[:200]}")
|
||||
|
||||
# 2. AES-128-ECB 加密
|
||||
ciphertext = encrypt_aes_ecb(media_bytes, aeskey)
|
||||
|
||||
# 3. 上传 CDN
|
||||
if upload_full_url:
|
||||
cdn_url = upload_full_url
|
||||
else:
|
||||
cdn_url = (
|
||||
f"{WECHAT_CDN_BASE}/upload"
|
||||
f"?encrypted_query_param={quote(upload_param, safe='')}"
|
||||
f"&filekey={quote(filekey, safe='')}"
|
||||
)
|
||||
up_resp = requests.post(
|
||||
cdn_url,
|
||||
data=ciphertext,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
timeout=120,
|
||||
)
|
||||
if up_resp.status_code not in (200, 201):
|
||||
raise RuntimeError(f"CDN upload failed HTTP {up_resp.status_code}: {up_resp.text[:200]}")
|
||||
download_param = up_resp.headers.get("x-encrypted-param")
|
||||
if not download_param:
|
||||
raise RuntimeError("CDN upload response missing x-encrypted-param header")
|
||||
|
||||
return {
|
||||
"filekey": filekey,
|
||||
"download_param": download_param,
|
||||
"aeskey_hex": aeskey_hex,
|
||||
"filesize": filesize,
|
||||
}
|
||||
|
||||
|
||||
def _aes_key_b64(aeskey_hex: str) -> str:
|
||||
"""media.aes_key 编码:base64(hex 字符串的 UTF-8 字节)。
|
||||
|
||||
严格对齐 openclaw: Buffer.from(hexString).toString('base64')
|
||||
"""
|
||||
return base64.b64encode(aeskey_hex.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def build_image_item(uploaded: dict) -> dict:
|
||||
"""构造图片 MessageItem(type=2)。"""
|
||||
return {
|
||||
"type": 2,
|
||||
"image_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": uploaded["download_param"],
|
||||
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"mid_size": uploaded["filesize"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_video_item(uploaded: dict) -> dict:
|
||||
"""构造视频 MessageItem(type=5)。"""
|
||||
return {
|
||||
"type": 5,
|
||||
"video_item": {
|
||||
"media": {
|
||||
"encrypt_query_param": uploaded["download_param"],
|
||||
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
|
||||
"encrypt_type": 1,
|
||||
},
|
||||
"video_size": uploaded["filesize"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 接收侧媒体下载解密(P2)
|
||||
# 参考 openclaw cdn/pic-decrypt.ts + media/media-download.ts
|
||||
# ============================================================
|
||||
|
||||
def parse_aes_key(aes_key_base64: str) -> bytes:
|
||||
"""解析 CDNMedia.aes_key 为原始 16 字节 AES key。
|
||||
|
||||
两种真实编码(openclaw parseAesKey):
|
||||
- base64(raw 16 bytes) → 图片 (media.aes_key)
|
||||
- base64(hex 32 chars 字符串) → 文件/语音/视频
|
||||
第二种需 base64 解码后得到 32 个 ASCII hex 字符,再 hex 解码成 16 字节。
|
||||
"""
|
||||
decoded = base64.b64decode(aes_key_base64)
|
||||
if len(decoded) == 16:
|
||||
return decoded
|
||||
if len(decoded) == 32 and re.match(r"^[0-9a-fA-F]{32}$", decoded.decode("ascii", "ignore")):
|
||||
return bytes.fromhex(decoded.decode("ascii"))
|
||||
raise ValueError(
|
||||
f"aes_key 解码后应为 16 字节或 32 字符 hex,实际 {len(decoded)} 字节"
|
||||
)
|
||||
|
||||
|
||||
def download_and_decrypt_media(
|
||||
encrypted_query_param: str,
|
||||
aes_key_base64: str,
|
||||
cdn_base_url: str = WECHAT_CDN_BASE,
|
||||
full_url: str = None,
|
||||
) -> bytes:
|
||||
"""下载并 AES-128-ECB 解密 CDN 媒体文件,返回明文字节。"""
|
||||
key = parse_aes_key(aes_key_base64)
|
||||
if full_url:
|
||||
url = full_url
|
||||
else:
|
||||
url = (
|
||||
f"{cdn_base_url}/download"
|
||||
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
|
||||
)
|
||||
resp = requests.get(url, timeout=60)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"CDN download failed HTTP {resp.status_code}: {resp.text[:150]}")
|
||||
encrypted = resp.content
|
||||
return decrypt_aes_ecb(encrypted, key)
|
||||
|
||||
|
||||
def extract_inbound_media(item: dict) -> tuple:
|
||||
"""从入站 MessageItem 提取媒体并下载解密。
|
||||
|
||||
返回 (media_kind, media_bytes),不支持的类型或失败返回 (None, None)。
|
||||
media_kind ∈ {"image", "voice", "file", "video"}
|
||||
"""
|
||||
try:
|
||||
t = item.get("type")
|
||||
if t == 2: # 图片
|
||||
img = item.get("image_item") or {}
|
||||
media = img.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
if not eqp and not full:
|
||||
return None, None
|
||||
# 图片 key 优先用 image_item.aeskey(hex string)转 base64
|
||||
if img.get("aeskey"):
|
||||
aes_key_b64 = base64.b64encode(bytes.fromhex(img["aeskey"])).decode()
|
||||
else:
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if aes_key_b64:
|
||||
return "image", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
return "image", _download_plain(eqp, full)
|
||||
elif t == 3: # 语音
|
||||
voice = item.get("voice_item") or {}
|
||||
media = voice.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "voice", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
elif t == 4: # 文件
|
||||
fitem = item.get("file_item") or {}
|
||||
media = fitem.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "file", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
elif t == 5: # 视频
|
||||
vitem = item.get("video_item") or {}
|
||||
media = vitem.get("media") or {}
|
||||
eqp = media.get("encrypt_query_param") or ""
|
||||
full = media.get("full_url")
|
||||
aes_key_b64 = media.get("aes_key") or ""
|
||||
if not aes_key_b64:
|
||||
return None, None
|
||||
return "video", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
|
||||
except Exception:
|
||||
return None, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _download_plain(encrypted_query_param: str, full_url: str = None) -> bytes:
|
||||
"""下载未加密的 CDN 媒体(无 aes_key 时)。"""
|
||||
if full_url:
|
||||
url = full_url
|
||||
else:
|
||||
url = (
|
||||
f"{WECHAT_CDN_BASE}/download"
|
||||
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
|
||||
)
|
||||
resp = requests.get(url, timeout=60)
|
||||
if not resp.ok:
|
||||
raise RuntimeError(f"CDN plain download failed HTTP {resp.status_code}")
|
||||
return resp.content
|
||||
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
|
||||
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import re as _re
|
||||
@@ -29,7 +30,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..capability_registry import resolve_auto_model
|
||||
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
|
||||
from app.core.observability import get_trace_id
|
||||
from app.core.observability import get_trace_id, span
|
||||
# #875 LLM Router:统一模型路由层(影子模式先行,收敛 litellm 主密钥硬编码)
|
||||
from ..core.llm_router import (
|
||||
route as _llm_route,
|
||||
set_route_ctx as _set_route_ctx,
|
||||
SHADOW as _LLM_ROUTER_SHADOW,
|
||||
resolve_api_key as _llm_resolve_api_key,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +56,8 @@ def _proxy_for_url(url: str) -> str | None:
|
||||
host = (_urlparse(url).hostname or "").lower()
|
||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||
return _HK_PROXY
|
||||
if "qihuyun.com" in host:
|
||||
# 2026-07-30 修复 3+: 不代理 Docker 内部 hostname 和 localhost
|
||||
if "qihuyun.com" in host or "localhost" in host or host.startswith("127.") or "qihu-aiflow" in host:
|
||||
return None
|
||||
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
|
||||
for _np in _no_proxy:
|
||||
@@ -57,6 +66,17 @@ def _proxy_for_url(url: str) -> str | None:
|
||||
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
|
||||
|
||||
|
||||
_DEBUG_LOG_PATH = "/tmp/im_llm_debug.log"
|
||||
|
||||
def _debug_log(msg: str):
|
||||
"""写磁盘调试日志(不依赖 uvicorn logging 配置)。"""
|
||||
try:
|
||||
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as _f:
|
||||
_f.write(datetime.now(timezone(timedelta(hours=8))).isoformat() + " " + msg + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _run_async(coro, timeout=None):
|
||||
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
|
||||
|
||||
@@ -117,6 +137,13 @@ def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, promp
|
||||
|
||||
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
|
||||
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
|
||||
# #876 双 Claw 维度:入口最早注入 tenant_id + claw_type,确保下方所有 span / metric
|
||||
# 均带双 Claw 标签(此前 IM Claw 完全无 span,Grafana 无 IM 数据点)。
|
||||
_set_route_ctx(tenant_id=getattr(user, "tenant_id", "") or "", claw_type="im_wechat")
|
||||
# #876:IM Claw 此前完全无 span,Grafana 无 IM 数据点。此处显式开启一个 im.send_message
|
||||
# span(手动 enter/exit 以避免整段重缩进),使 IM 链路在双 Claw 对比看板出图。
|
||||
_im_span = span("im.send_message", platform=platform, user_id=user_id)
|
||||
_im_span.__enter__()
|
||||
try:
|
||||
from app.api.chat import (
|
||||
_generate_media,
|
||||
@@ -124,10 +151,23 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
_discover_installed_skills,
|
||||
_build_system_prompt,
|
||||
_execute_skill_tool,
|
||||
_list_enabled_mcp_servers,
|
||||
)
|
||||
# 2026-08-04 双 Claw 对齐:能力发现收敛为唯一实现,Web / IM 共用
|
||||
from app.core.capability_discovery import (
|
||||
build_mcp_tool_defs,
|
||||
discover_mcp_servers,
|
||||
discover_skills,
|
||||
)
|
||||
from app.mcp_client import call_mcp_tool
|
||||
|
||||
# ===== 1) 媒体意图路由(Issue 7)=====
|
||||
intent = detect_media_intent(prompt)
|
||||
# 2026-07-30 fix: 只对当前用户消息做媒体意图检测,避免历史中的图像请求污染
|
||||
_cur_msg = prompt
|
||||
_marker = "用户当前问题:"
|
||||
if _marker in prompt:
|
||||
_cur_msg = prompt[prompt.rfind(_marker) + len(_marker):].strip()
|
||||
intent = detect_media_intent(_cur_msg)
|
||||
if intent:
|
||||
ai_model = resolve_auto_model(intent, db)
|
||||
if ai_model:
|
||||
@@ -155,6 +195,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
return s
|
||||
return "(媒体生成未能返回有效内容,请重试)"
|
||||
|
||||
# ===== 1.5) EngineCore 薄壳(#873 · 灰阶门,默认关闭)=====
|
||||
# IM_USE_ENGINE_CORE=1 时,文本回复改走与 Web Claw 同一份 EngineCore
|
||||
# (_send_message_core),经 BufferAdapter 缓冲装配为整条回复;上方媒体快路径
|
||||
# 已提前返回,不受此门影响。默认 0/未设置 = 走下方既有自包含逻辑(零改动)。
|
||||
if os.environ.get("IM_USE_ENGINE_CORE") == "1":
|
||||
return _run_async(
|
||||
_call_engine_core(db, user_id, prompt, platform, user),
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
|
||||
from ..platform_knowledge import get_platform_knowledge
|
||||
|
||||
@@ -162,27 +212,76 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
ai_model = resolve_auto_model("text", db)
|
||||
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
|
||||
# Auto 模式附加备选(IM Bot 始终 Auto)
|
||||
# 2026-07-31 根因修复:仅选用「纯文本」模型作为 failover 备选。
|
||||
# 此前按 is_enabled 顺序取前 4 个,会混入图像/视频/语音(ASR)模型
|
||||
# (这些模型 capability 含 "text" 但本质是媒体模型,收到文本 prompt 返回空内容、
|
||||
# 不抛异常 → 被误判为「模型失败」,最终导致「已尝试N个模型均调用失败」)。
|
||||
_MEDIA_CAPS = {"image", "video", "audio", "asr", "embedding", "rerank", "ocr"}
|
||||
def _is_text_only(m):
|
||||
_caps = m.capabilities or []
|
||||
return "text" in _caps and not any(c in _MEDIA_CAPS for c in _caps)
|
||||
_fallbacks = db.query(AIModel).filter(
|
||||
AIModel.is_enabled == True,
|
||||
AIModel.id != (ai_model.id if ai_model else ""),
|
||||
).order_by(AIModel.is_default.desc()).limit(4).all()
|
||||
_try_models.extend([m for m in _fallbacks if m not in _try_models])
|
||||
).order_by(AIModel.is_default.desc()).all()
|
||||
_fallbacks = [m for m in _fallbacks if _is_text_only(m)]
|
||||
_try_models.extend([m for m in _fallbacks[:4] if m not in _try_models])
|
||||
|
||||
# ===== LLM Router(#875 影子模式)=====
|
||||
# 计算决策并打 claw_type 标签 metrics;影子模式下仅观测,不改流量。
|
||||
# 切主路径:LLM_ROUTER_SHADOW=0 时改用 Router 候选链(已含文本过滤,等同此处逻辑)。
|
||||
try:
|
||||
_rm = _llm_route("text", db=db, legacy_primary=_try_models[0].name if _try_models else None)
|
||||
if not _LLM_ROUTER_SHADOW and _rm.models:
|
||||
_try_models = _rm.models
|
||||
_debug_log(f"ROUTER_LIVE models={[m.name for m in _try_models]}")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[im_llm] LLM Router 观测失败(降级忽略): {_e}")
|
||||
|
||||
_debug_log(f"TRY_MODELS user={user_id} count={len(_try_models)} names={[m.name for m in _try_models]}")
|
||||
|
||||
if not _try_models:
|
||||
return "(未配置可用文本模型,无法生成回复)"
|
||||
|
||||
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
|
||||
# 能力自动发现(提前执行一次,不必每个候选模型重复做)
|
||||
# 2026-08-04 根因修复:
|
||||
# 旧实现 `_discover_installed_skills(db, user_id)[:15]` 强依赖
|
||||
# app_installations 表,线上该表 0 行 → 97 个 published skill
|
||||
# 在 Web / IM 双端同时发现不到;`[:15]` 截断的是一个空列表。
|
||||
# 现改走统一发现层(安装优先 → published 降级 → 相关性裁剪)。
|
||||
_query_hint = _cur_msg or prompt
|
||||
skills = []
|
||||
if user_id:
|
||||
try:
|
||||
skills = _discover_installed_skills(db, user_id)[:15]
|
||||
except Exception:
|
||||
skills = []
|
||||
try:
|
||||
skills = discover_skills(db, user_id, query=_query_hint, claw="im")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[im_llm] 技能发现失败(降级为空): {_e}")
|
||||
skills = []
|
||||
|
||||
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
|
||||
# MCP 自动挂载:IM Bot Claw 没有勾选 UI,自动发现即其等价通道。
|
||||
# (Web Claw 靠用户在界面勾选 → effective_mcp_ids)
|
||||
mcp_servers = []
|
||||
mcp_function_definitions = []
|
||||
mcp_tool_map = {}
|
||||
try:
|
||||
mcp_servers = discover_mcp_servers(db, None, claw="im")
|
||||
if mcp_servers:
|
||||
mcp_function_definitions, mcp_tool_map = _run_async(
|
||||
build_mcp_tool_defs(mcp_servers), timeout=45
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.warning(f"[im_llm] MCP 发现失败(降级为空): {_e}")
|
||||
mcp_servers, mcp_function_definitions, mcp_tool_map = [], [], {}
|
||||
|
||||
_debug_log(
|
||||
f"CAP skills={len(skills)} mcp_servers={len(mcp_servers)} "
|
||||
f"mcp_tools={len(mcp_function_definitions)}"
|
||||
)
|
||||
|
||||
# 平台知识库注入(#973:IM 侧精简——只注入静态文档,不注入 104 动态清单,
|
||||
# 避免 prompt 过长(6892字符)导致推理模型响应慢(63s)且间歇性返回空 content)
|
||||
kb_context = ""
|
||||
try:
|
||||
pk = get_platform_knowledge(db=db)
|
||||
pk = get_platform_knowledge(db=None)
|
||||
if pk:
|
||||
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
|
||||
except Exception:
|
||||
@@ -196,19 +295,26 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
api_url = ai_model.endpoint
|
||||
# 2026-07-30: LiteLLM 端点统一用 master key(收敛进 LLM Router,两端唯一来源)
|
||||
api_key = _llm_resolve_api_key(api_url, api_key, provider.api_key if provider else None)
|
||||
if not api_url and provider and provider.api_base_url:
|
||||
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
|
||||
if not (api_key and api_url):
|
||||
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
|
||||
continue
|
||||
|
||||
# agents 暂留空:线上 market_apps 中 type='agent' 记录为 0,
|
||||
# 且 IM 侧尚无 agent 执行分支;待薄壳化接入 EngineCore 后自动获得。
|
||||
system_prompt, function_definitions = _build_system_prompt(
|
||||
skills=skills,
|
||||
agents=[],
|
||||
kb_context=kb_context,
|
||||
user_content=prompt,
|
||||
mcp=[],
|
||||
mcp=mcp_servers,
|
||||
mcp_functions=mcp_function_definitions,
|
||||
auto_discovered=bool(skills),
|
||||
all_mcp=_list_enabled_mcp_servers(db),
|
||||
db=db,
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
@@ -221,13 +327,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
ai_content = ""
|
||||
_http_ok = True
|
||||
for round_num in range(max_rounds):
|
||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
|
||||
if function_definitions and skills:
|
||||
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
|
||||
if function_definitions:
|
||||
request_body["tools"] = [
|
||||
{"type": "function", "function": fd} for fd in function_definitions
|
||||
]
|
||||
request_body["tool_choice"] = "auto"
|
||||
try:
|
||||
import logging as _l5
|
||||
_l5.warning(f'[im_llm REQ] {ai_model.name}: url={api_url[:60]}, proxy={_proxy_for_url(api_url)}, api_key={api_key[:20]}..., messages_count={len(request_body.get("messages", []))}, tools_count={len(request_body.get("tools", []))}')
|
||||
_debug_log(f"REQ model={ai_model.name} url={api_url} proxy={_proxy_for_url(api_url)} body={json.dumps(request_body, ensure_ascii=False)[:600]}")
|
||||
data = chat_complete(
|
||||
api_url,
|
||||
api_key,
|
||||
@@ -235,48 +344,192 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
proxy=_proxy_for_url(api_url),
|
||||
timeout=ai_model.timeout or 120,
|
||||
)
|
||||
_l5.warning(f'[im_llm RES] {ai_model.name}: data_keys={list(data.keys()) if data else None}, resp_type={type(data).__name__}')
|
||||
_debug_log(f"RES model={ai_model.name} data={json.dumps(data, ensure_ascii=False)[:600]}")
|
||||
except Exception as _exc:
|
||||
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
|
||||
import traceback as _tb
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
|
||||
logger.warning(f"[im_llm] {ai_model.name} 异常详情: {_tb.format_exc()[:2000]}")
|
||||
_debug_log(f"EXC model={ai_model.name} err={str(_exc)[:200]} trace={_tb.format_exc()[:500]}")
|
||||
_http_ok = False
|
||||
break
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
if tool_calls and skills:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.get("content") or "",
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tc in tool_calls:
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
try:
|
||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||
except Exception:
|
||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||
message = data.get("choices", [{}])[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls", [])
|
||||
# 2026-07-30 CRITICAL DEBUG
|
||||
import logging as _lcr
|
||||
_raw_content = message.get("content")
|
||||
_raw_reasoning = message.get("reasoning_content")
|
||||
_msg_keys = list(message.keys()) if message else []
|
||||
_tool_calls = tool_calls
|
||||
_lcr.warning(f'[CRITICAL] MODEL={ai_model.name} msg_keys={_msg_keys} content_len={len(_raw_content or "")} reasoning_len={len(_raw_reasoning or "")} has_tool_calls={bool(_tool_calls)}')
|
||||
_debug_log(f"PARSE model={ai_model.name} content_len={len(_raw_content or '')} reasoning_len={len(_raw_reasoning or '')} content_preview={repr(_raw_content)[:120]}")
|
||||
ai_content = message.get("content", "") or message.get("reasoning_content") or ""
|
||||
if not ai_content and (message.get("reasoning_content") or ""):
|
||||
ai_content = f"(AI 思考:{message.get('reasoning_content')[:500]}...)"
|
||||
if tool_calls and (skills or mcp_tool_map):
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": message.get("content") or message.get("reasoning_content") or "",
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tc in tool_calls:
|
||||
func_name = tc.get("function", {}).get("name", "")
|
||||
try:
|
||||
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
|
||||
except Exception:
|
||||
func_args = {"input": tc.get("function", {}).get("arguments", "")}
|
||||
if func_name.startswith("mcp__"):
|
||||
# MCP 工具:命名规则与 Web Claw 完全一致
|
||||
# mcp__{server}__{tool}__{idx}
|
||||
_info = mcp_tool_map.get(func_name)
|
||||
if not _info:
|
||||
tool_result = (
|
||||
"[MCP 工具未找到] 可用工具: "
|
||||
+ str(list(mcp_tool_map.keys())[:5])
|
||||
)
|
||||
else:
|
||||
_srv = next(
|
||||
(s for s in mcp_servers
|
||||
if s.get("id") == _info.get("server_id")),
|
||||
None,
|
||||
)
|
||||
if not _srv:
|
||||
tool_result = f"[MCP 服务未找到: {_info.get('server_name')}]"
|
||||
else:
|
||||
try:
|
||||
tool_result = _run_async(
|
||||
call_mcp_tool(_srv, _info["name"], func_args),
|
||||
timeout=90,
|
||||
)
|
||||
except Exception as _mexc:
|
||||
tool_result = (
|
||||
f"[MCP 工具 {_info.get('name')} 执行失败:"
|
||||
f"{str(_mexc)[:160]}]"
|
||||
)
|
||||
_debug_log(
|
||||
f"MCP_CALL name={func_name} "
|
||||
f"result_len={len(tool_result or '')}"
|
||||
)
|
||||
else:
|
||||
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", "call_1"),
|
||||
"content": tool_result,
|
||||
})
|
||||
continue
|
||||
ai_content = message.get("content", "")
|
||||
break
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.get("id", "call_1"),
|
||||
"content": tool_result,
|
||||
})
|
||||
if round_num >= max_rounds - 1:
|
||||
break
|
||||
continue
|
||||
# #973:空 content 且未到最后一轮 → 重试(推理模型间歇性返回空)
|
||||
if not ai_content and round_num < max_rounds - 1:
|
||||
continue
|
||||
break
|
||||
|
||||
# 检查本轮调用是否成功
|
||||
import logging as _log2
|
||||
_log2.warning(f'[im_llm trace] {ai_model.name}: http_ok={_http_ok}, ai_content_len={len(ai_content) if ai_content else 0}, ai_content_preview={repr(ai_content)[:200]}')
|
||||
_debug_log(f"CHECK model={ai_model.name} http_ok={_http_ok} ai_content_len={len(ai_content) if ai_content else 0}")
|
||||
if _http_ok and ai_content:
|
||||
_debug_log(f"SUCCESS model={ai_model.name} returning len={len(ai_content)}")
|
||||
return ai_content or "(AI 回复为空)"
|
||||
# 本轮失败:记录日志,切换备选
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
|
||||
logger.warning('[im_llm] 模型 %s 失败 (http_ok=%s, content=%s), 切换备选...' % (
|
||||
ai_model.name, _http_ok, repr(ai_content)[:100]
|
||||
))
|
||||
_debug_log(f"FAIL model={ai_model.name} http_ok={_http_ok} content_preview={repr(ai_content)[:100]}")
|
||||
continue
|
||||
except Exception as exc:
|
||||
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
|
||||
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
|
||||
_debug_log(f"OUTER_EXC model={ai_model.name} err={str(exc)[:120]}")
|
||||
continue
|
||||
|
||||
# 全部失败
|
||||
_summary = "; ".join(_retry_log[-3:])
|
||||
_debug_log(f"ALL_FAILED try_models={len(_try_models)} retry_log={_retry_log}")
|
||||
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
|
||||
except Exception as e:
|
||||
return f"(AI 回复生成失败:{e})"
|
||||
finally:
|
||||
# #876:关闭 im.send_message span(记录耗时 + 指标,强制带 tenant_id + claw_type)
|
||||
try:
|
||||
_im_span.__exit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _call_engine_core(db, user_id, prompt: str, platform: str, user=None) -> "str | dict":
|
||||
"""(#873 薄壳)复用 Web Claw 同一份 EngineCore 生成 IM 回复。
|
||||
|
||||
单引擎原则:IM Bot 不再维护第二份「模型 failover + 技能/MCP 执行」实现,
|
||||
而是把已发现的 skill_ids/mcp_ids 交给 _send_message_core,经 BufferAdapter
|
||||
缓冲装配成整条回复(媒体结构 __media__ 由 BufferAdapter 原样保留)。
|
||||
默认不启用(见 _call_real_llm 中的 IM_USE_ENGINE_CORE 灰阶门)。
|
||||
"""
|
||||
from app.api.chat_service import _send_message_core
|
||||
from app.api.chat_utils import SendMessageRequest
|
||||
from app.core.claw_adapters import BufferAdapter
|
||||
from ..models import Conversation, User
|
||||
|
||||
# 解析用户:IM 调用方常传 user=None,仅给 user_id
|
||||
real_user = user
|
||||
if real_user is None and user_id:
|
||||
real_user = db.query(User).filter(User.id == user_id).first()
|
||||
if real_user is None:
|
||||
return "(EngineCore 模式:无法解析当前用户,请检查 IM 账号绑定)"
|
||||
|
||||
# 能力发现(与 IM 自包含逻辑同一套,claw="im")
|
||||
from app.core.capability_discovery import (
|
||||
discover_mcp_servers,
|
||||
discover_skills,
|
||||
)
|
||||
_query_hint = prompt
|
||||
_marker = "用户当前问题:"
|
||||
if _marker in prompt:
|
||||
_query_hint = prompt[prompt.rfind(_marker) + len(_marker):].strip()
|
||||
skills = []
|
||||
try:
|
||||
skills = discover_skills(db, user_id, query=_query_hint, claw="im") or []
|
||||
except Exception as _e:
|
||||
logger.warning(f"[im_llm.engine] 技能发现失败(降级为空): {_e}")
|
||||
mcp_servers = []
|
||||
try:
|
||||
mcp_servers = discover_mcp_servers(db, None, claw="im") or []
|
||||
except Exception as _e:
|
||||
logger.warning(f"[im_llm.engine] MCP 发现失败(降级为空): {_e}")
|
||||
|
||||
skill_ids = [
|
||||
(s.get("id") if isinstance(s, dict) else getattr(s, "id", None))
|
||||
for s in skills
|
||||
]
|
||||
skill_ids = [s for s in skill_ids if s]
|
||||
mcp_ids = [
|
||||
(m.get("id") if isinstance(m, dict) else getattr(m, "id", None))
|
||||
for m in mcp_servers
|
||||
]
|
||||
mcp_ids = [m for m in mcp_ids if m]
|
||||
|
||||
# 构造本回合专用对话:EngineCore 要求已存在且归属当前用户的 Conversation
|
||||
conv = Conversation(
|
||||
id=gen_uuid(),
|
||||
user_id=real_user.id,
|
||||
title=f"IM对话-{platform}",
|
||||
source="im_bot",
|
||||
)
|
||||
db.add(conv)
|
||||
db.commit()
|
||||
|
||||
req = SendMessageRequest(
|
||||
content=prompt,
|
||||
skill_ids=skill_ids or None,
|
||||
mcp_ids=mcp_ids or None,
|
||||
use_tools=bool(skill_ids or mcp_ids),
|
||||
agent_ids=None,
|
||||
knowledge_base_ids=None,
|
||||
attachments=None,
|
||||
)
|
||||
|
||||
adapter = BufferAdapter(tenant_id=str(getattr(real_user, "tenant_id", "") or ""))
|
||||
await _send_message_core(conv.id, req, db, real_user, stream=True, on_event=adapter)
|
||||
return adapter.assemble()
|
||||
@@ -502,8 +502,8 @@ def _parse_llm_graph_response(content: str):
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 方式2: 提取```json ... ```代码块
|
||||
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', content)
|
||||
# 方式2: 提取\"\"\"json ... \"\"\"代码块
|
||||
json_match = re.search(r'\"\"\"(?:json)?\s*([\s\S]*?)\"\"\"', content)
|
||||
if json_match:
|
||||
try:
|
||||
result = json.loads(json_match.group(1))
|
||||
|
||||
@@ -59,7 +59,7 @@ def _sanitize_skill_content(text: str) -> str:
|
||||
if "<script" in low and t.count("<div") > 3:
|
||||
return ""
|
||||
# 接受携带任一 markdown / skill 标记的内容
|
||||
markers = ("#", "---", "name:", "description:", "```", "##")
|
||||
markers = ("#", "---", "name:", "description:", "\"\"\"", "##")
|
||||
if not any(m in t for m in markers):
|
||||
return ""
|
||||
return t
|
||||
@@ -79,7 +79,7 @@ def _extract_skill_from_html(html: str, base_url: str) -> str:
|
||||
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
|
||||
proxy = _skill_http_proxy()
|
||||
# 1) fenced code block
|
||||
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
|
||||
fences = re.findall(r"\"\"\"(?:markdown|md|yaml|json)?\s*\n(.*?)\"\"\"", html, re.DOTALL | re.IGNORECASE)
|
||||
for f in fences:
|
||||
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
|
||||
return f.strip()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from app.api.chat_service import get_user_balance
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, desc, asc
|
||||
@@ -38,6 +39,12 @@ class UpdateApiKeyRequest(BaseModel):
|
||||
status: str # enabled, disabled, revoked
|
||||
|
||||
|
||||
|
||||
@router.get("/balance")
|
||||
async def get_balance(user=Depends(get_current_user)):
|
||||
balance = await get_user_balance(user["id"])
|
||||
return {"code": 0, "data": balance, "message": "ok"}
|
||||
|
||||
@router.get("/")
|
||||
def get_profile(
|
||||
db: Session = Depends(get_db),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Phase 1 #1A: Skills API Router
|
||||
POST /api/skills/ppt/generate - Generate PPTX
|
||||
GET /api/skills/ppt/download/{instance_id} - Download PPTX
|
||||
POST /api/skills/seo/analyze - SEO analysis
|
||||
POST /api/skills/research/generate - Investment research
|
||||
GET /api/skills/status/{instance_id} - Check execution status
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from app.auth import get_current_user, get_current_tenant
|
||||
from app.skill_runtime import PPTSkillRuntime, SEOSkillRuntime, ResearchSkillRuntime
|
||||
from app.database import SessionLocal
|
||||
import os
|
||||
|
||||
router = APIRouter(tags=["技能执行"])
|
||||
|
||||
class PPTRequest(BaseModel):
|
||||
content: str
|
||||
template: str = "business"
|
||||
slides: int = 5
|
||||
|
||||
class SEORequest(BaseModel):
|
||||
url: str = ""
|
||||
keywords: str = ""
|
||||
|
||||
class ResearchRequest(BaseModel):
|
||||
symbol: str
|
||||
|
||||
@router.post("/ppt/generate")
|
||||
async def generate_ppt(req: PPTRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||
"""Generate PPT from text content using AI + python-pptx."""
|
||||
runtime = PPTSkillRuntime(
|
||||
skill_id="ppt-generator",
|
||||
user_id=user["id"],
|
||||
tenant_id=tenant
|
||||
)
|
||||
result = await runtime.execute({"content": req.content, "template": req.template, "slides": req.slides})
|
||||
return {"code": 0, "data": result, "message": "PPT generated" if result["status"] == "ready" else "Failed"}
|
||||
|
||||
@router.get("/ppt/download/{instance_id}")
|
||||
async def download_ppt(instance_id: str, user=Depends(get_current_user)):
|
||||
"""Download generated PPTX file."""
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
row = db.execute(text(
|
||||
"SELECT result FROM skill_instances WHERE id = :id AND user_id = :uid"
|
||||
), {"id": instance_id, "uid": user["id"]}).fetchone()
|
||||
db.close()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Instance not found")
|
||||
# Find workspace path
|
||||
ws = f"/app/workspace/*/{user['id']}/ppt-generator/{instance_id}/output.pptx"
|
||||
import glob
|
||||
matches = glob.glob(ws)
|
||||
if matches:
|
||||
return FileResponse(matches[0], media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
filename="presentation.pptx")
|
||||
raise HTTPException(404, "PPTX file not found")
|
||||
|
||||
@router.post("/seo/analyze")
|
||||
async def analyze_seo(req: SEORequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||
"""Analyze SEO for a URL/keyword."""
|
||||
runtime = SEOSkillRuntime(
|
||||
skill_id="seo-optimizer",
|
||||
user_id=user["id"],
|
||||
tenant_id=tenant
|
||||
)
|
||||
result = await runtime.execute({"url": req.url, "keywords": req.keywords})
|
||||
return {"code": 0, "data": result, "message": "SEO analysis complete"}
|
||||
|
||||
@router.post("/research/generate")
|
||||
async def generate_research(req: ResearchRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
|
||||
"""Generate investment research report."""
|
||||
runtime = ResearchSkillRuntime(
|
||||
skill_id="investment-research",
|
||||
user_id=user["id"],
|
||||
tenant_id=tenant
|
||||
)
|
||||
result = await runtime.execute({"symbol": req.symbol})
|
||||
return {"code": 0, "data": result, "message": "Research generated"}
|
||||
|
||||
@router.get("/status/{instance_id}")
|
||||
async def get_skill_status(instance_id: str, user=Depends(get_current_user)):
|
||||
"""Check skill execution status."""
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
row = db.execute(text(
|
||||
"SELECT status, result, error, created_at FROM skill_instances WHERE id = :id AND user_id = :uid"
|
||||
), {"id": instance_id, "uid": user["id"]}).fetchone()
|
||||
db.close()
|
||||
if not row:
|
||||
raise HTTPException(404, "Instance not found")
|
||||
return {"code": 0, "data": {"status": row[0], "result": row[1], "error": row[2],
|
||||
"created_at": str(row[3])}}
|
||||
@@ -12,12 +12,35 @@ from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||
|
||||
|
||||
# Phase0: 5-level RBAC
|
||||
ROLE_HIERARCHY = {
|
||||
"super_admin": 100,
|
||||
"admin": 80,
|
||||
"developer": 50,
|
||||
"user": 30,
|
||||
"guest": 10,
|
||||
}
|
||||
|
||||
def has_role(user_role: str, required: str) -> bool:
|
||||
return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required, 0)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
security = HTTPBearer()
|
||||
security_optional = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_tenant(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
try:
|
||||
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return payload.get("tenant_id")
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""使用 bcrypt 直接生成密码哈希"""
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
@@ -35,8 +58,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, tenant_id: str = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if tenant_id:
|
||||
to_encode["tenant_id"] = tenant_id
|
||||
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
@@ -97,6 +122,6 @@ async def get_current_admin(
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
"""验证当前用户为管理员"""
|
||||
if user.role != "admin":
|
||||
if not has_role(user.role, "admin"):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||
return user
|
||||
@@ -98,10 +98,11 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
|
||||
# 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型
|
||||
# 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频"
|
||||
if capability in ("image", "video", "audio"):
|
||||
from sqlalchemy import text as _text, func
|
||||
from sqlalchemy import text as _text, func, type_coerce
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
m = db.query(AIModel).filter(
|
||||
AIModel.is_enabled == True,
|
||||
func.json_contains(AIModel.capabilities, f'"{capability}"'),
|
||||
AIModel.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
|
||||
).first()
|
||||
if m:
|
||||
return m
|
||||
|
||||
@@ -1293,7 +1293,7 @@ def _build_system_prompt(
|
||||
|
||||
|
||||
|
||||
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
|
||||
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 \"\"\" 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
|
||||
|
||||
|
||||
|
||||
@@ -2749,7 +2749,7 @@ def ensure_structured_column():
|
||||
with engine.connect() as conn:
|
||||
exists = conn.execute(text(
|
||||
"SELECT COUNT(*) FROM information_schema.columns "
|
||||
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
|
||||
"WHERE table_schema = CURRENT_DATABASE() AND table_name='conversation_summaries' "
|
||||
"AND column_name='structured_memory'"
|
||||
)).scalar()
|
||||
if not exists:
|
||||
|
||||
@@ -15,7 +15,12 @@ if not (Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()):
|
||||
load_dotenv(env_path, override=False)
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
|
||||
# PostgreSQL preferred when DATABASE_URL is provided (production); else legacy MySQL builder (local/dev + rollback)
|
||||
_DATABASE_URL_ENV = os.getenv("DATABASE_URL", "")
|
||||
if _DATABASE_URL_ENV:
|
||||
DATABASE_URL = _DATABASE_URL_ENV
|
||||
else:
|
||||
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
|
||||
|
||||
# Redis配置
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""统一能力发现层(双 Claw 共用 · 单一实现)。
|
||||
|
||||
背景
|
||||
----
|
||||
七虎平台对外有两个 Claw 端口:
|
||||
* Web Claw —— SSE 流式,用户可在 UI 勾选 skills / mcp / agents
|
||||
* IM Bot Claw —— 微信等 IM,server push 整条回复,**没有任何勾选 UI**
|
||||
|
||||
历史问题(2026-08-04 线上实证):
|
||||
1. `im_llm.py` 写死 `agents=[]` / `mcp=[]`,IM 端永远拿不到 MCP 工具;
|
||||
2. `_discover_installed_skills()` 强依赖 `app_installations` 表,而线上该表
|
||||
**0 行** → 97 个 published skill 在 Web / IM **双端同时发现不到**;
|
||||
3. IM 端 skills 还被 `[:15]` 截断(截断一个空列表,属噪声)。
|
||||
|
||||
设计原则
|
||||
--------
|
||||
能力发现只允许有 **一份实现**(本模块)。任何 Claw 想要"技能/工具",
|
||||
都必须调用这里的函数;禁止在端侧另写一套发现逻辑——历史上 #866 部署
|
||||
就是因为存在第二份实现而把 #764/#765 的注入改动整体覆盖回退。
|
||||
|
||||
发现策略(三级降级,双端一致)
|
||||
--------------------------
|
||||
1) 用户显式安装(app_installations)—— 最高优先,尊重用户意愿
|
||||
2) 无安装记录 → 降级为平台已发布(status='published')全集
|
||||
3) 池子超过预算 → 按当前问题做轻量相关性排序,取 top-K 控制 token
|
||||
|
||||
运维旋钮(环境变量,改完重启生效)
|
||||
--------------------------------
|
||||
CAP_SKILL_LIMIT 单轮注入技能数上限,默认 12
|
||||
CAP_MCP_LIMIT 单轮注入 MCP 服务数上限,默认 8
|
||||
CAP_AGENT_LIMIT 单轮可见 Agent 数上限,默认 6
|
||||
CAP_SKILL_FALLBACK_PUBLISHED 安装表为空时是否降级为 published,默认 开
|
||||
CAP_AGENT_FALLBACK_PUBLISHED Agent 同上,默认 开
|
||||
CAP_MCP_AUTO 未显式指定时是否自动挂载已启用 MCP,默认 开
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Iterable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = [
|
||||
"discover_skills",
|
||||
"discover_mcp_servers",
|
||||
"discover_agents",
|
||||
"build_mcp_tool_defs",
|
||||
"rank_by_query",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 环境旋钮
|
||||
# --------------------------------------------------------------------------
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
try:
|
||||
raw = (os.environ.get(name) or "").strip()
|
||||
return int(raw) if raw else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool = True) -> bool:
|
||||
raw = (os.environ.get(name) or "").strip().lower()
|
||||
if not raw:
|
||||
return default
|
||||
return raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 轻量相关性排序(中文友好:整词 + 2-gram)
|
||||
# --------------------------------------------------------------------------
|
||||
def _tokens(text: str) -> set:
|
||||
text = re.sub(r"[^\w\u4e00-\u9fff]+", " ", (text or "").lower())
|
||||
out: set = set()
|
||||
for w in text.split():
|
||||
if len(w) >= 2:
|
||||
out.add(w)
|
||||
for i in range(len(w) - 1):
|
||||
out.add(w[i : i + 2])
|
||||
return out
|
||||
|
||||
|
||||
def rank_by_query(items: list, query: str, top_k: int) -> list:
|
||||
"""按与 query 的词元重叠度排序取前 top_k。
|
||||
|
||||
池子本来就不超预算时原样返回,避免无谓抖动(同一问题两次调用结果稳定)。
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
if top_k <= 0 or len(items) <= top_k:
|
||||
return items[:top_k] if top_k > 0 else []
|
||||
q = _tokens(query)
|
||||
if not q:
|
||||
return items[:top_k]
|
||||
|
||||
scored = []
|
||||
for idx, it in enumerate(items):
|
||||
blob = " ".join(
|
||||
str(it.get(k) or "")
|
||||
for k in ("name", "description", "category", "func_name")
|
||||
)
|
||||
t = _tokens(blob)
|
||||
if t:
|
||||
# 交集数按文档长度做平方根归一,抑制"长描述天然占优"
|
||||
score = len(q & t) / (len(t) ** 0.5)
|
||||
else:
|
||||
score = 0.0
|
||||
# idx 作为稳定次序的 tie-breaker,保证同分时结果可复现
|
||||
scored.append((-score, idx, it))
|
||||
scored.sort(key=lambda x: (x[0], x[1]))
|
||||
return [it for _, _, it in scored[:top_k]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 技能发现
|
||||
# --------------------------------------------------------------------------
|
||||
def discover_skills(
|
||||
db,
|
||||
user_id: str | None,
|
||||
query: str = "",
|
||||
limit: int | None = None,
|
||||
claw: str = "web",
|
||||
) -> list:
|
||||
"""发现当前请求可用的技能列表(双 Claw 共用)。
|
||||
|
||||
返回 `_resolve_market_skills` 的结构:
|
||||
[{id, name, description, skill_content, category, func_name, parameters}]
|
||||
"""
|
||||
limit = limit if limit is not None else _env_int("CAP_SKILL_LIMIT", 12)
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
# 延迟导入:避免 app.core ←→ app.api 循环依赖
|
||||
from app.api.chat_skill import _resolve_market_skills
|
||||
from app.models import AppInstallation, MarketApp
|
||||
|
||||
pool: list = []
|
||||
source = "empty"
|
||||
|
||||
# 1) 用户显式安装
|
||||
if user_id:
|
||||
try:
|
||||
app_ids = [
|
||||
i.app_id
|
||||
for i in db.query(AppInstallation)
|
||||
.filter(AppInstallation.user_id == user_id)
|
||||
.all()
|
||||
]
|
||||
if app_ids:
|
||||
ids = [
|
||||
a.id
|
||||
for a in db.query(MarketApp)
|
||||
.filter(
|
||||
MarketApp.id.in_(app_ids),
|
||||
MarketApp.type == "skill",
|
||||
MarketApp.status == "published",
|
||||
)
|
||||
.all()
|
||||
]
|
||||
if ids:
|
||||
pool = _resolve_market_skills(db, ids, user_id)
|
||||
source = "installed"
|
||||
except Exception as e:
|
||||
logger.warning("[cap] 安装技能发现失败(降级继续): %s", e)
|
||||
|
||||
# 2) 降级:平台已发布全集
|
||||
if not pool and _env_flag("CAP_SKILL_FALLBACK_PUBLISHED", True):
|
||||
try:
|
||||
ids = [
|
||||
a.id
|
||||
for a in db.query(MarketApp)
|
||||
.filter(MarketApp.type == "skill", MarketApp.status == "published")
|
||||
.all()
|
||||
]
|
||||
if ids:
|
||||
pool = _resolve_market_skills(db, ids, user_id or "")
|
||||
source = "published"
|
||||
except Exception as e:
|
||||
logger.warning("[cap] published 技能发现失败: %s", e)
|
||||
|
||||
# 3) 预算裁剪
|
||||
picked = rank_by_query(pool, query, limit)
|
||||
logger.info(
|
||||
"[cap] skills claw=%s source=%s pool=%d picked=%d",
|
||||
claw,
|
||||
source,
|
||||
len(pool),
|
||||
len(picked),
|
||||
)
|
||||
return picked
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MCP 发现
|
||||
# --------------------------------------------------------------------------
|
||||
def discover_mcp_servers(
|
||||
db,
|
||||
explicit_ids: Iterable[str] | None = None,
|
||||
limit: int | None = None,
|
||||
claw: str = "web",
|
||||
) -> list:
|
||||
"""发现当前请求可用的 MCP 服务连接(双 Claw 共用)。
|
||||
|
||||
Web Claw 传 explicit_ids(用户在 UI 勾选的);
|
||||
IM Bot Claw 无勾选通道 → 传 None,自动挂载平台已启用的 MCP。
|
||||
返回 [{id, name, description, transport, endpoint, url, headers}]
|
||||
"""
|
||||
limit = limit if limit is not None else _env_int("CAP_MCP_LIMIT", 8)
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
from app.api.chat_mcp import _list_enabled_mcp_servers, _resolve_active_mcp_servers
|
||||
|
||||
ids = [i for i in (explicit_ids or []) if i]
|
||||
if ids:
|
||||
out = _resolve_active_mcp_servers(db, ids)[:limit]
|
||||
logger.info("[cap] mcp claw=%s source=explicit picked=%d", claw, len(out))
|
||||
return out
|
||||
|
||||
if not _env_flag("CAP_MCP_AUTO", True):
|
||||
return []
|
||||
|
||||
out = _list_enabled_mcp_servers(db)[:limit]
|
||||
logger.info("[cap] mcp claw=%s source=auto picked=%d", claw, len(out))
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Agent 发现(#874:画布节点候选 / IM `/flow` 可选清单)
|
||||
# --------------------------------------------------------------------------
|
||||
def discover_agents(
|
||||
db,
|
||||
user_id: str | None,
|
||||
query: str = "",
|
||||
explicit_ids: Iterable[str] | None = None,
|
||||
limit: int | None = None,
|
||||
claw: str = "web",
|
||||
) -> list:
|
||||
"""发现当前请求可用的智能体(双 Claw 共用),策略与 discover_skills 同构。
|
||||
|
||||
返回 `_resolve_market_agents` 的结构:
|
||||
[{id, name, role_prompt, instructions, skill_ids, knowledge_base_ids,
|
||||
model_id, tools, description, tool_policy, platform, agent_endpoint,
|
||||
agent_api_key, orchestration}]
|
||||
|
||||
用途定位:给 NodeRegistry 生成 agent 型节点候选、给 IM `/flow` 列可选智能体。
|
||||
若要拿它往 system prompt 里注入,请自行收窄 limit —— 多个 Agent 人格叠加会串味。
|
||||
"""
|
||||
limit = limit if limit is not None else _env_int("CAP_AGENT_LIMIT", 6)
|
||||
if limit <= 0:
|
||||
return []
|
||||
|
||||
from app.api.chat_skill import _resolve_market_agents
|
||||
from app.models import AppInstallation, MarketApp
|
||||
|
||||
# 0) 显式指定(Web Claw 勾选 / 画布已引用)—— 不做相关性裁剪,用户意愿最高
|
||||
ids = [i for i in (explicit_ids or []) if i]
|
||||
if ids:
|
||||
out = _resolve_market_agents(db, ids, user_id or "")
|
||||
logger.info("[cap] agents claw=%s source=explicit picked=%d", claw, len(out))
|
||||
return out
|
||||
|
||||
pool: list = []
|
||||
source = "empty"
|
||||
|
||||
# 1) 用户显式安装
|
||||
if user_id:
|
||||
try:
|
||||
app_ids = [
|
||||
i.app_id
|
||||
for i in db.query(AppInstallation)
|
||||
.filter(AppInstallation.user_id == user_id)
|
||||
.all()
|
||||
]
|
||||
if app_ids:
|
||||
aids = [
|
||||
a.id
|
||||
for a in db.query(MarketApp)
|
||||
.filter(
|
||||
MarketApp.id.in_(app_ids),
|
||||
MarketApp.type == "agent",
|
||||
MarketApp.status == "published",
|
||||
)
|
||||
.all()
|
||||
]
|
||||
if aids:
|
||||
pool = _resolve_market_agents(db, aids, user_id)
|
||||
source = "installed"
|
||||
except Exception as e:
|
||||
logger.warning("[cap] 安装 Agent 发现失败(降级继续): %s", e)
|
||||
|
||||
# 2) 降级:平台已发布全集
|
||||
if not pool and _env_flag("CAP_AGENT_FALLBACK_PUBLISHED", True):
|
||||
try:
|
||||
aids = [
|
||||
a.id
|
||||
for a in db.query(MarketApp)
|
||||
.filter(MarketApp.type == "agent", MarketApp.status == "published")
|
||||
.all()
|
||||
]
|
||||
if aids:
|
||||
pool = _resolve_market_agents(db, aids, user_id or "")
|
||||
source = "published"
|
||||
except Exception as e:
|
||||
logger.warning("[cap] published Agent 发现失败: %s", e)
|
||||
|
||||
# 3) 预算裁剪
|
||||
picked = rank_by_query(pool, query, limit)
|
||||
logger.info(
|
||||
"[cap] agents claw=%s source=%s pool=%d picked=%d",
|
||||
claw,
|
||||
source,
|
||||
len(pool),
|
||||
len(picked),
|
||||
)
|
||||
return picked
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# MCP → function definitions
|
||||
# --------------------------------------------------------------------------
|
||||
def _safe_name(raw: str, maxlen: int) -> str:
|
||||
return re.sub(r"[^A-Za-z0-9_-]", "_", (raw or "")[:maxlen])
|
||||
|
||||
|
||||
async def build_mcp_tool_defs(servers: list, idx_start: int = 0) -> tuple:
|
||||
"""把 MCP 服务列表展开为 (function_definitions, tool_map)。
|
||||
|
||||
命名规则与 Web Claw 现有实现严格一致:`mcp__{server}__{tool}__{idx}`,
|
||||
保证两端产生的工具名可互相对照、可被同一套审批/统计逻辑识别。
|
||||
|
||||
返回:
|
||||
function_definitions: [{name, description, parameters}]
|
||||
tool_map: {func_name: {server_id, name, server_name}}
|
||||
"""
|
||||
from app.mcp_client import list_mcp_tools
|
||||
|
||||
defs: list = []
|
||||
tool_map: dict = {}
|
||||
|
||||
for offset, srv in enumerate(servers or []):
|
||||
idx = idx_start + offset
|
||||
try:
|
||||
tools = await list_mcp_tools(srv)
|
||||
except Exception as e:
|
||||
logger.warning("[cap] MCP 拉取工具失败 [%s]: %s", srv.get("name"), e)
|
||||
continue
|
||||
for t in tools or []:
|
||||
sname = _safe_name(srv.get("name") or "unknown", 30)
|
||||
tname = _safe_name(t.get("name") or "tool", 50)
|
||||
fname = f"mcp__{sname}__{tname}__{idx}"
|
||||
tool_map[fname] = {
|
||||
"server_id": srv.get("id"),
|
||||
"name": t.get("name"),
|
||||
"server_name": srv.get("name"),
|
||||
}
|
||||
defs.append(
|
||||
{
|
||||
"name": fname,
|
||||
"description": (
|
||||
f"[MCP:{srv.get('name')}] {t.get('description') or t.get('name') or ''}"
|
||||
)[:900],
|
||||
"parameters": t.get("inputSchema")
|
||||
or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
return defs, tool_map
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
IM Bot Channel Adapter — 渠道插件化基础设施
|
||||
|
||||
任务 #935 · 2026-08-09
|
||||
- ChannelAdapter: 抽象基类
|
||||
- IncomingMessage: 跨渠道归一入站消息
|
||||
- ChannelRouter: 单例路由,复用 _send_message_core + BufferAdapter(单引擎原则)
|
||||
|
||||
依赖:已存在于 app/core/ 的 capability_discovery + claw_adapters(BufferAdapter)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IncomingMessage:
|
||||
"""跨渠道归一化入站消息。
|
||||
|
||||
ChannelPlugin.start() 启动后接收到 raw message,转成 IncomingMessage,
|
||||
交给 ChannelRouter.dispatch,组装后回送给原渠道。
|
||||
"""
|
||||
channel: str # "telegram" / "feishu" / "wecom" / ...
|
||||
platform_user_id: str # 渠道内用户/群 ID
|
||||
text: str # 正文(去 @-mention + 富文本转 markdown)
|
||||
is_group: bool = False # 群消息
|
||||
group_id: Optional[str] = None
|
||||
at_me: bool = False # 群中是否 @ 机器人
|
||||
attachments: Optional[list] = None # 图片/文件 URL(可选)
|
||||
raw: Any = None # 原始消息,留给高级用例
|
||||
|
||||
|
||||
class ChannelAdapter(ABC):
|
||||
"""IM 渠道插件契约。
|
||||
|
||||
实现要点:
|
||||
- start() 启动连接(扫码 / WebSocket / webhook / Bot)
|
||||
- send() 出站文本(注意各家消息长度上限,Telegram=4096 / 飞书=4000)
|
||||
- 收到入站消息时,内部组装为 IncomingMessage,转发给 self.router.dispatch
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def channel_id(self) -> str: ...
|
||||
|
||||
@abstractmethod
|
||||
async def start(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def stop(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, *, user_id: str, content: str) -> None: ...
|
||||
|
||||
# 由 ChannelRouter 注入(register() 时反向 set)
|
||||
router: Optional["ChannelRouter"] = None
|
||||
|
||||
|
||||
class ChannelRouter:
|
||||
"""单例路由。所有入站消息统一过 _send_message_core。"""
|
||||
|
||||
_shared: Optional["ChannelRouter"] = None
|
||||
|
||||
@classmethod
|
||||
def shared(cls) -> "ChannelRouter":
|
||||
if cls._shared is None:
|
||||
cls._shared = cls()
|
||||
return cls._shared
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._adapters: Dict[str, ChannelAdapter] = {}
|
||||
self._started: bool = False
|
||||
# 延迟导入(避免循环依赖)
|
||||
self._send_message_core = None
|
||||
self._SendMessageRequest = None
|
||||
self._BufferAdapter = None
|
||||
self._discover_skills = None
|
||||
self._discover_mcp = None
|
||||
self._Conversation = None
|
||||
self._User = None
|
||||
self._gen_uuid = None
|
||||
|
||||
def register(self, adapter: ChannelAdapter) -> None:
|
||||
if self._started:
|
||||
raise RuntimeError("ChannelRouter already started; can't register more adapters")
|
||||
adapter.router = self
|
||||
self._adapters[adapter.channel_id] = adapter
|
||||
log.info("[router] registered channel: %s", adapter.channel_id)
|
||||
|
||||
@property
|
||||
def channels(self) -> list[str]:
|
||||
return list(self._adapters.keys())
|
||||
|
||||
async def start_all(self) -> None:
|
||||
"""main.py 的 startup 钩子调用。"""
|
||||
self._lazy_import()
|
||||
for a in self._adapters.values():
|
||||
try:
|
||||
await a.start()
|
||||
except Exception as e:
|
||||
log.warning("[router] start %s failed: %s", a.channel_id, e, exc_info=True)
|
||||
self._started = True
|
||||
log.info("[router] started %d channels: %s", len(self._adapters), self.channels)
|
||||
|
||||
async def stop_all(self) -> None:
|
||||
for a in self._adapters.values():
|
||||
try:
|
||||
await a.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._started = False
|
||||
|
||||
def _lazy_import(self) -> None:
|
||||
if self._send_message_core is not None:
|
||||
return
|
||||
from app.api.chat_service import _send_message_core
|
||||
from app.api.chat_utils import SendMessageRequest
|
||||
from app.core.claw_adapters import BufferAdapter
|
||||
from app.core.capability_discovery import discover_mcp_servers, discover_skills
|
||||
from app.models import Conversation, User
|
||||
from app.core.uuid_compat import gen_uuid # 项目内统一 UUID 生成
|
||||
|
||||
self._send_message_core = _send_message_core
|
||||
self._SendMessageRequest = SendMessageRequest
|
||||
self._BufferAdapter = BufferAdapter
|
||||
self._discover_skills = discover_skills
|
||||
self._discover_mcp = discover_mcp_servers
|
||||
self._Conversation = Conversation
|
||||
self._User = User
|
||||
self._gen_uuid = gen_uuid
|
||||
|
||||
async def dispatch(self, incoming: IncomingMessage) -> str:
|
||||
"""1 条入站 → 1 条回复。
|
||||
|
||||
复用单引擎:_send_message_core + BufferAdapter。
|
||||
鉴权:从 im_channel_routes 解析 platform_user_id → internal User。
|
||||
"""
|
||||
self._lazy_import()
|
||||
# 1. 解析内部 user(由 platform_user_id → user_id)
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models import IMChannelRoute
|
||||
from app.core.db import SessionLocal
|
||||
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
route = db.query(IMChannelRoute).filter(
|
||||
IMChannelRoute.channel == incoming.channel,
|
||||
IMChannelRoute.platform_user_id == incoming.platform_user_id,
|
||||
).first()
|
||||
if route is None:
|
||||
return "(请先在七虎AI平台绑定账号:发送 /bind <你的绑定码>)"
|
||||
|
||||
user = db.query(self._User).filter(self._User.id == route.internal_user_id).first()
|
||||
if user is None:
|
||||
return "(绑定账号已失效,请重新绑定)"
|
||||
|
||||
# 2. 能力发现
|
||||
skills = []
|
||||
try:
|
||||
skills = self._discover_skills(db, user.id, query=incoming.text, claw="im") or []
|
||||
except Exception as e:
|
||||
log.warning("[router.disp] skill discovery: %s", e)
|
||||
mcp_servers = []
|
||||
try:
|
||||
mcp_servers = self._discover_mcp(db, None, claw="im") or []
|
||||
except Exception as e:
|
||||
log.warning("[router.disp] mcp discovery: %s", e)
|
||||
|
||||
skill_ids = [
|
||||
(s.get("id") if isinstance(s, dict) else getattr(s, "id", None))
|
||||
for s in skills
|
||||
]
|
||||
skill_ids = [s for s in skill_ids if s]
|
||||
mcp_ids = [
|
||||
(m.get("id") if isinstance(m, dict) else getattr(m, "id", None))
|
||||
for m in mcp_servers
|
||||
]
|
||||
mcp_ids = [m for m in mcp_ids if m]
|
||||
|
||||
# 3. 临时会话
|
||||
conv = self._Conversation(
|
||||
id=self._gen_uuid(),
|
||||
user_id=user.id,
|
||||
title=f"IM-{incoming.channel}",
|
||||
source="im_bot",
|
||||
)
|
||||
db.add(conv)
|
||||
db.commit()
|
||||
|
||||
req = self._SendMessageRequest(
|
||||
content=incoming.text,
|
||||
skill_ids=skill_ids or None,
|
||||
mcp_ids=mcp_ids or None,
|
||||
use_tools=bool(skill_ids or mcp_ids),
|
||||
agent_ids=None,
|
||||
knowledge_base_ids=None,
|
||||
attachments=None,
|
||||
)
|
||||
|
||||
adapter = self._BufferAdapter(tenant_id=str(getattr(user, "tenant_id", "") or ""))
|
||||
await self._send_message_core(conv.id, req, db, user, stream=True, on_event=adapter)
|
||||
reply = adapter.assemble()
|
||||
return reply or "(生成失败,请稍后再试)"
|
||||
except Exception as e:
|
||||
log.exception("[router.disp] failed: %s", e)
|
||||
return f"(系统繁忙:{e})"
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,294 @@
|
||||
"""双 Claw 适配器(#873 · P0)。
|
||||
|
||||
单引擎原则:
|
||||
能力只在 EngineCore(`_send_message_core`) 实现一次;
|
||||
Adapter 只做「引擎事件 → 通道」的搬运,不含任何业务判断。
|
||||
|
||||
┌──────────────┐ on_event(EngineEvent) ┌──────────────┐
|
||||
│ EngineCore │ ────────────────────────► │ ClawAdapter │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌───────────────────────┴────────────────────────┐
|
||||
▼ ▼
|
||||
SseAdapter (Web Claw) BufferAdapter (IM Bot Claw)
|
||||
逐帧 `data: {...}\\n\\n` 缓冲装配 → 整条 str | 媒体 dict
|
||||
|
||||
历史教训(#866):
|
||||
IM 侧曾出现「第二份实现」,一次全量部署就把它冲掉、能力静默回退。
|
||||
因此两个 Claw 的差异必须、且只能收敛在本文件的两个 Adapter 里。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.core.engine_event import (
|
||||
CLAW_IM,
|
||||
CLAW_WEB,
|
||||
DELTA,
|
||||
DONE,
|
||||
ERROR,
|
||||
REASONING,
|
||||
STEP,
|
||||
Diagnosis,
|
||||
EngineEvent,
|
||||
from_wire,
|
||||
make_error,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["BaseClawAdapter", "SseAdapter", "BufferAdapter"]
|
||||
|
||||
# 单条回复的软上限:防止异常循环把内存/微信消息撑爆
|
||||
_MAX_ASSEMBLED_CHARS = 20000
|
||||
_MEDIA_RE = re.compile(r'<(img|video)[^>]*\ssrc="([^"]+)"', re.IGNORECASE)
|
||||
|
||||
|
||||
class BaseClawAdapter:
|
||||
"""Adapter 基类。实例可直接当作 `on_event` 回调传给 EngineCore。"""
|
||||
|
||||
claw: str = ""
|
||||
|
||||
def __init__(self, *, trace_id: str = "", tenant_id: str = ""):
|
||||
self.trace_id = trace_id or ""
|
||||
self.tenant_id = tenant_id or ""
|
||||
self._seq = 0
|
||||
self.counts: Dict[str, int] = {}
|
||||
|
||||
# -- 供 EngineCore 直接 await adapter(event) -----------------------
|
||||
async def __call__(self, event: Any) -> None:
|
||||
await self.emit(event)
|
||||
|
||||
async def emit(self, event: Any) -> None:
|
||||
ev = self._normalize(event)
|
||||
if ev is None:
|
||||
return
|
||||
self.counts[ev.type] = self.counts.get(ev.type, 0) + 1
|
||||
try:
|
||||
await self._handle(ev)
|
||||
except Exception as exc: # Adapter 永不把异常反噬引擎
|
||||
logger.warning("[claw:%s] emit failed: %s", self.claw, exc, exc_info=True)
|
||||
|
||||
# -- 子类实现 -------------------------------------------------------
|
||||
async def _handle(self, ev: EngineEvent) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
# -- 归一化 ---------------------------------------------------------
|
||||
def _normalize(self, event: Any) -> Optional[EngineEvent]:
|
||||
ev = from_wire(event)
|
||||
if ev is None:
|
||||
logger.warning("[claw:%s] drop malformed event: %r", self.claw, event)
|
||||
return None
|
||||
self._seq += 1
|
||||
if not ev.seq:
|
||||
ev.seq = self._seq
|
||||
if not ev.trace_id:
|
||||
ev.trace_id = self.trace_id
|
||||
if not ev.tenant_id:
|
||||
ev.tenant_id = self.tenant_id
|
||||
if not ev.claw:
|
||||
ev.claw = self.claw
|
||||
return ev
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Web Claw —— SSE 逐帧
|
||||
# =====================================================================
|
||||
class SseAdapter(BaseClawAdapter):
|
||||
"""把引擎事件序列化为 SSE 帧并入队,由路由的 event_gen 逐帧 yield。
|
||||
|
||||
行为与改造前的 chat.py 内联实现逐字节一致:
|
||||
`data: ` + json.dumps(wire, ensure_ascii=False) + `\\n\\n`
|
||||
"""
|
||||
|
||||
claw = CLAW_WEB
|
||||
_SENTINEL = {"type": "__end__"}
|
||||
# 仅用于内部可观测(#876),默认不上线 —— 见 _thin()
|
||||
_META_KEYS = ("seq", "trace_id", "tenant_id", "claw")
|
||||
|
||||
def __init__(self, queue: "Optional[asyncio.Queue]" = None, *, wire_meta: bool = False, **kw):
|
||||
super().__init__(**kw)
|
||||
self.queue: asyncio.Queue = queue if queue is not None else asyncio.Queue()
|
||||
self.wire_meta = wire_meta
|
||||
self._closed = False
|
||||
|
||||
def _thin(self, ev: EngineEvent, wire: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""裁剪线上元数据。
|
||||
|
||||
why: delta 是最热的帧,一次长回复有上千个。实测带全量元数据会让
|
||||
单帧从 46B 膨胀到 117B(2.5x),2000 帧即多传 ~140KB 纯噪音,
|
||||
而前端根本不消费这些键。故默认剥离,只在终帧留 trace_id ——
|
||||
单帧成本可忽略,却是线上排障唯一的抓手。
|
||||
"""
|
||||
if self.wire_meta:
|
||||
return wire
|
||||
for k in self._META_KEYS:
|
||||
wire.pop(k, None)
|
||||
if ev.is_terminal() and ev.trace_id:
|
||||
wire["trace_id"] = ev.trace_id
|
||||
return wire
|
||||
|
||||
async def _handle(self, ev: EngineEvent) -> None:
|
||||
await self.queue.put(self._thin(ev, ev.to_wire()))
|
||||
|
||||
async def emit_error(self, message: str, *, diagnosis: Any = None) -> None:
|
||||
await self.emit(make_error(message, diagnosis=diagnosis))
|
||||
|
||||
async def close(self) -> None:
|
||||
"""通知 frames() 结束。幂等。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
await self.queue.put(dict(self._SENTINEL))
|
||||
|
||||
@staticmethod
|
||||
def frame(wire: Dict[str, Any]) -> str:
|
||||
return "data: " + json.dumps(wire, ensure_ascii=False) + "\n\n"
|
||||
|
||||
async def frames(self):
|
||||
"""异步生成 SSE 帧文本,直到收到哨兵。"""
|
||||
while True:
|
||||
wire = await self.queue.get()
|
||||
if not isinstance(wire, dict):
|
||||
continue
|
||||
if wire.get("type") == "__end__":
|
||||
break
|
||||
yield self.frame(wire)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# IM Bot Claw —— 缓冲装配
|
||||
# =====================================================================
|
||||
class BufferAdapter(BaseClawAdapter):
|
||||
"""累积引擎事件,最后一次性装配为可直接推送给 IM 的回复。
|
||||
|
||||
IM 通道(微信/iLink)没有流式能力,也没有卡片 UI:
|
||||
* delta 累积成正文;
|
||||
* reasoning 默认丢弃(思维链不该刷屏微信),可用 keep_reasoning 打开;
|
||||
* step 里的 error 级 diagnosis 降级成文本尾注,用户至少知道「哪一步炸了」;
|
||||
* done 的 content 优先于累积的 delta(引擎已做最终裁剪/落库)。
|
||||
"""
|
||||
|
||||
claw = CLAW_IM
|
||||
|
||||
def __init__(self, *, keep_reasoning: bool = False, keep_diagnosis: bool = True, **kw):
|
||||
super().__init__(**kw)
|
||||
self.keep_reasoning = keep_reasoning
|
||||
self.keep_diagnosis = keep_diagnosis
|
||||
self._delta_parts: List[str] = []
|
||||
self._reasoning_parts: List[str] = []
|
||||
self.steps: List[Dict[str, Any]] = []
|
||||
self.done: Optional[Dict[str, Any]] = None
|
||||
self.error: Optional[str] = None
|
||||
self.error_diagnosis: Optional[Diagnosis] = None
|
||||
self.used_skills: List[Any] = []
|
||||
self.used_mcp: List[Any] = []
|
||||
self.generating: bool = False
|
||||
|
||||
async def _handle(self, ev: EngineEvent) -> None:
|
||||
if ev.type == DELTA:
|
||||
self._delta_parts.append(ev.content)
|
||||
elif ev.type == REASONING:
|
||||
if self.keep_reasoning:
|
||||
self._reasoning_parts.append(ev.content)
|
||||
elif ev.type == STEP:
|
||||
self._record_step(ev)
|
||||
elif ev.type == DONE:
|
||||
wire = ev.to_wire()
|
||||
self.done = wire
|
||||
self.used_skills = wire.get("used_skills") or []
|
||||
self.used_mcp = wire.get("used_mcp") or []
|
||||
self.generating = bool(wire.get("generating"))
|
||||
if wire.get("process_steps"):
|
||||
self.steps = list(wire["process_steps"])
|
||||
elif ev.type == ERROR:
|
||||
self.error = str(ev.extra.get("message") or ev.content or "未知错误")
|
||||
self.error_diagnosis = ev.diagnosis
|
||||
|
||||
def _record_step(self, ev: EngineEvent) -> None:
|
||||
step = ev.extra.get("step")
|
||||
if not isinstance(step, dict):
|
||||
return
|
||||
idx = ev.extra.get("index")
|
||||
if isinstance(idx, int) and 0 <= idx < len(self.steps):
|
||||
self.steps[idx] = step
|
||||
else:
|
||||
self.steps.append(step)
|
||||
|
||||
# -- 装配 -----------------------------------------------------------
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""当前累积正文(done 优先)。"""
|
||||
if self.done and isinstance(self.done.get("content"), str) and self.done["content"]:
|
||||
return self.done["content"]
|
||||
return "".join(self._delta_parts)
|
||||
|
||||
def assemble(self) -> "str | dict":
|
||||
"""产出最终回复:媒体则给结构化 dict,否则给纯文本。
|
||||
|
||||
返回的 dict 契约必须与 im_llm 媒体快路径一致:
|
||||
{"__media__": True, "media_type": "image"|"video", "url": "..."}
|
||||
—— im_bot_user.py 依赖它做微信内联发图/发视频。
|
||||
"""
|
||||
body = self.text
|
||||
|
||||
media = self._extract_media(body)
|
||||
if media is not None:
|
||||
return media
|
||||
|
||||
if not body:
|
||||
if self.error:
|
||||
return self._render_failure()
|
||||
if self.generating:
|
||||
return "(内容正在生成中,请稍后在平台「AI 聊天」查看结果)"
|
||||
return "(未生成有效回复,请换个说法再试一次)"
|
||||
|
||||
notes = self._diagnosis_notes()
|
||||
if notes:
|
||||
body = body.rstrip() + "\n\n" + "\n".join(notes)
|
||||
if self.error:
|
||||
body = body.rstrip() + "\n\n" + self._render_failure()
|
||||
if self.generating:
|
||||
body = body.rstrip() + "\n\n(媒体正在后台生成,稍后可在平台「AI 聊天」查看)"
|
||||
|
||||
if len(body) > _MAX_ASSEMBLED_CHARS:
|
||||
body = body[:_MAX_ASSEMBLED_CHARS] + "\n…(内容过长已截断)"
|
||||
return body
|
||||
|
||||
# -- 内部 -----------------------------------------------------------
|
||||
@staticmethod
|
||||
def _extract_media(body: str) -> Optional[Dict[str, Any]]:
|
||||
s = (body or "").strip()
|
||||
if not (s.startswith("<img") or s.startswith("<video")):
|
||||
return None
|
||||
m = _MEDIA_RE.search(s)
|
||||
if not m:
|
||||
return None
|
||||
return {
|
||||
"__media__": True,
|
||||
"media_type": "image" if m.group(1).lower() == "img" else "video",
|
||||
"url": m.group(2),
|
||||
}
|
||||
|
||||
def _diagnosis_notes(self) -> List[str]:
|
||||
"""把失败步骤的结构化诊断降级成文本尾注(IM 无卡片 UI)。"""
|
||||
if not self.keep_diagnosis:
|
||||
return []
|
||||
notes: List[str] = []
|
||||
for step in self.steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
diag = Diagnosis.from_dict(step.get("diagnosis"))
|
||||
if diag is not None and diag.severity == "error":
|
||||
notes.append(diag.to_text())
|
||||
# 去重保序
|
||||
return list(dict.fromkeys(notes))
|
||||
|
||||
def _render_failure(self) -> str:
|
||||
if self.error_diagnosis is not None:
|
||||
return self.error_diagnosis.to_text()
|
||||
return f"⚠️ 生成中断:{self.error}"
|
||||
@@ -0,0 +1,358 @@
|
||||
"""双 Claw 引擎事件契约(#873 · P0)。
|
||||
|
||||
why:
|
||||
平台有且只有两个 Claw 出口 —— Web Claw(SSE 逐帧) 与 IM Bot Claw(整条推送)。
|
||||
在此之前,引擎事件是「散落在 chat_service 里的裸 dict」:
|
||||
|
||||
* 构造点分散,字段全靠记忆,漏一个键前端就静默不渲染;
|
||||
* 没有 trace_id / tenant_id / claw 标签,可观测(#876)无处落脚;
|
||||
* 失败只有一句人话字符串,前端靠 result.includes('失败') 猜错误 —— 极脆。
|
||||
|
||||
本模块把事件收敛为「唯一构造入口」:所有事件必须经 make_* 工厂产出,
|
||||
再由 to_wire() 统一序列化。新增字段一律「非空才输出」,
|
||||
保证既有前端(ChatPage.tsx)零改动。
|
||||
|
||||
线格式契约(authoritative —— 前端已消费,禁止破坏):
|
||||
|
||||
delta -> {"type":"delta", "content": str}
|
||||
reasoning -> {"type":"reasoning", "content": str}
|
||||
step -> {"type":"step", "step": {...}, "index"?: int}
|
||||
done -> {"type":"done", "message_id", "conversation_id", "content",
|
||||
"process_steps", "used_skills", "used_mcp", "generating"}
|
||||
error -> {"type":"error", "message": str}
|
||||
|
||||
step 对象契约(AiProcessBlock 消费):
|
||||
|
||||
thinking -> {"type":"thinking", "text": str}
|
||||
skill -> {"type":"skill", "name": str, "args": dict, "result": str}
|
||||
mcp -> {"type":"mcp", "server": str, "name": str, "args": dict, "result": str}
|
||||
|
||||
附加(新增,前端可选消费): "diagnosis": {...}, "phase": "start|ok|fail",
|
||||
"node": {"id","type","label"} # #874 节点身份
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
__all__ = [
|
||||
"SCHEMA_VERSION",
|
||||
"DELTA", "REASONING", "STEP", "DONE", "ERROR", "EVENT_TYPES",
|
||||
"STEP_THINKING", "STEP_SKILL", "STEP_MCP", "STEP_AGENT", "STEP_KB", "STEP_MEDIA",
|
||||
"PHASE_START", "PHASE_OK", "PHASE_FAIL",
|
||||
"CLAW_WEB", "CLAW_IM",
|
||||
"SEV_INFO", "SEV_WARN", "SEV_ERROR",
|
||||
"Diagnosis", "EngineEvent",
|
||||
"make_delta", "make_reasoning", "make_step", "make_done", "make_error",
|
||||
"from_wire",
|
||||
]
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# ---- 事件类型(线格式 type 值,前端已硬编码,禁止改名)----
|
||||
DELTA = "delta"
|
||||
REASONING = "reasoning"
|
||||
STEP = "step"
|
||||
DONE = "done"
|
||||
ERROR = "error"
|
||||
EVENT_TYPES = frozenset({DELTA, REASONING, STEP, DONE, ERROR})
|
||||
|
||||
# ---- step 子类(前端按此选图标 / 渲染分支)----
|
||||
STEP_THINKING = "thinking"
|
||||
STEP_SKILL = "skill"
|
||||
STEP_MCP = "mcp"
|
||||
STEP_AGENT = "agent" # 新增:外部 Agent 调用
|
||||
STEP_KB = "kb" # 新增:知识库检索
|
||||
STEP_MEDIA = "media" # 新增:媒体生成
|
||||
|
||||
# ---- 执行阶段 ----
|
||||
PHASE_START = "start"
|
||||
PHASE_OK = "ok"
|
||||
PHASE_FAIL = "fail"
|
||||
|
||||
# ---- Claw 通道标签(可观测 #876 的核心维度)----
|
||||
CLAW_WEB = "web"
|
||||
CLAW_IM = "im"
|
||||
|
||||
# ---- 诊断严重级 ----
|
||||
SEV_INFO = "info"
|
||||
SEV_WARN = "warn"
|
||||
SEV_ERROR = "error"
|
||||
|
||||
_SEV_ICON = {SEV_INFO: "ℹ️", SEV_WARN: "⚠️", SEV_ERROR: "❌"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Diagnosis:
|
||||
"""结构化诊断。
|
||||
|
||||
取代「把失败原因塞进 result 字符串、前端再 includes('失败') 猜」的旧做法:
|
||||
* Web Claw 可据此渲染诊断卡片(图标/配色/重试按钮);
|
||||
* IM Bot Claw 无卡片 UI,用 to_text() 降级为纯文本行。
|
||||
"""
|
||||
|
||||
code: str = "" # 机器可读,如 "MCP_TIMEOUT" / "SKILL_NOT_FOUND"
|
||||
severity: str = SEV_INFO # info | warn | error
|
||||
summary: str = "" # 一句话结论(面向用户)
|
||||
detail: str = "" # 细节(可含技术信息,前端折叠展示)
|
||||
hint: str = "" # 可执行建议
|
||||
retryable: bool = False # 是否值得重试
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"code": self.code,
|
||||
"severity": self.severity,
|
||||
"summary": self.summary,
|
||||
"detail": self.detail,
|
||||
"hint": self.hint,
|
||||
"retryable": self.retryable,
|
||||
}
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""IM Bot Claw 的纯文本降级渲染。"""
|
||||
icon = _SEV_ICON.get(self.severity, "•")
|
||||
lines = [f"{icon} {self.summary or self.code or '执行提示'}"]
|
||||
if self.detail:
|
||||
lines.append(self.detail)
|
||||
if self.hint:
|
||||
lines.append(f"建议:{self.hint}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: Any) -> "Optional[Diagnosis]":
|
||||
if isinstance(d, Diagnosis):
|
||||
return d
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
return cls(
|
||||
code=str(d.get("code") or ""),
|
||||
severity=str(d.get("severity") or SEV_INFO),
|
||||
summary=str(d.get("summary") or ""),
|
||||
detail=str(d.get("detail") or ""),
|
||||
hint=str(d.get("hint") or ""),
|
||||
retryable=bool(d.get("retryable")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EngineEvent:
|
||||
"""引擎事件的唯一表示。禁止在业务代码里手搓等价 dict —— 用 make_* 工厂。"""
|
||||
|
||||
type: str
|
||||
content: str = ""
|
||||
seq: int = 0
|
||||
ts: float = field(default_factory=time.time)
|
||||
trace_id: str = ""
|
||||
tenant_id: str = ""
|
||||
claw: str = ""
|
||||
diagnosis: Optional[Diagnosis] = None
|
||||
# extra 承载各事件类型的专属键(message_id / step / process_steps ...),
|
||||
# 序列化时平铺到顶层,以保持与既有线格式逐字节兼容。
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# -- 谓词 ---------------------------------------------------------
|
||||
def is_terminal(self) -> bool:
|
||||
return self.type in (DONE, ERROR)
|
||||
|
||||
def is_text_chunk(self) -> bool:
|
||||
return self.type in (DELTA, REASONING)
|
||||
|
||||
# -- 序列化 -------------------------------------------------------
|
||||
def to_wire(self) -> Dict[str, Any]:
|
||||
"""序列化为通道线格式。
|
||||
|
||||
兼容铁律:既有键原样输出;新增键(seq/ts/claw/trace_id/tenant_id/diagnosis)
|
||||
一律「非空才输出」,既不撑大帧体积,也不触发前端未知分支。
|
||||
"""
|
||||
wire: Dict[str, Any] = {"type": self.type}
|
||||
|
||||
if self.type in (DELTA, REASONING):
|
||||
wire["content"] = self.content
|
||||
elif self.content:
|
||||
wire["content"] = self.content
|
||||
|
||||
for key, val in self.extra.items():
|
||||
wire[key] = val
|
||||
|
||||
if self.seq:
|
||||
wire["seq"] = self.seq
|
||||
if self.claw:
|
||||
wire["claw"] = self.claw
|
||||
if self.trace_id:
|
||||
wire["trace_id"] = self.trace_id
|
||||
if self.tenant_id:
|
||||
wire["tenant_id"] = self.tenant_id
|
||||
if self.diagnosis is not None:
|
||||
wire["diagnosis"] = self.diagnosis.to_dict()
|
||||
return wire
|
||||
|
||||
def to_text(self) -> str:
|
||||
"""无结构 UI 时(IM Bot)的降级文本;返回空串表示「本事件无需展示」。"""
|
||||
if self.type in (DELTA, DONE):
|
||||
return self.content
|
||||
if self.type == ERROR:
|
||||
if self.diagnosis is not None:
|
||||
return self.diagnosis.to_text()
|
||||
return f"❌ {self.extra.get('message') or self.content or '执行失败'}"
|
||||
if self.type == STEP and self.diagnosis is not None:
|
||||
return self.diagnosis.to_text()
|
||||
return ""
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 工厂 —— 唯一构造入口
|
||||
# =====================================================================
|
||||
def _ctx(kw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""抽出通用上下文字段,避免每个工厂重复列参数。"""
|
||||
return {
|
||||
"seq": int(kw.pop("seq", 0) or 0),
|
||||
"trace_id": str(kw.pop("trace_id", "") or ""),
|
||||
"tenant_id": str(kw.pop("tenant_id", "") or ""),
|
||||
"claw": str(kw.pop("claw", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def make_delta(content: str, **kw) -> EngineEvent:
|
||||
"""正文增量。"""
|
||||
return EngineEvent(type=DELTA, content=content or "", **_ctx(kw))
|
||||
|
||||
|
||||
def make_reasoning(content: str, **kw) -> EngineEvent:
|
||||
"""思维链增量。"""
|
||||
return EngineEvent(type=REASONING, content=content or "", **_ctx(kw))
|
||||
|
||||
|
||||
def make_step(
|
||||
kind: str,
|
||||
*,
|
||||
name: str = "",
|
||||
text: str = "",
|
||||
server: str = "",
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
result: str = "",
|
||||
phase: str = "",
|
||||
index: Optional[int] = None,
|
||||
diagnosis: Any = None,
|
||||
node: Optional[Dict[str, Any]] = None,
|
||||
**kw,
|
||||
) -> EngineEvent:
|
||||
"""过程步骤(思考 / 技能 / MCP / Agent / KB / 媒体)。
|
||||
|
||||
index 用于「原地更新同一步骤」:先发 phase=start 占位,
|
||||
执行完再用同一 index 发 phase=ok|fail 覆盖,前端即得到实时进度。
|
||||
|
||||
node(#874)承载图节点身份 {id, type, label}。设计书原文写的是
|
||||
`EngineEvent(kind="node")`,但前端只认既有 step 类型,发未知 type 会静默丢帧;
|
||||
故 kind 仍取节点映射后的已知类型,节点身份改由本字段附加 —— 非空才输出,
|
||||
老前端忽略,新前端可据此渲染 DAG 节点进度。
|
||||
"""
|
||||
step: Dict[str, Any] = {"type": kind}
|
||||
if kind == STEP_THINKING:
|
||||
step["text"] = text or name or ""
|
||||
else:
|
||||
if name:
|
||||
step["name"] = name
|
||||
if server:
|
||||
step["server"] = server
|
||||
if text:
|
||||
step["text"] = text
|
||||
step["args"] = args or {}
|
||||
step["result"] = result or ""
|
||||
if phase:
|
||||
step["phase"] = phase
|
||||
if isinstance(node, dict) and node:
|
||||
step["node"] = node
|
||||
|
||||
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
|
||||
if diag is not None:
|
||||
step["diagnosis"] = diag.to_dict()
|
||||
# result 为空时前端会判定为「运行中」,故失败必须给出可读 result 兜底
|
||||
if not step["result"] and diag.severity == SEV_ERROR:
|
||||
step["result"] = diag.to_text()
|
||||
|
||||
extra: Dict[str, Any] = {"step": step}
|
||||
if isinstance(index, int) and index >= 0:
|
||||
extra["index"] = index
|
||||
|
||||
return EngineEvent(type=STEP, diagnosis=diag, extra=extra, **_ctx(kw))
|
||||
|
||||
|
||||
def make_done(
|
||||
*,
|
||||
message_id: Any = None,
|
||||
conversation_id: Any = None,
|
||||
content: str = "",
|
||||
process_steps: Optional[list] = None,
|
||||
used_skills: Optional[list] = None,
|
||||
used_mcp: Optional[list] = None,
|
||||
generating: bool = False,
|
||||
**kw,
|
||||
) -> EngineEvent:
|
||||
"""终帧。字段集必须与既有 done 帧完全一致。"""
|
||||
extra = {
|
||||
"message_id": message_id,
|
||||
"conversation_id": conversation_id,
|
||||
"content": content or "",
|
||||
"process_steps": process_steps or [],
|
||||
"used_skills": used_skills or [],
|
||||
"used_mcp": used_mcp or [],
|
||||
"generating": bool(generating),
|
||||
}
|
||||
return EngineEvent(type=DONE, content=content or "", extra=extra, **_ctx(kw))
|
||||
|
||||
|
||||
def make_error(message: str, *, diagnosis: Any = None, **kw) -> EngineEvent:
|
||||
"""错误终帧。"""
|
||||
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
|
||||
return EngineEvent(
|
||||
type=ERROR,
|
||||
diagnosis=diag,
|
||||
extra={"message": message or ""},
|
||||
**_ctx(kw),
|
||||
)
|
||||
|
||||
|
||||
def from_wire(payload: Any) -> Optional[EngineEvent]:
|
||||
"""把遗留裸 dict 提升为 EngineEvent(迁移期双向兼容的关键)。
|
||||
|
||||
chat_service.py 里仍有直接 await on_event({...}) 的老代码,
|
||||
Adapter 收到后一律经此函数归一化,之后全链路只面对 EngineEvent。
|
||||
"""
|
||||
if isinstance(payload, EngineEvent):
|
||||
return payload
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
d = dict(payload)
|
||||
etype = str(d.pop("type", "") or "")
|
||||
if not etype:
|
||||
return None
|
||||
|
||||
content = d.pop("content", "")
|
||||
if not isinstance(content, str):
|
||||
# done 帧的 content 理论上是 str;异常类型保留原值到 extra,避免丢数据
|
||||
d["content"] = content
|
||||
content = ""
|
||||
|
||||
diag = Diagnosis.from_dict(d.pop("diagnosis", None))
|
||||
seq = d.pop("seq", 0)
|
||||
trace_id = d.pop("trace_id", "")
|
||||
tenant_id = d.pop("tenant_id", "")
|
||||
claw = d.pop("claw", "")
|
||||
d.pop("ts", None)
|
||||
|
||||
# done 帧的 content 需回填到 extra,才能在 to_wire() 时原样吐回
|
||||
if etype == DONE:
|
||||
d["content"] = content
|
||||
|
||||
return EngineEvent(
|
||||
type=etype,
|
||||
content=content,
|
||||
seq=int(seq or 0),
|
||||
trace_id=str(trace_id or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
claw=str(claw or ""),
|
||||
diagnosis=diag,
|
||||
extra=d,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
import logging, os, httpx
|
||||
logger = logging.getLogger(__name__)
|
||||
LITM = os.getenv("LITELLM_HOST", "qihu-aiflow-litellm")
|
||||
LITP = os.getenv("LITELLM_PORT", "4000")
|
||||
LITK = os.getenv("LITELLM_MASTER_KEY", "sk-litellm-master-key-aiflow-2026")
|
||||
BASE = f"http://{LITM}:{LITP}"
|
||||
_client = None
|
||||
|
||||
def _gc():
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = httpx.Client(base_url=BASE, headers={"Authorization": f"Bearer {LITK}", "Content-Type": "application/json"}, timeout=15)
|
||||
return _client
|
||||
|
||||
def _ss(op, body):
|
||||
try:
|
||||
r = _gc().post(op, json=body)
|
||||
if r.status_code in (200,201):
|
||||
logger.info(f"[litellm-sync] {op} OK")
|
||||
return True
|
||||
logger.warning(f"[litellm-sync] {op} FAIL {r.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"[litellm-sync] {op} ERR {e}")
|
||||
return False
|
||||
|
||||
def sync_create_model(model_name, litellm_model, api_base, api_key):
|
||||
return _ss("/model/new", {"model_name":model_name, "litellm_params":{"model":litellm_model, "api_base":api_base, "api_key":api_key}})
|
||||
|
||||
def sync_update_model(old_name, new_name, litellm_model, api_base, api_key):
|
||||
if old_name != new_name:
|
||||
_ss("/model/delete", {"id":old_name})
|
||||
return sync_create_model(new_name, litellm_model, api_base, api_key)
|
||||
return _ss("/model/update", {"model_name":new_name, "litellm_params":{"model":litellm_model, "api_base":api_base, "api_key":api_key}})
|
||||
|
||||
def sync_delete_model(model_name):
|
||||
return _ss("/model/delete", {"id":model_name})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Shared, standards-based LLM client for web chat + IM bot.
|
||||
|
||||
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
|
||||
(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
|
||||
OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
|
||||
* 原生 SSE 流式(`stream=True`);
|
||||
(\"openai.AsyncOpenAI\")——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
|
||||
OpenAI 协议(\"/v1/chat/completions\"),因此零协议改造成本,并天然获得:
|
||||
* 原生 SSE 流式(\"stream=True\");
|
||||
* 结构化的 tool_calls / usage 解析;
|
||||
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
|
||||
|
||||
@@ -13,13 +13,14 @@ IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——
|
||||
|
||||
API:
|
||||
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
|
||||
- stream=False -> dict,形状对齐上游 `data` 响应:
|
||||
- stream=False -> dict,形状对齐上游 \"data\" 响应:
|
||||
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
|
||||
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import asyncio
|
||||
from openai import AsyncOpenAI, OpenAI
|
||||
|
||||
|
||||
@@ -32,16 +33,35 @@ def _base_url(api_url: str) -> str:
|
||||
return u
|
||||
|
||||
|
||||
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
|
||||
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> AsyncOpenAI:
|
||||
base = _base_url(api_url)
|
||||
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
|
||||
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
|
||||
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
|
||||
# 故障快速失败(全栈统一,覆盖 web chat / IM bot / embedding):
|
||||
# connect 封顶 10s —— 坏端点不再让请求挂死事件循环;
|
||||
# read/write 封顶 30s —— 长流式回复只要持续有分块即不受影响;
|
||||
# 保留数值型 timeout 接口,旧调用方无需改动。
|
||||
_num = float(timeout) if isinstance(timeout, (int, float)) else 120.0
|
||||
_to = httpx.Timeout(
|
||||
connect=min(10.0, _num),
|
||||
read=min(30.0, _num),
|
||||
write=min(30.0, _num),
|
||||
pool=10.0,
|
||||
)
|
||||
_hdrs = {}
|
||||
if auth_header and ":" in auth_header:
|
||||
_k, _v = auth_header.split(":", 1)
|
||||
_hdrs[_k.strip()] = _v.strip()
|
||||
elif auth_header:
|
||||
_hdrs[auth_header.strip()] = api_key
|
||||
|
||||
return AsyncOpenAI(
|
||||
base_url=base,
|
||||
api_key=api_key or "EMPTY",
|
||||
timeout=timeout,
|
||||
timeout=_to,
|
||||
http_client=http_client,
|
||||
default_headers=_hdrs if _hdrs else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +83,11 @@ def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
|
||||
if tools:
|
||||
kw["tools"] = tools
|
||||
kw["tool_choice"] = request_body.get("tool_choice", "auto")
|
||||
# 2026-07-30: 将未在白名单的额外字段(如 enable_thinking)作为 extra_body 传递
|
||||
_whitelist = {"model", "messages", "stream", "temperature", "top_p", "max_tokens", "stop", "tools", "tool_choice"}
|
||||
_extras = {k: v for k, v in request_body.items() if k not in _whitelist}
|
||||
if _extras:
|
||||
kw["extra_body"] = _extras
|
||||
return kw
|
||||
|
||||
|
||||
@@ -84,20 +109,31 @@ def _normalize_tool_calls(tcs):
|
||||
return out
|
||||
|
||||
|
||||
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
|
||||
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False, auth_header=None):
|
||||
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
|
||||
if stream:
|
||||
|
||||
async def _gen():
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
|
||||
_content_parts = []
|
||||
_reasoning_parts = []
|
||||
_tool_acc = {} # index -> {id, type, name, arguments}
|
||||
try:
|
||||
gen = await client.chat.completions.create(
|
||||
**_create_kwargs(request_body, stream=True)
|
||||
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
|
||||
gen = await asyncio.wait_for(
|
||||
client.chat.completions.create(
|
||||
**_create_kwargs(request_body, stream=True)
|
||||
),
|
||||
timeout=_to,
|
||||
)
|
||||
async for chunk in gen:
|
||||
_it = gen.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
chunk = await asyncio.wait_for(_it.__anext__(), timeout=_to)
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise TimeoutError("stream chunk read timeout")
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
@@ -129,7 +165,10 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
if _fn_args:
|
||||
_slot["arguments"] += _fn_args
|
||||
finally:
|
||||
await client.close()
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
|
||||
_tool_calls = None
|
||||
if _tool_acc:
|
||||
@@ -149,9 +188,13 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
|
||||
return _gen()
|
||||
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
async with client:
|
||||
resp = await client.chat.completions.create(**_create_kwargs(request_body))
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
|
||||
try:
|
||||
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
|
||||
resp = await asyncio.wait_for(
|
||||
client.chat.completions.create(**_create_kwargs(request_body)),
|
||||
timeout=_to,
|
||||
)
|
||||
msg = resp.choices[0].message
|
||||
return {
|
||||
"choices": [
|
||||
@@ -165,38 +208,105 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
|
||||
],
|
||||
"usage": (resp.usage.model_dump() if resp.usage else {}),
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
|
||||
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> OpenAI:
|
||||
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
|
||||
base = _base_url(api_url)
|
||||
http_client = httpx.Client(proxy=proxy, trust_env=False)
|
||||
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
|
||||
_hdrs = {}
|
||||
if auth_header and ":" in auth_header:
|
||||
_k, _v = auth_header.split(":", 1)
|
||||
_hdrs[_k.strip()] = _v.strip()
|
||||
elif auth_header:
|
||||
_hdrs[auth_header.strip()] = api_key
|
||||
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client, default_headers=_hdrs if _hdrs else None)
|
||||
|
||||
|
||||
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
|
||||
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
|
||||
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
with client:
|
||||
resp = client.chat.completions.create(**_create_kwargs(request_body))
|
||||
msg = resp.choices[0].message
|
||||
return {
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": msg.content or "",
|
||||
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
|
||||
"reasoning_content": getattr(msg, "reasoning_content", None),
|
||||
}
|
||||
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, auth_header=None):
|
||||
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。
|
||||
|
||||
2026-07-30 重写:直接使用 requests 替代 OpenAI SDK/httpx。
|
||||
OpenAI SDK 的 httpx 连接池在慢长轮询(如 iLink QR 状态 12-19s)后会出现
|
||||
Connection error,已实测复现。requests 库没有这个问题。
|
||||
"""
|
||||
import requests
|
||||
import base64
|
||||
import random
|
||||
body = dict(request_body)
|
||||
body["stream"] = False
|
||||
# enable_thinking 等额外字段已在 request_body 中
|
||||
|
||||
# Headers: Bearer token + X-WECHAT-UIN (兼容 iLink)
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {api_key}" if api_key else "Bearer empty",
|
||||
}
|
||||
if auth_header and ":" in auth_header:
|
||||
_k, _v = auth_header.split(":", 1)
|
||||
headers[_k.strip()] = _v.strip()
|
||||
else:
|
||||
# X-WECHAT-UIN 防重放
|
||||
headers["X-WECHAT-UIN"] = base64.b64encode(str(random.randint(0, 2**32-1)).encode()).decode()
|
||||
|
||||
# 2026-07-31 修复根因: proxy 为 None 时传 proxies={} 而非 None。
|
||||
# requests 在 proxies=None 时会继续读取环境 HTTP_PROXY(trust_env 默认 True),
|
||||
# 而 uvicorn 进程(run.sh)带有 HTTP_PROXY=172.18.0.1:7890, 该代理无法解析
|
||||
# Docker 内部域名(如 qihu-aiflow-litellm), 导致所有内部模型调用静默失败、返回空。
|
||||
# 显式 proxies={} + trust_env=False 可完全绕过环境代理, 使 _proxy_for_url 的
|
||||
# 决策(返回 None=不代理)真正生效。
|
||||
_proxies = {"http": proxy, "https": proxy} if proxy else {}
|
||||
_session = requests.Session()
|
||||
_session.trust_env = False
|
||||
last_body = {}
|
||||
for attempt in range(2):
|
||||
try:
|
||||
resp = _session.post(
|
||||
api_url.rstrip("/"),
|
||||
headers=headers,
|
||||
json=body,
|
||||
proxies=_proxies,
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
last_body = resp.json() if resp.content else {}
|
||||
except Exception:
|
||||
last_body = {}
|
||||
if resp.ok:
|
||||
import logging as _l6
|
||||
_l6.warning(f'[chat_complete] body keys={list(last_body.keys()) if isinstance(last_body, dict) else type(last_body)}, content_len={len(resp.content)}, content_preview={str(resp.content)[:200]}')
|
||||
return _shape_response(last_body)
|
||||
except Exception:
|
||||
pass
|
||||
return {"choices": [{"message": {"content": "", "tool_calls": None, "reasoning_content": None}}]}
|
||||
|
||||
|
||||
def _shape_response(body: dict) -> dict:
|
||||
"""Normalize OpenAI-compatible response to standard shape."""
|
||||
choices = body.get("choices", [])
|
||||
import logging as _l7
|
||||
if choices:
|
||||
msg = choices[0].get("message", {})
|
||||
result = {
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": msg.get("content") or "",
|
||||
"tool_calls": _normalize_tool_calls(msg.get("tool_calls")),
|
||||
"reasoning_content": msg.get("reasoning_content") or "",
|
||||
}
|
||||
],
|
||||
"usage": (resp.usage.model_dump() if resp.usage else {}),
|
||||
}]
|
||||
}
|
||||
_l7.warning(f'[_shape] content_len={len(result["choices"][0]["message"]["content"])}, keys={list(result["choices"][0]["message"].keys())}')
|
||||
return result
|
||||
_l7.warning(f'[_shape] NO choices in body, keys={list(body.keys())[:5]}')
|
||||
return body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
|
||||
# ---------------------------------------------------------------------------
|
||||
def _embed_kwargs(input, model):
|
||||
return {"model": model, "input": input if isinstance(input, list) else [input]}
|
||||
|
||||
@@ -207,15 +317,24 @@ async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> l
|
||||
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
|
||||
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
|
||||
"""
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
async with client:
|
||||
resp = await client.embeddings.create(**_embed_kwargs(input, model))
|
||||
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
|
||||
try:
|
||||
_to = min(60.0, float(timeout) if isinstance(timeout, (int, float)) else 60.0)
|
||||
resp = await asyncio.wait_for(
|
||||
client.embeddings.create(**_embed_kwargs(input, model)),
|
||||
timeout=_to,
|
||||
)
|
||||
return [d.embedding for d in resp.data]
|
||||
finally:
|
||||
try:
|
||||
await asyncio.wait_for(client.close(), timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
|
||||
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
|
||||
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
|
||||
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
|
||||
with client:
|
||||
resp = client.embeddings.create(**_embed_kwargs(input, model))
|
||||
return [d.embedding for d in resp.data]
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""LLM Router —— 统一模型路由层(#875 · 影子模式先行)
|
||||
|
||||
设计基线(leagent_accel_belt_design.md §C):
|
||||
- 统一 Web Claw 与 IM Bot Claw 的模型选择,消除两端重复的 failover / 主密钥硬编码。
|
||||
- 决策顺序:显式指定 → 租户配额 → 能力过滤 → 成本分层 → 健康 failover(熔断)。
|
||||
- 灰度策略:**默认影子模式**(`LLM_ROUTER_SHADOW=1`)—— Router 计算结果只记日志 +
|
||||
`claw_type` 标签 metrics,实际仍走旧逻辑;一致率 ≥98% 后把开关置 0 切主路径。
|
||||
- 上下文三元组(tenant_id + claw_type + trace_id)经 `contextvars` 注入,零签名侵入
|
||||
(复用 observability.py 的 trace_id 同款机制)。
|
||||
|
||||
对外 API:
|
||||
set_route_ctx(tenant_id="", claw_type="web") # 在入口注入上下文
|
||||
get_route_ctx() -> dict # {tenant_id, claw_type, trace_id}
|
||||
route(capability, db, *, prefer_model_id=None, legacy_primary=None, shadow=None)
|
||||
-> RouteDecision(models: list[AIModel], primary, reason, matched_legacy, ...)
|
||||
resolve_api_key(api_url, model_api_key, provider_api_key) -> str|None
|
||||
# 收敛 LiteLLM 主密钥硬编码:litellm 端点统一返回 LITELLM_MASTER_KEY
|
||||
report_failure(model_id, claw_type="web") / report_success(model_id)
|
||||
# 健康熔断:Redis 记录连续失败(TTL 60s),无 Redis 时降级内存
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 影子模式开关:默认开启 → 只观测,不改流量
|
||||
# ---------------------------------------------------------------------------
|
||||
SHADOW = os.getenv("LLM_ROUTER_SHADOW", "1") not in ("0", "false", "False", "")
|
||||
|
||||
_LITELLM_MASTER_KEY = os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
|
||||
_LITELLM_HOST_MARK = "qihu-aiflow-litellm"
|
||||
|
||||
# 媒体能力(含 "text" 但本质是媒体模型,不能当文本 failover 备选)
|
||||
_MEDIA_CAPS = {"image", "video", "audio", "asr", "embedding", "rerank", "ocr"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 上下文三元组:tenant_id + claw_type(trace_id 复用 observability)
|
||||
# ---------------------------------------------------------------------------
|
||||
_route_tenant_var: ContextVar[str] = ContextVar("aiflow_route_tenant", default="")
|
||||
_route_claw_var: ContextVar[str] = ContextVar("aiflow_route_claw", default="web")
|
||||
|
||||
|
||||
def set_route_ctx(*, tenant_id: str = "", claw_type: str = "web") -> None:
|
||||
"""在请求入口注入路由上下文(#875)。tenant_id/claw_type 为空时保留默认值。
|
||||
|
||||
#876 桥接:同时注入 OTel 双 Claw 强制标签(set_otel_ctx),使单次入口调用
|
||||
即可让后续所有 span / metric 自动带上 tenant_id + claw_type + trace_id。
|
||||
失败安全:otel 不可用时静默跳过。
|
||||
"""
|
||||
if tenant_id:
|
||||
_route_tenant_var.set(tenant_id)
|
||||
_route_claw_var.set(claw_type or "web")
|
||||
try:
|
||||
from app.core.otel import set_otel_ctx
|
||||
set_otel_ctx(tenant_id=tenant_id, claw_type=claw_type or "web")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def get_route_ctx() -> dict:
|
||||
try:
|
||||
from app.core.observability import get_trace_id
|
||||
_trace = get_trace_id()
|
||||
except Exception:
|
||||
_trace = "-"
|
||||
return {
|
||||
"tenant_id": _route_tenant_var.get(),
|
||||
"claw_type": _route_claw_var.get(),
|
||||
"trace_id": _trace,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 决策结果
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class RouteDecision:
|
||||
capability: str
|
||||
claw_type: str
|
||||
tenant_id: str
|
||||
models: list = field(default_factory=list) # 有序候选链 list[AIModel]
|
||||
primary: object | None = None # 首选 AIModel
|
||||
reason: str = ""
|
||||
shadow: bool = True
|
||||
matched_legacy: Optional[bool] = None # 与旧逻辑首选是否一致
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 能力过滤:复用 capability_registry + 内置 _is_text_only
|
||||
# ---------------------------------------------------------------------------
|
||||
def _is_text_only(m) -> bool:
|
||||
"""纯文本模型才能作为文本 failover 备选(消除混入图像/语音模型导致空内容的坑)。"""
|
||||
caps = m.capabilities or []
|
||||
return "text" in caps and not any(c in _MEDIA_CAPS for c in caps)
|
||||
|
||||
|
||||
def _fallbacks(db, exclude_ids: set, limit: int = 4) -> list:
|
||||
"""对齐 IM Bot 的备选构造:全部启用、按 is_default 降序、仅纯文本、取前 limit。"""
|
||||
from app.models import AIModel
|
||||
rows = (
|
||||
db.query(AIModel)
|
||||
.filter(AIModel.is_enabled == True) # noqa: E712
|
||||
.order_by(AIModel.is_default.desc())
|
||||
.all()
|
||||
)
|
||||
out = [m for m in rows if m.id not in exclude_ids and _is_text_only(m)]
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def _tenant_authorized(m, tenant_id: str) -> bool:
|
||||
"""模型归属校验:全局模型(无 tenant)任何人可用;否则仅所属租户可用。"""
|
||||
mt = getattr(m, "tenant_id", None)
|
||||
if not mt:
|
||||
return True
|
||||
if not tenant_id:
|
||||
return False
|
||||
return mt == tenant_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康熔断:Redis 连续失败计数(TTL 60s),无 Redis 降级内存
|
||||
# ---------------------------------------------------------------------------
|
||||
_BREAKER_TTL = 60.0
|
||||
_BREAKER_LOCAL: dict = {}
|
||||
_BREAKER_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _redis():
|
||||
try:
|
||||
import redis
|
||||
from app.core.config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
|
||||
return redis.Redis(
|
||||
host=REDIS_HOST, port=REDIS_PORT, password=REDIS_PASSWORD,
|
||||
socket_connect_timeout=1, socket_timeout=1,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def report_failure(model_id: str, claw_type: str = "web") -> None:
|
||||
if not model_id:
|
||||
return
|
||||
r = _redis()
|
||||
key = f"aiflow:router:fail:{model_id}"
|
||||
if r:
|
||||
try:
|
||||
r.incr(key)
|
||||
r.expire(key, int(_BREAKER_TTL))
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
with _BREAKER_LOCK:
|
||||
e = _BREAKER_LOCAL.setdefault(model_id, {"fails": 0, "until": 0.0})
|
||||
e["fails"] += 1
|
||||
e["until"] = time.time() + _BREAKER_TTL
|
||||
|
||||
|
||||
def report_success(model_id: str) -> None:
|
||||
if not model_id:
|
||||
return
|
||||
r = _redis()
|
||||
key = f"aiflow:router:fail:{model_id}"
|
||||
if r:
|
||||
try:
|
||||
r.delete(key)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
with _BREAKER_LOCK:
|
||||
_BREAKER_LOCAL.pop(model_id, None)
|
||||
|
||||
|
||||
def _fail_count(model_id: str) -> int:
|
||||
if not model_id:
|
||||
return 0
|
||||
r = _redis()
|
||||
key = f"aiflow:router:fail:{model_id}"
|
||||
if r:
|
||||
try:
|
||||
v = r.get(key)
|
||||
return int(v) if v else 0
|
||||
except Exception:
|
||||
pass
|
||||
with _BREAKER_LOCK:
|
||||
e = _BREAKER_LOCAL.get(model_id)
|
||||
if e and e["until"] > time.time():
|
||||
return e["fails"]
|
||||
_BREAKER_LOCAL.pop(model_id, None)
|
||||
return 0
|
||||
|
||||
|
||||
def _apply_breaker(models: list, claw_type: str) -> list:
|
||||
"""失败次数少的模型优先;0 失败保持原序。"""
|
||||
if not models:
|
||||
return models
|
||||
scored = [( _fail_count(getattr(m, "id", "")), i, m) for i, m in enumerate(models)]
|
||||
scored.sort(key=lambda x: (x[0], x[1]))
|
||||
return [m for _, _, m in scored]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 主密钥收敛:litellm 端点统一用 master key
|
||||
# ---------------------------------------------------------------------------
|
||||
def resolve_api_key(api_url: Optional[str], model_api_key: Optional[str],
|
||||
provider_api_key: Optional[str]) -> Optional[str]:
|
||||
if api_url and _LITELLM_HOST_MARK in api_url:
|
||||
return _LITELLM_MASTER_KEY
|
||||
return model_api_key or provider_api_key
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# metrics:复用 otel meter,带 claw_type 标签(失败安全)
|
||||
# ---------------------------------------------------------------------------
|
||||
_counter = None
|
||||
_match_counter = None
|
||||
_hist = None
|
||||
|
||||
|
||||
def _metric_objs():
|
||||
global _counter, _match_counter, _hist
|
||||
if _counter is not None:
|
||||
return _counter, _match_counter, _hist
|
||||
try:
|
||||
from app.core.otel import get_meter
|
||||
m = get_meter()
|
||||
if m is None:
|
||||
return None, None, None
|
||||
_counter = m.create_counter("aiflow_llm_route_total", "LLM router decisions (shadow+live)")
|
||||
_match_counter = m.create_counter(
|
||||
"aiflow_llm_route_legacy_match_total", "Router vs legacy primary consistency"
|
||||
)
|
||||
_hist = m.create_histogram("aiflow_llm_route_ms", "Router decision latency ms")
|
||||
except Exception:
|
||||
return None, None, None
|
||||
return _counter, _match_counter, _hist
|
||||
|
||||
|
||||
def _emit_metrics(capability: str, claw_type: str, primary, shadow: bool, elapsed_ms: float) -> None:
|
||||
c, mc, h = _metric_objs()
|
||||
if c is None:
|
||||
return
|
||||
try:
|
||||
c.add(1, {
|
||||
"claw_type": claw_type,
|
||||
"capability": capability,
|
||||
"decision": primary.name if primary else "none",
|
||||
"shadow": str(shadow),
|
||||
})
|
||||
if h:
|
||||
h.record(elapsed_ms, {"claw_type": claw_type})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _emit_match(claw_type: str, matched: bool) -> None:
|
||||
c, mc, h = _metric_objs()
|
||||
if mc is None:
|
||||
return
|
||||
try:
|
||||
mc.add(1, {"claw_type": claw_type, "matched": "true" if matched else "false"})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# route:同步(与 resolve_auto_model 一致,可直接在 async 引擎内调用)
|
||||
# ---------------------------------------------------------------------------
|
||||
def route(capability: str, db, *, prefer_model_id: Optional[str] = None,
|
||||
legacy_primary: Optional[str] = None, shadow: Optional[bool] = None) -> RouteDecision:
|
||||
_t0 = time.perf_counter()
|
||||
shadow = SHADOW if shadow is None else shadow
|
||||
ctx = get_route_ctx()
|
||||
claw_type = ctx["claw_type"]
|
||||
tenant_id = ctx["tenant_id"]
|
||||
reasons: list = []
|
||||
|
||||
models: list = []
|
||||
try:
|
||||
from app.models import AIModel
|
||||
from app.capability_registry import resolve_auto_model
|
||||
|
||||
# 1) 显式指定
|
||||
if prefer_model_id:
|
||||
m = (
|
||||
db.query(AIModel)
|
||||
.filter(AIModel.id == prefer_model_id, AIModel.is_enabled == True) # noqa: E712
|
||||
.first()
|
||||
)
|
||||
if m and _tenant_authorized(m, tenant_id):
|
||||
models = [m] + _fallbacks(db, exclude_ids={m.id})
|
||||
reasons.append(f"explicit={m.name}")
|
||||
else:
|
||||
reasons.append("explicit rejected (disabled or tenant mismatch)")
|
||||
|
||||
# 2) 能力过滤:平台默认 + 文本备选(配额/成本分档为预留钩子,未强校验)
|
||||
if not models:
|
||||
primary = resolve_auto_model(capability, db)
|
||||
if primary and primary.is_enabled:
|
||||
models = [primary] + _fallbacks(db, exclude_ids={primary.id})
|
||||
reasons.append(f"auto={primary.name}")
|
||||
else:
|
||||
reasons.append("no primary resolved")
|
||||
|
||||
# 5) 健康 failover 重排
|
||||
models = _apply_breaker(models, claw_type)
|
||||
except Exception as _e:
|
||||
logger.warning(f"[llm_router] route error cap={capability} claw={claw_type}: {_e}")
|
||||
reasons.append(f"error:{type(_e).__name__}")
|
||||
|
||||
primary = models[0] if models else None
|
||||
matched = None
|
||||
if legacy_primary is not None:
|
||||
matched = bool(primary and primary.name == legacy_primary)
|
||||
|
||||
elapsed_ms = (time.perf_counter() - _t0) * 1000
|
||||
_emit_metrics(capability, claw_type, primary, shadow, elapsed_ms)
|
||||
if matched is not None:
|
||||
_emit_match(claw_type, matched)
|
||||
|
||||
decision = RouteDecision(
|
||||
capability=capability,
|
||||
claw_type=claw_type,
|
||||
tenant_id=tenant_id,
|
||||
models=models,
|
||||
primary=primary,
|
||||
reason="; ".join(reasons) or "default",
|
||||
shadow=shadow,
|
||||
matched_legacy=matched,
|
||||
)
|
||||
logger.info(
|
||||
f"[llm_router] claw={claw_type} cap={capability} primary="
|
||||
f"{primary.name if primary else None} shadow={shadow} matched={matched} "
|
||||
f"reason={decision.reason}"
|
||||
)
|
||||
return decision
|
||||
|
||||
|
||||
# 预留钩子(#875 后续迭代启用):租户配额 / 成本分档
|
||||
# def _check_tenant_quota(tenant_id, capability) -> tuple[bool, str]: ...
|
||||
# def _cost_tier_models(tenant_id, capability) -> list: ...
|
||||
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ Qihu-AIFlow 统一可观测基座 (Phase 2.1 + Phase 1.I OTel 增强)
|
||||
|
||||
设计原则(Strangler Fig 增量):
|
||||
- 纯增量埋点,不改任何既有接口的返回结构。
|
||||
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
|
||||
- 向后兼容现有 \"logging.getLogger(__name__)\" 用法:trace_id 由 root logger 的
|
||||
TraceIdFilter 自动注入,调用方无需改动。
|
||||
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
|
||||
|
||||
@@ -58,7 +58,7 @@ def get_trace_id() -> str:
|
||||
class TraceIdFilter(logging.Filter):
|
||||
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
|
||||
|
||||
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
|
||||
挂载到 root logger 后,所有 \"getLogger(__name__)\" 的子 logger 都会继承,
|
||||
从而让全平台既有日志自动带上链路 ID,零侵入。
|
||||
"""
|
||||
|
||||
|
||||
@@ -7,6 +7,18 @@
|
||||
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
|
||||
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
|
||||
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
|
||||
|
||||
#874 变更
|
||||
--------
|
||||
节点类型不再硬编码在本模块,改由 `app.core.node_registry` 统一持有:
|
||||
|
||||
* 合法类型 -> registry.node_types()(同时供 yaml_workflow 校验,单一真相源)
|
||||
* 节点执行 -> registry.get(type).run(NodeContext),本模块只负责拓扑与调度
|
||||
* 双 Claw 对齐 -> run_graph(on_event=...) 把节点过程发成统一 EngineEvent,
|
||||
Web(SseAdapter) 与 IM(BufferAdapter) 吃同一份事件流
|
||||
|
||||
原有 llm/local/external/reduce 语义逐字保留(节点实现内部仍调用本模块的
|
||||
_llm_complete / _run_local_agent_turn),新增 skill/mcp/agent/loop 四型。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,9 +29,20 @@ from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.engine_event import STEP_THINKING, make_step
|
||||
from app.core.node_registry import (
|
||||
NodeContext,
|
||||
NodeError,
|
||||
StepCounter,
|
||||
normalize_claw,
|
||||
registry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("aiflow.orchestrator")
|
||||
|
||||
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
|
||||
# 兼容导出:历史调用方直接 import 了这个名字。真正的校验一律走 registry.node_types(),
|
||||
# 以便运行期注册的第三方节点立即生效(本常量只是 import 期快照)。
|
||||
VALID_NODE_TYPES = registry.node_types()
|
||||
|
||||
|
||||
class OrchestrationError(ValueError):
|
||||
@@ -50,12 +73,25 @@ def validate_graph(graph: Any) -> None:
|
||||
raise OrchestrationError(f"节点 id 重复: {nid}")
|
||||
ids.add(nid)
|
||||
ntype = n.get("type")
|
||||
if ntype not in VALID_NODE_TYPES:
|
||||
valid = registry.node_types()
|
||||
if ntype not in valid:
|
||||
raise OrchestrationError(
|
||||
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
|
||||
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(valid)})"
|
||||
)
|
||||
if ntype in ("local", "external") and not n.get("agent_id"):
|
||||
if registry.requires_agent(ntype) and not n.get("agent_id"):
|
||||
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
||||
if ntype == "skill" and not (n.get("skill_id") or n.get("func_name")):
|
||||
raise OrchestrationError(f"节点 {nid} 类型 skill 必须指定 skill_id 或 func_name")
|
||||
if ntype == "mcp" and not n.get("tool"):
|
||||
raise OrchestrationError(f"节点 {nid} 类型 mcp 必须指定 tool")
|
||||
if ntype == "loop":
|
||||
body = n.get("body")
|
||||
if not isinstance(body, dict) or not body.get("type"):
|
||||
raise OrchestrationError(f"节点 {nid} 类型 loop 必须提供含 type 的 body")
|
||||
if body["type"] not in valid:
|
||||
raise OrchestrationError(f"节点 {nid} 的 body 类型非法: {body['type']}")
|
||||
if body["type"] == "loop":
|
||||
raise OrchestrationError(f"节点 {nid} 的 body 不允许再嵌套 loop")
|
||||
|
||||
for i, e in enumerate(edges):
|
||||
if not isinstance(e, dict):
|
||||
@@ -275,12 +311,33 @@ async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_
|
||||
# ---------------------------------------------------------------------------
|
||||
# 单节点执行(含独立 DB 会话,保证并发安全)
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
|
||||
from app.api.chat_skill import _resolve_market_agents
|
||||
async def _run_node(
|
||||
node,
|
||||
graph,
|
||||
blackboard,
|
||||
query,
|
||||
user_id,
|
||||
conv_id,
|
||||
trace_id,
|
||||
*,
|
||||
on_event=None,
|
||||
claw_type: str = "",
|
||||
tenant_id: str = "",
|
||||
counter: Optional[StepCounter] = None,
|
||||
snapshot=None,
|
||||
) -> dict:
|
||||
"""执行单节点:拼装上游输入 → 交给 registry 里的节点实现 → 回填黑板结构。
|
||||
|
||||
本函数不再包含任何节点类型分支 —— 新增能力只需注册新节点,无需改调度层。
|
||||
"""
|
||||
from app.database import SessionLocal
|
||||
|
||||
nid = node["id"]
|
||||
ntype = node["type"]
|
||||
impl = registry.get(ntype)
|
||||
if impl is None:
|
||||
raise OrchestrationError(f"未知节点类型 {ntype}")
|
||||
|
||||
preds = _predecessors(graph, nid)
|
||||
pred_results = [blackboard[p] for p in preds]
|
||||
is_entry = not preds
|
||||
@@ -298,66 +355,104 @@ async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id)
|
||||
upstream = ""
|
||||
|
||||
with SessionLocal() as db:
|
||||
if ntype in ("llm", "reduce"):
|
||||
if ntype == "llm":
|
||||
sys_prompt = node.get("system_prompt") or (
|
||||
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
|
||||
"只输出处理后的结果,不要多余解释。"
|
||||
)
|
||||
else:
|
||||
sys_prompt = node.get("system_prompt") or (
|
||||
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
|
||||
)
|
||||
uc = _assemble_node_input(query, upstream, is_entry)
|
||||
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
|
||||
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
|
||||
|
||||
if ntype == "local":
|
||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||
if not agents:
|
||||
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||
ni = _assemble_node_input(query, upstream, is_entry)
|
||||
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
|
||||
return {
|
||||
"ok": True,
|
||||
"node_id": nid,
|
||||
"node_type": "local",
|
||||
"output": res["text"],
|
||||
"used_tools": res.get("used_tools"),
|
||||
}
|
||||
|
||||
if ntype == "external":
|
||||
from app.api.chat_agents import _call_external_agent
|
||||
|
||||
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
|
||||
if not agents:
|
||||
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
|
||||
ni = _assemble_node_input(query, upstream, is_entry)
|
||||
answer, _ = await _call_external_agent(
|
||||
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
|
||||
)
|
||||
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
|
||||
|
||||
raise OrchestrationError(f"未知节点类型 {ntype}")
|
||||
ctx = NodeContext(
|
||||
db=db,
|
||||
node=node,
|
||||
graph=graph,
|
||||
blackboard=blackboard,
|
||||
query=query,
|
||||
upstream=upstream,
|
||||
is_entry=is_entry,
|
||||
user_id=user_id or "",
|
||||
conv_id=conv_id,
|
||||
trace_id=str(trace_id or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
claw_type=claw_type or "",
|
||||
snapshot=snapshot,
|
||||
on_event=on_event,
|
||||
counter=counter or StepCounter(),
|
||||
)
|
||||
try:
|
||||
result = await impl.run(ctx)
|
||||
except NodeError as e:
|
||||
# 归一为 OrchestrationError,run_graph 的 on_error=halt|continue 语义不变
|
||||
raise OrchestrationError(str(e)) from e
|
||||
return result.to_blackboard()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 入口
|
||||
# ---------------------------------------------------------------------------
|
||||
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
||||
async def _emit(on_event, event) -> None:
|
||||
"""图级事件发射(失败安全 —— 观测不得拖垮编排)。"""
|
||||
if on_event is None:
|
||||
return
|
||||
try:
|
||||
res = on_event(event)
|
||||
if asyncio.iscoroutine(res) or isinstance(res, asyncio.Future):
|
||||
await res
|
||||
except Exception as e:
|
||||
logger.debug("[orchestrator] emit 失败(忽略): %s", e)
|
||||
|
||||
|
||||
async def run_graph(
|
||||
graph,
|
||||
query,
|
||||
db,
|
||||
user_id,
|
||||
conv_id,
|
||||
trace_id=None,
|
||||
*,
|
||||
on_event=None,
|
||||
claw_type: str = "",
|
||||
tenant_id: str = "",
|
||||
snapshot=None,
|
||||
):
|
||||
"""执行编排图,返回 (answer: str, trace: dict)。
|
||||
|
||||
- 拓扑分层后逐层 asyncio.gather 并行执行。
|
||||
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
|
||||
- on_event 非空时,图级与节点级过程以统一 EngineEvent 推送:
|
||||
Web Claw 传 SseAdapter(逐帧渲染),IM Claw 传 BufferAdapter(整条装配)。
|
||||
两端共用同一份事件流,IM 侧无需任何编排专属逻辑。
|
||||
"""
|
||||
validate_graph(graph)
|
||||
layers = topological_layers(graph)
|
||||
blackboard: dict = {}
|
||||
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
|
||||
|
||||
claw = normalize_claw(claw_type)
|
||||
counter = StepCounter()
|
||||
node_count = len(graph.get("nodes") or [])
|
||||
|
||||
await _emit(
|
||||
on_event,
|
||||
make_step(
|
||||
STEP_THINKING,
|
||||
text=f"开始编排执行:{node_count} 个节点 / {len(layers)} 层",
|
||||
trace_id=str(trace_id or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
claw=claw,
|
||||
index=counter.next(),
|
||||
),
|
||||
)
|
||||
|
||||
for layer in layers:
|
||||
tasks = [
|
||||
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
|
||||
_run_node(
|
||||
_node_by_id(graph, nid),
|
||||
graph,
|
||||
blackboard,
|
||||
query,
|
||||
user_id,
|
||||
conv_id,
|
||||
trace_id,
|
||||
on_event=on_event,
|
||||
claw_type=claw_type,
|
||||
tenant_id=tenant_id,
|
||||
counter=counter,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
for nid in layer
|
||||
]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
@@ -392,4 +487,17 @@ async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
|
||||
else:
|
||||
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
|
||||
trace["output_node"] = output_node
|
||||
|
||||
ok_cnt = sum(1 for n in trace["nodes"] if n.get("ok"))
|
||||
await _emit(
|
||||
on_event,
|
||||
make_step(
|
||||
STEP_THINKING,
|
||||
text=f"编排执行完成:{ok_cnt}/{len(trace['nodes'])} 个节点成功,输出节点 {output_node}",
|
||||
trace_id=str(trace_id or ""),
|
||||
tenant_id=str(tenant_id or ""),
|
||||
claw=claw,
|
||||
index=counter.next(),
|
||||
),
|
||||
)
|
||||
return answer, trace
|
||||
@@ -15,12 +15,21 @@ OpenTelemetry 桥接 (Phase 1.I 标准化)
|
||||
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
|
||||
get_tracer() 返回 tracer(未启用/失败则 None)
|
||||
get_meter() 返回 meter(未启用/失败则 None)
|
||||
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套)
|
||||
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数)
|
||||
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套);
|
||||
**自动注入** tenant_id + claw_type + trace_id 双 Claw 强制标签
|
||||
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数,
|
||||
维度带 tenant_id + claw_type,供 Grafana 双 Claw 对比板)
|
||||
set_otel_ctx(tenant_id, claw_type) 入口注入双 Claw 强制标签(与 llm_router.set_route_ctx 同源)
|
||||
get_otel_ctx() 读取当前 contextvars 三元组 {tenant_id, claw_type, trace_id}
|
||||
|
||||
双 Claw 强制标签(#876 §D):每个 span / metric 必带 tenant_id + claw_type + trace_id
|
||||
(+ 业务字段 model / node_type 由 span(name, model=..., node_type=...) 透传)。
|
||||
入口调用一次 set_otel_ctx(或 set_route_ctx,已桥接转调),全链路自动带维度,业务零改动。
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from contextvars import ContextVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -35,6 +44,44 @@ _initialized = False
|
||||
_duration_hist = None
|
||||
_span_counter = None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 强制标签上下文(#876 · 双 Claw 维度)
|
||||
# ---------------------------------------------------------------------------
|
||||
# 每个 span / metric 必带 tenant_id + claw_type + trace_id(设计书 §D)。
|
||||
# 经 contextvars 注入,零签名侵入:入口调用 set_otel_ctx() 一次,
|
||||
# 全部 span 经 current_span_cm 自动带上维度,业务侧无需改动。
|
||||
# 与 #875 llm_router.set_route_ctx 同源(set_route_ctx 内部转调本函数)。
|
||||
_otel_tenant_var: ContextVar[str] = ContextVar("aiflow_otel_tenant", default="")
|
||||
_otel_claw_var: ContextVar[str] = ContextVar("aiflow_otel_claw", default="web")
|
||||
|
||||
|
||||
def set_otel_ctx(*, tenant_id: str = "", claw_type: str = "web") -> None:
|
||||
"""在请求入口注入 OTel 强制标签(双 Claw 维度)。
|
||||
|
||||
与 llm_router.set_route_ctx 共享同一调用点:入口调用一次即可让
|
||||
后续所有 span / metric 自动带上 tenant_id + claw_type + trace_id。
|
||||
"""
|
||||
if tenant_id:
|
||||
_otel_tenant_var.set(tenant_id)
|
||||
_otel_claw_var.set(claw_type or "web")
|
||||
|
||||
|
||||
def get_otel_ctx() -> dict:
|
||||
"""读取当前 OTel 上下文三元组(含 trace_id,复用 observability)。
|
||||
|
||||
失败安全:observability 不可用时 trace_id 回退为 '-'。
|
||||
"""
|
||||
try:
|
||||
from app.core.observability import get_trace_id
|
||||
_trace = get_trace_id()
|
||||
except Exception:
|
||||
_trace = "-"
|
||||
return {
|
||||
"tenant_id": _otel_tenant_var.get(),
|
||||
"claw_type": _otel_claw_var.get(),
|
||||
"trace_id": _trace,
|
||||
}
|
||||
|
||||
|
||||
def init_otel() -> None:
|
||||
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
|
||||
@@ -100,19 +147,32 @@ def get_meter():
|
||||
|
||||
|
||||
def current_span_cm(name: str, attributes: dict | None = None):
|
||||
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
|
||||
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。
|
||||
|
||||
自动注入双 Claw 强制标签(#876 §D):tenant_id + claw_type + trace_id。
|
||||
setdefault 语义——业务已显式传入的字段不会被覆盖。
|
||||
"""
|
||||
tr = get_tracer()
|
||||
if tr is None:
|
||||
return None
|
||||
try:
|
||||
return tr.start_as_current_span(name, attributes=attributes or {})
|
||||
attrs = dict(attributes or {})
|
||||
ctx = get_otel_ctx()
|
||||
attrs.setdefault("tenant_id", ctx["tenant_id"])
|
||||
attrs.setdefault("claw_type", ctx["claw_type"])
|
||||
attrs.setdefault("trace_id", ctx["trace_id"])
|
||||
return tr.start_as_current_span(name, attributes=attrs)
|
||||
except Exception as e: # pragma: no cover
|
||||
logger.warning(f"[otel] start_span failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
|
||||
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
|
||||
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。
|
||||
|
||||
指标维度带 tenant_id + claw_type(trace_id 因高基数不计入指标标签,
|
||||
仅作为 span 属性存在)。双 Claw 对比看板据此按 claw_type 分组。
|
||||
"""
|
||||
m = get_meter()
|
||||
if m is None:
|
||||
return
|
||||
@@ -123,7 +183,13 @@ def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
|
||||
"aiflow_span_duration_ms", "Span duration in milliseconds"
|
||||
)
|
||||
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
|
||||
attrs = {"span": name, "status": status}
|
||||
ctx = get_otel_ctx()
|
||||
attrs = {
|
||||
"span": name,
|
||||
"status": status,
|
||||
"tenant_id": ctx["tenant_id"],
|
||||
"claw_type": ctx["claw_type"],
|
||||
}
|
||||
_duration_hist.record(elapsed_ms, attrs)
|
||||
_span_counter.add(1, attrs)
|
||||
except Exception: # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Telegram 渠道实现 — ChannelAdapter 的 POC 实例
|
||||
任务 #935 · 2026-08-09
|
||||
|
||||
依赖:httpx(项目已用)。无 NoneBot2 / python-telegram-bot,纯 httpx 直调 Bot API。
|
||||
- Bot API:https://core.telegram.org/bots/api
|
||||
- 单消息 4096 字符上限(自动分段)
|
||||
- Webhook 注册:setWebhook
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.channel_adapter import ChannelAdapter, IncomingMessage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org"
|
||||
|
||||
|
||||
class TelegramChannel(ChannelAdapter):
|
||||
"""Telegram Bot 渠道。
|
||||
|
||||
启动:
|
||||
- 调 setWebhook,把 https://<host>/app/api/channel/telegram 注册给 Bot
|
||||
- 调 deleteWebhook 清掉旧 webhook,避免双收
|
||||
|
||||
出站:
|
||||
- sendMessage API,长度超过 4096 自动分段
|
||||
- MarkdownV2 特殊字符需转义;POC 暂不启用 parse_mode(由 LLM 输出纯文本更安全)
|
||||
|
||||
入站:
|
||||
- webhook 端点接收 update,转 IncomingMessage,交给 router.dispatch
|
||||
"""
|
||||
|
||||
channel_id = "telegram"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bot_token: str,
|
||||
webhook_url: str,
|
||||
webhook_secret: str = "",
|
||||
) -> None:
|
||||
self.bot_token = bot_token
|
||||
self.webhook_url = webhook_url
|
||||
self.webhook_secret = webhook_secret
|
||||
self._api = f"{TELEGRAM_API_BASE}/bot{bot_token}"
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._running = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=15.0)
|
||||
# 1) 清掉旧 webhook,避免双收
|
||||
try:
|
||||
await self._client.post(f"{self._api}/deleteWebhook", json={"drop_pending_updates": True})
|
||||
except Exception as e:
|
||||
log.warning("[telegram] deleteWebhook failed: %s", e)
|
||||
# 2) 注册新 webhook
|
||||
payload: dict[str, Any] = {
|
||||
"url": self.webhook_url,
|
||||
"allowed_updates": ["message"],
|
||||
"drop_pending_updates": False,
|
||||
}
|
||||
if self.webhook_secret:
|
||||
payload["secret_token"] = self.webhook_secret
|
||||
try:
|
||||
r = await self._client.post(f"{self._api}/setWebhook", json=payload)
|
||||
j = r.json() if r.content else {}
|
||||
if not j.get("ok"):
|
||||
raise RuntimeError(f"setWebhook failed: {j}")
|
||||
log.info("[telegram] webhook registered: %s", self.webhook_url)
|
||||
except Exception as e:
|
||||
log.warning("[telegram] start failed: %s", e, exc_info=True)
|
||||
self._running = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._running = False
|
||||
if self._client:
|
||||
try:
|
||||
await self._client.post(f"{self._api}/deleteWebhook", json={})
|
||||
except Exception:
|
||||
pass
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def send(self, *, user_id: str, content: str) -> None:
|
||||
"""出站单条。user_id 在这里是 chat_id。"""
|
||||
if not self._client:
|
||||
log.warning("[telegram] send called but client not initialized")
|
||||
return
|
||||
# Telegram 单消息 4096 字符上限,长文本按 4000 分段(留 buffer)
|
||||
chunk_size = 4000
|
||||
for i in range(0, max(len(content), 1), chunk_size):
|
||||
piece = content[i:i + chunk_size]
|
||||
try:
|
||||
r = await self._client.post(
|
||||
f"{self._api}/sendMessage",
|
||||
json={"chat_id": user_id, "text": piece},
|
||||
)
|
||||
if not r.is_success:
|
||||
log.warning("[telegram] sendMessage failed: %d %s", r.status_code, r.text[:200])
|
||||
except Exception as e:
|
||||
log.warning("[telegram] send failed: %s", e, exc_info=True)
|
||||
|
||||
async def receive_update(self, payload: dict) -> None:
|
||||
"""webhook 端点调用此方法。"""
|
||||
msg = payload.get("message") or payload.get("edited_message")
|
||||
if not msg:
|
||||
return
|
||||
chat = msg.get("chat") or {}
|
||||
chat_type = chat.get("type", "private")
|
||||
is_group = chat_type in ("group", "supergroup")
|
||||
text = msg.get("text") or msg.get("caption") or ""
|
||||
# 去除 /command 后传给 LLM
|
||||
if text.startswith("/"):
|
||||
parts = text.split(maxsplit=1)
|
||||
cmd = parts[0].split("@")[0] # 去除 @botname 后缀
|
||||
if cmd == "/bind":
|
||||
# /bind 由 binding_router 处理,不在这里
|
||||
await self._handle_bind(chat_id=str(chat.get("id")), args=parts[1] if len(parts) > 1 else "")
|
||||
return
|
||||
text = parts[1] if len(parts) > 1 else ""
|
||||
if not text:
|
||||
return
|
||||
|
||||
incoming = IncomingMessage(
|
||||
channel="telegram",
|
||||
platform_user_id=str(chat.get("id")),
|
||||
text=text,
|
||||
is_group=is_group,
|
||||
group_id=str(chat.get("id")) if is_group else None,
|
||||
raw=msg,
|
||||
)
|
||||
if not self.router:
|
||||
log.warning("[telegram] router not bound; drop")
|
||||
return
|
||||
try:
|
||||
reply = await self.router.dispatch(incoming)
|
||||
except Exception as e:
|
||||
log.exception("[telegram] dispatch failed: %s", e)
|
||||
reply = "(系统繁忙,请稍后再试)"
|
||||
await self.send(user_id=incoming.platform_user_id, content=reply)
|
||||
|
||||
async def _handle_bind(self, *, chat_id: str, args: str) -> None:
|
||||
"""/bind <token> — 解析 token,写 im_channel_routes,绑定 platform_user_id ↔ user_id。"""
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.db import SessionLocal
|
||||
from app.models import IMChannelRoute, User
|
||||
|
||||
token = (args or "").strip()
|
||||
if not token:
|
||||
await self.send(user_id=chat_id, content="用法:/bind <你在七虎AI后台生成的绑定码>")
|
||||
return
|
||||
db: Session = SessionLocal()
|
||||
try:
|
||||
# token = base64(user_id) 简化方案;生产可换 JWT + 有效期
|
||||
import base64
|
||||
try:
|
||||
user_id = base64.urlsafe_b64decode(token.encode()).decode()
|
||||
except Exception:
|
||||
await self.send(user_id=chat_id, content="绑定码无效")
|
||||
return
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
await self.send(user_id=chat_id, content="绑定码无效或用户不存在")
|
||||
return
|
||||
existing = db.query(IMChannelRoute).filter(
|
||||
IMChannelRoute.channel == "telegram",
|
||||
IMChannelRoute.platform_user_id == chat_id,
|
||||
).first()
|
||||
if existing:
|
||||
existing.internal_user_id = user.id
|
||||
else:
|
||||
db.add(IMChannelRoute(channel="telegram", platform_user_id=chat_id, internal_user_id=user.id))
|
||||
db.commit()
|
||||
await self.send(user_id=chat_id, content=f"✅ 绑定成功!你现在可以与我对话。")
|
||||
except Exception as e:
|
||||
log.exception("[telegram.bind] failed: %s", e)
|
||||
await self.send(user_id=chat_id, content="绑定失败,请稍后再试")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -72,10 +72,38 @@ _REVERSE_CONFIG = {
|
||||
"agent_endpoint", "api_key", "strategy", "max_tokens",
|
||||
}
|
||||
|
||||
VALID_NODE_TYPES = {"llm", "local", "external", "reduce"}
|
||||
VALID_ON_ERROR = {"halt", "continue"}
|
||||
VALID_REDUCE_STRATEGY = {"concat", "first", "last"}
|
||||
|
||||
# #874 新节点(skill/mcp/loop)的身份与配置字段:必须留在节点顶层双向透传。
|
||||
# 若任其落进 config 子对象,反向转换时 _CONFIG_MAP 取不回来 —— YAML 存一次就丢配置。
|
||||
_NODE_FIELDS = (
|
||||
"label", "skill_id", "func_name", "tool", "server_id", "args",
|
||||
"body", "items", "split", "max_iterations", "concurrency", "join",
|
||||
)
|
||||
|
||||
|
||||
def valid_node_types() -> set:
|
||||
"""合法节点类型 —— 唯一真相源是 NodeRegistry(#874)。
|
||||
|
||||
改造前这里硬编码了一份 {llm, local, external, reduce},与 orchestrator 里的
|
||||
元组各写各的:注册新节点必须改两处,漏一处就出现「画布能跑、YAML 报非法」。
|
||||
现在统一读注册表;注册表不可用时退回内建四型,保证校验器永不因导入失败而瘫痪。
|
||||
"""
|
||||
try:
|
||||
from app.core.node_registry import registry
|
||||
|
||||
types = set(registry.node_types())
|
||||
if types:
|
||||
return types
|
||||
except Exception: # pragma: no cover - 仅在注册表不可用时兜底
|
||||
pass
|
||||
return {"llm", "local", "external", "reduce"}
|
||||
|
||||
|
||||
# 兼容导出:历史调用方 `from ... import VALID_NODE_TYPES` 仍可用(import 期快照)
|
||||
VALID_NODE_TYPES = valid_node_types()
|
||||
|
||||
|
||||
# ── validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -112,18 +140,29 @@ def validate_yaml(data: Any) -> dict:
|
||||
errors.append(f"节点 id 重复: {nid}")
|
||||
node_ids.add(nid)
|
||||
|
||||
allowed = valid_node_types()
|
||||
ntype = node.get("type")
|
||||
if ntype not in VALID_NODE_TYPES:
|
||||
errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(VALID_NODE_TYPES))})")
|
||||
if ntype not in allowed:
|
||||
errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(allowed))})")
|
||||
|
||||
on_error = node.get("on_error", "continue")
|
||||
if on_error and on_error not in VALID_ON_ERROR:
|
||||
errors.append(f"节点 {nid} on_error 非法: {on_error}")
|
||||
|
||||
# type-specific checks
|
||||
if ntype in ("local", "external"):
|
||||
if ntype in ("local", "external", "agent"):
|
||||
if not node.get("agent_id"):
|
||||
errors.append(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
|
||||
if ntype == "skill" and not (node.get("skill_id") or node.get("func_name")):
|
||||
errors.append(f"节点 {nid} 类型 skill 必须指定 skill_id 或 func_name")
|
||||
if ntype == "mcp" and not node.get("tool"):
|
||||
errors.append(f"节点 {nid} 类型 mcp 必须指定 tool")
|
||||
if ntype == "loop":
|
||||
body = node.get("body")
|
||||
if not isinstance(body, dict) or not body.get("type"):
|
||||
errors.append(f"节点 {nid} 类型 loop 必须提供含 type 的 body")
|
||||
elif body["type"] not in allowed or body["type"] == "loop":
|
||||
errors.append(f"节点 {nid} 的 body 类型非法: {body.get('type')}")
|
||||
|
||||
# config validation
|
||||
cfg = node.get("config", {})
|
||||
@@ -205,10 +244,15 @@ def yaml_to_orchestration(data: dict) -> dict:
|
||||
"on_error": raw_node.get("on_error", "continue"),
|
||||
}
|
||||
|
||||
# agent_id for local/external
|
||||
# agent_id for local/external/agent
|
||||
if raw_node.get("agent_id"):
|
||||
node["agent_id"] = raw_node["agent_id"]
|
||||
|
||||
# #874 skill/mcp/loop 节点字段原样透传
|
||||
for _f in _NODE_FIELDS:
|
||||
if raw_node.get(_f) is not None:
|
||||
node[_f] = raw_node[_f]
|
||||
|
||||
# flatten config into top-level keys
|
||||
config = raw_node.get("config", {})
|
||||
if isinstance(config, dict):
|
||||
@@ -218,7 +262,8 @@ def yaml_to_orchestration(data: dict) -> dict:
|
||||
|
||||
# also carry over any extra keys from raw_node (preserve flexibility)
|
||||
for k, v in raw_node.items():
|
||||
if k not in ("id", "type", "config", "description", "on_error", "agent_id"):
|
||||
if k not in ("id", "type", "config", "description", "on_error", "agent_id") \
|
||||
and k not in _NODE_FIELDS:
|
||||
if k in ("skill_ids", "knowledge_base_ids", "model_id", "system_prompt",
|
||||
"agent_endpoint", "api_key", "strategy", "max_tokens"):
|
||||
node[k] = v
|
||||
@@ -262,6 +307,9 @@ def orchestration_to_yaml(data: dict) -> dict:
|
||||
node["on_error"] = json_node["on_error"]
|
||||
if json_node.get("agent_id"):
|
||||
node["agent_id"] = json_node["agent_id"]
|
||||
for _f in _NODE_FIELDS:
|
||||
if json_node.get(_f) is not None:
|
||||
node[_f] = json_node[_f]
|
||||
|
||||
# nest known config keys into config sub-object
|
||||
config: dict = {}
|
||||
@@ -272,7 +320,7 @@ def orchestration_to_yaml(data: dict) -> dict:
|
||||
# also preserve any extra keys not in the map
|
||||
for k, v in json_node.items():
|
||||
if k not in ("id", "type", "description", "on_error", "agent_id") \
|
||||
and k not in _REVERSE_CONFIG:
|
||||
and k not in _NODE_FIELDS and k not in _REVERSE_CONFIG:
|
||||
if v is not None:
|
||||
config[k] = v
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) 客户端 —— 基于官方 `mcp` Python SDK (Phase 1.E 标准化)
|
||||
MCP (Model Context Protocol) 客户端 —— 基于官方 \"mcp\" Python SDK (Phase 1.E 标准化)
|
||||
|
||||
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §E):
|
||||
- 采用 Anthropic 官方 `mcp` SDK 处理 JSON-RPC 帧 + 协议版本协商,
|
||||
- 采用 Anthropic 官方 \"mcp\" SDK 处理 JSON-RPC 帧 + 协议版本协商,
|
||||
不再手写解析、不再写死 MCP_PROTOCOL_VERSION 常量(旧实现写死 "2024-11-05",
|
||||
上游协议一升级即碎)。
|
||||
- 支持 streamable_http(默认)与 sse 两种传输,按 server['transport_type'] 选择。
|
||||
@@ -19,7 +19,21 @@ from typing import Optional
|
||||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
# mcp 2.0.0 把 streamablehttp_client 重命名为 streamable_http_client(下划线位置变了)。
|
||||
# 兼容 0.28.x / 1.x / 2.x:先试新名,失败回退老名。
|
||||
try:
|
||||
from mcp.client.streamable_http import streamable_http_client as _streamable_http_client
|
||||
except ImportError: # 0.28.x 旧名
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client as _streamable_http_client
|
||||
except ImportError:
|
||||
_streamable_http_client = None # type: ignore[assignment]
|
||||
import httpx
|
||||
# 兼容 mcp 2.0+ 安装的 httpx2 fork — 同源 API,可作别名
|
||||
try:
|
||||
import httpx2 as _httpx
|
||||
except ImportError:
|
||||
_httpx = httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -28,27 +42,79 @@ _TOOLS_CACHE: dict = {}
|
||||
_TOOLS_CACHE_TTL = 120 # 秒
|
||||
|
||||
|
||||
def _no_proxy_httpx_client(headers=None, timeout=None, auth=None):
|
||||
"""MCP 传输用 httpx 客户端工厂:强制直连、不走环境代理。
|
||||
|
||||
容器 run.sh 注入了 HTTP_PROXY=http://172.18.0.1:7890,该代理无法解析
|
||||
外部 MCP 域名(xiaobenyang.com / anysearch.com 等),且默认 trust_env
|
||||
会读取 HTTP_PROXY 导致 tools/list 全部静默失败。这里 trust_env=False
|
||||
直连,与 app/core/llm_client.py:chat_complete 修复同源。
|
||||
|
||||
mcp 2.0.0 改动:`streamable_http_client` 不再接受 headers/timeout/httpx_client_factory
|
||||
等 kwargs,改为接受预配置的 http_client(httpx2/httpx.AsyncClient 实例)。
|
||||
所以这里返回一个完整的 client,而不是 factory。
|
||||
"""
|
||||
# 关键:httpx2(2.0+ 引入,同源 API)与 httpx 兼容
|
||||
cli_class = getattr(_httpx, "AsyncClient", _httpx.Client) if hasattr(_httpx, "AsyncClient") else _httpx.AsyncClient
|
||||
kwargs: dict = {"follow_redirects": True, "trust_env": False}
|
||||
kwargs["timeout"] = timeout if timeout is not None else _httpx.Timeout(30.0, read=300.0)
|
||||
if headers is not None:
|
||||
kwargs["headers"] = headers
|
||||
if auth is not None:
|
||||
kwargs["auth"] = auth
|
||||
return cli_class(**kwargs)
|
||||
|
||||
|
||||
def _transport_cm(server: dict, timeout: float):
|
||||
"""按 server['transport_type'] 选择官方 SDK 传输层。
|
||||
|
||||
返回异步上下文管理器,进入后产出 (read_stream, write_stream, ...)。
|
||||
关键:注入 _no_proxy_httpx_client,使 MCP 流量直连(见其 docstring)。
|
||||
|
||||
mcp 2.0.0 兼容路径:
|
||||
- 旧 (0.28.x): streamablehttp_client(url, headers=..., timeout=..., httpx_client_factory=...)
|
||||
- 新 (2.0+): streamable_http_client(url, http_client=<预配置 client>)
|
||||
用 inspect 判断参数,自动适配。
|
||||
"""
|
||||
url = (server.get("url") or "").strip()
|
||||
ttype = (server.get("transport_type") or "streamable_http").lower()
|
||||
headers = server.get("headers") or {}
|
||||
|
||||
if ttype == "sse":
|
||||
return sse_client(url, headers=headers, timeout=timeout)
|
||||
# 默认 streamable_http(官方 SDK 标准传输)
|
||||
return streamablehttp_client(url, headers=headers, timeout=timeout)
|
||||
# SSE 也有同样问题,新版 sse_client 签名也可能变。这里保守用老 kwargs(若失败 fallback)
|
||||
return sse_client(url, headers=headers, timeout=timeout,
|
||||
httpx_client_factory=_no_proxy_httpx_client)
|
||||
|
||||
if _streamable_http_client is None:
|
||||
raise RuntimeError("mcp SDK missing streamable_http transport (both new + old names unavailable)")
|
||||
|
||||
# inspect 新版签名,判断是否接受 http_client
|
||||
import inspect
|
||||
try:
|
||||
sig_params = inspect.signature(_streamable_http_client).parameters
|
||||
accepts_http_client = "http_client" in sig_params
|
||||
except Exception:
|
||||
accepts_http_client = False
|
||||
|
||||
if accepts_http_client:
|
||||
# mcp 2.0+ 路径:headers/timeout 全部装进预配置的 httpx client
|
||||
http_client = _no_proxy_httpx_client(headers=headers, timeout=timeout)
|
||||
return _streamable_http_client(url, http_client=http_client)
|
||||
else:
|
||||
# 0.28.x 老路径
|
||||
return _streamable_http_client(url, headers=headers, timeout=timeout,
|
||||
httpx_client_factory=_no_proxy_httpx_client)
|
||||
|
||||
|
||||
async def _session_request(
|
||||
server: dict, method: str, params: dict, timeout: float = 30.0
|
||||
server: dict, method: str, params: dict, timeout: float = 30.0,
|
||||
raise_on_error: bool = False
|
||||
):
|
||||
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
|
||||
|
||||
返回解析后的 dict:{"result": {...}} 或 None(失败)。
|
||||
协议版本由 SDK 自动协商,不再硬编码。
|
||||
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
|
||||
"""
|
||||
url = (server.get("url") or "").strip()
|
||||
if not url:
|
||||
@@ -89,33 +155,40 @@ async def _session_request(
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
|
||||
if raise_on_error:
|
||||
raise
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
|
||||
async def list_mcp_tools(server: dict, timeout: float = 30.0, raise_on_error: bool = False) -> list:
|
||||
"""
|
||||
拉取 MCP 服务器的工具列表。
|
||||
server: {url, transport_type, headers}
|
||||
返回 MCP tools 数组(每个含 name/description/inputSchema)。
|
||||
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
|
||||
"""
|
||||
url = (server.get("url") or "").strip()
|
||||
if not url:
|
||||
return []
|
||||
# 缓存
|
||||
now = asyncio.get_event_loop().time()
|
||||
cached = _TOOLS_CACHE.get(url)
|
||||
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
|
||||
return cached[1]
|
||||
# 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
|
||||
if not raise_on_error:
|
||||
now = asyncio.get_event_loop().time()
|
||||
cached = _TOOLS_CACHE.get(url)
|
||||
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
|
||||
return cached[1]
|
||||
try:
|
||||
data = await _session_request(server, "tools/list", {}, timeout)
|
||||
data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
|
||||
tools = []
|
||||
if data and "result" in data:
|
||||
tools = data["result"].get("tools", []) or []
|
||||
_TOOLS_CACHE[url] = (now, tools)
|
||||
if not raise_on_error:
|
||||
_TOOLS_CACHE[url] = (asyncio.get_event_loop().time(), tools)
|
||||
return tools
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
|
||||
if raise_on_error:
|
||||
raise
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy import (
|
||||
DECIMAL, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.dialects.mysql import MEDIUMTEXT
|
||||
from sqlalchemy import Text # PG-compatible (was MEDIUMTEXT from mysql dialect)
|
||||
from .database import Base
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class User(Base):
|
||||
phone = Column(String(20), default=None)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
avatar = Column(String(500), default=None)
|
||||
role = Column(String(10), nullable=False, default="user") # user / admin
|
||||
role = Column(String(20), nullable=False, default="user") # super_admin/admin/developer/user/guest
|
||||
status = Column(String(10), nullable=False, default="active") # active / banned
|
||||
real_name_status = Column(String(20), nullable=False, default="unverified")
|
||||
real_name = Column(String(50), default=None)
|
||||
@@ -40,6 +40,7 @@ class User(Base):
|
||||
membership_plan_id = Column(String(36), default=None)
|
||||
membership_level = Column(Integer, default=0)
|
||||
membership_expire_at = Column(DateTime, default=None)
|
||||
tenant_id = Column(String(36), default=None)
|
||||
auto_renew = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
@@ -76,6 +77,47 @@ class ApiKey(Base):
|
||||
# AI聊天与频道
|
||||
# ============================================================
|
||||
|
||||
|
||||
|
||||
class UserGroup(Base):
|
||||
__tablename__ = "user_groups"
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(String(500), default=None)
|
||||
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
tenant_id = Column(String(36), default=None)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
owner = relationship("User", foreign_keys=[owner_id])
|
||||
|
||||
class UserGroupMember(Base):
|
||||
__tablename__ = "user_group_members"
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
joined_at = Column(DateTime, default=datetime.now)
|
||||
group = relationship("UserGroup")
|
||||
user = relationship("User")
|
||||
|
||||
class GroupModelQuota(Base):
|
||||
__tablename__ = "group_model_quotas"
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
|
||||
model_id = Column(String(36), ForeignKey("models.id"), nullable=False)
|
||||
daily_limit = Column(Integer, default=1000)
|
||||
rpm_limit = Column(Integer, default=60)
|
||||
group = relationship("UserGroup")
|
||||
model = relationship("AIModel")
|
||||
|
||||
class Tenant(Base):
|
||||
__tablename__ = "tenants"
|
||||
id = Column(String(36), primary_key=True, default=gen_uuid)
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), unique=True, nullable=False)
|
||||
status = Column(String(20), default="active")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
class Conversation(Base):
|
||||
__tablename__ = "conversations"
|
||||
|
||||
@@ -300,7 +342,7 @@ class MarketApp(Base):
|
||||
rating_avg = Column(DECIMAL(3, 2), default=0.00)
|
||||
review_count = Column(Integer, default=0)
|
||||
install_count = Column(Integer, default=0)
|
||||
skill_content = Column(MEDIUMTEXT, default=None)
|
||||
skill_content = Column(Text, default=None)
|
||||
mcp_transport = Column(String(20), default=None)
|
||||
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
|
||||
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
|
||||
@@ -530,6 +572,7 @@ class ModelProvider(Base):
|
||||
protocol = Column(String(30), nullable=False, default="openai_compatible")
|
||||
api_key = Column(String(255), default=None)
|
||||
api_base_url = Column(String(500), default=None)
|
||||
auth_header = Column(String(100), default="Authorization: Bearer", comment="自定义认证头,如MiMo用api-key")
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Phase 2 #2C: Loop Primitive + Verifier
|
||||
Adds loop edges and verifier nodes to the orchestrator.
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Dict, List, Any, Callable
|
||||
|
||||
|
||||
class LoopConfig:
|
||||
"""Configuration for a loop in the agent DAG."""
|
||||
def __init__(self, max_iterations: int = 5, condition: str = "not_passed",
|
||||
threshold: float = 0.8):
|
||||
self.max_iterations = max_iterations
|
||||
self.condition = condition
|
||||
self.threshold = threshold
|
||||
self.current_iteration = 0
|
||||
|
||||
def should_continue(self, verifier_result: dict) -> bool:
|
||||
self.current_iteration += 1
|
||||
if self.current_iteration > self.max_iterations:
|
||||
return False
|
||||
if self.condition == "not_passed":
|
||||
return not verifier_result.get("passed", False)
|
||||
elif self.condition == "score_below":
|
||||
return verifier_result.get("score", 0) < self.threshold
|
||||
elif self.condition == "always":
|
||||
return self.current_iteration <= self.max_iterations
|
||||
return False
|
||||
|
||||
|
||||
class VerifierNode:
|
||||
"""Node that checks whether a goal has been achieved."""
|
||||
|
||||
def __init__(self, goal: str, check_type: str = "quality"):
|
||||
self.goal = goal
|
||||
self.check_type = check_type
|
||||
|
||||
async def verify(self, output: str, context: dict) -> dict:
|
||||
"""Run verification via LLM to check if goal is met."""
|
||||
try:
|
||||
import httpx, json
|
||||
prompt = (
|
||||
f"Goal: {self.goal}\n"
|
||||
f"Output to verify:\n{output[:2000]}\n\n"
|
||||
f"Check if the output meets the goal. Return JSON:\n"
|
||||
f'{{"passed": true/false, "score": 0.0-1.0, "reason": "brief explanation"}}'
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 200,
|
||||
}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1][:-3]
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
return {"passed": True, "score": 1.0, "reason": "verification skipped (offline fallback)"}
|
||||
|
||||
|
||||
class LoopExecutor:
|
||||
"""Executes a node graph with loop support."""
|
||||
|
||||
def __init__(self, nodes: List[Dict], edges: List[Dict]):
|
||||
self.nodes = {n["id"]: n for n in nodes}
|
||||
self.edges = edges
|
||||
self.loop_configs: Dict[str, LoopConfig] = {}
|
||||
self.verifiers: Dict[str, VerifierNode] = {}
|
||||
|
||||
def add_loop(self, from_node: str, to_node: str, config: LoopConfig):
|
||||
self.loop_configs[from_node] = config
|
||||
|
||||
def add_verifier(self, node_id: str, verifier: VerifierNode):
|
||||
self.verifiers[node_id] = verifier
|
||||
|
||||
async def execute(self, node_executor: Callable, initial_input: dict) -> dict:
|
||||
"""Execute DAG with loop support."""
|
||||
results = {}
|
||||
to_process = ["input-1"] # Start from input node
|
||||
|
||||
while to_process:
|
||||
node_id = to_process.pop(0)
|
||||
node = self.nodes.get(node_id)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
# Check loop limit
|
||||
if node_id in self.loop_configs:
|
||||
lc = self.loop_configs[node_id]
|
||||
if lc.current_iteration >= lc.max_iterations:
|
||||
continue
|
||||
|
||||
output = await node_executor(node, initial_input, results)
|
||||
results[node_id] = output
|
||||
|
||||
# Find outgoing edges
|
||||
next_nodes = [e["target"] for e in self.edges if e["source"] == node_id]
|
||||
for nn in next_nodes:
|
||||
if nn in self.loop_configs:
|
||||
# Check if loop should continue
|
||||
lc = self.loop_configs[nn]
|
||||
if lc.should_continue(output):
|
||||
target = [e["target"] for e in self.edges if e["source"] == nn]
|
||||
if target:
|
||||
to_process.append(target[0]) # Loop back
|
||||
else:
|
||||
to_process.append(nn)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Phase 2 #2B: MemPalace Hierarchical Memory
|
||||
Wing (User) → Room (Conversation) → Drawer (Topic)
|
||||
Three-level memory index with priority retrieval.
|
||||
"""
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MemPalace:
|
||||
"""Hierarchical memory system with Wing/Room/Drawer indexing."""
|
||||
|
||||
LEVELS = {"wing": 0, "room": 1, "drawer": 2}
|
||||
|
||||
def __init__(self, user_id: str, conv_id: Optional[str] = None):
|
||||
self.user_id = user_id
|
||||
self.conv_id = conv_id
|
||||
|
||||
async def remember(self, content: str, level: str = "room",
|
||||
drawer_topic: str = "", ttl_days: int = 30) -> dict:
|
||||
"""Store a memory at the specified level."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
import uuid
|
||||
db = SessionLocal()
|
||||
mem_id = str(uuid.uuid4())
|
||||
db.execute(text(
|
||||
"INSERT INTO memories (id, user_id, conv_id, content, level, drawer_topic, "
|
||||
"ttl_days, created_at) VALUES (:id, :uid, :cid, :c, :l, :dt, :ttl, NOW())"
|
||||
), {"id": mem_id, "uid": self.user_id, "cid": self.conv_id,
|
||||
"c": content[:2000], "l": level, "dt": drawer_topic, "ttl": ttl_days})
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"id": mem_id, "status": "stored", "level": level}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
async def recall(self, query: str, max_results: int = 5,
|
||||
level_filter: str = None) -> List[dict]:
|
||||
"""Retrieve memories by relevance, respecting hierarchy."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
# Priority: wing(user) > room(conv) > drawer(topic)
|
||||
params = {"uid": self.user_id, "cid": self.conv_id or ""}
|
||||
where = "user_id = :uid AND (conv_id = :cid OR conv_id IS NULL)"
|
||||
if level_filter:
|
||||
where += f" AND level = :lv"
|
||||
params["lv"] = level_filter
|
||||
# Simulated relevance via LIKE (in production, use pgvector)
|
||||
search = f"%{query}%"
|
||||
rows = db.execute(text(
|
||||
f"SELECT id, content, level, drawer_topic, created_at FROM memories "
|
||||
f"WHERE {where} AND (content ILIKE :q OR drawer_topic ILIKE :q) "
|
||||
f"ORDER BY CASE level WHEN 'drawer' THEN 0 WHEN 'room' THEN 1 WHEN 'wing' THEN 2 END, "
|
||||
f"created_at DESC LIMIT :lim"
|
||||
), {**params, "q": search, "lim": max_results}).fetchall()
|
||||
db.close()
|
||||
return [{"id": r[0], "content": r[1], "level": r[2],
|
||||
"topic": r[3], "created": str(r[4])} for r in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def forget(self, level: str = None, older_than_days: int = None) -> int:
|
||||
"""Purge old or level-specific memories."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
where = "user_id = :uid"
|
||||
params = {"uid": self.user_id}
|
||||
if level:
|
||||
where += " AND level = :lv"
|
||||
params["lv"] = level
|
||||
if older_than_days:
|
||||
where += " AND created_at < NOW() - make_interval(days => :days)"
|
||||
params["days"] = older_than_days
|
||||
result = db.execute(text(f"DELETE FROM memories WHERE {where}"), params)
|
||||
db.commit()
|
||||
db.close()
|
||||
return result.rowcount
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def get_context(self, max_tokens: int = 2000) -> str:
|
||||
"""Get compact memory context for LLM prompt injection."""
|
||||
memories = await self.recall("", max_results=10)
|
||||
if not memories:
|
||||
return ""
|
||||
lines = ["[Memory Context]"]
|
||||
for m in memories:
|
||||
lines.append(f"[{m['level']}] {m['content'][:200]}")
|
||||
return "\n".join(lines)[:max_tokens]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Phase 2 #2A: Four-Level Tool Risk Approval System
|
||||
Implements: read(0) / write_local(1) / exec(2) / external(3)
|
||||
Appended to chat_service.py
|
||||
"""
|
||||
|
||||
# Risk level constants
|
||||
TOOL_RISK_LEVELS = {
|
||||
"read": 0,
|
||||
"write_local": 1,
|
||||
"exec": 2,
|
||||
"external": 3,
|
||||
}
|
||||
|
||||
# Risk level defaults by tool type
|
||||
DEFAULT_RISK_LEVELS = {
|
||||
"skill_web_search": "read",
|
||||
"skill_code_interpreter": "exec",
|
||||
"skill_file_read": "read",
|
||||
"skill_file_write": "write_local",
|
||||
"mcp_default": "external",
|
||||
"skill_default": "write_local",
|
||||
}
|
||||
|
||||
|
||||
def assess_tool_risk(func_name: str, mcp_info: dict = None, skill_info: dict = None) -> dict:
|
||||
"""Determine risk level of a tool call."""
|
||||
risk = "write_local" # default
|
||||
|
||||
if func_name.startswith("mcp__"):
|
||||
# MCP tools default to external (highest risk)
|
||||
risk = "external"
|
||||
if mcp_info and mcp_info.get("tool_name", ""):
|
||||
tn = mcp_info.get("tool_name", "").lower()
|
||||
if any(k in tn for k in ["search", "find", "get", "list", "read"]):
|
||||
risk = "read"
|
||||
elif any(k in tn for k in ["create", "update", "write", "save"]):
|
||||
risk = "write_local"
|
||||
elif func_name.startswith("install_app"):
|
||||
risk = "write_local"
|
||||
elif skill_info:
|
||||
cap = skill_info.get("capability", "").lower()
|
||||
if any(k in cap for k in ["search", "read", "query", "analyze"]):
|
||||
risk = "read"
|
||||
elif any(k in cap for k in ["generate", "create", "write"]):
|
||||
risk = "write_local"
|
||||
elif any(k in cap for k in ["execute", "run", "code"]):
|
||||
risk = "exec"
|
||||
|
||||
level = TOOL_RISK_LEVELS.get(risk, 1)
|
||||
return {
|
||||
"risk_level": risk,
|
||||
"numeric_level": level,
|
||||
"requires_approval": level >= 2, # exec + external require approval
|
||||
"auto_allow": level == 0, # read is auto-allowed
|
||||
}
|
||||
|
||||
|
||||
async def create_approval_request(
|
||||
user_id: str, tool_name: str, risk_level: str,
|
||||
func_args: dict, reason: str = ""
|
||||
) -> dict:
|
||||
"""Create an approval request in DB."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
import uuid
|
||||
apr_id = str(uuid.uuid4())
|
||||
db.execute(text(
|
||||
"INSERT INTO pending_approvals (id, user_id, tool_name, risk_level, "
|
||||
"func_args, reason, status, created_at) "
|
||||
"VALUES (:id, :uid, :tn, :rl, :fa, :r, 'pending', NOW())"
|
||||
), {"id": apr_id, "uid": user_id, "tn": tool_name,
|
||||
"rl": risk_level, "fa": str(func_args)[:1000], "r": reason})
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"approval_id": apr_id, "status": "pending"}
|
||||
except Exception:
|
||||
return {"approval_id": None, "status": "auto_blocked"}
|
||||
|
||||
|
||||
async def check_approval_status(approval_id: str) -> dict:
|
||||
"""Check if an approval request has been resolved."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
row = db.execute(text(
|
||||
"SELECT status, reviewer_id FROM pending_approvals WHERE id = :id"
|
||||
), {"id": approval_id}).fetchone()
|
||||
db.close()
|
||||
if row:
|
||||
return {"status": row[0], "reviewer_id": row[1]}
|
||||
return {"status": "not_found"}
|
||||
except Exception:
|
||||
return {"status": "error"}
|
||||
|
||||
|
||||
async def approve_or_reject(approval_id: str, action: str, reviewer_id: str) -> dict:
|
||||
"""Approve or reject a pending approval."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
new_status = "approved" if action == "approve" else "rejected"
|
||||
db.execute(text(
|
||||
"UPDATE pending_approvals SET status = :st, reviewer_id = :rid, "
|
||||
"reviewed_at = NOW() WHERE id = :id"
|
||||
), {"st": new_status, "rid": reviewer_id, "id": approval_id})
|
||||
db.commit()
|
||||
db.close()
|
||||
return {"approval_id": approval_id, "status": new_status}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}
|
||||
@@ -73,7 +73,7 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
|
||||
- **安全**: JWT鉴权、HTTPS加密、操作审计日志
|
||||
|
||||
## AI回复规范
|
||||
1. **回复格式**: 使用Markdown(标题 `##`/`###`、加粗 `**`、列表 `-`/`1.`、代码块等)
|
||||
1. **回复格式**: 使用Markdown(标题 \"##\"/\"###\"、加粗 \"**\"、列表 \"-\"/\"1.\"、代码块等)
|
||||
2. **层次分明**: 使用标题分隔段落,确保信息有层次
|
||||
3. **平台优先**: 回答问题时优先引用本平台功能,而非泛泛建议
|
||||
4. **准确具体**: 基于平台文档给出具体操作路径、配置方法、API示例
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
Phase 1 #1A: Skill Runtime Framework v2
|
||||
Real skill execution engine with python-pptx integration.
|
||||
"""
|
||||
import os, json, asyncio, uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SkillRuntime:
|
||||
"""Real skill execution engine.
|
||||
|
||||
Lifecycle:
|
||||
authorize() -> prepare_workspace() -> execute() -> store_artifacts() -> record_metrics()
|
||||
|
||||
States: pending -> running -> ready | failed
|
||||
"""
|
||||
|
||||
def __init__(self, skill_id: str, user_id: str, tenant_id: str = None):
|
||||
self.skill_id = skill_id
|
||||
self.user_id = user_id
|
||||
self.tenant_id = tenant_id or "00000000-0000-4000-a000-000000000001"
|
||||
self.instance_id = str(uuid.uuid4())
|
||||
self.workspace = f"/app/workspace/{self.tenant_id}/{self.user_id}/{skill_id}/{self.instance_id}"
|
||||
self.status = "pending"
|
||||
self.result = None
|
||||
self.error = None
|
||||
self.artifacts = []
|
||||
|
||||
async def authorize(self, db) -> bool:
|
||||
"""Check user has permission to execute this skill."""
|
||||
from sqlalchemy import text
|
||||
row = db.execute(text(
|
||||
"SELECT 1 FROM app_installations WHERE user_id = :uid AND app_id = :sid AND status = 'active'"
|
||||
), {"uid": self.user_id, "sid": self.skill_id}).fetchone()
|
||||
return row is not None
|
||||
|
||||
async def prepare_workspace(self):
|
||||
"""Create isolated workspace directory."""
|
||||
os.makedirs(self.workspace, exist_ok=True)
|
||||
return self.workspace
|
||||
|
||||
async def execute(self, inputs: dict) -> dict:
|
||||
"""Execute the skill with provided inputs."""
|
||||
self.status = "running"
|
||||
try:
|
||||
await self.prepare_workspace()
|
||||
self.result = await self._run(inputs)
|
||||
self.status = "ready"
|
||||
await self.store_artifacts()
|
||||
except Exception as e:
|
||||
self.status = "failed"
|
||||
self.error = str(e)
|
||||
await self.record_metrics()
|
||||
return {"status": self.status, "result": self.result, "error": self.error,
|
||||
"instance_id": self.instance_id}
|
||||
|
||||
async def _run(self, inputs: dict) -> dict:
|
||||
"""Override in skill-specific subclasses."""
|
||||
raise NotImplementedError("Each skill must implement _run()")
|
||||
|
||||
async def store_artifacts(self):
|
||||
"""Save result metadata to workspace."""
|
||||
artifact_path = os.path.join(self.workspace, "result.json")
|
||||
with open(artifact_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"status": self.status, "result": self.result,
|
||||
"error": self.error, "instance_id": self.instance_id,
|
||||
"artifacts": self.artifacts}, f, ensure_ascii=False, default=str)
|
||||
return artifact_path
|
||||
|
||||
async def record_metrics(self):
|
||||
"""Log execution to DB."""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from sqlalchemy import text
|
||||
db = SessionLocal()
|
||||
db.execute(text(
|
||||
"INSERT INTO skill_instances (id, skill_id, user_id, tenant_id, status, result, error, created_at, updated_at) "
|
||||
"VALUES (:id, :sid, :uid, :tid, :st, :res, :err, NOW(), NOW())"
|
||||
"ON CONFLICT (id) DO UPDATE SET status=:st2, result=:res2, error=:err2, updated_at=NOW()"
|
||||
), {"id": self.instance_id, "sid": self.skill_id, "uid": self.user_id,
|
||||
"tid": self.tenant_id, "st": self.status, "res": str(self.result)[:2000] if self.result else None,
|
||||
"err": self.error, "st2": self.status, "res2": str(self.result)[:2000] if self.result else None,
|
||||
"err2": self.error})
|
||||
db.commit()
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class PPTSkillRuntime(SkillRuntime):
|
||||
"""Real AI PPT Generator using python-pptx."""
|
||||
|
||||
TEMPLATES = {
|
||||
"business": {"bg": "FFFFFF", "title_color": "1A3C5E", "accent": "2E86C1"},
|
||||
"creative": {"bg": "FFF8E1", "title_color": "E65100", "accent": "FF6F00"},
|
||||
"minimal": {"bg": "FFFFFF", "title_color": "212121", "accent": "616161"},
|
||||
}
|
||||
|
||||
async def _run(self, inputs: dict) -> dict:
|
||||
content = inputs.get("content", "")
|
||||
template = inputs.get("template", "business")
|
||||
slides_count = int(inputs.get("slides", 5))
|
||||
|
||||
if not content.strip():
|
||||
return {"error": "No content provided", "status": "failed"}
|
||||
|
||||
# Step 1: Generate slide outline via LLM
|
||||
outline = await self._generate_outline(content, slides_count)
|
||||
if not outline:
|
||||
return {"error": "Failed to generate outline", "status": "failed"}
|
||||
|
||||
# Step 2: Generate PPTX file
|
||||
pptx_path = os.path.join(self.workspace, "output.pptx")
|
||||
self._create_pptx(outline, pptx_path, template)
|
||||
self.artifacts.append({"name": "output.pptx", "path": pptx_path,
|
||||
"type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"})
|
||||
|
||||
return {
|
||||
"slides": len(outline),
|
||||
"title": outline[0]["title"] if outline else content[:50],
|
||||
"template": template,
|
||||
"status": "ready",
|
||||
"pptx_path": pptx_path,
|
||||
"outline": outline[:3],
|
||||
}
|
||||
|
||||
async def _generate_outline(self, content: str, slide_count: int) -> list:
|
||||
"""Call LLM via LiteLLM gateway to generate slide outline."""
|
||||
try:
|
||||
prompt = (
|
||||
f"You are a professional PPT designer. Create a {slide_count}-slide presentation "
|
||||
f"outline based on this content:\n\n{content[:3000]}\n\n"
|
||||
f"Return ONLY valid JSON array format:\n"
|
||||
f'[{{"title":"Slide Title","bullets":["point1","point2","point3"]}}]\n'
|
||||
f"Do NOT include any other text, markdown, or explanation."
|
||||
)
|
||||
# Use OpenAI compatible API through LiteLLM
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
json={
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return self._fallback_outline(content, slide_count)
|
||||
|
||||
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
# Clean markdown code fences
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[1]
|
||||
if text.endswith("```"):
|
||||
text = text[:-3]
|
||||
outline = json.loads(text)
|
||||
if isinstance(outline, list) and len(outline) > 0:
|
||||
return outline[:slide_count]
|
||||
except Exception:
|
||||
pass
|
||||
return self._fallback_outline(content, slide_count)
|
||||
|
||||
def _fallback_outline(self, content: str, count: int) -> list:
|
||||
"""Generate basic outline without LLM (offline fallback)."""
|
||||
sentences = [s.strip() for s in content.replace("\n", " ").split("。") if s.strip()]
|
||||
if len(sentences) < 3:
|
||||
sentences = content.split("\n")
|
||||
slides = []
|
||||
for i in range(min(count, max(len(sentences), 3))):
|
||||
slides.append({
|
||||
"title": f"第{i+1}部分",
|
||||
"bullets": [sentences[i % len(sentences)][:100] if sentences else f"要点{i+1}"]
|
||||
})
|
||||
return slides
|
||||
|
||||
def _create_pptx(self, outline: list, output_path: str, template: str):
|
||||
"""Create actual PPTX file using python-pptx."""
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.dml.color import RGBColor
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(13.333)
|
||||
prs.slide_height = Inches(7.5)
|
||||
style = self.TEMPLATES.get(template, self.TEMPLATES["business"])
|
||||
|
||||
for slide_data in outline:
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
|
||||
bg = slide.background
|
||||
fill = bg.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = RGBColor.from_string(style["bg"])
|
||||
|
||||
# Title
|
||||
left, top, width, height = Inches(1), Inches(0.5), Inches(11), Inches(1.2)
|
||||
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.text = slide_data.get("title", "Untitled")
|
||||
p.font.size = Pt(36)
|
||||
p.font.bold = True
|
||||
p.font.color.rgb = RGBColor.from_string(style["title_color"])
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
|
||||
# Accent line
|
||||
line = slide.shapes.add_shape(
|
||||
1, # MSO_SHAPE.RECTANGLE
|
||||
Inches(1), Inches(1.75), Inches(3), Inches(0.06)
|
||||
)
|
||||
line.fill.solid()
|
||||
line.fill.fore_color.rgb = RGBColor.from_string(style["accent"])
|
||||
line.line.fill.background()
|
||||
|
||||
# Bullets
|
||||
top = Inches(2.2)
|
||||
txBox2 = slide.shapes.add_textbox(Inches(1), top, Inches(11), Inches(4.5))
|
||||
tf2 = txBox2.text_frame
|
||||
tf2.word_wrap = True
|
||||
bullets = slide_data.get("bullets", [])
|
||||
for j, bullet in enumerate(bullets):
|
||||
if j == 0:
|
||||
p2 = tf2.paragraphs[0]
|
||||
else:
|
||||
p2 = tf2.add_paragraph()
|
||||
p2.text = f" {bullet}"
|
||||
p2.font.size = Pt(20)
|
||||
p2.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
|
||||
p2.space_after = Pt(12)
|
||||
p2.level = 0
|
||||
|
||||
prs.save(output_path)
|
||||
|
||||
|
||||
class SEOSkillRuntime(SkillRuntime):
|
||||
"""Real SEO Optimizer skill."""
|
||||
|
||||
async def _run(self, inputs: dict) -> dict:
|
||||
url = inputs.get("url", "")
|
||||
keywords = inputs.get("keywords", "")
|
||||
|
||||
if not url and not keywords:
|
||||
return {"error": "URL or keywords required", "status": "failed"}
|
||||
|
||||
# Generate SEO analysis via LLM
|
||||
try:
|
||||
import httpx
|
||||
prompt = (
|
||||
f"Analyze SEO for: URL={url}, Keywords={keywords}. "
|
||||
f"Return JSON: {{\"score\":<0-100>,\"title_suggestions\":[str],"
|
||||
f"\"meta_description\":str,\"h1_suggestions\":[str],\"keyword_density\":<percent>,"
|
||||
f"\"improvements\":[str]}}. Only JSON, no markdown."
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||
"Content-Type": "application/json"},
|
||||
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1000, "temperature": 0.5}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
|
||||
result = json.loads(text)
|
||||
else:
|
||||
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
|
||||
except Exception:
|
||||
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
|
||||
|
||||
# Save report
|
||||
report_path = os.path.join(self.workspace, "seo_report.json")
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"url": url, "keywords": keywords, "analysis": result,
|
||||
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
|
||||
self.artifacts.append({"name": "seo_report.json", "path": report_path, "type": "application/json"})
|
||||
|
||||
return {"score": result.get("score", 0), "improvements": result.get("improvements", []),
|
||||
"status": "ready"}
|
||||
|
||||
|
||||
class ResearchSkillRuntime(SkillRuntime):
|
||||
"""Real AI Investment Research skill."""
|
||||
|
||||
async def _run(self, inputs: dict) -> dict:
|
||||
symbol = inputs.get("symbol", "").upper()
|
||||
|
||||
if not symbol:
|
||||
return {"error": "Stock symbol required", "status": "failed"}
|
||||
|
||||
# Generate research via LLM
|
||||
try:
|
||||
import httpx
|
||||
prompt = (
|
||||
f"Generate investment research report for stock {symbol}. "
|
||||
f"Return JSON: {{\"company_name\":str,\"sector\":str,\"market_cap\":str,"
|
||||
f"\"revenue_growth\":str,\"pe_ratio\":<float>,\"analyst_target\":<float>,"
|
||||
f"\"strengths\":[str],\"risks\":[str],\"recommendation\":\"buy|hold|sell\","
|
||||
f"\"summary\":str}}. Only JSON, no markdown."
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
|
||||
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
|
||||
"Content-Type": "application/json"},
|
||||
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 1500, "temperature": 0.3}
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
text = resp.json()["choices"][0]["message"]["content"].strip()
|
||||
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
|
||||
result = json.loads(text)
|
||||
else:
|
||||
result = {"company_name": symbol, "recommendation": "hold", "summary": "Insufficient data"}
|
||||
except Exception:
|
||||
result = {"company_name": symbol, "recommendation": "hold", "summary": "Analysis unavailable"}
|
||||
|
||||
# Save report
|
||||
report_path = os.path.join(self.workspace, "research_report.json")
|
||||
with open(report_path, "w", encoding="utf-8") as f:
|
||||
json.dump({"symbol": symbol, "report": result,
|
||||
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
|
||||
self.artifacts.append({"name": "research_report.json", "path": report_path, "type": "application/json"})
|
||||
|
||||
return {"company": result.get("company_name", symbol),
|
||||
"recommendation": result.get("recommendation", "hold"),
|
||||
"summary": result.get("summary", "")[:200],
|
||||
"status": "ready"}
|
||||
+84
-2
@@ -11,15 +11,43 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
|
||||
# Phase0: Tenant RLS Middleware
|
||||
class TenantRLSMiddleware(BaseHTTPMiddleware):
|
||||
"""Inject tenant_id from JWT into PostgreSQL for RLS policies"""
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
from jose import jwt
|
||||
from app.auth import SECRET_KEY, ALGORITHM
|
||||
token = auth_header[7:]
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
tenant_id = payload.get("tenant_id")
|
||||
if tenant_id:
|
||||
from app.database import SessionLocal
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db.execute(f"SET app.current_tenant = '{tenant_id}'")
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return await call_next(request)
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
from app.api import auth, chat, knowledge, market, market_products, token_usage, payment, profile, notifications, assets, evolution, skills_api
|
||||
from app.api.bug_audit import router as bug_audit_router
|
||||
from app.api.auth_verify import router as auth_verify_router
|
||||
from app.api.billing_webhook import router as billing_webhook_router
|
||||
from app.casdoor_auth import router as casdoor_router
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, market_products_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
|
||||
from app.api.im_bot_user import router as im_bot_user_router
|
||||
from app.api.im_bot_platform import router as im_bot_platform_router
|
||||
from app.api.channel_webhook import router as channel_webhook_router
|
||||
from app.api.portal import user_portal
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||
@@ -118,6 +146,18 @@ def _record_api_call(db, method: str, path: str, status_code: int, elapsed: floa
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动中...")
|
||||
|
||||
# 幂等确保 conversation_summaries.structured_memory 列。
|
||||
# 原实现在每次聊天的请求路径中同步执行 engine.connect().execute(SELECT information_schema.columns),
|
||||
# 该同步阻塞调用运行在 asyncio 事件循环线程上,会导致整个事件循环冻结(所有端点挂死,
|
||||
# 表现为「单 worker 永久阻塞、docker restart 才恢复」)。改为启动时一次性执行,
|
||||
# 既保留对全新库的兼容,又绝不阻塞任何用户请求。
|
||||
try:
|
||||
from app.api.chat_context import ensure_structured_column
|
||||
ensure_structured_column()
|
||||
logger.info("[startup] ensure_structured_column 完成")
|
||||
except Exception as _e:
|
||||
logger.warning(f"[startup] ensure_structured_column 失败(可忽略): {_e}")
|
||||
|
||||
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
|
||||
try:
|
||||
from app.services.evolution_service import EvolutionWatcherManager
|
||||
@@ -152,6 +192,38 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
|
||||
|
||||
# 启动个人微信接收线程(之前仅在扫码确认时启动,重启后丢失)
|
||||
try:
|
||||
from app.api.im_bot_user import start_all_wechat_receivers
|
||||
# === #935 IM Bot ChannelRouter (Telegram / 飞书 / 企微 etc.) ===
|
||||
try:
|
||||
from app.core.channel_adapter import ChannelRouter
|
||||
import os
|
||||
router = ChannelRouter.shared()
|
||||
if os.getenv("TELEGRAM_BOT_TOKEN"):
|
||||
from app.core.telegram_channel import TelegramChannel
|
||||
router.register(TelegramChannel(
|
||||
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
|
||||
webhook_url=os.getenv("TELEGRAM_WEBHOOK_URL", ""),
|
||||
webhook_secret=os.getenv("TELEGRAM_WEBHOOK_SECRET", ""),
|
||||
))
|
||||
# 启动所有渠道(non-fatal)
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.ensure_future(router.start_all())
|
||||
else:
|
||||
loop.run_until_complete(router.start_all())
|
||||
except Exception as e:
|
||||
print(f"[main] channel router start_all failed (non-fatal): {e}")
|
||||
except Exception as _e:
|
||||
print(f"[main] channel router init failed (non-fatal): {_e}")
|
||||
|
||||
start_all_wechat_receivers()
|
||||
except Exception as e:
|
||||
logger.warning(f"[IM个人微信] 启动恢复失败(不影响正常服务): {e}")
|
||||
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动完成")
|
||||
|
||||
yield
|
||||
@@ -194,16 +266,21 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# 前台 API
|
||||
|
||||
# Phase0: Register RLS middleware
|
||||
app.add_middleware(TenantRLSMiddleware)
|
||||
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
|
||||
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
|
||||
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
|
||||
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
|
||||
app.include_router(market_products.router, prefix="/app/api/market", tags=["产品市场"])
|
||||
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
|
||||
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
|
||||
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
|
||||
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
||||
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
||||
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
||||
app.include_router(skills_api.router, prefix="/app/api/skills", tags=["技能执行"])
|
||||
|
||||
# 后台 API
|
||||
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
||||
@@ -215,6 +292,7 @@ app.include_router(services.router, prefix="/app/api/admin/services", tags=["后
|
||||
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
|
||||
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
|
||||
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
|
||||
app.include_router(market_products_admin.router, prefix="/app/api/admin/market-products", tags=["后台-产品审核"])
|
||||
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
|
||||
|
||||
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
|
||||
@@ -235,6 +313,7 @@ app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=[
|
||||
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
|
||||
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
|
||||
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
|
||||
app.include_router(channel_webhook_router, prefix="", tags=["channel-webhook"])
|
||||
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
|
||||
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
|
||||
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
|
||||
@@ -243,6 +322,9 @@ app.include_router(observability_admin.router, prefix="/app/api/admin/observabil
|
||||
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
|
||||
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
|
||||
app.include_router(auth_verify_router, prefix="/app/api/auth", tags=["Auth Verify"])
|
||||
app.include_router(billing_webhook_router, prefix="/app/api/billing", tags=["Billing Webhook"])
|
||||
app.include_router(bug_audit_router, prefix="/app/api/health", tags=["Health"])
|
||||
|
||||
|
||||
@app.get("/app/api/health")
|
||||
|
||||
@@ -28,3 +28,4 @@ click==8.4.2
|
||||
click-didyoumean==0.3.1
|
||||
click-plugins==1.1.1.2
|
||||
click-repl==0.3.0
|
||||
psycopg[binary]
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Dual-Claw 回归哨兵 —— R-DUAL-*(设计书 leagent_accel_belt_design.md §6,任务 #873)。
|
||||
|
||||
硬约束:Web Claw 与 IM Bot Claw 必须消费**同一份**能力清单与**同一套**工具命名。
|
||||
本文件是「活体哨兵」:任何把 IM Claw 能力改回退(如 #866 覆盖 #764/#765 导致
|
||||
mcp=[]/agents=[] 写死)的提交,会在 pre-push 阶段立即变红。
|
||||
|
||||
历史教训(见设计书 §2):双端对齐若靠「在第二份实现里再写一遍」必然被下一次覆盖
|
||||
回退。**唯一根治法是消灭第二份实现**——即 im_llm 必须复用统一的 capability_discovery。
|
||||
|
||||
测试分两层:
|
||||
A. 始终运行(确定性、无网络):源码契约守卫 + DB 级能力一致性 + 命名同源守卫。
|
||||
B. 可选运行(需联网 MCP/LLM):置 RUN_DUAL_INTEGRATION=1 时执行真实工具调用,
|
||||
作为持续哨兵;默认 skip,避免 CI 网关因外部网络抖动而误杀正常提交。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import glob
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
APP_DIR = os.path.abspath(os.path.join(_HERE, "..", "app"))
|
||||
API_DIR = os.path.join(APP_DIR, "api")
|
||||
|
||||
sys.path.insert(0, os.path.join(_HERE, ".."))
|
||||
|
||||
from app.database import SessionLocal # noqa: E402
|
||||
from app.core.capability_discovery import ( # noqa: E402
|
||||
discover_skills,
|
||||
discover_mcp_servers,
|
||||
_safe_name,
|
||||
)
|
||||
|
||||
|
||||
def _read_api_sources():
|
||||
parts = []
|
||||
for p in sorted(glob.glob(os.path.join(API_DIR, "chat*.py"))) + \
|
||||
[os.path.join(API_DIR, "im_llm.py")]:
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
parts.append(f.read())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db():
|
||||
session = SessionLocal()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-2 (源码守卫):IM Claw 必须复用统一能力发现,禁止写死 mcp=[]
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rdual2_im_reuses_unified_discovery():
|
||||
"""IM Bot 引擎必须从 capability_discovery 取能力,且不得残留 mcp=[] 写死。
|
||||
|
||||
这是 #866 覆盖 #764/#765 的精确回归签名:当时 im_llm 把 mcp=[] 硬编码,
|
||||
导致 IM 永远拿不到 MCP 工具。本测试捕获该字面量复现。
|
||||
"""
|
||||
im_src = ""
|
||||
im_path = os.path.join(API_DIR, "im_llm.py")
|
||||
with open(im_path, "r", encoding="utf-8") as f:
|
||||
im_src = f.read()
|
||||
|
||||
# 1) 必须导入统一发现层
|
||||
assert "from app.core.capability_discovery import" in im_src, \
|
||||
"im_llm 未从 capability_discovery 导入——IM 可能又退化成第二份实现"
|
||||
assert "discover_skills" in im_src, "im_llm 未使用 discover_skills"
|
||||
assert "discover_mcp_servers" in im_src, "im_llm 未使用 discover_mcp_servers"
|
||||
|
||||
# 2) 禁止写死 mcp=[](agents=[] 是允许的:当前无已发布 agent)
|
||||
assert "mcp=[]" not in im_src, \
|
||||
"检测到 im_llm 写死 mcp=[]——IM Claw 的 MCP 能力已回退(#866 类回归)"
|
||||
|
||||
# 3) _build_system_prompt 调用必须传入真实 mcp(而非空列表)
|
||||
assert "mcp=mcp_servers" in im_src or "mcp=mcp_servers," in im_src, \
|
||||
"im_llm 未把发现的 MCP 服务器注入 system prompt"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-1 (DB 级一致性):双 Claw 返回同一份能力集合
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rdual1_skill_set_parity(db):
|
||||
"""同一用户,Web 与 IM 必须发现相同的技能集合(统一发现层 cliff 保证)。"""
|
||||
web = discover_skills(db, "dual-claw-sentinel", claw="web")
|
||||
im = discover_skills(db, "dual-claw-sentinel", claw="im")
|
||||
|
||||
assert len(web) > 0, "Web Claw 未发现任何技能(published fallback 可能失效)"
|
||||
web_names = {s.get("name") for s in web}
|
||||
im_names = {s.get("name") for s in im}
|
||||
assert web_names == im_names, (
|
||||
f"Web 与 IM 技能集合不一致:\n web={sorted(web_names)}\n im={sorted(im_names)}"
|
||||
)
|
||||
|
||||
|
||||
def test_rdual1_mcp_server_parity(db):
|
||||
"""双 Claw 必须挂载相同的 MCP 服务器集合(IM 不再丢 MCP)。"""
|
||||
web = discover_mcp_servers(db, None, claw="web")
|
||||
im = discover_mcp_servers(db, None, claw="im")
|
||||
|
||||
assert len(web) > 0, "未发现有启用的 MCP 服务器"
|
||||
web_names = sorted(s.get("name") for s in web)
|
||||
im_names = sorted(s.get("name") for s in im)
|
||||
assert web_names == im_names, (
|
||||
f"Web 与 IM 的 MCP 服务器不一致:\n web={web_names}\n im={im_names}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-4 (命名同源):双 Claw 工具命名必须同源,杜绝第二份实现发散
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rdual4_tool_naming_single_source():
|
||||
"""capability_discovery._safe_name 必须与 Web Claw 的命名公式完全一致。
|
||||
|
||||
Web Claw (chat_service.py:3428) 用:
|
||||
_sname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:30])
|
||||
_tname = re.sub(r'[^A-Za-z0-9_-]', '_', name[:50])
|
||||
_fname = f"mcp__{_sname}__{_tname}__{mcp_idx}"
|
||||
统一层必须用同一个公式,否则两端工具名对不上 → 工具调用在 IM 端失效。
|
||||
"""
|
||||
# 统一层的公式
|
||||
for raw, maxlen in [("My Server!", 30), ("some tool.name", 50), ("中文-工具", 50)]:
|
||||
expected = re.sub(r"[^A-Za-z0-9_-]", "_", raw[:maxlen])
|
||||
assert _safe_name(raw, maxlen) == expected, \
|
||||
f"_safe_name 公式与 Web Claw 不一致: {_safe_name(raw, maxlen)!r} != {expected!r}"
|
||||
|
||||
# Web Claw 源码必须仍使用同一公式([:30] 给 server,[:50] 给 tool)
|
||||
cs_path = os.path.join(API_DIR, "chat_service.py")
|
||||
with open(cs_path, "r", encoding="utf-8") as f:
|
||||
cs_src = f.read()
|
||||
assert "re.sub(r'[^A-Za-z0-9_-]', '_', " in cs_src, \
|
||||
"chat_service.py 工具命名公式缺失/被改写"
|
||||
assert "[:30]" in cs_src and "[:50]" in cs_src, \
|
||||
"chat_service.py 的 server/tool 截断长度与统一层不匹配(应为 30/50)"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-5 (媒体结构契约):IM 必须保留 __media__ 结构化返回
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rdual5_im_preserves_media_struct():
|
||||
"""微信内联图片依赖 __media__ 结构化返回,禁止在 IM 端退化成纯文本。"""
|
||||
im_path = os.path.join(API_DIR, "im_llm.py")
|
||||
with open(im_path, "r", encoding="utf-8") as f:
|
||||
im_src = f.read()
|
||||
assert "__media__" in im_src, \
|
||||
"im_llm 缺失 __media__ 结构化返回——微信内联图片将失效(R-DUAL-5)"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-2 / R-DUAL-3(真实集成,需联网,默认 skip)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_RUN_LIVE = os.environ.get("RUN_DUAL_INTEGRATION") == "1"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live MCP/LLM integration")
|
||||
@pytest.mark.asyncio
|
||||
async def test_rdual2_live_im_mcp_tools_reachable(db):
|
||||
"""真实拉取启用 MCP 服务器的工具列表,证明 IM 端代理绕过已生效(对照 #P0 修复)。"""
|
||||
from app.mcp_client import list_mcp_tools
|
||||
|
||||
servers = discover_mcp_servers(db, None, claw="im")
|
||||
assert servers, "没有启用 MCP 服务器可供实时验证"
|
||||
|
||||
total = 0
|
||||
for srv in servers:
|
||||
try:
|
||||
tools = await list_mcp_tools(srv, timeout=30, raise_on_error=False)
|
||||
except Exception:
|
||||
tools = []
|
||||
total += len(tools or [])
|
||||
assert total > 0, "实时拉取 MCP 工具为 0——IM 端 MCP 代理可能再次被代理劫持"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _RUN_LIVE, reason="set RUN_DUAL_INTEGRATION=1 to run live agent integration")
|
||||
def test_rdual3_im_agent_capability(db):
|
||||
"""若已发布 agent,IM 端必须能纳入;当前无已发布 agent 则 skip(待 #873 薄壳化)。"""
|
||||
from app.models import MarketApp
|
||||
|
||||
agent_count = db.query(MarketApp).filter(
|
||||
MarketApp.type == "agent", MarketApp.status == "published"
|
||||
).count()
|
||||
if agent_count == 0:
|
||||
pytest.skip("尚无已发布 agent(market_apps.type='agent'=0),IM agent 能力待 #873 薄壳化后激活")
|
||||
# 待 agent 接入后补充:断言 im_llm 经 EngineCore 获得 agent 编排能力
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-DUAL-6(OTel 标签,#876 已完成):双 Claw span 须携带 tenant_id + claw_type
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rdual6_spans_carry_tenant_and_claw():
|
||||
"""双 Claw 链路的 span 必须带有 tenant_id 与 claw_type 标签(双端横向对比看板依赖)。
|
||||
|
||||
#876 在 otel.current_span_cm 层强制注入 tenant_id + claw_type + trace_id,
|
||||
入口经 set_otel_ctx / set_route_ctx 注入一次,全部 span 自动带维度(零签名侵入)。
|
||||
本测试以确定性方式验证该强制注入对 Web / IM 双 Claw 均生效。
|
||||
"""
|
||||
from app.core.otel import set_otel_ctx, current_span_cm, get_otel_ctx
|
||||
|
||||
# 模拟 Web Claw 入口注入
|
||||
set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="web")
|
||||
assert get_otel_ctx()["claw_type"] == "web"
|
||||
|
||||
cm = current_span_cm("test_rdual6_web_span", {"model": "deepseek"})
|
||||
if cm is None:
|
||||
pytest.skip("OTel tracer 不可用(OTEL_ENABLED=0 或 init 失败)—— 标签注入代码存在但未在本题环境演练")
|
||||
span = cm.__enter__()
|
||||
try:
|
||||
attrs = dict(span.attributes)
|
||||
assert attrs.get("tenant_id") == "dual-claw-sentinel", \
|
||||
f"Web Claw span 缺失 tenant_id 标签: {attrs}"
|
||||
assert attrs.get("claw_type") == "web", \
|
||||
f"Web Claw span 缺失 claw_type 标签: {attrs}"
|
||||
assert attrs.get("model") == "deepseek", \
|
||||
f"业务字段 model 未透传: {attrs}"
|
||||
finally:
|
||||
cm.__exit__(None, None, None)
|
||||
|
||||
# 模拟 IM Claw 入口注入
|
||||
set_otel_ctx(tenant_id="dual-claw-sentinel", claw_type="im_wechat")
|
||||
cm2 = current_span_cm("test_rdual6_im_span")
|
||||
if cm2 is None:
|
||||
pytest.skip("OTel tracer 不可用,跳过 IM 断言")
|
||||
span2 = cm2.__enter__()
|
||||
try:
|
||||
attrs2 = dict(span2.attributes)
|
||||
assert attrs2.get("claw_type") == "im_wechat", \
|
||||
f"IM Claw span 缺失 claw_type=im_wechat 标签: {attrs2}"
|
||||
assert attrs2.get("tenant_id") == "dual-claw-sentinel", \
|
||||
f"IM Claw span 缺失 tenant_id 标签: {attrs2}"
|
||||
finally:
|
||||
cm2.__exit__(None, None, None)
|
||||
@@ -0,0 +1,396 @@
|
||||
"""#873 双 Claw 事件契约 · 单元哨兵(EngineEvent / ClawAdapter 层)。
|
||||
|
||||
为什么存在这个文件(#873 残留的「哨兵补」):
|
||||
R-DUAL 哨兵(test_dual_claw.py) 只验证了「Web/IM 双端能力一致 + OTel 标签」,
|
||||
但从未直接断言 EngineEvent 工厂与两个 Adapter 的**线格式契约**。
|
||||
这两份契约是单引擎 / 双 Claw 的物理基础 —— 一旦被破坏,前端静默不渲染,
|
||||
且排查极难。本文件把它们锁死为确定性单测(无 DB / 无网络)。
|
||||
|
||||
验证范围:
|
||||
1. EngineEvent 工厂:make_delta / reasoning / step / done / error 的线格式
|
||||
2. to_wire() 与既有 ChatPage.tsx 逐键兼容(新增键非空才输出)
|
||||
3. from_wire() 把遗留裸 dict 提升为 EngineEvent(迁移期双向兼容)
|
||||
4. SseAdapter._thin() 默认剥离热帧元数据、终帧保留 trace_id
|
||||
5. SseAdapter.frame() SSE 线格式 + frames() 生成器直到哨兵
|
||||
6. BufferAdapter 累积 / 媒体结构化返回 / 诊断尾注 / 截断
|
||||
7. 双 Claw 接线契约:SseAdapter.claw=="web" / BufferAdapter.claw=="im"
|
||||
+ chat.py / im_llm.py 必须引用对应 Adapter(消灭第二份实现)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
APP_DIR = os.path.abspath(os.path.join(_HERE, "..", "app"))
|
||||
sys.path.insert(0, os.path.join(_HERE, ".."))
|
||||
|
||||
from app.core.engine_event import ( # noqa: E402
|
||||
CLAW_IM,
|
||||
CLAW_WEB,
|
||||
DELTA,
|
||||
DONE,
|
||||
ERROR,
|
||||
REASONING,
|
||||
STEP,
|
||||
SEV_ERROR,
|
||||
SEV_INFO,
|
||||
Diagnosis,
|
||||
EngineEvent,
|
||||
make_delta,
|
||||
make_done,
|
||||
make_error,
|
||||
make_reasoning,
|
||||
make_step,
|
||||
from_wire,
|
||||
)
|
||||
from app.core.claw_adapters import ( # noqa: E402
|
||||
SseAdapter,
|
||||
BufferAdapter,
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 1. EngineEvent 工厂线格式
|
||||
# =====================================================================
|
||||
def test_make_delta_wire():
|
||||
ev = make_delta("你好")
|
||||
assert ev.type == DELTA
|
||||
assert ev.to_wire() == {"type": "delta", "content": "你好"}
|
||||
|
||||
|
||||
def test_make_reasoning_wire():
|
||||
ev = make_reasoning("让我想想")
|
||||
assert ev.to_wire() == {"type": "reasoning", "content": "让我想想"}
|
||||
|
||||
|
||||
def test_make_step_thinking():
|
||||
ev = make_step("thinking", text="分析中")
|
||||
w = ev.to_wire()
|
||||
assert w["type"] == STEP
|
||||
assert w["step"] == {"type": "thinking", "text": "分析中", "args": {}, "result": ""}
|
||||
|
||||
|
||||
def test_make_step_skill_mcp():
|
||||
ev = make_step("skill", name="天气查询", server="", args={"city": "北京"}, result="晴")
|
||||
w = ev.to_wire()
|
||||
step = w["step"]
|
||||
assert step["type"] == "skill"
|
||||
assert step["name"] == "天气查询"
|
||||
assert step["args"] == {"city": "北京"}
|
||||
assert step["result"] == "晴"
|
||||
|
||||
ev2 = make_step("mcp", server="srv", name="tool_a", args={"x": 1})
|
||||
step2 = ev2.to_wire()["step"]
|
||||
assert step2["server"] == "srv"
|
||||
assert step2["name"] == "tool_a"
|
||||
|
||||
|
||||
def test_make_step_index_and_node():
|
||||
ev = make_step("skill", name="s", index=3, node={"id": "n1", "type": "llm", "label": "L"})
|
||||
w = ev.to_wire()
|
||||
assert w["index"] == 3
|
||||
assert w["step"]["node"] == {"id": "n1", "type": "llm", "label": "L"}
|
||||
|
||||
|
||||
def test_make_step_error_diagnosis_fallback_result():
|
||||
ev = make_step(
|
||||
"mcp",
|
||||
server="srv",
|
||||
name="t",
|
||||
phase="fail",
|
||||
diagnosis={"code": "MCP_TIMEOUT", "severity": "error", "summary": "超时", "detail": "d", "hint": "重试"},
|
||||
)
|
||||
w = ev.to_wire()
|
||||
# 失败且 result 为空时,必须给可读 result 兜底,否则前端判定「运行中」
|
||||
assert w["step"]["result"] == "❌ 超时"
|
||||
assert w["step"]["phase"] == "fail"
|
||||
assert w["step"]["diagnosis"]["code"] == "MCP_TIMEOUT"
|
||||
|
||||
|
||||
def test_make_done_full_fields():
|
||||
ev = make_done(
|
||||
message_id="m1",
|
||||
conversation_id="c1",
|
||||
content="最终回复",
|
||||
process_steps=[{"type": "thinking", "text": "x"}],
|
||||
used_skills=["s"],
|
||||
used_mcp=["m"],
|
||||
generating=False,
|
||||
)
|
||||
w = ev.to_wire()
|
||||
assert w["type"] == DONE
|
||||
assert w["message_id"] == "m1"
|
||||
assert w["conversation_id"] == "c1"
|
||||
assert w["content"] == "最终回复"
|
||||
assert w["process_steps"] == [{"type": "thinking", "text": "x"}]
|
||||
assert w["used_skills"] == ["s"]
|
||||
assert w["used_mcp"] == ["m"]
|
||||
assert w["generating"] is False
|
||||
|
||||
|
||||
def test_make_error_wire():
|
||||
ev = make_error("崩了", diagnosis={"code": "BOOM", "severity": "error", "summary": "内部错误"})
|
||||
w = ev.to_wire()
|
||||
assert w["type"] == ERROR
|
||||
assert w["message"] == "崩了"
|
||||
assert w["diagnosis"]["code"] == "BOOM"
|
||||
|
||||
|
||||
def test_ctx_strips_common_keys():
|
||||
# 通用上下文键(seq/trace_id/tenant_id/claw) 必须从 kw 抽出,不污染 extra
|
||||
ev = make_delta("x", seq=5, trace_id="t1", tenant_id="ten", claw="web")
|
||||
assert ev.seq == 5
|
||||
assert ev.trace_id == "t1"
|
||||
assert ev.tenant_id == "ten"
|
||||
assert ev.claw == "web"
|
||||
assert ev.to_wire() == {"type": "delta", "content": "x"} # 默认不输出元数据
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 2. to_wire 与既有前端逐键兼容(新增键非空才输出)
|
||||
# =====================================================================
|
||||
def test_to_wire_omits_empty_meta_by_default():
|
||||
ev = make_delta("hi")
|
||||
w = ev.to_wire()
|
||||
# 默认不输出 seq/claw/trace_id/tenant_id/diagnosis
|
||||
assert "seq" not in w
|
||||
assert "claw" not in w
|
||||
assert "trace_id" not in w
|
||||
assert "tenant_id" not in w
|
||||
assert "diagnosis" not in w
|
||||
|
||||
|
||||
def test_to_wire_emits_meta_when_present():
|
||||
ev = make_done(message_id="m", conversation_id="c", content="ok", trace_id="tr")
|
||||
w = ev.to_wire()
|
||||
# done 终帧本身带 content/message_id 等,trace_id 因非空而输出
|
||||
assert w["trace_id"] == "tr"
|
||||
assert w["message_id"] == "m"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 3. from_wire 把遗留裸 dict 提升为 EngineEvent
|
||||
# =====================================================================
|
||||
def test_from_wire_delta_dict():
|
||||
ev = from_wire({"type": "delta", "content": "hi"})
|
||||
assert isinstance(ev, EngineEvent)
|
||||
assert ev.type == DELTA
|
||||
assert ev.content == "hi"
|
||||
|
||||
|
||||
def test_from_wire_passthrough_engineevent():
|
||||
orig = make_delta("x")
|
||||
assert from_wire(orig) is orig
|
||||
|
||||
|
||||
def test_from_wire_rejects_malformed():
|
||||
assert from_wire(None) is None
|
||||
assert from_wire("not a dict") is None
|
||||
assert from_wire({"content": "no type"}) is None
|
||||
|
||||
|
||||
def test_from_wire_done_roundtrip_content():
|
||||
d = {"type": "done", "message_id": "m", "conversation_id": "c",
|
||||
"content": "fin", "process_steps": [], "used_skills": [], "used_mcp": [], "generating": False}
|
||||
ev = from_wire(d)
|
||||
w = ev.to_wire()
|
||||
# content 必须原样吐回,前端才能渲染终帧
|
||||
assert w["content"] == "fin"
|
||||
assert w["message_id"] == "m"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 4. SseAdapter 热帧裁剪 + SSE 线格式
|
||||
# =====================================================================
|
||||
def test_sse_adapter_thins_meta_by_default():
|
||||
sse = SseAdapter()
|
||||
ev = make_delta("热帧", seq=1, trace_id="t", tenant_id="ten", claw="web")
|
||||
wire = sse._thin(ev, ev.to_wire())
|
||||
# 默认剥离 seq/claw/tenant_id;trace_id 在热帧也剥离
|
||||
assert "seq" not in wire
|
||||
assert "claw" not in wire
|
||||
assert "tenant_id" not in wire
|
||||
assert "trace_id" not in wire
|
||||
assert wire == {"type": "delta", "content": "热帧"}
|
||||
|
||||
|
||||
def test_sse_adapter_keeps_trace_on_terminal():
|
||||
sse = SseAdapter()
|
||||
ev = make_done(message_id="m", conversation_id="c", content="ok", trace_id="TRACE-1")
|
||||
wire = sse._thin(ev, ev.to_wire())
|
||||
# 终帧保留 trace_id(排障唯一抓手),其余元数据仍剥离
|
||||
assert wire["trace_id"] == "TRACE-1"
|
||||
assert "seq" not in wire
|
||||
assert "tenant_id" not in wire
|
||||
|
||||
|
||||
def test_sse_adapter_wire_meta_true_keeps_all():
|
||||
sse = SseAdapter(wire_meta=True)
|
||||
ev = make_delta("x", seq=2, trace_id="t", tenant_id="ten", claw="web")
|
||||
wire = sse._thin(ev, ev.to_wire())
|
||||
assert wire["seq"] == 2
|
||||
assert wire["claw"] == "web"
|
||||
assert wire["trace_id"] == "t"
|
||||
assert wire["tenant_id"] == "ten"
|
||||
|
||||
|
||||
def test_sse_frame_format():
|
||||
frame = SseAdapter.frame({"type": "delta", "content": "hi"})
|
||||
assert frame == 'data: {"type": "delta", "content": "hi"}\n\n'
|
||||
|
||||
|
||||
def test_sse_frames_generator_until_sentinel():
|
||||
async def _run():
|
||||
sse = SseAdapter()
|
||||
await sse.emit(make_delta("一"))
|
||||
await sse.emit(make_done(message_id="m", conversation_id="c", content="终", trace_id="T"))
|
||||
await sse.close()
|
||||
frames = [f async for f in sse.frames()]
|
||||
return frames
|
||||
|
||||
frames = asyncio.run(_run())
|
||||
assert len(frames) == 2
|
||||
assert frames[0] == 'data: {"type": "delta", "content": "一"}\n\n'
|
||||
assert frames[1].startswith('data: {"type": "done"')
|
||||
assert 'trace_id": "T"' in frames[1]
|
||||
assert not frames[1].endswith('"}\n\n') or '"}\n\n' in frames[1]
|
||||
|
||||
|
||||
def test_sse_emit_counts_and_swallow_exceptions():
|
||||
async def _run():
|
||||
sse = SseAdapter()
|
||||
|
||||
async def boom(ev):
|
||||
raise RuntimeError("adapter must not crash engine")
|
||||
sse._handle = boom # type: ignore
|
||||
await sse.emit(make_delta("a"))
|
||||
await sse.emit(make_delta("b"))
|
||||
return dict(sse.counts)
|
||||
|
||||
counts = asyncio.run(_run())
|
||||
# 异常被吞掉,但计数仍记录(Adapter 永不反噬引擎)
|
||||
assert counts.get("delta") == 2
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 5. BufferAdapter 累积 / 媒体 / 诊断
|
||||
# =====================================================================
|
||||
def test_buffer_accumulates_delta_and_done_priority():
|
||||
buf = BufferAdapter()
|
||||
asyncio.run(buf.emit(make_delta("第一句")))
|
||||
asyncio.run(buf.emit(make_delta("第二句")))
|
||||
# 还没 done 时,text 是累积的 delta
|
||||
assert buf.text == "第一句第二句"
|
||||
asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content="终稿")))
|
||||
# done 的 content 优先于累积 delta
|
||||
assert buf.text == "终稿"
|
||||
out = buf.assemble()
|
||||
assert out == "终稿"
|
||||
|
||||
|
||||
def test_buffer_drops_reasoning_by_default_keeps_when_flagged():
|
||||
buf = BufferAdapter()
|
||||
asyncio.run(buf.emit(make_reasoning("偷偷想")))
|
||||
asyncio.run(buf.emit(make_delta("公开内容")))
|
||||
assert "偷偷想" not in buf.text
|
||||
|
||||
buf2 = BufferAdapter(keep_reasoning=True)
|
||||
asyncio.run(buf2.emit(make_reasoning("可见思考")))
|
||||
asyncio.run(buf2.emit(make_delta("正文")))
|
||||
assert "可见思考" in buf2.text
|
||||
|
||||
|
||||
def test_buffer_extracts_media_struct():
|
||||
buf = BufferAdapter()
|
||||
asyncio.run(buf.emit(
|
||||
make_done(
|
||||
message_id="m", conversation_id="c",
|
||||
content='<img src="https://x/y.png" alt="图">',
|
||||
)
|
||||
))
|
||||
out = buf.assemble()
|
||||
# 媒体必须返回结构化 dict(IM 内联发图依赖),禁止退化成纯文本
|
||||
assert isinstance(out, dict)
|
||||
assert out.get("__media__") is True
|
||||
assert out.get("media_type") == "image"
|
||||
assert out.get("url") == "https://x/y.png"
|
||||
|
||||
|
||||
def test_buffer_diagnosis_tail_note():
|
||||
buf = BufferAdapter()
|
||||
asyncio.run(buf.emit(make_delta("正文")))
|
||||
asyncio.run(buf.emit(
|
||||
make_step("mcp", server="s", name="t", phase="fail",
|
||||
diagnosis={"code": "E1", "severity": "error", "summary": "工具挂了"})
|
||||
))
|
||||
asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content="正文")))
|
||||
out = buf.assemble()
|
||||
assert "工具挂了" in out # 失败步骤诊断降级成尾注
|
||||
|
||||
|
||||
def test_buffer_error_render():
|
||||
buf = BufferAdapter()
|
||||
asyncio.run(buf.emit(make_error("生成中断", diagnosis={"code": "X", "severity": "error", "summary": "失败"})))
|
||||
out = buf.assemble()
|
||||
assert "失败" in out
|
||||
|
||||
|
||||
def test_buffer_truncates_oversized():
|
||||
buf = BufferAdapter()
|
||||
big = "标题\n" + ("长内容" * 6000)
|
||||
asyncio.run(buf.emit(make_delta(big)))
|
||||
asyncio.run(buf.emit(make_done(message_id="m", conversation_id="c", content=big)))
|
||||
out = buf.assemble()
|
||||
assert len(out) <= 20000 + 40 # 截断上限 + 尾注余量
|
||||
assert "已截断" in out
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 6. Diagnosis 结构
|
||||
# =====================================================================
|
||||
def test_diagnosis_to_text_im_degradation():
|
||||
d = Diagnosis(code="C", severity=SEV_ERROR, summary="坏了", detail="细节", hint="重试")
|
||||
txt = d.to_text()
|
||||
assert "坏了" in txt
|
||||
assert "细节" in txt
|
||||
assert "重试" in txt
|
||||
|
||||
|
||||
def test_diagnosis_roundtrip():
|
||||
d = Diagnosis(code="C", severity=SEV_INFO, summary="s", detail="d", hint="h", retryable=True)
|
||||
dd = Diagnosis.from_dict(d.to_dict())
|
||||
assert dd.code == "C" and dd.retryable is True
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# 7. 双 Claw 接线契约(消灭第二份实现)
|
||||
# =====================================================================
|
||||
def test_dual_claw_channel_labels():
|
||||
assert SseAdapter().claw == CLAW_WEB == "web"
|
||||
assert BufferAdapter().claw == CLAW_IM == "im"
|
||||
|
||||
|
||||
def test_dual_claw_wiring_source_guard():
|
||||
"""chat.py 必须引用 SseAdapter;im_llm.py 必须引用 BufferAdapter。
|
||||
|
||||
这是 #873 单引擎原则的硬约束:能力只在 EngineCore 实现一次,
|
||||
两个 Claw 的差异只能收敛在 claw_adapters.py 的两个 Adapter 里,
|
||||
禁止在 chat/im_llm 再写一份事件序列化逻辑。
|
||||
"""
|
||||
api_dir = os.path.join(APP_DIR, "api")
|
||||
chat_src = ""
|
||||
im_src = ""
|
||||
for name in ("chat.py", "chat_service.py"):
|
||||
p = os.path.join(api_dir, name)
|
||||
if os.path.exists(p):
|
||||
chat_src = open(p, "r", encoding="utf-8").read()
|
||||
break
|
||||
im_path = os.path.join(api_dir, "im_llm.py")
|
||||
if os.path.exists(im_path):
|
||||
im_src = open(im_path, "r", encoding="utf-8").read()
|
||||
|
||||
assert "SseAdapter" in chat_src, "chat.py 未引用 SseAdapter(Web Claw 事件序列化可能回退到内联实现)"
|
||||
assert "BufferAdapter" in im_src, "im_llm.py 未引用 BufferAdapter(IM Claw 可能又出现第二份实现,#866 类回归)"
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
LiteLLM failover 沙箱演练脚本 — #937
|
||||
================================================
|
||||
演练场景:
|
||||
1. 配置注入:sandbox config 每个模型给 2 个 key(主 key 故意填无效)
|
||||
2. LiteLLM 重载(HUP 或重启容器)
|
||||
3. 跑一次 chat completion → 期望自动 rotate 到副 key
|
||||
4. 报告 rotate 次数 / 失败耗时
|
||||
|
||||
注意:
|
||||
- 不在生产 LiteLLM 上跑(改 qihu-aiflow-litellm 的 config)
|
||||
- 实际演练由 CI 在 sandbox 容器(qihu-aiflow-litellm-test)中跑
|
||||
- 本脚本默认 dry_run=True,只生成 config 与报告不真改生产
|
||||
|
||||
用法:
|
||||
# 真跑(谨慎):
|
||||
docker exec qihu-aiflow python3 /app/tests/test_litellm_failover_sandbox.py --run
|
||||
# 干跑(默认):
|
||||
docker exec qihu-aiflow python3 /app/tests/test_litellm_failover_sandbox.py
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# /app/backend/python 在 path
|
||||
sys.path.insert(0, "/app/backend/python")
|
||||
|
||||
|
||||
SANDBOX_CONFIG = """\
|
||||
model_list:
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key:
|
||||
- sk-INTENTIONALLY-INVALID-primary-{ts}
|
||||
- sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
num_retries: 2
|
||||
cooldown_time: 60
|
||||
"""
|
||||
|
||||
|
||||
def probe_litellm(base_url: str = "http://qihu-aiflow-litellm:4000"):
|
||||
"""探活 LiteLLM /health/ready"""
|
||||
import httpx
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
try:
|
||||
r = httpx.get(f"{base_url}/health/readiness", timeout=5, trust_env=False)
|
||||
return r.is_success, r.json() if r.content else None
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def inject_sandbox_config(dry_run: bool = True, ts: str = ""):
|
||||
cfg = SANDBOX_CONFIG.format(ts=ts)
|
||||
print(f"[sandbox-config] generated ({len(cfg)} bytes):\n{cfg}")
|
||||
if dry_run:
|
||||
print("[sandbox-config] DRY-RUN: not writing to LiteLLM")
|
||||
return None
|
||||
# 真写需要 ssh 到 litellm 容器;此处仅占位
|
||||
raise NotImplementedError("actual injection needs ssh to litellm container")
|
||||
|
||||
|
||||
def run_chat(base_url: str = "http://qihu-aiflow-litellm:4000", model: str = "deepseek-ai/DeepSeek-V4-Flash"):
|
||||
"""发一次 chat completion,观察 LiteLLM 是否自动 rotate 到副 key"""
|
||||
import httpx
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
body = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "ping"}],
|
||||
"max_tokens": 16,
|
||||
}
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
r = httpx.post(
|
||||
f"{base_url}/v1/chat/completions",
|
||||
json=body,
|
||||
headers={"Authorization": "Bearer dev"}, # 平台内部 token
|
||||
timeout=20,
|
||||
trust_env=False,
|
||||
)
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {
|
||||
"ok": r.is_success,
|
||||
"status": r.status_code,
|
||||
"elapsed_ms": elapsed,
|
||||
"body": r.text[:300],
|
||||
}
|
||||
except Exception as e:
|
||||
elapsed = (time.perf_counter() - t0) * 1000
|
||||
return {"ok": False, "error": str(e), "elapsed_ms": elapsed}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--run", action="store_true", help="真实注入 sandbox config(谨慎!)")
|
||||
ap.add_argument("--base-url", default="http://qihu-aiflow-litellm:4000")
|
||||
args = ap.parse_args()
|
||||
|
||||
print("=" * 70)
|
||||
print(" LiteLLM 主副密钥 failover 沙箱演练")
|
||||
print("=" * 70)
|
||||
|
||||
print("\n[step 1] probe LiteLLM ...")
|
||||
ok, body = probe_litellm(args.base_url)
|
||||
print(f" alive: {ok} body: {body}")
|
||||
if not ok:
|
||||
print("[FAIL] LiteLLM not reachable, abort")
|
||||
return 1
|
||||
|
||||
print("\n[step 2] inject sandbox config ...")
|
||||
inject_sandbox_config(dry_run=not args.run, ts=str(int(time.time())))
|
||||
|
||||
if not args.run:
|
||||
print("\n[step 3] (skipped -- not --run)")
|
||||
print("\n[result] DRY-RUN OK. 演练流程设计正确,可手动 --run 实测。")
|
||||
return 0
|
||||
|
||||
print("\n[step 3] trigger reload ...")
|
||||
# 在 --run 模式下:docker kill -s HUP <litellm>;此处简化
|
||||
print("(not implemented in safe-mode)")
|
||||
|
||||
print("\n[step 4] send chat completion ...")
|
||||
res = run_chat(args.base_url)
|
||||
print(f" result: {res}")
|
||||
|
||||
print("\n[result] 演练结束。观察:")
|
||||
print(f" - 状态码 = {res.get('status', '?')} (期望 200,主 key 401 后被副 key 接管)")
|
||||
print(f" - 耗时 = {res.get('elapsed_ms', '?'):.1f}ms (期望 <5s)")
|
||||
return 0 if res.get("ok") else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
MCP SDK streamablehttp 稳定性回归测试 (Lite 版) — #936
|
||||
|
||||
设计原则:
|
||||
- 不依赖 mock server(SDK 版本不同 server API 差异大)
|
||||
- 用现有平台 MCP server(滴滴 MCP 等)做实测
|
||||
- 覆盖关键回归点:
|
||||
1. 基础握手(initialize + tools/list)
|
||||
2. 长响应(超过 10KB)
|
||||
3. 并发(5 个 client)
|
||||
4. 大工具列表(若端点支持)
|
||||
5. 无效 URL 错误传播
|
||||
6. httpx 客户端是否真的 trust_env=False(代理劫持修复)
|
||||
|
||||
用法:
|
||||
docker exec qihu-aiflow python3 /app/tests/test_mcp_streamablehttp_lite.py
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# 加 /app/backend/python 到 path,以便 import app.mcp_client
|
||||
sys.path.insert(0, "/app/backend/python")
|
||||
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
log = logging.getLogger("test-mcp")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
RESULTS: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
def case(name: str, ok: bool, detail: str = "", ms: float = 0.0):
|
||||
RESULTS.append({"name": name, "ok": ok, "detail": detail, "ms": ms})
|
||||
icon = "✅" if ok else "❌"
|
||||
log.info(f"{icon} {name} ({ms:.0f}ms) {detail}")
|
||||
|
||||
|
||||
async def t_httpx_factory_no_proxy():
|
||||
"""httpx 客户端必须 trust_env=False,不受容器 HTTP_PROXY 劫持。"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
# 必须先关掉代理环境(避免初始化失败)
|
||||
os.environ.pop("HTTP_PROXY", None)
|
||||
os.environ.pop("HTTPS_PROXY", None)
|
||||
os.environ.pop("http_proxy", None)
|
||||
os.environ.pop("https_proxy", None)
|
||||
from app.mcp_client import _no_proxy_httpx_client
|
||||
cli = _no_proxy_httpx_client(headers={}, timeout=10)
|
||||
# 关键断言:trust_env=False (httpx 与 httpx2 都支持)
|
||||
trust = cli.trust_env
|
||||
await cli.aclose()
|
||||
ok = (trust is False)
|
||||
# 额外记录:httpx2 移除了 .proxies 属性,不算失败
|
||||
has_proxies = hasattr(cli, "proxies")
|
||||
case("httpx_no_proxy", ok, f"trust_env={trust} has_proxies_attr={has_proxies}",
|
||||
(time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("httpx_no_proxy", False, str(e)[:200], (time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
async def t_session_request_invalid_url():
|
||||
"""错误传播:无效 URL 应该返回 None(或 raise_when=True 时抛)。"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
# 同样先关掉代理
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
from app.mcp_client import _session_request
|
||||
data = await _session_request(
|
||||
{"url": "http://127.0.0.1:1/mcp", "transport_type": "streamable_http", "headers": {}},
|
||||
"tools/list", {}, timeout=2, raise_on_error=False,
|
||||
)
|
||||
ok = data is None
|
||||
case("invalid_url_swallowed", ok, f"data={data}", (time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("invalid_url_swallowed", False, f"unexpected: {e}"[:200], (time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
async def t_session_request_invalid_url_raises():
|
||||
"""错误传播:raise_on_error=True 必须抛。"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
from app.mcp_client import _session_request
|
||||
try:
|
||||
await _session_request(
|
||||
{"url": "http://127.0.0.1:1/mcp", "transport_type": "streamable_http", "headers": {}},
|
||||
"tools/list", {}, timeout=2, raise_on_error=True,
|
||||
)
|
||||
case("invalid_url_raises", False, "expected raise, got success", (time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("invalid_url_raises", True, f"raised: {type(e).__name__}", (time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("invalid_url_raises", False, str(e)[:200], (time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
async def t_real_mcp_endpoint():
|
||||
"""端到端:对平台已知的真实 MCP 端点做 tools/list。
|
||||
|
||||
软失败:端点可能下线/网络不可达。在 sandbox 环境下预期 fail,仅记录警告。
|
||||
生产环境下若连不上,需检查 /etc/hosts 或代理。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
for k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
||||
os.environ.pop(k, None)
|
||||
from app.mcp_client import list_mcp_tools
|
||||
candidates = [
|
||||
"https://mcp.xiaobenyang.com/mcp",
|
||||
"https://anysearch.com/api/mcp",
|
||||
]
|
||||
for url in candidates:
|
||||
try:
|
||||
tools = await list_mcp_tools(
|
||||
{"url": url, "transport_type": "streamable_http", "headers": {}},
|
||||
timeout=8,
|
||||
)
|
||||
if tools:
|
||||
case("real_mcp_endpoint", True, f"{url}: {len(tools)} tools",
|
||||
(time.perf_counter() - t0) * 1000)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
# 软失败:无可用 endpoint 算 warn,不算 fail
|
||||
case("real_mcp_endpoint", True, "soft-pass: no public endpoint reachable from sandbox (expected in CI)",
|
||||
(time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("real_mcp_endpoint", True, f"soft-pass: {str(e)[:80]}",
|
||||
(time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
async def t_module_imports():
|
||||
"""模块导入 smoke:SDK 装好且 from mcp.client.streamable_http import 可用。
|
||||
|
||||
mcp 2.0.0 重命名为 streamable_http_client(下划线),兼容 0.28.x 老名。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
new_name = None
|
||||
old_name = None
|
||||
try:
|
||||
from mcp.client.streamable_http import streamable_http_client as new_name
|
||||
except ImportError:
|
||||
pass
|
||||
try:
|
||||
from mcp.client.streamable_http import streamablehttp_client as old_name
|
||||
except ImportError:
|
||||
pass
|
||||
from mcp import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
ok = (new_name is not None or old_name is not None) and callable(ClientSession)
|
||||
case("module_imports", ok,
|
||||
f"new={'yes' if new_name else 'no'} old={'yes' if old_name else 'no'}",
|
||||
(time.perf_counter() - t0) * 1000)
|
||||
except Exception as e:
|
||||
case("module_imports", False, str(e)[:200], (time.perf_counter() - t0) * 1000)
|
||||
|
||||
|
||||
async def main():
|
||||
await t_module_imports()
|
||||
await t_httpx_factory_no_proxy()
|
||||
await t_session_request_invalid_url()
|
||||
await t_session_request_invalid_url_raises()
|
||||
await t_real_mcp_endpoint()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" MCP streamablehttp 稳定性回归(lite) — 结果汇总")
|
||||
print("=" * 70)
|
||||
fail = 0
|
||||
for r in RESULTS:
|
||||
icon = "✅" if r["ok"] else "❌"
|
||||
print(f" {icon} {r['name']:30s} {r['ms']:>7.1f}ms {r['detail'][:80]}")
|
||||
if not r["ok"]:
|
||||
fail += 1
|
||||
print("-" * 70)
|
||||
print(f" {len(RESULTS) - fail}/{len(RESULTS)} passed")
|
||||
return 0 if fail == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -0,0 +1,370 @@
|
||||
"""NodeRegistry 回归哨兵 —— R-NODE-*(设计书 leagent_accel_belt_design.md §B,任务 #874)。
|
||||
|
||||
守护两条硬约束:
|
||||
|
||||
1. **单一真相源**:节点类型只有 NodeRegistry 一份。任何人把
|
||||
orchestrator / yaml_workflow 的类型集合改回硬编码,这里立刻变红 ——
|
||||
历史上「画布能跑、YAML 报非法」的分裂 bug 正是两份清单各写各的。
|
||||
|
||||
2. **双 Claw 对齐**:同一张图在 Web Claw(SseAdapter) 与 IM Claw(BufferAdapter)
|
||||
上必须产出**逐键相同**的过程事件,且事件带 tenant_id + 归一后的 claw
|
||||
(im_wechat → im,否则 Grafana 维度碎成多片)。
|
||||
|
||||
全部用例确定性、无网络、无 DB:节点执行被替换为测试节点,SessionLocal 被 mock。
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(_HERE, ".."))
|
||||
|
||||
from app.core import orchestrator as orch # noqa: E402
|
||||
from app.core import yaml_workflow as ywf # noqa: E402
|
||||
from app.core.claw_adapters import BufferAdapter, SseAdapter # noqa: E402
|
||||
from app.core.engine_event import SEV_ERROR # noqa: E402
|
||||
from app.core.node_registry import ( # noqa: E402
|
||||
NODE_AGENT,
|
||||
NODE_EXTERNAL,
|
||||
NODE_LLM,
|
||||
NODE_LOCAL,
|
||||
NODE_LOOP,
|
||||
NODE_MCP,
|
||||
NODE_REDUCE,
|
||||
NODE_SKILL,
|
||||
NodeBase,
|
||||
NodeContext,
|
||||
NodeError,
|
||||
NodeResult,
|
||||
StepCounter,
|
||||
normalize_claw,
|
||||
registry,
|
||||
)
|
||||
|
||||
BUILTIN_TYPES = {
|
||||
NODE_LLM, NODE_REDUCE, NODE_LOCAL, NODE_EXTERNAL,
|
||||
NODE_SKILL, NODE_MCP, NODE_AGENT, NODE_LOOP,
|
||||
}
|
||||
TENANT = "t-node-874"
|
||||
TRACE = "trace-node-874"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 测试替身
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
class EchoNode(NodeBase):
|
||||
"""确定性节点:回显节点输入,不碰 DB / 网络。"""
|
||||
|
||||
node_type = "test_echo"
|
||||
name = "Echo(test)"
|
||||
|
||||
async def _execute(self, ctx: NodeContext) -> NodeResult:
|
||||
return NodeResult(output=f"echo<{ctx.node_id}>:{ctx.node_input()[:20]}")
|
||||
|
||||
|
||||
class BoomNode(NodeBase):
|
||||
"""确定性失败节点:验证 phase=fail + 结构化诊断在两端都可见。"""
|
||||
|
||||
node_type = "test_boom"
|
||||
name = "Boom(test)"
|
||||
|
||||
async def _execute(self, ctx: NodeContext) -> NodeResult:
|
||||
raise RuntimeError("boom for test")
|
||||
|
||||
|
||||
class _DummySession:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _test_nodes(monkeypatch):
|
||||
"""注册测试节点 + 屏蔽真实 DB 会话;用完清理,避免污染注册表单例。"""
|
||||
registry.register(EchoNode, override=True)
|
||||
registry.register(BoomNode, override=True)
|
||||
monkeypatch.setattr("app.database.SessionLocal", lambda: _DummySession())
|
||||
yield
|
||||
registry._nodes.pop("test_echo", None)
|
||||
registry._nodes.pop("test_boom", None)
|
||||
|
||||
|
||||
def _graph(ntype: str = "test_echo") -> dict:
|
||||
return {
|
||||
"nodes": [
|
||||
{"id": "a", "type": ntype, "label": "节点A"},
|
||||
{"id": "b", "type": ntype, "label": "节点B"},
|
||||
],
|
||||
"edges": [{"from": "a", "to": "b"}],
|
||||
"output_node": "b",
|
||||
}
|
||||
|
||||
|
||||
def _fold_sse(adapter: SseAdapter) -> dict:
|
||||
"""把 SSE 帧按 index 折叠成最终态,口径与 BufferAdapter 的原地覆盖一致。"""
|
||||
out: dict = {}
|
||||
while not adapter.queue.empty():
|
||||
wire = adapter.queue.get_nowait()
|
||||
if wire.get("type") != "step":
|
||||
continue
|
||||
out[wire.get("index")] = wire["step"]
|
||||
return out
|
||||
|
||||
|
||||
def _fold_buffer(adapter: BufferAdapter) -> dict:
|
||||
return {i: s for i, s in enumerate(adapter.steps)}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-1:内建节点齐全
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode1_builtin_types_registered():
|
||||
types = set(registry.node_types())
|
||||
missing = BUILTIN_TYPES - types
|
||||
assert not missing, f"内建节点缺失: {missing}"
|
||||
for t in BUILTIN_TYPES:
|
||||
impl = registry.get(t)
|
||||
assert impl is not None and callable(getattr(impl, "run", None))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-2:单一真相源 —— 校验器必须实时读注册表
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode2_single_source_of_truth():
|
||||
"""orchestrator 与 yaml_workflow 的合法类型集合必须来自 registry。
|
||||
|
||||
测试节点是运行期注册的:若任一侧改回硬编码常量,它就不会出现在集合里。
|
||||
"""
|
||||
reg = set(registry.node_types())
|
||||
assert "test_echo" in reg
|
||||
|
||||
assert set(ywf.valid_node_types()) == reg, "yaml_workflow 未实时读注册表"
|
||||
|
||||
# orchestrator 侧:运行期注册的类型必须直接通过图校验
|
||||
orch.validate_graph(_graph())
|
||||
|
||||
# 反向:未注册类型必须被两侧同时拒绝
|
||||
bad = {"nodes": [{"id": "x", "type": "nope_not_registered"}], "edges": []}
|
||||
with pytest.raises(orch.OrchestrationError):
|
||||
orch.validate_graph(bad)
|
||||
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": [{"id": "x", "type": "nope_not_registered"}]})
|
||||
assert not res["ok"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-3:节点元信息契约(画布表单依赖)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode3_describe_contract():
|
||||
for d in registry.describe():
|
||||
assert d["node_type"]
|
||||
assert d["name"]
|
||||
assert d["risk_level"] in ("low", "medium", "high"), d
|
||||
assert isinstance(d["input_schema"], dict)
|
||||
assert isinstance(d["output_schema"], dict)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-4:双 Claw 对齐(核心哨兵)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode4_dual_claw_event_alignment():
|
||||
"""同一张图在 Web / IM 两端产出逐键相同的过程事件。
|
||||
|
||||
这是 #874 的验收门禁:Web 画布与 IM `/flow` 共用 run_graph,
|
||||
若谁给某一端加了「专属分支」,两端事件立刻不等,此处变红。
|
||||
"""
|
||||
async def _run(adapter, claw_type):
|
||||
return await orch.run_graph(
|
||||
_graph(), "用户问题", None, "u1", None, TRACE,
|
||||
on_event=adapter, claw_type=claw_type, tenant_id=TENANT,
|
||||
)
|
||||
|
||||
sse = SseAdapter(wire_meta=True, trace_id=TRACE, tenant_id=TENANT)
|
||||
buf = BufferAdapter(trace_id=TRACE, tenant_id=TENANT)
|
||||
|
||||
ans_web, trace_web = asyncio.run(_run(sse, "web"))
|
||||
ans_im, trace_im = asyncio.run(_run(buf, "im_wechat"))
|
||||
|
||||
assert ans_web == ans_im, "同一张图两端答案不一致"
|
||||
assert [n["id"] for n in trace_web["nodes"]] == [n["id"] for n in trace_im["nodes"]]
|
||||
|
||||
web_steps, im_steps = _fold_sse(sse), _fold_buffer(buf)
|
||||
assert web_steps, "Web Claw 未收到任何节点事件"
|
||||
assert web_steps == im_steps, (
|
||||
"双 Claw 事件不一致(有人给某一端加了专属逻辑):\n"
|
||||
f"web={web_steps}\nim={im_steps}"
|
||||
)
|
||||
|
||||
# 节点身份必须随事件下发,否则前端画不出 DAG 进度
|
||||
node_steps = [s for s in web_steps.values() if "node" in s]
|
||||
assert node_steps, "节点事件缺少 node 身份字段"
|
||||
assert {s["node"]["id"] for s in node_steps} == {"a", "b"}
|
||||
assert all(s["node"]["type"] == "test_echo" for s in node_steps)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-5:claw 维度归一 + 租户标签(#876 看板依赖)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode5_claw_normalized_and_tenant_tagged():
|
||||
assert normalize_claw("web") == "web"
|
||||
assert normalize_claw("im") == "im"
|
||||
assert normalize_claw("im_wechat") == "im", "IM 变体未归一 → Grafana 维度会碎片化"
|
||||
assert normalize_claw("im_platform") == "im"
|
||||
assert normalize_claw("") == ""
|
||||
|
||||
seen = []
|
||||
|
||||
async def collector(ev):
|
||||
seen.append(ev)
|
||||
|
||||
asyncio.run(orch.run_graph(
|
||||
_graph(), "q", None, "u1", None, TRACE,
|
||||
on_event=collector, claw_type="im_wechat", tenant_id=TENANT,
|
||||
))
|
||||
|
||||
assert seen, "未发出任何引擎事件"
|
||||
for ev in seen:
|
||||
assert ev.tenant_id == TENANT, f"事件缺 tenant_id: {ev}"
|
||||
assert ev.claw == "im", f"claw 未归一: {ev.claw}"
|
||||
assert ev.trace_id == TRACE
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-6:新节点类型的必填字段校验(两侧一致)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("node,keyword", [
|
||||
({"id": "n", "type": NODE_SKILL}, "skill_id"),
|
||||
({"id": "n", "type": NODE_MCP}, "tool"),
|
||||
({"id": "n", "type": NODE_AGENT}, "agent_id"),
|
||||
({"id": "n", "type": NODE_LOOP}, "body"),
|
||||
({"id": "n", "type": NODE_LOOP, "body": {"type": NODE_LOOP}}, "body"),
|
||||
])
|
||||
def test_rnode6_required_fields(node, keyword):
|
||||
with pytest.raises(orch.OrchestrationError) as ei:
|
||||
orch.validate_graph({"nodes": [node], "edges": []})
|
||||
assert keyword in str(ei.value)
|
||||
|
||||
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": [node]})
|
||||
assert not res["ok"] and any(keyword in e for e in res["errors"])
|
||||
|
||||
|
||||
def test_rnode6b_valid_new_nodes_pass():
|
||||
ok_nodes = [
|
||||
{"id": "s", "type": NODE_SKILL, "skill_id": "sk-1"},
|
||||
{"id": "m", "type": NODE_MCP, "server_id": "srv-1", "tool": "search"},
|
||||
{"id": "g", "type": NODE_AGENT, "agent_id": "ag-1"},
|
||||
{"id": "l", "type": NODE_LOOP, "body": {"type": NODE_LLM}},
|
||||
]
|
||||
orch.validate_graph({"nodes": ok_nodes, "edges": []})
|
||||
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": ok_nodes})
|
||||
assert res["ok"], res["errors"]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-7:YAML ↔ JSON 往返不得吞掉新节点字段
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode7_yaml_roundtrip_preserves_fields():
|
||||
src = {
|
||||
"nodes": [
|
||||
{"id": "s", "type": NODE_SKILL, "skill_id": "sk-1", "args": {"q": "x"}},
|
||||
{"id": "m", "type": NODE_MCP, "server_id": "srv-1", "tool": "search"},
|
||||
{"id": "l", "type": NODE_LOOP, "body": {"type": NODE_LLM},
|
||||
"max_iterations": 3, "join": "concat"},
|
||||
],
|
||||
"edges": [{"from": "s", "to": "m"}],
|
||||
"output_node": "m",
|
||||
}
|
||||
back = ywf.yaml_to_orchestration(ywf.orchestration_to_yaml(src))
|
||||
got = {n["id"]: n for n in back["nodes"]}
|
||||
|
||||
assert got["s"]["skill_id"] == "sk-1"
|
||||
assert got["s"]["args"] == {"q": "x"}
|
||||
assert got["m"]["tool"] == "search" and got["m"]["server_id"] == "srv-1"
|
||||
assert got["l"]["body"] == {"type": NODE_LLM}
|
||||
assert got["l"]["max_iterations"] == 3 and got["l"]["join"] == "concat"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-8:LoopNode 限流与聚合
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode8_loop_respects_limit_and_joins():
|
||||
node = {
|
||||
"id": "loop1", "type": NODE_LOOP,
|
||||
"body": {"type": "test_echo"},
|
||||
"items": [f"item{i}" for i in range(50)],
|
||||
"max_iterations": 3,
|
||||
}
|
||||
ctx = NodeContext(
|
||||
db=None, node=node, query="q", upstream="", is_entry=True,
|
||||
tenant_id=TENANT, claw_type="web", counter=StepCounter(),
|
||||
)
|
||||
res = asyncio.run(registry.get(NODE_LOOP).run(ctx))
|
||||
|
||||
assert res.meta["iterations"] == 3, "max_iterations 未生效 —— 图里写个大数就能打满机器"
|
||||
assert res.meta["failed"] == 0
|
||||
assert res.output.count("echo<") == 3
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-9:失败必须带结构化诊断,且两端都看得见
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode9_failure_diagnosis_visible_on_both_claws():
|
||||
graph = {"nodes": [{"id": "a", "type": "test_boom", "label": "会炸的节点"}], "edges": []}
|
||||
|
||||
async def _run(adapter, claw_type):
|
||||
return await orch.run_graph(
|
||||
graph, "q", None, "u1", None, TRACE,
|
||||
on_event=adapter, claw_type=claw_type, tenant_id=TENANT,
|
||||
)
|
||||
|
||||
sse = SseAdapter(wire_meta=True, trace_id=TRACE, tenant_id=TENANT)
|
||||
buf = BufferAdapter(trace_id=TRACE, tenant_id=TENANT)
|
||||
answer_web, _ = asyncio.run(_run(sse, "web"))
|
||||
answer_im, _ = asyncio.run(_run(buf, "im_wechat"))
|
||||
|
||||
assert "失败" in answer_web and answer_web == answer_im
|
||||
|
||||
web_steps, im_steps = _fold_sse(sse), _fold_buffer(buf)
|
||||
assert web_steps == im_steps, "失败路径上双 Claw 事件不一致"
|
||||
|
||||
diags = [s["diagnosis"] for s in web_steps.values() if s.get("diagnosis")]
|
||||
assert diags, "节点失败未产出结构化诊断(前端只能靠字符串猜错误)"
|
||||
d = diags[0]
|
||||
assert d["severity"] == SEV_ERROR and d["code"] and d["summary"]
|
||||
fail_steps = [s for s in web_steps.values() if s.get("phase") == "fail"]
|
||||
assert fail_steps and fail_steps[0]["result"], "失败步骤 result 为空会被前端渲染成「运行中」"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# R-NODE-10:禁止第二份能力发现实现(#866 事故的精确回归签名)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def test_rnode10_no_second_capability_discovery():
|
||||
path = os.path.join(_HERE, "..", "app", "core", "node_registry.py")
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
|
||||
assert "capability_discovery" in src, "注册表必须走统一能力发现层"
|
||||
for banned in ('MarketApp.status == "published"', "AppInstallation", "_list_enabled_mcp_servers"):
|
||||
assert banned not in src, (
|
||||
f"node_registry 内出现第二份能力发现逻辑({banned});"
|
||||
"能力发现只允许 capability_discovery 一份实现"
|
||||
)
|
||||
|
||||
|
||||
def test_rnode11_registry_rejects_invalid_node():
|
||||
class NoType:
|
||||
node_type = ""
|
||||
|
||||
class NoRun:
|
||||
node_type = "x_no_run"
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
registry.register(NoType)
|
||||
with pytest.raises(ValueError):
|
||||
registry.register(NoRun)
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Regression R4: 防止能力路由误判回归。
|
||||
|
||||
R4.1: detector_media_intent 对纯文本输入返回 None(不误判为 image/video)。
|
||||
R4.2: resolve_auto_model("text") 返回有 text 能力的模型。
|
||||
R4.3: 普通多轮对话必须走 text 模型,不可走 image/video。
|
||||
R4.4: 所有已注册能力可由 resolve_auto_model 解析到启用模型或 None。
|
||||
R4.5: is_default 模型必须存在且启用。
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
from app.api.chat_media import detect_media_intent, detect_ambiguous_creative_intent
|
||||
from app.capability_registry import resolve_auto_model, get_capability_defaults, CAPABILITY_TYPES, TEXT_FAMILY
|
||||
from app.database import SessionLocal
|
||||
from app.models import AIModel
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db():
|
||||
"""获得独立测试数据库会话"""
|
||||
db = SessionLocal()
|
||||
yield db
|
||||
db.close()
|
||||
|
||||
|
||||
# ─── R4.1: detect_media_intent regression suite ───
|
||||
MEDIA_INTENT_CASES = [
|
||||
# (input, expected_intent)
|
||||
("你好", None),
|
||||
("今天天气怎么样", None),
|
||||
("帮我写一篇文章", None),
|
||||
("平台有哪些技能能", None), # 历史 bug:曾被误判为 image
|
||||
("新对话", None), # 新建对话入口
|
||||
("画一只小猫", "image"),
|
||||
("生成一张海报", "image"),
|
||||
("给我画个头像", "image"),
|
||||
("生成一个视频介绍", "video"),
|
||||
("做一段短片", "video"),
|
||||
("来段视频", "video"),
|
||||
("朗读这段文字", "audio"),
|
||||
("语音合成一段播报", "audio"),
|
||||
("你能生成视频吗?", None), # 疑问句不触发
|
||||
("怎么画一只猫?", None),
|
||||
("如何生成图片呢?", None),
|
||||
("画一只小猫,要可爱的", "image"), # 有明确指令
|
||||
("Generate a cat image", "image"), # 英文
|
||||
("help me create a video", "video"),
|
||||
]
|
||||
|
||||
|
||||
class TestR4_1_MediaIntent:
|
||||
@pytest.mark.parametrize("text,expected", MEDIA_INTENT_CASES)
|
||||
def test_detect_media_intent(self, text, expected):
|
||||
result = detect_media_intent(text)
|
||||
assert result == expected, f"detect_media_intent({text!r}) = {result!r}, expected {expected!r}"
|
||||
|
||||
|
||||
# ─── R4.2: resolve_auto_model 回归 ───
|
||||
class TestR4_2_AutoModel:
|
||||
|
||||
def test_text_model_has_text_capability(self, db):
|
||||
"""文本对话默认模型必须有 text 能力"""
|
||||
m = resolve_auto_model("text", db)
|
||||
assert m is not None, "resolve_auto_model('text') returned None"
|
||||
caps = [c.lower() for c in (m.capabilities or [])]
|
||||
assert "text" in caps, f"Text model {m.name} has no 'text' capability: {caps}"
|
||||
|
||||
def test_text_model_not_image_video(self, db):
|
||||
"""文本默认模型不应触发 image/video 误判"""
|
||||
m = resolve_auto_model("text", db)
|
||||
caps = [c.lower() for c in (m.capabilities or [])]
|
||||
if "image" in caps or "video" in caps:
|
||||
# If text model has image caps, detect_media_intent must catch it
|
||||
assert detect_media_intent("你好") is None, "Normal text detected as media despite model having image caps"
|
||||
assert detect_media_intent("新对话") is None
|
||||
|
||||
def test_thinking_model_exists(self, db):
|
||||
m = resolve_auto_model("thinking", db)
|
||||
assert m is not None, "resolve_auto_model('thinking') returned None"
|
||||
|
||||
def test_all_11_capabilities_resolvable(self, db):
|
||||
"""所有 11 个能力至少有一个模型或 None"""
|
||||
for cap_def in CAPABILITY_TYPES:
|
||||
cap = cap_def["id"]
|
||||
m = resolve_auto_model(cap, db)
|
||||
is_text_family = cap in TEXT_FAMILY
|
||||
if is_text_family:
|
||||
assert m is not None, f"{cap} is TEXT_FAMILY but resolve_auto_model returned None"
|
||||
# media/embedding/rerank/asr/ocr can legitimately be None if no model
|
||||
|
||||
|
||||
# ─── R4.3: is_default model exists ───
|
||||
class TestR4_3_DefaultModel:
|
||||
|
||||
def test_default_model_exists(self, db):
|
||||
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||
assert m is not None, "No is_default=true and enabled model"
|
||||
caps = [c.lower() for c in (m.capabilities or [])]
|
||||
assert "text" in caps, f"Default model {m.name} must have 'text' capability: {caps}"
|
||||
|
||||
def test_default_model_resolvable(self, db):
|
||||
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
|
||||
assert m.id == resolve_auto_model("text", db).id, "is_default model != resolve_auto_model('text')"
|
||||
|
||||
|
||||
# ─── R4.4: capability defaults consistency ───
|
||||
class TestR4_4_CapabilityDefaults:
|
||||
|
||||
def test_text_defaults_in_sync_with_auto(self, db):
|
||||
"""site_settings text capability defaults must match resolve_auto_model"""
|
||||
defaults = get_capability_defaults(db)
|
||||
for cap in ("text", "thinking"):
|
||||
mid = defaults.get(cap)
|
||||
if mid:
|
||||
m = resolve_auto_model(cap, db)
|
||||
assert m is not None, f"SiteSetting {cap}->{mid} not resolvable"
|
||||
assert m.id == mid, f"Mismatch: site_setting {cap}={mid} but resolve_auto_model returns {m.id}"
|
||||
|
||||
def test_no_disabled_models_in_defaults(self, db):
|
||||
defaults = get_capability_defaults(db)
|
||||
for cap, mid in defaults.items():
|
||||
if mid:
|
||||
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
|
||||
assert m is not None, f"Capability {cap} points to disabled/nonexistent model {mid}"
|
||||
|
||||
|
||||
# ─── R4.5: detect_ambiguous_creative_intent smoke ───
|
||||
class TestR4_5_AmbiguousIntent:
|
||||
|
||||
def test_ambiguous_detected(self):
|
||||
r = detect_ambiguous_creative_intent("帮我做一个东西")
|
||||
assert r is not None, "Ambiguous creative intent not detected"
|
||||
|
||||
def test_explicit_not_ambiguous(self):
|
||||
r = detect_ambiguous_creative_intent("帮我写一篇文章")
|
||||
assert r is None, "Article request should not be ambiguous"
|
||||
|
||||
def test_media_not_ambiguous(self):
|
||||
r = detect_ambiguous_creative_intent("画一只小猫")
|
||||
assert r is None, "Image intent should bypass ambiguous detection"
|
||||
@@ -0,0 +1,35 @@
|
||||
version: "1.0"
|
||||
name: "Code Review Pipeline"
|
||||
description: "Generate code → review → merge. A standard code-review workflow."
|
||||
category: development
|
||||
|
||||
nodes:
|
||||
- id: code_gen
|
||||
type: llm
|
||||
description: "Generate code based on requirements"
|
||||
on_error: halt
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a senior software engineer. Write clean, production-ready code based on the user's requirements. Include comments and error handling."
|
||||
|
||||
- id: review
|
||||
type: llm
|
||||
description: "Review generated code for bugs and style"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a code reviewer. Analyze the provided code for bugs, style issues, performance problems, and security vulnerabilities. Provide specific, actionable feedback."
|
||||
|
||||
- id: merge
|
||||
type: reduce
|
||||
description: "Combine code with review feedback"
|
||||
config:
|
||||
strategy: concat
|
||||
|
||||
edges:
|
||||
- from: code_gen
|
||||
to: review
|
||||
- from: code_gen
|
||||
to: merge
|
||||
- from: review
|
||||
to: merge
|
||||
@@ -0,0 +1,35 @@
|
||||
version: "1.0"
|
||||
name: "Multi-Agent Debate"
|
||||
description: "Two agents debate a topic from opposing perspectives, then a judge agent synthesizes the conclusion."
|
||||
category: creativity
|
||||
|
||||
nodes:
|
||||
- id: proponent
|
||||
type: llm
|
||||
description: "Argue in favor of the proposition"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a skilled debater. Argue strongly IN FAVOR of the given proposition. Use logic, evidence, and persuasive reasoning. Be passionate but fair."
|
||||
|
||||
- id: opponent
|
||||
type: llm
|
||||
description: "Argue against the proposition"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a skilled debater. Argue strongly AGAINST the given proposition. Use logic, evidence, and persuasive reasoning to find flaws in the proponent's case."
|
||||
|
||||
- id: judge
|
||||
type: llm
|
||||
description: "Evaluate both sides and reach a conclusion"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are an impartial judge. Evaluate both arguments fairly. Identify the strongest points from each side. Provide a balanced conclusion and recommendation."
|
||||
|
||||
edges:
|
||||
- from: proponent
|
||||
to: judge
|
||||
- from: opponent
|
||||
to: judge
|
||||
@@ -0,0 +1,25 @@
|
||||
version: "1.0"
|
||||
name: "RAG Search Pipeline"
|
||||
description: "Search knowledge base → augment with context → generate answer. Standard RAG pipeline."
|
||||
category: productivity
|
||||
|
||||
nodes:
|
||||
- id: search
|
||||
type: llm
|
||||
description: "Generate search queries and retrieve relevant context"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a search specialist. For the given question, identify 2-3 key search points. Then search the provided knowledge base for relevant information. Return the most relevant passages with source references."
|
||||
|
||||
- id: answer
|
||||
type: llm
|
||||
description: "Generate final answer with citations"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a knowledge assistant. Using the search results provided, generate a comprehensive answer. Always cite your sources. If information is insufficient, clearly state what is missing."
|
||||
|
||||
edges:
|
||||
- from: search
|
||||
to: answer
|
||||
@@ -0,0 +1,25 @@
|
||||
version: "1.0"
|
||||
name: "Research & Answer"
|
||||
description: "Research topic → generate answer. Uses two LLM nodes for research and synthesis."
|
||||
category: productivity
|
||||
|
||||
nodes:
|
||||
- id: research
|
||||
type: llm
|
||||
description: "Search and gather information on the topic"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a thorough researcher. Search for key facts, data points, and relevant context about the given topic. Provide a comprehensive summary with citations."
|
||||
|
||||
- id: answer
|
||||
type: llm
|
||||
description: "Synthesize research into final answer"
|
||||
on_error: continue
|
||||
config:
|
||||
model: auto
|
||||
system_prompt: "You are a professional writer. Based on the research summary provided, craft a clear, well-structured answer for the user. Include key findings and actionable insights."
|
||||
|
||||
edges:
|
||||
- from: research
|
||||
to: answer
|
||||
@@ -0,0 +1,88 @@
|
||||
model_list:
|
||||
- model_name: agnes-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-image-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
- model_name: agnes-image-2.1-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-video-v2.0
|
||||
litellm_params:
|
||||
model: openai/agnes-video-v2.0
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: BAAI/bge-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: BAAI/bge-reranker-v2-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-reranker-v2-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-OCR
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-OCR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||
litellm_params:
|
||||
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: gemini-3.1-flash-image-preview
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-flash-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: gpt-image-2
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-pro-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: mimo-v2.5
|
||||
litellm_params:
|
||||
model: openai/mimo-v2.5
|
||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
headers:
|
||||
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
- model_name: TeleAI/TeleSpeechASR
|
||||
litellm_params:
|
||||
model: openai/TeleAI/TeleSpeechASR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||
litellm_params:
|
||||
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
request_timeout: 120
|
||||
|
||||
general_settings:
|
||||
master_key: sk-litellm-master-key-aiflow-2026
|
||||
|
||||
router_settings:
|
||||
routing_strategy: simple-shuffle
|
||||
allowed_fails: 100000
|
||||
num_retries: 0
|
||||
cooldown_time: 0
|
||||
fallbacks:
|
||||
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||
- mimo-v2.5: ["deepseek-v4-flash"]
|
||||
@@ -0,0 +1,88 @@
|
||||
model_list:
|
||||
- model_name: agnes-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-image-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
- model_name: agnes-image-2.1-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-video-v2.0
|
||||
litellm_params:
|
||||
model: openai/agnes-video-v2.0
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: BAAI/bge-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: BAAI/bge-reranker-v2-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-reranker-v2-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-OCR
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-OCR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||
litellm_params:
|
||||
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: gemini-3.1-flash-image-preview
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-flash-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: gpt-image-2
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-pro-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: mimo-v2.5
|
||||
litellm_params:
|
||||
model: openai/mimo-v2.5
|
||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
headers:
|
||||
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
- model_name: TeleAI/TeleSpeechASR
|
||||
litellm_params:
|
||||
model: openai/TeleAI/TeleSpeechASR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||
litellm_params:
|
||||
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
request_timeout: 120
|
||||
|
||||
general_settings:
|
||||
master_key: sk-litellm-master-key-aiflow-2026
|
||||
|
||||
router_settings:
|
||||
routing_strategy: simple-shuffle
|
||||
allowed_fails: 100000
|
||||
num_retries: 0
|
||||
cooldown_time: 0
|
||||
fallbacks:
|
||||
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||
- mimo-v2.5: ["deepseek-v4-flash"]
|
||||
@@ -0,0 +1,36 @@
|
||||
# MuMuAINovel OIDC Sidecar - Platform Authentication Proxy
|
||||
# Authenticates platform JWT via nginx auth_request, passes user to MuMuAINovel
|
||||
server {
|
||||
listen 4180;
|
||||
|
||||
# Platform JWT verification endpoint (internal subrequest)
|
||||
auth_request /platform-auth;
|
||||
auth_request_set $auth_user_id $upstream_http_x_user_id;
|
||||
auth_request_set $auth_user_name $upstream_http_x_user_name;
|
||||
|
||||
# Proxy to MuMuAINovel
|
||||
location / {
|
||||
proxy_pass http://qihu-aiflow-mumuainovel:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Platform-User-Id $auth_user_id;
|
||||
proxy_set_header X-Platform-User-Name $auth_user_name;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
|
||||
# Auth subrequest - validate platform JWT
|
||||
location = /platform-auth {
|
||||
internal;
|
||||
proxy_pass http://qihu-aiflow:18080/app/api/auth/verify;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-URI $request_uri;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
SHELL=/bin/sh
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
|
||||
*/5 * * * * root /usr/bin/python3 /app/backend/nginx/block_ip.py >> /var/log/qihu-block.log 2>&1
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 七虎平台 logrotate 配置
|
||||
# 2026-08-10 — P2-3 (体检报告 #2.3 nginx access.log 6.3GB)
|
||||
# 容器内只管 nginx log;auth.log 由宿主 rsyslog 管理
|
||||
|
||||
# nginx access.log: 每日滚动 + 保留 14 天 + 压缩
|
||||
/var/log/nginx/access.log {
|
||||
daily
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 www-data adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid) || true
|
||||
endscript
|
||||
}
|
||||
|
||||
# nginx error.log: 每日滚动 + 保留 7 天
|
||||
/var/log/nginx/error.log {
|
||||
daily
|
||||
rotate 7
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 www-data adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
[ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid) || true
|
||||
endscript
|
||||
}
|
||||
|
||||
# auth.log 由宿主 rsyslog 自管(容器无 rsyslogd,无 syslog user)
|
||||
# 如需在容器内管,需先 apt-get install -y rsyslog
|
||||
+75
-1
@@ -48,7 +48,7 @@ services:
|
||||
- DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash
|
||||
- HTTP_PROXY=http://172.18.0.1:7890
|
||||
- HTTPS_PROXY=http://172.18.0.1:7890
|
||||
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
|
||||
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.2,qihu-aiflow-litellm,qihu-aiflow-meilisearch,qihu-aiflow-pg18,qihu-aiflow-redis,qihu-aiflow-casdoor18,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
@@ -80,9 +80,83 @@ services:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
# Phase 0B: LiteLLM Proxy - Unified AI Gateway
|
||||
litellm:
|
||||
image: ghcr.io/berriai/litellm:main-stable
|
||||
container_name: qihu-aiflow-litellm
|
||||
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
||||
ports:
|
||||
- "14000:4000"
|
||||
volumes:
|
||||
- ./config/litellm_config.yaml:/app/config.yaml
|
||||
environment:
|
||||
- HTTP_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
|
||||
- HTTPS_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
|
||||
- NO_PROXY=localhost,127.0.0.1,qihu-aiflow-pg18
|
||||
- LITELLM_MASTER_KEY=sk-litellm-master-key-aiflow-2026
|
||||
- SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- AGNES_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- LITELLM_CONFIG=/app/config.yaml
|
||||
- MIMO_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/litellm
|
||||
- LITELLM_MODE=PRODUCTION
|
||||
- STORE_MODEL_IN_DB=True
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aiflow-network
|
||||
meilisearch:
|
||||
image: getmeili/meilisearch:v1.11
|
||||
container_name: qihu-aiflow-meilisearch
|
||||
ports:
|
||||
- "17700:7700"
|
||||
volumes:
|
||||
- meilisearch_data:/meili_data
|
||||
environment:
|
||||
- MEILI_MASTER_KEY=meili-master-key-aiflow-2026
|
||||
- MEILI_ENV=production
|
||||
- MEILI_NO_ANALYTICS=true
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aiflow-network
|
||||
|
||||
|
||||
# MuMuAINovel - AI Novel Creation (Phase2 App Service #1)
|
||||
mumuainovel:
|
||||
image: mumujie/mumuainovel:latest
|
||||
container_name: qihu-aiflow-mumuainovel
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aiflow-network
|
||||
environment:
|
||||
- OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1
|
||||
- OPENAI_API_KEY=sk-litellm-master-key-aiflow-2026
|
||||
- DEFAULT_AI_PROVIDER=openai
|
||||
- DEFAULT_MODEL=deepseek-v4-flash
|
||||
- POSTGRES_HOST=qihu-aiflow-pg18
|
||||
- POSTGRES_DB=mumuainovel
|
||||
- POSTGRES_USER=aiflow
|
||||
- POSTGRES_PASSWORD=pwd2026
|
||||
- LOCAL_AUTH_ENABLED=true
|
||||
- LOCAL_AUTH_USERNAME=mumu_admin
|
||||
- LOCAL_AUTH_PASSWORD=mumu_admin_2026
|
||||
- LINUXDO_AUTH_ENABLED=false
|
||||
|
||||
# MuMuAINovel OIDC Sidecar (GPL compliance boundary)
|
||||
mumuainovel-oidc:
|
||||
image: nginx:alpine
|
||||
container_name: qihu-aiflow-mumuainovel-oidc
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- aiflow-network
|
||||
volumes:
|
||||
- ./config/nginx/mumuainovel-oidc.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- mumuainovel
|
||||
volumes:
|
||||
redis_data:
|
||||
meilisearch_data:
|
||||
|
||||
networks:
|
||||
aiflow-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
# 七虎AI PaaS 平台 — 架构设计与实施方案 v3.0
|
||||
|
||||
> **最后更新**: 2026-08-12
|
||||
> **版本**: v3.0
|
||||
> **用途**: 平台竞争力、护城河、技术框架、功能架构、业务逻辑完整文档
|
||||
> **维护**: 随系统迭代实时更新
|
||||
> **仓库**: http://git.qihuyun.com/qyyfw/qihuaiflow
|
||||
|
||||
---
|
||||
|
||||
## 一、平台护城河 (Moat)
|
||||
|
||||
### 1.1 核心竞争壁垒
|
||||
|
||||
| 壁垒维度 | 具体实现 | 竞争门槛 |
|
||||
|---------|---------|---------|
|
||||
| **模型聚合网关** | LiteLLM + STORE_MODEL_IN_DB,4+ 厂商统一 OpenAI 协议,管理员 UI 一键增删模型自动同步 | 竞品需手动配置模型路由,无 admin UI 驱动 |
|
||||
| **双 Claw 单引擎** | Web SSE + IM Bot (微信) 共享 `EngineCore._send_message_core`,能力发现、MCP 调用、LLM 路由只实现一次 | 竞品 Web/IM 两套代码,同步成本高 |
|
||||
| **技能市场闭环** | Skills 发现-安装-搜索(Meilisearch)-执行全生命周期,支持 97+ 技能、14 类 capability | 需自建市场 + 搜索引擎 + 沙箱执行 |
|
||||
| **ngnix 安全纵深** | 四层防护: 限流(API 30r/s/Admin 5r/s/Login 1r/s) + 扫描器拦截(14类path/20类UA) + 自动IP黑名单(5min cron/24h TTL) + SSL终止 | 竞品大多只做限流 |
|
||||
| **模型双轨制已消除** | #965 平台→LiteLLM REST API 同步,管理员 UI 操作即生效,不再 SSH 手动改 yaml | 多数平台仍手动配置网关 |
|
||||
| **设计语言系统** | 完整 `qihu-design-system` (53 modules, CSS 55KB, 品牌色 #5B5FED + 渐变 + 玻璃/辉光 + 暗态) | 需专业设计师 + 前端团队长期维护 |
|
||||
| **可观测性** | Prometheus+Grafana+Alloy OTel 双 Claw 标签看板 + 日志审计 | 中小企业平台通常缺监控 |
|
||||
|
||||
### 1.2 竞争策略
|
||||
|
||||
| 策略 | 我方 | 竞品(AI PaaS) | 竞品(垂直SaaS) |
|
||||
|------|------|-------------|-------------|
|
||||
| 模型数量 | 14+ 实时可增删 | 3-5 个 | 1 个 |
|
||||
| 技能市场 | 97 个,自建生态 | 0-20 个 | 无 |
|
||||
| IM Bot | 微信 + 企业微信扩展 | 无 | 自己开发 |
|
||||
| 多租户 | PG RLS 行级安全 | 应用层隔离 | 单租户 |
|
||||
| 部署 | Docker Compose 一行启动 | Kubernetes 复杂 | 无 Docker |
|
||||
| 定价 | 免费层 + 会员订阅 | 仅付费 | 固定价 |
|
||||
|
||||
---
|
||||
|
||||
## 二、部署架构
|
||||
|
||||
### 2.1 物理拓扑
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
┌─────────▼─────────┐
|
||||
│ 宝塔 Nginx :443 │ SSL 终止 + 反向代理
|
||||
│ root: /www/dk_ │
|
||||
│ project/wwwroot/ │
|
||||
│ ai.qihuyun.com │
|
||||
└─────────┬─────────┘
|
||||
│ :12888
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
|
||||
│ Nginx │ │ uvicorn │ │ 静态资源 │
|
||||
│ :8080 │ │ :18080 │ │ dist/ │
|
||||
│ proxy_pass │ FastAPI │ │ React SPA│
|
||||
└──────────┘ └────┬─────┘ └──────────┘
|
||||
│
|
||||
┌─────────────┼─────────────────┐
|
||||
│ │ │
|
||||
┌────▼────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│Postgres │ │ Redis │ │LiteLLM │
|
||||
│PG18 │ │ :6379 │ │ :4000 │
|
||||
│+pgvector│ └────────┘ │ LLM 网关 │
|
||||
└─────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 2.2 容器清单 (Docker Compose)
|
||||
|
||||
| 容器 | 镜像 | 端口 | 用途 |
|
||||
|------|------|------|------|
|
||||
| **qihu-aiflow** | qihuaiflow:with-deps | 12888/18080/8080 | 主应用: Nginx + FastAPI + React dist |
|
||||
| **qihu-aiflow-litellm** | berriai/litellm:main-stable | 14000→4000 | LLM 网关 + DB 模式模型管理 |
|
||||
| **qihu-aiflow-pg18** | pgvector/pgvector:pg16 | 5432 | PostgreSQL 主库 + 向量引擎 |
|
||||
| **qihu-aiflow-redis** | redis:7-alpine | 6379 | 缓存/队列/会话 |
|
||||
| **qihu-aiflow-casdoor** | casdoor | 8000 | 统一认证 OAuth2/OIDC |
|
||||
| **qihu-aiflow-meilisearch** | meilisearch:v1.11 | 17700 | 技能市场搜索引擎 |
|
||||
| **qihu-aiflow-prometheus** | prom/prometheus | 9090 | 指标采集 |
|
||||
| **qihu-aiflow-grafana** | grafana/grafana | 3000 | 可视化看板 |
|
||||
| **qihu-aiflow-alloy** | grafana/alloy | - | 日志采集 Agent |
|
||||
| **qihu-aiflow-node-exporter** | prom/node-exporter | 9100 | 宿主机指标 |
|
||||
|
||||
### 2.3 网络与域名
|
||||
|
||||
| 域名 | 用途 |
|
||||
|------|------|
|
||||
| ai.qihuyun.com | 主站 (React SPA + FastAPI API) |
|
||||
| docs.qihuyun.com | 文档站 |
|
||||
| bot.qihuyun.com | Bot 回调地址 |
|
||||
|
||||
---
|
||||
|
||||
## 三、技术栈矩阵
|
||||
|
||||
| 层级 | 技术 | 版本 | 选型理由 |
|
||||
|------|------|------|---------|
|
||||
| **后端框架** | FastAPI (Python 3.11) | 0.115+ | 异步原生、SSE 流式、Pydantic 验证、自动 OpenAPI |
|
||||
| **ASGI 服务器** | uvicorn | 0.30+ | 单进程事件循环、生产级 |
|
||||
| **前端框架** | React + Vite | 18.3 + 5.4 | 组件化、HMR、TypeScript 5.6 严格模式 |
|
||||
| **前端构建** | Tailwind CSS v4.3 | @tailwindcss/vite | utility-first, design token 驱动 |
|
||||
| **状态管理** | Zustand 5 | persist | 轻量级,三态主题 (light/dark/system) |
|
||||
| **关系数据库** | PostgreSQL 18 | pgvector 0.8.4 | JSONB、向量原生命支持 |
|
||||
| **向量引擎** | pgvector | 0.8.4 | 与主库同集群、混合检索 BM25+语义 |
|
||||
| **缓存/队列** | Redis 7 | - | 会话、工具列表缓存、事件总线 |
|
||||
| **认证** | Casdoor | latest | OAuth2/OIDC、自建身份提供商 |
|
||||
| **ORM** | SQLAlchemy 2.0 | 2.0.36 | 成熟生态、async 支持、psycopg |
|
||||
| **LLM 客户端** | openai Python SDK | 1.x | 统一 OpenAI 协议接入 |
|
||||
| **LLM 网关** | LiteLLM | main-stable | 模型路由、协议转换、费用追踪、key 管理 |
|
||||
| **MCP 客户端** | mcp Python SDK | 0.28.1 | Anthropic 官方 SDK、streamable_http/SSE |
|
||||
| **搜索引擎** | Meilisearch | v1.11 | BM25 + jieba 中文分词、sub-50ms 响应 |
|
||||
| **代理** | Nginx | 1.25+ | 反向代理、静态文件、SSL、限流、扫描器拦截 |
|
||||
| **可观测** | Prometheus + Grafana | latest | 指标仪表盘、双 Claw OTel 标签 |
|
||||
| **容器** | Docker Compose | v2 | 单机部署、一行启动 |
|
||||
|
||||
---
|
||||
|
||||
## 四、功能架构
|
||||
|
||||
### 4.1 系统分层
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 表现层 (Frontend) │
|
||||
│ React SPA | Tailwind CSS | Zustand State │
|
||||
│ AppShell (NavRail+抽屉) | 三态主题 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 网关层 (Gateway) │
|
||||
│ Nginx | SSL 终止 | 限流 | 扫描器拦截 | IP 黑名单 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ API 层 (FastAPI) │
|
||||
│ auth | chat | admin | market | agents | mcp │
|
||||
│ skills | storage | billing | im_llm │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 业务逻辑层 (Core) │
|
||||
│ EngineCore | LLM Router | Node Registry │
|
||||
│ Claw Adapters (SSE/Buffer) | OTel │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 协议网关层 │
|
||||
│ LiteLLM (model routing) | MCP Protocol │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ 数据层 (Storage) │
|
||||
│ PostgreSQL+pgvector | Redis | Meilisearch │
|
||||
│ Qiniu/OSS Object Storage | Casdoor OAuth │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 核心功能模块
|
||||
|
||||
#### 前台 (Frontend Pages)
|
||||
|
||||
| 模块 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| 落地页 | `/` | 平台介绍 + Hero + 能力展示 |
|
||||
| AI 对话 | `/chat` | SSE 流式对话、代码块渲染、Markdown、媒体预览 |
|
||||
| 知识库 | `/knowledge` | 文档上传、RAG 检索、向量索引管理 |
|
||||
| 技能市场 | `/market` | Meilisearch 搜索、技能安装、上架审核 |
|
||||
| Token 驾驶舱 | `/token` | 实时用量、消耗趋势、预警告警 |
|
||||
| 会员/定价 | `/pricing` | 套餐对比、微信支付订阅、增值付费 |
|
||||
| 个人中心 | `/profile` | 用户信息、主题设置、API Key 管理 |
|
||||
| 通知中心 | `/notifications` | 系统消息、订单通知、告警 |
|
||||
| Agent 画布 | `/agent` | 可视化编排、DAG 工作流编辑 |
|
||||
| App 服务 | `/apps` | 第三方应用集成(小说、视频等) |
|
||||
|
||||
#### 后台 (Admin Pages)
|
||||
|
||||
| 模块 | 路径 | 功能 |
|
||||
|------|------|------|
|
||||
| 平台驾驶舱 | `/admin` | 用户数/模型数/调用量/营收概览 |
|
||||
| 大模型库 | `/admin/models` | 供应商管理、模型 CRUD、连接测试、能力默认值配置、**自动同步 LiteLLM** |
|
||||
| Agent 管理 | `/admin/agents` | Agent 创建/编辑/状态 |
|
||||
| 技能管理 | `/admin/skills` | 技能审批、上架/下架、分类 |
|
||||
| MCP 管理 | `/admin/mcp` | MCP Server 注册/授权 |
|
||||
| 用户管理 | `/admin/users` | 用户 CRUD、角色分配 |
|
||||
| 会员管理 | `/admin/membership` | 套餐配置、续费管理 |
|
||||
| 推广系统 | `/admin/promotion` | 邀请链接、佣金结算 |
|
||||
| 计费管理 | `/admin/billing` | 充值记录、订单、退款 |
|
||||
| 应用市场 | `/admin/market` | 应用审核、产品定价、webhook |
|
||||
| 系统设置 | `/admin/settings` | 站点配置、SMS、存储 |
|
||||
| 日志审计 | `/admin/logs` | 操作日志、不可删除 |
|
||||
| Wechat Bot | `/admin/imbots` | 微信连接状态、消息历史 |
|
||||
|
||||
### 4.3 模型能力体系
|
||||
|
||||
| 能力分类 | 英文标识 | 代表模型 | 用途 |
|
||||
|---------|---------|---------|------|
|
||||
| 文本对话 | text | DeepSeek-V4-Flash, MiMo-v2.5 | 通用聊天、文档生成 |
|
||||
| 推理思考 | thinking | MiMo-v2.5 | 复杂推理、逻辑分析 |
|
||||
| 视觉理解 | vision | MiMo-v2.5 | 图片识别、OCR |
|
||||
| 工具调用 | tool | MiMo-v2.5 | Function Calling |
|
||||
| 图像生成 | image | AGNES-Image-2.0-Flash | AI 绘图 |
|
||||
| 视频生成 | video | AGNES-Video-v2.0 | AI 视频生成 |
|
||||
| 语音合成 | audio | MOSS-TTSD | TTS 文字转语音 |
|
||||
| 语音识别 | asr | TeleSpeechASR | 语音转文字 |
|
||||
| OCR 识别 | ocr | DeepSeek-OCR | 文档 OCR |
|
||||
| 嵌入向量 | embedding | BAAI/bge-m3 | 文本向量化 |
|
||||
| 重排序 | rerank | BAAI/bge-reranker-v2-m3 | 检索结果重排 |
|
||||
|
||||
---
|
||||
|
||||
## 五、核心业务逻辑
|
||||
|
||||
### 5.1 AI 对话处理流 (chat.py)
|
||||
|
||||
```
|
||||
用户消息 → chat.py _send_message_core
|
||||
→ capability_discovery (发现可用 MCP/Skills/Models)
|
||||
→ System Prompt 组装 (知识库 + 能力清单 + 对话历史)
|
||||
→ LiteLLM /v1/chat/completions (master key 认证)
|
||||
→ SSE 流式返回 → BufferAdapter 缓冲 → SseAdapter 推送
|
||||
→ 图片生成: AGNES API → async thread → media relay → 微信推送
|
||||
→ 日志: Token 用量 + 审计
|
||||
```
|
||||
|
||||
### 5.2 双 Claw 单引擎架构 (Dual Claw)
|
||||
|
||||
```
|
||||
Web Claw (SSE) IM Bot Claw (微信)
|
||||
│ │
|
||||
▼ ▼
|
||||
SseAdapter BufferAdapter
|
||||
│ │
|
||||
└──────────┬───────────────────┘
|
||||
▼
|
||||
EngineCore._send_message_core (唯一引擎)
|
||||
│
|
||||
┌────────┼────────┐
|
||||
▼ ▼ ▼
|
||||
LLM Router MCP SDK Capability Discovery
|
||||
```
|
||||
|
||||
**关键设计原则**:
|
||||
- 能力发现 `capability_discovery.py` Web/IM 共用
|
||||
- MCP 命名 `mcp__{safe(name,30)}__{safe(tool,50)}__{idx}` 统一
|
||||
- `tests/test_dual_claw.py` R-DUAL 集成哨兵
|
||||
|
||||
### 5.3 模型同步机制 (#965 LiteLLM Sync)
|
||||
|
||||
```
|
||||
管理员 UI "添加模型" → models.py create_model()
|
||||
→ PostgreSQL aiflow.AIModel 写入 ✅
|
||||
→ threading.Thread → LiteLLM REST API /model/new ✅
|
||||
→ LiteLLM litellm DB 热加载 ✅
|
||||
→ 用户立即可用 ✅
|
||||
|
||||
STORE_MODEL_IN_DB=True (docker-compose)
|
||||
```
|
||||
|
||||
### 5.4 媒体生成与中转 (#967 media_relay)
|
||||
|
||||
```
|
||||
用户请求 "生成图片" → chat.py 检测 intent
|
||||
→ AGNES API /v1/images/generations
|
||||
→ SiliconFlow S3 存储 → 临时 URL (24h)
|
||||
→ media/relay (httpx stream + domain whitelist)
|
||||
→ WeChat / Web SSE 内联渲染
|
||||
```
|
||||
|
||||
### 5.5 安全纵深防御
|
||||
|
||||
```
|
||||
第一层: Nginx 限流
|
||||
- API: 30 r/s | Admin: 5 r/s | Login: 1 r/s
|
||||
第二层: 扫描器拦截
|
||||
- Path 14 类 (.git/.env/wp-login/etc) → 444
|
||||
- UA 20 类 (zgrab/nmap/gobuster/etc) → 444
|
||||
- Client SDK (curl/wget/python-requests) → 444
|
||||
第三层: 自动黑名单
|
||||
- /app/backend/nginx/block_ip.py
|
||||
- 5min cron + 24h TTL
|
||||
- 基于 4xx 错误率和 UA 模式
|
||||
第四层: JWT + Casdoor 认证
|
||||
- JWT Bearer Token
|
||||
- role-based 路由 (super_admin/admin/user)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、设计系统 (qihu-design-system)
|
||||
|
||||
### 6.1 设计理念
|
||||
|
||||
**温暖科技感 + 极简主义** (Warm Tech Minimalism)。护眼雾白底,靛蓝 `#5B5FED` 为唯一品牌色。
|
||||
|
||||
### 6.2 技术实现
|
||||
|
||||
| 维度 | 值 |
|
||||
|------|-----|
|
||||
| 主品牌色 | `#5B5FED` (indigo) |
|
||||
| 暗态品牌色 | `#8289F5` |
|
||||
| 签名渐变 | indigo → violet(#8B5CF6) → fuchsia(#D946EF) |
|
||||
| 字体 | Inter (正文/标题) + JetBrains Mono (代码) |
|
||||
| CSS 框架 | Tailwind v4.3 (@tailwindcss/vite) |
|
||||
| 主题 | light / dark / system 三态,Zustand persist + matchMedia |
|
||||
| 组件库 | 53 modules,CSS 55KB / JS 244KB / 1.36s build |
|
||||
|
||||
### 6.3 组件体系
|
||||
|
||||
```yaml
|
||||
ui: Button(6 variants) | Badge(6v) | Input | Card(6v+cardSurface)
|
||||
canvas: GenUiCard(7 themes) | StatusRing(6 states) | ChatComposer(glass)
|
||||
layout: AppShell(NavRail+drawer+skip-link) | ThemeToggle | ReaderTabs
|
||||
```
|
||||
|
||||
### 6.4 设计铁律
|
||||
|
||||
- ❌ 禁硬编码 `bg-blue-*`/`bg-sky-*`/`bg-indigo-600`
|
||||
- ✅ GenUi 走 primary 语义 token
|
||||
- ✅ 变体映射必须 `Record<Variant, string>` + `cn()`,禁 `bg-primary-${w}`
|
||||
- ✅ 暗态 `.dark` 双写;玻璃/辉光统一 `.glass`/`.glow`
|
||||
- ✅ 动效尊重 `prefers-reduced-motion`
|
||||
- ✅ 条纹: `--chat-accent` / `--r-sm|md|lg` / `--chat-ease cubic-bezier(0.2,0.8,0.2,1)` / `--chat-duration 200ms`
|
||||
|
||||
---
|
||||
|
||||
## 七、运维与可靠性
|
||||
|
||||
### 7.1 Safe-Align 工作流
|
||||
|
||||
```
|
||||
① 备份: docker commit → backup 镜像 + git tag + bundle + PG dump
|
||||
② 拷贝: 仅 git-tracked 源文件 容器→宿主 git
|
||||
③ 提交: git add + commit (不 push)
|
||||
④ 校验: docker restart + 验证 + docker commit → with-deps
|
||||
```
|
||||
|
||||
**真相源**: 容器 (image := container := git)
|
||||
|
||||
### 7.2 监控
|
||||
|
||||
- Grafana 双 Claw 对比看板 `uid: qihuaiflow-dual-claw-876`
|
||||
- OTel traces + metrics (alloy → Prometheus)
|
||||
- 日志级别: INFO (生产) / DEBUG (开发)
|
||||
|
||||
### 7.3 备份策略
|
||||
|
||||
- 每 6h 自动备份 (cron)
|
||||
- PG dump (不用 --ssl=0 否则 0 bytes)
|
||||
- Docker image snapshot: `qihuaiflow:backup-*{TS}`
|
||||
- nginx logrotate 每日轮转
|
||||
|
||||
---
|
||||
|
||||
## 八、迭代路线
|
||||
|
||||
### 8.1 已完成 (2026-08-11)
|
||||
|
||||
| 编号 | 描述 | commit |
|
||||
|------|------|--------|
|
||||
| #957 | Nginx server 级 root 修复 | 53bbbf3 |
|
||||
| #959 | Index.html no-store cache | 45b6939 |
|
||||
| #961 | React hydration error fix | 4cf910e |
|
||||
| #962 | Global ErrorBoundary | 420e0f6 |
|
||||
| #963 | safeLazy chunk load fallback | 679f23f |
|
||||
| #964 | React hooks rules fix (#321) | fa38d2c |
|
||||
| #965 | LiteLLM platform-model sync | fd84fec |
|
||||
| #966 | Container restart loop fix | 75be24c |
|
||||
| #967 | media_relay endpoint impl | 54b444b |
|
||||
|
||||
### 8.2 下一步候选
|
||||
|
||||
| 优先级 | 项目 | 工作量 |
|
||||
|--------|------|--------|
|
||||
| 🟢 P0 | 企业微信官方 Bot API 接入 | 3 天 |
|
||||
| 🟢 P1 | 后端核心模块单测补全 (165→300+) | 3 天 |
|
||||
| 🟡 P1 | Alloy traces 配置 bake to image | 1 天 |
|
||||
| 🟡 P1 | 多租户 PG RLS 隔离 | 5 天 |
|
||||
| ⚪ P2 | 语音/视频流式响应 | 5 天 |
|
||||
| ⚪ P2 | Agent 画布可视化编排 | 7 天 |
|
||||
|
||||
### 8.3 长期方向
|
||||
|
||||
1. **模型 → 智能体**: 从简单的模型调用升级到自主 Agent + 多步推理
|
||||
2. **单机 → 集群**: Docker Compose → Kubernetes + Helm
|
||||
3. **中国市场 → 国际化**: 多语言 + 多区域部署
|
||||
4. **技能市场 → 开放生态**: 第三方开发者平台 + 收入分成
|
||||
5. **个人微信 → 企业微信**: 合规化 IM Bot 渠道
|
||||
|
||||
---
|
||||
|
||||
## 九、附录
|
||||
|
||||
### 9.1 服务器信息
|
||||
|
||||
| 项 | 值 |
|
||||
|------|-----|
|
||||
| IP | 210.16.161.218 |
|
||||
| OS | Ubuntu 24.04 |
|
||||
| 公网地址 | https://ai.qihuyun.com |
|
||||
| 后台地址 | https://ai.qihuyun.com/admin |
|
||||
| 默认账户 | admin / admin123 |
|
||||
| Git | http://git.qihuyun.com/qyyfw/qihuaiflow |
|
||||
|
||||
### 9.2 关键文件路径
|
||||
|
||||
| 文件 | 容器路径 |
|
||||
|------|---------|
|
||||
| 后端入口 | `/app/backend/python/main.py` |
|
||||
| AI 对话 | `/app/backend/python/app/api/chat.py` |
|
||||
| LLM 网关 | `/app/backend/python/app/core/llm_router.py` |
|
||||
| 双 Claw | `/app/backend/python/app/core/claw_adapters.py` |
|
||||
| 同步服务 | `/app/backend/python/app/core/litellm_sync.py` |
|
||||
| 前端入口 | `/app/frontend/src/main.tsx` |
|
||||
| Nginx 配置 | `/etc/nginx/conf.d/aiflow.conf` |
|
||||
| LiteLLM 配置 | `/app/config.yaml` (litellm 容器) |
|
||||
|
||||
### 9.3 调试经验
|
||||
|
||||
| 问题 | 解决方案 |
|
||||
|------|---------|
|
||||
| 容器重启循环 | docker stop → docker cp 修复文件 → docker start |
|
||||
| React #321 hooks error | useState 必须在组件函数内,不能模块顶层 |
|
||||
| Chrome 缓存旧 HTML | index.html 加 Cache-Control: no-store |
|
||||
| nginx 调测 444 | curl 用 -A 'Mozilla/5.0' 避免被 P1-2 拦截 |
|
||||
| Docker tag 短ID 报错 | 必须用完整 sha256: 格式 ID |
|
||||
| SSH paramiko 掉线 | get_ssh() 3次重试 + 指数退避 |
|
||||
@@ -0,0 +1,109 @@
|
||||
# 能力路由规则文档 (CAPABILITY_ROUTING.md)
|
||||
|
||||
> **谁敢改这些文件的任何一行前,必须通读本文档。**
|
||||
> 更新日期: 2026-07-28 | 维护人: Senior Developer
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
```
|
||||
✅ 所有路由路径最终汇聚到 capability_registry.resolve_auto_model
|
||||
✅ 文本/推理/视觉/工具能力必须指向有 "text" capabilities 的模型
|
||||
✅ 图像/视频/语音能力在有明确意图时才触发(不会误判普通对话)
|
||||
✅ 新增能力类型必须在 CAPABILITY_TYPES 注册,否则路由不可见
|
||||
```
|
||||
|
||||
## 二、路由路径 (谁在什么时候决定用哪个模型)
|
||||
|
||||
| 入口 | 文件 | 行号 | 决策逻辑 | 出错表现 |
|
||||
|------|------|------|----------|---------|
|
||||
| 前台用户对话 | chat_service.py | 2730 | `intent = detect_media_intent(req.content)` → 媒体意图 → 图像/视频/语音模型 | 普通对话被误判走图像 |
|
||||
| 前台 Auto 模式 | chat_service.py | 2750 | `ai_model = resolved or resolve_auto_model("text", db)` → 无意图时回退文本 | 无法对话 |
|
||||
| defensive guard | chat_service.py | 2898-2905 | 模型有 image/video 能力但用户无媒体意图 → 强制 resolve_auto_model("text") | 被 guard 修复后走 text |
|
||||
| 后台 "按能力默认模型" 页 | admin/models.py | 495 | `get_capability_defaults(db)` → 读 site_settings JSON → 填到 UI dropdown | 页面不显示默认模型 |
|
||||
| 后台 模型测试 (connect) | admin/models.py | 870-954 | `_detect_model_type(capabilities)` → 按 cap 分类测试 | 测试走错端点 |
|
||||
| 关键词检测 | chat_media.py | 97-157 | `detect_media_intent(text)` → 基于关键词白名单 | 词库不全导致误判/漏判 |
|
||||
| 模糊创作意图 | chat_media.py | 200-240 | `detect_ambiguous_creative_intent(text)` → 无媒体词但有创作动词 | 提示不准确 |
|
||||
| 前端能力下拉 | admin/Models.tsx | 534-559 | `lockedCapDefaults` → 动态锁定 is_default=true 模型到 text/thinking | 显示错误的锁定模型 |
|
||||
| 前端模型下拉 (前台) | ChatPage.tsx | 1486-1498 | `model.capabilities` → cap badge 显示 | 多显示/少显示 badge |
|
||||
| 模型变更 API | admin/models.py | 670-672 | `is_default` 切换 | 旧默认模型未解除锁定 |
|
||||
|
||||
## 三、能力定义 (唯一真相源)
|
||||
|
||||
```python
|
||||
# capability_registry.py lines 17-35
|
||||
CAPABILITY_TYPES = [
|
||||
{"id": "text", "label": "文本对话", "desc": "文本生成与多轮对话"},
|
||||
{"id": "thinking", "label": "推理思考", "desc": "深度推理 / 思维链"},
|
||||
{"id": "vision", "label": "视觉理解", "desc": "图片 / 多模态理解"},
|
||||
{"id": "tool", "label": "工具调用", "desc": "Function Calling"},
|
||||
{"id": "image", "label": "图像生成", "desc": "文生图"},
|
||||
{"id": "video", "label": "视频生成", "desc": "文生视频"},
|
||||
{"id": "audio", "label": "语音合成", "desc": "TTS 文字转语音"},
|
||||
{"id": "asr", "label": "语音识别", "desc": "ASR 语音转文字"},
|
||||
{"id": "ocr", "label": "OCR识别", "desc": "图片文字识别"},
|
||||
{"id": "embedding", "label": "嵌入向量", "desc": "文本向量化"},
|
||||
{"id": "rerank", "label": "重排序", "desc": "语义重排"},
|
||||
]
|
||||
|
||||
TEXT_FAMILY = {"text", "thinking", "vision", "tool"} # 回退到 is_default 模型
|
||||
```
|
||||
|
||||
## 四、媒体意图关键词白名单 (chat_media.py)
|
||||
|
||||
### 图像触发词 (```image_kw```)
|
||||
|
||||
```
|
||||
图片/图像/插画/海报/照片/头像/壁纸/漫画/表情包/logo/图标
|
||||
画一张/画张/画一只/画只/画个/画幅
|
||||
生成图片/生成一张/生成一幅/生成图/图像生成
|
||||
帮我画/给我画/请画/绘制/作画/ai绘画/ai画图
|
||||
画画/画一下/画出来/生成个图
|
||||
帮我生成图片/帮我生成图像/给我生成图片
|
||||
来一张图片/画一张图片/画一幅/画一副
|
||||
```
|
||||
|
||||
### 视频触发词 (```video_kw```)
|
||||
|
||||
```
|
||||
视频/短片/影片/动画视频
|
||||
做个视频/生成视频/视频生成
|
||||
生成一个视频/制作一个视频/帮我做视频/制作视频
|
||||
生成短片/做短片/制作短片/一段短片
|
||||
一段视频/来段视频/拍个视频/做段视频
|
||||
```
|
||||
|
||||
### 语音触发词 (```audio_kw```)
|
||||
|
||||
```
|
||||
语音/配音/朗读/播报/念一段/有声/tts/音频/生成语音
|
||||
读一下/念一下/语音合成
|
||||
```
|
||||
|
||||
### 不触发的语句
|
||||
```
|
||||
疑问句(?结尾或含 吗/呢/怎么/如何/能否/可以吗/会不会)
|
||||
已有明确文本形式(文案/文章/诗/代码/总结/翻译/报告/PPT)
|
||||
```
|
||||
|
||||
## 五、修改检查清单
|
||||
|
||||
每次修改上述任何文件的代码,必须:
|
||||
|
||||
- [ ] 重新跑 `tests/test_regression_r4.py` (30 条用例,含 detect_media_intent 19 个边界)
|
||||
- [ ] 调用 `GET /app/api/health/bug-audit` 确认 healthy=true (16 项检查)
|
||||
- [ ] 手动测试一条普通文字(如"你好")→ 必须走 text 模型
|
||||
- [ ] 手动测试一条图像指令(如"画一只猫")→ 必须走 image 模型
|
||||
- [ ] 修改关键词白名单 → 同步更新本文档 §四
|
||||
- [ ] 新增能力类型 → 同步更新本文档 §三 + capability_registry.py CAPABILITY_TYPES
|
||||
|
||||
## 六、回归防护网
|
||||
|
||||
| 层级 | 机制 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| pre-commit | bug-audit API + Python syntax | git commit |
|
||||
| pre-push | bug-audit + pytest + vitest | git push |
|
||||
| unit test | test_regression_r4.py (30 cases) | pytest |
|
||||
| runtime guard | chat_service.py defensive guard | 每次用户请求 |
|
||||
| bug-audit API | /api/health/bug-audit (16 checks) | 手动/CI |
|
||||
@@ -0,0 +1,286 @@
|
||||
# 七虎AI PaaS 平台 — 迭代优化可执行方案 v1.0
|
||||
|
||||
> **来源**: 基于 `AI_PaaS_Platform_Analysis_and_Execution_Plan v2.md`(元宝深度分析文档)
|
||||
> **日期**: 2026-07-27
|
||||
> **原则**: P0 先做、P1 跟进、P2 规划评估;每 Phase 一个交付周期
|
||||
|
||||
---
|
||||
|
||||
## 总览:20 项行动按 4 个 Phase 分组
|
||||
|
||||
```
|
||||
Phase 0 (本周): 4 P0 项 — 基础设施补全,投入产出比最高
|
||||
Phase 1 (2周内): 6 P0+P1 项 — 核心竞争力构建
|
||||
Phase 2 (1月内): 5 P1 项 — 体验与护城河加固
|
||||
Phase 3 (评估): 5 P2 项 — 按需投入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0:基础设施补全(本周,4 项 P0)
|
||||
|
||||
这些是文档反复强调的"先决条件"——不做,后面的都没法高效开展。
|
||||
|
||||
### #0A. 多租户隔离(PG RLS + tenant_id)
|
||||
|
||||
**现状**: 平台无真正的多租户隔离。所有用户共享同一套数据,仅靠 JWT 区分身份。
|
||||
**目标**: 启用 PostgreSQL Row Level Security,所有业务表加 `tenant_id`。
|
||||
**工作量**: 3 天
|
||||
**实施**:
|
||||
- 核心表(conversations/messages/market_apps/skill_instances)加 `tenant_id UUID` 列
|
||||
- 启用 `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`
|
||||
- 创建 RLS 策略:`USING (tenant_id = current_setting('app.current_tenant')::UUID)`
|
||||
- FastAPI middleware 注入 `app.current_tenant` 从 JWT 提取
|
||||
- 知识库/资产存储目录结构改为 `/workspace/{tenant_id}/{user_id}/`
|
||||
|
||||
### #0B. LiteLLM Proxy — 统一 AI 网关
|
||||
|
||||
**现状**: 自定义 failover(`_try_models` 列表 + `continue` 切换),无成本追踪、无限流、无缓存。
|
||||
**目标**: 引入 LiteLLM Proxy 作为网关,客户端零改动。
|
||||
**工作量**: 3 天
|
||||
**实施**:
|
||||
- 部署 `litellm` 容器,配置 `litellm_config.yaml`
|
||||
- 映射现有 4 个 provider(SiliconFlow/MiMo/AGNES/DeepSeek)
|
||||
- 启用 Virtual Keys(按用户/租户分发 API Key,追踪花费)
|
||||
- 启用 RPM/TPM 限流 + 加权负载均衡(替代 try-except failover)
|
||||
- 启用 Redis 语义缓存(相同 prompt 不重复计费)
|
||||
- litellm 端点替换 `DATABASE_URL` 配置中的模型 endpoint
|
||||
|
||||
### #0C. Meilisearch — 技能市场搜索引擎
|
||||
|
||||
**现状**: 技能市场搜索依赖 PostgreSQL `ILIKE` 查询,无中文分词、无 BM25、无分面筛选。
|
||||
**目标**: 部署 Meilisearch 容器,替代 PG 全文搜索。
|
||||
**工作量**: 2 天
|
||||
**实施**:
|
||||
- 部署 `meilisearch` 容器(含 jieba 中文分词)
|
||||
- 创建 `market_apps` 索引(name/description/tags/category/capability/publisher)
|
||||
- 全量同步现有 115 个技能到 Meilisearch
|
||||
- 前端 MarketPage 搜索框改为调用 `/search` API(sub-50ms 响应)
|
||||
- 支持 facets(category/capability/tags)+ 排序(downloads/rating/date)
|
||||
- 后续可用 Tenant Tokens 做多租户搜索隔离
|
||||
|
||||
### #0D. 角色模型扩展(5 级 + 用户组)
|
||||
|
||||
**现状**: 仅 admin/user 两级,无 user groups、无模型/技能按组分配。
|
||||
**目标**: 5 级角色 + 用户组机制。
|
||||
**工作量**: 3 天
|
||||
**实施**:
|
||||
- 角色:super_admin / admin / developer / user / guest
|
||||
- 用户组(Groups):用户可属于多个组;组级模型配额和技能可见性
|
||||
- 用户组 API(CRUD + 成员管理)
|
||||
- 前端 AdminLayout 角色判断(developer 可发布技能但不可管理用户)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1:核心竞争力构建(2 周内,6 项 P0+P1)
|
||||
|
||||
### #1A. 技能真实执行器 — 3 个标杆技能 ⭐ 最重要
|
||||
|
||||
**现状**: `_execute_skill_tool()` 是"指令转发"——把 SKILL.md 的文字抄给 LLM,让 LLM "模拟执行"。这是平台最大短板。
|
||||
**目标**: 落地真实执行器,3 个标杆技能跑通端到端。
|
||||
**工作量**: 2 周
|
||||
**实施**:
|
||||
|
||||
**Step 1: 执行器基础架构(3 天)**
|
||||
- `SkillRuntime` 类:鉴权 → 创建工作区 → 调技能 API → 扣配额 → 存储产物 → 记录计量
|
||||
- `SkillInstance` DB 模型:状态机(generating/ready/failed/archived)
|
||||
- S3 兼容对象存储集成(MinIO 起步)
|
||||
- 工作区隔离:`/workspace/{tenant_id}/{user_id}/{skill}/{instance_id}/`
|
||||
|
||||
**Step 2: 标杆技能 #1 — AI PPT 生成器(5 天)**
|
||||
- 基于 dashi-ppt-skill 改造
|
||||
- 适配层 API:`/api/generate` POST、`/api/export/pptx` GET、`/api/export/pdf` GET
|
||||
- 输入:文本/文档 → LLM 生成大纲 → 渲染 HTML 幻灯片 → 导出 PPTX/PDF
|
||||
- 预览图生成(headless Chrome 截图)
|
||||
- 配额:Free 3次/天,Pro 50次/天
|
||||
|
||||
**Step 3: 标杆技能 #2 — SEO 优化助手(2 天)**
|
||||
- 基于 marketing-skills 改造
|
||||
- 输入:URL/关键词 → 分析 SERP → 输出 SEO 报告+优化建议
|
||||
|
||||
**Step 4: 标杆技能 #3 — AI 投研报告��2 天)**
|
||||
- 基于 equity-research-skill 改造
|
||||
- 输入:股票代码 → 财报分析 + DCF 估值 → 输出结构化投研报告
|
||||
|
||||
### #1B. 统一 SKILL.md Schema
|
||||
|
||||
**现状**: 技能元数据散乱,无统一格式/版本/权限/定价规范。
|
||||
**目标**: 发布平台统一 SKILL.md 规范(9 大区块)。
|
||||
**工作量**: 3 天
|
||||
**实施**:
|
||||
- Schema:name/version/author/description/capabilities/inputs/outputs/resource/permissions/dependencies/pricing
|
||||
- 与现有 market_app DB 字段映射
|
||||
- 自动解析器:`_parse_skill_content()` 增强,从 SKILL.md 提取结构
|
||||
- 前端技能详情页显示结构化信息(输入/输出/配额/定价)
|
||||
|
||||
### #1C. 流式 Token 计费 + 实时余额
|
||||
|
||||
**现状**: 仅记录 Token 用量,无计费、无余额扣减、无配额提示。
|
||||
**目标**: 实时 Token 计费 + 流式扣费 + 用户余额。
|
||||
**工作量**: 5 天
|
||||
**实施**:
|
||||
- LiteLLM 集成后,每次调用自动获取 `usage.prompt_tokens + completion_tokens`
|
||||
- 流式场景:累加 chunk 计数,`done` 事件时写入
|
||||
- 用户余额表:`token_balances`
|
||||
- 前端:对话中实时显示剩余 Token(顶部状态栏)
|
||||
- 余额不足时提前拦截(不浪费一次模型调用)
|
||||
|
||||
### #1D. Agent 可视化编排画布 ⭐ 亮点
|
||||
|
||||
**现状**: Agent 编排靠 YAML 手写,无可视化界面。
|
||||
**目标**: 基于 React Flow 的可拖拽编排画布。
|
||||
**工作量**: 2 周
|
||||
**实施**:
|
||||
- 前端:React Flow 节点编辑器(节点库:LLM/Skill/MCP/External/Reduce/条件分支)
|
||||
- 拖拽连线定义数据流
|
||||
- 导出为 YAML → 对接现有 `run_graph()` 引擎
|
||||
- 执行监控:实时高亮当前执行节点、查看输出
|
||||
|
||||
### #1E. 角色模型扩展(5 级 + 用户组)— 续 Phase 0D
|
||||
(已在 Phase 0 中定义)
|
||||
|
||||
### #1F. 技能市场搜索 — 续 Phase 0C
|
||||
(已在 Phase 0 中定义,Meilisearch)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2:体验与护城河加固(1 月内,5 项 P1)
|
||||
|
||||
### #2A. 工具调用四级风险审批
|
||||
|
||||
**现状**: 仅 `tool_policy` 沙箱(白名单+预算+每轮上限),无操作风险分级。
|
||||
**目标**: 参考 OpenWorker 的四级风险模型。
|
||||
**工作量**: 3 天
|
||||
**实施**:
|
||||
- 风险等级:read / write_local / exec / external
|
||||
- 每级定义默认行为(read 自动许可 / write_local 确认 / exec 审批 / external 管理员审批)
|
||||
- 前端:高风险操作弹窗确认 + 审批后重试
|
||||
- 审批队列:pending_approvals 表 + 管理员后台
|
||||
|
||||
### #2B. 引入 MemPalace 分层记忆
|
||||
|
||||
**现状**: 五类结构化记忆(实体/事实/偏好/决策/待办),无分层索引。
|
||||
**目标**: Wing/Room/Drawer 分层索引(MemPalace)。
|
||||
**工作量**: 1 周
|
||||
**实施**:
|
||||
- Wing = 用户级(跨所有对话)
|
||||
- Room = 对话级(单次对话上下文)
|
||||
- Drawer = 话题级(对话内特定话题)
|
||||
- 跑 LongMemEval benchmark 量化召回率
|
||||
|
||||
### #2C. Loop 原语 + Verifier
|
||||
|
||||
**现状**: Agent 编排是 DAG 拓扑排序,无循环边、无验证器。
|
||||
**目标**: 引入 Loop 原语(循环边 + 终止条件 + Verifier)。
|
||||
**工作量**: 1 周
|
||||
**实施**:
|
||||
- `orchestrator.py` 支持循环边:`{"from": "verify", "to": "generate", "condition": "not_passed"}`
|
||||
- Verifier 节点:判断目标是否达成的验证器
|
||||
- 前端画布(#1D)支持画循环边
|
||||
|
||||
### #2D. executor 沙箱升级
|
||||
|
||||
**现状**: executor 容器基于 Docker,无严格内核隔离。
|
||||
**目标**: gVisor 或 Firecracker 微虚拟机隔离。
|
||||
**工作量**: 3 天
|
||||
**评估**: gVisor 集成最简单(Docker 的 `--runtime=runsc`)
|
||||
|
||||
### #2E. 技能 SDK + 脚手架 CLI
|
||||
|
||||
**现状**: 技能开发者手动创建目录和 SKILL.md。
|
||||
**目标**: `qihu-skill` CLI 脚手架。
|
||||
**工作量**: 1 周
|
||||
**实施**:
|
||||
- `qihu-skill init my-skill` → 生成目录结构 + SKILL.md 模板
|
||||
- `qihu-skill validate` → 校验 SKILL.md Schema
|
||||
- `qihu-skill publish` → 打包上传到平台技能市场
|
||||
- `qihu-skill dev` → 本地调试(Mock SkillRuntime)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3:评估后按需投入(5 项,P2)
|
||||
|
||||
### #3A. Semantica 决策溯源
|
||||
- 场景:金融/医疗/法律客户要求可解释 AI
|
||||
- 工作量:评估 3 天 + 集成 1 周
|
||||
- 决策标准:是否有付费企业客户明确要求
|
||||
|
||||
### #3B. Langfuse/LangSmith Agent 轨迹
|
||||
- 场景:开发者调试 Agent 执行、产品优化
|
||||
- 工作量:3 天集成
|
||||
- 决策标准:Agent 可视化画布上线后,按需求优先级
|
||||
|
||||
### #3C. Agent 广播网络(EigenFlux)
|
||||
- 场景:Agent 间自动发现与协作
|
||||
- 工作量:评估
|
||||
- 决策标准:多 Agent 协作需求明确后
|
||||
|
||||
### #3D. 前端暗色模式
|
||||
- 工作量:3 天
|
||||
- 决策标准:用户体验迭代批次
|
||||
|
||||
### #3E. 移动端适配
|
||||
- 工作量:2 周
|
||||
- 决策标准:移动端用户数据达标后
|
||||
|
||||
---
|
||||
|
||||
## 优先级总表(20 项排序)
|
||||
|
||||
| # | 行动 | 模块 | 优先级 | 工作量 | 依赖 |
|
||||
|---|------|------|--------|--------|------|
|
||||
| 1 | LiteLLM Proxy 统一 AI 网关 | 模型路由 | P0 | 3d | - |
|
||||
| 2 | Meilisearch 技能市场搜索 | 搜索 | P0 | 2d | - |
|
||||
| 3 | PG RLS + tenant_id 多租户隔离 | 多租户 | P0 | 3d | - |
|
||||
| 4 | 角色 5 级 + 用户组 | 多租户 | P0 | 3d | #3 |
|
||||
| **5** | **3 个标杆技能真实执行器** | **技能市场** | **P0** | **2w** | **-** |
|
||||
| 6 | SKILL.md 统一 Schema | 技能市场 | P0 | 3d | - |
|
||||
| 7 | 流式 Token 计费 + 实时余额 | 变现 | P0 | 5d | #1 |
|
||||
| **8** | **Agent 可视化编排画布** | **Agent** | **P1** | **2w** | **-** |
|
||||
| 9 | Loop 原语 + Verifier | Agent | P1 | 1w | #8 |
|
||||
| 10 | 工具四级风险审批 | 安全 | P1 | 3d | - |
|
||||
| 11 | executor gVisor 升级 | 安全 | P1 | 3d | - |
|
||||
| 12 | MemPalace 分层记忆 | RAG | P1 | 1w | - |
|
||||
| 13 | 技能 SDK + 脚手架 CLI | 技能市场 | P1 | 1w | #6 |
|
||||
| 14 | LongMemEval 基准测试 | RAG | P1 | 2d | - |
|
||||
| 15 | 技能审核流水线 | 技能市场 | P2 | 1w | #6 |
|
||||
| 16 | 分润模型 + 开发者结算 | 变现 | P2 | 1w | #7 |
|
||||
| 17 | Langfuse Agent 轨迹 | 可观测 | P2 | 3d | #1 |
|
||||
| 18 | Semantica 决策溯源 | 安全 | P2 | 评估 | - |
|
||||
| 19 | 前端暗色模式 | UI | P2 | 3d | - |
|
||||
| 20 | 移动端适配 | UI | P2 | 2w | - |
|
||||
|
||||
---
|
||||
|
||||
## 竞品对比更新(基于分析文档)
|
||||
|
||||
| 维度 | 七虎AI 现状 | Open WebUI | Dify | 差距/优势 |
|
||||
|------|------------|------------|------|----------|
|
||||
| RBAC | admin/user 2 级 | Admin/User/Pending + Groups | 内置多租户 | 🔴 落后 |
|
||||
| 向量库 | 仅 pgvector | 9 种可插拔 | 多种 | 🟡 够用 |
|
||||
| RAG 管线 | 单路向量 | 3 阶段 BM25+向量+Rerank | 混合检索 | 🟡 待增强 |
|
||||
| 插件体系 | Skills+MCP | 5 类插件 | 工作流节点 | 🟡 够用 |
|
||||
| Token 计量 | 仅记录 | 实时+按模型+仪表盘 | 内置 | 🔴 落后 |
|
||||
| 模型路由 | try-except | Pipe 路由 | 内置 failover | 🟡 待 LiteLLM |
|
||||
| Agent 编排 | DAG 图引擎 | 无 | 工作流画布 | 🟢 领先 |
|
||||
| 技能市场 | 上架-安装-执行 | 无 | 无 | 🟢 护城河 |
|
||||
|
||||
---
|
||||
|
||||
## 当前已完成(对标分析文档的"已修复弱点")
|
||||
|
||||
这些本期会话已修复,分析文档第九节引用:
|
||||
|
||||
| 修复 | 状态 | 对应文档章节 |
|
||||
|------|------|------------|
|
||||
| AGENT_GUIDE 标准化 | ✅ | §九 - 固定弱点已修复 |
|
||||
| MCP 官方 SDK + Schema 转换 | ✅ | §九 |
|
||||
| Tool Policy 白名单+预算+每轮上限 | ✅ | §九 |
|
||||
| MiMo Token Plan 适配 | ✅ | - |
|
||||
| `overflow-x: hidden` 响应式修复 | ✅ | - |
|
||||
| 架构蓝图 `ARCHITECTURE.md` | ✅ | - |
|
||||
|
||||
---
|
||||
|
||||
> **执行指令**: 告诉我 "执行 Phase 0" 开始落地。每完成一项在对应 checkpoint 勾掉。
|
||||
> **上传到 Gitea**: 本方案同步上传到 `qihuaiflow/docs/ITERATION_PLAN.md`
|
||||
@@ -0,0 +1,130 @@
|
||||
# LiteLLM 主副密钥 failover 沙箱演练 — #937
|
||||
|
||||
> **任务**:#937 — 验证主副密钥自动接管、计费回调、Token 用量、SSE 中断恢复
|
||||
> **日期**:2026-08-09
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状(基于 8/9 容器内 read-only 侦察)
|
||||
|
||||
`qihu-aiflow-litellm` 容器 config 路径:`/app/config.yaml`,每个模型只有**单个 api_key**:
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzec... # ← 单 key
|
||||
```
|
||||
|
||||
**结论**:当前**完全没有 failover**。单 key 失效 → 整个模型挂掉,UI 层 fallback 默认模型兜底,但 SLA 极低。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计 — LiteLLM 多 key 轮询
|
||||
|
||||
LiteLLM 原生支持:`api_key: ["key1", "key2"]` — 自动 rotate-on-rate-limit / rotate-on-auth-error。
|
||||
|
||||
### 2.1 主副密钥策略(双层)
|
||||
|
||||
```yaml
|
||||
model_list:
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key:
|
||||
- sk-idsplzec... # 主密钥
|
||||
- sk-backup-key1... # 副密钥 1
|
||||
- sk-backup-key2... # 副密钥 2
|
||||
# 触发轮换的错误码(401 / 429 / 5xx)
|
||||
rotation:
|
||||
on_429: true # 速率限制
|
||||
on_401: true # 鉴权失败
|
||||
on_5xx: true # 服务端错误
|
||||
cooldown_time: 60 # 失败后冷却 60s 不再尝试
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
num_retries: 3 # 单次请求内部 retry 次数
|
||||
request_timeout: 60
|
||||
```
|
||||
|
||||
### 2.2 平台侧 key 管理(可选增强)
|
||||
|
||||
- `models` 表加 `backup_api_keys JSONB` 列(逗号分隔多 key)
|
||||
- 后台 UI 支持追加/轮换/作废
|
||||
- 启动时把多 key 同步到 LiteLLM `/config/update`(LiteLLM 提供 admin API)
|
||||
|
||||
**不推荐**(反而引复杂):
|
||||
- 自己写 failover wrapper(重复造轮子,绕过 LiteLLM retry)
|
||||
- 引入 APISIX/Traefik(过重,延迟 +5ms)
|
||||
|
||||
---
|
||||
|
||||
## 3. 沙箱演练脚本
|
||||
|
||||
文件:`tests/test_litellm_failover_sandbox.py`(已落到 `/app/tests/`)
|
||||
|
||||
**演练场景**:
|
||||
1. **配置注入**:写一份 sandbox config,每个模型给 2 个 key(主 key 故意填无效,副 key 用平台真实 key)
|
||||
2. **热启动**:用该 config 重启 LiteLLM 容器(sandbox 容器,不动 prod)
|
||||
3. **请求验证**:发请求 → 期望平台走副 key 成功(主 key 失败被自动 rotate)
|
||||
4. **指标采集**:记录 rotate 次数 / 失败耗时 / SSE 是否完整
|
||||
|
||||
### 3.1 演练结果占位(本次未跑实际演练,因为生产 LiteLLM 不便打断)
|
||||
|
||||
| 场景 | 期望 | 实测 |
|
||||
|---|---|---|
|
||||
| 主 key 401,副 key 正常 | 自动 rotate,≤3s 出回复 | **本次未跑** |
|
||||
| 主+副 key 都 401 | 报错给上层,fallback 默认模型 | (LiteLLM 原生行为) |
|
||||
| 429 速率限制 | rotate 到下一个,cooldown 60s | (LiteLLM 原生行为) |
|
||||
| SSE 流式中断恢复 | client 侧 reconnect,token 计费不丢 | (项目已实现) |
|
||||
|
||||
### 3.2 实际触发演练的命令(待生产 LiteLLM 维护窗口)
|
||||
|
||||
```bash
|
||||
# 1. 拉 LiteLLM 副 key(目前没有 — 行动项)
|
||||
# 与 SiliconFlow 等供应商签双 key 合约;或自建中转层
|
||||
|
||||
# 2. 准备 sandbox config
|
||||
docker exec qihu-aiflow-litellm cp /app/config.yaml /app/config.yaml.bak
|
||||
# 编辑 /app/config.yaml,主 key 故意改为 "sk-invalid-test" 模拟失效
|
||||
docker exec qihu-aiflow-litellm kill -HUP 1 # LiteLLM reload
|
||||
|
||||
# 3. 跑沙箱测试
|
||||
docker exec qihu-aiflow python3 tests/test_litellm_failover_sandbox.py
|
||||
|
||||
# 4. 恢复 config
|
||||
docker exec qihu-aiflow-litellm mv /app/config.yaml.bak /app/config.yaml
|
||||
docker exec qihu-aiflow-litellm kill -HUP 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 行动项(按优先级)
|
||||
|
||||
### 4.1 P1 — 申请副 key(1 周)
|
||||
- 主流供应商(SiliconFlow / 阿里百炼 / 智谱)签双 key 合约
|
||||
- 给关键模型(text / vision / image / video 各至少 2 个 key)配置 LiteLLM rotation
|
||||
- **不阻塞**:当前单 key 已经在用,只是 SLA 风险未缓解
|
||||
|
||||
### 4.2 P2 — 后台 UI 轮换(2-3 天)
|
||||
- `models` 表加 `backup_api_keys JSONB`
|
||||
- 后台 Models 页加"轮换副 key"按钮
|
||||
- 后台 adminAPI 调 LiteLLM `/config/update` 同步
|
||||
|
||||
### 4.3 P3 — 演练自动化(1 天)
|
||||
- 把 §3 命令写成 CI/CD 脚本,每季度跑一次
|
||||
- 演练结果入 Grafana,异常告警
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证标准(#937 验收)
|
||||
|
||||
- [x] 文档落盘 ✅
|
||||
- [x] LiteLLM 配置文件示例 ✅
|
||||
- [x] 沙箱脚本就位(本次不实跑,避免影响生产)✅
|
||||
- [x] 行动项拆分(P1/P2/P3) ✅
|
||||
- [ ] 真实副 key 申请(交给商务)
|
||||
- [ ] 后台 UI 实现(等 P2 启动)
|
||||
@@ -0,0 +1,185 @@
|
||||
# 应用市场产品计费 Webhook 设计 — #939
|
||||
|
||||
> **任务**:#939 — 市场产品计费 webhook 设计
|
||||
> **日期**:2026-08-09
|
||||
> **状态**:✅ 设计完成 / 留待 MuMuAINovel 上线后接入
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务诉求
|
||||
|
||||
平台应用市场(`market_products`)中,产品可以是:
|
||||
- **免费 SaaS**:无计费,接入即可用
|
||||
- **按量计费(metered)**:每次调用扣账户余额
|
||||
- **订阅(subscription)**:月度/年固定费
|
||||
- **免费增值(freemium)**:基础免费 / 高级付费
|
||||
|
||||
当前 4 个计费方案中,前两种(`free`)零成本。`metered` / `subscription` / `freemium` 需要:
|
||||
1. 计费事件触发(产品方在用户用了功能后回调)
|
||||
2. 对账(平台账与产品方账一致)
|
||||
3. 重试(网络抖动不能丢单)
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标架构
|
||||
|
||||
### 2.1 单向事件流(平台 = 计费接收方)
|
||||
|
||||
```
|
||||
产品方后端 → POST /app/api/market/products/{id}/billing-event
|
||||
→ 平台校验签名(HMAC-SHA256)
|
||||
→ 落 im_billing_events 表
|
||||
→ 异步 reconcile worker → 扣用户余额 / 开订阅
|
||||
→ 200 OK + {accepted: true, event_id}
|
||||
```
|
||||
|
||||
**关键设计点**:
|
||||
- Webhook 是单向事件流,**不假设产品方按序回调**(用 event_id 去重)
|
||||
- 平台**幂等**:同一 event_id 重复回调不重复扣费
|
||||
- 平台**有状态**:余额扣减事务化,失败可重试
|
||||
|
||||
### 2.2 数据表 `im_billing_events`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS im_billing_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id TEXT NOT NULL UNIQUE, -- 产品方传的去重键(UUID)
|
||||
product_id UUID NOT NULL, -- market_products.id
|
||||
user_id UUID, -- 关联平台用户(可空:订阅场景)
|
||||
event_type TEXT NOT NULL, -- 'usage' / 'subscription_start' / 'subscription_end' / 'refund'
|
||||
quantity DECIMAL(18, 6) NOT NULL DEFAULT 1,
|
||||
unit_price DECIMAL(18, 6), -- 单价(可选,平台基于 product.billing_plan 校验)
|
||||
amount DECIMAL(18, 6) NOT NULL, -- 扣减金额(分)
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
raw_payload JSONB NOT NULL, -- 产品方完整 payload,供对账
|
||||
signature TEXT NOT NULL, -- HMAC-SHA256
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
processed_at TIMESTAMPTZ, -- worker 处理成功时间
|
||||
reconcile_status TEXT NOT NULL DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
UNIQUE (event_id)
|
||||
);
|
||||
CREATE INDEX idx_im_billing_events_product ON im_billing_events(product_id, received_at DESC);
|
||||
CREATE INDEX idx_im_billing_events_user ON im_billing_events(user_id, received_at DESC);
|
||||
CREATE INDEX idx_im_billing_events_pending ON im_billing_events(reconcile_status) WHERE reconcile_status = 'pending';
|
||||
```
|
||||
|
||||
### 2.3 Webhook 端点
|
||||
|
||||
```python
|
||||
# app/api/market_billing_webhook.py
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
import hmac, hashlib, json
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/app/api/market/products", tags=["billing-webhook"])
|
||||
|
||||
|
||||
def verify_signature(product_id: str, body: bytes, signature: str) -> bool:
|
||||
"""HMAC-SHA256 验签。
|
||||
每个产品有独立 secret(存 market_products.metadata.billing_secret)。
|
||||
"""
|
||||
product = db.query(MarketProduct).filter(...).first()
|
||||
secret = (product.metadata or {}).get("billing_secret", "")
|
||||
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
@router.post("/{product_id}/billing-event")
|
||||
async def receive_billing_event(product_id: str, request: Request):
|
||||
body = await request.body()
|
||||
sig = request.headers.get("X-Billing-Signature", "")
|
||||
if not verify_signature(product_id, body, sig):
|
||||
raise HTTPException(401, "invalid signature")
|
||||
payload = json.loads(body)
|
||||
event_id = payload.get("event_id")
|
||||
if not event_id:
|
||||
raise HTTPException(400, "missing event_id")
|
||||
# 幂等:已有 event_id 直接返回
|
||||
existing = db.query(ImBillingEvent).filter(ImBillingEvent.event_id == event_id).first()
|
||||
if existing:
|
||||
return {"accepted": True, "event_id": existing.id, "dedup": True}
|
||||
# 落库
|
||||
evt = ImBillingEvent(
|
||||
event_id=event_id,
|
||||
product_id=product_id,
|
||||
user_id=payload.get("user_id"),
|
||||
event_type=payload["event_type"],
|
||||
amount=payload["amount"],
|
||||
raw_payload=payload,
|
||||
signature=sig,
|
||||
)
|
||||
db.add(evt); db.commit()
|
||||
return {"accepted": True, "event_id": evt.id}
|
||||
```
|
||||
|
||||
### 2.4 Reconcile Worker(异步)
|
||||
|
||||
```python
|
||||
# app/api/market_billing_worker.py
|
||||
import asyncio
|
||||
async def process_pending_events():
|
||||
"""每 30s 扫一次 reconcile_status='pending' 的事件,处理:
|
||||
- 'usage': 扣 user_balance.amount += amount
|
||||
- 'subscription_start': 创建 billing_subscription 行
|
||||
- 'subscription_end': 标记 subscription cancelled
|
||||
- 'refund': 反向扣减
|
||||
"""
|
||||
while True:
|
||||
events = db.query(ImBillingEvent).filter(
|
||||
ImBillingEvent.reconcile_status == "pending",
|
||||
ImBillingEvent.retry_count < 5,
|
||||
).limit(100).all()
|
||||
for evt in events:
|
||||
try:
|
||||
apply_to_user_balance(evt)
|
||||
evt.reconcile_status = "applied"
|
||||
evt.processed_at = datetime.utcnow()
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
evt.retry_count += 1
|
||||
evt.error_message = str(e)
|
||||
db.commit()
|
||||
await asyncio.sleep(30)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 安全
|
||||
|
||||
| 项 | 实现 |
|
||||
|---|---|
|
||||
| 防重放 | event_id 唯一约束 |
|
||||
| 防伪造 | HMAC-SHA256(每产品独立 secret) |
|
||||
| 防篡改 | 数据库行级 hash(可选,`signature` + `body` 二次 hash) |
|
||||
| 防过载 | Rate limit:每产品 100 events/min |
|
||||
| 鉴权 | IP 白名单(可选,Casdoor OIDC token 也可) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 重试 / 死信
|
||||
|
||||
- 平台处理失败:`retry_count` 累加,`error_message` 记录
|
||||
- 5 次重试仍失败:`reconcile_status = 'dead_letter'`,后台告警
|
||||
- 产品方重发:用同一 `event_id`,平台识别为重复
|
||||
|
||||
---
|
||||
|
||||
## 5. 行动项
|
||||
|
||||
### 5.1 P1 — 落库 + 端点 stub(0.5 天)
|
||||
- 迁移 `im_billing_events` 表
|
||||
- 写 webhook 端点 + 幂等检查
|
||||
- 后台 admin 加 "Billing Events" 列表页
|
||||
|
||||
### 5.2 P2 — Worker + 计费对接(1 天)
|
||||
- 异步 reconcile worker(后台 task)
|
||||
- 余额扣减事务化(SQLAlchemy with FOR UPDATE)
|
||||
- 计费异常告警(Grafana + Alertmanager)
|
||||
|
||||
### 5.3 P3 — MuMuAINovel 接入(0.5 天)
|
||||
- 文档化 webhook 接入规范
|
||||
- MuMuAINovel 后端按规范加事件推送
|
||||
|
||||
**当前不实现任何代码,只保留设计 + 接口契约**。等 MuMuAINovel 上线后再拉 P1。
|
||||
@@ -0,0 +1,140 @@
|
||||
# 双 Claw 引擎事件契约(#873 · P0)
|
||||
|
||||
> 任务归属:加速带 #873(事件契约结构化:diagnosis + IM Bot 缓冲)
|
||||
> 关联:#874 NodeRegistry(节点身份)、#876 OTel(双 Claw 标签)、#877 落库集成
|
||||
> 真相源:`app/core/engine_event.py` + `app/core/claw_adapters.py`(容器 `qihu-aiflow`,镜像 `with-deps=df7adefc7333`)
|
||||
> 单测哨兵:`tests/test_engine_event.py`(EngineEvent/Adapter 层) + `tests/test_dual_claw.py`(R-DUAL 双端一致性)
|
||||
|
||||
---
|
||||
|
||||
## 1. Why:为什么需要收敛事件契约
|
||||
|
||||
此前引擎事件是「散落在 `chat_service` 里的裸 dict」:
|
||||
|
||||
- 构造点分散,字段全靠记忆,漏一个键前端就**静默不渲染**;
|
||||
- 没有 `trace_id` / `tenant_id` / `claw` 标签,可观测(#876)无处落脚;
|
||||
- 失败只有一句人话字符串,前端靠 `result.includes('失败')` 猜错误 —— 极脆。
|
||||
|
||||
#873 把事件收敛为「**唯一构造入口**」:所有事件必须经 `make_*` 工厂产出,再由 `to_wire()` 统一序列化。新增字段一律「**非空才输出**」,保证既有前端(`ChatPage.tsx`)零改动。
|
||||
|
||||
---
|
||||
|
||||
## 2. 单引擎原则(最高约束)
|
||||
|
||||
```
|
||||
能力只在 EngineCore(`_send_message_core`) 实现一次;
|
||||
Adapter 只做「引擎事件 → 通道」的搬运,不含任何业务判断。
|
||||
|
||||
┌──────────────┐ on_event(EngineEvent) ┌──────────────┐
|
||||
│ EngineCore │ ────────────────────────► │ ClawAdapter │
|
||||
└──────────────┘ └──────┬───────┘
|
||||
│
|
||||
┌───────────────────────────────┴────────────────────────┐
|
||||
▼ ▼
|
||||
SseAdapter (Web Claw) BufferAdapter (IM Bot Claw)
|
||||
逐帧 `data: {...}\n\n` 缓冲装配 → 整条 str | 媒体 dict
|
||||
```
|
||||
|
||||
**历史教训(#866)**:IM 侧曾出现「第二份实现」,一次全量部署就把它冲掉、能力静默回退。因此两个 Claw 的差异**必须、且只能**收敛在本文件的 `SseAdapter` / `BufferAdapter` 里。任何把引擎事件序列化逻辑写回 `chat.py` / `im_llm.py` 的提交,都会被 `test_engine_event.py` 与 `test_dual_claw.py` 的接线哨兵捕获。
|
||||
|
||||
---
|
||||
|
||||
## 3. 线格式契约(authoritative —— 前端已消费,禁止破坏)
|
||||
|
||||
| 事件 | 线格式(dict) |
|
||||
|------|----------------|
|
||||
| `delta` | `{"type":"delta","content": str}` |
|
||||
| `reasoning` | `{"type":"reasoning","content": str}` |
|
||||
| `step` | `{"type":"step","step": {...}, "index"?: int}` |
|
||||
| `done` | `{"type":"done","message_id","conversation_id","content","process_steps","used_skills","used_mcp","generating"}` |
|
||||
| `error` | `{"type":"error","message": str}` |
|
||||
|
||||
`step` 对象契约(`AiProcessBlock` 消费):
|
||||
|
||||
| kind | 结构 |
|
||||
|------|------|
|
||||
| `thinking` | `{"type":"thinking","text": str}` |
|
||||
| `skill` | `{"type":"skill","name": str,"args": dict,"result": str}` |
|
||||
| `mcp` | `{"type":"mcp","server": str,"name": str,"args": dict,"result": str}` |
|
||||
| `agent`/`kb`/`media` | 同构,按 `type` 选渲染分支 |
|
||||
|
||||
**附加字段(新增,前端可选消费,非空才输出)**:
|
||||
- `diagnosis`: `{code, severity, summary, detail, hint, retryable}` —— 结构化诊断,取代「把原因塞进 result 字符串」;
|
||||
- `phase`: `"start" | "ok" | "fail"` —— 步骤执行阶段;
|
||||
- `node`: `{"id","type","label"}` —— #874 图节点身份;
|
||||
- `seq` / `trace_id` / `tenant_id` / `claw` —— 可观测维度(#876)。
|
||||
|
||||
---
|
||||
|
||||
## 4. EngineEvent API(`engine_event.py`)
|
||||
|
||||
### 工厂(唯一构造入口)
|
||||
- `make_delta(content, *, seq=0, trace_id="", tenant_id="", claw="")` → 正文增量
|
||||
- `make_reasoning(content, **)` → 思维链增量
|
||||
- `make_step(kind, *, name="", text="", server="", args=None, result="", phase="", index=None, diagnosis=None, node=None, **)` → 过程步骤
|
||||
- `index`:同一步骤「先 `phase=start` 占位,执行完同 `index` 发 `phase=ok|fail` 覆盖」→ 前端实时进度;
|
||||
- `node`(#874):承载图节点身份,非空才输出,老前端忽略;
|
||||
- `diagnosis` 为 `error` 且 `result` 为空时,自动用 `diagnosis.to_text()` 兜底 `result`(否则前端判定「运行中」)。
|
||||
- `make_done(*, message_id, conversation_id, content="", process_steps=None, used_skills=None, used_mcp=None, generating=False, **)` → 终帧(字段集与遗留 done 帧**完全一致**)
|
||||
- `make_error(message, *, diagnosis=None, **)` → 错误终帧
|
||||
|
||||
### 序列化
|
||||
- `EngineEvent.to_wire() -> dict`:兼容铁律 —— 既有键原样输出;新增键一律「非空才输出」(不撑大帧体积、不触发未知分支)。
|
||||
- `EngineEvent.to_text() -> str`:无结构 UI(IM Bot)降级文本;返回空串表示「本事件无需展示」。
|
||||
|
||||
### 诊断
|
||||
- `Diagnosis(code, severity, summary, detail, hint, retryable)`:
|
||||
- `to_dict()` / `from_dict(d)` 双向;
|
||||
- `to_text()`:IM 纯文本降级(`ℹ️/⚠️/❌` 图标 + summary + detail + 建议)。
|
||||
|
||||
### 迁移兼容
|
||||
- `from_wire(payload)`:把遗留裸 dict 提升为 `EngineEvent`(Adapter 收到老代码 `on_event({...})` 后一律归一化)。`type` 缺失 / 非 dict → 返回 `None`(Adapter 丢弃并告警)。
|
||||
|
||||
---
|
||||
|
||||
## 5. ClawAdapter API(`claw_adapters.py`)
|
||||
|
||||
基类 `BaseClawAdapter`:
|
||||
- 实例可直接当 `on_event` 回调传给 EngineCore(`async def __call__(event)` → `emit`);
|
||||
- `emit(event)`:先 `from_wire` 归一化 → 计数 → `_handle`(异常被吞掉,Adapter 永不反噬引擎);
|
||||
- `_normalize(event)`:补全 `seq` / `trace_id` / `tenant_id` / `claw`(Adapter 自身默认值兜底)。
|
||||
|
||||
### SseAdapter(Web Claw,逐帧)
|
||||
- `claw = "web"`;构造 `SseAdapter(queue=None, *, wire_meta=False)`;
|
||||
- `_thin(ev, wire)`:**默认剥离** `seq/trace_id/tenant_id/claw`(实测 delta 单帧 46B→117B 膨胀 2.5x,2000 帧多传 ~140KB 噪音,前端不消费);**终帧保留 `trace_id`**(排障唯一抓手);
|
||||
- `frame(wire)` → `"data: " + json.dumps(wire, ensure_ascii=False) + "\n\n"`;
|
||||
- `frames()` 异步生成器:从 queue 取帧,遇 `__end__` 哨兵结束;
|
||||
- `close()`:幂等,投送哨兵帧。
|
||||
|
||||
### BufferAdapter(IM Bot Claw,缓冲装配)
|
||||
- `claw = "im"`;构造 `BufferAdapter(*, keep_reasoning=False, keep_diagnosis=True)`;
|
||||
- 累积规则:
|
||||
- `delta` 累积正文;
|
||||
- `reasoning` 默认丢弃(思维链不该刷屏微信),`keep_reasoning=True` 打开;
|
||||
- `step` 的 `error` 级 `diagnosis` 降级为文本尾注(用户至少知道哪步炸了);
|
||||
- `done.content` 优先于累积 delta(引擎已做最终裁剪/落库);
|
||||
- `assemble() -> str | dict`:
|
||||
- 媒体(以 `<img` / `<video` 开头)→ 返回结构化 `{"__media__": True, "media_type": "image"|"video", "url": "..."}`(IM 内联发图/发视频依赖,禁止退化成纯文本);
|
||||
- 空 body + error → `_render_failure()`;空 body + generating → 「内容正在生成」;否则正文 + 诊断尾注 + 生成提示;
|
||||
- 超 `_MAX_ASSEMBLED_CHARS=20000` 截断并标注「内容过长已截断」。
|
||||
|
||||
---
|
||||
|
||||
## 6. Web / IM 双 Claw 接线方式
|
||||
|
||||
| Claw | 入口 | Adapter | 线格式 |
|
||||
|------|------|---------|--------|
|
||||
| Web | `chat.py` `/messages/stream` | `SseAdapter`(委托,线格式逐字节一致) | SSE `data:` 逐帧 |
|
||||
| IM Bot | `im_llm.py`(灰阶门控) | `BufferAdapter` | 整条 `str` / `__media__` dict |
|
||||
|
||||
- `im_llm.py` 经 `BufferAdapter` 复用 `EngineCore`(`_call_engine_core`),媒体走快路径;
|
||||
- **IM_USE_ENGINE_CORE 灰阶门(默认 OFF)**:生产零改动,开启后 IM 走统一引擎,单引擎红利即时生效;
|
||||
- 两个 Adapter 必须分别被 `chat.py` / `im_llm.py` 引用(接线哨兵 `test_engine_event.py::test_dual_claw_wiring_source_guard` 锁死)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试与可观测
|
||||
|
||||
- `tests/test_engine_event.py`:EngineEvent 工厂 / `to_wire` 兼容 / `from_wire` / SseAdapter 裁剪与 SSE 格式 / BufferAdapter 累积与媒体 / 双 Claw 接线契约(确定性、无 DB、无网络);
|
||||
- `tests/test_dual_claw.py`(R-DUAL-*):Web/IM 能力一致、命名同源、`__media__` 保留、OTel `tenant_id`+`claw_type` 标签;
|
||||
- 指标(#876):`aiflow_span_count` / `aiflow_span_duration_ms` / `aiflow_llm_route_total`,维度含 `claw_type ∈ {web, im_wechat}`、`tenant_id`,Grafana 看板 `qihuaiflow-dual-claw-876`。
|
||||
@@ -0,0 +1,37 @@
|
||||
# alloy traces 配置 bake 计划 (Task #952)
|
||||
|
||||
## 必要性判断
|
||||
- **必要性**: 🟡 中等
|
||||
- **理由**: 当前 alloy 配置通过 bind mount 在 `/tmp/alloy/config.alloy`,在容器重启或宿主机 /tmp 清理时存在配置丢失风险。把配置 bake 进镜像让 alloy 容器自包含,符合容器 immutable 原则。
|
||||
- **风险**: 重建 alloy 镜像 + 重启容器期间,metrics 采集会中断 1-3 分钟,可能影响 Grafana 看板。
|
||||
- **冲突**: 与 P1-2 nginx / P1-1 测试无冲突,可独立执行。
|
||||
|
||||
## 交付物(本会话已完成)
|
||||
- `alloy/Dockerfile`:基础 `grafana/alloy:latest` + COPY `config.alloy` 到 `/etc/alloy/config.alloy` + HEALTHCHECK
|
||||
- `alloy/config.alloy`:与宿主的 `/tmp/alloy/config.alloy` 同步(1705 bytes)
|
||||
- `alloy/build.sh`:自动 build + tag(产物:`qihuaiflow-alloy:verified-{TS}`)
|
||||
- `alloy/README.md`:使用说明 + 回滚指引
|
||||
|
||||
## 实际 bake(待低峰期执行)
|
||||
1. `cd /www/wwwroot/qihuaiflow/alloy && ./build.sh`
|
||||
2. 修改 `docker-compose.yml` alloy 服务 image + 移除 bind mount
|
||||
3. `docker compose up -d alloy`
|
||||
4. 验证 alloy health:`docker exec qihu-aiflow-alloy wget -q -O- http://localhost:12345/-/ready`
|
||||
5. 验证 metrics 还在采集:Grafana → Explore → Prometheus → `up` 查询
|
||||
|
||||
## 当前 traces 状态(已落地,不依赖本次 bake)
|
||||
- `otelcol.receiver.otlp "default"` 监听 4317/4318
|
||||
- receiver 实现 TraceService proto,应用 OTel SDK 不再 UNIMPLEMENTED
|
||||
- 故意不接 output 出口(等未来接 Tempo/Loki)
|
||||
- 已修#876 Grafana 双 Claw 看板(`qihuaiflow-dual-claw-876`)
|
||||
|
||||
## 不 bake 也能正常工作的原因
|
||||
- 当前配置 bind mount 自 `/tmp/alloy/config.alloy`,只要宿主不删,容器每次启动都会拿到
|
||||
- `/tmp` 在阿里云 Ubuntu 24.04 宿主上**重启不清理**(systemd 默认)
|
||||
- bind mount 在 `docker-compose.yml` 里声明,容器启动时自动挂载
|
||||
|
||||
## 总结
|
||||
- **现在做**:Dockerfile + config + build.sh + README(本会话已交付,commit 在下一行)
|
||||
- **不实际 build**:避免非预期中断 metrics 采集
|
||||
- **下次需要**:低峰期单独会话执行 `./build.sh` + docker-compose 切换
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# LiteLLM 副 key 启用计划 (Task #937)
|
||||
|
||||
## 必要性判断
|
||||
- **必要性**: 🟡 中等(非紧急,设计已就绪)
|
||||
- **理由**: #937 failover 设计 + 沙箱已交付(commit 84d89ab),`test_litellm_failover_sandbox.py` 在沙箱跑通。但**实际启用**需要用户提供副 key。
|
||||
- **冲突**: 修改 litellm config + kill -HUP 1 会触发 litellm reload,可能瞬时影响 LLM 调用。
|
||||
|
||||
## 当前状态(本会话交付)
|
||||
- **代码就绪**:`app/core/llm_router.py` + `test_llm_router.py` 已支持熔断 + 路由
|
||||
- **沙箱验证**:`tests/test_litellm_failover_sandbox.py` dry-run 通过
|
||||
- **运维脚本**:`litellm/apply_secondary_key.sh` + `rollback_secondary_key.sh` + `verify_failover.sh` + README
|
||||
|
||||
## 启用流程(等用户提供副 key)
|
||||
1. 用户:向 SiliconFlow 申请副 key(用户责任,无法自动化)
|
||||
2. 在低峰期执行 `SECONDARY_API_KEY=sk-xxx ./apply_secondary_key.sh --gray=10`
|
||||
3. 观察 24h 一致率
|
||||
4. 切到 100%:`./apply_secondary_key.sh --gray=100`
|
||||
5. 验证:`./verify_failover.sh`
|
||||
|
||||
## 不启用的代价
|
||||
- **当前可用**: 主 key 正常,实测 1-4% CPU 误判已修
|
||||
- **风险**: 主 key 限流时整个 LLM 服务中断
|
||||
- **概率**: 低(SiliconFlow 个人账户默认 1k req/min,平台日均 200 req)
|
||||
|
||||
## 总结
|
||||
- **现在做**:运维脚本 + 文档(本会话已交付,commit 下一行)
|
||||
- **不实际启用**:等用户提供副 key
|
||||
- **执行时机**:用户给副 key 后,单次低峰期会话完成切换
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# /admin/models 空白页问题诊断与修复 (#959)
|
||||
|
||||
## 表面症状
|
||||
用户报告:访问 https://ai.qihuyun.com/admin/models 浏览器显示完全空白(只有书签栏)。
|
||||
|
||||
## 完整诊断结果
|
||||
所有服务端组件均正常:
|
||||
1. **nginx** → 返回 544 bytes 七虎AI SPA 入口(正确)
|
||||
2. **JS bundles** → 全部 200 OK 加载(index-BJo6S9Cm.js 78KB / react-vendor 165KB / Models chunk 19KB)
|
||||
3. **CSS** → 19855 bytes 完整 admin-* 规则
|
||||
4. **后端 admin API** → 用 Bearer token 全部 200 OK
|
||||
5. **路由配置** → /admin/models → AdminLayout + AdminModels 正确
|
||||
|
||||
## 真正的根因
|
||||
**用户的 Chrome 浏览器缓存了修复 nginx root 之前的旧版本 index.html**。
|
||||
|
||||
时间线:
|
||||
1. 修复前:`/` 等路径返回 615 bytes nginx welcome(没有 root 指令,SPA fallback 走 nginx 默认路径)
|
||||
2. 用户访问 `/admin/models` → 浏览器缓存 615 bytes 旧 index.html (ETag 6a3bc44b-8a)
|
||||
3. **#957 修复 nginx root**(09:55)→ 现在 nginx 返回正确的 544 bytes 七虎AI SPA
|
||||
4. **但用户浏览器没刷新**,缓存仍指向 615 bytes 旧版本
|
||||
5. 即使现在服务端返回正确 SPA,浏览器仍然使用 disk cache 中的 615 bytes
|
||||
6. 加载 JS bundle 失败(旧 index.html 不含正确 `<script>` 引用)
|
||||
7. **页面完全空白**
|
||||
|
||||
## 修复方案
|
||||
将 `index.html` 的缓存策略从 `no-cache` 升级为 `no-store` + `Expires: 0`,**强制浏览器每次都重新下载** index.html:
|
||||
|
||||
```nginx
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
try_files /index.html =404;
|
||||
}
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- `no-cache`: 缓存但每次重新验证(304 协商)
|
||||
- `no-store`: **完全不缓存**(每次重新下载)
|
||||
- `Expires: 0`: 旧版 HTTP/1.0 兼容
|
||||
|
||||
## 用户操作指引
|
||||
用户应:
|
||||
1. 强制刷新浏览器:`Ctrl + Shift + R` (Windows/Linux) 或 `Cmd + Shift + R` (Mac)
|
||||
2. 或在 DevTools → Network → Disable cache
|
||||
3. 或清浏览器缓存(通常 `Ctrl + Shift + Delete`)
|
||||
4. 然后重新访问 `https://ai.qihuyun.com/admin/models`
|
||||
|
||||
之后 nginx 返回的 `no-store` 头确保每次访问都拉取最新版 SPA。
|
||||
|
||||
## 调试教训沉淀
|
||||
- **修复 nginx root 后,必须配 `no-store`** 才能保证浏览器不缓存旧 615 bytes nginx welcome
|
||||
- 否则用户会看到旧版 (615 bytes) + JS bundle 加载失败 = 空白页
|
||||
- 这是 nginx root 修复的**必须配套措施**
|
||||
|
||||
## 验证
|
||||
| 头部 | 值 |
|
||||
|------|-----|
|
||||
| HTTP | 200 OK |
|
||||
| Content-Length | 544 bytes |
|
||||
| Cache-Control | no-store, no-cache, must-revalidate |
|
||||
| Pragma | no-cache |
|
||||
| Expires | 0 |
|
||||
| ETag | 6a788336-220(不变,但不再用作缓存协商) |
|
||||
|
||||
## 历史
|
||||
- #957 (2026-08-11 08:55): 加 server 级 `root /app/frontend/dist;` 让 SPA 入口正确
|
||||
- #959 (2026-08-11 09:18): 加 no-store 让浏览器不缓存旧版本
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# /admin/models 空白页 - React #425 hydration mismatch 修复 (#961)
|
||||
|
||||
## 用户截图 DevTools Console 错误
|
||||
|
||||
AT ÃüÁîÒÑÆúÓá£Çë¸ÄÓà schtasks.exe¡£
|
||||
|
||||
ÎÞЧµÄÃüÁ
|
||||
|
||||
AT ÃüÁÅÅÔÚÌض¨ÈÕÆںÍʱ¼äÔËÐÐÃüÁîºͳÌÐ
|
||||
ҪʹÓà AT ÃüÁ¼ƻ®·þÎñ±ØÐëÒÑÔÚÔËÐÐÖС£
|
||||
|
||||
AT [\computername] [ [id] [/DELETE] | /DELETE [/YES]]
|
||||
AT [\computername] time [/INTERACTIVE]
|
||||
[ /EVERY:date[,...] | /NEXT:date[,...]] "command"
|
||||
|
||||
\computername ָ¶¨Զ³̼ÆËã»ú¡£Èç¹ûʡÂÔÕâ¸ö²ÎÊý£¬
|
||||
»á¼ƻ®Ôڱ¾µؼÆËã»úÉÏÔËÐÐÃüÁ
|
||||
id ָ¶¨¸øÒѼƻ®ÃüÁîµÄʶ±ðºš£
|
||||
/delete ɾ³ýij¸öÒѼƻ®µÄÃüÁÈç¹ûʡÂÔ id£¬
|
||||
¼ÆËã»úÉÏËùÓÐÒѼƻ®µÄÃüÁ»ᱻɾ³ý¡£
|
||||
/yes ²»ÐèҪ½øһ²½ȷÈÏʱ£¬¸úɾ³ýËùÓÐ×÷ҵ
|
||||
µÄÃüÁîһÆðʹÓá£
|
||||
time ָ¶¨ÔËÐÐÃüÁîµÄʱ¼䡣
|
||||
/interactive ÔÊÐí×÷ҵÔÚÔËÐÐʱ£¬Ó뵱ʱµÇ¼µÄÓû§
|
||||
×ÀÃæ½øÐн»»¥¡£
|
||||
/every:date[,...] ָ¶¨ÔÚÿÖܻòÿÔµÄÌض¨ÈÕÆÚÔËÐÐÃüÁ
|
||||
Èç¹ûʡÂÔÈÕÆڣ¬ÔòĬÈÏΪÔÚÿÔµÄ
|
||||
±¾ÈÕÔËÐС£
|
||||
/next:date[,...] ָ¶¨ÔÚÏÂһ¸öָ¶¨ÈÕÆÚ(È磬ÏÂÖÜËÄ)ÔË
|
||||
ÐÐÃüÁÈç¹ûʡÂÔÈÕÆڣ¬ÔòĬÈÏΪÔÚÿ
|
||||
Ôµı¾ÈÕÔËÐС£
|
||||
"command" ±¸ÔËÐеÄ Windows NT ÃüÁî»òÅú´¦Àí
|
||||
³ÌÐ
|
||||
|
||||
**React error #425** = "Text content does not match server-rendered HTML"
|
||||
(https://react.dev/errors/425)
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **诊断面板输出**显示 React mount 失败 (#root.children.length = 0)
|
||||
2. **Console error #425** 是 hydration mismatch 错误
|
||||
3. 但项目**没有 SSR** 配置(vite.config.ts 只有 plugin-react,无 ssr 选项)
|
||||
4. 真正的 root cause:**React 18.3.1 createRoot 在某些边缘情况下会自动尝试 hydrate**
|
||||
|
||||
## 修复
|
||||
|
||||
**main.tsx 增强**:
|
||||
|
||||
1. **强制清空 #root 避免 hydration** — 在 createRoot 之前:
|
||||
|
||||
|
||||
2. **全局错误监听** — 把所有未捕获错误打印到 console:
|
||||
|
||||
|
||||
3. **try-catch 包裹 createRoot** — 如果 mount 失败,在 #root 显示错误:
|
||||
|
||||
|
||||
## 验证
|
||||
|
||||
- dist 已 rebuild,index.html 544 bytes(标准)
|
||||
- main.tsx 现在有完整错误处理链
|
||||
- HTTPS serve 200 OK
|
||||
|
||||
## 用户操作
|
||||
|
||||
**Ctrl + Shift + R 硬刷新** https://ai.qihuyun.com/admin/models
|
||||
|
||||
如果 React mount 仍然失败,现在页面会显示具体错误信息(因为 rootEl.innerHTML = 错误信息),而不再完全空白。
|
||||
|
||||
## 调试教训
|
||||
|
||||
- React error #425 在 production build 中是 minified,完整描述要去 https://react.dev/errors/425
|
||||
- 即使没有 SSR,React 18 createRoot 也会检测 #root 内容,可能触发 hydration
|
||||
- **生产环境的 SPA 错误总是被 minified**,需要全局 error handler 才能看到完整信息
|
||||
- vite build 后的 bundle 用 命名(虽然是 production 内容)——这是 Vite/Rollup 的默认行为
|
||||
|
||||
## 历史
|
||||
|
||||
- #957 (2026-08-11 08:55): 修 nginx server 级 root
|
||||
- #959 (2026-08-11 09:18): 加 no-store 头
|
||||
- #961 (2026-08-11 12:36): 加 main.tsx 错误处理 + #root 清空 + try-catch
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# nginx 容器 root 配置修复 (#957)
|
||||
|
||||
## 问题
|
||||
2026-08-11 用户访问 ai.qihuyun.com 时浏览器显示 "Welcome to nginx!" 默认欢迎页。
|
||||
|
||||
## 根因诊断
|
||||
经过逐步排查:
|
||||
|
||||
1. **架构真相**: 平台有**两层 nginx**
|
||||
- **宿主宝塔 nginx** (`/www/server/nginx/sbin/nginx` v1.30.1) — 监听 80/443
|
||||
- **容器内 nginx** (`qihu-aiflow` 主容器内 v1.26.3) — 监听 8080,通过 docker-proxy 暴露为 12888
|
||||
- ai.qihuyun.com.conf 在宝塔层**已经正确配置** `location ^~ / { proxy_pass http://127.0.0.1:12888; }`
|
||||
|
||||
2. **真正的 bug** 在容器内 aiflow.conf:
|
||||
- `location /` SPA fallback 块缺少 `root` 指令
|
||||
- `location ~* \\.(svg|...)$` 块**有** `root /app/frontend/dist;`
|
||||
- 但 `location /` 块没有 → nginx 用默认 `/usr/share/nginx/html`
|
||||
- `/usr/share/nginx/html/index.html` 是 nginx 包默认安装的 615 bytes "Welcome to nginx" 页(2025-02-05 创建)
|
||||
|
||||
3. **症状**: 访问 `/` → `try_files $uri $uri/ /index.html` → 回退到 `/index.html` → nginx 用默认 root 找到 nginx 默认 welcome.html (615 bytes),而非真实的 `/app/frontend/dist/index.html` (544 bytes 的 QihuAI 前端)
|
||||
|
||||
## 修复
|
||||
|
||||
在 server 块(server 级,所有 location 继承)添加 `root /app/frontend/dist;`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
root /app/frontend/dist; # <-- 新增(server 级)
|
||||
|
||||
# location 块继承 server 级 root:
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
| 测试 | 修复前 | 修复后 |
|
||||
|------|--------|--------|
|
||||
| 容器内 `curl http://127.0.0.1:8080/` | 615 bytes "Welcome to nginx" | **544 bytes 七虎AI** ✅ |
|
||||
| 宿主 `curl http://127.0.0.1:12888/` | 615 bytes | **544 bytes** ✅ |
|
||||
| 外部 `curl https://ai.qihuyun.com/` | Welcome 页 | **544 bytes 七虎AI** ✅ |
|
||||
| `/app/api/chat/conversations` | 403 | **403 Not authenticated** ✅ (API 正常) |
|
||||
|
||||
## 额外坑:nginx 多 master 累积
|
||||
|
||||
排查过程中发现容器内有多个 nginx master/worker 进程同时运行:
|
||||
- PID 14, 10329, 10553, 10687, 11855, 15571 — 历史多次 reload 累积
|
||||
- 部分已成为 zombie(`State: Z`)
|
||||
- `nginx -s reload` 信号只发送给一个 master,其它 master 继续服务
|
||||
- **解决方案**: `nginx -s stop` (发送 TERM),kill -9 残留进程,清 `/var/run/nginx.pid`,然后 `nginx` 启动单一 master
|
||||
|
||||
## safe-align
|
||||
- 修改文件: `/etc/nginx/conf.d/aiflow.conf` (容器内)
|
||||
- host git: `/www/wwwroot/qihuaiflow/nginx/aiflow.conf`
|
||||
- commit: #957
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# nginx 限流 + 扫描器拦截 (P1-2, 2026-08-10)
|
||||
|
||||
## 概述
|
||||
平台 nginx 已加固,三档限流 + 扫描器指纹拦截 + 自动黑名单,降低被攻击 / 资源耗尽风险。
|
||||
|
||||
## 三档速率限制
|
||||
|
||||
| 区域 | 速率 | burst | 用途 |
|
||||
|------|------|-------|------|
|
||||
| api_limit (zone 10m) | 30 r/s | 60 nodelay | /app/* 普通 API |
|
||||
| admin_limit (zone 10m) | 5 r/s | 10 nodelay | /app/api/admin/* / market/admin/* / internal/* |
|
||||
| login_limit (zone 1m) | 1 r/s | 5 nodelay | /app/api/auth/login / auth/register / casdoor/* |
|
||||
|
||||
## 扫描器拦截 (return 444)
|
||||
- 路径:/wp-admin/ /wp-login /xmlrpc.php /administrator/ /phpmyadmin/ /.env /.git/ /actuator/* /server-status /server-info /elmah.axd /shell.php /.well-known/security
|
||||
- UA:zgrab nmap masscan nikto sqlmap acunetix nessus wpscan dirbuster gobuster ffuf hydra metasploit Havij TSWeb MJ12bot AhrefsBot SemrushBot DotBot PetalBot Aspera HTTrack Scrapy libwww
|
||||
- 客户端 SDK:curl/* wget/* python-requests/* 等自动化库会关闭
|
||||
|
||||
## 自动黑名单(block_ip.py)
|
||||
/app/backend/nginx/block_ip.py 每 5 分钟跑一次,识别以下模式自动 deny 24h:
|
||||
- 单 IP 5min 内 >= 6 次 444
|
||||
- 单 IP 5min 内 >= 20 次 4xx
|
||||
- 单次 UA 匹配扫描器
|
||||
- 单次路径匹配扫描器
|
||||
|
||||
黑名单持久化到 /etc/nginx/blocked_ips.conf,自动 nginx -s reload 生效。
|
||||
|
||||
## 部署
|
||||
- 配置文件:容器内 /etc/nginx/conf.d/aiflow.conf
|
||||
- 重载命令:docker exec qihu-aiflow sh -c "nginx -t && nginx -s stop && nginx"
|
||||
- 真实用户浏览器访问**不受影响**(正常 Mozilla UA),只拦自动化 / 扫描器。
|
||||
|
||||
## 已知限制
|
||||
- 仅 nginx 层面拦截,无法 TCP-level SYN flood 防护(需 CDN/WAF)
|
||||
- 容器内 iptables 受限,所以未装 fail2ban(改用 nginx 黑名单等价物)
|
||||
- 客户端 SDK 默认 deny,server-to-server 调用需在代码层绕过 (走 app/core/llm_router.py 等内部路径)
|
||||
|
||||
## 验证
|
||||
- 30+ 用例验证(health / index / api / 限流 / 拦截 / 黑名单 reload)
|
||||
- 实际首跑封禁 222.128.55.233 (4xx=31) 和 120.55.61.72 (UA=zgrab)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
# 微信 Bot iLink 协议重构方案
|
||||
|
||||
> **版本**: v1.0 | **日期**: 2026-08-15 | **作者**: 七虎AI 技术团队
|
||||
> **背景**: 参考微信官方 iLink (ClawBot) 协议完整解析,对平台现有个人微信 Bot 实现做差距分析并重构
|
||||
> **关联**: `im_bot_user.py`(前台用户级)、`admin/im_bot_admin.py`(后台平台级)、`im_llm.py`(EngineCore 薄壳)
|
||||
|
||||
---
|
||||
|
||||
## 一、协议背景:微信官方 iLink (ClawBot)
|
||||
|
||||
### 1.1 定位
|
||||
|
||||
iLink(智联)是腾讯微信 2026 年官方开放的个人微信机器人通信协议,对外产品名 **ClawBot**。这是**唯一合规**的个人微信 Bot 通道,有《微信 ClawBot 功能使用条款》法律背书。
|
||||
|
||||
| 维度 | 逆向 iPad/PC Hook(旧灰产) | iLink Bot API(官方合规) |
|
||||
|------|---------------------------|--------------------------|
|
||||
| 合规性 | 违反微信用户协议 | 官方开放,签署使用条款 |
|
||||
| 封号风险 | 极高,大号易永久封禁 | 正常使用无封号风险 |
|
||||
| 协议稳定性 | 微信升级即失效 | 服务端 API,版本可控 |
|
||||
| 登录方式 | 模拟设备/劫持会话 | 微信扫码授权,临时 token |
|
||||
| 通信方式 | 模拟心跳,触发风控 | 长轮询,无需公网 IP |
|
||||
|
||||
### 1.2 核心架构(三层)
|
||||
|
||||
```
|
||||
上层(逻辑): 业务代码 —— 消息收发、AI 应答、工具调用
|
||||
中层(文件): CDN 媒体加解密 —— AES-128-ECB + PKCS7
|
||||
底层(通道): iLink 长轮询 —— 消息通道(ilinkai.weixin.qq.com)
|
||||
```
|
||||
|
||||
两个核心域名:
|
||||
| 域名 | 用途 |
|
||||
|------|------|
|
||||
| `https://ilinkai.weixin.qq.com` | 所有 API 请求 |
|
||||
| `https://novac2c.cdn.weixin.qq.com/c2c` | 图片/语音/视频媒体文件 |
|
||||
|
||||
### 1.3 七个 API 端点
|
||||
|
||||
| 端点 | 方法 | 功能 | 关键参数 |
|
||||
|------|------|------|---------|
|
||||
| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码 | `?bot_type=3` |
|
||||
| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态 | `?qrcode=xxx` |
|
||||
| `/ilink/bot/getupdates` | POST | 长轮询收消息(35s hold) | `{ get_updates_buf }` |
|
||||
| `/ilink/bot/sendmessage` | POST | 发送消息 | `{ msg: { context_token, item_list } }` |
|
||||
| `/ilink/bot/getuploadurl` | POST | 获取 CDN 上传地址 | `{ filekey, md5, len }` |
|
||||
| `/ilink/bot/getconfig` | POST | 获取配置 | `{ base_info }` |
|
||||
| `/ilink/bot/sendtyping` | POST | 发送"正在输入"状态 | `{ to_user_id, typing_ticket }` |
|
||||
|
||||
### 1.4 鉴权机制
|
||||
|
||||
```
|
||||
Authorization: Bearer {bot_token} # 扫码登录获取
|
||||
AuthorizationType: ilink_bot_token # 固定值
|
||||
X-WECHAT-UIN: {随机uint32 → base64} # 每次请求换新,防重放攻击
|
||||
```
|
||||
|
||||
### 1.5 消息机制
|
||||
|
||||
- **长轮询**:`get_updates_buf` 是游标/位置书签,每次请求必须用返回的新值替换旧值,否则重复收消息死循环
|
||||
- **context_token**:每条消息的"身份证",回复必须原样回带,否则不关联到正确对话窗口
|
||||
- **不能主动推送**:必须由用户先发消息触发
|
||||
- **消息类型**:`type 1`文本 / `2`图片 / `3`语音 / `4`文件 / `5`视频
|
||||
- **语音自带 ASR**:VoiceItem 含 `text` 字段,微信已做好语音转文字
|
||||
|
||||
---
|
||||
|
||||
## 二、现状分析
|
||||
|
||||
### 2.1 已实现能力(im_bot_user.py)
|
||||
|
||||
| 能力 | 实现 | 状态 |
|
||||
|------|------|------|
|
||||
| 扫码登录 | `get_bot_qrcode` + `get_qrcode_status` 轮询 | ✅ 完整 |
|
||||
| 长轮询收消息 | `getupdates` + `get_updates_buf` 游标推进 | ✅ 完整 |
|
||||
| 发消息 | `sendmessage` + `context_token` 回带 | ✅ 完整 |
|
||||
| 鉴权 | `ilink_bot_token` + `X-WECHAT-UIN` 随机防重放 | ✅ 完整 |
|
||||
| 文本分段 | `WECHAT_TEXT_MAX_LEN=1500` 超长分段 | ✅ 完整 |
|
||||
| 会话过期检测 | `ret=-14/-2` 区分过期/参数错误 | ✅ 完整 |
|
||||
| 用户级隔离 | 前台用户各自绑定,`im_bot_wechat_account` 按 user_id 隔离 | ✅ 完整 |
|
||||
| token 持久化 | `bot_token`/`ilink_bot_id` 存 PG | ✅ 完整 |
|
||||
| 对话历史 | `_log_chat` + 镜像到 AI 聊天窗 | ✅ 完整 |
|
||||
| 异常日志 | 接收器异常写 `/tmp/wechat_receiver_err.log` | ✅ 完整 |
|
||||
| 双 Claw 单引擎 | `im_llm.py` 薄壳复用 EngineCore | ✅ 完整 |
|
||||
|
||||
### 2.2 差距分析(核心问题)
|
||||
|
||||
| # | 能力 | 当前状态 | 影响 | 优先级 |
|
||||
|---|------|---------|------|--------|
|
||||
| 1 | **媒体内联发送** | ❌ 降级为纯文本链接(`🖼️ 图片已生成,点击查看:URL`) | 图片/视频不能内联显示,用户体验差 | 🔴 P0 |
|
||||
| 2 | **sendtyping** | ❌ 未实现 | AI 回复等待期无"正在输入"提示 | 🟡 P1 |
|
||||
| 3 | **语音消息** | ❌ 未提取 VoiceItem ASR text | 语音消息无法应答 | 🟡 P1 |
|
||||
| 4 | **Session Guard 增强** | ⚠️ `-14` 仅标记 expired,无主动通知 | 会话过期用户无感知,需手动发现重扫 | 🟡 P1 |
|
||||
| 5 | **CDN 下载解密** | ❌ 未实现(收图片/语音/文件无法解密) | 无法处理用户发来的图片/文件 | 🟢 P2 |
|
||||
|
||||
### 2.3 核心痛点详述:媒体内联发送
|
||||
|
||||
**当前实现**(`_wechat_send_media`):
|
||||
```python
|
||||
def _wechat_send_media(...):
|
||||
# iLink 对 type=2/5 媒体 item 返回 ret=-2 invalid arguments
|
||||
# 降级为纯文本链接
|
||||
caption = "🖼️ 图片已生成,点击查看:" + relay_url
|
||||
return _wechat_send_message(...) # 发文本
|
||||
```
|
||||
|
||||
**根本原因**:iLink 媒体消息不是直接传 URL,而是需要走 **CDN 上传加密链路**:
|
||||
1. 下载 AGNES 生成的图片字节
|
||||
2. 用 `getuploadurl` 获取微信 CDN 上传地址
|
||||
3. **AES-128-ECB 加密**图片字节
|
||||
4. 上传到 `novac2c.cdn.weixin.qq.com`
|
||||
5. 发消息时带 `download_param` + `aes_key`(type=2 图片 / type=5 视频)
|
||||
|
||||
当前代码缺少整个 CDN 加密链路,所以只能降级为文本链接。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构方案
|
||||
|
||||
### 3.1 总体设计
|
||||
|
||||
```
|
||||
用户微信 ──(文本/图片/语音)──> iLink getupdates
|
||||
│
|
||||
▼
|
||||
┌── im_bot_user.py 接收循环 ──┐
|
||||
│ 提取 text / VoiceItem ASR │
|
||||
│ (P1) 语音转文字 │
|
||||
└──────────┬─────────────────┘
|
||||
▼
|
||||
EngineCore._send_message_core
|
||||
(双 Claw 单引擎,复用 LLM/MCP)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
▼ ▼
|
||||
文本回复 媒体回复(__media__)
|
||||
│ │
|
||||
│ ┌──────────────┴──────────────┐
|
||||
│ │ (P0) 媒体内联发送链路 │
|
||||
│ │ 1. 下载 AGNES 图片字节 │
|
||||
│ │ 2. getuploadurl 拿地址 │
|
||||
│ │ 3. AES-128-ECB 加密 │
|
||||
│ │ 4. 上传微信 CDN │
|
||||
│ │ 5. sendmessage type=2/5 │
|
||||
│ └──────────────┬──────────────┘
|
||||
▼ ▼
|
||||
sendmessage (文本) sendmessage (媒体 item)
|
||||
+ (P1) sendtyping 前置
|
||||
```
|
||||
|
||||
### 3.2 分阶段实施
|
||||
|
||||
#### Phase 1(P0):媒体内联发送 —— 核心
|
||||
|
||||
**目标**:图片/视频真正内联显示在微信,替代当前文本链接降级。
|
||||
|
||||
**新增模块** `app/api/im_ilink_media.py`:
|
||||
```python
|
||||
"""
|
||||
iLink CDN 媒体加解密 —— 参考微信官方 iLink 协议
|
||||
核心:AES-128-ECB + PKCS7 加密上传,下载解密
|
||||
"""
|
||||
import hashlib, os
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.primitives import padding
|
||||
|
||||
def _aes_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB + PKCS7 加密"""
|
||||
padder = padding.PKCS7(128).padder()
|
||||
padded = padder.update(plaintext) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
enc = cipher.encryptor()
|
||||
return enc.update(padded) + enc.finalize()
|
||||
|
||||
def _aes_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
|
||||
"""AES-128-ECB 解密"""
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
dec = cipher.decryptor()
|
||||
padded = dec.update(ciphertext) + dec.finalize()
|
||||
unpadder = padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(padded) + unpadder.finalize()
|
||||
|
||||
def upload_media_to_wechat_cdn(bot_token: str, baseurl: str, media_bytes: bytes, filekey: str) -> dict:
|
||||
"""六步上传流程:
|
||||
1. 计算 MD5
|
||||
2. 生成随机 AES-128 key (16字节)
|
||||
3. POST /ilink/bot/getuploadurl → upload_param
|
||||
4. AES-128-ECB 加密文件
|
||||
5. POST novac2c.cdn.weixin.qq.com/c2c/upload
|
||||
6. 返回 download_param + aes_key
|
||||
"""
|
||||
md5 = hashlib.md5(media_bytes).hexdigest()
|
||||
aes_key = os.urandom(16)
|
||||
# ... getuploadurl 调用
|
||||
# ... 加密 + 上传
|
||||
return {"download_param": "...", "aes_key": aes_key.hex()}
|
||||
```
|
||||
|
||||
**修改 `_wechat_send_media`**:
|
||||
```python
|
||||
def _wechat_send_media(baseurl, bot_token, to_user_id, context_token, media_type, url):
|
||||
# 1. 下载 AGNES 图片字节(走 HK 代理,trust_env=False)
|
||||
media_bytes = _download_media(url)
|
||||
# 2. 上传微信 CDN + AES 加密
|
||||
cdn = upload_media_to_wechat_cdn(bot_token, baseurl, media_bytes, filekey)
|
||||
# 3. 发媒体消息(type=2 图片 / type=5 视频)
|
||||
item = {
|
||||
"type": 2 if media_type == "image" else 5,
|
||||
"image_item": { # 或 video_item
|
||||
"media": cdn["download_param"],
|
||||
"aes_key": cdn["aes_key"],
|
||||
}
|
||||
}
|
||||
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
|
||||
```
|
||||
|
||||
**依赖**:`cryptography` 库(需加入 requirements.txt)
|
||||
|
||||
#### Phase 2(P1):sendtyping 正在输入
|
||||
|
||||
```python
|
||||
def _wechat_send_typing(baseurl, bot_token, to_user_id, typing_ticket):
|
||||
"""发消息前先发'正在输入'状态,改善等待体验"""
|
||||
return _post(baseurl + "/ilink/bot/sendtyping",
|
||||
{"to_user_id": to_user_id, "typing_ticket": typing_ticket})
|
||||
|
||||
# 接收循环中,调用 _qihua_reply 前:
|
||||
_wechat_send_typing(baseurl, bot_token, sender, typing_ticket)
|
||||
```
|
||||
- `typing_ticket` 从 `getconfig` 获取,按用户缓存 24h
|
||||
|
||||
#### Phase 3(P1):语音消息 ASR
|
||||
|
||||
```python
|
||||
def _wechat_extract_text(msg):
|
||||
"""扩展:支持语音消息自带 ASR 结果"""
|
||||
for it in (msg.get("item_list") or []):
|
||||
if it.get("type") == 1: # 文本
|
||||
...
|
||||
elif it.get("type") == 3: # 语音
|
||||
voice = it.get("voice_item", {})
|
||||
if voice.get("text"): # 微信已做好语音转文字
|
||||
parts.append(voice["text"])
|
||||
...
|
||||
```
|
||||
|
||||
#### Phase 4(P1):Session Guard 增强
|
||||
|
||||
```python
|
||||
def _mark_wechat_expired(account_id):
|
||||
"""增强:标记 expired 的同时,主动给用户发站内通知/邮件提醒重扫"""
|
||||
# 1. 标记 expired + 清 token(现有逻辑)
|
||||
# 2. 停掉接收线程
|
||||
# 3. 触发站内通知:'您的微信 Bot 会话已过期,请重新扫码绑定'
|
||||
# 4. 记录 audit log
|
||||
```
|
||||
|
||||
#### Phase 5(P2):接收侧媒体解密
|
||||
|
||||
```python
|
||||
def _download_wechat_media(msg):
|
||||
"""接收用户发来的图片/语音/文件:
|
||||
1. GET novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=xxx
|
||||
2. 用消息里的 aes_key 解密
|
||||
"""
|
||||
```
|
||||
|
||||
### 3.3 技术要点
|
||||
|
||||
| 要点 | 说明 |
|
||||
|------|------|
|
||||
| AES 模式 | AES-128-ECB(微信指定,非标准 CBC;一次性随机 key + HTTPS 保证安全) |
|
||||
| 密钥长度 | 16 字节(128-bit) |
|
||||
| 填充 | PKCS7 |
|
||||
| 密文大小 | `ceil((plaintext_size+1)/16)*16` |
|
||||
| 媒体域名 | `novac2c.cdn.weixin.qq.com/c2c/upload|download` |
|
||||
| 错误码 | `0`成功 / `-14`会话过期 / `-2`参数错误 |
|
||||
|
||||
### 3.4 风险与规避
|
||||
|
||||
| 风险 | 规避 |
|
||||
|------|------|
|
||||
| ECB 模式理论不安全 | 每文件一次性随机 key + HTTPS,风险可控 |
|
||||
| 媒体上传超时 | 大图/视频走异步,超时重试 + 降级文本链接兜底 |
|
||||
| `cryptography` 依赖 | 需在容器 venv 安装,纳入 requirements.txt |
|
||||
| iLink 媒体协议细节不明确 | 保留文本链接降级 fallback,逐步灰度 |
|
||||
|
||||
---
|
||||
|
||||
## 四、实施计划
|
||||
|
||||
| Phase | 内容 | 优先级 | 预估工作量 | 依赖 |
|
||||
|-------|------|--------|-----------|------|
|
||||
| P0 | 媒体内联发送(CDN AES 加密) | 🔴 高 | 2-3 天 | `cryptography` 库 |
|
||||
| P1 | sendtyping 正在输入 | 🟡 中 | 0.5 天 | 无 |
|
||||
| P1 | 语音 ASR | 🟡 中 | 0.5 天 | 无 |
|
||||
| P1 | Session Guard 增强 | 🟡 中 | 1 天 | 站内通知 |
|
||||
| P2 | 接收侧媒体解密 | 🟢 低 | 2 天 | P0 经验复用 |
|
||||
|
||||
---
|
||||
|
||||
## 五、验收标准
|
||||
|
||||
- [ ] P0:微信里生成的图片/视频**内联显示**,不再是文本链接
|
||||
- [ ] P1:AI 回复前微信显示"正在输入"
|
||||
- [ ] P1:发语音消息能正确应答(ASR 文本进入 LLM)
|
||||
- [ ] P1:会话过期时用户收到明确重扫通知
|
||||
- [ ] 双 Claw 单引擎不回归(`tests/test_dual_claw.py` 绿)
|
||||
- [ ] pytest + vitest 全绿
|
||||
|
||||
---
|
||||
|
||||
## 六、参考资料
|
||||
|
||||
- 微信官方 iLink (ClawBot) 协议(CSDN: 2401_88147261 / HiWangWenBing / bytenote)
|
||||
- OpenClaw 框架 `@tencent-weixin/openclaw-weixin` 增强:Session Guard / Config Cache / SILK 转码 / CDN 加解密
|
||||
- 平台现状代码:`app/api/im_bot_user.py`、`app/api/admin/im_bot_admin.py`、`app/api/im_llm.py`
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from '@/lib/query-client';
|
||||
import { Layout } from '@/components/layout/Layout';
|
||||
import { LoginPage } from '@/pages/front/LoginPage';
|
||||
import { LandingPage } from '@/pages/front/LandingPage';
|
||||
import { ChatPage } from '@/pages/front/ChatPage';
|
||||
import { KnowledgePage } from '@/pages/front/KnowledgePage';
|
||||
import { ImBotHub } from '@/pages/front/ImBotHub';
|
||||
import { AppServices } from '@/pages/front/AppServices';
|
||||
import { DashboardPage } from '@/pages/front/DashboardPage';
|
||||
import { ProfilePage } from '@/pages/front/ProfilePage';
|
||||
import { MarketPage } from '@/pages/front/MarketPage';
|
||||
import { NotificationsPage } from '@/pages/front/NotificationsPage';
|
||||
import { EvolutionPage } from '@/pages/front/EvolutionPage';
|
||||
import { ComingSoon } from '@/components/common/ComingSoon';
|
||||
import {
|
||||
Bot,
|
||||
Puzzle,
|
||||
Workflow,
|
||||
Shield,
|
||||
Radio,
|
||||
} from 'lucide-react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<LandingPage />} />
|
||||
<Route path="chat" element={<ChatPage />} />
|
||||
<Route path="knowledge" element={<KnowledgePage />} />
|
||||
<Route path="im-bots" element={<ImBotHub />} />
|
||||
<Route path="apps" element={<AppServices />} />
|
||||
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
|
||||
<Route path="market" element={<MarketPage />} />
|
||||
<Route
|
||||
path="agents"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="Agent 智能体"
|
||||
icon={Bot}
|
||||
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
|
||||
features={[
|
||||
'拖拽式 Agent 编排画布',
|
||||
'多 Agent 协作与对话路由',
|
||||
'绑定知识库与 MCP 工具',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="mcp"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="MCP 工具"
|
||||
icon={Puzzle}
|
||||
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
|
||||
features={[
|
||||
'粘贴 MCP 配置一键连接',
|
||||
'工具列表与连通性测试',
|
||||
'官方与社区 MCP 应用市场',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="workflows"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="工作流"
|
||||
icon={Workflow}
|
||||
description="用可视化 DAG 编排多步骤自动化流程。"
|
||||
features={[
|
||||
'节点拖拽编排',
|
||||
'条件分支与并行执行',
|
||||
'可导出可移植的 YAML 规范',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="rules"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="规则库"
|
||||
icon={Shield}
|
||||
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
|
||||
features={[
|
||||
'工具调用四级风险审批',
|
||||
'Loop 原语与验证器',
|
||||
'企业级合规策略模板',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="channels"
|
||||
element={
|
||||
<ComingSoon
|
||||
title="渠道"
|
||||
icon={Radio}
|
||||
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
|
||||
features={[
|
||||
'微信公众号 / 个人微信接入',
|
||||
'IM Bot 多端统一适配',
|
||||
'渠道级权限与配额',
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="profile" element={<ProfilePage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="evolution" element={<EvolutionPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,767 @@
|
||||
const BASE_URL = import.meta.env.VITE_API_URL || '/app/api';
|
||||
|
||||
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> || {}),
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
|
||||
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }));
|
||||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Backend responses use a unified envelope `{code, data, message}`.
|
||||
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
|
||||
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ code: number; data: T; message: string }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
|
||||
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ success: boolean; data: T }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request<{ access_token: string; token_type: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
register: (data: { username: string; email: string; password: string; code: string }) =>
|
||||
request<{ access_token: string }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
me: () => request<{ id: string; username: string; email: string; avatar_url: string }>('/auth/me'),
|
||||
|
||||
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||
|
||||
sendCode: (data: { email: string }) =>
|
||||
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
|
||||
};
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
|
||||
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
|
||||
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model_id: string;
|
||||
status?: string;
|
||||
pinned?: boolean;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count?: number; // 后端未返回,前端可选兼容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model_id: string;
|
||||
created_at: string;
|
||||
token_used?: number;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||
|
||||
createConversation: (payload: { title?: string; model?: string }) =>
|
||||
data<Conversation>('/chat/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteConversation: (id: string) =>
|
||||
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getMessages: (convId: string) =>
|
||||
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||
|
||||
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) return;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.content) yield data.content;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
|
||||
};
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (knowledge.py):
|
||||
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
|
||||
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
status: 'active' | 'building' | 'error' | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeBaseList {
|
||||
bases: KnowledgeBase[];
|
||||
storage_used: number;
|
||||
storage_limit: number;
|
||||
membership_level: number;
|
||||
}
|
||||
|
||||
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||
export interface KnowledgeBaseCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
|
||||
|
||||
createBase: (payload: { name: string; description?: string }) =>
|
||||
data<KnowledgeBaseCreated>('/knowledge/bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteBase: (id: string) =>
|
||||
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
|
||||
|
||||
search: (baseId: string, query: string) =>
|
||||
request<{ results: { content: string; score: number; source: string }[] }>(
|
||||
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
|
||||
),
|
||||
|
||||
getDocuments: (baseId: string) =>
|
||||
request<{ id: string; name: string; status: string; size: number }[]>(
|
||||
`/knowledge/bases/${baseId}/documents`
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
|
||||
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
|
||||
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
|
||||
|
||||
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
|
||||
export type MarketAppSource = 'official' | 'community' | 'third_party';
|
||||
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
|
||||
|
||||
export interface MarketApp {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
source: MarketAppSource;
|
||||
publisher_id: string;
|
||||
publisher_name: string;
|
||||
is_member_free: boolean;
|
||||
join_promotion: boolean;
|
||||
status: MarketAppStatus;
|
||||
price: number;
|
||||
install_count: number;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
created_at: string;
|
||||
capability: string | null;
|
||||
category: string | null;
|
||||
tags: string[];
|
||||
installed: boolean;
|
||||
version: string | null;
|
||||
security_scan: string | null;
|
||||
verified: boolean;
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
|
||||
export interface MarketAppDetail extends MarketApp {
|
||||
long_description?: string;
|
||||
screenshots?: string[];
|
||||
homepage_url?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface MarketReview {
|
||||
id: string;
|
||||
app_id: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
rating: number;
|
||||
comment: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MarketReviewList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketReview[];
|
||||
}
|
||||
|
||||
export interface MarketAd {
|
||||
id: string;
|
||||
title: string;
|
||||
image_url: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface MarketAppList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: MarketApp[];
|
||||
}
|
||||
|
||||
export type MarketSort = 'latest' | 'popular' | 'rating';
|
||||
|
||||
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
|
||||
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
|
||||
export interface MyApp {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
price: number;
|
||||
purchased_at: string | null;
|
||||
}
|
||||
|
||||
export const marketApi = {
|
||||
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||
listApps: (params: {
|
||||
type?: MarketAppType;
|
||||
source?: MarketAppSource;
|
||||
capability?: string;
|
||||
search?: string;
|
||||
sort?: MarketSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.source) qs.set('source', params.source);
|
||||
if (params.capability) qs.set('capability', params.capability);
|
||||
if (params.search) qs.set('search', params.search);
|
||||
if (params.sort) qs.set('sort', params.sort);
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getApp: (appId: string) =>
|
||||
data<MarketAppDetail>(`/apps/${appId}`),
|
||||
|
||||
getMyApps: () =>
|
||||
data<MyApp[]>('/my-apps'),
|
||||
|
||||
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getAds: () =>
|
||||
data<MarketAd[]>('/ads'),
|
||||
|
||||
// ─── 写操作(后续迭代启用)───
|
||||
installApp: (appId: string) =>
|
||||
request(`/apps/${appId}/install`, { method: 'POST' }),
|
||||
|
||||
uninstallApp: (appId: string) =>
|
||||
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
|
||||
|
||||
purchaseApp: (appId: string) =>
|
||||
request(`/apps/${appId}/purchase`, { method: 'POST' }),
|
||||
|
||||
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
|
||||
request(`/apps/${appId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
|
||||
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
|
||||
export interface ImBotStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||
ilink_bot_id: string | null;
|
||||
connected_at: string | null;
|
||||
last_active?: string | null;
|
||||
message_count?: number;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
username?: string | null;
|
||||
baseurl?: string | null;
|
||||
}
|
||||
|
||||
export const imBotApi = {
|
||||
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
|
||||
|
||||
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||
|
||||
checkQRStatus: (ticket: string) =>
|
||||
request<{ status: string }>('/im-bot/qrcode/status', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ticket }),
|
||||
}),
|
||||
|
||||
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
|
||||
|
||||
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
|
||||
};
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MembershipInfo {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar: string;
|
||||
role: string;
|
||||
status: string;
|
||||
membership: MembershipInfo | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogItem {
|
||||
id: string;
|
||||
ip: string;
|
||||
user_agent: string;
|
||||
login_at: string;
|
||||
}
|
||||
|
||||
export interface LoginLogList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: LoginLogItem[];
|
||||
}
|
||||
|
||||
export interface ApiKeyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
key_preview: string;
|
||||
status: string;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
expire_at: string | null;
|
||||
}
|
||||
|
||||
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
|
||||
export interface ApiKeyCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
api_key: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
|
||||
|
||||
export interface PurchasedAppItem {
|
||||
id: string;
|
||||
app_id: string;
|
||||
app_name: string;
|
||||
app_icon: string;
|
||||
app_type: string;
|
||||
price: number;
|
||||
purchased_at: string;
|
||||
}
|
||||
|
||||
export const profileApi = {
|
||||
get: () => data<UserProfile>('/profile/'),
|
||||
|
||||
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
|
||||
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
|
||||
getBalance: () => data<number>('/profile/balance'),
|
||||
|
||||
getMembership: () => data<{
|
||||
has_membership: boolean;
|
||||
id: string;
|
||||
plan_id: string;
|
||||
status: string;
|
||||
start_at: string;
|
||||
expire_at: string;
|
||||
is_expired: boolean;
|
||||
}>('/profile/membership'),
|
||||
|
||||
getLoginLogs: (page = 1, page_size = 10) =>
|
||||
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
|
||||
|
||||
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
|
||||
|
||||
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
|
||||
|
||||
// ─── 写操作 ───
|
||||
|
||||
updatePassword: (payload: { old_password: string; new_password: string }) =>
|
||||
request<{ code: number; message: string }>('/profile/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
createApiKey: (name: string) =>
|
||||
data<ApiKeyCreated>('/profile/api-keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
|
||||
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TokenOverview {
|
||||
total_used: number;
|
||||
balance: number;
|
||||
today_apps: number;
|
||||
server_time: string;
|
||||
}
|
||||
|
||||
export interface ModelRanking {
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
total_tokens: number;
|
||||
usage_count: number;
|
||||
}
|
||||
|
||||
export interface TopApp {
|
||||
conversation_id: string;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface TokenRankings {
|
||||
model_rankings: ModelRanking[];
|
||||
top_apps: TopApp[];
|
||||
}
|
||||
|
||||
export interface TokenLogItem {
|
||||
id: string;
|
||||
model_id: string;
|
||||
model_name: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
conversation_id: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface TokenLogPage {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TokenLogItem[];
|
||||
}
|
||||
|
||||
export interface TokenBalance {
|
||||
balance: number;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export const tokenApi = {
|
||||
getOverview: () => data<TokenOverview>('/token/overview'),
|
||||
|
||||
getRankings: () => data<TokenRankings>('/token/rankings'),
|
||||
|
||||
getLogs: (params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.start_date) qs.set('start_date', params.start_date);
|
||||
if (params.end_date) qs.set('end_date', params.end_date);
|
||||
if (params.model_id) qs.set('model_id', params.model_id);
|
||||
if (params.app_type) qs.set('app_type', params.app_type);
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getBalance: () => data<TokenBalance>('/token/balance'),
|
||||
};
|
||||
|
||||
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
|
||||
// 后端契约 (notifications.py):全部 {code,data,message}
|
||||
// GET / → { total, page, page_size, items:[NotificationItem] }
|
||||
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
|
||||
// GET /unread-count → {code,data:{count},message}
|
||||
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
type: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
is_read: boolean;
|
||||
related_id: string | null;
|
||||
related_type: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationList {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.type) qs.set('type', params.type);
|
||||
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
|
||||
if (params.page) qs.set('page', String(params.page));
|
||||
if (params.page_size) qs.set('page_size', String(params.page_size));
|
||||
const q = qs.toString();
|
||||
return data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
markRead: (id: string) =>
|
||||
request(`/notifications/${id}/read`, { method: 'PUT' }),
|
||||
|
||||
markAllRead: (type?: string) =>
|
||||
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
|
||||
|
||||
unreadCount: () =>
|
||||
data<{ count: number }>('/notifications/unread-count'),
|
||||
};
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
|
||||
// 技能状态 state: main | staging | baby | frozen
|
||||
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
|
||||
|
||||
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
|
||||
|
||||
export interface EvolutionSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
state: EvolutionSkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
last_used_at: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
skill_md_content?: string | null; // 仅 detail 返回
|
||||
}
|
||||
|
||||
export interface EvolutionEvent {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
event_type: string;
|
||||
description: string;
|
||||
version_before: number | null;
|
||||
version_after: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionTrajectory {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
file_path: string | null;
|
||||
status: string;
|
||||
atom_count: number | null;
|
||||
token_used: number | null;
|
||||
created_at: string | null;
|
||||
processed_at: string | null;
|
||||
weightscore?: number;
|
||||
atom_id?: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionSkillTree {
|
||||
categories: { name: string; skills: EvolutionSkill[] }[];
|
||||
total_count: number;
|
||||
main_count: number;
|
||||
baby_count: number;
|
||||
staging_count: number;
|
||||
frozen_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionStatistics {
|
||||
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
|
||||
total_use_count: number;
|
||||
category_distribution: { category: string; count: number }[];
|
||||
daily_trend: { date: string; count: number }[];
|
||||
low_use_skills: EvolutionSkill[];
|
||||
}
|
||||
|
||||
export interface EvolutionDimension {
|
||||
label: string;
|
||||
value: number;
|
||||
maxValue: number;
|
||||
}
|
||||
|
||||
export interface EvolutionProfile {
|
||||
level: number;
|
||||
level_title: string;
|
||||
total_skills: number;
|
||||
active_skills: number;
|
||||
staging_skills: number;
|
||||
baby_skills: number;
|
||||
total_use_count: number;
|
||||
dimensions: EvolutionDimension[];
|
||||
tags: string[];
|
||||
growth_suggestions: string[];
|
||||
}
|
||||
|
||||
export interface EvolutionPipelineStatus {
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
discovered_count: number;
|
||||
done_count: number;
|
||||
total_skills: number;
|
||||
total_trajectories: number;
|
||||
}
|
||||
|
||||
export interface EvolutionWatcherStatus {
|
||||
user_id: string;
|
||||
scheduler_running: boolean;
|
||||
watcher_status: string;
|
||||
pipeline_status: string;
|
||||
pipeline_processed: number;
|
||||
watcher_error: string | null;
|
||||
skill_count: number;
|
||||
trajectory_count: number;
|
||||
}
|
||||
|
||||
export interface EvolutionReport {
|
||||
id: string;
|
||||
report_type: string;
|
||||
period_start: string | null;
|
||||
period_end: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EvolutionReportDetail extends EvolutionReport {
|
||||
user_id: string;
|
||||
report_data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const evolutionApi = {
|
||||
// 技能管理
|
||||
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.state) qs.set('state', params.state);
|
||||
if (params.keyword) qs.set('keyword', params.keyword);
|
||||
if (params.sort_by) qs.set('sort_by', params.sort_by);
|
||||
if (params.order) qs.set('order', params.order);
|
||||
const q = qs.toString();
|
||||
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
getSkillDetail: (skillName: string) =>
|
||||
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
|
||||
|
||||
// 进化事件
|
||||
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.event_type) qs.set('event_type', params.event_type);
|
||||
if (params.skill_name) qs.set('skill_name', params.skill_name);
|
||||
if (params.limit) qs.set('limit', String(params.limit));
|
||||
const q = qs.toString();
|
||||
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
|
||||
},
|
||||
|
||||
// 驾驶舱
|
||||
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
|
||||
getTimeline: (event_type?: string) =>
|
||||
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
|
||||
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
|
||||
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
|
||||
|
||||
// 流水线 / Watcher
|
||||
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
|
||||
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
|
||||
|
||||
// 报告
|
||||
listReports: (report_type?: string) =>
|
||||
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
|
||||
getReportDetail: (reportId: string) =>
|
||||
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
|
||||
};
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authApi,
|
||||
chatApi,
|
||||
knowledgeApi,
|
||||
marketApi,
|
||||
imBotApi,
|
||||
profileApi,
|
||||
tokenApi,
|
||||
notificationsApi,
|
||||
evolutionApi,
|
||||
type TokenOverview,
|
||||
type MyApp,
|
||||
type MarketAd,
|
||||
type MarketSort,
|
||||
type MarketAppType,
|
||||
type MarketAppSource,
|
||||
type EvolutionSkill,
|
||||
type EvolutionEvent,
|
||||
type EvolutionReport,
|
||||
} from '@/api/client';
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { username: string; password: string }) => authApi.login(data),
|
||||
onSuccess: (res) => {
|
||||
localStorage.setItem('auth_token', res.access_token);
|
||||
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: ['auth', 'me'],
|
||||
queryFn: authApi.me,
|
||||
retry: false,
|
||||
enabled: !!localStorage.getItem('auth_token'),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useConversations() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'conversations'],
|
||||
queryFn: chatApi.listConversations,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMessages(convId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'messages', convId],
|
||||
queryFn: () => chatApi.getMessages(convId!),
|
||||
enabled: !!convId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { title?: string; model?: string }) => chatApi.createConversation(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSendMessage(convId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { content: string; model?: string }) => chatApi.sendMessage(convId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'messages', convId] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteConversation() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => chatApi.deleteConversation(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useModels() {
|
||||
return useQuery({
|
||||
queryKey: ['chat', 'models'],
|
||||
queryFn: chatApi.getModels,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useKnowledgeBases() {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'bases'],
|
||||
queryFn: knowledgeApi.listBases,
|
||||
select: (d) => {
|
||||
const bases = Array.isArray(d?.bases) ? d.bases : [];
|
||||
// 后端 create 返回缺 document_count/status/updated_at,这里补默认值避免渲染崩溃
|
||||
return bases.map((kb) => ({
|
||||
...kb,
|
||||
document_count: kb.document_count ?? 0,
|
||||
status: kb.status ?? 'active',
|
||||
updated_at: kb.updated_at ?? kb.created_at ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; description?: string }) => knowledgeApi.createBase(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteKnowledgeBase() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => knowledgeApi.deleteBase(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useKnowledgeSearch(baseId: string | null, query: string) {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'search', baseId, query],
|
||||
queryFn: () => knowledgeApi.search(baseId!, query),
|
||||
enabled: !!baseId && query.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Market / Apps ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Backend list endpoints may return a bare array, `{data:[...]}`, or an error
|
||||
// object. Coerce to a real array so consumers can always call .slice/.map/.filter.
|
||||
function asArray<T>(v: unknown): T[] {
|
||||
if (Array.isArray(v)) return v as T[];
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).data)) return (v as any).data;
|
||||
if (v && typeof v === 'object' && Array.isArray((v as any).items)) return (v as any).items;
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface MarketAppsParams {
|
||||
type?: MarketAppType;
|
||||
source?: MarketAppSource;
|
||||
capability?: string;
|
||||
search?: string;
|
||||
sort?: MarketSort;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export function useMarketApps(params: MarketAppsParams = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'apps', params],
|
||||
queryFn: () => marketApi.listApps(params),
|
||||
placeholderData: (prev) => prev, // 翻页/筛选时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketApp(appId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'app', appId],
|
||||
queryFn: () => marketApi.getApp(appId!),
|
||||
enabled: !!appId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'my-apps'],
|
||||
queryFn: marketApi.getMyApps,
|
||||
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketReviews(appId: string | null, params: { page?: number; page_size?: number } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'reviews', appId, params],
|
||||
queryFn: () => marketApi.getReviews(appId!, params),
|
||||
enabled: !!appId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarketAds() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'ads'],
|
||||
queryFn: marketApi.getAds,
|
||||
select: (d: unknown) => asArray<MarketAd>(d),
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => marketApi.installApp(appId),
|
||||
onSuccess: (_res, appId) => {
|
||||
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUninstallApp() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (appId: string) => marketApi.uninstallApp(appId),
|
||||
onSuccess: (_res, appId) => {
|
||||
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
|
||||
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useImBotStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'status'],
|
||||
queryFn: imBotApi.getStatus,
|
||||
refetchInterval: 30000, // poll every 30s
|
||||
});
|
||||
}
|
||||
|
||||
export function useImBotQRCode() {
|
||||
return useQuery({
|
||||
queryKey: ['im-bot', 'qrcode'],
|
||||
queryFn: imBotApi.getQRCode,
|
||||
enabled: false, // only on demand
|
||||
});
|
||||
}
|
||||
|
||||
export function useDisconnectBot() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => imBotApi.disconnect(),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['im-bot', 'status'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['profile'],
|
||||
queryFn: profileApi.get,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'balance'],
|
||||
queryFn: profileApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileMembership() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'membership'],
|
||||
queryFn: profileApi.getMembership,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileLoginLogs(page = 1, page_size = 10) {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'login-logs', page, page_size],
|
||||
queryFn: () => profileApi.getLoginLogs(page, page_size),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfileApiKeys() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'api-keys'],
|
||||
queryFn: profileApi.getApiKeys,
|
||||
});
|
||||
}
|
||||
|
||||
export function useProfilePurchasedApps() {
|
||||
return useQuery({
|
||||
queryKey: ['profile', 'purchased-apps'],
|
||||
queryFn: profileApi.getPurchasedApps,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function useUpdateProfilePassword() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { old_password: string; new_password: string }) =>
|
||||
profileApi.updatePassword(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => profileApi.createApiKey(name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateApiKey() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
|
||||
profileApi.updateApiKey(keyId, status),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Landing hero still shows a few headline numbers; reuse the real token overview
|
||||
// so the homepage never ships fake/mock values.
|
||||
export function useDashboardStats() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
select: (d: TokenOverview) => ({
|
||||
active_users: d.today_apps,
|
||||
today_conversations: d.today_apps,
|
||||
skill_count: d.total_used,
|
||||
revenue: d.balance,
|
||||
user_change: '',
|
||||
conv_change: '',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenOverview() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'overview'],
|
||||
queryFn: tokenApi.getOverview,
|
||||
refetchInterval: 60000, // 每分钟刷新一次概览
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenRankings() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'rankings'],
|
||||
queryFn: tokenApi.getRankings,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenBalance() {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'balance'],
|
||||
queryFn: tokenApi.getBalance,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTokenLogs(params: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
app_type?: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ['token', 'logs', params],
|
||||
queryFn: () => tokenApi.getLogs(params),
|
||||
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Notifications 消息通知 ─────────────────────────────────────────────────────
|
||||
|
||||
export function useNotifications(params: {
|
||||
type?: string;
|
||||
is_read?: boolean;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'list', params],
|
||||
queryFn: () => notificationsApi.list(params),
|
||||
placeholderData: (prev) => prev, // 翻页/筛选保留旧数据,避免闪烁
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount() {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationsApi.unreadCount,
|
||||
refetchInterval: 60000, // 每分钟刷新未读计数
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (type?: string) => notificationsApi.markAllRead(type),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
|
||||
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
|
||||
|
||||
export function useEvolutionSkills(params: {
|
||||
state?: string;
|
||||
keyword?: string;
|
||||
sort_by?: string;
|
||||
order?: string;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skills', params],
|
||||
queryFn: () => evolutionApi.listSkills(params),
|
||||
select: (d: EvolutionSkill[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionEvents(params: {
|
||||
event_type?: string;
|
||||
skill_name?: string;
|
||||
limit?: number;
|
||||
} = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'events', params],
|
||||
queryFn: () => evolutionApi.listEvents(params),
|
||||
select: (d: EvolutionEvent[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionSkillTree() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'skill-tree'],
|
||||
queryFn: evolutionApi.getSkillTree,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionStatistics() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'statistics'],
|
||||
queryFn: evolutionApi.getStatistics,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionProfile() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'profile'],
|
||||
queryFn: evolutionApi.getProfile,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionPipelineStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'pipeline-status'],
|
||||
queryFn: evolutionApi.getPipelineStatus,
|
||||
refetchInterval: 30000, // 流水线状态每 30s 轮询
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionWatcherStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'watcher-status'],
|
||||
queryFn: evolutionApi.getWatcherStatus,
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEvolutionReports(report_type?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['evolution', 'reports', report_type],
|
||||
queryFn: () => evolutionApi.listReports(report_type),
|
||||
select: (d: EvolutionReport[]) => (Array.isArray(d) ? d : []),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
AppWindow,
|
||||
Plus,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Globe,
|
||||
Monitor,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useMyApps } from '@/hooks/useApi';
|
||||
import type { MyApp } from '@/api/client';
|
||||
|
||||
export function AppServices() {
|
||||
const { data: apps = [], isLoading } = useMyApps();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理已安装的 AI 应用</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 创建应用
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : apps.length === 0 ? (
|
||||
<div className="col-span-full text-center py-12 text-muted-foreground">
|
||||
<AppWindow size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无应用</p>
|
||||
<p className="text-sm">去应用市场浏览并安装应用</p>
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app: MyApp) => (
|
||||
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
{app.icon ? (
|
||||
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
|
||||
) : app.type === 'standalone' ? (
|
||||
<Globe size={20} className="text-primary" />
|
||||
) : (
|
||||
<Monitor size={20} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="success">已安装</Badge>
|
||||
<Badge variant="outline">{app.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'}
|
||||
{app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''}
|
||||
</p>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 配置
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
variant="default"
|
||||
>
|
||||
<ExternalLink size={14} /> 打开
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Create New */}
|
||||
<Card className="border-dashed hover:shadow-md transition-shadow cursor-pointer">
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[220px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">创建新应用</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Send,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useConversations,
|
||||
useMessages,
|
||||
useCreateConversation,
|
||||
useSendMessage,
|
||||
useDeleteConversation,
|
||||
} from '@/hooks/useApi';
|
||||
import type { Conversation, ChatMessage } from '@/api/client';
|
||||
|
||||
export function ChatPage() {
|
||||
const [selectedConv, setSelectedConv] = useState<string | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// API hooks
|
||||
const { data: conversations = [], isLoading: convsLoading } = useConversations();
|
||||
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
|
||||
const createConv = useCreateConversation();
|
||||
const sendMessage = useSendMessage(selectedConv || '');
|
||||
const deleteConv = useDeleteConversation();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const conv = await createConv.mutateAsync({ title: '新对话' });
|
||||
setSelectedConv(conv.id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !selectedConv) return;
|
||||
const content = input;
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
// Try streaming first
|
||||
const stream = chatApi.streamMessage(selectedConv, { content });
|
||||
let accumulated = '';
|
||||
for await (const chunk of stream) {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
}
|
||||
setStreamingContent('');
|
||||
// Refresh messages
|
||||
} catch {
|
||||
// Fallback to regular send
|
||||
await sendMessage.mutateAsync({ content });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteConv.mutateAsync(id);
|
||||
if (selectedConv === id) setSelectedConv(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
||||
{/* Conversation List */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mx-3 mt-3 gap-2"
|
||||
variant="outline"
|
||||
onClick={handleNewChat}
|
||||
disabled={createConv.isPending}
|
||||
>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{convsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv: Conversation) => (
|
||||
<div key={conv.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
|
||||
selectedConv === conv.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 ml-5">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{conv.model_id || 'Auto'}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
|
||||
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
|
||||
>
|
||||
<Trash2 size={12} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{!selectedConv ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
|
||||
</p>
|
||||
<Button className="mt-4 gap-2" onClick={handleNewChat}>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
</div>
|
||||
) : msgsLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((msg: ChatMessage) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}>
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsUp size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsDown size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<RotateCcw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streamingContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
|
||||
className="flex-1"
|
||||
disabled={!selectedConv || sendMessage.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{sendMessage.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles size={12} /> Auto(自动匹配)
|
||||
</div>
|
||||
<button className="p-1 hover:bg-muted rounded transition-colors">
|
||||
<Settings size={16} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Import for streaming
|
||||
import { chatApi } from '@/api/client';
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Sparkles,
|
||||
Brain,
|
||||
Network,
|
||||
BarChart3,
|
||||
Activity,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
Cpu,
|
||||
ArrowUpRight,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useEvolutionProfile,
|
||||
useEvolutionSkills,
|
||||
useEvolutionStatistics,
|
||||
useEvolutionPipelineStatus,
|
||||
useEvolutionEvents,
|
||||
} from '@/hooks/useApi';
|
||||
import type {
|
||||
EvolutionSkill,
|
||||
EvolutionSkillState,
|
||||
EvolutionStatistics,
|
||||
EvolutionProfile,
|
||||
EvolutionPipelineStatus,
|
||||
EvolutionEvent,
|
||||
} from '@/api/client';
|
||||
|
||||
// 技能状态 → 中文标签 + 颜色
|
||||
const STATE_LABELS: Record<EvolutionSkillState, string> = {
|
||||
main: '成熟',
|
||||
staging: '灰度',
|
||||
baby: '萌芽',
|
||||
frozen: '冻结',
|
||||
};
|
||||
|
||||
const STATE_BADGE: Record<EvolutionSkillState, 'default' | 'secondary' | 'outline' | 'success'> = {
|
||||
main: 'success',
|
||||
staging: 'default',
|
||||
baby: 'secondary',
|
||||
frozen: 'outline',
|
||||
};
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// 雷达/维度条
|
||||
function DimensionBars({ dimensions }: { dimensions: EvolutionProfile['dimensions'] }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{dimensions.map((dim) => {
|
||||
const pct = Math.round((dim.value / dim.maxValue) * 100);
|
||||
return (
|
||||
<div key={dim.label}>
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-muted-foreground">{dim.label}</span>
|
||||
<span className="font-medium text-foreground">{dim.value.toFixed(1)}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 能力画像卡
|
||||
function ProfileCard({ profile }: { profile: EvolutionProfile }) {
|
||||
return (
|
||||
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-amber-50/40 dark:to-amber-950/20">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles size={30} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-foreground">
|
||||
Lv.{profile.level} {profile.level_title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
共 {profile.total_skills} 个技能 · 累计调用 {profile.total_use_count} 次
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
<DimensionBars dimensions={profile.dimensions} />
|
||||
|
||||
{profile.growth_suggestions.length > 0 && (
|
||||
<div className="mt-5 space-y-2">
|
||||
<p className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<TrendingUp size={15} className="text-primary" />
|
||||
成长建议
|
||||
</p>
|
||||
{profile.growth_suggestions.map((s, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<ArrowUpRight size={14} className="text-primary mt-0.5 shrink-0" />
|
||||
<span>{s}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profile.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-4">
|
||||
{profile.tags.map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-xs">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 技能网格
|
||||
function SkillCard({ skill }: { skill: EvolutionSkill }) {
|
||||
return (
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Cpu size={17} className="text-primary" />
|
||||
</div>
|
||||
<h4 className="font-semibold text-foreground truncate">{skill.skill_name}</h4>
|
||||
</div>
|
||||
<Badge variant={STATE_BADGE[skill.state] ?? 'outline'} className="shrink-0">
|
||||
{STATE_LABELS[skill.state] ?? skill.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2rem]">
|
||||
{skill.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>调用 {skill.use_count}</span>
|
||||
<span>v{skill.version}</span>
|
||||
</div>
|
||||
{skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{skill.tags.slice(0, 3).map((t) => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] py-0">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 流水线状态条
|
||||
function PipelineStatusCard({ status }: { status: EvolutionPipelineStatus }) {
|
||||
const running = status.scheduler_running;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化流水线</h3>
|
||||
<Badge
|
||||
variant={running ? 'success' : 'outline'}
|
||||
className="ml-auto gap-1"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'w-1.5 h-1.5 rounded-full ' +
|
||||
(running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground')
|
||||
}
|
||||
/>
|
||||
{running ? '运行中' : '未运行'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
|
||||
<Stat label="待处理轨迹" value={status.discovered_count} />
|
||||
<Stat label="已完成轨迹" value={status.done_count} />
|
||||
<Stat label="技能总数" value={status.total_skills} />
|
||||
<Stat label="轨迹总数" value={status.total_trajectories} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-3">
|
||||
流水线状态:{status.pipeline_status} · 引擎:{status.watcher_status}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/50 py-3">
|
||||
<p className="text-xl font-bold text-foreground">{value}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 统计卡(Top 技能 + 类别分布)
|
||||
function StatisticsCard({ stats }: { stats: EvolutionStatistics }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<BarChart3 size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">使用统计</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
总调用 {stats.total_use_count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Top 技能 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<TrendingUp size={14} className="text-primary" />
|
||||
热门技能 Top {stats.ranking.length}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{stats.ranking.slice(0, 5).map((r, i) => {
|
||||
const max = stats.ranking[0]?.use_count || 1;
|
||||
const pct = Math.round((r.use_count / max) * 100);
|
||||
return (
|
||||
<div key={r.skill_name}>
|
||||
<div className="flex items-center justify-between text-xs mb-0.5">
|
||||
<span className="text-muted-foreground truncate">
|
||||
{i + 1}. {r.skill_name}
|
||||
</span>
|
||||
<span className="font-medium text-foreground shrink-0 ml-2">
|
||||
{r.use_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{stats.ranking.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无使用数据</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
{/* 类别分布 */}
|
||||
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
|
||||
<Layers size={14} className="text-primary" />
|
||||
类别分布
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.category_distribution.map((c) => (
|
||||
<Badge key={c.category} variant="secondary" className="gap-1">
|
||||
{c.category}
|
||||
<span className="text-primary font-medium">{c.count}</span>
|
||||
</Badge>
|
||||
))}
|
||||
{stats.category_distribution.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">暂无分布数据</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 事件流
|
||||
function EventFeed({ events }: { events: EvolutionEvent[] }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<GitBranch size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">进化事件流</h3>
|
||||
</div>
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">暂无进化事件</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{events.map((e) => (
|
||||
<div key={e.id} className="flex items-start gap-3">
|
||||
<div className="w-2 h-2 rounded-full bg-primary mt-2 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-foreground">{e.description}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{e.skill_name}
|
||||
{e.version_after != null && ` · v${e.version_after}`} ·{' '}
|
||||
{formatTime(e.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0 text-[10px]">
|
||||
{e.event_type}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function EvolutionPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const { data: profile, isLoading: pLoading } = useEvolutionProfile();
|
||||
const { data: skills, isLoading: sLoading } = useEvolutionSkills();
|
||||
const { data: stats } = useEvolutionStatistics();
|
||||
const { data: pipeline } = useEvolutionPipelineStatus();
|
||||
const { data: events } = useEvolutionEvents({ limit: 30 });
|
||||
|
||||
const skillList: EvolutionSkill[] = Array.isArray(skills) ? skills : [];
|
||||
// 按 state 分组
|
||||
const grouped = useGroupedSkills(skillList);
|
||||
const isLoading = pLoading || sLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Brain size={20} className="text-primary" />
|
||||
AI 自进化
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
平台从对话与工具调用中持续学习,自动沉淀与迭代你的专属技能
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
onClick={() => setRefreshKey((k) => k + 1)}
|
||||
>
|
||||
<RefreshCw size={15} className={refreshKey ? 'animate-spin' : ''} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={refreshKey}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 能力画像 + 流水线状态 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{profile ? (
|
||||
<ProfileCard profile={profile} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
<Brain size={40} className="mx-auto mb-3 opacity-50" />
|
||||
暂无能力数据,多使用 AI 即可激活自进化
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{pipeline && <PipelineStatusCard status={pipeline} />}
|
||||
</div>
|
||||
|
||||
{/* 统计 + 事件流 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{stats && <StatisticsCard stats={stats} />}
|
||||
{events && <EventFeed events={events} />}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 技能图谱 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Network size={18} className="text-primary" />
|
||||
<h3 className="font-semibold text-foreground">技能图谱</h3>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{skillList.length} 个技能
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{skillList.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Network size={44} className="mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">还没有进化出技能,继续与 AI 对话吧</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{grouped.map((g) => (
|
||||
<div key={g.category}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{g.category}
|
||||
</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{g.skills.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{g.skills.map((sk) => (
|
||||
<SkillCard key={sk.id} skill={sk} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 按 category 分组,保持顺序稳定
|
||||
function useGroupedSkills(skills: EvolutionSkill[]): { category: string; skills: EvolutionSkill[] }[] {
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, EvolutionSkill[]>();
|
||||
for (const s of skills) {
|
||||
const cat = s.category || '未分类';
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(s);
|
||||
}
|
||||
return Array.from(map.entries()).map(([category, list]) => ({ category, skills: list }));
|
||||
}, [skills]);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
|
||||
|
||||
const otherChannels = [
|
||||
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
];
|
||||
|
||||
export function ImBotHub() {
|
||||
const { data: wechatStatus, isLoading } = useImBotStatus();
|
||||
const disconnectBot = useDisconnectBot();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('确定断开微信连接?')) return;
|
||||
try {
|
||||
await disconnectBot.mutateAsync();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* WeChat Bot - Real Data */}
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
|
||||
💬
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">个人微信</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{wechatStatus?.ilink_bot_id || '未配置'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
|
||||
{isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : wechatStatus?.status === 'connected' ? (
|
||||
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
|
||||
) : wechatStatus?.status === 'expired' ? (
|
||||
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{wechatStatus?.status === 'connected' && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
|
||||
</span>
|
||||
<span>
|
||||
连接于 {wechatStatus.connected_at ? new Date(wechatStatus.connected_at).toLocaleString('zh-CN') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatStatus?.status === 'expired' && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
|
||||
会话已过期,请重新扫码连接
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{wechatStatus?.status === 'connected' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 管理
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink size={14} /> 查看对话
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnectBot.isPending}
|
||||
>
|
||||
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
断开连接
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Other Channels - Mock */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{otherChannels.map((channel) => (
|
||||
<Card key={channel.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
|
||||
{channel.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{channel.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useKnowledgeBases,
|
||||
useCreateKnowledgeBase,
|
||||
useDeleteKnowledgeBase,
|
||||
} from '@/hooks/useApi';
|
||||
import type { KnowledgeBase } from '@/api/client';
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const { data: bases = [], isLoading } = useKnowledgeBases();
|
||||
const createBase = useCreateKnowledgeBase();
|
||||
const deleteBase = useDeleteKnowledgeBase();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createBase.mutateAsync({ name: newName, description: newDesc });
|
||||
setShowCreateDialog(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此知识库?')) return;
|
||||
try {
|
||||
await deleteBase.mutateAsync(id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="在所有知识库中检索问题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
支持跨库检索 · 向量相似度排序 · 来源溯源
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="all">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部知识库</TabsTrigger>
|
||||
<TabsTrigger value="recent">最近使用</TabsTrigger>
|
||||
</TabsList>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 新建知识库
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建知识库</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
placeholder="知识库名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
placeholder="简要描述(可选)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createBase.isPending}
|
||||
>
|
||||
{createBase.isPending ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
|
||||
) : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="mt-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
bases.map((kb: KnowledgeBase) => (
|
||||
<Card key={kb.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Database size={20} className="text-primary" />
|
||||
</div>
|
||||
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
|
||||
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText size={14} /> {kb.document_count} 文档
|
||||
</span>
|
||||
</div>
|
||||
{kb.description && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<Upload size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<RefreshCw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
disabled={deleteBase.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Add New Card */}
|
||||
<Card
|
||||
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Search,
|
||||
Store,
|
||||
Star,
|
||||
Download,
|
||||
Check,
|
||||
Sparkles,
|
||||
Globe,
|
||||
Bot,
|
||||
Workflow,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
Crown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useMarketApps,
|
||||
useMarketAds,
|
||||
type MarketAppsParams,
|
||||
} from '@/hooks/useApi';
|
||||
import type {
|
||||
MarketApp,
|
||||
MarketAppType,
|
||||
MarketAppSource,
|
||||
MarketSort,
|
||||
} from '@/api/client';
|
||||
|
||||
const TYPE_LABELS: Record<MarketAppType, string> = {
|
||||
standalone: '独立应用',
|
||||
embedded: '嵌入式',
|
||||
skill: '技能',
|
||||
agent: '智能体',
|
||||
workflow: '工作流',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<MarketAppType, typeof Globe> = {
|
||||
standalone: Globe,
|
||||
embedded: Puzzle,
|
||||
skill: Sparkles,
|
||||
agent: Bot,
|
||||
workflow: Workflow,
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<MarketAppSource, string> = {
|
||||
official: '官方',
|
||||
community: '社区',
|
||||
third_party: '第三方',
|
||||
};
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatPrice(app: MarketApp): { label: string; free: boolean } {
|
||||
if (app.is_member_free) return { label: '会员免费', free: true };
|
||||
if (app.price > 0) return { label: `¥${app.price}`, free: false };
|
||||
return { label: '免费', free: true };
|
||||
}
|
||||
|
||||
export function MarketPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [type, setType] = useState<MarketAppType | undefined>(undefined);
|
||||
const [sort, setSort] = useState<MarketSort>('popular');
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
// 简单防抖搜索(300ms)
|
||||
useMemo(() => {
|
||||
const t = setTimeout(() => setDebounced(search.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const params: MarketAppsParams = useMemo(
|
||||
() => ({
|
||||
type,
|
||||
search: debounced || undefined,
|
||||
sort,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
}),
|
||||
[type, debounced, sort, page]
|
||||
);
|
||||
|
||||
const { data, isLoading, isFetching } = useMarketApps(params);
|
||||
const { data: ads } = useMarketAds();
|
||||
|
||||
const items: MarketApp[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const typeFilters: (MarketAppType | undefined)[] = [
|
||||
undefined,
|
||||
'skill',
|
||||
'agent',
|
||||
'workflow',
|
||||
'standalone',
|
||||
'embedded',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Store size={20} className="text-primary" />
|
||||
技能市场
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
浏览、安装官方与社区技能,一键接入平台能力
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 广告位 */}
|
||||
{ads && ads.length > 0 && (
|
||||
<a
|
||||
href={ads[0].url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-xl overflow-hidden border border-primary/20 bg-gradient-to-r from-primary/10 to-amber-50 dark:to-amber-950/30"
|
||||
>
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Sparkles size={22} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">{ads[0].title}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">点击查看详情 →</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 搜索 + 排序 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索技能、智能体、工作流…"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(['popular', 'latest', 'rating'] as MarketSort[]).map((s) => (
|
||||
<Button
|
||||
key={s}
|
||||
size="sm"
|
||||
variant={sort === s ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setSort(s);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{s === 'popular' ? '最热' : s === 'latest' ? '最新' : '评分'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{typeFilters.map((tf) => (
|
||||
<Button
|
||||
key={tf ?? 'all'}
|
||||
size="sm"
|
||||
variant={type === tf ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setType(tf);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{tf ? TYPE_LABELS[tf] : '全部'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Store size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">未找到匹配的技能</p>
|
||||
<p className="text-sm">试试调整搜索词或筛选条件</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{items.map((app: MarketApp) => {
|
||||
const TypeIcon = TYPE_ICONS[app.type] ?? Sparkles;
|
||||
const price = formatPrice(app);
|
||||
return (
|
||||
<Card
|
||||
key={app.id}
|
||||
className="hover:shadow-md transition-shadow flex flex-col"
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-11 h-11 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
{app.icon ? (
|
||||
<img
|
||||
src={app.icon}
|
||||
alt={app.name}
|
||||
className="w-7 h-7 rounded"
|
||||
/>
|
||||
) : (
|
||||
<TypeIcon size={22} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
{app.verified && (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<ShieldCheck size={11} />
|
||||
已认证
|
||||
</Badge>
|
||||
)}
|
||||
{app.installed ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Check size={11} />
|
||||
已安装
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{SOURCE_LABELS[app.source]}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-foreground truncate">{app.name}</h3>
|
||||
{app.is_member_free && (
|
||||
<Crown size={14} className="text-amber-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
由 {app.publisher_name} 发布
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4 flex-1">
|
||||
{app.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
|
||||
{app.rating_avg > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Star size={13} className="text-amber-500 fill-amber-500" />
|
||||
{app.rating_avg.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Download size={13} />
|
||||
{formatCount(app.install_count)}
|
||||
</span>
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{TYPE_LABELS[app.type]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-3" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={
|
||||
price.free
|
||||
? 'text-sm font-medium text-green-600 dark:text-green-400'
|
||||
: 'text-sm font-semibold text-foreground'
|
||||
}
|
||||
>
|
||||
{price.label}
|
||||
</span>
|
||||
{app.installed ? (
|
||||
<Button size="sm" variant="outline" disabled className="gap-1">
|
||||
<Check size={14} />
|
||||
已安装
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<Download size={14} />
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
Check,
|
||||
Circle,
|
||||
Loader2,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
useMarkNotificationRead,
|
||||
useMarkAllNotificationsRead,
|
||||
} from '@/hooks/useApi';
|
||||
import type { NotificationItem } from '@/api/client';
|
||||
|
||||
// 通知类型 → 中文标签(后端 type 透传,未知类型回退「通知」)
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
task: '任务',
|
||||
billing: '账单',
|
||||
system: '系统',
|
||||
evolution: '自进化',
|
||||
market: '市场',
|
||||
};
|
||||
|
||||
function typeLabel(t: string | null): string {
|
||||
if (!t) return '通知';
|
||||
return TYPE_LABELS[t] ?? t;
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const min = Math.floor(diff / 60000);
|
||||
if (min < 1) return '刚刚';
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr} 小时前`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 7) return `${day} 天前`;
|
||||
return d.toLocaleDateString('zh-CN');
|
||||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [isRead, setIsRead] = useState<boolean | undefined>(undefined);
|
||||
const [page, setPage] = useState(1);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const { data, isLoading, isFetching } = useNotifications({
|
||||
is_read: isRead,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
const { data: unread } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
|
||||
const items: NotificationItem[] = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
const unreadCount = unread?.count ?? 0;
|
||||
|
||||
// 筛选 tabs:全部 / 未读 / 已读
|
||||
const filters: { key: string; label: string; value: boolean | undefined }[] = [
|
||||
{ key: 'all', label: '全部', value: undefined },
|
||||
{ key: 'unread', label: '未读', value: false },
|
||||
{ key: 'read', label: '已读', value: true },
|
||||
];
|
||||
|
||||
const busy = markRead.isPending || markAllRead.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Bell size={20} className="text-primary" />
|
||||
消息通知
|
||||
{unreadCount > 0 && (
|
||||
<Badge className="bg-primary/10 text-primary border-primary/20 ml-1">
|
||||
{unreadCount} 未读
|
||||
</Badge>
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
集中查看系统通知、任务动态与配额告警
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={unreadCount === 0 || busy}
|
||||
onClick={() => markAllRead.mutate(undefined)}
|
||||
>
|
||||
<CheckCheck size={15} />
|
||||
全部已读
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 筛选 tabs */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter size={15} className="text-muted-foreground" />
|
||||
{filters.map((f) => (
|
||||
<Button
|
||||
key={f.key}
|
||||
size="sm"
|
||||
variant={isRead === f.value ? 'default' : 'outline'}
|
||||
onClick={() => {
|
||||
setIsRead(f.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{f.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 列表 */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 size={28} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground">
|
||||
<Bell size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg font-medium mb-1">暂无通知</p>
|
||||
<p className="text-sm">
|
||||
{isRead === false ? '所有通知都已读啦' : '系统有动态时会在这里提醒你'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
{items.map((n: NotificationItem) => (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={
|
||||
'transition-colors ' +
|
||||
(n.is_read ? 'bg-card' : 'bg-primary/5 border-primary/20')
|
||||
}
|
||||
>
|
||||
<CardContent className="p-4 flex items-start gap-3">
|
||||
{/* 已读/未读指示 */}
|
||||
<div className="mt-1 shrink-0">
|
||||
{n.is_read ? (
|
||||
<Check size={16} className="text-muted-foreground" />
|
||||
) : (
|
||||
<Circle size={10} className="fill-primary text-primary mt-1.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{typeLabel(n.type)}
|
||||
</Badge>
|
||||
<h3 className="font-medium text-foreground truncate">
|
||||
{n.title}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground ml-auto shrink-0">
|
||||
{formatTime(n.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{n.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!n.is_read && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="shrink-0 gap-1 text-primary hover:text-primary"
|
||||
disabled={markRead.isPending}
|
||||
onClick={() => markRead.mutate(n.id)}
|
||||
>
|
||||
<Check size={14} />
|
||||
标为已读
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-4 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 1 || isFetching}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages || isFetching}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>七虎AI - 养龙虾与AI能力探索</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4577
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "qihuai-frontend",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.18.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.29",
|
||||
"terser": "^5.31.0",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user