102 KiB
Cyrene v2 客户端 API 契约
契约版本:
v0.67(上一版v0.66,其条目见 §6;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。 ⚠️ v0.67 是「契约先行」登记版:本版新增的三批 —— 语音消息(播放端点 + 消息 DTOvoice)、非消息事件查询面、账号在线状态 —— 当前尚未实现,形状取自设计文档(design/24 §10.11、design/26 §4.5/§2.6)而非代码核对;已在 §7.2 逐条如实标注。本文档其余内容仍以代码为真源。
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)
- 用户侧 REST:
- 破坏性变更升 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。
- ⚠️ v0.67 登记的
- 客户端按
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 登记,尚未实现;绝不回全 0 假汇总,见 §2.4(a)) |
| 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。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。 |
| 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)。嗅探裁决 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。 |
| 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。会话不存在/已删除 → 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) |
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):该账号自己的适配器是否已连接;键缺席 = 状态未知(该账号没有活适配器),不等于「未连接」—— 面板据此决定是否给「断开」按钮。v0.67 登记(尚未实现):各元素另可能带可选 accountOnline:bool(账号在平台上是否在线,与 connected 是两件事;键缺席 = 状态未知 ≠ 离线)—— 见 §2.4(b)。不含任何凭据 |
| 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 删除的收尾口径一致)。
2.4 v0.67 登记、尚未实现的管理面(渠道事件查询 + 账号在线状态)
⚠️ 本节全部内容尚未进
routes.go(截至契约 v0.67 冻结):形状取自设计文档(design/26 §4.5、§2.6),不是代码核对所得。§2.0 的计数(12 + 11 + 40 = 63)不含本节端点;实现落地后再回填 §2.1/§2.2 并更新计数。客户端不要现在就按本节编程到「已可用」——以 §7.2 的状态为准。
(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:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}},并带 approx:true 如实标注「计数为聚合近似值」(计数是内存聚合 + 周期 flush,非逐条实时)。⚠️ approx 的嵌套层级设计未明说,见 §7.4 |
- RBAC 档位未定:设计原文写「owner/admin;
viewer只读亦可,按既有 RBAC 惯例」—— 两种解释都成立,本契约不替它二选一(见 §7.4-12)。落地前不要假设 viewer 能读。 - 数据源两张表(
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行的投影。设计只给到落库列:id/platform/account_id/tier/category/platform_type/channel_id/channel_type/group_id/actor_id/actor_role(owner/member/system/unknown,结构化解析所得)/target_id/self_related/message_id/summary/created_at/updated_at。actor_id/target_id是 id 不是昵称;summary是固定文案(如「群成员加入」,不含任何平台原文)。⚠️ 元素的 JSON 键名设计未逐字给出,见 §7.4-9 —— 客户端现在不要硬编码。- 未接线存储时:两个端点都回
500 channel_events_unavailable(对齐usage_unavailable/storage_unavailable的诚实边界口径,绝不回全 0 假汇总)。 - 三个新 env 键(
design/26 §4.4,已登记进 §5):CHANNEL_EVENT_RETENTION(默认720h)、CHANNEL_EVENT_GC_INTERVAL(默认1h)、CHANNEL_EVENT_COUNTER_FLUSH(默认30s)。 - ⚠️ 微信渠道不提供非消息事件:查询面 / 面板对该渠道如实显示空 + 说明文字,绝不伪造、绝不用别的渠道事件顶替。
(b) GET /api/v1/admin/channels 的账号项:accountOnline
accounts[]元素可能带可选accountOnline:bool(v0.67 登记、尚未实现):该账号在平台侧是否在线。- 与
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 主动探活 OneBotget_status(读{online, good}),与上游可配的meta_event心跳并列(心跳优先、探活兜底),只在状态变化时记一条带source的日志。 - ⚠️ 字段名(
accountOnline)是本契约所拟:设计只规定「独立状态面 +AccountStatus()+known」,未给管理面字段名(见 §7.4-13)。实现若改名,须回改本行与 §6 的 v0.67 条目。
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时,服务端定向发送一帧:{"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。 |
interrupt |
{sessionId,scope?,turnId?} |
打断。见 §4.8。 |
voice_input |
{audio,format?,text?,sessionId?,deviceId?,clientMsgId?} |
语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 §4.5。 |
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 |
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不是同一个字段。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 ⇒ 未按路分桶) |
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、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 登记、尚未实现)、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 登记,尚未实现):GET /admin/channels/events对platform未知、since/until跨度 > 90 天都回400(具体code名设计未给出,见 §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
chatbody 与 WSchat帧同形;voice_input亦然)。 - 同一会话内同一个非空
clientMsgId只承认第一条。重复到达:- REST →
200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}} - WS → 下行帧
chat_duplicate(§3.4.3) - 不落库、不起回合、不发任何内容/流式新帧。
- REST →
- 判重由 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[] —— 同一个结构体:
{
"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目录),但不进文档的抽取 / 注入链路:不被抽取、不进documentsDTO 数组、不占注入预算、不出现在附件清单行里。 - 一条消息只落一份音频副本(= 被转写的那一条语音);同一条消息里的第 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直接内联 base64data: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,在上传那一刻定死,与本接口的字符串长度判据无关。
- base64 /
- 上限来源
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 同形):
{"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:base64data:URI、裸 base64 或 http(s) URL。上限按形态不同(判据是字符串长度,不是解码后字节),见 §4.5。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 原口径,逐字不变)。
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回
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 与模型配置(与契约相关)
.envvs 面板文件:面板「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 优先) |
CHANNEL_EVENT_RETENTION |
720h |
v0.67 登记(尚未实现):channel_events 保留期 —— 比文档的 168h 长,因为事件表的核心用途是事后定案(一周太短);≤0 = 关闭 GC |
CHANNEL_EVENT_GC_INTERVAL |
1h |
v0.67 登记(尚未实现):渠道事件 GC 扫描间隔(与 DOCUMENT_GC_INTERVAL 同范式) |
CHANNEL_EVENT_COUNTER_FLUSH |
30s |
v0.67 登记(尚未实现):计数内存聚合的 flush 间隔。计数是尽力而为的近似值(进程崩丢 ≤30s),查询面以 approx:true 如实标注(见 §2.4(a)) |
与契约相关但「配了不生效」的键:
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。
v0.67(当前契约版本)
三批已在设计文档定稿、但尚未实现的新增面一次性登记:语音消息(播放端点 + 消息 DTO voice + 音频副本口径)、非消息事件查询面、账号在线状态(依据 design/24 §10.11、design/26 §4.5 / §2.6)。
⚠️ 本版是「契约先行」登记:三批都还没实现(逐条见 §7.2),形状取自设计文档而非代码核对。客户端先按形状对齐、不要按「已可用」编程;实现落地后本版会补记 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)。 ⑥ 新 admin 端点GET /api/v1/admin/channels/events/counters(逐类计数汇总{data:{counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}},并带approx:true如实标注「计数为聚合近似值」)—— §2.4(a);未接线存储 ⇒500 channel_events_unavailable(新增错误码,绝不回全 0 假汇总)。 ⑦GET /api/v1/admin/channels的accounts[]增可选accountOnline(账号在平台侧是否在线,与connected(适配器/WS 是否连着)是两件事;键缺席 = 状态未知 ≠ 离线)—— §2.4(b)。 ⑧ 新 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、WSmessage_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 入站语音,Webvoice_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(WSchat帧 + 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 新登记的三批(语音消息 / 非消息事件查询面 / 账号在线状态)尚未实现,不在本表 —— 逐条见 §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) |
未实现(契约已先行登记):形状与错误码见 §2.4(a);权威设计 design/26 §4.5。未接线存储 ⇒ 500 channel_events_unavailable;⚠️ 两个端点的 RBAC 档位设计未定(见 §7.4-12) |
🔴 v0.67 登记的「账号在线状态」(GET /admin/channels 的 accounts[].accountOnline) |
未实现(契约已先行登记):形状见 §2.4(b)。状态源已落地(AccountStatus(),plugins/channels/qq_obv11/probe.go,a79fe96),但管理面还没有任何字段读它(全仓 grep 无 accountOnline)⇒ 该键当前一定会缺席 |
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同理只存在于网关回合。 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)。
7.4 「未确证」清单(客户端请以实测/代码为准)
GET /meta/enums逐值(platforms/moods/modes)取自归档转述,本文档未逐一回internal/contract/enums核对。客户端应直接读端点,不要硬编码。conflict(409) 通用错误码除chat_busy外,还有哪些端点会带它 —— 未逐一确证。- 各管理写端点的精确请求体字段与状态码:本文档 §2.3 只给「方法/路径/鉴权/一句话要点」,未逐字段展开(归档
Docs/archive/api-reference.v0.65.md有历史详细版,但可能漂移)。客户端实现管理写操作前请回代码或参考该归档并以代码为准。 observability_metrics的完整AdminMetrics字段集:本文档只说明它是扁平快照,未展开字段。subagent_event在subagent_ask应答路径上的精确字段(orchestration.AuditResult形状):未展开。account_not_registered(409) 的适用面:本文档只确证了 C19/C20(open/close)会带它;POST /admin/channels/test、/{platform}/message/test等其它渠道端点是否也有等价的账号级错误码 —— 未逐一确证。turn_begin.data.leg在长会话中的取值范围:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档未确证客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。GET /admin/channels的accounts[]除connected外的字段集:本文档未逐字段展开(该结构在 v0.66 只是新增一个可选键;v0.67 另登记了一个可选accountOnline,见 §2.4(b))。- (v0.67 登记)
GET /admin/channels/events的events[]元素 JSON 键名:设计只给到channel_events的落库列(snake_case 列名)与{events,total}外框,未逐字给出元素的 JSON 键名(camelCase 还是 snake_case 未定)⇒ 客户端现在不要硬编码;counters[]的元素名设计已逐字给出且为 camelCase(大概率同风格,但未确证)。 - (v0.67 登记)
events.total的语义:是筛选条件下的总条数(不受limit/offset影响)还是本次返回条数 —— 设计未说明。 - (v0.67 登记)渠道事件查询的参数默认值与错误码名:
limit/offset的默认值、以及platform未知 / 跨度超 90 天时的具体400code名(设计只写「→400」)—— 均未给出。 - (v0.67 登记)两个渠道事件端点的 RBAC 档位:设计原文「owner/admin;
viewer只读亦可,按既有 RBAC 惯例」自相含混(按既有分档,渠道消息日志面 B2/B3 是 owner/admin 档)⇒ 落地前不要假设 viewer 能读。 - (v0.67 登记)
accounts[].accountOnline的字段名与是否另有known同级键:字段名accountOnline是本契约所拟(设计只规定「独立状态面 /AccountStatus()/known三态」,未给管理面字段名);哪些平台会带该键、以及实现是否另发一个known布尔 —— 未确证。 - (v0.67 登记)音频各容器在各客户端的可播放性:
amr/flac/opus在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端以实测为准,播不了就照实提示。 - (v0.67 登记)
GET /admin/system/storage的documents段是否拆出语音副本:设计本期不拆(不新增kind列 / 不按 ext 聚合)⇒ 该段只增口径、不变形状,客户端不要指望能分开统计。 - (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为准。
本文件由「以代码为真源」维护(当前 v0.67;v0.66 见本文件 §6,v0.65 及更早见 Docs/archive/)。范围:只改 Docs/**,未改动任何代码。