Files
Cyrene_v2/Docs/api-client.md
T
AskaEth f4adf0b526
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(notify): IM 路由的主动消息也在非前台补浏览器通知(契约 v0.74)
主人裁定 C 的必然推论:她该不该提醒我,与后端走了哪条路由无关。P0 如实报回的缺口 = 通知只挂在 proactive_message 帧上,而 auto 提示在有已连接 IM 渠道时优先走 IM ⇒ 最常发生的路径恰恰不弹。

判据(先查现状、不猜):行字段**区分不出**主动推送与普通回复(已用真库证据排除 platform/adapterId/turnId 反推)⇒ 新增**结构化的正向声明**:帧顶 data.proactive === true,只由主动推送那条通道打(gateway.BroadcastProactivePersisted);普通路径永远传 false。
- 后端:channelbridge 抽出唯一写入口径 persistMain + 新增 proactive_notify 接缝;gateway 抽出 broadcastPersisted(msg, proactive) 两入口共用
- 前端:handlers/proactive-notice.ts 两条帧路共用同一套提醒运行时(有界去重 + 降级说明 + DOM/Notification 取值);message.ts 读到判据才走提醒
- 对照断言(必须有):普通 IM 回复/主人提问/显式 proactive:false ⇒ **0 通知、3 气泡**(不误伤)
- 变异实测:去掉非主动保护 / 跨两路去重失效 / 前台也弹 / IM 不发判据帧 / 普通广播带判据 ⇒ 5 条全部打红
- 契约 v0.74(纯增量:message_persisted 新增可选 proactive:true)
- 边界(如实):判据**不落库**(翻历史不弹提醒,也不得从行字段反推);落库失败时不发判据帧(无权威 id 的帧前端整帧丢弃)
2026-10-05 19:47:54 +08:00

155 KiB
Raw Blame History

Cyrene v2 客户端 API 契约

契约版本:v0.74(上一版 v0.73,其条目见 §6;v0.72 / v0.71 及更早见本文件 §6,v0.67 及更早的基线已归档、只读:Docs/archive/api-client.v0.65.md)。 读者:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等跨平台客户端开发者。 唯一真源 = 当前代码(internal/services/gateway/* 等)。本文档由代码逐条核对得出;与代码不符时以代码为准,并回改本文档。 旧的超详细版已归档、只读:Docs/archive/api-client.v0.65.md(412 KB,含 v0.28→v0.65 全部历史条目)与 Docs/archive/api-reference.v0.65.md(170 KB,后端全量字段级文档)。归档的原因是结构性臃肿 + 历史条目并列 + 长期漂移;本文档不复制那段历史,需要考古时去归档。 维护铁律:每实现/变更一个 REST 端点、WS 事件、admin API 立即改本文档;契约变更必须版本化,不破坏既有客户端。 准确性 > 详尽度:宁少写也别写错;写不了确证的写「未确证」,见 §7。 ⚠️ v0.67 是「契约先行」登记版:本版新增的三批 —— 语音消息(播放端点 + 消息 DTO voice)、非消息事件查询面、账号在线状态 —— 登记时都还没有实现,形状取自设计文档(design/24 §10.11、design/26 §4.5 / §2.6)而非代码核对。已落地:账号在线状态(52dac45)、非消息事件查询面(本切片,E6)—— 见 §2.4;语音消息尚未实现。已在 §7.2 逐条如实标注。本文档其余内容仍以代码为真源。 v0.68 是纯增量版(已实现,非契约先行):新增 1 条管理只读端点 GET /api/v1/admin/config-facts(配置事实面,见 §2.2 B12),并给 GET /api/v1/admin/backthink 的条目加 2 个可选字段(urgency / quietHoursBypassed,见 §2.2 B8)。无破坏性变更,老客户端零影响。 v0.69 是缺口修复版(已实现):PUT /api/v1/admin/channels/{platform}/config 现在接收并原样落盘账号顶层 directMsgPerMin,GET /api/v1/admin/channels 的账号元素也如实回显它(§2.2 A5 / §2.3 C28 注)。修的是「面板保存一次渠道配置,就把主人手配的关闭流控(-1)静默重置回 0 = 保护性默认 6 条/分」。无破坏性变更(老客户端不带该键,行为与 v0.68 相同)。 v0.70 是精确化版(已实现,纯增量):GET /api/v1/admin/backthink 的 urgency 默认就是该轮实际生效的精确档位(随记录落库),另加可选 urgencyLowerBound:true 专门标记旧行的下限推导值;实时 backthink 帧自本版起也带记录里的 urgency(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。无破坏性变更(新键可选,老客户端不读即零影响;urgency 的既有取值域与 quietHoursBypassed 语义都未改)。 v0.71 是链路图补版(已实现,纯增量):新增 1 条管理只读端点 GET /api/v1/admin/flow/graph,把 ws.graph_update 的最近一轮快照从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ /#/admin/flow 与后台思考行的『查看完整链路导图』永远空着」—— graph_update 只走实时帧,前端图桶(flowGraphs)不持久化,此前没有任何补拉通路。无破坏性变更(新端点;graph_update 帧形状、角色门、发布会话范围一字未改)。

v0.72 是主动推送可见性版(已实现,含一处行为变更):proactive_message 那条消息现在落进主会话历史(web / assistant / mode:"chat",先落库再发帧),帧新增可选 id/createdAt(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前只广播帧、一行都不写,刷新即丢且前端零消费点。两条路由互斥 ⇒ 主会话里恰好一条(不双份);proactive_message 帧逐字不变的部分是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。

v0.73 是链路图本地落盘版(已实现,纯增量):链路图从「只活在进程内存」变成每轮一行落进本地库(新表 flow_graphs,迁移 0022),GET /api/v1/admin/flow/graph 在 v0.71 之上增量:新增可选查询参数 turnId(省略 = 最近一轮;先查内存 LastGraph,miss 才回退落盘)、响应新增 turnId / source / turns[] 三键(多轮历史回放,design/19 §4 的 P3)。修的是主人 2026-10-05 追加要求的那句「链路流程应该有本地数据,不然每次重载之后页面就没数据了」—— v0.71 只覆盖「后端进程活着时的最近一轮」,后端一重启、或翻更早的轮次,仍然什么都查不到。无破坏性变更(旧键 sessionId/graph 形状语义不变,graph_update 帧、角色门、发布会话范围一字未改;老客户端不读新键即行为与 v0.71 相同)。落盘范围只有发布会话白名单内的会话(flowSessionAllowed),隔离渠道会话绝不入库。详见 §2.2 B13 / §4.11。

v0.74 是 IM 路由主动消息的提醒版(已实现,纯增量):message_persisted 的 data 新增可选结构化判据 proactive:true —— IM 路由(im_bridge)的主动推送在落库之后追加一条带该键的同类帧,客户端据此在非前台补一发浏览器通知(此前通知只挂在 proactive_message 上,而 auto 提示在主人有已连接 IM 渠道时优先走 IM ⇒ 实际最可能发生的路径恰恰不弹)。判据是结构化字段(不是正文/提示词):普通消息(Web 回合 / IM 回复 / IM 镜像)的帧永不带该键 ⇒ 不可能被误判成主动消息。同一行仍照旧发一条不带该键的普通镜像帧(v0.72 起的行为逐字不变,两帧同 id ⇒ 客户端按 id 去重后仍是一个气泡、仍只提醒一次)。详见 §3.4.4 后的「message_persisted 的 proactive 判据」。


0. 基础约定

0.1 基址与版本

  • Base URL:http://<host>:<PORT>。PORT 默认 8080(env:"PORT" default:"8080");生产一般经反代/HTTPS,运行实例可能自定义端口。
  • 唯一对外端口:单二进制、单端口(运行实例实测 :10942)。REST、WS、管理 API、静态资源全在同一端口;不另有监听端口(唯一登记例外:qq-obv11 渠道 OneBot 反连 server 模式,见 design/08 §4,与客户端契约无关)。
  • 版本前缀:
    • 用户侧 REST:/api/v1/{resource}
    • 管理侧 REST:/api/v1/admin/*
    • 实时:/ws/chat(WebSocket)
  • 破坏性变更升 v2;本文档内的纯增量变更不升。

0.2 认证与授权

凭据 用法 作用面 备注
Bearer JWT Authorization: Bearer <JWT> 用户侧 + 管理侧全部已认证端点 RBAC:owner > admin > viewer(递增)。access token TTL 24h(tokenTTL)。
X-API-Key 请求头 X-API-Key: <key> 用户侧 + 管理侧全部已认证端点 命中签发表即 owner 级(不做角色分档,裁决 15.4.3)。带该头时只走 key 分支:未知/已吊销/空值 → 401,绝不回落 Bearer(fail-closed)。key 表未注入 = 空表(带头一律 401)。
WS 握手 ?token=<JWT> 查询参数或 Authorization: Bearer <JWT> 仅 /ws/chat 只认 JWT(auth.Verify)。不接受 X-API-Key。失败在升级前回 401 {"error":{"code":"unauthorized",...}}。
  • JWT claims 的 role 决定授权分档;sub 是账号标识。
  • X-API-Key 命中后 sub 记作 apikey:<脱敏 id>(审计可定位哪把 key,但绝不含密钥材料)。
  • 身份/权限绝不来自提示词或 LLM 判断(项目红线):一律由 channel 层强类型 actor 元数据 + 白名单 + 策略引擎决定。

0.3 响应信封

  • 成功:{"data": <载荷>}(个别端点直接回二进制,见 §4.5 语音)。
  • 失败:{"error":{"code":"<机器可读码>","message":"<人读文案>"}}。
  • 状态码全集(代码实际使用):200 201 202 400 401 403 404 409 413 415 429 500 501 503。
    • ⚠️ v0.67 登记的 GET /api/v1/media/{name}(尚未实现,见 §7.2)会引入 206 / 304,以及一条非信封的 416(Range 错误由 Go 标准库直写 text/plain + Content-Range: bytes */<size>,不套 {"error":…})。这三者不在上面那份「代码实际使用」集合内,实现后才加入;客户端不要拿 error.code 去解析 416。
  • 客户端按 error.code 分支,不要按 message 文本(文案会随修改变动)。

0.4 三档标注惯例

本文档与「变更记录」用三档描述每次变更对客户端的影响:

  • 纯增量:只新增端点 / 帧 / 可选字段 / 取值。老客户端不读新键时行为逐字不变。
  • 行为变更:既有端点/帧的语义或响应面变了,客户端需改处理(如新状态码、新时点)。
  • 破坏性:既有字段/形状被移除或改义,老客户端会坏。

0.5 未接依赖时的诚实边界(fail-visible)

后端某些端点依赖注入的存储/服务;未接线时明确报错或退化为诚实空结果,绝不返回假数据:

