767 lines
77 KiB
Markdown
767 lines
77 KiB
Markdown
# Cyrene v2 客户端 API 契约
|
||
|
||
> **契约版本:`v0.66`**(上一版 `v0.65`;该基线已归档、只读:`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-在飞切片与未实现未确证)。
|
||
|
||
---
|
||
|
||
## 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`。
|
||
- **客户端按 `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"}` |
|
||
| 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`。 |
|
||
|
||
> **`GET /api/v1/files/{name}` 无鉴权**(能力 URL);其余全部用户端点至少要求已认证。
|
||
|
||
---
|
||
|
||
## 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) | **11** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出 |
|
||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||
|
||
**管理路由总数 = 12 + 11 + 40 = 63 条。**
|
||
|
||
> ⚠️ **与归档版不一致(已更正)**:`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):**该账号自己的**适配器是否已连接;**键缺席 = 状态未知**(该账号没有活适配器),**不等于**「未连接」—— 面板据此决定是否给「断开」按钮。**不含任何凭据** |
|
||
| 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 读(11 条,`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` | 后台思考记录 |
|
||
| B9 | GET | `/api/v1/admin/observability/audit` | 审计链 |
|
||
| B10 | GET | `/api/v1/admin/sessions/{id}/export` | 完整会话导出 |
|
||
| B11 | POST | `/api/v1/admin/rag/query` | 知识库检索(带审计的读型 POST) |
|
||
|
||
> **读取 `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`/`""`**。
|
||
> **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 删除的收尾口径一致)。
|
||
|
||
---
|
||
|
||
## 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,空则键省略)。
|
||
- **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>}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略) |
|
||
| `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 流式收尾直接丢弃思考文本)⇒ 该键在线上**永远不会出现**。
|
||
> **思维链的唯一出口是实时帧**:§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` 不是同一个字段。
|
||
|
||
#### 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`** ⇒ 未按路分桶) |
|
||
| `capacity_awaken` | sessionId | `{fromState,toState,tool}` |
|
||
| `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 |
|
||
| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId}`(已消毒,绝不含凭据/媒体字节) |
|
||
| `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)。
|
||
|
||
#### 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` | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20) |
|
||
| 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`、`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` 之外,本文档未逐一确证还有哪些端点会带它。
|
||
|
||
### 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;空则省略(老消息/未接管路径)
|
||
"createdAt": 1700000000000
|
||
}
|
||
```
|
||
|
||
字段省略规则:`mode`/`mood`/`image`/`thinking`/`platform`/`deviceId`/`senderName`/`adapterId`/`personaId`/`clientMsgId`/`turnId` 均为 `omitempty`(空时键缺席)。
|
||
|
||
**`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**,于是「旧行」与「没写」是同一个值)。
|
||
|
||
**`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` 可安全重试。
|
||
|
||
### 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 = 当回合角色;空 = 默认角色)。
|
||
|
||
---
|
||
|
||
## 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_*` | 见模板 | 后台思考与主动推送策略 |
|
||
| `SUBAGENT_TIMEOUT_SEC` | `600` | 单个子代理整次运行时限 |
|
||
| `MCP_SERVERS_JSON` | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 `MCP_NAME`/`MCP_COMMAND`/… 为**未接线残留键,配了不生效** |
|
||
| `CHANNEL_*` / `CHANNELS_JSON` | 见模板 | 渠道接入(`.env` 为兜底,面板 `channels.json` 优先) |
|
||
|
||
**与契约相关但「配了不生效」的键**:
|
||
|
||
- **`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.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 在飞切片
|
||
|
||
**当前没有已登记的在飞契约项**:本版之前登记的在飞 / 未实现项(`turn_begin`、`steer_ack.turnId`、`interrupt.turnId`、消息 DTO `turnId`、写类工具文件级互斥)**已全部提交并随 v0.66 冻结**,客户端可直接依赖:
|
||
|
||
| 项 | 状态 | 冻结位置 |
|
||
|---|---|---|
|
||
| 新下行帧 **`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 确认为「未实现」(别当已有)
|
||
|
||
| 项 | 现状 |
|
||
|---|---|
|
||
| **`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` **不落库**:父会话转录里没有新消息、**重连回放不补发**。
|
||
- `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` 同理只存在于网关回合。
|
||
|
||
### 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.66`;`v0.65` 及更早见 `Docs/archive/`)。范围:只改 `Docs/**`,未改动任何代码。*
|