Files
Cyrene_v2/Docs/api-reference.md
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00

1467 lines
154 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Cyrene v2 后端 API 参考(api-reference.md)
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.61`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
> **本文件的正确用法**:查「有哪些端点、大致形状、错误码、部署注意事项」可以;**写客户端代码时请以 `Docs/api-client.md` 为准**,本文件的字段表 / 示例可能滞后。
> **历史包袱(如实)**:本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际**系统性滞后于实现与 api-client**(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现**直接矛盾**的陈述、并把若干复述型段落改为引用 api-client;**§1 的 REST 端点表已于 2026-10-04 按 `internal/services/gateway/routes.go` 逐条核对并补登记**(补上此前缺失的 `POST /auth/refresh`、`POST /auth/logout`、`POST /upload`、`GET /files/{name}`、三支 `/audio/*`、`GET /admin/llm/catalog`、`GET /admin/observability/logs`、`GET /admin/notifications`、`GET /admin/plugins/installed`、`GET|PUT /admin/owner-profile`、`POST /admin/automations/reminders`)。**仍未补全的是 WS 事件帧清单**:§6.4 自述「并非全表」,`DevDocs/audit/00-总览.md` §4④ 列出的 `session_read` / `message_edited` / `message_deleted` 等仍未逐行登记。
> **其余仍成立**:所有字段名、取值、鉴权规则、状态码最终**以代码为准**(`internal/services/gateway/*.go`);契约变更版本化(REST 走 `/api/v1`,破坏性变更升 v2),不得破坏既有客户端(版本化承诺本身记在 api-client §5)。
---
## 0. 基础约定(全局)
- **Base URL**:`http://<host>:8080`。网关是唯一对外端口(单二进制、单端口 `:8080`、零跨进程 IPC 红线);生产一般经反向代理 + HTTPS。
- **版本**:REST 全部挂在 `/api/v1/{resource}`;WS 挂 `/ws/chat`;管理端点挂 `/api/v1/admin/*`。
- **响应统一信封**:
- 成功:`{"data": <payload>}`(HTTP 200 / 201 / 202 / 204)。
- 失败:`{"error": {"code": <string>, "message": <string>}}`(HTTP 400/401/403/404/409/500/501 等)。
- **错误码**:业务错误带稳定字符串 `code`(如 `unauthorized` / `not_found` / `bad_request` / `chat_busy` / `not_implemented`,**v0.56** 新增的 `image_too_large`(`chat.image` 超 5 MiB)与 `chat_dispatch_failed`(流式兜底派发失败),**v0.59** 新增的文档四码 `unsupported_media_type`(415) / `document_too_large`(413) / `document_not_found`(404) / `too_many_documents`(400)),便于客户端分支;`message` 面向人读。**v0.61**:文档 **415** 的判定改为「扩展名白名单(**23 项**,含 `.pdf`)∧ 字节嗅探」的**成对裁决**(HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都回 `415`,见 §3.8)。
- **CORS**:`Access-Control-Allow-Origin` 为配置的 `ALLOWED_ORIGINS` 白名单(`*` 时放开);`Allow-Methods: GET, POST, PUT, DELETE, OPTIONS`;`Allow-Headers: Authorization, Content-Type, X-API-Version`;`Access-Control-Expose-Headers: X-Voice-Provider`(跨域浏览器可读语音降级信号头);`Max-Age: 600`。`OPTIONS` 预检直接回 `204`。同源/无 Origin 请求放行。**注意:`X-API-Version` 仅作**advisory**惯例头(见 §0.2)——CORS 已放行但它**不参与服务端版本协商**,服务端当前不校验、不据此改变行为。**
- **请求日志**:每个请求由 `logging` 中间件记录一行(method/path/status/duration_ms),并计入可观测性指标。panic 由 `recover` 中间件兜底为 `500 {error:{code:internal,...}}`,不崩溃进程。
- **超时**:WS 连接 ping 周期 54s、pong 等待 60s、写超时 10s;读上限 4096 字节/帧。
- **数据目录 `DATA_DIR`(v0.55)**:默认 `./data`;**新增保留字哨兵 `platform`(大小写不敏感)** → 平台标准数据目录(Windows `%APPDATA%\Cyrene`、macOS `~/Library/Application Support/Cyrene`、Linux `~/.local/share/cyrene`,`internal/platform.DefaultDataDir()`)。显式路径与**显式空值**行为均不变(空值仍走组合根原兜底)。
- **管理面板安全入口 `ADMIN_PATH`(v0.55,env 默认 `admin`)**:面板**前端**挂在 `/<ADMIN_PATH>`(可配成 `xxx/admin`;**非法值回退 `/admin`**,见 `config.ServerConfig.AdminPathPrefix()`)。命中前缀(含深链)→ 返回**注入了 `<base href="/">` 与 `window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}` 的 `index.html`**(`Cache-Control: no-store`);**形如面板入口但前缀不符的路径 → `404`**(不回落用户端聊天壳);前缀恰好命中 dist 真实文件则仍走文件服务器。**REST/WS 路径不随它变化**——`/api/v1/*`、`/api/v1/admin/*`、`/ws/chat`、webhook 一律原样(**API 不藏**,安全靠 JWT + 角色门,design/07 §0.1);该前缀串**只注入面板入口页**,用户端 bundle 不含它(design/07 §0.2)。
- **额外监听端口的唯一豁免(v0.55 重述)**:`qq-obv11` 渠道的 **OneBot 反连 / HTTP 上报**模式需要独立 `net.Listen` 端口(`listen_addr`),这是 `AGENTS.md` §9 登记的例外(design/13 §0 原文即写「WS server / HTTP server」);**必须由配置显式给出**,为空则报错、**绝不默认开启**。其余渠道/模式一律不得新开监听端口。
### 0.1 鉴权模型(RBAC)
认证基于 **JWT Bearer**(`internal/auth`),角色层级 **owner > admin > viewer**(`owner` 满足 admin/viewer,`admin` 满足 viewer,`viewer` 仅满足 viewer)。
- **获取 token**:`POST /api/v1/auth/login`(见 §2.2)。token 有效期 **24 小时**(`tokenTTL`),签发角色恒为 `owner`。
- **REST 鉴权**:请求头 `Authorization: Bearer <JWT>`。`s.auth.Authorize(...)` 中间件:
- 缺失/无效/过期 token → `401 {error:{code:"unauthorized",...}}`。
- 有效 token 但角色不足 → `403 {error:{code:"forbidden",...}}`。
- `Authorize()`(无参)→ 任意**已认证**角色通过(only read metadata, e.g. GET /sessions、GET /personas)。
- `Authorize(owner)` → 仅 owner;`Authorize(owner, admin)` → owner 或 admin。
- **WS 鉴权(连接门与帧门是两回事)**:`GET /ws/chat` 通过 `?token=<JWT>` 或 `Authorization: Bearer` 取 token(`wsToken`),`Verify` 通过即可升级——**连接本身不校验角色**(任意有效 token,含 `viewer`,都可连接)。但**连接之后的帧按角色分档**(design/11 §7.2 上行 + §7.1 下行,2026-10-03 落实;**权威描述见 api-client §3 顶部的「两把角色门」,本节不重复维护**):
- **上行帧**:非 `owner`/`admin` 连接发来的**任何**上行 JSON 帧一律 **fail-closed 默认拒**,定向回 `{type:"error",data:{code:"forbidden"}}`——**不广播、不驱动模型、不落库**(`internal/services/gateway/ws_route.go` 的 `route` 首条语句即此门)。协议级帧(WS ping/pong/close)不受影响。
- **握手 `history` 回放**:**仅 `owner`/`admin`** 连接收到(`internal/services/gateway/ws_history.go` 的 `replayHistory` 首行门)。
- **下行帧**:只有 7 个**不含内容**的状态机信号(`session_busy`/`pending_ack`/`pending_drain`/`session_idle`/`session_interrupted`/`pending_full`/`session_read`)发给所有已认证连接;**其余全部**(正文 / 推理 / 工具 / 持久化与编辑删除的消息 / `graph_update` / `subagent_event` / `task_output` / `mood` / `device_update` / `reminder` …)**仅发 `owner`+`admin`**;未登记的新帧类型默认只发 owner/admin(`internal/services/gateway/ws_frame_scope.go` 的 `frameReachesAll`,fail-closed)。
- **口径**:`viewer` = **可连接、可读 12 条只读监控 REST、可收 7 个状态机信号帧**;除此以外 WS 上拿不到任何内容、也发不出任何指令。
- **API Key(`X-API-Key`)**:**API key 已接入鉴权(v0.51,见 api-client.md §认证)**——已认证端点同时接受该头,命中签发表即通过且一律 `owner` 级,带该头时只走 key 分支(未知/已吊销/空值回 `401`、不回落 Bearer);WS 握手仍只认 Bearer。
### 0.2 X-API-Version 头
`Docs/api-client.md` 建议客户端可带 `X-API-Version: 1`。**该头是 advisory 惯例头——服务端不校验、不据此协商版本,带不带行为完全不变**(`CORS Allow-Headers` 已放行它,跨域客户端发送不会预检失败)。若未来要真正版本化(`/api/v1` → 语义化协商),需另行实现服务端校验;在那之前客户端不应依赖它做任何行为分支。
### 0.3 上传 / 媒体(重要确认)
- **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/<name>`),可直接作 `chat.image`;也可继续用 **base64 data: URI** 或 http(s) URL 走 `POST /api/v1/chat` / WS `chat` 的 `image` 字段(入视觉路由,见 §6 流式与直连)。**语义(v0.53 修正,见 api-client §1 行 3)**:**主模型能否收图 = 生效 `models.json` 组的 `multimodal`**(不是「有没有配独立视觉模型」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**;**env-only 部署(无有效 `models.json`,走 `.env` 合成组)用 `LLM_MULTIMODAL`(默认 `false`,v0.58)声明这个能力**——它才是该组 `multimodal` 的 env 等价物,且**非 bool 值启动报错**;主模型不收图时由 vision/OCR 工具返回**文本描述**(另有 `image-crop` 工具可裁区域后重读,便于大图分区精读)。**内联上限(v0.56,2026-10-04 起可配)**:`chat.image` 解码后默认 **5 MiB**(与 `POST /api/v1/upload` 同口径;换算成字符串上限 `ceil(n/3)*4 + 64` 字符,已含 data: 前缀余量),超限 `400 {"error":{"code":"image_too_large",...}}`;字节上限由 `.env` 的 **`CHAT_IMAGE_MAX_BYTES`** 配置(默认 `5242880`;**≤0 视为「用默认」**,不存在「禁用上限」的取值),换配置时错误文案里的 MiB 数字会自动跟随;WS `chat` 帧受 4096 字节读上限约束(超限断连),故 WS 发大图必须走「上传 → URL」。**视觉链路的 URL 取回(v0.56,实现事实)**:`chat.image` 的 http(s) URL(含 `/api/v1/files/<name>`)由**服务端自己取回并内联为 `data:` URI** 再交 provider,**不再把 URL 原样转给上游**(同源 upload 产物优先走**本地文件读取**,`vision.WithUploadsDir`;取回沿用 http/https 白名单 + 50MiB 上限 + 取回超时)。**类型裁决(同一批实现事实)**:**字节嗅探是唯一裁决权**——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML/PROXY 错误页、SVG)或判不定且来源没声明 `image/*` → **明确报错**(`vision.ErrNotImage`),**绝不静默回退成 `image/png`**(旧文若写「非图片响应会被当 PNG」即按此更正)。
- **文档存储端点已存在(v0.59;v0.61 纳入 PDF 文本层)**:`POST /api/v1/documents`(owner/admin + 审计 `document.upload`,multipart 字段 `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 **20 MiB**)与 `GET /api/v1/documents/{name}`(**必须 Bearer owner/admin**,`attachment` + `nosniff`,与图片读侧刻意相反)。**类型裁决 = 字节嗅探(唯一)∧ 扩展名白名单(23 项,含 `.pdf`)**,**v0.61 起为成对裁决**:纯文本类嗅探基类型必须为 `text/plain`、`.pdf` 必须为 `application/pdf`(HTML 改名成 `.pdf`、或 PDF 改名成 `.txt` 均 `415`);`mime` 为嗅探基类型(`data.json` → `text/plain`,真 PDF → `application/pdf`)、`url` 为**相对路径**;同字节同扩展名重复上传回 `200` + 同一 `name` + `deduped:true`。**PDF 文本层抽正文(`status:"extracted"`);扫描件/图片型 PDF 无文本层 ⇒ 诚实拒绝 `status:"unreadable"`(需 OCR,当前未做);加密与损坏可区分;Office 仍诚实拒绝**。回合引用走 `chat` 的可选 `documents:[{name}]`(**≤5 条、只收服务端存储名**);历史/实时 DTO 是 **`documents` 数组**(元素 `{name,mime,size,chars,truncated,status}`,无附件时省略该键)。契约见 §3.8 与 api-client §1.2。
- **REST 语音端点已存在**:`POST /api/v1/audio/transcribe`(STT)、`POST /api/v1/audio/speech`(TTS)、`POST /api/v1/audio/voice-clone`(声音复刻),均 owner-only + 审计,绕开 WS 4KB 帧限。语音也可走 WS `voice_input`(上行)/`voice_output`(下行);**v0.59 起语音回合进会话编排器**(落库 / 流式 / 跨端 / 忙时排队),`voice_output` 增 `sessionId`/`deviceId?`、`voice_input` 增 `clientMsgId?`,见 §6.3/§6.4 与 api-client §3.15。
- 管理端导入知识文档(RAG)走 JSON body 文本(`POST /api/v1/admin/rag`),非文件上传。
---
## 1. REST 端点总览
> 下表为**全量登记**;下文 §2–§5 每端点给出字段级说明。`鉴权列`:`无` / `JWT`(任意已认证) / `owner|admin` / `owner`。`里程碑` 为代码/设计归属的大致里程碑(M8 网关、M9 前端、M10 补齐写、M16 会话持久化等)。**`#` 列是登记序号**:它历史上曾是跨表连续序号,但 §1.3 起就与前后表重复(`§8.1` 等处有 `§1.3 #23` 这类交叉引用,故既有编号一律不动);**补登记的行取本表内下一个可用号**。
### 1.1 公开(无鉴权)
| # | 方法 | 路径 | 状态 | 说明 | 里程碑 |
|---|------|------|:---:|------|:---:|
| 1 | GET | `/api/v1/health` | 200 | 存活探针 `{status:"ok"}` | M8 |
| 2 | POST | `/api/v1/auth/login` | 200 | 换取 JWT(恒 owner) | M8 |
| 3 | POST | `/api/v1/auth/refresh` | 200/400/401 | 静默续期(B2):body `{refreshToken}` → 新 access + 新 refresh(默认**单次使用旋转**;`AUTH_REFRESH_ROTATE_DISABLED=true` 时不旋转、旧 token 保持有效) | M17 |
| 4 | POST | `/api/v1/auth/logout` | 200/400/401 | 登出(B2):body `{refreshToken}` 撤**单个** grant(幂等);带 Bearer 且无 body 撤**该账号全部**(两种响应形状不同,见 §2.4) | M17 |
### 1.2 用户侧(已认证)
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|------|------|------|:---:|------|:---:|
| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/404/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超上限 → `400 image_too_large`(上限 = `CHAT_IMAGE_MAX_BYTES`,默认 5 MiB 与 `/upload` 同口径;≤0 视为用默认)、`clientMsgId` 重复 → `200 {duplicate:true}`;**v0.59**:可选 `documents:[{name}]`(≤5 条、只收服务端存储名)→ 超限 `400 too_many_documents`、名非法/不存在 `404 document_not_found`(见 §3.8) | M8/M15 |
| 4 | GET | `/api/v1/sessions` | JWT | 200 | 会话列表(持久化会话 or 渠道会话回退) | M16 |
| 5 | GET | `/api/v1/personas` | JWT | 200 | 人格列表 | M8 |
| 6 | GET | `/api/v1/sessions/{id}/messages` | owner\|admin | 200 | 会话消息热分页 | M16 |
| 7 | POST | `/api/v1/sessions` | owner\|admin | 201 | 新建次级会话 | M16 |
| 8 | DELETE | `/api/v1/sessions/{id}` | owner\|admin | 200 | 删除次级会话 | M16 |
| 9 | PATCH | `/api/v1/sessions/{id}` | owner\|admin | 200 | 会话改名(A4) + 置顶(`pinned`, D5) body `{title?,pinned?}`(部分更新,只回显变更字段;见 §3.6a) | M16 |
| 10 | PATCH | `/api/v1/messages/{id}` | owner\|admin | 200 | 编辑单条消息(D3) body `{content}` | M16 |
| 11 | DELETE | `/api/v1/messages/{id}` | owner\|admin | 200 | 删除单条消息(D3) | M16 |
| 12 | GET | `/api/v1/meta/enums` | JWT | 200 | 客户端共享枚举权威表(B4):`platform`/`mood`/`mode` + `unknown` 回落约定 | M8 |
| 13 | POST | `/api/v1/sessions/{id}/read` | owner\|admin | 200 | 推进已读游标(D4) body `{before?}`(缺省 = 现在)→ `{sessionId,readAt}`(见 §3.6b) | M16 |
| 14 | POST | `/api/v1/upload` | owner\|admin | 201 | multipart 图片上传(B1):光栅白名单(png/jpeg/gif/webp/bmp)、解码后上限 5 MiB → 同源 URL,可直接作 `chat.image`(见 §7) | M17 |
| 15 | GET | `/api/v1/files/{name}` | **无** | 200/404 | 已存上传的同源静态读(128-bit 随机名即能力 URL,`<img>`/`chat.image` 带不了 Bearer);`name` 只接受 32 位小写 hex + 白名单扩展名,路径穿越全形态拒绝 | M17 |
| 16 | POST | `/api/v1/audio/transcribe` | **owner** | 200 | 语音转写(STT,绕开 WS 4KB 帧限);静音/空结果时响应带 `provider` | M17 |
| 17 | POST | `/api/v1/audio/speech` | **owner** | 200 | 语音合成(TTS);响应头 `X-Voice-Provider` 报告实际提供方(降级可见) | M17 |
| 18 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 | 声音复刻(百炼 create_voice) | M17 |
| 19 | POST | `/api/v1/documents` | owner\|admin | 201/200/400/413/415 | **文档上传(v0.59;v0.61 纳入 PDF)**:multipart 字段 `file`(上限 `CHAT_DOCUMENT_MAX_BYTES`,默认 20 MiB)→ `201 {name,url,mime,size,sha256,deduped:false}`;**同字节同扩展名重复上传 → `200` + 同一 `name` + `deduped:true`**;字节嗅探(唯一裁决)∧ 扩展名白名单(**23 项,含 `.pdf`**),**v0.61 起为成对裁决**(纯文本类 → `text/plain`、`.pdf` → `application/pdf`;HTML 改名成 `.pdf`、PDF 改名成 `.txt` 均 `415`),`mime` 为嗅探基类型、`url` 为**相对路径**;错误码 `400 bad_request`/`413 document_too_large`/`415 unsupported_media_type`(见 §3.8) | M-S(design/24 S1 + §7 S6) |
| 20 | GET | `/api/v1/documents/{name}` | **owner\|admin** | 200/404 | **文档读取(v0.59;v0.61 增 PDF MIME)**:**必须带 Bearer**(与图片读侧免鉴权能力 URL 刻意相反);`name` = 32 位小写 hex + 白名单扩展名,路径穿越全形态拒绝;`Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`;嗅探不出放行类型 → `application/octet-stream`(真 PDF 按成对裁决如实下发 `application/pdf`,仍永不可内联);统一 `404 document_not_found`(见 §3.8) | M-S(design/24 S1 + §7 S6) |
### 1.3 管理后台(`/api/v1/admin/*`)
> **鉴权分档(以 `internal/services/gateway/routes.go` 为准;权威矩阵见 `Docs/api-client.md` §2.0,本表已按其逐条核对)**:管理**读**分两层——**只读监控档 `owner|admin|viewer`**(12 条非敏感 GET:`personas` / `sessions` / `plugins` / `plugins/installed` / `channels` / `iot` / `automations` / `rag` / `observability/{metrics,trace,logs}` / `notifications`,**现已全部登记进下表**)与 **`owner|admin` 档**(含他人数据 / 消息正文 / 凭据面 / 审计流水);**写一律 `owner`** + 全程审计。下表按注册顺序,标注鉴权与状态。
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|------|------|------|:---:|------|:---:|
| 9 | GET | `/api/v1/admin/personas` | owner\|admin\|viewer | 200 | 人格列表 | M8 |
| 10 | GET | `/api/v1/admin/sessions` | owner\|admin\|viewer | 200 | 会话列表 | M16 |
| 11 | GET | `/api/v1/admin/memories` | owner\|admin | 200 | 记忆列表 | M8 |
| 12 | GET | `/api/v1/admin/plugins` | owner\|admin\|viewer | 200 | 插件/能力列表 | M8 |
| 62 | GET | `/api/v1/admin/plugins/installed` | owner\|admin\|viewer | 200 | 运行时安装的插件清单(脱敏配置 + 每 server 工具数);鉴权以 `routes.go` 的注册(viewerAuth)为准 | M8 |
| 13 | GET | `/api/v1/admin/channels` | owner\|admin\|viewer | 200 | 渠道列表 + 已配账号(掩码+bot 身份+capabilities) | M8/M17 |
| 14 | GET | `/api/v1/admin/channels/messages` | owner\|admin | 200 | IM 适配器日志流(全量/平台/适配器三粒度) | M16 |
| 15 | GET | `/api/v1/admin/channels/{platform}/messages` | owner\|admin | 200 | 某平台 IM 适配器日志流 | M16 |
| 16 | GET | `/api/v1/admin/iot` | owner\|admin\|viewer | 200 | IoT 设备列表 | M8 |
| 17 | GET | `/api/v1/admin/llm` | owner\|admin | 200 | LLM 多厂商多模型组(APIKey 不回显) | M8/M15 |
| 65 | GET | `/api/v1/admin/llm/catalog` | owner\|admin | 200/503 | 模型目录候选(`?refresh=1` 强制在线刷新,否则复用 ≤24h 本地快照)→ `{source,fetchedAt?,stale?,warning?,providers[]}`;既无快照又抓不到 → `503 catalog_unavailable`(见 §4.9.1) | M15 |
| 18 | GET | `/api/v1/admin/automations` | owner\|admin\|viewer | 200 | 自动化/提醒列表 | M8 |
| 19 | GET | `/api/v1/admin/users` | owner\|admin | 200 | 账号/API-key 列表 | M8 |
| 20 | GET | `/api/v1/admin/backthink` | owner\|admin | 200 | 后台思考记录 | M9/M10 |
| 21 | GET | `/api/v1/admin/rag` | owner\|admin\|viewer | 200 | 知识库文档列表(可带 `?query=` 并入命中) | M10 |
| 22 | POST | `/api/v1/admin/rag/query` | owner\|admin | 200 | 知识库检索 | M10 |
| 23 | POST | `/api/v1/admin/rag/delete` | **owner** | 200/404 | 删除知识文档(项目索引条目 + 级联章节,真实删除;不存在 404) | 后 M12 |
| 24 | GET | `/api/v1/admin/observability/metrics` | owner\|admin\|viewer | 200 | 网关请求指标 | M10 |
| 25 | GET | `/api/v1/admin/observability/trace` | owner\|admin\|viewer | 200 | 最近 trace span | M10 |
| 63 | GET | `/api/v1/admin/observability/logs` | owner\|admin\|viewer | 200 | 后端日志尾部(`?level=`/`?limit=`,最新在前);读取即建立推送订阅(v0.55,见 §4.16) | M10 |
| 64 | GET | `/api/v1/admin/notifications` | owner\|admin\|viewer | 200 | 统一通知 feed(审计 + 渠道 + 后台思考 + 系统聚合;`?type=`/`?before=`/`?limit=`) | M10 |
| 26 | GET | `/api/v1/admin/observability/audit` | owner\|admin | 200 | 审计记录 | M10 |
| 27 | POST | `/api/v1/admin/plugins/policy` | **owner** | 200 | 写工具策略(审计) | M8 |
| 28 | GET | `/api/v1/admin/sessions/{id}/export` | owner\|admin | 200 | 导出会话元数据 | M8 |
| 29 | POST | `/api/v1/admin/personas` | **owner** | 201 | 新建人格 | M8 |
| 30 | PUT | `/api/v1/admin/personas/{id}` | **owner** | 200 | 编辑人格 | M8 |
| 66 | GET | `/api/v1/admin/owner-profile` | **owner** | 200 | 主人档案(白名单账号 + 显示名/关系备注/默认角色;档案文件不存在返回空白档案而非 404) | M17 |
| 67 | PUT | `/api/v1/admin/owner-profile` | **owner** | 200 | 保存主人档案(0600 原子落盘 + 热替换活档案,新白名单立即参与判主;校验失败 400) | M17 |
| 31 | POST | `/api/v1/admin/users` | **owner** | 501 | 新建用户(M10) | M10 |
| 32 | POST | `/api/v1/admin/users/apikey` | **owner** | 201 | 签发 API key | M8 |
| 33 | DELETE | `/api/v1/admin/users/apikey/{id}` | **owner** | 200 | 吊销 API key | M8 |
| 34 | POST | `/api/v1/admin/iot/{id}/control` | **owner** | 200 | 设备控制 | M8 |
| 35 | POST | `/api/v1/admin/iot/scenarios` | **owner** | 501 | IoT 场景(M10) | M10 |
| 36 | POST | `/api/v1/admin/iot/rules` | **owner** | 501 | IoT 规则(M10) | M10 |
| 37 | POST | `/api/v1/admin/automations` | **owner** | 201 | 新建自动化/提醒 | M8 |
| 68 | POST | `/api/v1/admin/automations/reminders` | **owner** | 201 | 新建定时提醒 body `{id?,at(RFC3339),message}` → 真实进 ReminderStore,到点发布 `reminder.trigger`(P0-22) | 后 M12 |
| 38 | POST | `/api/v1/admin/automations/{id}/run` | **owner** | 200 | 手动触发规则 | M8 |
| 39 | DELETE | `/api/v1/admin/automations/{id}` | **owner** | 200 | 删除规则 | M8 |
| 40 | POST | `/api/v1/admin/llm` | **owner** | 201 | 保存 LLM provider 组(落盘 + 热更新) | M8/M15 |
| 41 | POST | `/api/v1/admin/llm/test` | **owner** | 200 | 真实模型调用测试 | M8/M15 |
| 70 | POST | `/api/v1/admin/llm/reset-from-env` | **owner** | 200/400/500 | **重置为 env 默认(v0.62)**:把面板里 `.env` 声明过的键覆盖回 env 值(组集合不增减、env 未声明的键原样保留、无键可改时不写盘),body `{confirm:true}` 缺则 `400`,只回键名不回显凭据;失败 `env_defaults_unreadable`/`models_unreadable`/`llm_persist_failed`/`llm_reload_failed`/`llm_unavailable`(见 §5.13a) | 后 M12 |
| 42 | POST | `/api/v1/admin/channels/open` | **owner** | 200 | 连接渠道 | M8 |
| 43 | POST | `/api/v1/admin/channels/close` | **owner** | 200 | 断开渠道 | M8 |
| 44 | POST | `/api/v1/admin/channels/test` | **owner** | 200 | 结构性身份识别测试 | M8/M16 |
| 45 | GET | `/api/v1/admin/channels/{platform}/login-qr` | **owner** | 200 | 微信 iLink 扫码绑定 QR | M16 |
| 46 | GET | `/api/v1/admin/channels/{platform}/login-status` | **owner** | 200 | 微信 iLink 扫码状态 | M16 |
| 47 | GET | `/api/v1/admin/channels/{platform}/view` | **owner** | 200 | KOOK 账号聚合视图 | M17 |
| 48 | GET | `/api/v1/admin/channels/{platform}/friends` | **owner** | 200 | KOOK 好友/申请/屏蔽 | M17 |
| 49 | POST | `/api/v1/admin/channels/{platform}/friend/handle` | **owner** | 200 | KOOK 处理好友申请 | M17 |
| 50 | POST | `/api/v1/admin/channels/{platform}/message/test` | **owner** | 200 | KOOK 发送测试消息 | M17 |
| 51 | PUT | `/api/v1/admin/channels/{platform}/config` | **owner** | 201 | 保存渠道账号配置(落盘 + 热更新) | M16 |
| 52 | DELETE | `/api/v1/admin/channels/{platform}/config` | **owner** | 200 | 删除渠道账号 | M16 |
| 53 | POST | `/api/v1/admin/plugins/install` | **owner** | 200 | **热安装 MCP server 插件**(body `{kind:"mcp",name,config:{transport,endpoint?/command?,args?,apiKey?,env?}}` → 连接+发现+`RegisterTool` 进 registry → 落盘 `plugins.json` → `200 {data:{installed,name,kind,transport,toolCount}}`);连接/发现失败 `502 plugin_install_failed` 且**不落盘**;非法 `400`;manager 未注入 `501` | 后 M12 |
| 54 | POST | `/api/v1/admin/plugins/uninstall` | **owner** | 200 | 移除插件(MCP → `Unregister`+删 `plugins.json` 配置;普通能力 → `reg.Remove`) | M8 |
| 55 | POST | `/api/v1/admin/memories` | **owner** | 201 | 手动添加记忆 | M8 |
| 56 | POST | `/api/v1/admin/memories/search` | **owner** | 200 | 记忆检索 | M8 |
| 57 | POST | `/api/v1/admin/rag` | **owner** | 201 | 导入知识文档 | M10 |
| 58 | POST | `/api/v1/admin/system/revoke-keys` | **owner** | 200 | 吊销全部 API key | M10/M11 |
| 59 | POST | `/api/v1/admin/system/rotate-jwt` | **owner** | 200 | 轮换 JWT 密钥 | M10/M11 |
| 60 | POST | `/api/v1/admin/system/backup` | **owner** | 200 | 备份数据库 | M10/M11 |
| 61 | POST | `/api/v1/admin/system/restore` | **owner** | 200 | 恢复数据库 | M10/M11 |
| 69 | GET | `/api/v1/admin/system/storage` | **owner** | 200/500 | **存储状态(只读,v0.60)**:SQLite 文件/WAL/页级统计 + `documents` 表件数与占用 + 向量增强真假(`kind`/`enabled`/`ready`);**不含凭据**(DSN 一个字都不回);路径/活连接未接入或读不到表 → `500 storage_unavailable`(见 §5.35) | M10/M11 |
**另**:外部 webhook 回调(如 `webhook` 渠道的 HMAC 回调)由 composition root 通过 `Deps.Webhooks` 动态挂载,**不走网关鉴权**(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。
**补登记说明(2026-10-04)**:上表中 `#62`–`#68` 是本次按 `routes.go` 逐条核对后新增的行(`plugins/installed` / `observability/logs` / `notifications` / `llm/catalog` / `owner-profile` GET+PUT / `automations/reminders`),插在各自的鉴权分档里、**既有编号一律未动**。§1.1 的 `#3`/`#4`(`auth/refresh`、`auth/logout`)与 §1.2 的 `#14`–`#18`(`upload`、`files/{name}`、三支 `audio/*`)同理。**v0.59 再补两行**:§1.2 的 `#19`/`#20`(`POST /api/v1/documents`、`GET /api/v1/documents/{name}`,均按 `routes.go` 的 `writeAuth` 注册核对)——同样取本表下一个可用号、不动既有编号。**v0.60 再补一行**:`#69` GET `/api/v1/admin/system/storage`(owner-only 只读,按 `routes.go` 的 `ownerAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**v0.62 再补一行**:`#70` POST `/api/v1/admin/llm/reset-from-env`(**owner-only 写**,按 `routes.go` 的 `ownerAuth` 注册核对,见 §5.13a),同样取下一个可用号、既有编号未动。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。
**WS**:`GET /ws/chat`(握手,JWT,见 §6)。
---
## 2. 公开端点
### 2.1 GET /api/v1/health
- **鉴权**:无(探针)。
- **响应**:`200` `{"data":{"status":"ok"}}`。
- **备注**:仅存活探针;不含依赖健康状态。示例:
```bash
curl -s http://<host>:8080/api/v1/health
# => {"data":{"status":"ok"}}
```
### 2.2 POST /api/v1/auth/login
- **鉴权**:无(换取 JWT)。
- **请求 Body**(`loginRequest`):
| 字段 | 类型 | 必填 | 说明 |
|------|------|:---:|------|
| `username` | string | 是 | 管理员用户名(对照 `cfg.Auth.AdminUser`) |
| `password` | string | 是 | 管理员密码(对照 `cfg.Auth.AdminPass`) |
```json
{"username":"admin","password":"s3cret"}
```
- **响应**:`200` `{"data":{"token":"<JWT>","expiresAt":<unix秒>,"role":"owner","refreshToken":"<opaque>"}}`(`loginResponse`)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `token` | string | 已签名 JWT(HS256),下文请求头 `Authorization: Bearer <token>` |
| `expiresAt` | int64 | 过期时间,Unix 秒 = `now + 24h`(`tokenTTL=24h`) |
| `role` | string | **恒为 `"owner"`**(代码硬编码 `auth.RoleOwner`) |
| `refreshToken` | string | **不透明 256-bit 随机串**(B2)——服务端**仅存其 SHA-256 哈希**,明文只在响应出现一次;用于 `POST /api/v1/auth/refresh` 静默续期,`AUTH_REFRESH_TTL` 默认 `720h` |
- **错误**:`400` `{"error":{"code":"bad_request","message":"invalid request body"}}`(JSON 解析失败);`401` `{"error":{"code":"unauthorized","message":"invalid username or password"}}`;`500` `{"error":{"code":"login_failed",...}}`(签名失败)。
- **备注**:登录响应**以代码为准** = `{token, expiresAt, role, refreshToken?}`(**不是** `{token,jwt}`)。`refreshToken` 是 B2 新增的非透明长期凭据(未接线 refresh 存储时**该键省略**,此时形状与 B2 之前一致);旧 `api-client.md` §1 表内一行写 `{token,jwt}` 为**历史笔误**,以本档为准。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}'
# => {"data":{"token":"eyJ...","expiresAt":1799...,"role":"owner","refreshToken":"...base64url..."}}
```
### 2.3 POST /api/v1/auth/refresh(B2)
- **鉴权**:公开,body 带 `refreshToken`。
- **请求 Body**:`{refreshToken:"<opaque>"}`。
- **响应**:`200` `{"data":{"token":"<新JWT>","expiresAt":<unix秒>,"role":"<owner|admin|viewer>","refreshToken":"<新opaque?>"}}`(`role` 恒返回,取自该 refresh grant 记录的角色)——默认**单次使用旋转**:旧 refresh 立即作废并签发新的;`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh(`refreshToken` 省略)。
- **错误**:`400 bad_request`(JSON 解析失败 / 空 token);`401` `{"error":{"code":"invalid_grant",...}}`(未知/已撤销/已过期,统一口径,防枚举);仅成功才审计。
- **curl**:`curl -s -X POST http://<host>:8080/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"<opaque>"}'`
### 2.4 POST /api/v1/auth/logout(B2)
- **鉴权**:公开,两种方式二选一——body `{refreshToken}` 撤**单个** grant(幂等);或无 body 但带 `Authorization: Bearer <jwt>` 撤**该账号全部** grant(全设备登出)。
- **响应**(**两种形状,别混**):
- body 撤单个:`200` `{"data":{"revoked":<bool>}}`(幂等——未知/已吊销的 token 得 `revoked:false` 而**不是**错误)。
- Bearer 全撤:`200` `{"data":{"revoked":<bool>,"revokedCount":<n>}}`(`revokedCount` = 本次删除的该账号 refresh 行数,`revoked` = `revokedCount > 0`)。
- **错误**:不存在的 token 为幂等 no-op(仍 `200`);非法 body `400`;`refreshToken` 与 `Authorization` **都没给**回 `400` `{"error":{"code":"bad_request","message":"refreshToken or Authorization bearer token is required"}}`;Bearer 无效/过期回 `401` `{"error":{"code":"invalid_grant",...}}`;撤销失败回 `500 logout_failed`。
---
## 3. 用户侧端点(已认证)
### 3.1 POST /api/v1/chat
- **鉴权**:`Authorization: Bearer <JWT>`,`Authorize(owner, admin)`(owner 或 admin)。viewer 不能写。
- **请求 Body**(`chatRequest`):
| 字段 | 类型 | 必填 | 说明 |
|------|------|:---:|------|
| `sessionId` | string | 否 | 目标会话 id;缺省 `"web"`(主会话)。子会话用 `web:<uuid>` |
| `text` | string | 是* | 用户文本。`text` 与 `image` 至少一项非空 |
| `image` | string | 否 | 视觉输入:base64 **data: URI** 或 http(s) URL;入 vision/OCR 工具或主模型多模态(design/06)。图文并存则为带图 + 文本的 turn。**语义(v0.53)**:主模型能否收图看生效组的 `multimodal`;`LLM_VISION_MODEL`/`LLM_OCR_MODEL` 只配视觉/OCR **工具**的模型;**env-only 部署(无有效 `models.json`,走 `.env` 合成组)用 `LLM_MULTIMODAL`(默认 `false`,v0.58)声明该能力**(见 api-client §1 行 3) |
| `deviceId` | string | 否 | 客户端生成的端标识(design/16 §4.5,如 `web:chrome:a1b2c3`),落库到消息,可选 |
| `mode` | string | 否 | 行为模式(design/01 §3、design/16 §5.1,如 `chat`/`code`/…);随该轮落库供气泡渲染;**IM 恒 `chat`、缺省回落 `chat`**(`chatRequest.Mode`) |
| `clientMsgId` | string | 否 | **客户端幂等键(v0.56,G4)**:同一会话内同一个非空值只承认第一条,重复到达回 `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}` 且**不落库、不起回合**;省略 = 与加入该字段前逐字相同(老客户端零影响)。原生端用它把本地乐观气泡对齐到服务端真相 |
| `documents` | array | 否 | **文档附件引用(v0.59)**:`[{"name":"<32位hex>.<ext>"}]`,**≤5 条**、**只收服务端存储名**(`POST /api/v1/documents` 的响应 `name`),**绝不接受任意 URL**;客户端多带的 `mime`/`url`/`size` 一律被服务端覆盖。超出 → `400 too_many_documents`(**先于任何文件系统访问**);名非法/穿越/不存在 → `404 document_not_found`;`text`/`image`/`documents` **至少一项非空**(纯附件回合成立)。WS `chat` 帧同形(见 §6.3) |
| `stream` | bool | 否 | `true` = **直达会话编排器**的流式提交(v0.56/G2:接了编排器就直接 `Submit`,**不再经可丢弃的 bus**;未接编排器才回退 `publishChatStream` 发 `ws.chat`),**受理成功后才回响应**;缺省 / `false` = 同步返回完整回复(Go bool 零值即 false,仍**无「必填」约束**) |
```json
{"sessionId":"web","text":"你好","deviceId":"web:chrome:a1b2c3","stream":false}
```
- **响应**:
- 同步(`stream=false`,会话空闲)`200` `{"data":{"reply":"<助手文本>","steps":<int>,"stopReason":"<string>"}}`(`chatResponse`)。`steps` 为工具循环步数,`stopReason` 为停止原因(如 `stop`/`idle`/`error`)。
- 同步但会话**正忙**(`SubmitSync` 返回 `Pending`)`202` `{"data":{"queued":true,"queueDepth":<int>}}`(非阻塞 ack,稍后按上下文续接)。
- 同步且 **`clientMsgId` 重复到达**(`SubmitSync` 返回 `Duplicate`)`200` `{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}`(**v0.56/G4**:不落库、不起回合,`messageId` 是既有那条的 id)。
- 流式(`stream=true`,**v0.56 行为变更**):**受理成功后才回** —— 空闲已起回合 `200` `{"data":{"stream":true}}`;正忙已续接排队 `202` `{"data":{"stream":true,"queued":true,"queueDepth":<int>}}`;待处理满 `409` `{"error":{"code":"chat_busy",...}}`;幂等键重复 `200` `{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}`(**不落库、不起回合**)。实际文本经 WS 推送(见 §6)。**修复前**该分支在问编排器之前就回 `200 {"data":{"stream":true}}`,故 `202`/`409` 永不出现;**未接 loop 时由 `200 {stream:true}` 改为 `500 chat_unavailable`**(与同步路径同口径)。
- **错误**:
- `400` `{"error":{"code":"bad_request","message":"text or image is required"}}`(空消息)。
- `400` `{"error":{"code":"image_too_large",...}}`(**v0.56/G3**:`image` 解码后超上限;上限 = `CHAT_IMAGE_MAX_BYTES`(默认 **5 MiB**,`≤0` 用默认),base64 字符串上限 `ceil(n/3)*4+64`,与 `POST /api/v1/upload` 同口径,文案指路该端点)。
- `400` `{"error":{"code":"too_many_documents",...}}`(**v0.59**:`documents` 超过 5 条,先于任何文件系统访问判定)。
- `404` `{"error":{"code":"document_not_found",...}}`(**v0.59**:`documents[].name` 形态非法 / 路径穿越 / 文件不存在——三种情况同一响应)。
- `409` `{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}`(pending 队列满,`PendingFull`)。
- `500` `{"error":{"code":"chat_unavailable",...}}`(`runner` 未注入)/ `{"error":{"code":"chat_failed",...}}`(回合运行出错)。
- `503` `{"error":{"code":"chat_dispatch_failed",...}}`(**v0.56/G2**:仅**未接编排器**时的总线兜底派发失败——本轮**未提交**,不再是静默 200)。
- **备注**:
- **会话编排(design/15)**:同会话正忙时新消息**不阻塞**、入 pending(默认上限 5);满则 `409 chat_busy`。状态经 WS 状态事件通知(§6.4)。
- **幂等(v0.56/G4)**:带 `clientMsgId` 的用户轮次由 `(session_id, client_msg_id)` 的**部分唯一索引**原子判重(写在 `internal/runtime/history/store.go` 的 `AppendFull` + `client_msg_id.go`),重复到达**不落库、不起回合**;该键随后出现在消息 DTO(含 `GET /sessions/{id}/messages`)。
- 主会话 `web` 绑定**全量访问 env**(`loop.OwnerEnv()`:记忆读写 + 行为不变)。
- 敏感字段(`image` 可能为 base64)不走日志明文。
- curl:
```bash
TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}' | jq -r .data.token)
curl -s -X POST http://<host>:8080/api/v1/chat \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-API-Version: 1' \
-d '{"sessionId":"web","text":"讲个笑话","stream":false}'
# => {"data":{"reply":"...","steps":2,"stopReason":"stop"}}
```
### 3.2 GET /api/v1/sessions
- **鉴权**:`Authorize()`(任意已认证角色可读元数据)。
- **响应**:注入 `HistoryStore` 时返回**持久化会话**(`historySessionSummary`);否则回退渠道会话列表(`SessionSummary`,或 `[]`)。
**A) 持久化会话**(`history != nil`,主 + 子):
```json
{"data":[
{"id":"web","type":"main","title":"主控台","createdAt":1753...,"pinned":false,"unreadCount":2,
"lastMessage":{"role":"assistant","content":"好的,我在。","createdAt":1753...},"personaId":"xilian"},
{"id":"web:<uuid>","type":"sub","title":"临时小事","createdAt":1753...,"pinned":true,"unreadCount":0}
]}
```
- `id`:主会话恒 `"web"`;次级为 `web:<uuid>`。
- `type`:`"main"` | `"sub"`。
- `title`:可选(`omitempty`;首条用户消息到达时自动命名,无标题时缺该键)。
- `createdAt`:int64(Unix 毫秒)。
- `pinned`:bool,**恒返回**(D5 置顶标志;只是排序元数据,客户端无需第二次调用即可排序)。
- `unreadCount`:int,**恒返回**(D4;= 该会话中 `createdAt >` 已读游标的消息条数,`0` = 无未读)。它与 `lastMessage` 由**同一条 SQL** 派生,故查询数不随会话数增长(无 N+1)。
- `lastMessage`:可选(`omitempty`);最后一条消息的纯展示预览 `{role("user"|"assistant"), content, createdAt}`——`content` 已折叠空白并截断到 **60 rune** 加省略号;**无消息的会话缺该键**。
- `personaId`:可选(`omitempty`,v0.42);该会话绑定的角色 id(design/22 §2/§4.5)。**缺省 = 继承全局默认角色**(`owner.yaml:defaultPersona`),客户端据此渲染激活会话的角色名/头像。
**排序(以代码为准 = `pinned DESC, (type='main') DESC, created_at ASC`)**:置顶会话最前 → 未置顶的**主会话** → 未置顶的**子会话**(按 `createdAt` 升序)。⚠️ **置顶的次级会话会排在主会话之前**,因此客户端**不要假定「index 0 就是主会话」**;要定位主会话请按 `id == "web"`(或 `type == "main"`)查找。
**B) 渠道会话回退**(`history == nil`):
```json
{"data":[{"platform":"kook","channelId":"...","sessionId":"...","actorRole":"owner","owner":true,"isGroup":false,"memWritable":true}]}
```
`SessionSummary` 字段:`platform` / `channelId` / `sessionId` / `actorRole`(`owner|admin|viewer|...`)/ `owner`(bool) / `isGroup`(bool) / `memWritable`(bool)。
- **错误**:`500` `{"error":{"code":"sessions_failed",...}}`(列表失败)。
- **备注**:`HistoryStore` 未注入时后端**不报错**,退化为渠道视图(向后兼容)。curl:
```bash
curl -s http://<host>:8080/api/v1/sessions -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 3.3 GET /api/v1/personas
- **鉴权**:`Authorize()`(任意已认证角色)。
- **响应**:`200` `{"data":[<PersonaSummary>...]}`(无 store 时 `[]`)。
`PersonaSummary` 字段(**契约**见 api-client §1 行 9;用户侧 `GET /api/v1/personas` 与管理员侧 `GET /api/v1/admin/personas` 同形,**唯一差别是 admin 侧多一个 `thinking?`**):
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 人格 id(`PersonaID`,回退 `ID`) |
| `name` | string | 显示名(`c.Name`) |
| `character` | string | 角色名(当前恒 = `name`) |
| `isDefault` | bool | 是否默认人格 |
| `mode` | string | 首个行为模式(`modes[0]`,未设置为 `""`;内置默认人格未配置 `modes`,故实际为 `""`) |
| `memoryNamespace` | string | 记忆命名空间。**读侧当前恒为空串 `""`**(`collectPersonas` 不填此字段——**已知实现事实,客户端不要依赖读侧的该字段**);**人格写响应**(`POST`/`PUT /admin/personas`)才填真实值,未显式配置时为派生默认 `persona.<id>`(`loader.Persona.MemoryNamespace()`) |
| `avatar` | string | 视觉头像 URL(`omitempty`;该人格自己的,缺失则为空) |
| `chatBackground` | object | `omitempty`;`{landscape, portrait}` 双朝向壁纸,仅该人格自己的 |
| `mood` | string | **该角色当前情绪**(design/01 §2;恒返回,无 `omitempty`)。取值见 `GET /meta/enums` 的 `moods`;`""` = 该角色暂无被跟踪的情绪(**不伪造默认值**) |
| `thinking` | object | **仅 `GET /admin/personas` 返回**(`omitempty`,未配置则**无该键**):`{budgetTokens,effort,showToUser,mode}` 推理/呈现旋钮;用户侧 `GET /personas` **不返回**(详见 api-client「人格 `thinking` 读侧契约」) |
```json
{"data":[{"id":"cyrene","name":"Cyrene","character":"Cyrene","isDefault":true,"mode":"","memoryNamespace":"","mood":"calm","avatar":"/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png","chatBackground":{"landscape":"/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png","portrait":"/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png"}}]}
```
> 上例中 `memoryNamespace:""` 是**读侧的当前真实值**;`mood:"calm"` 来自默认人格 `configs/personas/cyrene.yaml` 的 `emotion.initialState`(其它角色可能为 `""`)。<!-- mood / memoryNamespace 的读写差异与 api-client §1 行 9 一致 -->
- **备注**:视觉资产按角色作用域读取,绝不返回别的角色的;缺失时前端回退中性默认。curl:
```bash
curl -s http://<host>:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 3.4 GET /api/v1/sessions/{id}/messages
- **鉴权**:`Authorize(owner, admin)`(会话消息/thinking 属主人私密历史,viewer 不可读)。
- **Path**:`{id}` = 会话 id(`web` 或 `web:<uuid>`;IM 隔离会话 `sess-xxx` 亦可用)。
- **Query**:
| 参数 | 类型 | 必填 | 默认 | 说明 |
|------|------|:---:|------|------|
| `limit` | int | 否 | 50 | 页大小,需 `>=1`,钳制上限 200(`maxPageLimit`);非法回 400 |
| `before` | int64 | 否 | 0 | 游标:上一页最旧消息的 `createdAt`(Unix 毫秒),向更旧翻页 |
| `after` | int64 | 否 | 0 | **正向增量**(A5):返回 `created_at > after`,**升序**;与 `before` **互斥**(同传 400);负数/非数字回 400 |
| `q` | string | 否 | - | **会话内搜索**(D2):`content LIKE` 大小写不敏感,`%`/`_`/`\` 转义为字面量;仍倒序 + before/limit 游标;与 `after` 互斥(同传 400);空 `q` 走普通分页;**严格限定单会话**(隔离会话不外泄) |
| `platform` | string | 否 | - | 来源平台过滤(design/16 §4.4)。Web 端不传 = 全量共享主会话;IM 传本平台(如 `telegram`)只拉本渠道消息 |
- **响应**:`200` `{"data":[<historyMessage>...]}`;**`before` 时按时间倒序**(newest first,需 `reverse()` 渲染);**`after` 时按时间升序**(最旧在前,可直接顺序渲染/追加)。
`historyMessage` 全字段:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 后端生成的消息 id(客户端据此去重) |
| `sessionId` | string | 会话 id |
| `role` | string | `user` / `assistant` |
| `content` | string | 正文(纯文本/图像消息文本;图像为 `image` 字段) |
| `mode` | string | 该条的模式(`omitempty`,供渲染气泡不用猜) |
| `mood` | string | 情绪标记(`omitempty`) |
| `image` | string | 图像引用(`omitempty`) |
| `documents` | array | **文档附件与抽取统计**(`omitempty`,**v0.59**):`[{name,mime,size,chars,truncated,status}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`。⚠️ **`chars`/`truncated` 是「本回合注入侧」事实**——`chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**「文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。**不含正文**。同一字段也出现在 `message_persisted` / `channel_persisted` / `message_edited` / 握手回放(同一结构体、四处同时生效)。⚠️ 单数 `document`(对象)形状**从未随任何已发布版本号发布**,本版起一律是 `documents` 数组 |
| `thinking` | string | 思维链(`omitempty`;**本端点 owner/admin-only,故可达**;**公开回放 / 实时持久化帧中已剥离**)。**v0.59 口径(有意默认,非缺陷)**:消息体的 `thinking` 字段在 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放中**恒为空**;**历史消息的思维链只有本端点(owner\|admin)返回**。实时思维链另走 `thinking.*` 流帧(§6.4,owner/admin 档) |
| `platform` | string | 来源平台(`web` / `telegram` / `weixin-ilink` / `qq-obv11`...,`omitempty`) |
| `deviceId` | string | 客户端端标识(`omitempty`,如 `web:chrome:a1b2c3`) |
| `senderName` | string | 第三方发送者昵称(`omitempty`;群聊/陌生人消息带,主人私聊为空) |
| `adapterId` | string | 产生该消息的适配器注册名(`omitempty`,如 `kook:3bef...`;Web 消息为空) |
| `personaId` | string | **该消息说话时的角色 id**(`omitempty`,**v0.42**,design/22 §7-5):会话角色在创建时固定,`assistant` 消息 = 生成它的角色、`user` 消息 = 当回合角色;**空 = 全局默认角色**(v0.42 之前的全部历史行)。客户端据此按角色渲染名字/头像 |
| `clientMsgId` | string | **客户端幂等键**(`omitempty`,**v0.56/G4**):带 `clientMsgId` 发来的 user 消息回填它;空 = 该消息没有幂等键(老客户端 / IM 镜像 / 历史行)。客户端用它把本地乐观气泡对齐到服务端真相 |
| `createdAt` | int64 | Unix 毫秒(兼作分页游标) |
```json
{"data":[{"id":"m1","sessionId":"web","role":"assistant","content":"你好","mode":"normal","mood":"calm","platform":"web","deviceId":"web:chrome:a1b2c3","createdAt":1753...}]}
```
- **错误**:`400`(`limit`/`before`/`after` 非法、`before`+`after` 同传、`id` 缺失);`501` `{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}}`;`500` `{"error":{"code":"messages_failed",...}}`。
- **备注**:热分页(design/16 §2.2,§5.1)。`thinking` 只在本 owner/admin 端点可见;WS 回放/广播已剥离。curl:
```bash
curl -s 'http://<host>:8080/api/v1/sessions/web/messages?limit=50&before=1753000000000&platform=web' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 3.5 POST /api/v1/sessions
- **鉴权**:`Authorize(owner, admin)`(写操作,变更持久态)。
- **请求 Body(可选)**:`{"personaId"?}`——**body 可完全省略或为空**。`personaId` 非空时把新会话绑定到该角色(未知角色回 `400 bad_request`);省略 / 空 = 不绑(`persona_id=''`,继承全局默认角色)。**会话角色只在创建时写入、之后不可变**(design/22 §8-1;`PATCH /sessions/{id}` 一旦收到 `personaId` 键即 `400`)。`ownerID` 从 JWT claims 取作子会话归属。契约见 api-client §1 行 5。
- **响应**:`201` `{"data":{"sessionId":"web:<uuid>"}}`;`personaId` 非空时**回显**该字段(省略时响应形状与旧版一致)。
- **另**:若注入的 `HistoryStore` **不支持角色绑定**且显式传了 `personaId`,回 `501 not_implemented`(不假装写入)。
- **错误**:`501` `{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}}`;`500` `{"error":{"code":"session_failed",...}}`。
- **备注**:新建次级会话(`NewSubSession`),独立上下文回溯。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/sessions \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
# => {"data":{"sessionId":"web:0123abcd..."}}
```
### 3.6 DELETE /api/v1/sessions/{id}
- **鉴权**:`Authorize(owner, admin)`。
- **Path**:`{id}` = 会话 id。
- **响应**:`200` `{"data":{"deleted":"<id>"}}`。
- **错误**:
- `403` `{"error":{"code":"forbidden","message":"the main session cannot be deleted"}}`(`history.ErrImmutableMain`,主会话 `web` 不可删)。
- `400`(id 缺失);`501`(无 HistoryStore);`500` `{"error":{"code":"session_failed",...}}`。
- **备注**:删除次级会话及其全部消息。**未知会话 id 也回 `200`(幂等)**——删除只是影响 0 行,不报 `404`(删除目标已不在时「已删除」依然成立)。curl:
```bash
curl -s -X DELETE http://<host>:8080/api/v1/sessions/web:0123abcd... \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# => {"data":{"deleted":"web:0123abcd..."}}
```
### 3.6a PATCH /api/v1/sessions/{id}
- **鉴权**:`Authorize(owner, admin)`(历史写;viewer → `403`)。
- **Path**:`{id}` = 会话 id。主会话 `web` **可以**改名/置顶,只是不可删。
- **请求 Body**:`{"title"?: string, "pinned"?: bool}`——**两个字段都缺失时回 `400`**(不支持「空 PATCH」):
- `title`:会话显示名。服务端 `TrimSpace` 后校验:**空白(`""` / 全空白)→ `400 title must not be blank`**;**超过 80 rune → `400 title is too long (max 80 characters)`**。**`""` 不能清空标题**——「`title` 存在但为空白」与「`title` 缺键」语义不同,后者才是「不改标题」;因此客户端**不提供「恢复自动命名」入口**(要换名只能给非空白标题)。
- `pinned`:bool,置顶 / 取消置顶(D5)。
- `personaId`:**不是可设置字段**——请求体里**出现该键(任何取值:`"yuki"` / `""` / `null` / 未知 id / 空白串)即 `400`**,错误消息点名 `personaId`;该检查在会话存在性检查**之前**执行,因此**未知会话 + `personaId` 回 `400`(不是 `404`)**,且与 `title` 同传时整体 `400`、**一个字段都不落库**(design/22 §8-1:会话角色创建时固定、之后不可变;v0.46)。
- **语义**:**部分更新**——只改请求中给出的字段;响应**只回显本次变更的字段**(title-only → `{id,title}`;pin-only → `{id,pinned}`;两者同给 → 三个键都在)。
- **响应**:`200`:
```json
{"data":{"id":"web:<uuid>","title":"新名字"}}
```
- **错误**:
- `400` `{"error":{"code":"bad_request","message":"at least one of title or pinned is required"}}`(`title` 与 `pinned` 都缺)。
- `400` `{"error":{"code":"bad_request","message":"title must not be blank"}}`(`title` 存在但 trim 后为空白)/ `"title is too long (max 80 characters)"`;`personaId` 出现即 `400`。
- `404` `{"error":{"code":"not_found","message":"session \"<id>\" not found"}}`。
- `501` `not_implemented`(未注入 HistoryStore);`500` `{"error":{"code":"session_failed",...}}`(改名/置顶失败)。
- **curl**:
```bash
curl -s -X PATCH http://<host>:8080/api/v1/sessions/web:0123abcd... \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pinned":true}'
# => {"data":{"id":"web:0123abcd...","pinned":true}}
```
### 3.6b POST /api/v1/sessions/{id}/read
- **鉴权**:`Authorize(owner, admin)`(历史写;viewer → `403`)。
- **请求 Body(可选)**:`{"before"?: int64}`——毫秒 Unix 时间戳。**缺省 / 空 body / `{}` = 现在**(「我看到的都算已读」);给值 = 把游标推进到该时刻。
- **语义**:`createdAt <= readAt` 的消息算已读(`GET /sessions` 的 `unreadCount` 只数 `createdAt > readAt` 的)。**会话级单一游标、单 owner 账号**——读一处 = **该账号所有端已读**(无 per-device 游标);**只前进不后退**(SQL `read_at = MAX(read_at, before)`),因此乱序/并发/重试请求幂等安全,绝不会把已读退回未读。
- **响应**:`200` `{"data":{"sessionId":"<id>","readAt":<推进后的游标毫秒>}}`。
- **错误**:`before` 为 `0`/负数 → `400 "before must be a positive millisecond timestamp"`;`before` 非数字或 body 非 JSON 对象 → `400 "invalid request body"`(**空对象 `{}` 合法** = 现在);未知会话(含不在 `chat_sessions` 里的隔离会话 `sess-xxx`)→ `404`;未注入 HistoryStore → `501 not_implemented`;推进失败 → `500 session_failed`。
- **副作用**:成功后向下行广播 `session_read`(见 api-client §3.9),让同账号其它端**免刷新**清红点;**失败路径不广播**。
- **升级回填**:迁移 `0010` 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读。
- **curl**:
```bash
curl -s -X POST http://<host>:8080/api/v1/sessions/web/read \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"before":1753000000000}'
# => {"data":{"sessionId":"web","readAt":1753000000000}}
```
---
### 3.7 GET /api/v1/meta/enums
- **鉴权**:`Authorize()`(**任意已认证角色**:owner/admin/viewer)——只读、无敏感信息,与 `GET /api/v1/sessions`(§3.2)同级。无 token 回 `401`。
- **用途**:客户端共享枚举权威表(Android 协同 B4,design/07 §6)。`platform`(消息来源平台)/ `mood`(情绪)/ `mode`(行为·呈现模式)此前只在文档里给示例,客户端各自硬编码映射导致来源角标与情绪标签漂移;本端点把三张表做成**机器可读的单一事实来源**,Web / Android / IM bridge 共用。
- **响应**:`200`
```json
{"data":{
"platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}],
"moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}],
"modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}],
"unknown":"display-raw"
}}
```
- **字段**:
| 字段 | 类型 | 说明 |
|------|------|------|
| `platforms` | `[]{id,label}` | 来源平台:各内置适配器 `Name()`(`kook`/`qq-obv11`/`weixin-ilink`/`telegram`/`webhook`)+ Web 主会话来源 `web`(`history.PlatformWeb`) |
| `moods` | `[]{id,label}` | 情绪:`internal/persona/emotion` 内置状态。状态机**可扩展**(persona 可经 `AddTransition` 注册更多状态),此表**非封闭集合** |
| `modes` | `[]{id,label}` | 九模式(design/07 §1);mode 只驱动呈现,不构成能力边界 |
| `unknown` | string | 恒 `"display-raw"`:未知值回落约定(机器可读) |
| `label` | string | **默认中文标签**,客户端可直接显示、也可按 i18n 覆盖;客户端比对应以 `id` 为准(小写、大小写敏感) |
- **错误**:仅 `401`(未认证)。无存储依赖,不会 `501`/`500`。
- **备注**:
- **未知值回落约定(红线)**:客户端遇不在表中的取值**原样显示原始字符串**——**不丢弃、不报错、不替换占位符**;**服务端新增取值是非破坏性变更**(forward-compatible),旧客户端无需升级,也不得因未知值清空角标或抛错。
- **单一事实来源在 Go 侧**:`internal/contract/enums`(`Platforms()`/`Moods()`/`Modes()`/`UnknownFallback`),网关只做投影(`internal/services/gateway/meta.go`)。
- **反漂移**:`internal/contract/enums` 的测试断言三张表与「适配器 `Name()`」「`emotion` 内置状态」「前端 `types.ts` 的 `MODES`」一致;`cmd/cyrene` 另断言表内每个平台都能被渠道工厂构建——漏登记或幽灵条目即 FAIL。
- Web 侧镜像:`frontend/web/src/lib/enums.ts`(`labelFor(kind, value)` 未知值返回原值),顶栏 / 侧栏 / 消息气泡的心情标签已改用。
- curl:
```bash
curl -s http://<host>:8080/api/v1/meta/enums -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# => {"data":{"platforms":[...],"moods":[...],"modes":[...],"unknown":"display-raw"}}
```
### 3.8 POST /api/v1/documents · GET /api/v1/documents/{name}(v0.59,v0.61 增 PDF 文本层;design/24 §3/§6 + §7 S6)
> **一句话**:文档 = 内容寻址存储 + 服务端抽取 + 清单/正文注入。**读侧要 Bearer**(与图片读侧「随机名即能力 URL」刻意相反);客户端只要这两个端点 + `chat` 的 `documents` 字段。权威口径与边界表见 api-client §1.2。
**A) 上传 `POST /api/v1/documents`(owner\|admin + 审计 `document.upload`)**
- **请求**:`multipart/form-data`,字段 `file`(单个)。**单文件上限** = `CHAT_DOCUMENT_MAX_BYTES`(默认 **20971520 = 20 MiB**;`≤0` 视为用默认,**不存在「禁用上限」的取值**)。它与图片的 `CHAT_IMAGE_MAX_BYTES` **刻意解耦**(改一边不影响另一边)。请求体额外留 1 MiB multipart 信封余量,**文件本身按确切上限校验两次**(部件大小 + 流式写入时多读 1 字节)。
- **响应**:
```json
{"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}}
```
首次 `201`(`deduped:false`);**同字节同扩展名重复上传 → `200` + 同一个 `name` + `deduped:true`**(落盘名 = `sha256(bytes)[:32] + ext`,**内容寻址**,盘上只留一份;元数据行 `ON CONFLICT(name) DO NOTHING` —— 首次的 uploader / 时间戳是权威)。客户端可把 `200` 当成功处理。
- **落盘位置**:`<DataDir>/uploads/documents/`(目录 0700、文件 0600);客户端原始文件名**只用来取扩展名**,永不参与路径。
- **类型裁决(唯一裁决权 = 字节嗅探;v0.61 起 = 成对裁决)**:
- **扩展名白名单(deny-by-default,23 个)**:`.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql`,**外加 `.pdf`(v0.61 新增,第 23 项)**;
- **字节嗅探(`http.DetectContentType`)与扩展名成对判定**(`internal/runtime/documents/store.go` 的 `ContentAllowed`,写侧与读侧同一份):**纯文本类**嗅探基类型必须恰为 **`text/plain`**;**`.pdf`** 必须恰为 **`application/pdf`**;
- 二者是**合取**:`.js` / `.py` 即便嗅探为文本也拒(不在白名单);改扩展名也换不了类型,且自 v0.61 起**双向**:**HTML/纯文本改名成 `.pdf` ⇒ `415`**(嗅探不是 PDF)、**PDF 改名成 `.txt` ⇒ `415`**(PDF 字节也只收在 `.pdf` 名下);
- **明令拒绝**:`text/html`、`image/svg+xml` 与 XML、`application/zip` 及封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者。
- 响应里的 **`mime` 是嗅探所得基类型**(去参数),故 `data.json` → `text/plain`(**不是** `application/json`)、真 PDF → `application/pdf`;**`url` 是相对路径**(读侧要 Bearer,绝对化没有意义)。
- **PDF 文本层(v0.61,切片 B1)**:**文本层 PDF 抽正文**(`Tj`/`TJ`/`'`/`"` 文本算子,页间以换行相接)⇒ DTO `status:"extracted"`;**扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `status:"unreadable"`**,原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,**绝不假装成功、也绝不返回空正文**(`ErrNoTextLayer`);**加密**(`ErrPDFEncrypted`)与**损坏/截断**(`ErrPDFDamaged`)是**可区分**的两类失败。安全阀:单份最多 **5000 页**、sidecar 正文 **≤8 MiB**(撞上即 `truncated`)。抽取器版本内部升到 **`documents/v3`** ⇒ **旧 sidecar 一次性重抽**(同一份旧附件的 `chars`/`truncated` 会在某次回合后重算;旧 PDF 若是文本层 PDF 会从 `unreadable` 变为 `extracted`)。**未做**:OCR/扫描件栅格化、PDF 写/生成、`.docx`/`.xlsx` 等 Office(仍诚实拒绝)。
- **错误**:`400 bad_request`(缺 `file` 部件 / 空文件 / 非 multipart);`413 document_too_large`(文件或信封超限,文案带生效上限字节数);`415 unsupported_media_type`(**扩展名白名单与字节嗅探的成对裁决**任一不过,文案同时点明两条口径);`401` / `403`(未认证 / 角色不足)。**审计**:成功 `document.upload`+`ok`、失败 +`error`(成对,不遗漏)。
```bash
curl -s -X POST http://<host>:8080/api/v1/documents \
-H "Authorization: Bearer $TOKEN" -F 'file=@notes.md'
# => 201 {"data":{"name":"…(32hex).md","url":"/api/v1/documents/….md","mime":"text/plain","size":1234,"sha256":"…","deduped":false}}
# 再传同一份:=> 200 {"data":{…同一 name…,"deduped":true}}
```
**B) 读取 `GET /api/v1/documents/{name}`(owner\|admin,不审计)**
- **鉴权**:**必须带 `Authorization: Bearer <JWT>`**(`owner`/`admin`);无 token → `401`,角色不足 → `403`。这是与 `GET /api/v1/files/{name}` 的**关键差异**——文档摄取在服务端完成,不需要被 `<img>` / provider 直接消费,因此不开「名字即凭据」的免鉴权读侧,也不放开内联服务 HTML/SVG 的口子。
- **`name` 形态**:`32 位小写十六进制 + '.' + 白名单扩展名`;**路径穿越全形态拒绝**(`..` / `%2e%2e` / `%2f` 等,含 ServeMux 清理前的守卫)。
- **响应头**:`Content-Type` = **读时重新嗅探**的基类型(**同一对裁决**:纯文本类 → `text/plain`、**真 PDF 如实下发 `application/pdf`**、嗅不出放行类型 → `application/octet-stream`);`Content-Disposition: attachment; filename="<name>"`;`X-Content-Type-Options: nosniff`;`Cache-Control: private, max-age=86400, immutable`(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ **PDF 也永不内联**:`attachment` + `nosniff` 一字未变。
- **错误**:`404 document_not_found` —— **名字非法 / 路径逃逸 / 文件不存在三种情况同一响应**(不泄露「是否存在」以外的信息)。
```bash
curl -s -D - -o /tmp/doc.md http://<host>:8080/api/v1/documents/<32hex>.md \
-H "Authorization: Bearer $TOKEN"
# => 200,头含 Content-Disposition: attachment、X-Content-Type-Options: nosniff
```
**C) `chat.documents`(REST 同步 / REST `stream:true` / WS `chat` 三处同形)**
```json
{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]}
```
- **只给 `name`**(服务端存储名);客户端多带的 `mime` / `url` / `size` **一律被服务端覆盖**(MIME 与大小取自落盘字节),**绝不接受任意 http(s) URL**(不重开 SSRF 面)。
- **≤5 条/回合**:超出 → `400 too_many_documents`,**先于任何文件系统访问**判定;名非法 / 穿越 / 不存在 → `404 document_not_found`;**任一条不合规都让整个请求被拒**(绝不静默丢掉附件)。
- 附件部件挂在**同一条 `user` 消息**上;**纯附件回合**(无文本、无图片)成立。WS 上行帧仍有 **4096 字节**硬上限 ⇒ 现实中文档走 REST;两条入口共用同一份解析与校验,不会分叉。
- **WS 侧失败**:经 `error` 帧下发 `data.code`(`too_many_documents` / `document_not_found` / `chat_documents_unavailable`)并**中止本回合**,绝不静默。
- **历史 / 实时 DTO(四处同源)**:`historyMessage.documents`(见 §3.4 表)——`[{name,mime,size,chars,truncated,status}]`,`status ∈ extracted|listed|unreadable`,**0 个附件时省略该键**;⚠️ `chars` / `truncated` 是**本回合注入侧**事实(全文长度只有 `document-read` 的 `totalChars` 可见)。
**D) 内置工具 `document-read`(模型侧)**:`{name, offset?, limit?}` → `{name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?}`;`limit` 默认 **8000**、上限 **64K** 字符(`≤0` 用缺省、超上限夹取);`offset` 为 **0 基 rune 偏移**、起点 = sidecar 正文(含 CSV/TSV 摘要行);`offset<0` 或 `>totalChars` → 工具错误(`==totalChars` 合法);`extractTruncated=true` 专指「抽取期撞上 **8 MiB** sidecar 安全上限」。只读 sidecar、**绝不重抽**;策略默认 **owner-only**。**诚实边界**:design/24 §6.2 列的 `document_unreadable`(400) **没有对应的 REST 错误码**,它只以本工具的错误形态存在(客户端在 DTO 里看到的是 `status:"unreadable"`)。
---
## 4. 管理后台 · 读端点(`owner|admin`)
> 以下端点 `鉴权 = Authorize(owner, admin)`(除特别标注 owner)。所有管理**读**不审计;管理**写**(§5)全程审计(who/what/when/result,见 §4.15)。
### 4.1 GET /api/v1/admin/personas
- 同 `GET /api/v1/personas`(§3.3),返回 `PersonaSummary[]`。`adminListPersonas` 直接复用 `collectPersonas()`。
- `200 {"data":[<PersonaSummary>...]}`。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/personas -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.2 GET /api/v1/admin/sessions
- 同 `GET /api/v1/sessions`(§3.2):注入 HistoryStore 返回持久化会话(主 + 子);否则回退渠道会话视图(`collectSessions`)。
- `200 {"data":[...]}`。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/sessions -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.3 GET /api/v1/admin/memories
- **响应**:`200 {"data":[<MemorySummary>...]}`(无 store 时 `[]`)。`MemorySummary` = `{id, scope, text}`。`scope` ∈ `user|persona|project`。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/memories -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.4 GET /api/v1/admin/plugins
- **响应**:`200 {"data":[<PluginSummary>...]}`(`PluginSummary` = `{name, kind, description, allowInGroup, allowInStranger}`)。`kind` 为能力类型,实际取值 = `tool|skill|channel|mcp|iot`(`internal/capability/registry` 的 `KindTool`/`KindSkill`/`KindChannel`/`KindMCP`/`KindIoT`);`allowInGroup/allowInStranger` 为可达性策略(**落盘 `tool_policies`,重启保留**——迁移 `0012_tool_policy`;`SetPolicy` 先落库后改内存,落盘失败内存保持旧值并返回错误,无行的能力按默认 owner-only)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/plugins -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.5 GET /api/v1/admin/channels
- **响应**:`200 {"data":[<ChannelSummary>...]}`。
`ChannelSummary` = `{name, connected, accounts?}`。`accounts` 为 `AdminChannelAccount[]`(掩码无 raw token),仅当 `s.channels` 注入且平台有已配账号时出现。
- `name`:平台名(如 `kook`、`telegram`、`weixin-ilink`)。
- `connected`:bool,该平台是否已连接。
- `accounts[]`(`AdminChannelAccount`)字段:`id` / `config`(掩码后的配置 map)/ `allowGroups` / `denyGroups` / `allowUsers` / `denyUsers` / `groupMsgPerMin` / `msgPerMin` / `maxMessages` / `botId?` / `botUsername?` / `botNickname?` / `capabilities?`(如 kook 的 `["friends","user_chats","channels","guilds","message_test"]`)。
- `botId/botUsername/botNickname` 为**已连接 bot 的真实身份**(如 KOOK Connect 时调 `/api/v3/user/me` 解析),非敏感来自 live adapter。
- `config` 值已脱敏(token 掩码 `****` + 尾 4);`bot_token` 等密钥**绝不回显**。
- **聚合逻辑**:`channels.json` 是账号存在性的真源;一个裸平台 + 多账号合并为**一行**;无注册 adapter 但已配账号的平台仍出现(供查看配置)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/channels -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.6 GET /api/v1/admin/channels/messages
- **鉴权**:`owner|admin`。
- **Query**:`limit`(默认50,上限200) / `before`(游标,Unix 毫秒) / `adapter`(适配器注册名,如 `kook:3bef...`) / `platform`。三粒度(互斥,`adapter` 优先,其次 `platform`,都不传=全量 IM 总览)。
- **响应**:`200 {"data":[<historyMessage>...]}`(newest first)。**本视图不过滤主会话**(因为它带 `adapter_id`);按 `adapter_id`/`platform` 过滤。`historyMessage` 见 §3.4。
- **错误**:`501`(无 HistoryStore)。
- **备注**:IM 适配器日志页全量数据源 + 轮询兜底;实时增量走 WS `channel_persisted`(§6.3)。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/messages?adapter=kook:3bef...&limit=50' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.7 GET /api/v1/admin/channels/{platform}/messages
- **鉴权**:`owner|admin`。
- **Path**:`{platform}` = IM 平台(如 `telegram`)。
- **Query**:`limit`(默认50,上限200) / `before`(Unix 毫秒)。
- **响应**:`200 {"data":[<historyMessage>...]}`。**排除主会话 `web`**,只返回该平台非主人消息流(隔离 `sess-xxx`)。`senderName` 标发送者昵称;`adapterId` 标来源适配器。
- **错误**:`400`(platform 缺失);`501`(无 HistoryStore);`500`。
- curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/telegram/messages?limit=50' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.8 GET /api/v1/admin/iot
- **响应**:`200 {"data":[<iotDeviceSummary>...]}`(`iotDeviceSummary` = `{id, name, type, state, online}`;`state` 为 `map[string]any`)。无 store 时 `[]`。
- **错误**:`500` `{"error":{"code":"iot_failed",...}}`。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/iot -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.9 GET /api/v1/admin/llm
- **响应**:`200 {"data":{"providers":[<AdminLlmProvider>...]}}`(`AdminLlmConfig`)。
- `AdminLlmProvider`:`{id, name, provider, baseUrl, model, multimodal, enabled, fallback?, maxTokens?, reasoning, personaModels?, personaProviders?}`(**v0.53 新增三键**,见 api-client 的「模型配置契约(v0.53)」);顶层 response 另含 `reasoningKinds` / `personaProviderOptions` 两个取值域数组(面板表单选项的唯一来源)。**`multimodal` = 主模型自己能否收图**(裁决 14.4.1)——它**不**表示「本部署有视觉能力」;视觉/OCR **工具**的模型由 `.env` 的 `LLM_VISION_MODEL` / `LLM_OCR_MODEL` 单独配置,与 `multimodal` 无关(**env-only 部署**——无有效 `models.json`、走 `.env` 合成组——该字段由 **`LLM_MULTIMODAL`** 声明,默认 `false`,v0.58)。`reasoning` 恒下发(`{kind}`,`kind:""` = 未声明形状)。
- `provider` 协议 ∈ `openai|anthropic`;`name` 为友好标签(OpenAI/Anthropic 或组 id)。
- `enabled`:index 0 的组为生效组 = `true`。
- `maxTokens`:该组的 token 预算(`omitempty`,缺省/0 = 未设);缺省时链末端回退步回落 `LLM_FALLBACK_MAX_TOKENS`(`.env`,默认 8192),再回落适配器默认(anthropic 8192)。
- `reasoning`(v0.53,恒下发):`{kind}` —— `kind` ∈ `auto|deepseek|openai|vllm|dashscope`,决定该组用哪种**提供商开关形状**下发推理指令(`""`/`auto` = 按模型名/baseURL 推断,形状有歧义就不下发);详见 api-client「模型配置契约(v0.53)」。
- `personaModels` / `personaProviders`(v0.53,`omitempty`):**角色级模型 / 协议路由表**(`{personaId: modelId}` / `{personaId: "openai"|"anthropic"}`);未配置 = 该键不出现。
- `multimodal`:**主模型自己能否收图**(= 面板的「多模态」勾选);视觉/OCR **工具**的模型是 `.env` 的 `LLM_VISION_MODEL`/`LLM_OCR_MODEL`,与本字段无关(裁决 14.4.1 修语义);**env-only 部署(无有效 `models.json`,走 `.env` 合成组)由 `LLM_MULTIMODAL`(默认 `false`,v0.58)声明本字段**。
- **API key 永不返回**(wire 无 key 字段)。
- `contextWindow`(v0.59,`omitempty`):该组声明的**模型上下文窗口**(token)。0 / 键缺失 = **未声明** ⇒ 运行时按回落窗口(`DefaultModelContextWindow`,131072)保守推导并记一条 WARN;负数在校验阶段被拒。**它是 `.env` 侧 `LLM_CONTEXT_WINDOW` 的对偶**(per-group 播种)。语义见 api-client §2「上下文窗口与预算契约(v0.59)」。
- 数据源:`models.json` 各组(index 0 = 生效组);无 `models.json` 时按 `.env` 合成单组。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/llm -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.9.1 GET /api/v1/admin/llm/catalog(v0.53,此前漏记)
- **鉴权**:`owner|admin` 读(**只读**)。**Query**:`?refresh=1` 强制在线刷新;否则复用 24h TTL 内的本地快照。数据源 `https://models.dev/api.json`。
- **响应**:`200 {"data":{"source":"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}}`;**既无本地快照又抓不到** → `503 {"error":{"code":"catalog_unavailable","message"}}`;**有快照但刷新失败** → `200` + `stale:true` + `warning`。带 `Accept-Encoding: gzip` 时响应带 `Content-Encoding: gzip`(**信封形状不变**)。
- **副作用只有一处**:落盘快照 `<DATA_DIR>/llm_catalog.json`(**不含任何凭据**、`data/` 已 gitignore、TTL 24h),**不改 `models.json`**。**审计动作**:`llm.catalog`(`ok`/`stale`/`error`)。完整契约与「不进运行时热路径」的边界见 `api-client.md` §2「模型配置契约(v0.53)」。
### 4.10 GET /api/v1/admin/automations
- **响应**:`200 {"data":[<AdminAutomationRecord>...]}`。
- `AdminAutomationRecord`:`{id, name, kind, cron?, enabled, lastRun?, nextRun?}`。`kind` ∈ `cron|event`;`lastRun/nextRun` 为 RFC3339(未知省略)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/automations -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.11 GET /api/v1/admin/users
- **响应**:`200 {"data":[<AdminUserRecord>...]}`。
- `AdminUserRecord`:`{id, username, role, createdAt?}`(`createdAt` RFC3339)。无 UserStore 时仅有配置的 admin 账号(`id:"admin"`、`role:"owner"`);注入 UserStore 后追加 API key 摘要(`username:"apikey"`、`id` 为掩码)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/users -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.12 GET /api/v1/admin/backthink
- **响应**:`200 {"data":[<AdminBackthinkItem>...]}`(无 store 时 `[]`)。`AdminBackthinkItem`:`{id, trigger, conclusion?, shouldNotify, pushed, at}`(`at` 为 Unix 毫秒)。默认取最近 50 条。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/backthink -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.13 GET /api/v1/admin/rag
- **Query**:`?query=<string>`(可选;带则并入检索命中)。
- **响应**:`200 {"data":{"documents":[<AdminRagDoc>...], "hits"?:[<AdminRagHit>...]}}`(`AdminRagResponse`)。
- `AdminRagDoc`:`{id, name, desc?, chunks, embedded}`(`embedded` 反映是否配置向量增强)。
- `AdminRagHit`:`{docId, docName, chapter?, text, score, semantic}`(`semantic` 布尔,语义/向量命中为 true)。
- **错误**:`500`。无 store 时返回空文档列表(不 500)。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/rag?query=部署' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.14 POST /api/v1/admin/rag/query
- **鉴权**:`owner|admin`。
- **Body**:`{"query":"<string>"}`(必填,非空)。
- **响应**:`200 {"data":{"query":"<string>","hits":[<AdminRagHit>...]}}`。**审计**:`rag.query`(成功 `ok` / 失败 `error`)。
- **错误**:`400`(query 缺失/空);`501`(未注入 RagStore);`500`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/rag/query \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"query":"部署要求是什么"}'
```
### 4.15 GET /api/v1/admin/observability/audit
- **Query**:`?limit=N`(默认 50,上限 500)。
- **响应**:`200 {"data":[<AuditRecord>...]}`(newest first)。`AuditRecord` = `{id(int64递增), who, what, when(RFC3339), result}`。`who` 形如 `admin:owner`;`what` 为审计动作(如 `system.backup`);`result` ∈ `ok|error`。
- 来源:SQLite `audit_logs`(`SQLiteAuditStore`);未注入时退化为进程内存。
- 示例:`{"data":[{"id":1,"who":"admin:owner","what":"system.backup","when":"2026-09-08T00:00:00Z","result":"ok"}]}`。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/observability/audit?limit=100' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.16 GET /api/v1/admin/observability/metrics
- **Query**:无(不接收 limit)。
- **副作用(v0.55)**:owner/admin 凭据**读取本端点即建立可观测推送订阅**(`observability_metrics` 帧此后由 WS 推来,面板不再轮询;`observability_push.go`)——该凭据的 WS 连接全断即退订。同理见 §4.17 与 `GET /api/v1/admin/observability/logs`(三者的读取都是订阅握手)。响应形状**一字未变**。
- **响应**:`200 {"data":<AdminMetrics>}`。`AdminMetrics` = `{totalRequests, qps, avgLatencyMs, maxLatencyMs, perMethod{}, perPath{}, statusCounts{}, llmCalls, subagentEvents}`。
- `qps`:近 1 秒窗口计数;`avgLatencyMs/maxLatencyMs`:均值/最大耗时(毫秒)。
- `perMethod/perPath/statusCounts`:分布计数。
- `llmCalls/subagentEvents`:由实际观测的 trace span 按 `kind` 累计(Tracer 未注入为 0)。
- **Token/成本后端不统计、不返回**(不伪造指标)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/observability/metrics -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.17 GET /api/v1/admin/observability/trace
- **Query**:`?limit=N`(默认 50,上限 500)。
- **副作用(v0.55)**:读取本端点同样建立推送订阅(`observability_trace` 帧随后推来),详见 §4.16。
- **响应**:`200 {"data":[<AdminTraceSpan>...]}`(newest first)。`AdminTraceSpan` = `{id, parentId?, name, kind, status, startedAt, endedAt?, durationNs, props?, events?}`。`kind` ∈ `llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail`;`events` 为 `[{time,type,props?}]`;时间 RFC3339。
- **旁路节点的 span 现已真实产生**(裁决 11.4.2 / 11.4.3,2026-10-04):`memory.recall` / `memory.write` / `guardrail.input` / `guardrail.output` / `orchestration.spawn` 均由真实生产者开出,因此 `kind` 取值集里的 `memory|guardrail|orchestration` 不再只是枚举——trace 列表中会真的出现这三类 span(此前三者无生产者,全仓只有 `llm_call`/`tool_call`/`subagent`/`state_transition`)。它们只带元数据(记忆作用域/条数/字节数、护栏裁决与耗时、被派发的子代理 id 与目标角色 id),**不含记忆正文、不含提示词、不含密钥**;见 `design/19 §1`。
- 来源:环形缓冲(默认 512 条),由 `ObserveSpan`/`TraceSink` 注入。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/observability/trace?limit=100' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 4.18 GET /api/v1/admin/sessions/{id}/export
- **鉴权**:`owner|admin`(注册为 `adminAuth`)。
- **Path**:`{id}` = 会话 id。
- **响应**:`200 {"data":{"session":<SessionSummary>}}`(`summaryOfSession`:`{platform, channelId, sessionId, actorRole, owner, isGroup, memWritable}`)。
- **错误**:`404`(会话未找到);`500`。
- **备注**:导出会话身份元数据;完整消息转录由 M10 提供。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/sessions/web:0123.../export -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
---
## 5. 管理后台 · 写端点(`owner`,全程审计)
> 以下端点**鉴权 = `Authorize(owner)`**(仅主人)。每个写操作都会调用 `auditClaims(r, "<action>", "ok"|"error")` 记录 who/what/when/result(落 `audit_logs` + 网关日志)。**敏感值(token/密钥/新 JWT 密钥/DB 路径)不在响应/日志回显**。**动作名约定**:多词动作用 camelCase(`llm.addProvider` / `llm.resetFromEnv` / `persona.update` / `memory.search` …),与 §8.3 的清单逐字一致。
### 5.1 POST /api/v1/admin/plugins/policy
- **Body**(`policyRequest`):`{"name":"<能力名>","allowInGroup":bool,"allowInStranger":bool}`。
- **响应**:`200 {"data":{"name":"<能力名>","allowInGroup":bool,"allowInStranger":bool}}`。
- **错误**:`400`(name 缺失);`500`(cap service 未配置);`404`(能力不存在);`500`。
- **审计**:`policy.set`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/policy \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"web_search","allowInGroup":true,"allowInStranger":false}'
```
### 5.2 POST /api/v1/admin/personas
- **Body**(`personaUpsertRequest`):`{"id","name","characterRef?","role","systemPrompt?","personality?","speechStyle?","catchphrase?","background?","values?[]","modes?[]"/"mode?","memoryNamespace?"}`。`id/name/role` 必填;`systemPrompt` 空时派生默认;`characterRef` 空时 = id。
- **响应**:`201 {"data":<PersonaSummary>}`。注册进 persona registry(真实运行效果)+ 持久化到 `configs/personas/<id>.yaml`(重启存活)。
- **错误**:`400`(id/name/role 缺失);`409` `{"error":{"code":"duplicate","message":"人格已存在,请使用编辑"}}`;`500`(registry 未配置 / 持久化失败 / 注册失败)。
- **审计**:`persona.create`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/personas \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":"luna","name":"Luna","role":"女仆","modes":["normal"],"memoryNamespace":"luna"}'
# => {"data":{"id":"luna","name":"Luna","character":"Luna","isDefault":false,"mode":"normal","memoryNamespace":"luna"}}
```
### 5.3 PUT /api/v1/admin/personas/{id}
- **Path**:`{id}` = 人格 id。**Body** 同 5.2(不含 id;服务端以 path 覆盖 `req.ID`)。`name`/`role` 必填。
- **响应**:`200 {"data":<PersonaSummary>}`。
- **错误**:`400`(name/role 缺失);`404`(不存在);`500`(registry 未配置 / 持久化 / 更新失败)。
- **审计**:`persona.update`。curl:
```bash
curl -s -X PUT http://<host>:8080/api/v1/admin/personas/luna \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"Luna","role":"女仆","modes":["gentle"]}'
```
### 5.4 POST /api/v1/admin/users
- **响应**:`501 {"error":{"code":"not_implemented","message":"新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)"}}`。**审计**:`user.create`(error)。
- **备注**:诚实不伪造(无持久用户表)。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/users \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{}'
```
### 5.5 POST /api/v1/admin/users/apikey
- **Body**(`apiKeyIssueRequest`):`{"role":"owner|admin|viewer"}`。
- **响应**:`201 {"data":{"id":"<掩码>","key":"<明文·仅此一次>","role":"<role>"}}`。`key` 为 base64 raw URL 编码的 32 字节随机串,**仅此响应可见一次**;`id` 为掩码(`xxxx....xxxx`),用于后续吊销。
- **错误**:`400`(invalid role/role 缺失);`500`(user service 未配置 / 生成失败)。
- **审计**:`apikey.issue`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/users/apikey \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"role":"admin"}'
# => {"data":{"id":"abcd****wxyz","key":"LQV...","role":"admin"}}
```
### 5.6 DELETE /api/v1/admin/users/apikey/{id}
- **Path**:`{id}` = 列表返回的掩码 id。
- **响应**:`200 {"data":{"id":"<掩码>","revoked":true}}`。
- **错误**:`404`(掩码未命中);`500`(user service 未配置)。
- **审计**:`apikey.revoke`。curl:
```bash
curl -s -X DELETE http://<host>:8080/api/v1/admin/users/apikey/abcd****wxyz \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.7 POST /api/v1/admin/iot/{id}/control
- **Path**:`{id}` = 设备 id。**Body**(`iotControlRequest`):`{"action":"<string>","params":{...}?}`。
- **响应**:`200 {"data":{<iotDeviceSummary>}}`(`{id,name,type,state,online}`)——真实下发到 IoT provider,并发布 `device_update` 事件。
- **错误**:`400`(action 缺失 / 动作不支持 / 参数错);`404`(设备不存在);`409`(设备离线);`500`(IoT 服务未配置 / 控制失败)。
- **审计**:`iot.control`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/iot/d1/control \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"action":"toggle","params":{"brightness":50}}'
```
### 5.8 POST /api/v1/admin/iot/scenarios
- **响应**:`501 {"error":{"code":"not_implemented","message":"IoT 场景未实现:IoT 子系统仅有设备列表/控制/状态(Provider 接口无场景模型与存储);场景 CRUD 需先新增模型 + 执行器"}}`。**审计**:`iot.scenario`(error)。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/iot/scenarios -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
```
### 5.9 POST /api/v1/admin/iot/rules
- **响应**:`501 {"error":{"code":"not_implemented","message":"IoT 自动化规则未实现:IoT 子系统无规则模型与存储(通用 automations 服务只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器)"}}`(同 5.8,审计 `iot.rule`)。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/iot/rules -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
```
### 5.10 POST /api/v1/admin/automations
- **Body**(`automationCreateRequest`):`{"name","kind":"cron|event","expression"}`(均必填)。
- **响应**:`201 {"data":{"id":"<id>","name":"<name>","kind":"<kind>"}}`——真实注册规则。
- **错误**:`400`(字段缺失 / `invalid_cron` / `invalid_rule`);`500`(自动化服务未配置 / 添加失败)。
- **审计**:`automation.create`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/automations \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"晨间问候","kind":"cron","expression":"0 8 * * *"}'
```
### 5.11 POST /api/v1/admin/automations/{id}/run
- **Path**:`{id}`。**Body**:无。
- **响应**:`200 {"data":{"id":"<id>","output":"<string>"}}`(手动触发一次)。
- **错误**:`404`;`500`。
- **审计**:`automation.run`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/automations/<id>/run -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.12 DELETE /api/v1/admin/automations/{id}
- **Path**:`{id}`。**响应**:`200 {"data":{"id":"<id>","deleted":true}}`。
- **错误**:`404`;`500`。**审计**:`automation.delete`。curl:
```bash
curl -s -X DELETE http://<host>:8080/api/v1/admin/automations/<id> -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.13 POST /api/v1/admin/llm
- **Body**(`llmProviderRequest`):`{"id?","name?","provider?","baseUrl","apiKey?","model","multimodal?","fallback?","maxTokens?","contextWindow?","reasoning?","personaModels?","personaProviders?"}`。`baseUrl`+`model` 必填;`provider` 空默认 `openai`(仅允许 `openai|anthropic`);`maxTokens` 缺省/`0` = 未设,负数回 `400`。**v0.59 `contextWindow?`**:该组声明的**模型上下文窗口**(token)——**缺失 = 保留已存窗口**、**显式 `0` 与缺失同义**(都 = 未声明 ⇒ 按回落窗口 131072 推导)、**负数回 `400` 且不落盘**;保存响应的 `<AdminLlmProvider>` 会把它回显(`omitempty`)。**v0.53 三键**缺省 = 保留已存值(`reasoning` 整块给出即整块替换;两张 persona 表给出即整表替换);非法 `reasoning.kind`、非法协议名、anthropic 组声明 openai 系形状 → `400 bad_request`(`Group.Validate` 报错,不落盘)。契约见 api-client「模型配置契约(v0.53)」与「上下文窗口与预算契约(v0.59)」。
- **响应**:`201 {"data":{"provider":<AdminLlmProvider>,"persisted":true,"path":"<models.json 绝对路径>"}}`。**真实持久化**:读-改-写 `models.json`,保存组**提升为生效组**(index 0),并**热重载**运行中的 LLM stack(无需重启)。
- **错误**:`400`(缺 baseUrl/model / invalid provider / `contextWindow` 负值);`500`(LLM 服务未注入 / 写盘失败 / 热重载失败)。**API key 写入文件但永不回显、不落日志**。
- **审计**:`llm.addProvider`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/llm \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-...","model":"gpt-4o","multimodal":true}'
```
### 5.13a POST /api/v1/admin/llm/reset-from-env(v0.62,「重置为 env 默认」)
- **鉴权**:**`owner` 专属**(与同组 `POST /admin/llm`、`POST /admin/llm/test` 同门;`admin` 与 `viewer` 均 `403`)——它是破坏性写(覆盖面板对若干键的修改),**门不比同族写端点更宽**。**审计**:`llm.resetFromEnv`(成功 `ok`、被拒/失败 `error`)。
- **Body**(`llmResetRequest`):`{"confirm":true}` —— **必须显式为 true**。缺失 / `false` / 非法 body 一律 `400 {"error":{"code":"bad_request"}}`,且**一个字节都不动**(本动作用 env 值覆盖面板修改,是破坏性操作,缺护栏的默认必须是「不执行」)。
- **语义**:把面板(`models.json`)里 **`.env` 默认层确实声明过(非空值)** 的键覆盖回 env 值。它与启动期 materialize 的补齐**方向相反、键表同源**(`runtimeconfig` 的 `groupFillSpecs` / `envLayer`),因此逐条是:
- **只动** `provider` / `baseUrl` / `apiKey` / `model` / `contextWindow`(缺省协议族按**组自己的 `provider`** 选:`.env` 同时配了扁平块与 `ANTHROPIC_*` 时两族各取自己的值;`contextWindow` 只认正数)。
- **env 未声明的键一律保留面板值**(`fallback` / `multimodal` / `maxTokens` / `reasoning` / `personaModels` 在 `.env` 里没有出处 ⇒ 永不被本动作改动)。
- **组集合不增减**(不复活被面板删掉的组、不删面板新增的组,**顺序也不动**)。
- **面板值已与 env 一致的键不算「改回」**(`provider` 恒属这种情形),不出现在 `reset` 里。
- **无键可改时一个字节都不写**(幂等;重复调用第二次会返回 `reset:[]` + 每组一条「已一致」的 `skipped`)。
- 真的改了盘就**热重载**运行中的 LLM 栈(无需重启);但 `contextWindow` 的**上下文预算推导**仍只在进程启动时算一次(与 `POST /admin/llm` 同口径,重启后生效)。
- **对照基准**:**请求时刻按既有 config 解析入口读到的 `.env` 默认层**(`config.LoadLLMDefaults`,优先级与启动期 `config.Load` 逐字一致:进程环境 > `.env` > 标签默认);`.env` 路径由组合根经 `Deps.EnvPath` 注入(与 `main.go` 的 `config.MustLoad(envPath)` 同一条)——**网关自己绝不 fopen `.env`**。
- **响应**:`200 {"data":{"reset":[{"group":"<组 id>","keys":["baseUrl","apiKey",…]}],"skipped":[{"group":"<组 id>","reason":"<中文原因>"}],"path":"<models.json 路径>"}}`。`keys` **只有键名**(顺序 = 补齐表顺序),**凭据值绝不回显**;`reset` / `skipped` 恒为数组(无记录时是 `[]` 而非 `null`);`reason` 是两种之一:`` "面板值已与 `.env` 默认一致,无需重置" `` / `` "`.env` 未声明该组的默认键(面板值原样保留)" ``。
- **错误**(都**不改文件**且带审计 `error`):`.env` 源不可读 → `500 env_defaults_unreadable`;面板配置不存在/不可解析 → `500 models_unreadable`;写盘失败 → `500 llm_persist_failed`;已写盘但热重载失败 → `500 llm_reload_failed`(说明文件已改、未生效);LLM 栈未注入 → `500 llm_unavailable`。
- **面板**:LLM 页的「重置为 env 默认」卡片(`frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx`)——先展开中文二次确认文案,确认后才带 `confirm:true` 提交。完整契约见 `api-client.md` §2「重置为 env 默认契约(v0.62)」。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/llm/reset-from-env \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"confirm":true}'
# => {"data":{"reset":[{"group":"deepseek","keys":["baseUrl","apiKey","model"]}],"skipped":[],"path":".../models.json"}}
```
### 5.14 POST /api/v1/admin/llm/test
- **Body**(`llmTestRequest`):`{"provider?","baseUrl","model","apiKey?","prompt?"}`。`model`+`baseUrl` 必填;`provider` 空默认 `openai`;`prompt` 空用探针 `"Reply with the single word: OK"`。
- **响应**:`200 {"data":{"ok":true,"provider":"openai","model":"gpt-4o","reply":"<模型回复>","tokens":<int>}}`——**真实模型调用**(超时 30s)。
- **错误**:`400`(缺 model/baseUrl / invalid provider);`500`(调用失败)。
- **审计**:`llm.test`。**API key 仅本次请求内使用,不落库、不进 wire**。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/llm/test \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"provider":"openai","baseUrl":"https://api.openai.com/v1","model":"gpt-4o","apiKey":"sk-...","prompt":"你好"}'
```
### 5.15 POST /api/v1/admin/channels/open
- **Body**(`channelActionRequest`):`{"name":"<渠道名>"}`(如 `"kook"` / `"kook:conf0"` / 裸平台名解析到该平台首个账号)。
- **响应**:`200 {"data":{"name":"<name>","connected":true}}`——真实 Connect。
- **错误**:`400`(name 缺失);`404`(渠道未注册);`409` `{"error":{"code":"needs_login","message":"该渠道尚未登录:请先用「扫码登录」绑定,再连接"}}`(weixin-iLink 无 token);`500`(连接失败)。
- **审计**:`channel.open`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/channels/open \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"kook"}'
```
### 5.16 POST /api/v1/admin/channels/close
- **Body**:`{"name":"<渠道名>"}`。**响应**:`200 {"data":{"name":"<name>","connected":false}}`——真实 Disconnect。
- **错误**:`400` / `404` / `500`。**审计**:`channel.close`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/channels/close \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{"name":"kook"}'
```
### 5.17 POST /api/v1/admin/channels/test
- **Body**(`channelTestRequest`):`{"platform?","accountId","isGroup"}`(`accountId` 必填)。
- **响应**:`200 {"data":{"role":"owner|member|...","owner":bool,"isGroup":bool,"env":"owner|...|","verdict":"<人读结论>"}}`。**结构性身份识别**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,**绝不靠提示词**。`verdict` 说明:群聊→隔离+记忆只读+仅允许工具;命中白名单→owner(全量访问);未命中→按陌生人(default deny)。
- **错误**:`400`(accountId 缺失)。**审计**:`channel.identity-test`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/channels/test \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"platform":"weixin-ilink","accountId":"wx_uid_123","isGroup":false}'
# => {"data":{"role":"owner","owner":true,"isGroup":false,"env":"owner","verdict":"匹配主人白名单 → owner(全量访问,记忆可读写)"}}
```
### 5.18 GET /api/v1/admin/channels/{platform}/login-qr
- **鉴权**:`owner`。**Path**:`{platform}` = `weixin-ilink`(其它平台 501)。**Query**:`?accountId=`(可选)。
- **响应**:`200 {"data":{"qrUrl?","qrCode?","status":"confirmed|<状态>"}}`。`qrUrl` 为微信二维码图 URL(前端据此渲染可扫 QR),`qrCode` 为原始扫码标识;已登录时 `status:"confirmed"`。
- **错误**:`501`(非 weixin-ilink);`404`(账号未找到,提示先保存账号);`500`(取码失败)。
- **审计**:`channel.weixin.login.qr`。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.19 GET /api/v1/admin/channels/{platform}/login-status
- **鉴权**:`owner`。**Path**:`{platform}` = `weixin-ilink`。**Query**:`?accountId=`(可选)。
- **响应**:`200 {"data":{"status":"confirmed|failed|...","botId?","botToken?","err?"}}`。`botToken` 为**掩码(`****`+尾4)**;`status==confirmed` 时后端已把刚取到的 `bot_token/bot_id/owner_id` **写入 channels.json** 并**热加载**该平台(bridge 连上已配置的 adapter,无需重启),且(owner_id 存在时)把扫码者绑定 id 登记进 owner 白名单(`owner.yaml`,热生效)。`status==failed` 时 `err` 带原因。
- **错误**:`501` / `404` / `500`。
- **审计**:`channel.weixin.login.status`(+ `channel.weixin.login.persist`(确认时))。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# 确认后 => {"data":{"status":"confirmed","botId":"wxid_xx","botToken":"****abcd"}}
```
### 5.20 GET /api/v1/admin/channels/{platform}/view
- **鉴权**:`owner`。**Path**:`{platform}` = `kook`(其它 501)。**Query**:`?accountId=`(可选)。
- **响应**:`200 {"data":{"platform":"kook","accountId?","friends?","userChats?","guilds?","channels?","members?","errors?"}}`。聚合该账号真实数据(好友/申请/屏蔽、私信会话、服务器 + 首个服务器频道与成员)。`errors` 为 `{子项:原因}` 记录失败子项(不伪造)。token/密钥不打印不落日志。
- **错误**:`501`(非 kook);`404`(账号未找到);`500`。
- **审计**:`channel.platform.view`。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/kook/view?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.21 GET /api/v1/admin/channels/{platform}/friends
- **鉴权**:`owner`。**Path**:`kook`。**Query**:`?type=friend|request|block&accountId=`(`type` 空=全部)。
- **响应**:`200 {"data":<KOOK FriendList>}`(含 friends/requests/blocked)。
- **错误**:`501` / `404` / `500`。**审计**:`channel.friends`。curl:
```bash
curl -s 'http://<host>:8080/api/v1/admin/channels/kook/friends?type=request&accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.22 POST /api/v1/admin/channels/{platform}/friend/handle
- **鉴权**:`owner`。**Path**:`kook`。**Query**:`?accountId=`。**Body**(`kookFriendHandleRequest`):`{"id":<int64>,"accept":bool}`(id 必填)。
- **响应**:`200 {"data":{"handled":true,"id":<int64>,"accept":bool}}`。
- **错误**:`400`(id 缺失);`501` / `404` / `500`。**审计**:`channel.friend.handle`。curl:
```bash
curl -s -X POST 'http://<host>:8080/api/v1/admin/channels/kook/friend/handle?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":12345,"accept":true}'
```
### 5.23 POST /api/v1/admin/channels/{platform}/message/test
- **鉴权**:`owner`。**Path**:`kook`。**Query**:`?accountId=`。**Body**(`kookMessageTestRequest`):`{"targetId":"<频道id>","content":"<文本>"}`(均必填)。
- **响应**:`200 {"data":{<KOOK TestMessageResult>}}`(如 `{msg_id,msg_timestamp}`)——真实向目标发一条 kmarkdown 测试消息。
- **错误**:`400`(字段缺失);`501` / `404` / `500`。**审计**:`channel.message.test`。curl:
```bash
curl -s -X POST 'http://<host>:8080/api/v1/admin/channels/kook/message/test?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"targetId":"123456","content":"ping"}'
```
### 5.24 PUT /api/v1/admin/channels/{platform}/config
- **鉴权**:`owner`。**Path**:`{platform}`。**Body**(`channelConfigRequest`):
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 账号 id(可选;空则由服务端生成) |
| `token` | string | 快捷 token,并入 `config["bot_token"]`(若 config 未显式给) |
| `baseURL` | string | → `config["base_url"]` |
| `remoteURL` | string | → `config["remote_url"]` |
| `listenAddr` | string | → `config["listen_addr"]` |
| `mode` | string | → `config["mode"]` |
| `accessToken` | string | → `config["access_token"]` |
| `selfID` | string | → `config["self_id"]` |
| `config` | object | 平台专属键值(如 `bot_token`/`base_url`/`remote_url`/`endpointPath`/`receive_mode`/`webhook_verify_token`/`transport`/`api_base_url`/`report_path`...) |
| `allowGroups`/`denyGroups` | string[] | 黑白名单(群) |
| `allowUsers`/`denyUsers` | string[] | 黑白名单(用户) |
| `groupMsgPerMin` | int | 群消息限流 |
| `msgPerMin` | int | 用户消息限流 |
| `maxMessages` | int | 单账号最大消息数 |
> 空配置时:`weixin-ilink` 豁免(先建账号再扫码),其它平台 `400`。
> **`config` 是补丁(v0.54,与 `personaId` 同构)**:只含本次请求显式给出的键——键省略(`config` 里没有该键 / `config` 整体缺席或 `null`)= **保留已存值**、给非空值 = 覆盖、显式 `null`/`""` = **清除**(`0`/`false` 不算空);顶层便捷字段(`token`/`baseURL`/…)是普通 string,只在**非空**时并入 ⇒ 清除 `bot_token` 须走 `config` 的显式 `null`/`""`。`allowGroups`/`denyGroups`/`allowUsers`/`denyUsers` 与三个限流键仍是**整值替换**(省略 = 清空)。
> **`qq-obv11` 的 `config` 新增三键(v0.55,OneBot v11 四端,裁决 1.4 / 12.4.1)**:`transport`(`ws`|`http`,默认 `ws`,未知值回落 `ws`)、`api_base_url`(OneBot HTTP API 基址,**必须显式给出、不从 `remote_url` 推导**)、`report_path`(http+server 收上报的路径,默认 `/`)。`mode` 决定哪一半必需:`server` 必需 `listen_addr`(**独立监听端口,`AGENTS.md` §9 登记的唯一豁免,为空则 `Connect` 报错、绝不开端口**)、`client` 必需 `api_base_url`;另一半给齐字段就一并启用(单个适配器即可「收上报 + 回消息」)。env 兜底 `CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH}`。四端完整语义见 `api-client.md` §2 的「OneBot v11 四端契约(v0.55)」。
- **响应**:`201 {"data":{"persisted":true,"path":"<channels.json 路径>","platform":"<platform>","accountId":"<accountId>"}}`。**真实持久化 + 热更新**(读 channels.json→**按 id 打补丁**(`PatchChannel`)→落盘→`Reload(platform)` 热替换 adapter,无需重启;**v0.54 起 `config` 为 patch 三态**(与 `personaId` 同构):键省略=保留该账号已存值 / 给出非空值=覆盖该键 / 显式 `null`或`""`=清除该键,详见 `api-client.md` §2)。token 写入文件但**不回显**(GET 列表掩码)。
- **错误**:`400`(platform 缺失 / 空配置);`500`(渠道配置服务未注入 / 写盘失败 / 热重载失败)。
- **审计**:`channel.save`。curl:
```bash
curl -s -X PUT http://<host>:8080/api/v1/admin/channels/kook/config \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":"conf0","token":"<bot_token>","allowUsers":["user_i_like"],"groupMsgPerMin":20}'
```
### 5.25 DELETE /api/v1/admin/channels/{platform}/config
- **鉴权**:`owner`。**Path**:`{platform}`。**Query**:`?id=<accountId>`(必填)。
- **响应**:`200 {"data":{"persisted":true,"path":"<channels.json>","platform":"","accountId":"<id>"}}`。删除账号 → 落盘 → `Reload(platform)` 热更新(对应 adapter 优雅关停)。
- **错误**:`400`(platform/id 缺失);`500`(未注入 / 写盘 / 重载失败)。
- **审计**:`channel.delete`。curl:
```bash
curl -s -X DELETE 'http://<host>:8080/api/v1/admin/channels/kook/config?id=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
### 5.26 POST /api/v1/admin/plugins/install
- **Body**(`pluginInstallRequest`):`{"kind":"mcp","name":"<名>","config":{"transport":"stdio|http|sse","endpoint"?|"command"?, "args"?, "apiKey"?, "env"?}}`。`name` 必填;`kind` 缺省 `"mcp"`,**仅 `mcp` 可装**(其它回 `400`)。`endpoint` 的别名是 `url`。
- **响应**:`200 {"data":{"installed":true,"name":"<名>","kind":"mcp","transport":"<t>","toolCount":<n>}}`——**真实安装**:connect + 发现工具 + 热注册进 capability registry(下一轮模型请求即注入,无需重启)→ 落盘 `plugins.json`(gitignore、0600)→ 持有 live server 供后续刷新/卸载。**同名重复安装 = 刷新**(先卸旧再装新,失败回滚旧)。
- **错误**:`400`(缺 `name` / `kind` 非 `mcp` / transport 非法 / `stdio` 缺 `command` / `http|sse` 缺 `endpoint`);**`502 plugin_install_failed`**(连接/发现/注册失败,且**不落盘**,绝不装一半);`501 not_implemented`(未注入 plugin manager)。
- **审计**:`plugin.install`。**契约见 api-client §2 的 `POST /admin/plugins/install` 条(本节为索引,不重复维护契约)**。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/install \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"kind":"mcp","name":"fs","config":{"transport":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}}'
```
### 5.27 POST /api/v1/admin/plugins/uninstall
- **Body**(`pluginActionRequest`):`{"name":"<能力名>"}`(必填)。
- **响应**:已装 MCP → `200 {"data":{"name":"<name>","uninstalled":true,"kind":"mcp"}}`(`Unregister` 移除其工具 + close + 删 `plugins.json` 条目);**非 MCP / 普通能力** → `200 {"data":{"name":"<name>","uninstalled":true}}`(走 `registry.Remove`)。
- **错误**:`400`(name 缺失);`404`(不存在);`500`。
- **审计**:`plugin.uninstall`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/uninstall \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{"name":"web_search"}'
```
### 5.28 POST /api/v1/admin/memories
- **Body**(`memoryAddRequest`):`{"personaId?","content","scope?"}`。`content` 必填;`personaId` 缺省 `"cyrene"`;`scope` ∈ `user|persona|project`(缺省 `persona`;无效非空回 400)。
- **响应**:`201 {"data":{"id":"<id>","scope":"<scope>","text":"<内容>"}}`——真实写入 memory store。
- **错误**:`400`(content 缺失 / scope 非法);`501`(memory store 未配置 / 未注入 `memory.Store`);`500`。
- **审计**:`memory.add`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/memories \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"personaId":"cyrene","content":"主人喜欢喝美式咖啡","scope":"user"}'
```
### 5.29 POST /api/v1/admin/memories/search
- **Body**(`memorySearchRequest`):`{"query","personaId?","topK?"}`。`query` 必填;`personaId` **省略时回落到库里注册的默认角色**(`defaultPersonaID()`,不再写死 `"cyrene"`),**既未传又无默认角色 → `400 bad_request`**(诚实报错,不拿不可能命中的 id 去查);`topK` ≤0 取 10。
- **响应**:`200 {"data":{"query":"<query>","personaId":"<personaId>","semantic":false,"hits":[{"id","scope","text","score"}]}}`。真实检索(keyword × 衰减)。**`semantic` 当前仍恒为 `false`,但原因已变**:向量索引**已由组合根注入**(`cmd/cyrene/wire.go` 的 `memory.WithVectorIndex`),真实原因是**索引内当前无向量**(生产路径无向索引写入向量,仅测试有 `Index.Add`),故语义打分返回空;这是诚实反映,不是伪造。
- **错误**:`400`(query 缺失);`500`(memory store 未配置 / 检索失败)。
- **审计**:`memory.search`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/memories/search \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"query":"咖啡","personaId":"cyrene","topK":5}'
```
### 5.30 POST /api/v1/admin/rag
- **Body**(`ragIndexRequest`):`{"name","desc?","content"}`。`name`+`content` 必填。
- **响应**:`201 {"data":{"name":"<name>"}}`——真实导入:分块并落项目索引,立即可被关键词/行上下文检索。
- **错误**:`400`(name/content 缺失);`501`(未注入 RagStore);`500`。
- **审计**:`rag.index`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/rag \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"部署说明","desc":"v2 部署要求","content":"单二进制...\n单端口 8080..."}'
```
### 5.31 POST /api/v1/admin/system/revoke-keys
- **Body**:无。
- **响应**:`200 {"data":{"revoked":<int>}}`——清空 API key 表,先前签发的所有 key 立即失效。
- **错误**:`500`(user service 未配置,`users_unavailable`)。
- **审计**:`system.revoke_keys`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/system/revoke-keys \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
```
### 5.32 POST /api/v1/admin/system/rotate-jwt
- **Body**(`rotateJWTRequest`):`{"newSecret":"<≥32 位>"}`(必填)。
- **响应**:`200 {"data":{"rotated":true,"refreshTokensRevoked":<int>,"note":"现有 token 与 refresh token 已全部失效,需重新登录"}}`(`refreshTokensRevoked` = 一并删除的 refresh token 行数)。
- **错误**:`400`(newSecret 缺失 / 长度 <32,`"新密钥长度不足 32 位"`);`500`(认证服务未配置 / 轮换失败)。
- **审计**:`system.rotate_jwt`。**新密钥永不回显、不落日志**。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/system/rotate-jwt \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"newSecret":"a-32-char-long-random-secret-12345678"}'
```
### 5.33 POST /api/v1/admin/system/backup
- **Body**:无。
- **响应**:`200 {"data":{"path":"<dataDir>/backup-<ts>.db","size":<字节数>}}`。经 SQLite `VACUUM INTO`(含已提交 WAL)快照到数据目录。
- **错误**:`500`(DB 路径未配置 `backup_unavailable` / 备份失败)。
- **审计**:`system.backup`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/system/backup \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
```
### 5.34 POST /api/v1/admin/system/restore
- **Body**(`restoreRequest`):`{"path":"<dataDir 内的备份文件>"}`(必填)。
- **响应**:`200 {"data":{"restored":true,"backup":"<恢复前自动快照路径>","note":"已安排恢复,需重启服务后生效"}}`。**流程**:校验 path 在 dataDir 内(`filepath.Rel` 防路径穿越,`..` 逃逸回 400)→ 文件存在校验 → **先自动备份当前 DB** → 复制替换 DB 文件 → 返回(替换后需重启服务生效,因运行进程持有 DB 句柄)。
- **错误**:`400`(path 缺失 / 逃逸数据目录);`404`(备份文件不存在);`500`(DB 路径未配置 / 恢复前备份失败 / 替换失败)。
- **审计**:`system.restore`。curl:
```bash
curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"path":"/abs/path/data/backup-20260908T000000.000000000Z.db"}'
```
### 5.35 GET /api/v1/admin/system/storage(v0.60,只读)
- **鉴权**:**owner**(`ownerAuth`,与四条 `POST /system/*` 动作端点同门)。无凭据 / 非 owner → `401`。
- **Body / query**:无。
- **性质**:**只读查询**,**没有审计动作**(审计只覆盖动作)。实现只有 `SELECT`/`PRAGMA` 与 `os.Stat`,且复用组合根注入的**活连接**(`Deps.Database`)——**不新开连接池、不写、不迁移、不备份**。调用前后 `documents` 行数与数据目录内文件集合均不变。
- **响应**:`200`
```json
{"data":{
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
"freelistPages":12,"freeBytes":49152},
"documents":{"count":37,"bytes":2143567},
"vector":{"kind":"none","enabled":false,"ready":false}
}}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `main.kind` | string | 恒 `"sqlite"`(当前唯一主库引擎) |
| `main.path` | string | 组合根实际打开的库文件路径(与备份/恢复端点同源) |
| `main.fileBytes` | int | 主库文件字节数(`os.Stat`) |
| `main.walBytes` / `main.shmBytes` | int | `<db>-wal` / `<db>-shm` 边车字节数;**合法地不存在时如实为 `0`**(已 checkpoint / 未开 WAL) |
| `main.pageSize` / `main.pageCount` / `main.freelistPages` | int | `PRAGMA page_size` / `page_count` / `freelist_count`(一条 SELECT 取完) |
| `main.freeBytes` | int | `pageSize × freelistPages`(已分配但空闲、VACUUM 可回收的量级) |
| `documents.count` | int | `documents` 表行数 |
| `documents.bytes` | int | `SUM(documents.size)`;空表为 `0`(`COALESCE`),**不是** `null` |
| `vector.kind` | string | `"pgvector"` = 已配置 `DB_PG_URL`;`"none"` = 未配置 |
| `vector.enabled` | bool | 增强开关在不在(等价于 `kind != "none"`) |
| `vector.ready` | bool | **活着的**向量索引确实是 PostgreSQL+pgvector 后端 |
- **`vector` 的读法(重要)**:`kind` 说「配没配 `DB_PG_URL`」,`ready` 说「现在真的在跑什么」。配了 PG 但连接/schema 准备失败 ⇒ `enabled:true` + `ready:false`(检索已降级),客户端应显示「已配置但未就绪」,**不要把 `enabled` 当成生效**。`kind:"none"` 时前端显示「未启用(未配置 `DB_PG_URL`)」。**真源** = 组合根 `cmd/cyrene/wire_frontend.go` 的 `app.vectorState()`(`DB_PG_URL` 非空 + 活索引的具体类型),网关不读环境变量、不新开 PG 连接、不自行探测。
- **不含凭据(红线)**:DSN / 用户名 / 密码 / `postgres://` 全文**一个字都不回**,`vector` 段只有 `kind`/`enabled`/`ready` 三个键(有用例断言响应体里搜不到这些串)。
- **错误**:`401`(无凭据 / 非 owner);`500 {"error":{"code":"storage_unavailable","message":…}}` —— 数据库**路径**未配置、活**连接**未注入、或页级统计 / `documents` 表读不到(例如库没跑迁移)。**诚实边界**:此时**不返回**任何全 0 的假快照。
- **前端落点**:`frontend/web/src/lib/api/adminSystem.ts` 的 `adminSystemStorage()` → 面板首页「存储」卡(`admin/modules/dashboard/overviewMetrics.ts`):`value` = `SQLite 12.3 MiB`、`hint` = WAL / 页数 / 空闲页、详情两行 = `documents N 件 · …` 与 `PG 增强 …`。取数走与其它已接线卡片同一条加载/错误态(失败显示端点错误,不回退成 `0`)。curl:
```bash
curl -s http://<host>:8080/api/v1/admin/system/storage \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
```
---
## 6. WebSocket 实时协议(`GET /ws/chat`)
> 唯一实时通道。**单二进制、单端口**:同一 `:8080` 上,REST/WS/admin 共用。生产者(loop/IoT/task/automation/reminder/subagent)经进程内 `bus` 发布,hub 订阅 `ws.>` 及桥接模式并规范化成 WS 帧广播给客户端。**上行与下行都有角色门(fail-closed)**:① **上行帧**——只有 `owner`/`admin` 能发,非 owner/admin 连接发来的**任何**上行 JSON 帧被定向拒为 `{type:"error",data:{code:"forbidden"}}`(`ws_route.go` 的 `route` 首条语句,默认拒,不广播/不驱动模型/不落库);② **握手 `history` 回放**——**仅 `owner`/`admin`**(`ws_history.go` 的 `replayHistory` 首行);③ **下行帧**——只有 7 个不含内容的状态机信号(含 `session_read`)发给所有已认证连接,其余全部(含 `proactive_message`)**仅 `owner`/`admin`**(`ws_frame_scope.go`)。**权威口径与帧清单见 `Docs/api-client.md` §3 顶部的两把角色门**;本节其余部分只描述帧形状,鉴权以 api-client 为准。
### 6.1 握手与连接
- **URL**:`ws://<host>:8080/ws/chat?token=<JWT>`(或 `Authorization: Bearer <JWT>` 头)。
- **方法**:必须 `GET`(否则握手前回 `400 {"error":{"code":"bad_request","message":"websocket requires GET"}}`)。
- **鉴权**:`s.auth.Verify(wsToken(r))`。`wsToken` 先取 `?token=`,再取 `Authorization: Bearer`。失败 → **握手前**回 `401 {"error":{"code":"unauthorized",...}}`(浏览器/IM 客户端收到干净拒绝)。**连接层不校验角色**(任意有效 JWT——含 `viewer`——都可连)。**但连接之后的帧按角色分档**:上行仅 `owner`/`admin` 可发、`history` 回放仅 `owner`/`admin` 可收、下行除 7 个状态机信号外仅 `owner`/`admin`(见本节开头与 api-client §3 顶部两把角色门)。
- **Origin 校验**:`CheckOrigin` 允许「同一 origin」+「同一 host」+ 白名单;`*` 全放行。同 host 放宽:production 内嵌前端页面 origin 即网关 Host,故 `PORT` 改变无需改 `.env`。**跨源客户端**(外部 IM / 另一 origin)需在 `ALLOWED_ORIGINS` 白名单。
- **升级后流程**:注册连接 → `replayHistory(c, "web")`(首帧推 `history` 回放)→ 启动 `writePump`(唯一写者,含 ping/pong)与 `readPump`(读客户端帧)。
- **心跳/限帧**:读上限 4096 字节/帧;写超时 10s;pong 等待 60s;ping 周期 54s。发送缓冲 32(慢客户端满则丢帧,生产者不阻塞)。
- **`history` 回放(首帧)**:`{type:"history", sessionId:"web", data:{messages:[historyMessage...]}}`——主会话最近 50 条(`wsReplayLimit=50`),**`thinking` 已剥离**;**仅 `owner`/`admin` 连接收到**(`replayHistory` 首行角色门,design/11 §7.2 A;`viewer` 连接建立后不收回放)。回放仅在 `history != nil` 时发生,失败静默跳过。
### 6.2 帧壳(Frame Shell)
- **下行(服务端→客户端)** `wsEvent`:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:---:|------|
| `type` | string | 是 | 事件类型(见 §6.3 表) |
| `turnId` | string | 否 | 一轮交换的关联 id(流式;`turn-<hex>`)。**v0.36 起结构化流帧(`message.*`/`thinking.*`/`tool_call.*`)同样带 `turnId`**(`broadcastStream` 从 `model.StreamEvent` 取,loop 在回合边界注入);v0.50 起这些帧的 `data` 内另带 `streamId`(见 §6.6)。**旧文曾说这条路径只有 `seq`、没有轮次标识,那是错的,已更正** |
| `seq` | int | 否 | hub 级单调递增序号,供客户端排序关联流式突发 |
| `sessionId` | string | 否 | 能确定会话的事件带上,客户端按它把帧路由到该会话的消息桶(v0.54 起非激活会话的帧不再丢弃;只有单槽忙态仍按激活会话判定,见 `api-client.md` §3.1/§3.2/§3.3);可观测推送三帧(`observability_log`/`observability_trace`/`observability_metrics`,v0.55)**不带**该字段,帧壳只有 `{type,data}` |
| `data` | any | 否 | 类型化负载(每个事件见 §6.3) |
- **上行(客户端→服务端)** `wsInbound`:
| 字段 | 类型 | 必填 | 说明 |
|------|------|:---:|------|
| `type` | string | 是 | 事件类型(见 §6.3 上行表) |
| `reqId` | string | 否 | 请求关联 id(当前服务端**不**回填,预留) |
| `data` | object | 否 | 每类型负载(raw JSON) |
### 6.3 上行事件(客户端 → 服务端)
> 由 `wsConn.route(in)` 按 `in.Type` 分派。未知类型回 `{type:"error", data:{message:"unknown event type"}}`。**角色门(design/11 §7.2,fail-closed)**:`route` 的**第一条语句**就是角色检查——非 `owner`/`admin` 连接发来的**任何**上行帧一律**定向**回 `{type:"error",data:{code:"forbidden"}}`,不广播、不驱动模型、不落库;因此下表**只对 owner/admin 连接生效**(`viewer` 连上也发不出任何一条)。权威帧清单与版本记录见 api-client §3。
| type | 数据负载 | 服务端行为 | 结果 |
|------|----------|-----------|------|
| `mode_switch` | 任意(如 `{"mode":"chat"}`) | 发布 `ws.mode_switch` → hub 规范化广播 | 下行 `mode_switch`(广播) |
| `chat` | `{sessionId?, text, image?, deviceId?, mode?, clientMsgId?, documents?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate`。**`documents?`(v0.59)** = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 `error` 帧带 `data.code` 并中止本回合) | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播);重复时下行 `chat_duplicate` |
| `interrupt` | `{sessionId, scope?}` | 按 `scope` 中断(**v0.56/A2**):省略 / `""` / `"all"` = 取消在跑 turn + **清空 pending**(现状语义);`"current"` = **只取消在跑 turn、保留 pending**(队列按编排器续跑规则继续执行);**其它取值 → 定向回 `error{bad_request}`,不打断任何会话** | 下行 `session_interrupted`(`{sessionId,mode,dropped}`;`dropped` = **真的被丢弃**的 pending 条数,`scope:"current"` 时为 `0` 且 `mode` 为当时的忙碌通道) |
| `voice_input` | `{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?}` | STT→**会话编排器**→结算时 TTS(缺省 sessionId=`web`);`deviceId` 落库、`platform` 走存储默认 `web`;**`clientMsgId?`(v0.59)** 非空且重复到达 → 回下行 `chat_duplicate`,**不产生新回合、不发音频** | 下行 `voice_output`(**定向回请求连接**,回合结算时才到);与文本同一条编排路径(忙时排队 / 跨端同步 / 落库) |
| `subagent_ask` | `{id, mode?(""/full/tail/keyword), n, query?}` | 审计子代理上下文(缺省 `mode`=full) | 下行 `subagent_event`(或 `error`) |
| `task_output_query` | `{taskId}` | 查询后台任务输出 | 下行 `task_output`(或 `error`) |
**上行各负载字段**
- **`chat`**(`chatStreamRequest`):`{sessionId`(缺省`"web"`), `text`, `image?`(base64 data: URI 或 URL), `deviceId?`, `mode?`(行为模式,缺省回落 `chat`), `clientMsgId?`(客户端幂等键,**v0.56/G4**:同一会话内同一非空值只承认第一条,重复到达回下行 `chat_duplicate` `{sessionId,clientMsgId,messageId}`,且**不落库、不起回合**;省略 = 老行为逐字不变), `documents?`(文档附件引用数组,**v0.59**:`[{name}]`,≤5 条、只收服务端存储名;超出 `too_many_documents`、名非法/不存在 `document_not_found`,两处入口共用同一份解析)}。text / image / documents 至少一项非空;`runner` 未注入或三者皆空 → 直接广播 `stream_end{stopReason:"idle"}`。主会话 `web` 绑定全量 env(`loop.OwnerEnv()`)。`chat` 进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。**仅 owner/admin 连接可发**(见本节角色门)。
- **`interrupt`**(**v0.56/A2**):`{sessionId, scope?}`。`scope` 省略 / `""` / `"all"` = 取消当前任务 + 清空待处理队列(现状语义);`"current"` = **只取消当前任务、保留待处理队列**(队列按编排器续跑规则继续执行);**其它取值 → 定向回** `{type:"error",data:{code:"bad_request"}}`,**不广播、不打断任何会话**。命中空闲会话为 no-op(两种 scope 都是)。
- **`voice_input`**:`{audio`(base64 或 data: URI), `format`(`wav|mp3|ogg|webm|mpeg`,空由 mime 猜,默认 `wav`), `text`(已转写则跳过 STT), `sessionId?`, `deviceId?`, `clientMsgId?`}。**v0.59**:`deviceId` 随语音轮次落库(`platform` 走存储默认 `web`)、`clientMsgId` 为**可选幂等键**(重复到达回下行 `chat_duplicate`:不产生新回合、不发音频);**语音回合与文本回合走同一条编排路径**(落库 / 流式 / 跨端 / 忙时排队),音频在**回合结算时**定向回发起连接(`{audio,format,text,reply,sessionId,deviceId?,provider?}`)。无 `voice` 服务 → `{type:"error",data:{message:"voice unavailable"}}`。
- **`subagent_ask`**:`{id`(必填), `mode`(空/full/tail/keyword), `n`(tail 查询窗长), `query`(keyword 查询词)}。无 auditor → `error`;id 缺失 → `error`。
- **`task_output_query`**:`{taskId}`。无 task store → `error`;运行中任务返回截至目前输出;输出拼接 `stdout + "\n" + stderr`。
### 6.4 下行事件全表
> **角色门(design/11 §7.1,fail-closed)**:下行帧按类型分档,收口在 `internal/services/gateway/ws_frame_scope.go` 的 `frameReachesAll`(**唯一一处判定**,各调用点不自行决定可见性)。**只有 7 个不含内容的状态机信号**发给**所有已认证连接**(含 `viewer`):`session_busy` / `pending_ack` / `pending_drain` / `session_idle` / `session_interrupted` / `pending_full` / **`session_read`**。**其余全部只发 `owner`+`admin`**:流式内容(`message.*` / `thinking.*` / `tool_call.*` / `tool_execution.update` / `stream_chunk` / `stream_end`)、持久化(`message_persisted` / `message_edited` / `message_deleted` / `channel_persisted`)、系统内部(`graph_update` / `subagent_event` / `task_output` / `task_done` / `tool_used` / `capacity_awaken` / `mode_switch`)、私有状态(`mood` / `device_update` / `reminder` / `proactive_message`)、**可观测推送(`observability_log` / `observability_trace` / `observability_metrics`,v0.55)**、**`chat_duplicate`(v0.56/G4:虽由编排器状态通道下发,但未进白名单)**、**`steer_ack`(v0.57:忙时排队消息被在飞回合吃进同一回合的回执,携 `messageId`/`atStep`,未进白名单 ⇒ fail-closed 只发 `owner`+`admin`;帧壳带 `sessionId`、不带 `turnId`/`seq`,经 hub 直接广播、不经 bus)**;**未登记的新帧类型默认只发 owner/admin**。**本表并非全表**:`session_read` / `message_edited` / `message_deleted` 等已实现事件尚未逐行登记于此;**权威清单与逐帧口径见 `Docs/api-client.md` §3**。
>
> **来源标记**:`[direct]` 网关直接 emit;`[relay]` hub 从 bus `ws.>` 规范化(任意 `ws.X` 主题 → 类型 `X`);`[bridge]` 桥接 producer 主题;`[target]` 仅定向特定连接。
| type | 来源 | 数据(`data`) | 用途 |
|------|------|----------------|------|
| `history` | direct | `{messages:[historyMessage]} ` | 连接首帧回放主会话最近 50 条(thinking 剥离);**仅 owner/admin 连接收到**(`viewer` 不收) |
| `message.start` | direct | `{index}` | 正文段开始 |
| `message.delta` | direct | `{delta,index}` | 正文 token 增量(累加到当前正文) |
| `message.end` | direct | `{index}` | 正文段结束(不结束整轮,由 `stream_end` 收尾) |
| `thinking.start` | direct | `{index}` | 思维链段开始(前端折叠块) |
| `thinking.delta` | direct | `{delta,index}` | 思维链 token 增量 |
| `thinking.end` | direct | `{index}` | 思维链段结束 |
| `tool_call.start` | direct | `{id,index}` | 工具调用中间态开始 |
| `tool_call.delta` | direct | `{id,index,delta}` | 工具调用参数增量(如 `input_json_delta`) |
| `tool_call.end` | direct | `{id,index}` | 工具调用中间态结束 |
| `tool_execution.update` | direct | `{id,index,delta}` | 工具执行进度/局部输出 |
| `stream_chunk` | direct | `{delta}` | **向后兼容整段文本**(仅非流式回退路径;流式路径不 emit) |
| `stream_end` | direct | `{stopReason, steps?}` / `{stopReason:"idle"}` / `{stopReason:"error", error}` | 一轮结束 |
| `session_busy` | direct(编排) | `{sessionId, mode}` | 会话进入忙碌态(mode∈idle/thinking/executing/subagent) |
| `pending_ack` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙时新消息已入队「收到,稍后回」 |
| `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth}` | 忙完开始消费下一待处理消息 |
| `session_idle` | direct(编排) | `{sessionId, mode}` | 会话回到空闲、队列清空 |
| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped}` | 用户 `interrupt`。**v0.56/A2**:帧形状零变化——`scope:"all"`(省略 / 空串同义)= 任务取消 + pending 清空(`mode=idle`、`dropped`=清空条数);`scope:"current"` = **只取消任务、保留 pending**(`dropped` 恒 `0`、`mode` 为当时忙碌通道) |
| `pending_full` | direct(编排) | `{sessionId, mode, max, clientMsgId?, messageId?}` | 单会话 pending 达上限(默认 5)拒绝入队。**v0.59/G5 纯增量两键**:`clientMsgId` = **被拒那条消息**的幂等键(该消息未携带则整个键缺席);`messageId` = **仅当该消息此前已落库/已认领时**才回填——**绝不在此处新铸 id**(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。**另**:同一时刻向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",message,sessionId,clientMsgId?,messageId?}}`(只发发送者、不广播;`code` 与 REST `409 chat_busy` 同码) |
| `chat_duplicate` | direct(编排) | `{sessionId, clientMsgId, messageId}` | **v0.56/G4(新增帧)**:同一会话内**重复到达**的 `clientMsgId` 的明确回执——**不落库、不起回合、不发任何内容帧**。帧壳带 `sessionId`;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(发过该 `clientMsgId` 的客户端用它把本地乐观气泡对齐到既有 `messageId`,其它客户端按值自行忽略) |
| `steer_ack` | direct(编排) | `{messageId, atStep}`(帧壳带 `sessionId`,`data` 内不重复该键) | **v0.57(新增帧)**:忙时会话里新到的用户消息**已被在飞回合吃进同一回合**(已从 pending 移除、**尚未被正面回答**)——`messageId` = 那条用户消息的**权威 id**(与 `message_persisted` 的 `data.message.id` 同源)、`atStep` = 吃进它的**模型步序号(1 基)**;一批吃进 N 条发 N 帧(不合并)。客户端把对应气泡渲染为「已读、未答」的**灰态**,直到该回合的 `message.*`/`turn.end` 到达(不需要第二个确认帧)。**只在回合内边界**产生(一次模型步与其工具批次都已结束、下一次模型调用尚未开始;无匹配消息则一帧不发);**不带** `turnId`/`seq`,经 hub 直接广播、**不经 bus**;**未进 7 个状态机信号白名单 ⇒ fail-closed 只发 `owner`+`admin`**(`viewer` 收不到)。详见 api-client §3.14 |
| `message_persisted` | direct | `{message:historyMessage}`(sessionId 设) | 每条 user/assistant 消息落库后广播(主会话 `web`/`web:<uuid>`),thinking 剥离 |
| `channel_persisted` | direct | `{message:historyMessage}`(sessionId=`sess-xxx`) | 群聊/陌生人(非主人)消息落库后广播,供 IM 适配器日志页 |
| `voice_output` | direct/target | `{audio, format, text, reply, sessionId, deviceId?, provider?}` | 语音合成回复(**定向回发起 `voice_input` 的那条连接**;**不按 `deviceId` 路由**、断线**不补投**)。**v0.59 纯增量**:`sessionId`(恒有)与 `deviceId`(非空才有)标注归属;`provider` 在**空正文**或 **TTS 失败/降级为空音频**时出现(如 `"silent"`),该帧仍照常发出、不是业务错误。**时点**:该回合**结算时**才到(可能晚于若干状态帧,也可能由续跑或被 steer 注入的那一回合产生) |
| `subagent_event` | direct+relay | 审计结果 或 `{id,name,status,role,mode}` | 子代理上下文查询应答 / 子代理进度 |
| `task_output` | direct+bridge | `{taskId, output, status}` | 后台任务输出查询应答 / task.output 桥接 |
| `capacity_awaken` | direct | `{fromState, toState, tool?}` | 能力状态迁移(design/14 §5,`sessionId` 设) |
| `proactive_message` | direct | `{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}` | 自主主动推送(仅 owner/admin 连接收到) |
| `device_update` | bridge | producer payload | IoT 设备状态更新(`iot.device_update`) |
| `task_done` | bridge | producer payload | 后台任务完成(`task.done`) |
| `automation_result` | bridge | producer payload | 自动化规则结果(`automation.result`) |
| `reminder` | bridge | producer payload | 提醒触发(`reminder.trigger`) |
| `mode_switch` | direct | 上行原样(任意) | 行为模式切换回显(`ws.mode_switch`) |
| `mood`/`tool_used`/`graph_update`/… | relay | `ws.X` 主题负载(`ws.mood`→`mood`,`ws.tool_used`→`tool_used`,`ws.graph_update`→`graph_update`,…) | 生产者在 bus 上发 `ws.X` 即规范化成类型 `X` 广播 |
| `observability_log` | bridge | `{entries:[{time,level,source,msg}…]}`(最新在前) | **可观测推送(v0.55)**:订阅后新增日志,≤1s 一拍最多一帧、无新条目不发;单帧上限 200 条;**不带 `sessionId`** |
| `observability_trace` | bridge | `{spans:[AdminTraceSpan…]}`(最新在前) | **可观测推送(v0.55)**:订阅后新完成的 span;单帧上限 100 条;**不带 `sessionId`** |
| `observability_metrics` | bridge | `<AdminMetrics>`(扁平,与 REST 同形) | **可观测推送(v0.55)**:与上次发出的不同才发;**不带 `sessionId`** |
| `error` | direct | `{message}` / `{code, message, sessionId?, clientMsgId?, messageId?}` | 非法 JSON / 未知类型 / 语音·子代理·任务错误等。**v0.59 新增一种定向用法**:队列满时向**发送者**回 `{code:"chat_busy", …}`(G5,见上表 `pending_full` 行);`chat` 帧的文档引用被拒时回 `{code:"too_many_documents"|"document_not_found"|"chat_documents_unavailable", sessionId}` 并中止本回合 |
> **`auth`**(旧 design/07 列表):当前**不**由网关直接 emit;若未来有 `ws.auth` 生产者主题,则按 `[relay]` 规则广播。本文档以代码为准。
**`message_persisted` / `channel_persisted` / `history` 的 `historyMessage` 全字段**(含 `thinking` 剥离规则):
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | string | 后端生成消息 id(客户端去重键) |
| `sessionId` | string | 会话 id |
| `role` | string | `user` / `assistant` |
| `content` | string | 正文 |
| `mode` | string | `omitempty` |
| `mood` | string | `omitempty` |
| `image` | string | `omitempty` |
| `documents` | array | **`omitempty`(v0.59)**:`[{name,mime,size,chars,truncated,status}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`;**不含正文**。⚠️ `chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**文件是否被截断;全文长度只有 `document-read` 的 `totalChars` 可见)。同一字段也出现在 `GET /sessions/{id}/messages`(§3.4)与 `message_edited`;单数 `document` 形状**从未随任何已发布版本发布** |
| `thinking` | string | **`omitempty`,但在这些实时帧中恒为空**(`BroadcastPersisted`/`replayHistory` 均置空;`/messages` owner/admin 端点才带)。**v0.59 口径(有意默认)**:实时帧与握手回放的该字段**一律剥离**;实时思维链另走 `thinking.*` 流帧(owner/admin 档),**历史思维链只有 owner/admin 的历史端点返回** |
| `platform` | string | `omitempty`(`web`/`telegram`/`weixin-ilink`/`qq-obv11`...) |
| `deviceId` | string | `omitempty` |
| `senderName` | string | `omitempty`(第三方昵称,主人私聊为空) |
| `adapterId` | string | `omitempty`(仅 `channel_persisted` 常见,如 `kook:3bef...`) |
| `personaId` | string | `omitempty`(**v0.42**:该消息说话时的角色 id——`assistant` 消息 = 生成它的角色、`user` 消息 = 当回合角色;空 = 全局默认角色。与 `GET /sessions/{id}/messages` 同源字段,客户端按角色渲染名字/头像) |
| `clientMsgId` | string | `omitempty`(**v0.56/G4**:客户端自带的幂等键——由 `chat` 上行带 `clientMsgId` 的 user 消息写入,之后在 `message_persisted` / `channel_persisted` / `history` 回放 / `GET /sessions/{id}/messages` 中原样回填;空 = 无幂等键,即老客户端 / IM 镜像 / 历史行) |
| `createdAt` | int64 | Unix 毫秒 |
**事件类型分流**(`BroadcastPersisted`):`sessionId == "web"` 或以 `web:` 前缀 → **`message_persisted`**(Web 聊天实时同步);其它(隔离 `sess-xxx`)→ **`channel_persisted`**(IM 适配器日志专用)。Web 聊天无 `channel_persisted` handler,天然忽略。
### 6.5 会话编排状态(design/15,`data.sessionId` 区分)
- **语义**:同一会话 `chat` 到达时若「她忙」(跑 loop/工具/子代理/长回复)→ 新消息**不阻塞**、入该会话 pending(上限默认 5);状态经上述 `session_busy`/`pending_ack`/`pending_drain`/`session_idle`/`session_interrupted`/`pending_full` 推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。
- **回合内 steer(v0.57)**:pending 里的消息**不是只能等下一个回合**——在飞回合会在**回合内边界**(上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始)把它那批已落库的消息**注入同一回合**(**绝不新开第二个回合**),并逐条下发 `steer_ack`(§6.4)告知「**队列里哪一条已被本回合吃进**」(`messageId` 级;吃进=已从 pending 移除,但**尚未被正面回答** ⇒ 前端呈灰态)。`pending_ack`/`session_busy` 描述**队列深度与忙闲**,`steer_ack` 描述**哪一条被吃进**;无可注入消息的边界不发任何帧。详见 api-client §3.14。
- **续接**:忙完重开时新回合**带上上一轮上下文**(`tail` 默认 20 条),非清空重来。
- **mode** 取值:`idle` / `thinking` / `executing` / `subagent`。
- REST 同步路径(§3.1)在忙时返回 `202 queued` / 满 `409 chat_busy`,与这些 WS 状态事件一致。
- **队列满的身份回填与定向回执(v0.59/G5)**:`pending_full` 的**广播**载荷增 `clientMsgId?` / `messageId?`(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者**仅当该消息此前已落库/已认领**时回填,**绝不新铸 id**);同时向**发送者那条连接**定向回 `{type:"error",data:{code:"chat_busy",…}}`(`code` 与 REST `409 chat_busy` **同码**,客户端一个 code 覆盖两条入口;只发发送者、不广播)。详见 api-client §3.2。
### 6.6 流式关联(`turnId` / `seq`)
- `turnId`:`runTurn` 每次生成 `turn-<hex>`(crypto/rand),用于关联一轮流式突发;`stream_end`/`stream_chunk`(回退路径)带 `turnId`。**结构化流路径(`message.*` / `thinking.*` / `tool_call.*`)自 v0.36 起同样带 `sessionId` + `turnId`**——`broadcastStream` 直接把 `model.StreamEvent` 的 `SessionID`/`TurnID` 填进帧壳,loop 在回合边界把 `turnId` 挂进运行上下文(同轮所有帧一致)。**旧文曾说这条路径只有 `seq`、缺轮次标识,是错的,本版按实现更正**(与 api-client 的 v0.36 条目一致)。v0.50 起这些帧的 `data` 内另带 `streamId`(同一次模型流的多帧共享,多步工具轮的第二次模型调用取新 id);`tool_execution.*` 与 `turn.start/end` 帧**不带** `streamId`。
- `seq`:hub 级单调递增 `atomic.Uint64`,保证一次突发有序,即便 loop 多 goroutine 触发。
### 6.7 WS 示例
**握手 + 收 `history` + 发 `chat` 收流式**(伪代码/curl-like):
```bash
# 1) 拿 token
TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}' | jq -r .data.token)
# 2) 连接(浏览器用 ws = new WebSocket('ws://host:8080/ws/chat?token='+TOKEN))
# 首帧:{"type":"history","sessionId":"web","data":{"messages":[...]}}
# 3) 上行 chat
# {"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"web:chrome:a1b2c3"}}
# 下行(流式回退):{"type":"stream_chunk","turnId":"turn-abc123","seq":1,"sessionId":"web","data":{"delta":"你"}}
# ... {"type":"stream_chunk",...} ...
# {"type":"stream_end","turnId":"turn-abc123","seq":8,"sessionId":"web","data":{"stopReason":"stop","steps":2}}
# 4) 上行 interrupt
# {"type":"interrupt","data":{"sessionId":"web"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"idle","dropped":2}}
# v0.56/A2:只打断当前那一轮、保留待处理队列 → scope:"current"(此时 dropped 恒 0、mode 为当时忙碌通道,如 thinking)
# {"type":"interrupt","data":{"sessionId":"web","scope":"current"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"thinking","dropped":0}}
# 非法 scope:{"type":"interrupt","data":{"sessionId":"web","scope":"cancel"}}
# 定向回发送者:{"type":"error","data":{"code":"bad_request"}}(不广播、不打断任何会话)
# 5) 上行 voice_input
# {"type":"voice_input","data":{"audio":"<base64>","format":"wav","sessionId":"web"}}
# 下行(定向):{"type":"voice_output","data":{"audio":"<base64>","format":"wav","text":"你好","reply":"你好呀"}}
# 6) 上行 subagent_ask
# {"type":"subagent_ask","data":{"id":"sub-1","mode":"full"}}
# 下行:{"type":"subagent_event","data":{...审计结果...}}
# 7) 上行 task_output_query
# {"type":"task_output_query","data":{"taskId":"task-1"}}
# 下行:{"type":"task_output","data":{"taskId":"task-1","output":"...","status":"done"}}
```
---
## 7. 上传 / 媒体说明
> ⚠️ **本节曾长期写「无 `/upload` 端点 / 无 REST 语音端点」,与实现恰好相反,已于 2026-10-03 修正**——正确口径见上文 §0.3 与 api-client §1 行 10–14。
- **`POST /api/v1/upload` 存在**(owner/admin + 审计):multipart `file`(光栅图片,5MB 上限)→ `201 {"data":{"url","size","mime","name"}}`;`url` 为**同源访问地址** `/api/v1/files/<name>`,可直接作 `chat.image`。
- **`GET /api/v1/files/{name}` 存在**(**无需鉴权**——128-bit 随机能力 URL,`<img>`/`chat.image` 无法带 Bearer):从 `<dataDir>/uploads/` 读该文件;`name` 仅接受「32 位小写 hex + 白名单扩展名」,**路径穿越全形态拒绝**。契约见 api-client §1 行 14。
- **REST 语音端点存在**(均 owner-only + 审计):`POST /api/v1/audio/{transcribe,speech,voice-clone}`,供原生/移动端绕开 WS 4KB 帧限;语音**也可**走 WS 上行 `voice_input` / 下行 `voice_output`(定向回请求连接)。
- **图片 + 文本并存**:`image` 非空时 turn 变为「带图 + 可选文本」;`text` 空 + `image` 有 → 纯图像 turn。
- **文档导入**:走 `POST /api/v1/admin/rag` 的 JSON `content` 文本,**不是**文件上传。
- **敏感面**:`image`(base64)与 `bot_token`/`apiKey`/`newSecret` 等概不作为日志明文输出;wire 响应对 token/key 做掩码或不返回。
---
## 8. 附录
### 8.1 通用错误码 / 状态码
| 状态码 | code(示例) | 说明 |
|:---:|------|------|
| 200 | - | 成功 |
| 201 | - | 创建成功(personas/子会话/apikey/automations/llm/channels config/memories/rag/documents) |
| 202 | - | 已接受(chat 排队 `queued:true,queueDepth`) |
| 204 | - | CORS 预检 OPTIONS 成功无内容 |
| 400 | `bad_request` / `invalid_cron` / `invalid_rule` / `invalid_role` / `unsupported_action` / `invalid_param` / `invalid_provider` / `invalid_scope` / `too_many_documents`(v0.59) | 请求体/参数非法 |
| 401 | `unauthorized` | 认证缺失/失效/过期(REST Bearer 与 WS) |
| 403 | `forbidden` | 角色不足(viewer 读管理/主会话删除/写操作);主会话不可删 |
| 404 | `not_found` / `document_not_found`(v0.59) | 资源不存在(文档:名字非法 / 路径穿越 / 文件不存在**同一响应**) |
| 409 | `chat_busy` / `duplicate` / `device_offline` / `needs_login` | 冲突:队列满(**REST 响应体与 WS 定向 `error` 帧同码**,v0.59/G5)/ 人格已存在 / 设备离线 / 渠道未登录 |
| 413 | `document_too_large`(v0.59) | 文档超 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB),文案带生效上限字节数 |
| 415 | `unsupported_media_type`(v0.59;判定口径 v0.61) | 文档类型不在白名单——**扩展名白名单(23 项,含 `.pdf`)与字节嗅探的成对裁决**任一不过(HTML/纯文本改名成 `.pdf`、或 PDF 改名成 `.txt` 都走它) |
| 500 | `internal` / `chat_failed` / `sessions_failed` / … | 服务端错误(含未注入依赖) |
| 501 | `not_implemented` | 后端**确定未实现**(新增用户 / IoT 场景·规则 / 编辑环境变量·重启服务·检查更新 / 完整会话转录导出等)——诚实返回,不伪造成功。**注:插件安装(§5.26)、知识库删除(§1.3 #23)、主人档案保存(`PUT /api/v1/admin/owner-profile`,§1.3 #67)均已实现,不再返回 501** |
### 8.2 依赖注入与「未配置」行为
网关依赖(`Deps`)大多为 **nil 容忍**,未注入时端点要么回退、要么**如实报错**,绝不伪造成功:
- `History` 未注入:`GET /sessions` 回退渠道会话视图;`/messages`、`POST /sessions`、`DELETE /sessions/{id}`、`/channels/messages` 回 `501 not_implemented`。
- `RAG` 未注入:`GET /admin/rag` 返回空文档列表;`POST /admin/rag`、`/admin/rag/delete` 回 `501`。
- `Runner(Loop)` 未注入:`/chat` 回 `500 chat_unavailable`;WS `chat` 直接广播 `stream_end{stopReason:"idle"}`。
- `Channels` 未注入:`/admin/channels/config` 回 `500 channels_unavailable`。
- `LLM` 未注入:`/admin/llm` 按 `.env` 合成单组;`POST /admin/llm` 回 `500 llm_unavailable`;`POST /admin/llm/reset-from-env`(v0.62)同样回 `500 llm_unavailable`(不假装重置成功)。
- `Deps.EnvPath`(v0.62):组合根注入的启动用 `.env` 路径,**只**供 `POST /admin/llm/reset-from-env` 按既有解析入口重读 env 默认层(网关不 fopen `.env`);为空 = 只看进程环境变量(生产 systemd/env_file 形态)。`.env` 读不出(例如路径是个目录)→ `500 env_defaults_unreadable`,**不改任何文件**。
- `Voice` 未注入:WS `voice_input` 回 `error {message:"voice unavailable"}`。
- `Scheduler(auditor)` 未注入:WS `subagent_ask` 回 `error {message:"subagent audit unavailable"}`。
- `Tasks` 未注入:WS `task_output_query` 回 `error {message:"task output unavailable"}`。
- `AuditStore` 未注入:审计退化为进程内存(端点仍返回数据)。
- 管理员**只读**端点若依赖缺失,大多返回 `[]`,不 500。
### 8.3 审计动作清单(`what` 字段,落 `audit_logs`)
`policy.set` / `persona.create` / `persona.update` / `user.create` / `apikey.issue` / `apikey.revoke` / `iot.control` / `iot.scenario` / `iot.rule` / `automation.create` / `automation.run` / `automation.delete` / `llm.addProvider` / `llm.test` / `llm.resetFromEnv`(v0.62)/ `channel.open` / `channel.close` / `channel.identity-test` / `channel.weixin.login.qr` / `channel.weixin.login.status` / `channel.weixin.login.persist` / `channel.platform.view` / `channel.friends` / `channel.friend.handle` / `channel.message.test` / `channel.save` / `channel.delete` / `plugin.install` / `plugin.uninstall` / `memory.add` / `memory.search` / `rag.index` / `rag.query` / `rag.delete` / `session.export` / `system.revoke_keys` / `system.rotate_jwt` / `system.backup` / `system.restore`。
(`what` 后缀 `_failed/_persist_failed/_reload_failed` 等计入 `error` 分支。)
### 8.4 里程碑与未实现集合(诚实标注)
- **已真实实现**:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/**文档存储与摄取(v0.59:`POST`/`GET /api/v1/documents` + `chat.documents` + `documents` DTO + `document-read` 工具;**v0.61:PDF 文本层抽取**,白名单 23 项含 `.pdf`、成对裁决、扫描件诚实拒绝)**/**语音回合进编排(v0.59:落库 / 流式 / 跨端 / 忙时排队,`voice_output` 归属键)**/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试·**重置为 env 默认(v0.62,owner-only)**/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(`GET`/`PUT /api/v1/admin/owner-profile`,v0.51)/系统设置 4 项)。
- **`thinking` 可达性(v0.59 口径,不改行为)**:消息体 `thinking` 字段在**实时帧**(`message_persisted`/`channel_persisted`/`message_edited`)与**握手回放**中**一律剥离**(有意默认);**实时思维链**走 `thinking.*` 流帧(owner/admin);**历史思维链**只有 `GET /sessions/{id}/messages`(owner/admin)返回。
- **仍为后置 M10(返回 `501 not_implemented`,不伪造)**:新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。**文档侧后置**:配额与引用计数/GC(design/24 S7)、**Office 解析(`.doc`/`.docx`/`.xls`/`.xlsx`/`.ppt`/`.pptx`/`.odt`/`.ods`/`.odp`/`.rtf`,仍诚实拒绝)**、**扫描件/图片型 PDF 的 OCR**(v0.61 已做文本层 PDF,无文本层者如实报 `unreadable`)、IM 侧 `msg.Media` 摄取(`channelbridge` 只取 `msg.Content`)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态;客户端在 DTO 里看到的是 `documents[].status = "unreadable"`)。
- **语义/向量增强**:`memory.search` 的 `semantic` 当前恒为 `false`——**向量索引已由组合根注入**(`cmd/cyrene/wire.go` 的 `memory.WithVectorIndex`),但**索引内当前无向量**(生产无写入),故语义打分返回空,属诚实反映而非伪造;RAG `embedded` 反映部署是否配置向量增强;**配置了增强必须真实走语义/向量路径(红线,非空壳)**。
- **API key 认证**:`X-API-Key` **已接入鉴权**(v0.51,见 §0.1 注与 api-client.md §认证)——命中签发表即通过、一律 `owner` 级(取消 role 分级),带该头时只走 key 分支(坏 key 回 `401`、不回落 Bearer);签发/吊销/列表同样真实生效。
### 8.5 客户端接入快速索引
1. `POST /api/v1/auth/login` → `{token, expiresAt, role}`(恒 `owner`)。
2. 之后每个 REST 请求带 `Authorization: Bearer <token>`;管理写需 `owner`;管理读需 `owner|admin`;`GET /sessions`、`GET /personas` 任意已认证。
3. 实时:`ws://<host>:8080/ws/chat?token=<token>`;首帧 `history` 回放(**仅 `owner`/`admin` 连接收到**);上行 `chat`/`interrupt`/`voice_input`/`subagent_ask`/`task_output_query`/`mode_switch`(**仅 `owner`/`admin` 连接可发**,`viewer` 发送一律被定向拒);下行见 §6.4 与 api-client §3。
4. 身份判定永远来自后端 channel 层 actor 元数据 + 白名单(`configs/users/owner.yaml`),**客户端不要用提示词/文本判身份**;`thinking` 在公开回放/实时帧中已剥离,客户端不应依赖。
5. 发送消息带 `deviceId`(推荐)以便端标识落库;`platform` 用于 IM 渠道过滤。