chore: initial public release

This commit is contained in:
SMNET Studio
2026-08-10 17:22:45 +08:00
commit c000c31c22
186 changed files with 71441 additions and 0 deletions
+70
View File
@@ -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 网关 + Chatflow2026-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。
+280
View File
@@ -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 前缀);换图会更新 hashCDN 用 `?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 jobWorker 进程内 `BroadcastRunner` 限速发送(`BROADCAST_INTERVAL_MS`,默认 200ms;生产可调到 50100ms 加速,注意 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` | 应被轮询的 botactive + 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% 等价。
- **范围** 0500,默认 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)。
+32
View File
@@ -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
+59
View File
@@ -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`
+134
View File
@@ -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`
+195
View File
@@ -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 cacheEdge 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 cacheEdge 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 cacheEdge TTL 5 minutes 或 Respect originIgnore 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. 部署后刷新缓存
| 变更 | 做法 |
|------|------|
| 新版本 HTMLapp/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-storeDYNAMIC 或 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
View File
@@ -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` 去掉该源站。
+113
View File
@@ -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`);未部署时这两节整体跳过。
+55
View File
@@ -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
View File
@@ -0,0 +1,289 @@
# WeChat-AI 运维手册
## 1. 首次部署清单
### 1.1 依赖
- Node.js 20+
- pnpm
- **Upstash Redis**`rediss://...`
- **LINUX DO OAuth** 应用
- LLM API KeyOpenAI 兼容)
### 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 节点额外允许的 hosttools 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`
+145
View File
@@ -0,0 +1,145 @@
# 运行时配置(管理面板)
`/admin`**设置** 页可以改绝大多数原本只能写在 `.env` 里的配置。
## 优先级
```
.env(进程启动时读一次) ← 默认值
↓ 被覆盖
wa:settings:runtimeRedis 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 秒收敛
窗口内可能短暂不一致,表现为一次多余的再平衡。改这几项建议避开高峰。
+95
View File
@@ -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 常 150300ms,串行几条命令就到秒级) |
| **避免 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