依赖 未接线时的行为
HistoryStore GET /sessions 回退到渠道会话视图;POST /sessions、GET /sessions/{id}/messages、PATCH/DELETE /sessions/{id}、POST /sessions/{id}/read、PATCH/DELETE /messages/{id} → 501 not_implemented
语音服务 POST /audio/* → 500 voice_unavailable;WS voice_input → error 帧 {"message":"voice unavailable"}
渠道事件存储(internal/runtime/events) GET /admin/channels/events 与 .../events/counters → 500 channel_events_unavailable(v0.67 已实现,见 §2.4(a);绝不回全 0 假汇总)
LLM usage 出口 GET /admin/llm/usage → 500 usage_unavailable(绝不回全 0 假汇总)
活跃 SQLite 连接 GET /admin/system/storage → 500 storage_unavailable
可观测日志缓冲 GET /admin/observability/logs → 501(组合根注入前)

1. 用户侧 REST 端点

1.1 公开端点(无需认证)

# 方法 路径 状态码 要点
P1 GET /api/v1/health 200 存活探针 → {data:{status:"ok"}}
P2 POST /api/v1/auth/login 200 / 400 / 401 / 500 body {username,password} → {data:{token,expiresAt,role,refreshToken?}}。access 24h;role 恒 owner;refreshToken 仅在接了 refresh 存储时出现(AUTH_REFRESH_TTL 默认 720h)。凭据错 → 401 unauthorized。
P3 POST /api/v1/auth/refresh 200 / 400 / 401 / 500 body {refreshToken} → 新 access + 新 refresh(默认单次使用轮换)。未知/已吊销/过期 → 401 invalid_grant。AUTH_REFRESH_ROTATE_DISABLED=true 时旧 token 保持有效、不返回新 refresh。未接存储 → 500 refresh_unavailable。
P4 POST /api/v1/auth/logout 200 / 400 / 401 / 500 带 {refreshToken} → 撤单份 {data:{revoked:bool}}(幂等,未知也 200);带 Bearer 且无 body → 撤该账号全部 {data:{revoked,revokedCount}}(两种响应形状不同)。两者都无 → 400。

登录/刷新/登出公开是刻意的:非透明 refresh token 本身就是凭据,故过期 access 的客户端仍能轮换/吊销它。它们对非法 token fail-closed。

1.2 用户端点

# 方法 路径 鉴权 状态码 要点
U1 POST /api/v1/chat owner/admin 200 / 202 / 400 / 409 / 500 / 503 主对话入口,见 §4.6。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) 13 viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)
owner-only 档 ownerAuth(owner) 40 带审计的策略写、每个获取凭据的渠道端点、其余全部变更

管理路由总数 = 12 + 13 + 40 = 65 条。

计数口径(v0.71 明确):总数是 §2.1 / §2.2 / §2.3 三张清单里列出的行数(与 v0.68 起的口径一致)。⚠️ internal/services/gateway/routes.go 里实际经 writeAuth 挂载的 admin 路由比 §2.2 多:v0.67 登记的 GET /admin/channels/events 与 .../counters(见 §2.4(a))未计入本表——这是历史口径,本版未改,也未把它算进上面的 13。逐条以代码为准。

⚠️ 与归档版不一致(已更正):Docs/archive/api-client.v0.65.md §2.0 写「12 + 11 + 38 = 61」(其 v0.62 条目又写「12 + 10 + 38 = 60」,自相矛盾)。以代码为准 = 12 + 11 + 40 = 63。

2.1 viewer 可读(12 条,viewerAuth)

# 方法 路径 要点
A1 GET /api/v1/admin/personas 人格清单(含 admin 专属 thinking 块)
A2 GET /api/v1/admin/sessions 会话清单
A3 GET /api/v1/admin/plugins 能力/插件清单
A4 GET /api/v1/admin/plugins/installed 已安装插件清单
A5 GET /api/v1/admin/channels 渠道清单。accounts[] 各元素可能带可选 connected:bool(v0.66):该账号自己的适配器是否已连接;键缺席 = 状态未知(该账号没有活适配器),不等于「未连接」—— 面板据此决定是否给「断开」按钮。v0.67 已实现(本次落地;落地前基线 f91a83f):各元素另可能带可选 accountOnline:bool(账号在平台上是否在线,与 connected 是两件事;键缺席 = 状态未知 ≠ 离线)—— 见 §2.4(b);各元素可能带可选 directMsgPerMin:number(v0.69 新增,已实现):该账号非主人私聊(陌生人)每分钟每发送者上限,是账号顶层字段(与 groupMsgPerMin 同一条写路径,不在 config 里)。语义:键缺席或 0 ⇒ 保护性默认 6 条/分(陌生人必须有流控,缺省绝不解释成「关闭」)、正值 ⇒ 该值、负值 ⇒ 关闭该限制(主人的显式选择)。⚠️ 因 omitempty,0 与未设置线上同形(既有约定,未改)不含任何凭据
A6 GET /api/v1/admin/iot IoT 设备清单
A7 GET /api/v1/admin/automations 自动化与提醒清单
A8 GET /api/v1/admin/rag 知识库文档清单(名字/描述 + 切分/嵌入状态);?query= 搜索亦只读
A9 GET /api/v1/admin/observability/metrics 运行时指标快照
A10 GET /api/v1/admin/observability/trace 运行时 span
A11 GET /api/v1/admin/observability/logs 后端日志尾部(最新优先,?level=&limit=;缓冲未注入时 501)
A12 GET /api/v1/admin/notifications 统一通知 feed(审计链 + 后台思考推送的只读投影)

2.2 owner/admin 读(13 条,writeAuth)

# 方法 路径 要点
B1 GET /api/v1/admin/memories 记忆条目(含他人账号数据)
B2 GET /api/v1/admin/channels/messages 适配器消息日志,?adapter=&platform= 三档粒度(before/limit)
B3 GET /api/v1/admin/channels/{platform}/messages 单平台跨隔离会话消息(最新优先)
B4 GET /api/v1/admin/llm LLM provider 栈(组 DTO,含 contextWindow 等)
B5 GET /api/v1/admin/llm/usage LLM 用量汇总(切片 U1,v0.64)。?since=&until=&purpose=;缺省近 24h、跨度上限 90 天(超 → 400);hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0。不含凭据与消息正文。未接线 → 500 usage_unavailable
B6 GET /api/v1/admin/llm/catalog 模型目录候选(?refresh=1 强制在线刷新)。既无快照又抓不到 → 503 catalog_unavailable
B7 GET /api/v1/admin/users 账号/API key 清单
B8 GET /api/v1/admin/backthink 后台思考记录。每条可能带可选 urgency("routine"/"warning"/"critical",internal/urgency 词汇;默认是精确档位,带 urgencyLowerBound:true 时才是下限,见 §4.10(b))与可选 quietHoursBypassed:true(该轮穿透了安静时段)—— v0.68 新增;档位的精确语义与 urgencyLowerBound 为 v0.70
B9 GET /api/v1/admin/observability/audit 审计链
B10 GET /api/v1/admin/sessions/{id}/export 完整会话导出
B11 POST /api/v1/admin/rag/query 知识库检索(带审计的读型 POST)
B12 GET /api/v1/admin/config-facts 配置事实面(v0.68 新增,只读):记忆整理 7 键 + 安静时段 QUIET_START_HOUR/QUIET_END_HOUR 的进程生效值,逐条带 default/isDefault。不含任何密钥/令牌值(连「是否已配置」都不回凭据类的)。形状见 §4.10
B13 GET /api/v1/admin/flow/graph 链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量),只读、不审计。?sessionId=(省略 = 主会话 web)+ v0.73 新增 ?turnId=(省略 = 最近一轮,先内存后落盘)。响应 {data:{sessionId,turnId,source,graph,turns[]}}:graph 是一份 §3.4.5 graph_update 同形快照,没有该轮时如实为 null(绝不画假图);source ∈ live(内存最近一轮)/persisted(落盘的历史轮)/none;turns[]({turnId,createdAt},最新在前)供面板选历史轮(v0.73)。未接线图源 → 500 flow_unavailable。语义见 §4.11

读取 GET /admin/observability/{logs,metrics,trace} 兼任可观测推送的订阅握手(该凭据的连接全部断开即退订)。REST 响应形状不变。

2.3 owner-only(40 条,ownerAuth)

# 方法 路径 要点
C1 POST /api/v1/admin/plugins/policy 设置能力可达性标记(工具策略,落盘 tool_policies)
C2 POST /api/v1/admin/personas 创建人格
C3 PUT /api/v1/admin/personas/{id} 更新人格(identity.systemPrompt 必填,缺失/空白 → 400)
C4 GET /api/v1/admin/owner-profile 主人档案读取
C5 PUT /api/v1/admin/owner-profile 主人档案写(落盘 configs/users/owner.yaml + 热更新活档案;审计 owner.profile.update)
C6 POST /api/v1/admin/users 创建账号
C7 POST /api/v1/admin/users/apikey 签发 API key(明文仅返回一次)
C8 DELETE /api/v1/admin/users/apikey/{id} 吊销 API key
C9 POST /api/v1/admin/iot/{id}/control 控制设备
C10 POST /api/v1/admin/iot/scenarios IoT 场景写
C11 POST /api/v1/admin/iot/rules IoT 规则写
C12 POST /api/v1/admin/automations 创建自动化
C13 POST /api/v1/admin/automations/reminders 创建定时提醒(到点发 reminder.trigger)
C14 POST /api/v1/admin/automations/{id}/run 手动运行自动化
C15 DELETE /api/v1/admin/automations/{id} 删除自动化
C16 POST /api/v1/admin/llm 添加/更新 LLM provider(写 models.json + 热重载)
C17 POST /api/v1/admin/llm/test 连通性测试
C18 POST /api/v1/admin/llm/reset-from-env 重置为 env 默认(切片 R1,v0.62;body 必须 {"confirm":true},否则 400 且一个字节都不动;审计 llm.resetFromEnv)
C19 POST /api/v1/admin/channels/open 打开渠道(v0.66 增 accountId,见下注)
C20 POST /api/v1/admin/channels/close 关闭渠道(v0.66 增 accountId,见下注)
C21 POST /api/v1/admin/channels/test 渠道连通测试
C22 GET /api/v1/admin/channels/{platform}/login-qr 取登录二维码(获取凭据,故 owner-only)
C23 GET /api/v1/admin/channels/{platform}/login-status 查扫码登录状态
C24 GET /api/v1/admin/channels/{platform}/view 平台专属只读视图
C25 GET /api/v1/admin/channels/{platform}/friends 好友/联系人清单
C26 POST /api/v1/admin/channels/{platform}/friend/handle 好友申请处理
C27 POST /api/v1/admin/channels/{platform}/message/test 发测试消息
C28 PUT /api/v1/admin/channels/{platform}/config 保存渠道账号配置(按 id patch 语义,见 C29 注)
C29 DELETE /api/v1/admin/channels/{platform}/config 删除渠道账号。必须显式寻址:?id=<账号 id> 或 ?empty=1(v0.66),见下注
C30 POST /api/v1/admin/plugins/install 安装插件
C31 POST /api/v1/admin/plugins/uninstall 卸载插件
C32 POST /api/v1/admin/memories 写记忆
C33 POST /api/v1/admin/memories/search 搜记忆
C34 POST /api/v1/admin/rag 索引文档(切分 + 嵌入)
C35 POST /api/v1/admin/rag/delete 删文档(项目条目 + 级联章节)
C36 POST /api/v1/admin/system/revoke-keys 全部吊销 API key
C37 POST /api/v1/admin/system/rotate-jwt 轮换 JWT 密钥
C38 POST /api/v1/admin/system/backup 备份
C39 POST /api/v1/admin/system/restore 恢复
C40 GET /api/v1/admin/system/storage 存储状态只读(v0.60)。{data:{main,documents,vector}};不含凭据;未接活连接 → 500 storage_unavailable

C28 的 patch 语义(v0.54 起):config 内键省略 = 保留已存值;给出非空值 = 覆盖;显式 null/"" = 清除该键(0/false 是真实取值,不算空)。顶层便捷字段(token/baseURL 等)只在非空时并入补丁,要清 bot_token 必须走 config 的显式 null/""。 C28 的账号级限流字段(v0.69 修正):groupMsgPerMin / msgPerMin / maxMessages / directMsgPerMin 都是账号顶层字段(不在 config 里,C28 的 patch 三态不适用于它们)。它们是值语义:请求体省略 ⇒ 0 = 未设置(对 directMsgPerMin 即保护性默认 6 条/分)。要保住已设的值必须把它回送;面板先由 GET /admin/channels 回显、再原样写回(frontend/web/src/admin/modules/channels/channelUtils.ts 的 limitInitialOf / limitRequestBody)。⚠️ v0.68 及更早的后端完全忽略 directMsgPerMin:老客户端不受影响,但要表达「关闭流控(-1)」必须对 v0.69+ 后端发。 C18 破坏性:只覆盖 .env 声明过非空值的键(provider/baseUrl/apiKey/model/contextWindow),fallback/multimodal/maxTokens/reasoning/personaModels 永不被改;组集合不增减。 C19/C20 的 accountId(v0.66,多账号平台必需):请求体 = {name, accountId?}(name 必填:平台名或精确适配器名)。

  • 给出 accountId ⇒ 只驱动该账号的适配器(账号 id 非敏感;"default" 映射到该平台的裸适配器名,即无 id 账号的注册口径)。账号完全不存在 ⇒ 404 account_not_found(文案附该平台已知账号 id 列表);账号已配置但当前没有活适配器 ⇒ 409 account_not_registered(常见原因:缺该账号必需凭据、或保存后尚未重载)。两种都零连接,绝不静默回落到第一条(「点了连接却连上别的账号」正是此改动要修的缺口)。
  • 缺省 accountId ⇒ 平台级旧语义(老客户端零影响):精确同名适配器 → 该平台 channels.json 有效配置顺序第一条 → 裸平台名兜底;仍找不到 ⇒ 404 not_found。⚠️ 第 2 步在 v0.66 起是确定性的(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)。
  • 响应(open/close 同形):{data:{name, resolved, connected, accountId?}} —— name 是请求里的名字(旧契约不变),resolved 是实际被驱动的适配器注册名(多账号平台上与平台名不同),accountId 仅在请求指定时回显,connected 为操作后的状态。凭据永不出现。审计动作名不变(channel.open / channel.close)。 C29 的两种寻址(v0.66,都必须显式给出):
  • ?id=<账号 id> ⇒ 删该 id 那一条。⚠️ id 不存在时仍回 200(removed:1),与「刚删过」不可区分 —— 客户端按幂等处理。
  • ?empty=1 ⇒ 删该平台上全部无 id 的历史条目(这类条目改不动:带空 id 保存会被当成新账号另开一条)。无可删 ⇒ 404 not_found,不写盘、不重载、不假装删过。
  • id 与 empty=1 同时给出 ⇒ 400 bad_request;两者都缺 ⇒ 400 bad_request(绝不把「id 缺失」默认解释成删无 id 条目 —— 那是一次没有字面证据的批量删除)。?empty= / ?empty=0 / ?empty=false 一律是「否」。
  • 响应:{data:{persisted, path, platform, accountId, empty, removed}}(empty/removed 是 v0.66 新增);removed = 实际删除的条目数。删空后平台键整个消失(与按 id 删除的收尾口径一致)。

2.4 v0.67 登记的管理面(渠道事件查询 + 账号在线状态)—— (a) 已实现 / (b) 已实现

⚠️ 本节两项都已落地(账号在线状态 52dac45;渠道事件查询面本切片 E6):形状取自设计文档(design/26 §4.5、§2.6),落地时已按代码核对。§2.0 的计数(12 + 11 + 40 = 63)原本不含本节端点;两个渠道事件端点已登记进 routes.go(owner|admin 档),下版改文档时应把它们并入 §2.1/§2.2 并更新计数。本行留在此处仅为与 v0.67 冻结版对照。

(a) 渠道事件(非消息事件)只读查询 —— 已实现

# 方法 路径 要点
D1 GET /api/v1/admin/channels/events 非消息事件列表(倒序)。query:platform / tier / category / channelId / since / until / limit / offset。响应 {data:{events:[…], total}}。since / until 按 UTC ISO-8601 定宽字符串做字典序比较(与 GET /admin/llm/usage 同口径);跨度上限 90 天(超 → 400);platform 未知 → 400。不含正文、不含任何媒体 URL、不含任何凭据 / flag
D2 GET /api/v1/admin/channels/events/counters 逐类计数汇总(近似值)。query:platform。响应 {data:{approx:true, counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}},approx:true 如实标注「计数为聚合近似值」(计数是内存聚合 + 周期 flush,非逐条实时)。落地口径:approx 与 counters 同级(都在 data 内,见 §7.4-11 的落地记录)
  • RBAC 档位已定(落地口径):两个端点走 owner|admin(writeAuth),与同族的渠道消息日志面(§2.1 B2/B3、/admin/channels/messages)同档 —— 事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是给 viewer 的监控投影。viewer 为 403(已进 rbac_viewer_test.go 的 ownerAdminReadRoutes 覆盖)。
  • 参数缺省与错误码(落地口径,见 §7.4-11):limit ≤0 或未给 ⇒ 用存储层缺省 100;超硬上限 ⇒ 钳到 500(不报错,total 仍如实给出筛选条件下的总条数);非法整数(如 limit=abc)⇒ 400 bad_request;since/until 非法 ⇒ 400 bad_request;platform 未知 ⇒ 400 bad_request;tier 非 t1/t2/t3 ⇒ 400 bad_request。⚠️ category 不做白名单校验:类别轴的权威在适配器层且会持续增加,未知类别是一次精确匹配(返回空),不是 400。
  • 时间窗缺省(落地口径):没给 since/until 就不注入时间条件(不是「近 24h」)—— 端点绝不替客户端造一个下界(那正是「静默裁剪」)。只给 since 时按「现在」判跨度(超 90 天 ⇒ 400),但不把「现在」写进过滤条件。
  • 数据源两张表(design/26 §4.1,表名复数):channel_events(tier 为 t2/t3 的事件与所有 unknown 逐条留存,参与保留期 GC)与 channel_event_counters(t1 与所有帧的计数,按 (platform, account_id, platform_type) upsert,不参与 GC)。
  • tier 取值域:t1(只计数)/ t2 / t3(逐条留存;T3 是「谁对谁做了什么」的定向交互记录)。category 取值域(归一化类别,设计原文给出 heartbeat / lifecycle / join / leave / recall / ban / admin / friend_request / group_request / poke / reaction / button / vote / item / link / unknown,并在末位带 … ⇒ 非穷举,客户端未知取值原样展示、不报错)。
  • events[] 元素 = 一条 channel_events 行的投影。落地口径(见 §7.4-9):键名一律 camelCase,逐字如下 —— id / platform / accountId / tier / category / platformType / channelId / channelType / groupId / actorId / actorRole(owner/member/system/unknown,结构化解析所得)/ targetId / selfRelated(bool)/ messageId / summary / createdAt / updatedAt(后两者是 UTC RFC3339,与 llm/usage 的时间回显同口径)。actorId/targetId 是 id 不是昵称;summary 是固定文案(如「群成员加入」,不含任何平台原文)。无数据时 events 是 [](不是 null)。
  • 未接线存储时:两个端点都回 500 channel_events_unavailable(对齐 usage_unavailable / storage_unavailable 的诚实边界口径,绝不回全 0 假汇总)。读库失败同样是 500 channel_events_unavailable(错误文案不同)。
  • 三个新 env 键(design/26 §4.4,已登记进 §5):CHANNEL_EVENT_RETENTION(默认 720h)、CHANNEL_EVENT_GC_INTERVAL(默认 1h)、CHANNEL_EVENT_COUNTER_FLUSH(默认 30s)。
  • ⚠️ 微信渠道不提供非消息事件:查询面 / 面板对该渠道如实显示空 + 说明文字,绝不伪造、绝不用别的渠道事件顶替(落地时已有测试钉住:platform=weixin-ilink 的响应里绝不出现别的平台的事件)。

(b) GET /api/v1/admin/channels 的账号项:accountOnline

  • accounts[] 元素可能带可选 accountOnline:bool(v0.67 登记,已实现 —— 管理面已落地,见 §7.2):该账号在平台侧是否在线。
  • 与 connected 是两件事(这是本案的全部理由):connected = 本项目的适配器是否连着(WS 会话连通);accountOnline = 账号本身在平台上是否在线。OneBot 实现(NapCat 等)的进程与正向 WS 可以一直连着,而背后的 QQ 账号已经掉线 —— 此时 connected:true 而 accountOnline:false,消息既收不到也发不出。
  • 三态(known 口径):accountOnline:true = 确知在线;accountOnline:false = 确知离线;键缺席 = 状态未知(尚未探到:探活未接线 / 刚启动 / 一直失败 / 该平台没有独立账号状态面)。绝不可把「未知」画成「离线」,也不可画成「在线」—— 那正是 2026-10-05 事故里「面板仍显示已连接、日志一片空白」的同一类坑。
  • 状态源:AccountStatus()(plugins/channels/qq_obv11/probe.go),其 known 区分「确知离线」与「尚未探到」;本字段的缺席即承载 known=false。当前只有 qq-obv11 有这条独立状态面:周期约 60s 主动探活 OneBot get_status(读 {online, good}),与上游可配的 meta_event 心跳并列(心跳优先、探活兜底),只在状态变化时记一条带 source 的日志。
  • 已实现口径(本次落地):管理列表按可选接口断言读它 —— 适配器实现了 AccountStatus() 才读,否则键缺席;known=false 时同样键缺席(绝不用 false 冒充离线)。不另发 known 同级键:缺席本身即承载 known=false。当前只有 qq-obv11 会带该键(telegram / webhook 等没有独立状态面)。
  • ⚠️ 字段名(accountOnline)是本契约所拟:设计只规定「独立状态面 + AccountStatus() + known」,未给管理面字段名(见 §7.4-13)。实现沿用了本契约所拟的名字(未改名)。

3. WebSocket /ws/chat

3.1 握手

  • GET /ws/chat,凭据取自 ?token=<JWT> 或 Authorization: Bearer <JWT>(只认 JWT)。
  • 校验失败:升级前回 401 {"error":{"code":"unauthorized",...}}。
  • 方法非 GET → 400 bad_request。
  • 帧大小:读上限 4096 字节/帧(wsReadLimit)。超限由传输层直接断连 —— 因此真实音频/图片二进制必须走 REST,WS 只发小载荷。
  • keepalive:服务端周期 Ping(54s),客户端应回 Pong;读超时 60s。
  • 非 JSON 帧 → 定向 {"type":"error","data":{"message":"invalid json"}}(连接保留)。

3.2 握手 history 回放(角色门:只发 owner+admin)

  • 连接注册后,仅当该连接是 owner/admin 时,服务端定向发送一帧:

    {"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>,proactive?:true} Web 主会话及 web:* 子会话的消息落库实时同步。thinking 恒缺席;可能带可选 turnId(v0.66,空则键省略);可能带可选结构化判据 proactive:true(v0.74,仅 IM 路由的主动推送 —— 见下方说明,普通消息永不带它)
channel_persisted sessionId {message:<historyMessage>} 隔离渠道会话(sess-*)的消息落库,供 IM 日志页。thinking 恒缺席;可能带可选 turnId(v0.66)
message_edited sessionId <historyMessage>(data 直接就是 DTO) 编辑单条消息。thinking 恒缺席;可能带可选 turnId(v0.66)
message_deleted sessionId {id} 删除单条消息

thinking 可达性口径(v0.66 口径更正):消息 DTO 的 thinking 在全部读取面(GET /sessions/{id}/messages、GET /admin/channels[/{platform}]/messages、握手 history 回放、message_persisted / channel_persisted / message_edited)恒为缺席。原因不是「有意清空」,而是 history.Message.Thinking 全仓没有任何写入者(loop.Result 没有该字段、Anthropic 流式收尾直接丢弃思考文本)⇒ 该键在线上永远不会出现。

message_persisted 的 proactive 判据(v0.74,纯增量):帧 data 里可选的布尔键 proactive:true 是「这条是她主动说的话」在线上唯一的结构化判据,由后端的主动推送通道打上(gateway.BroadcastProactivePersisted)。客户端读到它就应走与 proactive_message 同一套提醒逻辑(页面不在前台时补一发浏览器通知、同一条只打扰一次),因此「她该不该提醒我」与后端走了哪条路由无关。

  • 为什么需要它:auto 提示在主人有已连接 IM 渠道时优先走 IM,而 IM 路由过去只发普通 message_persisted —— 浏览器通知挂在 proactive_message 上 ⇒ 实际最可能发生的路径恰恰不弹。修的是这条边界(主人 2026-10-05 裁定「她说话该不该提醒我,与走哪条路由无关」)。
  • 判据为什么不会误伤普通消息:只有主动推送那条通道会调用带判据的广播入口;普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口,那条路径永不往帧顶放该键。客户端只应做 data.proactive === true 的严格相等判断 —— 缺失、false、非布尔值都按「不是主动消息」处理。
  • 去重键 = message.id:主动推送镜像回主会话时,同一条消息会先发一条不带判据的普通帧(v0.72 起的行为,逐字不变,任何客户端都能渲染),随后追加一条带判据的帧。两帧同 message.id ⇒ 合并成一个气泡(消息 store 按 id 去重),提醒也只发一次(提醒去重表按同一个 id 记)。proactive_message 与带判据的 message_persisted 同时到达时同样只提醒一次。
  • 落库失败时不发判据帧:没有落库行就没有权威 id,而 message_persisted 的载荷要求非空 id(缺了客户端会整帧丢弃)⇒ 那种情况下只记 WARN、不弹通知(绝不发一条会被丢掉的帧假装提醒过)。
  • ⚠️ 判据不落库:它只活在实时帧上(写入那一刻才知道这条是不是主动推送),库里的行没有对应列(不改 schema)。因此刷新后从 GET /sessions/web/messages / 握手 history 回放读到的那一行不带该信息 —— 这是预期的:通知只对「实时到达」有意义,翻历史不该弹提醒。 思维链的唯一出口是实时帧:§3.4.2 的 thinking.start / thinking.delta / thinking.end(owner/admin 档)。客户端不要为「从历史里读回思维链」写任何代码。 ⚠️ 本行更正本文档 v0.65 及更早的写法(旧版称「历史思维链只有 GET /sessions/{id}/messages(owner/admin)返回」)—— 那是文档写错了,与实现不符。见 §7.3。 另注:管理面板人格清单里的 thinking 是人格旋钮(GET /admin/personas 的 thinking 块),与消息 DTO 的 thinking 不是同一个字段。 voice 覆盖同一批读取面(v0.67 登记,尚未实现):消息 DTO 新增的可选 voice 对象一次覆盖 message_persisted / channel_persisted / message_edited 与握手 history 回放(形状与语义见 §4.4)。WS 协议本身零变化:无新帧、无新上行字段;voice 只是既有 DTO 的新可选键。

3.4.5 子代理 / 任务 / 系统帧(owner+admin)

type 帧壳 data(要点)
subagent_event — {id,name,status,role,mode}(进度:dispatched/started/done/failed/aborted);subagent_ask 的应答另有 AuditResult 形状
subagent_result sessionId = 父会话 批次结果 {sessionId,status,summary,agents:[{id,goal,role?,mode?,status,summary?,text?,steps?}]}。不落库(父会话转录无新消息、重连回放不补发)
task_output — {taskId,output,status}(task_output_query 应答);亦桥接自 task.output 主题
task_done — 桥接自 task.done
device_update — 桥接自 iot.device_update
automation_result — 桥接自 automation.result
reminder — 桥接自 reminder.trigger
graph_update — 实时 flow 图快照(data.sessionId 标会话;无 turnId ⇒ 未按路分桶)。⚠️ 纯实时、不重放:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 GET /api/v1/admin/flow/graph(§2.2 B13 / §4.11)拉最近一轮快照
capacity_awaken sessionId {fromState,toState,tool}
mood (无 sessionId) {mood,personaId,affection};角色级情绪,仅变化时发
proactive_message — {platform,channelId,channelType,content,contentType,sender,timestamp,messageId,id?,createdAt?}(已消毒,绝不含凭据/媒体字节)。v0.72:id/createdAt 是这条消息在主会话历史里的权威行 id 与落库时间戳(同现/同省),落库失败时键缺席 —— 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在)
approval_notice sessionId {actor,tool,op,detail,explain,allowed,at}(主代理代审小字;explain 由后端产出、客户端原样渲染)
mode_switch — 上行 mode_switch 的回放(透传 data)
observability_log — {entries:[{time,level,source,msg}...]},上限 200,最新在前
observability_trace — {spans:[...]},上限 100,最新在前
observability_metrics — 扁平 AdminMetrics 快照

observability_ 的订阅*:owner/admin 凭据读取 GET /admin/observability/{logs,metrics,trace} 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。

proactive_message 的落库口径(v0.72,行为变更):后台思考决定「此刻主动开口」时,这条消息现在是主会话(web)历史里的一条真实 assistant 消息 —— 走活跃客户端路由(在线 owner/admin 连接)的那一路此前只广播帧、一行都不写 ⇒ 刷新/重载后什么都补不回来(既不显示气泡,也补不出历史)。本版 SendProactive 先落库、再发帧:

  • 落库行:sessionId="web"、role="assistant"、content = 帧里的 content(后端已净化的同一份文本,系统元数据绝不进正文)、platform = 帧里的 platform、mode="chat"、turn_id 留空(主动推送不属于任何一路回合)。因此 GET /sessions/web/messages、握手 history 回放、message_persisted 都能读到它。
  • id/createdAt 随帧下发,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。落库失败时这两个键缺席,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 route/detail 会写明未落库(不会再说成「已推送」)。
  • 无在线 owner/admin 连接时也会落库:推送器如实报告「没有活跃客户端」(pushed=false),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。
  • IM 路由(v0.74 起也提醒):im_bridge 路由本就把消息镜像回主会话并广播 message_persisted(逐条一条,不双份),v0.74 起在落库之后追加一条帧顶带 proactive:true 的同类帧 ⇒ 客户端走与 proactive_message 同一套提醒逻辑(见 §3.4.4 后的「message_persisted 的 proactive 判据」)。两条路由在一次推送里互斥(web 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里恰好一条。

3.4.6 定向帧(不经 hub.broadcast,只到指定连接)

type 说明
history 握手回放(§3.2,仅 owner/admin)
voice_output 语音回合结算后定向回发起 voice_input 的那条连接。帧壳无 sessionId;data {audio(base64),format,text,reply,sessionId,deviceId?,provider?}。定向目标是连接本身,服务端不按 deviceId 路由;连接已断不补投
error 上行拒绝 / 处理函数级失败(形状见 §3.3 与 §4.1)

4. 契约细节

4.1 错误响应与错误码

  • 形状:{"error":{"code":"<string>","message":"<string>"}}。
  • 中间件级(Bearer/角色门)固定:401 unauthorized / 403 forbidden。
  • 常用业务码(按状态分组):
状态 code(示例) 场景
400 bad_request、image_too_large、too_many_documents 请求体/字段非法、图片超限(image 字符串长度,仅 base64 / data: 形态会触发,见 §4.5)、附件超条数
401 unauthorized、invalid_grant 无/坏凭据;refresh token 非法
403 forbidden 角色不足;主会话不可删
404 not_found、session_not_found、document_not_found、account_not_found、media_not_found(v0.67 登记、尚未实现) 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);语音副本缺失(含名字非法/穿越,三者同一响应,见 §4.5)
409 chat_busy、account_not_registered 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66)
413 payload_too_large、document_too_large 上传/文档超限
415 unsupported_media_type 图片/文档类型拒绝
429 rate_limited 限流(框架级)
500 internal、chat_unavailable、chat_failed、voice_unavailable、stt_failed、tts_failed、tts_empty、voice_clone_failed、usage_unavailable、storage_unavailable、channel_events_unavailable(v0.67 已实现,见 §2.4(a))、refresh_unavailable、sessions_failed、messages_failed、session_failed、message_failed、document_upload_failed、login_failed 内部错误或依赖未接线(绝不伪装成功)
501 not_implemented HistoryStore 等未注入
503 timeout、chat_dispatch_failed、catalog_unavailable 派发/上游不可用

未确证:conflict(409) 的通用码存在(框架级 ErrConflict),但除了 chat_busy 之外,本文档未逐一确证还有哪些端点会带它。 ⚠️ 416 不是错误信封(v0.67 登记,尚未实现):GET /api/v1/media/{name} 的 Range 语法非法或范围不可满足时,由 Go 标准库直写 416(text/plain + Content-Range: bytes */<size>),不套 {"error":{…}} ⇒ 客户端不要拿 error.code 去解析它(见 §4.5)。这是字节流端点的既有事实(/audio/speech 的 200、/documents/{name} 的 200 同样不是 JSON 信封),不是本版新引入的怪癖。 400 的渠道事件用法(v0.67 已实现,见 §2.4(a)):GET /admin/channels/events 对 platform 未知、tier 非法、since/until 跨度 > 90 天、since/until 时间点非法、limit/offset 非整数都回 400(落地用的 code 是通用的 bad_request,见 §7.4-11)。

