旧版积了 v0.28→v0.65 的历史条目、「契约版本」标题并列、双变更记录、mtime 落后等结构性问题; 归档保留完整历史(含 §5 变更记录),新文件将从零重写,只留最近若干版并指向本归档。
1500 lines
166 KiB
Markdown
1500 lines
166 KiB
Markdown
# Cyrene v2 后端 API 参考(api-reference.md)
|
||
|
||
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.65`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 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);**v0.65**:可选 `parallel:true`(忙时显式声明把本条作为**独立一路**;照准则同步入口 `202 {parallel:true,turnId}`、`stream:true` 的 `200 {stream:true,parallel:true,turnId}`;未照准 / 省略时形状不变) | 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/**404** | 会话消息热分页;**v0.63**:会话**不存在/已删除** ⇒ `404 session_not_found`,**存在但没有消息仍 `200 {"data":[]}`**(见 §3.4) | 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 |
|
||
| 71 | GET | `/api/v1/admin/llm/usage` | owner\|admin | 200/400/500 | **LLM 用量汇总(只读,v0.64,切片 U1)**:`?since=&until=&purpose=`(RFC3339 或 Unix 毫秒;缺省近 24h;跨度上限 90 天,超过 → `400`)→ `{window,totals{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose[],byModel[]}`;`hitRate = cacheHit/(cacheHit+cacheMiss)`,**分母 0 ⇒ 0**;**绝不含凭据与消息正文**;未接线/查库失败 → `500 usage_unavailable`(见 §4.19) | 后 M12 |
|
||
|
||
**另**:外部 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),同样取下一个可用号、既有编号未动。**v0.64 再补一行**:`#71` GET `/api/v1/admin/llm/usage`(**owner|admin 读**,切片 U1,按 `routes.go` 的 `writeAuth` 注册核对;**无审计动作**——它是查询不是动作),同样取下一个可用号、既有编号未动。**仍未登记的是 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) |
|
||
| `parallel` | bool | 否 | **独立一路的显式声明(v0.65)**:`true` 且会话正忙、单会话并发额度未满 ⇒ 本条**升为一条独立的并行路立即起**(不排队);空闲时无效果;**省略 = 与加入该字段前逐字不变**(服务端**不做**启发式自动升级)。额度:单会话默认 2、硬上限 3、`<2` = 关闭真并行(此时仍可传,只是落回排队并回 `pending_ack{reason:"parallel_limit"}`)。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,稍后按上下文续接)。
|
||
- 同步、**已升为独立并行路**(`SubmitSync` 返回 `Parallel`,**v0.65**,仅在客户端发了 `parallel:true` 且额度未满时出现)`202` `{"data":{"parallel":true,"turnId":"turn-…"}}`(**不是 `queued`**:第二条路立即起、不排队,本次请求拿不到同步 `reply`,文本照旧经 WS 流式到达;`turnId` = 那一路的权威回合标识、与该路全部流帧同值)。
|
||
- 同步且 **`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>}}`(**不落库、不起回合**)。;**v0.65**:已升为独立并行路 ⇒ `200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}`(仅 `parallel:true` 且额度未满时)。实际文本经 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}` = **会话注册表 `chat_sessions` 里可寻址的**会话 id(`web` 或 `web:<uuid>`)。**v0.63 起**:不在 `chat_sessions` 里的 id(已删除的次级会话、凭空编造的 id、**以及隔离的 IM 会话 `sess-xxx` 与内部 `sess-backthink`**)⇒ `404 session_not_found` —— 这与 `POST /sessions/{id}/read`(v0.37 起对隔离会话即 404)是同一口径;隔离会话的消息请走 `GET /api/v1/admin/channels/{platform}/messages`(§4.7)。⚠️ 旧文那句「IM 隔离会话 `sess-xxx` 亦可用」**已随 v0.63 作废**。
|
||
- **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,reason?}]`,**≤5 条**、顺序 = 客户端发送顺序;**0 个附件时该键省略**;`status ∈ extracted|listed|unreadable`。⚠️ **`chars`/`truncated` 是「本回合注入侧」事实**——`chars` = 本回合**注入**上下文的字符数、`truncated` = **注入**是否被截断(**不是**「文件是否被截断」);**全文长度**只有 `document-read` 的 `totalChars` 能看到。**不含正文**。**`reason`(v0.63,可选)**=**读不到的原因文案**,**只在 `status:"unreadable"` 时出现**(`extracted`/`listed` 时该键缺席);它**逐字就是注入模型的那份原因**(如 `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`),**只有文案**:不含路径、不含凭据、不含存储名以外的内容(原始错误只进日志)。同一字段也出现在 `message_persisted` / `channel_persisted` / `message_edited` / 握手回放(同一结构体、四处同时生效);⚠️ 实时帧在用户消息落库时发出,那时抽取未发生 ⇒ **`status`/`reason` 以读接口为准**。⚠️ 单数 `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` 同传、`q`+`after` 同传、`id` 缺失);**`404` `{"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}`(v0.63,行为变更)——会话不存在 / 已删除**(含不在 `chat_sessions` 的隔离 `sess-xxx`);**存在但确实没有消息仍是 `200 {"data":[]}`**;参数校验**先于**存在性判定;`501` `{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}}`;`500` `{"error":{"code":"messages_failed",...}}`(含存在性判定本身读不出来时 —— **不静默退化成空列表**)。
|
||
- **备注**:热分页(design/16 §2.2,§5.1)。`thinking` 只在本 owner/admin 端点可见;WS 回放/广播已剥离。**会话存活判据(v0.63)**:客户端用 **404(`session_not_found`)**判定「当前会话已不在」,据此退回主会话 + 一句提示;**不要**用「返回空数组」推断存活 —— 空数组现在**只**表示「这条会话存在但没有消息」。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,reason?}]`,`status ∈ extracted|listed|unreadable`,**0 个附件时省略该键**;⚠️ `chars` / `truncated` 是**本回合注入侧**事实(全文长度只有 `document-read` 的 `totalChars` 可见);**`reason`(v0.63)**只在 `unreadable` 时出现 = 读不到的原因文案(**逐字**等于注入模型那份,**只有文案、不含路径/凭据**)。
|
||
|
||
**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'
|
||
```
|
||
|
||
### 4.19 GET /api/v1/admin/llm/usage(v0.64,只读;切片 U1)
|
||
- **鉴权**:`Authorize(owner, admin)`(`routes.go` 的 `writeAuth`);**无凭据 → `401`**、**`viewer` → `403`**(与同页 `GET /admin/llm` 同门:它含成本与模型/用途分解,不是给 viewer 的监控投影)。**无审计动作**(查询不是动作)。
|
||
- **请求**:`GET /api/v1/admin/llm/usage?since=&until=&purpose=`
|
||
- `since`/`until`:**RFC3339**(`2026-10-05T00:00:00Z`)或 **Unix 毫秒**(纯数字,与 `/sessions/{id}/messages` 的 `before`/`after` 同口径);其它形态 → `400 bad_request`。
|
||
- **缺省窗口 = 近 24h**(`until` 缺省 = 现在,`since` 缺省 = `until − 24h`);`until` 必须严格晚于 `since`;**跨度上限 90 天**,超过 → `400`(**不静默裁剪**)。
|
||
- `purpose`:截断空白后精确匹配;空 = 不过滤。
|
||
- **响应**:`200`
|
||
```json
|
||
{"data":{
|
||
"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
|
||
"totals":{"calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
|
||
"byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
|
||
"byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
|
||
}}
|
||
```
|
||
- **字段**(逐字对齐 `internal/services/gateway/admin_llm_usage.go`):`window` 回显补齐后的 UTC RFC3339 时间窗与 `purpose`(`""` = 未过滤);`totals` 恒为六个键(`calls`/`prompt`/`completion`/`cacheHit`/`cacheMiss`/`hitRate`);`byPurpose[].purpose` 与 `byModel[].model` 是分组键,其余同上;**排序 = 调用次数降序、同次数按键升序**;无数据时两个数组是 `[]` **不是 `null`**。
|
||
- **`hitRate` 口径**:`cacheHit / (cacheHit + cacheMiss)`;**分母为 0 ⇒ `0`**(口径与 `model.Response.CacheHitRate` 同源)。客户端应把「分母 0」与「真 0% 命中」分开(前者显示 `—`)。
|
||
- **错误**:`400 bad_request`(时间形态非法 / `since` 不早于 `until` / 跨度超 90 天);`401`;`403`;`500 {"error":{"code":"usage_unavailable","message":"读取 LLM 用量汇总失败"}}`(汇总出口未接线或查库失败 —— **绝不回全 0 假汇总**)。
|
||
- **数据来源**:每次**成功**模型调用把一条非敏感样本异步落进 `llm_usage`(迁移 `0017`;写入口径见 §4.19 的契约与 changelog v0.64)——**有界队列 + 单写者 + 批量事务,队列满丢弃并限流 WARN,绝不阻塞模型调用**;**凭据与消息正文绝不入库**。✅ **`session_id` 已接线(v0.65 更正)**:loop 在 `Run` 顶部把会话 id 播种进 ctx、适配器落库时带上它(空串映射 `NULL`)——v0.64 写的「恒为 `NULL`」已过期;但本端点**仍不按会话过滤 / 分组**,响应形状不变。保留期 `LLM_USAGE_RETENTION`(默认 `720h`;`≤0` = 关闭清理)。
|
||
- **curl**:
|
||
|
||
```bash
|
||
curl -s 'http://<host>:8080/api/v1/admin/llm/usage?since=2026-10-04T00:00:00Z&until=2026-10-05T00:00:00Z' \
|
||
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
|
||
# => {"data":{"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},"totals":{...},...}}
|
||
```
|
||
|
||
---
|
||
|
||
## 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?, parallel?}` | **直达会话编排器的非阻塞 `Submit`(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 `ws.chat`)** → `streamChat`(跑 loop 并流式)。`clientMsgId`(**v0.56/G4**)非空且重复到达 → **不落库、不起回合**,只回下行 `chat_duplicate`。**`documents?`(v0.59)** = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 `error` 帧带 `data.code` 并中止本回合)。**`parallel?`(v0.65)** = 显式声明「把本条作为独立一路」:会话正忙且额度未满 ⇒ 立即起第二条路(忙态帧随之带 `activeTurns`);否则落回排队并在 `pending_ack` 带 `reason:"parallel_limit"`;空闲时无效果;**省略 = 逐字不变** | 下行 `stream_chunk`/`stream_end` 或 `message.*/thinking.*/tool_call.*`(广播);重复时下行 `chat_duplicate`;并行路照准时本轮帧的 `turnId` 即该路 |
|
||
| `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`,两处入口共用同一份解析),`parallel?`(独立一路的显式声明,**v0.65**:会话正忙且额度未满 ⇒ 立即起第二条路;省略 = 逐字不变,服务端不做启发式升级)}。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, activeTurns}` | 会话进入忙碌态(mode∈idle/thinking/executing/subagent)。**v0.65**:`activeTurns` = 该会话当前**在途路数**(含正在收尾的路;单路时非 0 即 1),纯增量 |
|
||
| `pending_ack` | direct(编排) | `{sessionId, mode, queueDepth, reason?}` | 忙时新消息已入队「收到,稍后回」。**v0.65**:可选 `reason:"parallel_limit"`,**只在客户端显式声明了并行(`parallel:true`)却落回排队时出现**(额度已满 / 真并行被 `<2` 关掉),其它情况该键缺席 |
|
||
| `pending_drain` | direct(编排) | `{sessionId, mode, queueDepth, activeTurns}` | 忙完开始消费下一待处理消息。**v0.65**:增 `activeTurns` |
|
||
| `session_idle` | direct(编排) | `{sessionId, mode, activeTurns}` | 会话回到空闲。**v0.65**:增 `activeTurns`;时点收紧为「**所有在途路收尾且 pending 空**」(**仅并行时生效**,单路逐字不变) |
|
||
| `session_interrupted` | direct(编排) | `{sessionId, mode, dropped, cancelledTurns, activeTurns}` | 用户 `interrupt`。**v0.56/A2**:`scope:"all"`(省略 / 空串同义)= 任务取消 + pending 清空(`mode=idle`、`dropped`=清空条数);`scope:"current"` = **取消全部在途路、保留 pending**(`dropped` 恒 `0`、`mode` 为当时忙碌通道)。**v0.65 纯增量两键**:`cancelledTurns` = 本次真的取消了几路;`activeTurns` = 清点后仍在途的路(`scope:"all"` 恒 `0`)。`scope:"all"` **不发 `session_idle`**(客户端靠本帧收口) |
|
||
| `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.65,基础管线)**:上行 `chat.parallel:true`(WS 帧 / REST 三入口同形)+ 单会话额度未满(默认 2 / 硬上限 3 / `<2` 关闭)⇒ 本条升为**独立一路立即起**(不排队)。忙态帧随之带 `activeTurns`、`session_interrupted` 带 `cancelledTurns`、`pending_ack` 在「声明了并行却落回排队」时带 `reason:"parallel_limit"`;`session_idle` 时点收紧为「所有在途路收尾且 pending 空」(**仅并行时生效**,单路逐字不变)。**未实现**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / `graph_update` 按路分桶 / 写类工具文件级互斥 / 第二路进行态 system 摘要;env `CHAT_MAX_PARALLEL_TURNS` **未接线**(生产恒为默认 2)。详见 api-client §5 v0.65。
|
||
- **队列满的身份回填与定向回执(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`:**v0.65 起由编排器生成、loop 复用**——编排器在启动某一路前生成 `turn-<hex>`(crypto/rand)并经 `loop.WithTurnID` 挂进该路运行上下文,`runTurn` **ctx 上已有则复用、没有才新铸**(未接编排器的回退路径 / 子代理 / backthink 仍新铸)。单路时帧上仍是每回合全新的随机 id ⇒ **对客户端无可见差异**。用于关联一轮流式突发;`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) / `session_not_found`(**v0.63**) | 资源不存在(文档:名字非法 / 路径穿越 / 文件不存在**同一响应**;会话:`GET /sessions/{id}/messages` 对**不存在 / 已删除**的会话,**空会话仍是 `200 {"data":[]}`**) |
|
||
| 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"}`。
|
||
- **IM 入站语音(v0.65)**:`channelbridge` 的 `VoiceSTT` 由组合根注入(与 Web 侧 `voice_input` **同一条** `voice.Chain`,不另建一条);超时旋钮 **`VOICE_STT_TIMEOUT_SEC`**(秒,默认 **20**,`≤0` 用默认)**只约束 IM 渠道入站语音的这一次转写**(流水线要为它一直举着一个入站并发槽;Web 客户端的 `voice_input` 不受它约束)。转写不成功(未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默档位)时**消息面落一行中文诚实提示**(按原因分类、含临时熔断退避),**绝不留空消息、绝不假装成功**。
|
||
- `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 侧**富媒体摄取**(v0.65 起 `channelbridge` 已摄取入站**内联附件字节**——图片/文件引用写进用户消息 `documents` 列——与入站**语音**字节(经 ASR 归一为文本,见 §8.2);其余媒体类型仍未做)。**已知缺口**:design/24 §6.2 的 `document_unreadable`(400) 无 REST 落点(只有 `document-read` 工具错误形态;客户端在 DTO 里看到的是 `documents[].status = "unreadable"`)。
|
||
- **忙时并行(v0.65,基础管线)的未实现集合(诚实标注)**:`turn_begin` 帧、`steer_ack` 的帧壳 `turnId`、`interrupt.turnId`、`chat_messages.turn_id` 列与 DTO 键、`graph_update` 按路分桶、**写类工具文件级互斥**(`internal/runtime/tool/legfile.go` 的 `FileClaimer`/`ErrFileBusy`/`WithLegID` 仅**定死形状、无实现无接线**)、第二路进行态 system 摘要(`loop.WithParallelNotice`)**均未实现**;env `CHAT_MAX_PARALLEL_TURNS` **未接线**(`Deps.MaxParallelTurns` 无人从 env 读 ⇒ **生产恒为默认 2**,`.env` 配了不生效)。**已实现的是「基础管线」**:上行 `chat.parallel`(WS/REST 三入口同形)、忙态帧 `activeTurns`、`session_interrupted.cancelledTurns`、`pending_ack.reason:"parallel_limit"`、两条 REST 应答形状(同步 `202 {parallel:true,turnId}` / `stream:true` 的 `200 {stream:true,parallel:true,turnId}`)、`session_idle` 时点收紧(仅并行时生效)。详见 api-client §5 v0.65。
|
||
- **语义/向量增强**:`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 渠道过滤。
|