mirror of
https://github.com/SMNETSTUDIO/WeChat-AI.git
synced 2026-08-12 22:23:42 +08:00
chore: initial public release
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# 验收状态
|
||||
|
||||
**日期**:2026-07-19
|
||||
**结论**:**系统已完成并可验收**(离线/自动化门禁 + 文档化真机清单)
|
||||
|
||||
## 已交付范围(v1)
|
||||
|
||||
| 能力 | 实现 |
|
||||
|------|------|
|
||||
| 直连 iLink(无 OpenClaw) | `packages/ilink` |
|
||||
| 多用户隔离会话/记忆 | `packages/core` + DB |
|
||||
| 人设模板与发布 | seed 猫娘/女友 + Admin/API |
|
||||
| 仅后台分配角色 | `/角色` 拒绝自助 |
|
||||
| OpenAI 兼容 LLM | `packages/llm` |
|
||||
| 多 Bot 登录 | `pnpm ilink:login -- --name …` |
|
||||
| Admin Web | `/admin` |
|
||||
| 限流 / typing / 非文字提示 | worker |
|
||||
| 语音转写文本(有则用) | `extractText` |
|
||||
| 运维 | `pnpm diag`、`docs/runbook.md` |
|
||||
| E2E 清单 | `docs/e2e-checklist.md` |
|
||||
|
||||
## 自动化门禁
|
||||
|
||||
```bash
|
||||
pnpm accept
|
||||
```
|
||||
|
||||
包含:全仓单元测试、migrate/seed、diag、关键文件存在性。
|
||||
|
||||
## 真机项(人工)
|
||||
|
||||
见 `docs/e2e-checklist.md` 章节 C–E(需扫码 + 真实 LLM Key)。
|
||||
|
||||
## 非目标 / 可选后续
|
||||
|
||||
- ~~图片/文件 CDN 加解密全链路 / 入站 Vision~~ → **入站图片已实现**(CDN 下载 + AES-128-ECB 解密 + 视觉模型,`VISION_ENABLED`,默认关)。仍未做:入站语音 ASR(微信 SILK/AMR 无 OpenAI 兼容端点可用,现依赖 iLink 自带转写)、入站文件解析
|
||||
- Embedding 记忆检索(无 Embedding API;已用文本 top-k 替代)
|
||||
- 多模型路由
|
||||
- 联网搜索工具
|
||||
- React 工程化 Admin
|
||||
- Windows 服务安装包
|
||||
- 固定时段问候(可叠在主动联系调度器上)
|
||||
|
||||
## 已补充(2026-07-20)
|
||||
|
||||
- 无向量记忆 top-k(`MEMORY_TOP_K` / `MEMORY_FULL_INJECT_MAX`)+ 条数上限/去重
|
||||
- 记忆治理:单条删除 + 用户中心「记忆」面板
|
||||
- `get_current_time` 工具(`TIME_TOOL_ENABLED`,默认开)
|
||||
|
||||
## 主动找用户(空闲)
|
||||
|
||||
- 全局 `PROACTIVE_ENABLED` + Bot 开关 + peer「允许主动」
|
||||
- LLM 生成;可 skip;见 `docs/runbook.md` §2.1
|
||||
|
||||
## AI 网关 + Chatflow(2026-07-26)
|
||||
|
||||
| 能力 | 实现 |
|
||||
|------|------|
|
||||
| 唯一外网 AI 出口 | `huggingface/wechat-ai-tools`(FastAPI + Dockerfile) |
|
||||
| 用户自定义 OpenAI 兼容 API | 加密存储;**仅** HF 代发,主站不 dial 用户 base_url |
|
||||
| 联网搜索 | 仅 HF `/v1/web-search`;全局 + 人设双开关 |
|
||||
| 「我的模型」UI | `/app` → 我的模型(增删改 / 启停 / 掩码 Key) |
|
||||
| Chatflow 引擎 | `packages/core/src/chatflow/*`;节点 start/llm/answer/if-else/http/memory/search |
|
||||
| Chatflow 编辑器 | `/chatflow`(拖拽 + 连线 + 属性 + 保存启用) |
|
||||
| 试聊支持 chatflow | 强制平台上游,不消耗作者额度 |
|
||||
| 就绪探针 | `/health/ready` 纳入 tools 健康(缓存 15s) |
|
||||
|
||||
约束:chatflow 不做主动联系;Fork 不继承作者密钥;http 节点默认仅工具域。
|
||||
|
||||
文档:`docs/ai-gateway.md`、`docs/chatflow.md`;验收项见 `docs/e2e-checklist.md` §I / §J。
|
||||
@@ -0,0 +1,280 @@
|
||||
# API 概览(Redis + LINUX DO OAuth)
|
||||
|
||||
Base: `http://127.0.0.1:8787`
|
||||
Auth: **Cookie 会话**(OAuth 登录后 `wa_session`),`credentials: include`
|
||||
|
||||
## 认证
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/auth/config` | `{ oauthEnabled, provider, localAuthEnabled, inviteRequiredForLocal, passwordMinLength }` |
|
||||
| GET | `/api/v1/auth/login` | 跳转 LINUX DO OAuth |
|
||||
| GET | `/api/v1/auth/callback` | OAuth 回调(新 OAuth 用户**不需要**邀请码) |
|
||||
| POST | `/api/v1/auth/register` | 本地注册:`{ inviteCode, username, password, name? }` → cookie |
|
||||
| POST | `/api/v1/auth/password-login` | 用户名密码登录:`{ username, password }` → cookie |
|
||||
| GET | `/api/v1/auth/invite/:code` | 预检邀请码(不消费) |
|
||||
| POST | `/api/v1/auth/logout` | 退出 |
|
||||
| GET | `/api/v1/auth/me` | 当前用户(含 `authProvider`、`isSuperAdmin`,不含密码) |
|
||||
|
||||
## 邀请(登录用户)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/me/invites` | pending 列表 + 配额 + 邀请链接 |
|
||||
| POST | `/api/v1/me/invites` | 生成一次性码;受「每 X 小时 N 个」滑动窗口限制 |
|
||||
| DELETE | `/api/v1/me/invites/:code` | 撤销未使用码 |
|
||||
|
||||
邀请链接形态:`{PUBLIC_BASE_URL}/app?invite={CODE}`(打开后自动填入注册表单)。
|
||||
|
||||
## 用户
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/me/bots` | 我的机器人 |
|
||||
| POST | `/api/v1/me/bots/login/start` | 扫码添加机器人(新建 botId) |
|
||||
| POST | `/api/v1/me/bots/:botId/relogin/start` | **重新扫码绑定**(更新 token,保留 peers/记忆/分配) |
|
||||
| GET | `/api/v1/me/bots/login/:sessionId` | 扫码状态 |
|
||||
| DELETE | `/api/v1/me/bots/:botId` | 删除自己的机器人(含 Redis token) |
|
||||
| GET | `/api/v1/me/peers` | 私聊用户 |
|
||||
| POST | `/api/v1/me/peers/approve` | 批准 |
|
||||
| PUT | `/api/v1/me/assignments` | 分配人设(须在库中) |
|
||||
| GET | `/api/v1/me/personas` | 我的库 + 我创建的 |
|
||||
| POST | `/api/v1/me/personas/:id/add` | 添加人设到库 |
|
||||
| DELETE | `/api/v1/me/personas/:id` | 从库移除 |
|
||||
| GET | `/api/v1/me/memories?botAccountId=&peerId=&personaId?` | 长期记忆;无 personaId 时返回 `{total,groups}` |
|
||||
| POST | `/api/v1/me/memories/reset` | `{ botAccountId, peerId, personaId? }` 清空记忆 |
|
||||
| DELETE | `/api/v1/me/memories` | `{ botAccountId, peerId, personaId, memoryId }` 删单条 |
|
||||
| GET | `/api/v1/me/wechat-bind` | 当前 LINUX DO ↔ 微信绑定状态(`reachable` 表示是否有 context_token) |
|
||||
| POST | `/api/v1/me/wechat-bind/code` | 生成 6 位绑定码(微信 `/绑定 CODE`) |
|
||||
| DELETE | `/api/v1/me/wechat-bind` | 解除绑定并结束相关用户对话 |
|
||||
| GET | `/api/v1/me/blocks` | 我的黑名单(LINUX DO 用户) |
|
||||
| POST | `/api/v1/me/blocks` | `{ username }` 或 `{ userId }` 拉黑 |
|
||||
| DELETE | `/api/v1/me/blocks/:userId` | 取消拉黑 |
|
||||
|
||||
## 人设广场
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/square/personas?q=&page=&limit=&sort=heat\|use\|recent\|name` | 搜索公开人设(默认 sort=heat;字段含 useCount / assignCount / forkCount / heatScore / forkedFrom) |
|
||||
| GET | `/api/v1/square/personas/:id` | 详情(含 systemPrompt) |
|
||||
| POST | `/api/v1/square/personas` | 发布(public/private) |
|
||||
| POST | `/api/v1/square/personas/:id/fork` | Fork 为当前用户私有草稿(`PERSONA_FORK_ENABLED`,默认开) |
|
||||
| PUT | `/api/v1/square/personas/:id` | 作者更新 |
|
||||
| DELETE | `/api/v1/square/personas/:id` | 作者软删除 |
|
||||
| GET | `/api/v1/square/stickers?q=&page=&limit=&sort=use\|recent\|name` | 已审核公开表情包(默认 sort=use) |
|
||||
| GET | `/api/v1/square/stickers/:id` | 详情(`imageUrl` 对公开已审为 CDN 路径) |
|
||||
| GET | `/api/v1/square/stickers/:id/image` | 预览图(**鉴权**;私有/待审/作者预览) |
|
||||
| GET | `/cdn/s/:id?v={content_hash}` | **公开 CDN**(无 Cookie):仅 `public`+`approved`+`enabled`;长缓存 immutable |
|
||||
| POST | `/api/v1/square/stickers` | 投稿(public→待审 / private 自用) |
|
||||
| PUT | `/api/v1/square/stickers/:id` | 作者更新(公开改动回待审) |
|
||||
| DELETE | `/api/v1/square/stickers/:id` | 作者软删除 |
|
||||
| GET | `/api/v1/me/stickers` | 我的库 + 我创建的 |
|
||||
| POST | `/api/v1/me/stickers/:id/add` | 加入表情库 |
|
||||
| DELETE | `/api/v1/me/stickers/:id` | 从库移除(对称递减 use_count) |
|
||||
|
||||
## 网页试聊
|
||||
|
||||
不经微信,在 `/app` 内限量体验公开/可用人设。计入用户 Token 用量;会话存 Redis TTL。
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| POST | `/api/v1/try-chat/sessions` | `{ personaId, botName? }` → `{ sessionId, persona, remainingToday, expiresInSec }` |
|
||||
| POST | `/api/v1/try-chat/sessions/:sessionId/messages` | `{ text }` → `{ messages[], remainingToday, remainingSession, usage }` |
|
||||
| DELETE | `/api/v1/try-chat/sessions/:sessionId` | 结束会话 |
|
||||
|
||||
环境变量:`TRY_CHAT_ENABLED`、`TRY_CHAT_MAX_USER_MSGS_PER_DAY`、`TRY_CHAT_MAX_USER_MSGS_PER_SESSION`、`TRY_CHAT_SESSION_TTL_SEC`、`TRY_CHAT_MAX_HISTORY`。
|
||||
|
||||
热度公式:`heatScore = use_count*2 + assign_count*5 + fork_count*3`(分配仅在 persona 变化时 +1)。
|
||||
|
||||
## 管理员
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/admin/dashboard` | 仪表盘 + 今日 Token + **全舰队** workerStats + nodes 摘要 + Redis |
|
||||
| GET | `/api/v1/admin/system` | 系统健康 / doctor / **全舰队** workerStats / nodes 摘要 |
|
||||
| GET | `/api/v1/admin/nodes` | **仅超管** 部署节点列表(含 fenced) |
|
||||
| POST | `/api/v1/admin/nodes/:workerId/force-offline` | **仅超管** 强制下线 |
|
||||
| POST | `/api/v1/admin/nodes/:workerId/clear-fence` | **仅超管** 解除封锁 |
|
||||
| POST | `/api/v1/admin/nodes/:workerId/weight` | **仅超管** 设负载权重 `{ weight: 0..500 }` |
|
||||
| DELETE | `/api/v1/admin/nodes/:workerId/weight` | **仅超管** 恢复默认权重(100%) |
|
||||
| POST | `/api/v1/admin/nodes/weights/prune` | **仅超管** 立即清理失效权重(平时会自动过期) |
|
||||
| POST | `/api/v1/admin/workers/restart-all` | **仅超管** 批量恢复 pollable 并尝试认领(有 token 的 active bot) |
|
||||
| POST | `/api/v1/admin/workers/stop-all` | **仅超管** 暂停全部可轮询 bot(写 pause 标记,不删账号) |
|
||||
| POST | `/api/v1/admin/system/seed-personas` | 幂等补种官方人设 |
|
||||
| GET | `/api/v1/admin/memories?botAccountId=&peerId=` | 查看 peer 长期记忆(按人设分组) |
|
||||
| DELETE | `/api/v1/admin/memories` | `{ botAccountId, peerId, personaId, memoryId }` 删单条 |
|
||||
| POST | `/api/v1/admin/memories/reset` | `{ botAccountId, peerId, personaId? }` 清记忆 |
|
||||
| POST | `/api/v1/admin/messages/clear` | `{ botAccountId, peerId }` 清除短期对话历史(保留长期记忆) |
|
||||
| GET | `/api/v1/admin/peers?status=unapproved\|approved\|all` | 全站私聊 peer(默认待批准) |
|
||||
| POST | `/api/v1/admin/peers/approve` | `{ botAccountId, peerId }` 管理员代批 |
|
||||
| POST | `/api/v1/admin/peers/approve-all` | 批准全部待批准 peer |
|
||||
| GET | `/api/v1/admin/usage?day=` | 按日用量 |
|
||||
| GET | `/api/v1/admin/usage?days=7` | 近 N 日用量列表 |
|
||||
| GET | `/api/v1/admin/users` | 用户全量列表(含 botCount、isBanned、authProvider、isSuperAdmin) |
|
||||
| GET | `/api/v1/admin/users/:id` | 用户详情 + 名下机器人 |
|
||||
| PATCH | `/api/v1/admin/users/:id` | **仅超管** `{ isAdmin }` 授予/撤销管理员(不可撤自己/最后一位;**不可撤超管**) |
|
||||
| POST | `/api/v1/admin/users/:id/ban` | `{ reason?, cascadeBots? }` 封禁(踢 session;默认级联停用 bot;**不可封超管**) |
|
||||
| POST | `/api/v1/admin/users/:id/unban` | 解封(不自动启 bot) |
|
||||
| DELETE | `/api/v1/admin/users/:id?confirm=username` | 删除用户(级联删 bot/session/索引;**不可删超管**) |
|
||||
|
||||
> **超管**:仍为管理员的用户中 `created_at` 最早者(系统首位管理员)。对超管的撤销管理 / 封禁 / 删除一律拒绝(`cannot_revoke_super_admin` / `cannot_ban_super_admin` / `cannot_delete_super_admin`)。
|
||||
| GET | `/api/v1/admin/settings/invites` | 邀请策略(配额窗口小时 / 每窗口上限 / TTL / pending) |
|
||||
| PATCH | `/api/v1/admin/settings/invites` | 更新邀请策略(Redis 覆盖 env 默认) |
|
||||
| GET | `/api/v1/admin/settings/runtime` | **仅超管** 运行时配置:分组 + 全部项(当前值 / env 默认 / 是否已覆盖 / 是否需重启)+ 警告 |
|
||||
| PATCH | `/api/v1/admin/settings/runtime` | **仅超管** `{ patch: {key: value}, reset: [key] }` 写 Redis 覆盖 |
|
||||
| POST | `/api/v1/admin/settings/runtime/reset` | **仅超管** 删除全部覆盖,回到 `.env` |
|
||||
|
||||
> 运行时配置详见 [`docs/runtime-settings.md`](./runtime-settings.md):`.env` 是默认值,Redis 存覆盖,各节点 5 秒内同步。密钥字段 GET 只返回掩码;PATCH 传空 = 不改,传 `-` = 清空。
|
||||
| GET | `/api/v1/admin/bots` | 全部机器人(owner、worker、hasToken、peer 计数;前端分页,后端批量读) |
|
||||
| GET | `/api/v1/admin/bots/:botId` | 机器人详情 + peers |
|
||||
| PATCH | `/api/v1/admin/bots/:botId` | 改名 / `{ status: active\|inactive }` 启停 |
|
||||
| POST | `/api/v1/admin/bots/:botId/stop-worker` | **仅超管** 停止 Worker 轮询 |
|
||||
| POST | `/api/v1/admin/bots/:botId/start-worker` | **仅超管** 启动/重启 Worker(需已有 Redis token) |
|
||||
| DELETE | `/api/v1/admin/bots/:botId` | 删除机器人 |
|
||||
| GET | `/api/v1/admin/bots/:botId/send-targets` | **仅超管** 该 bot 的 peers + `hasContextToken`(广播勾选) |
|
||||
| POST | `/api/v1/admin/broadcast` | **仅超管** 创建广播任务或 `preview:true` 仅预估人数 |
|
||||
| GET | `/api/v1/admin/broadcast?limit=` | **仅超管** 最近广播任务列表 |
|
||||
| GET | `/api/v1/admin/broadcast/:id` | **仅超管** 任务详情与进度 |
|
||||
| POST | `/api/v1/admin/broadcast/:id/cancel` | **仅超管** 取消 pending/running 任务 |
|
||||
| GET | `/api/v1/admin/personas?q=&includeDisabled=1` | 人设列表 |
|
||||
| GET | `/api/v1/admin/personas/:id` | 详情(含 prompt) |
|
||||
| POST | `/api/v1/admin/personas` | 创建官方人设(可带 tags / isDefault) |
|
||||
| PUT | `/api/v1/admin/personas/:id` | 更新元信息 / prompt |
|
||||
| POST | `/api/v1/admin/personas/:id/publish` | 发布新版本 prompt |
|
||||
| POST | `/api/v1/admin/personas/:id/takedown` | 下架非官方人设 |
|
||||
| POST | `/api/v1/admin/personas/:id/restore` | 恢复已下架 |
|
||||
| POST | `/api/v1/admin/personas/:id/set-default` | 设为默认人设 |
|
||||
| GET | `/api/v1/admin/audit?limit=` | 审计 |
|
||||
| GET | `/api/v1/admin/stream/recent?limit=&types=&full=` | **仅超管** 活动数据流 backlog(消息 / Worker / LLM;`full=1` 消息预览加长;`types=message,redis,worker,llm`) |
|
||||
| GET | `/api/v1/admin/stream?types=&full=&heartbeat=` | **仅超管** SSE 实时数据流(`text/event-stream`;Redis 命令为进程内抽样,不写 Redis backlog) |
|
||||
| GET | `/api/v1/admin/stickers?q=&enabled=` | 表情包列表 |
|
||||
| GET | `/api/v1/admin/stickers/:id` | 表情详情 |
|
||||
| GET | `/api/v1/admin/stickers/:id/image` | 预览原图(admin cookie) |
|
||||
| POST | `/api/v1/admin/stickers` | 上传:`{ slug, displayName, description?, tags?, mime?, dataBase64, enabled? }` |
|
||||
| PUT | `/api/v1/admin/stickers/:id` | 更新元信息 / 可选换图 |
|
||||
| DELETE | `/api/v1/admin/stickers/:id` | 删除 meta + 本地文件 |
|
||||
|
||||
页面:`/` 功能介绍 · `/app` 用户中心 · `/admin` 管理后台 · `/og.jpg` 社交分享图
|
||||
|
||||
### 表情包广场 / 回图
|
||||
|
||||
- **元数据 + 图片二进制均在 Redis**(`wa:sticker:{id}` / `wa:sticker:{id}:blob`)。
|
||||
- Meta 含 **`content_hash`**(blob sha256 前缀);换图会更新 hash,CDN 用 `?v=` 缓存破坏。
|
||||
- **公开已审图**可走无登录 `GET /cdn/s/:id?v=hash`(`public, max-age=31536000, immutable`),供 Cloudflare 边缘缓存。
|
||||
- **公开投稿必须管理员审核**(`pending` → `approve` 后进广场与 CDN);私有仅作者 bot 可用。
|
||||
- 上传强制安全扫描:禁 SVG、magic/mime 校验、脚本/PHP/polyglot 尾部检测(非杀软)。
|
||||
- 运行时按 **机器人主人** 的表情库 + 自建可用表情注入 LLM;`{"messages":["文字",{"type":"sticker","slug":"..."}]}`。
|
||||
- Admin:`GET /admin/stickers?status=pending`、`POST .../approve|reject|takedown|restore`。
|
||||
- 环境变量:`STICKER_SEND_ENABLED`、`MAX_STICKERS_PER_REPLY`、`STICKER_MAX_BYTES`(默认 2MB)。
|
||||
- Cloudflare 部署:见 `docs/cloudflare.md`。
|
||||
### Worker 相关配置(与 API 同进程 / 单镜像)
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `WORKER_ENABLED` | `true` | 是否在本进程跑 iLink 轮询与回复 |
|
||||
| `MAX_BOTS_PER_WORKER` | `500` | 本进程最多同时 long-poll 的 bot 数 |
|
||||
| `REPLY_CONCURRENCY` | `16` | 进程内 LLM/发送并发 |
|
||||
| `INBOX_MAX_LEN` | `20000` | 入站队列深度上限 |
|
||||
|
||||
### 管理后台广播(纯文本推送)
|
||||
|
||||
- **仅纯文本**;不经 LLM、不写短期对话 / 长期记忆。
|
||||
- **必须有 `context_token`**(peer 曾给该 bot 发过消息);无 token 的目标在展开时跳过。
|
||||
- **不按批准过滤**:未批准但可触达的 peer 也会进入队列。
|
||||
- 异步任务:`POST` 只写 Redis job;Worker 进程内 `BroadcastRunner` 限速发送(`BROADCAST_INTERVAL_MS`,默认 200ms;生产可调到 50–100ms 加速,注意 iLink 限流)。
|
||||
- `scope`:
|
||||
- `all_bots` — 全部机器人下可触达 peer
|
||||
- `bots` — `botIds[]` 指定机器人
|
||||
- `targets` — 显式 `{ botId, peerId }[]`
|
||||
- `POST` body 带 `preview: true` 时只返回 `{ deliverable, skippedNoToken, missingBots }`,不创建任务。
|
||||
- 需 `WORKER_ENABLED=true`(默认)才会实际发送;否则任务保持 `pending`。
|
||||
- 环境变量:`BROADCAST_INTERVAL_MS`、`BROADCAST_MAX_TEXT`(默认 2000)、`BROADCAST_HISTORY`(默认 100)。
|
||||
|
||||
### `workerStats` 字段(dashboard / system)
|
||||
|
||||
**全舰队**租约汇总(`scope: "fleet"`):
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `scope` | `fleet`(管理页)或 `local` |
|
||||
| `leasedLocal` / `leasedFleet` | 全舰队正在 poll 的 bot 数(fleet 模式下两者相同) |
|
||||
| `maxBots` | 全舰队容量合计(各节点 maxBots 之和) |
|
||||
| `pollable` | 应被轮询的 bot(active + token + 未 pause) |
|
||||
| `nodesOnline` / `nodesTotal` | 在线 / 注册部署节点数 |
|
||||
| `atCapacity` | 任一点触顶或舰队已满 |
|
||||
| `inboxDepth` 等 | **当前应答节点本机** inbox/任务计数(非全舰队加总) |
|
||||
|
||||
### 超管(super admin)
|
||||
|
||||
`created_at` 最早的 `is_admin` 用户。节点管理 API 与后台「节点」页仅超管可用;普通管理员仍可看 Workers 全舰队 bot 列表。
|
||||
|
||||
### `nodes` / `GET /admin/nodes`(fleet)
|
||||
|
||||
Redis 心跳注册的**部署进程**(多机时每台一条),**不包含**源站公网 URL(用户统一走主域名;源站 IP 只在 CF Worker `ORIGINS`)。
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | `WORKER_ID` |
|
||||
| `hostname` / `pid` | 主机与进程 |
|
||||
| `botCount` / `maxBots` / `leasedCount` / `leasedBotIds` | 容量与租约 |
|
||||
| `label` / `region` / `version` | 可选运维标签;`version` 为进程 appVersion |
|
||||
| `online` / `isSelf` | 心跳是否新鲜;是否为当前应答节点 |
|
||||
| `fenced` / `fenceReason` / `fencedAt` / `fencedBy` | 管理员强制下线封锁(进程可仍在跑 HTTP,但不 poll) |
|
||||
| `weight` / `weightOverride` / `weightUpdatedAt` / `weightBy` | 负载权重(百分比,默认 100)与是否管理员覆盖 |
|
||||
| `weightLastSeenAt` | 舰队最后一次确认该节点存活的时间(自动过期计时起点) |
|
||||
| `targetShare` | 按权重应分配的 bot 数(受 `maxBots` 约束);离线/封锁节点为 `null` |
|
||||
| `update` | OTA:`outdated` / `desiredVersion` / `status` / `progress` / `error` |
|
||||
| `startedAt` / `updatedAt` | 启动与心跳时间 |
|
||||
|
||||
响应另含 `release`(通道当前版本摘要)、`appVersion`、`otaEnabled`、`weights`、`weightTotal`(在线节点权重和)、`weightLimits`、`weightTtlSec`(权重自动清理宽限期)、`pollableTotal`、`rebalanceEnabled`、`rebalanceIntervalSec`。
|
||||
|
||||
**强制下线说明:** 不停止 Docker/OS 进程;目标 `WORKER_ID` 在 fence 清除前不会 re-register / claim。租约由其他节点接管。从 CF Worker `ORIGINS` 移除源站 IP 是流量侧下线,与本 API 独立。
|
||||
|
||||
### 节点负载权重(Worker 负载调节,仅超管)
|
||||
|
||||
后台「节点」页 → **负载权重**列 → 滑杆 / 预设 / 手填百分比,保存前可预览各节点调整后的目标 bot 数。
|
||||
|
||||
- **相对值**:在线节点按各自权重占比分摊 `wa:bots:pollable`。A=200%、B=100% → 2:1;两个节点都是 200% 与都是 100% 等价。
|
||||
- **范围** 0–500,默认 100。写入 100 等同删除覆盖(`wa:workers:weights` 只保存真正调过的节点)。
|
||||
- **0% = 腾空**:节点保持在线心跳、继续处理 HTTP,但不再认领 bot,并会把已持有的租约全部释放(此时忽略 `REBALANCE_SLACK`,能真正归零)。与「强制下线」不同:不写 fence,改回非 0 即刻恢复。
|
||||
- **上限仍然生效**:实际数量取 `min(权重份额, MAX_BOTS_PER_WORKER)`。权重只在容量内重新分配,不能突破单进程上限。
|
||||
- **单节点例外**:只有一个在线节点时它拿全部(权重是节点之间的比例)。全舰队都设 0% 时退化为均分,避免所有 bot 停止轮询。
|
||||
- **生效时机**:写入后 publish `wa:worker:wake`,各节点丢弃权重缓存;认领在下一个 `LEASE_RENEW_SEC` 生效,多余租约按 `REBALANCE_INTERVAL_SEC` / `REBALANCE_MAX_PER_TICK` 逐步释放。`REBALANCE_ENABLED=false` 时已认领的租约不迁移,权重只影响后续认领(后台会提示)。
|
||||
|
||||
#### 存储与自动清理
|
||||
|
||||
存储在 Redis,不进 env,节点重启不丢失。两个 hash 分开写,互不干扰:
|
||||
|
||||
| Key | 内容 | 写入方 |
|
||||
|-----|------|--------|
|
||||
| `wa:workers:weights` | `workerId` → JSON `{ percent, updatedAt, byUserId, byUsername }` | 仅管理员操作 |
|
||||
| `wa:workers:weights:seen` | `workerId` → 最后一次确认存活的 ISO 时间 | 仅 GC |
|
||||
|
||||
> 拆成两个 hash 是必需的:GC 若为了盖时间戳而回写整条权重记录,会把管理员同一时刻的改动覆盖掉(read-modify-write 丢失更新)。
|
||||
|
||||
**节点消失后权重会自动删除**,无需人工干预:
|
||||
|
||||
- 判活以心跳 meta(`wa:worker:<id>`,TTL `WORKER_STALE_SEC`)为准,不看时间戳——所有版本的进程都会写 meta,因此 OTA 混版滚动期间旧版本节点不会被误判为消失。
|
||||
- 任一在线节点每 5 分钟扫一次(`HSET`/`HDEL` 幂等,多节点并发无害),给还活着的节点刷新时间戳,给已消失且超过宽限期的删除记录,并顺带清掉没有对应权重的孤儿时间戳。权重 hash 为空时完全不产生 Redis 调用。
|
||||
- 宽限期 `WORKER_WEIGHT_TTL_SEC`(默认 3600 秒,下限 60 秒)必须长于一次重启 / OTA 应用,否则每次发版都会重置调节。
|
||||
- **已封锁(force-offline)节点永不过期**:封锁是临时的,解除后权重仍在。
|
||||
- 「立即清理权重」按钮跳过宽限期,立刻删除所有无心跳且未封锁节点的权重(`POST /admin/nodes/weights/prune`)。
|
||||
|
||||
> **注意**:未在环境变量固定 `WORKER_ID` 时,进程每次重启都会生成新的随机 ID(`w_<host>_<pid>_<rand>`),权重不会跟随到新 ID——这也正是必须自动清理的原因,否则 hash 会随重启次数无限增长。需要权重长期生效,请为每个节点固定 `WORKER_ID`。
|
||||
|
||||
### OTA 发布与节点更新(仅超管)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/v1/admin/releases/current` | 通道当前 release |
|
||||
| GET | `/api/v1/admin/releases` | 最近版本列表 |
|
||||
| POST | `/api/v1/admin/releases` | `mode=blob\|publish\|pack` 上传/注册 |
|
||||
| POST | `/api/v1/admin/releases/current` | `{ version }` 切换通道版本 |
|
||||
| POST | `/api/v1/admin/nodes/:workerId/update` | 对该节点下发更新 job |
|
||||
| POST | `/api/v1/admin/nodes/update-outdated` | 批量更新落后在线节点 |
|
||||
| GET | `/api/v1/admin/nodes/:workerId/update-status` | 单节点进度 |
|
||||
|
||||
CLI 打包:`pnpm release:pack` / `pnpm docker:build`(见 `docs/docker.md`)。
|
||||
发布通道:`/admin` → 部署节点 → **上传通道包**(`files.json`,浏览器超管会话)→ 节点「更新」。
|
||||
|
||||
另有公开探活:`GET /health`(进程)、`GET /health/ready`(含 Redis,供 LB)。
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR-0001: 直连 iLink,不依赖 OpenClaw Gateway
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-19)
|
||||
|
||||
## Context
|
||||
|
||||
产品需要接入微信 ClawBot / OpenClaw 生态下的官方通道,并支持多人角色扮演。
|
||||
曾考虑以 OpenClaw Gateway + `@tencent-weixin/openclaw-weixin` 为运行时。
|
||||
|
||||
## Decision
|
||||
|
||||
**本系统直接调用腾讯 iLink Bot HTTP API**(`https://ilinkai.weixin.qq.com`):
|
||||
|
||||
- 扫码登录拿 `bot_token`
|
||||
- `getupdates` 长轮询收消息
|
||||
- `sendmessage` 回复(必带 `context_token`)
|
||||
|
||||
会话、人设、记忆、LLM 均在本仓库实现。OpenClaw **不是运行时依赖**。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 运维更轻:只需本服务 + LLM Key
|
||||
- 需自行维护 iLink 适配与协议变更
|
||||
- 多人隔离用 DB 行级 `(bot_account_id, peer_id[, persona_id])` 即可
|
||||
- 协议细节以 PR0 实测与官方插件行为为准;社区文档可能滞后
|
||||
|
||||
## References
|
||||
|
||||
- OpenClaw WeChat channel docs (plugin path)
|
||||
- Community iLink protocol notes / `@tencent-weixin/openclaw-weixin` behavior
|
||||
@@ -0,0 +1,59 @@
|
||||
# AI 网关:平台 LLM vs 用户自定义 / 搜索
|
||||
|
||||
## 原则
|
||||
|
||||
| 流量 | 出站位置 |
|
||||
|------|----------|
|
||||
| **管理员配置的平台 LLM**(`LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL`) | **主站直连** |
|
||||
| **用户自定义 OpenAI 兼容 API** | **仅** `huggingface/wechat-ai-tools`(HF / Docker) |
|
||||
| **联网搜索** | **仅** tools 服务 |
|
||||
|
||||
主站 Node 进程**不会** `fetch(用户的 base_url)`,也不会直连搜索引擎。
|
||||
|
||||
```
|
||||
主站 ──平台 LLM──► 管理员配置的上游
|
||||
│
|
||||
└──TOOLS_BASE_URL──► wechat-ai-tools
|
||||
├── /v1/chat/completions + body.upstream ──► 用户 API
|
||||
└── /v1/web-search ──► DDG / SearXNG / …
|
||||
```
|
||||
|
||||
## 主站环境变量
|
||||
|
||||
```env
|
||||
# 平台(管理员)
|
||||
LLM_BASE_URL=https://api.openai.com/v1
|
||||
LLM_API_KEY=sk-...
|
||||
LLM_MODEL=gpt-4o-mini
|
||||
|
||||
# 工具网关
|
||||
TOOLS_BASE_URL=http://127.0.0.1:7860 # 或 https://xxx.hf.space
|
||||
TOOLS_API_KEY=shared-secret
|
||||
WEB_SEARCH_ENABLED=true
|
||||
LLM_PROVIDER_SECRET=... # 加密用户保存的 API Key
|
||||
```
|
||||
|
||||
## 打包 tools 镜像
|
||||
|
||||
```bash
|
||||
# 在 tools 目录
|
||||
docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
|
||||
|
||||
# 或 compose profile
|
||||
docker compose --profile tools up -d --build
|
||||
# 主站 .env 使用:TOOLS_BASE_URL=http://wechat-ai-tools:7860
|
||||
```
|
||||
|
||||
详见 `huggingface/wechat-ai-tools/README.md`。
|
||||
|
||||
## Chatflow
|
||||
|
||||
编排图中的 `llm` / `search` / `http` 同样遵守上表:用户自定义与搜索只经 tools;`http` 节点默认仅 tools host。见 `docs/chatflow.md`。
|
||||
|
||||
## 诊断
|
||||
|
||||
```bash
|
||||
pnpm diag
|
||||
```
|
||||
|
||||
会检查平台 `LLM_API_KEY`,以及(若配置了)`TOOLS_BASE_URL/health`。
|
||||
@@ -0,0 +1,134 @@
|
||||
# Chatflow
|
||||
|
||||
可视化对话编排(MVP)。人设可在 **prompt** 与 **chatflow** 两种模式间切换。
|
||||
|
||||
## 入口
|
||||
|
||||
- 用户中心「我的人设」:运行模式选 **Chatflow 流程**
|
||||
- 编辑器:`/chatflow?persona=<id>`
|
||||
- API:
|
||||
- `GET /api/v1/square/personas/:id/graph`
|
||||
- `PUT /api/v1/square/personas/:id/graph`(保存图并切换为 chatflow)
|
||||
|
||||
## 节点
|
||||
|
||||
| 类型 | 说明 | 出站 |
|
||||
|------|------|------|
|
||||
| `start` | 唯一入口 | 本地 |
|
||||
| `llm` | 调用模型 | 平台 LLM 直连,或用户自定义 → **仅** HF tools `/v1/chat/completions` |
|
||||
| `search` | 联网搜索 | **仅** HF tools `/v1/web-search` |
|
||||
| `http` | HTTP 请求 | **仅** `TOOLS` 主机 + `CHATFLOW_HTTP_ALLOWLIST`(`*` = 任意公网,见下)|
|
||||
| `memory` | 注入本轮已选记忆 | 本地 |
|
||||
| `if-else` | 条件分支(true/false 句柄) | 本地 |
|
||||
| `answer` | 最终回复模板 | 本地 |
|
||||
|
||||
默认图:`start → llm → answer`。
|
||||
|
||||
## 模板变量
|
||||
|
||||
节点文案支持 `{{query}}`、`{{system_prompt}}`、`{{history}}`、`{{memories}}`、`{{bot_name}}`、`{{llm_text}}`、`{{节点id.text}}` 等。
|
||||
|
||||
## 试聊
|
||||
|
||||
网页试聊支持 chatflow 人设:执行同一张已发布的图,但 **强制平台上游**(`upstream: null`),
|
||||
避免消耗或泄露作者的自定义模型额度。
|
||||
|
||||
## 限制(MVP)
|
||||
|
||||
- Chatflow 人设 **禁用主动联系**(`skipReason: chatflow_no_proactive`)
|
||||
- Fork 复制 graph/mode/web_search,**不复制** `llm_provider_id`
|
||||
- 试聊不注入长期记忆(`memories` 为空)与表情目录
|
||||
- LLM 节点的 `temperature` 暂用客户端默认值
|
||||
- 完整 Dify(知识库、代码节点等)不做
|
||||
|
||||
## 环境变量
|
||||
|
||||
```env
|
||||
CHATFLOW_HTTP_ALLOWLIST= # 额外允许的 http 节点 host;`*` = 任意公网
|
||||
CHATFLOW_MAX_STEPS=32
|
||||
CHATFLOW_MAX_NODES=40
|
||||
CHATFLOW_HTTP_TIMEOUT_MS=15000 # 单个 http 节点的墙钟上限
|
||||
TOOLS_BASE_URL=... # 搜索 / 用户自定义 LLM 必需
|
||||
TOOLS_API_KEY=...
|
||||
WEB_SEARCH_ENABLED=true # 全局搜索开关;人设还需 webSearchEnabled
|
||||
WEB_SEARCH_MAX_RESULTS=5 # 默认条数;search 节点可用 max_results 覆盖
|
||||
```
|
||||
|
||||
以上都是**默认值**:`/admin` → 设置 → 「Chatflow」「联网搜索与工具网关」可直接改,
|
||||
覆盖存 Redis,各节点 5 秒内生效,无需重启。详见 `docs/runtime-settings.md`。
|
||||
|
||||
## http 节点的出站边界
|
||||
|
||||
图是**人设作者**写的(`PUT .../graph` 仅 owner,`requireUser` 之外无审核),
|
||||
响应正文还会回灌进 `vars`(截断 50KB)供 `answer` 引用。也就是说 http 节点等于把一次
|
||||
**可读的**服务端请求交到普通注册用户手里,所以出站要按白名单管。
|
||||
|
||||
两个放大器值得单独记住:
|
||||
|
||||
- URL 是**模板**:`renderTemplate(node.data.url, vars)`,而 `vars.query` 就是原始
|
||||
用户消息(`engine.ts:127-133`)、`vars.llm_text` 是模型输出。作者写成
|
||||
`https://{{query}}/` 就等于把 host 的选择权交给**任何发消息的人**;写成引用上游
|
||||
`llm` 节点的变量,就等于交给可被 prompt 注入的模型输出。所以校验必须发生在
|
||||
**渲染之后**(现实现即如此),作者时校验 URL 是没用的。
|
||||
- 触发不需要微信:网页试聊走同一张图,人设可以一直是 private,作者从不出现在广场
|
||||
或任何审核面上。
|
||||
|
||||
`CHATFLOW_HTTP_ALLOWLIST` 的两种写法:
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 空 | http 节点只能打 `TOOLS_BASE_URL` 的 host:port。没配 TOOLS 就完全不可用 |
|
||||
| `a.com,b.com` | 精确匹配 hostname,**不支持通配**:`a.com` 不覆盖 `api.a.com` |
|
||||
| `*` | 任意**公网**地址 |
|
||||
|
||||
`*` 不是「不做检查」。放行前仍然逐项拦掉(`packages/core/src/chatflow/http-guard.ts`):
|
||||
|
||||
**第一道:地址段**(写死的 IP 字面量)
|
||||
|
||||
- 回环 `127.0.0.0/8`、`::1`、`localhost`
|
||||
- 私有段 `10/8`、`172.16/12`、`192.168/16`
|
||||
- 链路本地 `169.254/16` —— 含 AWS/Azure IMDS `169.254.169.254`
|
||||
- 运营商级 NAT `100.64/10` —— 含阿里云元数据 `100.100.100.200`
|
||||
- IPv6 `fc00::/7`、**整个 `fe00::/8`**(不只 `fe80::/10`,站点本地 `fec0::/10` 同样在内)、
|
||||
`ff00::/8`,以及 `::ffff:`、`64:ff9b::` 里嵌的 IPv4
|
||||
- IPv6 兜底:**不在全球单播 `2000::/3` 内的一律拒**,免得再漏某个保留前缀
|
||||
- `0/8`、`192.0/16`、`198.18/15`、多播与保留段
|
||||
|
||||
判断走**地址段**而不是字面量,因为 `new URL()` 会把 `2130706433`、`0x7f.0.0.1`、
|
||||
`0177.0.0.1`、`::ffff:127.0.0.1` 归一成别的写法 —— 只比字符串是拦不住的。
|
||||
|
||||
**第二道:主机名**
|
||||
|
||||
- 单标签主机名(docker service 名、`metadata`、`instance-data`)
|
||||
- `.local` `.localhost` `.internal` `.svc` `.lan` `.intranet` `.corp` `.private` `.home.arpa`
|
||||
- 长得像公网、其实是云元数据的名字:`metadata.tencentyun.com`(腾讯云 CVM,直接给
|
||||
角色凭证,无 token 步骤)、`metadata.goog`
|
||||
|
||||
**第三道:解析结果**
|
||||
|
||||
只看字面量挡不住 `169-254-169-254.nip.io` 这种通配 DNS(一个普通 `.io` 域名,解析到
|
||||
IMDS 地址,不需要攻击者自建任何东西),也挡不住攻击者把自家 A 记录指向 `10.0.0.5`,
|
||||
或 docker 内嵌 DNS 把 `service.network` 解析成网桥地址。所以放行前会 `dns.lookup`
|
||||
一次,**任一**返回地址落在上面的段里就拒。
|
||||
|
||||
解析失败**不**算拦截:解析不出来的名字 fetch 也出不去,没有任何流量发生,不该把一次
|
||||
DNS 抖动升级成 `http_blocked` 把整个流程打断。
|
||||
|
||||
**第四道:重定向与凭证**
|
||||
|
||||
- **每一跳重定向都重新过一遍全部检查**(`redirect: "manual"`,最多 3 跳)。否则一个允许的
|
||||
公网域名只要回一个 `302 → http://169.254.169.254/`,前面三道全白做。
|
||||
- `Authorization` 跨 origin 会被摘掉;`TOOLS_API_KEY` 只发给 `TOOLS_BASE_URL` 的
|
||||
**host:port** —— 同一台机器换个端口都不给,`TOOLS_BASE_URL` 指向 loopback 时尤其重要。
|
||||
|
||||
### 已知边界
|
||||
|
||||
**DNS rebinding 仍有窗口。** 这里解析一次、`fetch` 自己再解析一次,控制着权威 DNS 且
|
||||
TTL 压到极短的攻击者可以在两次之间翻记录。要堵死得把 socket 钉在**已经校验过的那个
|
||||
地址**上,即 undici `Agent` 的 `connect.lookup` —— 需要把 undici 收成直接依赖,当前没做。
|
||||
现在挡住的是所有直接写内网地址、以及所有**静态**解析到内网的名字。
|
||||
|
||||
**白名单是全站共享的**:加进去的 host 等于允许所有人的人设去请求它,别放带鉴权的内部接口。
|
||||
|
||||
联网搜索是**两道闸的与**:全局开关 + 人设自身的「联网」开关(用户中心「我的人设」里勾选)。
|
||||
详见 `docs/ai-gateway.md`。
|
||||
@@ -0,0 +1,195 @@
|
||||
# Cloudflare Business 缓存适配
|
||||
|
||||
本项目源站(Fastify Docker)已输出 **Cloudflare 友好** 的缓存头。你只需在 Cloudflare Dashboard 配好 **Cache Rules** 与区域级开关,即可最大化边缘命中。
|
||||
|
||||
相关代码:
|
||||
|
||||
| 能力 | 位置 |
|
||||
|------|------|
|
||||
| HTML / OG 内存缓冲 + ETag | `apps/api/src/static-pages.ts`, `index.ts` |
|
||||
| 统一 Cache-Control | `apps/api/src/cache-headers.ts` |
|
||||
| 公开表情 CDN | `GET /cdn/s/:id?v={content_hash}` |
|
||||
| 默认 API `private, no-store` | `apps/api/src/routes.ts` `onSend` |
|
||||
| CORS 白名单 | `CORS_ORIGINS` + `PUBLIC_BASE_URL` |
|
||||
|
||||
**本阶段不上 R2**;公开表情仍由源站 Redis blob 提供,靠边缘长缓存降压。
|
||||
|
||||
---
|
||||
|
||||
## 1. 源站响应策略(已实现)
|
||||
|
||||
| 路径 | Cache-Control | Cloudflare-CDN-Cache-Control | 鉴权 |
|
||||
|------|---------------|------------------------------|------|
|
||||
| `/` `/docs` | `public, max-age=300` | `max-age=3600, swr=86400` | 无 |
|
||||
| `/app` `/admin` | `public, max-age=60` | `max-age=3600, swr=86400` | 无(壳静态;数据走 API) |
|
||||
| `/og.jpg` | `public, max-age=86400, immutable` | `max-age=604800` | 无 |
|
||||
| `/cdn/s/:id?v=` | `public, max-age=31536000, immutable` | 同左 | **无**;仅 public+approved+enabled |
|
||||
| `/api/v1/auth/config` | `public, max-age=60` | `max-age=300` | 无 |
|
||||
| `/health` | `no-store` | — | 无 |
|
||||
| 其余 `/api/v1/**` | `private, no-store` | — | Cookie 会话 |
|
||||
| 鉴权表情图 | `private, no-store` | — | 登录 / admin |
|
||||
|
||||
HTML 带 `ETag` + `Cache-Tag: html-shell`;公开表情带 `Cache-Tag: sticker-{id}`(便于定向 Purge)。
|
||||
|
||||
---
|
||||
|
||||
## 2. DNS / SSL
|
||||
|
||||
1. 域名 A/AAAA/CNAME 橙云代理到源站
|
||||
2. SSL/TLS → **Full (strict)**
|
||||
- 源站用有效证书,或 [Cloudflare Origin CA](https://developers.cloudflare.com/ssl/origin-configuration/origin-ca/)
|
||||
3. Always Use HTTPS = On
|
||||
4. 源站 `.env`:
|
||||
- `PUBLIC_BASE_URL=https://你的域名`
|
||||
- `COOKIE_SECURE=true`
|
||||
- `LINUXDO_REDIRECT_URI=https://你的域名/api/v1/auth/callback`
|
||||
- 可选 `CORS_ORIGINS=https://你的域名`(默认已含 PUBLIC_BASE_URL origin)
|
||||
|
||||
---
|
||||
|
||||
## 3. Cache Rules(按顺序创建,先匹配先生效)
|
||||
|
||||
路径:**Caching → Cache Rules**(Business 推荐用 Cache Rules,而不是旧 Page Rules)。
|
||||
|
||||
### Rule 1 — Bypass 动态 API
|
||||
|
||||
- **Name:** `bypass-api-private`
|
||||
- **When:**
|
||||
`(starts_with(http.request.uri.path, "/api/v1/") and not http.request.uri.path eq "/api/v1/auth/config")`
|
||||
或方法为 `POST` / `PUT` / `PATCH` / `DELETE`
|
||||
- **Then:** Cache eligibility = **Bypass cache**
|
||||
|
||||
### Rule 2 — HTML 壳 Cache Everything + 忽略 Cookie
|
||||
|
||||
- **Name:** `cache-html-shells`
|
||||
- **When:**
|
||||
`http.request.uri.path in {"/" "/app" "/docs" "/admin"}`
|
||||
- **Then:**
|
||||
- Eligible for cache
|
||||
- Edge TTL: **Respect origin**(或 Override 1 hour)
|
||||
- Browser TTL: Respect origin
|
||||
- **Cache key → Ignore query string**(可选)
|
||||
- **Cookie handling → Ignore presence of cookies**(**关键**:否则带 `wa_session` 永远 MISS)
|
||||
|
||||
### Rule 3 — OG 图
|
||||
|
||||
- **Name:** `cache-og`
|
||||
- **When:** `http.request.uri.path eq "/og.jpg"`
|
||||
- **Then:** Eligible for cache;Edge TTL Respect origin 或 7d;**Ignore cookies**
|
||||
|
||||
### Rule 4 — 公开表情 CDN
|
||||
|
||||
- **Name:** `cache-cdn-stickers`
|
||||
- **When:** `starts_with(http.request.uri.path, "/cdn/s/")`
|
||||
- **Then:** Eligible for cache;Edge TTL Respect origin;**Ignore cookies**
|
||||
- Cache key **保留 query string**(`v=` 内容哈希)
|
||||
|
||||
### Rule 5 — auth/config 短缓存
|
||||
|
||||
- **Name:** `cache-auth-config`
|
||||
- **When:** `http.request.uri.path eq "/api/v1/auth/config"`
|
||||
- **Then:** Eligible for cache;Edge TTL 5 minutes 或 Respect origin;Ignore cookies
|
||||
|
||||
### 默认
|
||||
|
||||
未匹配规则时:尊重源站 `Cache-Control`;无 `public` 的不进共享缓存。
|
||||
|
||||
---
|
||||
|
||||
## 4. 区域级推荐(Business)
|
||||
|
||||
| 设置 | 建议 |
|
||||
|------|------|
|
||||
| HTTP/3 (QUIC) | On |
|
||||
| Brotli | On |
|
||||
| Tiered Cache | On |
|
||||
| Early Hints | 可选(当前 HTML 内联,收益有限) |
|
||||
| Auto Minify | **Off**(避免改 HTML 导致 ETag 与部署不一致) |
|
||||
| Rocket Loader | **Off** |
|
||||
| Polish | 可选;若开 WebP,注意 `Accept` 变体,或只对 `/cdn/s/*` 试 |
|
||||
| Mirage | 可选(移动列表) |
|
||||
| Cache Reserve | 可选(长尾表情) |
|
||||
| Argo Smart Routing | 源站距用户远时可选 |
|
||||
| WAF Managed Rules | On |
|
||||
| Rate limiting | 建议:`/api/v1/auth/*`、try-chat、上传 POST |
|
||||
| Bot Fight | 按需;勿误伤 OAuth 回调 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 部署后刷新缓存
|
||||
|
||||
| 变更 | 做法 |
|
||||
|------|------|
|
||||
| 新版本 HTML(app/admin/docs/landing) | Purge by URL:`/` `/app` `/docs` `/admin`,或 Purge by Tag:`html-shell` |
|
||||
| 换图表情 | **无需 purge**:`?v={content_hash}` 自动新键 |
|
||||
| 下架 / 拒绝公开表情 | 源站 404;边缘可能仍 HIT 至 TTL → Purge URL 或 Tag `sticker-{id}` |
|
||||
| 紧急全站 | Purge Everything(会短暂升高源站压力) |
|
||||
|
||||
API Purge 示例(可选):
|
||||
|
||||
```bash
|
||||
# 需要 Zone ID + API Token (Cache Purge 权限)
|
||||
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CF_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"files":["https://你的域名/","https://你的域名/app","https://你的域名/docs","https://你的域名/admin"]}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收 curl
|
||||
|
||||
```bash
|
||||
HOST=https://你的域名
|
||||
|
||||
# HTML:第二次应 CF-Cache-Status: HIT(即使带假 cookie)
|
||||
curl -sI "$HOST/" | grep -iE 'cf-cache-status|cache-control|etag|content-encoding'
|
||||
curl -sI "$HOST/app" -H "Cookie: wa_session=fake" | grep -i cf-cache-status
|
||||
|
||||
# 公开表情
|
||||
curl -sI "$HOST/cdn/s/$STICKER_ID?v=$HASH" | grep -iE 'cf-cache-status|cache-control'
|
||||
curl -sI "$HOST/cdn/s/$STICKER_ID?v=$HASH" | grep -i cf-cache-status # HIT
|
||||
|
||||
# 私有 API
|
||||
curl -sI "$HOST/api/v1/auth/me" -H "Cookie: wa_session=..." | grep -iE 'cache-control|cf-cache-status'
|
||||
# 期望: private, no-store;DYNAMIC 或 BYPASS
|
||||
|
||||
# 压缩
|
||||
curl -sI -H 'Accept-Encoding: br' "$HOST/app" | grep -i content-encoding
|
||||
|
||||
# 私有 / 未审表情不得公开
|
||||
curl -sI "$HOST/cdn/s/$PRIVATE_ID" # 404
|
||||
```
|
||||
|
||||
Dashboard → Caching → Cache Analytics 可看 HIT 率。
|
||||
|
||||
---
|
||||
|
||||
## 7. 反代注意
|
||||
|
||||
- 优先 **CF 橙云 → 源站 443/8787**,中间少一层
|
||||
- **多节点负载均衡**:使用仓库内 **`cloudflare-worker/`**(静态 `ORIGINS` + 健康检查 + 轮询),主域名绑 Worker;详见 `cloudflare-worker/README.md`
|
||||
- 若 Nginx 仅 TLS 终结再反代 Node:
|
||||
- 转发 `Host`、`X-Forwarded-Proto`、`CF-Connecting-IP`
|
||||
- **不要**强行剥掉 `Accept-Encoding` 导致双重压缩混乱
|
||||
- 源站健康检查:`GET /health`(进程)或 `GET /health/ready`(含 Redis,推荐给 LB)
|
||||
- 应用侧 `PUBLIC_BASE_URL` 始终为主域名;源站 IP 不必暴露在后台 UI
|
||||
---
|
||||
|
||||
## 8. 安全边界
|
||||
|
||||
- `/cdn/s/*` **仅** `visibility=public` 且 `review_status=approved` 且 `enabled`
|
||||
- 私有 / pending / rejected → 统一 404
|
||||
- 用户 JSON、admin、try-chat、OAuth callback **永不**边缘共享缓存
|
||||
- 广场列表含 `inLibrary` 个性化,**不**做公共 CDN 缓存(升级路径:拆无个性化的 public catalog API)
|
||||
|
||||
---
|
||||
|
||||
## 9. 可选升级路径(未实现)
|
||||
|
||||
1. 公开表情同步 **R2** + 自定义域,源站只写 meta
|
||||
2. 拆 `GET /api/v1/public/square/*` 无 `inLibrary` 的目录接口短缓存
|
||||
3. 内联 CSS/JS 拆出带 content-hash 的静态文件
|
||||
4. Cache Reserve + Image Resizing
|
||||
|
||||
详见主 README / 本文件与 `docs/docker.md` 交叉引用。
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
# Docker 部署
|
||||
|
||||
## 前置条件
|
||||
|
||||
| 依赖 | 说明 |
|
||||
|------|------|
|
||||
| Docker + Compose | 本机或服务器已安装 |
|
||||
| Upstash Redis | `.env` 中 `REDIS_URL=rediss://...` |
|
||||
| LINUX DO OAuth | Client ID/Secret + **公网回调地址** |
|
||||
| 平台 LLM(主站直连) | `LLM_API_KEY` / `LLM_BASE_URL` / `LLM_MODEL` |
|
||||
| 工具网关(用户自定义 API + 搜索) | `TOOLS_BASE_URL` / `TOOLS_API_KEY`;镜像见 `huggingface/wechat-ai-tools` |
|
||||
|
||||
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
cd /path/to/WeChat-AI
|
||||
|
||||
# 配置环境变量(勿提交 .env)
|
||||
# 生产务必修改:
|
||||
# PUBLIC_BASE_URL=https://你的域名
|
||||
# LINUXDO_REDIRECT_URI=https://你的域名/api/v1/auth/callback
|
||||
# REDIS_URL / LLM_* / LINUXDO_CLIENT_*
|
||||
# 用户自定义 API + 搜索:TOOLS_BASE_URL / TOOLS_API_KEY
|
||||
# docker compose --profile tools up -d --build
|
||||
# TOOLS_BASE_URL=http://wechat-ai-tools:7860
|
||||
|
||||
# 推荐:升版 + 打 OTA 包 + 构建(无需 Cookie)
|
||||
pnpm docker:up
|
||||
# 自定义镜像名:
|
||||
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
|
||||
|
||||
# 包在 dist/release/<版本>/files.json
|
||||
# 浏览器登录 /admin → 部署节点 →「上传通道包」→ 再点节点「更新」
|
||||
|
||||
docker compose logs -f wechat-ai
|
||||
```
|
||||
|
||||
### 单独构建 tools 镜像
|
||||
|
||||
```bash
|
||||
docker build -t wechat-ai-tools:latest -f huggingface/wechat-ai-tools/Dockerfile huggingface/wechat-ai-tools
|
||||
docker run --rm -p 7860:7860 -e TOOLS_API_KEY=secret -e ALLOW_REQUEST_UPSTREAM=true wechat-ai-tools:latest
|
||||
```
|
||||
|
||||
详见 `docs/ai-gateway.md`、`huggingface/wechat-ai-tools/README.md`。
|
||||
|
||||
|
||||
> **版本与通道:** `pnpm docker:build` 默认:升版 → **本地 pack** → Docker。
|
||||
> 通道发布只走网页:`/admin` → 上传 `files.json`(无 CLI Cookie)。
|
||||
> 直接跑 `docker build` **不会**升版、也**不会**打通道包。
|
||||
|
||||
| 地址 | 说明 |
|
||||
|------|------|
|
||||
| `/` | 功能介绍落地页(OG 分享图 `/og.jpg`) |
|
||||
| `/app` | 用户中心(LINUX DO 登录、加机器人) |
|
||||
| `/admin` | 管理后台(仪表盘 / Token) |
|
||||
| `/health` | 健康检查 |
|
||||
|
||||
## 仅用 Dockerfile
|
||||
|
||||
```bash
|
||||
# 升版 + docker build -t wechat-ai .
|
||||
pnpm docker:build -- --raw
|
||||
# 或自定义:node scripts/docker-build.mjs -- docker build -t wechat-ai:0.2.1 .
|
||||
|
||||
docker run -d --name wechat-ai --restart unless-stopped \
|
||||
--env-file .env \
|
||||
-e WECHAT_AI_HOST=0.0.0.0 \
|
||||
-e WECHAT_AI_PORT=8787 \
|
||||
-p 8787:8787 \
|
||||
wechat-ai
|
||||
```
|
||||
|
||||
Bot token 与表情包均存 **Redis**(与 `REDIS_URL` 同库),容器重建不丢;无需本地数据卷。
|
||||
|
||||
## 生产环境检查清单
|
||||
|
||||
1. **LINUX DO** 应用回调与 `.env` 完全一致:
|
||||
`https://你的域名/api/v1/auth/callback`
|
||||
2. **`PUBLIC_BASE_URL`** = `https://你的域名`(无尾斜杠)
|
||||
3. **HTTPS** 时设置 `COOKIE_SECURE=true`
|
||||
4. **Redis** 使用 Upstash `rediss://`,服务器能访问外网
|
||||
5. Bot **token 已写入 Redis**(与 `REDIS_URL` 同库),重建容器不会丢登录
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
docker compose ps
|
||||
docker compose logs -f
|
||||
docker compose restart
|
||||
docker compose down # 停服务(Bot token 在 Redis,不受影响)
|
||||
docker compose down -v # 同 down(本服务无持久化 volume)
|
||||
pnpm docker:up # 升版+本地 pack+compose up --build
|
||||
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
|
||||
# 然后 /admin → 上传通道包 (files.json) → 更新节点
|
||||
pnpm docker:build -- --no-channel # 只升版构建,不 pack
|
||||
```
|
||||
|
||||
## OTA 增量更新(多节点日常热修)
|
||||
|
||||
业务源码小改可不必每台 `docker build`:本地 pack → 管理后台上传通道包 → 对落后节点点「更新」。
|
||||
|
||||
```bash
|
||||
# 构建顺带打通道包
|
||||
pnpm docker:build -- -- docker build -t e51l6pwpe/wxai:latest .
|
||||
# 或仅 pack:
|
||||
pnpm release:pack
|
||||
|
||||
# 浏览器 /admin → 部署节点 →「上传通道包」选 dist/release/<ver>/files.json
|
||||
# →「更新全部落后」
|
||||
```
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 差量 | 按文件 sha256 比对,只下发变更文件 |
|
||||
| 重启 | 节点 `process.exit(0)`,依赖 `restart: unless-stopped` 拉起**同一容器**(可写层保留补丁) |
|
||||
| 版本 | 心跳 `version`:`.wa-version`(OTA 写入)→ `APP_VERSION` → 根 `package.json` |
|
||||
| 仍需镜像 | Node 基础镜像、系统包、Dockerfile、`OTA_ALLOW_INSTALL=false` 时的依赖大变 |
|
||||
|
||||
环境变量:`OTA_ENABLED`(默认 true)、`OTA_ALLOW_INSTALL`、`APP_VERSION`(无 OTA 戳时可选)、`OTA_STAGING_DIR`。
|
||||
**注意:** OTA 只改文件、不改环境变量;版本靠 `/app/.wa-version` 上报,无需、也不应靠 `APP_VERSION` 跟版。
|
||||
`docker compose up --build` / 重建容器会丢掉仅靠 OTA 写入的补丁;长期仍以镜像为 source of truth。
|
||||
|
||||
## 反代示例
|
||||
|
||||
生产推荐把域名挂在 **Cloudflare**(橙云代理 + Cache Rules),见 **`docs/cloudflare.md`**(Business 缓存规则、忽略 Cookie、Purge 清单)。
|
||||
|
||||
### Caddy(无 CF 时)
|
||||
|
||||
```caddy
|
||||
your.domain.com {
|
||||
reverse_proxy 127.0.0.1:8787
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx(无 CF 时,或 CF → Nginx → Node)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your.domain.com;
|
||||
# ssl_certificate ...;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
# Cloudflare 还原真实 IP 时可用:
|
||||
# proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
源站已输出 `Cache-Control` / `Cloudflare-CDN-Cache-Control`、HTML ETag、公开表情 `/cdn/s/:id?v=`。反代层**不必**再写 `proxy_cache`,除非你不用 Cloudflare。
|
||||
## 镜像说明
|
||||
|
||||
- 基础镜像:`node:22-bookworm-slim`
|
||||
- 包管理:pnpm monorepo
|
||||
- 启动:`pnpm db:seed`(幂等)→ `pnpm --filter @wechat-ai/api start`(**API + Worker 同进程**)
|
||||
- 非 root 用户 `appuser` 运行
|
||||
- 健康检查:`GET /health`
|
||||
|
||||
## Worker 规模(单镜像)
|
||||
|
||||
默认仍是 **一个容器跑全部**:HTTP + iLink 长轮询 + AI 回复。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `MAX_BOTS_PER_WORKER` | `500` | 本进程最多同时 long-poll 的 bot 数 |
|
||||
| `REPLY_CONCURRENCY` | `16` | 同时进行的 LLM/发送任务数 |
|
||||
| `LEASE_TTL_SEC` | `45` | 租约 TTL(同镜像多副本时防重复 poll) |
|
||||
|
||||
机器人很多时优先调高 `MAX_BOTS_PER_WORKER` 与系统 `ulimit -n`(注意内存与出站连接数)。
|
||||
默认 **单副本一体部署** 即可;同镜像多副本已支持(Redis 租约分片 poll)。
|
||||
|
||||
## 多节点同构部署(10+ 台)
|
||||
|
||||
每台服务器跑**同一镜像**(API + Worker),共用一个 Upstash Redis;用户只访问**主域名**。
|
||||
|
||||
### 应用 env(全站一致)
|
||||
|
||||
```env
|
||||
PUBLIC_BASE_URL=https://你的主域名
|
||||
LINUXDO_REDIRECT_URI=https://你的主域名/api/v1/auth/callback
|
||||
REDIS_URL=rediss://...
|
||||
COOKIE_SECURE=true
|
||||
WORKER_ENABLED=true
|
||||
```
|
||||
|
||||
### 每节点不同
|
||||
|
||||
```env
|
||||
WORKER_ID=node-01 # 必填且唯一
|
||||
NODE_LABEL=cn-east-1a # 可选,管理后台展示
|
||||
NODE_REGION=cn-east # 可选
|
||||
```
|
||||
|
||||
**不要**给每台设不同的 `PUBLIC_BASE_URL`。源站直连地址(IP:8787)只写在 Cloudflare Worker 的 `ORIGINS`,见 `cloudflare-worker/README.md`。
|
||||
|
||||
### 部署步骤摘要
|
||||
|
||||
1. 各机:`docker run ... --env-file .env -e WORKER_ID=node-0N -p 8787:8787 wechat-ai`
|
||||
2. 配置并部署 `cloudflare-worker`,`ORIGINS=http://ip1:8787,http://ip2:8787,...`
|
||||
3. 主域名绑到 Worker
|
||||
4. 打开 `/admin` → **节点**:应看到各 `WORKER_ID` 心跳与租约 bot 数
|
||||
|
||||
| 探活 | 路径 |
|
||||
|------|------|
|
||||
| Docker / 轻量 | `GET /health` |
|
||||
| LB 就绪(含 Redis) | `GET /health/ready` |
|
||||
|
||||
管理 API:`GET /api/v1/admin/nodes`(Cookie 管理员)。
|
||||
|
||||
扫码加机器人会话状态在 Redis,HTTP 无需粘性会话。
|
||||
|
||||
### 租约自动再平衡(rebalance)
|
||||
|
||||
多节点默认 **开启**:租约偏多的进程会周期性 **主动释放** 多余 lease(不 pause bot),空闲节点下一轮 `claim` 捡走,使各节点 bot 数接近均分。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `REBALANCE_ENABLED` | `true` | 设为 `false` 关闭(租约粘在首占节点) |
|
||||
| `REBALANCE_INTERVAL_SEC` | `60` | 同一进程两次 shed 最小间隔 |
|
||||
| `REBALANCE_SLACK` | `2` | 允许高出均分多少个再释放 |
|
||||
| `REBALANCE_MAX_PER_TICK` | `50` | 每次最多释放数(避免瞬间空窗过大) |
|
||||
|
||||
日志关键字:`[worker] rebalance shed N bot(s)`。约 `ceil(超额 / 50)` 分钟内收敛。
|
||||
|
||||
### 强制下线节点
|
||||
|
||||
管理后台 **节点** 页 → **强制下线**:
|
||||
|
||||
1. 写入 Redis fence(`wa:worker:{id}:fence`)
|
||||
2. 释放该 WORKER_ID 下全部 bot 租约
|
||||
3. 从 `wa:workers:reg` 移除
|
||||
|
||||
目标进程下一轮 reconcile 发现 fence 后停止认领;其他节点 claim 这些 bot。
|
||||
**解除下线** 后该节点可重新加入。
|
||||
|
||||
这**不会** `docker stop`;若要从 LB 摘流量,还要从 Cloudflare Worker `ORIGINS` 去掉该源站。
|
||||
@@ -0,0 +1,113 @@
|
||||
# E2E 验收清单
|
||||
|
||||
环境:已配置 `.env`、完成 `db:migrate` + `db:seed`、至少一个 Bot 登录、LLM 可用。
|
||||
|
||||
## A. 安装与诊断
|
||||
|
||||
- [ ] `pnpm install` 成功
|
||||
- [ ] `pnpm db:migrate` / `pnpm db:seed` 成功
|
||||
- [ ] `pnpm diag` 在配置正确时 PASS(弱 token / 无 bot 仅警告)
|
||||
- [ ] `pnpm test` 全部通过
|
||||
|
||||
## B. Admin
|
||||
|
||||
- [ ] 打开 `http://127.0.0.1:8787/admin` 可加载
|
||||
- [ ] 填入正确 Token 后总览卡片有数据
|
||||
- [ ] 可见默认人设 `catgirl` / `girlfriend`
|
||||
- [ ] 可创建新人设并出现在列表
|
||||
- [ ] 可发布人设新版本
|
||||
|
||||
## C. Bot 与多人
|
||||
|
||||
- [ ] `pnpm ilink:login` 扫码成功,Admin → Bot 可见账号
|
||||
- [ ] 微信用户 A 发消息后,Admin → 用户出现 pending
|
||||
- [ ] 批准 A 后,A 收到角色扮演回复(默认猫娘风格)
|
||||
- [ ] 用户 B 同时发消息:会话与记忆不与 A 串味
|
||||
- [ ] Admin 将 A 分配为女友人设,A 回复风格变化
|
||||
- [ ] A 发送 `/角色 xxx` 收到「仅后台分配」提示
|
||||
- [ ] 未批准用户收到开通引导(或被忽略)
|
||||
|
||||
## D. 记忆
|
||||
|
||||
- [ ] A 陈述偏好(如「我叫小明」)后,多轮后记忆列表出现相关事实(每 N 轮抽取)
|
||||
- [ ] 重启 `pnpm dev` 后 A 的记忆仍在
|
||||
- [ ] Admin 重置 A 记忆后列表清空
|
||||
|
||||
## E. 多 Bot(可选)
|
||||
|
||||
- [ ] 第二次 `pnpm ilink:login -- --name bot2` 增加第二个 Bot
|
||||
- [ ] 两个 Bot 的 peer 互不混淆
|
||||
|
||||
## F. 限流与体验
|
||||
|
||||
- [ ] 同一用户短时间连发超限,收到「发得太快」提示
|
||||
- [ ] typing 调用失败不影响正常回复(best-effort)
|
||||
|
||||
### F.1 输入状态(getconfig + status 1/2)
|
||||
|
||||
- [ ] 发消息后微信显示「对方正在输入中」
|
||||
- [ ] 回复送达后指示器**消失**(不是等服务端超时)
|
||||
- [ ] 多气泡回复期间指示器在气泡之间持续显示
|
||||
- [ ] 被限流 / 未批准被拒 / 用户互聊中继后,指示器也停止
|
||||
- [ ] 抓包确认每个 peer 只有一次 `getconfig`(票据缓存生效),之后只有 `sendtyping`
|
||||
- [ ] `sendtyping` 携带 `typing_ticket` 与 `status`
|
||||
- [ ] 人为让 `getconfig` 失败(改 baseUrl)→ 回复仍正常送达
|
||||
|
||||
### F.2 入站媒体
|
||||
|
||||
- [ ] `VISION_ENABLED=false` 时发图 → 回「看不了图片」,且**没有**产生 LLM 调用
|
||||
- [ ] `VISION_ENABLED=true` 且模型支持视觉时发图 → 回复据实描述图中内容
|
||||
- [ ] 发图 + 文字说明 → 两者一起进入同一轮对话
|
||||
- [ ] 一条消息发 3+ 张图 → 只识别 `VISION_MAX_IMAGES` 张,其余按「看不了」告知
|
||||
- [ ] 超过 `INBOUND_MEDIA_MAX_BYTES` 的图 → 降级为「看不了」,回复不失败
|
||||
- [ ] 发视频 / 文件 → 按类型回话,且模型**没有**编造内容
|
||||
- [ ] 发带转写的微信语音 → 按文字正常对话,不出现「我听不到」
|
||||
- [ ] `VOICE_TRANSCRIPT_ENABLED=false` 后同一条语音 → 回「没听清,麻烦打字」,且不走 LLM
|
||||
- [ ] 该开关与 `VISION_ENABLED` 互不影响(图片关、语音开是默认组合)
|
||||
- [ ] 发图后下一轮追问 → 历史里是 `[图片]` 占位符,上下文没丢
|
||||
- [ ] chatflow 模式人设收到图 → 看到 `[图片]` 占位符,不报错
|
||||
|
||||
## G. 安全
|
||||
|
||||
- [ ] 无 Token 访问 `/api/v1/*` 返回 401
|
||||
- [ ] Token 不出现在审计全文中;仅存 Redis `wa:bot:{id}:creds`
|
||||
|
||||
## H. 用户对话(@LINUX DO)
|
||||
|
||||
- [ ] 用户中心可生成绑定码;微信 `/绑定 CODE` 成功;`/我的身份` 显示用户名
|
||||
- [ ] 双方均绑定且各聊过机器人后,A 发 `@B用户名`,B 收到请求
|
||||
- [ ] B `/同意` 后互发文字,内容带 `[用户名]` 前缀
|
||||
- [ ] `/断开` 后普通消息恢复 AI 角色扮演
|
||||
- [ ] 未绑定 / 无 context_token 的目标:发起方收到明确错误提示
|
||||
- [ ] `hello @user` 不触发请求(整条消息匹配)
|
||||
|
||||
## I. AI 网关(自定义模型 / 联网搜索)
|
||||
|
||||
前置:已部署 `huggingface/wechat-ai-tools`,主站配置 `TOOLS_BASE_URL` / `TOOLS_API_KEY` / `LLM_PROVIDER_SECRET`。
|
||||
|
||||
- [ ] `/app` → **我的模型**:可添加连接(名称 / Base URL / Key / 模型),列表显示掩码 Key
|
||||
- [ ] 可停用 / 启用 / 删除连接
|
||||
- [ ] 未配置 `LLM_PROVIDER_SECRET` 时页面提示明确、不落库
|
||||
- [ ] 人设选择「我的连接」后对话正常;抓包确认主站 **未**直连该 base_url(仅打 TOOLS)
|
||||
- [ ] 人设开启联网搜索 + `WEB_SEARCH_ENABLED=true`:问时事类问题触发 `/v1/web-search`
|
||||
- [ ] 审计日志 / 应用日志中 **不含** upstream api_key 明文
|
||||
- [ ] Fork 他人人设:新人设 `llmProviderId` 为空(不继承作者密钥)
|
||||
|
||||
## J. Chatflow
|
||||
|
||||
- [ ] 人设编辑器切「Chatflow 流程」保存后,卡片显示 Chatflow 徽章
|
||||
- [ ] `/chatflow?persona=<id>` 可加载(未保存图时显示默认 start→llm→answer)
|
||||
- [ ] 拖拽节点、连线、编辑属性后「保存并启用」成功
|
||||
- [ ] 非本人人设打开为只读(保存按钮禁用)
|
||||
- [ ] 微信对该人设发消息,回复由流程产出
|
||||
- [ ] 网页试聊 chatflow 人设可正常回复(走平台模型,不消耗作者额度)
|
||||
- [ ] `search` 节点在人设未开搜索 / 未配 TOOLS 时报错清晰
|
||||
- [ ] `http` 节点填非 allowlist 域名 → 被拒(`http_blocked`)
|
||||
- [ ] chatflow 人设不触发主动联系(skip: `chatflow_no_proactive`)
|
||||
- [ ] 图校验:无 answer / 多个 start / 超 `CHATFLOW_MAX_NODES` → 400
|
||||
|
||||
## 验收结论
|
||||
|
||||
全部关键项(A–D、F–G)通过即可判定:**系统已完成并可验收**。
|
||||
E 为多 Bot 增强项;H 为用户对话增强项;真机扫码依赖本机微信与 iLink 可用性。
|
||||
I / J 需先部署 HF 工具服务(`docs/ai-gateway.md`、`docs/chatflow.md`);未部署时这两节整体跳过。
|
||||
@@ -0,0 +1,55 @@
|
||||
# LINUX DO OAuth 接入
|
||||
|
||||
## 1. 申请应用
|
||||
|
||||
1. 打开 [LINUX DO Connect](https://connect.linux.do/)(或社区应用管理入口)
|
||||
2. 创建 OAuth 应用,回调地址填:
|
||||
|
||||
```text
|
||||
http://你的域名/api/v1/auth/callback
|
||||
```
|
||||
|
||||
本地开发示例:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8787/api/v1/auth/callback
|
||||
```
|
||||
|
||||
3. 拿到 `client_id` / `client_secret`
|
||||
|
||||
## 2. 配置 `.env`
|
||||
|
||||
```env
|
||||
LINUXDO_CLIENT_ID=...
|
||||
LINUXDO_CLIENT_SECRET=...
|
||||
LINUXDO_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/callback
|
||||
LINUXDO_ADMIN_IDS=你的LINUXDO数字ID,你的用户名
|
||||
REDIS_URL=redis://:密码@远端:6379/0
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
`LINUXDO_ADMIN_IDS`:匹配 OAuth 返回的 **用户 id** 或 **username** 即视为管理员。
|
||||
|
||||
## 3. 流程
|
||||
|
||||
1. 用户访问 `/app` → 可用 **用户名密码** 登录,或点「LINUX DO 登录」
|
||||
2. LINUX DO 跳转 `connect.linux.do` 授权(scope:`openid profile`)
|
||||
3. 回调 `/api/v1/auth/callback` 写 Redis session cookie
|
||||
- **OAuth 新用户不需要邀请码**(与本地注册不同)
|
||||
4. 本地注册:需好友分享的一次性邀请码/链接(`/app?invite=CODE`),成功后同样写 session cookie
|
||||
5. 普通用户:管理自己的机器人(添加/删除、批准 peer、分配人设)、生成邀请
|
||||
6. 管理员:`/admin` 仪表盘;可配置「每 X 小时可生成 N 个邀请」、封禁/删除用户、停用/删除机器人
|
||||
|
||||
未配置 `LINUXDO_ADMIN_IDS` 时,**第一个成功注册/登录的用户**自动成为管理员(`FIRST_USER_IS_ADMIN=true`)。
|
||||
|
||||
封禁用户后 OAuth 仍可在 LINUX DO 授权,但 callback / 密码登录 / 会话校验会返回 `user_banned`。
|
||||
|
||||
## 4. 协议端点(默认 / OIDC Discovery)
|
||||
|
||||
| 用途 | URL |
|
||||
|------|-----|
|
||||
| Discovery | `https://connect.linux.do/.well-known/openid-configuration` |
|
||||
| 授权 | `https://connect.linux.do/oauth2/authorize` |
|
||||
| Token | `https://connect.linux.do/oauth2/token` |
|
||||
| 用户信息 | `https://connect.linux.do/api/user` |
|
||||
| 支持 scope | `openid` `profile` `email` |
|
||||
+289
@@ -0,0 +1,289 @@
|
||||
# WeChat-AI 运维手册
|
||||
|
||||
## 1. 首次部署清单
|
||||
|
||||
### 1.1 依赖
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm
|
||||
- **Upstash Redis**(`rediss://...`)
|
||||
- **LINUX DO OAuth** 应用
|
||||
- LLM API Key(OpenAI 兼容)
|
||||
|
||||
### 1.2 `.env` 必填
|
||||
|
||||
```env
|
||||
REDIS_URL=rediss://default:密码@xxxx.upstash.io:6379
|
||||
LLM_API_KEY=...
|
||||
LLM_BASE_URL=...
|
||||
LLM_MODEL=...
|
||||
|
||||
LINUXDO_CLIENT_ID=...
|
||||
LINUXDO_CLIENT_SECRET=...
|
||||
LINUXDO_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/callback
|
||||
# 可选:数字 ID 或用户名;留空时「第一个登录的用户」自动成为管理员
|
||||
LINUXDO_ADMIN_IDS=
|
||||
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:8787
|
||||
```
|
||||
|
||||
LINUX DO 应用回调地址必须与 `LINUXDO_REDIRECT_URI` **完全一致**。
|
||||
|
||||
### 1.4 多节点运维要点
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 共享 | 所有节点同一 `REDIS_URL`、`PUBLIC_BASE_URL`(主域名)、OAuth 回调 |
|
||||
| 每机 | 唯一 `WORKER_ID`;可选 `NODE_LABEL` / `NODE_REGION` |
|
||||
| 入口 | 主域名 → `cloudflare-worker` 的 `ORIGINS`(源站 IP:端口) |
|
||||
| 后台 | `/admin` → **节点**:进程心跳与 bot 租约;**不显示**源站 URL |
|
||||
| 扫码 | 登录会话在 Redis,无需粘性会话 |
|
||||
| 探活 | LB 用 `/health/ready`;Docker 可用 `/health` |
|
||||
| 下线 | 从 Worker `ORIGINS` 移除并 deploy;停容器后租约 TTL 过期自动转移 |
|
||||
| 扩容 | 新机起容器 + 更新 `ORIGINS`;bot 由租约自动分片 |
|
||||
| 日常代码热修 | `pnpm release:pack` 或 `pnpm docker:build` → `/admin` 上传通道包 → 节点「更新」 |
|
||||
| 基础镜像变更 | 仍需各机 `docker build` / 拉新镜像 |
|
||||
|
||||
进程内 HTTP 限流为单机计数;生产建议在 Cloudflare 对 `/api/v1/auth/*` 做 Rate Limiting。
|
||||
|
||||
### 1.3 启动
|
||||
|
||||
```powershell
|
||||
cd F:\Code-Other-4\WeChat-AI
|
||||
pnpm install
|
||||
pnpm diag
|
||||
pnpm db:seed
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
| 页面 | URL |
|
||||
|------|-----|
|
||||
| 用户中心 | http://127.0.0.1:8787/app |
|
||||
| 管理后台 | http://127.0.0.1:8787/admin |
|
||||
|
||||
## 2. 用户操作流程
|
||||
|
||||
1. 打开 `/app` → **LINUX DO 登录**
|
||||
2. **扫码添加微信机器人**(ClawBot)
|
||||
3. 微信好友私聊机器人 → 在用户中心 **批准**
|
||||
4. 可选:分配人设(猫娘 / 女友)
|
||||
5. 正常聊天(AI 会分多条气泡回复)
|
||||
|
||||
### 2.0 输入状态(「对方正在输入中」)
|
||||
|
||||
两步协议,`packages/ilink` 内部完成,无需配置:
|
||||
|
||||
1. `POST /ilink/bot/getconfig { ilink_user_id, context_token }` → `typing_ticket`
|
||||
2. `POST /ilink/bot/sendtyping { ilink_user_id, typing_ticket, status }` — `status: 1` 开始,`status: 2` 停止
|
||||
|
||||
票据**按用户缓存**(服务端有效期约 24h,本地按 20h 过期后自动重取),所以每个 peer 大约一天一次 `getconfig`;并发的多次输入调用会合并成一次取票。
|
||||
|
||||
指示器的生命周期:收到消息立刻开始 → 调模型前再次开始 → 多气泡之间每条前再次开始 → **`handleJob` 的 `finally` 统一停止**。回复、拒绝、限流、用户互聊中继、异常,任何出口都会停止,不会把「正在输入中」留在对方屏幕上。主动联系发完也会停止。
|
||||
|
||||
排查:`getconfig` 失败时会退化成不带票据的 `sendtyping`(指示器可能不显示,但**绝不影响回复**);票据被服务端提前失效时会强制重取并重试一次。
|
||||
|
||||
### 2.0.1 图片理解(入站 Vision)
|
||||
|
||||
默认**关闭**,且由 `apps/api/src/shipped-defaults.test.ts` 守着——`VISION_ENABLED` 只认精确的 `"true"`,`"1"` / `"yes"` / `"TRUE"` 都不算开。关闭状态下:不去 CDN 取字节、不调任何模型,收到图片直接回一句按类型区分的话。
|
||||
|
||||
> **出站语音 / 视频 / 文件**:`sendVoice` / `sendVideo` / `sendFile` 已在 `packages/ilink` 实现并有测试,但**没有任何调用点**,回复路径不会用到。要真正让角色发语音,还差两段:TTS 产出音频、再转成微信用的 SILK 编码(TTS 给的是 mp3/wav/opus,直接发大概率放不出来)。另外出站 voice 的 item type 只在入站验证过,需真机确认。
|
||||
|
||||
|
||||
**关键点:人设模型不需要支持视觉。** 默认的 `caption` 模式先让一个识图端点把图片转成文字描述,只把这段**文字**交给人设模型——所以 `deepseek-v4-flash` 这类纯文本模型照样能"看图"。描述还会写进对话历史,隔几轮再问"刚那张图里的猫呢"仍然接得上。
|
||||
|
||||
| 模式 | 人设模型要求 | 说明 |
|
||||
|------|--------------|------|
|
||||
| `caption`(默认) | 无 | 识图端点出描述 → 文字进人设模型 |
|
||||
| `direct` | **必须支持视觉** | 图片原样交给人设模型;不支持就直接报错 |
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `VISION_ENABLED` | `false` | 总闸。关闭时不下载、不调模型 |
|
||||
| `VISION_MODE` | `caption` | 见上表 |
|
||||
| `VISION_BASE_URL` / `VISION_API_KEY` | 空 | 识图端点。**env-only**(与平台 LLM 同信任级,直连不走 tools)。留空则复用 `LLM_BASE_URL` / `LLM_API_KEY` |
|
||||
| `VISION_MODEL` | 空 | **必填**,留空则所有图片按「看不了」处理。对「我的模型连接」无效(那条链路只认连接里的模型名) |
|
||||
| `VISION_CAPTION_MAX_TOKENS` | `300` | 描述长度上限 |
|
||||
| `VISION_MAX_IMAGES` | `2` | 单条消息最多识别几张(每张都实打实花 token) |
|
||||
| `INBOUND_MEDIA_MAX_BYTES` | `4194304` | 单个附件下载上限(解密后原始大小;base64 再涨约 1/3) |
|
||||
|
||||
`caption` 模式的成本:每张图多一次识图调用(记在机器人主人账上),人设模型那一轮只多几十个 token 的描述文字——比把 4MB base64 塞进上下文便宜得多。识图失败会降级成「看不了」,**不会让回复失败**。
|
||||
|
||||
行为说明:
|
||||
|
||||
- **只有图片会被下载**。语音/视频/文件的字节拿来也喂不进模型,所以根本不去 CDN 取——微信语音是 SILK/AMR,没有 OpenAI 兼容端点收。
|
||||
- `caption` 模式下人设模型**收不到字节**,只收到方括号里的描述;提示词会要求它「当作亲眼所见,但只依据描述内容,不要往外扩写」。
|
||||
- **语音靠微信自带转写**(`VOICE_TRANSCRIPT_ENABLED`,**默认开**):转写文字随入站消息一起到,`extractText` 把它并入文本,这类语音当文字处理,不再额外列为附件(否则会告诉模型「你听不到」它正要读的内容)。这个开关与 `VISION_ENABLED` **互不相干**——用转写不花钱、不需要任何模型。设 `false` 后语音一律回「没听清,麻烦打字」。
|
||||
- 模型看不到的附件仍会写进系统提示,并明确要求**不许猜测内容**——否则人设会张口就编视频里有什么。
|
||||
- 整条消息只有看不了的媒体且没有文字时,直接回一句按类型区分的话(不调模型,不花钱)。
|
||||
- 附件下载失败不会拖垮回复,降级成「只告知存在」。
|
||||
- Chatflow 模式的人设看到的是 `[图片]` 占位符——图执行器没有多模态节点。
|
||||
- 历史里存的也是 `[图片]` 占位符(字节不落库),所以下一轮追问「所以呢?」时上下文仍知道发过图。
|
||||
|
||||
### 2.1 智能体主动找用户(空闲触发)
|
||||
|
||||
默认**全局关闭**。开启后,空闲一段时间的用户可被角色主动联系。
|
||||
|
||||
1. `.env` 设置 `PROACTIVE_ENABLED=true` 并重启服务
|
||||
2. 在 `/app` 机器人卡片中打开 **「主动找用户聊天」** 并保存参数(空闲小时 / 间隔 / 每日上限 / 安静时段)
|
||||
3. 在用户列表对该 peer 勾选 **「允许主动」**(仅已批准用户)
|
||||
4. 对方须**曾经聊过**(系统存有 iLink `context_token`);从未发过消息的人无法冷启动主动触达
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `PROACTIVE_ENABLED` | `false` | 全局总闸 |
|
||||
| `PROACTIVE_IDLE_HOURS` | `12` | 默认空闲阈值(小时) |
|
||||
| `PROACTIVE_MIN_INTERVAL_HOURS` | `24` | 两次主动最小间隔 |
|
||||
| `PROACTIVE_MAX_PER_DAY` | `1` | 每用户每日上限 |
|
||||
| `PROACTIVE_QUIET_HOURS` | `0-8` | 安静时段(上海时区);空字符串关闭 |
|
||||
| `PROACTIVE_SCAN_INTERVAL_SEC` | `300` | 扫描周期 |
|
||||
| `PROACTIVE_MAX_PER_SCAN` | `10` | 每轮最多发送数 |
|
||||
|
||||
### 2.2 用户之间通过 @LINUX DO 用户名对话
|
||||
|
||||
机器人可中继两个已绑定用户的文字消息(**不经过 LLM**)。
|
||||
|
||||
1. 双方均用 LINUX DO 登录 `/app`
|
||||
2. 用户中心 → **用户对话** → 生成绑定码 → 微信给任意机器人发 `/绑定 ABC123`
|
||||
3. 双方至少各给机器人发过一次消息(写入 `context_token`,否则不可达)
|
||||
4. A 发送整条消息 `@对方用户名` → B 收到请求 → `/同意`
|
||||
5. 会话中直接发文字(前缀 `[用户名]`);`/断开` 结束;空闲约 30 分钟自动结束
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `/绑定 CODE` | 认领绑定码 |
|
||||
| `/解绑` | 解除绑定 |
|
||||
| `/我的身份` | 查看绑定与会话状态 |
|
||||
| `@username` | 发起对话请求(整条消息) |
|
||||
| `/同意` `/拒绝` | 处理入站请求 |
|
||||
| `/取消请求` | 取消自己发出的请求 |
|
||||
| `/断开` | 结束当前用户对话 |
|
||||
| `/拉黑 用户名` | 拉黑(无法再互相 @) |
|
||||
| `/取消拉黑 用户名` | 移出黑名单 |
|
||||
| `/黑名单` | 查看黑名单 |
|
||||
|
||||
### 2.3 管理后台广播(全站 / 单 bot 推送)
|
||||
|
||||
管理员在 `/admin` → **广播** 可向微信用户推送**纯文本**(系统更新、通知等)。
|
||||
|
||||
1. 登录管理后台 → 侧栏 **广播**
|
||||
2. 撰写文本,选择范围:
|
||||
- **全部机器人**:每个 bot 下全部有 `context_token` 的 peer(含未批准)
|
||||
- **指定机器人**:多选 bot,再向其可触达 peer 群发
|
||||
- **指定 Peer**:选 bot → 勾选 peers
|
||||
3. **预估人数** → 确认后创建异步任务;列表可看进度 / 取消
|
||||
4. 机器人详情页也可 **向此 bot 群发** / 对某 peer **发消息**(跳转并预填)
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `BROADCAST_INTERVAL_MS` | `200` | 两条消息间隔(限速,降低 iLink 风险) |
|
||||
| `BROADCAST_MAX_TEXT` | `2000` | 文本最大长度 |
|
||||
| `BROADCAST_HISTORY` | `100` | 保留的历史任务数 |
|
||||
|
||||
约束:
|
||||
|
||||
- 从未给 bot 发过消息的用户**无法送达**(无 context_token)
|
||||
- 需 Worker 开启(`WORKER_ENABLED` 默认 true);Worker 关则任务一直 `pending`
|
||||
- 已发出的消息**无法撤回**;取消只停止剩余目标
|
||||
- 不进 LLM / 人设记忆
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `P2P_ENABLED` | `true` | 总开关(`false` 关闭) |
|
||||
| `P2P_BIND_CODE_TTL_SEC` | `600` | 绑定码有效期 |
|
||||
| `P2P_REQUEST_TTL_SEC` | `300` | 对话请求有效期 |
|
||||
| `P2P_SESSION_IDLE_SEC` | `1800` | 会话空闲超时 |
|
||||
| `P2P_RELAY_MAX_CHARS` | `500` | 单条中继最大字数 |
|
||||
| `P2P_MAX_REQUESTS_PER_DAY` | `20` | 每 peer 每日发起 `@` 次数 |
|
||||
|
||||
跨 Bot 可用:A 绑在 Bot1、B 绑在 Bot2,中继走目标方 bot 凭证 + 其 `context_token`。
|
||||
|
||||
### 2.4 记忆检索与时间工具
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `MEMORY_TOP_K` | `12` | 记忆超过全量阈值时注入条数 |
|
||||
| `MEMORY_FULL_INJECT_MAX` | `20` | ≤ 此数时仍全量注入(与旧行为一致) |
|
||||
| `MEMORY_MAX_ITEMS` | `100` | 每个 peer+人设最多存储条数 |
|
||||
| `TIME_TOOL_ENABLED` | `true` | 允许模型调用 `get_current_time` |
|
||||
| `TIME_TOOL_TIMEZONE` | `Asia/Shanghai` | 默认时区 |
|
||||
|
||||
用户中心 → 微信用户行 → **记忆**:查看 / 删单条 / 清空。
|
||||
|
||||
日志关键字:`[proactive]`(`action=send|skip|lock_miss|no_ctx`);`[broadcast]`(管理广播任务)。
|
||||
|
||||
### 2.5 自定义模型与联网搜索(经 HF 工具服务)
|
||||
|
||||
主站**只**直连管理员配置的平台 LLM;用户自定义 API 与联网搜索一律经
|
||||
`huggingface/wechat-ai-tools` 出站。
|
||||
|
||||
| 环境变量 | 说明 |
|
||||
|----------|------|
|
||||
| `TOOLS_BASE_URL` | 工具服务根地址(HF Space / 自托管容器) |
|
||||
| `TOOLS_API_KEY` | 与工具服务共享的调用密钥 |
|
||||
| `LLM_PROVIDER_SECRET` | 加密用户保存的自定义 API Key(必填,否则无法添加连接) |
|
||||
| `WEB_SEARCH_ENABLED` | 全局搜索开关;人设还需自行开启 |
|
||||
|
||||
用户路径:`/app` → **我的模型** 添加连接 → 人设编辑器里选择该连接 / 勾选联网搜索。
|
||||
|
||||
排查:
|
||||
- `pnpm diag` 会探测 `TOOLS_BASE_URL/health`
|
||||
- `/health/ready` 在 `WEB_SEARCH_ENABLED=true` 且 tools 不可达时返回 503(结果缓存 15s)
|
||||
- 日志与审计**不记录** upstream api_key
|
||||
|
||||
### 2.6 Chatflow
|
||||
|
||||
人设可切 `chatflow` 模式,用 `/chatflow?persona=<id>` 编排流程图。
|
||||
|
||||
| 环境变量 | 默认 | 说明 |
|
||||
|----------|------|------|
|
||||
| `CHATFLOW_HTTP_ALLOWLIST` | 空 | http 节点额外允许的 host(tools host:port 始终允许);`*` = 任意公网,内网/云元数据仍拦 |
|
||||
| `CHATFLOW_MAX_STEPS` | `32` | 单次执行最大步数 |
|
||||
| `CHATFLOW_MAX_NODES` | `40` | 图最大节点数 |
|
||||
|
||||
要点:chatflow 人设**不参与主动联系**;试聊强制走平台模型。详见 `docs/chatflow.md`。
|
||||
|
||||
## 3. 管理员
|
||||
|
||||
- 配置了 `LINUXDO_ADMIN_IDS`:名单内用户登录后为管理员
|
||||
- 未配置:`FIRST_USER_IS_ADMIN` 默认 true,**首个登录用户**自动管理员
|
||||
- 打开 `/admin`:今日 Token、用户数、机器人、审计、**广播**
|
||||
|
||||
## 4. Worker 与规模
|
||||
|
||||
API 与 iLink Worker **同进程**(单镜像 / 单容器)。
|
||||
|
||||
- 收消息:`getUpdates` 长轮询(每 bot 一路,有 `MAX_BOTS_PER_WORKER` 上限)
|
||||
- 回消息:进程内 inbox 队列 + `REPLY_CONCURRENCY` 并发,避免 LLM 堵住轮询
|
||||
- 日志出现 `at capacity`:提高 `MAX_BOTS_PER_WORKER`,或同镜像多副本分片
|
||||
|
||||
## 5. 故障
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| Redis Connection closed / 占位符 | 填真实 Upstash `rediss://` URL |
|
||||
| OAuth redirect_uri mismatch | 控制台回调与 `.env` 一致 |
|
||||
| OAuth userinfo 失败 | 确认 scope `openid profile`(已默认) |
|
||||
| 无管理员 | 清空 Redis 用户或设置 `LINUXDO_ADMIN_IDS` 后重登 |
|
||||
| 微信 session expired | 在用户中心删除机器人后重新扫码 |
|
||||
| 无 AI 回复 | 检查 LLM_API_KEY、用户是否批准 |
|
||||
| `at capacity` / 部分 bot 不 poll | 调高 `MAX_BOTS_PER_WORKER` |
|
||||
| 自定义模型报 TOOLS_BASE_URL required | 部署工具服务并配置 `TOOLS_BASE_URL` / `TOOLS_API_KEY` |
|
||||
| 添加模型连接 503 | 未设置 `LLM_PROVIDER_SECRET` |
|
||||
| chatflow `http_blocked` | 目标域名不在 tools host / `CHATFLOW_HTTP_ALLOWLIST`;或命中内网段(报文里带 `private host blocked` 与原因);或重定向跳进内网 |
|
||||
| chatflow http 节点全部 `resolves to ...` 被拦 | 本机 DNS 在劫持解析(把所有域名答成 CGNAT/`198.18` 之类占位地址)。先 `nslookup` 确认,再排查 resolver,不要直接放宽白名单 |
|
||||
| chatflow `search_disabled` | 人设未开搜索,或 `WEB_SEARCH_ENABLED` / TOOLS 未配 |
|
||||
| `/health/ready` 503 但 Redis 正常 | tools 网关不可达(见 §2.5) |
|
||||
|
||||
## 6. 备份
|
||||
|
||||
- Upstash:控制台备份 / 导出(按套餐)
|
||||
- Redis `wa:bot:{id}:creds`(Bot token,敏感)
|
||||
- `.env`(勿提交 Git)
|
||||
|
||||
## 7. 文档
|
||||
|
||||
- Upstash:`docs/upstash-redis.md`
|
||||
- OAuth:`docs/oauth-linuxdo.md`
|
||||
- API:`docs/admin-api.md`
|
||||
- AI 网关(主站↔HF 契约):`docs/ai-gateway.md`
|
||||
- Chatflow:`docs/chatflow.md`
|
||||
@@ -0,0 +1,145 @@
|
||||
# 运行时配置(管理面板)
|
||||
|
||||
`/admin` → **设置** 页可以改绝大多数原本只能写在 `.env` 里的配置。
|
||||
|
||||
## 优先级
|
||||
|
||||
```
|
||||
.env(进程启动时读一次) ← 默认值
|
||||
↓ 被覆盖
|
||||
wa:settings:runtime(Redis JSON) ← 管理面板写入
|
||||
↓
|
||||
生效配置(进程内的同一个 cfg 对象)
|
||||
```
|
||||
|
||||
- Redis 里**没有**某一项时,就用 `.env` 的值。
|
||||
- 面板里把某项改回 `.env` 默认值,会**删除**该覆盖 —— 以后再改 `.env` 又能生效。
|
||||
- 「全部恢复默认」删除整份 Redis 覆盖文档。
|
||||
|
||||
## 传播
|
||||
|
||||
每个节点每 **5 秒** 读一次 `wa:settings:runtime`,有变化才写入本进程配置并推给各服务。
|
||||
所以:改动在**本节点立即生效**,其他节点**最多 5 秒**。没有用 pub/sub —— 一次 GET
|
||||
相对请求路径上的 Redis 流量可以忽略,也省掉一条订阅连接。
|
||||
|
||||
## 范围
|
||||
|
||||
**不可配置(env-only)**,因为它们要么在能读 Redis 之前就得正确,要么改了会破坏已有数据:
|
||||
|
||||
| 变量 | 原因 |
|
||||
|------|------|
|
||||
| `REDIS_URL` | 鸡生蛋:覆盖本身存在 Redis 里 |
|
||||
| `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` | 平台模型凭证 |
|
||||
| `LLM_PROVIDER_SECRET` | 改了之后所有用户已加密的自定义模型 key 全部解不开 |
|
||||
| `WECHAT_AI_TOKEN` / `LINUXDO_ADMIN_IDS` | 权限根,改错会把自己锁在外面 |
|
||||
| `SESSION_COOKIE_NAME` / `COOKIE_SECURE` | 改了当场踢掉所有会话 |
|
||||
| `PUBLIC_BASE_URL` / `CORS_ORIGINS` | 站点自身 URL 与跨域白名单 |
|
||||
| `WECHAT_AI_HOST` / `WECHAT_AI_PORT` | 监听端口在 `listen()` 时固定 |
|
||||
|
||||
其余全部在面板里,包括 `TOOLS_BASE_URL` / `TOOLS_API_KEY`(密钥在 UI 里只显示掩码)。
|
||||
|
||||
`apps/api/src/runtime-config.test.ts` 有一条守卫用例:**任何新增的 AppConfig 字段**要么进
|
||||
`SETTING_SPECS`,要么进那份 env-only 名单,否则测试直接失败。
|
||||
|
||||
`DEFAULT_PERSONA_SLUG` 刻意不在面板里:`cfg.defaultPersonaSlug` 在代码里没有任何消费方,
|
||||
默认人设走的是数据库 `is_default`(管理台「人设」→ 设默认)。放进面板只会得到一个
|
||||
「保存成功但什么都没发生」的控件。
|
||||
|
||||
## 需重启的项
|
||||
|
||||
绝大多数配置是热生效的。以下几项写入 Redis 后会打上橙色「需重启」徽章:
|
||||
|
||||
| 项 | 为什么 |
|
||||
|----|--------|
|
||||
| `WORKER_ENABLED` | `worker.start()` 只在启动时跑一次 |
|
||||
| `REPLY_CONCURRENCY` | 消费者池在 `start()` 一次性拉起,没有单消费者取消机制,缩容做不到 |
|
||||
| `LOG_LEVEL` | Fastify 创建实例时固化 logger |
|
||||
| `STICKER_MAX_BYTES` | 路由注册时固化 `bodyLimit` |
|
||||
|
||||
## 热生效是怎么做到的
|
||||
|
||||
`cfg` 对象**按引用**传给 `registerRoutes` 和各个服务,所以:
|
||||
|
||||
- **路由**:87 处 `ctx.cfg.*` 是每请求读的,原地改 `cfg` 就够了,零改动。
|
||||
- **在构造时快照选项的服务**:由 `runtime-config-apply.ts` 显式推送 ——
|
||||
`ChatService` / `TryChatService` / `BotWorkerManager` / `ActivityBus` 各有一个
|
||||
`applyRuntimeOptions`(或 `applyRuntimeConfig`)。
|
||||
|
||||
几个需要特殊处理、否则会静默失效的地方:
|
||||
|
||||
- `ChatService.webSearch` 原本在构造函数里判断一次,`WEB_SEARCH_ENABLED=false` 启动就永远
|
||||
是 `null`。现在按 tools 配置的指纹惰性重建。
|
||||
- `BotWorkerManager` 的 10 个 `readonly` 标量改成可写,且 setter 里复刻了构造函数的
|
||||
clamp —— 面板不能写进一个构造函数本来会拒绝的值(比如 `leaseRenewSec=0`)。
|
||||
- `leaseRenewSec` 还决定 `setInterval` 周期,改动时重新装载定时器。
|
||||
- `ProactiveScheduler.globalEnabled` 被 `start()` 闩住(早退且留下 `stopped=true`),
|
||||
false→true 必须重新进 `start()`,光改标志没用。
|
||||
- `setRedisCommandHook` 现在无条件安装,否则 `DATA_STREAM_ENABLED` 这个开关永远是死的。
|
||||
|
||||
## API
|
||||
|
||||
**整个页面(含只读)限超管。** 超管 = 仍是管理员的用户里 `created_at` 最早那位。
|
||||
|
||||
| Method | Path | 权限 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| GET | `/api/v1/admin/settings/runtime` | **超管** | 分组 + 全部项(当前值 / env 默认值 / 是否已覆盖 / 是否需重启)+ 交叉校验警告 |
|
||||
| PATCH | `/api/v1/admin/settings/runtime` | **超管** | `{ patch: {key: value}, reset: [key] }` |
|
||||
| POST | `/api/v1/admin/settings/runtime/reset` | **超管** | 删除全部覆盖 |
|
||||
|
||||
连读也限超管:这个面板能拿到 tools 网关密钥,能整个集群关掉 worker,
|
||||
payload 本身也把每一个调参项和当前生效值列了出来。普通管理员访问返回
|
||||
403 `super_admin_required`。
|
||||
|
||||
前端跟着服务端一起收口,和「节点 / 广播 / 数据流」用同一套三处门禁:
|
||||
`switchTab` 拦截并回落到「系统」页、`boot()` 隐藏侧栏按钮与 `<section>`、
|
||||
命令面板(Ctrl/⌘K)过滤掉该条目。
|
||||
|
||||
两个写操作都会记审计(`runtime_settings_updated` / `runtime_settings_reset`),
|
||||
含变更的 key 列表与需重启的 key。
|
||||
|
||||
密钥字段(`type: "secret"`)的约定:
|
||||
|
||||
- GET 只返回掩码 `••••••••`,永远不回真值
|
||||
- PATCH 传空字符串 = **不修改**
|
||||
- PATCH 传 `-` = **清空**
|
||||
- 把掩码原样回传会被忽略,不会变成字面值
|
||||
|
||||
## 交叉校验
|
||||
|
||||
保存时会返回警告(只提示,不改用户填的值):租约 TTL ≤ 续约间隔、延迟上下限倒挂、
|
||||
`memoryFullInjectMax < memoryTopK`、开了联网但没配 tools 网关、配了网关但密钥为空、
|
||||
二次过滤与模型直出 JSON 同时开启。
|
||||
|
||||
## Redis 读失败时的行为
|
||||
|
||||
「读不到」和「不存在」必须分开——把两者都当成「没有覆盖」会让一次网络抖动
|
||||
把整个节点悄悄退回 `.env`(此前被面板收紧的开关会瞬间放开)。所以:
|
||||
|
||||
- **轮询读失败** → 保持上一份已知配置不动,打一条日志,`refresh()` 返回 false。
|
||||
- **写路径读失败** → 直接拒绝,返回 **503**。绝不能基于失败的读做合并再写回,
|
||||
那会把其他所有覆盖一起抹掉。
|
||||
- **例外:全部恢复默认**。它本来就要丢弃旧文档,所以读不到时也允许执行 ——
|
||||
这是文档损坏时唯一的产品内自救路径。
|
||||
|
||||
## 取值受限的项
|
||||
|
||||
`SettingSpec.options` 声明闭集,面板渲染成下拉框,服务端对不在集合里的值直接拒绝
|
||||
(不做替换,免得把拼写错误藏起来)。目前只有 `LOG_LEVEL` 用到:pino 遇到未知级别会在
|
||||
Fastify 建实例时抛错,而这一项又是「需重启」,一个拼写错误会在下次重启时让每个节点
|
||||
反复崩溃,而且管理接口起不来、改不回去。
|
||||
|
||||
## 并发写
|
||||
|
||||
`patch()` 是读-改-写:读 Redis 当前文档 → 合并 → 整份写回。合并是**按文档**而不是按字段的,
|
||||
所以两个超管在两个节点同时保存,后写的那份会带着自己的改动覆盖掉前一份的全部改动。
|
||||
|
||||
为此写路径上加了一把短锁 `wa:settings:runtime:lock`(`SET NX EX 5`,最多重试 5 次 ×
|
||||
120ms),把整个集群的读-改-写串起来。锁只在写时用,读路径完全不碰它。
|
||||
拿不到锁时不阻塞管理员,仍然继续写 —— 锁是降低窗口,不是强一致保证;写操作是超管专属
|
||||
且极低频,这个取舍是刻意的。
|
||||
|
||||
## 多节点注意
|
||||
|
||||
`MAX_BOTS_PER_WORKER`、`LEASE_TTL_SEC`、`REBALANCE_SLACK` 参与的是**集群共享**的租约/再平衡
|
||||
协议。因为覆盖存在 Redis、所有节点读同一份,各节点最终看到的是同一组值 —— 但在 5 秒收敛
|
||||
窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。
|
||||
@@ -0,0 +1,95 @@
|
||||
# 使用 Upstash Redis
|
||||
|
||||
本项目通过 **ioredis + Redis 协议** 连接远端 Redis,兼容 [Upstash](https://upstash.com/)。
|
||||
|
||||
## 1. 创建数据库
|
||||
|
||||
1. 登录 [Upstash Console](https://console.upstash.com/)
|
||||
2. **Create Database** → 选区域(建议离你服务器近的,如 `ap-southeast-1`)
|
||||
3. 打开数据库 → **Connect** / **REST API & Redis**
|
||||
|
||||
## 2. 复制连接串
|
||||
|
||||
在 Connect 面板选 **ioredis / Node.js**,复制 **Redis URL**,形如:
|
||||
|
||||
```text
|
||||
rediss://default:[email protected]:6379
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 协议 | 必须是 **`rediss://`**(多一个 s = TLS) |
|
||||
| 用户名 | 一般为 `default` |
|
||||
| 密码 | Upstash 提供的 token,当作密码 |
|
||||
| 端口 | `6379` |
|
||||
|
||||
不要用 Upstash 的 **REST URL**(`https://xxx.upstash.io`)填到 `REDIS_URL`——那是 HTTP REST,不是本项目用的协议。
|
||||
|
||||
## 3. 写入 `.env`
|
||||
|
||||
```env
|
||||
REDIS_URL=rediss://default:你的密码@你的主机.upstash.io:6379
|
||||
```
|
||||
|
||||
可选:
|
||||
|
||||
```env
|
||||
# 连接超时毫秒(默认 15000)
|
||||
REDIS_CONNECT_TIMEOUT_MS=15000
|
||||
# 若 URL 是 redis:// 但强制 TLS(一般不需要,rediss:// 已够)
|
||||
REDIS_TLS=true
|
||||
# 并发命令自动 pipeline(默认开;高延迟远端 Redis 强烈建议保持)
|
||||
REDIS_AUTO_PIPELINE=true
|
||||
# 进程内 session/user 缓存(默认开;显著降低鉴权 RTT)
|
||||
REDIS_L1_CACHE=true
|
||||
# TCP keep-alive 毫秒(默认 10000)
|
||||
REDIS_KEEPALIVE_MS=10000
|
||||
```
|
||||
|
||||
## 延迟优化建议
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| **区域** | Upstash 选与 API 服务器最近的 region(跨洲 RTT 常 150–300ms,串行几条命令就到秒级) |
|
||||
| **避免 N+1** | 列表接口应批量 MGET/pipeline;本项目已对 me/bots、me/peers、广场列表等做批处理 |
|
||||
| **L1 缓存** | 鉴权路径对 session/user 做短 TTL 进程缓存,重复请求几乎零 RTT |
|
||||
| **命令数** | 免费档按命令计费;pipeline/MGET 既降延迟也降用量 |
|
||||
|
||||
## 4. 验证
|
||||
|
||||
```powershell
|
||||
cd F:\Code-Other-4\WeChat-AI
|
||||
pnpm diag
|
||||
```
|
||||
|
||||
应看到类似:
|
||||
|
||||
```text
|
||||
✓ REDIS_URL=rediss://...
|
||||
✓ PONG
|
||||
```
|
||||
|
||||
然后:
|
||||
|
||||
```powershell
|
||||
pnpm db:seed
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 5. 常见问题
|
||||
|
||||
| 现象 | 处理 |
|
||||
|------|------|
|
||||
| `ECONNREFUSED` / 连不上 | 检查 URL 是否完整、是否 `rediss://`、密码有无复制错 |
|
||||
| `ENOTFOUND` | 主机名错误 |
|
||||
| TLS / certificate 错误 | 确认用 `rediss://`;升级 Node 20+ |
|
||||
| 免费额度耗尽 | Upstash 控制台看 Command 用量;开发时减少轮询/调试 |
|
||||
| 误填 REST token URL | 改用 **Redis** 协议 URL,不是 `https://...upstash.io` |
|
||||
|
||||
## 6. 安全建议
|
||||
|
||||
- 不要把 `REDIS_URL` 提交到 Git(已在 `.gitignore` 的 `.env` 中)
|
||||
- 生产环境在 Upstash 开启合适的 IP 限制(若控制台提供)
|
||||
- 定期轮换 token
|
||||
Reference in New Issue
Block a user