4.2 分页与游标

GET /api/v1/sessions/{id}/messages:

  • limit:默认 50,生效范围 [1,200](大于 200 钳到 200,不报错);非数字或 <1 → 400。
  • before(毫秒 createdAt):返回严格早于 before 的消息,倒序(默认方向,向后翻历史)。
  • after(毫秒 createdAt):返回严格新于 after 的消息,正序(重连后的前向增量追赶)。
  • before 与 after 互斥(同传且都 >0 → 400);after 非数字/负 → 400。
  • q:会话内内容搜索(大小写不敏感 ASCII LIKE,%/_/\ 已转义),倒序、仍遵守 before/limit;q 与 after 互斥;空 q = 未传。
  • platform:可选来源平台过滤(Web 端不传 = 全量;IM 适配器传本平台)。
  • 形状校验(limit/before/after 非法、互斥冲突)先于 404 判定:形状错就是 400,与目标会话存在性无关。

4.3 幂等(clientMsgId)

  • 上行可选字段(REST chat body 与 WS chat 帧同形;voice_input 亦然)。
  • 同一会话内同一个非空 clientMsgId 只承认第一条。重复到达:
    • REST → 200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}
    • WS → 下行帧 chat_duplicate(§3.4.3)
    • 不落库、不起回合、不发任何内容/流式新帧。
  • 判重由 SQLite 部分唯一索引 (session_id, client_msg_id) WHERE client_msg_id != '' 原子裁决(并发重发只有一个赢家)。
  • clientMsgId 回填进消息 DTO(空则省略),读接口与 message_persisted 给出同一答案 —— 客户端据此对齐本地乐观气泡(替代「靠文本相等」的脆弱匹配)。
  • 省略它时行为逐字不变(老客户端零影响)。

4.4 消息 DTO(historyMessage)

GET /sessions/{id}/messages 的数组元素、PATCH /messages/{id} 的响应 data、GET /admin/channels[/{platform}]/messages 的元素、message_persisted / channel_persisted 的 data.message、message_edited 的 data、握手 history 回放的 messages[] —— 同一个结构体:

{
  "id": "<消息 id>",
  "sessionId": "web",
  "role": "user|assistant",
  "content": "...",
  "mode": "chat",
  "mood": "",
  "image": "",
  "documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ],
  "thinking": "...",           // 恒缺席(全仓无写入者;见 §3.4.4 口径更正)
  "platform": "web",
  "deviceId": "web:chrome:a1b2c3",
  "senderName": "...",         // 第三方发送者显示名;空 = 主人
  "adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空
  "personaId": "...",          // 空 = 全局默认角色
  "clientMsgId": "...",        // 空则省略
  "turnId": "turn-…",          // v0.66;空则省略(老消息/未接管路径)
  "voice": {                   // v0.67 登记(尚未实现);非语音消息键缺席;🎤 判据 = 本键存在
    "source": "asr",           // "asr"(服务端识别,含云端)| "platform"(渠道平台自带转写)
    "status": "transcribed",   // "transcribed" | "empty" | "failed"(闭集三态)
    "name": "a1b2c3….ogg",     // 音频副本存储名;无副本 ⇒ 键省略
    "mime": "audio/ogg",
    "size": 12345,
    "durationMs": 3200,        // 可选:渠道如实给出时才有(绝不估算)
    "url": "/api/v1/media/a1b2c3….ogg" // 与 name 同生共死(服务端相对路径)
  },
  "createdAt": 1700000000000
}

字段省略规则:mode/mood/image/thinking/platform/deviceId/senderName/adapterId/personaId/clientMsgId/turnId/voice 均为 omitempty(空时键缺席;voice 是指针,非语音消息整键缺席,不是 {} 也不是 null)。

turnId 语义(v0.66,可选):

  • 形状:字符串,与该路的实时流帧帧壳 turnId、以及 turn_begin 帧壳 turnId 同值(编排器生成,见 §3.4.3 / §4.8)。
  • 同一路的 user 行与 assistant 行同值 ⇒ 客户端在重连 / 回放时可用它精确配对「回复 ↔ 提问」,并恢复回复侧的分组小标(真并行下两路回答交错,只按顺序渲染会配错)。
  • 退化为空(键省略)的三类情形:① 加入该列(迁移 0018)之前的历史行;② 未经编排器的写入路径(IM 渠道桥等,其行 turn_id 留空是预期行为);③ 忙时入队 / 被转向吃进的消息 —— 它们落库那一刻还没有自己的路,那一帧 message_persisted 因此不带 turnId,客户端从随后的 turn_begin(帧壳 turnId + data.messageIds)得知归属;行本身由实际处理它的那一路随后补写(先到先得)。
  • 列口径:chat_messages.turn_id TEXT NOT NULL DEFAULT ''(不用 NULL,于是「旧行」与「没写」是同一个值)。

voice 语义(v0.67 登记,尚未实现):

  • 形状:{source,status,name?,mime?,size?,durationMs?,url?},omitempty(非语音消息键缺席,与引入语音前逐字相同)。
  • 🎤 判据 = voice 键存在(这条消息来自一条语音消息)。客户端绝不从 content 正文里认媒体类型:QQ 语音正文里那个 [语音] 占位标记自 v0.67 起不再下发(见 §6 的 v0.67 行为变更),正文里也可能出现用户自己打出的 [语音] 字样。只认 voice 键。
  • source ∈ "asr"(服务端语音识别所得,含云端)/ "platform"(渠道平台自带转写,微信 iLink 的 voice_item.text)。
  • status ∈ "transcribed"(正文即转写文本)/ "empty"(转写成功但没识别出文字)/ "failed"(没拿到转写文本 —— 正文是那行中文诚实提示)。客户端按 status 分支,绝不按正文文案分支(§0.3 的同一纪律)。设计还刻意不下发 failed 的机器可读细分(未配置 / 鉴权 / 欠费 / 限流 / 超时 / 格式不支持 / 无字节 ⇒ 分类只进服务端日志,正文给的是人读的原因方向)。
  • name / mime / size / url 四者同生共死:有音频副本时全在,无副本时全缺席(不会出现 url 指着一个不存在的 name 这种形状)。
  • durationMs 只有渠道如实给出时才有;读不出来就省略键,绝不估算(当前未确证各渠道是否都给得出,见 design/24 §10.12-8)。
  • url 是服务端相对路径 /api/v1/media/<name>(唯一派生字段,由服务端一处拼出)⇒ 客户端绝不自己拼路径。
  • 落库形状 = DTO 形状减去 url(列 chat_messages.voice TEXT NOT NULL DEFAULT '',JSON 单对象;用空串不用 NULL,与 turn_id 同一口径)。音频原件复用文档那一套存储 / 元数据表 / 引用计数 / GC(同一 <DataDir>/uploads/documents 目录),但不进文档的抽取 / 注入链路:不被抽取、不进 documents DTO 数组、不占注入预算、不出现在附件清单行里。
  • 一条消息只落一份音频副本(= 被转写的那一条语音);同一条消息里的第 2..N 条语音既不转写也不落盘,DTO 里也没有它们(刻意的边界,不是遗漏)。
  • ⚠️ 微信(weixin-ilink)语音没有音频副本、不可回放:按既有裁决「平台自带转写就不白下载那份音频」⇒ source:"platform" 且无 name/mime/size/url,只保留 🎤 标记。

