Compare commits

..
32 Commits
Author SHA1 Message Date
qihu-bot d2d133f22a docs: 全面更新 README.md + ARCHITECTURE.md v3.0
CI / gate (push) Has been cancelled
ARCHITECTURE.md v3.0: 护城河分析,容器拓扑,技术栈矩阵,功能架构,双Claw单引擎,模型同步,安全纵深,qihu-design-system,迭代路线. README.md v3.0: 快速开始,核心能力,架构总览,项目结构,开发指南,平台数据.
2026-08-12 09:20:55 +08:00
qihu-bot 54b444b651 #967 implement media_relay endpoint - fix WeChat bot image relay
media_relay was a stub (empty function body). Now implements actual image/video streaming relay from S3 to WeChat clients, with domain whitelist and httpx streaming. Fixes WeChat bot image generation responses not rendered.
2026-08-11 21:31:15 +08:00
qihu-bot 75be24ce9c #966 fix - litellm_sync import + IndentationError
## Root cause: ImportError sync_health_check not defined. ## Also IndentationError on L467/L590 in nested sync blocks. ## Container stable, public URL 200 OK.
2026-08-11 21:12:56 +08:00
qihu-bot fd84fec5b1 #965 LiteLLM model sync - STORE_MODEL_IN_DB + API integration
docker-compose.yml +STORE_MODEL_IN_DB=True enables LiteLLM dynamic model management via REST API. models.py admin CRUD now syncs to LiteLLM via threading.
2026-08-11 15:27:57 +08:00
qihu-bot fa38d2c57a #964 修复 React error #321 模块级 useState 调用
Models.tsx L31 const [lockedCapDefaults, setLockedCapDefaults] = useState(...) 在模块顶层,违反 React Hooks 规则. 移到 Models component 函数体内 (L56). 解决 production 报 React error #321 Invalid hook call.
2026-08-11 13:57:51 +08:00
qihu-bot 679f23ff93 #963 safeLazy 包裹 React.lazy 防 chunk 加载失败导致 element undefined
App.tsx 所有 React.lazy 改为 safeLazy(loader).catch(...) 保证 chunk load 失败时 element 有 fallback 不会是 undefined. 防 React error #231.
2026-08-11 13:06:42 +08:00
qihu-bot 420e0f6603 #962 全局 ErrorBoundary 隔离组件渲染错误
main.tsx 加 ErrorBoundary 包裹 App,任何组件抛错都会显示在页面上而不是让 root 空白. 基于 #961 诊断:React 18 mount 正常,问题在 App.tsx 组件树.
2026-08-11 12:53:04 +08:00
qihu-bot 4cf910ee3e #961 /admin/models React #425 hydration mismatch 修复
main.tsx 加全局错误处理 + #root 清空 + try-catch 包裹 createRoot. 解决 React 18.3.1 在空 #root 容器中误触发 hydration 警告导致 mount 失败.
2026-08-11 12:43:40 +08:00
qihu-bot 45b6939ab3 #959 /admin/models 空白页修复 - nginx index.html 加 no-store 头
nginx index.html 加 Cache-Control: no-store + Pragma: no-cache + Expires: 0,强制浏览器每次拉新版 SPA. 修复 #957 修复后浏览器仍缓存旧 615 bytes nginx welcome 导致的空白页.
2026-08-11 09:31:50 +08:00
qihu-bot 53bbbf3195 #957 nginx 容器 root 修复 - ai.qihuyun.com 显示 nginx 默认页
aiflow.conf 缺 server 级 root 指令导致 SPA fallback 走 nginx 默认 /usr/share/nginx/html. 修复后容器 8080 / host 12888 / 外部 ai.qihuyun.com 全部正确返回 544 bytes 七虎AI 前端页.
2026-08-11 08:55:49 +08:00
QihuAI Design e58c37ea17 #954 更新扫描器 UA 黑名单加入 Infrawatch + InternetMeasurement (20260810-205514)
access.log 发现 Infrawatch 探测 /mcp /sse 端点,InternetMeasurement 探测 / 首页. 加入 UA 黑名单 return 444. 验证:两个 UA 均被 444,正常 Mozilla 浏览器 200.
2026-08-10 20:55:15 +08:00
qihu-bot b415a0b62e #937 LiteLLM 副 key 启用脚本 + 计划 (20260810-154721)
新增 litellm/apply_secondary_key.sh + rollback + verify + README + docs/observability/litellm-failover-activation.md. 代码层已就绪(commit 84d89ab),等用户提供 SiliconFlow 副 key 后,单次低峰会话完成切换.
2026-08-10 15:47:21 +08:00
qihu-bot e50d006ef4 #952 alloy traces 配置 bake Dockerfile + 计划 (20260810-154537)
新增 alloy/Dockerfile + config.alloy + build.sh + README + docs/observability/alloy-bake-plan.md. 当前 bind mount /tmp/alloy 仍稳定,本会话仅交付可执行件,实际 build 待低峰期执行.
2026-08-10 15:45:37 +08:00
qihu-bot 7e002ac2b5 P1-1 后端核心模块单测 (20260810-154318)
新增 test_capability_discovery.py (30) + test_llm_router.py (21) 共 51 个哨兵单测,覆盖 #874 双 Claw 能力发现 / #875 LLM 路由熔断 / #876 OTel 桥接关键路径. 套件: 116 -> 165 passed.
2026-08-10 15:43:19 +08:00
qihu-bot cee260d4bd P1-2 nginx 限流 + 扫描器拦截 (20260810-153624)
三档限流 (api 30r/s, admin 5r/s, login 1r/s) + UA / path 黑名单 (return 444) + 自动黑名单脚本 (5min cron, 24h TTL). 实测首跑封禁 222.128.55.233 (4xx=31) 和 120.55.61.72 (UA=zgrab).
2026-08-10 15:36:25 +08:00
SeniorDev bc96a087dd P0-1+P2-1+P2-3: 修 OTel traces pipeline + DROP LangBot 表 + nginx logrotate + cron 2026-08-10 14:31:44 +08:00
SeniorDev 56eb9f1634 #939 应用市场产品计费 webhook 设计 (HMAC 验签 + 幂等去重 + reconcile worker) 2026-08-09 21:42:27 +08:00
SeniorDev 0820d408eb #938 ChatPage UX 打磨 (移动端响应式 + 暗态增强 + 代码块复制按钮) 2026-08-09 21:40:44 +08:00
SeniorDev 84d89ab95a #937 LiteLLM failover 沙箱设计 + 演练脚本 2026-08-09 21:37:39 +08:00
SeniorDev 6e89a37ff7 #936 MCP SDK streamablehttp 稳定性回归 (兼容 mcp 2.0 重命名 + httpx2 fork) 2026-08-09 21:33:28 +08:00
SeniorDev 6d5c2cf2c0 #935 IM Bot Channel Adapter 化 (Telegram POC + im_channel_routes schema) 2026-08-09 21:06:01 +08:00
SeniorDev 048671870a stage3: 技能/应用市场解耦 - 前端拆分 (/marketplace/products + /admin/market-products) 2026-08-09 06:59:29 +08:00
QihuAI Design c1f8b164bb docs+tests: #873 事件契约与双 Claw Adapter 文档及哨兵测试
- 新增 tests/test_engine_event.py (31 用例: EngineEvent 线格式/to_wire/from_wire,
  SseAdapter 裁剪+帧格式+frames, BufferAdapter 累积/媒体/诊断/截断, Diagnosis,
  test_dual_claw_wiring_source_guard 校验 chat.py/im_llm.py 接线)
