主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。 判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。 - 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用 - 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒 - 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤) - 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红 - 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true) - 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
1109 lines
155 KiB
Markdown
1109 lines
155 KiB
Markdown
# Cyrene v2 客户端 API 契约
|
||
|
||
> **契约版本:`v0.74`**(上一版 `v0.73`,其条目见 §6;`v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(本切片,E6)—— 见 §2.4;**语音消息尚未实现**。已在 §7.2 逐条如实标注。**本文档其余内容仍以代码为真源**。
|
||
> **v0.68 是纯增量版(已实现,非契约先行)**:新增 1 条管理只读端点 `GET /api/v1/admin/config-facts`(配置事实面,见 §2.2 B12),并给 `GET /api/v1/admin/backthink` 的条目加 2 个可选字段(`urgency` / `quietHoursBypassed`,见 §2.2 B8)。**无破坏性变更**,老客户端零影响。
|
||
> **v0.69 是缺口修复版(已实现)**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的**关闭流控(-1)**静默重置回 `0` = 保护性默认 6 条/分」。**无破坏性变更**(老客户端不带该键,行为与 v0.68 相同)。
|
||
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||
> **v0.71 是链路图补版(已实现,纯增量)**:新增 1 条管理只读端点 **`GET /api/v1/admin/flow/graph`**,把 `ws.graph_update` 的**最近一轮快照**从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ `/#/admin/flow` 与后台思考行的『查看完整链路导图』永远空着」—— `graph_update` **只走实时帧**,前端图桶(`flowGraphs`)**不持久化**,此前**没有任何**补拉通路。**无破坏性变更**(新端点;`graph_update` 帧形状、角色门、发布会话范围**一字未改**)。
|
||
|
||
> **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。
|
||
|
||
> **v0.73 是链路图本地落盘版(已实现,纯增量)**:链路图从「只活在进程内存」变成**每轮一行落进本地库**(新表 `flow_graphs`,迁移 `0022`),`GET /api/v1/admin/flow/graph` 在 v0.71 之上**增量**:新增可选查询参数 `turnId`(省略 = 最近一轮;**先查内存 `LastGraph`,miss 才回退落盘**)、响应新增 `turnId` / `source` / `turns[]` 三键(**多轮历史回放**,`design/19 §4` 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。**无破坏性变更**(旧键 `sessionId`/`graph` 形状语义不变,`graph_update` 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围**只有**发布会话白名单内的会话(`flowSessionAllowed`),隔离渠道会话绝不入库。详见 **§2.2 B13 / §4.11**。
|
||
|
||
> **v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量)**:`message_persisted` 的 `data` 新增**可选**结构化判据 `proactive:true` —— IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧,客户端据此在**非前台**补一发浏览器通知(此前通知只挂在 `proactive_message` 上,而 `auto` 提示在主人有已连接 IM 渠道时**优先走 IM** ⇒ 实际最可能发生的路径恰恰不弹)。判据是**结构化字段**(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧**永不**带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条**不带**该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 **§3.4.4 后的「`message_persisted` 的 `proactive` 判据」**。
|
||
|
||
---
|
||
|
||
## 0. 基础约定
|
||
|
||
### 0.1 基址与版本
|
||
|
||
- **Base URL**:`http://<host>:<PORT>`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。
|
||
- **唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**;不另有监听端口(唯一登记例外:`qq-obv11` 渠道 OneBot 反连 server 模式,见 design/08 §4,与客户端契约无关)。
|
||
- **版本前缀**:
|
||
- 用户侧 REST:`/api/v1/{resource}`
|
||
- 管理侧 REST:`/api/v1/admin/*`
|
||
- 实时:`/ws/chat`(WebSocket)
|
||
- 破坏性变更升 v2;本文档内的纯增量变更不升。
|
||
|
||
### 0.2 认证与授权
|
||
|
||
| 凭据 | 用法 | 作用面 | 备注 |
|
||
|---|---|---|---|
|
||
| **Bearer JWT** | `Authorization: Bearer <JWT>` | 用户侧 + 管理侧**全部已认证端点** | RBAC:`owner` > `admin` > `viewer`(递增)。access token TTL **24h**(`tokenTTL`)。 |
|
||
| **`X-API-Key`** | 请求头 `X-API-Key: <key>` | 用户侧 + 管理侧全部已认证端点 | **命中签发表即 `owner` 级**(不做角色分档,裁决 15.4.3)。**带该头时只走 key 分支**:未知/已吊销/空值 → `401`,**绝不回落 Bearer**(fail-closed)。key 表未注入 = 空表(带头一律 401)。 |
|
||
| **WS 握手** | `?token=<JWT>` 查询参数**或** `Authorization: Bearer <JWT>` | 仅 `/ws/chat` | **只认 JWT**(`auth.Verify`)。**不接受 `X-API-Key`**。失败在升级前回 `401 {"error":{"code":"unauthorized",...}}`。 |
|
||
|
||
- JWT claims 的 `role` 决定授权分档;`sub` 是账号标识。
|
||
- `X-API-Key` 命中后 `sub` 记作 `apikey:<脱敏 id>`(审计可定位哪把 key,但绝不含密钥材料)。
|
||
- **身份/权限绝不来自提示词或 LLM 判断**(项目红线):一律由 channel 层强类型 `actor` 元数据 + 白名单 + 策略引擎决定。
|
||
|
||
### 0.3 响应信封
|
||
|
||
- 成功:`{"data": <载荷>}`(个别端点直接回二进制,见 §4.5 语音)。
|
||
- 失败:`{"error":{"code":"<机器可读码>","message":"<人读文案>"}}`。
|
||
- 状态码全集(代码实际使用):`200 201 202 400 401 403 404 409 413 415 429 500 501 503`。
|
||
- ⚠️ v0.67 登记的 `GET /api/v1/media/{name}`(**尚未实现**,见 §7.2)会引入 **`206` / `304`**,以及一条**非信封**的 **`416`**(`Range` 错误由 Go 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":…}`)。这三者**不在**上面那份「代码实际使用」集合内,实现后才加入;客户端**不要**拿 `error.code` 去解析 `416`。
|
||
- **客户端按 `error.code` 分支,不要按 `message` 文本**(文案会随修改变动)。
|
||
|
||
### 0.4 三档标注惯例
|
||
|
||
本文档与「变更记录」用三档描述每次变更对客户端的影响:
|
||
|
||
- **纯增量**:只新增端点 / 帧 / 可选字段 / 取值。老客户端不读新键时**行为逐字不变**。
|
||
- **行为变更**:既有端点/帧的语义或响应面**变了**,客户端需改处理(如新状态码、新时点)。
|
||
- **破坏性**:既有字段/形状被移除或改义,老客户端会坏。
|
||
|
||
### 0.5 未接依赖时的诚实边界(fail-visible)
|
||
|
||
后端某些端点依赖注入的存储/服务;未接线时**明确报错或退化为诚实空结果**,绝不返回假数据:
|
||
|
||
| 依赖 | 未接线时的行为 |
|
||
|---|---|
|
||
| `HistoryStore` | `GET /sessions` 回退到渠道会话视图;`POST /sessions`、`GET /sessions/{id}/messages`、`PATCH/DELETE /sessions/{id}`、`POST /sessions/{id}/read`、`PATCH/DELETE /messages/{id}` → `501 not_implemented` |
|
||
| 语音服务 | `POST /audio/*` → `500 voice_unavailable`;WS `voice_input` → `error` 帧 `{"message":"voice unavailable"}` |
|
||
| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a);绝不回全 0 假汇总) |
|
||
| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) |
|
||
| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` |
|
||
| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) |
|
||
|
||
---
|
||
|
||
## 1. 用户侧 REST 端点
|
||
|
||
### 1.1 公开端点(无需认证)
|
||
|
||
| # | 方法 | 路径 | 状态码 | 要点 |
|
||
|---|---|---|---|---|
|
||
| P1 | GET | `/api/v1/health` | 200 | 存活探针 → `{data:{status:"ok"}}` |
|
||
| P2 | POST | `/api/v1/auth/login` | 200 / 400 / 401 / 500 | body `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`。access 24h;`role` 恒 `owner`;`refreshToken` 仅在接了 refresh 存储时出现(`AUTH_REFRESH_TTL` 默认 720h)。凭据错 → `401 unauthorized`。 |
|
||
| P3 | POST | `/api/v1/auth/refresh` | 200 / 400 / 401 / 500 | body `{refreshToken}` → 新 access + 新 refresh(默认**单次使用轮换**)。未知/已吊销/过期 → `401 invalid_grant`。`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh。未接存储 → `500 refresh_unavailable`。 |
|
||
| P4 | POST | `/api/v1/auth/logout` | 200 / 400 / 401 / 500 | 带 `{refreshToken}` → 撤单份 `{data:{revoked:bool}}`(幂等,未知也 200);带 Bearer 且无 body → 撤该账号全部 `{data:{revoked,revokedCount}}`(**两种响应形状不同**)。两者都无 → `400`。 |
|
||
|
||
> 登录/刷新/登出**公开**是刻意的:非透明 refresh token 本身就是凭据,故过期 access 的客户端仍能轮换/吊销它。它们对非法 token **fail-closed**。
|
||
|
||
### 1.2 用户端点
|
||
|
||
| # | 方法 | 路径 | 鉴权 | 状态码 | 要点 |
|
||
|---|---|---|---|---|---|
|
||
| U1 | POST | `/api/v1/chat` | owner/admin | 200 / 202 / 400 / 409 / 500 / 503 | 主对话入口,见 [§4.6](#46-chat-请求与响应面)。body `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}`。 |
|
||
| U2 | GET | `/api/v1/sessions` | 任意已认证 | 200 / 500 | 会话列表 `{data:[{id,type,title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`。**排序:`pinned DESC, (type='main') DESC, created_at ASC`** —— 置顶的次级会话会排在主会话之前,**勿假定 index 0 是主会话**。主会话 `id="web"`、`type="main"`;子会话 `web:<uuid>`、`type="sub"`。history 未注入时回退渠道会话视图。 |
|
||
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
|
||
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
|
||
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
|
||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text,voice?,format?,instruction?}` → **`200` 原始音频字节**(`Content-Type: audio/<format>`,**非 JSON 信封**)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
|
||
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
|
||
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
|
||
| U10 | POST | `/api/v1/documents` | owner/admin | 201 / 200 / 400 / 413 / 415 / 500 | **文档上传**,`multipart/form-data` 字段 `file`。首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同 `name` + `deduped:true`**(可当成功)。单文件上限 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 用默认)。见 [§4.5](#45-图片--文档--语音链路)。 |
|
||
| U11 | GET | `/api/v1/documents/{name}` | **owner/admin(必须 Bearer)** | 200 / 404 | 文档读取。与图片读侧**刻意相反**(必须鉴权)。响应 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`,**永不内联渲染**。名字非法/穿越/不存在统一 `404 document_not_found`。 |
|
||
| U12 | GET | `/api/v1/sessions/{id}/messages` | owner/admin | 200 / 400 / 404 / 500 / 501 | 热分页历史。query `?limit=&before=&after=&platform=&q=`,见 [§4.2](#42-分页与游标)。**会话不存在/已删除 → `404 session_not_found`**;**存在但无消息 → `200 {"data":[]}`**。 |
|
||
| U13 | POST | `/api/v1/sessions` | owner/admin | 201 / 400 / 501 | 新建子会话。body 可选 `{personaId?}` → `{data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。未知 personaId → `400`。**会话角色创建时固定、之后不可变**(见 U15)。 |
|
||
| U14 | DELETE | `/api/v1/sessions/{id}` | owner/admin | 200 / 403 / 500 / 501 | 删除子会话及其消息 → `{data:{deleted:<id>}}`。主会话不可删 → `403 forbidden`。 |
|
||
| U15 | PATCH | `/api/v1/sessions/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 会话元数据。body `{title?,pinned?}`(**至少一个**)。`title` trim、≤80 rune、空/纯空白 → `400 "title must not be blank"`(**空串不能清空标题**)。**`personaId` 键出现即 `400`**(任何取值,含 `null`/`""`),且**先于**会话存在性检查(未知会话 + personaId 也回 `400`)。响应只回显本次变更字段 `{data:{id,title?}}` / `{id,pinned?}`。 |
|
||
| U16 | POST | `/api/v1/sessions/{id}/read` | owner/admin | 200 / 400 / 404 / 500 / 501 | 推进已读游标。body `{before?}`(毫秒;缺省=现在)→ `{data:{sessionId,readAt}}`。游标**会话级单所有者、只进不退**(读一处 = 该账号所有端已读)。成功广播 `session_read` 帧;失败不广播。 |
|
||
| U17 | PATCH | `/api/v1/messages/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 编辑单条消息。body `{content}`(非空、≤4000 rune)→ **`{data:<消息 DTO>}`**(`data` 直接就是完整消息,**不再包一层 `message`**)。只改 `content`。成功广播 `message_edited`。 |
|
||
| U18 | DELETE | `/api/v1/messages/{id}` | owner/admin | 200 / 404 / 500 / 501 | 删除单条消息 → `{data:{deleted:<id>}}`。成功广播 `message_deleted`。 |
|
||
| U19 | GET | `/api/v1/media/{name}` | **owner/admin(必须 Bearer)** | 200 / 206 / 304 / 401 / 403 / 404 / 416 | **语音副本播放 / 取字节**(**v0.67 登记,尚未实现**,见 §7.2)。`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;支持 `Range` 与 `HEAD`;名字非法 / 穿越 / 不存在统一 `404 media_not_found`。⚠️ **`416` 不是 JSON 信封**(标准库直写)。详见 §4.5 语音。 |
|
||
|
||
> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);**`GET /api/v1/media/{name}` 与 `GET /api/v1/documents/{name}` 必须 Bearer owner/admin**(与图片读侧「128-bit 名即凭据」的取向**刻意相反**);其余全部用户端点至少要求已认证。
|
||
|
||
---
|
||
|
||
## 2. 管理侧端点与权限矩阵
|
||
|
||
> 仅管理面板与授权客户端使用。RBAC `owner`/`admin`/`viewer`;**写操作全程审计**。
|
||
> 本节与 `internal/services/gateway/routes.go` **逐条一致**。
|
||
|
||
### 2.0 权限矩阵(口径与计数)
|
||
|
||
- **匿名(无 token / token 无效)**:任意 `/api/v1/admin/*` → `401 unauthorized`(fail-closed,全部管理路由无一例外)。
|
||
- **角色分档**(✅ 放行,❌ `403 forbidden`):
|
||
|
||
| 档位 | 中间件 | 条数 | 说明 |
|
||
|---|:--:|:--:|---|
|
||
| **只读监控档** | `viewerAuth`(owner/admin/viewer) | **12** | 非敏感投影:无凭据、无他人账号数据、无消息正文 |
|
||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **13** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71) |
|
||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||
|
||
**管理路由总数 = 12 + 13 + 40 = 65 条。**
|
||
|
||
> **计数口径(v0.71 明确)**:总数是 §2.1 / §2.2 / §2.3 三张清单里**列出的行数**(与 v0.68 起的口径一致)。⚠️ `internal/services/gateway/routes.go` 里实际经 `writeAuth` 挂载的 **admin** 路由比 §2.2 多:v0.67 登记的 `GET /admin/channels/events` 与 `.../counters`(见 §2.4(a))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上面的 13。逐条以代码为准。
|
||
|
||
> ⚠️ **与归档版不一致(已更正)**:`Docs/archive/api-client.v0.65.md` §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。**以代码为准 = 12 + 11 + 40 = 63**。
|
||
|
||
### 2.1 viewer 可读(12 条,`viewerAuth`)
|
||
|
||
| # | 方法 | 路径 | 要点 |
|
||
|---|---|---|---|
|
||
| A1 | GET | `/api/v1/admin/personas` | 人格清单(含 admin 专属 `thinking` 块) |
|
||
| A2 | GET | `/api/v1/admin/sessions` | 会话清单 |
|
||
| A3 | GET | `/api/v1/admin/plugins` | 能力/插件清单 |
|
||
| A4 | GET | `/api/v1/admin/plugins/installed` | 已安装插件清单 |
|
||
| A5 | GET | `/api/v1/admin/channels` | 渠道清单。`accounts[]` 各元素**可能**带可选 `connected:bool`(v0.66):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**v0.67 已实现**(本次落地;落地前基线 `f91a83f`):各元素另**可能**带可选 `accountOnline:bool`(**账号在平台上是否在线**,与 `connected` 是**两件事**;**键缺席 = 状态未知 ≠ 离线**)—— 见 §2.4(b);各元素**可能**带可选 `directMsgPerMin:number`(**v0.69 新增,已实现**):该账号**非主人私聊(陌生人)**每分钟每发送者上限,是账号**顶层**字段(与 `groupMsgPerMin` 同一条写路径,**不在 `config` 里**)。语义:**键缺席或 `0` ⇒ 保护性默认 6 条/分**(陌生人必须有流控,缺省绝不解释成「关闭」)、**正值 ⇒ 该值**、**负值 ⇒ 关闭该限制**(主人的显式选择)。⚠️ 因 `omitempty`,**`0` 与未设置线上同形**(既有约定,未改)**不含任何凭据** |
|
||
| A6 | GET | `/api/v1/admin/iot` | IoT 设备清单 |
|
||
| A7 | GET | `/api/v1/admin/automations` | 自动化与提醒清单 |
|
||
| A8 | GET | `/api/v1/admin/rag` | 知识库文档清单(名字/描述 + 切分/嵌入状态);`?query=` 搜索亦只读 |
|
||
| A9 | GET | `/api/v1/admin/observability/metrics` | 运行时指标快照 |
|
||
| A10 | GET | `/api/v1/admin/observability/trace` | 运行时 span |
|
||
| A11 | GET | `/api/v1/admin/observability/logs` | 后端日志尾部(最新优先,`?level=&limit=`;缓冲未注入时 501) |
|
||
| A12 | GET | `/api/v1/admin/notifications` | 统一通知 feed(审计链 + 后台思考推送的只读投影) |
|
||
|
||
### 2.2 owner/admin 读(13 条,`writeAuth`)
|
||
|
||
| # | 方法 | 路径 | 要点 |
|
||
|---|---|---|---|
|
||
| B1 | GET | `/api/v1/admin/memories` | 记忆条目(含他人账号数据) |
|
||
| B2 | GET | `/api/v1/admin/channels/messages` | 适配器消息日志,`?adapter=&platform=` 三档粒度(`before`/`limit`) |
|
||
| B3 | GET | `/api/v1/admin/channels/{platform}/messages` | 单平台跨隔离会话消息(最新优先) |
|
||
| B4 | GET | `/api/v1/admin/llm` | LLM provider 栈(组 DTO,含 `contextWindow` 等) |
|
||
| B5 | GET | `/api/v1/admin/llm/usage` | LLM 用量汇总(切片 U1,v0.64)。`?since=&until=&purpose=`;缺省近 24h、跨度上限 90 天(超 → `400`);`hitRate = cacheHit/(cacheHit+cacheMiss)`,分母 0 ⇒ `0`。**不含凭据与消息正文**。未接线 → `500 usage_unavailable` |
|
||
| B6 | GET | `/api/v1/admin/llm/catalog` | 模型目录候选(`?refresh=1` 强制在线刷新)。既无快照又抓不到 → `503 catalog_unavailable` |
|
||
| B7 | GET | `/api/v1/admin/users` | 账号/API key 清单 |
|
||
| B8 | GET | `/api/v1/admin/backthink` | 后台思考记录。每条**可能**带可选 `urgency`(`"routine"`/`"warning"`/`"critical"`,`internal/urgency` 词汇;**默认是精确档位**,带 `urgencyLowerBound:true` 时才是下限,见 §4.10(b))与可选 `quietHoursBypassed:true`(该轮**穿透了安静时段**)—— **v0.68 新增;档位的精确语义与 `urgencyLowerBound` 为 v0.70** |
|
||
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
|
||
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
|
||
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
|
||
| B12 | GET | `/api/v1/admin/config-facts` | **配置事实面(v0.68 新增,只读)**:记忆整理 7 键 + 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
|
||
| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
|
||
|
||
> **读取 `GET /admin/observability/{logs,metrics,trace}` 兼任可观测推送的订阅握手**(该凭据的连接全部断开即退订)。REST 响应形状不变。
|
||
|
||
### 2.3 owner-only(40 条,`ownerAuth`)
|
||
|
||
| # | 方法 | 路径 | 要点 |
|
||
|---|---|---|---|
|
||
| C1 | POST | `/api/v1/admin/plugins/policy` | 设置能力可达性标记(工具策略,落盘 `tool_policies`) |
|
||
| C2 | POST | `/api/v1/admin/personas` | 创建人格 |
|
||
| C3 | PUT | `/api/v1/admin/personas/{id}` | 更新人格(`identity.systemPrompt` 必填,缺失/空白 → `400`) |
|
||
| C4 | GET | `/api/v1/admin/owner-profile` | 主人档案读取 |
|
||
| C5 | PUT | `/api/v1/admin/owner-profile` | 主人档案写(落盘 `configs/users/owner.yaml` + 热更新活档案;审计 `owner.profile.update`) |
|
||
| C6 | POST | `/api/v1/admin/users` | 创建账号 |
|
||
| C7 | POST | `/api/v1/admin/users/apikey` | 签发 API key(明文仅返回一次) |
|
||
| C8 | DELETE | `/api/v1/admin/users/apikey/{id}` | 吊销 API key |
|
||
| C9 | POST | `/api/v1/admin/iot/{id}/control` | 控制设备 |
|
||
| C10 | POST | `/api/v1/admin/iot/scenarios` | IoT 场景写 |
|
||
| C11 | POST | `/api/v1/admin/iot/rules` | IoT 规则写 |
|
||
| C12 | POST | `/api/v1/admin/automations` | 创建自动化 |
|
||
| C13 | POST | `/api/v1/admin/automations/reminders` | 创建定时提醒(到点发 `reminder.trigger`) |
|
||
| C14 | POST | `/api/v1/admin/automations/{id}/run` | 手动运行自动化 |
|
||
| C15 | DELETE | `/api/v1/admin/automations/{id}` | 删除自动化 |
|
||
| C16 | POST | `/api/v1/admin/llm` | 添加/更新 LLM provider(写 `models.json` + 热重载) |
|
||
| C17 | POST | `/api/v1/admin/llm/test` | 连通性测试 |
|
||
| C18 | POST | `/api/v1/admin/llm/reset-from-env` | 重置为 env 默认(切片 R1,v0.62;body 必须 `{"confirm":true}`,否则 `400` 且一个字节都不动;审计 `llm.resetFromEnv`) |
|
||
| C19 | POST | `/api/v1/admin/channels/open` | 打开渠道(v0.66 增 `accountId`,见下注) |
|
||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注) |
|
||
| C21 | POST | `/api/v1/admin/channels/test` | 渠道连通测试 |
|
||
| C22 | GET | `/api/v1/admin/channels/{platform}/login-qr` | 取登录二维码(获取凭据,故 owner-only) |
|
||
| C23 | GET | `/api/v1/admin/channels/{platform}/login-status` | 查扫码登录状态 |
|
||
| C24 | GET | `/api/v1/admin/channels/{platform}/view` | 平台专属只读视图 |
|
||
| C25 | GET | `/api/v1/admin/channels/{platform}/friends` | 好友/联系人清单 |
|
||
| C26 | POST | `/api/v1/admin/channels/{platform}/friend/handle` | 好友申请处理 |
|
||
| C27 | POST | `/api/v1/admin/channels/{platform}/message/test` | 发测试消息 |
|
||
| C28 | PUT | `/api/v1/admin/channels/{platform}/config` | 保存渠道账号配置(**按 id patch** 语义,见 C29 注) |
|
||
| C29 | DELETE | `/api/v1/admin/channels/{platform}/config` | 删除渠道账号。**必须显式寻址**:`?id=<账号 id>` 或 `?empty=1`(v0.66),见下注 |
|
||
| C30 | POST | `/api/v1/admin/plugins/install` | 安装插件 |
|
||
| C31 | POST | `/api/v1/admin/plugins/uninstall` | 卸载插件 |
|
||
| C32 | POST | `/api/v1/admin/memories` | 写记忆 |
|
||
| C33 | POST | `/api/v1/admin/memories/search` | 搜记忆 |
|
||
| C34 | POST | `/api/v1/admin/rag` | 索引文档(切分 + 嵌入) |
|
||
| C35 | POST | `/api/v1/admin/rag/delete` | 删文档(项目条目 + 级联章节) |
|
||
| C36 | POST | `/api/v1/admin/system/revoke-keys` | 全部吊销 API key |
|
||
| C37 | POST | `/api/v1/admin/system/rotate-jwt` | 轮换 JWT 密钥 |
|
||
| C38 | POST | `/api/v1/admin/system/backup` | 备份 |
|
||
| C39 | POST | `/api/v1/admin/system/restore` | 恢复 |
|
||
| C40 | GET | `/api/v1/admin/system/storage` | 存储状态**只读**(v0.60)。`{data:{main,documents,vector}}`;**不含凭据**;未接活连接 → `500 storage_unavailable` |
|
||
|
||
> **C28 的 patch 语义(v0.54 起)**:`config` 内**键省略 = 保留已存值**;给出非空值 = 覆盖;**显式 `null`/`""` = 清除该键**(`0`/`false` 是真实取值,不算空)。顶层便捷字段(`token`/`baseURL` 等)只在非空时并入补丁,**要清 `bot_token` 必须走 `config` 的显式 `null`/`""`**。
|
||
> **C28 的账号级限流字段(v0.69 修正)**:`groupMsgPerMin` / `msgPerMin` / `maxMessages` / **`directMsgPerMin`** 都是账号**顶层**字段(**不在 `config` 里**,C28 的 patch 三态**不适用**于它们)。它们是**值语义**:请求体省略 ⇒ `0` = 未设置(对 `directMsgPerMin` 即保护性默认 6 条/分)。**要保住已设的值必须把它回送**;面板先由 `GET /admin/channels` 回显、再原样写回(`frontend/web/src/admin/modules/channels/channelUtils.ts` 的 `limitInitialOf` / `limitRequestBody`)。⚠️ v0.68 及更早的后端**完全忽略** `directMsgPerMin`:老客户端不受影响,但**要表达「关闭流控(-1)」必须对 v0.69+ 后端发**。
|
||
> **C18 破坏性**:只覆盖 `.env` 声明过非空值的键(`provider`/`baseUrl`/`apiKey`/`model`/`contextWindow`),`fallback`/`multimodal`/`maxTokens`/`reasoning`/`personaModels` 永不被改;组集合不增减。
|
||
> **C19/C20 的 `accountId`(v0.66,多账号平台必需)**:请求体 = `{name, accountId?}`(`name` 必填:平台名或精确适配器名)。
|
||
> - 给出 `accountId` ⇒ **只**驱动该账号的适配器(账号 id 非敏感;`"default"` 映射到该平台的裸适配器名,即无 id 账号的注册口径)。账号**完全不存在** ⇒ `404 account_not_found`(文案附该平台已知账号 id 列表);账号**已配置但当前没有活适配器** ⇒ `409 account_not_registered`(常见原因:缺该账号必需凭据、或保存后尚未重载)。两种都**零连接**,**绝不静默回落到第一条**(「点了连接却连上别的账号」正是此改动要修的缺口)。
|
||
> - 缺省 `accountId` ⇒ 平台级旧语义(老客户端零影响):精确同名适配器 → 该平台 `channels.json` **有效配置顺序第一条** → 裸平台名兜底;仍找不到 ⇒ `404 not_found`。⚠️ 第 2 步在 v0.66 起是**确定性的**(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)。
|
||
> - 响应(open/close 同形):`{data:{name, resolved, connected, accountId?}}` —— `name` 是请求里的名字(旧契约不变),`resolved` 是**实际被驱动**的适配器注册名(多账号平台上与平台名不同),`accountId` 仅在请求指定时回显,`connected` 为操作后的状态。**凭据永不出现**。审计动作名不变(`channel.open` / `channel.close`)。
|
||
> **C29 的两种寻址(v0.66,都必须显式给出)**:
|
||
> - `?id=<账号 id>` ⇒ 删该 id 那一条。⚠️ **`id` 不存在时仍回 `200`**(`removed:1`),与「刚删过」不可区分 —— 客户端按幂等处理。
|
||
> - `?empty=1` ⇒ 删该平台上**全部无 id 的历史条目**(这类条目改不动:带空 id 保存会被当成新账号另开一条)。**无可删 ⇒ `404 not_found`**,不写盘、不重载、不假装删过。
|
||
> - `id` 与 `empty=1` 同时给出 ⇒ `400 bad_request`;**两者都缺 ⇒ `400 bad_request`**(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。`?empty=` / `?empty=0` / `?empty=false` 一律是「否」。
|
||
> - 响应:`{data:{persisted, path, platform, accountId, empty, removed}}`(`empty`/`removed` 是 v0.66 新增);`removed` = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。
|
||
|
||
### 2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— **(a) 已实现 / (b) 已实现**
|
||
|
||
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。**本行留在此处仅为与 v0.67 冻结版对照**。
|
||
|
||
#### (a) 渠道事件(非消息事件)只读查询 —— **已实现**
|
||
|
||
| # | 方法 | 路径 | 要点 |
|
||
|---|---|---|---|
|
||
| D1 | GET | `/api/v1/admin/channels/events` | 非消息事件列表(**倒序**)。query:`platform` / `tier` / `category` / `channelId` / `since` / `until` / `limit` / `offset`。响应 `{data:{events:[…], total}}`。`since` / `until` 按 **UTC ISO-8601 定宽字符串**做**字典序**比较(与 `GET /admin/llm/usage` 同口径);**跨度上限 90 天**(超 → `400`);`platform` 未知 → `400`。**不含正文、不含任何媒体 URL、不含任何凭据 / `flag`** |
|
||
| D2 | GET | `/api/v1/admin/channels/events/counters` | 逐类计数汇总(**近似值**)。query:`platform`。响应 `{data:{approx:true, counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`,`approx:true` 如实标注「计数为聚合近似值」(计数是**内存聚合 + 周期 flush**,非逐条实时)。**落地口径**:`approx` 与 `counters` **同级**(都在 `data` 内,见 §7.4-11 的落地记录) |
|
||
|
||
- **RBAC 档位已定(落地口径)**:两个端点走 **`owner|admin`**(`writeAuth`),与同族的渠道消息日志面(§2.1 B2/B3、`/admin/channels/messages`)同档 —— 事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是给 viewer 的监控投影。**viewer 为 `403`**(已进 `rbac_viewer_test.go` 的 `ownerAdminReadRoutes` 覆盖)。
|
||
- **参数缺省与错误码(落地口径,见 §7.4-11)**:`limit` ≤0 或未给 ⇒ 用存储层缺省 `100`;超硬上限 ⇒ **钳到 `500`**(不报错,`total` 仍如实给出筛选条件下的总条数);非法整数(如 `limit=abc`)⇒ `400 bad_request`;`since`/`until` 非法 ⇒ `400 bad_request`;`platform` 未知 ⇒ `400 bad_request`;`tier` 非 `t1/t2/t3` ⇒ `400 bad_request`。⚠️ **`category` 不做白名单校验**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 400。
|
||
- **时间窗缺省(落地口径)**:**没给 `since`/`until` 就不注入时间条件**(不是「近 24h」)—— 端点绝不替客户端造一个下界(那正是「静默裁剪」)。只给 `since` 时按「现在」判跨度(超 90 天 ⇒ 400),但**不**把「现在」写进过滤条件。
|
||
- **数据源两张表**(`design/26 §4.1`,表名**复数**):`channel_events`(`tier` 为 `t2`/`t3` 的事件与**所有** `unknown` 逐条留存,**参与保留期 GC**)与 `channel_event_counters`(`t1` 与**所有**帧的计数,按 `(platform, account_id, platform_type)` upsert,**不参与 GC**)。
|
||
- `tier` 取值域:`t1`(只计数)/ `t2` / `t3`(逐条留存;T3 是「谁对谁做了什么」的定向交互记录)。`category` 取值域(**归一化类别**,设计原文给出 `heartbeat` / `lifecycle` / `join` / `leave` / `recall` / `ban` / `admin` / `friend_request` / `group_request` / `poke` / `reaction` / `button` / `vote` / `item` / `link` / `unknown`,并在末位带 `…` ⇒ **非穷举**,客户端**未知取值原样展示、不报错**)。
|
||
- `events[]` 元素 = 一条 `channel_events` 行的投影。**落地口径(见 §7.4-9):键名一律 camelCase,逐字如下** —— `id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole`(`owner`/`member`/`system`/`unknown`,结构化解析所得)/ `targetId` / `selfRelated`(bool)/ `messageId` / `summary` / `createdAt` / `updatedAt`(后两者是 **UTC RFC3339**,与 `llm/usage` 的时间回显同口径)。`actorId`/`targetId` 是 **id 不是昵称**;`summary` 是**固定文案**(如「群成员加入」,**不含任何平台原文**)。无数据时 `events` 是 `[]`(不是 `null`)。
|
||
- **未接线存储时**:两个端点都回 **`500 channel_events_unavailable`**(对齐 `usage_unavailable` / `storage_unavailable` 的诚实边界口径,**绝不回全 0 假汇总**)。读库失败同样是 `500 channel_events_unavailable`(错误文案不同)。
|
||
- 三个新 env 键(`design/26 §4.4`,已登记进 §5):`CHANNEL_EVENT_RETENTION`(默认 `720h`)、`CHANNEL_EVENT_GC_INTERVAL`(默认 `1h`)、`CHANNEL_EVENT_COUNTER_FLUSH`(默认 `30s`)。
|
||
- ⚠️ **微信渠道不提供非消息事件**:查询面 / 面板对该渠道**如实显示空 + 说明文字**,绝不伪造、绝不用别的渠道事件顶替(落地时已有测试钉住:`platform=weixin-ilink` 的响应里绝不出现别的平台的事件)。
|
||
|
||
#### (b) `GET /api/v1/admin/channels` 的账号项:`accountOnline`
|
||
|
||
- `accounts[]` 元素**可能**带可选 `accountOnline:bool`(**v0.67 登记,已实现** —— 管理面已落地,见 §7.2):该账号**在平台侧是否在线**。
|
||
- **与 `connected` 是两件事**(这是本案的全部理由):`connected` = **本项目的适配器是否连着**(WS 会话连通);`accountOnline` = **账号本身在平台上是否在线**。OneBot 实现(NapCat 等)的进程与正向 WS 可以一直连着,而背后的 QQ 账号已经掉线 —— 此时 `connected:true` 而 `accountOnline:false`,消息**既收不到也发不出**。
|
||
- **三态**(`known` 口径):`accountOnline:true` = **确知在线**;`accountOnline:false` = **确知离线**;**键缺席 = 状态未知**(尚未探到:探活未接线 / 刚启动 / 一直失败 / 该平台没有独立账号状态面)。**绝不可把「未知」画成「离线」**,也不可画成「在线」—— 那正是 2026-10-05 事故里「面板仍显示已连接、日志一片空白」的同一类坑。
|
||
- 状态源:`AccountStatus()`(`plugins/channels/qq_obv11/probe.go`),其 `known` 区分「确知离线」与「尚未探到」;本字段的**缺席**即承载 `known=false`。当前**只有 `qq-obv11`** 有这条独立状态面:周期约 **60s** 主动探活 OneBot `get_status`(读 `{online, good}`),与上游可配的 `meta_event` 心跳**并列**(心跳优先、探活兜底),**只在状态变化时**记一条带 `source` 的日志。
|
||
- **已实现口径**(本次落地):管理列表按**可选接口断言**读它 —— 适配器实现了 `AccountStatus()` 才读,否则**键缺席**;`known=false` 时同样**键缺席**(绝不用 `false` 冒充离线)。**不另发** `known` 同级键:缺席本身即承载 `known=false`。当前**只有 `qq-obv11`** 会带该键(telegram / webhook 等没有独立状态面)。
|
||
- ⚠️ **字段名(`accountOnline`)是本契约所拟**:设计只规定「独立状态面 + `AccountStatus()` + `known`」,**未给管理面字段名**(见 §7.4-13)。实现沿用了本契约所拟的名字(未改名)。
|
||
|
||
---
|
||
|
||
## 3. WebSocket `/ws/chat`
|
||
|
||
### 3.1 握手
|
||
|
||
- `GET /ws/chat`,凭据取自 `?token=<JWT>` 或 `Authorization: Bearer <JWT>`(**只认 JWT**)。
|
||
- 校验失败:升级前回 `401 {"error":{"code":"unauthorized",...}}`。
|
||
- 方法非 GET → `400 bad_request`。
|
||
- 帧大小:**读上限 4096 字节/帧**(`wsReadLimit`)。超限由传输层直接断连 —— 因此**真实音频/图片二进制必须走 REST**,WS 只发小载荷。
|
||
- keepalive:服务端周期 `Ping`(54s),客户端应回 `Pong`;读超时 60s。
|
||
- 非 JSON 帧 → 定向 `{"type":"error","data":{"message":"invalid json"}}`(连接保留)。
|
||
|
||
### 3.2 握手 `history` 回放(角色门:只发 owner+admin)
|
||
|
||
- 连接注册后,**仅当该连接是 `owner`/`admin`** 时,服务端**定向**发送一帧:
|
||
|
||
```json
|
||
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
|
||
```
|
||
|
||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)与可选 `voice`(**v0.67 登记、尚未实现**;形状见 §4.4)。
|
||
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
|
||
- 回放是尽力而为:存储未接或读出错静默跳过。
|
||
|
||
### 3.3 上行帧(客户端 → 服务端)
|
||
|
||
> **角色门(fail-closed)**:**只有 `owner`/`admin` 连接可以发送任何应用内上行帧**。非 owner/admin 发任何上行 JSON 帧 → **定向**回 `{"type":"error","data":{"code":"forbidden"}}`(只到发送者,不广播、不驱动模型、不落库)。该门是 `route()` 的第一条语句且**默认拒**:未登记的新帧类型在显式登记前一律被拒。协议级帧(ping/pong/close)由 gorilla 内部处理,不受影响。
|
||
|
||
帧壳:`{"type":"<type>","reqId?":"<string>","data":{...}}`。`reqId` 保留未用。
|
||
|
||
| type | data | 语义 |
|
||
|---|---|---|
|
||
| `chat` | `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?}` | 会话消息。**与 REST `POST /chat` 同形**。见 [§4.6](#46-chat-请求与响应面)。 |
|
||
| `interrupt` | `{sessionId,scope?,turnId?}` | 打断。见 [§4.8](#48-interrupt-的-scope-与-turnid)。 |
|
||
| `voice_input` | `{audio,format?,text?,sessionId?,deviceId?,clientMsgId?}` | 语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 [§4.5](#45-图片--文档--语音链路)。 |
|
||
| `mode_switch` | 任意(透传) | 行为模式广播;服务端以 `mode_switch` 下行帧回放给(owner/admin)连接。 |
|
||
| `subagent_ask` | `{id,mode?,n?,query?}` | 审计某子代理上下文。`mode` ∈ `""/full/tail/keyword`。定向回 `subagent_event`;未接 auditor → `error {"message":"subagent audit unavailable"}`。 |
|
||
| `task_output_query` | `{taskId}` | 取任务输出。定向回 `task_output`;未接 task store → `error {"message":"task output unavailable"}`。 |
|
||
|
||
上行错误应答(均**定向**,`{"type":"error","data":{...}}`):
|
||
|
||
| 情形 | data |
|
||
|---|---|
|
||
| 非 owner/admin 发送任意上行帧 | `{"code":"forbidden"}` |
|
||
| 未知 type | `{"message":"unknown event type"}` |
|
||
| 非 JSON | `{"message":"invalid json"}` |
|
||
| `interrupt` scope 非法 | `{"code":"bad_request"}` |
|
||
| `interrupt` 指定 `turnId` 不存在(仅 scope:"current") | `{"code":"bad_request"}` |
|
||
| `chat` 待处理队列满 | `{"code":"chat_busy","message":...,"sessionId":...,"clientMsgId?":...,"messageId?":...}`(**只发发送者**;同时广播 `pending_full` 状态帧) |
|
||
| `chat` 携带的文档不合法 | `{"code":"<too_many_documents\|document_not_found\|chat_documents_unavailable>"}` |
|
||
| `voice_input` 无 audio 也无 text | `{"message":"voice audio or text required"}` |
|
||
| `voice_input` 未接语音服务 | `{"message":"voice unavailable"}` |
|
||
|
||
### 3.4 下行帧(服务端 → 客户端)与角色门
|
||
|
||
> **唯一投递入口 `hub.broadcast` 即角色门**:它按帧类型查 `frameReachesAll` 表决定受众。
|
||
> - **全员白名单(7 个「状态机信号」,owner/admin/viewer 都收)**:`session_busy`、`pending_ack`、`pending_drain`、`session_idle`、`session_interrupted`、`pending_full`、`session_read`。它们**只报告会话忙闲/游标,不含任何内容**。
|
||
> - **其余全部下行帧 = fail-closed 默认档:只发 owner + admin**。新增帧类型在有人显式登记前一律落此档(漏分类不泄露内容)。
|
||
> - 分类表在 `internal/services/gateway/ws_frame_scope.go`,扩白名单属契约变更。
|
||
|
||
帧壳:`{"type":"<type>","turnId?":"...","seq?":<int>,"sessionId?":"...","data":{...}}`(`turnId`/`seq` 为空则键省略)。
|
||
|
||
#### 3.4.1 全员白名单(7 帧)
|
||
|
||
| type | 帧壳 | data | 说明 |
|
||
|---|---|---|---|
|
||
| `session_busy` | sessionId | `{sessionId,mode,activeTurns}` | 会话转忙。`mode` ∈ `thinking/executing/subagent` |
|
||
| `pending_ack` | sessionId | `{sessionId,mode,queueDepth,reason?}` | 忙时消息入队。`reason` **仅在显式声明 `parallel` 却落回排队时**出现,取值 `"parallel_limit"` |
|
||
| `pending_drain` | sessionId | `{sessionId,mode:"thinking",queueDepth,activeTurns}` | 续跑弹出队首 |
|
||
| `session_idle` | sessionId | `{sessionId,mode:"idle",activeTurns:0}` | 转空闲。**时点收紧(v0.65)**:所有在途路收尾**且** pending 空才发(单路时与旧行为逐字相同)。见 [§4.6](#46-chat-请求与响应面) |
|
||
| `session_interrupted` | sessionId | `{sessionId,mode,dropped,cancelledTurns,activeTurns}` | 打断结果。`scope:"all"` ⇒ `mode:"idle"`、`dropped`=清空的 pending 数、`activeTurns:0`;`scope:"current"` ⇒ `mode` 是当时忙碌通道(非 idle)、`dropped:0` |
|
||
| `pending_full` | sessionId | `{sessionId,mode,max,clientMsgId?,messageId?}` | 队列满(`max` 为上限)。`clientMsgId` = 被拒消息的幂等键(未携带则键缺席);`messageId` **仅当该消息此前已落库/已认领时**出现(服务端绝不在此处新铸 id) |
|
||
| `session_read` | sessionId | `{readAt}` | 已读游标推进(毫秒)。同账号所有端据此清角标 |
|
||
|
||
#### 3.4.2 结构化流帧(owner+admin;帧壳带 sessionId + turnId + seq)
|
||
|
||
| type | data |
|
||
|---|---|
|
||
| `message.start` / `message.delta` / `message.end` | `{index, delta?, streamId?}` |
|
||
| `thinking.start` / `thinking.delta` / `thinking.end` | `{index, delta?, streamId?}` |
|
||
| `tool_call.start` / `tool_call.delta` / `tool_call.end` | `{id, index, delta?, streamId?}` |
|
||
| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**) |
|
||
| `turn.start` / `turn.end` | 无 data(帧壳即边界信号) |
|
||
| `stream_chunk` | `{delta}`(非流式路径的单段文本) |
|
||
| `stream_end` | `{stopReason, steps?, error?}` |
|
||
|
||
> `streamId`(可选)= 同一次模型流(一次 ChatStream 调用)的多帧关联键;`turnId` = 整个回合恒定。两者分工不同。
|
||
> `tool_execution.update`:wire 映射已实现,但**工具分发器目前没有中途进度上报源**,故实际**暂无生产者**(`start`/`end` 真实发出)。
|
||
|
||
#### 3.4.3 编排帧(owner+admin)
|
||
|
||
| type | 帧壳 | data | 说明 |
|
||
|---|---|---|---|
|
||
| `turn_begin` | sessionId + **turnId** | `{kind,messageIds,leg,activeTurns}` | 每起一路(含续跑路)**恰好发一帧**(v0.66 落地)。`kind` ∈ `"turn"`(新回合/**续跑**)/`"parallel"`(显式并行且照准,**仅供展示**);`messageIds` = 该路已落库用户消息的权威 id 数组(可为空数组,**恒为数组不为 null**);`leg` = 会话内路序号(1 基,会话内单调递增);`activeTurns` 同忙态帧。客户端据此把气泡置「已受理·回答中」并按 `sessionId + turnId` **两级分桶**;服务端口径是「按 `messageIds` 是否含本气泡的 `messageId`」判定归属,`kind` **不是**判据。 |
|
||
| `steer_ack` | sessionId + **turnId** | `{messageId,atStep}` | 忙时新消息被在飞回合吃进同一回合:`messageId` 是权威 id,`atStep` 是模型步序号(1 基)。语义 = 「已读、未答」⇒ 客户端把该气泡渲染灰态,直到该路 `message.*`/`turn.end` 到达(**不需要第二个确认帧**)。帧壳 `turnId` 与吃进它的那一路 `turn_begin` 同值(v0.66 落地)—— 并行下没有它配不上正确的 `turn.end`。 |
|
||
| `chat_duplicate` | sessionId | `{sessionId,clientMsgId,messageId}` | `clientMsgId` 判重命中:**没有新回合、没有新内容帧**,客户端把本地乐观气泡对齐到既有 `messageId` |
|
||
|
||
> **`turn_begin` 的帧序(v0.66,按实现)**:`session_busy`(新回合)或 `pending_drain`(续跑)→ `message_persisted`(user) → `turn_begin`。
|
||
> 顺序是刻意的、不可调换:`turn_begin.data.messageIds` 必须是**已落库的权威 id**,因此它只能在用户消息落库之后发;客户端因此总能先看到 `message_persisted`,再收到把该气泡钉到某一路的 `turn_begin`。
|
||
> 起第 1 路与起并行路**共用同一条启动路径**,帧序完全同形 —— 客户端只需实现一遍「`turn_begin` → 灰态」。
|
||
|
||
#### 3.4.4 持久化 / 历史帧(owner+admin)
|
||
|
||
| type | 帧壳 | data | 说明 |
|
||
|---|---|---|---|
|
||
| `message_persisted` | sessionId | `{message:<historyMessage>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选结构化判据 `proactive:true`(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它) |
|
||
| `channel_persisted` | sessionId | `{message:<historyMessage>}` | 隔离渠道会话(`sess-*`)的消息落库,供 IM 日志页。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
|
||
| `message_edited` | sessionId | `<historyMessage>`(**data 直接就是 DTO**) | 编辑单条消息。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66) |
|
||
| `message_deleted` | sessionId | `{id}` | 删除单条消息 |
|
||
|
||
> **`thinking` 可达性口径(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**全部读取面**(`GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、握手 `history` 回放、`message_persisted` / `channel_persisted` / `message_edited`)**恒为缺席**。原因不是「有意清空」,而是 `history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 没有该字段、Anthropic 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。
|
||
|
||
> **`message_persisted` 的 `proactive` 判据(v0.74,纯增量)**:帧 `data` 里**可选**的布尔键 `proactive:true` 是「这条是她**主动**说的话」在线上**唯一**的结构化判据,由后端的主动推送通道打上(`gateway.BroadcastProactivePersisted`)。客户端读到它就应走与 `proactive_message` **同一套**提醒逻辑(页面不在前台时补一发浏览器通知、同一条只打扰一次),因此「她该不该提醒我」**与后端走了哪条路由无关**。
|
||
> - **为什么需要它**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由过去只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。修的是这条边界(主人 2026-10-05 裁定「她说话该不该提醒我,与走哪条路由无关」)。
|
||
> - **判据为什么不会误伤普通消息**:只有主动推送那条通道会调用带判据的广播入口;普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口,那条路径**永不**往帧顶放该键。客户端只应做 `data.proactive === true` 的**严格相等**判断 —— 缺失、`false`、非布尔值都按「不是主动消息」处理。
|
||
> - **去重键 = `message.id`**:主动推送镜像回主会话时,**同一条**消息会先发一条**不带**判据的普通帧(v0.72 起的行为,逐字不变,任何客户端都能渲染),随后追加一条带判据的帧。两帧同 `message.id` ⇒ 合并成一个气泡(消息 store 按 id 去重),提醒也只发一次(提醒去重表按同一个 id 记)。**`proactive_message` 与带判据的 `message_persisted` 同时到达时同样只提醒一次**。
|
||
> - **落库失败时不发判据帧**:没有落库行就没有权威 `id`,而 `message_persisted` 的载荷要求非空 `id`(缺了客户端会整帧丢弃)⇒ 那种情况下只记 WARN、不弹通知(绝不发一条会被丢掉的帧假装提醒过)。
|
||
> - **⚠️ 判据不落库**:它只活在**实时帧**上(写入那一刻才知道这条是不是主动推送),库里的行没有对应列(不改 schema)。因此刷新后从 `GET /sessions/web/messages` / 握手 `history` 回放读到的那一行**不带**该信息 —— 这是预期的:通知只对「实时到达」有意义,翻历史不该弹提醒。
|
||
> **思维链的唯一出口是实时帧**:§3.4.2 的 `thinking.start` / `thinking.delta` / `thinking.end`(owner/admin 档)。客户端**不要**为「从历史里读回思维链」写任何代码。
|
||
> ⚠️ 本行**更正**本文档 v0.65 及更早的写法(旧版称「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」)—— **那是文档写错了**,与实现不符。见 §7.3。
|
||
> 另注:管理面板人格清单里的 `thinking` 是**人格旋钮**(`GET /admin/personas` 的 `thinking` 块),与消息 DTO 的 `thinking` 不是同一个字段。
|
||
> **`voice` 覆盖同一批读取面(v0.67 登记,尚未实现)**:消息 DTO 新增的可选 `voice` 对象一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。**WS 协议本身零变化**:无新帧、无新上行字段;`voice` 只是既有 DTO 的新可选键。
|
||
|
||
#### 3.4.5 子代理 / 任务 / 系统帧(owner+admin)
|
||
|
||
| type | 帧壳 | data(要点) |
|
||
|---|---|---|
|
||
| `subagent_event` | — | `{id,name,status,role,mode}`(进度:dispatched/started/done/failed/aborted);`subagent_ask` 的应答另有 `AuditResult` 形状 |
|
||
| `subagent_result` | **sessionId = 父会话** | 批次结果 `{sessionId,status,summary,agents:[{id,goal,role?,mode?,status,summary?,text?,steps?}]}`。**不落库**(父会话转录无新消息、重连回放不补发) |
|
||
| `task_output` | — | `{taskId,output,status}`(`task_output_query` 应答);亦桥接自 `task.output` 主题 |
|
||
| `task_done` | — | 桥接自 `task.done` |
|
||
| `device_update` | — | 桥接自 `iot.device_update` |
|
||
| `automation_result` | — | 桥接自 `automation.result` |
|
||
| `reminder` | — | 桥接自 `reminder.trigger` |
|
||
| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**无 `turnId`** ⇒ 未按路分桶)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照** |
|
||
| `capacity_awaken` | sessionId | `{fromState,toState,tool}` |
|
||
| `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 |
|
||
| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId,id?,createdAt?}`(已消毒,绝不含凭据/媒体字节)。**v0.72**:`id`/`createdAt` 是这条消息在**主会话历史**里的权威行 id 与落库时间戳(同现/同省),落库失败时键缺席 —— 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在) |
|
||
| `approval_notice` | sessionId | `{actor,tool,op,detail,explain,allowed,at}`(主代理代审小字;`explain` 由后端产出、客户端原样渲染) |
|
||
| `mode_switch` | — | 上行 `mode_switch` 的回放(透传 `data`) |
|
||
| `observability_log` | — | `{entries:[{time,level,source,msg}...]}`,上限 200,最新在前 |
|
||
| `observability_trace` | — | `{spans:[...]}`,上限 100,最新在前 |
|
||
| `observability_metrics` | — | 扁平 `AdminMetrics` 快照 |
|
||
|
||
> **observability_* 的订阅**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。
|
||
|
||
> **`proactive_message` 的落库口径(v0.72,行为变更)**:后台思考决定「此刻主动开口」时,这条消息现在是**主会话(`web`)历史里的一条真实 `assistant` 消息** —— 走活跃客户端路由(在线 owner/admin 连接)的那一路此前**只广播帧、一行都不写** ⇒ 刷新/重载后什么都补不回来(既不显示气泡,也补不出历史)。本版 `SendProactive` **先落库、再发帧**:
|
||
> - 落库行:`sessionId="web"`、`role="assistant"`、`content` = 帧里的 `content`(后端已净化的同一份文本,系统元数据绝不进正文)、`platform` = 帧里的 `platform`、`mode="chat"`、`turn_id` **留空**(主动推送不属于任何一路回合)。因此 `GET /sessions/web/messages`、握手 `history` 回放、`message_persisted` 都能读到它。
|
||
> - **`id`/`createdAt` 随帧下发**,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。**落库失败时这两个键缺席**,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 `route/detail` 会写明**未落库**(不会再说成「已推送」)。
|
||
> - **无在线 owner/admin 连接时也会落库**:推送器如实报告「没有活跃客户端」(`pushed=false`),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。
|
||
> - **IM 路由(v0.74 起也提醒)**:`im_bridge` 路由本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**),v0.74 起在落库之后**追加**一条帧顶带 `proactive:true` 的同类帧 ⇒ 客户端走与 `proactive_message` 同一套提醒逻辑(见 §3.4.4 后的「`message_persisted` 的 `proactive` 判据」)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。
|
||
|
||
#### 3.4.6 定向帧(不经 `hub.broadcast`,只到指定连接)
|
||
|
||
| type | 说明 |
|
||
|---|---|
|
||
| `history` | 握手回放(§3.2,仅 owner/admin) |
|
||
| `voice_output` | 语音回合结算后**定向回发起 `voice_input` 的那条连接**。帧壳**无 sessionId**;data `{audio(base64),format,text,reply,sessionId,deviceId?,provider?}`。定向目标是连接本身,**服务端不按 deviceId 路由**;连接已断不补投 |
|
||
| `error` | 上行拒绝 / 处理函数级失败(形状见 §3.3 与 §4.1) |
|
||
|
||
---
|
||
|
||
## 4. 契约细节
|
||
|
||
### 4.1 错误响应与错误码
|
||
|
||
- 形状:`{"error":{"code":"<string>","message":"<string>"}}`。
|
||
- 中间件级(Bearer/角色门)固定:`401 unauthorized` / `403 forbidden`。
|
||
- 常用业务码(按状态分组):
|
||
|
||
| 状态 | code(示例) | 场景 |
|
||
|---|---|---|
|
||
| 400 | `bad_request`、`image_too_large`、`too_many_documents` | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数 |
|
||
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
|
||
| 403 | `forbidden` | 角色不足;主会话不可删 |
|
||
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、尚未实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5) |
|
||
| 409 | `chat_busy`、`account_not_registered` | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66) |
|
||
| 413 | `payload_too_large`、`document_too_large` | 上传/文档超限 |
|
||
| 415 | `unsupported_media_type` | 图片/文档类型拒绝 |
|
||
| 429 | `rate_limited` | 限流(框架级) |
|
||
| 500 | `internal`、`chat_unavailable`、`chat_failed`、`voice_unavailable`、`stt_failed`、`tts_failed`、`tts_empty`、`voice_clone_failed`、`usage_unavailable`、`storage_unavailable`、`channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a))、`refresh_unavailable`、`sessions_failed`、`messages_failed`、`session_failed`、`message_failed`、`document_upload_failed`、`login_failed` | 内部错误或依赖未接线(**绝不伪装成功**) |
|
||
| 501 | `not_implemented` | HistoryStore 等未注入 |
|
||
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable` | 派发/上游不可用 |
|
||
|
||
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
|
||
> **⚠️ `416` 不是错误信封(v0.67 登记,尚未实现)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
|
||
> **`400` 的渠道事件用法(v0.67 已实现,见 §2.4(a))**:`GET /admin/channels/events` 对 `platform` 未知、`tier` 非法、`since`/`until` 跨度 > 90 天、`since`/`until` 时间点非法、`limit`/`offset` 非整数都回 `400`(**落地用的 `code` 是通用的 `bad_request`**,见 §7.4-11)。
|
||
|
||
### 4.2 分页与游标
|
||
|
||
`GET /api/v1/sessions/{id}/messages`:
|
||
|
||
- `limit`:默认 **50**,生效范围 **[1,200]**(大于 200 **钳到 200**,不报错);非数字或 `<1` → `400`。
|
||
- `before`(毫秒 `createdAt`):返回**严格早于** before 的消息,**倒序**(默认方向,向后翻历史)。
|
||
- `after`(毫秒 `createdAt`):返回**严格新于** after 的消息,**正序**(重连后的前向增量追赶)。
|
||
- `before` 与 `after` **互斥**(同传且都 >0 → `400`);`after` 非数字/负 → `400`。
|
||
- `q`:会话内内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义),**倒序**、仍遵守 `before`/`limit`;**`q` 与 `after` 互斥**;空 `q` = 未传。
|
||
- `platform`:可选来源平台过滤(Web 端不传 = 全量;IM 适配器传本平台)。
|
||
- **形状校验(`limit`/`before`/`after` 非法、互斥冲突)先于 404 判定**:形状错就是 `400`,与目标会话存在性无关。
|
||
|
||
### 4.3 幂等(`clientMsgId`)
|
||
|
||
- 上行可选字段(REST `chat` body 与 WS `chat` 帧**同形**;`voice_input` 亦然)。
|
||
- **同一会话内同一个非空 `clientMsgId` 只承认第一条**。重复到达:
|
||
- REST → `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}`
|
||
- WS → 下行帧 `chat_duplicate`(§3.4.3)
|
||
- **不落库、不起回合、不发任何内容/流式新帧**。
|
||
- 判重由 SQLite 部分唯一索引 `(session_id, client_msg_id) WHERE client_msg_id != ''` 原子裁决(并发重发只有一个赢家)。
|
||
- `clientMsgId` **回填进消息 DTO**(空则省略),读接口与 `message_persisted` 给出同一答案 —— 客户端据此对齐本地乐观气泡(替代「靠文本相等」的脆弱匹配)。
|
||
- 省略它时行为逐字不变(老客户端零影响)。
|
||
|
||
### 4.4 消息 DTO(`historyMessage`)
|
||
|
||
`GET /sessions/{id}/messages` 的数组元素、`PATCH /messages/{id}` 的响应 `data`、`GET /admin/channels[/{platform}]/messages` 的元素、`message_persisted` / `channel_persisted` 的 `data.message`、`message_edited` 的 `data`、握手 `history` 回放的 `messages[]` —— **同一个结构体**:
|
||
|
||
```json
|
||
{
|
||
"id": "<消息 id>",
|
||
"sessionId": "web",
|
||
"role": "user|assistant",
|
||
"content": "...",
|
||
"mode": "chat",
|
||
"mood": "",
|
||
"image": "",
|
||
"documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ],
|
||
"thinking": "...", // 恒缺席(全仓无写入者;见 §3.4.4 口径更正)
|
||
"platform": "web",
|
||
"deviceId": "web:chrome:a1b2c3",
|
||
"senderName": "...", // 第三方发送者显示名;空 = 主人
|
||
"adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空
|
||
"personaId": "...", // 空 = 全局默认角色
|
||
"clientMsgId": "...", // 空则省略
|
||
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
|
||
"voice": { // v0.67 登记(尚未实现);非语音消息键缺席;🎤 判据 = 本键存在
|
||
"source": "asr", // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
|
||
"status": "transcribed", // "transcribed" | "empty" | "failed"(闭集三态)
|
||
"name": "a1b2c3….ogg", // 音频副本存储名;无副本 ⇒ 键省略
|
||
"mime": "audio/ogg",
|
||
"size": 12345,
|
||
"durationMs": 3200, // 可选:渠道如实给出时才有(绝不估算)
|
||
"url": "/api/v1/media/a1b2c3….ogg" // 与 name 同生共死(服务端相对路径)
|
||
},
|
||
"createdAt": 1700000000000
|
||
}
|
||
```
|
||
|
||
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`)。
|
||
|
||
**`turnId` 语义(v0.66,可选)**:
|
||
|
||
- 形状:字符串,与**该路**的实时流帧帧壳 `turnId`、以及 `turn_begin` 帧壳 `turnId` **同值**(编排器生成,见 §3.4.3 / §4.8)。
|
||
- **同一路的 user 行与 assistant 行同值** ⇒ 客户端在**重连 / 回放**时可用它精确配对「回复 ↔ 提问」,并恢复回复侧的分组小标(真并行下两路回答交错,只按顺序渲染会配错)。
|
||
- 退化为空(**键省略**)的三类情形:① 加入该列(迁移 `0018`)之前的历史行;② 未经编排器的写入路径(**IM 渠道桥等**,其行 `turn_id` 留空是预期行为);③ 忙时入队 / 被转向吃进的消息 —— 它们**落库那一刻**还没有自己的路,那一帧 `message_persisted` 因此**不带** `turnId`,客户端从随后的 `turn_begin`(帧壳 `turnId` + `data.messageIds`)得知归属;行本身由**实际处理它的那一路**随后补写(先到先得)。
|
||
- 列口径:`chat_messages.turn_id TEXT NOT NULL DEFAULT ''`(**不用 NULL**,于是「旧行」与「没写」是同一个值)。
|
||
|
||
**`voice` 语义(v0.67 登记,尚未实现)**:
|
||
|
||
- 形状:`{source,status,name?,mime?,size?,durationMs?,url?}`,`omitempty`(**非语音消息键缺席**,与引入语音前逐字相同)。
|
||
- **🎤 判据 = `voice` 键存在**(这条消息来自一条语音消息)。客户端**绝不**从 `content` 正文里认媒体类型:QQ 语音正文里那个 `[语音]` 占位标记自 v0.67 起**不再下发**(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 `[语音]` 字样。**只认 `voice` 键**。
|
||
- `source` ∈ `"asr"`(服务端语音识别所得,含云端)/ `"platform"`(渠道平台自带转写,微信 iLink 的 `voice_item.text`)。
|
||
- `status` ∈ `"transcribed"`(正文即转写文本)/ `"empty"`(转写成功但没识别出文字)/ `"failed"`(没拿到转写文本 —— 正文是那行中文诚实提示)。**客户端按 `status` 分支,绝不按正文文案分支**(§0.3 的同一纪律)。设计还**刻意不下发** `failed` 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
|
||
- `name` / `mime` / `size` / `url` **四者同生共死**:有音频副本时全在,**无副本时全缺席**(**不会**出现 `url` 指着一个不存在的 `name` 这种形状)。
|
||
- `durationMs` **只有渠道如实给出时才有**;读不出来就**省略键,绝不估算**(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
|
||
- `url` 是**服务端相对路径** `/api/v1/media/<name>`(唯一派生字段,由服务端一处拼出)⇒ **客户端绝不自己拼路径**。
|
||
- 落库形状 = DTO 形状**减去** `url`(列 `chat_messages.voice TEXT NOT NULL DEFAULT ''`,JSON **单对象**;**用空串不用 NULL**,与 `turn_id` 同一口径)。音频原件**复用文档那一套存储 / 元数据表 / 引用计数 / GC**(同一 `<DataDir>/uploads/documents` 目录),但**不进文档的抽取 / 注入链路**:不被抽取、不进 `documents` DTO 数组、不占注入预算、不出现在附件清单行里。
|
||
- **一条消息只落一份音频副本**(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(**刻意的边界**,不是遗漏)。
|
||
- ⚠️ **微信(`weixin-ilink`)语音没有音频副本、不可回放**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `source:"platform"` 且**无 `name`/`mime`/`size`/`url`**,只保留 🎤 标记。
|
||
|
||
**`documents` 数组语义(核心)**:
|
||
|
||
- 形状:`{name,mime,size,chars,truncated,status,reason?}`,**≤5 条/回合**,顺序 = 客户端发送顺序。
|
||
- **0 个附件时该键省略**(不是 `[]`,与引入文档前逐字相同)。
|
||
- `status` ∈ `extracted`(正文已进上下文)/ `listed`(只给了模型附件清单,正文没进上下文)/ `unreadable`(读不到正文)。
|
||
- **`chars` / `truncated` 是「本回合注入侧」语义**:`chars` = **本回合注入**模型上下文的字符数(截断时为截断后值),**不是文件字数**;`truncated` = **注入**是否被预算截断,**不是文件是否被截断**。全文长度只有 `document-read` 工具的 `totalChars` 能看到。
|
||
- `reason`(可选,`omitempty`):**只在 `status:"unreadable"` 时下发**(`extracted`/`listed` 时键缺席)。逐字就是服务端注入给模型的那份原因(同一字符串),客户端**不必自己拼**。当前文案集合(服务端常量,逐字):
|
||
- `无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)`
|
||
- `无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传`
|
||
- `无法抽取正文:PDF 损坏或无法解析`
|
||
- `无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)`
|
||
- `无法抽取正文:内容不在可抽取的纯文本范围内`
|
||
- `无法抽取正文:文档文件不存在或已被清理` / `…:读取文档失败` / `…:文档引用形态非法` / `…:文档上传目录未配置`
|
||
- `无法读取或解析失败`(解析器整条消息失败时的诚实兜底)
|
||
- ⚠️ **只放文案**:不含落盘路径、不含凭据;原始错误只进服务端日志。
|
||
- **不含正文**(结构上保证:正文若从历史回传,每翻一页都要背着它)。
|
||
- 时点:`status`/`chars`/`truncated`/`reason` 由**回合结束后**的回写写进 `document` 列 ⇒ 只在**读取面**(`GET /sessions/{id}/messages`、握手回放)可见;`message_persisted` 在用户消息落库时就发出(那时还没有结论)。
|
||
- 列被写坏时(外部改动/格式变更)**绝不静默当无附件**:回一条 `status:"unreadable"` 的最小引用。
|
||
|
||
### 4.5 图片 / 文档 / 语音链路
|
||
|
||
**图片**:
|
||
|
||
- 两条入口:`POST /api/v1/upload`(multipart,**单文件字节 ≤ 5 MiB**)取**绝对同源 URL**;或 `chat.image` 直接内联 base64 `data:` URI / http(s) URL。
|
||
- ⚠️ **按形态写清(v0.66 补充说明,更正旧措辞「解码后 5 MiB」)**:`chat.image` 的**唯一入口判据是 `image` 字符串长度,不是「解码后字节数」** —— `POST /api/v1/chat` 的 `handleChat` 在**解码 / 落库 / 入模型之前**调用 `checkChatImageSize(image, limit)`(`internal/services/gateway/rest.go`),判定就是 `len(image) <= limit`;`limit` = `CHAT_IMAGE_MAX_BYTES`(默认 `5242880`)按 base64 膨胀 4/3 换算出的**字符**上限 = `ceil(n/3)*4 + 64`,默认 **6990572 字符**(那 64 字符是 `data:` 前缀余量)。**超一字符**即 `400 {"error":{"code":"image_too_large"}}`(`message` 带上限与本次长度,文案指路先上传)。
|
||
- **base64 / `data:` URI 形态**:受上述 **6990572 字符**约束,**等价于解码后约 5 MiB**。`data:` 前缀越长,留给载荷的字符越少;**裸 base64**(无前缀)在恰界(正好 6990572 字符)时解码 ≈ `5242929` 字节 = **5 MiB + 49 字节** ⇒ **「解码后 5 MiB」是换算口径**,逐字节的硬判据在代码里**不存在**(视觉解码层的兜底预检 `vision/limits.go` 同样是**按字符串长度**判 `MaxInlineImageChars`)。要与「一张图 5 MiB」严格对齐,客户端按 `ceil(n/3)*4 + 64` 自查即可,不要按解码字节数试探。
|
||
- **http(s) URL 形态**(含 `/api/v1/upload` 返回的 `/api/v1/files/<32hex>.<ext>`):判据与形态无关(任何**短于 6990572 字符**的串都通过),而正常 URL 只有约 60 字符 ⇒ **实践中 `image_too_large` 不会因 URL 形态触发**。URL 形态真正的天花板在**视觉取回层**:`internal/runtime/multimodal/vision/image.go` 的 `maxFetchedBody = maxImageBytes = 50 << 20`(**50 MiB**;其**视觉/OCR 工具路径**下同源 upload 产物优先走本地直读 `readFile`,用同一常量,而**主模型直收路径**仍走一次 HTTP 自取),另有 **30 s** 取回超时。⚠️ 该 50 MiB 是 `io.LimitReader` 的**截断**上限 —— **超出部分被静默丢弃、不返回错误** ⇒ 客户端**不要**把「服务端会拒绝超大 URL 图」当契约,也不要把 50 MiB 当成能用满的额度。
|
||
- **upload 形态**:`POST /api/v1/upload` 自带 **5 MiB** 单文件硬上限(`maxUploadBytes = 5 << 20`;超限 `413 {"error":{"code":"payload_too_large"}}`)⇒ 经 upload 拿到的同源 URL 图,有效上限仍是 **5 MiB**,在**上传那一刻**定死,与本接口的字符串长度判据无关。
|
||
- 上限来源 `CHAT_IMAGE_MAX_BYTES`:`≤0` = 用默认(**不存在「禁用上限」的取值**);**只能收紧、不能放宽**(钳到 vision 解码层硬顶 `vision.MaxInlineImageBytes` = 5 MiB)。
|
||
- `data:` 前缀与类型嗅探**都不参与**这个体积判据:前缀只通过那 64 字符余量影响能塞多少;类型裁决(**字节嗅探**为唯一裁决权;HTML/SVG/判不定 → 明确报错,绝不静默当 PNG)发生在**视觉层**,属回合内错误,**不是**入口 `400`。
|
||
- **WS `chat` 帧到不了这条判定**:WS 上行(`streamChat`)**没有任何图片体积校验**,读上限 4096 字节/帧由传输层直接断连 ⇒ **WS 上发真实图片必须「上传 + URL」**。
|
||
- 视觉 URL 由服务端取回内联为 `data:` URI 再交 provider。**主模型能否收图**看生效组的 `multimodal`(见 §5),与 `LLM_VISION_MODEL`/`LLM_OCR_MODEL` 无关。
|
||
|
||
**文档**:
|
||
|
||
- 上传 `POST /api/v1/documents`(owner/admin,字段 `file`,上限 `CHAT_DOCUMENT_MAX_BYTES` 默认 20 MiB)。
|
||
- **类型裁决 = 字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 `.pdf`)**,**成对判定**(`.pdf` 必须嗅探为 `application/pdf`,纯文本类必须 `text/plain`)。HTML 改名 `.pdf` ⇒ `415`;PDF 改名 `.txt` ⇒ `415`。`mime` 是**嗅探基类型**(`data.json` → `text/plain`;真 PDF → `application/pdf`)。
|
||
- 落盘名 = `sha256(bytes)[:32] + ext`(内容寻址;原始文件名永不参与路径 ⇒ 同字节同扩展名天然去重)。
|
||
- 读取 `GET /api/v1/documents/{name}` **必须 Bearer owner/admin**,一律 `attachment` + `nosniff`(永不内联)。
|
||
- 对话携带:`chat.documents = [{"name":"<服务端存储名>"}]`(**只给 name**,绝不接受 URL;多带的 `mime`/`url`/`size` 一律被服务端覆盖)。**≤5 条**,超出 `400 too_many_documents`(先于任何文件系统访问);任一条非法/不存在 → **整个请求被拒**(`404 document_not_found`),绝不静默丢附件。附件部件挂在**同一条 user 消息**上;**纯附件回合**(无文本、无图片)同样成立。
|
||
- 内置工具 `document-read`(模型侧,客户端一般只需知道它存在):注入被截断时清单行给续读游标 `offset`。策略默认 owner-only(群聊/陌生人天然读不到)。
|
||
- **PDF(v0.61)**:文本层 PDF ⇒ `extracted`;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 `unreadable`;加密与损坏可区分。
|
||
|
||
**语音**:
|
||
|
||
- REST 源(`/api/v1/audio/*`,均 **owner-only + 审计**,绕开 WS 4KB 帧限):见 U5–U7。
|
||
- `/transcribe`:`{audio,format?,deviceId?}` → `{data:{text,provider?}}`。
|
||
- `/speech`:`{text,voice?,format?,instruction?}` → **原始音频字节**(`Content-Type: audio/<format>`)。`instruction`(≤100 字符,汉字记 2)只对复刻/设计音色生效,系统音色 `longxiaochun_v3` 被忽略。
|
||
- `/voice-clone`:`{url,prefix?}` → `{data:{voiceId}}`。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
|
||
- WS 语音回合(`voice_input`):**与文本回合走同一条路**(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;`deviceId`/`clientMsgId` 与 `chat` 帧同形。
|
||
- `voice_output` 在**该回合结算时**才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 `provider` 降级信号(`silent` 等),**不把 `stopReason` 当语音内容**;连接断开不补投,客户端带回同一 `clientMsgId` 可安全重试。
|
||
|
||
**入站语音的音频副本与播放端点(v0.67 登记,尚未实现)**:
|
||
|
||
- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。
|
||
- **播放端点 `GET /api/v1/media/{name}`**(**owner/admin,必须 Bearer**,与 `GET /api/v1/documents/{name}` 同一套门):
|
||
|
||
| 项 | 口径 |
|
||
|---|---|
|
||
| 名字形态 | 32 位小写十六进制主名 + **音频白名单**扩展名(**10 项**:`.wav` `.mp3` `.aac` `.ogg` `.oga` `.opus` `.m4a` `.webm` `.flac` `.amr`);`..` 等路径穿越全形态拒绝 |
|
||
| `Content-Type` | 按**存储名的扩展名**映射,**恒为 `audio/*`**(`.wav→audio/wav`、`.mp3→audio/mpeg`、`.aac→audio/aac`、`.ogg`/`.oga`/`.opus→audio/ogg`、`.m4a→audio/mp4`、`.webm→audio/webm`、`.flac→audio/flac`、`.amr→audio/amr`)。**不查库、不重新嗅探**(扩展名与字节在**保存时**已成对裁决) |
|
||
| `Content-Disposition` | `inline; filename="<name>"` —— 与文档读侧的 `attachment` **刻意相反**(音频要被播放器消费;因下发类型恒 `audio/*` + `nosniff`,内联不构成文档侧那类 XSS 面) |
|
||
| 其他响应头 | `X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**不设 `ETag`**(不自造语义,条件请求走 `Last-Modified`) |
|
||
| 状态码 | `200` 全量 / `206 Partial Content`(`Range: bytes=0-`、`bytes=N-M`、`bytes=-N`,**由 Go 标准库 `http.ServeContent` 处理 —— 客户端不要自研 range 解析**)/ `304 Not Modified`(条件请求)/ `404 media_not_found`(**名字形态非法 / 路径穿越 / 文件不存在三者同一响应**,不泄露「存在与否」以外的信息)/ `401` / `403`(中间件) |
|
||
| `HEAD` | 支持(`GET` 模式同时匹配 `HEAD`)⇒ 客户端可先探长度再决定要不要整段拉 |
|
||
| ⚠️ `416` | **不是 JSON 信封**:`Range` 语法非法或范围不可满足 ⇒ 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":{…}}`。唯一例外:**空文件** + 范围不可满足 ⇒ 忽略 `Range` 回 `200`(标准库的特别处理) |
|
||
| 日志 | 404 **不记**错误日志(可预期的输入问题);不记任何字节内容 |
|
||
|
||
- **三条读侧路的边界(别混)**:
|
||
|
||
| 端点 | 鉴权 | Disposition | 名字形态 | 语义 |
|
||
|---|---|---|---|---|
|
||
| `GET /api/v1/files/{name}` | **无**(能力 URL) | `inline`(按图 MIME) | 128-bit 随机名 + 图片扩展名 | 图片,给 `<img>` / provider 直接吃 |
|
||
| `GET /api/v1/documents/{name}` | **Bearer owner/admin** | `attachment` | 32 hex + **文档白名单**(23 项) | 文档,**永不内联渲染** |
|
||
| `GET /api/v1/media/{name}`(v0.67 登记) | **Bearer owner/admin** | `inline; filename=…` | 32 hex + **音频白名单**(10 项) | 语音副本,供播放器消费 |
|
||
|
||
- ⚠️ **音频 URL 不能直接贴进 `<audio src>`**:读侧要 Bearer(与图片读侧「128-bit 名即凭据」**刻意相反** —— 语音是私密内容,不是「谁拿到 URL 谁就能听」)⇒ 客户端须**带 token 取字节**(Web:`fetch` → `blob:` → 播放;原生:认证 HTTP 客户端)。这是**有意**的设计,不是缺陷。
|
||
- ⚠️ **可播放性未逐一实测**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了 ⇒ 客户端**以实测为准**,播不了就照实提示(不假装;**绝不给一个播不了的 `url`**)。「能播」必须是**格式级**结论,不得靠「扩展名看起来像音频」就宣称可播。
|
||
- ⚠️ **没有「删除一条语音副本」的 API**:保留期 GC 是唯一出口(`DOCUMENT_RETENTION`,默认 168h)。
|
||
|
||
### 4.6 `chat` 请求与响应面
|
||
|
||
**请求体**(`POST /api/v1/chat` 与 WS `chat` 帧 `data` **同形**):
|
||
|
||
```json
|
||
{"sessionId":"web","text":"...","image":"<base64/data:/URL>","deviceId":"web:chrome:a1b2c3",
|
||
"mode":"chat","clientMsgId":"<uuid>","documents":[{"name":"a1b2....md"}],"parallel":true,"stream":false}
|
||
```
|
||
|
||
- `sessionId` 省略 → `"web"`。
|
||
- `deviceId`:客户端端标识(如 `web:chrome:a1b2c3`),随消息落库。
|
||
- `mode`:行为模式(如 `chat`/`code`/`home`/…),随该回合落库;缺省回落 `chat`。
|
||
- `image`:base64 `data:` URI、裸 base64 或 http(s) URL。**上限按形态不同**(判据是**字符串长度**,不是解码后字节),见 [§4.5](#45-图片--文档--语音链路)。
|
||
- `stream`:仅 REST 用,见下。
|
||
|
||
**同步(非 `stream`)响应面**:
|
||
|
||
| 情形 | 状态 | 载荷 |
|
||
|---|---|---|
|
||
| 正常跑完 | `200` | `{data:{reply,steps,stopReason}}` |
|
||
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
|
||
| 忙、已续接排队 | `202` | `{data:{queued:true,queueDepth}}` |
|
||
| 忙、显式并行且照准 | `202` | `{data:{parallel:true,turnId:"turn-…"}}`(**拿不到同步 reply**;文本经 WS 流式到达) |
|
||
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
|
||
| 空回合(无 text/image/documents) | `400` | `{error:{code:"bad_request"}}` |
|
||
| **`image` 字符串超长**(**仅 base64 / `data:` 形态会触发**;URL 形态不会) | `400` | `{error:{code:"image_too_large"}}` —— 在解码/落库/入模型**之前**拒;上限 = `CHAT_IMAGE_MAX_BYTES` 换算出的**字符**上限(默认 **6990572 字符**),见 §4.5 |
|
||
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
|
||
| 回合内部报错 | `500` | `{error:{code:"chat_failed"}}` |
|
||
|
||
**`stream:true` 响应面(行为变更,v0.56/G2;**受理成功后才回**)**:
|
||
|
||
| 情形 | 状态 | 载荷 |
|
||
|---|---|---|
|
||
| 空闲已起回合 | `200` | `{data:{stream:true}}` |
|
||
| 忙、已续接排队 | `202` | `{data:{stream:true,queued:true,queueDepth}}` |
|
||
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
|
||
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
|
||
| 已升为独立并行路 | `200` | `{data:{stream:true,parallel:true,turnId:"turn-…"}}` |
|
||
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
|
||
| 仅未接编排器时的总线派发失败 | `503` | `{error:{code:"chat_dispatch_failed"}}` |
|
||
|
||
> **不再有「先回 200 再看有没有落地」**;`202`/`409` 真的会出现。派发用的回合上下文与请求作用域脱钩(`WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。
|
||
|
||
**忙时并行(v0.65 起,`parallel:true`,仅显式声明时生效)**:
|
||
|
||
- **触发权在客户端**:服务端**不做任何启发式自动升级**。空闲时 `parallel` 无效果(照常起第 1 路)。
|
||
- 会话**忙**且单会话并发额度未满(默认 **2**,硬上限 **3**)⇒ 本条**升为一条独立并行路立即起**(见上表 `{parallel:true,turnId}`;该 `turnId` 随后出现在这一路的 `turn_begin` 帧壳与全部流帧帧壳上)。
|
||
- 额度已满、**或真并行被 `<2` 关掉** ⇒ 落回排队语义(`202 {queued:true,queueDepth}`)**并在 `pending_ack` 带 `reason:"parallel_limit"`**。
|
||
- 在途路数经 `activeTurns` 出现在忙态帧(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`)与 `turn_begin.data` 中。
|
||
- `session_idle` 时点收紧(**仅并行时与旧行为不同**):所有在途路收尾**且** pending 空才报空闲(单路时逐字不变)。
|
||
- **每一路起跑都发一帧 `turn_begin`**(v0.66 落地,见 §3.4.3)——客户端据此把用户气泡钉到路、并按 `sessionId + turnId` 两级分桶。
|
||
- **省略 `parallel` 的客户端行为逐字不变**。
|
||
- ⚠️ **env `CHAT_MAX_PARALLEL_TURNS` 未接线**(见 §5、§7):生产**恒为默认 2**。
|
||
|
||
### 4.7 枚举权威表
|
||
|
||
`GET /api/v1/meta/enums` → `{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`。
|
||
|
||
- `platform`:`web`、`kook`、`qq-obv11`、`weixin-ilink`、`telegram`、`webhook`。
|
||
- `mood`:`calm`、`happy`、`excited`、`sad`、`angry`(**可扩展**,非封闭集合)。
|
||
- `mode`:`chat`、`code`、`home`、`work`、`docs`、`voice`、`vision`、`agents`、`settings`。
|
||
- `unknown` 恒 `"display-raw"`:**未知取值原样显示**——不丢弃、不报错、不替换占位符。判定**只比对 `id`**(大小写敏感);`label` 仅默认显示。服务端新增取值是非破坏性变更。
|
||
- ⚠️ **未确证**:上表逐值取自 `internal/contract/enums`(本文档经归档版转述,**未逐一回代码核对**)。客户端**应直接读本端点**而非硬编码。
|
||
|
||
### 4.8 `interrupt` 的 `scope` 与 `turnId`
|
||
|
||
上行 `{"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-…"}}`。
|
||
|
||
- `scope` 取值域 `{"", "all", "current"}`(**大小写敏感**,只容忍首尾空白):
|
||
- 省略 / `""` / `"all"`:取消**全部在途路**并**清空待处理队列**(历史语义)。
|
||
- `"current"`:只取消在途路(**多路时为每一路**),**保留**待处理队列(队列消息按正常续跑规则继续执行)。
|
||
- 其它取值 → **定向**回发送者 `{"type":"error","data":{"code":"bad_request"}}`(不广播、不打断任何会话,绝不静默按默认执行)。
|
||
- **`turnId`(v0.66 落地)**:**只在 `scope:"current"` 时生效**,只取消该 `turnId` 那一**路**(其余在途路与待处理队列不受影响)。省略 scope 或 `scope:"all"` 时按契约**忽略** `turnId`(服务端只留一条 debug 日志,响应面不变)。
|
||
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 `{"code":"bad_request"}`(只到发送者,**不广播、不动任何会话**),**绝不**静默降级成「停全部」。
|
||
- 省略 `turnId` 的 `scope:"current"` = 作用于**每一路**(v0.65 原口径,逐字不变)。
|
||
- **`scope:"all"` 不发 `session_idle`**:客户端靠 `session_interrupted` 收口。
|
||
- 命中空闲会话为 no-op。
|
||
|
||
### 4.9 会话角色绑定
|
||
|
||
- 会话角色(`personaId`)**创建时确定、之后不可变**(design/22 §8-1):`POST /sessions {personaId}` 在创建时绑定;`PATCH /sessions/{id}` 携带 `personaId` 键一律 `400`。
|
||
- 空/缺省 = 继承全局默认角色(`owner.yaml:defaultPersona`)。
|
||
- 消息 DTO 的 `personaId` = **该消息说话时的角色**(assistant = 生成它的角色,user = 当回合角色;空 = 默认角色)。
|
||
|
||
### 4.10 v0.68 新增:配置事实面与后台思考行的紧急度
|
||
|
||
#### (a) `GET /api/v1/admin/config-facts`(只读,`owner`/`admin`)
|
||
|
||
响应 `{data:{facts:[…], quietHours:{…}}}`:
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `facts[].key` | string | env 键名,**与 `.env.example` 逐字一致**(后端有测试钉住) |
|
||
| `facts[].value` | string | **进程生效值**(已解析:bool → `"true"`/`"false"`;整数 → 十进制;比例 → 归一化小数如 `"0.8"`)。**不是** `.env` 原文 |
|
||
| `facts[].default` | string | 该键的**声明默认值**(`internal/config` 结构体 `default:"…"` 标签,唯一真源) |
|
||
| `facts[].isDefault` | bool | `value == default` ⇒ 面板显示「默认」(**不代表 `.env` 里没写过这个键**:网关按红线**不读 `.env`**,无从不区分「默认层」与「结构体默认」) |
|
||
| `quietHours.startHour` / `endHour` | int | 安静时段的生效钟点(本地时区小时)。⚠️ 报的是**运行时真正生效**的那一个:两端都写 0 时按「未填」回落到声明默认(见下 `fallbackToDefault`) |
|
||
| `quietHours.disabled` | bool | 窗口是否**关闭**(两个相等的**非 0** 钟点,如 `23/23`) |
|
||
| `quietHours.label` | string | 面板可直接显示的文案:`"23:00-07:00"` 或 `"已关闭"` |
|
||
| `quietHours.fallbackToDefault` | bool,**可选**(`true` 才出现) | 两端都被写成 `0` ⇒ 视为**未填**,运行时回落到默认窗口(`23:00-07:00`,此时 `startHour`/`endHour` 已是回落后的值)。⚠️ **`0/0` 不是「关闭」**:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」 |
|
||
|
||
- **固定 9 条事实**(记忆整理 7 键 + 安静时段 2 键):`MEMORY_TIDY_ENABLED`、`MEMORY_TIDY_INTERVAL_SEC`、`MEMORY_TIDY_QUIET_START_HOUR`、`MEMORY_TIDY_QUIET_END_HOUR`、`MEMORY_TIDY_RECALL_THRESHOLD`、`MEMORY_TIDY_SCAN_LIMIT`、`MEMORY_TIDY_CANDIDATE_LIMIT`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。
|
||
- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 9 个非敏感调参键。
|
||
- **「默认 / 已改」怎么读**:`isDefault:true` ⇒ 面板显示「默认」(生效值等于该键的声明默认值);`isDefault:false` ⇒ 显示「已改」。⚠️ 它**不是**「`.env` 里没写过这个键」的断言 —— 网关按红线**不读 `.env`**,无从不区分「`.env` 给的值恰好等于默认」与「完全没配」。**哪一项没配**因此不会被伪造成一个值:面板拿 `value` + `isDefault` 如实展示即可。
|
||
- 9 条事实的 `default` **必须都能取到**(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,**不是**「未配置」。
|
||
- **安静时段的语义(重要,别照旧实现写文案)**:安静时段**只服务「定时主动思考 → 主动搭话」**(`design/12 §6` 策略五),**不静默回复** —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案**不得**写成「夜间全静默」。
|
||
|
||
#### (b) `GET /api/v1/admin/backthink` 条目的紧急度字段(v0.68 引入;**v0.70 起是精确档位**)
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `urgency` | string,**可选** | `"routine"` / `"warning"` / `"critical"`(`internal/urgency` 的档位词汇)。**默认 = 该轮实际生效的精确档位**(随记录落库)。**只有与 `urgencyLowerBound:true` 同时出现时才读作「下限」**(`warning` = 至少 high、可能更高) |
|
||
| `urgencyLowerBound` | bool,**可选**(`true` 才出现,**v0.70 新增**) | 该行**没有落库档位**(`urgency` 列出现之前的旧行,或从未走到档位判定点的行)⇒ `urgency` 是由安静时段窗口规则**反推出的下限**,不是精确档位 |
|
||
| `quietHoursBypassed` | bool,**可选**(`true` 才出现) | 该轮**穿透了安静时段**:轮次时间落在安静时段窗口内**且**该轮判定为「要推送」 |
|
||
|
||
- **取值优先级(v0.70,客户端按这个顺序读)**:
|
||
1. `urgency` 存在且 `urgencyLowerBound` 缺席 ⇒ **精确档位**,可直接显示(`critical` 即安全域,`warning` 即 high);
|
||
2. `urgency` 存在且 `urgencyLowerBound:true` ⇒ **下限**,只可显示为「≥ X」(`≥ 高` = 至少 high、可能更高;`≥ 例常` = 只确定不低于最低档),**不得**显示成精确值;
|
||
3. `urgency` 缺席 ⇒ **未知**(存档里没有这个事实),面板显示「未知」而**不是**「例常」——把未知画成「例常」与把下限画成精确值,是同一类「把猜当事实」。
|
||
- 三个键都可能缺席,缺席 ≠ 常规档位。旧行(`urgency` 缺席)出现 `urgencyLowerBound:true` 的情况**不存在**:没有值就没有可标记的下限。
|
||
- 旧行(无落库档位)与 `quietHoursBypassed` 的口径(**v0.68 起不变**):只在该轮时间**落在安静时段窗口内**时给出下限 —— 窗口内被放行 ⇒ `warning`(由 `backthink.Decide` 的级联可证:安静时段只放行 ≥ `high` 的轮次)且 `quietHoursBypassed:true`;窗口内被扣下 ⇒ `routine`。窗口之外的旧行判定不看档位、存档里也没有档位,故**不下发**。窗口口径与 B12 的 `quietHours` **同一份**(含「`0/0` 视为未填、回落到默认」这条),因此 0/0 的部署也不会漏报穿透。
|
||
- `quietHoursBypassed` 与既有的 `bypassed`(v0.66 前已有,含义是**事件驱动绕过限频闸**)是**两件事**,别混用:前者说「安静时段」,后者说「限频门」。
|
||
- ⚠️ **实时帧(v0.70 更正)**:`/ws/chat` 下行的 `backthink` 帧其 data 是 bus 上发布的原始 `backthink.Record`(Go 字段名 `ID`/`Trigger`/`ShouldPush`/`PushResult`…)。**该记录自 v0.70 起带 `urgency`(该轮实际生效的精确档位,非下限)** ⇒ 实时行同样可显示精确档位,不必等重开页面。**但实时帧仍不带 `urgencyLowerBound` 与 `quietHoursBypassed`**:这两个键是网关读历史时按安静时段窗口推导的,**不在记录里**(`urgencyLowerBound` 对实时行也不会出现——实时行要么有精确档位、要么无档位)。⚠️ 原 v0.68 表述「实时帧不带这两个键」对 `urgency` **已过期**,本版更正;对 `quietHoursBypassed` 仍然成立。
|
||
|
||
#### (c) 档位的来源与诚实边界(**v0.70 重写**)
|
||
|
||
- **精确档位(默认路径)**:`backthink.Record.Urgency` 由 backthink 在档位判定点原样落库(该轮实际生效的档位,含她自报紧急度上抬后的结果)⇒ 网关**优先读它**并**原样下发**,与安静时段窗口、与时间戳都无关。因此「白天的一轮」如今也有精确档位(v0.68 时代窗口之外一律显示「未知」)。
|
||
- **下限推导(只对没有落库档位的行)**:该列为空的行 —— 该列出现之前的**旧行**,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并**必须**带 `urgencyLowerBound:true`。⚠️ 旧行**不得**被显示成精确档位,未知也不得被显示成某个档位。
|
||
- 每轮 `Import` 落库(`urgency` 列)在 X2a 切片实现(`213aff2`);「该轮实际生效」的取数点见 `internal/services/backthink/backthink.go` 的判定点。⚠️ 本版**不改** `quietHoursBypassed` 的语义。
|
||
|
||
### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量)
|
||
|
||
**为什么有这条**:`graph_update`(§3.4.5)是**纯实时帧** —— 后端在每个 span 开始/结束时广播一次,**不重放**;前端把图存在**内存**的会话图桶里(`flowGraphs`)。于是「**轮次结束后才打开页面**」或「**刷新过页面**」看到的永远是空态(`/#/admin/flow` 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。v0.71 补上**打开页面时按会话只读拉一次最近一轮的快照**;v0.73 再补上**本地落盘 + 多轮历史回放** —— 图从「只活在进程内存」变成每轮一行落进本地库(新表 `flow_graphs`,迁移 `0022`),后端重启后仍能查回,且可按 `turnId` 翻更早的轮次(`design/19 §4` 的 P3)。
|
||
|
||
#### (a) 请求与响应
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 方法 / 路径 | `GET /api/v1/admin/flow/graph` |
|
||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 `design/19 §3`「flow 可视化仅 owner/admin」一致 |
|
||
| 查询参数 | `sessionId`(**可选**)。省略 / 空串 ⇒ 主会话 `web`(`internal/runtime/history.MainSessionID`)。其它取值就是该会话 id,如 `sess-backthink`(后台思考,design/20 §2)、`web:<uuid>`(次级会话)。**v0.73 新增** `turnId`(**可选**):省略 / 空串 ⇒ **最近一轮**(**先查内存 `loop.FlowPublisher.LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮(形如 `web-7`,取自 `turns[].turnId` / `graph.id`) |
|
||
| 响应体 | `{data:{sessionId, turnId, source, graph, turns}}`(**v0.73**;v0.71 只有 `{sessionId,graph}`)。`sessionId` = 解析后的会话;`turnId` = **实际返回的那一轮**的标识(无图时 `""`);`source` ∈ `"live"`(内存最近一轮)/`"persisted"`(落盘的历史轮)/`"none"`(没有图);`graph` = 该轮的图或 `null`;`turns` = 该会话**已落盘轮次**摘要 `[{turnId,createdAt}]`,**最新在前**(未接线持久化或没有轮次时是 `[]`,恒为非 null 数组) |
|
||
| `graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) |
|
||
| 归档 | **不审计**(只读查询,与同档的 `/admin/llm`、`/admin/config-facts` 一致) |
|
||
| 失败 | 图源(内存 + 落盘)**都没接线** ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这一轮没有图」**);该轮**确实**没有图 / 该 `turnId` 不存在 ⇒ `200` + `graph:null`(`source:"none"`)。落盘查询本身失败(库不可用)⇒ 同样 `500 flow_unavailable`(**不把「读库失败」伪装成「没有这一轮」**) |
|
||
|
||
#### (b) 语义硬约束(客户端必须照这个读)
|
||
|
||
1. **省略 `turnId` = 「最近一轮」而不是「你要的那一轮」。** 省略或空串时后端**先**取内存里该会话最新的一张图(`loop.FlowPublisher.LastGraph`,即正在跑/刚跑完的那一轮),**取不到才**回退到落盘的最新一行 —— 因此「最新一轮永远是最新的」。**不给 `turnId` 就不要假定它等于你眼前那条记录那一轮**:后台思考行上点开的图可能不是该行那轮 ⇒ 面板**必须**说明(当前实现:`frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`)。要精确取某轮就带上 `turns[].turnId`。
|
||
2. **给了 `turnId` = 精确取那一轮。** 该 `turnId` 正好等于内存里最新一轮 ⇒ 回内存图(`source:"live"`,更实时);否则读落盘行(`source:"persisted"`)。**该轮不存在 ⇒ `graph:null` + `source:"none"`**(诚实空态),**绝不**用别的轮次顶替。
|
||
3. **只读、不发布。** 拉快照**不产生** `graph_update` 帧、不改变发布白名单、不改变任何会话的实时图、也不落盘。
|
||
4. **`graph:null` 是诚实的空。** 该会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 `sess-xxx` 从未建图、也从不落盘)时返回 `null` ⇒ 客户端**必须**渲染空态,**绝不允许**补画任何节点/假图。
|
||
5. **落盘只覆盖发布会话白名单内的会话,且每轮一行**(`EndRound` 时写入,**不是每 span 一行**):图里可能含用户输入文本 ⇒ 客户端按既有 §1.1 脱敏口径读即可,但**不得**把图当成「有全部原文」的凭据。落盘件按保留期清扫(`FLOW_GRAPH_RETENTION`,默认 168h),**过期轮次会被回收** ⇒ `turns[]` 只反映「仍在保留期内」的轮次。
|
||
6. **快照会晚于实时帧到达,反之亦然。** 客户端已有实时图时**不应**被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在**本会话还没有任何图**时才拉。
|
||
7. **不替代** `graph_update`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。
|
||
|
||
#### (c) 与 design/19 的关系
|
||
|
||
`design/19 §4` 把「多轮历史回放」列为 **P3**;v0.71 落实了 P3 里**最小的一步**(每会话最近一轮的只读快照),**v0.73 把 P3 补全**(本地落盘 + 按 `turnId` 取任意保留期内的轮次 + 轮次列表)。二者都**不改** design/19 §1 的发布会话范围(`web` / `web:<uuid>` / `sess-backthink`,裁决 01.4.5)、**不改** `graph_update` 两路互顶的既有裁决(见 §7.4-19)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。
|
||
|
||
---
|
||
|
||
## 5. env 与模型配置(与契约相关)
|
||
|
||
> **`.env` vs 面板文件**:面板「LLM 模型」页保存写 `<DATA_DIR>/models.json`(默认 `./data/models.json`);「渠道管理」页写 `<DATA_DIR>/channels.json`。**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层。`.env.example` 与实现同步(键名逐字一致、值占位符)。
|
||
|
||
| 键 | 默认 | 与契约的关系 |
|
||
|---|---|---|
|
||
| `PORT` | `8080` | 网关唯一端口(含 REST/WS/admin) |
|
||
| `HOST` | `0.0.0.0` | 监听地址 |
|
||
| `ADMIN_PATH` | `admin` | 管理面板**前端路由前缀**(`/`+值)。**不是 REST 契约**:`/api/v1/admin/*` 一律不藏。非法值回退 `/admin`;路径形如面板入口但前缀不符 → `404` |
|
||
| `CHAT_IMAGE_MAX_BYTES` | `5242880`(5 MiB) | `chat.image` **字符串长度**上限的换算源(**单位是字节**,按 base64 4/3 膨胀换成字符:`ceil(n/3)*4 + 64`,默认 **6990572 字符**;只作用于 **base64 / `data:` 形态**,URL 形态不走这道检查)。`≤0` = 用默认;**只能收紧**,钳到 vision 硬顶(见 §4.5) |
|
||
| `CHAT_DOCUMENT_MAX_BYTES` | `20971520`(20 MiB) | `POST /documents` 单文件上限(`≤0` 用默认;与图片上限**刻意解耦**) |
|
||
| `AUTH_REFRESH_TTL` | `720h` | refresh token 有效期(access 固定 24h) |
|
||
| `AUTH_REFRESH_ROTATE_DISABLED` | `false` | `true` = 关闭 refresh 单次轮换 |
|
||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 占位 | 登录凭据(`POST /auth/login`);必填 |
|
||
| `JWT_SECRET` | 占位 | 必填、≥32 字符(签名 JWT) |
|
||
| `DATA_DIR` | `./data` | 数据目录;接受保留字 `platform`(大小写不敏感)→ 平台标准目录 |
|
||
| `DB_SQLITE_PATH` | `./data/cyrene.db` | SQLite 路径 |
|
||
| `DB_PG_URL` | 空 | pgvector **可选增强**;配置了必须真实走语义/向量路径。是否生效另由 `GET /admin/system/storage` 的 `vector.{enabled,ready}` 如实报告 |
|
||
| `ALLOWED_ORIGINS` | 示例值 | CORS + WS Origin 白名单(同 host 与回环别名额外放行) |
|
||
| `WS_MAX_CONNECTIONS` | `1000` | WS 连接上限(配置项,**未确证**是否在网关强制生效 —— 代码只见解析,未见到接线点) |
|
||
| `LLM_MULTIMODAL` | `false` | **主模型自身能否收图**的 `.env` 等价物(只作用于 `.env` 合成组;有有效 `models.json` 时以该组 `multimodal` 为准)。`true` = 图片直进主模型;`false`/留空 = 图片交 vision/OCR 工具。**非法值启动即报错** |
|
||
| `LLM_VISION_MODEL` / `LLM_OCR_MODEL` | 空 | **只供视觉/OCR 工具**调用;**不代表主模型能收图** |
|
||
| `LLM_CONTEXT_WINDOW` | 空 → 131072 | 上下文窗口(预算推导依据);负数启动报错 |
|
||
| `CONTEXT_MAX_TOKENS` | 空 → 按窗口推导 | `>0` 显式覆盖;`0`/负 = 关闭压缩。⚠️ 若 `.env` 里还留着 `32768`,它就是显式覆盖、推导不生效 |
|
||
| `CONTEXT_BUDGET_RATIO` | `2/3` | 预算占窗口比例(接受 `2/3` 或 `0.6667`) |
|
||
| `DOCUMENT_MAX_INJECT_CHARS` | 空 → 预算 1/8 | 单份附件注入上限 |
|
||
| `GUARDRAIL_MAX_INPUT_CHARS` / `GUARDRAIL_KEYWORDS` | 空 | 请求侧字符上限 / 关键词护栏 |
|
||
| `VOICE_STT_TIMEOUT_SEC` | `20` | **只约束 IM 渠道入站语音转写**;Web 客户端 `voice_input` 不受它约束 |
|
||
| `VOICE_TTS_VOICE_ID` / `VOICE_TTS_FORMAT` / `VOICE_TTS_MODEL` | 空 | `POST /audio/speech` 当前遵循的服务端配置(请求体 `voice`/`format` 为前向兼容接受但暂不生效) |
|
||
| `VOICE_TTS_INSTRUCTION` | 空 | 系统音色 `longxiaochun_v3` 固定格式、**不使用自由 instruction** |
|
||
| `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_API_KEY` / `LLM_MODEL` / `LLM_FALLBACK_MODEL` / `LLM_REASONING_KIND` 等 | 见模板 | LLM provider 组的 `.env` 合成组兜底(有有效 `models.json` 时被其覆盖) |
|
||
| `DOCUMENT_GC_INTERVAL` / `DOCUMENT_RETENTION` | `1h` / `168h` | 文档 GC(`0`/负 = 关闭 GC) |
|
||
| `LLM_USAGE_RETENTION` | `720h` | `llm_usage` 保留期(`0`/负 = 关闭清理) |
|
||
| `THINK_*` / `QUIET_*` | 见模板 | 后台思考与主动推送策略。`QUIET_START_HOUR`(`23`)/ `QUIET_END_HOUR`(`7`)是**本地钟点**的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 `23/7`):**只服务「定时主动思考 → 主动搭话」**,**不静默回复**(v0.68 起可经 B12 读到生效值) |
|
||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆** |
|
||
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
|
||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7` |
|
||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定) |
|
||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / `20` | 每分区每轮的扫描上限 / 候选上限 |
|
||
|
||
> **上面 9 个键(记忆整理 7 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」;在此之前面板没有任何 env 读取面。
|
||
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
|
||
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
|
||
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
|
||
| `CHANNEL_EVENT_RETENTION` | `720h` | **v0.67 登记,已接线**(E4 `afe9afb`;`.env.example` 同步于 `f9c5b56`):`channel_events` 保留期 —— 比文档的 `168h` 长,因为事件表的核心用途是**事后定案**(一周太短);`≤0` = 关闭 GC |
|
||
| `CHANNEL_EVENT_GC_INTERVAL` | `1h` | **v0.67 登记,已接线**(E4 `afe9afb`):渠道事件 GC 扫描间隔(与 `DOCUMENT_GC_INTERVAL` 同范式) |
|
||
| `CHANNEL_EVENT_COUNTER_FLUSH` | `30s` | **v0.67 登记,已接线**(E4 `afe9afb`):计数内存聚合的 flush 间隔。计数是**尽力而为的近似值**(进程崩丢 ≤30s),查询面以 `approx:true` 如实标注(见 §2.4(a)) |
|
||
| `FLOW_GRAPH_RETENTION` | `168h` | **v0.73 新增,已接线**(切片 F2):`flow_graphs` 轮次图保留期;`≤0` = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 `turns[]` 消失(见 §4.11(b)-5 / §7.4-20) |
|
||
| `FLOW_GRAPH_GC_INTERVAL` | `1h` | **v0.73 新增,已接线**(切片 F2):链路图保留期清扫间隔(与 `CHANNEL_EVENT_GC_INTERVAL` / `DOCUMENT_GC_INTERVAL` 同范式;启动期扫一次 + 定时 + 失败不退出) |
|
||
|
||
**与契约相关但「配了不生效」的键**:
|
||
|
||
- **`CHAT_MAX_PARALLEL_TURNS`:未接线**。编排器有构造期旋钮 `Deps.MaxParallelTurns`,但**组合根未从任何 env 读它** ⇒ **生产恒为默认 2**(`.env` 里配了不生效)。**本文档不把它登记为可用旋钮**。
|
||
- 旧 `MCP_*` 扁平键:生产装配只读 `MCP_SERVERS_JSON`。
|
||
- `GETUI_*`:SDK 未接入,预留占位、当前不生效。
|
||
|
||
---
|
||
|
||
## 6. 变更记录
|
||
|
||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||
|
||
### v0.74(当前契约版本)
|
||
|
||
**IM 路由的主动消息也补浏览器通知 —— `message_persisted` 新增可选结构化判据 `proactive:true`**(依据主人 2026-10-05 裁定 C 的必然推论「她说话该不该提醒我,与走哪条路由无关」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
|
||
|
||
- **纯增量**:
|
||
① **`message_persisted` 的 `data` 新增可选键 `proactive:true`**:IM 路由(`im_bridge`)的主动推送在落库之后**追加**一条带该键的同类帧 —— 帧类型、角色门、`message` DTO 形状**一字未改**,只多一个布尔键(**§3.4.4**)。
|
||
② 客户端据此走与 `proactive_message` **同一套**提醒逻辑(非前台补一发通知;去重键同为 `message.id`)。老客户端不读该键即行为与 v0.73 完全相同。
|
||
- **修的是什么(根因)**:`auto` 提示在主人有已连接 IM 渠道时**优先走 IM**,而 IM 路由此前只发普通 `message_persisted` —— 浏览器通知挂在 `proactive_message` 上 ⇒ **实际最可能发生的路径恰恰不弹**。这就是 P0(v0.72)如实报回的边界。
|
||
- **判据是结构化的**:主会话落库行的 `platform`/`adapter_id` **区分不出**主动推送与普通回复(主动推送写的是解析后的真实平台,例如 `weixin-ilink`,与普通回复行同一形状)⇒ 判据放在**落库那一刻的通知通道**上(`gateway.BroadcastProactivePersisted`),随帧顶到达客户端。**绝不**读正文关键词、**绝不**用提示词/模型判断(项目红线:身份与判定靠元数据)。
|
||
- **不误伤**:普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口 `BroadcastPersisted`,那条路径**永不**放该键 ⇒ 客户端不可能把普通消息误判成主动消息。客户端只应做 `data.proactive === true` 的严格相等判断。
|
||
- **行为变更**:无(普通镜像帧照旧发一条;主会话里仍**恰好一条**行,P0 的「不双份」红线未动)。
|
||
- **破坏性**:无。
|
||
- ⚠️ **诚实边界**:① 判据**不落库**(不改 schema)⇒ 刷新后从历史读回的行不带它(预期:通知只对实时到达有意义)。② **落库失败时不发判据帧**:没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃,发它等于假装提醒过 —— 那种情况下只记 WARN。③ 「她主动说的话」在三类帧上到达的完整清单见 §3.4.4 与 §3.4.5。
|
||
|
||
### v0.73(上一版)
|
||
|
||
**链路图本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库,`GET /api/v1/admin/flow/graph` 增量支持按 `turnId` 取历史轮**(依据主人 2026-10-05 追加要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」—— v0.71 只做了其中最小一步)。
|
||
|
||
- **纯增量**:
|
||
① **落盘**:每轮收尾(`EndRound`)把该轮的图写进新表 `flow_graphs`(迁移 `0022`),**每轮一行**(不是每 span 一行);只落**发布会话白名单内**的会话(复用 `flowSessionAllowed`,与实时发布同一判据)。新 env 键 `FLOW_GRAPH_RETENTION`(默认 `168h`)/ `FLOW_GRAPH_GC_INTERVAL`(默认 `1h`),GC 口径照 `events`/`documents`(启动期扫一次 + 定时 + 失败不退出;`≤0` = 关闭;**不许无限增长**)。
|
||
② **`GET /api/v1/admin/flow/graph` 新增 `?turnId=`**:省略 = 最近一轮(**先查内存 `LastGraph`,miss 才回退落盘** ⇒ 最新一轮永远最新);给了 = 精确取该落盘轮。
|
||
③ **响应新增三键 `turnId` / `source` / `turns[]`**:`source` ∈ `live`/`persisted`/`none`;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮 —— `graph`/`sessionId` 两键的既有语义一字未改。 —— **§2.2 B13 / §4.11**。
|
||
④ 前端:图桶 `flowGraphs` 纳入 store 持久化(刷新即刻有图)+ 历史轮次选择 UI + 诚实文案(白名单外会话永远没有图;未跑新轮次前的旧轮次靠落盘)。
|
||
- **行为变更**:无(`graph_update` 帧形状、角色门、发布会话范围全部未改;`graph`/`sessionId` 语义未改)。
|
||
- **破坏性**:无(新查询参数可选、新响应键可选;老客户端不读即行为与 v0.71 相同)。
|
||
- ⚠️ **诚实边界**:落盘只覆盖**保留期内**的轮次(默认 7 天)—— `turns[]` 不是全历史;`graph_update` 两路互顶的既有裁决**本版未动**(§7.4-19)。当前仍**没有图**的轮次见 §7.4-20。
|
||
|
||
### v0.72(上一版)
|
||
|
||
**主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id**(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
|
||
|
||
- **行为变更**:
|
||
① **`proactive_message` 那条消息现在会落进主会话历史**(`web`、`role: assistant`、`mode: "chat"`、`turn_id` 留空)。此前后台思考走**活跃客户端路由**时只广播 WS 帧、**一行都不写** ⇒ 刷新/重载后既不显示气泡、也补不出历史;那句 `delivered to 2 active client(s)` 只表示「帧被 2 个连接收下」,**不代表进了会话**。顺序改为**先落库、再发帧**。
|
||
② **帧新增两个可选键 `id` / `createdAt`**(同现/同省):这条消息在主会话历史里的权威行 id 与落库时间戳。客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后由历史行接续)。**落库失败时两键缺席** —— 客户端不应把「没有 id」当成一条可持久化的消息。
|
||
③ **没有在线 owner/admin 连接时也落库**:推送器如实回 `pushed=false`(没有活跃客户端),但那句话仍作为真实记录留在主会话里。
|
||
④ **后台思考记录里的 `PushResult.Detail` 措辞**:活跃客户端路由改为 `delivered to N active client(s), persisted to main session`(未落库时写明 `not persisted…`,无在线客户端但已落库时写明 `no active client, recorded in main session`)—— 面板因此不会把「帧发出去了、刷新就没了」误读成「已推送」。
|
||
- **纯增量**:帧的形状只在**新增可选键**;落库行的形状就是既有 `historyMessage`(`§4.4`),因此既有读取面(`GET /sessions/web/messages`、握手 `history` 回放、`message_persisted`)**逐字不变**。
|
||
- **破坏性**:无。老客户端按未知键忽略 `id`/`createdAt` 即可;但它会把主动消息**多看到一次**(历史行)—— 这是本版的目的,不是缺陷。
|
||
- **不双份(已验证)**:两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端;其余先 IM 桥、IM 投递失败才降级活跃客户端),而 IM 路由本就把消息镜像回主会话 ⇒ 同一条主动消息在主会话里**恰好一条**。契约细节见 §3.4.5 后的「`proactive_message` 的落库口径」。
|
||
- ⚠️ **诚实边界**:被频率门(`PushGate`)拒绝的推送**不落库**(限流就是「这一条不发」,绝不留下一条它没发过的记录)。
|
||
|
||
### v0.71(上一版)
|
||
|
||
**链路图补版 —— 新增只读端点 `GET /api/v1/admin/flow/graph`,让「轮次结束后才打开的页面」也能看到最近一轮的链路图**(依据主人 2026-10-05 第二次报「`/#/admin/flow` 停在暂无流程图数据 + 后台思考行提示该会话还没有 graph_update 快照」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」里的最小一步)。
|
||
|
||
- **纯增量**:
|
||
① **新 admin 只读端点 `GET /api/v1/admin/flow/graph?sessionId=<id>`**(`owner`/`admin`,`?sessionId=` 省略 = 主会话 `web`):返回 `{data:{sessionId,graph}}`,`graph` 与 `graph_update` 帧的 `data` **逐字同形**;该会话还没有图 ⇒ **如实 `null`**(绝不画假图);图源未接线 ⇒ `500 flow_unavailable`(**不假装「这一轮没有图」**)—— **§2.2 B13 / §4.11**。权限矩阵随之更新:`writeAuth` 12 → **13**,总数 64 → **65**。
|
||
② §3.4.5 `graph_update` 行补一句「**纯实时、不重放**」并指向本端点。
|
||
③ §7.4 新增两条诚实边界:**「最近一轮」≠「你要的那一轮」**(后台思考行上的图可能不是该行那轮;面板必须在图上说明)、**图源是进程内内存**(后端重启后如实回 `null`)。
|
||
- **修的是什么(根因)**:`graph_update` **只走实时帧**,前端图桶 `flowGraphs` **不持久化**(`store/persistOptions.ts` 的 `partialize` 不含它),且此前**没有任何 REST/WS 补拉通路** ⇒ 页面在轮次结束后打开、或刷新过页面,就永远空着。本端点补上唯一缺的那一环。
|
||
- **行为变更**:无(既有端点/帧一字未动)。
|
||
- **破坏性**:无(新端点;`graph_update` 帧形状、`frameReachesAll` 角色门、发布会话范围(裁决 01.4.5)**全部未改**)。
|
||
- ⚠️ **诚实边界**:只回**每会话最近一轮**;**多轮历史回放**(按 turn/时间点取任意一轮)**仍未实现**(`design/19 §4` P3)—— 见 §7.4-18。**`graph_update` 两路互顶**的既有裁决**本版未动**(§7.4-19)。
|
||
|
||
### v0.70(上一版)
|
||
|
||
**后台思考行的紧急度精确化 —— `urgency` 默认即精确档位,另加 `urgencyLowerBound` 标记下限**(依据主人 2026-10-05 裁定「要**精确**」;P4 只能给下限,本版把精确值送到两面:管理 REST 与实时帧)。
|
||
|
||
- **纯增量**:
|
||
① `GET /api/v1/admin/backthink` 条目的 `urgency` **默认语义改为该轮实际生效的精确档位**(backthink 在档位判定点随记录落库,含她自报紧急度上抬后的结果)—— **§2.2 B8 / §4.10(b)(c)**。
|
||
② 新增**可选** `urgencyLowerBound:true`(只在为 `true` 时出现):标记该 `urgency` 是**旧行的下限推导值**而不是精确档位(旧行 = `urgency` 列出现之前的行,以及从未走到档位判定点的行)。客户端据此区分「精确档位」/「≥ 下限」/「未知」三态。
|
||
③ **实时 `backthink` 帧带上记录里的 `urgency`**(精确值)⇒ 本次会话新到的行不必等重开页面就有档位。⚠️ 实时帧仍不带 `urgencyLowerBound` / `quietHoursBypassed`(两者由网关按安静时段窗口推导,不在记录里)—— 更正 §4.10(b) 原「实时帧不带这两个键」的表述。
|
||
④ §7.4-17 补「客户端不得把『下限』显示成精确值、不得把缺席显示成『例常』」。面板三态文案与悬停说明见 `frontend/web/src/admin/modules/backthinkUrgency.ts`。
|
||
- **行为变更**:`urgency` 的**读法**变了(v0.68/0.69 一律读作下限;v0.70 默认读作精确值,唯有带 `urgencyLowerBound:true` 才是下限)。取值域(`routine`/`warning`/`critical`)与 `quietHoursBypassed` 语义**一字未改**。
|
||
- **破坏性**:无(新键可选;把 `urgency` 当「下限」显示的老客户端最坏是显示得比事实保守,不会谎报精确值)。
|
||
- ⚠️ **诚实边界**:旧行(本列之前落下的)**没有**精确档位可显示 —— 它们仍走下限额推导并带 `urgencyLowerBound:true`;未知仍是未知(不编值)。见 §4.10(c) / §7.4-17。
|
||
|
||
### v0.69(上一版)
|
||
|
||
**`directMsgPerMin` 的 gateway 写入路径修复**(切片 P1-T;缺口 = 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分)。
|
||
|
||
- **纯增量 / 缺口修复**:`PUT /api/v1/admin/channels/{platform}/config` 现在**接收并原样落盘**账号顶层 `directMsgPerMin`,`GET /api/v1/admin/channels` 的账号元素也**如实回显**它 —— §2.2 A5 行 / §2.3 C28 注 / §7.2。
|
||
- **行为变更**:无(对不带该键的老客户端,行为与 v0.68 相同)。
|
||
- **破坏性**:无。
|
||
- ⚠️ **诚实边界**:账号级限流字段仍是**值语义**(请求体省略 ⇒ `0` = 未设置),要保住已设的值必须回送 —— 见 §7.3。
|
||
|
||
### v0.68(上一版)
|
||
|
||
**管理面「配置事实」只读端点 + 后台思考行的紧急度/穿透字段**(依据主人指正「你多久没把新功能同步管理面板更新了?」与裁决 14;**本版全部已实现、非契约先行**)。
|
||
|
||
- **纯增量**:
|
||
① **新 admin 端点 `GET /api/v1/admin/config-facts`**(`owner`/`admin`,只读、不审计):返回 9 条配置事实(记忆整理 7 键 + `QUIET_START_HOUR`/`QUIET_END_HOUR`)的**进程生效值**(`value`)+ **声明默认值**(`default`)+ `isDefault`,外加 `quietHours{startHour,endHour,disabled,label}` 的窗口解释 —— **§2.2 B12 / §4.10(a)**。**不含任何凭据值**(连凭据类键的「是否已配置」都不回)。权限矩阵随之更新:`writeAuth` 11 → **12**、总数 63 → **64**。
|
||
② `GET /api/v1/admin/backthink` 的条目增 2 个**可选**字段:`urgency`(`routine`/`warning`/`critical`)与 `quietHoursBypassed`(`true` 才出现)—— **§2.2 B8 / §4.10(b)**。语义:`urgency` 是**存档可证的下限**(`warning` = 至少 `high`),`quietHoursBypassed` = 「这轮在安静时段里仍被放行发出去」。
|
||
③ §5 登记 9 个键的默认值与语义(此前只以 `THINK_*`/`QUIET_*` 概括)。
|
||
- **行为变更**:无(既有两个端点的既有键一字未改;新字段都是可选键,老客户端不读即零影响)。
|
||
- **破坏性**:无。
|
||
- ⚠️ **诚实边界**:后台思考的**精确**档位(`high` 还是 `critical`)在存档里**已丢失**(`backthink.Record` 不带重要度,推送成功的行其 `detail` 被投递说明覆盖)⇒ 本版只下发**可证下限**,窗口外的轮次干脆不下发(面板显示「未知」),**绝不猜**。见 §4.10(c) / §7.4-17。
|
||
|
||
### v0.67(上一版)
|
||
|
||
**三批已在设计文档定稿的新增面一次性登记**:语音消息(播放端点 + 消息 DTO `voice` + 音频副本口径)、非消息事件查询面、账号在线状态(依据 `design/24 §10.11`、`design/26 §4.5` / `§2.6`)。登记时三批**都还没有实现**;其中**账号在线状态**(`52dac45`)与**非消息事件查询面**(本切片 E6)**已落地**(见下)。
|
||
|
||
> ⚠️ **本版是「契约先行」登记**:登记的三批里,**账号在线状态**(见 §2.4(b))与**非消息事件查询面**(见 §2.4(a))**已落地**;**语音消息尚未实现**,那一批的形状**取自设计文档而非代码核对**。客户端**先按形状对齐、不要按「已可用」编程**;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。
|
||
|
||
- **纯增量**:
|
||
① 新端点 `GET /api/v1/media/{name}`(用户侧,**owner/admin 必须 Bearer**):`200` 字节流 / `206 Partial Content` / `304 Not Modified`;`Content-Type: audio/*`;`Content-Disposition: inline; filename="<name>"`;`nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;支持 `Range` 与 `HEAD`;`404 media_not_found`;`401`/`403` 由中间件给出。⚠️ **`416` 是标准库直写的非 JSON 响应**(见 §0.3、§4.1、§4.5)。
|
||
② 消息 DTO 增**可选** `voice` 对象(`omitempty`;**非语音消息键缺席**):`{source,status,name?,mime?,size?,durationMs?,url?}`;`source` ∈ `asr`/`platform`,`status` ∈ `transcribed`/`empty`/`failed`,`name`/`mime`/`size`/`url` **四者同生共死**(有副本时才在);`url` 是服务端相对路径 `/api/v1/media/<name>`。**🎤 判据 = `voice` 键存在**。一次覆盖全部读取面:`GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(§3.2、§3.4.4、§4.4)。
|
||
③ 新错误码 **`media_not_found`(404)** → 加进 §4.1 的 404 行(与 `document_not_found` 并列;**刻意不复用**它,否则客户端分不清缺的是文档还是语音)。
|
||
④ 新错误形态 **`416`**(**非信封**,标准库直写)→ §0.3 / §4.1 / §4.5。
|
||
⑤ 新 admin 端点 **`GET /api/v1/admin/channels/events`**(非消息事件列表,倒序;`?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=`;响应 `{data:{events:[…],total}}`;`since`/`until` 按 UTC ISO-8601 定宽字符串**字典序**比较、**跨度上限 90 天**;不含正文、不含媒体 URL、不含任何凭据 / `flag`)—— §2.4(a)。**已落地**(`owner|admin` 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。
|
||
⑥ 新 admin 端点 **`GET /api/v1/admin/channels/events/counters`**(逐类计数汇总 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}`)—— §2.4(a);未接线存储 ⇒ **`500 channel_events_unavailable`**(新增错误码,**绝不回全 0 假汇总**)。**已落地**(`approx` 与 `counters` 同级;`platform` 未知 ⇒ `400 bad_request`)。
|
||
⑦ `GET /api/v1/admin/channels` 的 `accounts[]` 增**可选** `accountOnline`(**账号在平台侧是否在线**,与 `connected`(适配器/WS 是否连着)**是两件事**;**键缺席 = 状态未知 ≠ 离线**)—— §2.4(b)。**已落地**(管理面按可选接口断言读 `AccountStatus()`;`known=false` 或适配器无该状态面 ⇒ 键缺席)。
|
||
⑧ 新 env 键 3 个:`CHANNEL_EVENT_RETENTION`(`720h`)/ `CHANNEL_EVENT_GC_INTERVAL`(`1h`)/ `CHANNEL_EVENT_COUNTER_FLUSH`(`30s`)—— 已在 §5 登记(本切片**不新增**语音相关 env 键;语音上限/保留期复用既有常量与 `DOCUMENT_*`)。
|
||
⑨ **WS 协议零变化**:`media` 是 REST 端点;无新帧、无新上行字段。
|
||
- **行为变更**:
|
||
⑩ **QQ 语音消息的 `content` 不再含 `[语音]` 占位标记**(旧行为 → 移除):今天从正文里认 `[语音]` 的客户端**必须**改为看结构化字段(**`voice` 键存在 = 语音消息**)。⚠️ **这是本版唯一会打到老客户端的地方**(见 §7.2)。
|
||
⑪ `GET /api/v1/admin/system/storage` 的 `documents` 段(件数 / 字节)**自此包含语音副本**(音频副本与文档**同表**:`SELECT COUNT(*), SUM(size) FROM documents`)—— **形状不变**,变的只是**口径**(面板文案宜标注「含语音副本」)。
|
||
- **破坏性**:无(新端点、新可选字段、新错误码都是增量;`documents` / `/upload` / `/files` / `/audio/*` 的既有端点**一字未改**;WS 零变化)。
|
||
|
||
### v0.66(上一版)
|
||
|
||
**并行帧层上线 + 消息回合标识落库 + 渠道按账号操作;并修正 v0.65 的两处过期/写错的陈述**(依据 design/15 §2.5、design/08 §4;提交 `c0dc435` / `d64b26d` + `d804c4e` / `42279b7` / `a918819` / `679cee0`)。
|
||
|
||
- **纯增量**:
|
||
① 新下行帧 **`turn_begin`**(owner+admin,**不进** `frameReachesAll` 白名单):帧壳 `{type,sessionId,turnId}`、data `{kind,messageIds,leg,activeTurns}`,**每起一路(含续跑路)恰好一帧**(`c0dc435`)。
|
||
② **`steer_ack` 增帧壳 `turnId`**(data `{messageId,atStep}` 不变)(`c0dc435`)。
|
||
③ **上行 `interrupt.turnId`**:仅 `scope:"current"` 生效(其它 scope 按契约忽略);目标路不存在 ⇒ **定向** `{type:"error",data:{code:"bad_request"}}`(复用既有 code),不广播、不动会话(`c0dc435`)。
|
||
④ 消息 DTO 增**可选** `turnId`(`omitempty`,空则键省略;同一路的 user 与 assistant 同值)—— 一次覆盖 `GET /sessions/{id}/messages`、`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、WS `message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放(`d64b26d` + `d804c4e`;迁移 `0018`)。
|
||
⑤ `POST /admin/channels/open|close` 请求体增**可选** `accountId`;未知 id ⇒ `404 account_not_found`,已配置未注册 ⇒ `409 account_not_registered`;响应增 `resolved` / `accountId`(`42279b7`)。
|
||
⑥ `DELETE /admin/channels/{platform}/config` 增**显式** `?empty=1`(与 `?id=` 互斥 ⇒ `400`;无可删 ⇒ `404`);响应增 `empty` / `removed`(`42279b7`)。
|
||
⑦ `GET /admin/channels` 的 `accounts[]` 增**可选** `connected`(键缺席 = 状态未知 ≠ 未连接)(`42279b7`)。
|
||
⑧ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 `20`;**只约束 IM 入站语音**,Web `voice_input` 不受其约束)(`a918819`)—— 已在 §5 登记。
|
||
- **行为变更**:
|
||
⑨ **IM 用户消息现在带 `documents`**(同一 DTO、无新字段;0 附件仍**省略**该键)(`679cee0`)—— v0.65 曾预告,本次随该提交真正上线。
|
||
⑩ `turn_begin` 的**帧序**:`session_busy`/`pending_drain` → `message_persisted`(user) → `turn_begin`(`messageIds` 必须是已落库的权威 id,故只能在落库之后发)(`c0dc435`)。
|
||
⑪ **不带 `accountId` 的 open/close 目标现在是确定性的**:按 `channels.json` 有效配置顺序第一条(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)(`42279b7`)。
|
||
⑫ `DELETE .../config?empty=1` 由「被当成 id 缺失 ⇒ `400`」变为**真的删除无 id 条目**(显式化;`?id=` / `?empty=` 语义边界见 §2.3 注)(`42279b7`)。
|
||
- **破坏性**:无(新帧对新客户端可见、老客户端忽略即可;所有新增字段皆可选;无既有字段被移除或改义)。
|
||
- ⚠️ **本版更正 v0.65 的两处陈述(文档写错,不是代码变更)**:
|
||
- **`thinking` 口径**:本文档曾写「历史思维链只有 `GET /sessions/{id}/messages`(owner/admin)返回」—— **与实现不符**。`history.Message.Thinking` **全仓没有任何写入者**(`loop.Result` 无该字段、`finalizeAnthropicStream` 直接丢弃思考文本)⇒ 该键在**所有**读取面**恒缺席**;思维链的**唯一出口**是实时 `thinking.*` 帧(owner/admin)。已在 §3.4.4、§4.4 按实测改写。
|
||
- **v0.65 §7 的「未实现」清单**:`turn_begin` / `steer_ack.turnId` / `interrupt.turnId` / `chat_messages.turn_id` / 写类工具文件级互斥 —— **现已全部实现并上线**(`c0dc435`、`d64b26d`+`d804c4e`、`354b08a`),§7 已相应改写。
|
||
- ⚠️ **v0.66 补充说明(描述性更正,不动形状/阈值,不升版本号)**:**`chat.image` 的体积判据是「字符串长度」而不是「解码后字节」** —— 旧措辞「内联上限 = 解码后 5 MiB」只对 **base64 / `data:` 形态**成立,且它本身是**换算口径**(`len(image) <= 6990572` 字符,见 §4.5)。**URL 形态不触发 `image_too_large`**(URL 串约 60 字符,必然通过):其天花板在视觉取回层的 **50 MiB**(`io.LimitReader`,**超出静默截断**、不报错);**`POST /api/v1/upload` 自身是 5 MiB 字节硬限**(`413 payload_too_large`)。四处已按实现写清(§4.5 图片、§4.6 响应面、§5 环境变量表、U8 行 + 错误码表)。**无端点 / 帧 / 字段形状变更,无默认值变更**。
|
||
|
||
### v0.65
|
||
|
||
**忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记**(依据 design/15 §2.5,主人 2026-10-05 裁决 B)。
|
||
|
||
- **纯增量**:① 上行 `chat.parallel?:bool`(WS `chat` 帧 + REST 三入口同形);② `session_busy`/`session_idle`/`pending_drain`/`session_interrupted` 的 `data` 增 `activeTurns:int`;③ `session_interrupted.data` 增 `cancelledTurns:int`;④ `pending_ack.data` 增**可选** `reason:"parallel_limit"`;⑤ REST 两条新应答形状(同步 `202 {parallel:true,turnId}` / `stream:true` 的 `200 {stream:true,parallel:true,turnId}`);⑥ 新 env 键 `VOICE_STT_TIMEOUT_SEC`(默认 20s)。
|
||
- **行为变更**:⑦ IM 入站附件引用**真的写进落库的用户消息**(`documents` 列,同一 DTO、无新字段);⑧ IM 各渠道语音进上下文(云端 ASR 转写为文本,失败落一行中文诚实提示);⑨ `session_idle` 时点收紧为「所有在途路收尾且 pending 空」(**仅并行时生效**)。
|
||
- **破坏性**:无(省略 `parallel` / 不读新键的客户端行为逐字不变)。
|
||
- ⚠️ **回填**:上条 ⑦(IM 附件引用落库)与 ⑥(`VOICE_STT_TIMEOUT_SEC`)在 v0.65 冻结时**尚未提交**,已分别在 `679cee0` / `a918819` 落地,见 v0.66。
|
||
|
||
### v0.64
|
||
|
||
**LLM 用量落库 + 只读汇总端点 + 面板用量小卡**。
|
||
|
||
- **纯增量**:新端点 `GET /api/v1/admin/llm/usage`(owner/admin 读)、新迁移 `0017_llm_usage`、新 env 键 `LLM_USAGE_RETENTION`(默认 `720h`,`≤0` = 关闭清理)。
|
||
- **行为变更 / 破坏性**:无(`model` 层新增的是**可选** sink,不注入时行为逐字不变)。
|
||
- ⚠️ v0.64 曾写「`llm_usage.session_id` 恒为 `NULL`」,**已由 v0.65 更正**:loop 已把会话 id 播种进运行 ctx 并落库(空串仍映射 `NULL`);但 `GET /admin/llm/usage` **仍不按 `session_id` 过滤/分组**,响应形状不变。
|
||
|
||
### v0.63
|
||
|
||
**客户端登记两条落地:`documents[]` 增可选 `reason` + `GET /sessions/{id}/messages` 对不存在会话回 `404 session_not_found`**。
|
||
|
||
- **纯增量**:① `documents[]` 元素增**可选** `reason`(`omitempty`,只在 `status:"unreadable"` 时下发)。
|
||
- **行为变更**:② `GET /api/v1/sessions/{id}/messages` 对**不存在/已删除**会话由 `200 {"data":[]}` 改为 **`404 session_not_found`**(客户端须处理 404)。判据是会话注册表 `chat_sessions`:隔离 `sess-*` 也回 `404`(其消息走 `/admin/channels/{platform}/messages`)。
|
||
- **破坏性**:无。
|
||
|
||
### v0.62
|
||
|
||
**面板「重置为 env 默认」动作**:新增 owner-only `POST /api/v1/admin/llm/reset-from-env`(body 必须 `{"confirm":true}`)。
|
||
|
||
- **纯增量**:新端点 + 新审计动作 `llm.resetFromEnv` + 2 个新错误码(`env_defaults_unreadable` / `models_unreadable`),复用既有 `llm_persist_failed`/`llm_reload_failed`/`llm_unavailable`。
|
||
- **行为变更 / 破坏性**:无。
|
||
|
||
### v0.61
|
||
|
||
**PDF 文本层抽取:白名单第 23 项 `.pdf` + 类型裁决改为 `(扩展名, 嗅探)` 成对裁决**。
|
||
|
||
- **行为变更**:① `.pdf` 由「一律 `415`」变为「可上传 + 可读取 + 文本层可抽取」;② 类型裁决收紧为成对判定(HTML/纯文本改名 `.pdf` ⇒ `415`;PDF 改名 `.txt` ⇒ `415`)。
|
||
- **纯增量**:白名单 22 → 23 项(唯一新增 `.pdf`),其余 22 个文本类判定与响应一字未变。
|
||
- **破坏性**:无。
|
||
|
||
### v0.60
|
||
|
||
**管理侧只读「存储状态」端点 + 面板存储卡接真数据**:新增 `GET /api/v1/admin/system/storage`(**owner-only**、只读、无审计动作)→ `{data:{main,documents,vector}}`。
|
||
|
||
- **纯增量**:新端点 + 前端新行为;既有端点/帧/字段形状一字未动。
|
||
- **行为变更 / 破坏性**:无。
|
||
|
||
> **更早版本(v0.28 → v0.59)**:见归档 `Docs/archive/api-client.v0.65.md` §5。
|
||
> **未实现的契约变更记录**:归档 §5 里 v0.56–v0.59 的多条「未做/诚实边界」在 §7 重新登记。
|
||
|
||
---
|
||
|
||
## 7. 在飞切片与「未实现 / 未确证」
|
||
|
||
> 本节是**诚实边界**:让客户端**不要以为已有**、也不要**误以为没有**。
|
||
|
||
### 7.1 在飞切片
|
||
|
||
**v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖**(下表)。⚠️ **v0.67 新登记的三批中,账号在线状态(`accounts[].accountOnline`)与非消息事件查询面都已落地**(见 §7.2);**语音消息尚未实现**,**不在本表** —— 逐条见 §7.2。
|
||
|
||
| 项 | 状态 | 冻结位置 |
|
||
|---|---|---|
|
||
| 新下行帧 **`turn_begin`** | **已上线**(`c0dc435`) | §3.4.3;`session_events.go`、`session_parallel.go` |
|
||
| **`steer_ack` 的帧壳 `turnId`** | **已上线**(`c0dc435`) | §3.4.3;`session_steer.go`(`emitTurnState`) |
|
||
| **`interrupt.turnId`** | **已上线**(`c0dc435`) | §4.8;`ws_hub_turn.go`、`session_interrupt.go` |
|
||
| **消息 DTO `turnId`**(含 `chat_messages.turn_id` 迁移 `0018`) | **已上线**(`d64b26d` + `d804c4e`) | §4.4;`history.go`、`session_turn_id.go` |
|
||
| **写类工具文件级互斥**(读共享 / 读写与写写互斥,被占住回 `ErrFileBusy` 文案给模型) | **已上线**(`354b08a`、`ef3dcb6`)—— **纯服务端行为,无客户端可见契约面** | `internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}`、`loop/tools.go` |
|
||
|
||
> ⚠️ **工作树另有未提交改动(未冻结、未登记为契约项)**:当前工作树存在与本文档无关的未提交改动(例如助手回复的 `<p>` 段落包裹规整 `internal/runtime/turnreply/`、前端 `turn` 分桶接线等)。它们**尚未 commit、未经契约评审**,本文档**不逐项登记**其形状;客户端**不要**按当前工作树形态编程 —— 等它们冻结成下一版契约条目后再对齐(本表只登记已冻结的项)。
|
||
> 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
|
||
|
||
### 7.2 确认为「未实现」(别当已有)
|
||
|
||
| 项 | 现状 |
|
||
|---|---|
|
||
| **🔴 v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **未实现**(**契约已先行登记**):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 `design/24 §10`。⚠️ 同批的**行为变更**(QQ 语音 `content` 不再含 `[语音]`)与**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)也**随该切片一起生效**,现在**尚未生效** |
|
||
| **✅ v0.67 登记的「非消息事件查询面」**(`GET /admin/channels/events`、`GET /admin/channels/events/counters`) | **已实现**(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、`approx` 与 `counters` 同级、`400 bad_request`、`owner\|admin` 档);权威设计 `design/26 §4.5`。未接线存储 ⇒ `500 channel_events_unavailable`。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数 |
|
||
| **✅ v0.67 登记的「账号在线状态」**(`GET /admin/channels` 的 `accounts[].accountOnline`) | **已实现**(v0.67 登记项本次落地;落地前基线 `f91a83f`,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按**可选接口断言**读 `AccountStatus()`(`plugins/channels/qq_obv11/probe.go`,`a79fe96`);适配器**没有**该状态面、或实现了但 `known=false` ⇒ **键缺席**(未知 ≠ 离线,绝不用 `false` 冒充)。当前只有 `qq-obv11` 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表 |
|
||
| **✅ P1-T:`directMsgPerMin` 的 gateway 写入路径**(`GET /admin/channels` 的 `accounts[].directMsgPerMin` + `PUT .../config` 的顶层 `directMsgPerMin`) | **已实现**(v0.69):缺口 = gateway 面没让该字段通过 ⇒ **面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 `0` = 保护性默认 6 条/分**。现 5 处透传(`AdminChannelAccount` DTO / `channelConfigRequest` / `config.ChannelAccount` 拷贝 / `adminChannelAccountOf` / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注 |
|
||
| **`graph_update` 按路分桶** | **未实现**:`graph_update` 只带 `data.sessionId`(会话级),**无 `turnId`** |
|
||
| **第二路进行态 system 摘要**(`WithParallelNotice`) | **未实现**:全仓无该符号;`session_parallel.go` 明示留待后续切片 |
|
||
| **env `CHAT_MAX_PARALLEL_TURNS`** | **未接线**:`Deps.MaxParallelTurns` 是构造期旋钮,但组合根未从任何 env 读它 ⇒ **生产恒为默认 2** |
|
||
| **`tool_execution.update` 的生产者** | wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 `StreamKindToolExecutionUpdate` 的生产者)⇒ 实际**暂无生产者**(`start`/`end` 真实发出) |
|
||
| **`chat_messages.turn_id` 上的 IM 行** | IM 渠道桥**不走**网关编排器 ⇒ 其行 `turn_id` 留空属**预期**(契约里该键缺席),不是缺陷 |
|
||
|
||
### 7.3 诚实边界(既有事实)
|
||
|
||
- `subagent_result` **不落库**:父会话转录里没有新消息、**重连回放不补发**。
|
||
- **`proactive_message`(v0.72 起)落库**:成为主会话 `web` 的一条 `assistant` 行(见 §3.4.5 后的「落库口径」)。**仍未落库的情形**(都是如实、预期的):① 被 `PushGate`(频率门 / 冷却 / 按风格窗口)或安静时段挡下的推送 —— 「这一条不发」就没有记录,**绝不留下一条它没发过的消息**;② 后端未接线历史存储(`HistoryStore: nil`,仅测试与异常部署)时只发帧不写库,进程重启即丢。
|
||
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
||
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
||
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
|
||
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。
|
||
- `/audio/voice-clone` **真建音色**需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
|
||
- 可观测三帧 `observability_*`:`?level=` 分级过滤**由客户端本地做**(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(**未做** `observability_audit` 帧)。
|
||
- **`thinking` 恒缺席(v0.66 口径更正)**:消息 DTO 的 `thinking` 在**所有**读取面都不出现(`history.Message.Thinking` 全仓无写入者)。思维链只有实时 `thinking.*` 帧(owner/admin)。**旧文档称「历史思维链可由 `GET /sessions/{id}/messages` 返回」是错的**,见 §3.4.4。
|
||
- **消息 DTO 的 `turnId` 只在 Web/网关回合存在**:IM 渠道桥**不经过**网关编排器 ⇒ 其落库行的 `turn_id` 为空、DTO 里 `turnId` 键缺席(预期行为)。`turn_begin` / `steer_ack` / `interrupt.turnId` 同理只存在于网关回合。
|
||
- **`GET /api/v1/media/{name}` 读侧要 Bearer owner/admin(v0.67 登记)**:音频 URL **不能**直接贴进 `<audio src>`(浏览器不带 token)⇒ 客户端须带 token 取字节(Web:`fetch` → `blob:`)。这是**有意**的(语音是私密内容,与图片读侧「128-bit 名即凭据」的取向刻意相反)。
|
||
- **微信(`weixin-ilink`)入站语音没有音频副本、不可回放(v0.67 登记)**:按既有裁决「平台自带转写就不白下载那份音频」⇒ `voice.source:"platform"` 且**无 `name`/`url`**,只保留 🎤 标记。
|
||
- **`416` 不是 JSON 信封(v0.67 登记)**:`GET /api/v1/media/{name}` 的 `Range` 错误由 Go 标准库直写 `text/plain`,**不套** `{"error":{…}}` ⇒ 客户端别拿 `error.code` 解析它(§0.3 / §4.1 / §4.5)。
|
||
- **账号级限流字段是「值语义」,不是三态(v0.69 明确)**:`groupMsgPerMin`/`msgPerMin`/`maxMessages`/`directMsgPerMin` 在请求体里**省略即 `0`(未设置)**—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 `directMsgPerMin:-1`)重置为 `0`。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 **未改**。
|
||
- **后台思考行的实时帧只带记录里的字段(v0.70 边界)**:`urgency` 在实时帧上一定是**精确档位**(记录落库值),`urgencyLowerBound` / `quietHoursBypassed` **不在记录里**、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
||
|
||
### 7.4 「未确证」清单(客户端请以实测/代码为准)
|
||
|
||
1. **`GET /meta/enums` 逐值**(platforms/moods/modes)取自归档转述,本文档**未逐一回 `internal/contract/enums` 核对**。客户端应直接读端点,不要硬编码。
|
||
2. **`conflict`(409) 通用错误码**除 `chat_busy` 外,还有哪些端点会带它 —— 未逐一确证。
|
||
3. **各管理写端点的精确请求体字段与状态码**:本文档 §2.3 只给「方法/路径/鉴权/一句话要点」,**未逐字段展开**(归档 `Docs/archive/api-reference.v0.65.md` 有历史详细版,但可能漂移)。客户端实现管理写操作前请回代码或参考该归档并**以代码为准**。
|
||
4. **`observability_metrics` 的完整 `AdminMetrics` 字段集**:本文档只说明它是扁平快照,未展开字段。
|
||
5. **`subagent_event` 在 `subagent_ask` 应答路径上的精确字段**(`orchestration.AuditResult` 形状):未展开。
|
||
6. **`account_not_registered`(409) 的适用面**:本文档只确证了 C19/C20(open/close)会带它;`POST /admin/channels/test`、`/{platform}/message/test` 等其它渠道端点是否也有等价的账号级错误码 —— **未逐一确证**。
|
||
7. **`turn_begin.data.leg` 在长会话中的取值范围**:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档**未确证**客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。
|
||
8. **`GET /admin/channels` 的 `accounts[]` 除 `connected` 外的字段集**:本文档未逐字段展开(该结构在 v0.66 只是**新增**一个可选键;v0.67 另登记了一个可选 `accountOnline`,见 §2.4(b))。
|
||
9. **(v0.67 登记 · 已落地确证)`GET /admin/channels/events` 的 `events[]` 元素 JSON 键名**:**已定为 camelCase**,逐字与 `internal/services/gateway/admin_channel_events.go` 的 `channelEventDTO` 一致:`id` / `platform` / `accountId` / `tier` / `category` / `platformType` / `channelId` / `channelType` / `groupId` / `actorId` / `actorRole` / `targetId` / `selfRelated` / `messageId` / `summary` / `createdAt` / `updatedAt`(时间为 **UTC RFC3339**;`counters[]` 同为 camelCase,风格一致)。客户端**可以**硬编码这些键名了。
|
||
10. **(v0.67 登记 · 已落地确证)`events.total` 的语义**:是**筛选条件下的总条数**(**不受** `limit`/`offset` 影响;与 `events` 同源条件),因此 `total > events.length` 是正常的分页状态。
|
||
11. **(v0.67 登记 · 已落地确证)渠道事件查询的参数默认值与错误码名**:`limit` 未给 / `≤0` ⇒ 存储层缺省 **`100`**;超硬上限 ⇒ **钳到 `500`**(不报错);`offset` 负值 ⇒ `0`;**非法整数 ⇒ `400 bad_request`**。`platform` 未知 / `tier` 非 `t1|t2|t3` / `since`-`until` 跨度超 90 天 / 时间点非法 ⇒ 一律 **`400` + `code:"bad_request"`**(未新造专用错误码)。`category` **不做白名单**(未知类别 = 精确匹配 ⇒ 空结果)。`approx` 的**嵌套层级已定为 `data.approx`**(与 `data.counters` 同级)。
|
||
12. **(v0.67 登记 · 已落地确证)两个渠道事件端点的 RBAC 档位**:**`owner|admin`**(`writeAuth`),与渠道消息日志面(B2/B3)同档;**viewer 为 `403`**。依据:事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是 viewer 的监控投影。
|
||
13. **(v0.67 登记 · 已确证)`accounts[].accountOnline` 的字段名与是否有 `known` 同级键**:字段名 `accountOnline` 为本契约所拟(设计只规定「独立状态面 / `AccountStatus()` / `known` 三态」,未给管理面字段名),**实现沿用了它、未改名**;**不发** `known` 同级键 —— 「未知」由**键缺席**承载。哪些平台会带该键**已确证**:只有适配器实现了 `AccountStatus()` 的平台(当前仅 `qq-obv11`)。
|
||
14. **(v0.67 登记)音频各容器在各客户端的可播放性**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端**以实测为准**,播不了就照实提示。
|
||
15. **(v0.67 登记)`GET /admin/system/storage` 的 `documents` 段是否拆出语音副本**:设计**本期不拆**(不新增 `kind` 列 / 不按 ext 聚合)⇒ 该段**只增口径、不变形状**,客户端**不要**指望能分开统计。
|
||
16. **(v0.67 登记 · 前向提示)`voice.url` 指向的可能不是原件,而是转码后的副本**:`design/24 §10.14` 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = `amr → wav`)」立为口径,转码能力规格见 `design/25-转码能力.md`(**该能力尚未接线**)⇒ 客户端**不要假定 `url` 一定是某一种音频格式**,一律**按响应的 `Content-Type` / `mime` 如实处理**,播不了就照实提示(**绝不给一个播不了的 `url` 假装能播**)。⚠️ 本条**不在 `design/24 §10.11` 的差异清单内**(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「`url` 的字节格式」会直接影响播放端;**最终口径以 `design/25` 为准**。
|
||
|
||
17. **(v0.68 登记 · v0.70 更正)`urgency` 的「下限」与「精确」之分**:
|
||
- **v0.68 原文(当时成立,现已过期)**:`urgency` 一律是「档位下限」,`warning` 读作「至少 `high`(也可能是 `critical`)」;**窗口之外的轮次不下发该键**(`backthink.Record` 不带重要度、推送成功的行 `detail` 被投递说明覆盖 ⇒ 无损精确档位已不可能),要精确档位需 backthink 侧把每轮 `Import` 落进记录(当时未做)。
|
||
- **v0.70 更正**:每轮 `Import` **已落库** ⇒ `urgency` **默认就是精确档位**(含窗口之外/白天的轮次);只有与 `urgencyLowerBound:true` 同时出现时才是**下限**(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 `backthink` 帧也带 `urgency`(精确值),但**不带** `urgencyLowerBound` / `quietHoursBypassed`(见 §4.10(b) 更正)。
|
||
- **客户端两条硬约束(v0.70 新增)**:① **不得把带 `urgencyLowerBound:true` 的「下限」显示成精确档位**(`≥ 高` ≠ `高`;同形就等于把推导冒充事实);② **不得把键缺席显示成「例常 / routine」**(缺席 = 存档里没有这个事实),也不得据此推断安全域。
|
||
|
||
18. **(v0.71 登记 · v0.73 更新)「最近一轮」≠「你要的那一轮」**:`GET /api/v1/admin/flow/graph`(§4.11)在**省略 `turnId`** 时回该会话**最新**的一张图(先内存 `loop.FlowPublisher.LastGraph`,miss 才落盘)。**后台思考行**上点开的图因此可能不是该行那一轮 ⇒ 面板**必须在图上说明**(当前 `frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx`),客户端**不得**把它渲染成「这一行的链路」。**v0.73 起**客户端要精确取某轮就带 `turns[].turnId`(**多轮历史回放已实现**,`design/19 §4` 的 **P3**)。
|
||
19. **(v0.71 登记 · v0.73 更新)图源是**进程内内存**,但**每轮收尾会落盘**(v0.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**登记既有裁决不改**:`graph_update` 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍**未修**,本版**不碰**它。
|
||
20. **(v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话**:`turns[]` 是**已落盘且未被 GC 回收**的轮次摘要,**不是全历史**(`FLOW_GRAPH_RETENTION` 默认 168h,`≤0` = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布**同一白名单**(`flowSessionAllowed`:`web` / `web:<uuid>` / `sess-backthink`)—— **群聊 / 陌生人会话永远不会出现在 `turns[]` 里**。此外**正在跑、尚未 `EndRound`** 的那一轮不会在 `turns[]` 中出现(它是内存里的「实时」那一轮,`source:"live"`),这是如实而非缺失。
|
||
|
||
21. **(v0.74 登记)`message_persisted` 的 `proactive` 判据只活在实时帧上,不落库**:它是「这条是她主动说的话」在线上**唯一**的结构化判据(帧顶布尔键,由 `gateway.BroadcastProactivePersisted` 打上),**没有对应数据库列**(本片不动 schema)。因此 ① `GET /sessions/{id}/messages`、握手 `history` 回放、`GET /admin/channels/**` 读到的历史行**不带**它(预期:通知只对实时到达有意义);② 客户端**不得**从 `platform` / `adapterId` / `turnId` 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是**解析后的真实平台**(例如 `weixin-ilink`),与普通回复行同一形状,反推必然误伤。**落库失败时不发该帧**(没有权威 `id` 的 `message_persisted` 会被客户端整帧丢弃)。
|
||
|
||
---
|
||
|
||
*本文件由「以代码为真源」维护(当前 `v0.74`;`v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.74` 同步了 `internal/services/gateway/**`(`proactive.go` / `broadcast.go`)、`internal/services/channelbridge/**`(`proactive.go` / `persist.go` / `proactive_notify.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/lib/ws/handlers/**` 与本文件;`v0.73` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`internal/runtime/flowgraph/**`、`internal/db/migrations/0022_flow_graphs.*`、`frontend/web/src/**` 与本文件;`v0.72` 同步了 `internal/services/gateway/**`(`proactive*`)、`internal/services/backthink/**`、`frontend/web/src/**` 与本文件;`v0.71` 同步了 `internal/services/gateway/**`、`internal/runtime/loop/flow.go`、`frontend/web/src/**` 与本文件。*
|