documents 数组语义(核心):

  • 形状:{name,mime,size,chars,truncated,status,reason?},≤5 条/回合,顺序 = 客户端发送顺序。
  • 0 个附件时该键省略(不是 [],与引入文档前逐字相同)。
  • status ∈ extracted(正文已进上下文)/ listed(只给了模型附件清单,正文没进上下文)/ unreadable(读不到正文)。
  • chars / truncated 是「本回合注入侧」语义:chars = 本回合注入模型上下文的字符数(截断时为截断后值),不是文件字数;truncated = 注入是否被预算截断,不是文件是否被截断。全文长度只有 document-read 工具的 totalChars 能看到。
  • reason(可选,omitempty):只在 status:"unreadable" 时下发(extracted/listed 时键缺席)。逐字就是服务端注入给模型的那份原因(同一字符串),客户端不必自己拼。当前文案集合(服务端常量,逐字):
    • 无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)
    • 无法抽取正文:PDF 已加密(无法用空口令打开),请解除密码后重新上传
    • 无法抽取正文:PDF 损坏或无法解析
    • 无法抽取正文:本期不支持 Word/Excel/PPT 解析(无文本层)
    • 无法抽取正文:内容不在可抽取的纯文本范围内
    • 无法抽取正文:文档文件不存在或已被清理 / …:读取文档失败 / …:文档引用形态非法 / …:文档上传目录未配置
    • 无法读取或解析失败(解析器整条消息失败时的诚实兜底)
    • ⚠️ 只放文案:不含落盘路径、不含凭据;原始错误只进服务端日志。
  • 不含正文(结构上保证:正文若从历史回传,每翻一页都要背着它)。
  • 时点:status/chars/truncated/reason 由回合结束后的回写写进 document 列 ⇒ 只在读取面(GET /sessions/{id}/messages、握手回放)可见;message_persisted 在用户消息落库时就发出(那时还没有结论)。
  • 列被写坏时(外部改动/格式变更)绝不静默当无附件:回一条 status:"unreadable" 的最小引用。

4.5 图片 / 文档 / 语音链路

图片:

  • 两条入口:POST /api/v1/upload(multipart,单文件字节 ≤ 5 MiB)取绝对同源 URL;或 chat.image 直接内联 base64 data: URI / http(s) URL。
  • ⚠️ 按形态写清(v0.66 补充说明,更正旧措辞「解码后 5 MiB」):chat.image 的唯一入口判据是 image 字符串长度,不是「解码后字节数」 —— POST /api/v1/chat 的 handleChat 在解码 / 落库 / 入模型之前调用 checkChatImageSize(image, limit)(internal/services/gateway/rest.go),判定就是 len(image) <= limit;limit = CHAT_IMAGE_MAX_BYTES(默认 5242880)按 base64 膨胀 4/3 换算出的字符上限 = ceil(n/3)*4 + 64,默认 6990572 字符(那 64 字符是 data: 前缀余量)。超一字符即 400 {"error":{"code":"image_too_large"}}(message 带上限与本次长度,文案指路先上传)。
    • base64 / data: URI 形态:受上述 6990572 字符约束,等价于解码后约 5 MiB。data: 前缀越长,留给载荷的字符越少;裸 base64(无前缀)在恰界(正好 6990572 字符)时解码 ≈ 5242929 字节 = 5 MiB + 49 字节 ⇒ 「解码后 5 MiB」是换算口径,逐字节的硬判据在代码里不存在(视觉解码层的兜底预检 vision/limits.go 同样是按字符串长度判 MaxInlineImageChars)。要与「一张图 5 MiB」严格对齐,客户端按 ceil(n/3)*4 + 64 自查即可,不要按解码字节数试探。
    • http(s) URL 形态(含 /api/v1/upload 返回的 /api/v1/files/<32hex>.<ext>):判据与形态无关(任何短于 6990572 字符的串都通过),而正常 URL 只有约 60 字符 ⇒ 实践中 image_too_large 不会因 URL 形态触发。URL 形态真正的天花板在视觉取回层:internal/runtime/multimodal/vision/image.go 的 maxFetchedBody = maxImageBytes = 50 << 20(50 MiB;其视觉/OCR 工具路径下同源 upload 产物优先走本地直读 readFile,用同一常量,而主模型直收路径仍走一次 HTTP 自取),另有 30 s 取回超时。⚠️ 该 50 MiB 是 io.LimitReader 的截断上限 —— 超出部分被静默丢弃、不返回错误 ⇒ 客户端不要把「服务端会拒绝超大 URL 图」当契约,也不要把 50 MiB 当成能用满的额度。
    • upload 形态:POST /api/v1/upload 自带 5 MiB 单文件硬上限(maxUploadBytes = 5 << 20;超限 413 {"error":{"code":"payload_too_large"}})⇒ 经 upload 拿到的同源 URL 图,有效上限仍是 5 MiB,在上传那一刻定死,与本接口的字符串长度判据无关。
  • 上限来源 CHAT_IMAGE_MAX_BYTES:≤0 = 用默认(不存在「禁用上限」的取值);只能收紧、不能放宽(钳到 vision 解码层硬顶 vision.MaxInlineImageBytes = 5 MiB)。
  • data: 前缀与类型嗅探都不参与这个体积判据:前缀只通过那 64 字符余量影响能塞多少;类型裁决(字节嗅探为唯一裁决权;HTML/SVG/判不定 → 明确报错,绝不静默当 PNG)发生在视觉层,属回合内错误,不是入口 400。
  • WS chat 帧到不了这条判定:WS 上行(streamChat)没有任何图片体积校验,读上限 4096 字节/帧由传输层直接断连 ⇒ WS 上发真实图片必须「上传 + URL」。
  • 视觉 URL 由服务端取回内联为 data: URI 再交 provider。主模型能否收图看生效组的 multimodal(见 §5),与 LLM_VISION_MODEL/LLM_OCR_MODEL 无关。

文档:

  • 上传 POST /api/v1/documents(owner/admin,字段 file,上限 CHAT_DOCUMENT_MAX_BYTES 默认 20 MiB)。
  • 类型裁决 = 字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 .pdf),成对判定(.pdf 必须嗅探为 application/pdf,纯文本类必须 text/plain)。HTML 改名 .pdf ⇒ 415;PDF 改名 .txt ⇒ 415。mime 是嗅探基类型(data.json → text/plain;真 PDF → application/pdf)。
  • 落盘名 = sha256(bytes)[:32] + ext(内容寻址;原始文件名永不参与路径 ⇒ 同字节同扩展名天然去重)。
  • 读取 GET /api/v1/documents/{name} 必须 Bearer owner/admin,一律 attachment + nosniff(永不内联)。
  • 对话携带:chat.documents = [{"name":"<服务端存储名>"}](只给 name,绝不接受 URL;多带的 mime/url/size 一律被服务端覆盖)。≤5 条,超出 400 too_many_documents(先于任何文件系统访问);任一条非法/不存在 → 整个请求被拒(404 document_not_found),绝不静默丢附件。附件部件挂在同一条 user 消息上;纯附件回合(无文本、无图片)同样成立。
  • 内置工具 document-read(模型侧,客户端一般只需知道它存在):注入被截断时清单行给续读游标 offset。策略默认 owner-only(群聊/陌生人天然读不到)。
  • PDF(v0.61):文本层 PDF ⇒ extracted;扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝 unreadable;加密与损坏可区分。