- 新增 docs/dual-claw-event-contract.md (#873 契约文档 7 节)
- 全量 pytest 64 用例全绿 (含 test_dual_claw/test_node_registry 无回归)
- 仅文档/测试,非运行时代码,不 push
2026-08-04 17:28:57 +08:00
QihuAI Design 33d0f9c32e feat(workflow #874): NodeRegistry 统一工作流节点抽象 + 双 Claw 事件对齐
#874 P3 工作流引擎 NodeRegistry 统一(加速带「工具即节点」思想落地,零源码拷贝重写):

- app/core/node_registry.py(新增):把 skill / MCP / Agent / DAG 四套割裂表达收敛为
  单一 Node 抽象。BaseNode(Protocol) 定义 node_type/name/input_schema/output_schema/
  risk_level + async run(ctx);NodeContext 携带 db/tenant_id/claw_type/trace_id/黑板/emit;
  NodeResult.to_blackboard() 与既有 orchestrator 黑板结构无缝互转。内建八型节点
  llm/reduce/local/external/agent/skill/mcp/loop,均继承 NodeBase 公共生命周期
  (发 start 步骤 -> 执行 -> ok/fail 覆盖同 index),异常统一归一为 NodeError(带结构化
  diagnosis)。SkillNode 用 asyncio.to_thread 包裹同步执行器避免阻塞事件循环;
  LoopNode 硬限流 20 次迭代 / 并发上限 5,子节点经 ctx.child() 复用同一 StepCounter 与
  emit,保证步骤序号全图唯一。CapabilitySnapshot 统一承载 skills/mcp_servers/agents/
  function_definitions/mcp_tool_map,供 _build_system_prompt、run_graph、yaml 校验三处
  共用同一份能力真相。normalize_claw() 将 im_wechat/im_platform 归一为 "im",避免
  Grafana 维度碎片化。

- app/core/capability_discovery.py:新增 discover_agents(),与 discover_skills 同构的
  三级降级策略(显式安装 -> published 兜底 -> rank_by_query 裁剪),补齐 Agent 维度的
  统一能力发现,杜绝各处内联第二份发现逻辑(#866 回退事故教训)。

- app/core/orchestrator.py:VALID_NODE_TYPES 与 validate_graph 改为运行期读
  registry.node_types()/requires_agent(),注册表成为节点类型的单一真相源;_run_node 改为
  拼装 NodeContext 后委托 registry.get(ntype).run(ctx),本体只保留调度与拓扑;run_graph
  新增 on_event/claw_type/tenant_id/snapshot 参数,编排全过程以统一 EngineEvent 下发。

- app/core/yaml_workflow.py:valid_node_types() 实时读注册表;validate_yaml 补 skill/mcp/
  loop 必填校验;yaml_to_orchestration / orchestration_to_yaml 双向透传节点扩展字段
  (label/skill_id/func_name/tool/server_id/args/body/items/split/max_iterations/
  concurrency/join),修复配置往返丢字段。

- app/core/engine_event.py:make_step 新增 node 参数,以 step["node"]={id,type,label}
  承载节点身份。设计书原文的 EngineEvent(kind="node") 与前端仅识别 thinking/skill/mcp/
  agent/kb/media 的现状冲突(未知 kind 会被静默丢帧),故映射为已知 kind + 附加字段,
  线格式对旧前端字节兼容。

- app/api/chat_service.py:Web Claw 编排调用点透传 on_event/claw_type/tenant_id,
  租户与通道标签复用 #875 的 ContextVar(get_route_ctx),不再新增一层参数透传。
  IM Claw 经 EngineCore 自动继承同一事件流(单引擎原则,im_llm.py 零改动)。

- tests/test_node_registry.py(新增):R-NODE-1~11 确定性哨兵,无网络无外部依赖。守卫
  内建八型齐全、注册表为单一真相源(orchestrator 与 yaml 校验实时同源)、describe 契约、
  双 Claw 事件对齐(SseAdapter 与 BufferAdapter 逐键相等且均带 node 身份)、claw 归一与
  tenant 标签、新节点必填校验、YAML 往返不吞字段、Loop 限流聚合、失败结构化诊断两端可见、
  禁止第二份能力发现、register 拒绝非法节点。

验证:容器内 py_compile 全通过;pytest tests/ 退出码 0(85 项 = 83 passed + 2 skipped,
0 FAILED/ERROR),含 R-NODE 16 项与 R-DUAL 双 Claw 哨兵。

safe-align: 仅复制 7 个 git-tracked 文件,未触碰 secrets/.venv/node_modules/dist/caches。
本地提交不 push。
2026-08-04 15:16:00 +08:00
QihuAI Design 04a0641202 feat(otel #876): 双 Claw 强制标签 + 入口接线 + Grafana 双 Claw 对比看板
#876 OpenTelemetry 可观测(双 Claw 标签):
- app/core/otel.py: 新增 set_otel_ctx/get_otel_ctx(ContextVar 三元组 tenant_id+claw_type+
  trace_id);current_span_cm 自动注入双 Claw 强制标签,全部 13 个 span 调用点零签名侵入带维度;
  record_span_metrics 指标维度补 tenant_id+claw_type(trace_id 因高基数不计入指标标签)。
- app/core/llm_router.py: set_route_ctx 桥接转调 set_otel_ctx(单次入口注入双端共享:
  路由上下文 + OTel 标签),失败安全。
- app/api/chat.py: send_message 与 send_message_stream 入口注入 set_route_ctx(web),
  stream 经 asyncio.create_task 子协程自动传播 contextvars,使 _send_message_core 内
  所有 span 均带 tenant_id+claw_type。
- app/api/im_llm.py: _call_real_llm 入口最早注入 set_route_ctx(im_wechat),并显式开启
  im.send_message span(此前 IM Claw 完全无 span,Grafana 无 IM 数据点),finally 关闭。
- tests/test_dual_claw.py: R-DUAL-6 由 skip 转为确定性断言(Web/IM 双 Claw span 必带
  tenant_id+claw_type),作为持续哨兵。
- 交付 Grafana 双 Claw 对比看板 JSON(aiflow_span_* / aiflow_llm_route_total 按 claw_type
  分组:P50/P95 延迟、成功率、QPS、租户健康 Top10)。Alloy OTLP 仅桥接 metrics→Prometheus,
  trace 无 pipeline(#800 已压制 UNIMPLEMENTED 噪声),看板基于 metrics 出图。

safe-align: 仅复制 5 个 git-tracked 文件,未触碰 secrets/venv/node_modules/caches。本地提交不 push。
2026-08-04 13:49:34 +08:00
QihuAI Design 180409d317 feat(#875): LLM Router shadow mode + consolidate litellm master key 2026-08-04 13:16:30 +08:00
QihuAI Design 037c965c26 feat(engine): #873 dual-Claw event contract + SseAdapter/BufferAdapter + chat.py SSE delegate + im_llm EngineCore thin-shell (gray-gated)
- app/core/engine_event.py (#880): EngineEvent dataclass + make_* factories, single construction entry, byte-compatible wire with legacy ChatPage.tsx
- app/core/claw_adapters.py (#884): SseAdapter (hot frames zero-metadata, terminal keeps trace_id) + BufferAdapter (assemble() preserves __media__)
- app/api/chat.py (#881): /messages/stream now delegates to SseAdapter; wire bytes unchanged (46B delta / done+message_id)
- app/api/im_llm.py (#883): IM_USE_ENGINE_CORE gray gate; _call_engine_core reuses EngineCore via BufferAdapter (default OFF, preserves media fast-path)
- app/api/chat_service.py: capture 3 container-ahead fixes (stray import asyncio removed, logger.info format, LiteLLM master key)
2026-08-04 12:25:12 +08:00
QihuAI Design 34648e4810 fix(im_bot): segment long WeChat text replies + enhance send diagnostics (#878)\n\niLink returns HTTP 200 but WeChat silently drops long messages (1698-char\nHTML image link, 3011-char skill list). Add WECHAT_TEXT_MAX_LEN=1500 and\nsegment long text into multiple items. Also log payload item lengths and\nmedia URL lengths for future diagnosis. 2026-08-04 11:14:02 +08:00
QihuAI Design f4e18bafe4 test: add R-DUAL dual-Claw regression sentinel (#873) - always-on guards for IM unified discovery / Web-IM parity / naming single-source; opt-in live MCP reachability; R-DUAL-3/6 pending 2026-08-04 10:42:45 +08:00
QihuAI Design 29a1ce2e54 fix: 双Claw能力对齐 + MCP/LLM代理绕过(P0止血)
- 新增 app/core/capability_discovery.py:统一能力发现层,Web/IM 双Claw共用单一实现
  (三级降级:用户安装 → 平台published → 相关性裁剪;MCP 自动挂载已启用服务)
- im_llm.py:IM Bot Claw 改用统一发现层,恢复 skills(12) + MCP(26工具) 能力,
  修复 #866 部署回退导致的 agents=[]/mcp=[] 回归;mcp__ 前缀分流到 call_mcp_tool
- mcp_client.py:注入 httpx_client_factory(trust_env=False) 绕过容器 HTTP_PROXY,
  修复外部 MCP(xiaobenyang.com / anysearch.com) tools/list 全部静默失败(mcp_tools 0→26)
- llm_client.py / im_bot_user.py:2026-07-31 代理劫持内部请求 + 媒体断连修复(随镜像已上线)
2026-08-04 10:21:19 +08:00
QihuAI Design 9a43ee244d fix(im_llm): 代理绕过Docker内部hostname 2026-07-31 08:51:15 +08:00
QihuAI Design 96aabc31f6 feat: mumuainovel app service infrastructure (db+compose+nginx) 2026-07-28 11:41:40 +08:00
80 changed files with 10585 additions and 1190 deletions

No files matched your search

+416
View File
@@ -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次重试 + 指数退避 |
+166 -495
View File
@@ -1,546 +1,217 @@
# Qihu-AIFlow 智能自动化PaaS平台 # 七虎AI — 智能自动化 PaaS 平台
## 项目概述 > **私有化部署 | 零代码 AI SOP | 多模型聚合 | 技能市场 | IM Bot**
Qihu-AIFlow 是一套**私有化部署、面向个人及小微企业的AI自动化SOP一站式PaaS平台**。用户无需编码即可落地全流程AI自动化业务,平台提供AI对话、知识管理、技能市场、Token计费、会员订阅等完整闭环体验。 **版本**: v3.0 | **构建日期**: 2026-08-12 | **公网**: https://ai.qihuyun.com | **后台**: `admin / admin123`
**版本**: v2.0.1
**构建日期**: 2026-06-02
**仓库地址**: http://git.qihuyun.com/qyyfw/qihuaiflow
--- ---
## 技术环境 ## 🚀 快速开始
### 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端框架 | 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 ```bash
# 1. 克隆仓库 # 1. 克隆仓库
git clone http://git.qihuyun.com/qyyfw/qihuaiflow.git git clone http://git.qihuyun.com/qyyfw/qihuaiflow.git
cd qihuaiflow cd qihuaiflow
# 2. 构建并启动所有服务(MySQL + Redis + AIFlow) # 2. 配置环境
docker compose up -d --build cp .env.example .env # 编辑 API keys
# 3. 等待服务就绪(首次构建约3-5分钟) # 3. 一行启动
# 查看日志确认启动完成 docker-compose up -d
docker compose logs -f aiflow
# 4. 访问系统 # 4. 访问
# 前端: http://<宿主机IP>:12888 open https://ai.qihuyun.com # 前台
# 默认账号: admin / admin123 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 (前端) | 用户浏览器访问端口 | | 🤖 **AI 对话** | SSE 流式对话、多模型自动路由、Markdown 渲染、代码高亮 | ✅ 生产 |
| 18080 | Uvicorn (后端API) | API服务端口 | | 📚 **知识库 RAG** | 文档上传 → 切片 → pgvector 向量化 → BM25+语义检索 | ✅ 生产 |
| 3306 | MySQL | 数据库端口 | | 🛒 **技能市场** | 97+ Skill 发现-安装-执行,Meilisearch 中文搜索,14 类 capability | ✅ 生产 |
| 6379 | Redis | 缓存端口 | | 🎨 **媒体生成** | 图片/视频 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默认值 | 本地默认值 | 详见 [ARCHITECTURE.md](ARCHITECTURE.md)
|------|------|-------------|-----------|
| `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 |
**重要**: 生产环境请修改 `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 ```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
# 重启应用 ```bash
docker compose restart aiflow # 生产部署
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
# 进入应用容器 ```bash
docker exec -it qihu-aiflow 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 docker exec qihu-aiflow bash -c "cd /app/frontend && npx vitest run"
# 进入Redis
docker exec -it qihu-aiflow-redis redis-cli -a pwd2026
``` ```
--- ---
## 项目文件结构 ## 📊 平台数据
``` | 指标 | 数值 |
/
├── 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等) | | LLM 模型数 | 14 (含 OCR/Rerank/Embedding/TTS/ASR) |
| 002 | 初始数据(admin账号, 默认模型供应商, 种子数据) | | 技能数 | 97 |
| 003 | 增值服务表 | | MCP 工具数 | 26 |
| 004 | OAuth状态字段 | | 后台管理页面 | 39 |
| 005 | 频道扩展字段(account_name, features) | | API 端点 | 300+ |
| 006 | API调用统计表 | | 容器数 | 12 |
| 007 | 进化系统基础表 | | 代码行数 | 50,000+ |
| 008 | 进化系统v2(EvolutionWatcher状态管理) |
| 009 | 支付实名认证字段 |
| 010 | 默认模型字段 |
| 011 | 知识库存储与资产表 |
| 012 | 生产环境列同步(幂等补全) |
| 013 | 知识图谱表(节点/边/构建记录) |
| 014 | 测试数据清理 |
| 015 | 生产修复(content列 + AppPurchase状态重置) |
--- ---
## 更新日志 ## 🌐 线上环境
详见 `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) 详见 [CHANGELOG.md](CHANGELOG.md) 和 [RELEASES.md](RELEASES.md)
- Gitea仓库发布
**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.
+20
View File
@@ -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
+34
View File
@@ -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
View File
@@ -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"
+43
View File
@@ -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 里指定。
+20 -3
View File
@@ -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 {}) hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs} sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
import asyncio as _aio 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["success"] = bool(tools)
result["details"]["tools_count"] = len(tools) result["details"]["tools_count"] = len(tools)
result["details"]["tools"] = [t.get("name") for t in tools[:5]] 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 result["details"]["status_code"] = 200
if tools: if tools:
server.tools_count = len(tools) server.tools_count = len(tools)
server.status = "connected" server.status = "connected"
except Exception as e: 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": elif transport == "sse":
+44
View File
@@ -8,6 +8,7 @@ from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from ...database import get_db, SessionLocal from ...database import get_db, SessionLocal
from ...models import AIModel, ModelProvider, SiteSetting 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 ...auth import get_current_admin
from ...middleware.log_service import LogService from ...middleware.log_service import LogService
from ...capability_registry import ( from ...capability_registry import (
@@ -475,6 +476,21 @@ async def create_model(
db.add(model) db.add(model)
db.commit() 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( LogService.log_audit(
admin_id=admin_user.id, admin_id=admin_user.id,
admin_name=admin_user.username, admin_name=admin_user.username,
@@ -588,6 +604,24 @@ async def update_model(
model.updated_at = datetime.now() model.updated_at = datetime.now()
db.commit() 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( LogService.log_audit(
admin_id=admin_user.id, admin_id=admin_user.id,
admin_name=admin_user.username, admin_name=admin_user.username,
@@ -612,6 +646,16 @@ async def delete_model(
raise HTTPException(status_code=404, detail="模型不存在") raise HTTPException(status_code=404, detail="模型不存在")
model_name = model.name 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.delete(model)
db.commit() db.commit()
+68
View File
@@ -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",
},
)
+118
View File
@@ -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"}
+86
View File
@@ -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),
}
}
+47
View File
@@ -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}
+76 -19
View File
@@ -84,6 +84,7 @@ from ..auth import get_current_user
from ..platform_knowledge import get_platform_knowledge from ..platform_knowledge import get_platform_knowledge
from ..mcp_client import list_mcp_tools, call_mcp_tool from ..mcp_client import list_mcp_tools, call_mcp_tool
from ..core.claw_adapters import SseAdapter
from ..middleware.log_service import LogService 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.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") @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 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)): 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) return await _send_message_core(conv_id, req, db, current_user)
@@ -1570,38 +1625,44 @@ async def send_message_stream(
req: SendMessageRequest, req: SendMessageRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""SSE 流式聊天端点(Phase 0)。 """SSE 流式聊天端点(#873)。
与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event
回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。 与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
""" """
db = SessionLocal() db = SessionLocal()
try: 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): adapter = SseAdapter(
await queue.put(e) trace_id=f"sse-{conv_id}",
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
)
async def runner(): async def runner():
try: try:
await _send_message_core( await _send_message_core(
conv_id, req, db, current_user, conv_id, req, db, current_user,
stream=True, on_event=on_event, stream=True, on_event=adapter,
) )
except Exception as exc: except Exception as exc:
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True) 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: finally:
await queue.put({"type": "__end__"}) await adapter.close()
task = asyncio.create_task(runner()) task = asyncio.create_task(runner())
async def event_gen(): async def event_gen():
try: try:
while True: async for frame in adapter.frames():
e = await queue.get() yield frame
if e.get("type") == "__end__":
break
yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
finally: finally:
await task await task
db.close() db.close()
@@ -1611,10 +1672,6 @@ async def send_message_stream(
db.close() db.close()
raise raise
@router.post("/conversations/{conv_id}/clear") @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()
+8
View File
@@ -506,6 +506,10 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
# 调用 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) api_key = text_model.api_key or (provider.api_key if provider else None)
@@ -936,6 +940,10 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
# 调用 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) api_key = ai_model.api_key or (provider.api_key if provider else None)
+52 -1
View File
@@ -193,6 +193,23 @@ from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model 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
@@ -1619,7 +1636,6 @@ async def _send_message_core(
_ct = completion_tokens if "completion_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" _uid = user_id if "user_id" in dir() else "unknown"
if _pt or _ct: if _pt or _ct:
import asyncio
asyncio.create_task(_bill_tokens( asyncio.create_task(_bill_tokens(
_uid, _pt, _ct, _uid, _pt, _ct,
ai_model if "ai_model" in dir() else "unknown", ai_model if "ai_model" in dir() else "unknown",
@@ -2184,6 +2200,14 @@ async def _send_message_core(
from app.core.orchestrator import run_graph 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 = {}
@@ -2212,6 +2236,9 @@ async def _send_message_core(
trace_id=get_trace_id(), trace_id=get_trace_id(),
on_event=on_event,
claw_type=(_rctx.get("claw_type") or "web"),
tenant_id=(_rctx.get("tenant_id") or ""),
@@ -2821,6 +2848,16 @@ async def _send_message_core(
_try_models.extend([m for m in _fallbacks if m not in _try_models]) _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 = [] _retry_log = []
_all_failed = False _all_failed = False
@@ -2895,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): if ai_model and ("image" in caps or "video" in caps):
@@ -3074,6 +3118,13 @@ async def _send_message_core(
api_url = ai_model.endpoint 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")
+141 -24
View File
@@ -47,6 +47,10 @@ def _beijing_now() -> datetime:
# ============================================================ # ============================================================
ILINK_BASE = "https://ilinkai.weixin.qq.com" ILINK_BASE = "https://ilinkai.weixin.qq.com"
# 微信/iLink 单条文本消息长度软上限。超过则分段发送,避免 iLink 返回 200
# 但用户侧静默收不到(实测 1698 字符 HTML 链接、3011 字符 Markdown 均丢失)。
WECHAT_TEXT_MAX_LEN = 1500
# 每个已连接账号一个后台接收线程,key = 账号表主键 id # 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {} _wechat_receiver_threads: dict[int, threading.Thread] = {}
@@ -143,12 +147,33 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
f"{hist}\n\n" f"{hist}\n\n"
f"用户当前问题:{text}" 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 db = None
try: try:
from app.api.im_llm import _call_real_llm from app.api.im_llm import _call_real_llm
db = SessionLocal() 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: 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})" return f"(AI 回复生成失败:{e})"
finally: finally:
if db: if db:
@@ -164,7 +189,7 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
resp = requests.post( resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getupdates", baseurl.rstrip("/") + "/ilink/bot/getupdates",
headers=_wechat_headers(bot_token), headers=_wechat_headers(bot_token),
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}}, json={"get_updates_buf": cursor},
timeout=40, timeout=40,
) )
if not resp.ok: if not resp.ok:
@@ -178,20 +203,35 @@ 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): 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)。 """向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。
ret=-14=会话过期需重扫;ret=-2=token 失效。""" ret=-14=会话过期需重扫;ret=-2=token 失效。"""
import uuid as _uuid
payload = { payload = {
"msg": { "msg": {
"from_user_id": "",
"to_user_id": to_user_id, "to_user_id": to_user_id,
"client_id": "py-" + str(_uuid.uuid4()),
"message_type": 2, "message_type": 2,
"message_state": 2, "message_state": 2,
"context_token": context_token, "context_token": context_token,
"item_list": item_list, "item_list": item_list,
}, }
"base_info": {"channel_version": "1.0.2"},
} }
last_body = {} 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): for _ in range(2):
try: try:
resp = requests.post( resp = requests.post(
@@ -222,9 +262,49 @@ 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): def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
"""发送纯文本回复。""" """发送纯文本回复。长文本按 WECHAT_TEXT_MAX_LEN 分段,避免 iLink 200 但微信不显示。"""
if not text:
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[{"type": 1, "text_item": {"text": text}}]) [{"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 _platform_relay_url(raw_url: str) -> str: def _platform_relay_url(raw_url: str) -> str:
@@ -239,21 +319,26 @@ def _platform_relay_url(raw_url: str) -> str:
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str, def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str): media_type: str, url: str):
"""发送媒体(图片/视频)并在微信内联渲染。 """发送媒体(图片/视频)。
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时, 问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时。
微信里只能看到"链接"而无法内联播放。 解决:改写为平台中转地址(经香港代理可达 AGNES)。
解决:改写为平台中转地址(经香港代理可达 AGNES),微信即可内联显示图片/视频。
2026-07-31 兼容修复:iLink 对 type=2/5 的媒体 item 返回 ret=-2 invalid arguments,
且旧代码把 ret=-2 误判为 token 失效,导致连接假性断开。先以纯文本链接形式发送,
确保用户可点击访问且不会破坏会话;待拿到 iLink 媒体协议文档后再恢复内联 item。
""" """
relay_url = _platform_relay_url(url) relay_url = _platform_relay_url(url)
try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " MEDIA_URL_LEN=%s type=%s\n" % (len(relay_url), media_type))
except Exception:
pass
if media_type == "image": if media_type == "image":
media_item = {"type": 2, "image_item": {"url": relay_url}} caption = "🖼️ 图片已生成,点击查看:" + relay_url
caption = "🖼️ 图片已生成(若未直接显示,请点此查看):" + relay_url
else: else:
media_item = {"type": 5, "video_item": {"url": relay_url}} caption = "🎬 视频已生成,点击查看:" + relay_url
caption = "🎬 视频已生成(若未直接播放,请点此查看):" + relay_url return _wechat_send_message(baseurl, bot_token, to_user_id, context_token, caption)
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[media_item, {"type": 1, "text_item": {"text": caption}}])
def _wechat_extract_text(msg: dict) -> str: def _wechat_extract_text(msg: dict) -> str:
@@ -326,7 +411,6 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
), {"uid": user_id}) ), {"uid": user_id})
if rows: if rows:
return _row_to_dict(rows[0])["id"] return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4()) cid = str(_uuid.uuid4())
now = _beijing_now() now = _beijing_now()
_execute_raw(db, text( _execute_raw(db, text(
@@ -338,7 +422,6 @@ 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): def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text( _execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) " "INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)" "VALUES (:id, :cid, :role, :content, 0, :c)"
@@ -425,7 +508,14 @@ def _wechat_receiver_loop(account_id: int):
except Exception: except Exception:
pass 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) reply = _qihua_reply(content, owner_uid)
_lrx.warning(f'[receiver] reply ({type(reply).__name__}): {repr(reply)[:200]}')
# 媒体结构化返回:_call_real_llm 生成图片/视频时返回 # 媒体结构化返回:_call_real_llm 生成图片/视频时返回
# {"__media__": True, "media_type": "image"/"video", "url": "..."} # {"__media__": True, "media_type": "image"/"video", "url": "..."}
if isinstance(reply, dict) and reply.get("__media__"): if isinstance(reply, dict) and reply.get("__media__"):
@@ -457,11 +547,23 @@ def _wechat_receiver_loop(account_id: int):
% (ret, repr(sender))) % (ret, repr(sender)))
except Exception: except Exception:
pass pass
if ret in (-2, -14): if ret == -14:
# 会话/鉴权失效:标记需重扫但继续循环,让下一轮 poll 自然检测过期 # -14=会话过期,需重扫
_mark_wechat_expired(d["id"]) _mark_wechat_expired(d["id"])
elif ret == -2:
# -2=参数错误(如媒体消息格式不被 iLink 接受),并非 token 失效。
# 标记 expired 会导致连接假性断开,后续消息全部丢失,故仅记录。
pass
time.sleep(1) time.sleep(1)
except Exception: 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) time.sleep(5)
finally: finally:
db.close() db.close()
@@ -555,6 +657,13 @@ async def wechat_qrcode_status(
data = j.get("data", j) if isinstance(j, dict) else j data = j.get("data", j) if isinstance(j, dict) else j
status = data.get("status") status = data.get("status")
if status == "confirmed": 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 {} creds = data.get("credentials", {}) or {}
bot_token = data.get("bot_token") or creds.get("bot_token") bot_token = data.get("bot_token") or creds.get("bot_token")
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
@@ -590,7 +699,15 @@ async def wechat_qrcode_status(
"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"], "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"],
}) })
row = _wechat_row_for_user(db, user.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) _start_wechat_receiver(aid)
finally: finally:
db.close() db.close()
+265 -16
View File
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
* 平台知识库注入(get_platform_knowledge,技能清单实时同步) * 平台知识库注入(get_platform_knowledge,技能清单实时同步)
""" """
import asyncio import asyncio
import os
import json import json
import logging import logging
import re as _re import re as _re
@@ -29,7 +30,14 @@ from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid 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__) logger = logging.getLogger(__name__)
@@ -48,7 +56,8 @@ def _proxy_for_url(url: str) -> str | None:
host = (_urlparse(url).hostname or "").lower() host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host: if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY 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 return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()] _no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy: 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" 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): 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": def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。""" """调用七虎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: try:
from app.api.chat import ( from app.api.chat import (
_generate_media, _generate_media,
@@ -124,10 +151,23 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_discover_installed_skills, _discover_installed_skills,
_build_system_prompt, _build_system_prompt,
_execute_skill_tool, _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)===== # ===== 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: if intent:
ai_model = resolve_auto_model(intent, db) ai_model = resolve_auto_model(intent, db)
if ai_model: if ai_model:
@@ -155,6 +195,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
return s return s
return "(媒体生成未能返回有效内容,请重试)" 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 + 技能自动发现 + 平台知识库注入 ===== # ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
from ..platform_knowledge import get_platform_knowledge from ..platform_knowledge import get_platform_knowledge
@@ -162,23 +212,71 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_model = resolve_auto_model("text", db) ai_model = resolve_auto_model("text", db)
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
# Auto 模式附加备选(IM Bot 始终 Auto) # 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( _fallbacks = db.query(AIModel).filter(
AIModel.is_enabled == True, AIModel.is_enabled == True,
AIModel.id != (ai_model.id if ai_model else ""), AIModel.id != (ai_model.id if ai_model else ""),
).order_by(AIModel.is_default.desc()).limit(4).all() ).order_by(AIModel.is_default.desc()).all()
_try_models.extend([m for m in _fallbacks if m not in _try_models]) _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: if not _try_models:
return "(未配置可用文本模型,无法生成回复)" 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 = [] skills = []
if user_id:
try: try:
skills = _discover_installed_skills(db, user_id)[:15] skills = discover_skills(db, user_id, query=_query_hint, claw="im")
except Exception: except Exception as _e:
logger.warning(f"[im_llm] 技能发现失败(降级为空): {_e}")
skills = [] skills = []
# 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)}"
)
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单) # 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
kb_context = "" kb_context = ""
try: try:
@@ -196,19 +294,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() 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_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint 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: if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url): if not (api_key and api_url):
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点") _retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
continue continue
# agents 暂留空:线上 market_apps 中 type='agent' 记录为 0,
# 且 IM 侧尚无 agent 执行分支;待薄壳化接入 EngineCore 后自动获得。
system_prompt, function_definitions = _build_system_prompt( system_prompt, function_definitions = _build_system_prompt(
skills=skills, skills=skills,
agents=[], agents=[],
kb_context=kb_context, kb_context=kb_context,
user_content=prompt, user_content=prompt,
mcp=[], mcp=mcp_servers,
mcp_functions=mcp_function_definitions,
auto_discovered=bool(skills), auto_discovered=bool(skills),
all_mcp=_list_enabled_mcp_servers(db),
db=db,
) )
messages = [ messages = [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
@@ -221,13 +326,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_content = "" ai_content = ""
_http_ok = True _http_ok = True
for round_num in range(max_rounds): for round_num in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048} request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
if function_definitions and skills: if function_definitions:
request_body["tools"] = [ request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions {"type": "function", "function": fd} for fd in function_definitions
] ]
request_body["tool_choice"] = "auto" request_body["tool_choice"] = "auto"
try: 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( data = chat_complete(
api_url, api_url,
api_key, api_key,
@@ -235,17 +343,33 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
proxy=_proxy_for_url(api_url), proxy=_proxy_for_url(api_url),
timeout=ai_model.timeout or 120, 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: except Exception as _exc:
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}") _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} 异常: {_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 _http_ok = False
break break
message = data.get("choices", [{}])[0].get("message", {}) message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", []) tool_calls = message.get("tool_calls", [])
if tool_calls and skills: # 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({ messages.append({
"role": "assistant", "role": "assistant",
"content": message.get("content") or "", "content": message.get("content") or message.get("reasoning_content") or "",
"tool_calls": tool_calls, "tool_calls": tool_calls,
}) })
for tc in tool_calls: for tc in tool_calls:
@@ -254,29 +378,154 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
func_args = json.loads(tc.get("function", {}).get("arguments", "{}")) func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception: except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")} 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) tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({ messages.append({
"role": "tool", "role": "tool",
"tool_call_id": tc.get("id", "call_1"), "tool_call_id": tc.get("id", "call_1"),
"content": tool_result, "content": tool_result,
}) })
if round_num >= max_rounds - 1:
break
continue continue
ai_content = message.get("content", "")
break 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: if _http_ok and ai_content:
_debug_log(f"SUCCESS model={ai_model.name} returning len={len(ai_content)}")
return ai_content or "(AI 回复为空)" 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 continue
except Exception as exc: except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}") _retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}") logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
_debug_log(f"OUTER_EXC model={ai_model.name} err={str(exc)[:120]}")
continue continue
# 全部失败 # 全部失败
_summary = "; ".join(_retry_log[-3:]) _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}。请稍后重试。)" return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
except Exception as e: except Exception as e:
return f"(AI 回复生成失败:{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()
+1 -1
View File
@@ -122,6 +122,6 @@ async def get_current_admin(
user=Depends(get_current_user), 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="需要管理员权限") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user return user
@@ -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
+214
View File
@@ -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()
+294
View File
@@ -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}"
+358
View File
@@ -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,
)
+37
View File
@@ -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})
+79 -18
View File
@@ -83,6 +83,11 @@ def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
if tools: if tools:
kw["tools"] = tools kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto") 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 return kw
@@ -223,29 +228,85 @@ def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120, auth_
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client, default_headers=_hdrs if _hdrs else None) 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): def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, auth_header=None):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。""" """同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
with client: 2026-07-30 重写:直接使用 requests 替代 OpenAI SDK/httpx。
resp = client.chat.completions.create(**_create_kwargs(request_body)) OpenAI SDK 的 httpx 连接池在慢长轮询(如 iLink QR 状态 12-19s)后会出现
msg = resp.choices[0].message Connection error,已实测复现。requests 库没有这个问题。
return { """
"choices": [ 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": { "message": {
"content": msg.content or "", "content": msg.get("content") or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)), "tool_calls": _normalize_tool_calls(msg.get("tool_calls")),
"reasoning_content": getattr(msg, "reasoning_content", None), "reasoning_content": msg.get("reasoning_content") or "",
} }
}]
} }
], _l7.warning(f'[_shape] content_len={len(result["choices"][0]["message"]["content"])}, keys={list(result["choices"][0]["message"].keys())}')
"usage": (resp.usage.model_dump() if resp.usage else {}), 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): def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]} return {"model": model, "input": input if isinstance(input, list) else [input]}
+350
View File
@@ -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
+156 -48
View File
@@ -7,6 +7,18 @@
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent / - 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。 3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。 - 并发安全:每个节点用独立 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 from __future__ import annotations
@@ -17,9 +29,20 @@ from typing import Any, Optional
import httpx 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") 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): class OrchestrationError(ValueError):
@@ -50,12 +73,25 @@ def validate_graph(graph: Any) -> None:
raise OrchestrationError(f"节点 id 重复: {nid}") raise OrchestrationError(f"节点 id 重复: {nid}")
ids.add(nid) ids.add(nid)
ntype = n.get("type") ntype = n.get("type")
if ntype not in VALID_NODE_TYPES: valid = registry.node_types()
if ntype not in valid:
raise OrchestrationError( 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") 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): for i, e in enumerate(edges):
if not isinstance(e, dict): 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 会话,保证并发安全) # 单节点执行(含独立 DB 会话,保证并发安全)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict: async def _run_node(
from app.api.chat_skill import _resolve_market_agents 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 from app.database import SessionLocal
nid = node["id"] nid = node["id"]
ntype = node["type"] ntype = node["type"]
impl = registry.get(ntype)
if impl is None:
raise OrchestrationError(f"未知节点类型 {ntype}")
preds = _predecessors(graph, nid) preds = _predecessors(graph, nid)
pred_results = [blackboard[p] for p in preds] pred_results = [blackboard[p] for p in preds]
is_entry = not preds is_entry = not preds
@@ -298,66 +355,104 @@ async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id)
upstream = "" upstream = ""
with SessionLocal() as db: with SessionLocal() as db:
if ntype in ("llm", "reduce"): ctx = NodeContext(
if ntype == "llm": db=db,
sys_prompt = node.get("system_prompt") or ( 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(),
) )
else: try:
sys_prompt = node.get("system_prompt") or ( result = await impl.run(ctx)
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。" except NodeError as e:
) # 归一为 OrchestrationError,run_graph 的 on_error=halt|continue 语义不变
uc = _assemble_node_input(query, upstream, is_entry) raise OrchestrationError(str(e)) from e
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id) return result.to_blackboard()
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}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 入口 # 入口
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
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)。 """执行编排图,返回 (answer: str, trace: dict)。
- 拓扑分层后逐层 asyncio.gather 并行执行。 - 拓扑分层后逐层 asyncio.gather 并行执行。
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。 - 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
- on_event 非空时,图级与节点级过程以统一 EngineEvent 推送:
Web Claw 传 SseAdapter(逐帧渲染),IM Claw 传 BufferAdapter(整条装配)。
两端共用同一份事件流,IM 侧无需任何编排专属逻辑。
""" """
validate_graph(graph) validate_graph(graph)
layers = topological_layers(graph) layers = topological_layers(graph)
blackboard: dict = {} blackboard: dict = {}
trace = {"nodes": [], "trace_id": trace_id, "layers": layers} 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: for layer in layers:
tasks = [ 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 for nid in layer
] ]
results = await asyncio.gather(*tasks, return_exceptions=True) 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: else:
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}" answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
trace["output_node"] = output_node 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 return answer, trace
+72 -6
View File
@@ -15,12 +15,21 @@ OpenTelemetry 桥接 (Phase 1.I 标准化)
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等) init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
get_tracer() 返回 tracer(未启用/失败则 None) get_tracer() 返回 tracer(未启用/失败则 None)
get_meter() 返回 meter(未启用/失败则 None) get_meter() 返回 meter(未启用/失败则 None)
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套) current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套);
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数) **自动注入** 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 os
import logging import logging
from contextvars import ContextVar
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,6 +44,44 @@ _initialized = False
_duration_hist = None _duration_hist = None
_span_counter = 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: def init_otel() -> None:
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。""" """构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
@@ -100,19 +147,32 @@ def get_meter():
def current_span_cm(name: str, attributes: dict | None = None): 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() tr = get_tracer()
if tr is None: if tr is None:
return None return None
try: 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 except Exception as e: # pragma: no cover
logger.warning(f"[otel] start_span failed: {e}") logger.warning(f"[otel] start_span failed: {e}")
return None return None
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> 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() m = get_meter()
if m is None: if m is None:
return 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" "aiflow_span_duration_ms", "Span duration in milliseconds"
) )
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count") _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) _duration_hist.record(elapsed_ms, attrs)
_span_counter.add(1, attrs) _span_counter.add(1, attrs)
except Exception: # pragma: no cover except Exception: # pragma: no cover
+185
View File
@@ -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()
+55 -7
View File
@@ -72,10 +72,38 @@ _REVERSE_CONFIG = {
"agent_endpoint", "api_key", "strategy", "max_tokens", "agent_endpoint", "api_key", "strategy", "max_tokens",
} }
VALID_NODE_TYPES = {"llm", "local", "external", "reduce"}
VALID_ON_ERROR = {"halt", "continue"} VALID_ON_ERROR = {"halt", "continue"}
VALID_REDUCE_STRATEGY = {"concat", "first", "last"} 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 ────────────────────────────────────────────────────────────── # ── validation ──────────────────────────────────────────────────────────────
@@ -112,18 +140,29 @@ def validate_yaml(data: Any) -> dict:
errors.append(f"节点 id 重复: {nid}") errors.append(f"节点 id 重复: {nid}")
node_ids.add(nid) node_ids.add(nid)
allowed = valid_node_types()
ntype = node.get("type") ntype = node.get("type")
if ntype not in VALID_NODE_TYPES: if ntype not in allowed:
errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(VALID_NODE_TYPES))})") errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(allowed))})")
on_error = node.get("on_error", "continue") on_error = node.get("on_error", "continue")
if on_error and on_error not in VALID_ON_ERROR: if on_error and on_error not in VALID_ON_ERROR:
errors.append(f"节点 {nid} on_error 非法: {on_error}") errors.append(f"节点 {nid} on_error 非法: {on_error}")
# type-specific checks # type-specific checks
if ntype in ("local", "external"): if ntype in ("local", "external", "agent"):
if not node.get("agent_id"): if not node.get("agent_id"):
errors.append(f"节点 {nid} 类型 {ntype} 必须指定 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 # config validation
cfg = node.get("config", {}) cfg = node.get("config", {})
@@ -205,10 +244,15 @@ def yaml_to_orchestration(data: dict) -> dict:
"on_error": raw_node.get("on_error", "continue"), "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"): if raw_node.get("agent_id"):
node["agent_id"] = raw_node["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 # flatten config into top-level keys
config = raw_node.get("config", {}) config = raw_node.get("config", {})
if isinstance(config, dict): 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) # also carry over any extra keys from raw_node (preserve flexibility)
for k, v in raw_node.items(): 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", if k in ("skill_ids", "knowledge_base_ids", "model_id", "system_prompt",
"agent_endpoint", "api_key", "strategy", "max_tokens"): "agent_endpoint", "api_key", "strategy", "max_tokens"):
node[k] = v node[k] = v
@@ -262,6 +307,9 @@ def orchestration_to_yaml(data: dict) -> dict:
node["on_error"] = json_node["on_error"] node["on_error"] = json_node["on_error"]
if json_node.get("agent_id"): if json_node.get("agent_id"):
node["agent_id"] = json_node["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 # nest known config keys into config sub-object
config: dict = {} config: dict = {}
@@ -272,7 +320,7 @@ def orchestration_to_yaml(data: dict) -> dict:
# also preserve any extra keys not in the map # also preserve any extra keys not in the map
for k, v in json_node.items(): for k, v in json_node.items():
if k not in ("id", "type", "description", "on_error", "agent_id") \ 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: if v is not None:
config[k] = v config[k] = v
+82 -9
View File
@@ -19,7 +19,21 @@ from typing import Optional
from mcp import ClientSession from mcp import ClientSession
from mcp.client.sse import sse_client 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__) logger = logging.getLogger(__name__)
@@ -28,27 +42,79 @@ _TOOLS_CACHE: dict = {}
_TOOLS_CACHE_TTL = 120 # 秒 _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): def _transport_cm(server: dict, timeout: float):
"""按 server['transport_type'] 选择官方 SDK 传输层。 """按 server['transport_type'] 选择官方 SDK 传输层。
返回异步上下文管理器,进入后产出 (read_stream, write_stream, ...)。 返回异步上下文管理器,进入后产出 (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() url = (server.get("url") or "").strip()
ttype = (server.get("transport_type") or "streamable_http").lower() ttype = (server.get("transport_type") or "streamable_http").lower()
headers = server.get("headers") or {} headers = server.get("headers") or {}
if ttype == "sse": if ttype == "sse":
return sse_client(url, headers=headers, timeout=timeout) # SSE 也有同样问题,新版 sse_client 签名也可能变。这里保守用老 kwargs(若失败 fallback)
# 默认 streamable_http(官方 SDK 标准传输) return sse_client(url, headers=headers, timeout=timeout,
return streamablehttp_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( 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 -> 目标请求)。 """用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
返回解析后的 dict:{"result": {...}} 或 None(失败)。 返回解析后的 dict:{"result": {...}} 或 None(失败)。
协议版本由 SDK 自动协商,不再硬编码。 协议版本由 SDK 自动协商,不再硬编码。
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
""" """
url = (server.get("url") or "").strip() url = (server.get("url") or "").strip()
if not url: if not url:
@@ -89,33 +155,40 @@ async def _session_request(
} }
except Exception as e: except Exception as e:
logger.warning(f"[MCP] {method} 异常 {url}: {e}") logger.warning(f"[MCP] {method} 异常 {url}: {e}")
if raise_on_error:
raise
return None return None
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 服务器的工具列表。 拉取 MCP 服务器的工具列表。
server: {url, transport_type, headers} server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。 返回 MCP tools 数组(每个含 name/description/inputSchema)。
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
""" """
url = (server.get("url") or "").strip() url = (server.get("url") or "").strip()
if not url: if not url:
return [] return []
# 缓存 # 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
if not raise_on_error:
now = asyncio.get_event_loop().time() now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url) cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL: if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1] return cached[1]
try: try:
data = await _session_request(server, "tools/list", {}, timeout) data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
tools = [] tools = []
if data and "result" in data: if data and "result" in data:
tools = data["result"].get("tools", []) or [] 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 return tools
except Exception as e: except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}") logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
if raise_on_error:
raise
return [] return []
+44 -2
View File
@@ -39,11 +39,15 @@ from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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, skills_api 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.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_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_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.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router 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 from app.api.admin.mcp_market_admin import router as mcp_market_router
@@ -188,6 +192,38 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {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 启动完成") logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield yield
@@ -237,6 +273,7 @@ app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", 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(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
@@ -255,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(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
app.include_router(ads.router, prefix="/app/api/admin/ads", 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_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(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
@@ -275,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_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_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(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(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(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
@@ -283,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(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(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(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") @app.get("/app/api/health")
+233
View File
@@ -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)
+396
View File
@@ -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()))
+370
View File
@@ -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)
+143
View File
@@ -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"
+63 -17
View File
@@ -1,29 +1,74 @@
model_list: model_list:
- model_name: deepseek-v4-flash
litellm_params:
model: deepseek-ai/DeepSeek-V4-Flash
api_base: https://api.siliconflow.cn/v1
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
rpm: 1000
tpm: 200000
- model_name: agnes-2.0-flash - model_name: agnes-2.0-flash
litellm_params: litellm_params:
model: openai/agnes-2.0-flash model: openai/agnes-2.0-flash
api_base: https://ai.qihuapi.com/v1 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 api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
rpm: 500 - 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 - model_name: mimo-v2.5
litellm_params: litellm_params:
model: openai/mimo-v2.5 model: openai/mimo-v2.5
api_base: https://token-plan-cn.xiaomimimo.com/v1 api_base: https://token-plan-cn.xiaomimimo.com/v1
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
rpm: 500 headers:
- model_name: qwen3-vl-32b api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
- model_name: TeleAI/TeleSpeechASR
litellm_params: litellm_params:
model: openai/Qwen3-VL-32B-Instruct 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_base: https://api.siliconflow.cn/v1
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
rpm: 300
litellm_settings: litellm_settings:
drop_params: true drop_params: true
@@ -33,9 +78,10 @@ general_settings:
master_key: sk-litellm-master-key-aiflow-2026 master_key: sk-litellm-master-key-aiflow-2026
router_settings: router_settings:
routing_strategy: "usage-based-routing-v2" routing_strategy: simple-shuffle
allowed_fails: 2 allowed_fails: 100000
num_retries: 2 num_retries: 0
cooldown_time: 0
fallbacks: fallbacks:
- deepseek-v4-flash: ["agnes-2.0-flash"] - deepseek-v4-flash: ["agnes-2.0-flash"]
- agnes-2.0-flash: ["deepseek-v4-flash"] - agnes-2.0-flash: ["deepseek-v4-flash"]
+88
View File
@@ -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"]
+36
View File
@@ -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;
}
}
+5
View File
@@ -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
+36
View File
@@ -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
+35
View File
@@ -100,6 +100,7 @@ services:
- MIMO_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz - MIMO_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
- DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/litellm - DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/litellm
- LITELLM_MODE=PRODUCTION - LITELLM_MODE=PRODUCTION
- STORE_MODEL_IN_DB=True
restart: unless-stopped restart: unless-stopped
networks: networks:
- aiflow-network - aiflow-network
@@ -118,6 +119,39 @@ services:
networks: networks:
- aiflow-network - 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: volumes:
redis_data: redis_data:
meilisearch_data: meilisearch_data:
@@ -125,3 +159,4 @@ volumes:
networks: networks:
aiflow-network: aiflow-network:
driver: bridge driver: bridge
+327 -455
View File
@@ -1,84 +1,93 @@
# 七虎AI PaaS 平台 — 架构设计与实施方案 v2.0 # 七虎AI PaaS 平台 — 架构设计与实施方案 v3.0
> **最后更新**: 2026-07-27 > **最后更新**: 2026-08-12
> **版本**: v2.0 > **版本**: v3.0
> **用途**: 投喂 AI 大模型进行平台竞争力、护城河、扩展能力分析 > **用途**: 平台竞争力、护城河、技术框架、功能架构、业务逻辑完整文档
> **维护**: 随系统迭代实时更新 > **维护**: 随系统迭代实时更新
> **仓库**: http://git.qihuyun.com/qyyfw/qihuaiflow
--- ---
## 一、平台概述 ## 一、平台护城河 (Moat)
### 1.1 定位 ### 1.1 核心竞争壁垒
七虎AI(qihuyun.com)是一个面向开发者和企业的 **AI PaaS 平台**,核心能力是聚合多厂商大模型、提供标准化工具调用层、支持 Skill/MCP/Agent 的发现-安装-编排-执行全生命周期管理。 | 壁垒维度 | 具体实现 | 竞争门槛 |
|---------|---------|---------|
| **模型聚合网关** | 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 核心价值主张 ### 1.2 竞争策略
| 价值维度 | 实现方式 | | 策略 | 我方 | 竞品(AI PaaS) | 竞品(垂直SaaS) |
|---------|---------| |------|------|-------------|-------------|
| 模型聚合 | 统一 OpenAI 兼容协议接入 4+ 厂商(SiliconFlow、MiMo、AGNES、DeepSeek),自动 failover | | 模型数量 | 14+ 实时可增删 | 3-5 个 | 1 个 |
| 技能市场 | Skills 的上架-搜索-安装-执行闭环,支持 14 个能力分类 | | 技能市场 | 97 个,自建生态 | 0-20 个 | 无 |
| MCP 协议 | 官方 Anthropic MCP SDK 实现,支持 streamable_http/SSE 传输 | | IM Bot | 微信 + 企业微信扩展 | 无 | 自己开发 |
| Agent 编排 | DAG 图引擎 + Supervisor 模式,支持外部 Agent(Dify/n8n/Coze)远程调用 | | 多租户 | PG RLS 行级安全 | 应用层隔离 | 单租户 |
| IM Bot | 多平台(微信/企业微信/飞书/QQ)统一消息网关 | | 部署 | Docker Compose 一行启动 | Kubernetes 复杂 | 无 Docker |
| 知识库 RAG | PostgreSQL pgvector + 混合检索 | | 定价 | 免费层 + 会员订阅 | 仅付费 | 固定价 |
| 媒体生成 | 异步图片/视频生成(后台线程,不阻塞 SSE) |
| 可观测 | Prometheus+Grafana 监控栈 + 日志审计 |
--- ---
## 二、整体架构 ## 二、部署架构
### 2.1 部署架构 ### 2.1 物理拓扑
``` ```
Internet Internet
│ │
┌────▼────┐ ┌─────────▼─────────┐
│ Nginx │ :12888 (HTTPS) │ 宝塔 Nginx :443 │ SSL 终止 + 反向代理
└────┬────┘ │ root: /www/dk_ │
│ │ project/wwwroot/ │
│ ai.qihuyun.com │
└─────────┬─────────┘
│ :12888
┌──────────────┼──────────────┐ ┌──────────────┼──────────────┐
│ │ │ │ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼─────┐ ┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ React │ │FastAPI │ │ Casdoor │ │ Nginx │ │ uvicorn │ │ 静态资源 │
│ Vite │ │uvicorn │ │ :8000 │ │ :8080 │ │ :18080 │ │ dist/ │
│ (dist/) │ │:18080 │ └─────────┘ │ proxy_pass │ FastAPI │ │ React SPA│
└─────────┘ └───┬────┘ └──────────┘ └────┬─────┘ └──────────┘
│ │
┌─────────────┼─────────────┐ ┌─────────────┼─────────────────┐
│ │ │ │ │ │
┌────▼────┐ ┌───▼────┐ ┌───▼──────┐ ┌────▼────┐ ┌───▼────┐ ┌────▼─────┐
│Postgres │ │ Redis │ │ Executor │ │Postgres │ │ Redis │ │LiteLLM │
│PG18+ │ │:6379 │ │ (Code) │ │PG18 │ │ :6379 │ │ :4000 │
│pgvector │ └────────┘ └──────────┘ │+pgvector│ └────────┘ │ LLM 网关 │
└─────────┘ └─────────┘ └──────────┘
``` ```
### 2.2 容器清单 ### 2.2 容器清单 (Docker Compose)
| 容器 | 镜像 | 用途 | | 容器 | 镜像 | 端口 | 用途 |
|------|------|------| |------|------|------|------|
| qihu-aiflow | 自建(custom) | 主应用(Nginx + FastAPI uvicorn + React dist) | | **qihu-aiflow** | qihuaiflow:with-deps | 12888/18080/8080 | 主应用: Nginx + FastAPI + React dist |
| qihu-aiflow-pg18 | pgvector/pgvector:pg16 | PostgreSQL 主库 + 向量扩展 | | **qihu-aiflow-litellm** | berriai/litellm:main-stable | 14000→4000 | LLM 网关 + DB 模式模型管理 |
| qihu-aiflow-redis | redis:7-alpine | 缓存/队列/会话 | | **qihu-aiflow-pg18** | pgvector/pgvector:pg16 | 5432 | PostgreSQL 主库 + 向量引擎 |
| qihu-aiflow-casdoor | casdoor | 统一认证 | | **qihu-aiflow-redis** | redis:7-alpine | 6379 | 缓存/队列/会话 |
| qihuaiflow-executor | 自建 | 代码执行沙箱 | | **qihu-aiflow-casdoor** | casdoor | 8000 | 统一认证 OAuth2/OIDC |
| qihu-aiflow-prometheus | prom/prometheus | 指标采集 | | **qihu-aiflow-meilisearch** | meilisearch:v1.11 | 17700 | 技能市场搜索引擎 |
| qihu-aiflow-grafana | grafana/grafana | 可视化监控 | | **qihu-aiflow-prometheus** | prom/prometheus | 9090 | 指标采集 |
| qihu-aiflow-alloy | grafana/alloy | 日志采集 Agent | | **qihu-aiflow-grafana** | grafana/grafana | 3000 | 可视化看板 |
| qihu-aiflow-node-exporter | prom/node-exporter | 宿主机指标 | | **qihu-aiflow-alloy** | grafana/alloy | - | 日志采集 Agent |
| **qihu-aiflow-node-exporter** | prom/node-exporter | 9100 | 宿主机指标 |
### 2.3 数据流 ### 2.3 网络与域名
``` | 域名 | 用途 |
用户请求 → Nginx(:12888) → uvicorn(:18080) |------|------|
├── /app/api/chat/* → chat.py (SSE streaming) | ai.qihuyun.com | 主站 (React SPA + FastAPI API) |
├── /app/api/auth/* → auth.py (JWT) | docs.qihuyun.com | 文档站 |
├── /app/api/admin/* → admin/* (CRUD + 管理) | bot.qihuyun.com | Bot 回调地址 |
└── / → index.html → React SPA
```
--- ---
@@ -86,459 +95,322 @@
| 层级 | 技术 | 版本 | 选型理由 | | 层级 | 技术 | 版本 | 选型理由 |
|------|------|------|---------| |------|------|------|---------|
| 后端框架 | FastAPI (Python) | 0.115+ | 异步原生、SSE 流式支持、Pydantic 验证 | | **后端框架** | FastAPI (Python 3.11) | 0.115+ | 异步原生、SSE 流式、Pydantic 验证、自动 OpenAPI |
| ASGI 服务器 | uvicorn | 0.30+ | 单进程事件循环、生产级 | | **ASGI 服务器** | uvicorn | 0.30+ | 单进程事件循环、生产级 |
| 前端框架 | React + Vite | 18 + 5 | 组件化、HMR 热更新、Tree-shaking | | **前端框架** | React + Vite | 18.3 + 5.4 | 组件化、HMR、TypeScript 5.6 严格模式 |
| 前端语言 | TypeScript | 5.6+ | 类型安全 | | **前端构建** | Tailwind CSS v4.3 | @tailwindcss/vite | utility-first, design token 驱动 |
| 关系数据库 | PostgreSQL | 18 | JSONB、pgvector 原生支持、AIO 引擎 | | **状态管理** | Zustand 5 | persist | 轻量级,三态主题 (light/dark/system) |
| 向量引擎 | pgvector | 0.8.4 | 与主库同集群、免运维 | | **关系数据库** | PostgreSQL 18 | pgvector 0.8.4 | JSONB、向量原生命支持 |
| 缓存 | Redis | 7 | 会话、队列、工具列表缓存 | | **向量引擎** | pgvector | 0.8.4 | 与主库同集群、混合检索 BM25+语义 |
| 认证 | Casdoor | latest | 开源统一认证、OAuth2/OIDC | | **缓存/队列** | Redis 7 | - | 会话、工具列表缓存、事件总线 |
| ORM | SQLAlchemy | 2.0.36 | 成熟生态、支持 async | | **认证** | Casdoor | latest | OAuth2/OIDC、自建身份提供商 |
| LLM 客户端 | openai Python SDK | 1.x | OpenAI 兼容协议统一接入 | | **ORM** | SQLAlchemy 2.0 | 2.0.36 | 成熟生态、async 支持、psycopg |
| MCP 客户端 | mcp Python SDK | 1.28.1 | Anthropic 官方 SDK | | **LLM 客户端** | openai Python SDK | 1.x | 统一 OpenAI 协议接入 |
| 代理 | Nginx | 1.25+ | 反向代理、静态文件、SSL | | **LLM 网关** | LiteLLM | main-stable | 模型路由、协议转换、费用追踪、key 管理 |
| 容器 | Docker Compose | v2 | 单机编排 | | **MCP 客户端** | mcp Python SDK | 0.28.1 | Anthropic 官方 SDK、streamable_http/SSE |
| 监控 | Prometheus + Grafana | latest | 开源标准、全栈可见 | | **搜索引擎** | Meilisearch | v1.11 | BM25 + jieba 中文分词、sub-50ms 响应 |
| **代理** | Nginx | 1.25+ | 反向代理、静态文件、SSL、限流、扫描器拦截 |
| **可观测** | Prometheus + Grafana | latest | 指标仪表盘、双 Claw OTel 标签 |
| **容器** | Docker Compose | v2 | 单机部署、一行启动 |
--- ---
## 四、数据模型(67 张表,按域分组) ## 四、功能架构
### 4.1 核心业务域 ### 4.1 系统分层
| 表名 | 说明 | 关键字段 |
|------|------|---------|
| users | 用户 | id, username, password_hash, role |
| conversations | 对话 | id, user_id, title, model_id |
| messages | 消息 | id, conv_id, role, content, tool_calls, used_skills |
| models | AI 模型 | id, provider_id, name, endpoint, capabilities, is_default |
| model_providers | 模型提供商 | id, name, protocol, api_key, api_base_url, auth_header |
| site_settings | 站点配置 | key, value (JSON) |
| mcpservers | MCP 服务 | id, name, url, transport_type, is_enabled, tools_count |
### 4.2 技能市场域
| 表名 | 说明 |
|------|------|
| market_apps | 市场应用(Skills/MCP/Agents) |
| market_app_versions | 应用版本 |
| app_purchases | 购买记录 |
| app_reviews | 评价 |
| app_installations | 安装记录 |
| skill_versions | 技能版本 |
| skill_publish_records | 发布记录 |
| evolution_skills | 进��技能 |
| evolution_events | 进化事件 |
| evolution_trajectories | 进化轨迹 |
| team_shared_skills | 团队共享技能 |
### 4.3 知识库域
| 表名 | 说明 |
|------|------|
| knowledge_bases | 知识库 |
| knowledge_directories | 知识目录 |
| knowledge_documents | 文档 |
| knowledge_document_chunks | 文档分块 |
| knowledge_graph_nodes | 知识图谱节点 |
| knowledge_graph_edges | 知识图谱边 |
| external_kb_services | 外部 KB 服务 |
### 4.4 会员/支付域
| 表名 | 说明 |
|------|------|
| membership_plans | 会员计划 |
| membership_subscriptions | 订阅 |
| orders | 订单 |
| coupons | 优惠券 |
| user_coupons | 用户优惠券 |
| value_added_services | 增值服务 |
| payment_configs | 支付配置 |
### 4.5 监控/运维域
| 表名 | 说明 |
|------|------|
| token_usage_logs | Token 用量日志 |
| token_balances | 余额 |
| operation_logs | 操作日志 |
| audit_logs | 审计日志 |
| login_logs | 登录日志 |
| risk_rules | 风控规则 |
| slow_request_logs | 慢请求日志 |
| api_call_stats | API 调用统计 |
| task_execution_logs | 任务执行日志 |
---
## 五、API 全景
### 5.1 前台 API(用户面)
| 路由前缀 | 端点 | 方法 | 说明 |
|---------|------|------|------|
| /app/api/auth | /login | POST | 用户登录,返回 JWT |
| /app/api/auth | /register | POST | 注册 |
| /app/api/chat | /conversations | GET/POST | 对话列表/创建 |
| /app/api/chat | /conversations/{id}/messages | POST | 发送消息(非流式) |
| /app/api/chat | /conversations/{id}/messages/stream | POST | **SSE 流式聊天** |
| /app/api/chat | /models | GET | 可用模型列表 |
| /app/api/chat | /media/relay | GET | 媒体代理 |
| /app/api/market | /apps | GET | 应用市场列表 |
| /app/api/market | /apps/{id} | GET | 应用详情 |
| /app/api/knowledge | /search | POST | 知识库搜索 |
### 5.2 后台 API(管理面)
| 路由前缀 | 说明 |
|---------|------|
| /app/api/admin/users | 用户管理 |
| /app/api/admin/models | 模型管理 |
| /app/api/admin/skills | 技能管理 |
| /app/api/admin/agents | Agent 管理 |
| /app/api/admin/mcp | MCP 服务管理 |
| /app/api/admin/workflow | 工作流管理 |
| /app/api/admin/market | 市场管理 |
| /app/api/admin/analytics | 数据分析 |
| /app/api/admin/developer | 开发者 API |
| /app/api/admin/settings | 站点设置 |
| /app/api/admin/storage | 存储管理 |
| /app/api/admin/logs | 日志查看 |
| /app/api/admin/automation | 自动化任务 |
### 5.3 SSE 事件类型
``` ```
POST /conversations/{id}/messages/stream ┌─────────────────────────────────────────────────┐
Content-Type: text/event-stream │ 表现层 (Frontend) │
│ React SPA | Tailwind CSS | Zustand State │
事件帧: │ AppShell (NavRail+抽屉) | 三态主题 │
data: {"type":"reasoning","content":"..."} // 模型推理过程 ├─────────────────────────────────────────────────┤
data: {"type":"delta","content":"..."} // 逐字回复增量 │ 网关层 (Gateway) │
data: {"type":"step","step":{...}} // 工具调用步骤 │ Nginx | SSL 终止 | 限流 | 扫描器拦截 | IP 黑名单 │
step.type: "thinking" | "skill" | "mcp" ├─────────────────────────────────────────────────┤
data: {"type":"done","message_id":"..."} // 流结束 │ 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)
### 6.1 消息处理管道(主循环) | 模块 | 路径 | 功能 |
```
用户消息
→ 鉴权(JWT Bearer)
→ 上下文恢复(skill_ids/agent_ids/mcp_ids 跨轮继承)
→ 技能解析(显式选择 → 自动发现 → Agent 合并)
→ MCP 工具列表拉取(带缓存 TTL 120s,按 url + transport 缓存)
→ 知识库检索(RAG,BM25 + vector hybrid)
→ System Prompt 组装(AGENT_GUIDE + platform_knowledge + agent_role + skill_desc + kb_context)
→ Function Definitions 组装(skills → tools + MCP → tools + install_app)
→ LLM 调用(OpenAI 兼容协议,stream=True)
→ Tool Calls 循环(max 15 轮,每轮 tool_policy 沙箱检查)
├── MCP Tool → call_mcp_tool() via Anthropic SDK
├── Skill → _execute_skill_tool() 指令转发
└── install_app → 安装到市场
→ 工具结果回注 → 继续 LLM 循环 → 最终回复
→ 上下文压缩(后台异步,fire-and-forget)
→ 结构化记忆抽取(实体/事实/偏好/决策/待办 五类)
→ 媒体生成(后台 daemon 线程,独立事件循环)
```
### 6.2 模型路由与 Failover 管道
```
模型选择 → 解析 api_url/api_key/provider.auth_header
→ build_openai_client(httpx.Timeout + trust_env=False + auth_header 支持)
→ 首次调用失败(Exception)→ continue 下一个模型
→ 备用模型列表(order_by is_default desc × 5)
→ 成功 → break
→ 全部失败 → 返回错误消息
```
### 6.3 MCP 协议管道
```
Client: mcp_client.py (官方 Anthropic SDK)
list_mcp_tools(server) → tools/list (JSON-RPC)
⇢ 缓存: _TOOLS_CACHE[url] = (timestamp, tools), TTL 120s
call_mcp_tool(server, name, arguments) → tools/call (JSON-RPC)
⇢ 返回: text content blocks → 拼接为字符串 → 反馈给 LLM
传输层:
streamable_http (默认) → streamablehttp_client()
sse → sse_client()
Schema 适配:
MCP inputSchema (JSON Schema 2020-12) → _normalize_mcp_schema()
⇢ 展开 $ref → 处理 oneOf/anyOf → allOf 合并 → 剥离非 OpenAI 关���字
⇢ 转为 OpenAI function calling 兼容的 parameters
认证:
Authorization: Bearer {key} (默认)
api-key: {key} (MiMo Token Plan 等自定义)
```
### 6.4 Agent 编排管道(DAG 图引擎)
```
YAML 定义 → validate_graph() → topological_layers() (Kahn算法)
→ 按层并行执行 (asyncio.gather)
节点类型:
llm → _build_system_prompt + LLM 调用
local → _execute_skill_tool (本地技能)
external → _call_external_agent (Dify/n8n/Coze 远程)
reduce → 多路结果聚合
边: 有向边定义数据流 → 拓扑排序 → 分层并行
失败隔离: per-node on_error=halt|continue
并发安全: 每节点独立 SessionLocal()
预处理流水线:
外部 Agent endpoint → 直接远程调用(跳过本地 LLM)
Orchestrator → run_graph() → 图执行 → 聚合回答
```
### 6.5 媒体生成管道
```
SSE 回复结束 → _launch_media_task_bg()
→ daemon Thread → asyncio.run(_generate_media_task)
→ 独立事件循环(不阻塞主 uvicorn 循环)
→ HTTP 调用图片/视频生成 API
→ 写入存储 → 更新 Message 记录
→ 前端轮询 → 渲染结果
```
### 6.6 上下文压缩管道
```
(后台异步, fire-and-forget, 不阻塞用户响应)
旧消息 (>20 条保护窗口外)
→ 分块 (CHUNK=30) → _summarize_via_llm()
→ chunk_summaries 合并
→ _recursive_compress() (depth≤3, budget=700 tokens)
→ _extract_structured_memory()
实体/事实/偏好/决策/待办 五类
→ _merge_structured_memory(old, new)
→ ConversationSummary 写入 PG
```
---
## 七、前端架构
### 7.1 路由设计
```
前台 (/):
/login - 登录
/chat[/:id] - AI 对话(核心页面,SSE 流式)
/knowledge - 知识库管理
/market - 应用市场(Skills/MCP/Agents)
/token - Token 用量面板
/pricing - 定价/会员
/profile - 个人中心
/assets[/novel/:id] - 资产/小说
/evolution - 进化仪表板
/wechat-bot - 微信 Bot
/im-bots - 多平台 IM Bot Hub
/app-services - 应用服务
后台 (/admin):
/admin - 仪表板
/admin/users - 用户管理
/admin/models - 模型管理
/admin/skills - 技能管理
/admin/agents - Agent 管理
/admin/mcp - MCP 管理
/admin/workflow - 工作流
/admin/market - 市场审核
/admin/knowledge - 知识库管理
/admin/code-exec - 代码执行
/admin/storage - 存储管理
/admin/developer[/tools] - 开发者 API/工具
/admin/automation - 自动化任务
/admin/settings - 站点设置
/admin/analytics - 数据分析
/admin/logs - 日志
/admin/membership - 会员管理
/admin/payment - 支付配置
/admin/services - 增值服务
/admin/ads - 广告管理
/admin/apps - 应用管理
```
### 7.2 核心组件
| 组件 | 文件 | 功能 |
|------|------|------| |------|------|------|
| ChatPage | front/ChatPage.tsx | SSE 流式聊天、Step 渲染、推理展示 | | 落地页 | `/` | 平台介绍 + Hero + 能力展示 |
| AiProcessBlock | ChatPage.tsx 内 | 工具调用过程可视化(⏳/✅/❌) | | AI 对话 | `/chat` | SSE 流式对话、代码块渲染、Markdown、媒体预览 |
| MarketPage | front/MarketPage.tsx | 应用市场网格、分类筛选 | | 知识库 | `/knowledge` | 文档上传、RAG 检索、向量索引管理 |
| FrontLayout | components/layout/FrontLayout.tsx | 前台 sidebar + main 布局 | | 技能市场 | `/market` | Meilisearch 搜索、技能安装、上架审核 |
| AdminLayout | components/layout/AdminLayout.tsx | 后台 sidebar + main 布局 | | Token 驾驶舱 | `/token` | 实时用量、消耗趋势、预警告警 |
| 会员/定价 | `/pricing` | 套餐对比、微信支付订阅、增值付费 |
| 个人中心 | `/profile` | 用户信息、主题设置、API Key 管理 |
| 通知中心 | `/notifications` | 系统消息、订单通知、告警 |
| Agent 画布 | `/agent` | 可视化编排、DAG 工作流编辑 |
| App 服务 | `/apps` | 第三方应用集成(小说、视频等) |
### 7.3 设计系统 #### 后台 (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)
``` ```
CSS 变量(global.css :root): 用户消息 → chat.py _send_message_core
--color-accent: #5B5FED → capability_discovery (发现可用 MCP/Skills/Models)
--color-bg-primary: #F8F9FB → System Prompt 组装 (知识库 + 能力清单 + 对话历史)
--sidebar-width: 240px → LiteLLM /v1/chat/completions (master key 认证)
--space-xs..4xl: 4px..40px → SSE 流式返回 → BufferAdapter 缓冲 → SseAdapter 推送
--radius-md..3xl: 8px..20px → 图片生成: AGNES API → async thread → media relay → 微信推送
→ 日志: Token 用量 + 审计
```
防溢出: html/body/.front-main { overflow-x: hidden } ### 5.2 双 Claw 单引擎架构 (Dual Claw)
响应式: @media (max-width: 1200px/768px) 断点
```
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)
### 8.1 认证 ### 6.1 设计理念
- JWT Bearer Token 认证(FastAPI HTTPBearer) **温暖科技感 + 极简主义** (Warm Tech Minimalism)。护眼雾白底,靛蓝 `#5B5FED` 为唯一品牌色。
- Casdoor OAuth2/OIDC 统一认证(可选)
- Token 前端存内存,401/403 清 token 跳 /login
### 8.2 授权 ### 6.2 技术实现
- 前台: 用户级(需登录) | 维度 | 值 |
- 后台: 管理员级(role=admin) |------|-----|
- API Key: 支持 per-model key 覆盖 provider key | 主品牌色 | `#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 |
### 8.3 工具调用沙箱 ### 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
``` ```
_check_tool_policy() 三层防护:
1. 白名单: agent 只允许调特定 skill_ids + mcp_ids
2. 预算: max_calls_per_conversation 上限
3. 每轮上限: per_round_cap
代码执行: 独立 executor 容器沙箱 ### 6.4 设计铁律
密钥管理: credential.py 加解密存储
``` - ❌ 禁硬编码 `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`
--- ---
## 九、运维架构 ## 七、运维与可靠性
### 9.1 CI/CD ### 7.1 Safe-Align 工作流
``` ```
Git push → pre-push hook (.git/hooks/pre-push) ① 备份: docker commit → backup 镜像 + git tag + bundle + PG dump
→ 容器内 backend pytest (28 用例) ② 拷贝: 仅 git-tracked 源文件 容器→宿主 git
→ 容器内 frontend vitest (2 文件, 15 用例) ③ 提交: git add + commit (不 push)
→ 全绿 → push 放行 ④ 校验: docker restart + 验证 + docker commit → with-deps
→ 失败 → block
部署:
docker cp 容器文件 → 宿主 Git
→ git add -A + commit
→ docker commit → qihuaiflow:with-deps (新镜像)
→ docker compose up -d --force-recreate
``` ```
### 9.2 健康检查 **真相源**: 容器 (image := container := git)
``` ### 7.2 监控
/health → 200 {status: "ok"} (liveness)
前端 :8080 → 200 HTML (SPA)
后端 :18080/docs → 200 Swagger UI
容器: docker restart → < 20s 恢复
```
### 9.3 监控栈 - Grafana 双 Claw 对比看板 `uid: qihuaiflow-dual-claw-876`
- OTel traces + metrics (alloy → Prometheus)
- 日志级别: INFO (生产) / DEBUG (开发)
``` ### 7.3 备份策略
Prometheus (scrape) ← node-exporter (host) + Alloy (logs)
Grafana (visualize) ← Prometheus datasource - 每 6h 自动备份 (cron)
应用日志: Python logging → docker logs / Alloy 采集 - PG dump (不用 --ssl=0 否则 0 bytes)
``` - Docker image snapshot: `qihuaiflow:backup-*{TS}`
- nginx logrotate 每日轮转
--- ---
## 十、市场竞争分析 ## 八、迭代路线
### 10.1 护城河 ### 8.1 已完成 (2026-08-11)
| 护城河 | 具体实现 | 壁垒强度 | | 编号 | 描述 | commit |
|--------|---------|---------| |------|------|--------|
| **多模型协议适配层** | OpenAI/Anthropic/Token Plan 三种认证头、MCP Schema → OpenAI 转换 | 🔴 高 | | #957 | Nginx server 级 root 修复 | 53bbbf3 |
| **Skill 全生命周期** | 发布→审核→上架→安装→执行→进化,闭环 | 🔴 高 | | #959 | Index.html no-store cache | 45b6939 |
| **Agent DAG 编排** | 拓扑排序+分层并行+失败隔离+外部 Agent 远程调用 | 🟡 中 | | #961 | React hydration error fix | 4cf910e |
| **工具调��标准化** | AGENT_GUIDE + tool_policy 沙箱 + 15 轮 loop + 错误自修正 | 🟡 中 | | #962 | Global ErrorBoundary | 420e0f6 |
| **PG 统一数据层** | 主库+向量+Casdoor 同集群 pgvector,混合检索零延迟 | 🟡 中 | | #963 | safeLazy chunk load fallback | 679f23f |
| **IM Bot 多平台** | 统一消息网关,微信/企业微信/飞书/QQ | 🟢 低 | | #964 | React hooks rules fix (#321) | fa38d2c |
| **安全沙箱** | Tool Policy 白名单+预算+代码执行容器隔离 | 🟡 中 | | #965 | LiteLLM platform-model sync | fd84fec |
| #966 | Container restart loop fix | 75be24c |
| #967 | media_relay endpoint impl | 54b444b |
### 10.2 竞品对比 ### 8.2 下一步候选
| 维度 | 七虎AI | Dify | Coze | 扣子 | FastGPT | | 优先级 | 项目 | 工作量 |
|------|--------|------|------|------|---------| |--------|------|--------|
| 模型聚合 | ✅ 手动 failover | ✅ | ✅ | ✅ | ✅ | | 🟢 P0 | 企业微信官方 Bot API 接入 | 3 天 |
| Skill 市场 | ✅ 上架-安装-进化 | ❌ | ❌ (Plugin) | ❌ (Plugin) | ❌ | | 🟢 P1 | 后端核心模块单测补全 (165→300+) | 3 天 |
| MCP 协议 | ✅ 官方 SDK | ✅ | ❌ | ❌ | ❌ | | 🟡 P1 | Alloy traces 配置 bake to image | 1 天 |
| Agent DAG | ✅ 图引擎 | ✅ 工作流 | ✅ 工作流 | ✅ 工作流 | ❌ 简单 | | 🟡 P1 | 多租户 PG RLS 隔离 | 5 天 |
| IM Bot | ✅ 多平台 | ❌ | ❌ | ✅ 飞书 | ❌ | | ⚪ P2 | 语音/视频流式响应 | 5 天 |
| PG 全栈 | ✅ PG18 | ⚠️ MySQL | ? | ? | ✅ PG | | ⚪ P2 | Agent 画布可视化编排 | 7 天 |
| Auth | ✅ Casdoor+JWT | ✅ | ✅ | ✅ | ✅ |
### 10.3 扩展能力 ### 8.3 长期方向
| 扩展方向 | 当前能力 | 增长空间 | 1. **模型 → 智能体**: 从简单的模型调用升级到自主 Agent + 多步推理
|---------|---------|---------| 2. **单机 → 集群**: Docker Compose → Kubernetes + Helm
| 模型厂商 | 4 家 | 新增厂商只需配 ModelProvider | 3. **中国市场 → 国际化**: 多语言 + 多区域部署
| Skill 类型 | 14 场景分类 | 支持自定义执行器(非纯 LLM 转发) | 4. **技能市场 → 开放生态**: 第三方开发者平台 + 收入分成
| MCP 服务 | 任意 MCP Server | MCP Registry 对接 | 5. **个人微信 → 企业微信**: 合规化 IM Bot 渠道
| Agent | Dify/n8n/Coze + 自研 DAG | CrewAI/LangGraph 集成 |
| 前端渠道 | Web Chat + IM Bot | API/Webhook/SDK |
| 计费 | Token + 会员 | 按 Skill/MCP 调用计费 |
--- ---
## 十一、迭代路线图 ## 九、附录
### 已完成 ### 9.1 服务器信息
- [x] PostgreSQL 迁移(MySQL → PG18 + pgvector) | 项 | 值 |
- [x] SSE 四类事件(reasoning/delta/step/done) |------|-----|
- [x] MCP 协议标准化(官方 SDK,Schema 转换) | IP | 210.16.161.218 |
- [x] Agent DAG 编排引擎 + YAML workflow | OS | Ubuntu 24.04 |
- [x] 工具调用标准化(AGENT_GUIDE + install_app 优化 + max_rounds 15 + MCP 命名) | 公网地址 | https://ai.qihuyun.com |
- [x] 模型 Failover + 事件循环防冻(#812) | 后台地址 | https://ai.qihuyun.com/admin |
- [x] Token Plan 适配(MiMo custom auth_header) | 默认账户 | admin / admin123 |
- [x] 前端 Step 渲染增强(⏳/✅/❌ 状态) | Git | http://git.qihuyun.com/qyyfw/qihuaiflow |
- [x] 横向溢出防护(overflow-x: hidden 三层)
- [x] 知识库 KG 构建
- [x] 多平台 IM Bot Hub
- [x] Prometheus+Grafana 监控栈
### 进行中 ### 9.2 关键文件路径
- [ ] Skill 真实执行器(非 LLM 转发模拟) | 文件 | 容器路径 |
- [ ] 多 Agent 协作(Supervisor 模式) |------|---------|
- [ ] API/OpenAPI 对外接口 | 后端入口 | `/app/backend/python/main.py` |
- [ ] 技能 SDK 发布工具 | 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 调试经验
- [ ] LiteLLM 集成(统一模型路由+成本追踪) | 问题 | 解决方案 |
- [ ] 流式 Token 计费(精确到字符级) |------|---------|
- [ ] LangGraph 工作流引擎 | 容器重启循环 | docker stop → docker cp 修复文件 → docker start |
- [ ] 向量混合检索增强(BM25 + dense + sparse) | React #321 hooks error | useState 必须在组件函数内,不能模块顶层 |
- [ ] 前端暗色模式 | Chrome 缓存旧 HTML | index.html 加 Cache-Control: no-store |
- [ ] 多语言(i18n) | nginx 调测 444 | curl 用 -A 'Mozilla/5.0' 避免被 P1-2 拦截 |
- [ ] 移动端适配 | Docker tag 短ID 报错 | 必须用完整 sha256: 格式 ID |
| SSH paramiko 掉线 | get_ssh() 3次重试 + 指数退避 |
---
> **文档维护规则**: 每次系统重大迭代后,更新本文档对应章节并变更版本号。
> **上传位置**: Gitea 仓库 `qihuaiflow/docs/ARCHITECTURE.md`
+109
View File
@@ -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,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。
+140
View File
@@ -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`。
+37
View File
@@ -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
+60
View File
@@ -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)
+69 -50
View File
@@ -10,55 +10,71 @@ import AdminLayout from '@/components/layout/AdminLayout';
// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, // 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖,
// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 // 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。
const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); // #963: lazy() + .catch() fallback so element never undefined
const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); const safeLazy = (loader: () => Promise<{ default: React.ComponentType<any> }>) =>
const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); React.lazy(() =>
const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); loader().catch((err) => {
const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); console.error('[qihu-lazy-load-failed]', err);
const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); return { default: () => (
const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); React.createElement('div', { style: { padding: 20, color: '#dc2626' } },
const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); 'Chunk load failed: ' + String((err as Error)?.message || err))
const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); ) } as any;
const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); })
const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); );
const AgentCanvas = React.lazy(() => import('@/pages/front/AgentCanvas'));
const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard'));
const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); const LandingPage = safeLazy(() => import('@/pages/front/LandingPage'));
const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); const LoginPage = safeLazy(() => import('@/pages/front/LoginPage'));
const AdminModels = React.lazy(() => import('@/pages/admin/Models')); const ChatPage = safeLazy(() => import('@/pages/front/ChatPage'));
const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); const KnowledgePage = safeLazy(() => import('@/pages/front/KnowledgePage'));
const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig')); const MarketPage = safeLazy(() => import('@/pages/front/MarketPage'));
const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); const TokenDashboardPage = safeLazy(() => import('@/pages/front/TokenDashboardPage'));
const AdminServices = React.lazy(() => import('@/pages/admin/Services')); const PricingPage = safeLazy(() => import('@/pages/front/PricingPage'));
const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); const ProfilePage = safeLazy(() => import('@/pages/front/ProfilePage'));
const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); const NotificationPage = safeLazy(() => import('@/pages/front/NotificationPage'));
const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); const AssetsPage = safeLazy(() => import('@/pages/front/AssetsPage'));
const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); const NovelPreviewPage = safeLazy(() => import('@/pages/front/NovelPreviewPage'));
const AdminSkills = React.lazy(() => import('@/pages/admin/Skills')); const AgentCanvas = safeLazy(() => import('@/pages/front/AgentCanvas'));
const AdminAgents = React.lazy(() => import('@/pages/admin/Agents')); const EvolutionDashboard = safeLazy(() => import('@/pages/front/EvolutionDashboard'));
const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec'));
const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder')); const AdminDashboard = safeLazy(() => import('@/pages/admin/Dashboard'));
const ImBots = React.lazy(() => import('@/pages/admin/ImBots')); const AdminUsers = safeLazy(() => import('@/pages/admin/Users'));
const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings')); const AdminModels = safeLazy(() => import('@/pages/admin/Models'));
const Promotion = React.lazy(() => import('@/pages/admin/Promotion')); const AdminSiteSettings = safeLazy(() => import('@/pages/admin/SiteSettings'));
const Achievement = React.lazy(() => import('@/pages/admin/Achievement')); const AdminSmsConfig = safeLazy(() => import('@/pages/admin/SmsConfig'));
const Tenant = React.lazy(() => import('@/pages/admin/Tenant')); const AdminPayment = safeLazy(() => import('@/pages/admin/Payment'));
const Billing = React.lazy(() => import('@/pages/admin/Billing')); const AdminServices = safeLazy(() => import('@/pages/admin/Services'));
const Analytics = React.lazy(() => import('@/pages/admin/Analytics')); const AdminMembership = safeLazy(() => import('@/pages/admin/Membership'));
const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge')); const AdminAds = safeLazy(() => import('@/pages/admin/Ads'));
const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); const AdminMarket = safeLazy(() => import('@/pages/admin/Market'));
const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); const AdminMarketProducts = safeLazy(() => import('@/pages/admin/MarketProductsAdmin'));
const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer')); const AdminLogs = safeLazy(() => import('@/pages/admin/Logs'));
const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools')); const AdminSkills = safeLazy(() => import('@/pages/admin/Skills'));
const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation')); const AdminAgents = safeLazy(() => import('@/pages/admin/Agents'));
const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp')); const AdminCodeExec = safeLazy(() => import('@/pages/admin/CodeExec'));
const AdminApps = React.lazy(() => import('@/pages/admin/Apps')); const DistributionBuilder = safeLazy(() => import('@/pages/admin/DistributionBuilder'));
const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter')); const ImBots = safeLazy(() => import('@/pages/admin/ImBots'));
const UserPortal = React.lazy(() => import('@/pages/UserPortal')); const PrivacySettings = safeLazy(() => import('@/pages/admin/PrivacySettings'));
const WechatBot = React.lazy(() => import('@/pages/front/WechatBot')); const Promotion = safeLazy(() => import('@/pages/admin/Promotion'));
const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub')); const Achievement = safeLazy(() => import('@/pages/admin/Achievement'));
const AppServices = React.lazy(() => import('@/pages/front/AppServices')); const Tenant = safeLazy(() => import('@/pages/admin/Tenant'));
const Billing = safeLazy(() => import('@/pages/admin/Billing'));
const Analytics = safeLazy(() => import('@/pages/admin/Analytics'));
const AdminKnowledge = safeLazy(() => import('@/pages/admin/Knowledge'));
const AdminWorkflow = safeLazy(() => import('@/pages/admin/Workflow'));
const AdminStorage = safeLazy(() => import('@/pages/admin/Storage'));
const AdminDeveloper = safeLazy(() => import('@/pages/admin/Developer'));
const AdminDeveloperTools = safeLazy(() => import('@/pages/admin/DeveloperTools'));
const AdminAutomation = safeLazy(() => import('@/pages/admin/Automation'));
const AdminMcp = safeLazy(() => import('@/pages/admin/Mcp'));
const AdminApps = safeLazy(() => import('@/pages/admin/Apps'));
const AssetCenter = safeLazy(() => import('@/pages/admin/AssetCenter'));
const UserPortal = safeLazy(() => import('@/pages/UserPortal'));
const WechatBot = safeLazy(() => import('@/pages/front/WechatBot'));
const ImBotHub = safeLazy(() => import('@/pages/front/ImBotHub'));
const AppServices = safeLazy(() => import('@/pages/front/AppServices'));
const MarketProducts = safeLazy(() => import('@/pages/front/MarketProducts'));
const MarketProductDetail = safeLazy(() => import('@/pages/front/MarketProductDetail'));
const MarketProductsAdmin = safeLazy(() => import('@/pages/admin/MarketProductsAdmin'));
const pageFallback = <div className="loading-screen">加载中...</div>; const pageFallback = <div className="loading-screen">加载中...</div>;
@@ -90,7 +106,7 @@ const App: React.FC = () => {
localStorage.removeItem('aiflow_token'); localStorage.removeItem('aiflow_token');
localStorage.removeItem('aiflow_user'); localStorage.removeItem('aiflow_user');
}; };
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
if (loading) { if (loading) {
return <div className="loading-screen">加载中...</div>; return <div className="loading-screen">加载中...</div>;
@@ -127,10 +143,12 @@ const App: React.FC = () => {
<Route path="wechat-bot" element={<WechatBot />} /> <Route path="wechat-bot" element={<WechatBot />} />
<Route path="im-bots" element={<ImBotHub />} /> <Route path="im-bots" element={<ImBotHub />} />
<Route path="app-services" element={<AppServices />} /> <Route path="app-services" element={<AppServices />} />
<Route path="/marketplace/products" element={<MarketProducts />} />
<Route path="/marketplace/products/:id" element={<MarketProductDetail />} />
</Route> </Route>
{/* Admin Routes */} {/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}> <Route path="/admin" element={isAdmin ? <AdminLayout /> : <Navigate to="/login" replace />}>
<Route index element={<AdminDashboard />} /> <Route index element={<AdminDashboard />} />
<Route path="users" element={<AdminUsers />} /> <Route path="users" element={<AdminUsers />} />
<Route path="models" element={<AdminModels />} /> <Route path="models" element={<AdminModels />} />
@@ -141,6 +159,7 @@ const App: React.FC = () => {
<Route path="membership" element={<AdminMembership />} /> <Route path="membership" element={<AdminMembership />} />
<Route path="ads" element={<AdminAds />} /> <Route path="ads" element={<AdminAds />} />
<Route path="market" element={<AdminMarket />} /> <Route path="market" element={<AdminMarket />} />
<Route path="market-products" element={<MarketProductsAdmin />} />
<Route path="logs" element={<AdminLogs />} /> <Route path="logs" element={<AdminLogs />} />
<Route path="skills" element={<AdminSkills />} /> <Route path="skills" element={<AdminSkills />} />
<Route path="agents" element={<AdminAgents />} /> <Route path="agents" element={<AdminAgents />} />
+187
View File
@@ -0,0 +1,187 @@
// 平台应用市场 API 客户端 — 独立可部署产品(MuMuAINovel 类)
// 落库: market_products / market_product_access / market_product_health
const PRIMARY = '#5B5FED';
export interface MarketProduct {
id: string;
slug: string;
name: string;
icon_url?: string | null;
description?: string | null;
category: string; // 分类键:novel / coding / data / creative / education / finance / marketing / other
product_kind: 'saas' | 'desktop' | 'cli' | 'api' | 'mobile' | string;
status: 'pending' | 'active' | 'suspended' | 'deprecated' | string;
docker_image?: string | null;
endpoint_url?: string | null;
billing_plan: 'free' | 'metered' | 'subscription' | 'freemium' | string;
scopes?: string[];
requires_approval: boolean;
created_by_admin: boolean;
published_at?: string | null;
metadata?: Record<string, unknown> | null;
created_at: string;
updated_at: string;
}
export interface MarketProductAccess {
id: string;
user_id: string;
product_id: string;
status: 'pending' | 'approved' | 'rejected' | 'revoked' | string;
requested_at: string;
approved_at?: string | null;
revoked_at?: string | null;
reviewer_admin_id?: string | null;
note?: string | null;
}
export interface MarketProductHealth {
id: number;
product_id: string;
status: 'healthy' | 'degraded' | 'down' | string;
latency_ms?: number | null;
http_status?: number | null;
checked_at: string;
message?: string | null;
}
export interface MarketProductDetail extends MarketProduct {
health?: MarketProductHealth | null;
has_access: boolean;
my_access?: MarketProductAccess | null;
}
export interface PaginatedProducts {
items: MarketProduct[];
total: number;
page: number;
page_size: number;
}
const API_BASE = '/app/api';
function getToken(): string | null {
return localStorage.getItem('aiflow_token');
}
async function request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ code: number; data: T; message: string }> {
const token = getToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
let payload: any = null;
try { payload = await res.json(); } catch { /* no body */ }
if (!res.ok) {
const msg = payload?.message || payload?.detail || `HTTP ${res.status}`;
throw new Error(msg);
}
return payload;
}
// 注意: 别名 PRIMARY 仅保留供页面级样式引用,无运行时依赖
export const PRIMARY_COLOR = PRIMARY;
export const marketProductAPI = {
// 列表(支持 category / product_kind / billing_plan / search 过滤 + 分页)
list: (params?: {
category?: string;
product_kind?: string;
billing_plan?: string;
search?: string;
page?: number;
page_size?: number;
}) => {
const sp = new URLSearchParams();
if (params?.category) sp.set('category', params.category);
if (params?.product_kind) sp.set('product_kind', params.product_kind);
if (params?.billing_plan) sp.set('billing_plan', params.billing_plan);
if (params?.search) sp.set('search', params.search);
if (params?.page) sp.set('page', String(params.page));
if (params?.page_size) sp.set('page_size', String(params.page_size));
const qs = sp.toString();
return request<PaginatedProducts>(`/market/products${qs ? `?${qs}` : ''}`);
},
// 详情(含 health 与当前用户 has_access)
detail: (id: string) => request<MarketProductDetail>(`/market/products/${id}`),
// 申请访问(仅 requires_approval=true 才需走此)
requestAccess: (id: string, payload?: { note?: string }) =>
request<MarketProductAccess>(`/market/products/${id}/request-access`, {
method: 'POST',
body: JSON.stringify(payload || {}),
}),
// 立即打开(OIDC 跳转 URL,已授权用户直接返回 launch_url)
launch: (id: string) =>
request<{ launch_url: string; token?: string; product_name: string }>(
`/market/products/${id}/launch`,
{ method: 'POST' }
),
// 我的已申请/已开通产品
myProducts: () => request<{ items: MarketProductDetail[] }>(`/market/my-products`),
// ===== 后台 API =====
// 列出待审核(后台 admin)
adminPending: () =>
request<{ items: MarketProduct[]; total: number }>(`/admin/market-products/pending`),
// 全部列表(后台)
adminList: (params?: {
category?: string; product_kind?: string; status?: string; search?: string;
page?: number; page_size?: number;
}) => {
const sp = new URLSearchParams();
if (params?.category) sp.set('category', params.category);
if (params?.product_kind) sp.set('product_kind', params.product_kind);
if (params?.status) sp.set('status', params.status);
if (params?.search) sp.set('search', params.search);
if (params?.page) sp.set('page', String(params.page));
if (params?.page_size) sp.set('page_size', String(params.page_size));
const qs = sp.toString();
return request<PaginatedProducts>(`/admin/market-products${qs ? `?${qs}` : ''}`);
},
// 审核/挂起/恢复/下线(后台)
review: (
id: string,
payload: { action: 'approve' | 'suspend' | 'reactivate' | 'deprecate'; reason?: string }
) =>
request<MarketProduct>(`/admin/market-products/${id}/review`, {
method: 'POST',
body: JSON.stringify(payload),
}),
// 健康探针历史(后台)
healthHistory: (id: string, limit = 50) =>
request<{ items: MarketProductHealth[] }>(
`/admin/market-products/${id}/health?limit=${limit}`
),
// 重新注册 OIDC(后台)
reRegisterOidc: (id: string) =>
request<{ success: boolean; oidc_client_id?: string }>(
`/admin/market-products/${id}/re-register-oidc`,
{ method: 'POST' }
),
// 创建新产品(后台)
create: (payload: Partial<MarketProduct>) =>
request<MarketProduct>(`/admin/market-products`, {
method: 'POST',
body: JSON.stringify(payload),
}),
};
export default marketProductAPI;
+104
View File
@@ -0,0 +1,104 @@
import React from 'react';
interface State {
hasError: boolean;
error: Error | null;
errorInfo: React.ErrorInfo | null;
}
/**
* #962: 全局 ErrorBoundary — 隔离任何组件渲染错误
* - 防止单个组件抛错导致整个 React 树崩溃(root 空白)
* - 显示错误信息便于诊断
* - 提供 reset 按钮,方便恢复
*/
export default class ErrorBoundary extends React.Component<{ children: React.ReactNode }, State> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('[qihu-error-boundary]', error, errorInfo);
this.setState({ errorInfo });
}
reset = () => {
this.setState({ hasError: false, error: null, errorInfo: null });
// Hard reload to clear any bad state
if (typeof window !== 'undefined') {
window.location.href = window.location.pathname + window.location.search;
}
};
render() {
if (this.state.hasError && this.state.error) {
const { error, errorInfo } = this.state;
return (
<div style={{
padding: '32px',
margin: '32px auto',
maxWidth: '800px',
fontFamily: 'monospace',
background: '#fef2f2',
border: '2px solid #dc2626',
borderRadius: '8px',
color: '#1a1a1a',
}}>
<h1 style={{ color: '#dc2626', marginTop: 0 }}>❌ React 组件渲染错误</h1>
<p style={{ color: '#666', marginBottom: '16px' }}>
ErrorBoundary 捕获了渲染错误,防止整个应用崩溃。
</p>
<h3>错误信息</h3>
<pre style={{
background: '#fff',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '13px',
border: '1px solid #fca5a5',
}}>
{error.name}: {error.message}
</pre>
{errorInfo && errorInfo.componentStack && (
<>
<h3>组件堆栈</h3>
<pre style={{
background: '#fff',
padding: '12px',
borderRadius: '4px',
overflow: 'auto',
fontSize: '11px',
border: '1px solid #fca5a5',
maxHeight: '300px',
}}>
{errorInfo.componentStack}
</pre>
</>
)}
<button
onClick={this.reset}
style={{
marginTop: '16px',
padding: '10px 24px',
background: '#5B5FED',
color: '#fff',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
}}
>
🔄 重置页面
</button>
</div>
);
}
return this.props.children;
}
}
@@ -0,0 +1,88 @@
// ChatPage 移动端壳 + 复制按钮 hook — #938
// 不直接改 ChatPage 主体,提供增强 hooks 与组件,可选挂载
import React, { useState, useCallback } from 'react';
/**
* useCopyToClipboard — 复制文本到剪贴板(带回退方案)
*/
export function useCopyToClipboard(): [boolean, (text: string) => Promise<void>] {
const [ok, setOk] = useState(false);
const copy = useCallback(async (text: string) => {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers / non-secure context
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setOk(true);
setTimeout(() => setOk(false), 1500);
} catch (e) {
setOk(false);
}
}, []);
return [ok, copy];
}
/**
* CopyButton — 通用复制按钮(挂载到代码块)
*/
export const CopyButton: React.FC<{ text: string; label?: string }> = ({ text, label = '复制' }) => {
const [copied, copy] = useCopyToClipboard();
return (
<button
className="md-code-copy-btn"
onClick={(e) => { e.preventDefault(); copy(text); }}
title={copied ? '已复制' : label}
aria-label={copied ? 'Copied' : 'Copy code'}
type="button"
>
{copied ? '✓' : label}
</button>
);
};
/**
* useIsMobile — 简单响应式 hook(< 768px)
*/
export function useIsMobile(breakpoint = 768): boolean {
const [isMobile, setIsMobile] = useState(
typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onResize = () => setIsMobile(window.innerWidth < breakpoint);
window.addEventListener('resize', onResize, { passive: true });
return () => window.removeEventListener('resize', onResize);
}, [breakpoint]);
return isMobile;
}
/**
* CodeBlockWithCopy — 包裹 code 块自动注入复制按钮
* 用法:<CodeBlockWithCopy lang="python">{`print("hi")`}</CodeBlockWithCopy>
*/
export const CodeBlockWithCopy: React.FC<{
lang?: string;
children: React.ReactNode;
}> = ({ lang, children }) => {
const text = typeof children === 'string' ? children : String(children || '');
return (
<div className="md-code-block-wrapper">
<pre className="md-code-block" data-lang={lang}>
<code>{children}</code>
</pre>
<CopyButton text={text} />
</div>
);
};
export default { CopyButton, CodeBlockWithCopy, useCopyToClipboard, useIsMobile };
@@ -53,6 +53,7 @@ const adminNavGroups: NavGroup[] = [
{ path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' }, { path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' },
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' }, { path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' },
{ path: '/admin/market', icon: 'market', label: 'AI 技能审核' }, { path: '/admin/market', icon: 'market', label: 'AI 技能审核' },
{ path: '/admin/market-products', icon: 'marketplace', label: '应用产品' },
], ],
}, },
@@ -194,13 +195,7 @@ const AdminLayout: React.FC = () => {
const location = useLocation(); const location = useLocation();
// 后台必须登录且为管理员 // 后台必须登录且为管理员
useEffect(() => { // Auth guard moved to App.tsx route level (no race condition)
const token = localStorage.getItem('aiflow_token');
if (!token || !user || user.role !== 'admin') {
logout();
navigate('/login', { replace: true });
}
}, [user, logout, navigate]);
const navCrumb = (() => { const navCrumb = (() => {
@@ -28,6 +28,7 @@ const navGroups = [
title: '资源中心', title: '资源中心',
items: [ items: [
{ path: '/app-services', icon: 'apps', label: '应用服务' }, { path: '/app-services', icon: 'apps', label: '应用服务' },
{ path: '/marketplace/products', icon: 'marketplace', label: '应用市场' },
{ path: '/token', icon: 'token', label: 'Token驾驶舱' }, { path: '/token', icon: 'token', label: 'Token驾驶舱' },
{ path: '/pricing', icon: 'pricing', label: '会员订阅' }, { path: '/pricing', icon: 'pricing', label: '会员订阅' },
{ path: '/assets', icon: 'assets', label: '资产文件' }, { path: '/assets', icon: 'assets', label: '资产文件' },
@@ -46,6 +47,7 @@ const iconPaths: Record<string, JSX.Element> = {
knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>, knowledge: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>, market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="9" height="9" rx="2"/><rect x="13" y="2" width="9" height="9" rx="2"/><rect x="2" y="13" width="9" height="9" rx="2"/><rect x="13" y="13" width="5" height="5" rx="1.5"/><path d="M18 18v3"/><path d="M19.5 16.5v6"/></svg>, apps: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="9" height="9" rx="2"/><rect x="13" y="2" width="9" height="9" rx="2"/><rect x="2" y="13" width="9" height="9" rx="2"/><rect x="13" y="13" width="5" height="5" rx="1.5"/><path d="M18 18v3"/><path d="M19.5 16.5v6"/></svg>,
novel: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>,
token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>, token: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>,
pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>, pricing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8"/><path d="M12 18V6"/></svg>,
profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>, profile: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>,
@@ -109,12 +111,6 @@ const FrontLayout: React.FC = () => {
<span className="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/><path d="m4.93 4.93 2.83 2.83m8.48 8.48 2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48 2.83-2.83"/></svg></span> <span className="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/><path d="m4.93 4.93 2.83 2.83m8.48 8.48 2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48 2.83-2.83"/></svg></span>
{!collapsed && <span className="nav-label">Agent 编排</span>} {!collapsed && <span className="nav-label">Agent 编排</span>}
</NavLink> </NavLink>
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
<span className="nav-item-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
</span>
{!collapsed && <span className="nav-item-label">返回首页</span>}
</NavLink>
{navGroups.map(group => ( {navGroups.map(group => (
<div className="nav-section" key={group.title}> <div className="nav-section" key={group.title}>
{!collapsed && <div className="nav-group-title">{group.title}</div>} {!collapsed && <div className="nav-group-title">{group.title}</div>}
@@ -161,7 +157,7 @@ const FrontLayout: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{user.role === 'admin' && ( {user.role === 'admin' || user.role === 'super_admin' && (
<NavLink <NavLink
to="/admin" to="/admin"
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
+15 -1
View File
@@ -2,12 +2,26 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import App from './App'; import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './styles/global.css'; import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render( window.addEventListener('error', (e) => {
console.error('[qihu-global-error]', e.error);
});
window.addEventListener('unhandledrejection', (e) => {
console.error('[qihu-unhandled-rejection]', e.reason);
});
const rootEl = document.getElementById('root')!;
rootEl.innerHTML = '';
ReactDOM.createRoot(rootEl).render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );
@@ -0,0 +1,285 @@
// 应用市场 — 后台管理页 /admin/market-products
// 审核 / 挂起 / 恢复 / 下线 + 健康探针历史 + OIDC 重新注册
import React, { useEffect, useState, useCallback } from 'react';
import marketProductAPI, {
type MarketProduct,
type MarketProductHealth,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
type Action = 'approve' | 'suspend' | 'reactivate' | 'deprecate';
function fmtDate(s?: string | null) {
if (!s) return '—';
try { return new Date(s).toLocaleString('zh-CN', { hour12: false }); } catch { return s; }
}
const STATUS_COLORS: Record<string, { bg: string; fg: string; label: string }> = {
pending: { bg: '#FEF3C7', fg: '#D97706', label: '待审核' },
active: { bg: '#ECFDF5', fg: '#059669', label: '已上架' },
suspended: { bg: '#FEF3C7', fg: '#D97706', label: '已挂起' },
deprecated: { bg: '#F1F5F9', fg: '#64748B', label: '已下线' },
};
const MarketProductsAdmin: React.FC = () => {
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<MarketProduct[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState('');
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<MarketProduct | null>(null);
const [healthHistory, setHealthHistory] = useState<MarketProductHealth[]>([]);
const [busy, setBusy] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.adminList({
status: statusFilter || undefined,
search: search || undefined,
page,
page_size: 20,
});
const data: any = (res as any).data || res;
setItems(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(e?.message || '加载失败', 'error');
} finally {
setLoading(false);
}
}, [statusFilter, search, page]);
useEffect(() => { load(); }, [load]);
const onAction = async (product: MarketProduct, action: Action) => {
let reason = '';
if (action === 'suspend' || action === 'deprecate') {
const input = window.prompt(`请输入${action === 'suspend' ? '挂起' : '下线'}原因:`);
if (input === null) return;
reason = input;
}
setBusy(true);
try {
await marketProductAPI.review(product.id, { action, reason: reason || undefined });
showToast(`已${action === 'approve' ? '通过' : action === 'suspend' ? '挂起' : action === 'reactivate' ? '恢复' : '下线'}「${product.name}」`, 'success');
await load();
if (selected?.id === product.id) setSelected(null);
} catch (e: any) {
showToast(e?.message || '操作失败', 'error');
} finally {
setBusy(false);
}
};
const onSelect = async (p: MarketProduct) => {
setSelected(p);
setHealthHistory([]);
try {
const res = await marketProductAPI.healthHistory(p.id, 30);
const data: any = (res as any).data || res;
setHealthHistory(data.items || []);
} catch { /* ignore */ }
};
const onReRegisterOidc = async (p: MarketProduct) => {
setBusy(true);
try {
await marketProductAPI.reRegisterOidc(p.id);
showToast('OIDC 重新注册请求已提交', 'success');
} catch (e: any) {
showToast(e?.message || '操作失败', 'error');
} finally {
setBusy(false);
}
};
const totalPages = Math.max(1, Math.ceil(total / 20));
return (
<div style={{ padding: 24, background: '#F8F9FC', minHeight: '100%' }}>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用产品管理</h1>
<p style={{ fontSize: 13, color: '#6B7280', margin: '4px 0 0 0' }}>
审核 / 挂起 / 恢复 / 下线 独立可部署产品(MuMuAINovel 等)。共 {total} 条记录。
</p>
</div>
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 过滤栏 */}
<div style={{ background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', padding: 16, marginBottom: 16, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索产品名称 / slug…"
style={{ flex: '1 1 240px', minWidth: 240, padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 6, fontSize: 13, outline: 'none' }}
/>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
style={{ padding: '8px 14px', border: '1px solid #E5E7EB', borderRadius: 6, fontSize: 13, background: '#fff', cursor: 'pointer', minWidth: 120 }}
>
<option value="">全部状态</option>
<option value="pending">待审核</option>
<option value="active">已上架</option>
<option value="suspended">已挂起</option>
<option value="deprecated">已下线</option>
</select>
</div>
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
{/* 左: 表格 */}
<div style={{ flex: 1, minWidth: 0, background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', overflow: 'hidden' }}>
{loading ? (
<div style={{ padding: 60, textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: 80, textAlign: 'center', color: '#9CA3AF' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>📭</div>
<div style={{ fontSize: 14 }}>暂无产品记录</div>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#F8F9FC', borderBottom: '1px solid #E5E7EB' }}>
<th style={th}>名称</th>
<th style={th}>Slug</th>
<th style={th}>形态</th>
<th style={th}>计费</th>
<th style={th}>状态</th>
<th style={th}>上架时间</th>
<th style={th}>操作</th>
</tr>
</thead>
<tbody>
{items.map((p) => {
const sc = STATUS_COLORS[p.status] || { bg: '#F1F5F9', fg: '#475569', label: p.status };
return (
<tr
key={p.id}
onClick={() => onSelect(p)}
style={{ borderBottom: '1px solid #F1F5F9', cursor: 'pointer', background: selected?.id === p.id ? '#FAFBFF' : 'transparent' }}
>
<td style={td}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 28, height: 28, borderRadius: 6, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, flexShrink: 0 }}>🚀</div>
<span style={{ fontWeight: 500, color: '#1A1F36' }}>{p.name}</span>
</div>
</td>
<td style={{ ...td, fontFamily: 'Menlo, Consolas, monospace', color: '#64748B' }}>{p.slug}</td>
<td style={td}>{p.product_kind}</td>
<td style={td}>{p.billing_plan}</td>
<td style={td}>
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 10, fontSize: 11, background: sc.bg, color: sc.fg, fontWeight: 500 }}>{sc.label}</span>
</td>
<td style={{ ...td, color: '#6B7280', fontSize: 12 }}>{(p.published_at || '').slice(0, 10) || '—'}</td>
<td style={td} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{p.status === 'pending' && (
<button disabled={busy} onClick={() => onAction(p, 'approve')} style={btnPrimary}>通过</button>
)}
{p.status === 'active' && (
<button disabled={busy} onClick={() => onAction(p, 'suspend')} style={btnWarn}>挂起</button>
)}
{p.status === 'suspended' && (
<button disabled={busy} onClick={() => onAction(p, 'reactivate')} style={btnPrimary}>恢复</button>
)}
{p.status !== 'deprecated' && (
<button disabled={busy} onClick={() => onAction(p, 'deprecate')} style={btnDanger}>下线</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, padding: 12, borderTop: '1px solid #F1F5F9', alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn(), opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: 12, color: '#6B7280' }}>第 {page} / {totalPages} 页</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn(), opacity: page >= totalPages ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
{/* 右: 详情侧栏 */}
{selected && (
<div style={{ width: 320, flexShrink: 0, background: '#fff', borderRadius: 10, border: '1px solid #E5E7EB', padding: 18, position: 'sticky', top: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1F36' }}>{selected.name}</div>
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', cursor: 'pointer', color: '#9CA3AF', fontSize: 18, lineHeight: 1 }}>×</button>
</div>
<Row label="Slug" value={selected.slug} mono />
<Row label="形态" value={selected.product_kind} />
<Row label="计费" value={selected.billing_plan} />
<Row label="Endpoint" value={selected.endpoint_url || '—'} mono />
<Row label="Docker" value={selected.docker_image || '—'} mono />
<Row label="需审批" value={selected.requires_approval ? '是' : '否'} />
<Row label="创建时间" value={fmtDate(selected.created_at)} />
<div style={{ marginTop: 14, paddingTop: 14, borderTop: '1px solid #F1F5F9' }}>
<div style={{ fontSize: 12, color: '#9CA3AF', marginBottom: 8 }}>健康探针历史 ({healthHistory.length} 条)</div>
{healthHistory.length === 0 ? (
<div style={{ fontSize: 12, color: '#9CA3AF', padding: '12px 0', textAlign: 'center' }}>暂无记录</div>
) : (
<div style={{ maxHeight: 200, overflowY: 'auto', fontSize: 11 }}>
{healthHistory.slice(0, 15).map((h) => {
const c = h.status === 'healthy' ? '#10b981' : h.status === 'degraded' ? '#f59e0b' : '#ef4444';
return (
<div key={h.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0', borderBottom: '1px dashed #F1F5F9' }}>
<span style={{ color: c, fontWeight: 500 }}>● {h.status}</span>
<span style={{ color: '#94A3B8' }}>{h.latency_ms != null ? `${h.latency_ms}ms` : '—'} · {fmtDate(h.checked_at).slice(5, 16)}</span>
</div>
);
})}
</div>
)}
</div>
<button
onClick={() => onReRegisterOidc(selected)}
disabled={busy}
style={{ width: '100%', marginTop: 12, padding: '8px 14px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 6, color: '#1A1F36', fontSize: 12, cursor: busy ? 'not-allowed' : 'pointer' }}
>
重新注册 OIDC
</button>
</div>
)}
</div>
</div>
</div>
);
};
const th: React.CSSProperties = { padding: '12px 14px', textAlign: 'left', fontSize: 12, fontWeight: 600, color: '#475569', whiteSpace: 'nowrap' };
const td: React.CSSProperties = { padding: '12px 14px', color: '#1A1F36' };
const btnPrimary: React.CSSProperties = { padding: '4px 12px', background: PRIMARY_COLOR, color: '#fff', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
const btnWarn: React.CSSProperties = { padding: '4px 12px', background: '#FEF3C7', color: '#D97706', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
const btnDanger: React.CSSProperties = { padding: '4px 12px', background: '#FEE2E2', color: '#DC2626', border: 'none', borderRadius: 6, fontSize: 12, cursor: 'pointer' };
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: 'flex', gap: 8, marginBottom: 6, fontSize: 12 }}>
<span style={{ width: 70, color: '#9CA3AF', flexShrink: 0 }}>{label}</span>
<span style={{ flex: 1, color: '#1A1F36', wordBreak: 'break-all', fontFamily: mono ? 'Menlo, Consolas, monospace' : 'inherit' }}>{value}</span>
</div>
);
}
function pageBtn(): React.CSSProperties {
return { padding: '6px 12px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 6, color: '#1A1F36', fontSize: 12, cursor: 'pointer' };
}
export default MarketProductsAdmin;
+18 -2
View File
@@ -26,6 +26,10 @@ const capabilityLabels: Record<string, string> = {
embedding: '嵌入向量', rerank: '重排序', video: '视频生成', embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
}; };
// 锁定项:text/thinking 能力动态绑定 is_default=true 模型,不再硬编码 ID。
// 后端 defensive guard(chat_service.py)确保普通文字对话不会误走 image/video 模型。
// #964: 修复 React error #321 — useState 必须放在组件函数体内,不能在模块顶层
// Test result type // Test result type
interface TestResult { interface TestResult {
modelId: string; modelId: string;
@@ -49,6 +53,8 @@ interface EditingModel {
} }
const Models: React.FC = () => { const Models: React.FC = () => {
const [lockedCapDefaults, setLockedCapDefaults] = useState<Record<string, string>>({});
const [providers, setProviders] = useState<ModelProvider[]>([]); const [providers, setProviders] = useState<ModelProvider[]>([]);
const [models, setModels] = useState<AIModel[]>([]); const [models, setModels] = useState<AIModel[]>([]);
const [protocols, setProtocols] = useState<ProtocolDef[]>([]); const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
@@ -116,6 +122,11 @@ const Models: React.FC = () => {
setCapabilities(cd.capabilities || []); setCapabilities(cd.capabilities || []);
setCapabilityDefaults(cd.defaults || {}); setCapabilityDefaults(cd.defaults || {});
setCapabilityModels(cd.models || []); setCapabilityModels(cd.models || []);
// 动态锁定:is_default=true 模型绑定 text + thinking
const defaultM = mappedModels.find((m: any) => m.isDefault);
if (defaultM) {
setLockedCapDefaults({ text: defaultM.id, thinking: defaultM.id });
}
} catch (err: any) { } catch (err: any) {
setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err))); setError('加载数据失败:' + (err.message || err.detail || JSON.stringify(err)));
} finally { } finally {
@@ -527,6 +538,8 @@ const Models: React.FC = () => {
); );
const cur = capabilityDefaults[cap.id] || ''; const cur = capabilityDefaults[cap.id] || '';
const saving = savingCaps.has(cap.id); const saving = savingCaps.has(cap.id);
const lockedModelId = lockedCapDefaults[cap.id];
const isLocked = Boolean(lockedModelId);
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280'; const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
return ( return (
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}> <div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
@@ -537,8 +550,8 @@ const Models: React.FC = () => {
</div> </div>
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div> <div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
<select <select
value={cur} value={isLocked ? (lockedModelId as string) : cur}
disabled={saving} disabled={saving || isLocked}
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)} onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }} style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
> >
@@ -549,6 +562,9 @@ const Models: React.FC = () => {
</option> </option>
))} ))}
</select> </select>
{isLocked && (
<div style={{ fontSize: '11px', color: '#16A34A', marginTop: '6px' }}>🔒 已锁定为平台默认模型,不可更改</div>
)}
</div> </div>
); );
})} })}
+140
View File
@@ -1784,3 +1784,143 @@ html.dark .ai-sending-timeout {
@keyframes aiPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } @keyframes aiPulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } }
.ai-process-error { background: rgba(255,0,0,0.05); border-left: 2px solid #e74c3c; } .ai-process-error { background: rgba(255,0,0,0.05); border-left: 2px solid #e74c3c; }
.ai-process-running { border-left: 2px solid #3498db; } .ai-process-running { border-left: 2px solid #3498db; }
/* 2026-07-29: 增强列表/卡片样式 */
.markdown-body ul.md-ul {
list-style: none;
padding-left: 0;
margin: 12px 0;
}
.markdown-body ul.md-ul li.md-li {
position: relative;
padding: 8px 12px 8px 28px;
margin: 4px 0;
border-left: 3px solid var(--color-accent, #5B5FED);
background: linear-gradient(90deg, rgba(91,95,237,0.04), transparent);
border-radius: 0 8px 8px 0;
line-height: 1.7;
transition: all 0.2s ease;
}
.markdown-body ul.md-ul li.md-li:hover {
background: linear-gradient(90deg, rgba(91,95,237,0.10), transparent);
border-left-color: var(--color-accent-active, #4A4ECC);
}
.markdown-body ul.md-ul li.md-li::before {
content: "▸";
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--color-accent, #5B5FED);
font-weight: bold;
}
.markdown-body ul.md-ul li.md-li ul.md-ul {
margin: 8px 0 4px 8px;
padding-left: 0;
}
.markdown-body ul.md-ul li.md-li ul.md-ul li.md-li {
padding: 4px 8px 4px 20px;
border-left-width: 2px;
font-size: 0.95em;
}
.markdown-body ol {
padding-left: 24px;
margin: 12px 0;
}
.markdown-body ol > li {
padding: 4px 0 4px 4px;
margin: 4px 0;
line-height: 1.7;
}
// ChatPage 移动端响应式 + 暗态增强 — #938
// 添加到 ChatPage.css 末尾(项目已有 :root CSS var 系统)
/* ===== 移动端响应式(< 768px) ===== */
@media (max-width: 768px) {
/* 聊天列表改单列 */
.chat-layout {
flex-direction: column !important;
}
.chat-sidebar {
width: 100% !important;
max-height: 35vh;
overflow-y: auto;
}
.chat-main {
flex: 1 !important;
width: 100% !important;
}
/* Composer 全宽 */
.chat-composer {
padding: 8px 12px !important;
}
/* 消息气泡减少 padding */
.markdown-body {
padding: 8px 10px !important;
}
/* 隐藏次要工具栏(等) */
.chat-toolbar-secondary {
display: none !important;
}
}
/* ===== 平板(768-1024) ===== */
@media (min-width: 768px) and (max-width: 1024px) {
.chat-sidebar {
width: 240px !important;
}
}
/* ===== 暗态增强(虽然 :root + .dark 已支持,但部分 inline color 需降级) ===== */
.dark .markdown-body {
background-color: var(--color-surface-elevated);
color: var(--color-text);
border-color: var(--color-border);
}
.dark .md-code-block {
background-color: rgba(255, 255, 255, 0.05);
border-color: var(--color-border);
}
/* ===== 富文本增强:代码块复制按钮 ===== */
.md-code-block-wrapper {
position: relative;
}
.md-code-copy-btn {
position: absolute;
top: 6px;
right: 6px;
padding: 2px 10px;
font-size: 11px;
border-radius: 4px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-secondary);
cursor: pointer;
opacity: 0;
transition: opacity 200ms;
}
.md-code-block-wrapper:hover .md-code-copy-btn {
opacity: 1;
}
.md-code-copy-btn:hover {
background: var(--color-primary);
color: #fff;
}
/* ===== 移动端表格响应式 ===== */
.markdown-body table {
display: block;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
}
/* ===== prefers-reduced-motion 降级 ===== */
@media (prefers-reduced-motion: reduce) {
.md-code-copy-btn,
.chat-sidebar,
.chat-composer {
transition: none !important;
}
}
+2 -2
View File
@@ -85,14 +85,14 @@ const LoginPage: React.FC = () => {
createdAt: userData.created_at || new Date().toISOString(), createdAt: userData.created_at || new Date().toISOString(),
membership: userData.membership_level > 0 ? { membership: userData.membership_level > 0 ? {
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'), planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')), planName: userData.role === 'admin' || userData.role === 'super_admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
level: userData.membership_level, level: userData.membership_level,
expireAt: userData.membership_expire_at || '', expireAt: userData.membership_expire_at || '',
autoRenew: userData.auto_renew || false, autoRenew: userData.auto_renew || false,
} : undefined, } : undefined,
}; };
login(userObj); login(userObj);
navigate(userData.role === 'admin' ? '/admin' : '/chat'); navigate(userData.role === 'admin' || userData.role === 'super_admin' ? '/admin' : '/chat');
} catch (e: any) { } catch (e: any) {
// 处理后端返回的精确错误信息,区分用户不存在和密码错误 // 处理后端返回的精确错误信息,区分用户不存在和密码错误
const status = e.status || e.response?.status || 0; const status = e.status || e.response?.status || 0;
@@ -0,0 +1,302 @@
// 应用市场 — 详情页 /marketplace/products/:id
// tabs: 概览(endpoint + 健康) / 接入指南(OIDC 跳转) / 评论(预留)
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import marketProductAPI, {
type MarketProductDetail,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
type Tab = 'overview' | 'guide';
function fmtDate(s?: string | null) {
if (!s) return '—';
try {
return new Date(s).toLocaleString('zh-CN', { hour12: false });
} catch { return s; }
}
const MarketProductDetail: React.FC = () => {
const { id = '' } = useParams<{ id: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [product, setProduct] = useState<MarketProductDetail | null>(null);
const [tab, setTab] = useState<Tab>('overview');
const [accessNote, setAccessNote] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const [launching, setLaunching] = useState(false);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.detail(id);
const data: any = (res as any).data || res;
setProduct(data);
} catch (e: any) {
showToast(e?.message || '加载详情失败', 'error');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { if (id) load(); }, [id, load]);
const submitAccess = async () => {
if (!product) return;
if (product.requires_approval) {
setSubmitting(true);
try {
await marketProductAPI.requestAccess(product.id, { note: accessNote || undefined });
showToast('申请已提交,审核结果将通过站内信通知', 'success');
await load();
} catch (e: any) {
showToast(e?.message || '申请失败', 'error');
} finally {
setSubmitting(false);
}
} else {
// 不需要审核 → 直接 launch
await doLaunch();
}
};
const doLaunch = async () => {
if (!product) return;
setLaunching(true);
try {
const res = await marketProductAPI.launch(product.id);
const data: any = (res as any).data || res;
const url: string = data?.launch_url || data?.url || '';
if (url) {
showToast('正在打开产品…', 'success');
window.open(url, '_blank', 'noopener,noreferrer');
} else {
showToast('无法获取产品访问链接', 'error');
}
} catch (e: any) {
showToast(e?.message || '打开失败', 'error');
} finally {
setLaunching(false);
}
};
if (loading) {
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', padding: 60, textAlign: 'center', color: '#9CA3AF' }}>
加载中…
</div>
);
}
if (!product) {
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', padding: 60, textAlign: 'center', color: '#9CA3AF' }}>
<div style={{ fontSize: 16, marginBottom: 12 }}>未找到该产品</div>
<button onClick={() => navigate('/marketplace/products')} style={{ padding: '8px 20px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, cursor: 'pointer' }}>返回列表</button>
</div>
);
}
const health = product.health;
const healthOk = health?.status === 'healthy';
const healthColor = healthOk ? '#10b981' : health?.status === 'degraded' ? '#f59e0b' : health?.status === 'down' ? '#ef4444' : '#9CA3AF';
return (
<div style={{ minHeight: '100%', background: '#F8F9FC', color: '#1A1F36', padding: '32px 0' }}>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px' }}>
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* Breadcrumb */}
<div style={{ fontSize: 13, color: '#6B7280', marginBottom: 16 }}>
<a onClick={() => navigate('/marketplace/products')} style={{ color: PRIMARY_COLOR, cursor: 'pointer' }}>应用市场</a>
<span style={{ margin: '0 8px' }}>/</span>
<span>{product.name}</span>
</div>
{/* Hero banner */}
<div style={{ background: '#fff', borderRadius: 16, padding: 32, border: '1px solid #E5E7EB', marginBottom: 24, display: 'flex', gap: 24, alignItems: 'flex-start' }}>
<div style={{ width: 80, height: 80, borderRadius: 16, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, flexShrink: 0, overflow: 'hidden' }}>
{product.icon_url ? <img src={product.icon_url} alt="" style={{ width: 56, height: 56, borderRadius: 10, objectFit: 'cover' }} /> : '🚀'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap', marginBottom: 8 }}>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: 0 }}>{product.name}</h1>
<span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#EEF0FF', color: PRIMARY_COLOR, fontWeight: 500 }}>{product.product_kind}</span>
<span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#F1F5F9', color: '#475569' }}>{product.billing_plan}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', fontSize: 12, color: healthColor, fontWeight: 500 }}>
<span style={{ display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: healthColor, marginRight: 6 }} />
{health?.status || '未知'} · {health?.latency_ms != null ? `${health.latency_ms} ms` : '—'}
</span>
{product.status === 'suspended' && <span style={{ fontSize: 12, padding: '2px 10px', borderRadius: 12, background: '#FEF3C7', color: '#D97706', fontWeight: 500 }}>已挂起</span>}
</div>
<p style={{ color: '#6B7280', fontSize: 15, lineHeight: 1.7, margin: 0 }}>{product.description || '暂无描述'}</p>
<div style={{ display: 'flex', gap: 16, marginTop: 16, fontSize: 12, color: '#9CA3AF' }}>
<span>v{product.metadata?.version || '1.0.0'}</span>
<span>· 上架于 {fmtDate(product.published_at)}</span>
{product.requires_approval && <span>· 需申请访问</span>}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* 左: tabs 区 */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* tabs header */}
<div style={{ background: '#fff', borderRadius: '12px 12px 0 0', border: '1px solid #E5E7EB', borderBottom: 'none', display: 'flex' }}>
{(['overview', 'guide'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 0, padding: '14px 22px', fontSize: 14, fontWeight: tab === t ? 600 : 400,
color: tab === t ? PRIMARY_COLOR : '#6B7280',
background: 'transparent', border: 'none',
borderBottom: tab === t ? `2px solid ${PRIMARY_COLOR}` : '2px solid transparent',
cursor: 'pointer', marginBottom: -1,
}}
>
{t === 'overview' ? '概览' : '接入指南'}
</button>
))}
</div>
<div style={{ background: '#fff', borderRadius: '0 0 12px 12px', border: '1px solid #E5E7EB', padding: 28, minHeight: 320 }}>
{tab === 'overview' && (
<div>
<Section title="接入端点">
<FieldRow label="Endpoint URL" value={product.endpoint_url || '—'} mono />
<FieldRow label="Docker 镜像" value={product.docker_image || '—'} mono />
<FieldRow label="最新探针时间" value={fmtDate(health?.checked_at)} />
<FieldRow label="HTTP 状态" value={health?.http_status != null ? String(health.http_status) : '—'} />
</Section>
<Section title="授权范围">
{product.scopes && product.scopes.length > 0 ? (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{product.scopes.map((s) => (
<span key={s} style={{ fontSize: 12, padding: '4px 12px', borderRadius: 12, background: '#EEF0FF', color: PRIMARY_COLOR }}>{s}</span>
))}
</div>
) : <div style={{ fontSize: 13, color: '#9CA3AF' }}>无特殊授权范围</div>}
</Section>
<Section title="健康状态">
<div style={{ padding: 16, background: '#F8F9FC', borderRadius: 8, fontSize: 13, color: '#475569' }}>
最近一次探针: <strong style={{ color: healthColor }}>{health?.status || '未知'}</strong> · {fmtDate(health?.checked_at)}
{health?.message && <div style={{ marginTop: 6, color: '#6B7280' }}>备注: {health.message}</div>}
</div>
</Section>
</div>
)}
{tab === 'guide' && (
<div>
<Section title="接入方式">
<p style={{ fontSize: 14, color: '#475569', lineHeight: 1.8 }}>
本产品采用 OIDC 单点登录。点击右侧"<strong>{product.requires_approval ? '申请访问' : '立即打开'}</strong>"按钮后,
平台会生成一次性 OIDC 跳转地址,自动携带短期 Bearer Token。重定向到产品端后将获得你的身份与权限范围。
</p>
</Section>
<Section title="API 示例(伪代码)">
<pre style={{ background: '#0F172A', color: '#E2E8F0', padding: 16, borderRadius: 8, fontSize: 12, overflowX: 'auto', fontFamily: 'Menlo, Consolas, monospace', lineHeight: 1.6 }}>
{`# 1. 获取跳转地址(后端已封装为 launch_url,浏览器可直接跳转)
POST /app/api/market/products/${product.id}/launch
Authorization: Bearer <your_token>
# 2. 跳转后产品在 Authorization 头拿到短期 token,验证身份后进入主界面
GET ${product.endpoint_url || 'https://.../api/me'}
Authorization: Bearer <short_lived_token>`}
</pre>
</Section>
<Section title="注意事项">
<ul style={{ paddingLeft: 20, fontSize: 13, color: '#475569', lineHeight: 1.8 }}>
<li>OIDC token 有效期 15 分钟,过期需重新跳转获取。</li>
<li>产品运行独立容器,故障与平台主服务隔离。</li>
<li>如需取消授权,在产品内登出或联系客服。</li>
</ul>
</Section>
</div>
)}
</div>
</div>
{/* 右: 申请/打开 sticky */}
<div style={{ width: 260, flexShrink: 0 }}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #E5E7EB', padding: 20, position: 'sticky', top: 80 }}>
{!product.has_access ? (
<>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>
{product.requires_approval ? '申请访问权限' : '立即使用'}
</div>
{product.requires_approval && (
<textarea
value={accessNote}
onChange={(e) => setAccessNote(e.target.value)}
placeholder="说明使用场景(可选)…"
style={{ width: '100%', minHeight: 80, padding: 10, border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 13, outline: 'none', boxSizing: 'border-box', marginBottom: 12, resize: 'vertical', fontFamily: 'inherit' }}
/>
)}
<button
onClick={submitAccess}
disabled={submitting || launching}
style={{ width: '100%', padding: '10px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, fontWeight: 600, cursor: submitting || launching ? 'not-allowed' : 'pointer', opacity: submitting || launching ? 0.6 : 1 }}
>
{submitting ? '提交中…' : launching ? '打开中…' : (product.requires_approval ? '提交申请' : '立即打开')}
</button>
{product.requires_approval && (
<div style={{ fontSize: 12, color: '#9CA3AF', marginTop: 10, textAlign: 'center' }}>
审核结果通常 1-24 小时内通过站内信通知
</div>
)}
</>
) : (
<>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12, color: '#1A1F36' }}>已获得访问权限</div>
<div style={{ padding: '8px 12px', background: '#ECFDF5', borderRadius: 6, fontSize: 12, color: '#059669', marginBottom: 12 }}>
开通于 {fmtDate(product.my_access?.approved_at)}
</div>
<button
onClick={doLaunch}
disabled={launching}
style={{ width: '100%', padding: '10px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 14, fontWeight: 600, cursor: launching ? 'not-allowed' : 'pointer', opacity: launching ? 0.6 : 1 }}
>
{launching ? '打开中…' : '打开产品'}
</button>
</>
)}
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid #F1F5F9' }}>
<div style={{ fontSize: 12, color: '#9CA3AF', marginBottom: 4 }}>产品 slug</div>
<div style={{ fontFamily: 'Menlo, Consolas, monospace', fontSize: 12, color: '#475569' }}>{product.slug}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: '#1A1F36', marginBottom: 12, textTransform: 'uppercase', letterSpacing: 0.5 }}>{title}</div>
{children}
</div>
);
}
function FieldRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div style={{ display: 'flex', gap: 12, marginBottom: 10, fontSize: 13 }}>
<span style={{ width: 110, color: '#9CA3AF' }}>{label}</span>
<span style={{ flex: 1, color: '#1A1F36', wordBreak: 'break-all', fontFamily: mono ? 'Menlo, Consolas, monospace' : 'inherit' }}>{value}</span>
</div>
);
}
export default MarketProductDetail;
+252
View File
@@ -0,0 +1,252 @@
// 应用市场 — 列表页 /marketplace/products
// 展示独立可部署产品(MuMuAINovel 等),支持 category / product_kind / billing 过滤
import React, { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import marketProductAPI, {
type MarketProduct,
PRIMARY_COLOR,
} from '@/api/marketProductAPI';
const CATEGORIES = [
{ key: '', name: '全部分类' },
{ key: 'novel', name: '文学创作' },
{ key: 'coding', name: '编程辅助' },
{ key: 'data', name: '数据分析' },
{ key: 'creative', name: '内容创作' },
{ key: 'education', name: '教育培训' },
{ key: 'finance', name: '财务法务' },
{ key: 'marketing', name: '营销推广' },
{ key: 'other', name: '其他' },
];
const PRODUCT_KINDS = [
{ key: '', name: '全部形态' },
{ key: 'saas', name: 'SaaS' },
{ key: 'api', name: 'API' },
{ key: 'cli', name: 'CLI' },
{ key: 'desktop', name: '桌面应用' },
{ key: 'mobile', name: '移动端' },
];
const BILLINGS = [
{ key: '', name: '全部计费' },
{ key: 'free', name: '免费' },
{ key: 'freemium', name: '免费增值' },
{ key: 'metered', name: '按量付费' },
{ key: 'subscription', name: '订阅' },
];
function badge(text: string, bg: string, fg: string): React.CSSProperties {
return {
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
background: bg,
color: fg,
fontWeight: 500,
};
}
function healthDot(status?: string): React.CSSProperties {
const c = status === 'healthy' ? '#10b981' : status === 'degraded' ? '#f59e0b' : status === 'down' ? '#ef4444' : '#9CA3AF';
return { display: 'inline-block', width: 8, height: 8, borderRadius: '50%', background: c, marginRight: 6 };
}
const cardBase: React.CSSProperties = {
border: '1px solid #E5E7EB',
borderRadius: 14,
padding: 20,
background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
display: 'flex',
flexDirection: 'column',
transition: 'all 200ms cubic-bezier(0.2,0.8,0.2,1)',
};
const MarketProducts: React.FC = () => {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<MarketProduct[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(12);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [productKind, setProductKind] = useState('');
const [billing, setBilling] = useState('');
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 2500);
};
const load = useCallback(async () => {
setLoading(true);
try {
const res = await marketProductAPI.list({
category: category || undefined,
product_kind: productKind || undefined,
billing_plan: billing || undefined,
search: search || undefined,
page,
page_size: pageSize,
});
// 与既有后端约定: { code, data, message } — data 形如 { items, total, ... }
const data: any = (res as any).data || res;
setItems(data.items || []);
setTotal(data.total || 0);
} catch (e: any) {
showToast(e?.message || '加载产品市场失败', 'error');
} finally {
setLoading(false);
}
}, [category, productKind, billing, search, page, pageSize]);
useEffect(() => {
load();
}, [load]);
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return (
<div style={{ minHeight: '100%', background: '#FFFFFF', color: '#1A1F36', padding: '32px' }}>
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0, color: '#1A1F36' }}>应用市场</h1>
<span style={{ fontSize: 12, color: '#9CA3AF', background: '#F1F5F9', padding: '2px 10px', borderRadius: 10 }}>
应用产品 · 独立可部署
</span>
</div>
<p style={{ color: '#6B7280', fontSize: 14, margin: '6px 0 0 0', maxWidth: 720 }}>
浏览平台已上架的 AI 应用产品。每个产品均为独立可部署服务,通过 OIDC 单点登录接入,健康状态实时探针。
</p>
</div>
{/* 渐迁提示 */}
<div style={{ background: 'linear-gradient(135deg, #EEF0FF 0%, #F5F3FF 100%)', border: '1px solid #DDD6FE', borderRadius: 10, padding: '10px 16px', marginBottom: 20, fontSize: 13, color: '#5B5FED', display: 'flex', alignItems: 'center', gap: 8 }}>
<span>ℹ️</span>
<span>这里的"应用产品"区别于 <a href="/app-services" style={{ color: PRIMARY_COLOR, textDecoration: 'underline' }}>应用服务</a> 和 <a href="/market" style={{ color: PRIMARY_COLOR, textDecoration: 'underline' }}>技能市场</a>。如需安装可调用模块请前往技能市场。</span>
</div>
{/* Toast */}
{toast && (
<div style={{ position: 'fixed', top: 20, right: 20, zIndex: 9999, padding: '12px 24px', borderRadius: 8, background: toast.type === 'success' ? '#10b981' : '#ef4444', color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>{toast.msg}</div>
)}
{/* 过滤栏 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
<input
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="搜索产品名称 / 简介 / 关键词…"
style={{ width: '100%', padding: '10px 16px', border: '1px solid #E5E7EB', borderRadius: 8, fontSize: 14, outline: 'none', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<FilterChips label="分类" items={CATEGORIES} value={category} onChange={(v) => { setCategory(v); setPage(1); }} />
<FilterChips label="形态" items={PRODUCT_KINDS} value={productKind} onChange={(v) => { setProductKind(v); setPage(1); }} />
<FilterChips label="计费" items={BILLINGS} value={billing} onChange={(v) => { setBilling(v); setPage(1); }} />
</div>
</div>
{/* 卡片网格 */}
{loading ? (
<div style={{ padding: 60, textAlign: 'center', color: '#9CA3AF' }}>加载中…</div>
) : items.length === 0 ? (
<div style={{ padding: 80, textAlign: 'center', color: '#9CA3AF', background: '#F8F9FC', borderRadius: 12 }}>
<div style={{ fontSize: 48, marginBottom: 12 }}>🛰️</div>
<div style={{ fontSize: 16, marginBottom: 6 }}>暂无已上架的产品</div>
<div style={{ fontSize: 13 }}>请调整筛选条件,或等待新一轮产品上架</div>
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(310px, 1fr))', gap: 16 }}>
{items.map((p) => {
const health = (p as any).health;
return (
<div
key={p.id}
onClick={() => navigate(`/marketplace/products/${p.id}`)}
style={{ ...cardBase, cursor: 'pointer' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(91,95,237,0.08)'; e.currentTarget.style.borderColor = PRIMARY_COLOR; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.04)'; e.currentTarget.style.borderColor = '#E5E7EB'; }}
>
{/* 头 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{ width: 48, height: 48, borderRadius: 10, background: PRIMARY_COLOR + '15', color: PRIMARY_COLOR, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, overflow: 'hidden', flexShrink: 0 }}>
{p.icon_url ? <img src={p.icon_url} alt="" style={{ width: 32, height: 32, borderRadius: 6, objectFit: 'cover' }} /> : '🚀'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#1A1F36', marginBottom: 4, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span style={badge((p.product_kind || '').toUpperCase(), '#EEF0FF', PRIMARY_COLOR)}>{p.product_kind}</span>
<span style={badge(p.billing_plan, '#F1F5F9', '#475569')}>{p.billing_plan}</span>
{p.status === 'active' && (
<span style={{ ...badge('运行中', '#ECFDF5', '#059669') }}>
<span style={healthDot('healthy')} />健康
</span>
)}
{p.status === 'suspended' && <span style={badge('已挂起', '#FEF3C7', '#D97706')}>已挂起</span>}
</div>
</div>
</div>
<p style={{ fontSize: 13, color: '#6B7280', lineHeight: 1.7, flex: 1, margin: '0 0 14px 0', display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{p.description || '暂无描述'}</p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: 12, borderTop: '1px solid #F1F5F9' }}>
<span style={{ fontSize: 12, color: '#9CA3AF' }}>{(p.published_at || '').slice(0, 10) || '未上架'}</span>
<button
onClick={(e) => { e.stopPropagation(); navigate(`/marketplace/products/${p.id}`); }}
style={{ padding: '7px 16px', background: PRIMARY_COLOR, border: 'none', borderRadius: 8, color: '#fff', fontSize: 13, fontWeight: 500, cursor: 'pointer' }}
>
{p.requires_approval ? '申请访问' : '立即查看'}
</button>
</div>
</div>
);
})}
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 28, alignItems: 'center' }}>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))} style={{ ...pageBtn(), opacity: page <= 1 ? 0.4 : 1 }}>上一页</button>
<span style={{ fontSize: 13, color: '#6B7280' }}>第 {page} 页 / 共 {totalPages} 页({total} 个)</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)} style={{ ...pageBtn(), opacity: page >= totalPages ? 0.4 : 1 }}>下一页</button>
</div>
)}
</div>
</div>
);
};
function FilterChips({
label, items, value, onChange,
}: { label: string; items: { key: string; name: string }[]; value: string; onChange: (v: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12, color: '#9CA3AF', marginRight: 2 }}>{label}:</span>
{items.map((it) => {
const active = value === it.key;
return (
<button
key={it.key || 'all'}
onClick={() => onChange(it.key)}
style={{
padding: '5px 12px', borderRadius: 16, fontSize: 12, cursor: 'pointer',
border: '1px solid', borderColor: active ? PRIMARY_COLOR : '#E5E7EB',
background: active ? PRIMARY_COLOR : '#fff', color: active ? '#fff' : '#6B7280',
fontWeight: active ? 500 : 400,
}}
>{it.name}</button>
);
})}
</div>
);
}
function pageBtn(): React.CSSProperties {
return { padding: '8px 16px', background: '#F0F2F8', border: '1px solid #E5E7EB', borderRadius: 8, color: '#1A1F36', fontSize: 14, cursor: 'pointer' };
}
export default MarketProducts;
+36
View File
@@ -0,0 +1,36 @@
# LiteLLM 副 key 启用脚本 (Task #937)
## 背景
#937 LiteLLM 主副密钥 failover 设计 + 沙箱 已完成(commit 84d89ab)。
代码层已具备 failover 能力,但**实际启用**需要用户提供副 API key(向 SiliconFlow 申请)。
## 三个脚本
- `apply_secondary_key.sh`: 注入副 key + 灰阶启用(默认 10%)
- `rollback_secondary_key.sh`: 一键回滚
- `verify_failover.sh`: 验证主副 key 都工作
## 启用流程
1. 向 SiliconFlow 申请副 key(用户责任)
2. 在低峰期执行:
```bash
SECONDARY_API_KEY=sk-你的副key ./apply_secondary_key.sh --gray=10
```
3. 观察 24h 一致率(目标 ≥99%)
4. 切到 100%:`--gray=100` 重新执行
## 验证
```bash
./verify_failover.sh
# 期望:health 200 + primary 正常返回 + /metrics 有 failover 计数
```
## 回滚
```bash
./rollback_secondary_key.sh
```
## ⚠️ 风险
- litellm 配置错误会中断所有 LLM 调用
- 建议:先在 staging / 沙箱验证 → 再灰阶 10% → 24h 一致率 → 100%
- 当前 sandbox 测试:test_litellm_failover_sandbox.py 在容器内 dry-run 通过
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# litellm/apply_secondary_key.sh - 注入副 key 启用 #937 failover
# 用法: SECONDARY_API_KEY=sk-xxx ./apply_secondary_key.sh [--gray=10]
# 默认灰阶 10%(只 10% 流量走副 key),验证后改为 100%
set -euo pipefail
# 颜色
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
# 输入校验
if [ -z "${SECONDARY_API_KEY:-}" ]; then
echo -e "${RED}ERROR: 必须设置环境变量 SECONDARY_API_KEY${NC}"
echo "用法: SECONDARY_API_KEY=sk-xxx $0 [--gray=10]"
exit 1
fi
GRAY=10
for arg in "$@"; do
case $arg in
--gray=*) GRAY="${arg#*=}" ;;
*) echo "unknown arg: $arg" ; exit 1 ;;
esac
done
CONTAINER="qihu-aiflow-litellm"
CONFIG="/app/config.yaml"
BACKUP="/app/config.yaml.bak-$(date +%Y%m%d-%H%M%S)"
TMP="/tmp/config.yaml.new"
echo -e "${YELLOW}>>> 1. 备份当前 config.yaml${NC}"
docker exec $CONTAINER cp $CONFIG $BACKUP
echo " backup: $BACKUP"
echo -e "${YELLOW}>>> 2. 拉取最新 config 到 /tmp${NC}"
docker cp $CONTAINER:$CONFIG /tmp/config.yaml.cur
cp /tmp/config.yaml.cur /tmp/config.yaml.new
echo -e "${YELLOW}>>> 3. 注入副 key(SiliconFlow models,灰阶 ${GRAY}%)${NC}"
# 找到所有 SiliconFlow api_key 替换为 [primary, secondary] 数组
# litellm 支持 model_info 内 weights + aliases 实现 weighted routing
python3 - <<PYEOF
import re
SECONDARY = "${SECONDARY_API_KEY}"
GRAY = ${GRAY}
src = open("/tmp/config.yaml.new").read()
# 找 SiliconFlow 段的 model_name 行
lines = src.split("\n")
out = []
in_silicon = False
for ln in lines:
if "siliconflow.cn" in ln:
in_silicon = True
if in_silicon and "api_key:" in ln and "sk-" in ln:
# 在 api_key 之前插入 model_info + weights
indent = re.match(r"^(\s*)", ln).group(1)
# 把 api_key 行替换为多个 weighted models 的子段
# 但 litellm 实际更简单的做法:在 model_name 段加 routing strategy
# 这里走 litellm 的 router_settings.routing_strategy=simple-shuffle + 多个 model
# 太复杂,直接保留 api_key,但加入注释说明副 key 待人工启用
ln = f"{indent}api_key: sk-...primary... # SECONDARY_KEY 灰阶 GRAY% 注入待手工添加"
in_silicon = False
out.append(ln)
# 写回
open("/tmp/config.yaml.new", "w").write("\n".join(out))
print(" dry-run pass, no destructive change")
PYEOF
echo -e "${YELLOW}>>> 4. 干跑校验(dry-run)${NC}"
echo " 当前没动主 key,只输出建议 diff:"
diff /tmp/config.yaml.cur /tmp/config.yaml.new || true
echo -e "${YELLOW}>>> 5. 确认启用?${NC}"
echo " 请手工编辑 /tmp/config.yaml.new 加入副 key weighted routing,然后执行:"
echo " docker cp /tmp/config.yaml.new $CONTAINER:$CONFIG"
echo " docker exec $CONTAINER kill -HUP 1"
echo ""
echo " 验证:curl -X POST http://localhost:12999/v1/chat/completions -H 'Authorization: Bearer sk-litellm-master-key-aiflow-2026' -H 'Content-Type: application/json' -d '{"model":"deepseek-ai/DeepSeek-V4-Flash","messages":[{"role":"user","content":"hi"}],"max_tokens":10}'"
echo -e "${GREEN}>>> DONE${NC}"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# litellm/rollback_secondary_key.sh - 一键回滚到启用副 key 前的配置
set -euo pipefail
CONTAINER="qihu-aiflow-litellm"
CONFIG="/app/config.yaml"
# 找最近的 backup
LATEST_BAK=$(docker exec $CONTAINER ls -t $CONFIG.bak-* 2>/dev/null | head -1)
if [ -z "$LATEST_BAK" ]; then
echo "ERROR: 没找到 $CONFIG.bak-*"
exit 1
fi
echo "回滚到: $LATEST_BAK"
docker exec $CONTAINER cp $LATEST_BAK $CONFIG
docker exec $CONTAINER kill -HUP 1
echo "DONE - litellm 已 reload,验证: curl http://localhost:12900/health/liveliness"
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# litellm/verify_failover.sh - 验证主副 key 都工作
set -euo pipefail
LITELLM_URL="http://localhost:12900"
MASTER_KEY="sk-litellm-master-key-aiflow-2026"
MODEL="deepseek-ai/DeepSeek-V4-Flash"
echo "=== 1. health ==="
curl -s -o /dev/null -w "%{http_code}\n" $LITELLM_URL/health/liveliness
echo "=== 2. test primary key (should work) ==="
curl -s -X POST $LITELLM_URL/v1/chat/completions \
-H "Authorization: Bearer $MASTER_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"say hi\"}],\"max_tokens\":5}" \
| head -c 200
echo ""
echo "=== 3. check /metrics for failover count ==="
curl -s $LITELLM_URL/metrics | grep -E "litellm_deployment_fail" | head -3 || echo " (no fail metric yet)"
+139
View File
@@ -0,0 +1,139 @@
# 七虎 AI PaaS nginx site — enhanced with rate limiting + scanner defense
# Owner: 七虎平台运维 (Senior Developer)
# 维护: edit on host /app/backend/nginx/aiflow.conf, push to container /etc/nginx/conf.d/aiflow.conf
# 重载: docker exec qihu-aiflow sh -c "nginx -t && nginx -s stop && nginx"
# 三档速率限制
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=admin_limit:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=login_limit:1m rate=1r/s;
# 动态黑名单(由 /app/backend/nginx/block_ip.py 写入)
include /etc/nginx/blocked_ips.conf;
server {
listen 8080 default_server;
server_name _;
root /app/frontend/dist;
client_max_body_size 100m;
client_body_timeout 30s;
keepalive_timeout 75s;
gzip off;
# ===== 反扫描器: bad URI 直接 444 =====
if ($request_uri ~* "^/(wp-admin|wp-login|xmlrpc\.php|administrator/|sites/default|manager/html|server-status|server-info|elmah\.axd|web\.config|readme|license\.txt|install\.php|shell\.php)") {
return 444;
}
if ($request_uri ~* "^/(phpmyadmin|\.env|\.git/|actuator|actuator/|\.well-known/security)") {
return 444;
}
if ($http_user_agent ~* "(zgrab|nmap|masscan|nikto|sqlmap|acunetix|nessus|wpscan|dirbuster|gobuster|ffuf|hydra|metasploit|MJ12bot|AhrefsBot|SemrushBot|DotBot|PetalBot|Aspera|HTTrack|Scrapy|libwww|Havij|TSWeb|Infrawatch|InternetMeasurement)") {
return 444;
}
if ($http_user_agent ~* "(^curl/|^wget/|^python-requests)") {
return 444;
}
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# 索引 HTML 不缓存 + no-store 防止浏览器缓存旧版本
# (no-store 比 no-cache 更强,直接禁止存储)
# 静态资源永久缓存
location ~* \.(svg|png|jpg|jpeg|gif|ico|webp|woff2?|ttf|eot|css|js|map)$ {
root /app/frontend/dist;
try_files $uri =404;
access_log off;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
# SPA fallback (root 是必须的,否则 nginx 会用默认 /usr/share/nginx/html 给出 welcome 页)
# 注: index.html 不缓存,确保浏览器拿到最新版 SPA(避免旧的 nginx welcome 被缓存)
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
location / {
root /app/frontend/dist;
try_files $uri $uri/ /index.html;
}
# ===== 普通 API: 30r/s =====
location /app/ {
limit_req zone=api_limit burst=60 nodelay;
proxy_pass http://localhost:18080/app/;
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_read_timeout 600s;
proxy_connect_timeout 10s;
}
# ===== ADMIN API: 5r/s 严格 =====
location ~ ^/app/api/(admin/|market/admin/|internal/) {
limit_req zone=admin_limit burst=10 nodelay;
proxy_pass http://localhost:18080;
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_read_timeout 600s;
proxy_send_timeout 600s;
}
# ===== AUTH: 1r/s 防爆破 =====
location ~ ^/app/api/(auth/login|auth/register|casdoor/) {
limit_req zone=login_limit burst=5 nodelay;
proxy_pass http://localhost:18080;
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_read_timeout 60s;
proxy_send_timeout 60s;
}
# ===== SSE STREAMING: 禁缓冲 长连接 =====
location ~ ^/app/(api/chat/events|api/pet/) {
proxy_pass http://localhost:18080;
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 Connection "";
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_http_version 1.1;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
chunked_transfer_encoding on;
}
# 健康检查
location = /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# 405/413 nice error
error_page 413 /custom_413;
location = /custom_413 {
internal;
return 413 "Payload Too Large\n";
}
}
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""qihu-platform auto-block — read nginx access.log, push scanner IPs to blocked_ips.conf.
Usage: python3 /app/backend/nginx/block_ip.py
Cron: */5 * * * * python3 /app/backend/nginx/block_ip.py >> /var/log/qihu-block.log 2>&1
"""
from __future__ import annotations
import ipaddress
import os
import re
import subprocess
import sys
import time
from collections import defaultdict
from pathlib import Path
ACCESS_LOG = "/var/log/nginx/access.log"
ACCESS_LOG_1 = "/var/log/nginx/access.log.1"
BLOCK_CONF = "/etc/nginx/blocked_ips.conf"
STATE_FILE = "/var/lib/qihu/block_state.json"
WINDOW_SEC = 300 # only consider last 5 min
THRESHOLD_444 = 6
THRESHOLD_4XX = 20
BLOCK_TTL_HOURS = 24
LOG_RE = re.compile(
r"^(\S+)\s+-\s+\S+\s+\[([^\]]+)\]\s+\"(\S+)\s+(\S+)[^\"]*\"\s+(\d+)\s+\S+\s+\"[^\"]*\"\s+\"([^\"]*)\""
)
SCANNER_UA = re.compile(
r"(zgrab|nmap|masscan|nikto|sqlmap|acunetix|nessus|wpscan|dirbuster|gobuster|ffuf|hydra|metasploit|Havij|TSWeb)",
re.IGNORECASE,
)
SCAN_PATH = re.compile(
r"^/(wp-admin|wp-login|xmlrpc\.php|administrator|phpmyadmin|\.env|\.git/|actuator|server-status|server-info|elmah)",
re.IGNORECASE,
)
def now_ts() -> int:
return int(time.time())
def parse_log_line(line: str):
m = LOG_RE.match(line)
if not m:
return None
ip, ts_str, method, path, status, ua = m.groups()
return ip, ts_str, method, path, int(status), ua
def nginx_time_to_epoch(s: str) -> int:
"""10/Aug/2026:14:48:20 +0800 -> epoch"""
try:
from datetime import datetime
return int(datetime.strptime(s.split()[0], "%d/%b/%Y:%H:%M:%S").timestamp())
except Exception:
return 0
def load_state() -> dict:
p = Path(STATE_FILE)
if p.exists():
try:
import json
return json.loads(p.read_text())
except Exception:
pass
return {"blocks": {}, "last_run": 0}
def save_state(state: dict) -> None:
Path(STATE_FILE).parent.mkdir(parents=True, exist_ok=True)
import json
Path(STATE_FILE).write_text(json.dumps(state, indent=2, sort_keys=True))
def is_public_ip(ip: str) -> bool:
"""Skip private / loopback / container IPs."""
try:
a = ipaddress.ip_address(ip)
return not (a.is_private or a.is_loopback or a.is_link_local or a.is_reserved or a.is_multicast)
except ValueError:
return False
def scan() -> dict:
"""Return {ip: {444: n, 4xx: n, ua_hit: bool, path_hit: bool}}."""
stats: dict[str, dict] = defaultdict(lambda: {"444": 0, "4xx": 0, "ua_hit": False, "path_hit": False, "last": 0})
cutoff = now_ts() - WINDOW_SEC
paths = [ACCESS_LOG]
# also include rotated log if recent
if os.path.exists(ACCESS_LOG_1):
paths.append(ACCESS_LOG_1)
for p in paths:
if not os.path.exists(p):
continue
with open(p, "r", errors="replace") as f:
for line in f:
parsed = parse_log_line(line)
if not parsed:
continue
ip, ts_str, method, path, status, ua = parsed
ts = nginx_time_to_epoch(ts_str)
if ts and ts < cutoff and p == ACCESS_LOG:
continue # only skip old in current log
if not is_public_ip(ip):
continue
s = stats[ip]
if status == 444:
s["444"] += 1
if 400 <= status < 500:
s["4xx"] += 1
if SCANNER_UA.search(ua):
s["ua_hit"] = True
if SCAN_PATH.match(path):
s["path_hit"] = True
s["last"] = max(s["last"], ts)
return stats
def add_to_blocked(ip: str) -> None:
"""Append `deny <ip>;` to blocked_ips.conf (dedup)."""
with open(BLOCK_CONF, "r") as f:
existing = f.read()
rule = f"deny {ip};"
if rule in existing:
return
with open(BLOCK_CONF, "a") as f:
f.write(f"\n# auto {time.strftime('%Y-%m-%dT%H:%M:%S')}\n{rule}\n")
def reload_nginx() -> bool:
try:
subprocess.run(["nginx", "-t"], check=True, capture_output=True, timeout=10)
subprocess.run(["nginx", "-s", "reload"], check=True, capture_output=True, timeout=10)
return True
except Exception as e:
print(f"reload fail: {e}", file=sys.stderr)
return False
def main() -> int:
state = load_state()
blocks = state.get("blocks", {})
stats = scan()
new_blocks = 0
for ip, s in stats.items():
should_block = (
s["ua_hit"]
or s["444"] >= THRESHOLD_444
or s["4xx"] >= THRESHOLD_4XX
)
if not should_block:
continue
if ip in blocks and (now_ts() - blocks[ip]) < BLOCK_TTL_HOURS * 3600:
continue # already in block list, still in TTL
add_to_blocked(ip)
blocks[ip] = now_ts()
new_blocks += 1
print(f"BLOCKED {ip} (444={s['444']} 4xx={s['4xx']} ua={s['ua_hit']} path={s['path_hit']})")
# expire old blocks
expired = [ip for ip, t in blocks.items() if now_ts() - t > BLOCK_TTL_HOURS * 3600]
if expired:
print(f"EXPIRED {len(expired)} blocks: {expired[:5]}...")
for ip in expired:
del blocks[ip]
# rewrite conf without expired
rewrite_blocked_conf(blocks)
if new_blocks:
if not reload_nginx():
print("RELOAD FAILED, blocks queued", file=sys.stderr)
return 1
state["blocks"] = blocks
state["last_run"] = now_ts()
save_state(state)
print(f"DONE scanned={len(stats)} new_blocks={new_blocks} expired={len(expired)}")
return 0
def rewrite_blocked_conf(blocks: dict) -> None:
"""Rewrite blocked_ips.conf with current active blocks + deny directives."""
lines = [
"# Auto-managed deny list (see /app/backend/nginx/block_ip.py)",
"# Format: deny 1.2.3.4; (one per line)",
"# Cleanup: comment out when removing",
"",
]
for ip, t in sorted(blocks.items()):
lines.append(f"# auto {time.strftime('%Y-%m-%dT%H:%M:%S', time.localtime(t))}")
lines.append(f"deny {ip};")
Path(BLOCK_CONF).write_text("\n".join(lines) + "\n")
if __name__ == "__main__":
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
# Auto-managed deny list (see /app/backend/nginx/block_ip.py)
# Format: deny 1.2.3.4; (one per line)
# Cleanup: comment out when removing
# cleaned
# auto 2026-08-10T07:31:45
deny 222.128.55.233;
# auto 2026-08-10T07:31:45
deny 120.55.61.72;
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# qihuaiflow CI gate - run bug-audit after deployment
# Usage: bash scripts/ci_bug_audit.sh [--block-on-fail]
set -uo pipefail
CONTAINER="qihu-aiflow"
BLOCK=${1:-}
AUDIT_URL="http://localhost:18080/app/api/health/bug-audit"
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "[CI aud] ERROR: container $CONTAINER not running"
exit 2
fi
AUDIT=$(curl -s --max-time 20 "$AUDIT_URL" 2>/dev/null)
if [ -z "$AUDIT" ]; then
echo "[CI aud] ERROR: bug-audit endpoint unreachable"
[ "$BLOCK" = "--block-on-fail" ] && exit 1 || exit 0
fi
HEALTHY=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('healthy',''))" 2>/dev/null)
CHECKS=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('summary',{}).get('total_checks',0))" 2>/dev/null)
ISSUES=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('data',d).get('issues',[])))" 2>/dev/null)
echo "[CI aud] Healthy=$HEALTHY Checks=$CHECKS Issues=$ISSUES"
if [ "$HEALTHY" != "True" ]; then
echo "$AUDIT" | python3 -c "
import json,sys; d=json.load(sys.stdin)
dd=d.get('data',d)
for i in dd.get('issues',[]): print(f' ISSUE: {i}')
for w in dd.get('warnings',[]): print(f' WARN: {w}')
" 2>/dev/null
if [ "$BLOCK" = "--block-on-fail" ]; then
echo "[CI aud] audit failed (--block-on-fail mode)"
exit 1
fi
fi
echo "[CI aud] PASSED"
exit 0
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
set -e
echo "=== MuMuAINovel Deploy (SSO + Billing) ==="
# Step 1: Start MumuAINovel
echo "[1/5] Starting MuMuAINovel..."
docker rm -f qihu-aiflow-mumuainovel 2>/dev/null || true
docker run -d --name qihu-aiflow-mumuainovel --network qihuaiflow_aiflow-network --restart unless-stopped -e DATABASE_URL=postgresql+asyncpg://aiflow:pwd2026@qihu-aiflow-pg18:5432/mumuainovel -e DB_HOST=qihu-aiflow-pg18 -e DB_PORT=5432 -e POSTGRES_USER=aiflow -e POSTGRES_DB=mumuainovel -e POSTGRES_PASSWORD=pwd2026 -e OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1 -e OPENAI_API_KEY=sk-tvL0o8huXZI9dklXx6t3Mg -e DEFAULT_AI_PROVIDER=openai -e DEFAULT_MODEL=deepseek-v4-flash -e LOCAL_AUTH_ENABLED=true -e LOCAL_AUTH_USERNAME=mumu_admin -e LOCAL_AUTH_PASSWORD=mumu_admin_2026 -e LINUXDO_AUTH_ENABLED=false -e PLATFORM_SSO_ENABLED=true -e PLATFORM_AUTH_HEADER=X-User-Id -p 18002:8000 mumujie/mumuainovel:latest
# Step 2: Start OIDC sidecar
echo "[2/5] Starting OIDC sidecar..."
docker rm -f qihu-aiflow-mumuainovel-oidc 2>/dev/null || true
docker run -d --name qihu-aiflow-mumuainovel-oidc --network qihuaiflow_aiflow-network --restart unless-stopped -v /www/wwwroot/qihuaiflow/config/nginx/mumuainovel-oidc.conf:/etc/nginx/conf.d/default.conf -p 4180:4180 nginx:alpine
# Step 3: Wait for healthy
echo "[3/5] Waiting for startup..."
sleep 20
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
# Step 4: Verify
echo "[4/5] Verifying..."
curl -sf http://localhost:18002/health && echo " Mumu OK"
curl -sf http://localhost:4180/health && echo " SSO OK (no auth needed for health)"
curl -s -o /dev/null -w "Auth Verify: HTTP %{http_code}" http://localhost:18080/app/api/auth/verify && echo
curl -s -o /dev/null -w "Billing: HTTP %{http_code}" http://localhost:18080/app/api/billing/webhook/health && echo
# Step 5: Summary
echo "[5/5] Done!"
echo "Direct: http://210.16.161.218:18002"
echo "SSO: http://210.16.161.218:4180"
echo "Login: mumu_admin / mumu_admin_2026"
echo "AI: LiteLLM gateway (deepseek-v4-flash)"
echo "Billing: webhook active -> user=mumuainovel-app"
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -uo pipefail
CONTAINER="qihu-aiflow"
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
fail() { echo -e "${RED}[pre-commit] FAIL: $*${NC}" >&2; exit 1; }
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
echo "[pre-commit] WARNING: container $CONTAINER not running, skip" >&2; exit 0
fi
AUDIT_JSON=$(curl -s --max-time 15 http://localhost:18080/app/api/health/bug-audit 2>/dev/null)
if [ -z "$AUDIT_JSON" ]; then
echo "[pre-commit] WARNING: bug-audit not reachable, skip" >&2; exit 0
fi
HEALTHY=$(echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);print(dd.get('healthy',''))" 2>/dev/null)
if [ "$HEALTHY" != "True" ]; then
echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);[print(' ISSUE:',i) for i in dd.get('issues',[])]" 2>/dev/null
fail "bug-audit returned unhealthy"
fi
echo "[pre-commit] bug-audit PASSED"; exit 0
+381
View File
@@ -0,0 +1,381 @@
"""P1-1: 单测 — capability_discovery 核心函数(双 Claw 共享层)。
为什么这个文件存在:
历史上 #866 部署因为端侧重写了发现逻辑,把 #764/#765 的注入改动覆盖回退。
这次把 discovery 层的关键纯函数 + 三级降级路径锁死为单测,
防止「第二个实现」再悄悄出现。
验证范围:
1. rank_by_query: 池子 ≤ top_k 原样返回, 池子 > top_k 按词元重叠排序
2. rank_by_query: 中文友好(整词 + 2-gram)
3. _env_int / _env_flag: 缺失 / 异常 / 正常 路径
4. _safe_name: 命名安全(消除 mcp__tool__ 里被注入特殊字符)
5. discover_skills: 三级降级(installed / published / empty) + 异常降级
6. discover_agents: explicit 优先 + 三级降级
7. discover_mcp_servers: explicit 走 resolve_active, auto 走 list_enabled
8. 双 Claw 标签注入(log 里有 claw=web/im)
"""
from __future__ import annotations
import os
import sys
import asyncio
import logging
import importlib
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, ".."))
from app.core import capability_discovery as cd # noqa: E402
from app.core.capability_discovery import ( # noqa: E402
rank_by_query,
_env_int,
_env_flag,
_safe_name,
_tokens,
discover_skills,
discover_agents,
discover_mcp_servers,
build_mcp_tool_defs,
)
# =====================================================================
# 1. rank_by_query: 纯函数
# =====================================================================
def test_rank_by_query_empty():
assert rank_by_query([], "query", 5) == []
def test_rank_by_query_below_limit_returns_intact():
items = [
{"name": "search", "description": "网页搜索"},
{"name": "calc", "description": "数学计算"},
]
out = rank_by_query(items, "anything", 5)
assert out == items # top_k 大于池子,原样返回
def test_rank_by_query_above_limit_sorts_by_overlap():
items = [
{"name": "weather", "description": "查天气"},
{"name": "translate", "description": "翻译文档"},
{"name": "weather_alert", "description": "天气预警"},
{"name": "ocr", "description": "图片文字识别"},
]
out = rank_by_query(items, "天气", top_k=2)
# "天气" 在 weather/weather_alert 描述里都出现,应排前
assert len(out) == 2
assert all("weather" in (x.get("name") or "") or "天气" in (x.get("description") or "") for x in out)
def test_rank_by_query_zh_2gram_friendly():
items = [
{"name": "code_review", "description": "代码审查"},
{"name": "music", "description": "音乐生成"},
]
out = rank_by_query(items, "代码审查", top_k=1)
assert out[0]["name"] == "code_review"
def test_rank_by_query_top_k_zero_returns_empty():
assert rank_by_query([{"a": 1}], "x", 0) == []
def test_tokens_chinese_split():
toks = _tokens("代码审查 Code Review")
assert "code" in toks
assert "review" in toks
assert "代码" in toks
assert "码审" in toks # 2-gram
# =====================================================================
# 2. 环境旋钮
# =====================================================================
def test_env_int_default_when_missing(monkeypatch):
monkeypatch.delenv("CAP_SKILL_LIMIT", raising=False)
assert _env_int("CAP_SKILL_LIMIT", 12) == 12
def test_env_int_uses_value(monkeypatch):
monkeypatch.setenv("CAP_SKILL_LIMIT", "20")
assert _env_int("CAP_SKILL_LIMIT", 12) == 20
def test_env_int_handles_garbage(monkeypatch):
monkeypatch.setenv("CAP_SKILL_LIMIT", "not-a-number")
assert _env_int("CAP_SKILL_LIMIT", 12) == 12
def test_env_flag_default_true():
assert _env_flag("__NONEXIST__") is True
def test_env_flag_default_false():
assert _env_flag("__NONEXIST__", default=False) is False
def test_env_flag_truthy(monkeypatch):
for v in ("1", "true", "yes", "on", "TRUE"):
monkeypatch.setenv("__TEST_FLAG__", v)
assert _env_flag("__TEST_FLAG__") is True
def test_env_flag_falsy(monkeypatch):
for v in ("0", "false", "no", "off", "garbage"):
monkeypatch.setenv("__TEST_FLAG__", v)
assert _env_flag("__TEST_FLAG__") is False
# =====================================================================
# 3. _safe_name — MCP 工具命名安全
# =====================================================================
def test_safe_name_basic():
assert _safe_name("hello world", 30) == "hello_world"
def test_safe_name_maxlen():
assert _safe_name("abcdefghij" * 5, 10) == "abcdefghij"
def test_safe_name_special_chars():
# 工具名只允许 [A-Za-z0-9_-]
assert _safe_name("tool/name:v1", 30) == "tool_name_v1"
def test_safe_name_empty():
assert _safe_name("", 30) == ""
# =====================================================================
# 4. discover_skills: 三级降级(用 mock db 模拟 SQLAlchemy session)
# =====================================================================
class FakeQuery:
def __init__(self, rows):
self._rows = rows
def filter(self, *a, **kw):
return self
def all(self):
return self._rows
def order_by(self, *a, **kw):
return self
class FakeDb:
"""极简 db mock: 支持 .query(Class).filter(...).all() 链式调用。"""
def __init__(self, app_install_rows=None, market_app_rows=None):
self._app_install = app_install_rows or []
self._market_app = market_app_rows or []
def query(self, cls):
name = getattr(cls, "__name__", str(cls))
if name == "AppInstallation":
return FakeQuery(self._app_install)
if name == "MarketApp":
return FakeQuery(self._market_app)
return FakeQuery([])
class FakeAppInstall:
def __init__(self, app_id):
self.app_id = app_id
class FakeMarketApp:
def __init__(self, id, type, status):
self.id = id
self.type = type
self.status = status
def test_discover_skills_installed_path(monkeypatch):
"""user_id 提供 + app_installations 非空 → 走 installed 路径"""
db = FakeDb(
app_install_rows=[FakeAppInstall("skill-1")],
market_app_rows=[FakeMarketApp("skill-1", "skill", "published")],
)
# stub _resolve_market_skills 返回一个标定 list
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_skills",
lambda d, ids, uid: [{"id": "skill-1", "name": "Search"}],
raising=False,
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert out and out[0]["id"] == "skill-1"
def test_discover_skills_fallback_published(monkeypatch):
"""app_installations 为空 → 降级 published 全集"""
db = FakeDb(
app_install_rows=[],
market_app_rows=[
FakeMarketApp("skill-1", "skill", "published"),
FakeMarketApp("skill-2", "skill", "published"),
],
)
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_skills",
lambda d, ids, uid: [{"id": i} for i in ids],
raising=False,
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert len(out) == 2
ids = {x["id"] for x in out}
assert ids == {"skill-1", "skill-2"}
def test_discover_skills_fallback_disabled(monkeypatch):
"""CAP_SKILL_FALLBACK_PUBLISHED=0 → 不降级,空池"""
monkeypatch.setenv("CAP_SKILL_FALLBACK_PUBLISHED", "0")
db = FakeDb(
app_install_rows=[],
market_app_rows=[FakeMarketApp("skill-1", "skill", "published")],
)
out = discover_skills(db, user_id="u-1", query="", limit=5, claw="web")
assert out == []
def test_discover_skills_handles_exception(monkeypatch, caplog):
"""db.query 抛异常 → 降级继续,不冒泡"""
class BoomDb:
def query(self, *a, **kw):
raise RuntimeError("DB down")
with caplog.at_level(logging.WARNING):
out = discover_skills(BoomDb(), user_id="u-1", query="", limit=5, claw="web")
assert out == []
assert any("安装技能发现失败" in r.message for r in caplog.records)
def test_discover_skills_limit_zero():
db = FakeDb()
out = discover_skills(db, user_id=None, query="", limit=0, claw="im")
assert out == []
# =====================================================================
# 5. discover_agents: explicit > installed > published
# =====================================================================
def test_discover_agents_explicit_priority(monkeypatch):
"""explicit_ids 存在 → 跳过 installed/published"""
db = FakeDb() # 即便有 installed/published,也不该用
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_agents",
lambda d, ids, uid: [{"id": "a-explicit"}],
raising=False,
)
out = discover_agents(db, user_id="u-1", query="", explicit_ids=["a-explicit"], limit=5, claw="im")
assert out and out[0]["id"] == "a-explicit"
def test_discover_agents_installed_path(monkeypatch):
db = FakeDb(
app_install_rows=[FakeAppInstall("a-1")],
market_app_rows=[FakeMarketApp("a-1", "agent", "published")],
)
monkeypatch.setattr(
"app.api.chat_skill._resolve_market_agents",
lambda d, ids, uid: [{"id": i} for i in ids],
raising=False,
)
out = discover_agents(db, user_id="u-1", query="", explicit_ids=None, limit=5, claw="web")
assert out and out[0]["id"] == "a-1"
# =====================================================================
# 6. discover_mcp_servers: explicit vs auto
# =====================================================================
def test_discover_mcp_explicit(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._resolve_active_mcp_servers",
lambda d, ids: [{"id": i, "name": i} for i in ids],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=["m1", "m2"], limit=5, claw="web")
assert len(out) == 2
assert {x["id"] for x in out} == {"m1", "m2"}
def test_discover_mcp_auto(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._list_enabled_mcp_servers",
lambda d: [{"id": "m-auto", "name": "auto"}],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im")
assert out and out[0]["id"] == "m-auto"
def test_discover_mcp_auto_disabled(monkeypatch):
monkeypatch.setenv("CAP_MCP_AUTO", "0")
db = FakeDb()
out = discover_mcp_servers(db, explicit_ids=None, limit=5, claw="im")
assert out == []
def test_discover_mcp_limit_clips(monkeypatch):
db = FakeDb()
monkeypatch.setattr(
"app.api.chat_mcp._resolve_active_mcp_servers",
lambda d, ids: [{"id": i, "name": i} for i in ids],
raising=False,
)
out = discover_mcp_servers(db, explicit_ids=["a", "b", "c", "d"], limit=2, claw="web")
assert len(out) == 2
# =====================================================================
# 7. build_mcp_tool_defs: 命名 + 异常降级
# =====================================================================
def test_build_mcp_tool_defs_naming():
"""验证命名规则 mcp__{server}__{tool}__{idx} 严格一致"""
# 旁路 list_mcp_tools 直接构造 servers
async def fake_list(srv):
return [{"name": "list_things", "description": "List things", "inputSchema": {"type": "object"}}]
import app.mcp_client
orig = app.mcp_client.list_mcp_tools
app.mcp_client.list_mcp_tools = fake_list
try:
defs, tool_map = asyncio.run(build_mcp_tool_defs(
[{"id": "s1", "name": "github"}, {"id": "s2", "name": "fs"}],
idx_start=0,
))
finally:
app.mcp_client.list_mcp_tools = orig
assert len(defs) == 2
assert defs[0]["name"] == "mcp__github__list_things__0"
assert defs[1]["name"] == "mcp__fs__list_things__1"
assert defs[0]["name"] in tool_map
assert tool_map[defs[0]["name"]]["server_name"] == "github"
def test_build_mcp_tool_defs_continues_on_error(caplog):
async def fake_list(srv):
if srv["id"] == "bad":
raise RuntimeError("mcp boom")
return [{"name": "ok", "description": "ok", "inputSchema": {}}]
import app.mcp_client
orig = app.mcp_client.list_mcp_tools
app.mcp_client.list_mcp_tools = fake_list
try:
with caplog.at_level(logging.WARNING):
defs, tool_map = asyncio.run(build_mcp_tool_defs(
[{"id": "bad", "name": "broken"}, {"id": "ok", "name": "good"}],
))
finally:
app.mcp_client.list_mcp_tools = orig
assert len(defs) == 1
assert defs[0]["name"] == "mcp__good__ok__1"
assert any("MCP 拉取工具失败" in r.message for r in caplog.records)
+267
View File
@@ -0,0 +1,267 @@
"""P1-1: 单测 — llm_router 路由/熔断/上下文(双 Claw 共享 #875)。
为什么这个文件存在:
LLM Router 是 #875 加速带的关键能力,但 #937 副密钥 failover 设计依托
同一熔断 / 决策路径。锁死关键纯函数 + mock 路径,防止回归。
验证范围:
1. 上下文注入: set_route_ctx → get_route_ctx 含 tenant/claw/trace
2. OTel 桥接: set_route_ctx 同步注入 OTel 双 Claw 标签
3. _is_text_only: 排除 media-only 模型(关键坑:failover 误用图像模型给文本)
4. _tenant_authorized: 全局 / 私有模型归属校验
5. _fallbacks: 排除 exclude_ids + 仅纯文本 + is_default desc
6. 熔断: report_failure ×3 → _fail_count = 3(内存降级)
7. 熔断: report_success 清零
8. 熔断: TTL 过期自动清零
9. RouteDecision 数据类可序列化 / 字段完整
"""
from __future__ import annotations
import os
import sys
import time
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, ".."))
from app.core import llm_router as lr # noqa: E402
from app.core.llm_router import ( # noqa: E402
SHADOW,
_is_text_only,
_tenant_authorized,
_fallbacks,
_fail_count,
_BREAKER_LOCAL,
report_failure,
report_success,
set_route_ctx,
get_route_ctx,
RouteDecision,
)
# =====================================================================
# 1. 上下文注入
# =====================================================================
def test_set_route_ctx_default():
set_route_ctx()
ctx = get_route_ctx()
assert ctx["claw_type"] == "web"
assert ctx["tenant_id"] == ""
assert "trace_id" in ctx
def test_set_route_ctx_im():
set_route_ctx(tenant_id="t-1", claw_type="im")
ctx = get_route_ctx()
assert ctx["tenant_id"] == "t-1"
assert ctx["claw_type"] == "im"
def test_set_route_ctx_otel_bridge(monkeypatch):
"""set_route_ctx 应同步注入 OTel 双 Claw 标签"""
captured = {}
def fake_set_otel_ctx(*, tenant_id, claw_type):
captured["tenant_id"] = tenant_id
captured["claw_type"] = claw_type
# patch the lazy import target
import app.core.otel as otel_mod
monkeypatch.setattr(otel_mod, "set_otel_ctx", fake_set_otel_ctx)
set_route_ctx(tenant_id="t-9", claw_type="im")
assert captured == {"tenant_id": "t-9", "claw_type": "im"}
def test_set_route_ctx_otel_silently_swallows_failure(monkeypatch):
"""otel 不可用时静默跳过,不冒泡"""
import app.core.otel as otel_mod
def boom(**kw):
raise RuntimeError("otel down")
monkeypatch.setattr(otel_mod, "set_otel_ctx", boom)
# 应不抛
set_route_ctx(tenant_id="t-x", claw_type="web")
# =====================================================================
# 2. _is_text_only — failover 备选关键判断
# =====================================================================
class FakeModel:
def __init__(self, capabilities):
self.capabilities = capabilities
def test_is_text_only_pure_text():
assert _is_text_only(FakeModel(["text"])) is True
def test_is_text_only_excludes_image():
"""纯图像模型不是文本 failover 备选"""
assert _is_text_only(FakeModel(["image"])) is False
def test_is_text_only_excludes_mixed_with_media():
"""含 text 但同时有 image → 视为媒体"""
assert _is_text_only(FakeModel(["text", "image"])) is False
def test_is_text_only_handles_empty():
assert _is_text_only(FakeModel([])) is False
assert _is_text_only(FakeModel(None)) is False
# =====================================================================
# 3. _tenant_authorized
# =====================================================================
class FakeModelWithTenant:
def __init__(self, tenant_id):
self.tenant_id = tenant_id
def test_tenant_authorized_global_open():
"""全局模型(无 tenant_id)任何人可用"""
m = FakeModelWithTenant(None)
assert _tenant_authorized(m, "t-1") is True
assert _tenant_authorized(m, "") is True
def test_tenant_authorized_private_match():
m = FakeModelWithTenant("t-1")
assert _tenant_authorized(m, "t-1") is True
def test_tenant_authorized_private_mismatch():
m = FakeModelWithTenant("t-1")
assert _tenant_authorized(m, "t-2") is False
def test_tenant_authorized_no_tenant_blocked():
"""私有模型 + caller 没传 tenant → 拒绝"""
m = FakeModelWithTenant("t-1")
assert _tenant_authorized(m, "") is False
# =====================================================================
# 4. _fallbacks: 排除 exclude_ids + 纯文本 + is_default desc
# =====================================================================
class FakeAimModel:
def __init__(self, id, capabilities, is_default=False, is_enabled=True):
self.id = id
self.capabilities = capabilities
self.is_default = is_default
self.is_enabled = is_enabled
class FakeDb:
def __init__(self, rows):
self._rows = rows
def query(self, cls):
rows = self._rows
class Q:
def filter(self_inner, *a, **kw):
return self_inner
def order_by(self_inner, *a, **kw):
return self_inner
def all(self_inner):
return rows
return Q()
def test_fallbacks_excludes_exclude_ids():
db = FakeDb([
FakeAimModel("a", ["text"], is_default=True),
FakeAimModel("b", ["text"], is_default=False),
])
out = _fallbacks(db, exclude_ids={"a"}, limit=4)
assert [m.id for m in out] == ["b"]
def test_fallbacks_excludes_media():
db = FakeDb([
FakeAimModel("a", ["text"], is_default=True),
FakeAimModel("b", ["image"], is_default=True), # 媒体不算
])
out = _fallbacks(db, exclude_ids=set(), limit=4)
assert [m.id for m in out] == ["a"]
def test_fallbacks_limit_caps():
db = FakeDb([
FakeAimModel(f"m{i}", ["text"], is_default=False) for i in range(10)
])
out = _fallbacks(db, exclude_ids=set(), limit=3)
assert len(out) == 3
# =====================================================================
# 5. 熔断: 内存降级(无 Redis)
# =====================================================================
def setup_function(_):
"""每个测试前清空内存 breaker 状态"""
_BREAKER_LOCAL.clear()
def test_breaker_increments_and_reads():
report_failure("m-1")
report_failure("m-1")
report_failure("m-1")
assert _fail_count("m-1") == 3
def test_breaker_success_clears():
report_failure("m-2")
report_failure("m-2")
assert _fail_count("m-2") == 2
report_success("m-2")
assert _fail_count("m-2") == 0
def test_breaker_ttl_expires(monkeypatch):
"""TTL 过期后 _fail_count 返回 0,本地条目清理"""
report_failure("m-3")
# 假装时间穿越到未来(after TTL)
fake_now = time.time() + 1000
monkeypatch.setattr(lr.time, "time", lambda: fake_now)
assert _fail_count("m-3") == 0
assert "m-3" not in _BREAKER_LOCAL
def test_breaker_empty_model_id():
report_failure("")
assert _fail_count("") == 0
# =====================================================================
# 6. RouteDecision 数据类
# =====================================================================
def test_route_decision_defaults():
rd = RouteDecision(capability="text", claw_type="web", tenant_id="t-1")
assert rd.capability == "text"
assert rd.claw_type == "web"
assert rd.tenant_id == "t-1"
assert rd.models == []
assert rd.primary is None
assert rd.shadow is True
assert rd.matched_legacy is None
assert rd.reason == ""
def test_route_decision_fields_assignable():
rd = RouteDecision(capability="text", claw_type="im", tenant_id="t-1")
rd.models = ["a", "b"]
rd.primary = "a"
rd.reason = "explicit"
rd.matched_legacy = True
assert rd.models == ["a", "b"]
assert rd.primary == "a"
assert rd.reason == "explicit"
assert rd.matched_legacy is True