Compare commits

...
74 Commits
Author SHA1 Message Date
agent 25f3edac0a feat(admin): 后台脚手架-布局/路由/守卫 + Dashboard 真实数据页 (#1013)
CI / gate (push) Has been cancelled
2026-08-24 11:32:18 +08:00
qihuaiflow 7b8c3ba2dd feat(frontend): Notifications + Evolution 真实数据页接用户端 API (#1008/#1009) 2026-08-24 08:47:36 +08:00
QihuAI Design 8f30a588fb feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包 2026-08-23 15:54:43 +08:00
QihuAI Design 1ba81ba0d1 feat(frontend-v2): /market 技能市场真实数据页 - 重写 MarketApp 类型+marketApi 对齐真实后端契约(#993/#994),新增 MarketPage 替换 ComingSoon(#995) 2026-08-23 13:50:11 +08:00
agent 66f696c1e6 feat(profile): 补全写操作-改资料/改密/API Key 增删状态切换 (#991) 2026-08-23 13:21:16 +08:00
QihuAI Design 09440c2577 feat(frontend-v2): ProfilePage 接 profile API - client/useApi/App 对齐真实契约 (#987-#990) 2026-08-23 12:30:20 +08:00
QihuAI Design 8a9c2e4868 feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) 接 profile API 6 区块 2026-08-23 12:28:41 +08:00
QihuAI Design 39fcdbf6da feat(frontend-v2): ProfilePage 真实数据页 (#987-#990) - 接 profile API 6 区块 2026-08-23 12:26:56 +08:00
QihuAI Design 910394423d feat(frontend-v2): #986 Token 驾驶舱真实数据页 (token/overview|rankings|logs|balance) 2026-08-23 12:02:37 +08:00
QihuAI Design 4a2920e637 #985 阶段3: 9个占位路由升级为 amber 金暖白 ComingSoon 页 (统一品牌感+功能预告) 2026-08-23 11:36:02 +08:00
QihuAI Design 6f192a89cf #983/#984 amber 金暖白设计语言:globals.css 主色令牌 indigo->amber + 3处硬编码色修正 2026-08-23 11:21:22 +08:00
QihuAI Design 8f57956904 #978 safe-align: 固化 frontend-v2 空白页修复真相源
- 新增 frontend-v2/ 为生产前端真相源(此前仅容器 dist,未入 git)
- vite.config.ts: minify:false 根治压缩器压坏 react-router 6.28 (o.slice)
- useApi.ts: asArray select 防御非裸数组返回值
- LandingPage/AppServices: Array.isArray coerce 双重防御 (apps.slice)
- 关联: INCIDENT-2026-08-23-blank-page.md / SOP-FRONTEND-ITERATIVE-DEVELOPMENT.md
- 公网验证通过 2026-08-23 09:31 (DOM 24493B, 无 slice 错误)
2026-08-23 09:36:35 +08:00
qihu-bot 552a962123 #977 时间错位8小时彻底修复 - _beijing_now返回aware
CI / gate (push) Has been cancelled
根因: _beijing_now返回naive datetime(无时区), PG timestamptz列按UTC误解, 存13:53UTC前端按Asia/Shanghai显示21:53错位8小时。修复: 去掉replace(tzinfo=None)返回aware datetime(带+08:00), PG正确转UTC存储, 前端正确显示。
2026-08-18 14:01:57 +08:00
qihu-bot ab11283d92 #976 微信对话同步WebClaw修复 + admin连接时间统一北京时间
CI / gate (push) Has been cancelled
1.mirror_to_ai_chat: _uuid→uuid(NameError被except吞了, 对话从未同步到webclaw) 2.im_bot_admin加_beijing_now并替换5处datetime.now()(容器UTC存naive时间前端按+08:00展示错位8小时)
2026-08-18 13:47:07 +08:00
qihu-bot f975672b0e #974 微信Bot sendtyping彻底修复
CI / gate (push) Has been cancelled
根因: sendtyping三个bug - 1.ilink_user_id误用bot ID(应from_user_id用户ID) 2.请求缺base_info 3.未检查ret==0。另发现账号session已过期(-14 session timeout)是sendtyping失效+文本失败的根源。修复: getconfig/sendtyping补base_info+正确ilink_user_id+ret==0判断, -14时写session_expired日志。
2026-08-18 11:30:15 +08:00
qihu-bot 9cf4378bf5 #973 微信Bot文本回复慢+间歇空修复
CI / gate (push) Has been cancelled
根因: 多轮对话时图片URL(含AWS token)存进历史+104技能清单注入prompt, 导致推理模型响应慢(72s)且间歇返回空content。修复: 1.IM侧精简prompt(db=None只注入静态文档) 2.空content重试(同模型最多3轮) 3.图片回复存[图片]占位符不存URL 4.历史每条截断500字符。验证: 你好15s/1+1=4s可正常回复。
2026-08-18 11:15:50 +08:00
qihu-bot 1c0dc59842 #972 微信Bot多轮对话修复 - sendmessage补client_id+run_id
CI / gate (push) Has been cancelled
对齐openclaw: payload补client_id(随机uuid去重)+run_id+from_user_id。缺失client_id导致多轮对话第二轮消息被iLink误判重复丢弃(第一轮成功第二轮失败)。
2026-08-15 19:42:15 +08:00
qihu-bot 51d8df6e6a #971 微信Bot P2 - 接收侧媒体下载解密
CI / gate (push) Has been cancelled
实现CDN媒体下载解密(parse_aes_key支持base64(raw16B)和base64(hex32)两种编码)+extract_inbound_media(图片/语音/文件/视频)。接收循环检测媒体消息下载解密保存到/tmp。图片已保存供后续vision模型接入。
2026-08-15 18:40:01 +08:00
qihu-bot e284d43cc7 #970 微信Bot P1增强 - sendtyping + 语音ASR + Session Guard
CI / gate (push) Has been cancelled
1.语音消息ASR: 提取voice_item.text(微信自带转文字) 2.sendtyping正在输入状态: getconfig拿typing_ticket缓存24h + sendtyping发送 3.Session Guard增强: 会话过期记录告警日志供前台提示重扫
2026-08-15 18:13:47 +08:00
qihu-bot 46e9f1e1e6 #969 微信Bot媒体内联发送 - iLink CDN AES加密上传
CI / gate (push) Has been cancelled
参考微信官方openclaw-weixin源码实现媒体内联发送链路:下载媒体→getuploadurl→AES-128-ECB加密→上传微信CDN→内联发送(type=2/5)。新增im_ilink_media.py模块。失败降级文本链接兜底。修复pg18/casdoor容器未启动问题。
2026-08-15 17:45:47 +08:00
qihu-bot 894eeff65a docs: 微信Bot iLink协议重构方案 v1.0
CI / gate (push) Has been cancelled
分析微信官方iLink(ClawBot)协议完整能力,对比平台现状,识别5项差距。核心:媒体内联发送(CDN AES-128-ECB加密)替代文本链接降级。分5阶段实施(P0-P2)。
2026-08-15 17:14:07 +08:00
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
QihuAI Design a9d8642db5 phase2-risk-loop-mempalace
CI / gate (push) Has been cancelled
2026-07-28 09:18:16 +08:00
QihuAI Design 88b9a232ce fix-frontend-3-items-complete
CI / gate (push) Has been cancelled
2026-07-28 06:31:48 +08:00
QihuAI Design 1633d5c33e phase1-agent-canvas-done
CI / gate (push) Has been cancelled
2026-07-27 23:20:22 +08:00
QihuAI Design 96be3fdeb3 phase1-final
CI / gate (push) Has been cancelled
2026-07-27 22:27:56 +08:00
QihuAI Design 52f1770515 phase1-complete-billing-skillruntime 2026-07-27 18:16:28 +08:00
QihuAI Design 89ce7d80dc phase1-billing-skillruntime 2026-07-27 18:14:57 +08:00
QihuAI Design 65440d91ee phase1-billing-meilisearch 2026-07-27 17:05:21 +08:00
QihuAI Design 1e0bd6f7a2 phase1-skillmd-v2-schema 2026-07-27 16:55:48 +08:00
QihuAI Design a336ac283d phase0-litellm-deployed-via-hk-proxy 2026-07-27 15:54:28 +08:00
QihuAI Design cf8c955d45 phase0-containers-meilisearch-litellm
CI / gate (push) Has been cancelled
2026-07-27 11:09:49 +08:00
QihuAI Design 245484a0c5 phase0-final-rls-rbac-complete
CI / gate (push) Has been cancelled
2026-07-27 09:57:55 +08:00
QihuAI Design 2068421d41 phase0-fix-auth-order-db-migration 2026-07-27 09:54:03 +08:00
QihuAI Design 7b1941fdaf phase0-multi-tenant-rls-rbac 2026-07-27 09:44:06 +08:00
QihuAI Design f9c8bda6e0 docs-add-iteration-plan
CI / gate (push) Has been cancelled
2026-07-27 09:26:16 +08:00
QihuAI Design ec7de645f4 docs-add-architecture-blueprint
CI / gate (push) Has been cancelled
2026-07-27 09:10:11 +08:00
QihuAI Design efa63f9d25 fix-overflow-x-horizontal-scroll
CI / gate (push) Has been cancelled
2026-07-27 08:54:39 +08:00
QihuAI Design c624d8f37c mimo-token-plan-adapter
CI / gate (push) Has been cancelled
2026-07-26 21:47:48 +08:00
QihuAI Design bb32884ecb p2-mcp-schema-error-step-enhancements
CI / gate (push) Has been cancelled
2026-07-26 20:59:52 +08:00
QihuAI Design df67a298be mcp-tool-naming-fix
CI / gate (push) Has been cancelled
2026-07-26 15:37:17 +08:00
QihuAI Design e1e50e884e feat(chat): align AI tool-use with GPT-4.1/Anthropic standards -- Fix1: AGENT_GUIDE in system prompt (Persistence/Tool-calling/Planning), Fix2: install_app def improved, Fix3: max_tool_rounds 5->15
CI / gate (push) Has been cancelled
2026-07-26 08:35:42 +08:00
QihuAI Design a2504bce34 fix(chat): resolve Auto-route failover event-loop freeze (#812)
Root cause: ensure_structured_column() executed a synchronous blocking
engine.connect().execute(SELECT information_schema.columns ...) inside the
async request path (_build_chat_history / _maybe_compress_history), freezing
the uvicorn event loop and hanging all endpoints (~200s, recovered only by
docker restart).

Fix:
- chat_context.py: remove per-request ensure_structured_column() calls; run
  once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE()
  for PostgreSQL compatibility.
- main.py: add startup-only ensure_structured_column() hook (try/except).
- llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken
  endpoints fail fast instead of hanging the event loop.
- chat_service.py: run media generation in a background daemon thread with its
  own event loop (_launch_media_task_bg) so SSE end does not cancel image/video
  tasks (#811); remove TEMP faulthandler diagnostic.

Safe-align: persist accumulated live container changes (admin/*, knowledge,
market, models, config, observability, mcp_client, platform_knowledge,
im_bot_platform, chat_media, capability_registry, requirements) and add
workflow_templates.

Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with
concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
2026-07-26 00:35:35 +08:00
172 changed files with 26987 additions and 1450 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计费、会员订阅等完整闭环体验。
**版本**: v2.0.1
**构建日期**: 2026-06-02
**仓库地址**: http://git.qihuyun.com/qyyfw/qihuaiflow
**版本**: v3.0 | **构建日期**: 2026-08-12 | **公网**: https://ai.qihuyun.com | **后台**: `admin / admin123`
---
## 技术环境
### 技术栈
| 层级 | 技术 | 说明 |
|------|------|------|
| 前端框架 | React 18 + TypeScript | 现代化响应式UI,HashRouter前端路由 |
| 构建工具 | Vite 5 | 快速HMR开发服务器,高效生产构建 |
| UI样式 | CSS Modules | 组件级样式隔离 |
| 后端框架 | Python 3.11 + FastAPI | 高性能异步Web框架,自动生成OpenAPI文档 |
| ORM | SQLAlchemy 2.0 | 全功能Python ORM,支持MySQL方言 |
| 数据库 | MySQL 8.0 | 关系型数据库,InnoDB引擎,utf8mb4编码 |
| 缓存 | Redis 7 | 内存缓存,支持持久化(AOF),事件总线 |
| Web服务器 | Nginx | 反向代理 + 静态资源服务 |
| 容器化 | Docker + Docker Compose | 一键构建部署,环境隔离 |
| API认证 | JWT (python-jose) | 无状态Bearer Token认证 |
| 密码加密 | bcrypt | 安全的密码哈希存储 |
| 实时通信 | SSE (Server-Sent Events) | AI对话流式输出 |
| IM平台接入 | 平台适配器模式 | 飞书/微信/钉钉/WhatsApp/Telegram/Slack统一抽象 |
| 微信集成 | ClawBot API | 微信个人号扫码绑定,官方API对接 |
### 核心组件
| 组件 | 版本 | 用途 |
|------|------|------|
| MySQL | 8.0 | 用户数据、聊天记录、知识库、订单、配置等持久化存储 |
| Redis | 7-alpine | 会话缓存、事件总线(发布/订阅)、API限流 |
| Nginx | latest | 前端静态资源服务(:8080)、后端API反向代理(:18080) |
| Python | 3.11-slim | 后端API服务运行环境 |
| Node.js | LTS | Docker镜像内前端构建(Dockerfile中安装) |
---
## 系统架构
### 架构设计图
```
┌─────────────────────────────────────────────────────────────────────┐
│ 用户浏览器 │
│ https://bot.qihuyun.com │
└──────────────────────────────┬──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Docker: qihu-aiflow 容器 │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Nginx (:8080) │ │
│ │ / → frontend/dist (静态SPA) │ │
│ │ /app/ → proxy localhost:18080/app/ │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────────────────┐ │
│ │ FastAPI + Uvicorn (:18080) │ │
│ │ ┌───────────┐ ┌──────────┐ ┌───────────┐ │ │
│ │ │ auth │ │ chat │ │ channels │ │ │
│ │ │ knowledge │ │ market │ │ token │ │ │
│ │ │ payment │ │ profile │ │ assets │ │ │
│ │ │ evolution │ │ admin/* │ │ webhook │ │ │
│ │ └───────────┘ └──────────┘ └───────────┘ │ │
│ │ ┌──────────────────────────────────────┐ │ │
│ │ │ 平台适配器层 │ │ │
│ │ │ feishu│wechat│dingtalk│slack│... │ │ │
│ │ └──────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
└────────────────────────┼─────────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ MySQL 8.0 │ │ Redis 7 │
│ :3306 │ │ :6379 │
│ 容器: qihu- │ │ 容器: qihu- │
│ aiflow-mysql │ │ aiflow-redis │
└──────────────────┘ └──────────────────┘
━━━━━━━━━━━━━━━━ 外部集成 ━━━━━━━━━━━━━━━━
ClawBot API AI模型供应商 微信/支付宝
(微信IM绑定) (OpenAI兼容) (支付网关)
```
### 请求流程
```
用户请求 → Nginx(:8080) → /app/* → Uvicorn(:18080) → FastAPI路由
↓
JWT认证中间件
↓
SQLAlchemy ORM
↙ ↘
MySQL 8.0 Redis 7
```
### 数据隔离
- 所有用户数据查询强制带 `user_id` 过滤,实现物理级数据隔离
- 前后台权限分离:`role: admin` 可访问 `/admin/*` 路由
- API密钥独立管理,支持颁发/撤销
---
## 功能模块
### 前台功能(10大模块)
#### 1. AI聊天
- 多模型对话:支持切换不同AI模型供应商和模型
- 流式输出(SSE):AI回复实时逐字展示
- 知识库关联:选择知识库让AI基于专属知识回答
- Agent/技能调用:Tool-call机制自动调用关联技能
- 会话管理:创建/归档/置顶/搜索会话
- 对话历史:完整的用户-AI对话记录
#### 2. 频道(IM接入)
- 多平台支持:飞书、微信、钉钉、WhatsApp、Telegram、Slack
- **微信**: 对接ClawBot官方API,扫码绑定微信个人号
- 调用 `POST /api/v1/wechat/qrcode` 获取官方二维码
- 轮询 `POST /api/v1/wechat/qrcode/status` 等待扫码确认
- 自动保存 bot_token/ilink_bot_id/baseurl 等凭据
- 支持 `POST /api/v1/wechat/channel_reset` 重置通道
- 统一Webhook端点:`POST /app/api/channels/webhook/{platform}` 接收各平台消息
- AI行为配置:Bot前缀、流式输出、私聊/群聊策略、白名单
- 平台适配器模式:签名验证 + 消息解析 + 统一格式
- 频道成员管理:邀请/移除成员
#### 3. 知识库
- 文档上传管理:PDF/Word/TXT等多格式支持
- 目录组织:多级目录结构管理文档
- AI知识图谱:LLM自动提取实体和关系
- Canvas可视化:节点拖拽、悬停高亮、点击详情
- Pipeline自动构建:知识库变更触发自动更新
#### 4. 应用市场
- 应用浏览:分类展示,详情查看
- 搜索去重:智能搜索,防止重复
- 购买安装:付费后一键安装
- 卸载管理:支持卸载后重新安装(状态重置)
- 后台审核:应用上架审核流程
#### 5. Token驾驶舱
- 消耗概览:今日/累计/日均Token统计
- 用量图表:按模型、日期维度可视化
- CSV导出:消耗明细数据导出下载
- 一键充值:快捷跳转付费中心
- 服务器时间:上海时区(UTC+8)实时同步
#### 6. 付费中心
- 会员订阅:多等级会员体系(基础/高级/企业)
- 增值服务:单项服务购买
- 实名认证:身份信息验证
- 支付集成:微信支付 + 支付宝双渠道
- 自动续费:会员到期自动续订
#### 7. 资产中心
- 文件上传:支持多种格式
- 分类管理:按文件类型自动归类
- 在线预览:Office文档(Word/Excel/PPT) + PDF
- AI图像生成:广告图/配图AI生成
- 去重存储:按ID去重节省空间
#### 8. AI进化
- 进化Pipeline:自动化知识图谱构建流程
- 配置管理:启动/停止/参数调整
- 监控面板:Pipeline状态、进度实时展示
- 自动恢复:系统重启后自动恢复运行中的Pipeline
#### 9. 个人中心
- 用户资料:修改头像、邮箱、手机号
- 密码修改:安全的密码更新
- 会员信息:等级、到期时间查看
#### 10. 消息通知
- 系统通知:平台公告、功能更新
- 应用通知:安装/更新提醒
- 付费通知:订单状态变更
### 后台管理(13大模块)
#### 1. 平台驾驶舱
- 核心指标:用户总数、活跃模型数、今日订单、Token消耗
- Token统计面板:今日/累计/日均消耗,活跃模型数
- 快捷入口:大模型库、网站设置、支付配置、增值服务等
- 操作日志:最近管理员操作记录
- 审计日志:安全审计追踪
#### 2. 用户管理
- 用户列表:搜索、筛选、分页
- 角色管理:admin / user 角色分配
- 状态管理:激活 / 封禁用户
- 用户详情:基本信息、会员状态
#### 3. 大模型库管理
- 模型供应商:添加/编辑AI模型供应商
- 模型配置:模型名称、API地址、API密钥
- 默认模型:设置全局默认使用的模型
#### 4. 供应商管理
- 供应商配置:管理第三方AI服务提供商
- API密钥管理:密钥加密存储
#### 5. 网站基础设置
- 站点信息:网站名称、Logo、描述
- 邮件配置:SMTP服务器 + 测试发送
- 短信配置:短信服务配置
- AI内容生成:AI辅助生成网站文案
#### 6. 支付渠道配置
- 微信支付:商户号、API密钥配置
- 支付宝:AppID、商户私钥配置
#### 7. 增值服务管理
- 服务列表:所有增值服务CRUD
- 定价管理:服务价格设置
- 上下架:控制服务可用状态
#### 8. 会员订阅体系
- 会员等级:多等级定义和权限
- 订阅计划:时长、价格配置
- 自动化规则:会员自动升级/降级
#### 9. 广告位管理
- Banner管理:图片上传、排序
- AI图像生成:AI自动生成广告图
- 投放配置:位置、展示频率
#### 10. 应用市场审核
- 应用审核:审核上架应用
- 优惠券管理:创建/发放优惠券
#### 11. 频道管理
- 频道监控:查看所有频道状态
- 平台配置:各平台接入凭证和AI行为配置
#### 12. 知识库管理
- 知识库概览:查看系统所有知识库
- 文档管理:文档列表和状态
#### 13. 日志与风控
- 操作日志:管理员操作审计
- API调用日志:接口调用统计
- 付费日志:订单和交易记录
---
## Docker 部署手册
### 前置条件
- Docker Engine 20.10+
- Docker Compose v2+
- 可用磁盘空间 ≥ 10GB
- 宿主机端口:12888(前端)、18080(后端)、3306(MySQL)、6379(Redis)
### 方式一:从 Git 仓库部署
## 🚀 快速开始
```bash
# 1. 克隆仓库
git clone http://git.qihuyun.com/qyyfw/qihuaiflow.git
cd qihuaiflow
# 2. 构建并启动所有服务(MySQL + Redis + AIFlow)
docker compose up -d --build
# 2. 配置环境
cp .env.example .env # 编辑 API keys
# 3. 等待服务就绪(首次构建约3-5分钟)
# 查看日志确认启动完成
docker compose logs -f aiflow
# 3. 一行启动
docker-compose up -d
# 4. 访问系统
# 前端: http://<宿主机IP>:12888
# 默认账号: admin / admin123
# 4. 访问
open https://ai.qihuyun.com # 前台
open https://ai.qihuyun.com/admin # 后台
```
### 方式二:从程序包部署
**前置要求**: Docker Compose v2 + 8GB RAM
```bash
# 1. 解压程序包
tar -xzf Qihu-aiflow.tar.gz
cd <解压目录>
---
# 2. 修改环境变量(可选,编辑 .env 或 docker-compose.yml)
# vim .env
## ✨ 核心能力
# 3. 构建并启动
docker compose up -d --build
# 4. 验证
curl http://localhost:12888/ # 前端
curl http://localhost:18080/app/api/health # 后端
```
### Docker Compose 自动执行流程
`docker compose up -d --build` 启动后会自动:
1. **拉取基础镜像**: python:3.11-slim, mysql:8.0, redis:7-alpine
2. **构建AIFlow镜像**:
- 安装系统依赖 (nginx, mysql-client, redis-tools, build-essential)
- 安装 Python 依赖 (requirements.txt, 使用清华镜像源)
- 安装 Node.js + npm,构建前端 `npm install && npm run build`
- 配置 Nginx 反向代理
3. **启动MySQL**: 等待健康检查通过 (mysqladmin ping)
4. **启动Redis**: 密码认证 (requirepass pwd2026)
5. **初始化数据库**:
- 创建数据库 `aiflow` (utf8mb4)
- 按序执行 migrations/ 下 15 个 SQL 迁移脚本
- 智能检测 admin 密码哈希有效性,仅在无效时重置
6. **启动应用**: Nginx(:8080) + Uvicorn(:18080)
### 端口说明
| 端口 | 服务 | 说明 |
| 能力 | 描述 | 状态 |
|------|------|------|
| 12888 | Nginx (前端) | 用户浏览器访问端口 |
| 18080 | Uvicorn (后端API) | API服务端口 |
| 3306 | MySQL | 数据库端口 |
| 6379 | Redis | 缓存端口 |
| 🤖 **AI 对话** | SSE 流式对话、多模型自动路由、Markdown 渲染、代码高亮 | ✅ 生产 |
| 📚 **知识库 RAG** | 文档上传 → 切片 → pgvector 向量化 → BM25+语义检索 | ✅ 生产 |
| 🛒 **技能市场** | 97+ Skill 发现-安装-执行,Meilisearch 中文搜索,14 类 capability | ✅ 生产 |
| 🎨 **媒体生成** | 图片/视频 AI 生成,media relay 中转,微信内联渲染 | ✅ 生产 |
| 📊 **Token 驾驶舱** | 实时用量监控、趋势图表、预警告警、计费追踪 | ✅ 生产 |
| 💰 **会员订阅** | 多套餐对比、微信支付、增值单点付费 | ✅ 生产 |
| 🏪 **应用市场** | 第三方应用集成,产品定价,webhook 回调 | ✅ 生产 |
| 🤖 **Agent 编排** | DAG 图引擎 + Supervisor 模式 | 🔨 迭代中 |
| 📱 **IM Bot** | 微信扫码连接,AI 对话 + 图片生成 | ✅ 生产 |
| 🛡️ **安全纵深** | 四层防护: 限流+扫描器拦截+IP黑名单+JWT认证 | ✅ 生产 |
**注意**: 前端映射到宿主机 12888 端口(而非 8080),用户访问: `http://<IP>:12888`
---
### 环境变量配置
## 🏗️ 架构总览
Docker Compose 环境变量在 `docker-compose.yml` 的 `environment` 段配置:
```
┌──────────────────────────────────────────────────┐
│ React SPA (Vite 5 + Tailwind 4) │
│ Landing | Chat | Knowledge | Market | Agent │
├──────────────────────────────────────────────────┤
│ Nginx (限流 + 扫描器 + SSL) │
├──────────────────────────────────────────────────┤
│ FastAPI uvicorn (Python 3.11) │
│ EngineCore (双 Claw 单引擎) | LLM Router │
├──────────────────────────────────────────────────┤
│ LiteLLM │ MCP SDK │ Casdoor OAuth │
│ (模型网关) │ (工具协议) │ (统一认证) │
├──────────────────────────────────────────────────┤
│ PostgreSQL+pgvector │ Redis │ Meilisearch │
└──────────────────────────────────────────────────┘
```
| 变量 | 说明 | Docker默认值 | 本地默认值 |
|------|------|-------------|-----------|
| `MYSQL_HOST` | MySQL地址 | mysql (容器名) | localhost |
| `MYSQL_PORT` | MySQL端口 | 3306 | 3306 |
| `MYSQL_USER` | MySQL用户名 | root | root |
| `MYSQL_PASSWORD` | MySQL密码 | pwd2026 | pwd2026 |
| `MYSQL_DATABASE` | 数据库名 | aiflow | aiflow |
| `REDIS_HOST` | Redis地址 | redis (容器名) | localhost |
| `REDIS_PORT` | Redis端口 | 6379 | 6379 |
| `REDIS_PASSWORD` | Redis密码 | pwd2026 | pwd2026 |
| `SECRET_KEY` | JWT签名密钥 | (固定值) | (固定值) |
| `APP_ACCESS_URL` | 外网访问地址 | https://bot.qihuyun.com | https://bot.qihuyun.com |
| `DEBUG` | 调试模式 | false | false |
详见 [ARCHITECTURE.md](ARCHITECTURE.md)
**重要**: 生产环境请修改 `SECRET_KEY`、`MYSQL_PASSWORD`、`REDIS_PASSWORD`。
---
### 管理员账号
## 📦 技术栈
部署后默认管理员账号:
| 层 | 技术 |
|----|------|
| 前端 | React 18 + TypeScript 5.6 + Vite 5 + Tailwind CSS 4 + Zustand 5 |
| 后端 | Python 3.11 + FastAPI + SQLAlchemy 2.0 + Pydantic |
| 数据库 | PostgreSQL 18 + pgvector (向量) + Redis 7 (缓存) |
| 认证 | Casdoor (OAuth2/OIDC) + JWT |
| LLM 网关 | LiteLLM (模型路由 + 费用追踪 + 协议转换) |
| 搜索引擎 | Meilisearch v1.11 (BM25 + jieba 中文分词) |
| 可观测 | Prometheus + Grafana + Alloy (OTel) |
| 部署 | Docker Compose (一行启动,自包含) |
| 设计 | qihu-design-system (53 modules, indigo #5B5FED) |
| 用户名 | 密码 | 角色 |
|--------|------|------|
| admin | admin123 | 超级管理员 |
---
> 首次登录后请立即修改密码:进入「个人中心」页面进行修改。
> run.sh 会智能检测密码哈希有效性——如果用户已修改密码(有效bcrypt哈希),则保持不变。
## 🗂️ 项目结构
### 常用命令
```
qihuaiflow/
├── backend/python/app/ # FastAPI 后端
│ ├── api/ # API 路由
│ │ ├── admin/ # 后台管理 API (models/skills/agents/users/...)
│ │ ├── chat.py # AI 对话核心 (SSE流式 + 媒体生成)
│ │ ├── auth.py # JWT 认证
│ │ └── im_llm.py # IM Bot LLM 薄壳
│ ├── core/ # 核心引擎
│ │ ├── llm_router.py # LLM 路由 + 熔断
│ │ ├── claw_adapters.py # 双 Claw 适配器 (SSE/Buffer)
│ │ ├── litellm_sync.py # LiteLLM 模型同步服务
│ │ ├── node_registry.py # Agent 节点注册
│ │ └── capability_discovery.py # 能力发现
│ └── models/ # 数据库模型
├── frontend/ # React 前端
│ └── src/
│ ├── pages/admin/ # 后台页面 (39 modules)
│ ├── pages/front/ # 前台页面
│ ├── components/ # 设计系统组件
│ │ ├── ui/ # Button/Badge/Input/Card
│ │ ├── canvas/ # GenUiCard/StatusRing/ChatComposer
│ │ └── layout/ # AppShell/ThemeToggle
│ └── styles/ # 全局 CSS + chat 样式
├── nginx/ # Nginx 配置文件
├── config/ # LiteLLM + 平台配置
├── migrations/ # 数据库迁移 SQL
├── docker-compose.yml # Docker Compose 编排
├── Dockerfile # 主应用镜像
├── ARCHITECTURE.md # 架构设计文档
└── DESIGN.md # 设计系统规范
```
---
## 🔧 开发指南
### 环境要求
- Docker Compose v2
- Python 3.11+ (本地开发)
- Node.js 22+ (前端开发)
- Git
### 本地开发
```bash
# 查看所有容器状态
docker compose ps
# 后端
cd backend/python
pip install -r requirements.txt
uvicorn main:app --reload --port 18080
# 查看应用日志
docker compose logs -f aiflow
# 前端
cd frontend
npm install
npm run dev # Vite dev server :5173
```
# 查看MySQL日志
docker compose logs -f mysql
### 部署
# 重启应用
docker compose restart aiflow
```bash
# 生产部署
docker-compose up -d --build
# 停止所有服务
docker compose down
# 查看状态
docker-compose ps
docker logs qihu-aiflow --tail 50
# 停止并清除数据(危险!)
docker compose down -v
# 重启
docker restart qihu-aiflow
```
# 重新构建并启动
docker compose up -d --build
### 测试
# 进入应用容器
docker exec -it qihu-aiflow bash
```bash
# 后端测试
docker exec qihu-aiflow bash -c "cd /app/backend/python && .venv/bin/python -m pytest"
# 当前: 165 passed / 2 skipped
# 进入MySQL
docker exec -it qihu-aiflow-mysql mysql -u root -ppwd2026 aiflow
# 进入Redis
docker exec -it qihu-aiflow-redis redis-cli -a pwd2026
# 前端测试
docker exec qihu-aiflow bash -c "cd /app/frontend && npx vitest run"
```
---
## 项目文件结构
## 📊 平台数据
```
/
├── frontend/ # 前端项目 (React + TypeScript)
│ ├── dist/ # 构建产物(Docker构建时生成)
│ ├── public/ # 静态资源
│ ├── src/ # TypeScript源码
│ │ ├── api/ # API客户端 (index.ts)
│ │ ├── components/ # 通用组件 (layout/)
│ │ ├── contexts/ # React上下文
│ │ ├── hooks/ # 自定义Hooks
│ │ ├── mock/ # Mock数据(开发用)
│ │ ├── pages/ # 页面组件
│ │ │ ├── admin/ # 后台管理页面
│ │ │ └── front/ # 前台页面
│ │ ├── styles/ # 全局样式
│ │ └── types/ # TypeScript类型定义
│ ├── package.json # 前端依赖配置
│ ├── package-lock.json # 依赖锁文件
│ └── vite.config.ts # Vite构建配置
├── backend/python/ # 后端项目 (Python FastAPI)
│ ├── main.py # 应用入口 (FastAPI app)
│ ├── requirements.txt # Python依赖列表
│ └── app/ # 应用核心代码
│ ├── api/ # API路由模块
│ │ ├── auth.py # 认证 (login/register)
│ │ ├── chat.py # AI聊天 (SSE流式)
│ │ ├── channels.py # IM频道管理 (ClawBot对接)
│ │ ├── knowledge.py # 知识库管理
│ │ ├── market.py # 应用市场
│ │ ├── token_usage.py # Token统计
│ │ ├── payment.py # 付费中心
│ │ ├── profile.py # 个人中心
│ │ ├── assets.py # 资产管理
│ │ ├── evolution.py # AI进化
│ │ ├── notifications.py # 消息通知
│ │ └── admin/ # 后台管理API
│ │ ├── dashboard.py # 驾驶舱
│ │ ├── users.py # 用户管理
│ │ ├── models.py # 模型管理
│ │ ├── settings.py # 网站设置
│ │ ├── payment_admin.py # 支付配置
│ │ ├── services.py # 增值服务
│ │ ├── membership_admin.py # 会员管理
│ │ ├── ads.py # 广告管理
│ │ ├── market_admin.py # 应用审核
│ │ └── logs.py # 日志风控
│ ├── models.py # SQLAlchemy数据模型 (全部表定义)
│ ├── database.py # 数据库连接配置
│ ├── config.py # 应用配置
│ ├── auth.py # JWT认证 & 权限控制
│ ├── middleware/ # 中间件
│ │ ├── log_middleware.py # API追踪中间件
│ │ └── log_service.py # 操作日志服务
│ ├── services/ # 业务服务
│ │ └── evolution_service.py # 进化Pipeline服务
│ ├── platform_adapter.py # 平台适配器基类
│ └── platform_adapters/ # 各平台适配器实现
│ ├── feishu_adapter.py
│ ├── wecom_adapter.py
│ ├── dingtalk_adapter.py
│ ├── whatsapp_adapter.py
│ ├── telegram_adapter.py
│ └── slack_adapter.py
├── migrations/ # 数据库迁移脚本 (001-015)
│ ├── 001_init.sql # 初始化表结构
│ ├── 002_init_data.sql # 初始数据
│ ├── ...
│ └── 015_production_fixes.sql # 生产修复
├── docs/ # 项目文档
│ ├── PRD.md # 产品需求文档
│ ├── PRD-self-evolution.md # AI自进化PRD
│ ├── PRD-self-evolution-v2.md # AI自进化PRD v2
│ └── frontend-dev-record.md # 前端开发记录
├── Dockerfile # Docker镜像构建文件
├── docker-compose.yml # Docker Compose编排配置
├── nginx.conf # Nginx反向代理配置
├── run.sh # 一键启动脚本
├── .env # 环境变量配置
├── .gitignore # Git忽略配置
├── .dockerignore # Docker构建忽略配置
├── VERSION # 版本信息
├── CHANGELOG.md # 变更日志
└── README.md # 项目文档(本文件)
```
---
## API接口概览
所有接口以 `/app/api/` 为前缀,完整OpenAPI文档访问: `http://<host>:18080/docs`
| 模块 | 基础路径 | 主要接口 |
|------|---------|---------|
| 认证 | `/app/api/auth` | login, register, logout, me |
| AI聊天 | `/app/api/chat` | conversations CRUD, messages, SSE流式 |
| 频道 | `/app/api/channels` | channels CRUD, webhook, 扫码授权, 状态轮询 |
| 知识库 | `/app/api/knowledge` | bases CRUD, documents, directories, graph |
| 应用市场 | `/app/api/market` | apps, install, uninstall |
| Token | `/app/api/token` | overview, stats, export CSV |
| 付费 | `/app/api/payment` | plans, orders, realname verification |
| 资产 | `/app/api/assets` | files CRUD, upload, preview |
| 进化 | `/app/api/evolution` | configs, pipeline status |
| 后台-仪表盘 | `/app/api/admin/dashboard` | stats, charts |
| 后台-用户 | `/app/api/admin/users` | users CRUD |
| 后台-模型 | `/app/api/admin/models` | providers, models CRUD |
| 后台-设置 | `/app/api/admin/settings` | site, email, sms config |
| 后台-支付 | `/app/api/admin/payment` | payment channel config |
| 后台-日志 | `/app/api/admin/logs` | operation, api, payment logs |
---
## 数据库迁移
| 编号 | 说明 |
| 指标 | 数值 |
|------|------|
| 001 | 初始化表结构(users, conversations, messages, channels等) |
| 002 | 初始数据(admin账号, 默认模型供应商, 种子数据) |
| 003 | 增值服务表 |
| 004 | OAuth状态字段 |
| 005 | 频道扩展字段(account_name, features) |
| 006 | API调用统计表 |
| 007 | 进化系统基础表 |
| 008 | 进化系统v2(EvolutionWatcher状态管理) |
| 009 | 支付实名认证字段 |
| 010 | 默认模型字段 |
| 011 | 知识库存储与资产表 |
| 012 | 生产环境列同步(幂等补全) |
| 013 | 知识图谱表(节点/边/构建记录) |
| 014 | 测试数据清理 |
| 015 | 生产修复(content列 + AppPurchase状态重置) |
| LLM 模型数 | 14 (含 OCR/Rerank/Embedding/TTS/ASR) |
| 技能数 | 97 |
| MCP 工具数 | 26 |
| 后台管理页面 | 39 |
| API 端点 | 300+ |
| 容器数 | 12 |
| 代码行数 | 50,000+ |
---
## 更新日志
## 🌐 线上环境
详见 `VERSION` 和 `CHANGELOG.md`。
| 资源 | 地址 |
|------|------|
| 前台 | https://ai.qihuyun.com |
| 后台 | https://ai.qihuyun.com/admin |
| Grafana | http://210.16.161.218:3000 |
| Prometheus | http://210.16.161.218:9090 |
| Casdoor | http://210.16.161.218:8000 |
| Git 仓库 | http://git.qihuyun.com/qyyfw/qihuaiflow |
### v2.0.1 (2026-06-02)
---
- 微信ClawBot官方API标准重构(频道扫码绑定)
- 6项生产缺陷修复(二维码、知识库、tool-call、应用安装、时区、邮件)
- 完整项目文档(README.md)
- Gitea仓库发布
## 📝 更新日志
详见 [CHANGELOG.md](CHANGELOG.md) 和 [RELEASES.md](RELEASES.md)
**v3.0 重点更新 (2026-08-12)**:
- ✅ 修复 `/admin/models` 空白页 (React hooks + 缓存问题)
- ✅ 实现 LiteLLM 模型同步 (管理员 UI → LiteLLM REST API)
- ✅ 实现 media_relay 端点 (微信 Bot 图片中转)
- ✅ 设计系统全量收口 + ErrorBoundary + safeLazy
- ✅ Docker Compose STORE_MODEL_IN_DB 模式
- ✅ Nginx 限流 + 扫描器拦截 + IP 黑名单
---
## 📄 许可证
私有项目。© 2026 七虎AI. All rights reserved.
+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 里指定。
@@ -284,7 +284,7 @@ async def claim_achievement_reward(
# Mark reward as claimed
_execute_raw(db,
"UPDATE user_achievements SET reward_claimed = 1, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
"UPDATE user_achievements SET reward_claimed = true, claimed_at = NOW() WHERE user_id = :uid AND achievement_id = :aid",
{"uid": user_id, "aid": ach_id})
return {"code": 0, "message": "奖励领取成功"}
@@ -316,7 +316,7 @@ async def create_dashboard(
config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]')
if is_default:
db.execute(text("UPDATE analytics_dashboards SET is_default=0"))
db.execute(text("UPDATE analytics_dashboards SET is_default=false"))
db.execute(text("""
INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by)
@@ -332,7 +332,7 @@ async def create_dashboard(
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)):
"""删除仪表盘"""
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id})
db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=false"), {"id": dashboard_id})
db.commit()
return {"code": 200, "data": {"message": "仪表盘已删除"}}
@@ -445,7 +445,7 @@ async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_
SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev
FROM billing_transactions
WHERE transaction_type='recharge' AND status='completed'
AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
AND created_at >= NOW() - INTERVAL '7 DAY'
GROUP BY DATE(created_at) ORDER BY d
""")).fetchall()
daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d]
@@ -103,7 +103,7 @@ async def get_execution_stats(db: Session = Depends(get_db), _=Depends(get_curre
try:
total = _execute_raw(db, "SELECT COUNT(*) as cnt FROM code_exec_tasks")[0]["cnt"]
today = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURDATE()")[0]["cnt"]
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE DATE(created_at) = CURRENT_DATE")[0]["cnt"]
success_count = _execute_raw(db,
"SELECT COUNT(*) as cnt FROM code_exec_tasks WHERE exit_code = 0 AND error IS NULL")[0]["cnt"]
failed_count = _execute_raw(db,
+1 -1
View File
@@ -703,7 +703,7 @@ echo "Installation complete!"
{d.get('version', '1.0.0')}
## Installation
Run `bash install.sh` to install.
Run \"bash install.sh\" to install.
## License
A valid license key is required. Contact your administrator for details.
+18 -10
View File
@@ -14,7 +14,15 @@ import hmac
import time
import threading
import requests
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
# #976 全平台统一使用北京时间(容器 UTC,前端按 +08:00 展示)
_TZ_BEIJING = timezone(timedelta(hours=8))
def _beijing_now():
"""返回带时区的北京时间(aware datetime,存 PG timestamptz 不误解)。"""
return datetime.now(_TZ_BEIJING)
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -169,7 +177,7 @@ async def get_im_bot_stats(user=Depends(get_current_user)):
today_stats = _row_to_dict(rows[0]) if rows else {"msg_in": 0, "msg_out": 0, "users": 0, "errors": 0}
# Total routing rules
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=1"))[0]._mapping['cnt']
routes_count = _execute_raw(db, text("SELECT COUNT(*) as cnt FROM im_bot_routes WHERE is_active=true"))[0]._mapping['cnt']
return {
"success": True,
@@ -202,7 +210,7 @@ async def get_dashboard(user=Depends(get_current_user)):
SUM(messages_out) as msg_out,
SUM(unique_users) as users
FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
WHERE stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
GROUP BY stat_date
ORDER BY stat_date
"""))
@@ -217,7 +225,7 @@ async def get_dashboard(user=Depends(get_current_user)):
LEFT JOIN (
SELECT channel_id, SUM(messages_in + messages_out) as msg_count
FROM im_bot_daily_stats
WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE stat_date >= CURRENT_DATE - INTERVAL '30 DAY'
GROUP BY channel_id
) s ON c.id = s.channel_id
ORDER BY c.created_at DESC
@@ -357,7 +365,7 @@ async def get_channel_detail(channel_id: str, user=Depends(get_current_user)):
SELECT stat_date, messages_in, messages_out, unique_users, error_count
FROM im_bot_daily_stats
WHERE channel_id = :cid
AND stat_date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND stat_date >= CURRENT_DATE - INTERVAL '7 DAY'
ORDER BY stat_date
"""), {"cid": channel_id})
channel['stats_7d'] = _rows_to_dict(stats)
@@ -428,7 +436,7 @@ async def activate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal()
try:
_execute_raw(db, text(
"UPDATE channels SET is_active=1, status='connected' WHERE id=:cid"
"UPDATE channels SET is_active=true, status='connected' WHERE id=:cid"
), {"cid": channel_id})
return {"success": True, "message": "Channel activated"}
except Exception as e:
@@ -443,7 +451,7 @@ async def deactivate_channel(channel_id: str, user=Depends(get_current_user)):
db = SessionLocal()
try:
_execute_raw(db, text(
"UPDATE channels SET is_active=0, status='disconnected' WHERE id=:cid"
"UPDATE channels SET is_active=false, status='disconnected' WHERE id=:cid"
), {"cid": channel_id})
return {"success": True, "message": "Channel deactivated"}
except Exception as e:
@@ -773,13 +781,13 @@ async def wechat_qrcode_status(qrcode: str = Query(...), user=Depends(get_curren
(bot_token, ilink_bot_id, baseurl, status, connected_at, created_at, updated_at)
VALUES (:bot_token, :ilink_bot_id, :baseurl, 'connected', :connected_at, :created_at, :updated_at)"""),
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
"connected_at": datetime.now(), "created_at": datetime.now(), "updated_at": datetime.now()})
"connected_at": _beijing_now(), "created_at": _beijing_now(), "updated_at": _beijing_now()})
else:
_execute_raw(db, text("""UPDATE im_bot_wechat_account SET
bot_token=:bot_token, ilink_bot_id=:ilink_bot_id, baseurl=:baseurl,
status='connected', connected_at=:connected_at, updated_at=:updated_at WHERE id=:id"""),
{"bot_token": bot_token, "ilink_bot_id": ilink_bot_id, "baseurl": baseurl,
"connected_at": datetime.now(), "updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
"connected_at": _beijing_now(), "updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
row = _wechat_row(db)
aid = _row_to_dict(row)["id"] if row else 1
_start_wechat_receiver(aid)
@@ -821,7 +829,7 @@ async def wechat_disconnect(user=Depends(get_current_user)):
row = _wechat_row(db)
if row:
_execute_raw(db, text("UPDATE im_bot_wechat_account SET bot_token=NULL, status='disconnected', updated_at=:updated_at WHERE id=:id"),
{"updated_at": datetime.now(), "id": _row_to_dict(row)["id"]})
{"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"]})
return {"success": True, "message": "已断开个人微信连接"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+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 {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
import asyncio as _aio
tools = _aio.run(_lmt(sd, timeout=15))
# raise_on_error=True 让网络异常透传,暴露真实错误而非 "no tools found"
tools = _aio.run(_lmt(sd, timeout=15, raise_on_error=True))
result["success"] = bool(tools)
result["details"]["tools_count"] = len(tools)
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
if tools:
result["message"] = f"Connected — {len(tools)} tools available"
else:
result["message"] = "Connected but no tools found (server returned empty list)"
result["details"]["status_code"] = 200
if tools:
server.tools_count = len(tools)
server.status = "connected"
except Exception as e:
result["message"] = f"Connection failed: {str(e)}"
# 把真实异常(类型+消息)透传给前端,告别误导性的 "Connected but no tools found"
err_type = type(e).__name__
err_msg = str(e)[:300]
result["message"] = f"{err_type}: {err_msg}"
result["details"]["error_type"] = err_type
result["details"]["error"] = err_msg
if "ConnectError" in err_type or "Timeout" in err_type:
result["details"]["hint"] = "网络层无法到达 MCP 服务器,请检查代理配置或服务端 URL"
elif "404" in err_msg or "NotFound" in err_type:
result["details"]["hint"] = "服务端 URL 不存在或路径错误"
elif "401" in err_msg or "Unauthorized" in err_type:
result["details"]["hint"] = "认证失败:检查请求头中的 Authorization"
else:
result["details"]["hint"] = "检查认证头/transport_type 或服务端状态"
elif transport == "sse":
+44
View File
@@ -8,6 +8,7 @@ from typing import List, Optional
from pydantic import BaseModel
from ...database import get_db, SessionLocal
from ...models import AIModel, ModelProvider, SiteSetting
from ...models.litellm_sync import sync_create_model, sync_update_model, sync_delete_model, sync_health_check
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from ...capability_registry import (
@@ -475,6 +476,21 @@ async def create_model(
db.add(model)
db.commit()
# #965: LiteLLM model sync
import threading
def _sync_create():
try:
from ...models.litellm_sync import sync_create_model
litellm_model = "openai/" + data.name
api_base = provider.api_base_url if provider else data.endpoint or ""
api_key = data.api_key or (provider.api_key if provider else "") or ""
if api_base:
sync_create_model(data.name, litellm_model, api_base.rstrip("/"), api_key)
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[litellm-sync] create failed: {e}")
threading.Thread(target=_sync_create, daemon=True).start()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
@@ -588,6 +604,24 @@ async def update_model(
model.updated_at = datetime.now()
db.commit()
# #965: LiteLLM model sync
import threading
def _sync_update():
try:
from ...models.litellm_sync import sync_update_model
litellm_model = "openai/" + (data.name if data.name else model.name)
provider = db.query(ModelProvider).filter(ModelProvider.id == model.provider_id).first()
api_base = provider.api_base_url if provider else model.endpoint or ""
api_key = data.api_key if data.api_key else (provider.api_key if provider else "") or ""
old_name = model.name
new_name = data.name if data.name else old_name
if api_base:
sync_update_model(old_name, new_name, litellm_model, api_base.rstrip("/"), api_key)
except Exception as e:
import logging
logging.getLogger("aiflow").warning(f"[litellm-sync] update failed: {e}")
threading.Thread(target=_sync_update, daemon=True).start()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
@@ -612,6 +646,16 @@ async def delete_model(
raise HTTPException(status_code=404, detail="模型不存在")
model_name = model.name
# #965: 同步删除到 LiteLLM(异步)
delete_model_name = model.name
import threading
def _sync_delete():
try:
sync_delete_model(delete_model_name)
except Exception as e:
logging.getLogger("aiflow").warning(f"[litellm-sync] delete failed for {delete_model_name}: {e}")
threading.Thread(target=_sync_delete, daemon=True).start()
db.delete(model)
db.commit()
@@ -442,7 +442,7 @@ def revoke_license(
raise HTTPException(404, "License not found")
_execute_raw(db, text(
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = 0 WHERE id = :lid"
"UPDATE offline_licenses SET status = 'revoked', revoked_reason = :reason, is_active = false WHERE id = :lid"
), {"reason": reason, "lid": license_id})
return {"code": 0, "message": "License revoked"}
@@ -623,7 +623,7 @@ def get_usage_stats(db: Session = Depends(get_db)):
"SELECT COUNT(DISTINCT user_hash) FROM usage_reports"
))
today_reports = _execute_raw(db, text(
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURDATE()"
"SELECT COUNT(*) FROM usage_reports WHERE DATE(created_at) = CURRENT_DATE"
))
by_type = _execute_raw(db, text(
"SELECT report_type, COUNT(*) as cnt FROM usage_reports GROUP BY report_type"
+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 ..mcp_client import list_mcp_tools, call_mcp_tool
from ..core.claw_adapters import SseAdapter
from ..middleware.log_service import LogService
@@ -142,6 +143,7 @@ from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRe
from app.api.chat_service import _send_message_core
from app.core.llm_router import set_route_ctx
@@ -153,6 +155,17 @@ _MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com")
# Media relay client (trust_env=False to bypass proxy for image fetches)
_media_client = None
def _get_media_client():
global _media_client
if _media_client is None:
_media_client = httpx.Client(timeout=30.0, follow_redirects=True, trust_env=False)
return _media_client
@router.get("/media/relay")
@@ -195,6 +208,42 @@ async def media_relay(url: str = Query(...)):
"""
import urllib.parse as _up
import httpx
from fastapi.responses import StreamingResponse
host = (_up.urlparse(url).hostname or "").lower()
# 白名单域名检查
allowed = ("siliconflow.cn", "s3.siliconflow.cn", "aliyuncs.com", "tencentcos.cn",
"qpic.cn", "alicdn.com", "api.openai.com", "myqcloud.com")
if not any(a in host for a in allowed):
raise HTTPException(status_code=400, detail="unsupported media host")
try:
client = _get_media_client()
resp = client.get(url, timeout=30.0, follow_redirects=True)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"upstream returned {resp.status_code}")
ct = resp.headers.get("content-type", "application/octet-stream")
cl = resp.headers.get("content-length")
headers = {}
if ct:
headers["content-type"] = ct
if cl:
headers["content-length"] = cl
headers["cache-control"] = "public, max-age=3600"
return StreamingResponse(
resp.iter_bytes(chunk_size=65536),
status_code=200,
headers=headers,
media_type=ct
)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="upstream timeout")
except Exception as e:
raise HTTPException(status_code=502, detail=f"relay error: {str(e)[:200]}")
@@ -1559,6 +1608,12 @@ async def send_message(
from app.core.observability import span
# #876 双 Claw 维度:入口最早注入,确保外层 span 即带 tenant_id + claw_type 标签
set_route_ctx(
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
claw_type="web",
)
async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)):
return await _send_message_core(conv_id, req, db, current_user)
@@ -1570,38 +1625,44 @@ async def send_message_stream(
req: SendMessageRequest,
current_user: User = Depends(get_current_user),
):
"""SSE 流式聊天端点(Phase 0)。
与普通 /messages 共用 _send_message_core,仅通过 stream=True + on_event
回调实时吐出增量(delta/reasoning),末尾推 done 事件(含 message_id)。
"""SSE 流式聊天端点(#873)。
与 /messages 共用 EngineCore(_send_message_core),仅通过 stream=True +
on_event=SseAdapter 实时吐出增量(delta/reasoning/step),末尾推 done 终帧
(含 message_id 等)。SseAdapter 保证线格式与改造前逐字节一致。
"""
db = SessionLocal()
try:
queue = asyncio.Queue()
# #876 双 Claw 维度:入口最早注入。runner() 经 asyncio.create_task 派生子协程,
# contextvars 自动随上下文传播,使 _send_message_core 内所有 span 均带 tenant_id + claw_type。
set_route_ctx(
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
claw_type="web",
)
async def on_event(e):
await queue.put(e)
adapter = SseAdapter(
trace_id=f"sse-{conv_id}",
tenant_id=str(getattr(current_user, "tenant_id", "") or ""),
)
async def runner():
try:
await _send_message_core(
conv_id, req, db, current_user,
stream=True, on_event=on_event,
stream=True, on_event=adapter,
)
except Exception as exc:
logger.warning(f"[sse] chat stream error: {exc}", exc_info=True)
await queue.put({"type": "error", "message": str(exc)})
await adapter.emit_error(str(exc))
finally:
await queue.put({"type": "__end__"})
await adapter.close()
task = asyncio.create_task(runner())
async def event_gen():
try:
while True:
e = await queue.get()
if e.get("type") == "__end__":
break
yield f"data: {json.dumps(e, ensure_ascii=False)}\n\n"
async for frame in adapter.frames():
yield frame
finally:
await task
db.close()
@@ -1611,10 +1672,6 @@ async def send_message_stream(
db.close()
raise
@router.post("/conversations/{conv_id}/clear")
@@ -2655,7 +2712,7 @@ def get_models(db: Session = Depends(get_db)):
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
models = db.query(AIModel).filter(AIModel.is_enabled == True).order_by(AIModel.is_default.desc(), AIModel.name.asc()).all()
+22 -4
View File
@@ -1164,6 +1164,26 @@ def _build_system_prompt(
system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"]
# === Agent 工具使用标准指引(对标 OpenAI GPT-4.1 / Anthropic ReAct) ===
system_parts.append("""【工具使用规则——请严格遵守】
1. 持久性:请持续工作直到用户的问题完全解决,不要中途放弃。
如果一次操作未得到期望结果,请尝试替代方法或不同参数组合,直到成功。
2. 工具优先:如果你不确定某个信息,或需要获取外部数据、实时信息来完成用户请求,
请务必使用工具来获取——绝对不要猜测或编造答案。
3. 规划与反思:调用工具前,先思考需要什么信息、按什么顺序调用。结果返回后,
检查是否符合预期;不符合则调整计划重试。
4. 何时不调用工具:纯闲聊/问候/简单常识性问题/用户明确要求不要用工具时。
5. 工具调用顺序:先获取信息的工具(搜索/查询),再执行操作的工具(创建/修改)。
不要跳过必要的前置查询步骤。
6. 工具结果反馈:工具返回的结果是客观事实,请忠实地引用,不要虚构或修改。
""")
@@ -1251,7 +1271,7 @@ def _build_system_prompt(
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 \"\"\" 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
@@ -2536,7 +2556,6 @@ def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40,
- 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。
- exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。
"""
ensure_structured_column()
summary_block = None
if include_summary:
try:
@@ -2707,7 +2726,7 @@ def ensure_structured_column():
with engine.connect() as conn:
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
"WHERE table_schema = CURRENT_DATABASE() AND table_name='conversation_summaries' "
"AND column_name='structured_memory'"
)).scalar()
if not exists:
@@ -2832,7 +2851,6 @@ async def _maybe_compress_history(conv_id):
失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。
"""
ensure_structured_column()
sdb = None
try:
sdb = SessionLocal()
+13 -4
View File
@@ -3,7 +3,8 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from sqlalchemy import and_, desc, asc, func, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
from pydantic import BaseModel, Field
from typing import Optional
import uuid
@@ -505,7 +506,11 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
api_key = text_model.api_key or (provider.api_key if provider else None)
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
if text_model.endpoint and "qihu-aiflow-litellm" in text_model.endpoint:
api_key = "sk-litellm-master-key-aiflow-2026"
else:
api_key = text_model.api_key or (provider.api_key if provider else None)
@@ -893,7 +898,7 @@ def _build_media_route(db, capability: str, preferred_id: str = None):
others = db.query(_AM).filter(
_AM.is_enabled == True,
func.json_contains(_AM.capabilities, f'"{capability}"'),
_AM.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
).order_by(_AM.is_default.desc(), _AM.created_at.asc()).all()
for m in others:
_add(m)
@@ -935,7 +940,11 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_key = ai_model.api_key or (provider.api_key if provider else None)
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
if ai_model.endpoint and "qihu-aiflow-litellm" in ai_model.endpoint:
api_key = "sk-litellm-master-key-aiflow-2026"
else:
api_key = ai_model.api_key or (provider.api_key if provider else None)
+152 -12
View File
@@ -1,3 +1,4 @@
from app.phase2_risk import TOOL_RISK_LEVELS, assess_tool_risk, create_approval_request, check_approval_status, approve_or_reject, DEFAULT_RISK_LEVELS
# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig).
@@ -47,6 +48,7 @@ import json
import asyncio
import threading
@@ -191,6 +193,23 @@ from ..middleware.log_service import LogService
from ..capability_registry import resolve_auto_model
try:
from ..core.llm_router import (
resolve_api_key as _llm_resolve_api_key,
route as _llm_route,
set_route_ctx as _set_route_ctx,
SHADOW as _LLM_ROUTER_SHADOW,
)
except Exception as _llm_router_err:
import os as _os, logging as _logging
_logging.getLogger(__name__).warning(f"[chat] llm_router import failed, router disabled: {_llm_router_err}")
def _llm_resolve_api_key(api_url, model_api_key, provider_api_key):
if api_url and "qihu-aiflow-litellm" in api_url:
return _os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
return model_api_key or provider_api_key
_llm_route = None
_set_route_ctx = None
_LLM_ROUTER_SHADOW = True
@@ -1611,6 +1630,19 @@ async def _send_message_core(
except Exception:
pass
# Phase 1: Bill tokens on stream completion
try:
_pt = prompt_tokens if "prompt_tokens" in dir() else 0
_ct = completion_tokens if "completion_tokens" in dir() else 0
_uid = user_id if "user_id" in dir() else "unknown"
if _pt or _ct:
asyncio.create_task(_bill_tokens(
_uid, _pt, _ct,
ai_model if "ai_model" in dir() else "unknown",
conv_id))
except Exception:
pass
return {
@@ -2168,6 +2200,14 @@ async def _send_message_core(
from app.core.orchestrator import run_graph
# #874 双 Claw 对齐:编排过程以统一 EngineEvent 下发,
# 租户/通道标签复用 #875 的 ContextVar,避免再加一层参数透传。
try:
from app.core.llm_router import get_route_ctx as _get_route_ctx
_rctx = _get_route_ctx()
except Exception:
_rctx = {}
@@ -2196,6 +2236,9 @@ async def _send_message_core(
trace_id=get_trace_id(),
on_event=on_event,
claw_type=(_rctx.get("claw_type") or "web"),
tenant_id=(_rctx.get("tenant_id") or ""),
@@ -2805,6 +2848,16 @@ async def _send_message_core(
_try_models.extend([m for m in _fallbacks if m not in _try_models])
# ===== LLM Router(#875 影子模式)=====
try:
if _llm_route is not None:
_set_route_ctx(tenant_id=getattr(current_user, "tenant_id", "") or "", claw_type="web")
_rm = _llm_route("text", db=db, legacy_primary=_try_models[0].name if _try_models else None)
if not _LLM_ROUTER_SHADOW and _rm.models:
_try_models = _rm.models
except Exception as _e:
logger.warning(f"[chat] LLM Router 观测失败(降级忽略): {_e}")
_retry_log = []
_all_failed = False
@@ -2879,6 +2932,13 @@ async def _send_message_core(
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
logger.info("[chat] Override media-cap model %s -> text (no media intent in: %.50s)", ai_model.name, req.content)
ai_model = resolve_auto_model("text", db)
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
if ai_model and ("image" in caps or "video" in caps):
@@ -3040,6 +3100,7 @@ async def _send_message_core(
api_key = ai_model.api_key or (provider.api_key if provider else None)
auth_header = provider.auth_header if provider and hasattr(provider, "auth_header") else None
@@ -3057,6 +3118,13 @@ async def _send_message_core(
api_url = ai_model.endpoint
# 2026-07-29 fix: 调用 LiteLLM 端点时,必须用 master key (sk-litellm-...) 而非模型自身 key (tp-c...)
# LiteLLM 内置每个 model 的真实 key 在 litellm_config.yaml,chat 不应直接传模型的 key
api_key = _llm_resolve_api_key(api_url, api_key, provider.api_key if provider else None)
if api_url and "qihu-aiflow-litellm" in api_url:
auth_header = None # 用默认 Authorization: Bearer
logger.info("[chat] LiteLLM endpoint detected, using master key instead of model key")
@@ -3395,7 +3463,9 @@ async def _send_message_core(
_fname = f"mcp_tool_{mcp_idx}"
_sname = re.sub(r'[^A-Za-z0-9_-]', '_', _srv.get("name","unknown")[:30])
_tname = re.sub(r'[^A-Za-z0-9_-]', '_', _t.get("name","tool")[:50])
_fname = f"mcp__{_sname}__{_tname}__{mcp_idx}"
@@ -3460,6 +3530,7 @@ async def _send_message_core(
_schema = {"type": "object", "properties": {}}
_schema = _normalize_mcp_schema(_schema)
@@ -4354,11 +4425,15 @@ async def _send_message_core(
if isinstance(function_definitions, list):
function_definitions.append({
"name": "install_app",
"description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。",
"description": "安装新的技能(Skill)、MCP 服务或外部 Agent 到平台。\n"
"\n"
"【何时调用】用户明确提供链接并表示要安装/导入/添加时。例如:'帮我安装这个技能 https://github.com/...'\n"
"【何时不调用】用户只询问安装方法、讨论某个工具但没有提供可安装链接时。\n"
"【注意】安装完成后该技能/MCP/Agent 可在本对话中直接使用,无需额外配置。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"},
"url": {"type": "string", "description": "要安装的资源完整地址。必须是 http/https URL,支持 GitHub 仓库、mcp.so、modelscope 等平台链接。"},
"type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"},
},
"required": ["url"],
@@ -4561,7 +4636,7 @@ async def _send_message_core(
max_tool_rounds = 5
max_tool_rounds = 15 # 对齐 GPT-4.1/Anthropic 标准: 足够完成复杂多步工具调用
@@ -4814,6 +4889,7 @@ async def _send_message_core(
proxy=_proxy_for_url(api_url),
timeout=ai_model.timeout or 120,
stream=True,
auth_header=auth_header,
):
if _kind == "content":
_sc += _text
@@ -4831,6 +4907,7 @@ async def _send_message_core(
request_body,
proxy=_proxy_for_url(api_url),
timeout=ai_model.timeout or 120,
auth_header=auth_header,
)
except Exception as _exc:
@@ -5189,7 +5266,7 @@ async def _send_message_core(
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None
_mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp__") else None
@@ -5221,7 +5298,7 @@ async def _send_message_core(
tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}"
tool_result = f"[策略拦截: {_policy_check.get('message', _reason)}] 建议: 检查权限设置或新开对话"
@@ -5373,7 +5450,7 @@ async def _send_message_core(
if func_name.startswith("mcp_tool_"):
if func_name.startswith("mcp__"):
@@ -5397,7 +5474,7 @@ async def _send_message_core(
tool_result = "[MCP 工具未找到或映射缺失]"
tool_result = "[MCP 工具未找到] 建议: 检查工具名或尝试其他MCP工具。可用: " + str(list(mcp_tool_map.keys())[:5])
@@ -7927,7 +8004,7 @@ async def _send_message_core(
asyncio.create_task(
_launch_media_task_bg(ai_msg.id, ai_model.id, req.content, current_user.id)
@@ -7943,7 +8020,6 @@ async def _send_message_core(
_generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id)
@@ -7959,7 +8035,6 @@ async def _send_message_core(
)
@@ -8397,6 +8472,35 @@ def _resolve_agent_tool_policy(agents):
def _normalize_mcp_schema(schema: dict) -> dict:
"""MCP JSON Schema -> OpenAI function calling 兼容。
处理: $ref, oneOf/anyOf -> first option, 剥离非标关键字"""
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
if "$ref" in schema:
return {"type": "object", "properties": {}}
if "oneOf" in schema or "anyOf" in schema:
opts = schema.get("oneOf") or schema.get("anyOf") or []
if opts and isinstance(opts[0], dict):
return _normalize_mcp_schema(opts[0])
return {"type": "object", "properties": {}}
if "allOf" in schema:
merged = {"type": "object", "properties": {}}
for s in schema.get("allOf", []):
sub = _normalize_mcp_schema(s)
if sub.get("properties"):
merged["properties"].update(sub["properties"])
return merged
result = {}
for k in ("type", "properties", "required", "description", "enum", "items"):
if k in schema:
result[k] = schema[k]
if result.get("type") != "object":
result["type"] = "object"
if "properties" not in result:
result["properties"] = {}
return result
def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count):
@@ -8442,7 +8546,7 @@ def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_co
is_mcp = func_name.startswith("mcp_tool_")
is_mcp = func_name.startswith("mcp__")
@@ -8547,3 +8651,39 @@ def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_co
# 后台线程独立事件循环运行媒体生成,确保 SSE 响应结束后任务不被 uviorn 取消,
# 避免图片/视频永远停留在「生成中…」。_generate_media_task 内部自管 DB 会话。
def _launch_media_task_bg(ai_msg_id, ai_model_id, prompt, user_id):
def _run():
try:
asyncio.run(_generate_media_task(ai_msg_id, ai_model_id, prompt, user_id))
except Exception:
pass
_t = threading.Thread(target=_run, daemon=True)
_t.start()
# Phase 1: Token billing
async def _bill_tokens(user_id, prompt_tokens, completion_tokens, model_name, conv_id):
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
total = prompt_tokens + completion_tokens
cost = round(total * 0.002 / 1000, 6)
db.execute(text("UPDATE user_balances SET balance = balance - :c, total_used = total_used + :c, updated_at = NOW() WHERE user_id = :u AND balance >= :c"), {"c": cost, "u": user_id})
db.execute(text("INSERT INTO token_usage_logs (id, user_id, model_name, conversation_id, prompt_tokens, completion_tokens, total_tokens, cost, created_at) VALUES (gen_random_uuid(), :u, :m, :cv, :pt, :ct, :tt, :c, NOW())"), {"u": user_id, "m": model_name, "cv": conv_id, "pt": prompt_tokens, "ct": completion_tokens, "tt": total, "c": cost})
db.commit()
db.close()
except Exception:
pass
async def get_user_balance(user_id):
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
r = db.execute(text("SELECT balance, total_used FROM user_balances WHERE user_id = :u"), {"u": user_id}).fetchone()
db.close()
if r: return {"balance": float(r[0]), "total_used": float(r[1])}
return {"balance": 10.0, "total_used": 0}
+110 -1
View File
@@ -1272,7 +1272,7 @@ def _execute_skill_tool(
return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。"
return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。如有已加载的技能,可使用其名称直接调用。"
@@ -1740,3 +1740,112 @@ def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto")
db.commit()
return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。"
# Phase 1: Enhanced SKILL.md parser with 9-block standard
SKILLMD_SCHEMA = {
"name": "name",
"version": "version",
"author": "author",
"description": "description",
"capabilities": "capabilities",
"inputs": "inputs",
"outputs": "outputs",
"permissions": "permissions",
"dependencies": "dependencies",
"pricing": "pricing",
}
def _parse_skillmd_v2(content: str) -> dict:
"""Phase 1: Parse SKILL.md with standardized 9-block schema.
SKILL.md Standard v2.0 format:
---
name: Skill Name
version: 1.0.0
author: Author Name <email>
description: |
Multi-line description
capabilities: [text_generation, code_execution]
inputs:
- name: input_text
type: string
description: The input text
outputs:
- name: result
type: string
description: The processed result
permissions: [file_read, network]
dependencies: [python>=3.10, requests]
pricing:
free_tier: 10/day
pro_tier: 100/day
---
Skill instruction content...
"""
import re, yaml
result = {
"name": None, "version": "1.0.0", "author": None,
"description": None, "capabilities": [], "inputs": [],
"outputs": [], "permissions": [], "dependencies": [],
"pricing": {}, "instructions": "", "valid": False, "errors": []
}
if not content or not content.strip():
result["errors"].append("Empty content")
return result
# Extract YAML frontmatter
fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n?(.*)', content, re.DOTALL)
if fm_match:
try:
fm = yaml.safe_load(fm_match.group(1))
if isinstance(fm, dict):
for key in SKILLMD_SCHEMA:
if key in fm:
result[key] = fm[key]
result["valid"] = True
except yaml.YAMLError as e:
result["errors"].append(f"YAML parse error: {e}")
result["instructions"] = fm_match.group(2).strip()
else:
# No frontmatter: treat entire content as instructions
result["instructions"] = content.strip()
result["description"] = content[:200].strip()
# Validate required fields
if not result["name"]:
result["errors"].append("Missing required field: name")
if not result["description"]:
result["errors"].append("Missing recommended field: description")
result["valid"] = len([e for e in result["errors"] if "Missing required" in e]) == 0
return result
def _validate_skillmd(content: str) -> dict:
"""Validate a SKILL.md content against the v2 schema. Returns {valid, errors, warnings}."""
parsed = _parse_skillmd_v2(content)
errors = list(parsed["errors"])
warnings = []
if not parsed["name"]:
errors.append("REQUIRED: 'name' field is missing")
if not parsed["description"]:
warnings.append("Recommended: add 'description' field")
if not parsed["version"]:
warnings.append("Recommended: specify 'version' (e.g. 1.0.0)")
if not parsed["capabilities"]:
warnings.append("Recommended: list 'capabilities' for marketplace visibility")
return {
"valid": len(errors) == 0,
"errors": errors,
"warnings": warnings,
"parsed": parsed
}
+7 -7
View File
@@ -109,20 +109,20 @@ def _ensure_table(db):
"""确保 im_bot_platform_account 表存在(首次调用时创建)。"""
db.execute(text("""
CREATE TABLE IF NOT EXISTS im_bot_platform_account (
id INT AUTO_INCREMENT PRIMARY KEY,
id SERIAL PRIMARY KEY,
user_id VARCHAR(64) DEFAULT NULL,
platform VARCHAR(32) NOT NULL,
status VARCHAR(32) DEFAULT 'disconnected',
creds MEDIUMTEXT NULL,
creds TEXT NULL,
send_url VARCHAR(512) NULL,
api_base VARCHAR(512) NULL,
bot_token VARCHAR(512) NULL,
webhook_token VARCHAR(64) NULL,
last_error MEDIUMTEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_platform (user_id, platform)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
last_error TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, platform)
)
"""))
db.commit()
+314 -37
View File
@@ -18,6 +18,7 @@ import os
import random
import threading
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import quote as _quote
@@ -39,17 +40,29 @@ TZ_BEIJING = timezone(timedelta(hours=8))
def _beijing_now() -> datetime:
"""返回北京时间的 naive datetime(去掉 tzinfo 以便直接存入 MySQL DATETIME)。"""
return datetime.now(TZ_BEIJING).replace(tzinfo=None)
"""返回带时区的北京时间(aware datetime)。
#977 关键修复:平台已全量迁 PG(时间列均为 timestamp with time zone),
必须返回 aware datetime。此前 .replace(tzinfo=None) 返回 naive,PG 按 UTC
误解导致错位 8 小时(如北京时间 13:53 存成 13:53 UTC → 前端显示 21:53)。
"""
return datetime.now(TZ_BEIJING)
# ============================================================
# iLink 协议常量(腾讯官方 iLink Bot HTTP API,直连免 AppID)
# ============================================================
ILINK_BASE = "https://ilinkai.weixin.qq.com"
# 微信/iLink 单条文本消息长度软上限。超过则分段发送,避免 iLink 返回 200
# 但用户侧静默收不到(实测 1698 字符 HTML 链接、3011 字符 Markdown 均丢失)。
WECHAT_TEXT_MAX_LEN = 1500
# 每个已连接账号一个后台接收线程,key = 账号表主键 id
_wechat_receiver_threads: dict[int, threading.Thread] = {}
# typing_ticket 缓存:{ilink_user_id: (ticket, 写入时间戳)},24h 有效
_typing_ticket_cache: dict[str, tuple[str, float]] = {}
# ============================================================
# 通用 DB 工具
@@ -123,7 +136,11 @@ def _recent_history(user_id: str, limit: int = 6) -> str:
for r in reversed(rows):
dd = _row_to_dict(r)
role = "用户" if dd["role"] == "user" else "七虎AI"
pairs.append(f"{role}:{dd['content']}")
content = dd.get("content") or ""
# #973:每条历史最多 500 字符,防止超长图片 URL/完整清单撑爆多轮 prompt
if len(content) > 500:
content = content[:500] + "…"
pairs.append(f"{role}:{content}")
return "\n".join(pairs)
except Exception:
return ""
@@ -143,12 +160,33 @@ def _qihua_reply(text: str, user_id: str = None) -> str:
f"{hist}\n\n"
f"用户当前问题:{text}"
)
# 磁盘调试日志(不依赖 uvicorn logger)
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply user={user_id} prompt_len={len(prompt)} prompt={repr(prompt)[:500]}\n")
except Exception:
pass
db = None
try:
from app.api.im_llm import _call_real_llm
db = SessionLocal()
return _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
result = _call_real_llm(db, None, prompt, "个人微信", user_id=user_id)
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply RESULT len={len(result) if result else 0} preview={repr(result)[:200]}\n")
except Exception:
pass
return result
except Exception as e:
import traceback as _tb2
import logging
_log = logging.getLogger('im_bot_user')
_log.warning(f'[_qihua_reply] 异常: {e}\n{_tb2.format_exc()[:1000]}')
try:
with open("/tmp/im_llm_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + f" _qihua_reply EXCEPTION: {e}\n{_tb2.format_exc()[:800]}\n")
except Exception:
pass
return f"(AI 回复生成失败:{e})"
finally:
if db:
@@ -164,7 +202,7 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getupdates",
headers=_wechat_headers(bot_token),
json={"get_updates_buf": cursor, "base_info": {"channel_version": "1.0.2"}},
json={"get_updates_buf": cursor},
timeout=40,
)
if not resp.ok:
@@ -178,20 +216,38 @@ def _wechat_get_updates(baseurl: str, bot_token: str, cursor: str):
def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_token: str, item_list: list):
"""向 iLink 发送一条消息(item_list 可含文本/图片/视频等)。返回 (ok, ret, body)。
ret=-14=会话过期需重扫;ret=-2=token 失效。"""
import uuid as _uuid
payload = {
"msg": {
"from_user_id": "",
"to_user_id": to_user_id,
"client_id": "py-" + str(_uuid.uuid4()),
"client_id": uuid.uuid4().hex,
"message_type": 2,
"message_state": 2,
"context_token": context_token,
"item_list": item_list,
},
"base_info": {"channel_version": "1.0.2"},
"run_id": uuid.uuid4().hex,
}
}
last_body = {}
# 07-30 diagnostic
import json as _json3
try:
# 增强诊断:记录每条消息类型、长度、内容摘要,用于定位“iLink 200 但用户收不到”
_item_summaries = []
for _it in item_list:
_t = _it.get("type")
_txt = ""
if _t == 1 and _it.get("text_item"):
_txt = _it["text_item"].get("text", "")
_item_summaries.append(f"type={_t} len={len(_txt)} text={repr(_txt[:80])}")
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SEND_DEBUG token=" + repr(bot_token[:20] if bot_token else "NULL")
+ " to=" + repr(to_user_id[:30]) + " ctx=" + repr(context_token[:30]) + "\n")
_f.write(" payload_keys=" + str(list(payload.get("msg", {}).keys()))
+ " items=[" + "; ".join(_item_summaries) + "]\n")
except Exception:
pass
for _ in range(2):
try:
resp = requests.post(
@@ -222,9 +278,113 @@ def _wechat_post_msg(baseurl: str, bot_token: str, to_user_id: str, context_toke
def _wechat_send_message(baseurl: str, bot_token: str, to_user_id: str, context_token: str, text: str):
"""发送纯文本回复。"""
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[{"type": 1, "text_item": {"text": text}}])
"""发送纯文本回复。长文本按 WECHAT_TEXT_MAX_LEN 分段,避免 iLink 200 但微信不显示。"""
if not text:
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[{"type": 1, "text_item": {"text": "(无内容)"}}])
# 单条不超过软上限;保留完整段落优先在换行处切分
chunks = []
start = 0
while start < len(text):
end = min(start + WECHAT_TEXT_MAX_LEN, len(text))
if end < len(text):
# 尽量在换行处切断
_nl = text.rfind("\n", start, end)
if _nl > start + WECHAT_TEXT_MAX_LEN // 2:
end = _nl + 1
chunks.append(text[start:end])
start = end
all_ok = True
last_ret = None
for idx, chunk in enumerate(chunks):
suffix = ""
if len(chunks) > 1:
if idx == 0:
suffix = "\n(内容较长,分条发送)"
if idx == len(chunks) - 1:
suffix = "\n(已发送完毕;完整内容请访问平台 AI 聊天的 WeXin Bot 窗口)"
ok, ret, _ = _wechat_post_msg(
baseurl, bot_token, to_user_id, context_token,
[{"type": 1, "text_item": {"text": chunk + suffix}}]
)
if not ok:
all_ok = False
last_ret = ret
# 记录失败但不中断,尽量把已切好的消息发完
try:
with open("/tmp/wechat_send.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SEGMENT_FAIL idx=%s ret=%s\n" % (idx, ret))
except Exception:
pass
# 分段之间稍作间隔,降低频率/风控影响
if idx < len(chunks) - 1:
time.sleep(0.5)
return all_ok, last_ret, {}
def _wechat_get_typing_ticket(baseurl: str, bot_token: str, ilink_user_id: str, context_token: str) -> str:
"""getconfig 获取 typing_ticket(按 ilink_user_id 缓存 24h)。
#974 修复:ilink_user_id 必须传【用户 ID】(from_user_id),不是 bot ID;
请求体必须带 base_info;响应必须 ret==0 才算成功。
"""
global _typing_ticket_cache
_now = time.time()
_hit = _typing_ticket_cache.get(ilink_user_id)
if _hit and (_now - _hit[1]) < 86400:
return _hit[0]
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getconfig",
headers=_wechat_headers(bot_token),
json={
"ilink_user_id": ilink_user_id,
"context_token": context_token,
"base_info": {"channel_version": "1.0.3"},
},
timeout=10,
)
body = resp.json() if resp.content else {}
# ret==0 才算成功;-14 会话过期 / -4 RPC 失败均无 ticket
ret = body.get("ret")
errcode = body.get("errcode")
if ret == 0 or (ret is None and errcode is None):
ticket = body.get("typing_ticket", "") if isinstance(body, dict) else ""
if ticket:
_typing_ticket_cache[ilink_user_id] = (ticket, _now)
return ticket
# 会话过期:记录,供上层标记 expired
if errcode == -14 or ret == -14:
try:
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " GETCONFIG_SESSION_EXPIRED user=%s\n" % ilink_user_id)
except Exception:
pass
return ""
except Exception:
return ""
def _wechat_send_typing(baseurl: str, bot_token: str, ilink_user_id: str, typing_ticket: str, status: int = 1) -> bool:
"""发送/取消"正在输入"状态。status=1 typing,2 cancel。失败静默(不阻塞主流程)。"""
if not ilink_user_id or not typing_ticket:
return False
try:
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/sendtyping",
headers=_wechat_headers(bot_token),
json={
"ilink_user_id": ilink_user_id,
"typing_ticket": typing_ticket,
"status": status,
"base_info": {"channel_version": "1.0.3"},
},
timeout=10,
)
return resp.ok
except Exception:
return False
def _platform_relay_url(raw_url: str) -> str:
@@ -239,28 +399,62 @@ def _platform_relay_url(raw_url: str) -> str:
def _wechat_send_media(baseurl: str, bot_token: str, to_user_id: str, context_token: str,
media_type: str, url: str):
"""发送媒体(图片/视频)并在微信内联渲染。
"""发送媒体(图片/视频)。#969 iLink CDN 内联发送
问题:AGNES 媒体 URL 位于境外,iLink(中国大陆服务器)直连常被墙/超时,
微信里只能看到"链接"而无法内联播放。
解决:改写为平台中转地址(经香港代理可达 AGNES),微信即可内联显示图片/视频。
优先走 iLink 官方 CDN 加密上传链路,实现图片/视频真正内联显示;
失败时降级为纯文本链接(点击访问),确保会话不中断。
"""
relay_url = _platform_relay_url(url)
# 尝试内联发送(iLink CDN 加密上传)
try:
from app.api.im_ilink_media import (
MEDIA_TYPE_IMAGE, MEDIA_TYPE_VIDEO,
download_media_bytes, upload_media_to_wechat_cdn,
build_image_item, build_video_item,
)
media_bytes = download_media_bytes(url)
mt = MEDIA_TYPE_IMAGE if media_type == "image" else MEDIA_TYPE_VIDEO
uploaded = upload_media_to_wechat_cdn(baseurl, bot_token, to_user_id, media_bytes, mt)
item = build_image_item(uploaded) if media_type == "image" else build_video_item(uploaded)
ok, ret, _ = _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
if ok:
try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_OK type=%s size=%d\n"
% (media_type, len(media_bytes)))
except Exception:
pass
return True, ret, {}
try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_FAIL ret=%s → fallback text\n" % (ret,))
except Exception:
pass
except Exception as _e:
try:
with open("/tmp/wechat_send_debug.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " MEDIA_INLINE_EXC %s → fallback text\n" % (_e,))
except Exception:
pass
# 降级:纯文本链接
if media_type == "image":
media_item = {"type": 2, "image_item": {"url": relay_url}}
caption = "🖼️ 图片已生成(若未直接显示,请点此查看):" + relay_url
caption = "🖼️ 图片已生成,点击查看:" + relay_url
else:
media_item = {"type": 5, "video_item": {"url": relay_url}}
caption = "🎬 视频已生成(若未直接播放,请点此查看):" + relay_url
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token,
[media_item, {"type": 1, "text_item": {"text": caption}}])
caption = "🎬 视频已生成,点击查看:" + relay_url
return _wechat_send_message(baseurl, bot_token, to_user_id, context_token, caption)
def _wechat_extract_text(msg: dict) -> str:
parts = []
for it in (msg.get("item_list") or []):
if it.get("type") == 1 and it.get("text_item", {}).get("text"):
_t = it.get("type")
if _t == 1 and it.get("text_item", {}).get("text"):
parts.append(it["text_item"]["text"])
elif _t == 3:
# 语音消息:微信已自带 ASR 转文字(voice_item.text),直接复用
voice = it.get("voice_item") or {}
if voice.get("text"):
parts.append("[语音] " + str(voice["text"]))
if not parts:
for k in ("text", "content", "message", "msg"):
v = msg.get(k)
@@ -269,6 +463,42 @@ def _wechat_extract_text(msg: dict) -> str:
return "\n".join(parts)
def _extract_and_save_inbound_media(msg: dict) -> str:
"""检测入站媒体消息(图片/语音/文件/视频),下载解密保存,返回给 LLM 的提示文本。
无媒体或解密失败返回空字符串。图片已保存到本地,供后续 vision 模型接入。
"""
try:
from app.api.im_ilink_media import extract_inbound_media
import os as _os
for it in (msg.get("item_list") or []):
if it.get("type") in (2, 3, 4, 5):
kind, data = extract_inbound_media(it)
if kind and data:
_dir = "/tmp/wechat_inbound_media"
_os.makedirs(_dir, exist_ok=True)
_ext = {"image": "jpg", "voice": "silk", "file": "bin", "video": "mp4"}.get(kind, "bin")
_path = _os.path.join(_dir, "%d_%s.%s" % (int(time.time() * 1000), kind, _ext))
with open(_path, "wb") as _f:
_f.write(data)
_label = {"image": "图片", "voice": "语音", "file": "文件", "video": "视频"}.get(kind, kind)
try:
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " %s saved=%s size=%d\n"
% (kind, _path, len(data)))
except Exception:
pass
return "[用户发送了%s,已保存到 %s(%d 字节)]" % (_label, _path, len(data))
return ""
except Exception as _e:
try:
with open("/tmp/wechat_inbound_media.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " MEDIA_EXTRACT_ERR %s\n" % (_e,))
except Exception:
pass
return ""
def _wechat_sender_id(m: dict) -> str:
"""从入站消息中提取发送者 id(兼容多套字段名)。"""
for k in ("from_user_id", "from_user_name", "from_username", "user_id",
@@ -326,8 +556,7 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
), {"uid": user_id})
if rows:
return _row_to_dict(rows[0])["id"]
import uuid as _uuid
cid = str(_uuid.uuid4())
cid = str(uuid.uuid4())
now = _beijing_now()
_execute_raw(db, text(
"INSERT INTO conversations "
@@ -338,12 +567,11 @@ def _get_or_create_wechat_conv(db: Session, user_id: str) -> str:
def _append_ai_message(db: Session, conv_id: str, role: str, content: str, created=None):
import uuid as _uuid
_execute_raw(db, text(
"INSERT INTO messages (id, conversation_id, role, content, token_used, created_at) "
"VALUES (:id, :cid, :role, :content, 0, :c)"
), {
"id": str(_uuid.uuid4()), "cid": conv_id, "role": role,
"id": str(uuid.uuid4()), "cid": conv_id, "role": role,
"content": content, "c": created or _beijing_now(),
})
@@ -374,7 +602,7 @@ def _mirror_to_ai_chat(user_id: str, user_content: str, assistant_content: str):
# 后台接收线程(每用户独立)
# ============================================================
def _mark_wechat_expired(account_id: int):
"""发送会话失效(ret=-14/-2):标记账号需重扫,并清除失效 token。"""
"""发送会话失效(ret=-14/-2):标记账号需重扫,清除失效 token,并记录告警日志。"""
db = None
try:
db = SessionLocal()
@@ -382,6 +610,14 @@ def _mark_wechat_expired(account_id: int):
"UPDATE im_bot_wechat_account SET status='expired', bot_token=NULL, "
"updated_at=:ts WHERE id=:aid"
), {"aid": account_id, "ts": _beijing_now()})
# Session Guard:记录账号信息,供前台提示用户重扫
row = _execute_raw(db, text("SELECT user_id, username FROM im_bot_wechat_account WHERE id=:aid"), {"aid": account_id})
_uid = row[0][0] if row else "?"
try:
with open("/tmp/wechat_session_expired.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SESSION_EXPIRED account_id=%s user_id=%s\n" % (account_id, _uid))
except Exception:
pass
except Exception:
pass
finally:
@@ -414,7 +650,10 @@ def _wechat_receiver_loop(account_id: int):
continue # 仅跳过 bot 自己发出的回声,其余(含后续追问)均尝试处理
content = _wechat_extract_text(m)
if not content:
continue
# 入站媒体(图片/语音/文件/视频):下载解密保存,供 LLM 感知
content = _extract_and_save_inbound_media(m)
if not content:
continue
sender = _wechat_sender_id(m)
ctx = _wechat_context_token(m)
# 诊断:记录原始入站消息,定位字段名 / 第二轮不回复
@@ -424,8 +663,22 @@ def _wechat_receiver_loop(account_id: int):
+ " ctx=" + repr(ctx) + " raw=" + json.dumps(m, ensure_ascii=False) + "\n")
except Exception:
pass
# 发送"正在输入"状态(#974:ilink_user_id 用用户 ID = sender,非 bot ID)
try:
_ticket = _wechat_get_typing_ticket(baseurl, bot_token, sender, ctx)
if sender and _ticket:
_wechat_send_typing(baseurl, bot_token, sender, _ticket, 1)
except Exception:
pass
# 先生成回复(此时历史尚未包含本条,避免重复)
import logging as _lrx, inspect as _insp
_lrx.warning(f'[receiver] calling _qihua_reply({repr(content)}, {repr(owner_uid)})')
# 验证使用的 _call_real_llm 版本
from app.api.im_llm import _call_real_llm as _crl
_src = _insp.getsource(_crl)
_lrx.warning(f'[receiver] _call_real_llm source len={len(_src)}, has _try_models={"_try_models" in _src}')
reply = _qihua_reply(content, owner_uid)
_lrx.warning(f'[receiver] reply ({type(reply).__name__}): {repr(reply)[:200]}')
# 媒体结构化返回:_call_real_llm 生成图片/视频时返回
# {"__media__": True, "media_type": "image"/"video", "url": "..."}
if isinstance(reply, dict) and reply.get("__media__"):
@@ -434,11 +687,8 @@ def _wechat_receiver_loop(account_id: int):
ok, ret, _ = _wechat_send_media(baseurl, bot_token, sender, ctx, media_type, media_url)
# 对话历史里存可渲染的媒体标签(经平台中转,AI 聊天 WeXin Bot 窗与历史页均可内联显示)
relay_url = _platform_relay_url(media_url)
reply_for_log = (
f'<img src="{relay_url}" alt="生成图片"/>'
if media_type == "image"
else f'<video src="{relay_url}" controls></video>'
)
# #973:对话历史只存简短占位符,不存超长 relay URL(含 AWS token,会撑爆多轮 prompt)
reply_for_log = "[图片]" if media_type == "image" else "[视频]"
else:
reply_text = reply if isinstance(reply, str) else str(reply)
ok, ret, _ = _wechat_send_message(baseurl, bot_token, sender, ctx, reply_text)
@@ -457,11 +707,23 @@ def _wechat_receiver_loop(account_id: int):
% (ret, repr(sender)))
except Exception:
pass
if ret in (-2, -14):
# 会话/鉴权失效:标记需重扫但继续循环,让下一轮 poll 自然检测过期
if ret == -14:
# -14=会话过期,需重扫
_mark_wechat_expired(d["id"])
elif ret == -2:
# -2=参数错误(如媒体消息格式不被 iLink 接受),并非 token 失效。
# 标记 expired 会导致连接假性断开,后续消息全部丢失,故仅记录。
pass
time.sleep(1)
except Exception:
# 2026-07-31 修复:此前裸 except 静默吞掉所有异常,导致接收器出错时毫无日志、
# 完全无法排查。改为记录异常堆栈到磁盘,便于定位。
import traceback as _tb
try:
with open("/tmp/wechat_receiver_err.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " RECEIVER EXCEPTION:\n" + _tb.format_exc() + "\n")
except Exception:
pass
time.sleep(5)
finally:
db.close()
@@ -555,6 +817,13 @@ async def wechat_qrcode_status(
data = j.get("data", j) if isinstance(j, dict) else j
status = data.get("status")
if status == "confirmed":
# 07-30 diagnostic: log full iLink confirm response
import json as _json
try:
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " CONFIRM_RAW=" + _json.dumps(j, ensure_ascii=False)[:2000] + "\n")
except Exception:
pass
creds = data.get("credentials", {}) or {}
bot_token = data.get("bot_token") or creds.get("bot_token")
baseurl = data.get("baseurl") or creds.get("baseurl") or ILINK_BASE
@@ -590,7 +859,15 @@ async def wechat_qrcode_status(
"updated_at": _beijing_now(), "id": _row_to_dict(row)["id"],
})
row = _wechat_row_for_user(db, user.id)
aid = _row_to_dict(row)["id"] if row else 1
saved = _row_to_dict(row) if row else {}
import json as _json2
try:
with open("/tmp/wechat_confirm.log", "a", encoding="utf-8") as _f:
_f.write(_beijing_now().isoformat() + " SAVED bot_token=" + repr(saved.get("bot_token", "NULL")[:20])
+ " ilink_bot_id=" + repr(saved.get("ilink_bot_id")) + "\n")
except Exception:
pass
aid = saved.get("id", 1)
_start_wechat_receiver(aid)
finally:
db.close()
+330
View File
@@ -0,0 +1,330 @@
"""
iLink CDN 媒体加解密 —— 微信官方 iLink(ClawBot) 协议媒体内联发送核心模块
im_ilink_media.py
参考微信官方 OpenClaw 插件 (@tencent-weixin/openclaw-weixin) 源码实现:
- cdn/aes-ecb.ts: AES-128-ECB + PKCS7 加解密
- cdn/upload.ts: 六步上传流程
- messaging/send.ts: image_item / video_item 构造
严格照抄官方行为,保证字段名与编码方式与微信服务器一致:
- aeskey = 随机 16 字节
- getUploadUrl 传 aeskey.hex()(32 字符 hex)
- 加密用 16 字节原始 key
- sendmessage 的 media.aes_key = base64(hex 字符串的 UTF-8 字节)
媒体类型映射: IMAGE=1, VIDEO=2, FILE=3, VOICE=4
"""
import base64
import hashlib
import math
import os
import re
from urllib.parse import quote
import requests
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
# 微信 CDN 域名(图片/语音/视频媒体文件走这里)
WECHAT_CDN_BASE = "https://novac2c.cdn.weixin.qq.com/c2c"
# 媒体类型枚举(与 iLink UploadMediaType 一致)
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_FILE = 3
MEDIA_TYPE_VOICE = 4
def encrypt_aes_ecb(plaintext: bytes, key: bytes) -> bytes:
"""AES-128-ECB + PKCS7 加密(微信指定,非标准 CBC)。"""
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.ECB())
enc = cipher.encryptor()
return enc.update(padded) + enc.finalize()
def decrypt_aes_ecb(ciphertext: bytes, key: bytes) -> bytes:
"""AES-128-ECB + PKCS7 解密。"""
cipher = Cipher(algorithms.AES(key), modes.ECB())
dec = cipher.decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def aes_ecb_padded_size(plaintext_size: int) -> int:
"""AES-128-ECB PKCS7 填充后的密文大小。"""
return math.ceil((plaintext_size + 1) / 16) * 16
def _wechat_headers(bot_token: str) -> dict:
"""iLink 协议请求头(复制 im_bot_user 逻辑,避免循环依赖)。"""
uin = base64.b64encode(str(random_int()).encode("utf-8")).decode("utf-8")
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {bot_token}",
"AuthorizationType": "ilink_bot_token",
"X-WECHAT-UIN": uin,
}
def random_int():
import random
return random.randint(0, 2 ** 32 - 1)
def download_media_bytes(url: str) -> bytes:
"""下载 AGNES/供应商媒体字节(走 HK 代理,绕过劫持)。
复用 chat_utils._proxy_for_url 的代理选择逻辑;对境外媒体走 HK 代理。
"""
from app.api.chat_utils import _proxy_for_url
proxy = _proxy_for_url(url)
proxies = None
if proxy:
proxies = {"http": proxy, "https": proxy}
# trust_env=False 语义等价:requests 不读环境变量代理
# 显式传 proxies=None 且环境变量存在时 requests 会用环境变量,故需清空
import os as _os
env_bak = {}
for _k in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
if _k in _os.environ:
env_bak[_k] = _os.environ.pop(_k)
try:
resp = requests.get(url, proxies=proxies, timeout=180, allow_redirects=True)
finally:
_os.environ.update(env_bak)
if not resp.ok:
raise RuntimeError(f"media download failed: {resp.status_code} url={url[:120]}")
return resp.content
def upload_media_to_wechat_cdn(
baseurl: str,
bot_token: str,
to_user_id: str,
media_bytes: bytes,
media_type: int,
) -> dict:
"""六步上传流程:读文件→hash→gen aeskey→getUploadUrl→加密上传CDN→返回引用。
返回 uploaded dict: { filekey, download_param, aeskey_hex, filesize }
"""
rawsize = len(media_bytes)
rawfilemd5 = hashlib.md5(media_bytes).hexdigest()
filesize = aes_ecb_padded_size(rawsize)
filekey = os.urandom(16).hex() # 32 字符 hex
aeskey = os.urandom(16) # 16 字节原始 key
aeskey_hex = aeskey.hex() # 32 字符 hex
# 1. getUploadUrl
resp = requests.post(
baseurl.rstrip("/") + "/ilink/bot/getuploadurl",
headers=_wechat_headers(bot_token),
json={
"filekey": filekey,
"media_type": media_type,
"to_user_id": to_user_id,
"rawsize": rawsize,
"rawfilemd5": rawfilemd5,
"filesize": filesize,
"no_need_thumb": True,
"aeskey": aeskey_hex,
},
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"getuploadurl HTTP {resp.status_code}: {resp.text[:200]}")
body = resp.json() if resp.content else {}
upload_param = body.get("upload_param")
upload_full_url = (body.get("upload_full_url") or "").strip()
if not upload_param and not upload_full_url:
raise RuntimeError(f"getuploadurl returned no upload URL: {str(body)[:200]}")
# 2. AES-128-ECB 加密
ciphertext = encrypt_aes_ecb(media_bytes, aeskey)
# 3. 上传 CDN
if upload_full_url:
cdn_url = upload_full_url
else:
cdn_url = (
f"{WECHAT_CDN_BASE}/upload"
f"?encrypted_query_param={quote(upload_param, safe='')}"
f"&filekey={quote(filekey, safe='')}"
)
up_resp = requests.post(
cdn_url,
data=ciphertext,
headers={"Content-Type": "application/octet-stream"},
timeout=120,
)
if up_resp.status_code not in (200, 201):
raise RuntimeError(f"CDN upload failed HTTP {up_resp.status_code}: {up_resp.text[:200]}")
download_param = up_resp.headers.get("x-encrypted-param")
if not download_param:
raise RuntimeError("CDN upload response missing x-encrypted-param header")
return {
"filekey": filekey,
"download_param": download_param,
"aeskey_hex": aeskey_hex,
"filesize": filesize,
}
def _aes_key_b64(aeskey_hex: str) -> str:
"""media.aes_key 编码:base64(hex 字符串的 UTF-8 字节)。
严格对齐 openclaw: Buffer.from(hexString).toString('base64')
"""
return base64.b64encode(aeskey_hex.encode("utf-8")).decode("utf-8")
def build_image_item(uploaded: dict) -> dict:
"""构造图片 MessageItem(type=2)。"""
return {
"type": 2,
"image_item": {
"media": {
"encrypt_query_param": uploaded["download_param"],
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
"encrypt_type": 1,
},
"mid_size": uploaded["filesize"],
},
}
def build_video_item(uploaded: dict) -> dict:
"""构造视频 MessageItem(type=5)。"""
return {
"type": 5,
"video_item": {
"media": {
"encrypt_query_param": uploaded["download_param"],
"aes_key": _aes_key_b64(uploaded["aeskey_hex"]),
"encrypt_type": 1,
},
"video_size": uploaded["filesize"],
},
}
# ============================================================
# 接收侧媒体下载解密(P2)
# 参考 openclaw cdn/pic-decrypt.ts + media/media-download.ts
# ============================================================
def parse_aes_key(aes_key_base64: str) -> bytes:
"""解析 CDNMedia.aes_key 为原始 16 字节 AES key。
两种真实编码(openclaw parseAesKey):
- base64(raw 16 bytes) → 图片 (media.aes_key)
- base64(hex 32 chars 字符串) → 文件/语音/视频
第二种需 base64 解码后得到 32 个 ASCII hex 字符,再 hex 解码成 16 字节。
"""
decoded = base64.b64decode(aes_key_base64)
if len(decoded) == 16:
return decoded
if len(decoded) == 32 and re.match(r"^[0-9a-fA-F]{32}$", decoded.decode("ascii", "ignore")):
return bytes.fromhex(decoded.decode("ascii"))
raise ValueError(
f"aes_key 解码后应为 16 字节或 32 字符 hex,实际 {len(decoded)} 字节"
)
def download_and_decrypt_media(
encrypted_query_param: str,
aes_key_base64: str,
cdn_base_url: str = WECHAT_CDN_BASE,
full_url: str = None,
) -> bytes:
"""下载并 AES-128-ECB 解密 CDN 媒体文件,返回明文字节。"""
key = parse_aes_key(aes_key_base64)
if full_url:
url = full_url
else:
url = (
f"{cdn_base_url}/download"
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
)
resp = requests.get(url, timeout=60)
if not resp.ok:
raise RuntimeError(f"CDN download failed HTTP {resp.status_code}: {resp.text[:150]}")
encrypted = resp.content
return decrypt_aes_ecb(encrypted, key)
def extract_inbound_media(item: dict) -> tuple:
"""从入站 MessageItem 提取媒体并下载解密。
返回 (media_kind, media_bytes),不支持的类型或失败返回 (None, None)。
media_kind ∈ {"image", "voice", "file", "video"}
"""
try:
t = item.get("type")
if t == 2: # 图片
img = item.get("image_item") or {}
media = img.get("media") or {}
eqp = media.get("encrypt_query_param") or ""
full = media.get("full_url")
if not eqp and not full:
return None, None
# 图片 key 优先用 image_item.aeskey(hex string)转 base64
if img.get("aeskey"):
aes_key_b64 = base64.b64encode(bytes.fromhex(img["aeskey"])).decode()
else:
aes_key_b64 = media.get("aes_key") or ""
if aes_key_b64:
return "image", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
return "image", _download_plain(eqp, full)
elif t == 3: # 语音
voice = item.get("voice_item") or {}
media = voice.get("media") or {}
eqp = media.get("encrypt_query_param") or ""
full = media.get("full_url")
aes_key_b64 = media.get("aes_key") or ""
if not aes_key_b64:
return None, None
return "voice", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
elif t == 4: # 文件
fitem = item.get("file_item") or {}
media = fitem.get("media") or {}
eqp = media.get("encrypt_query_param") or ""
full = media.get("full_url")
aes_key_b64 = media.get("aes_key") or ""
if not aes_key_b64:
return None, None
return "file", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
elif t == 5: # 视频
vitem = item.get("video_item") or {}
media = vitem.get("media") or {}
eqp = media.get("encrypt_query_param") or ""
full = media.get("full_url")
aes_key_b64 = media.get("aes_key") or ""
if not aes_key_b64:
return None, None
return "video", download_and_decrypt_media(eqp, aes_key_b64, full_url=full)
except Exception:
return None, None
return None, None
def _download_plain(encrypted_query_param: str, full_url: str = None) -> bytes:
"""下载未加密的 CDN 媒体(无 aes_key 时)。"""
if full_url:
url = full_url
else:
url = (
f"{WECHAT_CDN_BASE}/download"
f"?encrypted_query_param={quote(encrypted_query_param, safe='')}"
)
resp = requests.get(url, timeout=60)
if not resp.ok:
raise RuntimeError(f"CDN plain download failed HTTP {resp.status_code}")
return resp.content
+292 -39
View File
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
"""
import asyncio
import os
import json
import logging
import re as _re
@@ -29,7 +30,14 @@ from sqlalchemy.orm import Session
from ..capability_registry import resolve_auto_model
from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid
from app.core.observability import get_trace_id
from app.core.observability import get_trace_id, span
# #875 LLM Router:统一模型路由层(影子模式先行,收敛 litellm 主密钥硬编码)
from ..core.llm_router import (
route as _llm_route,
set_route_ctx as _set_route_ctx,
SHADOW as _LLM_ROUTER_SHADOW,
resolve_api_key as _llm_resolve_api_key,
)
logger = logging.getLogger(__name__)
@@ -48,7 +56,8 @@ def _proxy_for_url(url: str) -> str | None:
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
if "qihuyun.com" in host:
# 2026-07-30 修复 3+: 不代理 Docker 内部 hostname 和 localhost
if "qihuyun.com" in host or "localhost" in host or host.startswith("127.") or "qihu-aiflow" in host:
return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
@@ -57,6 +66,17 @@ def _proxy_for_url(url: str) -> str | None:
return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890"
_DEBUG_LOG_PATH = "/tmp/im_llm_debug.log"
def _debug_log(msg: str):
"""写磁盘调试日志(不依赖 uvicorn logging 配置)。"""
try:
with open(_DEBUG_LOG_PATH, "a", encoding="utf-8") as _f:
_f.write(datetime.now(timezone(timedelta(hours=8))).isoformat() + " " + msg + "\n")
except Exception:
pass
def _run_async(coro, timeout=None):
"""在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。
@@ -117,6 +137,13 @@ def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, promp
def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict":
"""调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。"""
# #876 双 Claw 维度:入口最早注入 tenant_id + claw_type,确保下方所有 span / metric
# 均带双 Claw 标签(此前 IM Claw 完全无 span,Grafana 无 IM 数据点)。
_set_route_ctx(tenant_id=getattr(user, "tenant_id", "") or "", claw_type="im_wechat")
# #876:IM Claw 此前完全无 span,Grafana 无 IM 数据点。此处显式开启一个 im.send_message
# span(手动 enter/exit 以避免整段重缩进),使 IM 链路在双 Claw 对比看板出图。
_im_span = span("im.send_message", platform=platform, user_id=user_id)
_im_span.__enter__()
try:
from app.api.chat import (
_generate_media,
@@ -124,10 +151,23 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
_discover_installed_skills,
_build_system_prompt,
_execute_skill_tool,
_list_enabled_mcp_servers,
)
# 2026-08-04 双 Claw 对齐:能力发现收敛为唯一实现,Web / IM 共用
from app.core.capability_discovery import (
build_mcp_tool_defs,
discover_mcp_servers,
discover_skills,
)
from app.mcp_client import call_mcp_tool
# ===== 1) 媒体意图路由(Issue 7)=====
intent = detect_media_intent(prompt)
# 2026-07-30 fix: 只对当前用户消息做媒体意图检测,避免历史中的图像请求污染
_cur_msg = prompt
_marker = "用户当前问题:"
if _marker in prompt:
_cur_msg = prompt[prompt.rfind(_marker) + len(_marker):].strip()
intent = detect_media_intent(_cur_msg)
if intent:
ai_model = resolve_auto_model(intent, db)
if ai_model:
@@ -155,6 +195,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
return s
return "(媒体生成未能返回有效内容,请重试)"
# ===== 1.5) EngineCore 薄壳(#873 · 灰阶门,默认关闭)=====
# IM_USE_ENGINE_CORE=1 时,文本回复改走与 Web Claw 同一份 EngineCore
# (_send_message_core),经 BufferAdapter 缓冲装配为整条回复;上方媒体快路径
# 已提前返回,不受此门影响。默认 0/未设置 = 走下方既有自包含逻辑(零改动)。
if os.environ.get("IM_USE_ENGINE_CORE") == "1":
return _run_async(
_call_engine_core(db, user_id, prompt, platform, user),
timeout=300,
)
# ===== 2) 文本回复:模型 failover + 技能自动发现 + 平台知识库注入 =====
from ..platform_knowledge import get_platform_knowledge
@@ -162,27 +212,76 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_model = resolve_auto_model("text", db)
_try_models = [ai_model] if (ai_model and ai_model.is_enabled) else []
# Auto 模式附加备选(IM Bot 始终 Auto)
# 2026-07-31 根因修复:仅选用「纯文本」模型作为 failover 备选。
# 此前按 is_enabled 顺序取前 4 个,会混入图像/视频/语音(ASR)模型
# (这些模型 capability 含 "text" 但本质是媒体模型,收到文本 prompt 返回空内容、
# 不抛异常 → 被误判为「模型失败」,最终导致「已尝试N个模型均调用失败」)。
_MEDIA_CAPS = {"image", "video", "audio", "asr", "embedding", "rerank", "ocr"}
def _is_text_only(m):
_caps = m.capabilities or []
return "text" in _caps and not any(c in _MEDIA_CAPS for c in _caps)
_fallbacks = db.query(AIModel).filter(
AIModel.is_enabled == True,
AIModel.id != (ai_model.id if ai_model else ""),
).order_by(AIModel.is_default.desc()).limit(4).all()
_try_models.extend([m for m in _fallbacks if m not in _try_models])
).order_by(AIModel.is_default.desc()).all()
_fallbacks = [m for m in _fallbacks if _is_text_only(m)]
_try_models.extend([m for m in _fallbacks[:4] if m not in _try_models])
# ===== LLM Router(#875 影子模式)=====
# 计算决策并打 claw_type 标签 metrics;影子模式下仅观测,不改流量。
# 切主路径:LLM_ROUTER_SHADOW=0 时改用 Router 候选链(已含文本过滤,等同此处逻辑)。
try:
_rm = _llm_route("text", db=db, legacy_primary=_try_models[0].name if _try_models else None)
if not _LLM_ROUTER_SHADOW and _rm.models:
_try_models = _rm.models
_debug_log(f"ROUTER_LIVE models={[m.name for m in _try_models]}")
except Exception as _e:
logger.warning(f"[im_llm] LLM Router 观测失败(降级忽略): {_e}")
_debug_log(f"TRY_MODELS user={user_id} count={len(_try_models)} names={[m.name for m in _try_models]}")
if not _try_models:
return "(未配置可用文本模型,无法生成回复)"
# 技能自动发现(提前执行一次,不必每个候选模型重复做)
# 能力自动发现(提前执行一次,不必每个候选模型重复做)
# 2026-08-04 根因修复:
# 旧实现 `_discover_installed_skills(db, user_id)[:15]` 强依赖
# app_installations 表,线上该表 0 行 → 97 个 published skill
# 在 Web / IM 双端同时发现不到;`[:15]` 截断的是一个空列表。
# 现改走统一发现层(安装优先 → published 降级 → 相关性裁剪)。
_query_hint = _cur_msg or prompt
skills = []
if user_id:
try:
skills = _discover_installed_skills(db, user_id)[:15]
except Exception:
skills = []
try:
skills = discover_skills(db, user_id, query=_query_hint, claw="im")
except Exception as _e:
logger.warning(f"[im_llm] 技能发现失败(降级为空): {_e}")
skills = []
# 平台知识库注入(对齐 Web Chat——微信用户提问"平台有哪些技能"也能得到完整清单)
# MCP 自动挂载:IM Bot Claw 没有勾选 UI,自动发现即其等价通道。
# (Web Claw 靠用户在界面勾选 → effective_mcp_ids)
mcp_servers = []
mcp_function_definitions = []
mcp_tool_map = {}
try:
mcp_servers = discover_mcp_servers(db, None, claw="im")
if mcp_servers:
mcp_function_definitions, mcp_tool_map = _run_async(
build_mcp_tool_defs(mcp_servers), timeout=45
)
except Exception as _e:
logger.warning(f"[im_llm] MCP 发现失败(降级为空): {_e}")
mcp_servers, mcp_function_definitions, mcp_tool_map = [], [], {}
_debug_log(
f"CAP skills={len(skills)} mcp_servers={len(mcp_servers)} "
f"mcp_tools={len(mcp_function_definitions)}"
)
# 平台知识库注入(#973:IM 侧精简——只注入静态文档,不注入 104 动态清单,
# 避免 prompt 过长(6892字符)导致推理模型响应慢(63s)且间歇性返回空 content)
kb_context = ""
try:
pk = get_platform_knowledge(db=db)
pk = get_platform_knowledge(db=None)
if pk:
kb_context = f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}"
except Exception:
@@ -196,19 +295,26 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
# 2026-07-30: LiteLLM 端点统一用 master key(收敛进 LLM Router,两端唯一来源)
api_key = _llm_resolve_api_key(api_url, api_key, provider.api_key if provider else None)
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
_retry_log.append(f"{ai_model.name}: 缺少密钥或端点")
continue
# agents 暂留空:线上 market_apps 中 type='agent' 记录为 0,
# 且 IM 侧尚无 agent 执行分支;待薄壳化接入 EngineCore 后自动获得。
system_prompt, function_definitions = _build_system_prompt(
skills=skills,
agents=[],
kb_context=kb_context,
user_content=prompt,
mcp=[],
mcp=mcp_servers,
mcp_functions=mcp_function_definitions,
auto_discovered=bool(skills),
all_mcp=_list_enabled_mcp_servers(db),
db=db,
)
messages = [
{"role": "system", "content": system_prompt},
@@ -221,13 +327,16 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_content = ""
_http_ok = True
for round_num in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
if function_definitions and skills:
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
if function_definitions:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
try:
import logging as _l5
_l5.warning(f'[im_llm REQ] {ai_model.name}: url={api_url[:60]}, proxy={_proxy_for_url(api_url)}, api_key={api_key[:20]}..., messages_count={len(request_body.get("messages", []))}, tools_count={len(request_body.get("tools", []))}')
_debug_log(f"REQ model={ai_model.name} url={api_url} proxy={_proxy_for_url(api_url)} body={json.dumps(request_body, ensure_ascii=False)[:600]}")
data = chat_complete(
api_url,
api_key,
@@ -235,48 +344,192 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
proxy=_proxy_for_url(api_url),
timeout=ai_model.timeout or 120,
)
_l5.warning(f'[im_llm RES] {ai_model.name}: data_keys={list(data.keys()) if data else None}, resp_type={type(data).__name__}')
_debug_log(f"RES model={ai_model.name} data={json.dumps(data, ensure_ascii=False)[:600]}")
except Exception as _exc:
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
import traceback as _tb
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
logger.warning(f"[im_llm] {ai_model.name} 异常详情: {_tb.format_exc()[:2000]}")
_debug_log(f"EXC model={ai_model.name} err={str(_exc)[:200]} trace={_tb.format_exc()[:500]}")
_http_ok = False
break
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
# 2026-07-30 CRITICAL DEBUG
import logging as _lcr
_raw_content = message.get("content")
_raw_reasoning = message.get("reasoning_content")
_msg_keys = list(message.keys()) if message else []
_tool_calls = tool_calls
_lcr.warning(f'[CRITICAL] MODEL={ai_model.name} msg_keys={_msg_keys} content_len={len(_raw_content or "")} reasoning_len={len(_raw_reasoning or "")} has_tool_calls={bool(_tool_calls)}')
_debug_log(f"PARSE model={ai_model.name} content_len={len(_raw_content or '')} reasoning_len={len(_raw_reasoning or '')} content_preview={repr(_raw_content)[:120]}")
ai_content = message.get("content", "") or message.get("reasoning_content") or ""
if not ai_content and (message.get("reasoning_content") or ""):
ai_content = f"(AI 思考:{message.get('reasoning_content')[:500]}...)"
if tool_calls and (skills or mcp_tool_map):
messages.append({
"role": "assistant",
"content": message.get("content") or message.get("reasoning_content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
if func_name.startswith("mcp__"):
# MCP 工具:命名规则与 Web Claw 完全一致
# mcp__{server}__{tool}__{idx}
_info = mcp_tool_map.get(func_name)
if not _info:
tool_result = (
"[MCP 工具未找到] 可用工具: "
+ str(list(mcp_tool_map.keys())[:5])
)
else:
_srv = next(
(s for s in mcp_servers
if s.get("id") == _info.get("server_id")),
None,
)
if not _srv:
tool_result = f"[MCP 服务未找到: {_info.get('server_name')}]"
else:
try:
tool_result = _run_async(
call_mcp_tool(_srv, _info["name"], func_args),
timeout=90,
)
except Exception as _mexc:
tool_result = (
f"[MCP 工具 {_info.get('name')} 执行失败:"
f"{str(_mexc)[:160]}]"
)
_debug_log(
f"MCP_CALL name={func_name} "
f"result_len={len(tool_result or '')}"
)
else:
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
if round_num >= max_rounds - 1:
break
continue
# #973:空 content 且未到最后一轮 → 重试(推理模型间歇性返回空)
if not ai_content and round_num < max_rounds - 1:
continue
break
# 检查本轮调用是否成功
import logging as _log2
_log2.warning(f'[im_llm trace] {ai_model.name}: http_ok={_http_ok}, ai_content_len={len(ai_content) if ai_content else 0}, ai_content_preview={repr(ai_content)[:200]}')
_debug_log(f"CHECK model={ai_model.name} http_ok={_http_ok} ai_content_len={len(ai_content) if ai_content else 0}")
if _http_ok and ai_content:
_debug_log(f"SUCCESS model={ai_model.name} returning len={len(ai_content)}")
return ai_content or "(AI 回复为空)"
# 本轮失败:记录日志,切换备选
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
logger.warning('[im_llm] 模型 %s 失败 (http_ok=%s, content=%s), 切换备选...' % (
ai_model.name, _http_ok, repr(ai_content)[:100]
))
_debug_log(f"FAIL model={ai_model.name} http_ok={_http_ok} content_preview={repr(ai_content)[:100]}")
continue
except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {exc}")
_debug_log(f"OUTER_EXC model={ai_model.name} err={str(exc)[:120]}")
continue
# 全部失败
_summary = "; ".join(_retry_log[-3:])
_debug_log(f"ALL_FAILED try_models={len(_try_models)} retry_log={_retry_log}")
return f"(已尝试 {len(_try_models)} 个模型均调用失败。最后错误: {_summary}。请稍后重试。)"
except Exception as e:
return f"(AI 回复生成失败:{e})"
finally:
# #876:关闭 im.send_message span(记录耗时 + 指标,强制带 tenant_id + claw_type)
try:
_im_span.__exit__(None, None, None)
except Exception:
pass
async def _call_engine_core(db, user_id, prompt: str, platform: str, user=None) -> "str | dict":
"""(#873 薄壳)复用 Web Claw 同一份 EngineCore 生成 IM 回复。
单引擎原则:IM Bot 不再维护第二份「模型 failover + 技能/MCP 执行」实现,
而是把已发现的 skill_ids/mcp_ids 交给 _send_message_core,经 BufferAdapter
缓冲装配成整条回复(媒体结构 __media__ 由 BufferAdapter 原样保留)。
默认不启用(见 _call_real_llm 中的 IM_USE_ENGINE_CORE 灰阶门)。
"""
from app.api.chat_service import _send_message_core
from app.api.chat_utils import SendMessageRequest
from app.core.claw_adapters import BufferAdapter
from ..models import Conversation, User
# 解析用户:IM 调用方常传 user=None,仅给 user_id
real_user = user
if real_user is None and user_id:
real_user = db.query(User).filter(User.id == user_id).first()
if real_user is None:
return "(EngineCore 模式:无法解析当前用户,请检查 IM 账号绑定)"
# 能力发现(与 IM 自包含逻辑同一套,claw="im")
from app.core.capability_discovery import (
discover_mcp_servers,
discover_skills,
)
_query_hint = prompt
_marker = "用户当前问题:"
if _marker in prompt:
_query_hint = prompt[prompt.rfind(_marker) + len(_marker):].strip()
skills = []
try:
skills = discover_skills(db, user_id, query=_query_hint, claw="im") or []
except Exception as _e:
logger.warning(f"[im_llm.engine] 技能发现失败(降级为空): {_e}")
mcp_servers = []
try:
mcp_servers = discover_mcp_servers(db, None, claw="im") or []
except Exception as _e:
logger.warning(f"[im_llm.engine] MCP 发现失败(降级为空): {_e}")
skill_ids = [
(s.get("id") if isinstance(s, dict) else getattr(s, "id", None))
for s in skills
]
skill_ids = [s for s in skill_ids if s]
mcp_ids = [
(m.get("id") if isinstance(m, dict) else getattr(m, "id", None))
for m in mcp_servers
]
mcp_ids = [m for m in mcp_ids if m]
# 构造本回合专用对话:EngineCore 要求已存在且归属当前用户的 Conversation
conv = Conversation(
id=gen_uuid(),
user_id=real_user.id,
title=f"IM对话-{platform}",
source="im_bot",
)
db.add(conv)
db.commit()
req = SendMessageRequest(
content=prompt,
skill_ids=skill_ids or None,
mcp_ids=mcp_ids or None,
use_tools=bool(skill_ids or mcp_ids),
agent_ids=None,
knowledge_base_ids=None,
attachments=None,
)
adapter = BufferAdapter(tenant_id=str(getattr(real_user, "tenant_id", "") or ""))
await _send_message_core(conv.id, req, db, real_user, stream=True, on_event=adapter)
return adapter.assemble()
+2 -2
View File
@@ -502,8 +502,8 @@ def _parse_llm_graph_response(content: str):
except json.JSONDecodeError:
pass
# 方式2: 提取```json ... ```代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', content)
# 方式2: 提取\"\"\"json ... \"\"\"代码块
json_match = re.search(r'\"\"\"(?:json)?\s*([\s\S]*?)\"\"\"', content)
if json_match:
try:
result = json.loads(json_match.group(1))
+2 -2
View File
@@ -59,7 +59,7 @@ def _sanitize_skill_content(text: str) -> str:
if "<script" in low and t.count("<div") > 3:
return ""
# 接受携带任一 markdown / skill 标记的内容
markers = ("#", "---", "name:", "description:", "```", "##")
markers = ("#", "---", "name:", "description:", "\"\"\"", "##")
if not any(m in t for m in markers):
return ""
return t
@@ -79,7 +79,7 @@ def _extract_skill_from_html(html: str, base_url: str) -> str:
"""从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
proxy = _skill_http_proxy()
# 1) fenced code block
fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
fences = re.findall(r"\"\"\"(?:markdown|md|yaml|json)?\s*\n(.*?)\"\"\"", html, re.DOTALL | re.IGNORECASE)
for f in fences:
if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
return f.strip()
+7
View File
@@ -1,3 +1,4 @@
from app.api.chat_service import get_user_balance
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc
@@ -38,6 +39,12 @@ class UpdateApiKeyRequest(BaseModel):
status: str # enabled, disabled, revoked
@router.get("/balance")
async def get_balance(user=Depends(get_current_user)):
balance = await get_user_balance(user["id"])
return {"code": 0, "data": balance, "message": "ok"}
@router.get("/")
def get_profile(
db: Session = Depends(get_db),
+97
View File
@@ -0,0 +1,97 @@
"""
Phase 1 #1A: Skills API Router
POST /api/skills/ppt/generate - Generate PPTX
GET /api/skills/ppt/download/{instance_id} - Download PPTX
POST /api/skills/seo/analyze - SEO analysis
POST /api/skills/research/generate - Investment research
GET /api/skills/status/{instance_id} - Check execution status
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.auth import get_current_user, get_current_tenant
from app.skill_runtime import PPTSkillRuntime, SEOSkillRuntime, ResearchSkillRuntime
from app.database import SessionLocal
import os
router = APIRouter(tags=["技能执行"])
class PPTRequest(BaseModel):
content: str
template: str = "business"
slides: int = 5
class SEORequest(BaseModel):
url: str = ""
keywords: str = ""
class ResearchRequest(BaseModel):
symbol: str
@router.post("/ppt/generate")
async def generate_ppt(req: PPTRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
"""Generate PPT from text content using AI + python-pptx."""
runtime = PPTSkillRuntime(
skill_id="ppt-generator",
user_id=user["id"],
tenant_id=tenant
)
result = await runtime.execute({"content": req.content, "template": req.template, "slides": req.slides})
return {"code": 0, "data": result, "message": "PPT generated" if result["status"] == "ready" else "Failed"}
@router.get("/ppt/download/{instance_id}")
async def download_ppt(instance_id: str, user=Depends(get_current_user)):
"""Download generated PPTX file."""
from sqlalchemy import text
db = SessionLocal()
row = db.execute(text(
"SELECT result FROM skill_instances WHERE id = :id AND user_id = :uid"
), {"id": instance_id, "uid": user["id"]}).fetchone()
db.close()
if not row:
raise HTTPException(404, "Instance not found")
# Find workspace path
ws = f"/app/workspace/*/{user['id']}/ppt-generator/{instance_id}/output.pptx"
import glob
matches = glob.glob(ws)
if matches:
return FileResponse(matches[0], media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation",
filename="presentation.pptx")
raise HTTPException(404, "PPTX file not found")
@router.post("/seo/analyze")
async def analyze_seo(req: SEORequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
"""Analyze SEO for a URL/keyword."""
runtime = SEOSkillRuntime(
skill_id="seo-optimizer",
user_id=user["id"],
tenant_id=tenant
)
result = await runtime.execute({"url": req.url, "keywords": req.keywords})
return {"code": 0, "data": result, "message": "SEO analysis complete"}
@router.post("/research/generate")
async def generate_research(req: ResearchRequest, user=Depends(get_current_user), tenant=Depends(get_current_tenant)):
"""Generate investment research report."""
runtime = ResearchSkillRuntime(
skill_id="investment-research",
user_id=user["id"],
tenant_id=tenant
)
result = await runtime.execute({"symbol": req.symbol})
return {"code": 0, "data": result, "message": "Research generated"}
@router.get("/status/{instance_id}")
async def get_skill_status(instance_id: str, user=Depends(get_current_user)):
"""Check skill execution status."""
from sqlalchemy import text
db = SessionLocal()
row = db.execute(text(
"SELECT status, result, error, created_at FROM skill_instances WHERE id = :id AND user_id = :uid"
), {"id": instance_id, "uid": user["id"]}).fetchone()
db.close()
if not row:
raise HTTPException(404, "Instance not found")
return {"code": 0, "data": {"status": row[0], "result": row[1], "error": row[2],
"created_at": str(row[3])}}
+27 -2
View File
@@ -12,12 +12,35 @@ from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
# Phase0: 5-level RBAC
ROLE_HIERARCHY = {
"super_admin": 100,
"admin": 80,
"developer": 50,
"user": 30,
"guest": 10,
}
def has_role(user_role: str, required: str) -> bool:
return ROLE_HIERARCHY.get(user_role, 0) >= ROLE_HIERARCHY.get(required, 0)
logger = logging.getLogger(__name__)
security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False)
async def get_current_tenant(
credentials: HTTPAuthorizationCredentials = Depends(security),
):
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
return payload.get("tenant_id")
except JWTError:
return None
def hash_password(password: str) -> str:
"""使用 bcrypt 直接生成密码哈希"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
@@ -35,8 +58,10 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None, tenant_id: str = None) -> str:
to_encode = data.copy()
if tenant_id:
to_encode["tenant_id"] = tenant_id
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@@ -97,6 +122,6 @@ async def get_current_admin(
user=Depends(get_current_user),
):
"""验证当前用户为管理员"""
if user.role != "admin":
if not has_role(user.role, "admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
+3 -2
View File
@@ -98,10 +98,11 @@ def resolve_auto_model(capability: str, db: Session) -> Optional[AIModel]:
# 媒体类能力(image/video/audio):无显式配置时自动发现任一启用的模型
# 避免用户未在后台配置"能力默认模型"时 Auto 回退到文本模型导致"无法生成图片/视频"
if capability in ("image", "video", "audio"):
from sqlalchemy import text as _text, func
from sqlalchemy import text as _text, func, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
m = db.query(AIModel).filter(
AIModel.is_enabled == True,
func.json_contains(AIModel.capabilities, f'"{capability}"'),
AIModel.capabilities.cast(JSONB).contains(type_coerce([capability], JSONB)),
).first()
if m:
return m
+2 -2
View File
@@ -1293,7 +1293,7 @@ def _build_system_prompt(
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 \"\"\" 代码块包裹。避免冗余空行,保持紧凑、美观、易读。")
@@ -2749,7 +2749,7 @@ def ensure_structured_column():
with engine.connect() as conn:
exists = conn.execute(text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() AND table_name='conversation_summaries' "
"WHERE table_schema = CURRENT_DATABASE() AND table_name='conversation_summaries' "
"AND column_name='structured_memory'"
)).scalar()
if not exists:
+6 -1
View File
@@ -15,7 +15,12 @@ if not (Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()):
load_dotenv(env_path, override=False)
# 数据库配置
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
# PostgreSQL preferred when DATABASE_URL is provided (production); else legacy MySQL builder (local/dev + rollback)
_DATABASE_URL_ENV = os.getenv("DATABASE_URL", "")
if _DATABASE_URL_ENV:
DATABASE_URL = _DATABASE_URL_ENV
else:
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
# Redis配置
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
@@ -0,0 +1,371 @@
"""统一能力发现层(双 Claw 共用 · 单一实现)。
背景
----
七虎平台对外有两个 Claw 端口:
* Web Claw —— SSE 流式,用户可在 UI 勾选 skills / mcp / agents
* IM Bot Claw —— 微信等 IM,server push 整条回复,**没有任何勾选 UI**
历史问题(2026-08-04 线上实证):
1. `im_llm.py` 写死 `agents=[]` / `mcp=[]`,IM 端永远拿不到 MCP 工具;
2. `_discover_installed_skills()` 强依赖 `app_installations` 表,而线上该表
**0 行** → 97 个 published skill 在 Web / IM **双端同时发现不到**;
3. IM 端 skills 还被 `[:15]` 截断(截断一个空列表,属噪声)。
设计原则
--------
能力发现只允许有 **一份实现**(本模块)。任何 Claw 想要"技能/工具",
都必须调用这里的函数;禁止在端侧另写一套发现逻辑——历史上 #866 部署
就是因为存在第二份实现而把 #764/#765 的注入改动整体覆盖回退。
发现策略(三级降级,双端一致)
--------------------------
1) 用户显式安装(app_installations)—— 最高优先,尊重用户意愿
2) 无安装记录 → 降级为平台已发布(status='published')全集
3) 池子超过预算 → 按当前问题做轻量相关性排序,取 top-K 控制 token
运维旋钮(环境变量,改完重启生效)
--------------------------------
CAP_SKILL_LIMIT 单轮注入技能数上限,默认 12
CAP_MCP_LIMIT 单轮注入 MCP 服务数上限,默认 8
CAP_AGENT_LIMIT 单轮可见 Agent 数上限,默认 6
CAP_SKILL_FALLBACK_PUBLISHED 安装表为空时是否降级为 published,默认 开
CAP_AGENT_FALLBACK_PUBLISHED Agent 同上,默认 开
CAP_MCP_AUTO 未显式指定时是否自动挂载已启用 MCP,默认 开
"""
from __future__ import annotations
import logging
import os
import re
from typing import Any, Iterable
logger = logging.getLogger(__name__)
__all__ = [
"discover_skills",
"discover_mcp_servers",
"discover_agents",
"build_mcp_tool_defs",
"rank_by_query",
]
# --------------------------------------------------------------------------
# 环境旋钮
# --------------------------------------------------------------------------
def _env_int(name: str, default: int) -> int:
try:
raw = (os.environ.get(name) or "").strip()
return int(raw) if raw else default
except Exception:
return default
def _env_flag(name: str, default: bool = True) -> bool:
raw = (os.environ.get(name) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
# --------------------------------------------------------------------------
# 轻量相关性排序(中文友好:整词 + 2-gram)
# --------------------------------------------------------------------------
def _tokens(text: str) -> set:
text = re.sub(r"[^\w\u4e00-\u9fff]+", " ", (text or "").lower())
out: set = set()
for w in text.split():
if len(w) >= 2:
out.add(w)
for i in range(len(w) - 1):
out.add(w[i : i + 2])
return out
def rank_by_query(items: list, query: str, top_k: int) -> list:
"""按与 query 的词元重叠度排序取前 top_k。
池子本来就不超预算时原样返回,避免无谓抖动(同一问题两次调用结果稳定)。
"""
if not items:
return []
if top_k <= 0 or len(items) <= top_k:
return items[:top_k] if top_k > 0 else []
q = _tokens(query)
if not q:
return items[:top_k]
scored = []
for idx, it in enumerate(items):
blob = " ".join(
str(it.get(k) or "")
for k in ("name", "description", "category", "func_name")
)
t = _tokens(blob)
if t:
# 交集数按文档长度做平方根归一,抑制"长描述天然占优"
score = len(q & t) / (len(t) ** 0.5)
else:
score = 0.0
# idx 作为稳定次序的 tie-breaker,保证同分时结果可复现
scored.append((-score, idx, it))
scored.sort(key=lambda x: (x[0], x[1]))
return [it for _, _, it in scored[:top_k]]
# --------------------------------------------------------------------------
# 技能发现
# --------------------------------------------------------------------------
def discover_skills(
db,
user_id: str | None,
query: str = "",
limit: int | None = None,
claw: str = "web",
) -> list:
"""发现当前请求可用的技能列表(双 Claw 共用)。
返回 `_resolve_market_skills` 的结构:
[{id, name, description, skill_content, category, func_name, parameters}]
"""
limit = limit if limit is not None else _env_int("CAP_SKILL_LIMIT", 12)
if limit <= 0:
return []
# 延迟导入:避免 app.core ←→ app.api 循环依赖
from app.api.chat_skill import _resolve_market_skills
from app.models import AppInstallation, MarketApp
pool: list = []
source = "empty"
# 1) 用户显式安装
if user_id:
try:
app_ids = [
i.app_id
for i in db.query(AppInstallation)
.filter(AppInstallation.user_id == user_id)
.all()
]
if app_ids:
ids = [
a.id
for a in db.query(MarketApp)
.filter(
MarketApp.id.in_(app_ids),
MarketApp.type == "skill",
MarketApp.status == "published",
)
.all()
]
if ids:
pool = _resolve_market_skills(db, ids, user_id)
source = "installed"
except Exception as e:
logger.warning("[cap] 安装技能发现失败(降级继续): %s", e)
# 2) 降级:平台已发布全集
if not pool and _env_flag("CAP_SKILL_FALLBACK_PUBLISHED", True):
try:
ids = [
a.id
for a in db.query(MarketApp)
.filter(MarketApp.type == "skill", MarketApp.status == "published")
.all()
]
if ids:
pool = _resolve_market_skills(db, ids, user_id or "")
source = "published"
except Exception as e:
logger.warning("[cap] published 技能发现失败: %s", e)
# 3) 预算裁剪
picked = rank_by_query(pool, query, limit)
logger.info(
"[cap] skills claw=%s source=%s pool=%d picked=%d",
claw,
source,
len(pool),
len(picked),
)
return picked
# --------------------------------------------------------------------------
# MCP 发现
# --------------------------------------------------------------------------
def discover_mcp_servers(
db,
explicit_ids: Iterable[str] | None = None,
limit: int | None = None,
claw: str = "web",
) -> list:
"""发现当前请求可用的 MCP 服务连接(双 Claw 共用)。
Web Claw 传 explicit_ids(用户在 UI 勾选的);
IM Bot Claw 无勾选通道 → 传 None,自动挂载平台已启用的 MCP。
返回 [{id, name, description, transport, endpoint, url, headers}]
"""
limit = limit if limit is not None else _env_int("CAP_MCP_LIMIT", 8)
if limit <= 0:
return []
from app.api.chat_mcp import _list_enabled_mcp_servers, _resolve_active_mcp_servers
ids = [i for i in (explicit_ids or []) if i]
if ids:
out = _resolve_active_mcp_servers(db, ids)[:limit]
logger.info("[cap] mcp claw=%s source=explicit picked=%d", claw, len(out))
return out
if not _env_flag("CAP_MCP_AUTO", True):
return []
out = _list_enabled_mcp_servers(db)[:limit]
logger.info("[cap] mcp claw=%s source=auto picked=%d", claw, len(out))
return out
# --------------------------------------------------------------------------
# Agent 发现(#874:画布节点候选 / IM `/flow` 可选清单)
# --------------------------------------------------------------------------
def discover_agents(
db,
user_id: str | None,
query: str = "",
explicit_ids: Iterable[str] | None = None,
limit: int | None = None,
claw: str = "web",
) -> list:
"""发现当前请求可用的智能体(双 Claw 共用),策略与 discover_skills 同构。
返回 `_resolve_market_agents` 的结构:
[{id, name, role_prompt, instructions, skill_ids, knowledge_base_ids,
model_id, tools, description, tool_policy, platform, agent_endpoint,
agent_api_key, orchestration}]
用途定位:给 NodeRegistry 生成 agent 型节点候选、给 IM `/flow` 列可选智能体。
若要拿它往 system prompt 里注入,请自行收窄 limit —— 多个 Agent 人格叠加会串味。
"""
limit = limit if limit is not None else _env_int("CAP_AGENT_LIMIT", 6)
if limit <= 0:
return []
from app.api.chat_skill import _resolve_market_agents
from app.models import AppInstallation, MarketApp
# 0) 显式指定(Web Claw 勾选 / 画布已引用)—— 不做相关性裁剪,用户意愿最高
ids = [i for i in (explicit_ids or []) if i]
if ids:
out = _resolve_market_agents(db, ids, user_id or "")
logger.info("[cap] agents claw=%s source=explicit picked=%d", claw, len(out))
return out
pool: list = []
source = "empty"
# 1) 用户显式安装
if user_id:
try:
app_ids = [
i.app_id
for i in db.query(AppInstallation)
.filter(AppInstallation.user_id == user_id)
.all()
]
if app_ids:
aids = [
a.id
for a in db.query(MarketApp)
.filter(
MarketApp.id.in_(app_ids),
MarketApp.type == "agent",
MarketApp.status == "published",
)
.all()
]
if aids:
pool = _resolve_market_agents(db, aids, user_id)
source = "installed"
except Exception as e:
logger.warning("[cap] 安装 Agent 发现失败(降级继续): %s", e)
# 2) 降级:平台已发布全集
if not pool and _env_flag("CAP_AGENT_FALLBACK_PUBLISHED", True):
try:
aids = [
a.id
for a in db.query(MarketApp)
.filter(MarketApp.type == "agent", MarketApp.status == "published")
.all()
]
if aids:
pool = _resolve_market_agents(db, aids, user_id or "")
source = "published"
except Exception as e:
logger.warning("[cap] published Agent 发现失败: %s", e)
# 3) 预算裁剪
picked = rank_by_query(pool, query, limit)
logger.info(
"[cap] agents claw=%s source=%s pool=%d picked=%d",
claw,
source,
len(pool),
len(picked),
)
return picked
# --------------------------------------------------------------------------
# MCP → function definitions
# --------------------------------------------------------------------------
def _safe_name(raw: str, maxlen: int) -> str:
return re.sub(r"[^A-Za-z0-9_-]", "_", (raw or "")[:maxlen])
async def build_mcp_tool_defs(servers: list, idx_start: int = 0) -> tuple:
"""把 MCP 服务列表展开为 (function_definitions, tool_map)。
命名规则与 Web Claw 现有实现严格一致:`mcp__{server}__{tool}__{idx}`,
保证两端产生的工具名可互相对照、可被同一套审批/统计逻辑识别。
返回:
function_definitions: [{name, description, parameters}]
tool_map: {func_name: {server_id, name, server_name}}
"""
from app.mcp_client import list_mcp_tools
defs: list = []
tool_map: dict = {}
for offset, srv in enumerate(servers or []):
idx = idx_start + offset
try:
tools = await list_mcp_tools(srv)
except Exception as e:
logger.warning("[cap] MCP 拉取工具失败 [%s]: %s", srv.get("name"), e)
continue
for t in tools or []:
sname = _safe_name(srv.get("name") or "unknown", 30)
tname = _safe_name(t.get("name") or "tool", 50)
fname = f"mcp__{sname}__{tname}__{idx}"
tool_map[fname] = {
"server_id": srv.get("id"),
"name": t.get("name"),
"server_name": srv.get("name"),
}
defs.append(
{
"name": fname,
"description": (
f"[MCP:{srv.get('name')}] {t.get('description') or t.get('name') or ''}"
)[:900],
"parameters": t.get("inputSchema")
or {"type": "object", "properties": {}},
}
)
return defs, tool_map
+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})
+159 -40
View File
@@ -1,9 +1,9 @@
"""Shared, standards-based LLM client for web chat + IM bot.
平台红线(07-24):模型层以成熟/开源标准为设计基准。本模块使用官方 OpenAI SDK
(`openai.AsyncOpenAI`)——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(`/v1/chat/completions`),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(`stream=True`);
(\"openai.AsyncOpenAI\")——它是 OpenAI 兼容端点的事实标准客户端。平台本身已在讲
OpenAI 协议(\"/v1/chat/completions\"),因此零协议改造成本,并天然获得:
* 原生 SSE 流式(\"stream=True\");
* 结构化的 tool_calls / usage 解析;
* 统一的代理(trust_env=False + 显式 proxy)与超时处理。
@@ -13,13 +13,14 @@ IM bot 共用**同一模型层**、同一套 failover/代理/超时语义——
API:
achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False)
- stream=False -> dict,形状对齐上游 `data` 响应:
- stream=False -> dict,形状对齐上游 \"data\" 响应:
{"choices":[{"message":{"content","tool_calls","reasoning_content"}}], "usage":{...}}
- stream=True -> async generator,逐块产出 ("content"|"reasoning", text) 增量
"""
from __future__ import annotations
import httpx
import asyncio
from openai import AsyncOpenAI, OpenAI
@@ -32,16 +33,35 @@ def _base_url(api_url: str) -> str:
return u
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120) -> AsyncOpenAI:
def build_openai_client(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> AsyncOpenAI:
base = _base_url(api_url)
# 始终 trust_env=False:避免容器 HTTP_PROXY 对国内端点(siliconflow 等)造成 502;
# 代理经 proxy 显式传入(AGNES 等),未传则直连。
http_client = httpx.AsyncClient(proxy=proxy, trust_env=False)
# 故障快速失败(全栈统一,覆盖 web chat / IM bot / embedding):
# connect 封顶 10s —— 坏端点不再让请求挂死事件循环;
# read/write 封顶 30s —— 长流式回复只要持续有分块即不受影响;
# 保留数值型 timeout 接口,旧调用方无需改动。
_num = float(timeout) if isinstance(timeout, (int, float)) else 120.0
_to = httpx.Timeout(
connect=min(10.0, _num),
read=min(30.0, _num),
write=min(30.0, _num),
pool=10.0,
)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return AsyncOpenAI(
base_url=base,
api_key=api_key or "EMPTY",
timeout=timeout,
timeout=_to,
http_client=http_client,
default_headers=_hdrs if _hdrs else None,
)
@@ -63,6 +83,11 @@ def _create_kwargs(request_body: dict, stream: bool = False) -> dict:
if tools:
kw["tools"] = tools
kw["tool_choice"] = request_body.get("tool_choice", "auto")
# 2026-07-30: 将未在白名单的额外字段(如 enable_thinking)作为 extra_body 传递
_whitelist = {"model", "messages", "stream", "temperature", "top_p", "max_tokens", "stop", "tools", "tool_choice"}
_extras = {k: v for k, v in request_body.items() if k not in _whitelist}
if _extras:
kw["extra_body"] = _extras
return kw
@@ -84,20 +109,31 @@ def _normalize_tool_calls(tcs):
return out
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False):
async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, stream=False, auth_header=None):
"""统一的 OpenAI 兼容聊天补全。详见模块 docstring。"""
if stream:
async def _gen():
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
_content_parts = []
_reasoning_parts = []
_tool_acc = {} # index -> {id, type, name, arguments}
try:
gen = await client.chat.completions.create(
**_create_kwargs(request_body, stream=True)
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
gen = await asyncio.wait_for(
client.chat.completions.create(
**_create_kwargs(request_body, stream=True)
),
timeout=_to,
)
async for chunk in gen:
_it = gen.__aiter__()
while True:
try:
chunk = await asyncio.wait_for(_it.__anext__(), timeout=_to)
except StopAsyncIteration:
break
except asyncio.TimeoutError:
raise TimeoutError("stream chunk read timeout")
if not chunk.choices:
continue
delta = chunk.choices[0].delta
@@ -129,7 +165,10 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
if _fn_args:
_slot["arguments"] += _fn_args
finally:
await client.close()
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
# 流式结束时吐出已组装的完整 message(含 tool_calls),供调用方无缝衔接工具循环
_tool_calls = None
if _tool_acc:
@@ -149,9 +188,13 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
return _gen()
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
async with client:
resp = await client.chat.completions.create(**_create_kwargs(request_body))
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(30.0, float(timeout) if isinstance(timeout, (int, float)) else 120.0)
resp = await asyncio.wait_for(
client.chat.completions.create(**_create_kwargs(request_body)),
timeout=_to,
)
msg = resp.choices[0].message
return {
"choices": [
@@ -165,38 +208,105 @@ async def achat_complete(api_url, api_key, request_body, *, proxy=None, timeout=
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120) -> OpenAI:
def build_openai_client_sync(api_url, api_key, *, proxy=None, timeout=120, auth_header=None) -> OpenAI:
"""同步客户端工厂(供 IM bot 等同步上下文复用同一标准模型层)。"""
base = _base_url(api_url)
http_client = httpx.Client(proxy=proxy, trust_env=False)
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client)
_hdrs = {}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
_hdrs[_k.strip()] = _v.strip()
elif auth_header:
_hdrs[auth_header.strip()] = api_key
return OpenAI(base_url=base, api_key=api_key or "EMPTY", timeout=timeout, http_client=http_client, default_headers=_hdrs if _hdrs else None)
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
with client:
resp = client.chat.completions.create(**_create_kwargs(request_body))
msg = resp.choices[0].message
return {
"choices": [
{
"message": {
"content": msg.content or "",
"tool_calls": _normalize_tool_calls(getattr(msg, "tool_calls", None)),
"reasoning_content": getattr(msg, "reasoning_content", None),
}
def chat_complete(api_url, api_key, request_body, *, proxy=None, timeout=120, auth_header=None):
"""同步版(与 achat_complete 行为/返回形状完全一致),供非 async 上下文使用。
2026-07-30 重写:直接使用 requests 替代 OpenAI SDK/httpx。
OpenAI SDK 的 httpx 连接池在慢长轮询(如 iLink QR 状态 12-19s)后会出现
Connection error,已实测复现。requests 库没有这个问题。
"""
import requests
import base64
import random
body = dict(request_body)
body["stream"] = False
# enable_thinking 等额外字段已在 request_body 中
# Headers: Bearer token + X-WECHAT-UIN (兼容 iLink)
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}" if api_key else "Bearer empty",
}
if auth_header and ":" in auth_header:
_k, _v = auth_header.split(":", 1)
headers[_k.strip()] = _v.strip()
else:
# X-WECHAT-UIN 防重放
headers["X-WECHAT-UIN"] = base64.b64encode(str(random.randint(0, 2**32-1)).encode()).decode()
# 2026-07-31 修复根因: proxy 为 None 时传 proxies={} 而非 None。
# requests 在 proxies=None 时会继续读取环境 HTTP_PROXY(trust_env 默认 True),
# 而 uvicorn 进程(run.sh)带有 HTTP_PROXY=172.18.0.1:7890, 该代理无法解析
# Docker 内部域名(如 qihu-aiflow-litellm), 导致所有内部模型调用静默失败、返回空。
# 显式 proxies={} + trust_env=False 可完全绕过环境代理, 使 _proxy_for_url 的
# 决策(返回 None=不代理)真正生效。
_proxies = {"http": proxy, "https": proxy} if proxy else {}
_session = requests.Session()
_session.trust_env = False
last_body = {}
for attempt in range(2):
try:
resp = _session.post(
api_url.rstrip("/"),
headers=headers,
json=body,
proxies=_proxies,
timeout=timeout,
)
try:
last_body = resp.json() if resp.content else {}
except Exception:
last_body = {}
if resp.ok:
import logging as _l6
_l6.warning(f'[chat_complete] body keys={list(last_body.keys()) if isinstance(last_body, dict) else type(last_body)}, content_len={len(resp.content)}, content_preview={str(resp.content)[:200]}')
return _shape_response(last_body)
except Exception:
pass
return {"choices": [{"message": {"content": "", "tool_calls": None, "reasoning_content": None}}]}
def _shape_response(body: dict) -> dict:
"""Normalize OpenAI-compatible response to standard shape."""
choices = body.get("choices", [])
import logging as _l7
if choices:
msg = choices[0].get("message", {})
result = {
"choices": [{
"message": {
"content": msg.get("content") or "",
"tool_calls": _normalize_tool_calls(msg.get("tool_calls")),
"reasoning_content": msg.get("reasoning_content") or "",
}
],
"usage": (resp.usage.model_dump() if resp.usage else {}),
}]
}
_l7.warning(f'[_shape] content_len={len(result["choices"][0]["message"]["content"])}, keys={list(result["choices"][0]["message"].keys())}')
return result
_l7.warning(f'[_shape] NO choices in body, keys={list(body.keys())[:5]}')
return body
# ---------------------------------------------------------------------------
# 嵌入(Embeddings):OpenAI 兼容 /v1/embeddings,收敛散落的裸 httpx 嵌入调用
# ---------------------------------------------------------------------------
def _embed_kwargs(input, model):
return {"model": model, "input": input if isinstance(input, list) else [input]}
@@ -207,15 +317,24 @@ async def aembed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> l
供 pgvector_search.get_embedding 等 embeddings 路径复用,把散落的裸 httpx
嵌入调用收敛到同一标准模型层(与聊天共用代理/超时/failover 语义)。
"""
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout)
async with client:
resp = await client.embeddings.create(**_embed_kwargs(input, model))
client = build_openai_client(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
try:
_to = min(60.0, float(timeout) if isinstance(timeout, (int, float)) else 60.0)
resp = await asyncio.wait_for(
client.embeddings.create(**_embed_kwargs(input, model)),
timeout=_to,
)
return [d.embedding for d in resp.data]
finally:
try:
await asyncio.wait_for(client.close(), timeout=5)
except Exception:
pass
def embed(api_url, api_key, input, model, *, proxy=None, timeout=60) -> list:
"""同步版嵌入(与非 async 上下文复用同一标准模型层)。"""
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout)
client = build_openai_client_sync(api_url, api_key, proxy=proxy, timeout=timeout, auth_header=auth_header)
with client:
resp = client.embeddings.create(**_embed_kwargs(input, model))
return [d.embedding for d in resp.data]
+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
+2 -2
View File
@@ -6,7 +6,7 @@ Qihu-AIFlow 统一可观测基座 (Phase 2.1 + Phase 1.I OTel 增强)
设计原则(Strangler Fig 增量):
- 纯增量埋点,不改任何既有接口的返回结构。
- 向后兼容现有 `logging.getLogger(__name__)` 用法:trace_id 由 root logger 的
- 向后兼容现有 \"logging.getLogger(__name__)\" 用法:trace_id 由 root logger 的
TraceIdFilter 自动注入,调用方无需改动。
- 不引入第三方依赖(structlog 未安装),自带可选的 JSON formatter。
@@ -58,7 +58,7 @@ def get_trace_id() -> str:
class TraceIdFilter(logging.Filter):
"""为每一条日志记录注入 trace_id 属性(不存在则写为 '-')。
挂载到 root logger 后,所有 `getLogger(__name__)` 的子 logger 都会继承,
挂载到 root logger 后,所有 \"getLogger(__name__)\" 的子 logger 都会继承,
从而让全平台既有日志自动带上链路 ID,零侵入。
"""
+157 -49
View File
@@ -7,6 +7,18 @@
- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
#874 变更
--------
节点类型不再硬编码在本模块,改由 `app.core.node_registry` 统一持有:
* 合法类型 -> registry.node_types()(同时供 yaml_workflow 校验,单一真相源)
* 节点执行 -> registry.get(type).run(NodeContext),本模块只负责拓扑与调度
* 双 Claw 对齐 -> run_graph(on_event=...) 把节点过程发成统一 EngineEvent,
Web(SseAdapter) 与 IM(BufferAdapter) 吃同一份事件流
原有 llm/local/external/reduce 语义逐字保留(节点实现内部仍调用本模块的
_llm_complete / _run_local_agent_turn),新增 skill/mcp/agent/loop 四型。
"""
from __future__ import annotations
@@ -17,9 +29,20 @@ from typing import Any, Optional
import httpx
from app.core.engine_event import STEP_THINKING, make_step
from app.core.node_registry import (
NodeContext,
NodeError,
StepCounter,
normalize_claw,
registry,
)
logger = logging.getLogger("aiflow.orchestrator")
VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
# 兼容导出:历史调用方直接 import 了这个名字。真正的校验一律走 registry.node_types(),
# 以便运行期注册的第三方节点立即生效(本常量只是 import 期快照)。
VALID_NODE_TYPES = registry.node_types()
class OrchestrationError(ValueError):
@@ -50,12 +73,25 @@ def validate_graph(graph: Any) -> None:
raise OrchestrationError(f"节点 id 重复: {nid}")
ids.add(nid)
ntype = n.get("type")
if ntype not in VALID_NODE_TYPES:
valid = registry.node_types()
if ntype not in valid:
raise OrchestrationError(
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(valid)})"
)
if ntype in ("local", "external") and not n.get("agent_id"):
if registry.requires_agent(ntype) and not n.get("agent_id"):
raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
if ntype == "skill" and not (n.get("skill_id") or n.get("func_name")):
raise OrchestrationError(f"节点 {nid} 类型 skill 必须指定 skill_id 或 func_name")
if ntype == "mcp" and not n.get("tool"):
raise OrchestrationError(f"节点 {nid} 类型 mcp 必须指定 tool")
if ntype == "loop":
body = n.get("body")
if not isinstance(body, dict) or not body.get("type"):
raise OrchestrationError(f"节点 {nid} 类型 loop 必须提供含 type 的 body")
if body["type"] not in valid:
raise OrchestrationError(f"节点 {nid} 的 body 类型非法: {body['type']}")
if body["type"] == "loop":
raise OrchestrationError(f"节点 {nid} 的 body 不允许再嵌套 loop")
for i, e in enumerate(edges):
if not isinstance(e, dict):
@@ -275,12 +311,33 @@ async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_
# ---------------------------------------------------------------------------
# 单节点执行(含独立 DB 会话,保证并发安全)
# ---------------------------------------------------------------------------
async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
from app.api.chat_skill import _resolve_market_agents
async def _run_node(
node,
graph,
blackboard,
query,
user_id,
conv_id,
trace_id,
*,
on_event=None,
claw_type: str = "",
tenant_id: str = "",
counter: Optional[StepCounter] = None,
snapshot=None,
) -> dict:
"""执行单节点:拼装上游输入 → 交给 registry 里的节点实现 → 回填黑板结构。
本函数不再包含任何节点类型分支 —— 新增能力只需注册新节点,无需改调度层。
"""
from app.database import SessionLocal
nid = node["id"]
ntype = node["type"]
impl = registry.get(ntype)
if impl is None:
raise OrchestrationError(f"未知节点类型 {ntype}")
preds = _predecessors(graph, nid)
pred_results = [blackboard[p] for p in preds]
is_entry = not preds
@@ -298,66 +355,104 @@ async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id)
upstream = ""
with SessionLocal() as db:
if ntype in ("llm", "reduce"):
if ntype == "llm":
sys_prompt = node.get("system_prompt") or (
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
"只输出处理后的结果,不要多余解释。"
)
else:
sys_prompt = node.get("system_prompt") or (
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
)
uc = _assemble_node_input(query, upstream, is_entry)
text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
if ntype == "local":
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
if not agents:
raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
ni = _assemble_node_input(query, upstream, is_entry)
res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
return {
"ok": True,
"node_id": nid,
"node_type": "local",
"output": res["text"],
"used_tools": res.get("used_tools"),
}
if ntype == "external":
from app.api.chat_agents import _call_external_agent
agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
if not agents:
raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
ni = _assemble_node_input(query, upstream, is_entry)
answer, _ = await _call_external_agent(
db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
)
return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
raise OrchestrationError(f"未知节点类型 {ntype}")
ctx = NodeContext(
db=db,
node=node,
graph=graph,
blackboard=blackboard,
query=query,
upstream=upstream,
is_entry=is_entry,
user_id=user_id or "",
conv_id=conv_id,
trace_id=str(trace_id or ""),
tenant_id=str(tenant_id or ""),
claw_type=claw_type or "",
snapshot=snapshot,
on_event=on_event,
counter=counter or StepCounter(),
)
try:
result = await impl.run(ctx)
except NodeError as e:
# 归一为 OrchestrationError,run_graph 的 on_error=halt|continue 语义不变
raise OrchestrationError(str(e)) from e
return result.to_blackboard()
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
async def _emit(on_event, event) -> None:
"""图级事件发射(失败安全 —— 观测不得拖垮编排)。"""
if on_event is None:
return
try:
res = on_event(event)
if asyncio.iscoroutine(res) or isinstance(res, asyncio.Future):
await res
except Exception as e:
logger.debug("[orchestrator] emit 失败(忽略): %s", e)
async def run_graph(
graph,
query,
db,
user_id,
conv_id,
trace_id=None,
*,
on_event=None,
claw_type: str = "",
tenant_id: str = "",
snapshot=None,
):
"""执行编排图,返回 (answer: str, trace: dict)。
- 拓扑分层后逐层 asyncio.gather 并行执行。
- 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
- on_event 非空时,图级与节点级过程以统一 EngineEvent 推送:
Web Claw 传 SseAdapter(逐帧渲染),IM Claw 传 BufferAdapter(整条装配)。
两端共用同一份事件流,IM 侧无需任何编排专属逻辑。
"""
validate_graph(graph)
layers = topological_layers(graph)
blackboard: dict = {}
trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
claw = normalize_claw(claw_type)
counter = StepCounter()
node_count = len(graph.get("nodes") or [])
await _emit(
on_event,
make_step(
STEP_THINKING,
text=f"开始编排执行:{node_count} 个节点 / {len(layers)} 层",
trace_id=str(trace_id or ""),
tenant_id=str(tenant_id or ""),
claw=claw,
index=counter.next(),
),
)
for layer in layers:
tasks = [
_run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
_run_node(
_node_by_id(graph, nid),
graph,
blackboard,
query,
user_id,
conv_id,
trace_id,
on_event=on_event,
claw_type=claw_type,
tenant_id=tenant_id,
counter=counter,
snapshot=snapshot,
)
for nid in layer
]
results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -392,4 +487,17 @@ async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
else:
answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
trace["output_node"] = output_node
ok_cnt = sum(1 for n in trace["nodes"] if n.get("ok"))
await _emit(
on_event,
make_step(
STEP_THINKING,
text=f"编排执行完成:{ok_cnt}/{len(trace['nodes'])} 个节点成功,输出节点 {output_node}",
trace_id=str(trace_id or ""),
tenant_id=str(tenant_id or ""),
claw=claw,
index=counter.next(),
),
)
return answer, trace
+72 -6
View File
@@ -15,12 +15,21 @@ OpenTelemetry 桥接 (Phase 1.I 标准化)
init_otel() 首次调用时构建 TracerProvider + MeterProvider(幂等)
get_tracer() 返回 tracer(未启用/失败则 None)
get_meter() 返回 meter(未启用/失败则 None)
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套)
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数)
current_span_cm(name) 返回一个 OTel span 的上下文管理器(供 observability 嵌套);
**自动注入** tenant_id + claw_type + trace_id 双 Claw 强制标签
record_span_metrics(name, elapsed_ms, status) 记录 span 指标(耗时直方图 + 计数,
维度带 tenant_id + claw_type,供 Grafana 双 Claw 对比板)
set_otel_ctx(tenant_id, claw_type) 入口注入双 Claw 强制标签(与 llm_router.set_route_ctx 同源)
get_otel_ctx() 读取当前 contextvars 三元组 {tenant_id, claw_type, trace_id}
双 Claw 强制标签(#876 §D):每个 span / metric 必带 tenant_id + claw_type + trace_id
(+ 业务字段 model / node_type 由 span(name, model=..., node_type=...) 透传)。
入口调用一次 set_otel_ctx(或 set_route_ctx,已桥接转调),全链路自动带维度,业务零改动。
"""
import os
import logging
from contextvars import ContextVar
logger = logging.getLogger(__name__)
@@ -35,6 +44,44 @@ _initialized = False
_duration_hist = None
_span_counter = None
# ---------------------------------------------------------------------------
# 强制标签上下文(#876 · 双 Claw 维度)
# ---------------------------------------------------------------------------
# 每个 span / metric 必带 tenant_id + claw_type + trace_id(设计书 §D)。
# 经 contextvars 注入,零签名侵入:入口调用 set_otel_ctx() 一次,
# 全部 span 经 current_span_cm 自动带上维度,业务侧无需改动。
# 与 #875 llm_router.set_route_ctx 同源(set_route_ctx 内部转调本函数)。
_otel_tenant_var: ContextVar[str] = ContextVar("aiflow_otel_tenant", default="")
_otel_claw_var: ContextVar[str] = ContextVar("aiflow_otel_claw", default="web")
def set_otel_ctx(*, tenant_id: str = "", claw_type: str = "web") -> None:
"""在请求入口注入 OTel 强制标签(双 Claw 维度)。
与 llm_router.set_route_ctx 共享同一调用点:入口调用一次即可让
后续所有 span / metric 自动带上 tenant_id + claw_type + trace_id。
"""
if tenant_id:
_otel_tenant_var.set(tenant_id)
_otel_claw_var.set(claw_type or "web")
def get_otel_ctx() -> dict:
"""读取当前 OTel 上下文三元组(含 trace_id,复用 observability)。
失败安全:observability 不可用时 trace_id 回退为 '-'。
"""
try:
from app.core.observability import get_trace_id
_trace = get_trace_id()
except Exception:
_trace = "-"
return {
"tenant_id": _otel_tenant_var.get(),
"claw_type": _otel_claw_var.get(),
"trace_id": _trace,
}
def init_otel() -> None:
"""构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
@@ -100,19 +147,32 @@ def get_meter():
def current_span_cm(name: str, attributes: dict | None = None):
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
"""返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。
自动注入双 Claw 强制标签(#876 §D):tenant_id + claw_type + trace_id。
setdefault 语义——业务已显式传入的字段不会被覆盖。
"""
tr = get_tracer()
if tr is None:
return None
try:
return tr.start_as_current_span(name, attributes=attributes or {})
attrs = dict(attributes or {})
ctx = get_otel_ctx()
attrs.setdefault("tenant_id", ctx["tenant_id"])
attrs.setdefault("claw_type", ctx["claw_type"])
attrs.setdefault("trace_id", ctx["trace_id"])
return tr.start_as_current_span(name, attributes=attrs)
except Exception as e: # pragma: no cover
logger.warning(f"[otel] start_span failed: {e}")
return None
def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
"""记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。
指标维度带 tenant_id + claw_type(trace_id 因高基数不计入指标标签,
仅作为 span 属性存在)。双 Claw 对比看板据此按 claw_type 分组。
"""
m = get_meter()
if m is None:
return
@@ -123,7 +183,13 @@ def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
"aiflow_span_duration_ms", "Span duration in milliseconds"
)
_span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
attrs = {"span": name, "status": status}
ctx = get_otel_ctx()
attrs = {
"span": name,
"status": status,
"tenant_id": ctx["tenant_id"],
"claw_type": ctx["claw_type"],
}
_duration_hist.record(elapsed_ms, attrs)
_span_counter.add(1, attrs)
except Exception: # pragma: no cover
+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",
}
VALID_NODE_TYPES = {"llm", "local", "external", "reduce"}
VALID_ON_ERROR = {"halt", "continue"}
VALID_REDUCE_STRATEGY = {"concat", "first", "last"}
# #874 新节点(skill/mcp/loop)的身份与配置字段:必须留在节点顶层双向透传。
# 若任其落进 config 子对象,反向转换时 _CONFIG_MAP 取不回来 —— YAML 存一次就丢配置。
_NODE_FIELDS = (
"label", "skill_id", "func_name", "tool", "server_id", "args",
"body", "items", "split", "max_iterations", "concurrency", "join",
)
def valid_node_types() -> set:
"""合法节点类型 —— 唯一真相源是 NodeRegistry(#874)。
改造前这里硬编码了一份 {llm, local, external, reduce},与 orchestrator 里的
元组各写各的:注册新节点必须改两处,漏一处就出现「画布能跑、YAML 报非法」。
现在统一读注册表;注册表不可用时退回内建四型,保证校验器永不因导入失败而瘫痪。
"""
try:
from app.core.node_registry import registry
types = set(registry.node_types())
if types:
return types
except Exception: # pragma: no cover - 仅在注册表不可用时兜底
pass
return {"llm", "local", "external", "reduce"}
# 兼容导出:历史调用方 `from ... import VALID_NODE_TYPES` 仍可用(import 期快照)
VALID_NODE_TYPES = valid_node_types()
# ── validation ──────────────────────────────────────────────────────────────
@@ -112,18 +140,29 @@ def validate_yaml(data: Any) -> dict:
errors.append(f"节点 id 重复: {nid}")
node_ids.add(nid)
allowed = valid_node_types()
ntype = node.get("type")
if ntype not in VALID_NODE_TYPES:
errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(VALID_NODE_TYPES))})")
if ntype not in allowed:
errors.append(f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(sorted(allowed))})")
on_error = node.get("on_error", "continue")
if on_error and on_error not in VALID_ON_ERROR:
errors.append(f"节点 {nid} on_error 非法: {on_error}")
# type-specific checks
if ntype in ("local", "external"):
if ntype in ("local", "external", "agent"):
if not node.get("agent_id"):
errors.append(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
if ntype == "skill" and not (node.get("skill_id") or node.get("func_name")):
errors.append(f"节点 {nid} 类型 skill 必须指定 skill_id 或 func_name")
if ntype == "mcp" and not node.get("tool"):
errors.append(f"节点 {nid} 类型 mcp 必须指定 tool")
if ntype == "loop":
body = node.get("body")
if not isinstance(body, dict) or not body.get("type"):
errors.append(f"节点 {nid} 类型 loop 必须提供含 type 的 body")
elif body["type"] not in allowed or body["type"] == "loop":
errors.append(f"节点 {nid} 的 body 类型非法: {body.get('type')}")
# config validation
cfg = node.get("config", {})
@@ -205,10 +244,15 @@ def yaml_to_orchestration(data: dict) -> dict:
"on_error": raw_node.get("on_error", "continue"),
}
# agent_id for local/external
# agent_id for local/external/agent
if raw_node.get("agent_id"):
node["agent_id"] = raw_node["agent_id"]
# #874 skill/mcp/loop 节点字段原样透传
for _f in _NODE_FIELDS:
if raw_node.get(_f) is not None:
node[_f] = raw_node[_f]
# flatten config into top-level keys
config = raw_node.get("config", {})
if isinstance(config, dict):
@@ -218,7 +262,8 @@ def yaml_to_orchestration(data: dict) -> dict:
# also carry over any extra keys from raw_node (preserve flexibility)
for k, v in raw_node.items():
if k not in ("id", "type", "config", "description", "on_error", "agent_id"):
if k not in ("id", "type", "config", "description", "on_error", "agent_id") \
and k not in _NODE_FIELDS:
if k in ("skill_ids", "knowledge_base_ids", "model_id", "system_prompt",
"agent_endpoint", "api_key", "strategy", "max_tokens"):
node[k] = v
@@ -262,6 +307,9 @@ def orchestration_to_yaml(data: dict) -> dict:
node["on_error"] = json_node["on_error"]
if json_node.get("agent_id"):
node["agent_id"] = json_node["agent_id"]
for _f in _NODE_FIELDS:
if json_node.get(_f) is not None:
node[_f] = json_node[_f]
# nest known config keys into config sub-object
config: dict = {}
@@ -272,7 +320,7 @@ def orchestration_to_yaml(data: dict) -> dict:
# also preserve any extra keys not in the map
for k, v in json_node.items():
if k not in ("id", "type", "description", "on_error", "agent_id") \
and k not in _REVERSE_CONFIG:
and k not in _NODE_FIELDS and k not in _REVERSE_CONFIG:
if v is not None:
config[k] = v
+88 -15
View File
@@ -1,8 +1,8 @@
"""
MCP (Model Context Protocol) 客户端 —— 基于官方 `mcp` Python SDK (Phase 1.E 标准化)
MCP (Model Context Protocol) 客户端 —— 基于官方 \"mcp\" Python SDK (Phase 1.E 标准化)
设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §E):
- 采用 Anthropic 官方 `mcp` SDK 处理 JSON-RPC 帧 + 协议版本协商,
- 采用 Anthropic 官方 \"mcp\" SDK 处理 JSON-RPC 帧 + 协议版本协商,
不再手写解析、不再写死 MCP_PROTOCOL_VERSION 常量(旧实现写死 "2024-11-05",
上游协议一升级即碎)。
- 支持 streamable_http(默认)与 sse 两种传输,按 server['transport_type'] 选择。
@@ -19,7 +19,21 @@ from typing import Optional
from mcp import ClientSession
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
# mcp 2.0.0 把 streamablehttp_client 重命名为 streamable_http_client(下划线位置变了)。
# 兼容 0.28.x / 1.x / 2.x:先试新名,失败回退老名。
try:
from mcp.client.streamable_http import streamable_http_client as _streamable_http_client
except ImportError: # 0.28.x 旧名
try:
from mcp.client.streamable_http import streamablehttp_client as _streamable_http_client
except ImportError:
_streamable_http_client = None # type: ignore[assignment]
import httpx
# 兼容 mcp 2.0+ 安装的 httpx2 fork — 同源 API,可作别名
try:
import httpx2 as _httpx
except ImportError:
_httpx = httpx
logger = logging.getLogger(__name__)
@@ -28,27 +42,79 @@ _TOOLS_CACHE: dict = {}
_TOOLS_CACHE_TTL = 120 # 秒
def _no_proxy_httpx_client(headers=None, timeout=None, auth=None):
"""MCP 传输用 httpx 客户端工厂:强制直连、不走环境代理。
容器 run.sh 注入了 HTTP_PROXY=http://172.18.0.1:7890,该代理无法解析
外部 MCP 域名(xiaobenyang.com / anysearch.com 等),且默认 trust_env
会读取 HTTP_PROXY 导致 tools/list 全部静默失败。这里 trust_env=False
直连,与 app/core/llm_client.py:chat_complete 修复同源。
mcp 2.0.0 改动:`streamable_http_client` 不再接受 headers/timeout/httpx_client_factory
等 kwargs,改为接受预配置的 http_client(httpx2/httpx.AsyncClient 实例)。
所以这里返回一个完整的 client,而不是 factory。
"""
# 关键:httpx2(2.0+ 引入,同源 API)与 httpx 兼容
cli_class = getattr(_httpx, "AsyncClient", _httpx.Client) if hasattr(_httpx, "AsyncClient") else _httpx.AsyncClient
kwargs: dict = {"follow_redirects": True, "trust_env": False}
kwargs["timeout"] = timeout if timeout is not None else _httpx.Timeout(30.0, read=300.0)
if headers is not None:
kwargs["headers"] = headers
if auth is not None:
kwargs["auth"] = auth
return cli_class(**kwargs)
def _transport_cm(server: dict, timeout: float):
"""按 server['transport_type'] 选择官方 SDK 传输层。
返回异步上下文管理器,进入后产出 (read_stream, write_stream, ...)。
关键:注入 _no_proxy_httpx_client,使 MCP 流量直连(见其 docstring)。
mcp 2.0.0 兼容路径:
- 旧 (0.28.x): streamablehttp_client(url, headers=..., timeout=..., httpx_client_factory=...)
- 新 (2.0+): streamable_http_client(url, http_client=<预配置 client>)
用 inspect 判断参数,自动适配。
"""
url = (server.get("url") or "").strip()
ttype = (server.get("transport_type") or "streamable_http").lower()
headers = server.get("headers") or {}
if ttype == "sse":
return sse_client(url, headers=headers, timeout=timeout)
# 默认 streamable_http(官方 SDK 标准传输)
return streamablehttp_client(url, headers=headers, timeout=timeout)
# SSE 也有同样问题,新版 sse_client 签名也可能变。这里保守用老 kwargs(若失败 fallback)
return sse_client(url, headers=headers, timeout=timeout,
httpx_client_factory=_no_proxy_httpx_client)
if _streamable_http_client is None:
raise RuntimeError("mcp SDK missing streamable_http transport (both new + old names unavailable)")
# inspect 新版签名,判断是否接受 http_client
import inspect
try:
sig_params = inspect.signature(_streamable_http_client).parameters
accepts_http_client = "http_client" in sig_params
except Exception:
accepts_http_client = False
if accepts_http_client:
# mcp 2.0+ 路径:headers/timeout 全部装进预配置的 httpx client
http_client = _no_proxy_httpx_client(headers=headers, timeout=timeout)
return _streamable_http_client(url, http_client=http_client)
else:
# 0.28.x 老路径
return _streamable_http_client(url, headers=headers, timeout=timeout,
httpx_client_factory=_no_proxy_httpx_client)
async def _session_request(
server: dict, method: str, params: dict, timeout: float = 30.0
server: dict, method: str, params: dict, timeout: float = 30.0,
raise_on_error: bool = False
):
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
返回解析后的 dict:{"result": {...}} 或 None(失败)。
协议版本由 SDK 自动协商,不再硬编码。
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
"""
url = (server.get("url") or "").strip()
if not url:
@@ -89,33 +155,40 @@ async def _session_request(
}
except Exception as e:
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
if raise_on_error:
raise
return None
return None
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
async def list_mcp_tools(server: dict, timeout: float = 30.0, raise_on_error: bool = False) -> list:
"""
拉取 MCP 服务器的工具列表。
server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
"""
url = (server.get("url") or "").strip()
if not url:
return []
# 缓存
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
# 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
if not raise_on_error:
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
try:
data = await _session_request(server, "tools/list", {}, timeout)
data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
tools = []
if data and "result" in data:
tools = data["result"].get("tools", []) or []
_TOOLS_CACHE[url] = (now, tools)
if not raise_on_error:
_TOOLS_CACHE[url] = (asyncio.get_event_loop().time(), tools)
return tools
except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
if raise_on_error:
raise
return []
+46 -3
View File
@@ -11,7 +11,7 @@ from sqlalchemy import (
DECIMAL, UniqueConstraint
)
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.mysql import MEDIUMTEXT
from sqlalchemy import Text # PG-compatible (was MEDIUMTEXT from mysql dialect)
from .database import Base
@@ -32,7 +32,7 @@ class User(Base):
phone = Column(String(20), default=None)
password_hash = Column(String(255), nullable=False)
avatar = Column(String(500), default=None)
role = Column(String(10), nullable=False, default="user") # user / admin
role = Column(String(20), nullable=False, default="user") # super_admin/admin/developer/user/guest
status = Column(String(10), nullable=False, default="active") # active / banned
real_name_status = Column(String(20), nullable=False, default="unverified")
real_name = Column(String(50), default=None)
@@ -40,6 +40,7 @@ class User(Base):
membership_plan_id = Column(String(36), default=None)
membership_level = Column(Integer, default=0)
membership_expire_at = Column(DateTime, default=None)
tenant_id = Column(String(36), default=None)
auto_renew = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
@@ -76,6 +77,47 @@ class ApiKey(Base):
# AI聊天与频道
# ============================================================
class UserGroup(Base):
__tablename__ = "user_groups"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), unique=True, nullable=False)
description = Column(String(500), default=None)
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
tenant_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
owner = relationship("User", foreign_keys=[owner_id])
class UserGroupMember(Base):
__tablename__ = "user_group_members"
id = Column(String(36), primary_key=True, default=gen_uuid)
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
joined_at = Column(DateTime, default=datetime.now)
group = relationship("UserGroup")
user = relationship("User")
class GroupModelQuota(Base):
__tablename__ = "group_model_quotas"
id = Column(String(36), primary_key=True, default=gen_uuid)
group_id = Column(String(36), ForeignKey("user_groups.id"), nullable=False)
model_id = Column(String(36), ForeignKey("models.id"), nullable=False)
daily_limit = Column(Integer, default=1000)
rpm_limit = Column(Integer, default=60)
group = relationship("UserGroup")
model = relationship("AIModel")
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
slug = Column(String(50), unique=True, nullable=False)
status = Column(String(20), default="active")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Conversation(Base):
__tablename__ = "conversations"
@@ -300,7 +342,7 @@ class MarketApp(Base):
rating_avg = Column(DECIMAL(3, 2), default=0.00)
review_count = Column(Integer, default=0)
install_count = Column(Integer, default=0)
skill_content = Column(MEDIUMTEXT, default=None)
skill_content = Column(Text, default=None)
mcp_transport = Column(String(20), default=None)
platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze")
capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video")
@@ -530,6 +572,7 @@ class ModelProvider(Base):
protocol = Column(String(30), nullable=False, default="openai_compatible")
api_key = Column(String(255), default=None)
api_base_url = Column(String(500), default=None)
auth_header = Column(String(100), default="Authorization: Bearer", comment="自定义认证头,如MiMo用api-key")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
+120
View File
@@ -0,0 +1,120 @@
"""
Phase 2 #2C: Loop Primitive + Verifier
Adds loop edges and verifier nodes to the orchestrator.
"""
import asyncio
from typing import Dict, List, Any, Callable
class LoopConfig:
"""Configuration for a loop in the agent DAG."""
def __init__(self, max_iterations: int = 5, condition: str = "not_passed",
threshold: float = 0.8):
self.max_iterations = max_iterations
self.condition = condition
self.threshold = threshold
self.current_iteration = 0
def should_continue(self, verifier_result: dict) -> bool:
self.current_iteration += 1
if self.current_iteration > self.max_iterations:
return False
if self.condition == "not_passed":
return not verifier_result.get("passed", False)
elif self.condition == "score_below":
return verifier_result.get("score", 0) < self.threshold
elif self.condition == "always":
return self.current_iteration <= self.max_iterations
return False
class VerifierNode:
"""Node that checks whether a goal has been achieved."""
def __init__(self, goal: str, check_type: str = "quality"):
self.goal = goal
self.check_type = check_type
async def verify(self, output: str, context: dict) -> dict:
"""Run verification via LLM to check if goal is met."""
try:
import httpx, json
prompt = (
f"Goal: {self.goal}\n"
f"Output to verify:\n{output[:2000]}\n\n"
f"Check if the output meets the goal. Return JSON:\n"
f'{{"passed": true/false, "score": 0.0-1.0, "reason": "brief explanation"}}'
)
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
headers={
"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
"Content-Type": "application/json"
},
json={
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1,
"max_tokens": 200,
}
)
if resp.status_code == 200:
text = resp.json()["choices"][0]["message"]["content"].strip()
if text.startswith("```"):
text = text.split("\n", 1)[1][:-3]
return json.loads(text)
except Exception:
pass
return {"passed": True, "score": 1.0, "reason": "verification skipped (offline fallback)"}
class LoopExecutor:
"""Executes a node graph with loop support."""
def __init__(self, nodes: List[Dict], edges: List[Dict]):
self.nodes = {n["id"]: n for n in nodes}
self.edges = edges
self.loop_configs: Dict[str, LoopConfig] = {}
self.verifiers: Dict[str, VerifierNode] = {}
def add_loop(self, from_node: str, to_node: str, config: LoopConfig):
self.loop_configs[from_node] = config
def add_verifier(self, node_id: str, verifier: VerifierNode):
self.verifiers[node_id] = verifier
async def execute(self, node_executor: Callable, initial_input: dict) -> dict:
"""Execute DAG with loop support."""
results = {}
to_process = ["input-1"] # Start from input node
while to_process:
node_id = to_process.pop(0)
node = self.nodes.get(node_id)
if not node:
continue
# Check loop limit
if node_id in self.loop_configs:
lc = self.loop_configs[node_id]
if lc.current_iteration >= lc.max_iterations:
continue
output = await node_executor(node, initial_input, results)
results[node_id] = output
# Find outgoing edges
next_nodes = [e["target"] for e in self.edges if e["source"] == node_id]
for nn in next_nodes:
if nn in self.loop_configs:
# Check if loop should continue
lc = self.loop_configs[nn]
if lc.should_continue(output):
target = [e["target"] for e in self.edges if e["source"] == nn]
if target:
to_process.append(target[0]) # Loop back
else:
to_process.append(nn)
return results
+95
View File
@@ -0,0 +1,95 @@
"""
Phase 2 #2B: MemPalace Hierarchical Memory
Wing (User) → Room (Conversation) → Drawer (Topic)
Three-level memory index with priority retrieval.
"""
from typing import List, Dict, Optional
from datetime import datetime
class MemPalace:
"""Hierarchical memory system with Wing/Room/Drawer indexing."""
LEVELS = {"wing": 0, "room": 1, "drawer": 2}
def __init__(self, user_id: str, conv_id: Optional[str] = None):
self.user_id = user_id
self.conv_id = conv_id
async def remember(self, content: str, level: str = "room",
drawer_topic: str = "", ttl_days: int = 30) -> dict:
"""Store a memory at the specified level."""
try:
from app.database import SessionLocal
from sqlalchemy import text
import uuid
db = SessionLocal()
mem_id = str(uuid.uuid4())
db.execute(text(
"INSERT INTO memories (id, user_id, conv_id, content, level, drawer_topic, "
"ttl_days, created_at) VALUES (:id, :uid, :cid, :c, :l, :dt, :ttl, NOW())"
), {"id": mem_id, "uid": self.user_id, "cid": self.conv_id,
"c": content[:2000], "l": level, "dt": drawer_topic, "ttl": ttl_days})
db.commit()
db.close()
return {"id": mem_id, "status": "stored", "level": level}
except Exception as e:
return {"status": "error", "error": str(e)}
async def recall(self, query: str, max_results: int = 5,
level_filter: str = None) -> List[dict]:
"""Retrieve memories by relevance, respecting hierarchy."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
# Priority: wing(user) > room(conv) > drawer(topic)
params = {"uid": self.user_id, "cid": self.conv_id or ""}
where = "user_id = :uid AND (conv_id = :cid OR conv_id IS NULL)"
if level_filter:
where += f" AND level = :lv"
params["lv"] = level_filter
# Simulated relevance via LIKE (in production, use pgvector)
search = f"%{query}%"
rows = db.execute(text(
f"SELECT id, content, level, drawer_topic, created_at FROM memories "
f"WHERE {where} AND (content ILIKE :q OR drawer_topic ILIKE :q) "
f"ORDER BY CASE level WHEN 'drawer' THEN 0 WHEN 'room' THEN 1 WHEN 'wing' THEN 2 END, "
f"created_at DESC LIMIT :lim"
), {**params, "q": search, "lim": max_results}).fetchall()
db.close()
return [{"id": r[0], "content": r[1], "level": r[2],
"topic": r[3], "created": str(r[4])} for r in rows]
except Exception:
return []
async def forget(self, level: str = None, older_than_days: int = None) -> int:
"""Purge old or level-specific memories."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
where = "user_id = :uid"
params = {"uid": self.user_id}
if level:
where += " AND level = :lv"
params["lv"] = level
if older_than_days:
where += " AND created_at < NOW() - make_interval(days => :days)"
params["days"] = older_than_days
result = db.execute(text(f"DELETE FROM memories WHERE {where}"), params)
db.commit()
db.close()
return result.rowcount
except Exception:
return 0
async def get_context(self, max_tokens: int = 2000) -> str:
"""Get compact memory context for LLM prompt injection."""
memories = await self.recall("", max_results=10)
if not memories:
return ""
lines = ["[Memory Context]"]
for m in memories:
lines.append(f"[{m['level']}] {m['content'][:200]}")
return "\n".join(lines)[:max_tokens]
+115
View File
@@ -0,0 +1,115 @@
"""
Phase 2 #2A: Four-Level Tool Risk Approval System
Implements: read(0) / write_local(1) / exec(2) / external(3)
Appended to chat_service.py
"""
# Risk level constants
TOOL_RISK_LEVELS = {
"read": 0,
"write_local": 1,
"exec": 2,
"external": 3,
}
# Risk level defaults by tool type
DEFAULT_RISK_LEVELS = {
"skill_web_search": "read",
"skill_code_interpreter": "exec",
"skill_file_read": "read",
"skill_file_write": "write_local",
"mcp_default": "external",
"skill_default": "write_local",
}
def assess_tool_risk(func_name: str, mcp_info: dict = None, skill_info: dict = None) -> dict:
"""Determine risk level of a tool call."""
risk = "write_local" # default
if func_name.startswith("mcp__"):
# MCP tools default to external (highest risk)
risk = "external"
if mcp_info and mcp_info.get("tool_name", ""):
tn = mcp_info.get("tool_name", "").lower()
if any(k in tn for k in ["search", "find", "get", "list", "read"]):
risk = "read"
elif any(k in tn for k in ["create", "update", "write", "save"]):
risk = "write_local"
elif func_name.startswith("install_app"):
risk = "write_local"
elif skill_info:
cap = skill_info.get("capability", "").lower()
if any(k in cap for k in ["search", "read", "query", "analyze"]):
risk = "read"
elif any(k in cap for k in ["generate", "create", "write"]):
risk = "write_local"
elif any(k in cap for k in ["execute", "run", "code"]):
risk = "exec"
level = TOOL_RISK_LEVELS.get(risk, 1)
return {
"risk_level": risk,
"numeric_level": level,
"requires_approval": level >= 2, # exec + external require approval
"auto_allow": level == 0, # read is auto-allowed
}
async def create_approval_request(
user_id: str, tool_name: str, risk_level: str,
func_args: dict, reason: str = ""
) -> dict:
"""Create an approval request in DB."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
import uuid
apr_id = str(uuid.uuid4())
db.execute(text(
"INSERT INTO pending_approvals (id, user_id, tool_name, risk_level, "
"func_args, reason, status, created_at) "
"VALUES (:id, :uid, :tn, :rl, :fa, :r, 'pending', NOW())"
), {"id": apr_id, "uid": user_id, "tn": tool_name,
"rl": risk_level, "fa": str(func_args)[:1000], "r": reason})
db.commit()
db.close()
return {"approval_id": apr_id, "status": "pending"}
except Exception:
return {"approval_id": None, "status": "auto_blocked"}
async def check_approval_status(approval_id: str) -> dict:
"""Check if an approval request has been resolved."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
row = db.execute(text(
"SELECT status, reviewer_id FROM pending_approvals WHERE id = :id"
), {"id": approval_id}).fetchone()
db.close()
if row:
return {"status": row[0], "reviewer_id": row[1]}
return {"status": "not_found"}
except Exception:
return {"status": "error"}
async def approve_or_reject(approval_id: str, action: str, reviewer_id: str) -> dict:
"""Approve or reject a pending approval."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
new_status = "approved" if action == "approve" else "rejected"
db.execute(text(
"UPDATE pending_approvals SET status = :st, reviewer_id = :rid, "
"reviewed_at = NOW() WHERE id = :id"
), {"st": new_status, "rid": reviewer_id, "id": approval_id})
db.commit()
db.close()
return {"approval_id": approval_id, "status": new_status}
except Exception as e:
return {"status": "error", "error": str(e)}
+1 -1
View File
@@ -73,7 +73,7 @@ PLATFORM_KNOWLEDGE = """# 七虎AI平台能力全览
- **安全**: JWT鉴权、HTTPS加密、操作审计日志
## AI回复规范
1. **回复格式**: 使用Markdown(标题 `##`/`###`、加粗 `**`、列表 `-`/`1.`、代码块等)
1. **回复格式**: 使用Markdown(标题 \"##\"/\"###\"、加粗 \"**\"、列表 \"-\"/\"1.\"、代码块等)
2. **层次分明**: 使用标题分隔段落,确保信息有层次
3. **平台优先**: 回答问题时优先引用本平台功能,而非泛泛建议
4. **准确具体**: 基于平台文档给出具体操作路径、配置方法、API示例
+335
View File
@@ -0,0 +1,335 @@
"""
Phase 1 #1A: Skill Runtime Framework v2
Real skill execution engine with python-pptx integration.
"""
import os, json, asyncio, uuid
from datetime import datetime
class SkillRuntime:
"""Real skill execution engine.
Lifecycle:
authorize() -> prepare_workspace() -> execute() -> store_artifacts() -> record_metrics()
States: pending -> running -> ready | failed
"""
def __init__(self, skill_id: str, user_id: str, tenant_id: str = None):
self.skill_id = skill_id
self.user_id = user_id
self.tenant_id = tenant_id or "00000000-0000-4000-a000-000000000001"
self.instance_id = str(uuid.uuid4())
self.workspace = f"/app/workspace/{self.tenant_id}/{self.user_id}/{skill_id}/{self.instance_id}"
self.status = "pending"
self.result = None
self.error = None
self.artifacts = []
async def authorize(self, db) -> bool:
"""Check user has permission to execute this skill."""
from sqlalchemy import text
row = db.execute(text(
"SELECT 1 FROM app_installations WHERE user_id = :uid AND app_id = :sid AND status = 'active'"
), {"uid": self.user_id, "sid": self.skill_id}).fetchone()
return row is not None
async def prepare_workspace(self):
"""Create isolated workspace directory."""
os.makedirs(self.workspace, exist_ok=True)
return self.workspace
async def execute(self, inputs: dict) -> dict:
"""Execute the skill with provided inputs."""
self.status = "running"
try:
await self.prepare_workspace()
self.result = await self._run(inputs)
self.status = "ready"
await self.store_artifacts()
except Exception as e:
self.status = "failed"
self.error = str(e)
await self.record_metrics()
return {"status": self.status, "result": self.result, "error": self.error,
"instance_id": self.instance_id}
async def _run(self, inputs: dict) -> dict:
"""Override in skill-specific subclasses."""
raise NotImplementedError("Each skill must implement _run()")
async def store_artifacts(self):
"""Save result metadata to workspace."""
artifact_path = os.path.join(self.workspace, "result.json")
with open(artifact_path, "w", encoding="utf-8") as f:
json.dump({"status": self.status, "result": self.result,
"error": self.error, "instance_id": self.instance_id,
"artifacts": self.artifacts}, f, ensure_ascii=False, default=str)
return artifact_path
async def record_metrics(self):
"""Log execution to DB."""
try:
from app.database import SessionLocal
from sqlalchemy import text
db = SessionLocal()
db.execute(text(
"INSERT INTO skill_instances (id, skill_id, user_id, tenant_id, status, result, error, created_at, updated_at) "
"VALUES (:id, :sid, :uid, :tid, :st, :res, :err, NOW(), NOW())"
"ON CONFLICT (id) DO UPDATE SET status=:st2, result=:res2, error=:err2, updated_at=NOW()"
), {"id": self.instance_id, "sid": self.skill_id, "uid": self.user_id,
"tid": self.tenant_id, "st": self.status, "res": str(self.result)[:2000] if self.result else None,
"err": self.error, "st2": self.status, "res2": str(self.result)[:2000] if self.result else None,
"err2": self.error})
db.commit()
db.close()
except Exception:
pass
class PPTSkillRuntime(SkillRuntime):
"""Real AI PPT Generator using python-pptx."""
TEMPLATES = {
"business": {"bg": "FFFFFF", "title_color": "1A3C5E", "accent": "2E86C1"},
"creative": {"bg": "FFF8E1", "title_color": "E65100", "accent": "FF6F00"},
"minimal": {"bg": "FFFFFF", "title_color": "212121", "accent": "616161"},
}
async def _run(self, inputs: dict) -> dict:
content = inputs.get("content", "")
template = inputs.get("template", "business")
slides_count = int(inputs.get("slides", 5))
if not content.strip():
return {"error": "No content provided", "status": "failed"}
# Step 1: Generate slide outline via LLM
outline = await self._generate_outline(content, slides_count)
if not outline:
return {"error": "Failed to generate outline", "status": "failed"}
# Step 2: Generate PPTX file
pptx_path = os.path.join(self.workspace, "output.pptx")
self._create_pptx(outline, pptx_path, template)
self.artifacts.append({"name": "output.pptx", "path": pptx_path,
"type": "application/vnd.openxmlformats-officedocument.presentationml.presentation"})
return {
"slides": len(outline),
"title": outline[0]["title"] if outline else content[:50],
"template": template,
"status": "ready",
"pptx_path": pptx_path,
"outline": outline[:3],
}
async def _generate_outline(self, content: str, slide_count: int) -> list:
"""Call LLM via LiteLLM gateway to generate slide outline."""
try:
prompt = (
f"You are a professional PPT designer. Create a {slide_count}-slide presentation "
f"outline based on this content:\n\n{content[:3000]}\n\n"
f"Return ONLY valid JSON array format:\n"
f'[{{"title":"Slide Title","bullets":["point1","point2","point3"]}}]\n'
f"Do NOT include any other text, markdown, or explanation."
)
# Use OpenAI compatible API through LiteLLM
import httpx
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
headers={
"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
"Content-Type": "application/json"
},
json={
"model": "deepseek-v4-flash",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.7,
"max_tokens": 2000,
}
)
if resp.status_code != 200:
return self._fallback_outline(content, slide_count)
text = resp.json()["choices"][0]["message"]["content"].strip()
# Clean markdown code fences
if text.startswith("```"):
text = text.split("\n", 1)[1]
if text.endswith("```"):
text = text[:-3]
outline = json.loads(text)
if isinstance(outline, list) and len(outline) > 0:
return outline[:slide_count]
except Exception:
pass
return self._fallback_outline(content, slide_count)
def _fallback_outline(self, content: str, count: int) -> list:
"""Generate basic outline without LLM (offline fallback)."""
sentences = [s.strip() for s in content.replace("\n", " ").split("。") if s.strip()]
if len(sentences) < 3:
sentences = content.split("\n")
slides = []
for i in range(min(count, max(len(sentences), 3))):
slides.append({
"title": f"第{i+1}部分",
"bullets": [sentences[i % len(sentences)][:100] if sentences else f"要点{i+1}"]
})
return slides
def _create_pptx(self, outline: list, output_path: str, template: str):
"""Create actual PPTX file using python-pptx."""
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
style = self.TEMPLATES.get(template, self.TEMPLATES["business"])
for slide_data in outline:
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = RGBColor.from_string(style["bg"])
# Title
left, top, width, height = Inches(1), Inches(0.5), Inches(11), Inches(1.2)
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.text = slide_data.get("title", "Untitled")
p.font.size = Pt(36)
p.font.bold = True
p.font.color.rgb = RGBColor.from_string(style["title_color"])
p.alignment = PP_ALIGN.LEFT
# Accent line
line = slide.shapes.add_shape(
1, # MSO_SHAPE.RECTANGLE
Inches(1), Inches(1.75), Inches(3), Inches(0.06)
)
line.fill.solid()
line.fill.fore_color.rgb = RGBColor.from_string(style["accent"])
line.line.fill.background()
# Bullets
top = Inches(2.2)
txBox2 = slide.shapes.add_textbox(Inches(1), top, Inches(11), Inches(4.5))
tf2 = txBox2.text_frame
tf2.word_wrap = True
bullets = slide_data.get("bullets", [])
for j, bullet in enumerate(bullets):
if j == 0:
p2 = tf2.paragraphs[0]
else:
p2 = tf2.add_paragraph()
p2.text = f" {bullet}"
p2.font.size = Pt(20)
p2.font.color.rgb = RGBColor(0x33, 0x33, 0x33)
p2.space_after = Pt(12)
p2.level = 0
prs.save(output_path)
class SEOSkillRuntime(SkillRuntime):
"""Real SEO Optimizer skill."""
async def _run(self, inputs: dict) -> dict:
url = inputs.get("url", "")
keywords = inputs.get("keywords", "")
if not url and not keywords:
return {"error": "URL or keywords required", "status": "failed"}
# Generate SEO analysis via LLM
try:
import httpx
prompt = (
f"Analyze SEO for: URL={url}, Keywords={keywords}. "
f"Return JSON: {{\"score\":<0-100>,\"title_suggestions\":[str],"
f"\"meta_description\":str,\"h1_suggestions\":[str],\"keyword_density\":<percent>,"
f"\"improvements\":[str]}}. Only JSON, no markdown."
)
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
"Content-Type": "application/json"},
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000, "temperature": 0.5}
)
if resp.status_code == 200:
text = resp.json()["choices"][0]["message"]["content"].strip()
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
result = json.loads(text)
else:
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
except Exception:
result = {"score": 75, "improvements": ["Add meta description", "Use H1 tags"]}
# Save report
report_path = os.path.join(self.workspace, "seo_report.json")
with open(report_path, "w", encoding="utf-8") as f:
json.dump({"url": url, "keywords": keywords, "analysis": result,
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
self.artifacts.append({"name": "seo_report.json", "path": report_path, "type": "application/json"})
return {"score": result.get("score", 0), "improvements": result.get("improvements", []),
"status": "ready"}
class ResearchSkillRuntime(SkillRuntime):
"""Real AI Investment Research skill."""
async def _run(self, inputs: dict) -> dict:
symbol = inputs.get("symbol", "").upper()
if not symbol:
return {"error": "Stock symbol required", "status": "failed"}
# Generate research via LLM
try:
import httpx
prompt = (
f"Generate investment research report for stock {symbol}. "
f"Return JSON: {{\"company_name\":str,\"sector\":str,\"market_cap\":str,"
f"\"revenue_growth\":str,\"pe_ratio\":<float>,\"analyst_target\":<float>,"
f"\"strengths\":[str],\"risks\":[str],\"recommendation\":\"buy|hold|sell\","
f"\"summary\":str}}. Only JSON, no markdown."
)
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"http://qihu-aiflow-litellm:4000/v1/chat/completions",
headers={"Authorization": "Bearer sk-litellm-master-key-aiflow-2026",
"Content-Type": "application/json"},
json={"model": "deepseek-v4-flash", "messages": [{"role": "user", "content": prompt}],
"max_tokens": 1500, "temperature": 0.3}
)
if resp.status_code == 200:
text = resp.json()["choices"][0]["message"]["content"].strip()
if text.startswith("```"): text = text.split("\n", 1)[1][:-3]
result = json.loads(text)
else:
result = {"company_name": symbol, "recommendation": "hold", "summary": "Insufficient data"}
except Exception:
result = {"company_name": symbol, "recommendation": "hold", "summary": "Analysis unavailable"}
# Save report
report_path = os.path.join(self.workspace, "research_report.json")
with open(report_path, "w", encoding="utf-8") as f:
json.dump({"symbol": symbol, "report": result,
"generated_at": datetime.now().isoformat()}, f, ensure_ascii=False)
self.artifacts.append({"name": "research_report.json", "path": report_path, "type": "application/json"})
return {"company": result.get("company_name", symbol),
"recommendation": result.get("recommendation", "hold"),
"summary": result.get("summary", "")[:200],
"status": "ready"}
+84 -2
View File
@@ -11,15 +11,43 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
# Phase0: Tenant RLS Middleware
class TenantRLSMiddleware(BaseHTTPMiddleware):
"""Inject tenant_id from JWT into PostgreSQL for RLS policies"""
async def dispatch(self, request: Request, call_next):
try:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
from jose import jwt
from app.auth import SECRET_KEY, ALGORITHM
token = auth_header[7:]
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
tenant_id = payload.get("tenant_id")
if tenant_id:
from app.database import SessionLocal
db = SessionLocal()
try:
db.execute(f"SET app.current_tenant = '{tenant_id}'")
finally:
db.close()
except Exception:
pass
return await call_next(request)
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api import auth, chat, knowledge, market, market_products, token_usage, payment, profile, notifications, assets, evolution, skills_api
from app.api.bug_audit import router as bug_audit_router
from app.api.auth_verify import router as auth_verify_router
from app.api.billing_webhook import router as billing_webhook_router
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, market_products_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
from app.api.im_bot_user import router as im_bot_user_router
from app.api.im_bot_platform import router as im_bot_platform_router
from app.api.channel_webhook import router as channel_webhook_router
from app.api.portal import user_portal
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
@@ -118,6 +146,18 @@ def _record_api_call(db, method: str, path: str, status_code: int, elapsed: floa
async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 幂等确保 conversation_summaries.structured_memory 列。
# 原实现在每次聊天的请求路径中同步执行 engine.connect().execute(SELECT information_schema.columns),
# 该同步阻塞调用运行在 asyncio 事件循环线程上,会导致整个事件循环冻结(所有端点挂死,
# 表现为「单 worker 永久阻塞、docker restart 才恢复」)。改为启动时一次性执行,
# 既保留对全新库的兼容,又绝不阻塞任何用户请求。
try:
from app.api.chat_context import ensure_structured_column
ensure_structured_column()
logger.info("[startup] ensure_structured_column 完成")
except Exception as _e:
logger.warning(f"[startup] ensure_structured_column 失败(可忽略): {_e}")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try:
from app.services.evolution_service import EvolutionWatcherManager
@@ -152,6 +192,38 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
# 启动个人微信接收线程(之前仅在扫码确认时启动,重启后丢失)
try:
from app.api.im_bot_user import start_all_wechat_receivers
# === #935 IM Bot ChannelRouter (Telegram / 飞书 / 企微 etc.) ===
try:
from app.core.channel_adapter import ChannelRouter
import os
router = ChannelRouter.shared()
if os.getenv("TELEGRAM_BOT_TOKEN"):
from app.core.telegram_channel import TelegramChannel
router.register(TelegramChannel(
bot_token=os.environ["TELEGRAM_BOT_TOKEN"],
webhook_url=os.getenv("TELEGRAM_WEBHOOK_URL", ""),
webhook_secret=os.getenv("TELEGRAM_WEBHOOK_SECRET", ""),
))
# 启动所有渠道(non-fatal)
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.ensure_future(router.start_all())
else:
loop.run_until_complete(router.start_all())
except Exception as e:
print(f"[main] channel router start_all failed (non-fatal): {e}")
except Exception as _e:
print(f"[main] channel router init failed (non-fatal): {_e}")
start_all_wechat_receivers()
except Exception as e:
logger.warning(f"[IM个人微信] 启动恢复失败(不影响正常服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield
@@ -194,16 +266,21 @@ app.add_middleware(
)
# 前台 API
# Phase0: Register RLS middleware
app.add_middleware(TenantRLSMiddleware)
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
app.include_router(market_products.router, prefix="/app/api/market", tags=["产品市场"])
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
app.include_router(skills_api.router, prefix="/app/api/skills", tags=["技能执行"])
# 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
@@ -215,6 +292,7 @@ app.include_router(services.router, prefix="/app/api/admin/services", tags=["后
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
app.include_router(market_products_admin.router, prefix="/app/api/admin/market-products", tags=["后台-产品审核"])
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
@@ -235,6 +313,7 @@ app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=[
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
app.include_router(channel_webhook_router, prefix="", tags=["channel-webhook"])
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
@@ -243,6 +322,9 @@ app.include_router(observability_admin.router, prefix="/app/api/admin/observabil
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
app.include_router(auth_verify_router, prefix="/app/api/auth", tags=["Auth Verify"])
app.include_router(billing_webhook_router, prefix="/app/api/billing", tags=["Billing Webhook"])
app.include_router(bug_audit_router, prefix="/app/api/health", tags=["Health"])
@app.get("/app/api/health")
+1
View File
@@ -28,3 +28,4 @@ click==8.4.2
click-didyoumean==0.3.1
click-plugins==1.1.1.2
click-repl==0.3.0
psycopg[binary]
+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"
@@ -0,0 +1,35 @@
version: "1.0"
name: "Code Review Pipeline"
description: "Generate code → review → merge. A standard code-review workflow."
category: development
nodes:
- id: code_gen
type: llm
description: "Generate code based on requirements"
on_error: halt
config:
model: auto
system_prompt: "You are a senior software engineer. Write clean, production-ready code based on the user's requirements. Include comments and error handling."
- id: review
type: llm
description: "Review generated code for bugs and style"
on_error: continue
config:
model: auto
system_prompt: "You are a code reviewer. Analyze the provided code for bugs, style issues, performance problems, and security vulnerabilities. Provide specific, actionable feedback."
- id: merge
type: reduce
description: "Combine code with review feedback"
config:
strategy: concat
edges:
- from: code_gen
to: review
- from: code_gen
to: merge
- from: review
to: merge
@@ -0,0 +1,35 @@
version: "1.0"
name: "Multi-Agent Debate"
description: "Two agents debate a topic from opposing perspectives, then a judge agent synthesizes the conclusion."
category: creativity
nodes:
- id: proponent
type: llm
description: "Argue in favor of the proposition"
on_error: continue
config:
model: auto
system_prompt: "You are a skilled debater. Argue strongly IN FAVOR of the given proposition. Use logic, evidence, and persuasive reasoning. Be passionate but fair."
- id: opponent
type: llm
description: "Argue against the proposition"
on_error: continue
config:
model: auto
system_prompt: "You are a skilled debater. Argue strongly AGAINST the given proposition. Use logic, evidence, and persuasive reasoning to find flaws in the proponent's case."
- id: judge
type: llm
description: "Evaluate both sides and reach a conclusion"
on_error: continue
config:
model: auto
system_prompt: "You are an impartial judge. Evaluate both arguments fairly. Identify the strongest points from each side. Provide a balanced conclusion and recommendation."
edges:
- from: proponent
to: judge
- from: opponent
to: judge
@@ -0,0 +1,25 @@
version: "1.0"
name: "RAG Search Pipeline"
description: "Search knowledge base → augment with context → generate answer. Standard RAG pipeline."
category: productivity
nodes:
- id: search
type: llm
description: "Generate search queries and retrieve relevant context"
on_error: continue
config:
model: auto
system_prompt: "You are a search specialist. For the given question, identify 2-3 key search points. Then search the provided knowledge base for relevant information. Return the most relevant passages with source references."
- id: answer
type: llm
description: "Generate final answer with citations"
on_error: continue
config:
model: auto
system_prompt: "You are a knowledge assistant. Using the search results provided, generate a comprehensive answer. Always cite your sources. If information is insufficient, clearly state what is missing."
edges:
- from: search
to: answer
@@ -0,0 +1,25 @@
version: "1.0"
name: "Research & Answer"
description: "Research topic → generate answer. Uses two LLM nodes for research and synthesis."
category: productivity
nodes:
- id: research
type: llm
description: "Search and gather information on the topic"
on_error: continue
config:
model: auto
system_prompt: "You are a thorough researcher. Search for key facts, data points, and relevant context about the given topic. Provide a comprehensive summary with citations."
- id: answer
type: llm
description: "Synthesize research into final answer"
on_error: continue
config:
model: auto
system_prompt: "You are a professional writer. Based on the research summary provided, craft a clear, well-structured answer for the user. Include key findings and actionable insights."
edges:
- from: research
to: answer
+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"]
+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
+75 -1
View File
@@ -48,7 +48,7 @@ services:
- DEFAULT_MODEL=deepseek-ai/DeepSeek-V4-Flash
- HTTP_PROXY=http://172.18.0.1:7890
- HTTPS_PROXY=http://172.18.0.1:7890
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.218,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
- NO_PROXY=localhost,127.0.0.1,::1,210.16.161.2,qihu-aiflow-litellm,qihu-aiflow-meilisearch,qihu-aiflow-pg18,qihu-aiflow-redis,qihu-aiflow-casdoor18,172.17.0.0/16,172.18.0.0/16,172.19.0.0/16,172.20.0.0/16,10.0.0.0/8,192.168.0.0/16,.svc,.local,casdoor,qihu-aiflow-mysql,qihu-aiflow-redis,qihu-aiflow-pgvector,qihu-aiflow-pg18,qihu-aiflow-casdoor,qihuyun.com,ai.qihuyun.com,docs.qihuyun.com,bot.qihuyun.com,siliconflow.cn,api.siliconflow.cn,deepseek.com,api.deepseek.com,volcengineapi.com,aliyun.com,aliyuncs.com,dysmsapi.aliyuncs.com,tencentcloudapi.com,baidubce.com,dashscope.com,qwen.ai,zhipuai.cn,moonshot.cn,minimax.chat,baichuan.com,chatglm.cn,xiaomimimo.com,token-plan-cn.xiaomimimo.com,mi.com,pypi.tuna.tsinghua.edu.cn,feishu.cn,open.feishu.cn,dingtalk.com,open.dingtalk.com,weixin.qq.com,developers.weixin.qq.com,qyapi.weixin.qq.com,ilinkai.weixin.qq.com,tencent.com
depends_on:
redis:
condition: service_started
@@ -80,9 +80,83 @@ services:
max-size: "20m"
max-file: "5"
# Phase 0B: LiteLLM Proxy - Unified AI Gateway
litellm:
image: ghcr.io/berriai/litellm:main-stable
container_name: qihu-aiflow-litellm
command: ["--config", "/app/config.yaml", "--port", "4000"]
ports:
- "14000:4000"
volumes:
- ./config/litellm_config.yaml:/app/config.yaml
environment:
- HTTP_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
- HTTPS_PROXY=http://qyyfw:XHR521yhf@103.242.14.208:11495
- NO_PROXY=localhost,127.0.0.1,qihu-aiflow-pg18
- LITELLM_MASTER_KEY=sk-litellm-master-key-aiflow-2026
- SILICONFLOW_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
- AGNES_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
- LITELLM_CONFIG=/app/config.yaml
- MIMO_API_KEY=sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
- DATABASE_URL=postgresql://aiflow:pwd2026@qihu-aiflow-pg18:5432/litellm
- LITELLM_MODE=PRODUCTION
- STORE_MODEL_IN_DB=True
restart: unless-stopped
networks:
- aiflow-network
meilisearch:
image: getmeili/meilisearch:v1.11
container_name: qihu-aiflow-meilisearch
ports:
- "17700:7700"
volumes:
- meilisearch_data:/meili_data
environment:
- MEILI_MASTER_KEY=meili-master-key-aiflow-2026
- MEILI_ENV=production
- MEILI_NO_ANALYTICS=true
restart: unless-stopped
networks:
- aiflow-network
# MuMuAINovel - AI Novel Creation (Phase2 App Service #1)
mumuainovel:
image: mumujie/mumuainovel:latest
container_name: qihu-aiflow-mumuainovel
restart: unless-stopped
networks:
- aiflow-network
environment:
- OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1
- OPENAI_API_KEY=sk-litellm-master-key-aiflow-2026
- DEFAULT_AI_PROVIDER=openai
- DEFAULT_MODEL=deepseek-v4-flash
- POSTGRES_HOST=qihu-aiflow-pg18
- POSTGRES_DB=mumuainovel
- POSTGRES_USER=aiflow
- POSTGRES_PASSWORD=pwd2026
- LOCAL_AUTH_ENABLED=true
- LOCAL_AUTH_USERNAME=mumu_admin
- LOCAL_AUTH_PASSWORD=mumu_admin_2026
- LINUXDO_AUTH_ENABLED=false
# MuMuAINovel OIDC Sidecar (GPL compliance boundary)
mumuainovel-oidc:
image: nginx:alpine
container_name: qihu-aiflow-mumuainovel-oidc
restart: unless-stopped
networks:
- aiflow-network
volumes:
- ./config/nginx/mumuainovel-oidc.conf:/etc/nginx/conf.d/default.conf
depends_on:
- mumuainovel
volumes:
redis_data:
meilisearch_data:
networks:
aiflow-network:
driver: bridge
+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次重试 + 指数退避 |
+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 |
+286
View File
@@ -0,0 +1,286 @@
# 七虎AI PaaS 平台 — 迭代优化可执行方案 v1.0
> **来源**: 基于 `AI_PaaS_Platform_Analysis_and_Execution_Plan v2.md`(元宝深度分析文档)
> **日期**: 2026-07-27
> **原则**: P0 先做、P1 跟进、P2 规划评估;每 Phase 一个交付周期
---
## 总览:20 项行动按 4 个 Phase 分组
```
Phase 0 (本周): 4 P0 项 — 基础设施补全,投入产出比最高
Phase 1 (2周内): 6 P0+P1 项 — 核心竞争力构建
Phase 2 (1月内): 5 P1 项 — 体验与护城河加固
Phase 3 (评估): 5 P2 项 — 按需投入
```
---
## Phase 0:基础设施补全(本周,4 项 P0)
这些是文档反复强调的"先决条件"——不做,后面的都没法高效开展。
### #0A. 多租户隔离(PG RLS + tenant_id)
**现状**: 平台无真正的多租户隔离。所有用户共享同一套数据,仅靠 JWT 区分身份。
**目标**: 启用 PostgreSQL Row Level Security,所有业务表加 `tenant_id`。
**工作量**: 3 天
**实施**:
- 核心表(conversations/messages/market_apps/skill_instances)加 `tenant_id UUID` 列
- 启用 `ALTER TABLE ... ENABLE ROW LEVEL SECURITY`
- 创建 RLS 策略:`USING (tenant_id = current_setting('app.current_tenant')::UUID)`
- FastAPI middleware 注入 `app.current_tenant` 从 JWT 提取
- 知识库/资产存储目录结构改为 `/workspace/{tenant_id}/{user_id}/`
### #0B. LiteLLM Proxy — 统一 AI 网关
**现状**: 自定义 failover(`_try_models` 列表 + `continue` 切换),无成本追踪、无限流、无缓存。
**目标**: 引入 LiteLLM Proxy 作为网关,客户端零改动。
**工作量**: 3 天
**实施**:
- 部署 `litellm` 容器,配置 `litellm_config.yaml`
- 映射现有 4 个 provider(SiliconFlow/MiMo/AGNES/DeepSeek)
- 启用 Virtual Keys(按用户/租户分发 API Key,追踪花费)
- 启用 RPM/TPM 限流 + 加权负载均衡(替代 try-except failover)
- 启用 Redis 语义缓存(相同 prompt 不重复计费)
- litellm 端点替换 `DATABASE_URL` 配置中的模型 endpoint
### #0C. Meilisearch — 技能市场搜索引擎
**现状**: 技能市场搜索依赖 PostgreSQL `ILIKE` 查询,无中文分词、无 BM25、无分面筛选。
**目标**: 部署 Meilisearch 容器,替代 PG 全文搜索。
**工作量**: 2 天
**实施**:
- 部署 `meilisearch` 容器(含 jieba 中文分词)
- 创建 `market_apps` 索引(name/description/tags/category/capability/publisher)
- 全量同步现有 115 个技能到 Meilisearch
- 前端 MarketPage 搜索框改为调用 `/search` API(sub-50ms 响应)
- 支持 facets(category/capability/tags)+ 排序(downloads/rating/date)
- 后续可用 Tenant Tokens 做多租户搜索隔离
### #0D. 角色模型扩展(5 级 + 用户组)
**现状**: 仅 admin/user 两级,无 user groups、无模型/技能按组分配。
**目标**: 5 级角色 + 用户组机制。
**工作量**: 3 天
**实施**:
- 角色:super_admin / admin / developer / user / guest
- 用户组(Groups):用户可属于多个组;组级模型配额和技能可见性
- 用户组 API(CRUD + 成员管理)
- 前端 AdminLayout 角色判断(developer 可发布技能但不可管理用户)
---
## Phase 1:核心竞争力构建(2 周内,6 项 P0+P1)
### #1A. 技能真实执行器 — 3 个标杆技能 ⭐ 最重要
**现状**: `_execute_skill_tool()` 是"指令转发"——把 SKILL.md 的文字抄给 LLM,让 LLM "模拟执行"。这是平台最大短板。
**目标**: 落地真实执行器,3 个标杆技能跑通端到端。
**工作量**: 2 周
**实施**:
**Step 1: 执行器基础架构(3 天)**
- `SkillRuntime` 类:鉴权 → 创建工作区 → 调技能 API → 扣配额 → 存储产物 → 记录计量
- `SkillInstance` DB 模型:状态机(generating/ready/failed/archived)
- S3 兼容对象存储集成(MinIO 起步)
- 工作区隔离:`/workspace/{tenant_id}/{user_id}/{skill}/{instance_id}/`
**Step 2: 标杆技能 #1 — AI PPT 生成器(5 天)**
- 基于 dashi-ppt-skill 改造
- 适配层 API:`/api/generate` POST、`/api/export/pptx` GET、`/api/export/pdf` GET
- 输入:文本/文档 → LLM 生成大纲 → 渲染 HTML 幻灯片 → 导出 PPTX/PDF
- 预览图生成(headless Chrome 截图)
- 配额:Free 3次/天,Pro 50次/天
**Step 3: 标杆技能 #2 — SEO 优化助手(2 天)**
- 基于 marketing-skills 改造
- 输入:URL/关键词 → 分析 SERP → 输出 SEO 报告+优化建议
**Step 4: 标杆技能 #3 — AI 投研报告��2 天)**
- 基于 equity-research-skill 改造
- 输入:股票代码 → 财报分析 + DCF 估值 → 输出结构化投研报告
### #1B. 统一 SKILL.md Schema
**现状**: 技能元数据散乱,无统一格式/版本/权限/定价规范。
**目标**: 发布平台统一 SKILL.md 规范(9 大区块)。
**工作量**: 3 天
**实施**:
- Schema:name/version/author/description/capabilities/inputs/outputs/resource/permissions/dependencies/pricing
- 与现有 market_app DB 字段映射
- 自动解析器:`_parse_skill_content()` 增强,从 SKILL.md 提取结构
- 前端技能详情页显示结构化信息(输入/输出/配额/定价)
### #1C. 流式 Token 计费 + 实时余额
**现状**: 仅记录 Token 用量,无计费、无余额扣减、无配额提示。
**目标**: 实时 Token 计费 + 流式扣费 + 用户余额。
**工作量**: 5 天
**实施**:
- LiteLLM 集成后,每次调用自动获取 `usage.prompt_tokens + completion_tokens`
- 流式场景:累加 chunk 计数,`done` 事件时写入
- 用户余额表:`token_balances`
- 前端:对话中实时显示剩余 Token(顶部状态栏)
- 余额不足时提前拦截(不浪费一次模型调用)
### #1D. Agent 可视化编排画布 ⭐ 亮点
**现状**: Agent 编排靠 YAML 手写,无可视化界面。
**目标**: 基于 React Flow 的可拖拽编排画布。
**工作量**: 2 周
**实施**:
- 前端:React Flow 节点编辑器(节点库:LLM/Skill/MCP/External/Reduce/条件分支)
- 拖拽连线定义数据流
- 导出为 YAML → 对接现有 `run_graph()` 引擎
- 执行监控:实时高亮当前执行节点、查看输出
### #1E. 角色模型扩展(5 级 + 用户组)— 续 Phase 0D
(已在 Phase 0 中定义)
### #1F. 技能市场搜索 — 续 Phase 0C
(已在 Phase 0 中定义,Meilisearch)
---
## Phase 2:体验与护城河加固(1 月内,5 项 P1)
### #2A. 工具调用四级风险审批
**现状**: 仅 `tool_policy` 沙箱(白名单+预算+每轮上限),无操作风险分级。
**目标**: 参考 OpenWorker 的四级风险模型。
**工作量**: 3 天
**实施**:
- 风险等级:read / write_local / exec / external
- 每级定义默认行为(read 自动许可 / write_local 确认 / exec 审批 / external 管理员审批)
- 前端:高风险操作弹窗确认 + 审批后重试
- 审批队列:pending_approvals 表 + 管理员后台
### #2B. 引入 MemPalace 分层记忆
**现状**: 五类结构化记忆(实体/事实/偏好/决策/待办),无分层索引。
**目标**: Wing/Room/Drawer 分层索引(MemPalace)。
**工作量**: 1 周
**实施**:
- Wing = 用户级(跨所有对话)
- Room = 对话级(单次对话上下文)
- Drawer = 话题级(对话内特定话题)
- 跑 LongMemEval benchmark 量化召回率
### #2C. Loop 原语 + Verifier
**现状**: Agent 编排是 DAG 拓扑排序,无循环边、无验证器。
**目标**: 引入 Loop 原语(循环边 + 终止条件 + Verifier)。
**工作量**: 1 周
**实施**:
- `orchestrator.py` 支持循环边:`{"from": "verify", "to": "generate", "condition": "not_passed"}`
- Verifier 节点:判断目标是否达成的验证器
- 前端画布(#1D)支持画循环边
### #2D. executor 沙箱升级
**现状**: executor 容器基于 Docker,无严格内核隔离。
**目标**: gVisor 或 Firecracker 微虚拟机隔离。
**工作量**: 3 天
**评估**: gVisor 集成最简单(Docker 的 `--runtime=runsc`)
### #2E. 技能 SDK + 脚手架 CLI
**现状**: 技能开发者手动创建目录和 SKILL.md。
**目标**: `qihu-skill` CLI 脚手架。
**工作量**: 1 周
**实施**:
- `qihu-skill init my-skill` → 生成目录结构 + SKILL.md 模板
- `qihu-skill validate` → 校验 SKILL.md Schema
- `qihu-skill publish` → 打包上传到平台技能市场
- `qihu-skill dev` → 本地调试(Mock SkillRuntime)
---
## Phase 3:评估后按需投入(5 项,P2)
### #3A. Semantica 决策溯源
- 场景:金融/医疗/法律客户要求可解释 AI
- 工作量:评估 3 天 + 集成 1 周
- 决策标准:是否有付费企业客户明确要求
### #3B. Langfuse/LangSmith Agent 轨迹
- 场景:开发者调试 Agent 执行、产品优化
- 工作量:3 天集成
- 决策标准:Agent 可视化画布上线后,按需求优先级
### #3C. Agent 广播网络(EigenFlux)
- 场景:Agent 间自动发现与协作
- 工作量:评估
- 决策标准:多 Agent 协作需求明确后
### #3D. 前端暗色模式
- 工作量:3 天
- 决策标准:用户体验迭代批次
### #3E. 移动端适配
- 工作量:2 周
- 决策标准:移动端用户数据达标后
---
## 优先级总表(20 项排序)
| # | 行动 | 模块 | 优先级 | 工作量 | 依赖 |
|---|------|------|--------|--------|------|
| 1 | LiteLLM Proxy 统一 AI 网关 | 模型路由 | P0 | 3d | - |
| 2 | Meilisearch 技能市场搜索 | 搜索 | P0 | 2d | - |
| 3 | PG RLS + tenant_id 多租户隔离 | 多租户 | P0 | 3d | - |
| 4 | 角色 5 级 + 用户组 | 多租户 | P0 | 3d | #3 |
| **5** | **3 个标杆技能真实执行器** | **技能市场** | **P0** | **2w** | **-** |
| 6 | SKILL.md 统一 Schema | 技能市场 | P0 | 3d | - |
| 7 | 流式 Token 计费 + 实时余额 | 变现 | P0 | 5d | #1 |
| **8** | **Agent 可视化编排画布** | **Agent** | **P1** | **2w** | **-** |
| 9 | Loop 原语 + Verifier | Agent | P1 | 1w | #8 |
| 10 | 工具四级风险审批 | 安全 | P1 | 3d | - |
| 11 | executor gVisor 升级 | 安全 | P1 | 3d | - |
| 12 | MemPalace 分层记忆 | RAG | P1 | 1w | - |
| 13 | 技能 SDK + 脚手架 CLI | 技能市场 | P1 | 1w | #6 |
| 14 | LongMemEval 基准测试 | RAG | P1 | 2d | - |
| 15 | 技能审核流水线 | 技能市场 | P2 | 1w | #6 |
| 16 | 分润模型 + 开发者结算 | 变现 | P2 | 1w | #7 |
| 17 | Langfuse Agent 轨迹 | 可观测 | P2 | 3d | #1 |
| 18 | Semantica 决策溯源 | 安全 | P2 | 评估 | - |
| 19 | 前端暗色模式 | UI | P2 | 3d | - |
| 20 | 移动端适配 | UI | P2 | 2w | - |
---
## 竞品对比更新(基于分析文档)
| 维度 | 七虎AI 现状 | Open WebUI | Dify | 差距/优势 |
|------|------------|------------|------|----------|
| RBAC | admin/user 2 级 | Admin/User/Pending + Groups | 内置多租户 | 🔴 落后 |
| 向量库 | 仅 pgvector | 9 种可插拔 | 多种 | 🟡 够用 |
| RAG 管线 | 单路向量 | 3 阶段 BM25+向量+Rerank | 混合检索 | 🟡 待增强 |
| 插件体系 | Skills+MCP | 5 类插件 | 工作流节点 | 🟡 够用 |
| Token 计量 | 仅记录 | 实时+按模型+仪表盘 | 内置 | 🔴 落后 |
| 模型路由 | try-except | Pipe 路由 | 内置 failover | 🟡 待 LiteLLM |
| Agent 编排 | DAG 图引擎 | 无 | 工作流画布 | 🟢 领先 |
| 技能市场 | 上架-安装-执行 | 无 | 无 | 🟢 护城河 |
---
## 当前已完成(对标分析文档的"已修复弱点")
这些本期会话已修复,分析文档第九节引用:
| 修复 | 状态 | 对应文档章节 |
|------|------|------------|
| AGENT_GUIDE 标准化 | ✅ | §九 - 固定弱点已修复 |
| MCP 官方 SDK + Schema 转换 | ✅ | §九 |
| Tool Policy 白名单+预算+每轮上限 | ✅ | §九 |
| MiMo Token Plan 适配 | ✅ | - |
| `overflow-x: hidden` 响应式修复 | ✅ | - |
| 架构蓝图 `ARCHITECTURE.md` | ✅ | - |
---
> **执行指令**: 告诉我 "执行 Phase 0" 开始落地。每完成一项在对应 checkpoint 勾掉。
> **上传到 Gitea**: 本方案同步上传到 `qihuaiflow/docs/ITERATION_PLAN.md`
@@ -0,0 +1,130 @@
# LiteLLM 主副密钥 failover 沙箱演练 — #937
> **任务**:#937 — 验证主副密钥自动接管、计费回调、Token 用量、SSE 中断恢复
> **日期**:2026-08-09
---
## 1. 现状(基于 8/9 容器内 read-only 侦察)
`qihu-aiflow-litellm` 容器 config 路径:`/app/config.yaml`,每个模型只有**单个 api_key**:
```yaml
model_list:
- model_name: deepseek-ai/DeepSeek-V4-Flash
litellm_params:
model: openai/deepseek-ai/DeepSeek-V4-Flash
api_base: https://api.siliconflow.cn/v1
api_key: sk-idsplzec... # ← 单 key
```
**结论**:当前**完全没有 failover**。单 key 失效 → 整个模型挂掉,UI 层 fallback 默认模型兜底,但 SLA 极低。
---
## 2. 设计 — LiteLLM 多 key 轮询
LiteLLM 原生支持:`api_key: ["key1", "key2"]` — 自动 rotate-on-rate-limit / rotate-on-auth-error。
### 2.1 主副密钥策略(双层)
```yaml
model_list:
- model_name: deepseek-ai/DeepSeek-V4-Flash
litellm_params:
model: openai/deepseek-ai/DeepSeek-V4-Flash
api_base: https://api.siliconflow.cn/v1
api_key:
- sk-idsplzec... # 主密钥
- sk-backup-key1... # 副密钥 1
- sk-backup-key2... # 副密钥 2
# 触发轮换的错误码(401 / 429 / 5xx)
rotation:
on_429: true # 速率限制
on_401: true # 鉴权失败
on_5xx: true # 服务端错误
cooldown_time: 60 # 失败后冷却 60s 不再尝试
litellm_settings:
drop_params: true
num_retries: 3 # 单次请求内部 retry 次数
request_timeout: 60
```
### 2.2 平台侧 key 管理(可选增强)
- `models` 表加 `backup_api_keys JSONB` 列(逗号分隔多 key)
- 后台 UI 支持追加/轮换/作废
- 启动时把多 key 同步到 LiteLLM `/config/update`(LiteLLM 提供 admin API)
**不推荐**(反而引复杂):
- 自己写 failover wrapper(重复造轮子,绕过 LiteLLM retry)
- 引入 APISIX/Traefik(过重,延迟 +5ms)
---
## 3. 沙箱演练脚本
文件:`tests/test_litellm_failover_sandbox.py`(已落到 `/app/tests/`)
**演练场景**:
1. **配置注入**:写一份 sandbox config,每个模型给 2 个 key(主 key 故意填无效,副 key 用平台真实 key)
2. **热启动**:用该 config 重启 LiteLLM 容器(sandbox 容器,不动 prod)
3. **请求验证**:发请求 → 期望平台走副 key 成功(主 key 失败被自动 rotate)
4. **指标采集**:记录 rotate 次数 / 失败耗时 / SSE 是否完整
### 3.1 演练结果占位(本次未跑实际演练,因为生产 LiteLLM 不便打断)
| 场景 | 期望 | 实测 |
|---|---|---|
| 主 key 401,副 key 正常 | 自动 rotate,≤3s 出回复 | **本次未跑** |
| 主+副 key 都 401 | 报错给上层,fallback 默认模型 | (LiteLLM 原生行为) |
| 429 速率限制 | rotate 到下一个,cooldown 60s | (LiteLLM 原生行为) |
| SSE 流式中断恢复 | client 侧 reconnect,token 计费不丢 | (项目已实现) |
### 3.2 实际触发演练的命令(待生产 LiteLLM 维护窗口)
```bash
# 1. 拉 LiteLLM 副 key(目前没有 — 行动项)
# 与 SiliconFlow 等供应商签双 key 合约;或自建中转层
# 2. 准备 sandbox config
docker exec qihu-aiflow-litellm cp /app/config.yaml /app/config.yaml.bak
# 编辑 /app/config.yaml,主 key 故意改为 "sk-invalid-test" 模拟失效
docker exec qihu-aiflow-litellm kill -HUP 1 # LiteLLM reload
# 3. 跑沙箱测试
docker exec qihu-aiflow python3 tests/test_litellm_failover_sandbox.py
# 4. 恢复 config
docker exec qihu-aiflow-litellm mv /app/config.yaml.bak /app/config.yaml
docker exec qihu-aiflow-litellm kill -HUP 1
```
---
## 4. 行动项(按优先级)
### 4.1 P1 — 申请副 key(1 周)
- 主流供应商(SiliconFlow / 阿里百炼 / 智谱)签双 key 合约
- 给关键模型(text / vision / image / video 各至少 2 个 key)配置 LiteLLM rotation
- **不阻塞**:当前单 key 已经在用,只是 SLA 风险未缓解
### 4.2 P2 — 后台 UI 轮换(2-3 天)
- `models` 表加 `backup_api_keys JSONB`
- 后台 Models 页加"轮换副 key"按钮
- 后台 adminAPI 调 LiteLLM `/config/update` 同步
### 4.3 P3 — 演练自动化(1 天)
- 把 §3 命令写成 CI/CD 脚本,每季度跑一次
- 演练结果入 Grafana,异常告警
---
## 5. 验证标准(#937 验收)
- [x] 文档落盘 ✅
- [x] LiteLLM 配置文件示例 ✅
- [x] 沙箱脚本就位(本次不实跑,避免影响生产)✅
- [x] 行动项拆分(P1/P2/P3) ✅
- [ ] 真实副 key 申请(交给商务)
- [ ] 后台 UI 实现(等 P2 启动)
@@ -0,0 +1,185 @@
# 应用市场产品计费 Webhook 设计 — #939
> **任务**:#939 — 市场产品计费 webhook 设计
> **日期**:2026-08-09
> **状态**:✅ 设计完成 / 留待 MuMuAINovel 上线后接入
---
## 1. 业务诉求
平台应用市场(`market_products`)中,产品可以是:
- **免费 SaaS**:无计费,接入即可用
- **按量计费(metered)**:每次调用扣账户余额
- **订阅(subscription)**:月度/年固定费
- **免费增值(freemium)**:基础免费 / 高级付费
当前 4 个计费方案中,前两种(`free`)零成本。`metered` / `subscription` / `freemium` 需要:
1. 计费事件触发(产品方在用户用了功能后回调)
2. 对账(平台账与产品方账一致)
3. 重试(网络抖动不能丢单)
---
## 2. 目标架构
### 2.1 单向事件流(平台 = 计费接收方)
```
产品方后端 → POST /app/api/market/products/{id}/billing-event
→ 平台校验签名(HMAC-SHA256)
→ 落 im_billing_events 表
→ 异步 reconcile worker → 扣用户余额 / 开订阅
→ 200 OK + {accepted: true, event_id}
```
**关键设计点**:
- Webhook 是单向事件流,**不假设产品方按序回调**(用 event_id 去重)
- 平台**幂等**:同一 event_id 重复回调不重复扣费
- 平台**有状态**:余额扣减事务化,失败可重试
### 2.2 数据表 `im_billing_events`
```sql
CREATE TABLE IF NOT EXISTS im_billing_events (
id BIGSERIAL PRIMARY KEY,
event_id TEXT NOT NULL UNIQUE, -- 产品方传的去重键(UUID)
product_id UUID NOT NULL, -- market_products.id
user_id UUID, -- 关联平台用户(可空:订阅场景)
event_type TEXT NOT NULL, -- 'usage' / 'subscription_start' / 'subscription_end' / 'refund'
quantity DECIMAL(18, 6) NOT NULL DEFAULT 1,
unit_price DECIMAL(18, 6), -- 单价(可选,平台基于 product.billing_plan 校验)
amount DECIMAL(18, 6) NOT NULL, -- 扣减金额(分)
currency TEXT NOT NULL DEFAULT 'CNY',
raw_payload JSONB NOT NULL, -- 产品方完整 payload,供对账
signature TEXT NOT NULL, -- HMAC-SHA256
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ, -- worker 处理成功时间
reconcile_status TEXT NOT NULL DEFAULT 'pending',
error_message TEXT,
retry_count INT NOT NULL DEFAULT 0,
UNIQUE (event_id)
);
CREATE INDEX idx_im_billing_events_product ON im_billing_events(product_id, received_at DESC);
CREATE INDEX idx_im_billing_events_user ON im_billing_events(user_id, received_at DESC);
CREATE INDEX idx_im_billing_events_pending ON im_billing_events(reconcile_status) WHERE reconcile_status = 'pending';
```
### 2.3 Webhook 端点
```python
# app/api/market_billing_webhook.py
from fastapi import APIRouter, Request, HTTPException, Depends
import hmac, hashlib, json
from datetime import datetime
router = APIRouter(prefix="/app/api/market/products", tags=["billing-webhook"])
def verify_signature(product_id: str, body: bytes, signature: str) -> bool:
"""HMAC-SHA256 验签。
每个产品有独立 secret(存 market_products.metadata.billing_secret)。
"""
product = db.query(MarketProduct).filter(...).first()
secret = (product.metadata or {}).get("billing_secret", "")
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@router.post("/{product_id}/billing-event")
async def receive_billing_event(product_id: str, request: Request):
body = await request.body()
sig = request.headers.get("X-Billing-Signature", "")
if not verify_signature(product_id, body, sig):
raise HTTPException(401, "invalid signature")
payload = json.loads(body)
event_id = payload.get("event_id")
if not event_id:
raise HTTPException(400, "missing event_id")
# 幂等:已有 event_id 直接返回
existing = db.query(ImBillingEvent).filter(ImBillingEvent.event_id == event_id).first()
if existing:
return {"accepted": True, "event_id": existing.id, "dedup": True}
# 落库
evt = ImBillingEvent(
event_id=event_id,
product_id=product_id,
user_id=payload.get("user_id"),
event_type=payload["event_type"],
amount=payload["amount"],
raw_payload=payload,
signature=sig,
)
db.add(evt); db.commit()
return {"accepted": True, "event_id": evt.id}
```
### 2.4 Reconcile Worker(异步)
```python
# app/api/market_billing_worker.py
import asyncio
async def process_pending_events():
"""每 30s 扫一次 reconcile_status='pending' 的事件,处理:
- 'usage': 扣 user_balance.amount += amount
- 'subscription_start': 创建 billing_subscription 行
- 'subscription_end': 标记 subscription cancelled
- 'refund': 反向扣减
"""
while True:
events = db.query(ImBillingEvent).filter(
ImBillingEvent.reconcile_status == "pending",
ImBillingEvent.retry_count < 5,
).limit(100).all()
for evt in events:
try:
apply_to_user_balance(evt)
evt.reconcile_status = "applied"
evt.processed_at = datetime.utcnow()
db.commit()
except Exception as e:
evt.retry_count += 1
evt.error_message = str(e)
db.commit()
await asyncio.sleep(30)
```
---
## 3. 安全
| 项 | 实现 |
|---|---|
| 防重放 | event_id 唯一约束 |
| 防伪造 | HMAC-SHA256(每产品独立 secret) |
| 防篡改 | 数据库行级 hash(可选,`signature` + `body` 二次 hash) |
| 防过载 | Rate limit:每产品 100 events/min |
| 鉴权 | IP 白名单(可选,Casdoor OIDC token 也可) |
---
## 4. 重试 / 死信
- 平台处理失败:`retry_count` 累加,`error_message` 记录
- 5 次重试仍失败:`reconcile_status = 'dead_letter'`,后台告警
- 产品方重发:用同一 `event_id`,平台识别为重复
---
## 5. 行动项
### 5.1 P1 — 落库 + 端点 stub(0.5 天)
- 迁移 `im_billing_events` 表
- 写 webhook 端点 + 幂等检查
- 后台 admin 加 "Billing Events" 列表页
### 5.2 P2 — Worker + 计费对接(1 天)
- 异步 reconcile worker(后台 task)
- 余额扣减事务化(SQLAlchemy with FOR UPDATE)
- 计费异常告警(Grafana + Alertmanager)
### 5.3 P3 — MuMuAINovel 接入(0.5 天)
- 文档化 webhook 接入规范
- MuMuAINovel 后端按规范加事件推送
**当前不实现任何代码,只保留设计 + 接口契约**。等 MuMuAINovel 上线后再拉 P1。
+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)
+316
View File
@@ -0,0 +1,316 @@
# 微信 Bot iLink 协议重构方案
> **版本**: v1.0 | **日期**: 2026-08-15 | **作者**: 七虎AI 技术团队
> **背景**: 参考微信官方 iLink (ClawBot) 协议完整解析,对平台现有个人微信 Bot 实现做差距分析并重构
> **关联**: `im_bot_user.py`(前台用户级)、`admin/im_bot_admin.py`(后台平台级)、`im_llm.py`(EngineCore 薄壳)
---
## 一、协议背景:微信官方 iLink (ClawBot)
### 1.1 定位
iLink(智联)是腾讯微信 2026 年官方开放的个人微信机器人通信协议,对外产品名 **ClawBot**。这是**唯一合规**的个人微信 Bot 通道,有《微信 ClawBot 功能使用条款》法律背书。
| 维度 | 逆向 iPad/PC Hook(旧灰产) | iLink Bot API(官方合规) |
|------|---------------------------|--------------------------|
| 合规性 | 违反微信用户协议 | 官方开放,签署使用条款 |
| 封号风险 | 极高,大号易永久封禁 | 正常使用无封号风险 |
| 协议稳定性 | 微信升级即失效 | 服务端 API,版本可控 |
| 登录方式 | 模拟设备/劫持会话 | 微信扫码授权,临时 token |
| 通信方式 | 模拟心跳,触发风控 | 长轮询,无需公网 IP |
### 1.2 核心架构(三层)
```
上层(逻辑): 业务代码 —— 消息收发、AI 应答、工具调用
中层(文件): CDN 媒体加解密 —— AES-128-ECB + PKCS7
底层(通道): iLink 长轮询 —— 消息通道(ilinkai.weixin.qq.com)
```
两个核心域名:
| 域名 | 用途 |
|------|------|
| `https://ilinkai.weixin.qq.com` | 所有 API 请求 |
| `https://novac2c.cdn.weixin.qq.com/c2c` | 图片/语音/视频媒体文件 |
### 1.3 七个 API 端点
| 端点 | 方法 | 功能 | 关键参数 |
|------|------|------|---------|
| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码 | `?bot_type=3` |
| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态 | `?qrcode=xxx` |
| `/ilink/bot/getupdates` | POST | 长轮询收消息(35s hold) | `{ get_updates_buf }` |
| `/ilink/bot/sendmessage` | POST | 发送消息 | `{ msg: { context_token, item_list } }` |
| `/ilink/bot/getuploadurl` | POST | 获取 CDN 上传地址 | `{ filekey, md5, len }` |
| `/ilink/bot/getconfig` | POST | 获取配置 | `{ base_info }` |
| `/ilink/bot/sendtyping` | POST | 发送"正在输入"状态 | `{ to_user_id, typing_ticket }` |
### 1.4 鉴权机制
```
Authorization: Bearer {bot_token} # 扫码登录获取
AuthorizationType: ilink_bot_token # 固定值
X-WECHAT-UIN: {随机uint32 → base64} # 每次请求换新,防重放攻击
```
### 1.5 消息机制
- **长轮询**:`get_updates_buf` 是游标/位置书签,每次请求必须用返回的新值替换旧值,否则重复收消息死循环
- **context_token**:每条消息的"身份证",回复必须原样回带,否则不关联到正确对话窗口
- **不能主动推送**:必须由用户先发消息触发
- **消息类型**:`type 1`文本 / `2`图片 / `3`语音 / `4`文件 / `5`视频
- **语音自带 ASR**:VoiceItem 含 `text` 字段,微信已做好语音转文字
---
## 二、现状分析
### 2.1 已实现能力(im_bot_user.py)
| 能力 | 实现 | 状态 |
|------|------|------|
| 扫码登录 | `get_bot_qrcode` + `get_qrcode_status` 轮询 | ✅ 完整 |
| 长轮询收消息 | `getupdates` + `get_updates_buf` 游标推进 | ✅ 完整 |
| 发消息 | `sendmessage` + `context_token` 回带 | ✅ 完整 |
| 鉴权 | `ilink_bot_token` + `X-WECHAT-UIN` 随机防重放 | ✅ 完整 |
| 文本分段 | `WECHAT_TEXT_MAX_LEN=1500` 超长分段 | ✅ 完整 |
| 会话过期检测 | `ret=-14/-2` 区分过期/参数错误 | ✅ 完整 |
| 用户级隔离 | 前台用户各自绑定,`im_bot_wechat_account` 按 user_id 隔离 | ✅ 完整 |
| token 持久化 | `bot_token`/`ilink_bot_id` 存 PG | ✅ 完整 |
| 对话历史 | `_log_chat` + 镜像到 AI 聊天窗 | ✅ 完整 |
| 异常日志 | 接收器异常写 `/tmp/wechat_receiver_err.log` | ✅ 完整 |
| 双 Claw 单引擎 | `im_llm.py` 薄壳复用 EngineCore | ✅ 完整 |
### 2.2 差距分析(核心问题)
| # | 能力 | 当前状态 | 影响 | 优先级 |
|---|------|---------|------|--------|
| 1 | **媒体内联发送** | ❌ 降级为纯文本链接(`🖼️ 图片已生成,点击查看:URL`) | 图片/视频不能内联显示,用户体验差 | 🔴 P0 |
| 2 | **sendtyping** | ❌ 未实现 | AI 回复等待期无"正在输入"提示 | 🟡 P1 |
| 3 | **语音消息** | ❌ 未提取 VoiceItem ASR text | 语音消息无法应答 | 🟡 P1 |
| 4 | **Session Guard 增强** | ⚠️ `-14` 仅标记 expired,无主动通知 | 会话过期用户无感知,需手动发现重扫 | 🟡 P1 |
| 5 | **CDN 下载解密** | ❌ 未实现(收图片/语音/文件无法解密) | 无法处理用户发来的图片/文件 | 🟢 P2 |
### 2.3 核心痛点详述:媒体内联发送
**当前实现**(`_wechat_send_media`):
```python
def _wechat_send_media(...):
# iLink 对 type=2/5 媒体 item 返回 ret=-2 invalid arguments
# 降级为纯文本链接
caption = "🖼️ 图片已生成,点击查看:" + relay_url
return _wechat_send_message(...) # 发文本
```
**根本原因**:iLink 媒体消息不是直接传 URL,而是需要走 **CDN 上传加密链路**:
1. 下载 AGNES 生成的图片字节
2. 用 `getuploadurl` 获取微信 CDN 上传地址
3. **AES-128-ECB 加密**图片字节
4. 上传到 `novac2c.cdn.weixin.qq.com`
5. 发消息时带 `download_param` + `aes_key`(type=2 图片 / type=5 视频)
当前代码缺少整个 CDN 加密链路,所以只能降级为文本链接。
---
## 三、重构方案
### 3.1 总体设计
```
用户微信 ──(文本/图片/语音)──> iLink getupdates
│
▼
┌── im_bot_user.py 接收循环 ──┐
│ 提取 text / VoiceItem ASR │
│ (P1) 语音转文字 │
└──────────┬─────────────────┘
▼
EngineCore._send_message_core
(双 Claw 单引擎,复用 LLM/MCP)
│
┌──────────────┼──────────────┐
▼ ▼
文本回复 媒体回复(__media__)
│ │
│ ┌──────────────┴──────────────┐
│ │ (P0) 媒体内联发送链路 │
│ │ 1. 下载 AGNES 图片字节 │
│ │ 2. getuploadurl 拿地址 │
│ │ 3. AES-128-ECB 加密 │
│ │ 4. 上传微信 CDN │
│ │ 5. sendmessage type=2/5 │
│ └──────────────┬──────────────┘
▼ ▼
sendmessage (文本) sendmessage (媒体 item)
+ (P1) sendtyping 前置
```
### 3.2 分阶段实施
#### Phase 1(P0):媒体内联发送 —— 核心
**目标**:图片/视频真正内联显示在微信,替代当前文本链接降级。
**新增模块** `app/api/im_ilink_media.py`:
```python
"""
iLink CDN 媒体加解密 —— 参考微信官方 iLink 协议
核心:AES-128-ECB + PKCS7 加密上传,下载解密
"""
import hashlib, os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
def _aes_ecb_encrypt(plaintext: bytes, key: bytes) -> bytes:
"""AES-128-ECB + PKCS7 加密"""
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.ECB())
enc = cipher.encryptor()
return enc.update(padded) + enc.finalize()
def _aes_ecb_decrypt(ciphertext: bytes, key: bytes) -> bytes:
"""AES-128-ECB 解密"""
cipher = Cipher(algorithms.AES(key), modes.ECB())
dec = cipher.decryptor()
padded = dec.update(ciphertext) + dec.finalize()
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def upload_media_to_wechat_cdn(bot_token: str, baseurl: str, media_bytes: bytes, filekey: str) -> dict:
"""六步上传流程:
1. 计算 MD5
2. 生成随机 AES-128 key (16字节)
3. POST /ilink/bot/getuploadurl → upload_param
4. AES-128-ECB 加密文件
5. POST novac2c.cdn.weixin.qq.com/c2c/upload
6. 返回 download_param + aes_key
"""
md5 = hashlib.md5(media_bytes).hexdigest()
aes_key = os.urandom(16)
# ... getuploadurl 调用
# ... 加密 + 上传
return {"download_param": "...", "aes_key": aes_key.hex()}
```
**修改 `_wechat_send_media`**:
```python
def _wechat_send_media(baseurl, bot_token, to_user_id, context_token, media_type, url):
# 1. 下载 AGNES 图片字节(走 HK 代理,trust_env=False)
media_bytes = _download_media(url)
# 2. 上传微信 CDN + AES 加密
cdn = upload_media_to_wechat_cdn(bot_token, baseurl, media_bytes, filekey)
# 3. 发媒体消息(type=2 图片 / type=5 视频)
item = {
"type": 2 if media_type == "image" else 5,
"image_item": { # 或 video_item
"media": cdn["download_param"],
"aes_key": cdn["aes_key"],
}
}
return _wechat_post_msg(baseurl, bot_token, to_user_id, context_token, [item])
```
**依赖**:`cryptography` 库(需加入 requirements.txt)
#### Phase 2(P1):sendtyping 正在输入
```python
def _wechat_send_typing(baseurl, bot_token, to_user_id, typing_ticket):
"""发消息前先发'正在输入'状态,改善等待体验"""
return _post(baseurl + "/ilink/bot/sendtyping",
{"to_user_id": to_user_id, "typing_ticket": typing_ticket})
# 接收循环中,调用 _qihua_reply 前:
_wechat_send_typing(baseurl, bot_token, sender, typing_ticket)
```
- `typing_ticket` 从 `getconfig` 获取,按用户缓存 24h
#### Phase 3(P1):语音消息 ASR
```python
def _wechat_extract_text(msg):
"""扩展:支持语音消息自带 ASR 结果"""
for it in (msg.get("item_list") or []):
if it.get("type") == 1: # 文本
...
elif it.get("type") == 3: # 语音
voice = it.get("voice_item", {})
if voice.get("text"): # 微信已做好语音转文字
parts.append(voice["text"])
...
```
#### Phase 4(P1):Session Guard 增强
```python
def _mark_wechat_expired(account_id):
"""增强:标记 expired 的同时,主动给用户发站内通知/邮件提醒重扫"""
# 1. 标记 expired + 清 token(现有逻辑)
# 2. 停掉接收线程
# 3. 触发站内通知:'您的微信 Bot 会话已过期,请重新扫码绑定'
# 4. 记录 audit log
```
#### Phase 5(P2):接收侧媒体解密
```python
def _download_wechat_media(msg):
"""接收用户发来的图片/语音/文件:
1. GET novac2c.cdn.weixin.qq.com/c2c/download?encrypted_query_param=xxx
2. 用消息里的 aes_key 解密
"""
```
### 3.3 技术要点
| 要点 | 说明 |
|------|------|
| AES 模式 | AES-128-ECB(微信指定,非标准 CBC;一次性随机 key + HTTPS 保证安全) |
| 密钥长度 | 16 字节(128-bit) |
| 填充 | PKCS7 |
| 密文大小 | `ceil((plaintext_size+1)/16)*16` |
| 媒体域名 | `novac2c.cdn.weixin.qq.com/c2c/upload|download` |
| 错误码 | `0`成功 / `-14`会话过期 / `-2`参数错误 |
### 3.4 风险与规避
| 风险 | 规避 |
|------|------|
| ECB 模式理论不安全 | 每文件一次性随机 key + HTTPS,风险可控 |
| 媒体上传超时 | 大图/视频走异步,超时重试 + 降级文本链接兜底 |
| `cryptography` 依赖 | 需在容器 venv 安装,纳入 requirements.txt |
| iLink 媒体协议细节不明确 | 保留文本链接降级 fallback,逐步灰度 |
---
## 四、实施计划
| Phase | 内容 | 优先级 | 预估工作量 | 依赖 |
|-------|------|--------|-----------|------|
| P0 | 媒体内联发送(CDN AES 加密) | 🔴 高 | 2-3 天 | `cryptography` 库 |
| P1 | sendtyping 正在输入 | 🟡 中 | 0.5 天 | 无 |
| P1 | 语音 ASR | 🟡 中 | 0.5 天 | 无 |
| P1 | Session Guard 增强 | 🟡 中 | 1 天 | 站内通知 |
| P2 | 接收侧媒体解密 | 🟢 低 | 2 天 | P0 经验复用 |
---
## 五、验收标准
- [ ] P0:微信里生成的图片/视频**内联显示**,不再是文本链接
- [ ] P1:AI 回复前微信显示"正在输入"
- [ ] P1:发语音消息能正确应答(ASR 文本进入 LLM)
- [ ] P1:会话过期时用户收到明确重扫通知
- [ ] 双 Claw 单引擎不回归(`tests/test_dual_claw.py` 绿)
- [ ] pytest + vitest 全绿
---
## 六、参考资料
- 微信官方 iLink (ClawBot) 协议(CSDN: 2401_88147261 / HiWangWenBing / bytenote)
- OpenClaw 框架 `@tencent-weixin/openclaw-weixin` 增强:Session Guard / Config Cache / SILK 转码 / CDN 加解密
- 平台现状代码:`app/api/im_bot_user.py`、`app/api/admin/im_bot_admin.py`、`app/api/im_llm.py`
+126
View File
@@ -0,0 +1,126 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/query-client';
import { Layout } from '@/components/layout/Layout';
import { LoginPage } from '@/pages/front/LoginPage';
import { LandingPage } from '@/pages/front/LandingPage';
import { ChatPage } from '@/pages/front/ChatPage';
import { KnowledgePage } from '@/pages/front/KnowledgePage';
import { ImBotHub } from '@/pages/front/ImBotHub';
import { AppServices } from '@/pages/front/AppServices';
import { DashboardPage } from '@/pages/front/DashboardPage';
import { ProfilePage } from '@/pages/front/ProfilePage';
import { MarketPage } from '@/pages/front/MarketPage';
import { NotificationsPage } from '@/pages/front/NotificationsPage';
import { EvolutionPage } from '@/pages/front/EvolutionPage';
import { ComingSoon } from '@/components/common/ComingSoon';
import {
Bot,
Puzzle,
Workflow,
Shield,
Radio,
} from 'lucide-react';
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<Layout />}>
<Route index element={<LandingPage />} />
<Route path="chat" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} />
<Route path="im-bots" element={<ImBotHub />} />
<Route path="apps" element={<AppServices />} />
{/* 占位页:真实功能开发中,统一 amber 金暖白「敬请期待」态 */}
<Route path="market" element={<MarketPage />} />
<Route
path="agents"
element={
<ComingSoon
title="Agent 智能体"
icon={Bot}
description="可视化编排多源 Agent,组合技能、知识与工具完成复杂任务。"
features={[
'拖拽式 Agent 编排画布',
'多 Agent 协作与对话路由',
'绑定知识库与 MCP 工具',
]}
/>
}
/>
<Route
path="mcp"
element={
<ComingSoon
title="MCP 工具"
icon={Puzzle}
description="接入 Model Context Protocol 工具,扩展 AI 的外部执行能力。"
features={[
'粘贴 MCP 配置一键连接',
'工具列表与连通性测试',
'官方与社区 MCP 应用市场',
]}
/>
}
/>
<Route
path="workflows"
element={
<ComingSoon
title="工作流"
icon={Workflow}
description="用可视化 DAG 编排多步骤自动化流程。"
features={[
'节点拖拽编排',
'条件分支与并行执行',
'可导出可移植的 YAML 规范',
]}
/>
}
/>
<Route
path="rules"
element={
<ComingSoon
title="规则库"
icon={Shield}
description="配置业务规则与审批策略,管控 AI 工具调用风险。"
features={[
'工具调用四级风险审批',
'Loop 原语与验证器',
'企业级合规策略模板',
]}
/>
}
/>
<Route
path="channels"
element={
<ComingSoon
title="渠道"
icon={Radio}
description="管理多渠道接入,让 AI 能力触达不同终端用户。"
features={[
'微信公众号 / 个人微信接入',
'IM Bot 多端统一适配',
'渠道级权限与配额',
]}
/>
}
/>
<Route path="dashboard" element={<DashboardPage />} />
<Route path="profile" element={<ProfilePage />} />
<Route path="notifications" element={<NotificationsPage />} />
<Route path="evolution" element={<EvolutionPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;
+767
View File
@@ -0,0 +1,767 @@
const BASE_URL = import.meta.env.VITE_API_URL || '/app/api';
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = localStorage.getItem('auth_token');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> || {}),
};
const res = await fetch(`${BASE_URL}${path}`, { ...options, headers });
if (res.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
// Backend responses use a unified envelope `{code, data, message}`.
// `request<T>()` returns the raw envelope; `data<T>()` unwraps `.data`.
async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
const env = await request<{ code: number; data: T; message: string }>(path, options);
return env.data;
}
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
const env = await request<{ success: boolean; data: T }>(path, options);
return env.data;
}
// ─── Auth ───────────────────────────────────────────────────────────────────────
export const authApi = {
login: (data: { username: string; password: string }) =>
request<{ access_token: string; token_type: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify(data),
}),
register: (data: { username: string; email: string; password: string; code: string }) =>
request<{ access_token: string }>('/auth/register', {
method: 'POST',
body: JSON.stringify(data),
}),
me: () => request<{ id: string; username: string; email: string; avatar_url: string }>('/auth/me'),
logout: () => request('/auth/logout', { method: 'POST' }),
sendCode: (data: { email: string }) =>
request('/auth/send-code', { method: 'POST', body: JSON.stringify(data) }),
};
// ─── Chat ────────────────────────────────────────────────────────────────────────
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
export interface Conversation {
id: string;
title: string;
model_id: string;
status?: string;
pinned?: boolean;
source?: string;
created_at: string;
updated_at: string;
message_count?: number; // 后端未返回,前端可选兼容
}
export interface ChatMessage {
id: string;
conversation_id: string;
role: 'user' | 'assistant';
content: string;
model_id: string;
created_at: string;
token_used?: number;
}
export const chatApi = {
listConversations: () => data<Conversation[]>('/chat/conversations'),
createConversation: (payload: { title?: string; model?: string }) =>
data<Conversation>('/chat/conversations', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteConversation: (id: string) =>
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
getMessages: (convId: string) =>
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
sendMessage: (convId: string, data: { content: string; model?: string }) =>
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
method: 'POST',
body: JSON.stringify(data),
}),
streamMessage: async function* (convId: string, data: { content: string; model?: string }) {
const token = localStorage.getItem('auth_token');
const res = await fetch(`${BASE_URL}/chat/conversations/${convId}/messages/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(data),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) return;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(l => l.startsWith('data: '));
for (const line of lines) {
try {
const data = JSON.parse(line.slice(6));
if (data.content) yield data.content;
} catch {}
}
}
},
getModels: () => request<{ id: string; name: string; provider: string }[]>('/chat/models'),
};
// ─── Knowledge ───────────────────────────────────────────────────────────────────
// 后端契约 (knowledge.py):
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
export interface KnowledgeBase {
id: string;
name: string;
description: string;
document_count: number;
status: 'active' | 'building' | 'error' | string;
created_at: string;
updated_at: string;
}
export interface KnowledgeBaseList {
bases: KnowledgeBase[];
storage_used: number;
storage_limit: number;
membership_level: number;
}
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
export interface KnowledgeBaseCreated {
id: string;
name: string;
description: string;
created_at: string;
}
export const knowledgeApi = {
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
createBase: (payload: { name: string; description?: string }) =>
data<KnowledgeBaseCreated>('/knowledge/bases', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteBase: (id: string) =>
request(`/knowledge/bases/${id}`, { method: 'DELETE' }),
search: (baseId: string, query: string) =>
request<{ results: { content: string; score: number; source: string }[] }>(
`/knowledge/bases/${baseId}/search?q=${encodeURIComponent(query)}`
),
getDocuments: (baseId: string) =>
request<{ id: string; name: string; status: string; size: number }[]>(
`/knowledge/bases/${baseId}/documents`
),
};
// ─── Market / Apps ──────────────────────────────────────────────────────────────
// 后端契约:app/api/market.py → GET /apps (list_apps) 返回
// { code:0, data:{ total, page, page_size, items:[MarketApp] } }
// 字段以 list_apps 真实 item 为准(见 #993 重写)。
export type MarketAppType = 'standalone' | 'embedded' | 'skill' | 'agent' | 'workflow';
export type MarketAppSource = 'official' | 'community' | 'third_party';
export type MarketAppStatus = 'draft' | 'pending' | 'published' | 'rejected' | 'offline';
export interface MarketApp {
id: string;
name: string;
description: string;
icon: string | null;
type: MarketAppType;
source: MarketAppSource;
publisher_id: string;
publisher_name: string;
is_member_free: boolean;
join_promotion: boolean;
status: MarketAppStatus;
price: number;
install_count: number;
rating_avg: number;
review_count: number;
created_at: string;
capability: string | null;
category: string | null;
tags: string[];
installed: boolean;
version: string | null;
security_scan: string | null;
verified: boolean;
dependencies: string[];
}
// 详情页用:list 字段 + 可能更丰富的描述/截图/长文
export interface MarketAppDetail extends MarketApp {
long_description?: string;
screenshots?: string[];
homepage_url?: string | null;
updated_at?: string | null;
}
export interface MarketReview {
id: string;
app_id: string;
user_id: string;
user_name: string;
rating: number;
comment: string;
created_at: string;
}
export interface MarketReviewList {
total: number;
page: number;
page_size: number;
items: MarketReview[];
}
export interface MarketAd {
id: string;
title: string;
image_url: string;
url: string;
}
export interface MarketAppList {
total: number;
page: number;
page_size: number;
items: MarketApp[];
}
export type MarketSort = 'latest' | 'popular' | 'rating';
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
export interface MyApp {
id: string;
app_id: string;
name: string;
icon: string | null;
type: MarketAppType;
price: number;
purchased_at: string | null;
}
export const marketApi = {
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
listApps: (params: {
type?: MarketAppType;
source?: MarketAppSource;
capability?: string;
search?: string;
sort?: MarketSort;
page?: number;
page_size?: number;
} = {}) => {
const qs = new URLSearchParams();
if (params.type) qs.set('type', params.type);
if (params.source) qs.set('source', params.source);
if (params.capability) qs.set('capability', params.capability);
if (params.search) qs.set('search', params.search);
if (params.sort) qs.set('sort', params.sort);
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return data<MarketAppList>(`/apps${q ? `?${q}` : ''}`);
},
getApp: (appId: string) =>
data<MarketAppDetail>(`/apps/${appId}`),
getMyApps: () =>
data<MyApp[]>('/my-apps'),
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
const qs = new URLSearchParams();
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return data<MarketReviewList>(`/reviews/${appId}${q ? `?${q}` : ''}`);
},
getAds: () =>
data<MarketAd[]>('/ads'),
// ─── 写操作(后续迭代启用)───
installApp: (appId: string) =>
request(`/apps/${appId}/install`, { method: 'POST' }),
uninstallApp: (appId: string) =>
request(`/apps/${appId}/uninstall`, { method: 'DELETE' }),
purchaseApp: (appId: string) =>
request(`/apps/${appId}/purchase`, { method: 'POST' }),
reviewApp: (appId: string, payload: { rating: number; comment: string }) =>
request(`/apps/${appId}/review`, {
method: 'POST',
body: JSON.stringify(payload),
}),
};
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
export interface ImBotStatus {
status: 'connected' | 'disconnected' | 'expired' | string;
ilink_bot_id: string | null;
connected_at: string | null;
last_active?: string | null;
message_count?: number;
connected?: boolean;
configured?: boolean;
username?: string | null;
baseurl?: string | null;
}
export const imBotApi = {
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
checkQRStatus: (ticket: string) =>
request<{ status: string }>('/im-bot/qrcode/status', {
method: 'POST',
body: JSON.stringify({ ticket }),
}),
disconnect: () => request('/im-bot/disconnect', { method: 'POST' }),
getHistory: () => request<{ id: string; role: string; content: string; created_at: string }[]>('/im-bot/history'),
};
// ─── Profile ─────────────────────────────────────────────────────────────────────
export interface MembershipInfo {
id: string;
plan_id: string;
status: string;
start_at: string;
expire_at: string;
is_expired: boolean;
}
export interface UserProfile {
id: string;
username: string;
email: string;
phone: string;
avatar: string;
role: string;
status: string;
membership: MembershipInfo | null;
created_at: string;
updated_at: string;
}
export interface LoginLogItem {
id: string;
ip: string;
user_agent: string;
login_at: string;
}
export interface LoginLogList {
total: number;
page: number;
page_size: number;
items: LoginLogItem[];
}
export interface ApiKeyItem {
id: string;
name: string;
key_preview: string;
status: string;
last_used_at: string | null;
created_at: string;
expire_at: string | null;
}
// 创建 API Key 时后端返回的完整 key(仅创建时返回)
export interface ApiKeyCreated {
id: string;
name: string;
api_key: string;
status: string;
created_at: string;
}
export type ApiKeyStatus = 'enabled' | 'disabled' | 'revoked';
export interface PurchasedAppItem {
id: string;
app_id: string;
app_name: string;
app_icon: string;
app_type: string;
price: number;
purchased_at: string;
}
export const profileApi = {
get: () => data<UserProfile>('/profile/'),
update: (payload: Partial<Pick<UserProfile, 'username' | 'email' | 'phone' | 'avatar'>>) =>
data<UserProfile>('/profile/', { method: 'PUT', body: JSON.stringify(payload) }),
getBalance: () => data<number>('/profile/balance'),
getMembership: () => data<{
has_membership: boolean;
id: string;
plan_id: string;
status: string;
start_at: string;
expire_at: string;
is_expired: boolean;
}>('/profile/membership'),
getLoginLogs: (page = 1, page_size = 10) =>
data<LoginLogList>(`/profile/login-logs?page=${page}&page_size=${page_size}`),
getApiKeys: () => data<ApiKeyItem[]>('/profile/api-keys'),
getPurchasedApps: () => data<PurchasedAppItem[]>('/profile/purchased-apps'),
// ─── 写操作 ───
updatePassword: (payload: { old_password: string; new_password: string }) =>
request<{ code: number; message: string }>('/profile/password', {
method: 'PUT',
body: JSON.stringify(payload),
}),
createApiKey: (name: string) =>
data<ApiKeyCreated>('/profile/api-keys', {
method: 'POST',
body: JSON.stringify({ name }),
}),
updateApiKey: (keyId: string, status: ApiKeyStatus) =>
data<{ id: string; status: string }>(`/profile/api-keys/${keyId}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
};
// ─── Token 驾驶舱 ────────────────────────────────────────────────────────────────
export interface TokenOverview {
total_used: number;
balance: number;
today_apps: number;
server_time: string;
}
export interface ModelRanking {
model_id: string;
model_name: string;
total_tokens: number;
usage_count: number;
}
export interface TopApp {
conversation_id: string;
total_tokens: number;
}
export interface TokenRankings {
model_rankings: ModelRanking[];
top_apps: TopApp[];
}
export interface TokenLogItem {
id: string;
model_id: string;
model_name: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
conversation_id: string;
created_at: string | null;
}
export interface TokenLogPage {
total: number;
page: number;
page_size: number;
items: TokenLogItem[];
}
export interface TokenBalance {
balance: number;
updated_at: string | null;
}
export const tokenApi = {
getOverview: () => data<TokenOverview>('/token/overview'),
getRankings: () => data<TokenRankings>('/token/rankings'),
getLogs: (params: {
start_date?: string;
end_date?: string;
model_id?: string;
app_type?: string;
page?: number;
page_size?: number;
} = {}) => {
const qs = new URLSearchParams();
if (params.start_date) qs.set('start_date', params.start_date);
if (params.end_date) qs.set('end_date', params.end_date);
if (params.model_id) qs.set('model_id', params.model_id);
if (params.app_type) qs.set('app_type', params.app_type);
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return data<TokenLogPage>(`/token/logs${q ? `?${q}` : ''}`);
},
getBalance: () => data<TokenBalance>('/token/balance'),
};
// ─── Notifications 消息通知 ──────────────────────────────────────────────────────
// 后端契约 (notifications.py):全部 {code,data,message}
// GET / → { total, page, page_size, items:[NotificationItem] }
// PUT /{id}/read → {code,message}; PUT /read-all?type= → {code,data:{count},message}
// GET /unread-count → {code,data:{count},message}
export interface NotificationItem {
id: string;
type: string | null;
title: string;
content: string;
is_read: boolean;
related_id: string | null;
related_type: string | null;
created_at: string | null;
}
export interface NotificationList {
total: number;
page: number;
page_size: number;
items: NotificationItem[];
}
export const notificationsApi = {
list: (params: { type?: string; is_read?: boolean; page?: number; page_size?: number } = {}) => {
const qs = new URLSearchParams();
if (params.type) qs.set('type', params.type);
if (params.is_read !== undefined) qs.set('is_read', String(params.is_read));
if (params.page) qs.set('page', String(params.page));
if (params.page_size) qs.set('page_size', String(params.page_size));
const q = qs.toString();
return data<NotificationList>(`/notifications/${q ? `?${q}` : ''}`);
},
markRead: (id: string) =>
request(`/notifications/${id}/read`, { method: 'PUT' }),
markAllRead: (type?: string) =>
request(`/notifications/read-all${type ? `?type=${encodeURIComponent(type)}` : ''}`, { method: 'PUT' }),
unreadCount: () =>
data<{ count: number }>('/notifications/unread-count'),
};
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
// 后端契约 (evolution.py + services/evolution_service.py):全部 {code,data,...}
// 技能状态 state: main | staging | baby | frozen
// 事件类型 event_type: created | edited | promoted | frozen | ...(字符串透传)
export type EvolutionSkillState = 'main' | 'staging' | 'baby' | 'frozen';
export interface EvolutionSkill {
id: string;
skill_name: string;
description: string;
tags: string[];
category: string;
state: EvolutionSkillState;
use_count: number;
version: number;
last_used_at: string | null;
created_at: string | null;
updated_at: string | null;
skill_md_content?: string | null; // 仅 detail 返回
}
export interface EvolutionEvent {
id: string;
skill_name: string;
event_type: string;
description: string;
version_before: number | null;
version_after: number | null;
created_at: string | null;
}
export interface EvolutionTrajectory {
id: string;
conversation_id: string;
file_path: string | null;
status: string;
atom_count: number | null;
token_used: number | null;
created_at: string | null;
processed_at: string | null;
weightscore?: number;
atom_id?: string | null;
}
export interface EvolutionSkillTree {
categories: { name: string; skills: EvolutionSkill[] }[];
total_count: number;
main_count: number;
baby_count: number;
staging_count: number;
frozen_count: number;
}
export interface EvolutionStatistics {
ranking: { skill_name: string; category: string; use_count: number; state: string; last_used_at: string | null }[];
total_use_count: number;
category_distribution: { category: string; count: number }[];
daily_trend: { date: string; count: number }[];
low_use_skills: EvolutionSkill[];
}
export interface EvolutionDimension {
label: string;
value: number;
maxValue: number;
}
export interface EvolutionProfile {
level: number;
level_title: string;
total_skills: number;
active_skills: number;
staging_skills: number;
baby_skills: number;
total_use_count: number;
dimensions: EvolutionDimension[];
tags: string[];
growth_suggestions: string[];
}
export interface EvolutionPipelineStatus {
scheduler_running: boolean;
watcher_status: string;
pipeline_status: string;
discovered_count: number;
done_count: number;
total_skills: number;
total_trajectories: number;
}
export interface EvolutionWatcherStatus {
user_id: string;
scheduler_running: boolean;
watcher_status: string;
pipeline_status: string;
pipeline_processed: number;
watcher_error: string | null;
skill_count: number;
trajectory_count: number;
}
export interface EvolutionReport {
id: string;
report_type: string;
period_start: string | null;
period_end: string | null;
created_at: string | null;
}
export interface EvolutionReportDetail extends EvolutionReport {
user_id: string;
report_data: Record<string, unknown>;
}
export const evolutionApi = {
// 技能管理
listSkills: (params: { state?: string; keyword?: string; sort_by?: string; order?: string } = {}) => {
const qs = new URLSearchParams();
if (params.state) qs.set('state', params.state);
if (params.keyword) qs.set('keyword', params.keyword);
if (params.sort_by) qs.set('sort_by', params.sort_by);
if (params.order) qs.set('order', params.order);
const q = qs.toString();
return data<EvolutionSkill[]>(`/evolution/skills${q ? `?${q}` : ''}`);
},
getSkillDetail: (skillName: string) =>
data<EvolutionSkill>(`/evolution/skills/${encodeURIComponent(skillName)}`),
// 进化事件
listEvents: (params: { event_type?: string; skill_name?: string; limit?: number } = {}) => {
const qs = new URLSearchParams();
if (params.event_type) qs.set('event_type', params.event_type);
if (params.skill_name) qs.set('skill_name', params.skill_name);
if (params.limit) qs.set('limit', String(params.limit));
const q = qs.toString();
return data<EvolutionEvent[]>(`/evolution/events${q ? `?${q}` : ''}`);
},
// 驾驶舱
getSkillTree: () => data<EvolutionSkillTree>('/evolution/dashboard/skill-tree'),
getTimeline: (event_type?: string) =>
data<EvolutionEvent[]>(`/evolution/dashboard/timeline${event_type ? `?event_type=${encodeURIComponent(event_type)}` : ''}`),
getStatistics: () => data<EvolutionStatistics>('/evolution/dashboard/statistics'),
getProfile: () => data<EvolutionProfile>('/evolution/dashboard/profile'),
// 流水线 / Watcher
getPipelineStatus: () => data<EvolutionPipelineStatus>('/evolution/pipeline/status'),
getWatcherStatus: () => data<EvolutionWatcherStatus>('/evolution/watcher/status'),
// 报告
listReports: (report_type?: string) =>
data<EvolutionReport[]>(`/evolution/reports${report_type ? `?report_type=${encodeURIComponent(report_type)}` : ''}`),
getReportDetail: (reportId: string) =>
data<EvolutionReportDetail>(`/evolution/reports/${reportId}`),
};
+496
View File
@@ -0,0 +1,496 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
authApi,
chatApi,
knowledgeApi,
marketApi,
imBotApi,
profileApi,
tokenApi,
notificationsApi,
evolutionApi,
type TokenOverview,
type MyApp,
type MarketAd,
type MarketSort,
type MarketAppType,
type MarketAppSource,
type EvolutionSkill,
type EvolutionEvent,
type EvolutionReport,
} from '@/api/client';
// ─── Auth ───────────────────────────────────────────────────────────────────────
export function useLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; password: string }) => authApi.login(data),
onSuccess: (res) => {
localStorage.setItem('auth_token', res.access_token);
qc.invalidateQueries({ queryKey: ['auth', 'me'] });
},
});
}
export function useCurrentUser() {
return useQuery({
queryKey: ['auth', 'me'],
queryFn: authApi.me,
retry: false,
enabled: !!localStorage.getItem('auth_token'),
});
}
// ─── Chat ────────────────────────────────────────────────────────────────────────
export function useConversations() {
return useQuery({
queryKey: ['chat', 'conversations'],
queryFn: chatApi.listConversations,
});
}
export function useMessages(convId: string | null) {
return useQuery({
queryKey: ['chat', 'messages', convId],
queryFn: () => chatApi.getMessages(convId!),
enabled: !!convId,
});
}
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { title?: string; model?: string }) => chatApi.createConversation(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
});
}
export function useSendMessage(convId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { content: string; model?: string }) => chatApi.sendMessage(convId, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'messages', convId] }),
});
}
export function useDeleteConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => chatApi.deleteConversation(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['chat', 'conversations'] }),
});
}
export function useModels() {
return useQuery({
queryKey: ['chat', 'models'],
queryFn: chatApi.getModels,
});
}
// ─── Knowledge ───────────────────────────────────────────────────────────────────
export function useKnowledgeBases() {
return useQuery({
queryKey: ['knowledge', 'bases'],
queryFn: knowledgeApi.listBases,
select: (d) => {
const bases = Array.isArray(d?.bases) ? d.bases : [];
// 后端 create 返回缺 document_count/status/updated_at,这里补默认值避免渲染崩溃
return bases.map((kb) => ({
...kb,
document_count: kb.document_count ?? 0,
status: kb.status ?? 'active',
updated_at: kb.updated_at ?? kb.created_at ?? '',
}));
},
});
}
export function useCreateKnowledgeBase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string }) => knowledgeApi.createBase(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
});
}
export function useDeleteKnowledgeBase() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => knowledgeApi.deleteBase(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['knowledge', 'bases'] }),
});
}
export function useKnowledgeSearch(baseId: string | null, query: string) {
return useQuery({
queryKey: ['knowledge', 'search', baseId, query],
queryFn: () => knowledgeApi.search(baseId!, query),
enabled: !!baseId && query.length > 0,
});
}
// ─── Market / Apps ──────────────────────────────────────────────────────────────
// Backend list endpoints may return a bare array, `{data:[...]}`, or an error
// object. Coerce to a real array so consumers can always call .slice/.map/.filter.
function asArray<T>(v: unknown): T[] {
if (Array.isArray(v)) return v as T[];
if (v && typeof v === 'object' && Array.isArray((v as any).data)) return (v as any).data;
if (v && typeof v === 'object' && Array.isArray((v as any).items)) return (v as any).items;
return [];
}
export interface MarketAppsParams {
type?: MarketAppType;
source?: MarketAppSource;
capability?: string;
search?: string;
sort?: MarketSort;
page?: number;
page_size?: number;
}
export function useMarketApps(params: MarketAppsParams = {}) {
return useQuery({
queryKey: ['market', 'apps', params],
queryFn: () => marketApi.listApps(params),
placeholderData: (prev) => prev, // 翻页/筛选时保留旧数据,避免闪烁
});
}
export function useMarketApp(appId: string | null) {
return useQuery({
queryKey: ['market', 'app', appId],
queryFn: () => marketApi.getApp(appId!),
enabled: !!appId,
});
}
export function useMyApps() {
return useQuery({
queryKey: ['market', 'my-apps'],
queryFn: marketApi.getMyApps,
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
});
}
export function useMarketReviews(appId: string | null, params: { page?: number; page_size?: number } = {}) {
return useQuery({
queryKey: ['market', 'reviews', appId, params],
queryFn: () => marketApi.getReviews(appId!, params),
enabled: !!appId,
});
}
export function useMarketAds() {
return useQuery({
queryKey: ['market', 'ads'],
queryFn: marketApi.getAds,
select: (d: unknown) => asArray<MarketAd>(d),
});
}
export function useInstallApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (appId: string) => marketApi.installApp(appId),
onSuccess: (_res, appId) => {
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
},
});
}
export function useUninstallApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (appId: string) => marketApi.uninstallApp(appId),
onSuccess: (_res, appId) => {
qc.invalidateQueries({ queryKey: ['market', 'my-apps'] });
qc.invalidateQueries({ queryKey: ['market', 'apps'] });
qc.invalidateQueries({ queryKey: ['market', 'app', appId] });
},
});
}
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
export function useImBotStatus() {
return useQuery({
queryKey: ['im-bot', 'status'],
queryFn: imBotApi.getStatus,
refetchInterval: 30000, // poll every 30s
});
}
export function useImBotQRCode() {
return useQuery({
queryKey: ['im-bot', 'qrcode'],
queryFn: imBotApi.getQRCode,
enabled: false, // only on demand
});
}
export function useDisconnectBot() {
const qc = useQueryClient();
return useMutation({
mutationFn: () => imBotApi.disconnect(),
onSuccess: () => qc.invalidateQueries({ queryKey: ['im-bot', 'status'] }),
});
}
// ─── Profile ─────────────────────────────────────────────────────────────────────
export function useProfile() {
return useQuery({
queryKey: ['profile'],
queryFn: profileApi.get,
});
}
export function useUpdateProfile() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: Parameters<typeof profileApi.update>[0]) => profileApi.update(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile'] }),
});
}
export function useProfileBalance() {
return useQuery({
queryKey: ['profile', 'balance'],
queryFn: profileApi.getBalance,
});
}
export function useProfileMembership() {
return useQuery({
queryKey: ['profile', 'membership'],
queryFn: profileApi.getMembership,
});
}
export function useProfileLoginLogs(page = 1, page_size = 10) {
return useQuery({
queryKey: ['profile', 'login-logs', page, page_size],
queryFn: () => profileApi.getLoginLogs(page, page_size),
});
}
export function useProfileApiKeys() {
return useQuery({
queryKey: ['profile', 'api-keys'],
queryFn: profileApi.getApiKeys,
});
}
export function useProfilePurchasedApps() {
return useQuery({
queryKey: ['profile', 'purchased-apps'],
queryFn: profileApi.getPurchasedApps,
});
}
// ─── Profile 写操作 ─────────────────────────────────────────────────────────────
export function useUpdateProfilePassword() {
return useMutation({
mutationFn: (data: { old_password: string; new_password: string }) =>
profileApi.updatePassword(data),
});
}
export function useCreateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: (name: string) => profileApi.createApiKey(name),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
});
}
export function useUpdateApiKey() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ keyId, status }: { keyId: string; status: 'enabled' | 'disabled' | 'revoked' }) =>
profileApi.updateApiKey(keyId, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['profile', 'api-keys'] }),
});
}
// ─── Token 驾驶舱 ─────────────────────────────────────────────────────────────────
// Landing hero still shows a few headline numbers; reuse the real token overview
// so the homepage never ships fake/mock values.
export function useDashboardStats() {
return useQuery({
queryKey: ['token', 'overview'],
queryFn: tokenApi.getOverview,
select: (d: TokenOverview) => ({
active_users: d.today_apps,
today_conversations: d.today_apps,
skill_count: d.total_used,
revenue: d.balance,
user_change: '',
conv_change: '',
}),
});
}
export function useTokenOverview() {
return useQuery({
queryKey: ['token', 'overview'],
queryFn: tokenApi.getOverview,
refetchInterval: 60000, // 每分钟刷新一次概览
});
}
export function useTokenRankings() {
return useQuery({
queryKey: ['token', 'rankings'],
queryFn: tokenApi.getRankings,
});
}
export function useTokenBalance() {
return useQuery({
queryKey: ['token', 'balance'],
queryFn: tokenApi.getBalance,
});
}
export function useTokenLogs(params: {
start_date?: string;
end_date?: string;
model_id?: string;
app_type?: string;
page?: number;
page_size?: number;
}) {
return useQuery({
queryKey: ['token', 'logs', params],
queryFn: () => tokenApi.getLogs(params),
placeholderData: (prev) => prev, // 翻页时保留旧数据,避免闪烁
});
}
// ─── Notifications 消息通知 ─────────────────────────────────────────────────────
export function useNotifications(params: {
type?: string;
is_read?: boolean;
page?: number;
page_size?: number;
} = {}) {
return useQuery({
queryKey: ['notifications', 'list', params],
queryFn: () => notificationsApi.list(params),
placeholderData: (prev) => prev, // 翻页/筛选保留旧数据,避免闪烁
});
}
export function useUnreadCount() {
return useQuery({
queryKey: ['notifications', 'unread-count'],
queryFn: notificationsApi.unreadCount,
refetchInterval: 60000, // 每分钟刷新未读计数
});
}
export function useMarkNotificationRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => notificationsApi.markRead(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
export function useMarkAllNotificationsRead() {
const qc = useQueryClient();
return useMutation({
mutationFn: (type?: string) => notificationsApi.markAllRead(type),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['notifications', 'list'] });
qc.invalidateQueries({ queryKey: ['notifications', 'unread-count'] });
},
});
}
// ─── Evolution AI 自进化 ────────────────────────────────────────────────────────
export function useEvolutionSkills(params: {
state?: string;
keyword?: string;
sort_by?: string;
order?: string;
} = {}) {
return useQuery({
queryKey: ['evolution', 'skills', params],
queryFn: () => evolutionApi.listSkills(params),
select: (d: EvolutionSkill[]) => (Array.isArray(d) ? d : []),
});
}
export function useEvolutionEvents(params: {
event_type?: string;
skill_name?: string;
limit?: number;
} = {}) {
return useQuery({
queryKey: ['evolution', 'events', params],
queryFn: () => evolutionApi.listEvents(params),
select: (d: EvolutionEvent[]) => (Array.isArray(d) ? d : []),
});
}
export function useEvolutionSkillTree() {
return useQuery({
queryKey: ['evolution', 'skill-tree'],
queryFn: evolutionApi.getSkillTree,
});
}
export function useEvolutionStatistics() {
return useQuery({
queryKey: ['evolution', 'statistics'],
queryFn: evolutionApi.getStatistics,
});
}
export function useEvolutionProfile() {
return useQuery({
queryKey: ['evolution', 'profile'],
queryFn: evolutionApi.getProfile,
});
}
export function useEvolutionPipelineStatus() {
return useQuery({
queryKey: ['evolution', 'pipeline-status'],
queryFn: evolutionApi.getPipelineStatus,
refetchInterval: 30000, // 流水线状态每 30s 轮询
});
}
export function useEvolutionWatcherStatus() {
return useQuery({
queryKey: ['evolution', 'watcher-status'],
queryFn: evolutionApi.getWatcherStatus,
refetchInterval: 30000,
});
}
export function useEvolutionReports(report_type?: string) {
return useQuery({
queryKey: ['evolution', 'reports', report_type],
queryFn: () => evolutionApi.listReports(report_type),
select: (d: EvolutionReport[]) => (Array.isArray(d) ? d : []),
});
}
@@ -0,0 +1,105 @@
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
AppWindow,
Plus,
ExternalLink,
Settings,
Globe,
Monitor,
Loader2,
} from 'lucide-react';
import { useMyApps } from '@/hooks/useApi';
import type { MyApp } from '@/api/client';
export function AppServices() {
const { data: apps = [], isLoading } = useMyApps();
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
<p className="text-sm text-muted-foreground mt-1">管理已安装的 AI 应用</p>
</div>
<Button className="gap-2">
<Plus size={16} /> 创建应用
</Button>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : apps.length === 0 ? (
<div className="col-span-full text-center py-12 text-muted-foreground">
<AppWindow size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-1">暂无应用</p>
<p className="text-sm">去应用市场浏览并安装应用</p>
</div>
) : (
apps.map((app: MyApp) => (
<Card key={app.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
{app.icon ? (
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
) : app.type === 'standalone' ? (
<Globe size={20} className="text-primary" />
) : (
<Monitor size={20} className="text-primary" />
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="success">已安装</Badge>
<Badge variant="outline">{app.type}</Badge>
</div>
</div>
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
<p className="text-sm text-muted-foreground mb-4">
{app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'}
{app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''}
</p>
<Separator className="mb-4" />
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="gap-1">
<Settings size={14} /> 配置
</Button>
<Button
size="sm"
className="gap-1"
variant="default"
>
<ExternalLink size={14} /> 打开
</Button>
</div>
</CardContent>
</Card>
))
)}
{/* Create New */}
<Card className="border-dashed hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[220px]">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<Plus size={20} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">创建新应用</p>
</CardContent>
</Card>
</motion.div>
</div>
);
}
@@ -0,0 +1,270 @@
import { useState, useRef, useEffect } from 'react';
import { motion } from 'framer-motion';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import {
Send,
Sparkles,
Plus,
Search,
Settings,
MessageSquare,
Copy,
ThumbsUp,
ThumbsDown,
RotateCcw,
Trash2,
Loader2,
} from 'lucide-react';
import {
useConversations,
useMessages,
useCreateConversation,
useSendMessage,
useDeleteConversation,
} from '@/hooks/useApi';
import type { Conversation, ChatMessage } from '@/api/client';
export function ChatPage() {
const [selectedConv, setSelectedConv] = useState<string | null>(null);
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const [streamingContent, setStreamingContent] = useState('');
// API hooks
const { data: conversations = [], isLoading: convsLoading } = useConversations();
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
const createConv = useCreateConversation();
const sendMessage = useSendMessage(selectedConv || '');
const deleteConv = useDeleteConversation();
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent]);
const handleNewChat = async () => {
try {
const conv = await createConv.mutateAsync({ title: '新对话' });
setSelectedConv(conv.id);
} catch {}
};
const handleSend = async () => {
if (!input.trim() || !selectedConv) return;
const content = input;
setInput('');
setStreamingContent('');
try {
// Try streaming first
const stream = chatApi.streamMessage(selectedConv, { content });
let accumulated = '';
for await (const chunk of stream) {
accumulated += chunk;
setStreamingContent(accumulated);
}
setStreamingContent('');
// Refresh messages
} catch {
// Fallback to regular send
await sendMessage.mutateAsync({ content });
}
};
const handleDelete = async (id: string) => {
try {
await deleteConv.mutateAsync(id);
if (selectedConv === id) setSelectedConv(null);
} catch {}
};
return (
<div className="flex h-[calc(100vh-4rem)] gap-4">
{/* Conversation List */}
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="搜索对话..."
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<Button
className="mx-3 mt-3 gap-2"
variant="outline"
onClick={handleNewChat}
disabled={createConv.isPending}
>
<Plus size={16} /> 新建对话
</Button>
<ScrollArea className="flex-1 px-2 py-2">
<div className="space-y-1">
{convsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-muted-foreground" />
</div>
) : (
conversations.map((conv: Conversation) => (
<div key={conv.id} className="group relative">
<button
onClick={() => setSelectedConv(conv.id)}
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
selectedConv === conv.id
? 'bg-primary/10 text-primary'
: 'hover:bg-muted text-muted-foreground'
}`}
>
<div className="flex items-center gap-2">
<MessageSquare size={14} className="shrink-0" />
<span className="text-sm truncate flex-1">{conv.title}</span>
</div>
<div className="flex items-center gap-2 mt-1 ml-5">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{conv.model_id || 'Auto'}
</Badge>
<span className="text-[10px] text-muted-foreground">
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
</span>
</div>
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
>
<Trash2 size={12} className="text-destructive" />
</button>
</div>
))
)}
</div>
</ScrollArea>
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
{/* Messages */}
<ScrollArea className="flex-1 p-6">
{!selectedConv ? (
<div className="h-full flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
<Sparkles className="h-8 w-8 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
<p className="text-sm text-muted-foreground max-w-md">
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
</p>
<Button className="mt-4 gap-2" onClick={handleNewChat}>
<Plus size={16} /> 新建对话
</Button>
</div>
) : msgsLoading ? (
<div className="h-full flex items-center justify-center">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
<div className="space-y-6">
{messages.map((msg: ChatMessage) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-foreground'
}`}>
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
{msg.role === 'assistant' && (
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<Copy size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsUp size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<ThumbsDown size={14} className="text-muted-foreground" />
</button>
<button className="p-1 hover:bg-background/50 rounded transition-colors">
<RotateCcw size={14} className="text-muted-foreground" />
</button>
</div>
)}
</div>
</motion.div>
))}
{/* Streaming indicator */}
{streamingContent && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex justify-start"
>
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
<div className="flex items-center gap-1 mt-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
</div>
</div>
</motion.div>
)}
<div ref={messagesEndRef} />
</div>
)}
</ScrollArea>
{/* Input Area */}
<div className="p-4 border-t border-border">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
className="flex-1"
disabled={!selectedConv || sendMessage.isPending}
/>
<Button
onClick={handleSend}
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
className="gap-2"
>
{sendMessage.isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Send size={16} />
)}
</Button>
</div>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Sparkles size={12} /> Auto(自动匹配)
</div>
<button className="p-1 hover:bg-muted rounded transition-colors">
<Settings size={16} className="text-muted-foreground" />
</button>
</div>
</div>
</div>
</div>
);
}
// Import for streaming
import { chatApi } from '@/api/client';
@@ -0,0 +1,451 @@
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Sparkles,
Brain,
Network,
BarChart3,
Activity,
GitBranch,
RefreshCw,
Cpu,
ArrowUpRight,
Loader2,
TrendingUp,
Layers,
} from 'lucide-react';
import {
useEvolutionProfile,
useEvolutionSkills,
useEvolutionStatistics,
useEvolutionPipelineStatus,
useEvolutionEvents,
} from '@/hooks/useApi';
import type {
EvolutionSkill,
EvolutionSkillState,
EvolutionStatistics,
EvolutionProfile,
EvolutionPipelineStatus,
EvolutionEvent,
} from '@/api/client';
// 技能状态 → 中文标签 + 颜色
const STATE_LABELS: Record<EvolutionSkillState, string> = {
main: '成熟',
staging: '灰度',
baby: '萌芽',
frozen: '冻结',
};
const STATE_BADGE: Record<EvolutionSkillState, 'default' | 'secondary' | 'outline' | 'success'> = {
main: 'success',
staging: 'default',
baby: 'secondary',
frozen: 'outline',
};
function formatTime(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 雷达/维度条
function DimensionBars({ dimensions }: { dimensions: EvolutionProfile['dimensions'] }) {
return (
<div className="space-y-3">
{dimensions.map((dim) => {
const pct = Math.round((dim.value / dim.maxValue) * 100);
return (
<div key={dim.label}>
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-muted-foreground">{dim.label}</span>
<span className="font-medium text-foreground">{dim.value.toFixed(1)}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
);
}
// 能力画像卡
function ProfileCard({ profile }: { profile: EvolutionProfile }) {
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 to-amber-50/40 dark:to-amber-950/20">
<CardContent className="p-6">
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center shrink-0">
<Sparkles size={30} className="text-primary" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-xl font-bold text-foreground">
Lv.{profile.level} {profile.level_title}
</h3>
</div>
<p className="text-sm text-muted-foreground mt-1">
共 {profile.total_skills} 个技能 · 累计调用 {profile.total_use_count} 次
</p>
</div>
</div>
<Separator className="my-5" />
<DimensionBars dimensions={profile.dimensions} />
{profile.growth_suggestions.length > 0 && (
<div className="mt-5 space-y-2">
<p className="text-sm font-medium text-foreground flex items-center gap-1.5">
<TrendingUp size={15} className="text-primary" />
成长建议
</p>
{profile.growth_suggestions.map((s, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-muted-foreground">
<ArrowUpRight size={14} className="text-primary mt-0.5 shrink-0" />
<span>{s}</span>
</div>
))}
</div>
)}
{profile.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-4">
{profile.tags.map((t) => (
<Badge key={t} variant="outline" className="text-xs">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
}
// 技能网格
function SkillCard({ skill }: { skill: EvolutionSkill }) {
return (
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Cpu size={17} className="text-primary" />
</div>
<h4 className="font-semibold text-foreground truncate">{skill.skill_name}</h4>
</div>
<Badge variant={STATE_BADGE[skill.state] ?? 'outline'} className="shrink-0">
{STATE_LABELS[skill.state] ?? skill.state}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3 min-h-[2rem]">
{skill.description || '暂无描述'}
</p>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>调用 {skill.use_count}</span>
<span>v{skill.version}</span>
</div>
{skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{skill.tags.slice(0, 3).map((t) => (
<Badge key={t} variant="outline" className="text-[10px] py-0">
{t}
</Badge>
))}
</div>
)}
</CardContent>
</Card>
);
}
// 流水线状态条
function PipelineStatusCard({ status }: { status: EvolutionPipelineStatus }) {
const running = status.scheduler_running;
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<Activity size={18} className="text-primary" />
<h3 className="font-semibold text-foreground">进化流水线</h3>
<Badge
variant={running ? 'success' : 'outline'}
className="ml-auto gap-1"
>
<span
className={
'w-1.5 h-1.5 rounded-full ' +
(running ? 'bg-green-500 animate-pulse' : 'bg-muted-foreground')
}
/>
{running ? '运行中' : '未运行'}
</Badge>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-center">
<Stat label="待处理轨迹" value={status.discovered_count} />
<Stat label="已完成轨迹" value={status.done_count} />
<Stat label="技能总数" value={status.total_skills} />
<Stat label="轨迹总数" value={status.total_trajectories} />
</div>
<p className="text-xs text-muted-foreground mt-3">
流水线状态:{status.pipeline_status} · 引擎:{status.watcher_status}
</p>
</CardContent>
</Card>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-lg bg-muted/50 py-3">
<p className="text-xl font-bold text-foreground">{value}</p>
<p className="text-xs text-muted-foreground mt-0.5">{label}</p>
</div>
);
}
// 统计卡(Top 技能 + 类别分布)
function StatisticsCard({ stats }: { stats: EvolutionStatistics }) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<BarChart3 size={18} className="text-primary" />
<h3 className="font-semibold text-foreground">使用统计</h3>
<Badge variant="outline" className="ml-auto">
总调用 {stats.total_use_count}
</Badge>
</div>
{/* Top 技能 */}
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
<TrendingUp size={14} className="text-primary" />
热门技能 Top {stats.ranking.length}
</p>
<div className="space-y-2">
{stats.ranking.slice(0, 5).map((r, i) => {
const max = stats.ranking[0]?.use_count || 1;
const pct = Math.round((r.use_count / max) * 100);
return (
<div key={r.skill_name}>
<div className="flex items-center justify-between text-xs mb-0.5">
<span className="text-muted-foreground truncate">
{i + 1}. {r.skill_name}
</span>
<span className="font-medium text-foreground shrink-0 ml-2">
{r.use_count}
</span>
</div>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary/70"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
{stats.ranking.length === 0 && (
<p className="text-xs text-muted-foreground">暂无使用数据</p>
)}
</div>
<Separator className="my-4" />
{/* 类别分布 */}
<p className="text-sm font-medium text-foreground mb-2 flex items-center gap-1.5">
<Layers size={14} className="text-primary" />
类别分布
</p>
<div className="flex flex-wrap gap-2">
{stats.category_distribution.map((c) => (
<Badge key={c.category} variant="secondary" className="gap-1">
{c.category}
<span className="text-primary font-medium">{c.count}</span>
</Badge>
))}
{stats.category_distribution.length === 0 && (
<p className="text-xs text-muted-foreground">暂无分布数据</p>
)}
</div>
</CardContent>
</Card>
);
}
// 事件流
function EventFeed({ events }: { events: EvolutionEvent[] }) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 mb-4">
<GitBranch size={18} className="text-primary" />
<h3 className="font-semibold text-foreground">进化事件流</h3>
</div>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无进化事件</p>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{events.map((e) => (
<div key={e.id} className="flex items-start gap-3">
<div className="w-2 h-2 rounded-full bg-primary mt-2 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm text-foreground">{e.description}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{e.skill_name}
{e.version_after != null && ` · v${e.version_after}`} ·{' '}
{formatTime(e.created_at)}
</p>
</div>
<Badge variant="outline" className="shrink-0 text-[10px]">
{e.event_type}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export function EvolutionPage() {
const [refreshKey, setRefreshKey] = useState(0);
const { data: profile, isLoading: pLoading } = useEvolutionProfile();
const { data: skills, isLoading: sLoading } = useEvolutionSkills();
const { data: stats } = useEvolutionStatistics();
const { data: pipeline } = useEvolutionPipelineStatus();
const { data: events } = useEvolutionEvents({ limit: 30 });
const skillList: EvolutionSkill[] = Array.isArray(skills) ? skills : [];
// 按 state 分组
const grouped = useGroupedSkills(skillList);
const isLoading = pLoading || sLoading;
return (
<div className="space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Brain size={20} className="text-primary" />
AI 自进化
</h2>
<p className="text-sm text-muted-foreground mt-1">
平台从对话与工具调用中持续学习,自动沉淀与迭代你的专属技能
</p>
</div>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={() => setRefreshKey((k) => k + 1)}
>
<RefreshCw size={15} className={refreshKey ? 'animate-spin' : ''} />
刷新
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 size={28} className="animate-spin text-primary" />
</div>
) : (
<motion.div
key={refreshKey}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-6"
>
{/* 能力画像 + 流水线状态 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{profile ? (
<ProfileCard profile={profile} />
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
<Brain size={40} className="mx-auto mb-3 opacity-50" />
暂无能力数据,多使用 AI 即可激活自进化
</CardContent>
</Card>
)}
{pipeline && <PipelineStatusCard status={pipeline} />}
</div>
{/* 统计 + 事件流 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{stats && <StatisticsCard stats={stats} />}
{events && <EventFeed events={events} />}
</div>
<Separator />
{/* 技能图谱 */}
<div>
<div className="flex items-center gap-2 mb-4">
<Network size={18} className="text-primary" />
<h3 className="font-semibold text-foreground">技能图谱</h3>
<Badge variant="outline" className="ml-auto">
{skillList.length} 个技能
</Badge>
</div>
{skillList.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Network size={44} className="mx-auto mb-3 opacity-50" />
<p className="text-sm">还没有进化出技能,继续与 AI 对话吧</p>
</div>
) : (
<div className="space-y-5">
{grouped.map((g) => (
<div key={g.category}>
<div className="flex items-center gap-2 mb-2">
<h4 className="text-sm font-medium text-foreground">
{g.category}
</h4>
<Badge variant="secondary" className="text-xs">
{g.skills.length}
</Badge>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{g.skills.map((sk) => (
<SkillCard key={sk.id} skill={sk} />
))}
</div>
</div>
))}
</div>
)}
</div>
</motion.div>
)}
</div>
);
}
// 按 category 分组,保持顺序稳定
function useGroupedSkills(skills: EvolutionSkill[]): { category: string; skills: EvolutionSkill[] }[] {
return useMemo(() => {
const map = new Map<string, EvolutionSkill[]>();
for (const s of skills) {
const cat = s.category || '未分类';
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(s);
}
return Array.from(map.entries()).map(([category, list]) => ({ category, skills: list }));
}, [skills]);
}
@@ -0,0 +1,163 @@
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Wifi,
WifiOff,
Plus,
MessageSquare,
Settings,
QrCode,
ExternalLink,
Loader2,
AlertTriangle,
} from 'lucide-react';
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
const otherChannels = [
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
];
export function ImBotHub() {
const { data: wechatStatus, isLoading } = useImBotStatus();
const disconnectBot = useDisconnectBot();
const handleDisconnect = async () => {
if (!confirm('确定断开微信连接?')) return;
try {
await disconnectBot.mutateAsync();
} catch {}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
</div>
<Button className="gap-2">
<Plus size={16} /> 添加渠道
</Button>
</div>
{/* WeChat Bot - Real Data */}
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
<Card className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
💬
</div>
<div>
<h3 className="font-semibold text-foreground">个人微信</h3>
<p className="text-xs text-muted-foreground mt-0.5">
{wechatStatus?.ilink_bot_id || '未配置'}
</p>
</div>
</div>
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
{isLoading ? (
<Loader2 size={12} className="animate-spin" />
) : wechatStatus?.status === 'connected' ? (
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
) : wechatStatus?.status === 'expired' ? (
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
) : (
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
)}
</Badge>
</div>
{wechatStatus?.status === 'connected' && (
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
<span className="flex items-center gap-1">
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
</span>
<span>
连接于 {wechatStatus.connected_at ? new Date(wechatStatus.connected_at).toLocaleString('zh-CN') : '—'}
</span>
</div>
)}
{wechatStatus?.status === 'expired' && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
会话已过期,请重新扫码连接
</div>
)}
<Separator className="mb-4" />
<div className="flex items-center gap-2">
{wechatStatus?.status === 'connected' ? (
<>
<Button variant="outline" size="sm" className="gap-1">
<Settings size={14} /> 管理
</Button>
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink size={14} /> 查看对话
</Button>
<Button
variant="outline"
size="sm"
className="gap-1 text-destructive hover:text-destructive"
onClick={handleDisconnect}
disabled={disconnectBot.isPending}
>
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
断开连接
</Button>
</>
) : (
<Button size="sm" className="gap-1">
<QrCode size={14} /> 扫码绑定
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
{/* Other Channels - Mock */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
>
{otherChannels.map((channel) => (
<Card key={channel.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
{channel.icon}
</div>
<div>
<h3 className="font-semibold text-foreground">{channel.name}</h3>
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
</div>
</div>
<Badge variant="secondary">
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
</Badge>
</div>
<Separator className="mb-4" />
<div className="flex items-center gap-2">
<Button size="sm" className="gap-1">
<QrCode size={14} /> 扫码绑定
</Button>
</div>
</CardContent>
</Card>
))}
</motion.div>
</div>
);
}
@@ -0,0 +1,201 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Search,
Plus,
Upload,
FileText,
Database,
RefreshCw,
Trash2,
Loader2,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
useKnowledgeBases,
useCreateKnowledgeBase,
useDeleteKnowledgeBase,
} from '@/hooks/useApi';
import type { KnowledgeBase } from '@/api/client';
export function KnowledgePage() {
const [searchQuery, setSearchQuery] = useState('');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const { data: bases = [], isLoading } = useKnowledgeBases();
const createBase = useCreateKnowledgeBase();
const deleteBase = useDeleteKnowledgeBase();
const handleCreate = async () => {
if (!newName.trim()) return;
try {
await createBase.mutateAsync({ name: newName, description: newDesc });
setShowCreateDialog(false);
setNewName('');
setNewDesc('');
} catch {}
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此知识库?')) return;
try {
await deleteBase.mutateAsync(id);
} catch {}
};
return (
<div className="space-y-6">
{/* Search Bar */}
<Card>
<CardContent className="p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="在所有知识库中检索问题..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<p className="text-xs text-muted-foreground mt-2">
支持跨库检索 · 向量相似度排序 · 来源溯源
</p>
</CardContent>
</Card>
{/* Tabs */}
<Tabs defaultValue="all">
<div className="flex items-center justify-between">
<TabsList>
<TabsTrigger value="all">全部知识库</TabsTrigger>
<TabsTrigger value="recent">最近使用</TabsTrigger>
</TabsList>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button className="gap-2">
<Plus size={16} /> 新建知识库
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>新建知识库</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>名称</Label>
<Input
placeholder="知识库名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>描述</Label>
<Input
placeholder="简要描述(可选)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleCreate}
disabled={!newName.trim() || createBase.isPending}
>
{createBase.isPending ? (
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
) : '创建'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<TabsContent value="all" className="mt-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{isLoading ? (
<div className="col-span-full flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-primary" />
</div>
) : (
bases.map((kb: KnowledgeBase) => (
<Card key={kb.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Database size={20} className="text-primary" />
</div>
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
</Badge>
</div>
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<FileText size={14} /> {kb.document_count} 文档
</span>
</div>
{kb.description && (
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
)}
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
<span className="text-xs text-muted-foreground">
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
</span>
<div className="flex gap-1">
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
<Upload size={14} className="text-muted-foreground" />
</button>
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
<RefreshCw size={14} className="text-muted-foreground" />
</button>
<button
onClick={() => handleDelete(kb.id)}
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
disabled={deleteBase.isPending}
>
<Trash2 size={14} className="text-destructive" />
</button>
</div>
</div>
</CardContent>
</Card>
))
)}
{/* Add New Card */}
<Card
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
onClick={() => setShowCreateDialog(true)}
>
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
<Plus size={20} className="text-muted-foreground" />
</div>
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
</CardContent>
</Card>
</motion.div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,345 @@
import { useState, useMemo } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
Search,
Store,
Star,
Download,
Check,
Sparkles,
Globe,
Bot,
Workflow,
Puzzle,
ShieldCheck,
Crown,
ChevronLeft,
ChevronRight,
Loader2,
} from 'lucide-react';
import {
useMarketApps,
useMarketAds,
type MarketAppsParams,
} from '@/hooks/useApi';
import type {
MarketApp,
MarketAppType,
MarketAppSource,
MarketSort,
} from '@/api/client';
const TYPE_LABELS: Record<MarketAppType, string> = {
standalone: '独立应用',
embedded: '嵌入式',
skill: '技能',
agent: '智能体',
workflow: '工作流',
};
const TYPE_ICONS: Record<MarketAppType, typeof Globe> = {
standalone: Globe,
embedded: Puzzle,
skill: Sparkles,
agent: Bot,
workflow: Workflow,
};
const SOURCE_LABELS: Record<MarketAppSource, string> = {
official: '官方',
community: '社区',
third_party: '第三方',
};
function formatCount(n: number): string {
if (n >= 10000) return `${(n / 10000).toFixed(1)}w`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function formatPrice(app: MarketApp): { label: string; free: boolean } {
if (app.is_member_free) return { label: '会员免费', free: true };
if (app.price > 0) return { label: `¥${app.price}`, free: false };
return { label: '免费', free: true };
}
export function MarketPage() {
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [type, setType] = useState<MarketAppType | undefined>(undefined);
const [sort, setSort] = useState<MarketSort>('popular');
const [page, setPage] = useState(1);
const PAGE_SIZE = 12;
// 简单防抖搜索(300ms)
useMemo(() => {
const t = setTimeout(() => setDebounced(search.trim()), 300);
return () => clearTimeout(t);
}, [search]);
const params: MarketAppsParams = useMemo(
() => ({
type,
search: debounced || undefined,
sort,
page,
page_size: PAGE_SIZE,
}),
[type, debounced, sort, page]
);
const { data, isLoading, isFetching } = useMarketApps(params);
const { data: ads } = useMarketAds();
const items: MarketApp[] = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const typeFilters: (MarketAppType | undefined)[] = [
undefined,
'skill',
'agent',
'workflow',
'standalone',
'embedded',
];
return (
<div className="space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Store size={20} className="text-primary" />
技能市场
</h2>
<p className="text-sm text-muted-foreground mt-1">
浏览、安装官方与社区技能,一键接入平台能力
</p>
</div>
</div>
{/* 广告位 */}
{ads && ads.length > 0 && (
<a
href={ads[0].url}
target="_blank"
rel="noreferrer"
className="block rounded-xl overflow-hidden border border-primary/20 bg-gradient-to-r from-primary/10 to-amber-50 dark:to-amber-950/30"
>
<div className="flex items-center gap-3 p-4">
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<Sparkles size={22} className="text-primary" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{ads[0].title}</p>
<p className="text-xs text-muted-foreground truncate">点击查看详情 →</p>
</div>
</div>
</a>
)}
{/* 搜索 + 排序 */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="搜索技能、智能体、工作流…"
className="pl-9"
/>
</div>
<div className="flex gap-2">
{(['popular', 'latest', 'rating'] as MarketSort[]).map((s) => (
<Button
key={s}
size="sm"
variant={sort === s ? 'default' : 'outline'}
onClick={() => {
setSort(s);
setPage(1);
}}
>
{s === 'popular' ? '最热' : s === 'latest' ? '最新' : '评分'}
</Button>
))}
</div>
</div>
{/* 类型筛选 */}
<div className="flex flex-wrap gap-2">
{typeFilters.map((tf) => (
<Button
key={tf ?? 'all'}
size="sm"
variant={type === tf ? 'default' : 'outline'}
onClick={() => {
setType(tf);
setPage(1);
}}
>
{tf ? TYPE_LABELS[tf] : '全部'}
</Button>
))}
</div>
<Separator />
{/* 列表 */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 size={28} className="animate-spin text-primary" />
</div>
) : items.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Store size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-1">未找到匹配的技能</p>
<p className="text-sm">试试调整搜索词或筛选条件</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
{items.map((app: MarketApp) => {
const TypeIcon = TYPE_ICONS[app.type] ?? Sparkles;
const price = formatPrice(app);
return (
<Card
key={app.id}
className="hover:shadow-md transition-shadow flex flex-col"
>
<CardContent className="p-5 flex flex-col flex-1">
<div className="flex items-start justify-between mb-3">
<div className="w-11 h-11 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
{app.icon ? (
<img
src={app.icon}
alt={app.name}
className="w-7 h-7 rounded"
/>
) : (
<TypeIcon size={22} className="text-primary" />
)}
</div>
<div className="flex flex-col items-end gap-1.5">
{app.verified && (
<Badge variant="success" className="gap-1">
<ShieldCheck size={11} />
已认证
</Badge>
)}
{app.installed ? (
<Badge variant="secondary" className="gap-1">
<Check size={11} />
已安装
</Badge>
) : (
<Badge variant="outline">{SOURCE_LABELS[app.source]}</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-foreground truncate">{app.name}</h3>
{app.is_member_free && (
<Crown size={14} className="text-amber-500 shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground mb-2">
由 {app.publisher_name} 发布
</p>
<p className="text-sm text-muted-foreground line-clamp-2 mb-4 flex-1">
{app.description}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mb-3">
{app.rating_avg > 0 && (
<span className="flex items-center gap-1">
<Star size={13} className="text-amber-500 fill-amber-500" />
{app.rating_avg.toFixed(1)}
</span>
)}
<span className="flex items-center gap-1">
<Download size={13} />
{formatCount(app.install_count)}
</span>
<Badge variant="outline" className="ml-auto">
{TYPE_LABELS[app.type]}
</Badge>
</div>
<Separator className="mb-3" />
<div className="flex items-center justify-between">
<span
className={
price.free
? 'text-sm font-medium text-green-600 dark:text-green-400'
: 'text-sm font-semibold text-foreground'
}
>
{price.label}
</span>
{app.installed ? (
<Button size="sm" variant="outline" disabled className="gap-1">
<Check size={14} />
已安装
</Button>
) : (
<Button size="sm" className="gap-1">
<Download size={14} />
安装
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</motion.div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 pt-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1 || isFetching}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft size={16} />
上一页
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages || isFetching}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
下一页
<ChevronRight size={16} />
</Button>
</div>
)}
</div>
);
}
@@ -0,0 +1,229 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Bell,
CheckCheck,
Check,
Circle,
Loader2,
Filter,
} from 'lucide-react';
import {
useNotifications,
useUnreadCount,
useMarkNotificationRead,
useMarkAllNotificationsRead,
} from '@/hooks/useApi';
import type { NotificationItem } from '@/api/client';
// 通知类型 → 中文标签(后端 type 透传,未知类型回退「通知」)
const TYPE_LABELS: Record<string, string> = {
task: '任务',
billing: '账单',
system: '系统',
evolution: '自进化',
market: '市场',
};
function typeLabel(t: string | null): string {
if (!t) return '通知';
return TYPE_LABELS[t] ?? t;
}
function formatTime(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const now = new Date();
const diff = now.getTime() - d.getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return '刚刚';
if (min < 60) return `${min} 分钟前`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} 小时前`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day} 天前`;
return d.toLocaleDateString('zh-CN');
}
export function NotificationsPage() {
const [isRead, setIsRead] = useState<boolean | undefined>(undefined);
const [page, setPage] = useState(1);
const PAGE_SIZE = 15;
const { data, isLoading, isFetching } = useNotifications({
is_read: isRead,
page,
page_size: PAGE_SIZE,
});
const { data: unread } = useUnreadCount();
const markRead = useMarkNotificationRead();
const markAllRead = useMarkAllNotificationsRead();
const items: NotificationItem[] = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const unreadCount = unread?.count ?? 0;
// 筛选 tabs:全部 / 未读 / 已读
const filters: { key: string; label: string; value: boolean | undefined }[] = [
{ key: 'all', label: '全部', value: undefined },
{ key: 'unread', label: '未读', value: false },
{ key: 'read', label: '已读', value: true },
];
const busy = markRead.isPending || markAllRead.isPending;
return (
<div className="space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<Bell size={20} className="text-primary" />
消息通知
{unreadCount > 0 && (
<Badge className="bg-primary/10 text-primary border-primary/20 ml-1">
{unreadCount} 未读
</Badge>
)}
</h2>
<p className="text-sm text-muted-foreground mt-1">
集中查看系统通知、任务动态与配额告警
</p>
</div>
<Button
size="sm"
variant="outline"
className="gap-1.5"
disabled={unreadCount === 0 || busy}
onClick={() => markAllRead.mutate(undefined)}
>
<CheckCheck size={15} />
全部已读
</Button>
</div>
{/* 筛选 tabs */}
<div className="flex items-center gap-2 flex-wrap">
<Filter size={15} className="text-muted-foreground" />
{filters.map((f) => (
<Button
key={f.key}
size="sm"
variant={isRead === f.value ? 'default' : 'outline'}
onClick={() => {
setIsRead(f.value);
setPage(1);
}}
>
{f.label}
</Button>
))}
</div>
<Separator />
{/* 列表 */}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 size={28} className="animate-spin text-primary" />
</div>
) : items.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Bell size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium mb-1">暂无通知</p>
<p className="text-sm">
{isRead === false ? '所有通知都已读啦' : '系统有动态时会在这里提醒你'}
</p>
</div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-3"
>
{items.map((n: NotificationItem) => (
<Card
key={n.id}
className={
'transition-colors ' +
(n.is_read ? 'bg-card' : 'bg-primary/5 border-primary/20')
}
>
<CardContent className="p-4 flex items-start gap-3">
{/* 已读/未读指示 */}
<div className="mt-1 shrink-0">
{n.is_read ? (
<Check size={16} className="text-muted-foreground" />
) : (
<Circle size={10} className="fill-primary text-primary mt-1.5" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="shrink-0">
{typeLabel(n.type)}
</Badge>
<h3 className="font-medium text-foreground truncate">
{n.title}
</h3>
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{formatTime(n.created_at)}
</span>
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
{n.content}
</p>
</div>
{!n.is_read && (
<Button
size="sm"
variant="ghost"
className="shrink-0 gap-1 text-primary hover:text-primary"
disabled={markRead.isPending}
onClick={() => markRead.mutate(n.id)}
>
<Check size={14} />
标为已读
</Button>
)}
</CardContent>
</Card>
))}
</motion.div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4 pt-2">
<Button
size="sm"
variant="outline"
disabled={page <= 1 || isFetching}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
上一页
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
size="sm"
variant="outline"
disabled={page >= totalPages || isFetching}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
下一页
</Button>
</div>
)}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>七虎AI - 养龙虾与AI能力探索</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4577
View File
File diff suppressed because it is too large. Load diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "qihuai-frontend",
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^11.18.2",
"lucide-react": "^0.575.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.71.2",
"react-router-dom": "^6.28.0",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/react": "^18.3.29",
"terser": "^5.31.0",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"typescript": "^5.9.3",
"vite": "^6.3.5"
}
}
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more