语音:

  • REST 源(/api/v1/audio/*,均 owner-only + 审计,绕开 WS 4KB 帧限):见 U5–U7。
    • /transcribe:{audio,format?,deviceId?} → {data:{text,provider?}}。
    • /speech:{text,voice?,format?,instruction?} → 原始音频字节(Content-Type: audio/<format>)。instruction(≤100 字符,汉字记 2)只对复刻/设计音色生效,系统音色 longxiaochun_v3 被忽略。
    • /voice-clone:{url,prefix?} → {data:{voiceId}}。⚠️ 真建音色需公网参考音频 URL(当前无,待主人提供)+ 北京地域 API Key。
  • WS 语音回合(voice_input):与文本回合走同一条路(进编排器 → 落库 → 流式 → 跨端同步 → 忙碌/排队语义一致)。转写文本落库为用户消息、助手回复落库;deviceId/clientMsgId 与 chat 帧同形。
  • voice_output 在该回合结算时才定向回发起连接(可能晚于若干状态帧,甚至由续跑回合或被 steer 注入的那一回合产生)。空正文 / TTS 失败/降级都走 provider 降级信号(silent 等),不把 stopReason 当语音内容;连接断开不补投,客户端带回同一 clientMsgId 可安全重试。

入站语音的音频副本与播放端点(v0.67 登记,尚未实现):

  • 音频副本何时产生:渠道适配器入站拿到语音字节后,转写在流水线最前端归一(见上),而落盘在闸门之后 —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音盘上不留任何文件(与 IM 附件同一纪律)。落盘 = 内容寻址(sha256(bytes)[:32] + ext,同字节同扩展名天然去重),产出该消息的 voice 引用(DTO 见 §4.4)。
  • 播放端点 GET /api/v1/media/{name}(owner/admin,必须 Bearer,与 GET /api/v1/documents/{name} 同一套门):
项 口径
名字形态 32 位小写十六进制主名 + 音频白名单扩展名(10 项:.wav .mp3 .aac .ogg .oga .opus .m4a .webm .flac .amr);.. 等路径穿越全形态拒绝
Content-Type 按存储名的扩展名映射,恒为 audio/*(.wav→audio/wav、.mp3→audio/mpeg、.aac→audio/aac、.ogg/.oga/.opus→audio/ogg、.m4a→audio/mp4、.webm→audio/webm、.flac→audio/flac、.amr→audio/amr)。不查库、不重新嗅探(扩展名与字节在保存时已成对裁决)
Content-Disposition inline; filename="<name>" —— 与文档读侧的 attachment 刻意相反(音频要被播放器消费;因下发类型恒 audio/* + nosniff,内联不构成文档侧那类 XSS 面)
其他响应头 X-Content-Type-Options: nosniff;Accept-Ranges: bytes;Cache-Control: private, max-age=86400, immutable;不设 ETag(不自造语义,条件请求走 Last-Modified)
状态码 200 全量 / 206 Partial Content(Range: bytes=0-、bytes=N-M、bytes=-N,由 Go 标准库 http.ServeContent 处理 —— 客户端不要自研 range 解析)/ 304 Not Modified(条件请求)/ 404 media_not_found(名字形态非法 / 路径穿越 / 文件不存在三者同一响应,不泄露「存在与否」以外的信息)/ 401 / 403(中间件)
HEAD 支持(GET 模式同时匹配 HEAD)⇒ 客户端可先探长度再决定要不要整段拉
⚠️ 416 不是 JSON 信封:Range 语法非法或范围不可满足 ⇒ 标准库直写 text/plain + Content-Range: bytes */<size>,不套 {"error":{…}}。唯一例外:空文件 + 范围不可满足 ⇒ 忽略 Range 回 200(标准库的特别处理)
日志 404 不记错误日志(可预期的输入问题);不记任何字节内容
  • 三条读侧路的边界(别混):
端点 鉴权 Disposition 名字形态 语义
GET /api/v1/files/{name} 无(能力 URL) inline(按图 MIME) 128-bit 随机名 + 图片扩展名 图片,给 <img> / provider 直接吃
GET /api/v1/documents/{name} Bearer owner/admin attachment 32 hex + 文档白名单(23 项) 文档,永不内联渲染
GET /api/v1/media/{name}(v0.67 登记) Bearer owner/admin inline; filename=… 32 hex + 音频白名单(10 项) 语音副本,供播放器消费
  • ⚠️ 音频 URL 不能直接贴进 <audio src>:读侧要 Bearer(与图片读侧「128-bit 名即凭据」刻意相反 —— 语音是私密内容,不是「谁拿到 URL 谁就能听」)⇒ 客户端须带 token 取字节(Web:fetch → blob: → 播放;原生:认证 HTTP 客户端)。这是有意的设计,不是缺陷。
  • ⚠️ 可播放性未逐一实测:amr / flac / opus 在部分浏览器内核上播不了 ⇒ 客户端以实测为准,播不了就照实提示(不假装;绝不给一个播不了的 url)。「能播」必须是格式级结论,不得靠「扩展名看起来像音频」就宣称可播。
  • ⚠️ 没有「删除一条语音副本」的 API:保留期 GC 是唯一出口(DOCUMENT_RETENTION,默认 168h)。

4.6 chat 请求与响应面

请求体(POST /api/v1/chat 与 WS chat 帧 data 同形):

{"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。
  • stream:仅 REST 用,见下。

同步(非 stream)响应面:

情形 状态 载荷
正常跑完 200 {data:{reply,steps,stopReason}}
幂等键重复 200 {data:{duplicate:true,messageId,sessionId}}
忙、已续接排队 202 {data:{queued:true,queueDepth}}
忙、显式并行且照准 202 {data:{parallel:true,turnId:"turn-…"}}(拿不到同步 reply;文本经 WS 流式到达)
队列满 409 {error:{code:"chat_busy"}}
空回合(无 text/image/documents) 400 {error:{code:"bad_request"}}
image 字符串超长(仅 base64 / data: 形态会触发;URL 形态不会) 400 {error:{code:"image_too_large"}} —— 在解码/落库/入模型之前拒;上限 = CHAT_IMAGE_MAX_BYTES 换算出的字符上限(默认 6990572 字符),见 §4.5
未接 loop 500 {error:{code:"chat_unavailable"}}
回合内部报错 500 {error:{code:"chat_failed"}}

stream:true 响应面(行为变更,v0.56/G2;受理成功后才回):

情形 状态 载荷
空闲已起回合 200 {data:{stream:true}}
忙、已续接排队 202 {data:{stream:true,queued:true,queueDepth}}
队列满 409 {error:{code:"chat_busy"}}
幂等键重复 200 {data:{duplicate:true,messageId,sessionId}}
已升为独立并行路 200 {data:{stream:true,parallel:true,turnId:"turn-…"}}
未接 loop 500 {error:{code:"chat_unavailable"}}
仅未接编排器时的总线派发失败 503 {error:{code:"chat_dispatch_failed"}}

不再有「先回 200 再看有没有落地」;202/409 真的会出现。派发用的回合上下文与请求作用域脱钩(WithoutCancel + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。

忙时并行(v0.65 起,parallel:true,仅显式声明时生效):

  • 触发权在客户端:服务端不做任何启发式自动升级。空闲时 parallel 无效果(照常起第 1 路)。
  • 会话忙且单会话并发额度未满(默认 2,硬上限 3)⇒ 本条升为一条独立并行路立即起(见上表 {parallel:true,turnId};该 turnId 随后出现在这一路的 turn_begin 帧壳与全部流帧帧壳上)。
  • 额度已满、或真并行被 <2 关掉 ⇒ 落回排队语义(202 {queued:true,queueDepth})并在 pending_ack 带 reason:"parallel_limit"。
  • 在途路数经 activeTurns 出现在忙态帧(session_busy/session_idle/pending_drain/session_interrupted)与 turn_begin.data 中。
  • session_idle 时点收紧(仅并行时与旧行为不同):所有在途路收尾且 pending 空才报空闲(单路时逐字不变)。
  • 每一路起跑都发一帧 turn_begin(v0.66 落地,见 §3.4.3)——客户端据此把用户气泡钉到路、并按 sessionId + turnId 两级分桶。
  • 省略 parallel 的客户端行为逐字不变。
  • ⚠️ env CHAT_MAX_PARALLEL_TURNS 未接线(见 §5、§7):生产恒为默认 2。

4.7 枚举权威表

GET /api/v1/meta/enums → {data:{platforms,moods,modes,unknown}},每项 {id,label}。

  • platform:web、kook、qq-obv11、weixin-ilink、telegram、webhook。
  • mood:calm、happy、excited、sad、angry(可扩展,非封闭集合)。
  • mode:chat、code、home、work、docs、voice、vision、agents、settings。
  • unknown 恒 "display-raw":未知取值原样显示——不丢弃、不报错、不替换占位符。判定只比对 id(大小写敏感);label 仅默认显示。服务端新增取值是非破坏性变更。
  • ⚠️ 未确证:上表逐值取自 internal/contract/enums(本文档经归档版转述,未逐一回代码核对)。客户端应直接读本端点而非硬编码。

4.8 interrupt 的 scope 与 turnId

上行 {"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-…"}}。

  • scope 取值域 {"", "all", "current"}(大小写敏感,只容忍首尾空白):
    • 省略 / "" / "all":取消全部在途路并清空待处理队列(历史语义)。
    • "current":只取消在途路(多路时为每一路),保留待处理队列(队列消息按正常续跑规则继续执行)。
    • 其它取值 → 定向回发送者 {"type":"error","data":{"code":"bad_request"}}(不广播、不打断任何会话,绝不静默按默认执行)。
  • turnId(v0.66 落地):只在 scope:"current" 时生效,只取消该 turnId 那一路(其余在途路与待处理队列不受影响)。省略 scope 或 scope:"all" 时按契约忽略 turnId(服务端只留一条 debug 日志,响应面不变)。
    • 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 {"code":"bad_request"}(只到发送者,不广播、不动任何会话),绝不静默降级成「停全部」。
    • 省略 turnId 的 scope:"current" = 作用于每一路(v0.65 原口径,逐字不变)。
  • scope:"all" 不发 session_idle:客户端靠 session_interrupted 收口。
  • 命中空闲会话为 no-op。

4.9 会话角色绑定

  • 会话角色(personaId)创建时确定、之后不可变(design/22 §8-1):POST /sessions {personaId} 在创建时绑定;PATCH /sessions/{id} 携带 personaId 键一律 400。
  • 空/缺省 = 继承全局默认角色(owner.yaml:defaultPersona)。
  • 消息 DTO 的 personaId = 该消息说话时的角色(assistant = 生成它的角色,user = 当回合角色;空 = 默认角色)。

4.10 v0.68 新增:配置事实面与后台思考行的紧急度

(a) GET /api/v1/admin/config-facts(只读,owner/admin)

响应 {data:{facts:[…], quietHours:{…}}}:

键 类型 说明
facts[].key string env 键名,与 .env.example 逐字一致(后端有测试钉住)
facts[].value string 进程生效值(已解析:bool → "true"/"false";整数 → 十进制;比例 → 归一化小数如 "0.8")。不是 .env 原文
facts[].default string 该键的声明默认值(internal/config 结构体 default:"…" 标签,唯一真源)
facts[].isDefault bool value == default ⇒ 面板显示「默认」(不代表 .env 里没写过这个键:网关按红线不读 .env,无从不区分「默认层」与「结构体默认」)
quietHours.startHour / endHour int 安静时段的生效钟点(本地时区小时)。⚠️ 报的是运行时真正生效的那一个:两端都写 0 时按「未填」回落到声明默认(见下 fallbackToDefault)
quietHours.disabled bool 窗口是否关闭(两个相等的非 0 钟点,如 23/23)
quietHours.label string 面板可直接显示的文案:"23:00-07:00" 或 "已关闭"
quietHours.fallbackToDefault bool,可选(true 才出现) 两端都被写成 0 ⇒ 视为未填,运行时回落到默认窗口(23:00-07:00,此时 startHour/endHour 已是回落后的值)。⚠️ 0/0 不是「关闭」:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」
  • 固定 9 条事实(记忆整理 7 键 + 安静时段 2 键):MEMORY_TIDY_ENABLED、MEMORY_TIDY_INTERVAL_SEC、MEMORY_TIDY_QUIET_START_HOUR、MEMORY_TIDY_QUIET_END_HOUR、MEMORY_TIDY_RECALL_THRESHOLD、MEMORY_TIDY_SCAN_LIMIT、MEMORY_TIDY_CANDIDATE_LIMIT、QUIET_START_HOUR、QUIET_END_HOUR。
  • 🔴 不含任何凭据:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),也不回凭据类键的「是否已配置」 —— 本端点只覆盖上面 9 个非敏感调参键。
  • 「默认 / 已改」怎么读:isDefault:true ⇒ 面板显示「默认」(生效值等于该键的声明默认值);isDefault:false ⇒ 显示「已改」。⚠️ 它不是「.env 里没写过这个键」的断言 —— 网关按红线不读 .env,无从不区分「.env 给的值恰好等于默认」与「完全没配」。哪一项没配因此不会被伪造成一个值:面板拿 value + isDefault 如实展示即可。
  • 9 条事实的 default 必须都能取到(后端有测试逐条钉住非空):取不到即「配置结构体 ↔ 事实表」漂移,属缺陷,不是「未配置」。
  • 安静时段的语义(重要,别照旧实现写文案):安静时段只服务「定时主动思考 → 主动搭话」(design/12 §6 策略五),不静默回复 —— 白天晚上的聊天回复都不受它约束;群聊也没有夜间静默(主人裁决 10)。因此面板文案不得写成「夜间全静默」。

(b) GET /api/v1/admin/backthink 条目的紧急度字段(v0.68 引入;v0.70 起是精确档位)

键 类型 说明
urgency string,可选 "routine" / "warning" / "critical"(internal/urgency 的档位词汇)。默认 = 该轮实际生效的精确档位(随记录落库)。只有与 urgencyLowerBound:true 同时出现时才读作「下限」(warning = 至少 high、可能更高)
urgencyLowerBound bool,可选(true 才出现,v0.70 新增) 该行没有落库档位(urgency 列出现之前的旧行,或从未走到档位判定点的行)⇒ urgency 是由安静时段窗口规则反推出的下限,不是精确档位
quietHoursBypassed bool,可选(true 才出现) 该轮穿透了安静时段:轮次时间落在安静时段窗口内且该轮判定为「要推送」
  • 取值优先级(v0.70,客户端按这个顺序读):
    1. urgency 存在且 urgencyLowerBound 缺席 ⇒ 精确档位,可直接显示(critical 即安全域,warning 即 high);
    2. urgency 存在且 urgencyLowerBound:true ⇒ 下限,只可显示为「≥ X」(≥ 高 = 至少 high、可能更高;≥ 例常 = 只确定不低于最低档),不得显示成精确值;
    3. urgency 缺席 ⇒ 未知(存档里没有这个事实),面板显示「未知」而不是「例常」——把未知画成「例常」与把下限画成精确值,是同一类「把猜当事实」。
  • 三个键都可能缺席,缺席 ≠ 常规档位。旧行(urgency 缺席)出现 urgencyLowerBound:true 的情况不存在:没有值就没有可标记的下限。
  • 旧行(无落库档位)与 quietHoursBypassed 的口径(v0.68 起不变):只在该轮时间落在安静时段窗口内时给出下限 —— 窗口内被放行 ⇒ warning(由 backthink.Decide 的级联可证:安静时段只放行 ≥ high 的轮次)且 quietHoursBypassed:true;窗口内被扣下 ⇒ routine。窗口之外的旧行判定不看档位、存档里也没有档位,故不下发。窗口口径与 B12 的 quietHours 同一份(含「0/0 视为未填、回落到默认」这条),因此 0/0 的部署也不会漏报穿透。
  • quietHoursBypassed 与既有的 bypassed(v0.66 前已有,含义是事件驱动绕过限频闸)是两件事,别混用:前者说「安静时段」,后者说「限频门」。
  • ⚠️ 实时帧(v0.70 更正):/ws/chat 下行的 backthink 帧其 data 是 bus 上发布的原始 backthink.Record(Go 字段名 ID/Trigger/ShouldPush/PushResult…)。该记录自 v0.70 起带 urgency(该轮实际生效的精确档位,非下限) ⇒ 实时行同样可显示精确档位,不必等重开页面。但实时帧仍不带 urgencyLowerBound 与 quietHoursBypassed:这两个键是网关读历史时按安静时段窗口推导的,不在记录里(urgencyLowerBound 对实时行也不会出现——实时行要么有精确档位、要么无档位)。⚠️ 原 v0.68 表述「实时帧不带这两个键」对 urgency 已过期,本版更正;对 quietHoursBypassed 仍然成立。

(c) 档位的来源与诚实边界(v0.70 重写)

  • 精确档位(默认路径):backthink.Record.Urgency 由 backthink 在档位判定点原样落库(该轮实际生效的档位,含她自报紧急度上抬后的结果)⇒ 网关优先读它并原样下发,与安静时段窗口、与时间戳都无关。因此「白天的一轮」如今也有精确档位(v0.68 时代窗口之外一律显示「未知」)。
  • 下限推导(只对没有落库档位的行):该列为空的行 —— 该列出现之前的旧行,以及从未走到档位判定点的行(系统事件降级、思考失败)—— 才回落到 v0.68 的安静时段窗口推导,并必须带 urgencyLowerBound:true。⚠️ 旧行不得被显示成精确档位,未知也不得被显示成某个档位。
  • 每轮 Import 落库(urgency 列)在 X2a 切片实现(213aff2);「该轮实际生效」的取数点见 internal/services/backthink/backthink.go 的判定点。⚠️ 本版不改 quietHoursBypassed 的语义。

4.11 链路图快照 + 多轮历史回放(GET /api/v1/admin/flow/graph;v0.71 新增,v0.73 增量)

为什么有这条:graph_update(§3.4.5)是纯实时帧 —— 后端在每个 span 开始/结束时广播一次,不重放;前端把图存在内存的会话图桶里(flowGraphs)。于是「轮次结束后才打开页面」或「刷新过页面」看到的永远是空态(/#/admin/flow 的「暂无流程」、后台思考行「查看完整链路导图」的「该会话还没有 graph_update 快照」)。v0.71 补上打开页面时按会话只读拉一次最近一轮的快照;v0.73 再补上本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库(新表 flow_graphs,迁移 0022),后端重启后仍能查回,且可按 turnId 翻更早的轮次(design/19 §4 的 P3)。

(a) 请求与响应

项 值
方法 / 路径 GET /api/v1/admin/flow/graph
鉴权档 writeAuth(owner / admin;viewer → 403,未认证 → 401)。与 design/19 §3「flow 可视化仅 owner/admin」一致
查询参数 sessionId(可选)。省略 / 空串 ⇒ 主会话 web(internal/runtime/history.MainSessionID)。其它取值就是该会话 id,如 sess-backthink(后台思考,design/20 §2)、web:<uuid>(次级会话)。v0.73 新增 turnId(可选):省略 / 空串 ⇒ 最近一轮(先查内存 loop.FlowPublisher.LastGraph,miss 才回退落盘 ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮(形如 web-7,取自 turns[].turnId / graph.id)
响应体 {data:{sessionId, turnId, source, graph, turns}}(v0.73;v0.71 只有 {sessionId,graph})。sessionId = 解析后的会话;turnId = 实际返回的那一轮的标识(无图时 "");source ∈ "live"(内存最近一轮)/"persisted"(落盘的历史轮)/"none"(没有图);graph = 该轮的图或 null;turns = 该会话已落盘轮次摘要 [{turnId,createdAt}],最新在前(未接线持久化或没有轮次时是 [],恒为非 null 数组)
graph 形状 与 graph_update 帧的 data 逐字同形(§3.4.5:{id,nodes[],edges[],sessionId,startedAt,endedAt},节点 props/log 同 §1.1 的脱敏口径)
归档 不审计(只读查询,与同档的 /admin/llm、/admin/config-facts 一致)
失败 图源(内存 + 落盘)都没接线 ⇒ 500 {"code":"flow_unavailable"}(不假装「这一轮没有图」);该轮确实没有图 / 该 turnId 不存在 ⇒ 200 + graph:null(source:"none")。落盘查询本身失败(库不可用)⇒ 同样 500 flow_unavailable(不把「读库失败」伪装成「没有这一轮」)

(b) 语义硬约束(客户端必须照这个读)

  1. 省略 turnId = 「最近一轮」而不是「你要的那一轮」。 省略或空串时后端先取内存里该会话最新的一张图(loop.FlowPublisher.LastGraph,即正在跑/刚跑完的那一轮),取不到才回退到落盘的最新一行 —— 因此「最新一轮永远是最新的」。不给 turnId 就不要假定它等于你眼前那条记录那一轮:后台思考行上点开的图可能不是该行那轮 ⇒ 面板必须说明(当前实现:frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx)。要精确取某轮就带上 turns[].turnId。
  2. 给了 turnId = 精确取那一轮。 该 turnId 正好等于内存里最新一轮 ⇒ 回内存图(source:"live",更实时);否则读落盘行(source:"persisted")。该轮不存在 ⇒ graph:null + source:"none"(诚实空态),绝不用别的轮次顶替。
  3. 只读、不发布。 拉快照不产生 graph_update 帧、不改变发布白名单、不改变任何会话的实时图、也不落盘。
  4. graph:null 是诚实的空。 该会话从未跑过有 span 的轮次、或图源里没有该会话(如发布白名单之外的隔离渠道会话 sess-xxx 从未建图、也从不落盘)时返回 null ⇒ 客户端必须渲染空态,绝不允许补画任何节点/假图。
  5. 落盘只覆盖发布会话白名单内的会话,且每轮一行(EndRound 时写入,不是每 span 一行):图里可能含用户输入文本 ⇒ 客户端按既有 §1.1 脱敏口径读即可,但不得把图当成「有全部原文」的凭据。落盘件按保留期清扫(FLOW_GRAPH_RETENTION,默认 168h),过期轮次会被回收 ⇒ turns[] 只反映「仍在保留期内」的轮次。
  6. 快照会晚于实时帧到达,反之亦然。 客户端已有实时图时不应被快照覆盖(实时图更新;快照只是补冷启动的洞)——当前实现只在本会话还没有任何图时才拉。
  7. 不替代 graph_update。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。

(c) 与 design/19 的关系

design/19 §4 把「多轮历史回放」列为 P3;v0.71 落实了 P3 里最小的一步(每会话最近一轮的只读快照),v0.73 把 P3 补全(本地落盘 + 按 turnId 取任意保留期内的轮次 + 轮次列表)。二者都不改 design/19 §1 的发布会话范围(web / web:<uuid> / sess-backthink,裁决 01.4.5)、不改 graph_update 两路互顶的既有裁决(见 §7.4-19)、不改前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 flow_graphs(迁移 0022),GC 口径照 events/documents(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。


5. env 与模型配置(与契约相关)

.env vs 面板文件:面板「LLM 模型」页保存写 <DATA_DIR>/models.json(默认 ./data/models.json);「渠道管理」页写 <DATA_DIR>/channels.json。只要文件存在且有效,运行时优先用它,.env 只作兜底/播种层。.env.example 与实现同步(键名逐字一致、值占位符)。

键 默认 与契约的关系
PORT 8080 网关唯一端口(含 REST/WS/admin)
HOST 0.0.0.0 监听地址
ADMIN_PATH admin 管理面板前端路由前缀(/+值)。不是 REST 契约:/api/v1/admin/* 一律不藏。非法值回退 /admin;路径形如面板入口但前缀不符 → 404
CHAT_IMAGE_MAX_BYTES 5242880(5 MiB) chat.image 字符串长度上限的换算源(单位是字节,按 base64 4/3 膨胀换成字符:ceil(n/3)*4 + 64,默认 6990572 字符;只作用于 base64 / data: 形态,URL 形态不走这道检查)。≤0 = 用默认;只能收紧,钳到 vision 硬顶(见 §4.5)
CHAT_DOCUMENT_MAX_BYTES 20971520(20 MiB) POST /documents 单文件上限(≤0 用默认;与图片上限刻意解耦)
AUTH_REFRESH_TTL 720h refresh token 有效期(access 固定 24h)
AUTH_REFRESH_ROTATE_DISABLED false true = 关闭 refresh 单次轮换
ADMIN_USERNAME / ADMIN_PASSWORD 占位 登录凭据(POST /auth/login);必填
JWT_SECRET 占位 必填、≥32 字符(签名 JWT)
DATA_DIR ./data 数据目录;接受保留字 platform(大小写不敏感)→ 平台标准目录
DB_SQLITE_PATH ./data/cyrene.db SQLite 路径
DB_PG_URL 空 pgvector 可选增强;配置了必须真实走语义/向量路径。是否生效另由 GET /admin/system/storage 的 vector.{enabled,ready} 如实报告
ALLOWED_ORIGINS 示例值 CORS + WS Origin 白名单(同 host 与回环别名额外放行)
WS_MAX_CONNECTIONS 1000 WS 连接上限(配置项,未确证是否在网关强制生效 —— 代码只见解析,未见到接线点)
LLM_MULTIMODAL false 主模型自身能否收图的 .env 等价物(只作用于 .env 合成组;有有效 models.json 时以该组 multimodal 为准)。true = 图片直进主模型;false/留空 = 图片交 vision/OCR 工具。非法值启动即报错
LLM_VISION_MODEL / LLM_OCR_MODEL 空 只供视觉/OCR 工具调用;不代表主模型能收图
LLM_CONTEXT_WINDOW 空 → 131072 上下文窗口(预算推导依据);负数启动报错
CONTEXT_MAX_TOKENS 空 → 按窗口推导 >0 显式覆盖;0/负 = 关闭压缩。⚠️ 若 .env 里还留着 32768,它就是显式覆盖、推导不生效
CONTEXT_BUDGET_RATIO 2/3 预算占窗口比例(接受 2/3 或 0.6667)
DOCUMENT_MAX_INJECT_CHARS 空 → 预算 1/8 单份附件注入上限
GUARDRAIL_MAX_INPUT_CHARS / GUARDRAIL_KEYWORDS 空 请求侧字符上限 / 关键词护栏
VOICE_STT_TIMEOUT_SEC 20 只约束 IM 渠道入站语音转写;Web 客户端 voice_input 不受它约束
VOICE_TTS_VOICE_ID / VOICE_TTS_FORMAT / VOICE_TTS_MODEL 空 POST /audio/speech 当前遵循的服务端配置(请求体 voice/format 为前向兼容接受但暂不生效)
VOICE_TTS_INSTRUCTION 空 系统音色 longxiaochun_v3 固定格式、不使用自由 instruction
LLM_PROVIDER / LLM_BASE_URL / LLM_API_KEY / LLM_MODEL / LLM_FALLBACK_MODEL / LLM_REASONING_KIND 等 见模板 LLM provider 组的 .env 合成组兜底(有有效 models.json 时被其覆盖)
DOCUMENT_GC_INTERVAL / DOCUMENT_RETENTION 1h / 168h 文档 GC(0/负 = 关闭 GC)
LLM_USAGE_RETENTION 720h llm_usage 保留期(0/负 = 关闭清理)
THINK_* / QUIET_* 见模板 后台思考与主动推送策略。QUIET_START_HOUR(23)/ QUIET_END_HOUR(7)是本地钟点的安静时段窗口(跨午夜可用;两个相等的非 0 钟点 = 关闭;两端都为 0 = 未填 ⇒ 回落默认 23/7):只服务「定时主动思考 → 主动搭话」,不静默回复(v0.68 起可经 B12 读到生效值)
MEMORY_TIDY_ENABLED true 记忆整理总开关(design/23 §4)。关闭 ⇒ 完全不跑(日志写明);整理只产建议、绝不自动改记忆
MEMORY_TIDY_INTERVAL_SEC 86400 整理周期(秒),是下限:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者
MEMORY_TIDY_QUIET_START_HOUR / MEMORY_TIDY_QUIET_END_HOUR 23 / 7 记忆整理的低峰窗口(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = 未填,运行时回落到默认 23/7
MEMORY_TIDY_RECALL_THRESHOLD 0.80 候选召回门(余弦),不是合并判据(可否合并由包含关系 + 规则否决决定)
MEMORY_TIDY_SCAN_LIMIT / MEMORY_TIDY_CANDIDATE_LIMIT 5000 / 20 每分区每轮的扫描上限 / 候选上限

上面 9 个键(记忆整理 7 + 安静时段 2)自 v0.68 起可经 GET /api/v1/admin/config-facts 读到「进程生效值 + 声明默认值」;在此之前面板没有任何 env 读取面。 | SUBAGENT_TIMEOUT_SEC | 600 | 单个子代理整次运行时限 | | MCP_SERVERS_JSON | 空 | MCP server 注册表(JSON 数组)。⚠️ 扁平 MCP_NAME/MCP_COMMAND/… 为未接线残留键,配了不生效 | | CHANNEL_* / CHANNELS_JSON | 见模板 | 渠道接入(.env 为兜底,面板 channels.json 优先) | | CHANNEL_EVENT_RETENTION | 720h | v0.67 登记,已接线(E4 afe9afb;.env.example 同步于 f9c5b56):channel_events 保留期 —— 比文档的 168h 长,因为事件表的核心用途是事后定案(一周太短);≤0 = 关闭 GC | | CHANNEL_EVENT_GC_INTERVAL | 1h | v0.67 登记,已接线(E4 afe9afb):渠道事件 GC 扫描间隔(与 DOCUMENT_GC_INTERVAL 同范式) | | CHANNEL_EVENT_COUNTER_FLUSH | 30s | v0.67 登记,已接线(E4 afe9afb):计数内存聚合的 flush 间隔。计数是尽力而为的近似值(进程崩丢 ≤30s),查询面以 approx:true 如实标注(见 §2.4(a)) | | FLOW_GRAPH_RETENTION | 168h | v0.73 新增,已接线(切片 F2):flow_graphs 轮次图保留期;≤0 = 关闭 GC(显式选择,「配了却不生效」不存在)。过期轮次从 turns[] 消失(见 §4.11(b)-5 / §7.4-20) | | FLOW_GRAPH_GC_INTERVAL | 1h | v0.73 新增,已接线(切片 F2):链路图保留期清扫间隔(与 CHANNEL_EVENT_GC_INTERVAL / DOCUMENT_GC_INTERVAL 同范式;启动期扫一次 + 定时 + 失败不退出) |

与契约相关但「配了不生效」的键:

  • CHAT_MAX_PARALLEL_TURNS:未接线。编排器有构造期旋钮 Deps.MaxParallelTurns,但组合根未从任何 env 读它 ⇒ 生产恒为默认 2(.env 里配了不生效)。本文档不把它登记为可用旋钮。
  • 旧 MCP_* 扁平键:生产装配只读 MCP_SERVERS_JSON。
  • GETUI_*:SDK 未接入,预留占位、当前不生效。

6. 变更记录

只保留最近若干版(v0.60 起);更早的全部历史见归档 Docs/archive/api-client.v0.65.md §5(v0.28 → v0.59)。 每版一句话 + 三档标注。三档定义见 §0.4。

v0.74(当前契约版本)

IM 路由的主动消息也补浏览器通知 —— message_persisted 新增可选结构化判据 proactive:true(依据主人 2026-10-05 裁定 C 的必然推论「她说话该不该提醒我,与走哪条路由无关」;对齐 design/12 §3 的主动推送与 design/16 §4.4 的镜像口径)。

  • 纯增量: ① message_persisted 的 data 新增可选键 proactive:true:IM 路由(im_bridge)的主动推送在落库之后追加一条带该键的同类帧 —— 帧类型、角色门、message DTO 形状一字未改,只多一个布尔键(§3.4.4)。 ② 客户端据此走与 proactive_message 同一套提醒逻辑(非前台补一发通知;去重键同为 message.id)。老客户端不读该键即行为与 v0.73 完全相同。
  • 修的是什么(根因):auto 提示在主人有已连接 IM 渠道时优先走 IM,而 IM 路由此前只发普通 message_persisted —— 浏览器通知挂在 proactive_message 上 ⇒ 实际最可能发生的路径恰恰不弹。这就是 P0(v0.72)如实报回的边界。
  • 判据是结构化的:主会话落库行的 platform/adapter_id 区分不出主动推送与普通回复(主动推送写的是解析后的真实平台,例如 weixin-ilink,与普通回复行同一形状)⇒ 判据放在落库那一刻的通知通道上(gateway.BroadcastProactivePersisted),随帧顶到达客户端。绝不读正文关键词、绝不用提示词/模型判断(项目红线:身份与判定靠元数据)。
  • 不误伤:普通消息(Web 回合 / IM 回复 / IM 镜像)走的仍是原入口 BroadcastPersisted,那条路径永不放该键 ⇒ 客户端不可能把普通消息误判成主动消息。客户端只应做 data.proactive === true 的严格相等判断。
  • 行为变更:无(普通镜像帧照旧发一条;主会话里仍恰好一条行,P0 的「不双份」红线未动)。
  • 破坏性:无。
  • ⚠️ 诚实边界:① 判据不落库(不改 schema)⇒ 刷新后从历史读回的行不带它(预期:通知只对实时到达有意义)。② 落库失败时不发判据帧:没有权威 id 的 message_persisted 会被客户端整帧丢弃,发它等于假装提醒过 —— 那种情况下只记 WARN。③ 「她主动说的话」在三类帧上到达的完整清单见 §3.4.4 与 §3.4.5。

v0.73(上一版)

链路图本地落盘 + 多轮历史回放 —— 图从「只活在进程内存」变成每轮一行落进本地库,GET /api/v1/admin/flow/graph 增量支持按 turnId 取历史轮(依据主人 2026-10-05 追加要求「链路流程应该有本地数据,不然每次重载之后页面就没数据了」;对齐 design/19 §4 的 P3「多轮历史回放」—— v0.71 只做了其中最小一步)。

  • 纯增量: ① 落盘:每轮收尾(EndRound)把该轮的图写进新表 flow_graphs(迁移 0022),每轮一行(不是每 span 一行);只落发布会话白名单内的会话(复用 flowSessionAllowed,与实时发布同一判据)。新 env 键 FLOW_GRAPH_RETENTION(默认 168h)/ FLOW_GRAPH_GC_INTERVAL(默认 1h),GC 口径照 events/documents(启动期扫一次 + 定时 + 失败不退出;≤0 = 关闭;不许无限增长)。 ② GET /api/v1/admin/flow/graph 新增 ?turnId=:省略 = 最近一轮(先查内存 LastGraph,miss 才回退落盘 ⇒ 最新一轮永远最新);给了 = 精确取该落盘轮。 ③ 响应新增三键 turnId / source / turns[]:source ∈ live/persisted/none;turns[]({turnId,createdAt},最新在前)供面板选历史轮 —— graph/sessionId 两键的既有语义一字未改。 —— §2.2 B13 / §4.11。 ④ 前端:图桶 flowGraphs 纳入 store 持久化(刷新即刻有图)+ 历史轮次选择 UI + 诚实文案(白名单外会话永远没有图;未跑新轮次前的旧轮次靠落盘)。
  • 行为变更:无(graph_update 帧形状、角色门、发布会话范围全部未改;graph/sessionId 语义未改)。
  • 破坏性:无(新查询参数可选、新响应键可选;老客户端不读即行为与 v0.71 相同)。
  • ⚠️ 诚实边界:落盘只覆盖保留期内的轮次(默认 7 天)—— turns[] 不是全历史;graph_update 两路互顶的既有裁决本版未动(§7.4-19)。当前仍没有图的轮次见 §7.4-20。

v0.72(上一版)

主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 design/12 §3 的主动推送与 design/16 §4.4 的镜像口径)。

  • 行为变更: ① proactive_message 那条消息现在会落进主会话历史(web、role: assistant、mode: "chat"、turn_id 留空)。此前后台思考走活跃客户端路由时只广播 WS 帧、一行都不写 ⇒ 刷新/重载后既不显示气泡、也补不出历史;那句 delivered to 2 active client(s) 只表示「帧被 2 个连接收下」,不代表进了会话。顺序改为先落库、再发帧。 ② 帧新增两个可选键 id / createdAt(同现/同省):这条消息在主会话历史里的权威行 id 与落库时间戳。客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后由历史行接续)。落库失败时两键缺席 —— 客户端不应把「没有 id」当成一条可持久化的消息。 ③ 没有在线 owner/admin 连接时也落库:推送器如实回 pushed=false(没有活跃客户端),但那句话仍作为真实记录留在主会话里。 ④ 后台思考记录里的 PushResult.Detail 措辞:活跃客户端路由改为 delivered to N active client(s), persisted to main session(未落库时写明 not persisted…,无在线客户端但已落库时写明 no active client, recorded in main session)—— 面板因此不会把「帧发出去了、刷新就没了」误读成「已推送」。
  • 纯增量:帧的形状只在新增可选键;落库行的形状就是既有 historyMessage(§4.4),因此既有读取面(GET /sessions/web/messages、握手 history 回放、message_persisted)逐字不变。
  • 破坏性:无。老客户端按未知键忽略 id/createdAt 即可;但它会把主动消息多看到一次(历史行)—— 这是本版的目的,不是缺陷。
  • 不双份(已验证):两条路由在一次推送里互斥(web 提示只走活跃客户端;其余先 IM 桥、IM 投递失败才降级活跃客户端),而 IM 路由本就把消息镜像回主会话 ⇒ 同一条主动消息在主会话里恰好一条。契约细节见 §3.4.5 后的「proactive_message 的落库口径」。
  • ⚠️ 诚实边界:被频率门(PushGate)拒绝的推送不落库(限流就是「这一条不发」,绝不留下一条它没发过的记录)。

v0.71(上一版)

链路图补版 —— 新增只读端点 GET /api/v1/admin/flow/graph,让「轮次结束后才打开的页面」也能看到最近一轮的链路图(依据主人 2026-10-05 第二次报「/#/admin/flow 停在暂无流程图数据 + 后台思考行提示该会话还没有 graph_update 快照」;对齐 design/19 §4 的 P3「多轮历史回放」里的最小一步)。

  • 纯增量: ① 新 admin 只读端点 GET /api/v1/admin/flow/graph?sessionId=<id>(owner/admin,?sessionId= 省略 = 主会话 web):返回 {data:{sessionId,graph}},graph 与 graph_update 帧的 data 逐字同形;该会话还没有图 ⇒ 如实 null(绝不画假图);图源未接线 ⇒ 500 flow_unavailable(不假装「这一轮没有图」)—— §2.2 B13 / §4.11。权限矩阵随之更新:writeAuth 12 → 13,总数 64 → 65。 ② §3.4.5 graph_update 行补一句「纯实时、不重放」并指向本端点。 ③ §7.4 新增两条诚实边界:「最近一轮」≠「你要的那一轮」(后台思考行上的图可能不是该行那轮;面板必须在图上说明)、图源是进程内内存(后端重启后如实回 null)。
  • 修的是什么(根因):graph_update 只走实时帧,前端图桶 flowGraphs 不持久化(store/persistOptions.ts 的 partialize 不含它),且此前没有任何 REST/WS 补拉通路 ⇒ 页面在轮次结束后打开、或刷新过页面,就永远空着。本端点补上唯一缺的那一环。
  • 行为变更:无(既有端点/帧一字未动)。
  • 破坏性:无(新端点;graph_update 帧形状、frameReachesAll 角色门、发布会话范围(裁决 01.4.5)全部未改)。
  • ⚠️ 诚实边界:只回每会话最近一轮;多轮历史回放(按 turn/时间点取任意一轮)仍未实现(design/19 §4 P3)—— 见 §7.4-18。graph_update 两路互顶的既有裁决本版未动(§7.4-19)。

v0.70(上一版)

后台思考行的紧急度精确化 —— urgency 默认即精确档位,另加 urgencyLowerBound 标记下限(依据主人 2026-10-05 裁定「要精确」;P4 只能给下限,本版把精确值送到两面:管理 REST 与实时帧)。

  • 纯增量: ① GET /api/v1/admin/backthink 条目的 urgency 默认语义改为该轮实际生效的精确档位(backthink 在档位判定点随记录落库,含她自报紧急度上抬后的结果)—— §2.2 B8 / §4.10(b)(c)。 ② 新增可选 urgencyLowerBound:true(只在为 true 时出现):标记该 urgency 是旧行的下限推导值而不是精确档位(旧行 = urgency 列出现之前的行,以及从未走到档位判定点的行)。客户端据此区分「精确档位」/「≥ 下限」/「未知」三态。 ③ 实时 backthink 帧带上记录里的 urgency(精确值)⇒ 本次会话新到的行不必等重开页面就有档位。⚠️ 实时帧仍不带 urgencyLowerBound / quietHoursBypassed(两者由网关按安静时段窗口推导,不在记录里)—— 更正 §4.10(b) 原「实时帧不带这两个键」的表述。 ④ §7.4-17 补「客户端不得把『下限』显示成精确值、不得把缺席显示成『例常』」。面板三态文案与悬停说明见 frontend/web/src/admin/modules/backthinkUrgency.ts。
  • 行为变更:urgency 的读法变了(v0.68/0.69 一律读作下限;v0.70 默认读作精确值,唯有带 urgencyLowerBound:true 才是下限)。取值域(routine/warning/critical)与 quietHoursBypassed 语义一字未改。
  • 破坏性:无(新键可选;把 urgency 当「下限」显示的老客户端最坏是显示得比事实保守,不会谎报精确值)。
  • ⚠️ 诚实边界:旧行(本列之前落下的)没有精确档位可显示 —— 它们仍走下限额推导并带 urgencyLowerBound:true;未知仍是未知(不编值)。见 §4.10(c) / §7.4-17。

v0.69(上一版)

directMsgPerMin 的 gateway 写入路径修复(切片 P1-T;缺口 = 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 0 = 保护性默认 6 条/分)。

  • 纯增量 / 缺口修复:PUT /api/v1/admin/channels/{platform}/config 现在接收并原样落盘账号顶层 directMsgPerMin,GET /api/v1/admin/channels 的账号元素也如实回显它 —— §2.2 A5 行 / §2.3 C28 注 / §7.2。
  • 行为变更:无(对不带该键的老客户端,行为与 v0.68 相同)。
  • 破坏性:无。
  • ⚠️ 诚实边界:账号级限流字段仍是值语义(请求体省略 ⇒ 0 = 未设置),要保住已设的值必须回送 —— 见 §7.3。

v0.68(上一版)

管理面「配置事实」只读端点 + 后台思考行的紧急度/穿透字段(依据主人指正「你多久没把新功能同步管理面板更新了?」与裁决 14;本版全部已实现、非契约先行)。

  • 纯增量: ① 新 admin 端点 GET /api/v1/admin/config-facts(owner/admin,只读、不审计):返回 9 条配置事实(记忆整理 7 键 + QUIET_START_HOUR/QUIET_END_HOUR)的进程生效值(value)+ 声明默认值(default)+ isDefault,外加 quietHours{startHour,endHour,disabled,label} 的窗口解释 —— §2.2 B12 / §4.10(a)。不含任何凭据值(连凭据类键的「是否已配置」都不回)。权限矩阵随之更新:writeAuth 11 → 12、总数 63 → 64。 ② GET /api/v1/admin/backthink 的条目增 2 个可选字段:urgency(routine/warning/critical)与 quietHoursBypassed(true 才出现)—— §2.2 B8 / §4.10(b)。语义:urgency 是存档可证的下限(warning = 至少 high),quietHoursBypassed = 「这轮在安静时段里仍被放行发出去」。 ③ §5 登记 9 个键的默认值与语义(此前只以 THINK_*/QUIET_* 概括)。
  • 行为变更:无(既有两个端点的既有键一字未改;新字段都是可选键,老客户端不读即零影响)。
  • 破坏性:无。
  • ⚠️ 诚实边界:后台思考的精确档位(high 还是 critical)在存档里已丢失(backthink.Record 不带重要度,推送成功的行其 detail 被投递说明覆盖)⇒ 本版只下发可证下限,窗口外的轮次干脆不下发(面板显示「未知」),绝不猜。见 §4.10(c) / §7.4-17。

v0.67(上一版)

三批已在设计文档定稿的新增面一次性登记:语音消息(播放端点 + 消息 DTO voice + 音频副本口径)、非消息事件查询面、账号在线状态(依据 design/24 §10.11、design/26 §4.5 / §2.6)。登记时三批都还没有实现;其中账号在线状态(52dac45)与非消息事件查询面(本切片 E6)已落地(见下)。

⚠️ 本版是「契约先行」登记:登记的三批里,账号在线状态(见 §2.4(b))与非消息事件查询面(见 §2.4(a))已落地;语音消息尚未实现,那一批的形状取自设计文档而非代码核对。客户端先按形状对齐、不要按「已可用」编程;实现落地后本版会补记 commit 并把 §7.2 相应行改为上线。

  • 纯增量: ① 新端点 GET /api/v1/media/{name}(用户侧,owner/admin 必须 Bearer):200 字节流 / 206 Partial Content / 304 Not Modified;Content-Type: audio/*;Content-Disposition: inline; filename="<name>";nosniff;Accept-Ranges: bytes;Cache-Control: private, max-age=86400, immutable;支持 Range 与 HEAD;404 media_not_found;401/403 由中间件给出。⚠️ 416 是标准库直写的非 JSON 响应(见 §0.3、§4.1、§4.5)。 ② 消息 DTO 增可选 voice 对象(omitempty;非语音消息键缺席):{source,status,name?,mime?,size?,durationMs?,url?};source ∈ asr/platform,status ∈ transcribed/empty/failed,name/mime/size/url 四者同生共死(有副本时才在);url 是服务端相对路径 /api/v1/media/<name>。🎤 判据 = voice 键存在。一次覆盖全部读取面:GET /sessions/{id}/messages、PATCH /messages/{id} 响应、GET /admin/channels[/{platform}]/messages、message_persisted / channel_persisted / message_edited、握手 history 回放(§3.2、§3.4.4、§4.4)。 ③ 新错误码 media_not_found(404) → 加进 §4.1 的 404 行(与 document_not_found 并列;刻意不复用它,否则客户端分不清缺的是文档还是语音)。 ④ 新错误形态 416(非信封,标准库直写)→ §0.3 / §4.1 / §4.5。 ⑤ 新 admin 端点 GET /api/v1/admin/channels/events(非消息事件列表,倒序;?platform=&tier=&category=&channelId=&since=&until=&limit=&offset=;响应 {data:{events:[…],total}};since/until 按 UTC ISO-8601 定宽字符串字典序比较、跨度上限 90 天;不含正文、不含媒体 URL、不含任何凭据 / flag)—— §2.4(a)。已落地(owner|admin 档;元素键名 camelCase,逐字见 §2.4(a);未给窗口 ⇒ 不注入窗口)。 ⑥ 新 admin 端点 GET /api/v1/admin/channels/events/counters(逐类计数汇总 {data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}})—— §2.4(a);未接线存储 ⇒ 500 channel_events_unavailable(新增错误码,绝不回全 0 假汇总)。已落地(approx 与 counters 同级;platform 未知 ⇒ 400 bad_request)。 ⑦ GET /api/v1/admin/channels 的 accounts[] 增可选 accountOnline(账号在平台侧是否在线,与 connected(适配器/WS 是否连着)是两件事;键缺席 = 状态未知 ≠ 离线)—— §2.4(b)。已落地(管理面按可选接口断言读 AccountStatus();known=false 或适配器无该状态面 ⇒ 键缺席)。 ⑧ 新 env 键 3 个:CHANNEL_EVENT_RETENTION(720h)/ CHANNEL_EVENT_GC_INTERVAL(1h)/ CHANNEL_EVENT_COUNTER_FLUSH(30s)—— 已在 §5 登记(本切片不新增语音相关 env 键;语音上限/保留期复用既有常量与 DOCUMENT_*)。 ⑨ WS 协议零变化:media 是 REST 端点;无新帧、无新上行字段。
  • 行为变更: ⑩ QQ 语音消息的 content 不再含 [语音] 占位标记(旧行为 → 移除):今天从正文里认 [语音] 的客户端必须改为看结构化字段(voice 键存在 = 语音消息)。⚠️ 这是本版唯一会打到老客户端的地方(见 §7.2)。 ⑪ GET /api/v1/admin/system/storage 的 documents 段(件数 / 字节)自此包含语音副本(音频副本与文档同表:SELECT COUNT(*), SUM(size) FROM documents)—— 形状不变,变的只是口径(面板文案宜标注「含语音副本」)。
  • 破坏性:无(新端点、新可选字段、新错误码都是增量;documents / /upload / /files / /audio/* 的既有端点一字未改;WS 零变化)。

v0.66(上一版)

并行帧层上线 + 消息回合标识落库 + 渠道按账号操作;并修正 v0.65 的两处过期/写错的陈述(依据 design/15 §2.5、design/08 §4;提交 c0dc435 / d64b26d + d804c4e / 42279b7 / a918819 / 679cee0)。

  • 纯增量: ① 新下行帧 turn_begin(owner+admin,不进 frameReachesAll 白名单):帧壳 {type,sessionId,turnId}、data {kind,messageIds,leg,activeTurns},每起一路(含续跑路)恰好一帧(c0dc435)。 ② steer_ack 增帧壳 turnId(data {messageId,atStep} 不变)(c0dc435)。 ③ 上行 interrupt.turnId:仅 scope:"current" 生效(其它 scope 按契约忽略);目标路不存在 ⇒ 定向 {type:"error",data:{code:"bad_request"}}(复用既有 code),不广播、不动会话(c0dc435)。 ④ 消息 DTO 增可选 turnId(omitempty,空则键省略;同一路的 user 与 assistant 同值)—— 一次覆盖 GET /sessions/{id}/messages、PATCH /messages/{id} 响应、GET /admin/channels[/{platform}]/messages、WS message_persisted / channel_persisted / message_edited、握手 history 回放(d64b26d + d804c4e;迁移 0018)。 ⑤ POST /admin/channels/open|close 请求体增可选 accountId;未知 id ⇒ 404 account_not_found,已配置未注册 ⇒ 409 account_not_registered;响应增 resolved / accountId(42279b7)。 ⑥ DELETE /admin/channels/{platform}/config 增显式 ?empty=1(与 ?id= 互斥 ⇒ 400;无可删 ⇒ 404);响应增 empty / removed(42279b7)。 ⑦ GET /admin/channels 的 accounts[] 增可选 connected(键缺席 = 状态未知 ≠ 未连接)(42279b7)。 ⑧ 新 env 键 VOICE_STT_TIMEOUT_SEC(默认 20;只约束 IM 入站语音,Web voice_input 不受其约束)(a918819)—— 已在 §5 登记。
  • 行为变更: ⑨ IM 用户消息现在带 documents(同一 DTO、无新字段;0 附件仍省略该键)(679cee0)—— v0.65 曾预告,本次随该提交真正上线。 ⑩ turn_begin 的帧序:session_busy/pending_drain → message_persisted(user) → turn_begin(messageIds 必须是已落库的权威 id,故只能在落库之后发)(c0dc435)。 ⑪ 不带 accountId 的 open/close 目标现在是确定性的:按 channels.json 有效配置顺序第一条(此前走注册表 map 顺序,多账号平台上每次可能落到不同账号)(42279b7)。 ⑫ DELETE .../config?empty=1 由「被当成 id 缺失 ⇒ 400」变为真的删除无 id 条目(显式化;?id= / ?empty= 语义边界见 §2.3 注)(42279b7)。
  • 破坏性:无(新帧对新客户端可见、老客户端忽略即可;所有新增字段皆可选;无既有字段被移除或改义)。
  • ⚠️ 本版更正 v0.65 的两处陈述(文档写错,不是代码变更):
    • thinking 口径:本文档曾写「历史思维链只有 GET /sessions/{id}/messages(owner/admin)返回」—— 与实现不符。history.Message.Thinking 全仓没有任何写入者(loop.Result 无该字段、finalizeAnthropicStream 直接丢弃思考文本)⇒ 该键在所有读取面恒缺席;思维链的唯一出口是实时 thinking.* 帧(owner/admin)。已在 §3.4.4、§4.4 按实测改写。
    • v0.65 §7 的「未实现」清单:turn_begin / steer_ack.turnId / interrupt.turnId / chat_messages.turn_id / 写类工具文件级互斥 —— 现已全部实现并上线(c0dc435、d64b26d+d804c4e、354b08a),§7 已相应改写。
  • ⚠️ v0.66 补充说明(描述性更正,不动形状/阈值,不升版本号):chat.image 的体积判据是「字符串长度」而不是「解码后字节」 —— 旧措辞「内联上限 = 解码后 5 MiB」只对 base64 / data: 形态成立,且它本身是换算口径(len(image) <= 6990572 字符,见 §4.5)。URL 形态不触发 image_too_large(URL 串约 60 字符,必然通过):其天花板在视觉取回层的 50 MiB(io.LimitReader,超出静默截断、不报错);POST /api/v1/upload 自身是 5 MiB 字节硬限(413 payload_too_large)。四处已按实现写清(§4.5 图片、§4.6 响应面、§5 环境变量表、U8 行 + 错误码表)。无端点 / 帧 / 字段形状变更,无默认值变更。

v0.65

忙时并行回合基础管线(I1′ 有条件放宽)+ 六条登记(依据 design/15 §2.5,主人 2026-10-05 裁决 B)。

  • 纯增量:① 上行 chat.parallel?:bool(WS chat 帧 + REST 三入口同形);② session_busy/session_idle/pending_drain/session_interrupted 的 data 增 activeTurns:int;③ session_interrupted.data 增 cancelledTurns:int;④ pending_ack.data 增可选 reason:"parallel_limit";⑤ REST 两条新应答形状(同步 202 {parallel:true,turnId} / stream:true 的 200 {stream:true,parallel:true,turnId});⑥ 新 env 键 VOICE_STT_TIMEOUT_SEC(默认 20s)。
  • 行为变更:⑦ IM 入站附件引用真的写进落库的用户消息(documents 列,同一 DTO、无新字段);⑧ IM 各渠道语音进上下文(云端 ASR 转写为文本,失败落一行中文诚实提示);⑨ session_idle 时点收紧为「所有在途路收尾且 pending 空」(仅并行时生效)。
  • 破坏性:无(省略 parallel / 不读新键的客户端行为逐字不变)。
  • ⚠️ 回填:上条 ⑦(IM 附件引用落库)与 ⑥(VOICE_STT_TIMEOUT_SEC)在 v0.65 冻结时尚未提交,已分别在 679cee0 / a918819 落地,见 v0.66。

v0.64

LLM 用量落库 + 只读汇总端点 + 面板用量小卡。

  • 纯增量:新端点 GET /api/v1/admin/llm/usage(owner/admin 读)、新迁移 0017_llm_usage、新 env 键 LLM_USAGE_RETENTION(默认 720h,≤0 = 关闭清理)。
  • 行为变更 / 破坏性:无(model 层新增的是可选 sink,不注入时行为逐字不变)。
  • ⚠️ v0.64 曾写「llm_usage.session_id 恒为 NULL」,已由 v0.65 更正:loop 已把会话 id 播种进运行 ctx 并落库(空串仍映射 NULL);但 GET /admin/llm/usage 仍不按 session_id 过滤/分组,响应形状不变。

v0.63

客户端登记两条落地:documents[] 增可选 reason + GET /sessions/{id}/messages 对不存在会话回 404 session_not_found。

  • 纯增量:① documents[] 元素增可选 reason(omitempty,只在 status:"unreadable" 时下发)。
  • 行为变更:② GET /api/v1/sessions/{id}/messages 对不存在/已删除会话由 200 {"data":[]} 改为 404 session_not_found(客户端须处理 404)。判据是会话注册表 chat_sessions:隔离 sess-* 也回 404(其消息走 /admin/channels/{platform}/messages)。
  • 破坏性:无。

v0.62

面板「重置为 env 默认」动作:新增 owner-only POST /api/v1/admin/llm/reset-from-env(body 必须 {"confirm":true})。

  • 纯增量:新端点 + 新审计动作 llm.resetFromEnv + 2 个新错误码(env_defaults_unreadable / models_unreadable),复用既有 llm_persist_failed/llm_reload_failed/llm_unavailable。
  • 行为变更 / 破坏性:无。

v0.61

PDF 文本层抽取:白名单第 23 项 .pdf + 类型裁决改为 (扩展名, 嗅探) 成对裁决。

  • 行为变更:① .pdf 由「一律 415」变为「可上传 + 可读取 + 文本层可抽取」;② 类型裁决收紧为成对判定(HTML/纯文本改名 .pdf ⇒ 415;PDF 改名 .txt ⇒ 415)。
  • 纯增量:白名单 22 → 23 项(唯一新增 .pdf),其余 22 个文本类判定与响应一字未变。
  • 破坏性:无。

v0.60

管理侧只读「存储状态」端点 + 面板存储卡接真数据:新增 GET /api/v1/admin/system/storage(owner-only、只读、无审计动作)→ {data:{main,documents,vector}}。

  • 纯增量:新端点 + 前端新行为;既有端点/帧/字段形状一字未动。
  • 行为变更 / 破坏性:无。

更早版本(v0.28 → v0.59):见归档 Docs/archive/api-client.v0.65.md §5。 未实现的契约变更记录:归档 §5 里 v0.56–v0.59 的多条「未做/诚实边界」在 §7 重新登记。


7. 在飞切片与「未实现 / 未确证」

本节是诚实边界:让客户端不要以为已有、也不要误以为没有。

7.1 在飞切片

v0.66 及更早登记的在飞项已全部冻结、客户端可直接依赖(下表)。⚠️ v0.67 新登记的三批中,账号在线状态(accounts[].accountOnline)与非消息事件查询面都已落地(见 §7.2);语音消息尚未实现,不在本表 —— 逐条见 §7.2。

项 状态 冻结位置
新下行帧 turn_begin 已上线(c0dc435) §3.4.3;session_events.go、session_parallel.go
steer_ack 的帧壳 turnId 已上线(c0dc435) §3.4.3;session_steer.go(emitTurnState)
interrupt.turnId 已上线(c0dc435) §4.8;ws_hub_turn.go、session_interrupt.go
消息 DTO turnId(含 chat_messages.turn_id 迁移 0018) 已上线(d64b26d + d804c4e) §4.4;history.go、session_turn_id.go
写类工具文件级互斥(读共享 / 读写与写写互斥,被占住回 ErrFileBusy 文案给模型) 已上线(354b08a、ef3dcb6)—— 纯服务端行为,无客户端可见契约面 internal/runtime/tool/{fileclaims.go,legfile.go,tool.go}、loop/tools.go

⚠️ 工作树另有未提交改动(未冻结、未登记为契约项):当前工作树存在与本文档无关的未提交改动(例如助手回复的 <p> 段落包裹规整 internal/runtime/turnreply/、前端 turn 分桶接线等)。它们尚未 commit、未经契约评审,本文档不逐项登记其形状;客户端不要按当前工作树形态编程 —— 等它们冻结成下一版契约条目后再对齐(本表只登记已冻结的项)。 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是归档时的代码状态,已过期。

7.2 确认为「未实现」(别当已有)

项 现状
🔴 v0.67 登记的「语音消息面」(GET /api/v1/media/{name} + 消息 DTO voice + 入站语音音频副本落盘) 未实现(契约已先行登记):端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5;权威设计 design/24 §10。⚠️ 同批的行为变更(QQ 语音 content 不再含 [语音])与口径变更(/admin/system/storage 的 documents 段含语音副本)也随该切片一起生效,现在尚未生效
✅ v0.67 登记的「非消息事件查询面」(GET /admin/channels/events、GET /admin/channels/events/counters) 已实现(v0.67 登记项本切片落地):形状与错误码见 §2.4(a)(已按代码核对:元素键名 camelCase、approx 与 counters 同级、400 bad_request、owner|admin 档);权威设计 design/26 §4.5。未接线存储 ⇒ 500 channel_events_unavailable。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表并回填 §2.1/§2.2 的端点计数
✅ v0.67 登记的「账号在线状态」(GET /admin/channels 的 accounts[].accountOnline) 已实现(v0.67 登记项本次落地;落地前基线 f91a83f,本切片 commit 由主会话补记):形状见 §2.4(b)。管理面按可选接口断言读 AccountStatus()(plugins/channels/qq_obv11/probe.go,a79fe96);适配器没有该状态面、或实现了但 known=false ⇒ 键缺席(未知 ≠ 离线,绝不用 false 冒充)。当前只有 qq-obv11 会带该键。⚠️ 本行留在此表仅为与上一版对照,下版改文档时应移入 §7.1 在飞切片表
✅ P1-T:directMsgPerMin 的 gateway 写入路径(GET /admin/channels 的 accounts[].directMsgPerMin + PUT .../config 的顶层 directMsgPerMin) 已实现(v0.69):缺口 = gateway 面没让该字段通过 ⇒ 面板保存一次渠道配置就把主人手配的「关闭流控(-1)」静默重置回 0 = 保护性默认 6 条/分。现 5 处透传(AdminChannelAccount DTO / channelConfigRequest / config.ChannelAccount 拷贝 / adminChannelAccountOf / 微信扫码重建账号)。形状见 §2.2 A5 与 §2.3 C28 注
graph_update 按路分桶 未实现:graph_update 只带 data.sessionId(会话级),无 turnId
第二路进行态 system 摘要(WithParallelNotice) 未实现:全仓无该符号;session_parallel.go 明示留待后续切片
env CHAT_MAX_PARALLEL_TURNS 未接线:Deps.MaxParallelTurns 是构造期旋钮,但组合根未从任何 env 读它 ⇒ 生产恒为默认 2
tool_execution.update 的生产者 wire 映射已实现,但工具分发器暂无中途进度上报源(全仓无 StreamKindToolExecutionUpdate 的生产者)⇒ 实际暂无生产者(start/end 真实发出)
chat_messages.turn_id 上的 IM 行 IM 渠道桥不走网关编排器 ⇒ 其行 turn_id 留空属预期(契约里该键缺席),不是缺陷

7.3 诚实边界(既有事实)

  • subagent_result 不落库:父会话转录里没有新消息、重连回放不补发。
  • proactive_message(v0.72 起)落库:成为主会话 web 的一条 assistant 行(见 §3.4.5 后的「落库口径」)。仍未落库的情形(都是如实、预期的):① 被 PushGate(频率门 / 冷却 / 按风格窗口)或安静时段挡下的推送 —— 「这一条不发」就没有记录,绝不留下一条它没发过的消息;② 后端未接线历史存储(HistoryStore: nil,仅测试与异常部署)时只发帧不写库,进程重启即丢。
  • task_done / automation_result / subagent_result 不是会话消息:它们不进消息 store、不是气泡(后端也没有把它们写成 chat_messages 行;subagent_result 更是错过即永久丢)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
  • GET /api/v1/files/{name} 无鉴权(能力 URL);文档读侧相反、必须 Bearer。
  • GET /admin/llm/usage 不按 session_id 过滤/分组(尽管该列已落库)。
  • POST /audio/speech 的请求体 voice/format 当前不生效(合成遵循服务端 VOICE_TTS_VOICE_ID/VOICE_TTS_FORMAT)。
  • /audio/voice-clone 真建音色需公网参考音频 URL(当前无,待提供)+ 北京地域 API Key。
  • 可观测三帧 observability_*:?level= 分级过滤由客户端本地做(帧广播给所有 owner/admin 连接,订阅只决定循环跑不跑)。审计 tab 仍 5s REST 轮询(未做 observability_audit 帧)。
  • thinking 恒缺席(v0.66 口径更正):消息 DTO 的 thinking 在所有读取面都不出现(history.Message.Thinking 全仓无写入者)。思维链只有实时 thinking.* 帧(owner/admin)。旧文档称「历史思维链可由 GET /sessions/{id}/messages 返回」是错的,见 §3.4.4。
  • 消息 DTO 的 turnId 只在 Web/网关回合存在:IM 渠道桥不经过网关编排器 ⇒ 其落库行的 turn_id 为空、DTO 里 turnId 键缺席(预期行为)。turn_begin / steer_ack / interrupt.turnId 同理只存在于网关回合。
  • GET /api/v1/media/{name} 读侧要 Bearer owner/admin(v0.67 登记):音频 URL 不能直接贴进 <audio src>(浏览器不带 token)⇒ 客户端须带 token 取字节(Web:fetch → blob:)。这是有意的(语音是私密内容,与图片读侧「128-bit 名即凭据」的取向刻意相反)。
  • 微信(weixin-ilink)入站语音没有音频副本、不可回放(v0.67 登记):按既有裁决「平台自带转写就不白下载那份音频」⇒ voice.source:"platform" 且无 name/url,只保留 🎤 标记。
  • 416 不是 JSON 信封(v0.67 登记):GET /api/v1/media/{name} 的 Range 错误由 Go 标准库直写 text/plain,不套 {"error":{…}} ⇒ 客户端别拿 error.code 解析它(§0.3 / §4.1 / §4.5)。
  • 账号级限流字段是「值语义」,不是三态(v0.69 明确):groupMsgPerMin/msgPerMin/maxMessages/directMsgPerMin 在请求体里省略即 0(未设置)—— 一次「只改 token」的 PUT 若不带这四个键,会把已设限额(含 directMsgPerMin:-1)重置为 0。要保住就必须回送(面板已如此做)。这是 v0.68 起既有的顶层字段口径,v0.69 未改。
  • 后台思考行的实时帧只带记录里的字段(v0.70 边界):urgency 在实时帧上一定是精确档位(记录落库值),urgencyLowerBound / quietHoursBypassed 不在记录里、因此实时帧不带 —— 它们由网关读历史时按安静时段窗口推导(见 §4.10(b))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,这不等于「这一轮没有穿透」。

7.4 「未确证」清单(客户端请以实测/代码为准)

  1. GET /meta/enums 逐值(platforms/moods/modes)取自归档转述,本文档未逐一回 internal/contract/enums 核对。客户端应直接读端点,不要硬编码。

  2. conflict(409) 通用错误码除 chat_busy 外,还有哪些端点会带它 —— 未逐一确证。

  3. 各管理写端点的精确请求体字段与状态码:本文档 §2.3 只给「方法/路径/鉴权/一句话要点」,未逐字段展开(归档 Docs/archive/api-reference.v0.65.md 有历史详细版,但可能漂移)。客户端实现管理写操作前请回代码或参考该归档并以代码为准。

  4. observability_metrics 的完整 AdminMetrics 字段集:本文档只说明它是扁平快照,未展开字段。

  5. subagent_event 在 subagent_ask 应答路径上的精确字段(orchestration.AuditResult 形状):未展开。

  6. account_not_registered(409) 的适用面:本文档只确证了 C19/C20(open/close)会带它;POST /admin/channels/test、/{platform}/message/test 等其它渠道端点是否也有等价的账号级错误码 —— 未逐一确证。

  7. turn_begin.data.leg 在长会话中的取值范围:代码里是会话内单调递增序号(非「1/2 路」固定枚举),但本文档未确证客户端应如何展示 >2 的取值 —— 建议只当诊断信息用。

  8. GET /admin/channels 的 accounts[] 除 connected 外的字段集:本文档未逐字段展开(该结构在 v0.66 只是新增一个可选键;v0.67 另登记了一个可选 accountOnline,见 §2.4(b))。

  9. (v0.67 登记 · 已落地确证)GET /admin/channels/events 的 events[] 元素 JSON 键名:已定为 camelCase,逐字与 internal/services/gateway/admin_channel_events.go 的 channelEventDTO 一致:id / platform / accountId / tier / category / platformType / channelId / channelType / groupId / actorId / actorRole / targetId / selfRelated / messageId / summary / createdAt / updatedAt(时间为 UTC RFC3339;counters[] 同为 camelCase,风格一致)。客户端可以硬编码这些键名了。

  10. (v0.67 登记 · 已落地确证)events.total 的语义:是筛选条件下的总条数(不受 limit/offset 影响;与 events 同源条件),因此 total > events.length 是正常的分页状态。

  11. (v0.67 登记 · 已落地确证)渠道事件查询的参数默认值与错误码名:limit 未给 / ≤0 ⇒ 存储层缺省 100;超硬上限 ⇒ 钳到 500(不报错);offset 负值 ⇒ 0;非法整数 ⇒ 400 bad_request。platform 未知 / tier 非 t1|t2|t3 / since-until 跨度超 90 天 / 时间点非法 ⇒ 一律 400 + code:"bad_request"(未新造专用错误码)。category 不做白名单(未知类别 = 精确匹配 ⇒ 空结果)。approx 的嵌套层级已定为 data.approx(与 data.counters 同级)。

  12. (v0.67 登记 · 已落地确证)两个渠道事件端点的 RBAC 档位:owner|admin(writeAuth),与渠道消息日志面(B2/B3)同档;viewer 为 403。依据:事件行带群 id / 成员 id / 关联消息 id,属私有内容面,不是 viewer 的监控投影。

  13. (v0.67 登记 · 已确证)accounts[].accountOnline 的字段名与是否有 known 同级键:字段名 accountOnline 为本契约所拟(设计只规定「独立状态面 / AccountStatus() / known 三态」,未给管理面字段名),实现沿用了它、未改名;不发 known 同级键 —— 「未知」由键缺席承载。哪些平台会带该键已确证:只有适配器实现了 AccountStatus() 的平台(当前仅 qq-obv11)。

  14. (v0.67 登记)音频各容器在各客户端的可播放性:amr / flac / opus 在部分浏览器内核上播不了(design/24 §10.6 明确「未逐一实测」)⇒ 客户端以实测为准,播不了就照实提示。

  15. (v0.67 登记)GET /admin/system/storage 的 documents 段是否拆出语音副本:设计本期不拆(不新增 kind 列 / 不按 ext 聚合)⇒ 该段只增口径、不变形状,客户端不要指望能分开统计。

  16. (v0.67 登记 · 前向提示)voice.url 指向的可能不是原件,而是转码后的副本:design/24 §10.14 已把「源格式不可播 ⇒ 转码成可播副本(唯一必需的路 = amr → wav)」立为口径,转码能力规格见 design/25-转码能力.md(该能力尚未接线)⇒ 客户端不要假定 url 一定是某一种音频格式,一律按响应的 Content-Type / mime 如实处理,播不了就照实提示(绝不给一个播不了的 url 假装能播)。⚠️ 本条不在 design/24 §10.11 的差异清单内(§10.11 与 §10.13 均无「零转码」绑定表述),登记它是因为「url 的字节格式」会直接影响播放端;最终口径以 design/25 为准。

  17. (v0.68 登记 · v0.70 更正)urgency 的「下限」与「精确」之分:

    • v0.68 原文(当时成立,现已过期):urgency 一律是「档位下限」,warning 读作「至少 high(也可能是 critical)」;窗口之外的轮次不下发该键(backthink.Record 不带重要度、推送成功的行 detail 被投递说明覆盖 ⇒ 无损精确档位已不可能),要精确档位需 backthink 侧把每轮 Import 落进记录(当时未做)。
    • v0.70 更正:每轮 Import 已落库 ⇒ urgency 默认就是精确档位(含窗口之外/白天的轮次);只有与 urgencyLowerBound:true 同时出现时才是下限(旧行 / 从未走到档位判定点的行,值由安静时段窗口规则推导)。实时 backthink 帧也带 urgency(精确值),但不带 urgencyLowerBound / quietHoursBypassed(见 §4.10(b) 更正)。
    • 客户端两条硬约束(v0.70 新增):① 不得把带 urgencyLowerBound:true 的「下限」显示成精确档位(≥ 高 ≠ 高;同形就等于把推导冒充事实);② 不得把键缺席显示成「例常 / routine」(缺席 = 存档里没有这个事实),也不得据此推断安全域。
  18. (v0.71 登记 · v0.73 更新)「最近一轮」≠「你要的那一轮」:GET /api/v1/admin/flow/graph(§4.11)在省略 turnId 时回该会话最新的一张图(先内存 loop.FlowPublisher.LastGraph,miss 才落盘)。后台思考行上点开的图因此可能不是该行那一轮 ⇒ 面板必须在图上说明(当前 frontend/web/src/admin/modules/flow/FlowSnapshotHint.tsx),客户端不得把它渲染成「这一行的链路」。v0.73 起客户端要精确取某轮就带 turns[].turnId(多轮历史回放已实现,design/19 §4 的 P3)。

  19. (v0.71 登记 · v0.73 更新)图源是进程内内存**,但每轮收尾会落盘(v0.73):loop.FlowPublisher 的每会话最近一图仍是内存**(design/19 §1 的实时口径),但 EndRound 会把该轮写进 flow_graphs(迁移 0022)⇒ 后端重启后、该会话还没跑新轮次时,省略 turnId 的查询回退落盘即可拿到最近一轮(source:"persisted"),不再一律 graph:null。⚠️ 仍会 graph:null 的情形:该会话本就在发布会话白名单之外(隔离渠道会话,从不建图也从不落盘)、或该会话从未跑过带 span 的轮次、或该轮已被保留期 GC 回收(默认 168h)。登记既有裁决不改:graph_update 的两路互顶(同会话并行两路时后发的那张图会顶掉先发的)仍未修,本版不碰它。

  20. (v0.73 登记)落盘只覆盖「保留期内」且只覆盖白名单会话:turns[] 是已落盘且未被 GC 回收的轮次摘要,不是全历史(FLOW_GRAPH_RETENTION 默认 168h,≤0 = 关闭 GC ⇒ 那时才会无限增长,属显式选择)。落盘范围与实时发布同一白名单(flowSessionAllowed:web / web:<uuid> / sess-backthink)—— 群聊 / 陌生人会话永远不会出现在 turns[] 里。此外正在跑、尚未 EndRound 的那一轮不会在 turns[] 中出现(它是内存里的「实时」那一轮,source:"live"),这是如实而非缺失。

  21. (v0.74 登记)message_persisted 的 proactive 判据只活在实时帧上,不落库:它是「这条是她主动说的话」在线上唯一的结构化判据(帧顶布尔键,由 gateway.BroadcastProactivePersisted 打上),没有对应数据库列(本片不动 schema)。因此 ① GET /sessions/{id}/messages、握手 history 回放、GET /admin/channels/** 读到的历史行不带它(预期:通知只对实时到达有意义);② 客户端不得从 platform / adapterId / turnId 等行字段反推「是不是主动消息」—— 主会话里主动推送写的是解析后的真实平台(例如 weixin-ilink),与普通回复行同一形状,反推必然误伤。落库失败时不发该帧(没有权威 id 的 message_persisted 会被客户端整帧丢弃)。


本文件由「以代码为真源」维护(当前 v0.74;v0.73 / v0.72 / v0.71 / v0.70 / v0.69 / v0.68 见本文件 §6,v0.67 及更早见本文件 §6 与 Docs/archive/)。范围:v0.74 同步了 internal/services/gateway/**(proactive.go / broadcast.go)、internal/services/channelbridge/**(proactive.go / persist.go / proactive_notify.go)、cmd/cyrene/wire_frontend.go、frontend/web/src/lib/ws/handlers/** 与本文件;v0.73 同步了 internal/services/gateway/**、internal/runtime/loop/flow.go、internal/runtime/flowgraph/**、internal/db/migrations/0022_flow_graphs.*、frontend/web/src/** 与本文件;v0.72 同步了 internal/services/gateway/**(proactive*)、internal/services/backthink/**、frontend/web/src/** 与本文件;v0.71 同步了 internal/services/gateway/**、internal/runtime/loop/flow.go、frontend/web/src/** 与本文件。