2227 lines
453 KiB
Markdown
2227 lines
453 KiB
Markdown
# Cyrene v2 客户端 API 契约
|
||
|
||
> **契约版本:`v0.96`**(上一版 `v0.95`,其条目见 §6;`v0.89` / `v0.88` / `v0.87` / `v0.86` / `v0.85` / `v0.84` / `v0.83` / `v0.82` / `v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||
> **维护铁律**:每实现/变更一个 REST 端点、WS 事件、admin API **立即改本文档**;契约变更必须版本化,不破坏既有客户端。
|
||
> **准确性 > 详尽度**:宁少写也别写错;**写不了确证的写「未确证」**,见 [§7](#7-在飞切片与未实现未确证)。
|
||
> ⚠️ **v0.67 是「契约先行」登记版**:本版新增的三批 —— **语音消息**(播放端点 + 消息 DTO `voice`)、**非消息事件查询面**、**账号在线状态** —— 登记时**都还没有实现**,形状**取自设计文档**(`design/24 §10.11`、`design/26 §4.5` / `§2.6`)**而非代码核对**。**已落地**:账号在线状态(`52dac45`)、非消息事件查询面(切片 E6)—— 见 §2.4;**语音消息的「用户可见面」已由 v0.79 落地**(切片 L45:DTO `voice` + `GET /api/v1/media/{name}` + 副本落盘;残留缺口——WS 实时帧/握手回放不带 `voice`、AMR 不落副本(**后者已由 v0.80 修复**)——见 §4.5 / §7.2 / §7.4-29)。已在 §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` 判据」**。
|
||
|
||
> **v0.76 是三面增量版(⚠️ **逐条状态不同**:**① 已在 v0.78 实现、覆盖率又由 v0.79 补齐**;**②③ 已实现**)**:① **消息读取面新增 `toolCalls[]`(工具回看面)—— ✅ 已实现(v0.78,切片 CODE-TC),覆盖率已于 v0.79 补齐至全部编排回合**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`),每条含工具名 / 状态(`ok`/`error`/`unknown`)/ 耗时毫秒 / **已脱敏**的入参与结果摘要;数据源是**已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 加迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解**。🔴 **缺席语义**:图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / 该路标识查不到图)⇒ 字段**整体缺席**,**绝不回空数组**冒充「这一轮没调过工具」。⚠️ **历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** ⇒ 两者无法按消息字面 join,按时间窗猜映射项目口径不做;补齐需先让链路图带上**按路的回合标识**、并补一个**按路查图**的读侧入口。**v0.78 已把这两件都补上** ⇒ `toolCalls[]` 已实现;🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**(见 **§4.4、§7.2、§7.4-23 与 §6 的 v0.78 ① / v0.79 ④**)。② **未读面(✅ 已实现)** —— `message_persisted` 的 `data` 加**可选** `unreadCount`(`omitempty`:只在 **>0 且确实算得出来**时出现;**缺席 = 老行为**),`GET /api/v1/sessions` 列表项同键(该键**自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`)⇒ 多端共享**(Web 读了移动端也清),这是与安卓侧已对齐的**取舍**,不是缺陷。详见 **§3.4.4、§4.13**。③ **记忆整理试算加性异步** —— `POST /api/v1/admin/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`;新增 `GET /api/v1/admin/memories/tidy/dry-run/{runId}` 轮询;**不带 `async` 仍是原同步行为**。**无破坏性变更**(新键 / 新可选参数 / 新端点;老客户端零影响)。详见 §1(会话列表与消息读取)、§2.2 B15 / §4.12.1、§3.4.4。
|
||
|
||
> **v0.77 是链路图按路分桶 + 记忆整理护栏/异步落地版(已实现;纯增量)**:① **`graph_update` 按路分桶**(切片 FV2)—— 帧 `data` 新增可选 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号);真并行(`parallel:true`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),两路不再互相顶掉;`GET /api/v1/admin/flow/graph` 响应新增 `lanes[]`(**最近一轮每一路**的图,恒为非 null 数组),`graph` 语义不变(最新一路 / 指定轮 / `null` 诚实空态)⇒ **老的读取方零改动**。**无破坏性变更**(两个新键可选;单路时 `turnId` 缺席 ⇒ 客户端行为与 v0.75 逐字相同)。详见 **§2.2 B13 / §3.4.5 / §4.11 / §7.4-19**。② **记忆整理护栏抬高 + 诚实截断 + 试算异步落地**(切片 MT-A)—— 每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认值 `20 → 200`、两两比对上限改为 env 键新键 `MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`,原先**写死** `400`);配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(墙钟时间预算截断);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询可用;新错误码 **`tidy_run_not_found`**)。详见 **§2.2 B14/B15 / §4.10(a) / §4.12 / §4.12.1 / §5**。③ **「入库前审查」的 4 个 env 键登记**(切片 MT-B,纯增量)—— `MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **该默认值已由 v0.93 改为 `90`** —— 当时登记的值是准确的,见 §6 v0.93);⚠️ 该切片的**面板读侧 / 回滚端点目前没有 HTTP 入口**(见 §7.2)。详见 **§5**。
|
||
|
||
> **v0.86 是「搜索结果条数上限可配」版(已实现并推库 `bff57bd` + `c68387a`;**纯增量:面板多一个 number 字段 + 两个动作各多一个键;无破坏性变更**)**:`web-search` 的「结果条数上限」从**改不了**变成**面板上真能改**(此前三处断点:字段表里没这个字段 / `saveProvider` 只并四个键 / `mergeSelectionPatch` 只处理 `primary`+`fallback` ⇒ **手工塞进 `values` 也会被静默丢弃**,「我改了它却没变」成立)。① **面板声明**新增字段:`pluginId=web-search` / `panel=providers` / form `add` 多一个 **`maxResults`**(`type:"number"`, `min:1`, 标签「结果条数上限(默认 10)」)—— 前端本就渲染 `number` ⇒ **客户端零改动**。② **`plugin.saveProvider` 的 `values` 新增可写键 `maxResults`**:**三态** —— 缺位/null ⇒ 不改动;**正整数**(含整数字符串)⇒ 采用;`0` / 负数 / 小数 / 空串 / 非数 / 布尔 / 数组 ⇒ **`400 invalid_payload`**,且**盘上一字节不动**(**绝不静默忽略**)。③ 响应新增回显:`plugin.saveProvider` 的 `data.result.maxResults`(**0 = 文件里未声明**)、`plugin.reauthProvider` 的 `values.maxResults`(盘上有值才出现 ⇒ 用于「重配凭据」回填,否则一保存就把上限清掉)。④ **语义**:面板文件的 `maxResults` **压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`,是**运行期真上限**(模型的 `count` 只能更小),**保存即生效**(走既有热重载,响应里的 `hotReload`);代码默认值 **5 → 10**(三处口径已一致:env 默认 / io 包 `defaultMaxResults` / 面板文件)。⑤ **计数不变**(无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`)。⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(后端按「非数 ⇒ 报错」拒)⇒ 面板上**没有「去掉上限」的手势**(要回到 `.env` 兜底得改文件);**小数/负数一律拒**;文件里没写该键时 `reauth` **不回填**(避免回填 0 让保存整笔进不去),因此那个框是空的、看不出当前生效值。
|
||
> **v0.87 是「搜索 provider 扩到八家」版(已实现,纯增量;⚠️ 两家的成功响应都未真机实测)**:新增两家已登记接入位 —— **`tencent_wsa`**(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**;🔴 **HTTP 状态码恒 200** ⇒ 判失败**只能**读响应体 `Response.Error.Code`;`Pages` 是**JSON 字符串数组** ⇒ 需二次解析)与 **`metaso`**(秘塔搜索,`POST {base}/api/v1/search`;⚠️ 响应 schema 全部来自第三方来源且互相打架 ⇒ 实现走**宽松解析、未实测**)。对客户端的**唯一影响是两处名单**:`plugin.saveProvider` 的 `values.provider` 合法闭集(**六家 → 八家**)与 `plugin.providers` 的候选(多两条 `{value,label}`,中文名见 §6 本版条目)—— **端点形状 / 错误码 / 三态写回语义 / §2.0 计数(`14 + 25 + 40 = 79`)全部一字未改**,且前端候选本就是 `optionsFrom` 数据驱动 ⇒ **客户端零改动**。**零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`,`.env` / `.env.example` **未动**。
|
||
|
||
> **v0.85 是「整理不再只产建议」版(已实现并推库 `6d92536`;**新增 1 条 writeAuth 端点 + 一处行为变更(整理默认自动落地)+ `link` 候选停产;无破坏性变更**)**:① **新增端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)** —— 按**动作**批量把 `pending` 候选标成 `rejected`(**幂等、只改候选状态、绝不碰记忆行**)。`action` **必填**且是**闭集**(`merge|link|archive|reclassify|keep`,未知值 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400`。`200 {data:{action,limit,total,rejected,truncated}}`(`total` 是命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 `truncated:true`)。审计 `memory.tidy.reject`(只带计数)。语义见 **§4.15(e)**。② 🔴 **行为变更(默认生效):整理在轮末会「自动落地」两类高置信动作**,不再只产建议(主人 2026-10-07 口径「该删就删」)——**可自动的只有**:`merge`(**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」)、`archive`(理由 = `one_off_event` **且不像习惯**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、**受限来源(群聊/陌生人)**。六条护栏:软动作(正文不改、行不删)/ **每轮上限 50**(硬顶 500,超限如实 `truncated`)/ 审计 `memory.tidy.apply`(`auto=true`)/ **可 revert**(回滚路径与人工应用同一条)/ 幂等 / **一键关回只产建议**(`$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`,**零新 env 键**)。⚠️ **面板侧的候选列表仍可能显示历史 `link` 行**,直到用 B25 清掉。③ **`link` 候选停产**:`link` 在 `design/23 §14` 里被列为**未细化**(既无落点也无数据模型),且 `applyable()` 只认 merge/archive/reclassify ⇒ **应用 link 恒 `skipped`,产出它只是往待办里灌噪音**。现在规则层不再产出(判 `keep`);`TidyLink` 常量保留**只为读历史行**。存量用 B25 清(主会话已执行)。④ **计数**:`writeAuth` 段 24 → **25**、管理路由总数 78 → **79**(`14 + 25 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 **27**,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **25**)。⑤ ⚠️ **诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**(不能重启后端);`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(本次不改变任何判定结论,升版会让既有候选按幂等键复制出重复行)。
|
||
|
||
> **v0.84 是「显示名 + 段落标签硬约束」版(已实现并推库:`c46053e` 显示名 / `8051906` 段落修复 / `bc1dee3` provider 配置面去 env 化;**一处形状变更(插件动作响应)、一处新增硬约束;无破坏性变更**)**:① **`plugin.providers` 候选响应形状:裸字符串数组 → 对象数组 `{"options":[{"value":<id>,"label":<中文显示名>},…]}`**(§4.14(c))。修的是主人报障「中文搜索接口在管理后台下拉菜单里全给的英文名,你甚至不愿意带个括号把中文名写上去」—— 根因不是漏写文案,而是**整条链路没有「显示名」这个位置**(字段声明只有 `optionsFrom`,动作回的就是纯 id 数组)。🔴 **两条硬口径**:**(i) `label` 仅供显示,`value` 才是落盘值**(把 `label` 提交进 `provider` 会写坏配置文件);**(ii) 前端解码器兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**零影响**。表格单元格按 `value→label` 索引显示、查不到回落原值;多选(降级链)改为「候选开关(显示 label、写 value)+ 落盘值输入框」。② **产出侧新硬约束(§4.16)**:助手文本(落库 / 对外 / IM 镜像)**不得出现未配对的段落标签**(落单 `<p …>` 或 `</p>`)—— `一个 <p> = 一段`,**段落不嵌套**。修的是安卓端登记的 `<p><p>正文</p>` 畸形(他们按非贪婪契约必然把 `<p>` 显示给用户);**实测两半都是产出侧造成**:规整器**原样放过**该形状,且会在「末段漏 `</p>`」时**亲手套出**它(并顺带修掉一个幂等漏洞:落单 `</p>` 会越跑越多)。🔴 **历史行不回填**,消费方**仍应保留**剥落单标签的容错。③ **模型面登记(非客户端契约面,供跨端对照)**:`web-fetch` 的**浏览器渲染降级档已接线**(`design/28 §1 M5 / §8 B5`),其返回体用 **`fetchedBy`**(`direct` / `browser`)**如实标注正文实际来自哪条路**,两条档的失败各自留痕(`browserError` / `directError`),**绝不静默替换**;被 SSRF 拒绝的目标**不换路**。④ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**。⑤ ⚠️ **诚实边界**:**`web-search` 的真机工具路径在登记时仍有一处未部署的修复**(智谱对「热搜/资讯」类查询返回的条目 `link` 整批为空,旧判据把「无链接」当「不是结果」整批丢光 ⇒ 工具报 `no results` 而面板探针报 `ok` —— 因为探针**不过 `Finalize`**,对这一类缺陷**结构性失明**)。修复已推库(`2c56bc9`:无链接条目照原样保留、只有 link/title/content/snippet 全空的空壳行才不算结果),**要等下次 `build` + `restart` 才真机生效**。**教训登记**:**别拿面板探针通不通来推断工具路径通不通**。
|
||
|
||
> **v0.83 是记忆整理「应用面」版(已实现并推库:切片 TIDY-1 `454ddb0` + 读侧 status 闸 `9126d65` + 网关端到端回归 `f847769`;**纯增量、无破坏性变更**)**:① **新增 5 条 `writeAuth` 端点**,补上「整理只产建议、永远不落地」这个两天没闭环的缺口 —— **B20** `GET /api/v1/admin/memories/candidates`(候选列表)、**B21** `POST …/candidates/{id}/apply`(单条应用)、**B22** `POST …/candidates/apply?runId=&limit=`(批量应用;`runId` 省略 = **全部待定**)、**B23** `POST …/candidates/{id}/revert`(单条回滚)、**B24** `POST …/memories/tidy/run`(手动**真跑**一轮:写候选 + 留痕,**不自动应用**)。语义与形状见 **§4.15**。🔴 **三条硬口径(客户端按此编程)**:**(i) 幂等不是错误** —— 已决定过的候选回 `200` + `status:"skipped"`,界面**不得**画成失败;**(ii) 超限必须如实** —— 批量回执的 `total` 是**真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内,两者不等时 `truncated:true`(不许把「本轮 100 / 共 342」画成「全部完成」);**(iii) 永不物理删除** —— merge / archive 都是**软动作**(只改 `status`,正文一个字不改、行一行不删),因此 `revert` 无损可逆。② **读侧 status 闸(行为修复,形状零变化)**:记忆的**四条列表读路径**(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 **`status='active'`** 的行 ⇒ 被软合并(`'merged'`)与被归档(`'archived'`)的记忆**不再被召回、不再进上下文、也不再出现在面板 `GET /admin/memories`**(此前它们照样出现 ⇒ 等于「清了也白清」)。🔴 **刻意不过闸的两处**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕**本身没有 status 概念**,且它是软删行的查看与撤销入口 —— 加闸等于审计自我抹除)。③ **调度不再饿死(内部行为,对客户端零形状影响)**:整理从「ticker 从进程启动算起、重启十几次就永远走不完 24h」改成**到点即跑 + catch-up**,以 `memory_tidy_runs` 的留痕行为**持久事实**判「上次到底跑过没有」,并把手动真跑端点(B24)做成 panic button。④ **计数**:`writeAuth` 段 19 → **24**、管理路由总数 73 → **78**(`14 + 24 + 40`;按 `routes.go` 逐条核对:经 `writeAuth` 挂载的 **admin** 路由实有 **26** 条,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**)。⚠️ **诚实边界(别当已验证)**:**生产库全程零动作** —— 1191 行记忆与 77 条待定候选**都没有被应用或清理**(等主人拍板,见 §4.15(d));**面板上的真机端到端未跑**(前端操作面在本版登记时尚未落地,并行切片在做);旧候选的 `run_id` 是空串 ⇒ 「**按 run 批量**」找不到它们,要清它们必须走**全部待定**那一档;候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore`),**未**同步进迁移 `0019`。
|
||
>
|
||
> **v0.82 是三面增量版(① 已实现(切片 Y1 `215eedf`);② 已实现(切片 Y3 `4213b26`);③ 形状已定、代码在树(切片 Y4 `e0a6bca` + 组合根挂点);**纯增量、无破坏性变更**)**:① **`tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)** —— 工具卡从此能在**实时**显示「这次调用拿到了什么」,不必等事后翻回看面。它修的是「实时只看到工具名与状态、结果要事后才知道」。🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态** —— **内容与回看面 `toolCalls[].resultSummary` 同源同口径**(同一份**已脱敏**文本 `trace.RedactText`:凭据形状清洗 + 截断)、**同一个上限 `≤500` rune**(超长按 rune 截断、末位 `…`);**空值 ⇒ 整键缺席,绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空;`tool_execution.start` / `.update` **不带**该键。⚠️ **失败也带**(本版口径):调用失败 / 取消 / 超时的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与回看面(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。详见 **§3.4.2**。② **自排的成本可见面**(切片 Y3,design/12 §N C.5/C.6)—— `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(取值域 = **闭集**:三个内建标签 + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`;值**未知或为空** ⇒ **`400 invalid_trigger`**,**绝不静默忽略** —— 静默忽略会让面板在显示全部的同时声称「正在筛选」;合法值**下推 SQL**,因此 `limit` 数的是**匹配**条数);新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e),**三态** `available`/`running`/`reason` + `gate` 快照 + `configFile`)。⚠️ **`trigger` 的「显示口径」仍是开放的**(客户端按字面值显示、不做闭集校验)—— **筛选白名单**与**显示取值域**是两件事,别混(见 §4.10(d))。③ **`browser` 插件管理面板(B4)**(切片 Y4,design/28 §8)—— `GET /api/v1/admin/panels` 的面板目录新增 `browser` / `settings`(「浏览器与截图」,4 个区块:配置表单 + 状态 / 指纹 / 截图三张只读表),该插件声明**六个动作**(3 读 3 写,见 §4.14(d));**不开任何新路由**(走既有 A14 通用动作分发),因此 §2.0 计数**只**因 ② 的 self-wake 端点 +1。**模型面**(非客户端契约面)另有一条:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`)。**④ 本版把 §4.10(d) 一句已成假话的口径改正**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`;启动日志 `self wake tool registered tool=request_think`)⇒ `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**在生产里会真的出现**。**⑤ 计数**:`writeAuth` 段 18 → **19**、管理路由总数 72 → **73**(`14 + 19 + 40`)。⚠️ **登记时的工作树状态(如实)**:③ 的**组合根两个挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)与 ① 的**失败也带摘要**(`internal/runtime/loop/tool_frame_summary.go`)在本版登记时**仍在工作树、未提交** ⇒ 提交前,跑着的后端里**没有**这些行为;提交后请按 `git show` 逐字复核。
|
||
|
||
> **v0.81 是「她自排轮的用户可见面 + 时间轴保留期信号 + `round` 跨重启单调修复」版(切片 C1-1 `94da6bc` / C1-2 `ed50053`(自排)+ 切片 X1(时间轴两处真缺陷);**无新端点、无破坏性变更**)**:① **`GET /api/v1/admin/backthink` 的行 `trigger` 新增取值 `"self_wake"`**(= 这一轮是**她自己排起**的思考,`design/12 §N C`)—— `trigger` 的取值域**不封闭**(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮 = 事件主题 / `self_wake` 自排),客户端**按字面值显示、不要当闭集校验**(见 §4.10(d))。⚠️ **可达性**:她的入口工具 `request_think` **尚未接线** ⇒ 该取值与 ② 的动词在生产里**目前不会出现**(形状先登记)。—— ✅ **本条已过期,已由 v0.82 ④ 改正**:`request_think` 已接线(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`),生产里**会真的出现**。② **新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`,经既有 `GET /api/v1/admin/observability/audit`(B9)可查,`who` 恒为 `backthink`(加性,见 §4.10(d))。③ **时间轴新增 `retentionBoundaryReached`(bool)并与 `truncated` 分意**:`truncated:true` **只**表示「本次不是全部」(分页未到底 **或** 落盘源未接线),**不能**据此断言「更早的轮次已被保留期清扫」——那件事由 **`retentionBoundaryReached:true`** 单独回答(判据三条缺一不可 + **三个盲区如实登记**,见 §4.11.1(a)(b)-3)。④ **`round` 跨重启不再重复**(发布器先从落盘播种计数器):修的是「重启后轮次号从 1 重来 ⇒ 时间轴把重启前后两轮并成一块」的真机缺陷,契约里「`round` 每会话单调」这句**从此真的成立**(见 §4.11.1(e))。⑤ **计数**:本版**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 段内 B1–B18 共 **18** 条)。⚠️ **同批对客户端零形状影响(如实说明)**:技能渐进披露接线(L2 `6475eb8`)、示例技能与端到端验收(V3 `4a04ad4`)、浏览器 B1 接线 + B2 薄层工具面(V5 `f1fbc24`)**都不改变任何 REST / WS / admin 形状**。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面(`GET /admin/panels` 多一条目录项 + 六个动作走既有 A14),见 **§4.14(d)**。⚠️ **③④ 的形状取自切片 X1 的工作树实现,登记时尚未提交** ⇒ 提交后请按 `git show` 复核键名与判据。
|
||
|
||
> **v0.80 是三批增量版(均**已实现**,**纯增量、无破坏性变更**)**:① **链路图时间轴出口**(切片 W1,`design/19 §7`)—— 新增 1 条管理只读端点 **`GET /api/v1/admin/flow/timeline`**(§2.2 B18 / §4.11.1),把「链路图」从「**最近一轮** + 已落盘轮次的**键列表**」扩到**按时间正序的多轮时间轴**(每轮含该轮**每一路**),支持 `limit`(缺省 **50** / 硬上限 **200**)与 `before` 游标向**更早**翻页;响应另给 `retentionHours` / `retentionEnabled` / `truncated`,供面板**如实**标注「更早的轮次已过保留期」。修的是主人 2026-10-06 的原话「web 后台的全链路流程图我要的是**所有消息按时间顺序串起来**的流程图,而不是只有最新一个来回」。老端点 `GET /api/v1/admin/flow/graph`(B13)**逐字未改**(老客户端零改动)。② **搜索 provider 热重载 + 主 provider / 降级链**(切片 W3;裁决 2026-10-06 #1 / #2)—— `plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(`registry.ReplaceTool`,保留运行期策略)⇒ **下一轮对话即用新配置、不需要重启**;若这次改动会让 web-search **没有可用后端**(下次启动会 fail-fast)⇒ **文件按字节回滚** + **`409 config_unstartable`**。同一次提交可带两个**文件级**字段 `primary` / `fallback[]`(缺位 = 不改动);响应新增机器可读事实 **`hotReload:{applied,registered}`**;新增错误码 **`primary_not_configured`(400)** / **`config_unstartable`(409)**。🔴 **本版把三处已成假话的旧口径改正**(§4.14(b)(c) / §7.2)—— 此前写的「面板保存后**不**热替换运行中的工具、需重启」与「面板**无**主 provider / 降级链字段」**都不再成立**。⚠️ **未确证(如实)**:真机面板上「新增/改一家 provider → 下一次搜索真的走它」的端到端、以及**真机探针打真实上游**,都没跑。③ **入站语音 AMR 也可回放**(切片 W4;裁决 #11)—— 组合根补齐 `channelbridge.Options.VoiceTranscoder` 注入(`cmd/cyrene/wire_channels.go`)⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`,见 `internal/runtime/voice/playable.go`),客户端从此能拿到可回放的字节;此前走 `category=transcode_not_wired` 的诚实 WARN、**不落副本**。**形状零变化**(DTO `voice` 的键集合与语义一字未改;可播格式仍**原样落、一次转码都不发生**)。🔴 **同批改正 §4.5 / §7.1 / §7.2 / §7.4 里已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**(`transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档;旧版本横幅保留当时状态,另加「已由 v0.80 修复」注)。⚠️ **未确证(如实)**:③ 的**生产端到端未跑**(红线:不重启后端)⇒ 本期证据是 `channelbridge` 的行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**。详见 **§6**。
|
||
|
||
> **v0.79 是「面板动作真可用 + 记忆审查/语音用户可见面 + 链路图回合标识全覆盖」版(四批,均**已实现**;**唯一一处形状变化**)**:① **插件面板动作真可用**(切片 L1/PLG-2 的执行面接线)—— `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501**:web-search 的四个动作 + 下拉候选来源(`plugin.providers`)都已登记**生产处理器**,**真读写** `$DATA_DIR/config/plugins/web-search.json`、探针**真发一次 HTTP**;失败回**结构化非 2xx**(错误码/文案/状态由插件侧给出,宿主只透传),新增 `invalid_payload` / `unknown_provider` / `not_configured` / `config_unreadable` / `config_write_failed` 与一批探针码(502);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。审计新增附加字段 `code`(**不进 DB 记录体、不含请求体**)。详见 **§2.1 A14 / §4.1 / §4.14 / §7.2**。② **「入库前审查」(D3)的管理面落地**(切片 L45)—— 新增 2 条 `writeAuth` 端点 **`GET /api/v1/admin/memories/reviews?limit=`**(留痕列表,**不含记忆正文**;`limit` 缺省 100 / `≤0` 同缺省 / >500 钳位 / 非整数 `400`)与 **`POST /api/v1/admin/memories/{id}/revert`**(撤销一次软合并,**无损可逆**,落审计 `memory.review.revert`);未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕)。详见 **§2.2 B16/B17 / §4.1**。③ **语音消息的用户可见面落地**(切片 L45)—— 消息 DTO 的 **`voice`** 现在真的填充(REST 读取面 + `message_edited`),新增 **`GET /api/v1/media/{name}`**(200/206/304/416/`HEAD`;`audio/*` 内联 + `nosniff` + `Accept-Ranges`;**无 `ETag`**;名字非法 / 穿越 / 缺失统一 `404 media_not_found`),副本真落 `$DATA_DIR`(`documents.SaveAudio`,内容寻址、0600)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**;组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **AMR 这一项已由 v0.80(切片 W4)修复**。详见 **§1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5 / §7.2**。④ 🔴 **链路图的权威回合标识覆盖到全部编排回合**(切片 CODE-TC-2)—— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)的图现在都带权威 `turnId`,取值 = 该轮消息行的 `chat_messages.turn_id` ⇒ `GET /sessions/{id}/messages` 的 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)⇒ 客户端**一律使用响应里给出的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍整体缺席;**v0.79 之前的单路历史行不回填**。详见 **§4.4 / §4.11 / §7.2 / §7.4-23**。**无破坏性变更**(新端点 / 新可选键 / 新错误码;老客户端若不自己拼 `graph.id` 则零改动)。
|
||
|
||
> **v0.78 是六面增量版(⚠️ **①~⑤ 已实现;⑥ 只到落库面**(用户可见面**已由 v0.79 补齐**);以纯增量为主,无破坏性变更)**:① **消息读取面 `toolCalls[]` 落地**(切片 CODE-TC)—— v0.76 ① 从「未实现」改为**已实现**(数据源 `flow_graphs` + 迁移 `0023` 的**按路查询键** `lane_turn_id`);🔴 **v0.78 时覆盖率受限(如实)**:只有**真并行路**的图带权威 `turnId`,普通单路回合**查不到图 ⇒ 字段缺席**(契约允许,但不是想要的覆盖率)—— **已由 v0.79 补齐**(见 §6 v0.79 ④)。详见 **§4.4 / §7.2 / §7.4-23**。② **插件管理面板接入点(管理面新增两条路由 + 一条 WS 帧)**(切片 PLG-2)—— 新增 `GET /api/v1/admin/panels`(**按调用者角色过滤**后的接入点快照,viewer 可读)与 `POST /api/v1/admin/plugins/{id}/actions/{action}`(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 `writeAuth` + 审计);WS 下行帧 **`panels_changed`**(插件生命周期变化后推,**只发 owner/admin**)。⚠️ **动作执行器在 v0.78 无生产实现 ⇒ 当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。详见 **§2.1 A13/A14 / §3.4.5 / §4.14**。③ **搜索 provider 可变源**(切片 PLG-4)—— 插件 `web-search` 的配置从 `.env` 迁到 `$DATA_DIR/config/plugins/web-search.json`(0600,文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` 四个动作。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面)。详见 **§4.14(c)**。④ **账号上下线落事件 + 渠道事件实时帧**(切片 E7)—— 账号 online/offline 迁移落进 `internal/runtime/events`(复用迁移 `0021`,`tier=t2` / `category=lifecycle`),并经**既有进程内 bus** 推成 WS 下行帧 **`channel_event`**(**只发 owner/admin**,viewer 走 REST 只读档)。⚠️ **当前只覆盖账号状态迁移这一路**;覆盖其余全部渠道事件的装饰器**未接线**(见 §7.2)。详见 **§3.4.5**。⑤ **微信 iLink 账号状态三态**(切片 CODE-F3)—— `GET /api/v1/admin/channels/{platform}/login-status` 新增可选 `authState` / `authDetail`(**有效 / 被拒 / 未知**);🔴 **`errcode=-14` 是「被拒」不是「过期」**,**未知同样不许写成「过期」**。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。详见 **§2.4(c)**。⑥ **语音消息落库面**(切片 CODE-VOICE)—— 迁移 `0020` 给 `chat_messages` 加 `voice` 列(`TEXT NOT NULL DEFAULT ''`);**用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);残留缺口(WS 实时帧 / 握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 **§4.5 / §7.2**。**无破坏性变更**(新端点 / 新可选键 / 新帧;老客户端零影响)。
|
||
|
||
> **v0.75 是记忆整理试算版(已实现,纯增量)**:新增 1 条管理端点 **`POST /api/v1/admin/memories/tidy/dry-run`**(**只读语义的 POST**,`writeAuth` = owner/admin)—— 跑**同一套**记忆整理规则(召回门 + 包含判定 + 规则否决,与低峰定时轮共用代码路径),把候选**直接回在响应里**,**一行都不写**(不写 `memory_tidy_candidates` / `memory_tidy_runs`,绝不改 `memory_items`)。修的是「整理器只在 23:00–07:00 跑(design/23 §4),在它第一次开跑前面板只能空着」——主人要「现在就想看到在 736 条上它会建议合并哪些」。**零副作用是硬约束**(后端有逐行快照单测钉死);失败**一律如实作答**(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout`),**绝不回空候选数组假装「没有建议」**。**无破坏性变更**(新端点;既有端点/帧一字未改)。详见 **§2.2 B14 / §4.12**。
|
||
|
||
---
|
||
|
||
## 0. 基础约定
|
||
|
||
### 0.1 基址与版本
|
||
|
||
- **Base URL**:`http://<host>:<PORT>`。`PORT` 默认 **8080**(`env:"PORT" default:"8080"`);生产一般经反代/HTTPS,运行实例可能自定义端口。
|
||
- **框架唯一对外端口**:单二进制、单端口(运行实例实测 `:10942`)。REST、WS、管理 API、静态资源**全在同一端口**。🆕 **2026-10-07 口径补充(v0.88)**:这条「单端口」约束**只约束 Cyrene 框架自身**;**扩展插件**(渠道适配器 / 工具 / MCP server / 插件自拉的本地伴随服务)**可以开自己的监听端口、可以有自己的进程形态** —— 那些端口**不在本契约范围内**(客户端一律只连这一个网关端口)。原「`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 206 304 400 401 403 404 409 413 415 416 429 500 501 502 503`。
|
||
- ⚠️ **`502` 是 v0.78 引入**(插件动作执行失败 ⇒ `POST /admin/plugins/{id}/actions/{action}` 的 `action_failed`,见 §4.14(b));其余状态码集合未变。
|
||
- ⚠️ **`206` / `304` / `416` 是 v0.79 起「实际使用」**(`GET /api/v1/media/{name}` 落地,见 §1.2 U19 / §4.5):`206`(`Range` 部分内容)/ `304`(条件请求)由 Go 标准库给出;`416`(`Range` 语法非法或范围不可满足)是**非信封**的 —— 标准库直写 `text/plain` + `Content-Range: bytes */<size>`,**不套** `{"error":…}` ⇒ 客户端**不要**拿 `error.code` 去解析 `416`。
|
||
- **客户端按 `error.code` 分支,不要按 `message` 文本**(文案会随修改变动)。
|
||
|
||
### 0.4 三档标注惯例
|
||
|
||
本文档与「变更记录」用三档描述每次变更对客户端的影响:
|
||
|
||
- **纯增量**:只新增端点 / 帧 / 可选字段 / 取值。老客户端不读新键时**行为逐字不变**。
|
||
- **行为变更**:既有端点/帧的语义或响应面**变了**,客户端需改处理(如新状态码、新时点)。
|
||
- **破坏性**:既有字段/形状被移除或改义,老客户端会坏。
|
||
|
||
### 0.5 未接依赖时的诚实边界(fail-visible)
|
||
|
||
后端某些端点依赖注入的存储/服务;未接线时**明确报错或退化为诚实空结果**,绝不返回假数据:
|
||
|
||
| 依赖 | 未接线时的行为 |
|
||
|---|---|
|
||
| `HistoryStore` | `GET /sessions` 回退到渠道会话视图;`POST /sessions`、`GET /sessions/{id}/messages`、`PATCH/DELETE /sessions/{id}`、`POST /sessions/{id}/read`、`PATCH/DELETE /messages/{id}` → `501 not_implemented` |
|
||
| 语音服务 | `POST /audio/*` → `500 voice_unavailable`;WS `voice_input` → `error` 帧 `{"message":"voice unavailable"}` |
|
||
| 渠道事件存储(`internal/runtime/events`) | `GET /admin/channels/events` 与 `.../events/counters` → `500 channel_events_unavailable`(**v0.67 已实现**,见 §2.4(a);绝不回全 0 假汇总) |
|
||
| LLM usage 出口 | `GET /admin/llm/usage` → `500 usage_unavailable`(绝不回全 0 假汇总) |
|
||
| 活跃 SQLite 连接 | `GET /admin/system/storage` → `500 storage_unavailable` |
|
||
| 可观测日志缓冲 | `GET /admin/observability/logs` → `501`(组合根注入前) |
|
||
|
||
---
|
||
|
||
## 1. 用户侧 REST 端点
|
||
|
||
### 1.1 公开端点(无需认证)
|
||
|
||
| # | 方法 | 路径 | 状态码 | 要点 |
|
||
|---|---|---|---|---|
|
||
| P1 | GET | `/api/v1/health` | 200 | 存活探针 → `{data:{status:"ok"}}` |
|
||
| P2 | POST | `/api/v1/auth/login` | 200 / 400 / 401 / 500 | body `{username,password}` → `{data:{token,expiresAt,role,refreshToken?}}`。access 24h;`role` 恒 `owner`;`refreshToken` 仅在接了 refresh 存储时出现(`AUTH_REFRESH_TTL` 默认 720h)。凭据错 → `401 unauthorized`。 |
|
||
| P3 | POST | `/api/v1/auth/refresh` | 200 / 400 / 401 / 500 | body `{refreshToken}` → 新 access + 新 refresh(默认**单次使用轮换**)。未知/已吊销/过期 → `401 invalid_grant`。`AUTH_REFRESH_ROTATE_DISABLED=true` 时旧 token 保持有效、不返回新 refresh。未接存储 → `500 refresh_unavailable`。 |
|
||
| P4 | POST | `/api/v1/auth/logout` | 200 / 400 / 401 / 500 | 带 `{refreshToken}` → 撤单份 `{data:{revoked:bool}}`(幂等,未知也 200);带 Bearer 且无 body → 撤该账号全部 `{data:{revoked,revokedCount}}`(**两种响应形状不同**)。两者都无 → `400`。 |
|
||
|
||
> 登录/刷新/登出**公开**是刻意的:非透明 refresh token 本身就是凭据,故过期 access 的客户端仍能轮换/吊销它。它们对非法 token **fail-closed**。
|
||
|
||
### 1.2 用户端点
|
||
|
||
| # | 方法 | 路径 | 鉴权 | 状态码 | 要点 |
|
||
|---|---|---|---|---|---|
|
||
| U1 | POST | `/api/v1/chat` | owner/admin | 200 / 202 / 400 / 409 / 500 / 503 | 主对话入口,见 [§4.6](#46-chat-请求与响应面)。body `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?,stream?}`。 |
|
||
| U2 | GET | `/api/v1/sessions` | 任意已认证 | 200 / 500 | 会话列表 `{data:[{id,type,title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]}`。**排序:`pinned DESC, (type='main') DESC, created_at ASC`** —— 置顶的次级会话会排在主会话之前,**勿假定 index 0 是主会话**。主会话 `id="web"`、`type="main"`;子会话 `web:<uuid>`、`type="sub"`。history 未注入时回退渠道会话视图。 |
|
||
| U3 | GET | `/api/v1/personas` | 任意已认证 | 200 | 角色目录 `{data:[{id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}]}`。`mood` 为空 = 该角色暂无被跟踪情绪(不伪造)。**不含** admin 专属 `thinking` 旋钮。 |
|
||
| U4 | GET | `/api/v1/meta/enums` | 任意已认证 | 200 | 共享枚举权威表 `{data:{platforms,moods,modes,unknown}}`;每项 `{id,label}`。`unknown` 恒 `"display-raw"`。**未知取值原样显示,不要丢弃/报错**(服务端新增取值是非破坏性变更)。详见 [§4.7](#47-枚举权威表)。 |
|
||
| U5 | POST | `/api/v1/audio/transcribe` | **owner** | 200 / 400 / 500 | STT。body `{audio,format?,deviceId?}` → `{data:{text, provider?}}`。`provider` 只在结果为空/降级时出现(如 `"silent"`)。空 audio → `400`;未接语音 → `500 voice_unavailable`;识别失败 → `500 stt_failed`。**owner-only + 审计**。 |
|
||
| U6 | POST | `/api/v1/audio/speech` | **owner** | 200 / 400 / 500 | TTS。body `{text, voice?, format?, instruction?, sessionId?}` → **`200` 原始音频字节**(**非 JSON 信封**)。🔴 **v0.96 起发音人按 `sessionId` 的生效人格解析**(会话绑定 → 全局默认人格 → 空 ⇒ 配置的 `default`),**省略 `sessionId` 不等于没有角色**;请求体 `voice` 接受但不生效;产物恒 WAV,实际格式按字节嗅探(见 §6 v0.96)。空 text → `400`;未接语音 → `500 voice_unavailable`;失败 → `500 tts_failed`;人格无映射 → `500 tts_persona_unmapped`;连生效人格都没有且没配 default → `500 tts_no_character`;静默空音频 → `500 tts_empty` 且响应头 `X-Voice-Provider: silent`。**owner-only + 审计**。 |
|
||
| U7 | POST | `/api/v1/audio/voice-clone` | **owner** | 200 / 400 / 500 | 声音复刻建音色。body `{url,prefix?}` → `{data:{voiceId}}`。参考 URL **绝不进日志/审计**。空 url → `400`;失败 → `500 voice_clone_failed`。 |
|
||
| U8 | POST | `/api/v1/upload` | owner/admin | 201 / 400 / 413 / 415 / 500 | **图片上传**,`multipart/form-data` 字段 `file`(光栅图,**单文件字节 ≤ 5 MiB**,即 `maxUploadBytes = 5 << 20`)→ `{data:{url,size,mime,name}}`。`url` 是**绝对同源 URL**,可直接作 `chat.image`(该路径的 5 MiB 在**上传时**定死,与 `chat.image` 的字符长度判据无关,见 [§4.5](#45-图片--文档--语音链路))。嗅探裁决 png/jpeg/gif/webp/bmp(拒 SVG/HTML);原始文件名丢弃,用 128-bit 随机名。缺文件/空文件 `400`、超限 `413 payload_too_large`、非白名单 `415 unsupported_media_type`。**带审计**。 |
|
||
| U9 | GET | `/api/v1/files/{name}` | **无需**(能力 URL) | 200 / 404 | 上传文件读取。`name` 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝。**GET 不鉴权**是该 URL 为 128-bit 随机能力地址(`<img>`/provider 无法带 token);任何持有 URL 者可取,勿外泄。 |
|
||
| U10 | POST | `/api/v1/documents` | owner/admin | 201 / 200 / 400 / 413 / 415 / 500 | **文档上传**,`multipart/form-data` 字段 `file`。首次 `201 {data:{name,url,mime,size,sha256,deduped:false}}`;**同字节同扩展名重复上传 → `200` + 同 `name` + `deduped:true`**(可当成功)。单文件上限 `CHAT_DOCUMENT_MAX_BYTES`(默认 20 MiB,`≤0` 用默认)。见 [§4.5](#45-图片--文档--语音链路)。 |
|
||
| U11 | GET | `/api/v1/documents/{name}` | **owner/admin(必须 Bearer)** | 200 / 404 | 文档读取。与图片读侧**刻意相反**(必须鉴权)。响应 `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`,**永不内联渲染**。名字非法/穿越/不存在统一 `404 document_not_found`。 |
|
||
| U12 | GET | `/api/v1/sessions/{id}/messages` | owner/admin | 200 / 400 / 404 / 500 / 501 | 热分页历史。query `?limit=&before=&after=&platform=&q=`,见 [§4.2](#42-分页与游标)。**会话不存在/已删除 → `404 session_not_found`**;**存在但无消息 → `200 {"data":[]}`**。 |
|
||
| U13 | POST | `/api/v1/sessions` | owner/admin | 201 / 400 / 501 | 新建子会话。body 可选 `{personaId?}` → `{data:{sessionId,personaId?}}`(`sessionId="web:<uuid>"`)。未知 personaId → `400`。**会话角色创建时固定、之后不可变**(见 U15)。 |
|
||
| U14 | DELETE | `/api/v1/sessions/{id}` | owner/admin | 200 / 403 / 500 / 501 | 删除子会话及其消息 → `{data:{deleted:<id>}}`。主会话不可删 → `403 forbidden`。 |
|
||
| U15 | PATCH | `/api/v1/sessions/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 会话元数据。body `{title?,pinned?}`(**至少一个**)。`title` trim、≤80 rune、空/纯空白 → `400 "title must not be blank"`(**空串不能清空标题**)。**`personaId` 键出现即 `400`**(任何取值,含 `null`/`""`),且**先于**会话存在性检查(未知会话 + personaId 也回 `400`)。响应只回显本次变更字段 `{data:{id,title?}}` / `{id,pinned?}`。 |
|
||
| U16 | POST | `/api/v1/sessions/{id}/read` | owner/admin | 200 / 400 / 404 / 500 / 501 | 推进已读游标。body `{before?}`(毫秒;缺省=现在)→ `{data:{sessionId,readAt}}`。游标**会话级单所有者、只进不退**(读一处 = 该账号所有端已读)。成功广播 `session_read` 帧;失败不广播。 |
|
||
| U17 | PATCH | `/api/v1/messages/{id}` | owner/admin | 200 / 400 / 404 / 500 / 501 | 编辑单条消息。body `{content}`(非空、≤4000 rune)→ **`{data:<消息 DTO>}`**(`data` 直接就是完整消息,**不再包一层 `message`**)。只改 `content`。成功广播 `message_edited`。 |
|
||
| U18 | DELETE | `/api/v1/messages/{id}` | owner/admin | 200 / 404 / 500 / 501 | 删除单条消息 → `{data:{deleted:<id>}}`。成功广播 `message_deleted`。 |
|
||
| U19 | GET | `/api/v1/media/{name}` | **owner/admin(必须 Bearer)** | 200 / 206 / 304 / 401 / 403 / 404 / 416 | **语音副本播放 / 取字节**(**v0.67 登记,v0.79 已实现**,切片 L45)。`Content-Type: audio/*`(按**存储名扩展名**映射,不查库、不嗅探);`Content-Disposition: inline; filename="<name>"`;`X-Content-Type-Options: nosniff`;`Accept-Ranges: bytes`;`Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**(条件请求走 `Last-Modified`);支持 `Range` 与 `HEAD`;名字非法 / 穿越 / 不存在统一 `404 media_not_found`。⚠️ **`416` 不是 JSON 信封**(标准库直写 `text/plain` + `Content-Range: bytes */<size>`)。详见 §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) | **14** | 非敏感投影:无凭据、无他人账号数据、无消息正文。**v0.78 新增两条**:面板接入点快照(A13)与插件动作分发(A14,**写门落在 handler 内**,见 A14 行注) |
|
||
| **owner/admin 读档** | `writeAuth`(owner/admin) | **31** | viewer 不得看到的一切:记忆、LLM 栈、账号/key、后台思考、审计链、渠道消息日志、完整会话导出、配置事实面(v0.68)、最近一轮链路图快照(v0.71)、记忆整理试算(v0.75)、入库前审查留痕与回滚(**v0.79**,B16/B17)、链路图时间轴(**v0.80**,B18)、自排闸只读快照(**v0.82**,B19)、记忆整理应用面五条(**v0.83**,B20–B24)、**按动作批量拒绝候选(v0.85,B25)**、**记忆整理调优键读写(v0.92,B26/B27)**、**渠道非消息事件与静默窗口四条(v0.95 补登,B28–B31)** |
|
||
| **owner-only 档** | `ownerAuth`(owner) | **40** | 带审计的策略写、每个获取凭据的渠道端点、其余全部变更 |
|
||
| **合计** | — | **85** | `14 + 31 + 40`(v0.95) |
|
||
|
||
**管理路由总数 = 14 + 31 + 40 = 85 条(`routes.go` 逐条枚举;补齐 B28–B31 后与代码逐条相等)。**
|
||
> 🔴 **本节此前的「18 + 43 + 43 = 104」是一个口径错误,已改正**(2026-10-26 由切片 CONTRACTMATRIX 逐条复算):那三个数**不是本表三段的分组计数**,而是**整张表按中间件用量全文统计**的结果 —— `writeAuth` 全文 **43** = 本表 **31** + 用户面 12(`chat` / `upload` / `documents` / `media/{name}` / `history` 等);`ownerAuth` 全文 **43** = 本表 **40** + `audio/*` **3**;而「`viewerAuth` 18」**根本不是 viewerAuth**(它全文就是 **14**,且全部是本表路由),18 是「用户面被中间件包住」的条数(12 + 3 + 3)。把 18 与两个 43 相加 ⇒ 用户面那 18 条被**重复计入一次**。正确口径:被四种中间件包住的路由 = **85(管理)+ 18(用户面)= 103**,另有 6 条未包(4 条公开 + `GET /api/v1/files/{name}` + `/ws/chat`),字面量注册合计 109,再加 `mountWebhooks` 一条动态注册 ⇒ `grep -c` 得 110。
|
||
> ⚠️ 同理,此前「差 23 条遗漏行」也是巧合:**23 恰好是用户面路由总数**(§1.1 的 P1–P4 共 4 条 + §1.2 的 U1–U19 共 19 条)—— 用户面本来就一条不缺。真实缺口是 **4 条**(全在 `writeAuth`),已补为 B28–B31。
|
||
> ✅ **v0.94(MCP 管理页 + 按上游分节的 `origin`)与 v0.95(coalesce 两端点 + 窗口语义)的形状级条目已登记**,见 §6 v0.94 / v0.95。
|
||
> ✅ **本表现与 `routes.go` 逐条相等**(A14 / B31 / C40 = 85),此前的「已知文档债」警告已失效、已删除。反向也核过:81 行旧条目里**没有一条**是幽灵行,且**零条分档错误**。⚠️ 唯一一处**文档已自行标注**的差异是 B17(代码 `writeAuth`,`design/23 §11` 对同族标 owner-only)—— 那是对**设计文档**的差异,不在本节范围。
|
||
|
||
> **v0.78 计数变更**:`viewerAuth` 段新增两条(A13 `GET /admin/panels`、A14 `POST /admin/plugins/{id}/actions/{action}`)⇒ 12 → **14**、总数 66 → **68**。A14 走 `viewerAuth` 中间件,但**写动作在 handler 内按 owner/admin 收紧**(路由中间件只能有一道门)—— 因此它**是**本表的一行,读写面却不同档,详见 §2.1 A14 与 §4.14。
|
||
|
||
> **v0.79 计数变更**:`writeAuth` 段新增两条(B16 `GET /admin/memories/reviews`、B17 `POST /admin/memories/{id}/revert`,切片 L45)⇒ 14 → **16**、总数 68 → **70**。⚠️ 同批落地的 `GET /api/v1/media/{name}`(§1.2 U19)是**用户侧**端点,**不**进入本矩阵。
|
||
|
||
> **v0.80 计数变更**:`writeAuth` 段新增一条(B18 `GET /admin/flow/timeline`,切片 W1)⇒ 段内**实有 18 条**、**管理路由总数实有 72 条**(`14 + 18 + 40`)。**本版一并回补了自 v0.76 起累积的 −1 记账滞后**(见下一段),故上表「条数」记 **17 → 18**、总数记 **71 → 72**。它只读、不审计,与同族的 B13(`GET /admin/flow/graph`)**同门同档**。
|
||
|
||
> ✅ **记账滞后已修正(2026-10-06,本版回补)**:按上一段「计数口径」那句(= 三张清单**列出的行数**)核,**§2.2 实有 18 行**(B1–B18)、**§2.1 实有 14 行**(A1–A14)、**§2.3 实有 40 行** ⇒ 逐行相加 **14 + 18 + 40 = 72**,与上表「条数」序列(`writeAuth` **18** / 总数 **72**)**已一致**(`routes.go` 交叉核对:admin 侧经 `writeAuth` 挂载 20 条,减去 §2.4(a) 按历史口径不计入本表的 `channels/events` 与 `.../counters` ⇒ **18**)。**根因**(S6 报告 §6.1 发现):v0.76 新增 B15 时**漏把条数 +1**,此后 v0.78 / v0.79 / v0.80 各版沿旧序列 +1 而未回补 ⇒ 那个 **−1 滞后从 v0.76 一路带到 v0.80 的登记值**。
|
||
> ⚠️ **上面 v0.78 / v0.79 两个计数段的绝对值仍沿用当时未回补的写法**(其**增量正确** —— `viewerAuth` 12 → 14、`writeAuth` 各版 +1 / +2 —— 但**绝对值比真实行数少 1**);**§6 里各版条目(v0.76 ~ v0.80)的同类计数同理**。按本文件「历史条目保留当时状态」的惯例**不逐版改数**,**以本表 + 三张清单的行数为准**。
|
||
|
||
> **v0.82 计数变更(实数,已按 `routes.go` 核对)**:`writeAuth` 段新增一条(**B19 `GET /admin/backthink/self-wake`**,切片 Y3)⇒ 段内 **18 → 19**、总数 **72 → 73**。三张清单的行数同步为 **§2.1 14 行**(A1–A14)/ **§2.2 19 行**(B1–B19)/ **§2.3 40 行**(C1–C40)。**核对方式(可复现)**:`internal/services/gateway/routes.go` 里经 `writeAuth` 挂载的 **admin** 路由**实有 21 条**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **19**,与 §2.2 的行数一致;`viewerAuth` 挂载 **14**、`ownerAuth` 挂载 **40**(逐条列举见 `grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*<middleware>'`)。⚠️ **本版另一个「看似该 +1、其实不动计数」的面**:`browser` 插件的六个动作(§4.14(d))**不开任何新路由** —— 它走**既有** A14 `POST /admin/plugins/{id}/actions/{action}`,因此 §2.1 的 14 与总数都**不变**(A14 那一行本就写明「写门落在 handler 内」,动作名由**声明**白名单决定,声明多一个插件不新增路由)。
|
||
|
||
> **v0.83 计数变更(实数,已按 `routes.go` 逐条核对)**:`writeAuth` 段新增**五**条(**B20–B24**:候选列表 / 单条应用 / 批量应用 / 单条回滚 / 手动真跑,切片 TIDY-1)⇒ 段内 **19 → 24**、总数 **73 → 78**。三张清单行数同步为 **§2.1 14 行** / **§2.2 24 行**(B1–B24)/ **§2.3 40 行**。**核对方式(可复现)**:`grep -c 'mux.Handle("[A-Z]* /api/v1/admin.*writeAuth' internal/services/gateway/routes.go` = **26**,按历史口径减去 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ **24**(`viewerAuth` **14** / `ownerAuth` **40**,与上表一致)。
|
||
|
||
> **计数口径(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))**未计入本表**——这是历史口径,本版未改,也**未**把它算进上表的 **writeAuth 条数**(v0.83 为 **24**)。逐条以代码为准。
|
||
|
||
> ⚠️ **与归档版不一致(已更正)**:`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 可读(14 条,`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(审计链 + 后台思考推送的只读投影) |
|
||
| A13 | GET | `/api/v1/admin/panels` | **插件管理面板接入点快照(v0.78 新增,切片 PLG-2)**:**按调用者角色过滤**后的导航条目集合 —— viewer 只拿到只读区块(`status`/`log`/`table`,且 table 的行内动作被摘空、`form` 整体不出现、区块全空的面板整条不出现)。响应 `{data:{role,groups[],pluginIds[]}}`。未接面板中枢 → **`501 panels_unavailable`**(**绝不回空列表**)。形状与两条给客户端的口径见 §4.14(a)。**v0.82 起目录里多一条内置页**:`browser` / `settings`(「浏览器与截图」,切片 Y4,见 §4.14(d))⇒ 声明接线后 `pluginIds` 为 `["browser","web-search"]`(**已排序**)—— ⚠️ **登记时该挂点仍在工作树、未提交**,因此**在跑的那份产物里没有它**(真机已核:`:10942` 上 `GET /admin/panels` 需 owner 凭据,但**启动日志全篇 0 条 `browser panel declared`** ⇒ 该面板未接线)|
|
||
| A14 | POST | `/api/v1/admin/plugins/{id}/actions/{action}` | **插件动作分发(v0.78 新增,切片 PLG-2)**。⚠️ **读写面不同档**:路由走 `viewerAuth`,但**写动作在 handler 内按 owner/admin 收紧**(`form.submit` / `table.rowActions` 是写;`table`/`log` 的 `source` 与下拉 `optionsFrom` 是读,对 viewer 开放)。**只接受该插件声明过的动作名**(未声明 ⇒ `404 action_not_declared`,**不是**「没权限」)。请求体可选 JSON(空体 ⇒ `payload=null`);**宿主不接受任何来自声明的请求地址**(不给插件开 SSRF 面)。✅ **v0.79:生产执行器已接线**(切片 L1/PLG-2)—— 内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` 都已登记生产处理器,**真可用**(真读写可变源、探针真发一次 HTTP);**只有「已声明但宿主未登记处理器」才回 `501 action_not_implemented`**(不再是兜底档)。错误码与响应形状见 §4.14(b)(c) |
|
||
|
||
> **A13 / A14 的角色门依据**(`routes.go` 紧邻注释):快照端点对 viewer 开放是因为它回的**本来就是被裁掉写面的只读投影**;动作端点的读来源动作对 viewer 可见,**写门落在 handler 内**(路由中间件只能有一道门)。`panels_changed` 帧(§3.4.5)**不进** `frameReachesAll` 白名单 ⇒ **viewer 收不到该帧**,需自行刷新/轮询重拉 A13。
|
||
|
||
### 2.2 owner/admin 读(31 条,`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**。⚠️ 每条的 `trigger` 是**开放的**取值域(`heartbeat` 周期心跳 / `startup` 启动轮 / 事件轮的**事件主题** / **`self_wake`(v0.81 登记)** = 她自己排起的一轮思考),客户端**按字面值显示**、不做闭集校验,见 §4.10(d)。🆕 **v0.82 新增可选 `?trigger=` 服务端筛选**(切片 Y3):取值域 = **闭集**(= 上面那些取值,真源 `backthink.TriggerFilterValues()`);**未知值或空值 ⇒ `400 invalid_trigger`**(**绝不静默忽略**);合法值**下推 SQL**,`limit` 数的是**匹配**条数。⚠️ **「筛选白名单是闭集」与「显示取值域是开放集」不矛盾**,见 §4.10(d)(e) |
|
||
| 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 新增,只读;v0.75 追加 3 键;v0.77 追加 1 键)**:记忆整理 **8** 键 + 后台思考心跳 3 键(`ENABLE_BACKGROUND_THINKING`/`THINK_INTERVAL_SEC`/`THINK_QUIET_INTERVAL_SEC`)+ 安静时段 `QUIET_START_HOUR`/`QUIET_END_HOUR` 的**进程生效值**,逐条带 `default`/`isDefault`。**不含任何密钥/令牌值**(连「是否已配置」都不回凭据类的)。形状见 §4.10 |
|
||
| B13 | GET | `/api/v1/admin/flow/graph` | **链路图快照 / 多轮历史回放(v0.71 新增;v0.73 增量;v0.77 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ **v0.73 新增 `?turnId=`**(省略 = 最近一轮,**先内存后落盘**)。响应 `{data:{sessionId,turnId,source,graph,lanes[],turns[]}}`:`graph` 是**一份 §3.4.5 `graph_update` 同形快照**,没有该轮时**如实为 `null`**(**绝不画假图**);`source` ∈ `live`(内存最近一轮)/`persisted`(落盘的历史轮)/`none`;**v0.77 新增 `lanes[]`**(**最近一轮每一路**的图,每项 `{turnId,graph}`,按路的开始时间升序,恒为非 null 数组 —— 真并行时这才是全貌,`graph` 只给最新一路),`graph` 的既有语义**一字未改** ⇒ 老读取方零改动;`turns[]`(`{turnId,createdAt}`,最新在前)供面板选历史轮(**v0.73**)。未接线图源 → `500 flow_unavailable`。语义见 §4.11 |
|
||
| B14 | POST | `/api/v1/admin/memories/tidy/dry-run` | **记忆整理「试算」(v0.75 新增,只读语义的 POST)**:跑同一套整理规则、把候选**直接回在响应里**,**零副作用**(不写任何表)。请求体可为空。响应 `{data:{dryRun:true,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated?,candidates[]}}`;`candidates` 恒为非 null 数组(空数组 = 「算过了,没有建议」,与「没算出来」的错误响应**必须能区分**)。失败:未接线 → `500 tidy_unavailable`、配置关闭 → `409 tidy_disabled`、语义召回不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`。**不审计**(只读计算)。语义与形状见 §4.12 |
|
||
| B15 | GET | `/api/v1/admin/memories/tidy/dry-run/{runId}` | **异步试算结果查询(v0.76 新增 · ✅ 已实现(v0.77 校正))**:配合 `POST …/memories/tidy/dry-run?async=1`(后者仍属 B14,`async=1` 时回 `202` + `{data:{runId,status:"running",startedAt}}`)轮询取回结果。响应 `{data:{status:"running"|"done"|"failed", result?, error?}}`:**`running` 时 `result`/`error` 两键都缺席**(绝不回一个空 `result` 冒充「跑完了但没有建议」);`done` 时 `result` 与 §4.12 同步响应的 `data` **逐字节同形**(**同一套 JSON,不是第二份**);`failed` 时 `error` 为 `{code,message}`(`code` 沿用 §4.12 的失败码)。⚠️ **结果只活在进程内存**(单二进制、零跨进程):TTL **30 分钟**(从**完成时刻**起算)、同时只允许 **1** 个在跑(**第二个请求复用既有 `runId`**,不引入新错误码);进程重启后旧 `runId` **如实失效** ⇒ `404 tidy_run_not_found`,**绝不假装还在跑**。鉴权与 B14 同档(`writeAuth` = owner/admin)、同样**不审计**。语义见 §4.12.1 |
|
||
| B16 | GET | `/api/v1/admin/memories/reviews` | **入库前审查(D3)留痕列表(v0.79 新增,切片 L45;✅ 已实现)**。`?limit=`:缺省 **100**、`≤0` 同缺省、> **500** 钳到 500、非整数 → `400 bad_request`(钳位不报错,与渠道事件查询面同一约定)。响应 `{data:[…]}`,元素键 camelCase:`id` / `itemId` / `personaId` / `scope` / `projectId`(可选)/ `decision` ∈ `skip\|merge\|modify\|create` / `reason`(可选,规则依据)/ `veto`(可选,命中的否决规则)/ `targetId`(可选,命中的既有条目)/ `mergedIds`(**恒为数组**)/ `score` / `recallMode`(可选)/ `degraded` / `ruleVersion` / `sessionId`(可选)/ `createdAt`(**UTC RFC3339**)。🔴 **不含记忆正文**(命中的 id 由客户端回表取原文)。未接线 → `500 memory_review_unavailable`(**绝不回空数组**假装没有留痕);读取失败 → `500 reviews_failed`。只读、不审计 |
|
||
| B17 | POST | `/api/v1/admin/memories/{id}/revert` | **撤销一次软合并(v0.79 新增,切片 L45;✅ 已实现;design/23 §8「回滚」)**。**无请求体**。200 `{data:{reverted:"<id>"}}`;该行不在 `merged` 状态(id 拼错 / 已回滚过)→ `404 not_found`;未接线 → `500 memory_review_unavailable`;存储失败 → `500 revert_failed`。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`,并从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1);**行永不物理删除**。**落审计** `memory.review.revert`(who / result ∈ `ok\|error`;**只记 item id,不记正文**)。⚠️ 门是 `writeAuth`(owner\|admin);`design/23 §11` 表格对同族 `candidates/{id}/revert` 标的是 owner-only —— **口径差异待主会话裁决**(若以设计为准,路由换成 `ownerAuth` 即可) |
|
||
| B18 | GET | `/api/v1/admin/flow/timeline` | **链路图时间轴 / 多轮串联(v0.80 新增,切片 W1;v0.81 增量)**,只读、不审计。`?sessionId=`(省略 = 主会话 `web`)+ `?limit=`(缺省 **50**、> **200** 钳到 200、非整数 `400 bad_request`、`≤0` 同缺省)+ `?before=`(**游标**:取开始时刻**严格早于**它的轮次;省略 = 最新那一页;取值 = 响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒,非法 → `400 bad_request`)。响应 `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionBoundaryReached,retentionHours,retentionEnabled}}`:`rounds` **按时间正序**(最早在前),每项 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形);`round` 为 `null` = **未知**(老行),`endedAt` 为空串 = **尚未收尾**(在飞轮);`source` ∈ `persisted`(本页至少一轮来自落盘)/`live`(本页全部来自内存)/`none`。🆕 **v0.81(切片 X1)**:① 新增 **`retentionBoundaryReached`**(bool)= 「该会话确有更早的轮次已被保留期清扫」——**与 `truncated` 分意**,只有它 `true` 时面板才可说「更早的轮次已过保留期」(判据/盲区见 **§4.11.1(a)**);② **`round` 跨重启不再重复**(发布器先从落盘播种计数器,见 **§4.11.1(e)**)⇒ 按 `round` 分组不再把重启前后两轮并成一块。未接线(内存 + 落盘都没接)→ `500 flow_unavailable`。语义与空态见 **§4.11.1** |
|
||
| B19 | GET | `/api/v1/admin/backthink/self-wake` | **自排闸只读快照(v0.82 新增,切片 Y3;design/12 §N C.5 的读侧)**,只读、不审计、不写库。**无参数**。响应 `{data:{available,running,reason?,configFile?,gate?}}` —— **三态必须可区分**(面板**不得**把「看不到」画成「0 次」):① `available:false` ⇒ 本进程给不出快照(后台思考未接线 / 实现不提供读口 / 闸不在),此时 **`gate` 缺席**、`reason` 说明原因(**HTTP 仍是 `200`**,不假装有一个 0/0 的闸);② `available:true, running:false` ⇒ 已接线但不在运行,`gate` **仍带**配置面数字(上限/窗口/代码默认值),`reason` 说明「此刻她排不出新的一轮」;③ `available:true, running:true` ⇒ 正常态,`reason` 缺席。`gate` 快照 = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`(`used` 与工具收据 `SelfWakeReceipt.Used` **同一口径**;`limit`/`windowSec` 是**进程生效**值,已 normalize)。🔴 **`limitIsDefault` / `windowIsDefault` 的语义**:报告生效值**恰好等于**代码默认值 —— **不是**「配置文件里没写这个键」的断言(网关**不读** `backthink.json`,与 config-facts 的 `isDefault` 同一条诚实口径:无从区分「配了 4」与「没配」)。`configFile` = **相对落点**(`config/backthink.json`,相对 `$DATA_DIR`)。⚠️ **它不含敏感信息、也不是写口**:改上限/窗口要改配置文件 + 重启(面板写路径**未做**)。语义与三态口径见 **§4.10(e)**。✅ **真机已核(路由存在性,无需凭据)**:`:10942` 上 `POST /api/v1/admin/backthink/self-wake` → **`405` + `Allow: GET, HEAD`**、`GET` 同路径无 token → **`401`**(负对照:同法打一个不存在的 admin 路径 → `404`)⇒ 这条路由**确在**运行中的产物里(⚠️ **不代表**已用真凭据拿到过 `data`;后者**未验**) |
|
||
|
||
| B20 | GET | `/api/v1/admin/memories/candidates` | **整理候选列表(v0.83 新增,切片 TIDY-1)**,只读、不审计。`?status=`(**省略 ⇒ 默认只回 `pending`**;落库取值是**闭集** `pending` / `applied` —— `revert` 会把候选退回 `pending`;⚠️ **`rejected` 尚未实现**,没有 reject 端点)+ `?limit=`(缺省 **100**、`>500` 钳到 500、非整数 → `400 bad_request`)。响应 `{data:{candidates:[…],count:N}}`,`candidates` **恒为非 null 数组**(空数组 = 「真没有候选」,与「没接线」的错误响应必须能区分)。每条:`id` / `action` ∈ `merge\|link\|archive\|reclassify` / `status` / `sourceIds[]`(恒为数组)/ `targetId?` / `personaId` / `scope` / `projectId?` / `kind?` / `proposedContent?` / `mergedContent?` / `reason` / `ruleVersion` / `runId?` / `scoreMin` / `scoreMax` / `createdAt`(**UTC RFC3339**)/ `evidence`。失败:未接线 → `500 tidy_unavailable`(**绝不回空数组**假装没有候选)、读取失败 → `500 tidy_candidates_failed`。语义见 **§4.15(a)** |
|
||
| B21 | POST | `/api/v1/admin/memories/candidates/{id}/apply` | **应用一条候选(v0.83 新增,切片 TIDY-1;design/23 §7/§8)** —— 唯二真正改记忆的粒度之一。**无请求体**。`200 {data:{candidateId,action,status,reason?,targetId?,affectedIds[]}}`:`status` ∈ `applied`(真改了)/ `skipped`(**幂等**:这条已被决定过 ⇒ 不是错误,界面不得报红)。🔴 **别把这个 `status` 与 B20 候选行上的 `status` 混读**:这里是**本次操作的结果**(`memory.TidyApplyStatus`,另有 `failed` 一档),B20 那个是**候选的落库状态**(闭集 `pending`/`applied`)。`affectedIds` = 这次真的被改动的 `memory_items` id(**恒为数组**)。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(`result` 以 `ok` 开头 + 动作/结果/受影响行数;**绝不带正文**)。🔴 **软动作**:merge = 被吸收行 `status='merged'` + `merged_into=<存活 id>`,存活行 `merged_from` 追加 + `merge_count`+1 + `confidence` 取较大者 + `updated_at` 刷新;archive = `status='archived'`;**正文一个字不改、行一行不删** |
|
||
| B22 | POST | `/api/v1/admin/memories/candidates/apply?runId=&limit=` | **批量应用候选(v0.83 新增,切片 TIDY-1)**。`runId` 给出 ⇒ 只应用**该轮产出的**候选;**省略 ⇒ 全部待定**(面板的「清全部」档)。`limit` 省略/空 ⇒ 领域侧默认 **100**;`>500` **钳到 500**;非整数或负数 → `400 bad_request`。`200 {data:{runId?,limit,total,truncated,applied,skipped,failed,merged,archived,reclassified,results[]}}`。🔴 **两条读数铁律**:① `total` = **真正命中筛选的待定候选数**,而 `applied+skipped+failed` 只覆盖 `limit` 之内 ⇒ 两者不等时 **`truncated:true`**(客户端**必须**如实显示「本轮处理 N / 共 M」);② `results[]` 逐条给 `status`(**失败隔离**:一条失败不拖累其余)。失败:`runId` 查不到(**留痕行**不存在)→ `404 tidy_run_not_found`(与「这一轮没有待定候选」**不是一回事**);未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_apply_failed`。**落审计** `memory.tidy.apply`(带 run_id / limit / total / truncated / applied / skipped / failed)。⚠️ **旧候选(TIDY-1 之前产出的 77 条)的 `run_id` 是空串** ⇒ 「按 run 批量」找不到它们,要清它们只能走**全部待定**这一档 |
|
||
| B23 | POST | `/api/v1/admin/memories/candidates/{id}/revert` | **回滚一条已应用的候选(v0.83 新增,切片 TIDY-1;design/23 §8「回滚」)**。**无请求体**。响应形状与 B21 **逐字同形**(`{candidateId,action,status,reason?,targetId?,affectedIds[]}`)。**无损可逆**:被吸收行复原为 `active` + `merged_into=''`、从存活行 `merged_from` 摘除、`merge_count` 减 1(下限 1)、候选退回 `pending`;**行永不物理删除**。失败:候选不存在 → `404 not_found`;未接线 → `500 tidy_unavailable`;写失败 → `500 tidy_revert_failed`。**落审计** `memory.tidy.revert`(与 B17 的 `memory.review.revert` **不同**:B17 按**记忆 id** 撤销 D3 的软合并,本条按**候选 id**,故不复用同一动词) |
|
||
| B24 | POST | `/api/v1/admin/memories/tidy/run` | **手动真跑一轮整理(v0.83 新增,切片 TIDY-1)** —— 不等 23:00 的 panic button:按**同一套规则与配置**立刻跑一轮,**写候选 + 写运行留痕**,**不自动应用**(人工在环,能落地的只有 B21/B22)。**无请求体**。`200 {data:{runId,trigger:"manual",ruleVersion,durationMs,partitions,scanned,compared,pairs,vetoed,errors,truncated[],vetoReasons{},candidatesCreated,candidatesDuplicate,candidatesByAction{}}}`。🔴 **如实计数**:`candidatesCreated` = 本次**新建**候选数、`candidatesDuplicate` = 被**幂等键**挡下的重复数(同一批数据重复跑 ⇒ 全进 duplicate,**不是失败**);`errors` 非 0 面板必须显示;`truncated[]` 是**被护栏截断**的分区标签(扫描/比对/候选上限,或墙钟预算 `[budget]`)。失败:配置关闭 → `409 tidy_disabled`;超时 → `503 tidy_timeout`(**已跑部分仍写入了留痕**);只接线了试算(没接真跑)→ `500 tidy_run_unavailable`;未接线 → `500 tidy_unavailable`;失败 → `500 tidy_run_failed`。**审计不在这里写** —— 由领域侧记一条 `memory.tidy.run`(`trigger=manual` + run_id + 计数),**一次点击一条**。语义见 **§4.15(b)** |
|
||
|
||
| B25 | POST | `/api/v1/admin/memories/candidates/reject?action=&limit=` | **按动作批量拒绝整理候选(v0.85 新增,切片 TIDY-AUTO)** —— 把某一类 `pending` 候选一次性标成 `rejected`(**幂等**:同一调用再来一次 ⇒ `total:0`;**只改候选 status,绝不碰记忆行**)。`action` **必填**、**闭集** `merge\|link\|archive\|reclassify\|keep`(未知/空 → `400 bad_request`);`limit` 缺省 **1000**、硬顶 **10000**、非整数 → `400 bad_request`。`200 {data:{action,limit,total,rejected,truncated}}`。🔴 **两个计数口径不同**:`total` = 命中筛选的**真**条数,`rejected` 只覆盖 `limit` 之内 ⇒ 不等时 **`truncated:true`**(客户端必须如实显示)。**落审计** `memory.tidy.reject`(只带计数)。用法见 **§4.15(e)**(`link` 停产后的存量清理就靠它)。⚠️ **不是「删除」**:候选行仍在,只是不再待办 |
|
||
| B26 | GET | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键读取(v0.92 新增,切片 M4)**,只读、**不审计**。**无参数**。`200 {data:{path,present,hot,hotError?,specs[],values{},envValues{}}}`。🔴 **三块分工(客户端按此编程)**:`specs[]` = **取值规则唯一真源**(`{key,kind,unit?,min?,max?,fallback}`;`fallback` 只给**来源** `env:<键名>` | `panel:<键名>` | `code-default`,**不给数值**);`values` = **盘上**的值,**键缺位 ⇒ 该键在对象里不出现**(= 跟随兜底),**不是 0**;`envValues` = 键缺位时**下一档(`.env`)**的进程生效值(连 `.env` 档也没有 ⇒ 该键不出现;网关绝不 fopen `.env`)。管 7 键:`intervalSec`/`quietIntervalSec`/`quietStartHour`/`quietEndHour`/`ingestGate`/`autoApply`/`tempTtlSec`;同文件不受管的键(`t1Judge` 等)不回显。🔴 **`hot` 恒为 false** —— 读不改变任何东西,这是**如实占位**,别读成能力声明。失败:数据目录未接线 → `500 tuning_unavailable`。语义见 **§4.17** |
|
||
| B27 | PUT | `/api/v1/admin/memories/tidy/tuning` | **记忆整理调优键写入(v0.92 新增,切片 M4)**。门同 B26(`writeAuth`)。请求体 = **扁平 JSON 对象**,只放要改的键;值 int(秒/钟点)或 bool;**`null` = 清除该键**(回到下一档兜底)。🔴 **三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**;**未知键(含拼错)也归「非法」,绝不静默忽略**。`200` 与 B26 **同形状**,但值是**写完重新读盘**的真值(带 `hot`/`hotError`)。错误码:`400 invalid_payload`(值非法/未知键/体不是对象/体是 `null`)|`409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)|`500 tuning_unavailable`|`500 tuning_write_failed`(落盘失败,盘上未改动)。**两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。🔴 **`hot` 诚实口径**:`hot:true` = 值已叠到运行期(整理档会重排心跳,否则新周期要等旧周期那一觉睡完);`hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「**重启后端后生效**」—— **绝不把「落盘了」说成「生效了」**。**落审计** `memory.tidy.tuning`(`result` ∈ `ok\|error`)。语义见 **§4.17** |
|
||
|
||
| B28 | GET | `/api/v1/admin/channels/events` | **渠道非消息事件列表(倒序)**,只读、**不审计**。此前只登记在 §2.4(a) D1,本条是它的矩阵行。query:`platform`(闭集 `qq`/`kook`/`telegram`/`weixin-ilink`/`webhook`,未知 ⇒ `400`,**绝不降级成「不过滤」**)/ `tier`(`t1\|t2\|t3`,其它 ⇒ `400`)/ `category`(🔴 **不做白名单**:类别轴的权威在适配器层且会持续增加,未知类别是一次**精确匹配**(返回空),不是 `400`)/ `channelId` / `since` / `until`(RFC3339 或 Unix 毫秒;跨度 > **90 天** ⇒ `400`;**两个都不给就不注入任何时间条件** —— 端点绝不替客户端造下界)/ `limit`(`≤0` 或缺省 ⇒ 存储层默认 **100**;> **500** 钳到 500 **不报错**;非整数 ⇒ `400`)/ `offset`(非法整数 ⇒ `400`,负值归 0)。响应 `{data:{events:[…],total}}`,`events` **恒为 `[]` 不是 `null`**。元素键见 §2.4(a)。**不含正文、不含媒体 URL、不含凭据**。未接线或读库失败 ⇒ `500 channel_events_unavailable`(**绝不回全 0 假汇总**)。⚠️ `weixin-ilink` 事实上无此类事件 ⇒ 对它查询恒为空,**不得用别平台事件顶替** |
|
||
| B29 | GET | `/api/v1/admin/channels/events/counters` | **渠道事件逐类计数(近似值)**,只读、**不审计**。此前只登记在 §2.4(a) D2。query 仅 `platform`(空 = 全部平台;未知 ⇒ `400`,与 B28 同一把校验)。响应 `{data:{approx:true,counters:[{platform,accountId,tier,category,platformType,count,firstSeenAt,lastSeenAt}]}}` —— `approx` 与 `counters` **同级**。🔴 **`approx` 恒为 `true` 且不是装饰**:计数是**内存聚合 + 每 30s flush**,读到的表**不含待 flush 部分** ⇒ 只少不多、是个**下界**;面板必须如实标注,别画成精确账。未接线或读库失败 ⇒ `500 channel_events_unavailable` |
|
||
| B30 | GET | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的读(v0.95)**,只读、**不审计**。此前只登记在 §6 v0.95,本条是它的矩阵行。**无参数**。`200 {data:{path,present,hot,default{…六键…},patch{},conversations{},rules{…},ownerPrivate:"never"}}`。🔴 **三块分工(客户端按此编程)**:`default` = **生效值**(代码默认 + 盘上 `default` 段,**已 clamp**);`patch` = **盘上原值**,**键缺位 ⇒ 该键不出现**(= 跟随默认,**不是 0**);`conversations` 的键是 `platform\|channel`(可读形态,NUL 已换成 `\|`),值是**生效值**。`hot` 恒为 `true`(桥每次发车前重读同一个 store ⇒ **这是事实,不是承诺**)。🔴 `ownerPrivate` 恒为 `"never"` = **只读事实**:主人私聊的车道类型由 actor 元数据判定,**没有任何请求键能翻转它**,面板只显示一行说明、不给开关。取值区间:`windowMs` **300–15000**、`maxWaitMs` 下界**跟随窗口**且 ≤ **30000**、`maxBatchMessages` **1–50**;越界是 **clamp + WARN,不是 `400`**。`path` 是 `$DATA_DIR/config/coalesce.json` 的落点(**零新 `.env` 键**)。未接线 ⇒ `500 coalesce_unavailable` |
|
||
| B31 | PUT | `/api/v1/admin/channels/coalesce` | **渠道静默窗口合并的写(v0.95)**,门同 B30(`writeAuth` = owner/admin)。请求体 `{default?:{enabled?,windowMs?,maxWaitMs?,maxBatchMessages?,applyToStrangers?,strangerWindowMs?}, conversations?:{"<platform>\|<channel>": {…} 或 null}}`。🔴 **三态(硬要求)**:键**缺位** ⇒ 不动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**(校验全部发生在落盘**之前**,是结构性的、不靠回滚)。**严格解码**:未知键(含拼错)与「一个以上的 JSON 对象」都归 `400 invalid_payload`,**绝不静默忽略**;**负数**同样 `400 invalid_payload`(越界的合法数字**不是** 400 —— 由运行期 clamp + WARN)。`default` 缺位 ⇒ 全局不动;`conversations` 里值为 `null` ⇒ **删掉该会话覆盖**(回到全局 default),没提到的会话一概不动。**值一个都没变时不写盘**(连 mtime 都不动)但仍回 `200`。`200` 响应与 B30 **同形状**,是**写完重新读盘**的真值。错误码:`400 invalid_payload`(未知键/负数/体形态错)|`400 bad_request`(读体失败)|`500 coalesce_unavailable`|`500 coalesce_write_failed`(落盘失败,**盘上未改动**)。**落审计** `channels.coalesce`(`result` ∈ `ok\|error`;⚠️ **空补丁也记一条 `ok`**) |
|
||
|
||
> **读取 `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`,见下注;**v0.90 起 `connected` 为真实状态**,并按需回可选 `pending`/`ready`/`reason`) |
|
||
| C20 | POST | `/api/v1/admin/channels/close` | 关闭渠道(v0.66 增 `accountId`,见下注;**v0.90 起 `connected` 取真实 `IsConnected()`**) |
|
||
| 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) 已实现**;v0.78 追加 **(c) iLink 凭据三态**;v0.90 追加 **(d) 渠道连接态三态**
|
||
|
||
> ⚠️ **本节两项都已落地**(账号在线状态 `52dac45`;渠道事件查询面本切片 E6):形状取自设计文档(`design/26 §4.5`、`§2.6`),落地时已按代码核对。**§2.0 的计数原本不含本节端点**;两个渠道事件端点已登记进 `routes.go`(`owner|admin` 档)。✅ **已并入矩阵(v0.95 补登)**:B28 = `GET …/channels/events`、B29 = `GET …/channels/events/counters`。🔴 **形状级真源从此以 B28/B29 为准**,下面 D1/D2 两行**保留仅作 v0.67 冻结版对照**,改端点时只改 B28/B29,别在四处一起改。
|
||
|
||
#### (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)。实现沿用了本契约所拟的名字(未改名)。
|
||
|
||
#### (c) `GET /api/v1/admin/channels/{platform}/login-status` 的账号项:`authState` / `authDetail`(**v0.78 新增,切片 CODE-F3,已实现**)
|
||
|
||
- 该端点响应新增**可选** `authState:string` + `authDetail:string`(`omitempty`):该账号**会话凭据**的三态与**如实的**中文说明。**无新端点、无新路由**(沿用既有扫码登录状态端点,`owner-only`,v0.39 起就有)。
|
||
- **取值是闭集**:`"active"`(**有效**:iLink 接受过当前凭据)|`"rejected"`(**被拒**:iLink 以 `errcode=-14` 拒绝/作废了这份会话凭据,**需要重新扫码绑定**)|`"unknown"`(**未知**:没有凭据,或有凭据但还没有一次被 iLink 接受的调用可以作证)。
|
||
- **与 `status` 是两件事**(本字段的全部理由):`status` 是**扫码登录流程**的状态机(`idle`/`waiting`/`confirmed`/`failed`/`need_verifycode`);`authState` 是**凭据在 iLink 侧是否有效**的结论。一次 `confirmed` 的扫码只说明「拿到了凭据」—— 它是否被接受要等一次成功的调用(那时 `authState` 才从 `unknown` 变 `active`)。因此**允许**出现 `status:"confirmed"` 且 `authState:"unknown"`:**这是事实,不是矛盾**。
|
||
- 🔴 **`errcode=-14` 是「被拒」不是「过期」**:iLink 的 bot token 是**长期凭据**(实测一个月前扫码得到的 token 仍可用);`-14` 表示 iLink 不再接受这个会话(已解绑 / 在别处重新登录 / 协议层拒绝),**不是时间性过期**。`authState:"rejected"` 的说明只说「已被 iLink 拒绝,需要重新扫码绑定」。**未知同样绝不许写成「过期」**(也不许写成「未连接」)—— 服务端文案已由测试钉死(响应体全文不得出现「过期」)。
|
||
- **键缺席 ≠ `unknown`**:适配器**没有这条状态面**时两个键**缺席**(缺席 = 该账号没有这个状态面);`"unknown"` 是**值**,表示**有状态面但尚未确认** —— 面板必须能区分这两件事。
|
||
- 数据源 = 适配器的**可选能力接口** `SessionStatus() (state, detail string)`(`plugins/channels/weixin_ilink/session_status.go`),网关经类型断言读取(`internal/services/gateway/admin_channel_status.go` 的 `sessionStatusAware`,穿 `namedAdapter` 包装)。**当前只有 `weixin-ilink` 会带这两个键**;其它平台字段缺席,行为一字不变。
|
||
- `authDetail` **绝不含任何凭据**。
|
||
- **行为变更 / 破坏性**:无(两个键可选、纯增量;`status` / `botId` / `botToken` / `err` 与 v0.39 起逐字一致)。
|
||
- 🔴 **同域但未实现的面**:`GET /api/v1/admin/channels` 的 `accounts[]` 元素**尚未**带**每账号** `authState`/`authDetail`(缺 `AdminChannelAccount` 两字段与合并器的接线)—— 见 §7.2。
|
||
- ⚠️ **未确证(真机)**:真实 `errcode=-14` 至今**从未在日志里观测到** ⇒ `rejected` 分支只有单测 / httptest 证据,**没有真机证据**(见 §7.4-26)。
|
||
|
||
#### (d) 渠道连接态三态:`pending` / `ready` / `reason`(**v0.90 新增,已实现**)
|
||
|
||
- **出现位置**:`POST /api/v1/admin/channels/open` 与 `.../close` 的响应体、`GET /api/v1/admin/channels` 的**平台行**与 `accounts[]` **账号行**(三处同名同义)。
|
||
- **三个键都是可选的,取不到就整键缺席**(同 `needsRescan` / `lastError` 口径):`connected:true` 时,或适配器没有这条状态面时,`pending` / `ready` / `reason` 一律不出现。
|
||
- `reason` 只可能在 `pending:true` 时出现,是**已脱敏**的原因短句(`access_token` 等凭据绝不出现在其中)。
|
||
- **判据是结构化字段**(适配器自报的闭集状态),不是正文、不是提示词 —— 客户端据此把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||
- **口径归属**:**适配器自报**(`ConnectOutcome`,网关经类型断言探测);核心 `sdk.Channel` 契约**刻意不扩展** ⇒ 没实现该状态面的平台响应**逐字节不变**。
|
||
- ⚠️ **为什么 `ready` 不带 `reason`**:它本来就不是坏消息(监听已就绪、就等对面来连),给它编一个原因等于把「等接入」说成「出问题了」。
|
||
|
||
---
|
||
|
||
## 3. WebSocket `/ws/chat`
|
||
|
||
### 3.1 握手
|
||
|
||
- `GET /ws/chat`,凭据取自 `?token=<JWT>` 或 `Authorization: Bearer <JWT>`(**只认 JWT**)。
|
||
- 校验失败:升级前回 `401 {"error":{"code":"unauthorized",...}}`。
|
||
- 方法非 GET → `400 bad_request`。
|
||
- 帧大小:**读上限 4096 字节/帧**(`wsReadLimit`)。超限由传输层直接断连 —— 因此**真实音频/图片二进制必须走 REST**,WS 只发小载荷。
|
||
- keepalive:服务端周期 `Ping`(54s),客户端应回 `Pong`;读超时 60s。
|
||
- 非 JSON 帧 → 定向 `{"type":"error","data":{"message":"invalid json"}}`(连接保留)。
|
||
|
||
### 3.2 握手 `history` 回放(角色门:只发 owner+admin)
|
||
|
||
- 连接注册后,**仅当该连接是 `owner`/`admin`** 时,服务端**定向**发送一帧:
|
||
|
||
```json
|
||
{"type":"history","sessionId":"web","data":{"messages":[ <historyMessage> ... ]}}
|
||
```
|
||
|
||
- 内容 = 主会话最近 **50** 条(`wsReplayLimit`),`thinking` 恒缺席(见 §3.4.4 口径更正);每条**可能**带可选 `turnId`(v0.66,空则键省略)。🔴 **`voice` 不在握手回放里**(v0.67 登记时曾计划覆盖):v0.79 的 `voice` 只填在 4 个 REST 读取面 + `message_edited`,握手 `history` 回放**不带该键**(见 §6 v0.79 ③ / §4.4 / §7.2)。
|
||
- **viewer 连接完成握手时完全不做回放**(其只读窗口是 REST 监控面)。
|
||
- 回放是尽力而为:存储未接或读出错静默跳过。
|
||
|
||
### 3.3 上行帧(客户端 → 服务端)
|
||
|
||
> **角色门(fail-closed)**:**只有 `owner`/`admin` 连接可以发送任何应用内上行帧**。非 owner/admin 发任何上行 JSON 帧 → **定向**回 `{"type":"error","data":{"code":"forbidden"}}`(只到发送者,不广播、不驱动模型、不落库)。该门是 `route()` 的第一条语句且**默认拒**:未登记的新帧类型在显式登记前一律被拒。协议级帧(ping/pong/close)由 gorilla 内部处理,不受影响。
|
||
|
||
帧壳:`{"type":"<type>","reqId?":"<string>","data":{...}}`。`reqId` 保留未用。
|
||
|
||
| type | data | 语义 |
|
||
|---|---|---|
|
||
| `chat` | `{sessionId?,text,image?,deviceId?,mode?,clientMsgId?,documents?,parallel?}` | 会话消息。**与 REST `POST /chat` 同形**。见 [§4.6](#46-chat-请求与响应面)。 |
|
||
| `interrupt` | `{sessionId,scope?,turnId?}` | 打断。见 [§4.8](#48-interrupt-的-scope-与-turnid)。 |
|
||
| `voice_input` | `{audio,format?,text?,sessionId?,deviceId?,clientMsgId?}` | 语音回合:STT → 编排器 → 结算时 TTS 定向回本连接。见 [§4.5](#45-图片--文档--语音链路)。 |
|
||
| `mode_switch` | 任意(透传) | 行为模式广播;服务端以 `mode_switch` 下行帧回放给(owner/admin)连接。 |
|
||
| `subagent_ask` | `{id,mode?,n?,query?}` | 审计某子代理上下文。`mode` ∈ `""/full/tail/keyword`。定向回 `subagent_event`;未接 auditor → `error {"message":"subagent audit unavailable"}`。 |
|
||
| `task_output_query` | `{taskId}` | 取任务输出。定向回 `task_output`;未接 task store → `error {"message":"task output unavailable"}`。 |
|
||
|
||
上行错误应答(均**定向**,`{"type":"error","data":{...}}`):
|
||
|
||
| 情形 | data |
|
||
|---|---|
|
||
| 非 owner/admin 发送任意上行帧 | `{"code":"forbidden"}` |
|
||
| 未知 type | `{"message":"unknown event type"}` |
|
||
| 非 JSON | `{"message":"invalid json"}` |
|
||
| `interrupt` scope 非法 | `{"code":"bad_request"}` |
|
||
| `interrupt` 指定 `turnId` 不存在(仅 scope:"current") | `{"code":"bad_request"}` |
|
||
| `chat` 待处理队列满 | `{"code":"chat_busy","message":...,"sessionId":...,"clientMsgId?":...,"messageId?":...}`(**只发发送者**;同时广播 `pending_full` 状态帧) |
|
||
| `chat` 携带的文档不合法 | `{"code":"<too_many_documents\|document_not_found\|chat_documents_unavailable>"}` |
|
||
| `voice_input` 无 audio 也无 text | `{"message":"voice audio or text required"}` |
|
||
| `voice_input` 未接语音服务 | `{"message":"voice unavailable"}` |
|
||
|
||
### 3.4 下行帧(服务端 → 客户端)与角色门
|
||
|
||
> **唯一投递入口 `hub.broadcast` 即角色门**:它按帧类型查 `frameReachesAll` 表决定受众。
|
||
> - **全员白名单(7 个「状态机信号」,owner/admin/viewer 都收)**:`session_busy`、`pending_ack`、`pending_drain`、`session_idle`、`session_interrupted`、`pending_full`、`session_read`。它们**只报告会话忙闲/游标,不含任何内容**。
|
||
> - **其余全部下行帧 = fail-closed 默认档:只发 owner + admin**。新增帧类型在有人显式登记前一律落此档(漏分类不泄露内容)。
|
||
> - 分类表在 `internal/services/gateway/ws_frame_scope.go`,扩白名单属契约变更。
|
||
|
||
帧壳:`{"type":"<type>","turnId?":"...","seq?":<int>,"sessionId?":"...","data":{...}}`(`turnId`/`seq` 为空则键省略)。
|
||
|
||
#### 3.4.1 全员白名单(7 帧)
|
||
|
||
| type | 帧壳 | data | 说明 |
|
||
|---|---|---|---|
|
||
| `session_busy` | sessionId | `{sessionId,mode,activeTurns}` | 会话转忙。`mode` ∈ `thinking/executing/subagent` |
|
||
| `pending_ack` | sessionId | `{sessionId,mode,queueDepth,reason?}` | 忙时消息入队。`reason` **仅在显式声明 `parallel` 却落回排队时**出现,取值 `"parallel_limit"` |
|
||
| `pending_drain` | sessionId | `{sessionId,mode:"thinking",queueDepth,activeTurns}` | 续跑弹出队首 |
|
||
| `session_idle` | sessionId | `{sessionId,mode:"idle",activeTurns:0}` | 转空闲。**时点收紧(v0.65)**:所有在途路收尾**且** pending 空才发(单路时与旧行为逐字相同)。见 [§4.6](#46-chat-请求与响应面) |
|
||
| `session_interrupted` | sessionId | `{sessionId,mode,dropped,cancelledTurns,activeTurns}` | 打断结果。`scope:"all"` ⇒ `mode:"idle"`、`dropped`=清空的 pending 数、`activeTurns:0`;`scope:"current"` ⇒ `mode` 是当时忙碌通道(非 idle)、`dropped:0` |
|
||
| `pending_full` | sessionId | `{sessionId,mode,max,clientMsgId?,messageId?}` | 队列满(`max` 为上限)。`clientMsgId` = 被拒消息的幂等键(未携带则键缺席);`messageId` **仅当该消息此前已落库/已认领时**出现(服务端绝不在此处新铸 id) |
|
||
| `session_read` | sessionId | `{readAt}` | 已读游标推进(毫秒)。同账号所有端据此清角标 |
|
||
|
||
#### 3.4.2 结构化流帧(owner+admin;帧壳带 sessionId + turnId + seq)
|
||
|
||
| type | data |
|
||
|---|---|
|
||
| `message.start` / `message.delta` / `message.end` | `{index, delta?, streamId?}` |
|
||
| `thinking.start` / `thinking.delta` / `thinking.end` | `{index, delta?, streamId?}` |
|
||
| `tool_call.start` / `tool_call.delta` / `tool_call.end` | `{id, index, delta?, streamId?}` |
|
||
| `tool_execution.start` / `tool_execution.update` / `tool_execution.end` | `{tool, phase, id?, delta?, resultSummary?}`(`phase` ∈ `running/done/error`;**phase 绝不塞进 delta**;**`resultSummary` 只在 `end` 帧、且只在「真的有结论」时在场**(v0.82)—— **成功 ⇒ 结果摘要;失败/取消/超时 ⇒ 该次错误文本的摘要**(两边都走同一份脱敏路径);**只有「真的没有结果」才整键缺席**(成功但结果为空、或错误文本为空),**绝不用空串顶位**。上限 **≤500** rune(超长按 rune 截断、末位 `…`),与回看面 `toolCalls[].resultSummary` 同源同口径,读侧不做二次脱敏也不放宽) |
|
||
| `turn.start` / `turn.end` | 无 data(帧壳即边界信号) |
|
||
| `stream_chunk` | `{delta}`(非流式路径的单段文本) |
|
||
| `stream_end` | `{stopReason, steps?, error?}` |
|
||
|
||
> `streamId`(可选)= 同一次模型流(一次 ChatStream 调用)的多帧关联键;`turnId` = 整个回合恒定。两者分工不同。
|
||
> `tool_execution.update`:wire 映射已实现,但**工具分发器目前没有中途进度上报源**,故实际**暂无生产者**(`start`/`end` 真实发出)。
|
||
>
|
||
> **`resultSummary` 的缺席语义(v0.82,安卓端按此编程)**:它是 `tool_execution.end` 的**可选**键 —— **键在场 ⇔ 这次调用有了结论**;客户端**只做正向断言**,人缺席时显示「工具名 + 状态」即可。映射表(**客户端不要按 `phase` 反推键在不在**,只按「键在不在」分支):
|
||
|
||
| 情形 | `.end` 帧上的 `data.resultSummary` |
|
||
|---|---|
|
||
| 调用成功且结果非空 | **在场**(已脱敏 + 已截断;图侧同源文本通常 ≤80 rune,网关再按 ≤500 兜底) |
|
||
| 调用失败 / 取消 / 超时(`Invoke` 返回 error) | **在场** —— **该次错误文本的脱敏渲染**(与回看面「失败调用给图上的 `error` 摘要」同一说法) |
|
||
| 调用成功但结果为空(nil / 空串) | **缺席**(整键不出现) |
|
||
| 错误文本本身为空 | **缺席** |
|
||
| `tool_execution.start` / `.update`(即使有人误挂摘要) | **缺席**(契约只在 `end` 帧给) |
|
||
| 被静音的内部流程(backthink / 子代理) | **整帧不发**(既有行为,一字未改) |
|
||
|
||
> 「绝不回空串顶位」有两道保险:loop 侧留空 + 网关侧 `s != ""` 才写键(切片 Y1 的变异 A 证明这道守卫真的在起作用)。⚠️ **与回看面的差异(v0.82 起已收窄)**:v0.82 之前本帧**失败时整键缺席**、而回看面对失败调用给的是 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」。v0.82 把两侧统一为「失败也带该次错误文本的摘要」,**客户端从此只需一套判据**。⚠️ **未确证**:本键的**真机**(`:10942` 发布产物)端到端**未跑**,见 §7.4-30。
|
||
|
||
#### 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>,unreadCount?:<int>,proactive?:true}` | Web 主会话及 `web:*` 子会话的消息落库实时同步。**`thinking` 恒缺席**;**可能**带可选 `turnId`(v0.66,空则键省略);**可能**带可选 `unreadCount`(v0.76,该会话此刻的**绝对未读数**,**只在 >0 且算得出来时**出现 —— 见 §4.13);**可能**带可选结构化判据 `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 登记;🔴 **v0.79 只落地了其中一部分**)**:消息 DTO 的可选 `voice` 对象**设计上**一次覆盖 `message_persisted` / `channel_persisted` / `message_edited` 与握手 `history` 回放(形状与语义见 §4.4)。🔴 **v0.79 实测覆盖**:只有 `message_edited`(与 §4.4 列出的 4 个 REST 读取面)会带它;**`message_persisted` / `channel_persisted` 与握手 `history` 回放仍不带 `voice`**(补齐需给 `history.Message` 加字段,**未做**)⇒ 客户端**不要在实时帧 / 握手回放上等 `voice`**。**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` 标会话;**v0.77 起 `data` 另可带 `turnId`(该图的**路**的权威回合标识)与 `round`(会话轮次号)** ⇒ 真并行时同一会话**每一路各发自己的帧**,两路不再互相顶掉)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照**(该端点 v0.77 起另有 `lanes[]` 给出每一路)。缺席语义见 §4.11(d) |
|
||
| `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` 快照 |
|
||
| `panels_changed` | (无 sessionId) | **插件面板集合变更信号(v0.78 新增,切片 PLG-2)**:`{reason,pluginId}`;`reason ∈ {declare,install,uninstall,enable,disable}`,`pluginId` **可为空串**(无法归因到单个插件时)。🔴 **帧刻意不带任何面板内容** ⇒ 客户端据此**按自己的角色重拉一次** `GET /api/v1/admin/panels`(A13)。**只发 owner/admin**(未进 `frameReachesAll` 白名单 ⇒ **viewer 收不到**,须自行刷新/轮询)。传输 = 进程内 bus 主题 `ws.panels_changed`(**零新端口**) |
|
||
| `channel_event` | (无 sessionId) | **渠道事件实时帧(v0.78 新增,切片 E7)**:`{event:{…}}`,`data.event` 与 `GET /admin/channels/events` 的 `events[]` 元素**同一个对象**(v0.67 登记的 `ChannelEventJSON`,键名 camelCase)。⚠️ **`id` 键可能整个缺席**(实时帧对应**刚落库、id 尚未回读**的那一行;列表端点读已落盘行,`id` 恒有值)。**只发 owner/admin**(未进 `frameReachesAll` 白名单;viewer 走 REST 只读档)。🔴 **增量提示、不是权威列表**:帧是 best-effort(hub 发送缓冲满即丢该连接的那一帧)⇒ 客户端**必须**保留 REST 轮询兜底;去重建议按(`platform`,`accountId`,`platformType`,`createdAt`)或以轮询结果为准重排。⚠️ **当前只覆盖账号状态迁移这一路**(见 §7.2) |
|
||
|
||
> **observability_* 的订阅**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。
|
||
|
||
> **`channel_event` 当前实际会产生的事件(v0.78,切片 E7)**:只有**账号状态迁移** —— 即「**她自己的账号**上线 / 离线 / 未知」这一路。落库行为 `platform`(如 `qq-obv11`)/ `accountId` / `tier="t2"`(高价值 ⇒ **逐条落行**)/ `category="lifecycle"` / `platformType ∈ {get_status, meta_event.heartbeat, meta_event.lifecycle, account_status}` / `selfRelated:true` / `actorId:""` / `actorRole:"system"` / `channelId`·`channelType`·`groupId`·`messageId` 均空 / `summary` = **九句固定文案之一**(`账号在线(首次确知)`、`账号离线(首次确知)`、`账号状态未知(尚未探到)`、`账号离线(在线→离线)`、`账号上线(离线→在线)`、`账号上线(未知→在线)`、`账号离线(未知→离线)`、`账号状态未知(在线→未知)`、`账号状态未知(离线→未知)`,**不含任何平台原文**)。采样由网关**常驻**(约 60s 一拍,**不看面板也在跑**),**未知(`known=false`)也照常落行**(「尚未探到」≠ 离线)。当前**唯一**有账号状态面的平台是 `qq-obv11`;将来若把 `meta_event` 心跳也接进同一状态面,`platformType` 会跟着变。
|
||
|
||
> **`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`、`invalid_payload`(**v0.79**)、`unknown_provider`(**v0.79**)、`not_configured`(**v0.79**)、`primary_not_configured`(**v0.80**)、`invalid_trigger`(**v0.82**) | 请求体/字段非法、图片超限(`image` **字符串长度**,**仅 base64 / `data:` 形态会触发**,见 §4.5)、附件超条数;**插件动作的载荷形状错 / provider 名未登记 / 该家没配齐**(这三个码自 v0.79 起由动作处理器给出,见 §4.14(b)(c));**面板保存的 `primary` 指名一家「已登记但未配齐」的家**(**v0.80**,写盘**之前**就拒 —— 那份配置会让后端下次起不来,见 §4.14(c));**`GET /admin/backthink?trigger=` 的值未知或为空**(**v0.82**,见 §4.10(e-1);`browser` 面板的**未知字段**也走 `invalid_payload`,见 §4.14(d)) |
|
||
| 401 | `unauthorized`、`invalid_grant` | 无/坏凭据;refresh token 非法 |
|
||
| 403 | `forbidden`、`unknown_role`(**v0.78**,面板快照的角色无法识别,理论不可达、fail-closed) | 角色不足;主会话不可删;**插件写动作的调用者不是 owner/admin**(v0.78,见 §4.14(b)) |
|
||
| 404 | `not_found`、`session_not_found`、`document_not_found`、`account_not_found`、`media_not_found`(**v0.67 登记、v0.79 已实现**)、`tidy_run_not_found`(**v0.77 登记、已实现**)、`action_not_declared`(**v0.78 登记、已实现**) | 资源缺失;消息列表的会话不存在;文档缺失(含名字非法/穿越);渠道账号 id 不存在(C19/C20);**语音副本缺失**(含名字非法/穿越,三者同一响应,见 §4.5);**异步试算的 `runId` 未知 / 超 TTL / 进程重启后查不到**(三种原因刻意不区分,见 §4.12.1);**插件动作名未在该插件声明里出现过**(**不是**「没权限」,见 §4.14(b));**回滚的记忆条目不在被合并状态**(`not_found`,v0.79,见 §2.2 B17);**候选 id 不存在**与**「按 run 批量应用」的留痕行不存在**(**v0.83**,B21/B23 与 B22 —— 后者刻意与「这一轮没有待定候选」区分开) |
|
||
| 409 | `chat_busy`、`account_not_registered`、`plugin_disabled`(**v0.78**)、`config_unstartable`(**v0.80**)、`browser_unavailable` / `browser_session_not_open` / `config_rejected`(**v0.82**) | 待处理队列满;渠道账号已配置但没有已注册适配器(C19/C20,v0.66);**插件已被显式停用,其声明还在但入口与动作都下线**(v0.78,见 §4.14(b));**记忆整理被配置关闭**(`tidy_disabled`,v0.75 试算既有口径、**v0.83 的手动真跑 B24 复用同码同义** —— `MEMORY_TIDY_ENABLED=false`);**这次面板保存的结果会让 web-search 起不来**(主 provider 未配齐 / 未登记 ⇒ 下次启动 fail-fast)⇒ 已**按字节回滚**、运行中的工具保持原样(**v0.80**,见 §4.14(c));**`browser` 面板(v0.82,见 §4.14(d))**:本机没有可用浏览器来源 / 当前没有打开的页面(面板**不替模型开页**)/ 这次保存没能生效**已按字节回滚** |
|
||
| 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`、`memory_review_unavailable`(**v0.79 新增**,切片 L45:入库前审查留痕存储未接线 —— 列表与回滚**共用**)、`reviews_failed`(**v0.79**:读留痕失败)、`revert_failed`(**v0.79**:回滚写失败)、`config_unreadable`(**v0.79**:面板可变源存在但读不出来,**不覆盖**原文件)、`config_write_failed`(**v0.79**:可变源写不进去,本次改动**没有**生效;⚠️ **回滚本身失败**时也用这个码并明说「盘上可能已经变了」—— **v0.82 的 `browser` 面板复用这两个码**,见 §4.14(d))、**记忆整理应用面(v0.83,切片 TIDY-1)**:`tidy_unavailable`(整理写面未接线;**候选列表 / 应用 / 回滚共用**,与只读试算组同名同义)、`tidy_candidates_failed`(读候选失败)、`tidy_apply_failed`(单条或批量应用失败)、`tidy_revert_failed`(回滚写失败)、`tidy_run_unavailable`(**只接线了只读试算、真跑入口没接** —— 「没跑」与「跑了没产出」必须能分辨)、`tidy_run_failed`(真跑失败) | 内部错误或依赖未接线(**绝不伪装成功**) |
|
||
| 501 | `not_implemented`、`panels_unavailable`(**v0.78**)、`action_not_implemented`(**v0.78**) | HistoryStore 等未注入;**插件面板中枢未接线**(见 §4.14(a));**动作已声明,但宿主没有登记它的处理器** —— 🔴 **v0.79 起不再是兜底档**:内置 `web-search` 的动作已全部登记(见 §4.14(b)(c)),这条只在「声明写了却没实现」时出现 |
|
||
| 502 | `action_failed`(**v0.78**)、探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(**v0.79**)、`browser_action_failed`(**v0.82**) | **插件动作执行失败**(结构化错误码 + 文案,详细原因只进后端日志);中间一组是 `plugin.testProvider` 探针的**上游结论**(其中 **`no_results` = 连通正常但没查到结果**);末一个 = `browser` 面板的截图 / 指纹自检**执行失败**(**v0.82**,见 §4.14(b)(d)) |
|
||
| 503 | `timeout`、`chat_dispatch_failed`、`catalog_unavailable`、`tidy_timeout`(**v0.83** 手动真跑超时:**本轮未完整跑完,已跑部分仍写入了留痕** —— 不假装跑全了) | 派发/上游不可用 |
|
||
|
||
> **未确证**:`conflict`(409) 的通用码存在(框架级 `ErrConflict`),但除了 `chat_busy` 之外,本文档未逐一确证还有哪些端点会带它。
|
||
> **⚠️ `416` 不是错误信封(v0.67 登记,**v0.79 已实现**)**:`GET /api/v1/media/{name}` 的 `Range` 语法非法或范围不可满足时,**由 Go 标准库直写** `416`(`text/plain` + `Content-Range: bytes */<size>`),**不套** `{"error":{…}}` ⇒ 客户端**不要**拿 `error.code` 去解析它(见 §4.5)。这是字节流端点的既有事实(`/audio/speech` 的 `200`、`/documents/{name}` 的 `200` 同样不是 JSON 信封),**不是**本版新引入的怪癖。
|
||
> **`400` 的渠道事件用法(v0.67 已实现,见 §2.4(a))**:`GET /admin/channels/events` 对 `platform` 未知、`tier` 非法、`since`/`until` 跨度 > 90 天、`since`/`until` 时间点非法、`limit`/`offset` 非整数都回 `400`(**落地用的 `code` 是通用的 `bad_request`**,见 §7.4-11)。
|
||
|
||
### 4.2 分页与游标
|
||
|
||
`GET /api/v1/sessions/{id}/messages`:
|
||
|
||
- `limit`:默认 **50**,生效范围 **[1,200]**(大于 200 **钳到 200**,不报错);非数字或 `<1` → `400`。
|
||
- `before`(毫秒 `createdAt`):返回**严格早于** before 的消息,**倒序**(默认方向,向后翻历史)。
|
||
- `after`(毫秒 `createdAt`):返回**严格新于** after 的消息,**正序**(重连后的前向增量追赶)。
|
||
- `before` 与 `after` **互斥**(同传且都 >0 → `400`);`after` 非数字/负 → `400`。
|
||
- `q`:会话内内容搜索(大小写不敏感 ASCII LIKE,`%`/`_`/`\` 已转义),**倒序**、仍遵守 `before`/`limit`;**`q` 与 `after` 互斥**;空 `q` = 未传。
|
||
- `platform`:可选来源平台过滤(Web 端不传 = 全量;IM 适配器传本平台)。
|
||
- **形状校验(`limit`/`before`/`after` 非法、互斥冲突)先于 404 判定**:形状错就是 `400`,与目标会话存在性无关。
|
||
|
||
### 4.3 幂等(`clientMsgId`)
|
||
|
||
- 上行可选字段(REST `chat` body 与 WS `chat` 帧**同形**;`voice_input` 亦然)。
|
||
- **同一会话内同一个非空 `clientMsgId` 只承认第一条**。重复到达:
|
||
- REST → `200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}}`
|
||
- WS → 下行帧 `chat_duplicate`(§3.4.3)
|
||
- **不落库、不起回合、不发任何内容/流式新帧**。
|
||
- 判重由 SQLite 部分唯一索引 `(session_id, client_msg_id) WHERE client_msg_id != ''` 原子裁决(并发重发只有一个赢家)。
|
||
- `clientMsgId` **回填进消息 DTO**(空则省略),读接口与 `message_persisted` 给出同一答案 —— 客户端据此对齐本地乐观气泡(替代「靠文本相等」的脆弱匹配)。
|
||
- 省略它时行为逐字不变(老客户端零影响)。
|
||
|
||
### 4.4 消息 DTO(`historyMessage`)
|
||
|
||
`GET /sessions/{id}/messages` 的数组元素、`PATCH /messages/{id}` 的响应 `data`、`GET /admin/channels[/{platform}]/messages` 的元素、`message_persisted` / `channel_persisted` 的 `data.message`、`message_edited` 的 `data`、握手 `history` 回放的 `messages[]` —— **同一个结构体**:
|
||
|
||
```json
|
||
{
|
||
"id": "<消息 id>",
|
||
"sessionId": "web",
|
||
"role": "user|assistant",
|
||
"content": "...",
|
||
"mode": "chat",
|
||
"mood": "",
|
||
"image": "",
|
||
"documents": [ {"name":"...","mime":"...","size":123,"chars":8000,"truncated":false,"status":"extracted"} ],
|
||
"thinking": "...", // 恒缺席(全仓无写入者;见 §3.4.4 口径更正)
|
||
"platform": "web",
|
||
"deviceId": "web:chrome:a1b2c3",
|
||
"senderName": "...", // 第三方发送者显示名;空 = 主人
|
||
"adapterId": "kook:3bef...", // IM 来源适配器;Web 来源为空
|
||
"personaId": "...", // 空 = 全局默认角色
|
||
"clientMsgId": "...", // 空则省略
|
||
"turnId": "turn-…", // v0.66;空则省略(老消息/未接管路径)
|
||
"toolCalls": [ // v0.78;🔴 只在 GET /sessions/{id}/messages 这一个出口填充(见下)
|
||
{ "name": "web_search", "status": "ok", "durationMs": 1200,
|
||
"argsSummary": "{\"query\":\"明天天气\"}", "resultSummary": "晴,18~26℃" }
|
||
],
|
||
"voice": { // v0.67 登记,v0.79 已实现(切片 L45);非语音消息键缺席;🎤 判据 = 本键存在
|
||
"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`/`toolCalls`/`voice` 均为 `omitempty`(空时键缺席;`voice` 是**指针**,非语音消息整键缺席,**不是** `{}` 也不是 `null`;`toolCalls` 见下)。
|
||
|
||
> ⚠️ **`toolCalls` 只在一个出口填充(v0.78,切片 CODE-TC)**:字段加在共享 DTO 上,但**只有** `GET /api/v1/sessions/{id}/messages` 的 handler 会填它 —— 其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住它们不产出该键)。客户端不要在那些面上等 `toolCalls`。
|
||
|
||
**`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**,于是「旧行」与「没写」是同一个值)。
|
||
|
||
**`toolCalls` 语义(v0.78 落地,切片 CODE-TC;可选,仅 `GET /sessions/{id}/messages`)**:
|
||
|
||
- 形状:数组,每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`。`name` / `status` **恒在**;`status ∈ {ok,error,unknown}`(**闭集三态**);后三个键 `omitempty`(图上给不出该项时**键缺席**)。
|
||
- `name` = 图的工具节点里 `props.tool`(回落节点 `label`)。`status` 映射:图节点 `done → ok`、`error → error`、其余(`running`/`idle`/空)→ `unknown` —— **未知一律落 `unknown`**,绝不把没结论的调用说成成功。
|
||
- `durationMs` = 图上量到的起止差(毫秒);**缺任一时间戳、或不足 1ms ⇒ 键缺席,绝不估算、绝不回 `0` 冒充**。
|
||
- `argsSummary` / `resultSummary` 逐字来自**已落盘的轮次链路图**(`flow_graphs`)里**出图时已脱敏**的 props(`args_summary` / `result_summary`);**失败的调用在 `resultSummary` 里给的是图上的 `error` 摘要**(与面板同一份已脱敏文本)。**读侧不做二次脱敏、也不放宽**。两者各自 ≤ **500** 字符(超长按 rune 截断,末位 `…`)。
|
||
- 单条消息最多 **20** 条(超出按**图序**取前 20 条)。
|
||
- 🔴 **缺席语义(契约核心)**:`toolCalls` 出现 ⇔ 这条消息所属的那一轮**确实调用过工具**;图取不到(超保留期 / 该轮未落盘 / 隔离渠道会话从不入库 / **该路标识查不到图** / 图源未接线)**以及**这一轮没调过工具,**都是键缺席**,**绝不回 `[]` 冒充「没调过工具」** ⇒ 客户端只做**正向断言**(有键 ⇒ 调过工具),**不得**把「没有这个键」读成「没调过工具」。
|
||
- 数据源接线:`Deps.FlowGraphs`(`*flowgraph.SQLStore`)为 nil 时**只是没有这一面** —— 端点仍 `200`、正文一字不变(**不**像管理端点那样回 `500 flow_unavailable`)。查图失败只记一条带来源的 WARN 并让字段缺席(**不让可视化存档的故障把整页历史变成 500**)。
|
||
- ✅ **覆盖率已补齐(v0.79,切片 CODE-TC-2)**:编排器驱动的**每一路**(含**普通单路回合**与**队首续跑路**)的图现在都带权威 `turnId`(= 该轮消息行的 `chat_messages.turn_id`)⇒ **这些回复都能回看到工具**(此前只有真并行路可以)。仍**缺席**的只剩两类:① **拿不到权威标识的写入路径** —— 未接编排器的调用方、**后台思考**(`sess-backthink` 在发布会话白名单内,但它的 ctx 上没有权威标识)、旧后端;② **v0.79 之前落盘的历史单路行**(`lane_turn_id` 为空且**无可回填来源** —— 那时的图里根本没存这个标识,用会话 id / 时间戳 / 消息表 join 去补都是**编造身份**)。见 §7.2、§7.4-23、§7.4-25。
|
||
- 🔴 **一处形状变化(v0.79)**:`graph.id` 的 `#{回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 客户端若要给 `GET /admin/flow/graph?turnId=` 取值,**一律用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(老客户端自拼的 `web-7` **查不到新行**)。见 §4.11(a)(d)。
|
||
- 新增的读侧依赖:`FlowGraphStore` 增只读方法 `GraphsByTurnID(ctx, sessionID, laneTurnIDs)`(一页一次 `IN` 批量查,禁 N+1);新表列 `flow_graphs.lane_turn_id`(迁移 `0023`,等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)。**老行**(v0.77 之前的落盘)该列回填后仍为空 ⇒ 如实查不到。
|
||
|
||
**`voice` 语义(v0.67 登记;✅ **v0.79 已实现**,切片 L45 —— 覆盖率见本节末)**:
|
||
|
||
- 形状:`{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`**,只保留 🎤 标记。
|
||
- ✅ **覆盖率(v0.79,如实)**:`voice` 已在**这 4 个读取面**填充 —— `GET /sessions/{id}/messages`、`GET /admin/channels/messages`、`GET /admin/channels/{platform}/messages`、`PATCH /messages/{id}` 的**响应与 `message_edited` 广播帧**。🔴 **尚未填充**:`message_persisted` / `channel_persisted`(`broadcast.go`)与握手 `history` 回放(`ws_history.go`)—— 需要给 `history.Message` 加 `voice` 字段(**未做**);且入站语音的 `voice` 列是**消息落库之后**回填的,`message_persisted` 那一帧在**落库那一刻**发出,即使加了字段也拿不到值。⇒ **客户端不要在实时帧上等 `voice`**(见 §7.2、§7.4-29)。
|
||
- ⚠️ **列被外部改坏时可见地缺席**:非法 JSON / 闭集破坏时**不静默当「不是语音」** —— 记一条带来源与消息 id(**不含正文**)的 WARN,并让该字段缺席。契约的 `voice` 没有「读不出」这一态,因此缺席是唯一诚实的表达(与 `documents` 回一条 `unreadable` 不同)。
|
||
|
||
**`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 登记;✅ **v0.79 已实现**,切片 L45;残留缺口见下)**:
|
||
|
||
> ✅ **v0.79 落地进展(切片 L45,如实登记)**:**已实现** —— ① `documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 同字节去重 / 目录 0700、**文件 0600** / 元数据写同一张 `documents` 表、引用计数并入同一套 `documents.ref_count`);② `history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列的**读写两条路**)⇒ channelbridge 的入站副本链**端到端可落地**;③ **网关 DTO `voice`(含唯一派生字段 `url`)** 与 **`GET /api/v1/media/{name}`**(§1.2 U19 / 下表)。🔴 **残留缺口(如实,不美化)**:① ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入** `VoiceTranscoder`(`cmd/cyrene/wire_channels.go` 的 `channelbridge.Options`)⇒ **不可播格式(`amr`)也落副本**(落盘前先转 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档。⚠️ **生产端到端仍未跑**;**可播格式(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍原样落、不转码**;② **WS 实时帧 / 握手回放不带 `voice`**(见 §3.4.4 与 §4.4 覆盖率);③ **消息 / 会话被删除时音频副本的引用计数不被释放**(`refs.go` 的增减点只认 `document` 列)⇒ 副本 `ref_count` **偏高**(后果是**晚回收 / 不回收**,绝不早删);④ **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上 0600 副本 → 客户端取到字节」只有替身 + 临时库/临时目录级证据。
|
||
|
||
> **v0.78 已落地的基础件(切片 CODE-VOICE;v0.79 已在其上补齐用户可见面)**:① 迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''` 列(JSON 单对象,形状 = 下面的 DTO `voice` **减去 `url`**);② `internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);③ `channelbridge` 入站语音「归一(最前端)→ 闸门**之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC,但**不进文档抽取/注入链路**)。唯一已生效且**客户端可见**的既有变化:IM 语音在「音频格式不在可转写白名单内」时正文诚实提示由「…(需要先转码,当前未做)」改为「…(服务端只把白名单内的音频送语音识别),这条语音没有转写。」—— **形状不变**(正文仍是转写文本或一行诚实提示;客户端本就**不该**按正文文案分支)。
|
||
|
||
- **音频副本何时产生**:渠道适配器入站拿到语音字节后,转写在**流水线最前端**归一(见上),而**落盘在闸门之后** —— 被会话解析 / 黑白名单 / 最小打扰拦下的语音**盘上不留任何文件**(与 IM 附件同一纪律)。落盘 = 内容寻址(`sha256(bytes)[:32] + ext`,同字节同扩展名天然去重),产出该消息的 `voice` 引用(DTO 见 §4.4)。**不可播格式(`amr`)在落盘之前先转成可播副本(`amr→wav`)**;转码不可用时**不落副本、不打假 url、消息一个字不丢**(带稳定 `category` 的 WARN;10 类:`no_bytes`/`not_playable`/`transcode_not_wired`/`unsupported_format`/`input_too_large`/`output_too_large`/`decode_failed`/`encode_failed`/`timeout`/`busy`)。⚠️ **现状(v0.80 / 切片 W4 起)**:组合根**已注入 `VoiceTranscoder`** ⇒ `amr` 先转 `.wav` 再落副本(`mime: audio/wav`);可播格式仍原样落、**不转码**。⚠️ `transcode_not_wired` 从此只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**)。
|
||
- **播放端点 `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 登记 · **v0.79 已实现**) | **Bearer owner/admin** | `inline; filename=…` | 32 hex + **音频白名单**(10 项) | 语音副本,供播放器消费 |
|
||
|
||
- ⚠️ **音频 URL 不能直接贴进 `<audio src>`**:读侧要 Bearer(与图片读侧「128-bit 名即凭据」**刻意相反** —— 语音是私密内容,不是「谁拿到 URL 谁就能听」)⇒ 客户端须**带 token 取字节**(Web:`fetch` → `blob:` → 播放;原生:认证 HTTP 客户端)。这是**有意**的设计,不是缺陷。
|
||
- ⚠️ **可播放性未逐一实测**:`amr` / `flac` / `opus` 在部分浏览器内核上播不了 ⇒ 客户端**以实测为准**,播不了就照实提示(不假装;**绝不给一个播不了的 `url`**)。「能播」必须是**格式级**结论,不得靠「扩展名看起来像音频」就宣称可播。
|
||
- ⚠️ **没有「删除一条语音副本」的 API**:保留期 GC 是唯一出口(`DOCUMENT_RETENTION`,默认 168h)。
|
||
|
||
### 4.6 `chat` 请求与响应面
|
||
|
||
**请求体**(`POST /api/v1/chat` 与 WS `chat` 帧 `data` **同形**):
|
||
|
||
```json
|
||
{"sessionId":"web","text":"...","image":"<base64/data:/URL>","deviceId":"web:chrome:a1b2c3",
|
||
"mode":"chat","clientMsgId":"<uuid>","documents":[{"name":"a1b2....md"}],"parallel":true,"stream":false}
|
||
```
|
||
|
||
- `sessionId` 省略 → `"web"`。
|
||
- `deviceId`:客户端端标识(如 `web:chrome:a1b2c3`),随消息落库。
|
||
- `mode`:行为模式(如 `chat`/`code`/`home`/…),随该回合落库;缺省回落 `chat`。
|
||
- `image`:base64 `data:` URI、裸 base64 或 http(s) URL。**上限按形态不同**(判据是**字符串长度**,不是解码后字节),见 [§4.5](#45-图片--文档--语音链路)。
|
||
- `stream`:仅 REST 用,见下。
|
||
|
||
**同步(非 `stream`)响应面**:
|
||
|
||
| 情形 | 状态 | 载荷 |
|
||
|---|---|---|
|
||
| 正常跑完 | `200` | `{data:{reply,steps,stopReason}}` |
|
||
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
|
||
| 忙、已续接排队 | `202` | `{data:{queued:true,queueDepth}}` |
|
||
| 忙、显式并行且照准 | `202` | `{data:{parallel:true,turnId:"turn-…"}}`(**拿不到同步 reply**;文本经 WS 流式到达) |
|
||
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
|
||
| 空回合(无 text/image/documents) | `400` | `{error:{code:"bad_request"}}` |
|
||
| **`image` 字符串超长**(**仅 base64 / `data:` 形态会触发**;URL 形态不会) | `400` | `{error:{code:"image_too_large"}}` —— 在解码/落库/入模型**之前**拒;上限 = `CHAT_IMAGE_MAX_BYTES` 换算出的**字符**上限(默认 **6990572 字符**),见 §4.5 |
|
||
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
|
||
| 回合内部报错 | `500` | `{error:{code:"chat_failed"}}` |
|
||
|
||
**`stream:true` 响应面(行为变更,v0.56/G2;**受理成功后才回**)**:
|
||
|
||
| 情形 | 状态 | 载荷 |
|
||
|---|---|---|
|
||
| 空闲已起回合 | `200` | `{data:{stream:true}}` |
|
||
| 忙、已续接排队 | `202` | `{data:{stream:true,queued:true,queueDepth}}` |
|
||
| 队列满 | `409` | `{error:{code:"chat_busy"}}` |
|
||
| 幂等键重复 | `200` | `{data:{duplicate:true,messageId,sessionId}}` |
|
||
| 已升为独立并行路 | `200` | `{data:{stream:true,parallel:true,turnId:"turn-…"}}` |
|
||
| 未接 loop | `500` | `{error:{code:"chat_unavailable"}}` |
|
||
| 仅未接编排器时的总线派发失败 | `503` | `{error:{code:"chat_dispatch_failed"}}` |
|
||
|
||
> **不再有「先回 200 再看有没有落地」**;`202`/`409` 真的会出现。派发用的回合上下文与请求作用域脱钩(`WithoutCancel` + 30 分钟上限),回合不会在客户端刚收到 200 时被连带取消。
|
||
|
||
**忙时并行(v0.65 起,`parallel:true`,仅显式声明时生效)**:
|
||
|
||
- **触发权在客户端**:服务端**不做任何启发式自动升级**。空闲时 `parallel` 无效果(照常起第 1 路)。
|
||
- 会话**忙**且单会话并发额度未满(默认 **2**,硬上限 **3**)⇒ 本条**升为一条独立并行路立即起**(见上表 `{parallel:true,turnId}`;该 `turnId` 随后出现在这一路的 `turn_begin` 帧壳与全部流帧帧壳上)。
|
||
- 额度已满、**或真并行被 `<2` 关掉** ⇒ 落回排队语义(`202 {queued:true,queueDepth}`)**并在 `pending_ack` 带 `reason:"parallel_limit"`**。
|
||
- 在途路数经 `activeTurns` 出现在忙态帧(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`)与 `turn_begin.data` 中。
|
||
- `session_idle` 时点收紧(**仅并行时与旧行为不同**):所有在途路收尾**且** pending 空才报空闲(单路时逐字不变)。
|
||
- **每一路起跑都发一帧 `turn_begin`**(v0.66 落地,见 §3.4.3)——客户端据此把用户气泡钉到路、并按 `sessionId + turnId` 两级分桶。
|
||
- **省略 `parallel` 的客户端行为逐字不变**。
|
||
- ⚠️ **env `CHAT_MAX_PARALLEL_TURNS` 未接线**(见 §5、§7):生产**恒为默认 2**。
|
||
|
||
### 4.7 枚举权威表
|
||
|
||
`GET /api/v1/meta/enums` → `{data:{platforms,moods,modes,unknown}}`,每项 `{id,label}`。
|
||
|
||
- `platform`:`web`、`kook`、`qq-obv11`、`weixin-ilink`、`telegram`、`webhook`。
|
||
- `mood`:`calm`、`happy`、`excited`、`sad`、`angry`(**可扩展**,非封闭集合)。
|
||
- `mode`:`chat`、`code`、`home`、`work`、`docs`、`voice`、`vision`、`agents`、`settings`。
|
||
- `unknown` 恒 `"display-raw"`:**未知取值原样显示**——不丢弃、不报错、不替换占位符。判定**只比对 `id`**(大小写敏感);`label` 仅默认显示。服务端新增取值是非破坏性变更。
|
||
- ⚠️ **未确证**:上表逐值取自 `internal/contract/enums`(本文档经归档版转述,**未逐一回代码核对**)。客户端**应直接读本端点**而非硬编码。
|
||
|
||
### 4.8 `interrupt` 的 `scope` 与 `turnId`
|
||
|
||
上行 `{"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-…"}}`。
|
||
|
||
- `scope` 取值域 `{"", "all", "current"}`(**大小写敏感**,只容忍首尾空白):
|
||
- 省略 / `""` / `"all"`:取消**全部在途路**并**清空待处理队列**(历史语义)。
|
||
- `"current"`:只取消在途路(**多路时为每一路**),**保留**待处理队列(队列消息按正常续跑规则继续执行)。
|
||
- 其它取值 → **定向**回发送者 `{"type":"error","data":{"code":"bad_request"}}`(不广播、不打断任何会话,绝不静默按默认执行)。
|
||
- **`turnId`(v0.66 落地)**:**只在 `scope:"current"` 时生效**,只取消该 `turnId` 那一**路**(其余在途路与待处理队列不受影响)。省略 scope 或 `scope:"all"` 时按契约**忽略** `turnId`(服务端只留一条 debug 日志,响应面不变)。
|
||
- 目标不存在(已收尾/已取消/id 写错)⇒ 定向回 `{"code":"bad_request"}`(只到发送者,**不广播、不动任何会话**),**绝不**静默降级成「停全部」。
|
||
- 省略 `turnId` 的 `scope:"current"` = 作用于**每一路**(v0.65 原口径,逐字不变)。
|
||
- **`scope:"all"` 不发 `session_idle`**:客户端靠 `session_interrupted` 收口。
|
||
- 命中空闲会话为 no-op。
|
||
|
||
### 4.9 会话角色绑定
|
||
|
||
- 会话角色(`personaId`)**创建时确定、之后不可变**(design/22 §8-1):`POST /sessions {personaId}` 在创建时绑定;`PATCH /sessions/{id}` 携带 `personaId` 键一律 `400`。
|
||
- 空/缺省 = 继承全局默认角色(`owner.yaml:defaultPersona`)。
|
||
- 消息 DTO 的 `personaId` = **该消息说话时的角色**(assistant = 生成它的角色,user = 当回合角色;空 = 默认角色)。
|
||
|
||
### 4.10 v0.68 新增:配置事实面与后台思考行的紧急度
|
||
|
||
#### (a) `GET /api/v1/admin/config-facts`(只读,`owner`/`admin`)
|
||
|
||
响应 `{data:{facts:[…], quietHours:{…}}}`:
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `facts[].key` | string | env 键名,**与 `.env.example` 逐字一致**(后端有测试钉住) |
|
||
| `facts[].value` | string | **进程生效值**(已解析:bool → `"true"`/`"false"`;整数 → 十进制;比例 → 归一化小数如 `"0.8"`)。**不是** `.env` 原文 |
|
||
| `facts[].default` | string | 该键的**声明默认值**(`internal/config` 结构体 `default:"…"` 标签,唯一真源) |
|
||
| `facts[].isDefault` | bool | `value == default` ⇒ 面板显示「默认」(**不代表 `.env` 里没写过这个键**:网关按红线**不读 `.env`**,无从不区分「默认层」与「结构体默认」) |
|
||
| `quietHours.startHour` / `endHour` | int | 安静时段的生效钟点(本地时区小时)。⚠️ 报的是**运行时真正生效**的那一个:两端都写 0 时按「未填」回落到声明默认(见下 `fallbackToDefault`) |
|
||
| `quietHours.disabled` | bool | 窗口是否**关闭**(两个相等的**非 0** 钟点,如 `23/23`) |
|
||
| `quietHours.label` | string | 面板可直接显示的文案:`"23:00-07:00"` 或 `"已关闭"` |
|
||
| `quietHours.fallbackToDefault` | bool,**可选**(`true` 才出现) | 两端都被写成 `0` ⇒ 视为**未填**,运行时回落到默认窗口(`23:00-07:00`,此时 `startHour`/`endHour` 已是回落后的值)。⚠️ **`0/0` 不是「关闭」**:backthink 与记忆整理都会做这次回落,事实面按同一口径报,否则会把「未填」谎报成「已关闭」 |
|
||
|
||
- **固定 13 条事实**(记忆整理 **8** 键 + 后台思考心跳 3 键 + 安静时段 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`、`MEMORY_TIDY_COMPARE_LIMIT`、`ENABLE_BACKGROUND_THINKING`、`THINK_INTERVAL_SEC`、`THINK_QUIET_INTERVAL_SEC`、`QUIET_START_HOUR`、`QUIET_END_HOUR`。
|
||
- 🆕 **v0.75 追加(同一端点、无版本化形状变更)**:`ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / `THINK_QUIET_INTERVAL_SEC` 三键并入事实表(design/12 §N A4)—— 安静时段降频上线后,面板必须能读到「普通心跳周期」与「安静窗口内的心跳周期」。⚠️ `THINK_QUIET_INTERVAL_SEC` **≤0 = 关闭降频**(回落普通间隔,不是「半夜不思考」),事实面照原值回,不替用户改写。⚠️ 这几个键都是**启动期**读取的:改了要**重启**才生效(不可热改)。⚠️ `ENABLE_BACKGROUND_THINKING` 是**未接线**的死配置(全仓无消费者,见 §5「配了不生效」):它被列进事实表是为了**让面板看得见**,`value` 只是它解析出来的值,**不代表它会生效**;面板文案必须标明(见 `frontend/web/src/admin/modules/configFacts.ts`)。
|
||
- 🆕 **v0.77 追加(同一端点、无版本化形状变更)**:`MEMORY_TIDY_COMPARE_LIMIT` 并入事实表(切片 MT-A)—— 它是**每分区每轮真正参与两两比对**的条目上限,默认 `2000`;此前**写死为 `400`** 且没有 env 键(实测后果:459 条的分区被截到 400 ⇒ `compared(705) < scanned(784)`,观测口径被上限决定)。列进事实表是为了让面板看清「这项是不是还是默认值」。与记忆整理那组键一样是**启动期**读取的:改了要**重启**才生效。
|
||
- 🔴 **不含任何凭据**:响应里不出现 JWT 密钥 / LLM API key / 渠道 token / 数据库 DSN 的值(后端有哨兵测试钉死),**也不回凭据类键的「是否已配置」** —— 本端点只覆盖上面 13 个非敏感调参键。
|
||
- **「默认 / 已改」怎么读**:`isDefault:true` ⇒ 面板显示「默认」(生效值等于该键的声明默认值);`isDefault:false` ⇒ 显示「已改」。⚠️ 它**不是**「`.env` 里没写过这个键」的断言 —— 网关按红线**不读 `.env`**,无从不区分「`.env` 给的值恰好等于默认」与「完全没配」。**哪一项没配**因此不会被伪造成一个值:面板拿 `value` + `isDefault` 如实展示即可。
|
||
- 13 条事实的 `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` 的语义。
|
||
|
||
#### (d) v0.81 新增:她自排轮(`self_wake`)的用户可见面
|
||
|
||
> 依据 `design/12 §N C`(自排:结构化 self-initiated 触发 + 她可调用的「自排一次思考」工具 + 每窗口上限 + 审计)。**v0.81 当时无新端点、无新字段表**,只有两处**取值域加性扩张**与一条**口径澄清**。
|
||
> ✅ **可达性(v0.82 已改正,别按旧口径读)**:v0.81 落地的是**判定侧(C1-1 `94da6bc`)与自排本体(C1-2 `ed50053`)**,当时她的入口工具 `request_think` **尚未接线** ⇒ 那一版写的是「生产里还不会真的出现」。🔴 **这句已过期**:`request_think` **已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **`trigger="self_wake"` 与那 4 条审计动词在生产里会真的出现**。客户端可以把它当活跃数据读(仍**不保证每轮都有** —— 自排要过闸,见下)。
|
||
|
||
**(d-1) `GET /api/v1/admin/backthink` 的 `trigger` 出现新取值 `"self_wake"`**
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `trigger` | string,**恒在**(B8/§2.2) | 这一轮**为什么**被触发。取值**不封闭**,当前实际会出现:`"heartbeat"`(周期性自主反思)/ `"startup"`(启动轮自己的第一轮)/ **事件轮的 `Subject` 字符串**(IoT 设备变化、提醒等,形如事件主题)/ **`"self_wake"`(v0.81 新增)** = **她自己排起的一轮思考**(`internal/services/backthink/trigger.go` 的 `SelfWakeSource`;`design/12 §N C`)。⚠️ 面板**按字面值直显**即可;**不要**对 `trigger` 做闭集校验(事件轮那一支本来就是开放集合) |
|
||
|
||
- **自排轮的实质**:她(在主人会话里或后台思考轮里)调用内部工具 `request_think(reason, urgency?)` 排起一轮 —— 记录行 `trigger="self_wake"`、`urgency` 照旧是**该轮实际生效**的档位(她自报的档位只**上抬**、绝不压低,见 §4.10(c))。
|
||
- 🔴 **边界(别过度解读这个键)**:① 自排**只保证「她能被叫醒思考」** —— 这一轮**要不要真的发出去**仍由既有 `decide` 级联 + `PushGate` 决定(安静时段 / 限频 / 冷却照旧生效,**不**因为是她自排就绕过);② **`self_wake` 不会出现在 IM 渠道消息上**,也不代表「消息已发出」;③ **不给客户端任何新的操作入口**(自发自排是她的内部能力,不是客户端的 API)。
|
||
|
||
**(d-2) 新增 4 条审计动词(经 B9 `GET /api/v1/admin/observability/audit` 可查)**
|
||
|
||
| `what` | `who` | `result`(可直接显示的中文;`<reason>` 是她写的理由) |
|
||
|---|---|---|
|
||
| `backthink.self_wake.queued` | `backthink` | `已排入一轮自主思考,理由:<reason>` |
|
||
| `backthink.self_wake.coalesced` | `backthink` | `已合并到排队中的那一轮(不额外占额度),理由:<reason>` |
|
||
| `backthink.self_wake.capped` | `backthink` | `已达自我唤醒上限(本窗口 %d/%d,窗口 %s),本轮未排队` |
|
||
| `backthink.self_wake.denied` | `backthink` | `非主人环境,拒绝自排(actor=%s)` |
|
||
|
||
- 只有 `queued` 会**真的**新增一轮思考;`coalesced` = 已有一轮在排队 ⇒ 合并进去(**不额外占额度**);`capped` = 触到每窗口上限(**默认 4 次/小时**);`denied` = 非主人环境(安全闸拦下,**不做事**)。
|
||
- ⚠️ **上限值不在契约里冻结**:它的真实来源是 **`$DATA_DIR/config/backthink.json`**(`selfWakePerWindow` / `selfWakeWindowSec`,**启动期读一次、改文件需重启**;**不新增 env 键** —— `.env` 已冻结,见 §5 与 `AGENTS §10`)。✅ **v0.82 起有只读出口了**:新增 **`GET /api/v1/admin/backthink/self-wake`**(§2.2 B19 / §4.10(e))给出「本窗口已用 N / 上限 M / 窗口秒数」的**真读数**,不必再从审计行 `result` 的原文里抠。🔴 **仍没有写口**:改上限/窗口依旧只能改配置文件 + 重启(面板写路径**未做**)⇒ 客户端/面板**不得假定**能改到这个值。
|
||
|
||
#### (e) v0.82 新增:自排的成本可见面(读侧)
|
||
|
||
> 依据 `design/12 §N C.5`(每窗口上限 + 容量 1 待跑槽)与 `§N C.6`(服务端筛选)。**两条都只读**。
|
||
|
||
**(e-1) `GET /api/v1/admin/backthink?trigger=<值>` —— 服务端筛选(v0.82)**
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 查询参数 | `trigger`(**可选**)。**缺席** ⇒ 不过滤(既有行为,老客户端零影响);**在场但为空** ⇒ **`400 invalid_trigger`**;**不在白名单** ⇒ **`400 invalid_trigger`**(错误文案里**列出可选取值**,客户端可直接显示) |
|
||
| 白名单(**闭集**) | `heartbeat` / `startup` / `self_wake` + **本服务订阅的事件主题**。真源 = `internal/services/backthink/trigger_domain.go` 的 `TriggerFilterValues()`(内建标签 + `EventSubjects()`)—— **网关不另抄一份清单**(否则新增订阅主题时会出现「数据里有、筛选却 400」的静默漂移) |
|
||
| 筛选方式 | **下推 SQL**(`ListByTrigger`),因此 `limit` 数的是**匹配**条数,而不是「取一页再筛掉不匹配的」(后者会给面板一个**静默的假空值**:「0 条」而库里明明有) |
|
||
|
||
🔴 **为什么未知值必须报错、不能静默忽略**:静默忽略会让面板在「显示的是全部」的同时声称「正在筛选自我唤醒」—— 那是一个**假的筛选结果**。**空值同理**(客户端把想筛选的值弄丢了却以为在筛)。
|
||
|
||
⚠️ **与 (d) 的「取值域不封闭」不矛盾(这是本版最容易被误读的一处)**:
|
||
- **显示口径**((d),仍然成立):`Record.Trigger` 的取值**对客户端是开放的** —— 事件主题那一支本来就是开放的;客户端**按字面值显示、不做闭集校验、不隐藏不认识的取值**;
|
||
- **筛选白名单**((e-1)):`?trigger=` 是**客户端传进来的参数**,打错了必须报错,而不是静默返回一个空列表(静默的空列表会被读成「这段时间没有这一类的思考」)。
|
||
- 两者面向的是**不同方向的数据流**(一个是我们给出去、一个是客户端传进来),因此**不冲突**;判据的真源仍是**同一处**(`trigger_domain.go`)。
|
||
|
||
**(e-2) `GET /api/v1/admin/backthink/self-wake` —— 自排闸只读快照(v0.82 新增)**
|
||
|
||
**为什么有它**:`C.5` 的「本窗口已用 N / 上限 M」此前**只**出现在**被拒那一轮**的审计文案里(`backthink.self_wake.capped` 的 `result`)—— 也就是**「已经被拒过一次」才第一次看得见**。面板要能**提前**告诉主人「还能自排几次」,就得有一个只读快照出口。
|
||
|
||
**鉴权**:`writeAuth`(**owner / admin**;viewer → `403`)。理由:它含**她自排的成本口径与运行状态**,不是给 viewer 的监控投影(与列表端点 B8 同门同档)。**只读、不审计、不写库**。
|
||
|
||
**响应**:`{data:{available,running,reason?,configFile?,gate?}}`
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `available` | bool,**恒在** | 本进程**能不能**给出快照。`false` 的含义是「看不到」,**不是**「0 次」 |
|
||
| `running` | bool,**恒在** | 自排此刻**是否真的能排**(未 Start / 已 Stop ⇒ `false`:此刻 `WakeNow` 会返回 `ErrSelfWakeUnavailable`) |
|
||
| `reason` | string,**可选**(缺席 = 正常态) | 人类可读的原因。三种非正常态各一句:未接线 / 实现不提供读口 / 闸不在 / 未在运行 |
|
||
| `configFile` | string,**可选** | 要改哪个文件 —— **相对落点** `config/backthink.json`(相对 `$DATA_DIR`)。绝对路径随部署而变,接口只给相对落点 |
|
||
| `gate` | object,**可选**(`available:false` 时**整键缺席**) | 见下表 |
|
||
|
||
`gate` 子对象:
|
||
|
||
| 键 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| `pending` | bool | 容量 **1** 的待跑槽是否被占用(有一轮自排排着队还没跑) |
|
||
| `used` | int | 本窗口已用额度,与工具收据 `SelfWakeReceipt.Used` **同一口径**(同一次取数:过期的记录先被丢弃,再计数) |
|
||
| `limit` / `windowSec` | int | **进程生效**的上限与窗口(已 normalize:`≤0` 的配置不会走到这里)。窗口单位是**秒** |
|
||
| `defaultLimit` / `defaultWindowSec` | int | 代码默认值 |
|
||
| `limitIsDefault` / `windowIsDefault` | bool | 生效值**恰好等于**代码默认值 |
|
||
|
||
🔴 **`*IsDefault` 不是「配置里没写这个键」(诚实边界,与 B12 config-facts 同一条口径)**:网关**不读** `backthink.json`,因此**无从区分**「配了 4」与「没配(默认也是 4)」⇒ 客户端**只能**把它读作「生效值是否等于默认值」,**不得**显示成「未配置 / 已自定义」。要表达「未配置」需要另一次取数(当前**没有**)。
|
||
|
||
**三态(面板按此显示,别把「看不到」画成「0 次」)**:
|
||
|
||
| `available` | `running` | `gate` | `reason` | 面板该说什么 |
|
||
|:--:|:--:|:--:|---|---|
|
||
| `false` | `false` | **缺席** | 有 | 「读不到自排状态」+ 原因原文。**不要**显示 0 / 0 |
|
||
| `true` | `false` | 有(配置面数字) | 有 | 「后台思考未在运行:此刻她排不出新的一轮」+ 仍显示生效上限/窗口 |
|
||
| `true` | `true` | 有 | **缺席** | 正常:显示「本窗口已用 `used` / 上限 `limit`」+ 待跑槽占用 |
|
||
|
||
- **只在 `running:true` 时才可以说「她还能排 N 次」**:`used` 是**已用**额度(`limit - used` 是剩余,但闸的判定始终由滑窗与待跑槽做,**读本快照不参与判定、也不改变闸的状态** ⇒ 读它永远不会消耗额度)。
|
||
- **`available:false` 的 HTTP 状态是 `200`**(不是 5xx):这是「本进程给不出这张快照」的**如实答复**,与 B8「未接线时返回空列表」同族 —— 面板据 `reason` 显示即可,**不要**当成请求失败重试。
|
||
|
||
### 4.11 链路图快照 + 多轮历史回放(`GET /api/v1/admin/flow/graph`;v0.71 新增,v0.73 增量,v0.77 增量)
|
||
|
||
**为什么有这条**:`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 才回退落盘** ⇒ 最新一轮永远是最新的);给了就是该会话已落盘的某一轮 —— **取值一律用响应里的 `turns[].turnId` / `graph.id`**(🔴 **v0.79 起单路也带 `#{回合标识}` 后缀**,形如 `web-7#turn-3f9c…`;**不要自己拼** `{会话}-{序号}`),且该值**可能含 `#`**、作 query 值必须 URL 编码 |
|
||
| 响应体 | `{data:{sessionId, turnId, source, graph, lanes, turns}}`(**v0.73**;v0.71 只有 `{sessionId,graph}`;**v0.77 新增 `lanes`**)。`sessionId` = 解析后的会话;`turnId` = **实际返回的那一轮**的标识(无图时 `""`);`source` ∈ `"live"`(内存最近一轮)/`"persisted"`(落盘的历史轮)/`"none"`(没有图);`graph` = **该轮**的图或 `null`;`lanes` = **最近一轮每一路**的图 `[{turnId,graph}]`,按路的开始时间升序(最早的在前)—— 与 `?turnId=` **无关**(翻历史轮时 `lanes` 仍报「当前这一轮的各路」,`graph` 才是你指定的那一轮),恒为非 null 数组(图源未接线 / 没有路时是 `[]`);`turns` = 该会话**已落盘轮次**摘要 `[{turnId,createdAt}]`,**最新在前**(未接线持久化或没有轮次时是 `[]`,恒为非 null 数组) |
|
||
| `graph` / `lanes[].graph` 形状 | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5:`{id,nodes[],edges[],sessionId,turnId?,round?,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径)。`turnId` / `round` 为 **v0.77 新增的可选键**(缺席语义见下方 (d)) |
|
||
| 归档 | **不审计**(只读查询,与同档的 `/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`。实时帧仍是「正在跑的那一轮」的唯一来源;本端点补的是「已经跑完、帧已经错过」的那些轮次(最近一轮 + 可选的任意历史轮)。
|
||
8. **`lanes[]` = 最近一轮每一路的全貌(v0.77)。** 真并行(`parallel:true`)时同一会话同一轮有多条路,后端**每一路各建一张图、各落一行**;本端点的 `graph` 只给其中**最新的一路**(向后兼容,老的读取方零改动),`lanes[]` 才是全部(每项 `{turnId,graph}`,按路的开始时间升序,恒为非 null 数组;前端在 `lanes.length > 1` 时按**并列泳道**呈现)。⚠️ `lanes[]` **不跟随 `?turnId=`**:它恒报「当前这一轮的各路」(取内存里的实时图),`graph` 才是你要的那一轮。
|
||
|
||
#### (c) 与 design/19 的关系
|
||
|
||
`design/19 §4` 把「多轮历史回放」列为 **P3**;v0.71 落实了 P3 里**最小的一步**(每会话最近一轮的只读快照),**v0.73 把 P3 补全**(本地落盘 + 按 `turnId` 取任意保留期内的轮次 + 轮次列表);**v0.77(切片 FV2)补上 `lanes[]`,并把图按 `{会话}#{路}` 分桶**(此前同一会话并行两路会互相顶掉,见 §7.4-19)。三者都**不改** design/19 §1 的发布会话范围(`web` / `web:<uuid>` / `sess-backthink`,裁决 01.4.5)、**不改**前端图桶的按会话分桶口径(裁决 16.4.2)。落盘载体是新表 `flow_graphs`(迁移 `0022`),GC 口径照 `events`/`documents`(env 键 + 启动期扫一次 + 定时扫 + 失败不退出)。**v0.80(切片 W1)**另开 **§4.11.1** 的**时间轴**端点(`GET /api/v1/admin/flow/timeline`)负责「**多轮按时间串联**」—— 本小节的语义(最近一轮 / 按 `turnId` 取某一轮 / `lanes[]` / `turns[]`)**一字未改**(两条出口读同一份落盘数据,分工见 §4.11.1(c))。
|
||
|
||
#### (d) `turnId` / `round` 的增量键与**缺席语义**(v0.77)
|
||
|
||
`graph_update` 帧的 `data` 与本端点返回的每张图(`graph` / `lanes[].graph`)**同一个结构**;v0.77 起多两个**可选**键(老客户端忽略未知键仍可用)。⚠️ **v0.79 变化**:`turnId` **不再只对真并行路出现** —— 编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)都带它,且 `graph.id` 据此对单路也追加 `#{回合标识}`(见下);`round` 的语义**一字未改**。
|
||
|
||
| 键 | 类型 | 语义 |
|
||
|---|---|---|
|
||
| `turnId` | string | 这张图所属**路**的权威回合标识(与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同值**,design/15 §2.5.9)。**缺席**语义:这一路**没有权威标识**(未接编排器的调用方、**后台思考 `sess-backthink`**、旧后端,以及 **v0.79 之前的单路历史帧**)—— 此时客户端按「该会话那一张图」处理,**不得**臆造一个 id。⚠️ **v0.79 起**编排器驱动的**每一路**(含**普通单路回合**与队首续跑路)都带该键 ⇒ 它**不再只对真并行路出现**。⚠️ 它**不是** `?turnId=` 的参数(后者是 `graph.id`,两者不同源,见 §7.4-23) |
|
||
| `round` | number | 这张图所属的**会话轮次号**(每会话单调;同一条消息引出的多条路**共享**同一个值)。客户端据此只保留最新一轮的泳道、丢掉更早轮次的路。**缺席**(`0`)语义:旧后端 / 未设置 —— 客户端保守处理(保留已收到的路,**不按轮次淘汰**),**不得**臆造轮次号 |
|
||
|
||
- 真并行(上行 `chat.parallel`)时同一会话**每一路各发自己的帧**(`data.id` / `data.turnId` 各不相同),因此两路不再互相覆盖;每路的图也**各占一行落盘**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 因此不再把另一路整行顶掉)。
|
||
- `graph.id` 的形状自 v0.77 起是 `{会话}-{序号}[#{权威回合标识}]`(序号是发布器**进程内**的建图序号、重启即从头开始)。🔴 **v0.79 起**后缀对**单路也追加**(此前只对真并行路)—— 即**每一条编排回合**的 `graph.id` 都含 `#`(`web-7` → `web-7#turn-3f9c…`);**老行**(v0.79 之前落盘的**单路**行)仍是 `{会话}-{序号}`。它是 `?turnId=` 的参数值,**不透明**,**可能含 `#`** ⇒ 作 query 值必须 URL 编码(Web 前端用 `URLSearchParams`,已正确编码),且**客户端一律用响应给出的值、不要自拼**。
|
||
|
||
---
|
||
|
||
### 4.11.1 链路图**时间轴**(`GET /api/v1/admin/flow/timeline`;v0.80 新增,切片 W1)
|
||
|
||
**为什么有这条**:`§4.11` 的老端点回答的是「**最近一轮**」与「**已落盘轮次的键列表**(`turns[]`)」—— 两者都不足以把多轮**按时间串起来**;`graph_update` 又只覆盖当前这一轮。于是面板上「所有消息按时间顺序串起来」这件事此前**没有任何数据出口**(主人 2026-10-06 原话)。本端点补上它,且**不改**老端点的任何语义。
|
||
|
||
#### (a) 请求与响应
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 方法 / 路径 | `GET /api/v1/admin/flow/timeline` |
|
||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与 B13 / `design/19 §3` 同档 |
|
||
| 查询参数 | `sessionId`(**可选**;省略 / 空串 ⇒ 主会话 `web`)、`limit`(**可选**;缺省 **50**、硬上限 **200**(超上限即钳住)、`≤0` 同缺省、非整数 → `400 bad_request`)、`before`(**可选游标**;省略 = 最新那一页;给定时只返回**开始时刻严格早于它**的轮次;取值 = 上一次响应里的 `nextBefore`,也接受 RFC3339 或 Unix 毫秒;非法 → `400 bad_request`) |
|
||
| 响应体 | `{data:{sessionId,source,rounds[],nextBefore,hasMore,truncated,retentionHours,retentionEnabled}}` |
|
||
| `rounds[]` | **恒为非 null 数组**(没有轮次时是 `[]`),按**时间正序**(最早在前)。每项 `{turnId,round,startedAt,endedAt,lanes[]}`;`lanes[]` 恒为非 null 数组(每项 `{turnId,graph}`,按路的开始时刻升序) |
|
||
| `rounds[].turnId` | 这一轮**首路**的**图 id**(`graph.id`,真 id)。多路时它只代表首路,全貌在 `lanes[]`(每路的图 id 见 `lanes[].graph.id`、每路的**路标识**见 `lanes[].turnId`)⚠️ 与 `lanes[].turnId` **不同源**(前者是图 id = 老端点的 `?turnId=` 参数值;后者是路标识,见 §4.11(d)) |
|
||
| `rounds[].round` | number 或 **`null`**。`null` = **未知**(那批行早于轮次号落库)⇒ 客户端**不要**当成 `0`;**不保证连续**(轮次号 = 该轮首路的建图序号,真并行时中间会跳号) |
|
||
| `rounds[].startedAt` / `endedAt` | **UTC RFC3339 毫秒**(与本端点游标同一口径)。`endedAt` 为**空串** = 这一轮**尚未收尾**(内存里的在飞轮)⇒ 面板显示「进行中」,**不得**补一个结束时刻 |
|
||
| `rounds[].lanes[].graph` | **与 `graph_update` 帧的 `data` 逐字同形**(§3.4.5;`{id,nodes[],edges[],sessionId,turnId?,round?,startedAt,endedAt}`,节点 `props`/`log` 同 §1.1 的脱敏口径) |
|
||
| `source` | `"persisted"`(本页**至少有一轮**来自落盘;可能同时含内存里的在飞路)/`"live"`(本页**全部**来自内存:落盘源未接线,或该会话还没有落盘轮)/`"none"`(没有任何轮次) |
|
||
| `nextBefore` | 本页**最早一轮**的开始时刻(无轮次时 `""`)。`hasMore:true` 时**原样回带**成下一次请求的 `before` 即可继续向更早翻页 |
|
||
| `hasMore` | 库里**还有更早的轮次**没被本次返回(用 `nextBefore` 继续取) |
|
||
| `truncated` | 本次**不是全部**:`hasMore`(分页未到底)**或** 落盘源未接线(只剩内存最近一轮)。面板据此标注「更早的轮次未显示」,**绝不静默少一块**。⚠️ **它不携带「是否被保留期清扫」这一信息**(v0.81 收紧措辞):`truncated:true` **不等于**「更早的轮次过期了」—— 也可能是「还有下一页没取」。能够分开这两件事的是 **v0.81 新增的 `retentionBoundaryReached`**(见下一行)⇒ **只有它 `true` 时**才可以说「更早的轮次已过保留期」 |
|
||
| `retentionBoundaryReached` | **bool,v0.81 新增**(切片 X1)。**`true` = 该会话确有更早的轮次已被保留期清扫删掉,且本次已取到它现存最早的一端**。判据三条**缺一不可**:① 保留期清扫已启用(`retentionEnabled:true`);② `hasMore:false`(本次不是半页);③ 现存最早一轮的**轮次号 > 1**(轮次号每会话**自 1 起、跨重启单调** ⇒ 号更小的轮次曾发过号、又不在库里,而 `flow_graphs` 的唯一删除者是保留期清扫)。⚠️ **盲区(如实,登记在契约里)**:⑴ 一个会话若**整段历史都被清扫**(一条可用行都不剩)⇒ 发号从 1 重新开始 ⇒ 本字段为 **`false`(少说,绝不多说)**;⑵ 同一会话里有**轮次号未知**的老行 ⇒ `false`;⑶ 唯一可能**错报**的路径是该会话**最早的轮次落盘失败**(号已用掉、行从未进库)⇒ 会被算成「已清扫」(概率低,只在库写失败时出现)。⇒ **客户端话术**:它 `true` ⇒ 「更早的轮次已过保留期」;它 `false` 且 `truncated:true` ⇒ 「更早的轮次未显示」;`source:"live"` ⇒ 「只拿到最近一轮」 |
|
||
| `retentionHours` / `retentionEnabled` | 落盘保留期(小时,**下取整**)与它**是否启用**(`FLOW_GRAPH_RETENTION`,既有 env 键、默认 168h)。`retentionEnabled:false` ⇒ 「更早的轮次」**不是**被清扫掉的,**不要**说成过期 |
|
||
| 归档 | **不审计**(只读查询,与 B13 同档) |
|
||
| 失败 | 内存与落盘**都没接线** ⇒ `500 {"code":"flow_unavailable"}`(**不假装「这个会话没有轮次」**);落盘读取失败 ⇒ 同样 `500 flow_unavailable`(**不把「读库失败」伪装成「没有这一轮」**);`limit` / `before` 非法 ⇒ `400 bad_request` |
|
||
|
||
#### (b) 语义硬约束(客户端必须照这个读)
|
||
|
||
1. **它是「多轮并联」,不是「最近一轮」。** 老端点(§4.11)继续负责「最近一轮 + `lanes[]` + `turns[]`」,本端点负责「按时间串起来的多轮」。两者**读同一份落盘数据**,但**语义不同**:本端点的 `rounds[]` 是**时间轴**(正序、分页),老端点的 `graph` 是**你要的那一轮**。
|
||
2. 🔴 **一句话轮(round)与路(leg)不可混用**(`design/19 §6.0`):`rounds[]` 的一格 = **一轮**(一条用户消息引出的全部工作),一轮里**每一路**在 `lanes[]` 里各有 `{turnId,graph}`。真并行时同一轮会有 2~3 条路 —— **不要把两条路画成两轮**。
|
||
3. 🔴 **保留期只说真话(v0.81 定稿口径)**:过期轮次**不会**出现在 `rounds[]` 里(已被清扫),本端点**不猜、不补空块**。**两件事必须分开读**:`truncated` 的成因**只有两个**(分页未到底 / 落盘源未接线),**不含**「被清扫」;「更早的轮次是不是被清扫掉了」由 **`retentionBoundaryReached`** 单独回答(判据与盲区见上方 (a) 表)。⇒ 面板要说「更早的轮次已过保留期」时,**唯一**判据是 **`retentionBoundaryReached:true`**;**不得**因为「返回的轮次比想象中少」、`truncated:true` 或 `retentionEnabled:true` 就断言被清扫了(最后一个只说明「清扫开着」,不说明「发生过清扫」)。⚠️ 该字段**少说也不说谎**:它 `false` **不等于**「没被清扫」(整段历史被清扫 / 有轮次号未知的老行 / 最早那一轮落盘失败,三种盲区见 (a) 表)。
|
||
4. **分页不重不漏**:`before` 恒为「严格更早」,`nextBefore` 恒为「本页最早一轮」。逐页往更早翻时,**已返回过的轮次不会再出现**。⚠️ 游标精度到**毫秒**(与 `startedAt` 字符串同一口径);同一毫秒里启动的两个**不同**轮次在极端情况下可能被一并跳过 —— 实际不成立(同一会话的轮次启动间隔远大于 1ms),但这是**真实存在的理论边界**,不作隐瞒。正解(若将来需要)是复合游标 `(startedAt, roundKey)`,属**新契约**、本版未做。
|
||
5. **内存里的在飞轮会出现**(`source` 可能是 `live`、`endedAt` 可能是空串),这是**有意**的:面板打开时最新一轮还在跑,不该在时间轴上缺席。它与刚落盘的那一份**同一条路只出现一次**(按图 id 去重,以落盘那份为准)。
|
||
6. **只读、不发布、不落盘**:拉时间轴**不产生** `graph_update` 帧、不改任何会话的实时图、不动保留期、不写审计。
|
||
7. **白名单外的会话是诚实空态**:隔离渠道会话(群聊/陌生人 `sess-xxx`)从不建图、也从不落盘 ⇒ `rounds:[]` + `source:"none"`。**这不是**「这一轮没有链路」。
|
||
8. **不替代 `graph_update`**:实时帧仍是「正在跑的那一轮」的实时来源;本端点补的是「多轮 + 已错过帧」。
|
||
|
||
#### (c) 与老端点 `turns[]` 的分工(别把两者混成一条时间轴)
|
||
|
||
`§4.11` 的 `turns[]` 是**按路(leg)**列的条目(一轮两路会显示两条,形状 `{turnId,createdAt}`);本端点的 `rounds[]` 是**按轮(round)**分组的时间轴(一轮两路并进同一格的 `lanes[]`)。⇒ 面板要「轮次列表(含各路)」就用本端点;要「按图 id 精确取某一轮」仍用老端点的 `?turnId=`。
|
||
|
||
#### (d) 未确证(如实)
|
||
|
||
- **`graph_json` 破损行**:SQL 侧用嵌套 `json_valid` **保护**(坏行不会拖垮整条查询),但该行若落进本页,解码会**如实报错** ⇒ `500 flow_unavailable`(与既有 `graph` / `graph_turns` 口径一致:读不懂就报错,绝不把坏行当「没有图」)。**这条路径没有测试**(2026-10-06 复核:`internal/services/gateway/admin_flow_timeline_test.go` 现有 9 条用例,无一条喂坏 `graph_json` ⇒ 结论仍是「未确证」)。
|
||
- **前端渲染与真机面板联调**不在本条证据内(后端出口的单测覆盖见切片 W1 报告)。**代码级已确证**:前端有 `view="timeline"` 模式(`frontend/web/src/admin/modules/Flow.tsx`)+ `flow/TimelineView.tsx` / `useFlowTimeline.ts`;真机 `GET /api/v1/admin/flow/timeline`(**无凭据**)回 **`401 unauthorized`**(路由已随在跑产物部署,**不是** `404`)。**仍未确证**:浏览器里真面板跑通 —— **为什么验不了**:本轮禁重启后端 / 禁换产物,且取不到 owner 凭据,无法做面板联调(红线:不重启后端、只读验证)。
|
||
|
||
#### (e) v0.81 行为修复:`rounds[].round` 跨重启**不再重复**(切片 X1)
|
||
|
||
- **修的是什么**:轮次号原本只是发布器**进程内**计数 ⇒ 后端一重启就从 **1** 重来,而 `flow_graphs` 保留 **168h** ⇒ 时间轴按轮次号分组时把**重启前那一轮**与**重启后那一轮**并成**一块**(真机实测 `session=web`:同一分组键下 `startedAt` = 13:52:53 / 15:42:27 / 18:40:44 三次真实轮被并成「第 1 轮 · 3 路」的失真块)。
|
||
- **修法**(`internal/runtime/flowgraph/roundseed.go` 的 `MaxRound` + `internal/runtime/loop/flow_roundseed.go`):发布器在**本进程第一次**给某会话发号之前,从落盘侧读一次该会话**已落盘的最大轮次号**,把计数器播到它之上;同一会话的并发首轮会**等这一次播种**结束,而不是各自用未播种的计数器发号。落盘出口没有该能力时(测试桩 / 别的实现)不播种,行为与修复前逐字相同。
|
||
- **对客户端的意义**:契约里「`round` = 该轮次号(每会话单调)」这句现在**真的成立**(v0.81 之前只是声明)⇒ 客户端**可以**放心用 `round` 做分组与显示;块与块之间仍**不画因果连线**(时间相邻 ≠ 因果,见 §4.11.1(b)-2)。
|
||
- ⚠️ **边界(如实)**:① 播种**尽力而为** —— 读库失败只记一条 WARN 并退回**进程内计数**(= 修复前的行为,**轮次号仍可能重复**),且**不重试** ⇒ 那种情况下客户端仍应以 `startedAt` 为准;② 库里为空 / 该会话历史已被**整段**清扫 ⇒ 从 1 起(与修复前一致);③ `rounds[].round` 仍**可为 `null`**(老行没有轮次号),且**不保证连续**(真并行时中间会跳号)。
|
||
|
||
---
|
||
|
||
### 4.12 记忆整理「试算」(`POST /api/v1/admin/memories/tidy/dry-run`;v0.75 新增)
|
||
|
||
**为什么有这条**:记忆整理是一件**低峰定期后台任务**(`design/23 §4`):默认只在安静时段(`MEMORY_TIDY_QUIET_START_HOUR`–`_END_HOUR`,默认 `23:00–07:00`)由心跳开跑。于是在它第一次开跑之前,库里**既没有候选也没有轮次留痕** ⇒ 面板只能空着,主人无法判断「整理会不会乱合并」。本端点把**同一套规则**当场算一遍:候选直接回在响应里,**一行都不写**。
|
||
|
||
#### (a) 请求与响应
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 方法 / 路径 | `POST /api/v1/admin/memories/tidy/dry-run` |
|
||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同页的 `/admin/config-facts`、`/admin/memories` 同档 |
|
||
| 请求体 | **可为空**(端点不读 body)。没有参数:阈值/上限一律取**进程生效的既有配置**(`MEMORY_TIDY_RECALL_THRESHOLD` / `_SCAN_LIMIT` / `_CANDIDATE_LIMIT` / **`_COMPARE_LIMIT`(v0.77 新增)**),端点**自己不带任何旋钮** |
|
||
| 响应体 | `{data:{dryRun,recallMode,durationMs,ruleVersion,partitions,scanned,compared,pairs,vetoed,vetoReasons,truncated,candidates[]}}` |
|
||
| `dryRun` | 恒为 `true`。它是**结构化**判据:这份响应**没有**改动任何记忆(客户端不必只靠本地硬编码文案说明这件事) |
|
||
| `recallMode` | **如实上报本轮实际选用的召回路径**(不再是「配置态」):`"embedding"`(语义向量召回)|`"lexical"`(零依赖词项余弦)|`"mixed"`(**部分分区降级**、其余走向量)|`""`(**没有候选可比** ⇒ 本轮没做召回,不是降级、也不该显示成空)。⚠️ v0.91 起语义收紧:旧实现把「全量降级」静默报成 `embedding`,面板因此看不出降级 |
|
||
| `durationMs` | 本次试算的墙钟耗时(毫秒) |
|
||
| `ruleVersion` | 规则集版本(`internal/runtime/memory` 的 `TidyRuleVersion`,进候选幂等键;**改判定规则必须同时升它**) |
|
||
| 观测口径 | `partitions`(含在册条目的分区数)/ `scanned`(扫描条数)/ `compared`(**真正参与两两比对**的条数,受 **`MEMORY_TIDY_COMPARE_LIMIT`** 与**时间预算**两道闸约束 —— 因此 `compared < scanned` 时要看 `truncated` 是不是指出了原因)/ `pairs`(相似度 ≥ 召回门的对数)/ `vetoed` 与 `vetoReasons`(否决数与原因分布,如 `scope`/`persona`/`project`/`source`/`temporal`/`numeric`/`conflict`/`low_confidence`) |
|
||
| `truncated` | **可选**数组:被护栏截断的分区标签(`<分区>(scan)` / `(compare)` / `(candidates)` / **`(budget)`**)。它**不是**候选;**键缺席 = 本轮没有任何闸被打满**(客户端可把 `candidates:[]` 读成「规则判过了,确实没有」)。前三种说明「配置的配额比本轮实际看到的少」;**`(budget)` 是 v0.77 新增的第 4 种** —— 被**墙钟时间预算**截断(整分区跳过,或两两比对中途收手):成因是**本次调用的时间上限**而不是配置配额(运维动作不同),命中它时「候选少」由**预算**而非规则决定 |
|
||
| `candidates[]` | **恒为非 null 数组**(没有建议时是 `[]`)。每个元素见下表 |
|
||
| 归档 | **不审计**:它是只读计算(跑规则、不落库),与 `/admin/config-facts`、`/admin/flow/graph` 同一档 |
|
||
|
||
候选元素(`candidates[i]`):
|
||
|
||
| 字段 | 说明 |
|
||
|---|---|
|
||
| `action` | `merge` / `link` / `archive` / `reclassify`(`keep` 不进候选队列)。**`link` = 相似但互不包含 ⇒ 只建链接、绝不合并**(design/23 §1.3 的「红牛 vs 冰美式」) |
|
||
| `sourceIds` | 被处理的记忆 id 数组(恒为非 null)。`merge` 时是被并入方(可多条);`link` 时是那两条 |
|
||
| `targetId` | `merge` 时的**存活条目**(超集);其余动作为 `""`(键省略) |
|
||
| `personaId` / `scope` / `projectId` / `kind` | 分区键与被处理条目的 kind |
|
||
| `proposedContent` / `mergedContent` | `merge` 的 diff 预览:被并入的原文 / 存活条目的内容(其余动作为 `""`) |
|
||
| `reason` | 机器可读理由(如 `containment_a_subset_b` / `similar_but_not_contained` / `one_off_event` / `kind_mismatch`)。**客户端按它分支,不要按本地文案** |
|
||
| `ruleVersion` / `scoreMin` / `scoreMax` | 规则版本与召回分数区间(当前实现两者相等:一对一个分数) |
|
||
| `evidence` | 判定证据:`similarity`(只用它**召回**,不是判据)、`recall_mode`、`containment`(`a_subset_b`/`b_subset_a`/`both`/`none`)、`containment_evidence[]`(缺哪一要素)、`veto_reason`、`kind_a`/`kind_b`/`proposed_kind`、`content_a`/`content_b`(两条原文,供面板 diff 预览) |
|
||
|
||
#### (b) 硬约束(客户端必须照这个读)
|
||
|
||
1. 🔴 **零副作用**:本端点**不写任何表** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测把它钉死。因此试算**不会**让面板的「候选队列」多出东西,也**不影响**当晚那次真跑的结果。
|
||
2. 🔴 **同一套规则,不是第二套实现**:候选由与低峰定时轮**共用**的代码路径产出(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限)。后端另有一条单测:同一批数据上「试算候选」与「真跑一轮落库的候选」逐字段一致。
|
||
3. 🔴 **失败绝不伪装成「没有建议」**:未接线 / 配置关闭 / 语义召回不可用 / 超时都**如实回错误码**(见下表),**不回** `candidates:[]`。客户端**不得**把 `candidates:[]` 与错误态渲染成同一件事 —— 前者是「算过了,这一轮确实没有建议」,后者是「没算出来」。
|
||
4. **语义召回不可用时不静默降级**:若进程接了 embedder 而它不可用(如 `llama.cpp / :11434` 连不上),试算**报 `tidy_recall_unavailable`** 而不是偷偷回退到词项余弦 —— 否则面板会把「语义路径没跑起来」当成「没有候选」。⚠️ 这是**试算专属**的严格:低峰定时轮与手动轮仍按既有口径回退到零依赖的词项余弦(`recallMode:"lexical"` 时可从 `evidence.recall_mode` 逐条读到)。
|
||
5. **只算不写 ⇒ 不审计,也不产生候选队列**:本端点不写审计链、不产生任何后续动作;apply/reject 是**另外的**、尚未实现的写端点(`design/23 §11`,本版**不做**)。
|
||
6. **它是「现在就能看」,不是「调度改了」**:试算**不受安静时段约束**(低峰窗口是心跳的调度口径,不是规则的一部分),也**不改变**整理周期与心跳(试算不写 `lastRun`、不影响 `nextDelay`)。面板**按下才跑**:它是一次真实规则计算(扫描 + 两两召回 + 包含判定),**不是看板指标** ⇒ 客户端**不要**自动轮询它。
|
||
|
||
#### (c) 失败码
|
||
|
||
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|
||
|---|---|---|---|
|
||
| `500` | `tidy_unavailable` | 组合根未注入试算出口(仅异常部署/测试) | 显示「未接线」,**不要**显示「没有建议」 |
|
||
| `409` | `tidy_disabled` | `MEMORY_TIDY_ENABLED=false`(显式关闭整理) | 提示「整理已被配置关闭」,并指向该 env 键 |
|
||
| `500` | `tidy_recall_unavailable` | 语义召回(embedding 服务)不可用 | 显示具体原因类别;**不要**显示 0 候选 |
|
||
| `503` | `tidy_timeout` | 试算超过服务端墙钟上限(3 分钟) | 可提示重试;**不要**显示 0 候选 |
|
||
| `500` | `tidy_dry_run_failed` | 其它内部失败(分区查询/规则执行出错) | 显示错误;原始内部细节**不会**出现在 `message` 里 |
|
||
|
||
> ⚠️ 本端点的错误 `message` 只说明**原因类别**,原始错误(可能含内网地址等内部细节)只进服务端日志。
|
||
|
||
### 4.12.1 记忆整理「试算」的**加性异步**(`?async=1` + B15;v0.76 新增 · ✅ 已实现(v0.77 校正))
|
||
|
||
**为什么有这条**:试算在**真机**上实测 **89.9 秒 / 744 条**(784 条那轮同量级)。同步 HTTP 会被反代/客户端超时切断,而**把上限抬高**(治理「护栏被打满 ⇒ 候选数被上限而不是规则决定」)只会让它更慢。因此加一条**可选**的异步路径:**默认行为一字不改**(不带 `async` 仍是 §4.12 的同步语义),只在显式带 `async=1` 时改走后台。
|
||
|
||
| 项 | 口径 |
|
||
|---|---|
|
||
| 触发 | `POST /api/v1/admin/memories/tidy/dry-run?async=1`(请求体与 §4.12 同:可为空)。**只有字面量 `1` 触发异步**:`?async=0` / `?async=true` / 缺省 / 空值一律走同步(老客户端逐字不变) |
|
||
| 立即响应 | **`202`** + `{data:{runId,status:"running",startedAt}}`;`runId` 为进程内不透明字符串(客户端当不透明值用,**不要**解析其结构);`startedAt` 是**那次运行真正开始**的墙钟时刻(UTC RFC3339)—— 它是那次运行的时间,**不是本次请求的时间** |
|
||
| 轮询 | `GET /api/v1/admin/memories/tidy/dry-run/{runId}`(B15)⇒ `running` / `done`(带 `result`)/ `failed`(带 `error`);**`running` 时 `result` 与 `error` 两键都缺席**(**不造空 `result`** 冒充「跑完了但没有建议」) |
|
||
| 结果形状 | `done` 时的 `result` = §4.12 同步响应的 `data` **逐字节同形**(两条路径走**同一个**投影函数,后端有单测按**原始 JSON 逐字节**比对;含 `candidates` 恒非 null) |
|
||
| 并发 | **同时只允许 1 个在跑**;第二个请求**复用既有 `runId`**(实现取「复用」而非「拒绝」⇒ **不引入新错误码**),回的 `startedAt` 是**那次正在跑的运行**的开始时刻;上一个跑完后,新请求才是新 `runId` |
|
||
| 墙钟上限 | 与同步**同一个 3 分钟**(`tidy_timeout` 的语义对两条路径都成立);超时 ⇒ 轮询回 `failed` + `tidy_timeout`(错误链含 `context.DeadlineExceeded`) |
|
||
| 生命周期 | **进程内存**(不落库、不新开端口);TTL **30 分钟**(从**完成时刻**起算,`running` 的记录不会被 TTL 淘汰;另有 32 条的条数闸兜底);**进程重启 ⇒ 旧 `runId` 失效** |
|
||
| 未知 / 过期 runId | `404` + `{error:{code:"tidy_run_not_found"}}`(见 §4.1 的 404 行);「从未存在」「超 TTL」「进程重启过」三种原因**刻意不区分**(进程内不留跨重启痕迹) |
|
||
| 鉴权 | 与 B14 同档(`writeAuth` = owner/admin);**不审计** |
|
||
| 零副作用 | 🔴 与同步路径**同一条硬约束**:异步跑的是**同一个** `TidyService.DryRun`(同一套规则)⇒ 同样**一行都不写** `memory_tidy_candidates` / `memory_tidy_runs`、**绝不改 `memory_items`**(memory 包与网关两侧都有**逐行快照比对**的测试钉住) |
|
||
| 失败 | 沿用 §4.12 的失败码(`tidy_unavailable` / `tidy_disabled` / `tidy_recall_unavailable` / `tidy_timeout` / `tidy_dry_run_failed`);`failed.error = {code,message}` 走**同一个**失败码映射点 ⇒ 两条路径不可能漂移;**不回空候选数组假装「没有建议」**。⚠️ `MEMORY_TIDY_ENABLED=false` 时异步**仍接受**(回 `202`),失败在**轮询**里如实报 `tidy_disabled`(同步路径仍是立刻 `409`) |
|
||
| 破坏性 | **无**(新可选参数 + 新端点;老客户端逐字不变) |
|
||
|
||
> ✅ **落地校正(v0.77)**:本小节与 B15 在 v0.76 登记时是「契约先行、尚未落地」;**切片 MT-A 已落地**(`internal/runtime/memory/tidy_dryrun_async.go`、`internal/services/gateway/admin_memory_tidy_async.go`、`routes.go` 注册 `GET …/dry-run/{runId}`)。上表各行即**实现口径**,不再有「实现口径见提交」的占位。仍然如实的两条边界:① 后台运行**不随进程关停取消**(关停时在途的异步轮次会被丢掉 ⇒ 客户端自然拿到 `404`);② 结果**不是缓存**(每次调用重算,两次之间数据变了结果就可能不同)。
|
||
|
||
---
|
||
|
||
### 4.13 未读实时面:`message_persisted.data.unreadCount`(v0.76 新增 · ✅ 已实现)
|
||
|
||
**帧形状(纯增量)**
|
||
|
||
```json
|
||
{"type":"message_persisted","sessionId":"web",
|
||
"data":{"message":{…,见 §4.4},"unreadCount":3}}
|
||
```
|
||
|
||
| 项 | 口径 |
|
||
|---|---|
|
||
| 键名 / 类型 | `data.unreadCount`,整数 |
|
||
| 含义 | 该会话此刻的**绝对未读数** = 比会话读游标(`chat_sessions.read_at`)**严格更新**的消息条数。**不是**「自上次以来新增几条」,也**不是**每端各一份 |
|
||
| 口径来源 | 与 `GET /api/v1/sessions` 的元素 `unreadCount` **同一个谓词**(`created_at > read_at`)、**同一条游标** ⇒ 两条面在同一时刻给出同一个数字(后端有单测逐会话比对两者相等) |
|
||
| 出现条件 | **只在 `>0` 且确实算得出来时出现**(Go 的 `omitempty` 语义)。**缺席 = 老行为**(v0.75 及更早即如此),客户端**忽略未知键**即可 |
|
||
| 缺席的三种情形 | ① 未注入历史存储;② 注入的存储没有单会话未读视图;③ 该会话**没有会话行**(隔离渠道会话 `sess-*`、后台思考会话 —— 它们连读游标都没有)。三种情形后端都**不**写 `unreadCount:0`:**「算不出来」绝不冒充「已读完」** |
|
||
| 何时该用 | 客户端落下这帧时**直接用该数字刷新角标**(不必再拉一次 `GET /sessions`)。键缺席时**保持原值**并回落 `GET /sessions` 收敛 —— 不要当成 0 |
|
||
| 何时会归零 | 由 `session_read` 帧(§3.4.1 状态帧表 / §1 U16)在任一客户端 `POST /sessions/{id}/read` 后广播;**多端共享单游标**:Web 读了,移动端的红点也清 |
|
||
|
||
**多端共享单游标的取舍(与安卓侧已对齐,不是缺陷)**
|
||
|
||
后端只有一个主人账号、这一层没有按设备的身份 ⇒ 游标是**会话作用域、单所有者**(`chat_sessions.read_at`,迁移 `0010`):**任一端已读即所有端已读**。因此 `unreadCount` 是「主人还没看过的消息数」,**不是**「这台设备还没同步的消息数」。
|
||
|
||
- 🔴 客户端**不得**把它当「本端未读」渲染成「这条消息我没收到」(那就把跨端同步误报成了漏消息)。
|
||
- 🔴 **不要**自行改成「每端独立未读」(会引入第二份游标、并在多端之间互相打脸);如确需改,属**契约裁定**事项,须先写进本节。
|
||
|
||
**兼容性(逐字无破坏)**
|
||
|
||
- 纯增量:**新可选键**,无新帧、无新端点、无既有键改名或语义变化。
|
||
- 老客户端(不读 `unreadCount`)行为**逐字不变**;新客户端读到缺键时的正确答案是「不知道」,不是「0」。
|
||
- `channel_persisted`(隔离渠道会话)走**同一个**载荷构造函数 ⇒ 口径天然一致;实践上它永远不带该键(那类会话没有 `chat_sessions` 行,见上表第 4 行)。
|
||
|
||
**服务端实现位置**(供对照,非契约):`internal/services/gateway/broadcast.go`(帧构造,Web 路径与 IM 路径**共用同一个函数**)、`internal/services/gateway/session_read.go`(`unreadCountOf` + 可选窄接口 `sessionUnreadCounter`)、`internal/runtime/history/session_read.go`(`UnreadCount`)。
|
||
|
||
### 4.14 插件管理面板接入点与动作分发(v0.78 新增,切片 PLG-2 / PLG-4)
|
||
|
||
#### (a) `GET /api/v1/admin/panels` —— 面板接入点快照(**v0.78**)
|
||
|
||
**门**:`viewerAuth`(owner / admin / viewer 皆可,见 §2.1 A13)。**响应按调用者角色过滤**:viewer 只拿到只读区块(`status` / `log` / `table`,且 table 的**行内动作被摘空**、`form` **整体不出现**、区块全空的面板**整条不出现**)。**未接线面板中枢时回 `501 panels_unavailable`**(**绝不回空列表**把「没接线」伪装成「没有页面」)。
|
||
|
||
响应 `data`:
|
||
|
||
```jsonc
|
||
{
|
||
"role": "owner", // 本次快照按哪个角色裁剪(回显,便于客户端自检)
|
||
"groups": [ // §5.2.1 的 nav.group 一级
|
||
{
|
||
"id": "plugins", // 分组 id(本期闭集只有 plugins)
|
||
"title": "插件", // 分组显示名 = 宿主文案(分组本身没有声明)
|
||
"order": 20, // 分组排序 = 组内最小 order
|
||
"items": [ // nav.item
|
||
{
|
||
"pluginId": "web-search",
|
||
"id": "providers",
|
||
"title": "搜索提供商",
|
||
"description": "…", // 可选,缺省省略
|
||
"icon": "search", // 可选(宿主内置图标名),缺省省略
|
||
"order": 20,
|
||
"route": "plugin/web-search/providers", // **相对**管理面板前缀
|
||
"sections": [ /* design/09 §5.2 的 section 声明**原样**下发(字段名逐字同形) */ ]
|
||
},
|
||
{
|
||
"pluginId": "browser", // **v0.82 新增**(切片 Y4,见 §4.14(d))
|
||
"id": "settings",
|
||
"title": "浏览器与截图",
|
||
"icon": "browser", // 前端把它映射到侧边栏的「图片」图标(图标表闭集里没有专门的浏览器图标)
|
||
"order": 30,
|
||
"route": "plugin/browser/settings",
|
||
"sections": [ /* 4 个区块:form 配置 + 3 张只读 table(状态 / 指纹 / 截图) */ ]
|
||
}
|
||
]
|
||
}
|
||
],
|
||
"pluginIds": ["browser", "web-search"] // **声明过面板**的插件 id(**已排序**);≠ 已安装列表。⚠️ v0.82 的 `browser` 项在本版**登记时仍待提交**,提交前真机上是 `["web-search"]`
|
||
}
|
||
```
|
||
|
||
- `sections[]` 就是 `pkg/plugins/sdk` 的 `Section` 形状(`type` / `id` / `title` / `fields` / `submit.action` / `source.action` / `columns` / `rowActions` / `limit`),**宿主不翻译**;插件**不注入任何 JS/CSS/HTML**。`type ∈ {form,status,table,log}`(`form` 是可写区块,其余只读)。
|
||
- `route` 是**相对片段**(`plugin/<pluginId>/<panelId>`,**不含** `ADMIN_PATH` 前缀)⇒ 前端拼成 `<prefix>/plugin/<pluginId>/<panelId>`。
|
||
- `pluginIds` 是给「插件与能力」页做**对照**用的:拿它与 `GET /api/v1/admin/plugins/installed` 的 `name`/`id` 求**差集**即可得到「**已装但未声明任何页面**」的插件,客户端应把这类插件**如实显示成「未声明管理页面」**,**不得**藏掉。
|
||
|
||
**错误**:`501 {error:{code:"panels_unavailable"}}`(未接线);`403 unknown_role`(角色无法识别,理论不可达,fail-closed)。
|
||
|
||
> ⚠️ **当前运行期快照不是空的**:内置插件在**组合根装配期**声明给中枢 ⇒ 正常部署下有两个条目:
|
||
> - `web-search`(`plugins/tools/io/search` 的 `AdminPanels()`,随 v0.78 起);
|
||
> - `browser` / `settings`(`plugins/tools/browser` 的 `AdminPanels()`,**v0.82 新增**,切片 Y4,见 §4.14(d))—— 它写在 `cmd/cyrene/wire_frontend.go` 的 `wirePanelHub` 里(`wireBrowserPanel(hub, a, a.log)`,**登记时该挂点仍在工作树、未提交**);删掉这一行**没有任何编译期或运行期症状**,只是目录里少一页、六个动作全 `404 action_not_declared`,因此有一条**源码级**用例盯着它。🔴 **在跑的那份产物里没有它**(真机已核:启动日志全篇 **0 条 `browser panel declared`**,而该函数成功声明时必打这一行;同一次启动里 `self wake tool registered tool=request_think` 是有的)⇒ 那一刻真机上 `pluginIds == ["web-search"]`。
|
||
>
|
||
> ⇒ 接线后 `pluginIds == ["browser","web-search"]`(**已排序**)。热装插件(MCP 等)**目前恒为「未声明管理页面」**(声明来源 `manifest.json` 的读取未实现,见 §7.2)。
|
||
|
||
#### (b) `POST /api/v1/admin/plugins/{id}/actions/{action}` —— 插件动作分发(**v0.78**)
|
||
|
||
**路径**:`{id}` = 插件 id(面板目录键,本期即插件名,如 `web-search`);`{action}` = 插件**声明过的**动作名(如 `plugin.listProviders`)。
|
||
|
||
**门**:路由中间件 `viewerAuth`,**写动作在 handler 内再按 owner/admin 收紧**(见 §2.1 A14)。
|
||
|
||
| 情形 | 状态 | `error.code` | 说明 |
|
||
|---|---|---|---|
|
||
| 面板中枢未接线 | 501 | `panels_unavailable` | 未接线就说未接线 |
|
||
| 动作名未在声明里出现过 | 404 | `action_not_declared` | §5.2.1「只接受该插件声明过的动作名」;**未声明不是「没权限」** |
|
||
| 插件已被显式停用 | 409 | `plugin_disabled` | 声明还在,入口与动作都下线 |
|
||
| `kind=write` 且调用者不是 owner/admin | 403 | `forbidden` | 写面 = `form.submit` + `table.rowActions`(§5.4.1) |
|
||
| 已声明,但宿主**没有登记它的处理器** | 501 | `action_not_implemented` | ✅ **v0.79 起不是兜底档**:内置 web-search 的动作已全部登记 ⇒ 这条只在「声明写了却没实现」时出现 |
|
||
| 请求体不是合法 JSON,或超过 1 MiB | 400 | `bad_request` | 绝不把坏体当空体继续跑 |
|
||
| 载荷形状不对(缺 `values` / 缺 `provider` / 类型错) | 400 | `invalid_payload` | **v0.79**;由动作处理器给出(`pluginmgr.ActionCodeInvalidPayload`) |
|
||
| provider 名本期未登记 | 400 | `unknown_provider` | **v0.79**;`plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider` |
|
||
| 该家没配齐(缺 key 或缺接口地址) | 400 | `not_configured` | **v0.79**;`plugin.testProvider` |
|
||
| 可变源存在但读不出来(损坏/权限) | 500 | `config_unreadable` | **v0.79**;**不覆盖**原文件 |
|
||
| 可变源写不进去(磁盘/权限) | 500 | `config_write_failed` | **v0.79**;本次改动**没有**生效 |
|
||
| 探针(`plugin.testProvider`)的上游结论 | 502 | `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown` | **v0.79**;`no_results` 的文案明说「连通正常但没查到结果」 |
|
||
| 其它执行失败(内部错误) | 502 | `action_failed` | **兜底档**;详细原因只进后端日志 |
|
||
|
||
**请求体**:可选 JSON。**空体 ⇒ `payload=null`**(读来源动作通常无参)。**原样交给执行器**,宿主不解释插件的数据形状、**不认识任何 URL**(**不给插件开 SSRF 面**)。
|
||
|
||
**响应 `data`**:`{pluginId,action,kind,result}` —— `kind ∈ {read,write}`(`read` = `table`/`log` 的 `source` **与字段下拉的 `optionsFrom`**;`write` = `form.submit` / `table.rowActions`;同名动作同时出现在读面与写面时**一律按 write**);`result` = 动作处理器返回值**原样透传**。
|
||
|
||
**审计**:**每一次结算都落一行**,`what = "plugin.action"`,`result ∈ {ok,error,denied,not_declared,unimplemented,unavailable,bad_request}`。⚠️ 持久化审计记录仍只有 `who/what/when/result` 四字段 ⇒ **未含 pluginId / action**(它们只在**结构化日志行**里,见 §7.2);**v0.79 新增**:服务端日志行附加结构化失败码 `code` —— **只进日志,不进 DB 记录体、不含请求体**。
|
||
|
||
> ✅ **v0.79 的生产路径事实(切片 L1/PLG-2 执行面接线)**:组合根在 `wirePanelHub` 里调用 `wireBuiltinPluginActions`,把内置 `web-search` 的**四个动作 + 下拉候选来源**登记到面板中枢 ⇒ **这些动作真的执行**(真读写可变源、探针真发一次 HTTP),**不再一律 501**;装配期另把「**声明了却没有实现**」的动作与候选来源各记一条 WARN(`action_not_implemented` 因此是**缺口信号**而非常态)。⚠️ **残留(如实,见 §7.2)**:✅ **v0.80 起已热重载**(保存后重建工具 + 原子替换注册表条目,**下一轮生效、不需要重启**;若新配置会让后端下次起不来则**按字节回滚**并回 **`409 config_unstartable`**);探针**只用 `httptest` 假上游**验证过,**未对真实上游做过一次连通验证**(此项仍未做)。
|
||
|
||
#### (c) `web-search` 插件的动作面(v0.78 声明,**v0.79 起真可用**,切片 PLG-4 / L1;**v0.80 起可配主 provider / 降级链 + 保存后热重载**,切片 W3;供上面 (b) 的分发面使用)
|
||
|
||
插件 id `web-search`;可变源落点 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。组合根「**先读可变源、缺失回落 `.env` 播种**」:文件一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用(⚠️ **运维视角的行为变更**,见 §5)。🔴 **不新增任何 env 键**(`.env` 已冻结为引导面;本切片未改 `.env` / `.env.example`)。
|
||
|
||
`plugin.listProviders` 返回的表格行(`rows[]` 元素):`{ "provider": "bocha", "state": "ok", "enabled": true }`(**凭据与地址都不回**)。`state` 取值(闭集)与判定顺序(根因在前):`unknown`(名字本期没登记)→ `disabled`(显式停用)→ `not_configured`(名字已登记但没配齐:云家缺 key / searxng 缺地址)→ `ok`(其余)。⚠️「凭据失效」**不是** `state` 取值(它只能由一次真实调用得知,属 `plugin.testProvider` 的结论)。
|
||
|
||
四个动作 + 一个候选来源(**本插件声明的全部动作**;全部已登记生产处理器):
|
||
|
||
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|
||
|---|---|---|---|---|
|
||
| `plugin.listProviders` | 列出各条 provider 配置 | read(table `source`) | `{}` | `{"rows":[{"provider":"searxng","state":"ok\|not_configured\|disabled\|unknown","enabled":true}]}`(**凭据/地址不回**) |
|
||
| `plugin.providers` | 下拉候选:本期**全部已登记**的 provider(**带显示名**) | read(字段 `optionsFrom:"plugin:providers"`) | `{}` | `{"options":[{"value":"zhipu","label":"智谱 BigModel(zhipu)"},…]}`(稳定排序,**含尚未配置的家**)。🆕 **v0.84 形状变更(对象数组)**:此前是**裸字符串数组**(`{"options":["zhipu",…]}`)。🔴 **两条硬口径**:① **`label` 仅供显示,`value` 才是落盘值** —— 把 `label` 写回 `provider` 字段会把配置文件写坏(提交给 `plugin.saveProvider` 的必须是 `value`);② **前端解码器本就兼容三种形状**(裸字符串 / `{value,label}` / `{id}`)⇒ 仍返回裸字符串的其他插件与老声明**不受影响**(零破坏性)。前端「候选开关」写回的是 `value`;表格单元格按 `value→label` 索引显示、查不到回落显示原值 |
|
||
| `plugin.saveProvider` | 新增/修改一条(按 `provider` **upsert**);**同一次提交可带两个文件级字段** `primary` / `fallback`(主 provider 与降级链,**缺位 = 不改动**) | write(form `submit`) | `{"values":{"provider":"bocha","apiKey":"…","baseUrl":"https://…","enabled":true,"primary":"bocha","fallback":["searxng"]}}` | `{"saved":true,"provider":"bocha","created":true,"enabled":true,"primary":"bocha","fallback":["searxng"],"hotReload":{"applied":true,"registered":true}}`(`primary` / `fallback` 是**落盘后**的取值,没给也如实回显;`hotReload` 见下) |
|
||
| `plugin.testProvider` | 测试连通(**真发一次最小检索**) | write(table `rowActions`) | `{"row":{"provider":"bocha"}}`(亦可给 `{"values":{…}}` 或顶层 `provider`) | `{"ok":true,"provider":"bocha","count":1,"latencyMs":123}`;**没通 = 非 2xx**(见错误表),**不在 200 里回 `ok:false`** |
|
||
| `plugin.reauthProvider` | 重配凭据:把该条带回表单 | write(table `rowActions`) | `{"row":{"provider":"bocha"}}` | `{"values":{"provider":"bocha","apiKey":"****cdef","baseUrl":"https://api.bocha.cn","enabled":true,"primary":"bocha","fallback":["searxng"]}}`(**`apiKey` 只回掩码**;前端对 secret 字段一律不回填输入框,**空 = 不改动**)。⚠️ **v0.80 起 `values` 另带两个文件级字段** `primary`(字符串)/ `fallback`(字符串数组)—— **不带它们**的话,面板「清空降级链」在客户端三态写回里会落成「缺位 = 不改动」(**清不掉**) |
|
||
|
||
`plugin.saveProvider` 的**三态写回哨兵**(写侧语义,前端须照此送值):`values.apiKey` **缺位** ⇒ 保留盘上原值;**空串** ⇒ 显式清空;非空 ⇒ 覆盖。`values.baseUrl` / `values.enabled` **缺位** ⇒ 保留;给出(含 `""` / `false`)⇒ 采用(`enabled` 给非布尔值 ⇒ `400 invalid_payload`,**不静默保留**)。`values.provider` **必填**,且必须是本期已登记的接入位(`searxng` / `zhipu` / `aliyun_iqs` / `bocha` / `volcengine` / `baidu_qianfan` / **`tencent_wsa`** / **`metaso`**),否则 `400 unknown_provider`。🆕 **v0.87:名单由六家扩到八家**(新增腾讯云·元宝搜索与秘塔搜索),**端点形状与错误码一字未改**。
|
||
|
||
**(v0.80 新增)两个文件级字段的三态写回**(与条目字段同一套哨兵语义):`values.primary` **缺位** ⇒ 保留盘上值;给字符串(含空串 / `off` / `none`)⇒ 采用 —— **空串与 `off` / `none` 都是「关掉这把工具」的哨兵**(`off` / `none` 大小写与空白不敏感),`WEB_SEARCH_PROVIDER` 与面板**共用同一套关闭语义**(不另造第二套)。`values.primary` 指名一家**已登记但未配齐**的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒,因为那份配置会让后端下次起不来)。`values.fallback` **缺位** ⇒ 保留;给数组(或逗号 / 顿号 / 分号 / 换行分隔的字符串)⇒ 采用(去空白、去重、丢空项);元素**未登记** ⇒ `400 unknown_provider`;给非数组 / 非字符串 ⇒ `400 invalid_payload`(**不静默当空数组** —— 那等于一次保存悄悄清空降级链)。降级链**允许**放本期已登记但**尚未配齐**的家(运行期会跳过它)。
|
||
|
||
落盘:`$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename),**重启后仍然生效**,且 **v0.80 起保存后立即生效(热重载)**:`plugin.saveProvider` 成功落盘后,组合根会**按落盘后的配置重建 web-search 工具并替换能力注册表里的条目** ⇒ **下一轮对话即用新接线,不需要重启后端**(design/09 §5.5 的端到端判据)。响应新增机器可读事实 **`hotReload:{applied,registered}`**:`applied=true` 表示这次保存真的触发了热重载(`false` = 宿主没有能力注册表可换,只落了盘);`registered` 报告保存**之后** web-search 在不在模型的工具面里(主 provider / 端点被显式关闭时为 `false` —— 这是设计语义,不是失败;⚠️ `applied:false` 时该键**恒为 `false`**、不携带信息)。
|
||
|
||
**保存是一次事务**:若这次改动会让 web-search **没有可用后端**(design/27 §3 的启动期 fail-fast:主 provider 未登记 / 未配齐),端点返回 **`409 config_unstartable`**,且**文件按字节回滚**、运行中的工具保持原样(决不留「盘上新、内存旧」,也决不留「重启后起不来」的配置)。⚠️ 回滚本身也失败时**如实**回 **`500 config_write_failed`** 并明说「盘上可能已经变了」,**不假装改动没发生**。
|
||
|
||
⚠️ **触发面只有本动作**:直接改写可变源文件(不经面板)**不会**触发热重载 ⇒ 仍需重启后端(或从面板点一次保存)。这是本动作的**明确边界**(不做文件监听)。
|
||
|
||
`plugin.testProvider` 失败时**不**回 200:走上面 (b) 的错误表(`unknown_provider` / `not_configured` ⇒ 400;`unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown` ⇒ 502)。🔴 **`no_results` 表示「连通正常但没查到结果」**(空结果 ≠ 不通)⇒ 前端文案**必须**与真正的失败区分开,别把它显示成「该家不可用」。🔴 错误响应**绝不**含上游 URL 与凭据(只给稳定码 + 固定文案)。
|
||
|
||
> ⚠️ **v0.79 行为变化(登记)**:`optionsFrom` 声明的候选来源现在会进**动作索引** ⇒ `plugin.providers` 从「`404 action_not_declared`」变为**可调用的读动作**。前端此前对该动作 404 的降级分支(文本框 + 「候选项未能加载」)因此**不再触发**。⚠️ 另有一条**前端缺口**(不属本契约面):行内动作的成功分支只显示「已完成,正在刷新」,丢弃 `result` ⇒ 探针成功时的 `count` / `latencyMs` 在界面上仍不可见。
|
||
|
||
#### (d) `browser` 插件的动作面(**v0.82 新增**,切片 Y4;design/28 §8 B4)
|
||
|
||
插件 id `browser`,面板 id `settings`(「浏览器与截图」,`route = plugin/browser/settings`)。**不开任何新路由** —— 六个动作全部走上面 (b) 的通用分发端点 `POST /api/v1/admin/plugins/browser/actions/{action}`;可变源落点 `$DATA_DIR/config/plugins/browser.json`(**0600**、temp+rename;**文件名 = 插件 id**)—— ⚠️ **不进客户端契约**(客户端一律走动作端点,不直连文件)。
|
||
|
||
**页面构成(4 个区块)**:① `form` 配置表单(保存即热重载);② / ③ / ④ 三张只读 `table`(运行状态 / 指纹自检 / 最近截图)。
|
||
|
||
> **为什么三块活数据都用 `table` 而不是 `status`**:`status` 区块按 §5.2.1 的接入点表**不消费 `source`**(没有来源就没有活数据),硬要用只能给一份声明期的静态文本 —— 那才是真正的「看起来有、其实没有」。`table` 是声明面里唯一**既有来源又能带按钮**的形状。
|
||
|
||
**六个动作(3 读 3 写;全部已登记生产处理器)**:
|
||
|
||
| 动作名 | 语义 | 面 | 请求体 | 成功响应 `result` |
|
||
|---|---|---|---|---|
|
||
| `plugin.getBrowserState` | 运行状态只读事实(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"source","available","headless","viewport","profileDir","concurrency","idleTimeoutSeconds","shotMaxBytes"}]}`。**恒为 1 行**(事实只有一份;并发上限恒为 1)⇒ 多行会诱导读者以为「有多个浏览器在跑」 |
|
||
| `plugin.getFingerprint` | **上一次**指纹自检的读数(**不启动浏览器**) | read(table `source`) | `{}` | `{"rows":[{"webdriver","screen","timezone","language","userAgent","checkedAt"}]}`。**从未自检过 ⇒ 一行如实写「未自检」**(不编造数字、不回空数组) |
|
||
| `plugin.listShots` | 最近截图**统计**(**最新在前**,最多 20 条) | read(table `source`) | `{}` | `{"rows":[{"at","bytes","size","format","degraded"}]}`。🔴 **不含路径、不含内容、不含 URL**(面板是只读投影,viewer 也看得到表格行 ⇒ 把主人的浏览内容投到那里是越界)。没截过图时**如实为空表** `{"rows":[]}` |
|
||
| `plugin.saveBrowserConfig` | 保存浏览器配置(落盘 + **热重载**) | write(form `submit`) | `{"values":{…}}`(**只发改动过的字段**;缺位的键**保持原值**,**绝不**把「没提到」当「清空」) | `{"saved":true,"applied":<bool>,"registered":<bool>,"reason":"<原因码/文案>"}`。`applied=true` = 这次保存**真的触发了热重载**(按落盘后的配置重建工具 + 原子替换注册表条目 ⇒ **下一轮生效、不需重启**);`applied=false` 且保存前**本来没有**可用工具 ⇒ 文件留下、如实报 `false` + `reason`(那种情形没有能力可丢,报错反而会让主人**没法先配好**) |
|
||
| `plugin.shootPage` | 拍一张**当前页面** | write(table `rowActions`) | `{"row":{…}}`(载荷不被解释,只为触发) | `{"shot":{…}}`,`shot` = `browser` 工具 `screenshot` 的返回值原样(见下面「模型面」一节),含 `path` / `bytes` / `sha256` / `format` / `width` / `height` / `dimensionsKnown` / `degraded` / `turnAttachment` / `hint` |
|
||
| `plugin.checkFingerprint` | 重新做一次指纹自检(**会启动浏览器**) | write(table `rowActions`) | `{"row":{…}}` | `{"rows":[…]}`(与 `plugin.getFingerprint` 同形,刷新后的读数) |
|
||
|
||
**字段名(`values` 的键,逐字与 `$DATA_DIR/config/plugins/browser.json` 的 JSON tag 一致)**:`headless`(bool)、`executable`(string,留空 = 自动探测本机 Chrome/Chromium)、`cdpEndpoint`(string,显式给出时取代自动探测)、`profileDir`(string)、`viewportWidth` / `viewportHeight`(number,声明面已给 min/max)、`idleTimeoutSeconds`(number)、`concurrency`(number,**声明面 min = max = 1**:同一 profile 起两个实例会互锁)。🔴 **未知字段一律拒(`400 invalid_payload`),绝不静默忽略** —— 静默忽略会让「我改了但它没生效」变成查不出的谜,而且不该在盘上留下半份改动。
|
||
|
||
**错误码(本插件在 (b) 通用表之上追加的,宿主只透传「码 + 文案 + HTTP 状态」)**:
|
||
|
||
| 状态 | `error.code` | 触发条件 | 客户端应做什么 |
|
||
|---|---|---|---|
|
||
| `400` | `invalid_payload` | 载荷不是对象 / 缺 `values` / `values` 不是对象 / **未知字段** / 字段类型错 | 显示文案原文;**不要**当成「保存成功」 |
|
||
| `409` | `browser_unavailable` | 本机**没有**可用浏览器来源(未探测到 Chrome/Chromium,也没有在跑的 CDP 端点)⇒ `plugin.shootPage` / `plugin.checkFingerprint` 做不了 | 提示「先去配置里填可执行文件或 CDP 端点并保存」;面板可以配 ⇒ 不是死路 |
|
||
| `409` | `browser_session_not_open` | `plugin.shootPage` 时浏览器**没有打开的页面** | 提示「先让她打开一个页面(或在有头窗口里人工接管)」—— **面板刻意不替模型开页**(否则它就成了「任意 URL 的截图器」) |
|
||
| `409` | `config_rejected` | 这次保存**没能生效**(新配置会让工具不可用),**已按字节回滚**到保存前那一份 | 显示原因原文;**盘上没有变**,不必再手动改回 |
|
||
| `500` | `config_unreadable` | 面板配置存在但**读不出来**(损坏 / 权限)⇒ 本次**没有**改动它 | 显示「配置读不出来」,指向服务端日志;**不要**重试保存 |
|
||
| `500` | `config_write_failed` | 配置写不进去(磁盘 / 权限);⚠️ **回滚也失败**时也用这个码,文案明说「盘上的文件可能已经变了」 | **不假装改动没发生**;提示可能需要人工检查盘上文件 |
|
||
| `502` | `browser_action_failed` | 浏览器动作本身执行失败(截图 / 自检) | 详细原因只进后端日志;显示「执行失败」+ 文案 |
|
||
|
||
> **`applied:false` + `reason` 是正常结论,不是错误**:保存前**本来就没有**可用工具时(例如第一次配一个还没起来的容器端点),文件留下、`200` + `applied:false`。客户端**不要**把 `applied:false` 渲染成失败 —— 要说的是「已保存,但这次没有东西可换/没生效」+ `reason`。
|
||
|
||
**模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(**闭集**,v0.82):
|
||
|
||
| 取值 | 含义 |
|
||
|---|---|
|
||
| `queued` | 截图已入**本会话的待送附件箱**,将在本回合的下一次模型调用里作为附件送出(主模型收不了图时以视觉/OCR 的文字形态) |
|
||
| `no-session` | 本次调用拿不到会话 id(子代理 / 未接编排器的调用),**未入箱** ⇒ 由模型自己按 `path` 去读 |
|
||
| `outbox-full` | 该会话的箱子已满(**每会话最多 4 张**、TTL 10 分钟),**丢掉了最旧的那张**(如实报,不静默) |
|
||
| `disabled` | 本工具没有附件箱(`New(nil)` 这类夹具),未入箱 |
|
||
|
||
配套的 `hint` 字段是一句**如实**的下一步提示(`queued` 时说「已经作为图片附件送出」;其余档说「图没进上下文,按路径读」)。**「入箱」到「进上下文」靠 `loop.Options.TransformContext` 上挂的钩子**(`cmd/cyrene/wire_browser_context.go`)—— 它**取走即消费**(同一张图不会被送两次),也因此**不会**进 checkpoint、不会进后台记忆提取的输入面。⚠️ **该挂点在 v0.82 登记时仍在工作树、未提交**;缺了它,`turnAttachment` 仍会照报 `queued` 而图**一张都不会进上下文**(**零症状**的坏法,因此有一条源码级用例盯着 `wire_runtime.go` 那一行)。
|
||
|
||
---
|
||
|
||
### 4.15 记忆整理「应用面」(B20–B24;**v0.83 新增,切片 TIDY-1**)
|
||
|
||
> 依据 `design/23`(§7 人工在环 / §8 软动作与回滚 / §10 status 列 / §11 管理面)。**背景**:v0.75 起只有「试算」(B14/B15 —— 只回建议、**零副作用**)⇒ 主人的实测口径是「候选 77 条**全 pending**、两天**一条没应用**」。本版把**执行面**补上:候选能列、能应用、能回滚,整理能手动真跑。
|
||
|
||
#### (a) 端点族一览
|
||
|
||
| 端点 | 语义 | 关键参数 | 审计 |
|
||
|---|---|---|---|
|
||
| B20 `GET …/memories/candidates` | 列候选(含 `id`/`runId`/证据) | `status`(省略 = 全部)、`limit`(缺省 100 / 钳 500 / 非法 400) | 无(只读) |
|
||
| B21 `POST …/candidates/{id}/apply` | 应用**一条** | 无体 | `memory.tidy.apply` |
|
||
| B22 `POST …/candidates/apply` | **批量**应用 | `runId`(省略 = **全部待定**)、`limit`(缺省 100 / 钳 500 / 非法 400) | `memory.tidy.apply` |
|
||
| B23 `POST …/candidates/{id}/revert` | 回滚**一条**已应用 | 无体 | `memory.tidy.revert` |
|
||
| B24 `POST …/memories/tidy/run` | 手动**真跑**一轮(写候选 + 留痕,**不自动应用**) | 无体 | 领域侧记 `memory.tidy.run`(一次点击一条) |
|
||
|
||
形状逐字段见 §2.2 的 B20–B24 行。**门一律 `writeAuth`**(owner\|admin)—— 候选正文含隐私,与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档。⚠️ `design/23 §11` 表格把**回滚**标为 owner-only,而实现用了 `writeAuth`:**口径差异待裁决**(若以设计为准,`routes.go` 里把那两行换成 `ownerAuth` 即可,一行一处;与 §2.2 B17 的同类差异是同一件事)。
|
||
|
||
#### (b) 三条硬约束(客户端与面板都按这个编程)
|
||
|
||
1. **幂等不是错误**:执行闸是**候选状态的原子条件更新**(`WHERE id=? AND status='pending'` + `RowsAffected`),记忆行侧还有第二道闸(`AND status='active'`)。因此并发重复点击**只有一个赢家**,其余拿到 `200` + `status:"skipped"` —— 面板**不得**把它渲染成失败,也**不得**据此提示「重试」。
|
||
2. **超限必须如实**:批量回执的 `total` 与 `applied+skipped+failed` **口径不同**(前者 = 命中筛选的**真**待定数,后者只覆盖 `limit` 之内),差集由 **`truncated:true`** 表达。🔴 这是「不许把本轮 100 / 共 342 画成全部完成」的契约级要求。
|
||
3. **软动作、永不物理删除**:merge = 被吸收行 `status='merged'` + `merged_into`;archive = `status='archived'`;**`content` 一个字不改、行一行不删** ⇒ `revert`(B23)必然无损可逆。同时,**软删行必须真的从读侧消失**(v0.83 行为修复):记忆的四条列表读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)只回 `status='active'`,因此被软合并/归档的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。🔴 **只有两处刻意不过闸**:按 id 的 `Get`(撤销软合并必须先读得到被吸收的行)与 **`GET /admin/memories/reviews`**(审查留痕本身没有 status 概念,且它是软删行的查看入口 —— 加闸等于审计自我抹除)。
|
||
|
||
#### (c) 调度:到点即跑 + catch-up(对客户端零形状影响)
|
||
|
||
原实现是「`interval=24h` 的 ticker 从**进程启动**算起」⇒ 后端两天重启十几次,**24h 永远走不完**(`memory_tidy_runs` 只有 1 行,这就是「两天没跑」的一半根因)。现在以 **`memory_tidy_runs` 的留痕行**作为「上次到底跑过没有」的**持久事实**,启动时与心跳到点都判一次(三条判据),**重启不饿死**。B24 是它的手动入口(panic button),与心跳**同一套规则、同一份配置**。
|
||
|
||
#### (d) 诚实边界(**别当已验证**)
|
||
|
||
- 🔴 **生产库全程零动作**:1191 行记忆与 77 条待定候选**都没有被应用或清理**。本版交付的是**能力**,不是「已经清过了」—— 真跑与批量应用要主人拍板后执行(可分批、可 revert)。
|
||
- **面板上的真机端到端未跑**:本版登记时**前端操作面还没落地**(前端切片并行在做),因此本版证据是**存储层单测 + 网关端到端测试 + 变异自证**,不含「面板点一下真生效」。
|
||
- **旧候选的 `run_id` 是空串**(77 条在生产库里都是这样)⇒ 「**按 run 批量**」(B22 带 `runId`)**找不到它们**;要清它们必须走**全部待定**那一档。
|
||
- 候选表的 `run_id` 列目前**只在存储包前滚**(`memory.NewStore` 的 `ensureTidySchema`),**未**同步进迁移 `0019`;组合根每次启动都会跑到前滚,因此生产库拿到该列没问题,但「迁移是不是唯一真源」这条口径**有欠**。
|
||
- **`memory.recall` 的语义召回在本机不可复现**(远端 embedder 对同一文本返回不同向量,TIDY-2 诊断发现)—— 它**不影响**本族的规则层判定与状态机,但**影响「整理候选是否可复现」**,登记在此以免被当成实现缺陷。
|
||
|
||
|
||
|
||
#### (e) v0.85:「不再只产建议」+ `link` 停产 + 按动作批量拒绝(B25)
|
||
|
||
**① 轮末自动落地(默认生效)**:整理跑完一轮后会**自动**把两类**规则层已判定为高置信**的候选落地,不再等人点:
|
||
- `merge`:**字面重复**(两侧正文逐字相同)**或**「事实集合等价 ∧ 相似度 ≥ **0.95**」;
|
||
- `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:习惯类会因正文里带钟点被误判成一次性事件,**归档它是错的**)。
|
||
|
||
🔴 **不自动**(别指望它替你干这些):`reclassify`、**低置信** merge、跨分区 / 跨角色、**任何改正文**、**受限来源(群聊 / 陌生人)**。
|
||
**六条护栏**:软动作(只改 `status` / `merged_*`,**正文不改、行不删**)· **每轮上限 50**(硬顶 500,超限**如实** `truncated` + 真总数)· 审计 `memory.tidy.apply`(`auto=true`,**不带正文**)· **可 revert**(回滚路径与人工应用**同一条**)· 幂等(候选状态条件更新)· **一键关回只产建议**:写 `$DATA_DIR/config/memory-tidy.json` → `{"autoApply": false}`(调上限同文件 `autoApplyPerRound`;**启动期读一次,改了要重启**;**零新 env 键**)。
|
||
|
||
**② `link` 候选已停产**:`link` 在 `design/23 §14` 里是**未细化项**(既无存储落点、也无数据模型),而 `applyable()` 只认 `merge/archive/reclassify` ⇒ **应用一条 `link` 只会拿到 `skipped`**。⇒ 规则层不再产出它(判 `keep`)。`TidyLink` 常量保留**只为读历史行**与「不可应用」判定。
|
||
|
||
**③ 存量清理走 B25**(幂等、只改候选状态、不碰记忆):
|
||
|
||
```bash
|
||
BASE=http://127.0.0.1:10942
|
||
# ① 先看现状(默认只看 pending;limit 缺省 100、钳 500)
|
||
curl -s -H "X-API-Key: $KEY" "$BASE/api/v1/admin/memories/candidates?status=pending&limit=500"
|
||
|
||
# ② 一次性拒绝全部 link 候选(幂等;limit 缺省 1000、硬顶 10000)
|
||
curl -s -X POST -H "X-API-Key: $KEY" \
|
||
"$BASE/api/v1/admin/memories/candidates/reject?action=link&limit=1000"
|
||
# 期望 {"data":{"action":"link","limit":1000,"total":501,"rejected":501,"truncated":false}}
|
||
|
||
# ③ 复核:同一调用再来一次 ⇒ total:0 / rejected:0(**不是错误**)
|
||
```
|
||
|
||
🔴 **只该整类拒 `link`(必要时 `keep`)** —— `merge/archive/reclassify` **有执行语义**,交给自动档或人工,别整类拒掉。
|
||
⚠️ 本条的 `total/rejected/truncated` 口径与 B22(批量应用)**同构**:`total` 是真命中数,处理数只覆盖 `limit` 之内。
|
||
|
||
|
||
|
||
### 4.16 助手文本的段落标签约束(**v0.84 新增,产出侧硬约束**)
|
||
|
||
**约束(一句话)**:助手文本在**落库、对外读接口、IM 镜像**里**不得出现未配对的段落标签** —— 不许有落单的 `<p …>` 或 `</p>`。语义上:**一个 `<p>` = 一段,段落不嵌套**。
|
||
|
||
**为什么写进契约**(这曾是真实纠纷):安卓端从 `GET /api/v1/sessions/web/messages` 读到过一条 `content` 形如
|
||
|
||
```
|
||
<p><action>满意地点点头</action></p>
|
||
<p>真乖♪ 这可是今天最让人家放心的一句。</p>
|
||
<p><p>那现在——想让人家陪你看会儿晚霞,还是安静坐着发会儿呆?</p>
|
||
```
|
||
|
||
他们的解析契约 `P_BLOCK_RE = <p(\s[^>]*)?>([\s\S]*?)</p>` 是**非贪婪**的 ⇒ 第三段必然匹配成「外层 `<p>` 开、内容 = `<p>正文`、`</p>` 收」⇒ **用户直接看到字面的 `<p>`**。这不是他们解析器的口味问题:**任何按该契约定实现的消费方(Web 面板 / IM 桥 / 第三方)都会踩**。
|
||
|
||
**实测根因(两半都在产出侧,已修 `8051906`)**:
|
||
|
||
1. 规整器**原样放过**该形状(分块正则非贪婪 ⇒ `<p><p>X</p>` 被当成「一个已完整包裹的 `<p>` 块」逐字保留;更糟的是旧的覆盖检查用**同一非贪婪正则**,护栏本身是盲的);
|
||
2. 规整器还会**亲手制造**它:段首 `<p>` 未闭合(模型末段漏 `</p>`)时**无条件再套一层** —— 实证:模型吐 `<p>那现在…?`(无闭标签)经旧代码规整后与上报串**逐字节相同**;
|
||
3. 顺带修掉一个**幂等漏洞**:落单 `</p>` 旧行为 ⇒ `<p>正文。</p></p>`,再跑一次又变 ⇒ 越跑越多。
|
||
|
||
**修后的处置口径(产出侧,逐条有测试)**:`<p><p>X</p>` ⇒ `<p>X</p>`;`<p>X`(漏闭)⇒ 补 `</p>`(**补闭不补开**);`<p>正文<p>` ⇒ `<p>正文</p>`(合并不吞字);`<p>A</p><p>` ⇒ `<p>A</p>`(丢空开标签、不造空气泡);`<p></p>` ⇒ 整对丢;落单 `</p>` ⇒ 丢标签、**正文不动**。
|
||
|
||
**🔴 客户端要做 / 不用做的**:
|
||
|
||
- **不用做**:不必为这个畸形写特例解析器(产出侧已保证不再产出)。
|
||
- **仍要做**:**保留**你现有的「剥落单标签」容错 —— ① **历史行不回填**(库里已经有畸形的行);② 产出链**不止一条**(渠道镜像 / 主动推送 / 后台思考各有包装点),不能假设全都干净。
|
||
- **护栏(不许退化)**:`1 < 2`、`a<b`、`<p2>`、`<p`(无 `>`)、代码围栏内的 `<p>`、`<action>` 块内的 `<p>`、自闭合 `<p/>` —— 这些**都不是**段落标签,一律原样保留(产出侧与消费侧**同一条口径**)。
|
||
|
||
**一个保守处置(待设计裁决,生产库 0 例)**:`<p>A\n\n<p>B</p>`(未闭合段落之间夹正文)产出侧选择**合并** ⇒ `<p>A\n\nB</p>`,**不**做「插入式隐式收尾」(那要猜作者意图)。客户端不必为此适配。
|
||
|
||
### 4.17 记忆整理调优键的读写面(B26 / B27;**v0.92 新增,切片 M4**)
|
||
|
||
| 项 | 值 |
|
||
|---|---|
|
||
| 方法 / 路径 | `GET` 与 `PUT` `/api/v1/admin/memories/tidy/tuning` |
|
||
| 鉴权档 | `writeAuth`(**owner / admin**;`viewer` → `403`,未认证 → `401`)。与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档 |
|
||
| 审计 | GET **不审计**;PUT 落 **`memory.tidy.tuning`**(`result` ∈ `ok\|error`) |
|
||
| 落盘 | `$DATA_DIR/config/memory-tidy.json`(原子写:temp + rename;**值一个都没变时不写盘**) |
|
||
|
||
它救的是「这些键只能改文件 + 重启后端」——面板从此是配置的一等来源(`.env` 只做兜底)。
|
||
|
||
#### 管的 7 个键
|
||
|
||
| key | kind | unit | 取值 | 兜底来源 |
|
||
|---|---|---|---|---|
|
||
| `intervalSec` | int | sec | ≥ 1 | `env:MEMORY_TIDY_INTERVAL_SEC` |
|
||
| `quietIntervalSec` | int | sec | ≥ 1 | `panel:intervalSec` |
|
||
| `quietStartHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_START_HOUR` |
|
||
| `quietEndHour` | int | hour | 0–23 | `env:MEMORY_TIDY_QUIET_END_HOUR` |
|
||
| `ingestGate` | bool | — | — | `code-default` |
|
||
| `autoApply` | bool | — | — | `code-default` |
|
||
| `tempTtlSec` | int | sec | ≥ 1 | `code-default` |
|
||
|
||
🔴 **`specs` 是取值规则唯一真源**:面板据此渲染与就地校验 ⇒ 不会出现两套规则。`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
|
||
|
||
#### 响应(GET 与 PUT 的 200 同形状)
|
||
|
||
```json
|
||
{
|
||
"path": "config/memory-tidy.json",
|
||
"present": true,
|
||
"hot": false,
|
||
"hotError": "…(仅 hot=false 时出现)",
|
||
"specs": ["…见上表,每项 {key,kind,unit?,min?,max?,fallback}…"],
|
||
"values": {"intervalSec": 3600, "quietStartHour": 23, "quietEndHour": 7, "ingestGate": false},
|
||
"envValues": {"intervalSec": 86400, "quietStartHour": 23, "quietEndHour": 7}
|
||
}
|
||
```
|
||
|
||
🔴 **`values` 的缺位语义**:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键;同文件里**不受管**的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**,但 PUT 时**原样保留**。
|
||
|
||
🔴 **`envValues`**:键缺位时的**下一档(`.env`)进程生效值**(与 `/admin/config-facts` 同源 `s.cfg`,网关**绝不 fopen `.env`**);某键连 `.env` 档也没有值 ⇒ 该键不出现(例:`intervalSec ≤ 0`、安静时段两端都为 0 = 未填)。
|
||
|
||
#### 写侧三态(硬要求,逐字)
|
||
|
||
| 请求体里 | 结果 |
|
||
|---|---|
|
||
| 键**缺位** | 不改动 |
|
||
| **合法值** | 采用 |
|
||
| **非法值** | `400 invalid_payload`,且**盘上一个字节不动** |
|
||
| **未知键**(含拼错) | 归「非法」⇒ `400`,**绝不静默忽略** |
|
||
| 值 `null` | **清除该键**(回到下一档兜底) |
|
||
|
||
- 请求体 = **扁平 JSON 对象**(值类型 int 秒/钟点 或 bool)。
|
||
- **PUT 的 200 是「写完重新读盘」的真值**,不是回显请求体。
|
||
|
||
#### 错误码
|
||
|
||
| code | 何时 |
|
||
|---|---|
|
||
| `400 invalid_payload` | 值非法 / 未知键 / 体不是对象 / 体是 `null` |
|
||
| `409 tuning_corrupt` | 盘上那份**运行期读不走** ⇒ **拒绝覆盖**(文案说明此刻正回落 `.env`/代码默认) |
|
||
| `500 tuning_unavailable` | 数据目录未接线 |
|
||
| `500 tuning_write_failed` | 落盘失败(盘上未改动) |
|
||
|
||
#### 🔴 `hot` 的诚实口径(客户端按此渲染)
|
||
|
||
- `hot:true` = 值**已叠到运行期**:整理档会 `Stop→写→Start` **重排心跳**(否则新周期要等旧周期那一觉睡完 —— 主人把 24h 改成 3h 就「等于没改」);审查档重算 `ingestGate` / `tempTtlSec`。
|
||
- `hot:false` = **已落盘但运行期没接上**,`hotError` 给原因,面板文案必须是「已保存到面板配置文件,**重启后端后生效**」。
|
||
- **绝不把「落盘了」说成「生效了」。**
|
||
|
||
#### `recallMode` 的渲染(前端,承接 v0.91)
|
||
|
||
试算卡片(B14)的 `recallMode` 现在四态如实渲染:`embedding` ⇒「召回来源:语义向量」|`lexical` ⇒「召回来源:词项余弦(未接向量,已降级)」|`mixed` ⇒「召回来源:部分分区降级(其余走向量)」|`""` ⇒「召回来源:无候选可比(本轮未做召回)」——**不再渲染成空标签**。未登记取值 ⇒「…(未登记取值)」(不吞信息)。「尝试过但向量源不可用」走**错误路径**(`error.code === "tidy_recall_unavailable"`),面板额外渲染一句解释,**不套在别的错误码上**。
|
||
|
||
## 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` | 空 | ⚠️ **v0.96 起不再是发音人的真源**:发音人由**生效人格 → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射**决定(面板文件,零新 `.env` 键);请求体 `voice`/`format` 仍为前向兼容接受但不生效,产物恒 WAV |
|
||
| `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 读到生效值)。`THINK_INTERVAL_SEC`(`900`)/ **`THINK_QUIET_INTERVAL_SEC`(`3600`,v0.75 新增)**:落在安静窗口内时,**周期性心跳**改用后者降频、窗外回落前者;**`≤0` = 关闭降频**(回落普通间隔,**不是**「半夜不思考」)。⚠️ 它**只降周期性心跳**:事件驱动(安全域/急性事件)照旧立刻触发、照旧穿透安静时段 |
|
||
| `MEMORY_TIDY_ENABLED` | `true` | 记忆整理总开关(`design/23 §4`)。关闭 ⇒ 完全不跑(日志写明);整理**只产建议、绝不自动改记忆**。**v0.75 起**:关闭时试算端点(B14)也**拒绝作答**(`409 tidy_disabled`),不绕过这个选择 |
|
||
| `MEMORY_TIDY_INTERVAL_SEC` | `86400` | 整理周期(秒),是**下限**:实际在低峰窗口起点跑 ⇒ 真实间隔取「窗口每天一次」与本值的较长者 |
|
||
| `MEMORY_TIDY_QUIET_START_HOUR` / `MEMORY_TIDY_QUIET_END_HOUR` | `23` / `7` | 记忆整理的**低峰窗口**(本地钟点,默认与 backthink 同值);两端相等且非 0 = 关闭窗口、改为纯周期;两端都为 0 = **未填**,运行时回落到默认 `23/7`。⚠️ 窗口**只管心跳调度**:试算端点(B14)**不受它约束**(否则「现在就想看」正是它存在的理由) |
|
||
| `MEMORY_TIDY_RECALL_THRESHOLD` | `0.80` | 候选**召回门**(余弦),**不是**合并判据(可否合并由包含关系 + 规则否决决定)。试算端点(B14)用**同一个**值 |
|
||
| `MEMORY_TIDY_SCAN_LIMIT` / `MEMORY_TIDY_CANDIDATE_LIMIT` | `5000` / **`200`** | 每分区每轮的扫描上限 / **候选**上限(⚠️ 两者都是**每分区**的量,不是全库)。候选上限的默认值 **v0.77 由 `20` 抬到 `200`**(首轮真跑四个分区同时打满旧值 ⇒ 候选数由上限而不是规则决定);命中它时响应 `truncated` 带 `<分区>(candidates)`。试算端点(B14)用**同一对**上限(不另造一套) |
|
||
| `MEMORY_TIDY_COMPARE_LIMIT` | `2000` | **v0.77 新增**:每分区每轮**真正参与两两比对**的条目上限(性能护栏,两两相似度是 O(n²·dim))。此前**写死为 `400` 且没有 env 键**,实测把 459 条的分区截到 400 ⇒ `compared(705) < scanned(784)`(观测口径被上限决定)⇒ v0.77 抬到 `2000` 并开放为 env。命中它时响应 `truncated` 带 `<分区>(compare)` + 日志 WARN。`≤0` = 用默认 |
|
||
| `MEMORY_REVIEW_ENABLED` | `true` | **v0.77 登记**:记忆「入库前审查」(`design/23 §15.1 D3`,切片 MT-B)总开关。新记忆落库后由**独立的记忆管理会话**(复用 `sess-memorytidy` 装配)在有界后台队列里判定 `skip`/`merge`/`modify`/`create`,结论以**软合并**回写(`status` + `merged_into`/`merged_from`/`merge_count`,**可 revert、绝不改既有行 `content`**)。`false` ⇒ 组合根不建、不接,写入路径**逐字回到**改动之前。默认开启是因为判定是**纯规则、零模型调用**(T1 灰区模型判定**未实现**) |
|
||
| `MEMORY_REVIEW_QUEUE_SIZE` | `256` | **v0.77 登记**:有界审查队列长度。⚠️ **队列满 ⇒ 该条不做审查**(记忆本身早已落库,**绝不因队列满而丢记忆**),只记一条带来源的 WARN |
|
||
| `MEMORY_REVIEW_SCAN_LIMIT` | `200` | **v0.77 登记**:每条审查在同一分区内最多看多少条既有记忆(top-K 上限) |
|
||
| `MEMORY_REVIEW_TIMEOUT_SEC` | **`90`**(v0.93 起;v0.77–v0.92 为 `20`) | 单条审查的墙钟上限(秒)。超时 ⇒ **不做任何改写**(记忆照旧保留)+ WARN。🔴 **默认值的唯一真源在 memory 包**(`internal/runtime/memory` 的 `defaultReviewTimeout`)—— 该 env 键**不设自带默认值**,未配置读出 `0` = 「没配」,由 `normalizeReviewConfig` 单点落地;**显式写正数才压过它**(不做「保底抬到 90」的加码)。⚠️ `20s` 已被实测证伪:批量 8 时一条完整召回链墙钟 **21.5–28.2s** ⇒ 20s 预算会让审查**整条失败且不写留痕**(见 §6 v0.93) |
|
||
| (`MEMORY_REVIEW_*` 的共用口径) | — | 上面四个键 **`≤0` 一律视为「用默认」**(由 memory 包 normalize 收敛,避免一个笔误让进程起不来);召回门**复用 `MEMORY_TIDY_RECALL_THRESHOLD`**,不另立键。⚠️ **本版只登记已实现的部分**:审查的**读侧 / 回滚目前没有 HTTP 端点**(见 §7.2) |
|
||
|
||
> **上面 13 个键(记忆整理 8 + 后台思考心跳 3 + 安静时段 2)自 v0.68 起可经 `GET /api/v1/admin/config-facts` 读到**「进程生效值 + 声明默认值」(3 个心跳键自 v0.75 起并入、`MEMORY_TIDY_COMPARE_LIMIT` 自 v0.77 起并入);在此之前面板没有任何 env 读取面。⚠️ `MEMORY_REVIEW_*` 四个键**不在**事实面里(那是「入库前审查」的另一组配置)。
|
||
| `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` 同范式;启动期扫一次 + 定时 + 失败不退出) |
|
||
| `WEB_SEARCH_*`(`WEB_SEARCH_PROVIDER` / `_FALLBACK` / `_MAX_RESULTS` / `_ENDPOINT` / `_TIMEOUT` / 各家 `_API_KEY` / `_BASE_URL`) | 见模板 | **v0.78 起语义收窄(切片 PLG-4,运维视角的行为变更)**:web-search 的**可变源**改为 `$DATA_DIR/config/plugins/web-search.json`(0600、文件名 = 插件 id)。组合根**先读可变源、文件缺失才回落 `.env` 播种**(即 `.env` 只作**引导面**):**文件一旦落盘,改 `.env` 不再影响运行中的部署**。🔴 **不新增任何 env 键**(`.env` 已冻结);客户端一律走插件动作端点(§4.14(c)),**不直连该文件** |
|
||
|
||
> ⚠️ **v0.78 的「`.env` 只播种」口径**(切片 PLG-4):这与 §5 开头「**只要文件存在且有效,运行时优先用它**,`.env` 只作兜底/播种层」是**同一条原则**在搜索 provider 上的落地 —— 此前 web-search 是纯 `.env` 面,v0.78 起它也有了面板/文件配置面。**无 env 键新增/删除**;模板里既有 `WEB_SEARCH_*` 键**保持现状**。
|
||
|
||
**与契约相关但「配了不生效」的键**:
|
||
|
||
- **`CHAT_MAX_PARALLEL_TURNS`:未接线**。编排器有构造期旋钮 `Deps.MaxParallelTurns`,但**组合根未从任何 env 读它** ⇒ **生产恒为默认 2**(`.env` 里配了不生效)。**本文档不把它登记为可用旋钮**。
|
||
- **`ENABLE_BACKGROUND_THINKING` / `THINK_SILENCE_TIMEOUT_SEC`:未接线**(全仓无消费者;`DevDocs/audit/07-backthink-automation-iot.md` §1-17,是否接线待主人拍板)。`ENABLE_BACKGROUND_THINKING` 自 v0.75 起被列进 B12 的只读事实面 —— 那是为了**让面板看得见这个死开关**,**不是**「它已生效」的声称:`facts[].value` 只是解析值,配 `false` 也不会关掉后台思考。`THINK_SILENCE_TIMEOUT_SEC` **未列入**事实面。
|
||
- 旧 `MCP_*` 扁平键:生产装配只读 `MCP_SERVERS_JSON`。
|
||
- `GETUI_*`:SDK 未接入,预留占位、当前不生效。
|
||
|
||
---
|
||
|
||
## 6. 变更记录
|
||
|
||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||
|
||
### v0.96(当前契约版本)
|
||
|
||
**「语音发音人绑人格 + 管理面脱敏投影」版(M35 / M42;纯增量,无破坏性变更)**
|
||
|
||
- **`POST /api/v1/audio/speech` 新增可选 `sessionId`**(纯增量,`omitempty`)。发音人不再由请求体决定,而是按该会话的**生效人格**解析:**会话绑定 → 全局默认人格 → 空**。
|
||
- 🔴 **省略 `sessionId` 不等于「没有角色」**:此时按**全局默认人格**选发音人(常规安装必有全局默认人格 ⇒ 仍走 `personas` 映射)。只有**连全局默认人格都没有**时才落到 `voice-tts.json` 的 `default`(没配则不合成)。
|
||
- ⚠️ **不要**写成「`sessionId` 为空 ⇒ 发音人置空」——那会让本口永远用 `default` 那个声音,正是「换了人格还用老角色」的病害。
|
||
- 请求体的 `voice` 仍**接受但不生效**(前向兼容);音色只能由服务端 `personas` 映射决定。
|
||
- 产物**恒为 WAV**(服务端无转码),实际格式一律按**字节嗅探**判定,客户端**不要**照抄 `Content-Type`。请求体 `format` 仅为前向兼容接受。
|
||
- **新增两个错误码**(状态码仍是 `500` —— 这是**服务端配置缺失**,不是客户端的错):
|
||
| `error.code` | 含义 |
|
||
| --- | --- |
|
||
| `tts_persona_unmapped` | 这个生效人格在 `personas` 里没有映射(去面板补) |
|
||
| `tts_no_character` | 连生效人格都没有,且没配 `default` |
|
||
| `tts_failed` | 其余合成失败(原口径) |
|
||
- 🔴 **管理面两条读端点是「脱敏投影」,不是原始数据**(M42):
|
||
- `GET /api/v1/admin/observability/logs`(A10):每行 `msg` 先过凭据打码,**上限 4096 rune**(够一行日志全文,但不会把 MiB 级行塞进 JSON)。`time`/`level`/`source` **原样**,条目数与最新优先序不变。
|
||
- `GET /api/v1/admin/observability/trace`(A11):每个 span 先过 `SanitizeRecord` ⇒ `props` 变**白名单投影**,表外键折成 `omitted: {键名 → {bytes, runes, sha256 前缀}}`(**仍能回答「这次输出了多大、两次是不是同一份」**);事件里按定义是正文的键同样折成 Digest。**字段名与帧形状不变**,客户端**不得依赖**未声明的 props。
|
||
- `id/name/kind/status/startedAt/endedAt/durationNs`、`level/source/time`、条数与排序——**排障字段一律保留**。
|
||
|
||
### v0.95
|
||
|
||
|
||
**「渠道静默窗口可写」版 —— 群里「一鼓作气打三句」只回一次,且参数在面板上调(切片 M23)**
|
||
|
||
- **新增 `GET /api/v1/admin/channels/coalesce`(读)** 与 **`PUT` 同路径(写)**。门 = `writeAuth`(owner|admin;`viewer` → 403、未认证 → 401),与 `admin_memory_tuning` 同档:它写的是**面板配置文件**,不是身份/凭据面。GET 只读不审计;PUT 落审计 **`channels.coalesce`**(`result` ∈ `ok|error`)。
|
||
- **写面是「三态补丁」**(键缺位 ⇒ 不动;合法值 ⇒ 采用;非法值 ⇒ **`400 invalid_payload` 且盘上一个字节都不动** —— 校验全部发生在落盘之前):
|
||
| 键 | 含义 |
|
||
| --- | --- |
|
||
| `enabled` | 是否启用静默窗口合并(bool) |
|
||
| `windowMs` | 首条到达后再等多久收批(ms) |
|
||
| `maxWaitMs` | **硬上限**:从首条算起最多等多久,到点就发车 |
|
||
| `maxBatchMessages` | 一批最多攒几条,够数立即发车 |
|
||
| `applyToStrangers` | 是否对陌生人/成员车道也应用(bool) |
|
||
| `strangerWindowMs` | 陌生人/成员车道的独立窗口(ms) |
|
||
- **每会话覆盖**:请求/响应带 `conversations` 映射(会话 id → 同一组键的补丁),因此窗口是**按会话**算的,不是适配器顶层一个值。
|
||
- **热生效是事实不是承诺**:桥每次发车从**同一个** store 实例读生效值 ⇒ 保存后下一次发车即用新值,响应里 `hot:true`。
|
||
- 🔴 **主人私聊不受此设置影响,且没有任何键能翻转它**:车道类型由 actor 元数据决定(`!msg.IsGroup() && actor.IsOwner()` ⇒ direct 车道、**没有窗口对象、拿不到定时器工厂**)。响应里下发 `ownerPrivate:"never"` 是**只读事实**,面板据此只显示一行说明、不给开关。
|
||
- 🔴 **一批 = 一个 `turnID` = 一次 `loop.Run` = 一条助手回复**,但批内 N 条仍是**各自一行 `user`**(同 `turnID`、顺序 = 到达顺序),助手回复与它们同 `turnID`。⇒ 客户端按 `turnID` 聚合时,一个回合会看到 N 条用户消息 + 1 条助手消息。
|
||
- **零新 `.env` 键**(design/13 §2.1):配置落 `$DATA_DIR/config/coalesce.json`。
|
||
|
||
### v0.94
|
||
|
||
**「MCP 管理面 + 工具来源」版 —— 新增一页声明式管理面板,并给能力条目加可选 `origin`(切片 M20 / M30)**
|
||
|
||
- **前端路由 `/admin/plugin/mcp/servers`**。它**不是新路由/新端点**:这是在既有面板目录里声明的一页(`id = mcp`,`panel = servers`),字段与动作都由 `pkg/plugins/sdk` 的 `Panel` 描述,前端用**既有通用渲染器**画。
|
||
- **所有动作走既有的通用分发端点 `POST /api/v1/admin/plugins/mcp/actions/<action>`**(插件只给动作名、不给 URL)。**7 个动作**:
|
||
| 动作名 | 档 | 作用 |
|
||
| --- | --- | --- |
|
||
| `plugin.mcpServers` | 读 | 全部 server 的状态快照(表格数据源) |
|
||
| `plugin.mcpTransports` | 读 | 传输形态候选(`stdio` / `sse` / `http`) |
|
||
| `plugin.installMcpServer` | 写 | 安装(或刷新)一台 server |
|
||
| `plugin.reconnectMcpServer` | 写 | 断开重连并重新发现工具 |
|
||
| `plugin.enableMcpServer` | 写 | 启用一台被停用的 server |
|
||
| `plugin.disableMcpServer` | 写 | 停用一台 server |
|
||
| `plugin.uninstallMcpServer` | 写 | 卸载一台 server |
|
||
- **鉴权在两处**:路由挂 `viewerAuth`(读动作 viewer 可用),**写动作由分发器再查 `callerHasWrite`(owner|admin)**,每次写动作落审计。失败码是**稳定闭集**(前端按码本地化,不解析文案):`invalid_config` / `install_failed` / `reconnect_failed` / `enable_failed` / `disable_failed` / `uninstall_failed` / `server_not_found` / `server_disabled`。
|
||
- 🔴 **安全提示(客户端/运维必读)**:`transport=stdio` 的 `command` + `args` 等于**在本机执行任意命令**,安装即生效。⚠️ **本版存在一处档位不对称(已 file finding,未在本版修)**:面板的写动作是 **owner|admin**,而 REST 的 `POST /api/v1/admin/plugins/install` 与 `/uninstall` 是 **owner-only**(`ownerAuth`)⇒ 一个非 owner 的 `admin` 可以通过面板动作装上一台 stdio server,却走不通 REST 安装口。单 admin 部署无实际差别,多账号部署请把 admin 视同 owner 收敛。
|
||
- **工具条目新增可选 `origin` 对象**:`{ "kind": "mcp", "server": "<server 名>", "transport": "stdio|sse|http" }`。**宿主内置能力整键缺席**(不是空对象)—— 「没有 `origin`」正是「宿主自己提供」的表达。
|
||
- 🔴 **归属只来自注册那一刻的元数据,绝不解析工具名字前缀**:MCP server 的工具名由它自己给,宿主不加前缀;实测一台 `halo` server 的工具分属 4 个不同前缀族 ⇒ 按前缀猜归属必错。
|
||
- **这是呈现隔离,不是权限隔离**:`tool_policies` 没有 server 维度,`mcp:<server>` 策略行是惰性的 ⇒ **本版不提供「按 server 开关工具」,也不给假开关**。
|
||
|
||
### v0.93
|
||
|
||
|
||
**「审查预算的唯一真源」版 —— 一个 env 默认值两处副本导致的静默失效**(实现切片 M16;起因 = **塔在部署后读真实启动行时发现** v0.92 那批修复里的 F1 **根本没生效**)
|
||
|
||
- 🔴 **`MEMORY_REVIEW_TIMEOUT_SEC` 的默认值 `20` → `90`**,并把默认值**收到唯一真源**:它**不再自带 env tag 默认值**,未配置读出 `0` = 「没配」,由 memory 包 `normalizeReviewConfig` 单点落地 `defaultReviewTimeout`。
|
||
- **未配置 ⇒ `90s`**;**显式写正数 ⇒ 原样生效**(**不做**「保底抬到 90」的加码);**写 `0` / 负数 ⇒ `90s`**。
|
||
- 🔴 **它修的是一个「看起来改了、其实没改」的事故**:v0.92 那批切片把 memory 包的 `defaultReviewTimeout` 从 `20s` 改成 `90s`,但 config 包的 env tag `default:"20"` **永远产出非零的 20** ⇒ `normalizeReviewConfig` 的「`≤ 0` 才填默认」分支**永远不走** ⇒ **90s 是死代码**,线上启动行仍打印 `timeout=20s`。
|
||
- **为什么必须修**:批量上限抬到 8 之后,一条完整召回链实测 **21.5–28.2s** ⇒ 20s 预算下审查**整条失败、且不写留痕** ⇒ 召回降级、灰区恒空、T1 发不出调用 ⇒ 「重复记忆收不动」这条链**只修了一半**。
|
||
- **为什么旧断言拦不住**:既有跨包断言比的是 **memory 包的常量**(「我打算用多少」),而病害发生在**另一个包**(config 的 tag),单包自洽、跨包漂移看不见。本版新增一条**走真实组装路径**的断言(`LoadMemoryReviewConfig` → `ReviewConfig` → `normalizeReviewConfig` → **生效值**,再断言它 > 实测最坏链长 + T1 子超时),并在注释里写明「**比常量 vs 比生效值**」的区别。
|
||
- **兼容性**:**纯行为修复,无 API 形状变化**;`.env` / `.env.example` 文件内容**一字未动**(该键从未进过模板,符合 `.env` 冻结口径);受影响的只有「没显式配这个键」的部署 —— 它们的单条审查预算由 20s 变 90s。
|
||
- **诚实边界**:若某部署**显式**写了 `MEMORY_REVIEW_TIMEOUT_SEC=20`,本版**不会**替你抬到 90(那是显式配置,尊重它)。另有两处**散文副本**仍写着旧值(`internal/runtime/memory/preinsert_t1.go` 的一处注释),已 file finding、不在本版改动面内。
|
||
|
||
### v0.92
|
||
|
||
**「记忆整理调优键可写」版 —— 管理面新增 2 条 `writeAuth` 端点,把面板从「只能改文件 + 重启」救出来**(依据:主人 2026-10-13 口径「`.env` 只做兜底…这些键优先从面板配置上取」「频率必须调…白天间隔短一点、静默期长一点」;实现切片 M4)
|
||
|
||
- **新增 `GET /api/v1/admin/memories/tidy/tuning`(B26)** 与 **`PUT` 同路径(B27)**。管 7 个键:`intervalSec` / `quietIntervalSec` / `quietStartHour` / `quietEndHour` / `ingestGate` / `autoApply` / `tempTtlSec`。门 = `writeAuth`(owner|admin;`viewer` → 403,未认证 → 401),与同族 `GET /admin/memories`、`POST …/tidy/dry-run` 同档;GET 只读**不审计**,PUT 落审计 **`memory.tidy.tuning`**(`result` ∈ `ok|error`)。
|
||
- 🔴 **响应三块的分工(客户端按此编程,别自己算默认值)**:
|
||
- `specs[]` 是**取值规则唯一真源**(`kind` / `unit` / `min` / `max` / `fallback`),面板据此渲染与就地校验 ⇒ 不会出现两套规则;`fallback` **只给来源**(`env:<键名>` | `panel:<键名>` | `code-default`)、**不给数值** —— 数值真源仍在领域侧,网关不另抄一份默认值。
|
||
- `values` 是**盘上**的值:**键缺位 ⇒ 该键在对象里不出现**(= 跟随下一档兜底),**不是 0** ——「没配」与「配成 0」必须分得开。只回这 7 键,同文件里不受管的键(`t1Judge` / `t1PerWindow` / `autoApplyPerRound` 等)**不回显**。
|
||
- `envValues` 是键缺位时**下一档(`.env`)**的进程生效值(与 `/admin/config-facts` 同源,网关**绝不 fopen `.env`**);连 `.env` 档也没有值 ⇒ 该键不出现。
|
||
- 🔴 **写侧三态(硬要求,逐字)**:键**缺位** ⇒ 不改动;**合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节不动**。**未知键(含拼错)也归「非法」,绝不静默忽略**。值 `null` = **清除该键**(回到下一档兜底)。错误码:`400 invalid_payload` | `409 tuning_corrupt`(盘上那份运行期读不走 ⇒ **拒绝覆盖**)| `500 tuning_unavailable`(数据目录未接线)| `500 tuning_write_failed`(落盘失败,盘上未改动)。
|
||
- **两条语义**:**值一个都没变时不写盘**(连 mtime 都不动);同文件**不受管的键原样保留**。PUT 的 200 与 GET **同形状**,但值是**写完重新读盘**的真值。
|
||
- 🔴 **`hot` 语义(绝不把「落盘了」说成「生效了」)**:`hot:true` = 值已叠到运行期(整理档会 `Stop→写→Start` **重排心跳**,否则新周期要等旧周期那一觉睡完;审查档重算 `ingestGate`/`tempTtlSec`);`hot:false` = 已落盘但运行期没接上,`hotError` 给原因,面板文案必须是「**重启后端后生效**」。GET 的 `hot` **恒为 false**(读不改变任何东西)——那是如实占位,**别读成能力声明**。
|
||
- **兼容性**:**纯增量**(新端点;既有端点/帧一字未改),老客户端零影响。
|
||
- **计数**:`writeAuth` 段 25 → **27**;管理路由总数 79 → **81**(`14 + 27 + 40`)。字段级说明见 **§4.17**。
|
||
|
||
### v0.91
|
||
|
||
**「召回来源如实上报」版 —— `recallMode` 取值域扩两个 + 语义收紧**(实现 `5967272`,切片 M9;起因 = M6 诊断发现旧实现把「全量降级」静默报成 `embedding`,见 `DevDocs/audit/去重失效诊断-2026-10-13.md`)
|
||
|
||
- `recallMode` 现在**如实上报本轮实际选用的召回路径**,取值域:`"embedding"` | `"lexical"` | **`"mixed"`(部分分区降级、其余走向量 —— v0.91 新增)** | **`""`(没有候选可比 ⇒ 本轮未做召回 —— v0.91 新增;它不是降级,也不该被显示成空标签)**。
|
||
- 🔴 **行为变更(客户端须跟进)**:旧实现报的是**配置态**(进程接没接 embedder),因此「向量路径实际失败、回退到词项」会被显示成 `embedding` —— 面板**看不出降级**。现改为实际态。**试算端点(B14)与整理轮日志/审计同源**;整理轮另新增 `recall_degraded_partitions` 计数,用于回答「哪几个分区降级了」。
|
||
- **兼容性**:按 `embedding|lexical` 二分读的老客户端需自行兜底新值(`mixed` → 「部分降级」;`""` → 「无候选可比」)。前端渲染由后续切片修正(`MemoryTidyDryRunCard`)。
|
||
- **附带的稳定性修复(对客户端透明,无契约影响)**:embedding 批上限改为按「单次请求预算 = 客户端超时的一半」**推演**得出,并在 embedder 内部强制切片(任何调用方给多大的批都安全);**客户端 5s 超时值本身一字未改**。它修的是「客户端先放弃、服务端继续算 ⇒ 队列堆积级联」这条真机制(M9 实测:空闲态单条 ~0.03s(ASCII)/ ~0.16s(40 汉字),饱和态可放大 70~90 倍)。
|
||
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。字段级说明见 **§4.12** 的 `recallMode` 行。
|
||
|
||
### v0.90
|
||
|
||
**「渠道连接态三态」版 —— 管理面新增三个可选键,并把 `connected` 的语义从「动作被受理」纠正为「真的连上了」**(依据:2026-10-13 主人报「改了 QQ 适配器配置后点连接,卡片仍显示未连接,却提示已连接」;实现推库 `b4d55f4`。)
|
||
|
||
- 🔴 **纠正的谎**:`POST /api/v1/admin/channels/open` 此前在 `ch.Connect` 返回 nil 后**硬编码** `connected:true`。而 `Connect` 返回 nil 只说明「这次调用没有致命失败」——**ws+client 首连失败刻意非致命**(OneBot 实现可能比 Cyrene 后启动,重试交给读循环),**ws+server 则只需监听就绪**,这两种情况下 `IsConnected()` 都是 `false`。前端又只按「请求成功」写死「已连接」⇒ 管理面把「后端受理了」说成了「渠道连上了」。
|
||
- **三态闭集(口径落在适配器自身,网关只做映射)**:
|
||
|
||
| 状态 | 含义 | 线上投影 |
|
||
|---|---|---|
|
||
| `connected` | 真连上(client 拨号成功 / HTTP 传输已装配就绪) | `connected: true`,三个可选键**全缺席** |
|
||
| `pending` | 已在后台重试、当前未连上(仅 ws+client 有重试循环) | `connected: false` + `pending: true` + `reason` |
|
||
| `ready` | 已绑定监听、就绪等对端接入(ws+server);`IsConnected()` 同样为 `false`,但**不是失败** | `connected: false` + `ready: true`(**不带** `reason`) |
|
||
| 无意见 | 未启动,或已启动但无可解释的就绪面 | 退回只看 `IsConnected()`,三个键全缺席 |
|
||
|
||
- **新增可选键(三处形状同名同义)**:① `POST /api/v1/admin/channels/open` 与 `.../close` 的响应体;② `GET /api/v1/admin/channels` 的**平台行**;③ 同端点的**账号行**(`accounts[]`)。`reason` 已**脱敏**(形状级 → 已知明文替换 → 剥 `access_token=` 查询参数),**凭据绝不出现**。
|
||
- 🔴 **行为变更(须客户端跟进)**:`open` 的 `connected` 现在是**真实连接状态**,**不再恒为 `true`**;`close` 报**真实** `IsConnected()`,不再假定「调用成功 ⇒ 一定断开了」。老客户端若只看 HTTP 2xx 就提示「已连接」,在 `pending` / `ready` 下会继续骗人 —— 请改读 `connected` 加上述两个键,把卡片画成「已连接 / 连接中(原因)/ 就绪待接入 / 未连接」四档。
|
||
- **退回行为(无破坏性)**:没实现该状态面的平台(kook / telegram / webhook / weixin-iLink)响应**逐字节不变**(既有四键 + 零变换)。
|
||
- **计数不变**:无新端点 ⇒ §2.0 维持 `14 + 25 + 40 = 79`。详见 **§2.4(d)**。
|
||
|
||
### v0.89
|
||
|
||
**「助手消息的行内标签白名单」版 —— 只影响渲染口径,**服务端 API 形状一字未改****(依据:主人 2026-10-07「她在聊天消息中插入了 html/md 标签,能否增加网页前端对此类标签的基本渲染支持?」;Web 端实现推库 `2dae469`。)
|
||
|
||
- 🔴 **背景(实测)**:她输出的**不是 Markdown**(最近 12 条 assistant 消息里 `**` 记号 0 个、列表行 0 个),而是**有限的一小组 HTML 标签**:`<p>` 96 次、`<action>` 34 次、**`<b>` 6 次**。`<p>` / `<action>` 各端早已处理,**`<b>` 此前没有任何一端处理 ⇒ 用户直接看到裸标签**。
|
||
- **新增渲染口径(跨端一致)**:助手消息正文里,以下**行内标签**应被渲染成对应样式,而**不是**按字面显示:
|
||
`b` / `strong`(粗)· `i` / `em`(斜)· `code`(等宽)· `u`(下划线)。
|
||
⚠️ **白名单最终定为上面六项** —— 主人 2026-10-07 明确「**下划线确实可以保留**」⇒ `u` 加回;`s`(删除线)**仍不在其内**(她 100 条输出里 0 命中);`br` **不是行内标签**,见下一节(它是分隔符)。
|
||
- **🔴 `<br>` 是「气泡/消息分隔符」,不是行内标签(同日追加,主人口径)**:主人原话「**对消息内的 `<br>` 始终处理为多条消息隔断**」。
|
||
- **语义与 `<p>` 同级**:`<br>` 两边的文本**必须成为两条独立的消息**(IM 侧)/ **两个独立的气泡**(Web 侧),**而不是**同一条消息里的一个换行。
|
||
- `<br>` / `<br/>` / `</br>` 三种写法**等价**(HTML5 口径)。
|
||
- **空气泡照旧丢弃**(与既有 `<p>` 空气泡口径一致);`<br>` 两侧都是空白 ⇒ 不产生多余空气泡。
|
||
- ⚠️ **影响面(各端须知)**:一条含 `<br>` 的回复在 IM 上会变成**多条消息**(与 `<p>` 一样受既有的气泡间隔节流约束)。
|
||
- ⚠️ **实现状态**:本条发出时**实现跟进中**(Web 侧 `2dae469` 当时把 `<br>` 当行内换行,正在改;IM 出站侧当时连行内标签都还没剥,正在改)。**以本契约为准,不以当日实现为准。**
|
||
- **🆕 非 chat 模式(单卡正文)的口径(同日追加)**:工作 / 代码 / 文档 / 首页 / 视觉 / 语音 / 智能体 / 设置 等**单卡正文**界面**不是气泡模型** ⇒ 那些模式里:**`<br>` ⇒ 一个真换行(不切分)**、**行内白名单照常出样式**、白名单外仍逐字原样。⚠️ **这些模式里也不许出现字面标签**。各模式**既有的较强排版约定优先**(例:工作模式的按行切清单、代码模式的 `<pre>` 等宽块 —— 通用规则不得覆盖它们)。
|
||
- **🔴 `<br>` 在哪**不**分隔(同日追加,跨端口径)**:`<br>` **只在「普通文本层」分隔**。以下两种位置**不分隔**:① **角色注记内部**(`<action>` / `<narration>` 里)—— 否则注记正文会被切成聊天气泡、把本该隐藏的旁白发给对方;② **白名单行内元素内部**(尤其 `<code>`)—— 它是**成对**元素,从中间切开必然制造不平衡、触发「整段降级」,反而把裸标签露给用户。
|
||
- **🔴 三条硬口径(各端必须照此实现,否则跨端显示不一致)**:
|
||
1. **白名单外一律按纯文本原样显示** —— `<script>` 显示成字面量 `<script>`,**字符一个不丢**;
|
||
2. **属性一律不解析** —— `<b class=x onclick=y>` 只取标签名,**属性不得变成任何 DOM/视图属性**;
|
||
3. **结构不平衡**(未闭合 / 落单闭标签 / 交错嵌套)⇒ **该段不产生任何行内渲染,且字符一个不丢**。
|
||
⚠️ **同日晚修订**:具体粒度(「整段降级为原文」还是「只让坏的那一对保持字面」)**降级为实现细节**,契约不再强制统一 —— 这类输入极罕见(安卓扫 100 条、Web 扫 12 条,均 0 例),为它让任一端返工不划算。
|
||
- **🆕 行内标签在 IM 出口的行为**:IM 渠道只收纯聊天文本 ⇒ **成对且平衡的白名单行内标签在投递前剥除标签、保留内部文本**(`<b>18:35</b>` ⇒ `18:35`);**白名单外与结构不平衡的标签一律原样保留**(它们是文本,逐字不丢)。这与既有的 `<action>`/`<narration>`「IM 出口整体剥除」**语义不同**(那两个是角色扮演标记,要丢;行内标签是排版标记,剥样式留文本)。**`<br>` 不在此列** —— 它按上一节走「切分成多条消息」。
|
||
- **大小写口径(各端自行决定,契约不强制)**:Web 端**不敏感**(`<B>` = `<b>`);安卓端**只认小写**(与它 `parse` 只认小写 `<p>` 同口径)。两者都能通过本契约的硬口径,**这一条留待各端自定**(已与安卓端协商)。
|
||
- 🔴 **安全红线**:助手文本是**不可信内容**(模型生成 + 用户可诱导)⇒ **任何端都不许把整段内容当 HTML 注入**(Web 端禁 `dangerouslySetInnerHTML`,原生端禁等价手段)。正确姿势是**解析成结构化节点、交给各自的安全渲染原语**(Web:React 子节点自动转义)。Web 端已由「**全程无 `dangerouslySetInnerHTML`** + 节点类型里**没有属性位**」两条结构性保证锁住,并以「白名单外的 `script` 必须逐字原样」为测试断言。
|
||
- **范围克制**:**不做块级**(段落仍由 §4.16 的 `<p>` 机制表达)· **不做 `<a>`**(链接有独立安全面,且她目前不用)· **不做 Markdown**(她不用)。
|
||
- **不改既有**:`<action>` / `<narration>` 的既有行为逐字不变(Web 灰斜体、IM 出口剥除);**注记内部不参与行内渲染**(注记里写 `<b>` 仍是字面量)。
|
||
- **无回归证据**:Web 端改动前后拿 11 个消息样例过**真实气泡切分链路**逐字节对照 —— 7 个逐字节相同,4 个差异恰是含白名单标签的样例。
|
||
- **兼容性**:**服务端零变更**(无新端点/帧/字段,§2.0 计数不变)。对本契约的读者而言,本版是**新增的渲染约定**:老客户端不跟进 ⇒ 行为与 v0.88 相同(**只会继续显示裸标签**,不会崩)。
|
||
|
||
### v0.88
|
||
|
||
**「单端口红线的适用范围澄清」版 —— 不改任何端点 / 帧 / 字段,只澄清一个边界**(依据:主人 2026-10-07 口径补充「关于不允许开新端口的红线,我加一个补充。那是对 cyrene 框架自身的约束,而不是扩展插件的。」)
|
||
|
||
- 🔴 **澄清内容**:「单二进制、单端口、零跨进程 IPC」这条红线**只约束 Cyrene 框架自身** —— 框架不另开监听端口、不 HTTP 互调(§0.1 已更新措辞为「**框架**唯一对外端口」)。
|
||
- **扩展插件不受该约束**:`plugins/` 下的渠道适配器与工具、MCP server、以及插件自己拉起的本地伴随进程/服务,**可以开自己的监听端口、可以有自己的进程形态**。**那些端口不在本契约范围内** —— **客户端一律只连这一个网关端口**,不得假定能从插件端口取到任何契约面。
|
||
- 原「`qq-obv11` 渠道 OneBot 反连 server 模式」那条例外**由「例外」升格为一般规则的实例**(规格仍见 `design/08 §4`)。
|
||
- **插件端口的四条护栏(主人 2026-10-07 确认采纳)**:① 端口/地址必须由配置**显式给出**,不得默认开启、不得写死;② **默认绑 `127.0.0.1`**,要对外暴露必须由 `.env` 显式决定;③ 必须**登记日志来源与审计**;④ 端口**不得成为绕过身份/权限判定的入口**。
|
||
- **兼容性**:**零破坏性变更** —— 无新端点、无新帧、无字段形状变化、**§2.0 路由计数不变**(`14 + 25 + 40 = 79`)。唯一对客户端的要求是:**别把「插件监听端口」当成契约面**(此前也从未有过这种端口与契约的关联)。
|
||
|
||
### v0.87
|
||
|
||
**「搜索 provider 扩到八家」版 —— 名单扩充,端点形状与错误码一字未改**(切片 `agent-389`,本次提交;依据:主人「我看之前不是走有头CDP扒了好几家搜索提供商的接口方式在本地参考文档吗?为什么不都做一下适配?」+「优先只做国内能用到的几家」。)
|
||
|
||
- **新增两家已登记接入位**:`tencent_wsa`(腾讯云·元宝搜索,`POST {base}/SearchPro` + `Authorization: Bearer`,**零签名**)与 `metaso`(秘塔搜索,`POST {base}/api/v1/search`)。
|
||
- **契约面影响(只有两处,且都是名单扩充)**:① `plugin.saveProvider` 的 `values.provider` 合法名单 **六家 → 八家**(见 §4.14 的三态写回那段);② `plugin.providers` 候选新增两条 `{value,label}`(中文名见 §6 本版上方那条的逐字表)。**§2.0 计数不变**(无新端点,维持 `14 + 25 + 40 = 79`)。
|
||
- **前端零改动**:候选是数据驱动的(`optionsFrom`),中文名只是 `label`。
|
||
- ⚠️ **诚实边界(如实,别当已验)**:① **腾讯成功响应未真机实测**(无 key)—— 依据是官方文档 + 本机无 key 探测(缺 `Authorization` 也回 **HTTP 200** + `Response.Error`);② **秘塔响应 schema 全部来自第三方来源且互相打架** ⇒ 代码走**宽松解析**(多字段名回退),**未真机验证**,拿到真 key 后必须按真实报文收窄;③ 两家的请求体都**只发官方确证为必选的字段**(腾讯只发 `Query`),条数靠本地截断。
|
||
- **零新配置键**:两家只走 `$DATA_DIR/config/plugins/web-search.json`;`.env` / `.env.example` **未动**(`.env` 冻结口径不变)。
|
||
|
||
### v0.86
|
||
|
||
**「搜索结果条数上限可配」版 —— 面板多一个 number 字段 + 两个动作各多一个键**(切片 `bff57bd` + `c68387a`;依据:主人 2026-10-07 口径「返回五条结果会不会太少了?」+ 上一片登记的「`maxResults` 在面板上根本改不了」那条真缺陷。)
|
||
|
||
- **上限真源与语义**:运行期真源是**面板文件** `$DATA_DIR/config/plugins/web-search.json` 的 `maxResults`(**压过** `.env` 的 `WEB_SEARCH_MAX_RESULTS`),是**真上限** —— 模型的 `count` 只能更小(`count:50` ⇒ 只给上限那么多;`count:3` ⇒ 给 3;缺省/非法 ⇒ 上限)。代码默认值 **5 → 10**,三处口径已一致。
|
||
- **契约增量(三项,纯加性)**:① 面板声明新增字段 `maxResults`(`type:"number"`, `min:1`,前端无需改动);② `plugin.saveProvider` 的 `values` 新可写键(**三态**:缺位不改动 / 正整数采用 / 其余 **`400 invalid_payload`** 且**盘上不动**);③ 响应回显 —— `saveProvider.data.result.maxResults`(**0 = 未声明**)与 `reauthProvider.values.maxResults`(盘上有值才出现)。
|
||
- **计数**:**无新端点** ⇒ §2.0 维持 **`14 + 25 + 40 = 79`**。
|
||
- **验证证据**:整树 `go build ./...` 通过;三包 vet/测试绿;端到端「面板存 20 ⇒ 模型要 50 也只给 20、要 3 给 3」(真 action hub → 落盘 → 热重载 → `WithSearchMaxResults` → 真 dispatcher,**未重启后端**);主会话用 `-overlay` 摘掉落盘那一行 ⇒ 两条用例变红。
|
||
- ⚠️ **已知取舍(如实)**:**清空输入框 = `400`**(没有「去掉上限」的手势;要回 `.env` 兜底得改文件);**小数/负数一律拒**;文件未声明该键时 `reauth` **不回填**(该框为空,看不出当前生效值)—— 与「面板显示的是该条、不是生效值」的既有口径一致。
|
||
|
||
### v0.85(上一版)
|
||
|
||
**「整理不再只产建议」版 —— 新增 1 条端点(B25)+ 一处行为变更(轮末自动落地)+ `link` 候选停产**(切片 TIDY-AUTO `6d92536`;依据:主人 2026-10-07 口径「该删就删,怎么这个记忆整理机制这么保守呢」;`design/23 §7/§13/§14`。)
|
||
|
||
- **① 新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`(B25)**:按**动作**批量把 `pending` 候选标成 `rejected`。`action` **必填 + 闭集**;`limit` 缺省 1000 / 硬顶 10000;返回 `{action,limit,total,rejected,truncated}`;审计 `memory.tidy.reject`。**幂等**(再来一次 `total:0`),**只改候选状态、绝不碰记忆行**。详见 **§2.2 B25 / §4.15(e)**。
|
||
- **② 行为变更:轮末自动落地默认开**(可自动仅两类:字面重复 ∨ 事实等价 ∧ ≥0.95 的 `merge`;`one_off_event` 且不像习惯的 `archive`);六条护栏(软动作 / 每轮上限 50 / 审计 `auto=true` / 可 revert / 幂等 / 一键关回只产建议,开关落 `$DATA_DIR/config/memory-tidy.json`,**零新 env 键**)。**不自动**:`reclassify`、低置信 merge、跨分区/跨角色、改正文、受限来源。详见 **§4.15(e)**。
|
||
- **③ `link` 候选停产**:`design/23 §14` 把它列为未细化(无落点无模型),`applyable()` 也不认它 ⇒ 产出它只是噪音。存量清理由 B25 走(主会话已执行)。`TidyLink` 常量保留**只为读历史行**。
|
||
- **④ 计数**:`writeAuth` **24 → 25**、总数 **78 → 79**(`14 + 25 + 40`;`routes.go` 核:`writeAuth` 挂载的 admin 路由实有 27,按历史口径减 2 ⇒ 25)。
|
||
- **⑤ 诚实边界**:**未部署**(要一次 `build` + `restart`);**自动档的真机行为未验**;`reclassify` 仍全部人工;`TidyRuleVersion` **刻意未升版**(不改变任何判定结论;升版会让既有候选按幂等键复制重复行);面板「整理候选」页签仍未落地(前端切片)。
|
||
|
||
### v0.84(上一版)
|
||
|
||
**「显示名 + 段落标签硬约束」版 —— 一处形状变更(插件动作响应)+ 一处新增产出侧硬约束 + 一条模型面登记**(依据:主人报障「下拉里全给英文名,你甚至不愿意带个括号把中文名写上去」;安卓端登记 `<p><p>正文</p>` 畸形;`design/28 §1 M5 / §8 B5` 的浏览器降级档。切片:显示名 `c46053e`、段落修复 `8051906`、provider 配置面去 env 化 `bc1dee3`、搜索 linkless 修复 `2c56bc9`。)
|
||
|
||
- **① 形状变更:`plugin.providers` 候选从裸字符串数组 → 对象数组 `{value,label}`**。见 **§4.14(c)** 与 §2.1 A14。根因**不是**漏写文案,而是**整条链路没有「显示名」的位置**(字段声明只有 `optionsFrom`,动作回的就是 `KnownNames()` 的纯 id 数组)。中文名逐字:`SearXNG 本地兜底(searxng)` / `智谱 BigModel(zhipu)` / `阿里云 IQS 信息查询(aliyun_iqs)` / `博查 Bocha(bocha)` / `火山引擎·豆包搜索(volcengine)` / `百度千帆 AI 搜索(baidu_qianfan)` / **`腾讯云·元宝搜索(tencent_wsa)`** / **`秘塔搜索 Metaso(metaso)`**(🆕 **v0.87 起八家**)。🔴 **`label` 仅供显示、`value` 才是落盘值**;前端解码器兼容三种形状 ⇒ 其他插件零影响;表格按 `value→label` 索引显示、查不到回落原值。
|
||
- **② 新增产出侧硬约束:助手文本不得出现未配对的段落标签**(落单 `<p …>` / `</p>`)。见 **§4.16**。修的是安卓登记的 `<p><p>正文</p>`;**两半根因都在产出侧**(规整器原样放过 + 在「末段漏 `</p>`」时亲手制造),另修一个幂等漏洞。**历史行不回填**、消费方**仍应保留**容错。
|
||
- **③ 模型面登记(非客户端契约面)**:`web-fetch` 的浏览器渲染降级档已接线,返回体用 `fetchedBy`(`direct` / `browser`)**如实标注**正文实际来自哪条路;两条档的失败各自留痕,**绝不静默替换**,被 SSRF 拒绝的目标**不换路**。
|
||
- **④ provider 配置面「去 env 化」(对客户端零形状影响,登记供跨端对照)**:新增 provider 不再需要新的 `.env` 键 —— 面板文件 `$DATA_DIR/config/plugins/web-search.json` **是权威**,`.env` 只在文件缺失/为空时**播种**(文件一旦存在,「文件里没有的家」**不从 env 复活** —— 这是既有回归用例钉住的口径,本次未改);各家「没配 baseUrl 就回落代码默认值」的默认值表移进接入位表(`search.Descriptor.DefaultBaseURL`),**逐字未变**。
|
||
- **行为变更**:**有一处(且是"变真")**:① 让「文件里没有的家不从 env 复活」这条既有口径在**新家**上也成立(此前新家根本进不来);② 段落标签约束让产出**不再畸形**(对不产畸形的部署零影响)。
|
||
- **破坏性**:**无**(`plugin.providers` 只改**候选**形状且前端三形状兼容;无键被删、无端点被删、无既有语义被改)。
|
||
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 24 + 40 = 78**(§2.2 B1–B24)。
|
||
- **验证证据(本版实测)**:`go build ./...` + 各包 `vet` 绿;相关包测试全绿;三处变异由主会话**亲自**复现打红(把 `option.value` 换成 `option.label` ⇒ 渲染测试红,输出正是会写坏配置的 `"bocha, 智谱 BigModel(zhipu)"`;摘掉配对修复接线 ⇒ 段落用例红;把「文件里的 key」忽略 ⇒ 4 条 provider 投影用例红)。
|
||
- ⚠️ **诚实边界**:① **`web-search` 真机工具路径在登记时仍有一处未部署的修复**(详见 v0.84 横幅与 §7.2)—— 已推库、**未 build/restart**;② 显示名的**真机浏览器点击**未跑(项目无 jsdom,证据止于渲染层)—— **且 `internal/webui/dist` 是 `go:embed` 进二进制的,前端改动必须重新 `build` 才生效**(子代理曾误称"纯前端改动不需要重启",已纠正);③ **`viewer` 角色看不到 form 区块** ⇒ 表格对 viewer 仍显示裸 id(未做);④ 腾讯云元宝 / 秘塔两家的适配**尚未实现**(文档已逐页落盘)。
|
||
|
||
### v0.83(上一版)
|
||
|
||
**记忆整理「应用面」—— 五条 `writeAuth` 新端点 + 读侧 status 闸(行为修复)**(切片 TIDY-1 `454ddb0` + `9126d65` + `f847769`;依据:`design/23` §7/§8/§10/§11;主人实测口径「候选 77 条全 pending、两天一条没应用」+「重复无效记忆都一千多条了」。)
|
||
|
||
- **① 五条新端点(B20–B24)**:`GET …/memories/candidates`(候选列表,只读不审计)、`POST …/candidates/{id}/apply`(单条应用)、`POST …/candidates/apply?runId=&limit=`(批量;`runId` 省略 = 全部待定)、`POST …/candidates/{id}/revert`(单条回滚)、`POST …/memories/tidy/run`(手动真跑,写候选 + 留痕、**不自动应用**)。语义与逐字段形状见 **§2.2 B20–B24** 与 **§4.15**。审计:应用/回滚走 `memory.tidy.apply` / `memory.tidy.revert`(**只记计数与动作、不带正文**);真跑的 `memory.tidy.run` 由**领域侧**记(一次点击一条)。
|
||
- **② 读侧 status 闸(行为修复,形状零变化)**:记忆的四条**列表**读路径(`ListByPersona` / `List` / `ListScope` / `ListProject`)现在只回 `status='active'` ⇒ 被软合并(`'merged'`)/被归档(`'archived'`)的记忆**不再被召回、不再进上下文、不再出现在 `GET /admin/memories`**。修的是「软动作写了 status,读侧却不看 ⇒ 清了也白清」(生产库已有 10 条 `merged` 行是现成反例)。🔴 **两处刻意不过闸**:`Get`(撤销软合并要先读得到被吸收的行)与 `GET /admin/memories/reviews`(留痕表本身没有 status 概念,且它是查看入口 —— 加闸等于审计自我抹除)。
|
||
- **③ 调度不再饿死(对客户端零形状影响)**:整理由「ticker 从进程启动算起、重启十几次就永远走不完 24h」改为**到点即跑 + catch-up**,以 `memory_tidy_runs` 留痕行为**持久事实**判「上次跑过没有」;配 B24 作手动 panic button。
|
||
- **行为变更**:**有(一处)**:② 让软删行从检索/上下文/面板里**真的消失** —— 这正是本版的目的;对**不软删任何东西**的部署,行为与 v0.82 逐字相同。
|
||
- **破坏性**:**无**(五条都是新端点;② 是读侧收紧,不删键、不改键义、不改既有端点形状)。
|
||
- **计数**:`writeAuth` 段 **19 → 24**、管理路由总数 **73 → 78**(`14 + 24 + 40`;`routes.go` 核:经 `writeAuth` 挂载的 admin 路由实有 26,按历史口径减 §2.4(a) 的 `channels/events` 与 `.../counters` ⇒ 24)。§2.2 行数 **B1–B24**。
|
||
- **验证证据(本版实测)**:`go build ./...` + `go vet` 绿;`internal/runtime/memory` 包全量测试绿 + 新增读路径逐条断言(4 条列表 + 检索端到端「内容逐字相同的两条,软合并后只召回一条」+ `Get` 反向护栏);`internal/services/gateway` 新增面板端到端用例(真 `sql.DB` + 真 store + 真 HTTP handler);**两组变异**(overlay 把闸门改成恒真)都被打红。
|
||
- ⚠️ **诚实边界**:① **生产库全程零动作**(1191 行记忆 / 77 条候选都还没应用或清理 —— 交付的是**能力**);② **面板真机端到端未跑**(前端操作面在本版登记时未落地,并行切片在做);③ 旧候选 `run_id` 为空串 ⇒ 只能用「全部待定」档清;④ 候选表 `run_id` 列只在存储包前滚、**未**入迁移 `0019`;⑤ 远端 embedder 对同一文本返回不同向量 ⇒ **语义召回不可复现**(不影响规则层与状态机,但影响「候选是否可复现」)。
|
||
|
||
### v0.82(上一版)
|
||
|
||
**三面增量 —— ① `tool_execution.end` 带可选 `resultSummary`(工具执行结果实时可见,切片 Y1 `215eedf`)|② 自排的成本可见面:`?trigger=` 服务端筛选 + 新端点 `GET /admin/backthink/self-wake`(切片 Y3 `4213b26`)|③ `browser` 插件的管理面板与六个动作(切片 Y4 `e0a6bca`)**(依据:安卓端按 ① 把工具卡 UI 定死;`design/12 §N C.5/C.6` 的自排成本口径与筛选;`design/28 §8 B4` 的面板入口。)
|
||
|
||
- **① `tool_execution.end` 的 `data` 新增可选 `resultSummary`(string)**:`{tool, phase, id?, delta?, resultSummary?}`。数据来源 = **loop 侧**在工具返回值落定的那一刻生成(`internal/runtime/loop/tool_frame_summary.go`),走的是**与出图同一条**脱敏路径(`trace.RedactText`:凭据形状清洗 + 截断),**不从上游原文反解、不新开脱敏通道**。修的是「实时只看到工具名与状态、要事后翻回看面才知道结果」。**§3.4.2**
|
||
- 🔴 **缺席语义(安卓端按此编程)**:**键在场 ⇒ 渲染结果区;键缺席 ⇒ 只显示工具名与状态**。**只有「真的没有结果」才缺席**:调用成功但结果为空(nil / 空串)、或错误文本本身为空。**绝不用空串顶位**(空串会让客户端渲染出一个空的结果区)。`tool_execution.start` / `.update` **不带**该键。
|
||
- 🔴 **失败也带(本版口径)**:调用**失败 / 取消 / 超时**的 `.end` 帧带的是**该次错误文本的脱敏渲染** —— 与**回看面**(那里失败调用回的是图上的 `error` 摘要)**给同一份说法**,客户端不必为「实时」与「回看」写两套判据。⚠️ **本版之前**(切片 Y1 的首版实现)失败时是**整键缺席**、回看面却有 `error` 摘要 ⇒ 同一次调用「实时看不到原因、事后看得到」;本版把这处张力收掉了。
|
||
- **预算**:与回看面 `toolCalls[].resultSummary` **共用同一个常量与同一个截断函数**(`maxToolCallSummaryRunes = 500`,超长按 rune 截断、末位 `…`)。网关侧**只截断、不做二次脱敏**(与回看面「只搬运、不加工」同一口径)。
|
||
- **② 自排的成本可见面(切片 Y3)**:
|
||
- `GET /api/v1/admin/backthink` 新增**可选** `?trigger=` **服务端筛选**(design/12 §N C.6)。取值域 = **闭集**(`heartbeat` / `startup` / `self_wake` + 本服务订阅的事件主题,真源 `backthink.TriggerFilterValues()`);**值未知或为空 ⇒ `400 invalid_trigger`**(**绝不静默忽略** —— 那会让面板在显示全部的同时声称「正在筛选」);合法值**下推 SQL** ⇒ `limit` 数的是**匹配**条数。🔴 **「筛选白名单是闭集」与「`trigger` 的显示取值域不封闭」不矛盾**(前者约束客户端传进来的参数、后者约束我们发出去的显示值),见 §4.10(e-1)。
|
||
- 新增 1 条 `writeAuth` 只读端点 **`GET /api/v1/admin/backthink/self-wake`**(design/12 §N C.5 的读侧)。响应 `{data:{available,running,reason?,configFile?,gate?}}`,`gate` = `{pending,used,limit,windowSec,defaultLimit,defaultWindowSec,limitIsDefault,windowIsDefault}`。**三态必须可区分**(面板不得把「看不到」画成「0 次」);`available:false` 时 `gate` **整键缺席**且 `reason` 说明原因、**HTTP 仍是 `200`**。🔴 **`limitIsDefault` / `windowIsDefault` 只说「生效值恰好等于代码默认值」**,**不是**「配置文件里没写这个键」(网关不读 `backthink.json`,与 B12 的 `isDefault` 同一诚实口径)。🔴 **它只读、不审计、不写库**,也**不含任何可改配置的入口**(改上限/窗口仍要改文件 + 重启)。**§2.2 B19 / §4.10(e-2)**
|
||
- 🔴 **同版改正一处已成假话的旧口径**:v0.81 写的「`request_think` **尚未接线** ⇒ `self_wake` 与 4 条审计动词在生产里不会出现」**已过期** —— 入口工具**已接线**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,切片 X2 `2d211c2`;启动日志自证 `self wake tool registered tool=request_think`)⇒ **它们在生产里会真的出现**。§4.10(d) 与 §7.1 已同步改。
|
||
- **③ `browser` 插件的管理面板与六个动作(切片 Y4;design/28 §8 B4)**:`GET /api/v1/admin/panels` 的目录新增 **`browser` / `settings`**(「浏览器与截图」,`route = plugin/browser/settings`,4 个区块 = 1 个 `form` 配置 + 3 张只读 `table`),该插件声明 **6 个动作**(3 读 `plugin.getBrowserState` / `plugin.listShots` / `plugin.getFingerprint`;3 写 `plugin.saveBrowserConfig` / `plugin.shootPage` / `plugin.checkFingerprint`),**全部已登记生产处理器**,**不开任何新路由**(走既有 A14 通用动作分发)⇒ **§2.1 的 14 与总数不因本项变化**。保存 = **落盘 + 热重载**(重建工具 + 原子替换注册表条目 ⇒ 下一轮生效);保存前**本来有**可用工具而新配置让它不可用 ⇒ **回滚文件** + **`409 config_rejected`**;保存前**本来没有**可用工具 ⇒ 文件留下 + `200` + `applied:false` + `reason`(**不报错**,否则主人没法先配好)。🔴 **读面绝不启动浏览器**(面板的只读区块连 viewer 都能看,让「看一眼」产生进程外副作用是越界);真正会起浏览器的只有 `plugin.shootPage` / `plugin.checkFingerprint` 两个写动作(`plugin.saveBrowserConfig` 的「热重载」只在**重建工具**时按配置探测可执行文件)。**§4.14(d)**
|
||
- **③ 附:模型面(非客户端契约面,登记供跨端对照)**:`browser` 工具的 `screenshot` 返回值新增 **`turnAttachment`**(闭集 `queued` / `no-session` / `outbox-full` / `disabled`,配一句如实的 `hint`)—— 让**模型与面板**都能看出「这张图到底进没进上下文」,而不是只在日志里。⚠️ **`queued` 与「进上下文」之间还有一环**:`loop.Options.TransformContext` 上挂的装配钩子(`cmd/cyrene/wire_browser_context.go`)负责**取走即消费**地把它并进本轮视图。
|
||
- **行为变更**:**无**(既有键逐字未改;新增的都是**可选键 / 新端点 / 新声明**)。
|
||
- **破坏性**:**无**(`resultSummary` 是新可选键,老客户端不读即行为逐字不变;`?trigger=` 缺席即不过滤;`GET /admin/panels` 多一条目录项 —— 按 `pluginIds` 差集渲染「未声明管理页面」的客户端零影响)。
|
||
- **计数**:`writeAuth` 段 **18 → 19**(新增 B19 `GET /admin/backthink/self-wake`)、管理路由总数 **72 → 73**(`14 + 19 + 40`);§2.2 行数 **B1–B19**。**本版无其它新路由**(③ 走既有 A14)。
|
||
- ⚠️ **诚实边界**:① **①③ 的形状取自「工作树实现」** —— 登记时 ① 的「失败也带」(`internal/runtime/loop/tool_frame_summary.go`)与 ③ 的**两个组合根挂点**(`cmd/cyrene/wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`cmd/cyrene/wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),`)**都还在工作树、尚未提交** ⇒ **提交前生产里没有这些行为**;提交后请按 `git show` 逐字复核(见 §7.4-30 / §7.4-31);② ① 的**真机**(`:10942` 发布产物)端到端**未跑**;③ ② 的**真机面板端到端未跑**(面板写路径**未做**,只有读侧);④ ③ 的**真机 e2e 未跑**(既有 `wire_browser_e2e_test.go` 默认 `Skip`,需要真 Chrome + 可执行文件路径)。
|
||
|
||
### v0.81(上一版)
|
||
|
||
**一批增量 + 两处真缺陷修复 —— ① 她自排轮(`self_wake`)的用户可见面(形状登记;生产暂不可达)|② 时间轴新增 `retentionBoundaryReached` 并与 `truncated` 分意(切片 X1)|③ `round` 跨重启不再重复(切片 X1 的行为修复)**(依据:`design/12 §N C` 的自排切片 C1-1 `94da6bc` / C1-2 `ed50053`;`design/19 §7` 时间轴的保留期诚实口径与 §6.1 的「`round` 每会话单调」;`AGENTS §9.5` 的对照要求。)
|
||
|
||
- **① 她自排轮的用户可见面(切片 C1-1 / C1-2)**:`GET /api/v1/admin/backthink` 的行 **`trigger` 新增取值 `"self_wake"`**(= 她自己排起的一轮思考;`trigger` 的取值域**不封闭**:`heartbeat` / `startup` / 事件主题 / `self_wake`);**新增 4 条审计动词** `backthink.self_wake.{queued,coalesced,capped,denied}`(`who` 恒 `backthink`,经既有 B9 `GET /admin/observability/audit` 可查,`result` 是可直接显示的中文)。**无新端点、无新字段表**(两处均为取值域的**加性**扩张)。边界:自排**只保证「她被叫醒思考」**,要不要真发出去仍由既有 `decide` 级联 + `PushGate` 决定(安静时段/限频/冷却照生效);每窗口上限(默认 **4 次/小时**)**不在契约里冻结**(**不新增 env 键** —— `.env` 已冻结)。🔴 **可达性**:**她的入口工具 `request_think` 尚未接线**(下一片在办,`thinktool.go` 尚未提交)⇒ **该取值与这 4 条动词在生产里目前还不会出现**;本版**登记形状**,客户端不得当活跃数据依赖。**§4.10(d)**
|
||
- **② 时间轴的保留期信号落地 + `truncated` 分意(切片 X1)**:`GET /api/v1/admin/flow/timeline` 的响应新增 **`retentionBoundaryReached`**(bool)—— **`true` = 该会话确有更早的轮次已被保留期清扫删掉,且本次已取到它现存最早的一端**。判据三条**缺一不可**:① `retentionEnabled:true`;② `hasMore:false`;③ 现存最早一轮的**轮次号 > 1**(轮次号每会话自 1 起、跨重启单调 ⇒ 号更小的轮次曾落盘、又不在库,而 `flow_graphs` 的唯一删除者是保留期清扫)。⇒ **`truncated` 从此只表示「本次不是全部」**(分页未到底 / 落盘源未接线),**不携带**清扫信息;面板说「更早的轮次已过保留期」的**唯一**判据是 `retentionBoundaryReached:true`。⚠️ **盲区(写进契约,不掩盖)**:整段历史都被清扫 ⇒ `false`(**少说,绝不多说**);有轮次号未知的老行 ⇒ `false`;最早那一轮**落盘失败**(号已用掉、行从未进库)⇒ 可能**错报**为 `true`(概率低,只在库写失败时出现)。**§2.2 B18 / §4.11.1(a)(b)-3**
|
||
- **③ `round` 跨重启不再重复(切片 X1 的行为修复)**:轮次号原本只是发布器**进程内**计数 ⇒ 重启就从 1 重来,而 `flow_graphs` 保留 168h ⇒ 时间轴按轮次号分组时把**重启前那一轮**与**重启后那一轮**并成**一块**(真机实测 `session=web`:同一分组键下三次真实轮被并成「第 1 轮 · 3 路」)。修法 = 发布器**本进程第一次**给某会话发号前,从落盘侧读一次**已落盘的最大轮次号**并播种计数器(`flowgraph.MaxRound`),同一会话的并发首轮会等这一次播种。⇒ 契约里「`round` 每会话单调」这句**真的成立**。⚠️ 边界:播种是**尽力而为**(读库失败只 WARN 并退回进程内计数、**不重试** ⇒ 仍可能重复,客户端此时应以 `startedAt` 为准);库里为空 / 历史被整段清扫 ⇒ 从 1 起。**§4.11.1(e)**
|
||
- **行为变更**:**有(但都是「变真」而非「变形」)**:① 两处取值域**加性**扩张(只显示 `trigger` 原文的客户端零影响);② ③ 是**行为修复**(重启后轮次号不再重复 ⇒ 分组不再并块);② 是**字段加性**扩张 + 对既有 `truncated` 的**读数收紧**(此前把它当清扫信号读的客户端会发现自己原来读到的是假话)。
|
||
- **破坏性**:**无**(无键被删、无键被改义;`truncated` 的**定义**未变,变的是「不得拿它当清扫信号」这句口径)。
|
||
- **计数**:**无新端点** ⇒ §2.0 权限矩阵维持 **14 + 18 + 40 = 72**(§2.2 B1–B18 共 **18** 条;②③ 都是往既有 B18 的响应/行为上加东西)。
|
||
- ⚠️ **诚实边界**:① 自排的**端到端生产验证未做**(入口工具未接线,见 ①);② **②③ 的形状与判据取自切片 X1 的「工作树实现」**(本版登记时该切片**尚未提交**,`f1fbc24` 之后仍在工作树)⇒ **commit 后请按 `git show` 逐字复核键名与判据**;③ ② 的三条盲区**没有测试能覆盖到「整段被清扫」那一支**(属设计登记的诚实边界);④ 时间轴**真机面板端到端**仍未跑(同 v0.80 ①);⑤ 同波其余切片(技能渐进披露 L2 `6475eb8`、示例技能 V3 `4a04ad4`、浏览器 B1/B2 V5 `f1fbc24`)**对客户端零形状影响**,故未逐条登记。⚠️ 括注原文写「浏览器是**模型面工具**,不进客户端契约面」—— **对 B1/B2 成立,但已部分过期**:v0.82 的 **B4**(浏览器管理面板,切片 Y4)**进**客户端契约面,见 §4.14(d)。
|
||
|
||
### v0.80(上一版)
|
||
|
||
**三批增量 —— ① 链路图时间轴出口(切片 W1)|② 搜索 provider 热重载 + 主 provider / 降级链(切片 W3)|③ 入站语音 AMR 也可回放(切片 W4)**(依据:主人 2026-10-06 要「web 后台的全链路流程图 = **所有消息按时间顺序串起来**,而不是只有最新一个来回」;面板的意义就是「不改 `.env` 不重启」⇒ 配置面必须真热生效;微信 / QQ 的 AMR 入站语音不落副本 = 无法回放 / 多端同步。)
|
||
|
||
- **① 链路图时间轴出口(切片 W1,`design/19 §7`)**:新增 1 条 `writeAuth`(owner\|admin)**只读、不审计**端点 **`GET /api/v1/admin/flow/timeline`**(`?sessionId=&limit=&before=`)—— 把多轮链路图**按时间正序**返回,每轮 `{turnId,round,startedAt,endedAt,lanes[]}`(`lanes[]` 每项 `{turnId,graph}`,`graph` 与 `graph_update` 同形)。数据源 = **已落盘的 `flow_graphs`**(迁移 `0022`/`0023`,保留期 `FLOW_GRAPH_RETENTION` 默认 168h)**+ 内存里的最近一轮**(在飞 / 刚落盘失败的那一轮);`limit` 缺省 **50** / 硬上限 **200**(超上限钳住、`≤0` 同缺省、非整数 `400 bad_request`);`before` 游标取「开始时刻**严格早于**它」的轮次、响应回带 `nextBefore`(**原样回带即可连续翻页**,分页不重不漏);响应另给 `retentionHours` / `retentionEnabled` / `truncated` 供面板**如实**标注「更早的轮次已过保留期」。**未接线(内存 + 落盘都没接)⇒ `500 flow_unavailable`**,**绝不回空 `rounds`**(不假装「这个会话没有轮次」)。权限矩阵:`writeAuth` 段实有 **18** / 总数实有 **72**(**本版同时回补了自 v0.76 起的 −1 记账滞后**,见 §2.0)。🔴 **老端点 `GET /admin/flow/graph`(B13)逐字未改**(`admin_flow.go` 零改动,`routes.go` 只加一行路由 + 注释)⇒ **老客户端零影响**。**§2.0 / §2.2 B18 / §4.11.1**。
|
||
- **② 搜索 provider 热重载 + 主 provider / 降级链(切片 W3;裁决 2026-10-06 #1 / #2)**:`plugin.saveProvider` 成功落盘后,组合根**按落盘后的配置重建 web-search 工具并原子替换能力注册表里的条目**(新增窄方法 `registry.ReplaceTool` —— **不是** `Remove`+`Register`:中间无空窗,且**保留运行期策略**)⇒ **下一轮对话即用新配置,不需要重启**(design/09 §5.5)。**保存是一次事务**:快照文件 → 落盘 → 重建 → 换条目;③ 失败 ⇒ **文件按字节回滚**(temp+rename)+ **`409 config_unstartable`**(会 make-下次起不来的配置绝不留在盘上);回滚也失败 ⇒ 如实 `500 config_write_failed`。响应新增机器可读事实 **`hotReload:{applied,registered}`**。声明面**增补两个文件级字段** `primary`(`select`)/ `fallback`(`multiSelect`,候选来源 `plugin:providers`)—— **缺位 = 不改动**;`primary` 给空串 / `off` / `none` = **关掉这把工具**(与 `WEB_SEARCH_PROVIDER` 共用同一套关闭语义);点名「已登记但未配齐」的家 ⇒ **`400 primary_not_configured`**(**写盘之前**就拒);`fallback` 元素未登记 ⇒ `400 unknown_provider`、形状错 ⇒ `400 invalid_payload`。`plugin.reauthProvider` 的 `values` **追加** `primary` / `fallback`(否则面板「清空降级链」在客户端三态写回里清不掉)。**新增错误码**:`primary_not_configured`(400) / `config_unstartable`(409)。🔴 **本版改正三处已成假话的旧口径**(此前写「面板保存后**不**热替换运行中的工具、需重启」、「面板**无**主 provider / 降级链字段」):§4.14(b) / §4.14(c) / §7.2 / §7.4-26 / §7.4-27 已改。⚠️ **触发面只有本动作**:直接改写可变源文件(不经面板)**不**触发热重载,仍需重启。**§4.1 / §4.14(b)(c) / §7.2**。
|
||
- **③ 入站语音 AMR 也可回放(切片 W4;裁决 #11)**:组合根补齐 `channelbridge.Options.VoiceTranscoder`(`cmd/cyrene/wire_channels.go`)—— 注入的是 `wireTranscode` 装配的**同一个**引擎实例(与工具面 `transcode` 工具同源,不是第二条降级链)。⇒ **不可播格式的入站语音在落盘之前先转成 `.wav` 再落副本**(`mime: audio/wav`;本期唯一必需的转码路 = QQ OneBot v11 的 `amr → wav`),客户端的 `voice` 从此带副本事实(`name` / `mime` / `size` / `durationMs` / `url`);**此前这些键缺席**(走 `category=transcode_not_wired` 的诚实 WARN、不落副本)。**形状零变化**(DTO `voice` 的键集合与语义一字未改);**可播格式**(ogg/wav/mp3/m4a/aac/flac/webm/oga/opus)仍**原样落、一次转码都不发生**。稳定 `category` 列表**不变**(10 类),但 `transcode_not_wired` 在生产装配下**不再出现**(它现在只剩「引擎未装配 / 构造失败」这一档)。🔴 **本版改正 6 处已成假话的「组合根未注入 `VoiceTranscoder` ⇒ AMR 不落副本」**:§4.5(缺口① + 现状行)/ §7.1(对照注)/ §7.2(语音面行 ②)/ §7.4-16(前向提示)/ §7.4-29(②)/ 旧版本横幅与 §6 旧版条目(v0.67 / v0.78 / v0.79)。**§4.5 / §7.2 / §7.4-29**。
|
||
- **行为变更**:① 无(新端点;老端点逐字未改);② **有**(面板保存由「需重启生效」变为**下一轮即生效**;新增两个可配字段与两个错误码;`plugin.saveProvider` / `plugin.reauthProvider` 的响应形状**加键**);③ 无(DTO `voice` 的键集合与语义未变,只是**此前缺席的键现在会出现**)。
|
||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;新增的响应键都是**加性**的,老客户端不读即行为不变)。
|
||
- ⚠️ **诚实边界**:① 时间轴的**前端渲染 / 真机面板联调**不在后端出口的证据内;`graph_json` 破损行的 `500` 路径**无测试**;游标精度到**毫秒**(同一毫秒起跑的两个不同轮次在极端情况下可能被一并跳过 —— 理论边界,已写进 §4.11.1(b)-4)。② 热重载的**真机面板端到端未跑**、**真机探针未打真实上游**;**启用态不跨重启持久化**。③ 语音**生产端到端未跑**(红线:不重启后端)⇒ 证据是 `channelbridge` 行为单测(真 AMR 夹具 → 真引擎 → RIFF/WAVE)与 `cmd/cyrene` 的**源码级接线用例**;WS 实时帧 / 握手回放**仍不带 `voice`**;删除消息 / 会话时音频副本引用计数**仍不释放**。逐条见 **§7.2 / §7.3 / §7.4**。
|
||
|
||
### v0.79(上一版)
|
||
|
||
**四批增量 —— ① 插件面板动作真可用(切片 L1/PLG-2 的执行面接线)|② 「入库前审查」(D3)管理面落地(切片 L45)|③ 语音消息的用户可见面落地(切片 L45)|④ 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2,**唯一一处形状变化**)**(依据:面板动作此前一律 501 ⇒ 面板「点了没反应」;主人要「入库前审查可查、可回滚」;安卓/Web 要「语音能回放、消息能标出语音来源」;`toolCalls[]` 覆盖率此前只到真并行路 ⇒ 普通单路聊天与队首续跑回复回看不了工具。)
|
||
|
||
- **① 插件面板动作真可用(切片 L1/PLG-2)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions` → `Panels.RegisterActions`,取代单一全局执行器;**声明仍是唯一闸门** —— 登记≠可执行);组合根在 `wirePanelHub` 里登记内置 `web-search` 的**四个动作 + 下拉候选来源**(`plugin.providers`)⇒ `POST /api/v1/admin/plugins/{id}/actions/{action}` **不再一律 501** —— 真读写 `$DATA_DIR/config/plugins/web-search.json`(0600 + temp+rename,**重启仍生效**;**v0.80 起保存后即热重载**,见 §6 v0.80 ②)、探针**真发一次最小 HTTP 检索**。失败回**结构化非 2xx**(码/文案/状态由动作处理器给出,宿主只透传、状态越界回落 502):新增 `invalid_payload`(400) / `unknown_provider`(400) / `not_configured`(400) / `config_unreadable`(500) / `config_write_failed`(500),以及探针码 `unauthorized` / `rate_limited` / `quota_exceeded` / `network` / `upstream` / `bad_response` / `no_results` / `unknown`(502);**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(**不再是兜底档**);兜底执行失败仍是 `502 action_failed`。审计仍 `what="plugin.action"`,**服务端日志新增附加字段 `code`**(**不进 DB 记录体、不含请求体**)。**§2.1 A14 / §4.1 / §4.14**。
|
||
- **② 「入库前审查」(D3)的管理面(切片 L45)**:新增 2 条 `writeAuth`(owner\|admin)端点 —— `GET /api/v1/admin/memories/reviews?limit=`(留痕列表:`limit` 缺省 100、`≤0` 同缺省、>500 钳位、非整数 `400`;元素含 `decision ∈ skip\|merge\|modify\|create`、`mergedIds` 恒为非 null 数组、`createdAt` UTC RFC3339;🔴 **不含记忆正文**)与 `POST /api/v1/admin/memories/{id}/revert`(撤销一次**软合并**:被吸收行复原 `active`、从存活行 `merged_from` 摘除、`merge_count` 减 1;**行永不物理删除**)。未接线 ⇒ `500 memory_review_unavailable`(**绝不回空数组**);回滚落审计新动词 **`memory.review.revert`**(只记 item id)。权限矩阵:`writeAuth` 14 → **16**、总数 68 → **70**。**§2.0 / §2.2 B16/B17 / §4.1**。
|
||
- **③ 语音消息的用户可见面(切片 L45)**:消息 DTO 的 **`voice`**(含服务端唯一派生的 `url`)在 **4 个 REST 读取面 + `message_edited`** 真实填充;新增 **`GET /api/v1/media/{name}`**(`writeAuth`;`audio/*` 内联 + `nosniff` + `Accept-Ranges` + `Cache-Control: private, max-age=86400, immutable`;**无 `ETag`**;`Range` ⇒ 206、条件请求 ⇒ 304、支持 `HEAD`、不可满足 `Range` ⇒ 416(**非 JSON 信封**);名字非法 / 穿越 / 缺失统一 `404 media_not_found`);副本落盘走新增 `documents.Store.SaveAudio`(内容寻址、0600、元数据进同一张 `documents` 表、并入同一套引用计数),`chat_messages.voice` 列读写两条路补齐(`history.Store.UpdateMessageVoice` / `VoiceColumns`)。⚠️ **残留缺口(如实)**:`message_persisted` / `channel_persisted` 帧与握手 `history` 回放**仍不带 `voice`**(需给 `history.Message` 加字段,未做;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值);组合根**未注入 `VoiceTranscoder`** ⇒ **AMR 不落副本**(可播格式照落)—— ✅ **该缺口已由 v0.80(切片 W4)修复**(组合根已注入,见 §6 v0.80 ③ / §4.5 / §7.2)。**§0.3 / §1.2 U19 / §3.4.4 / §4.1 / §4.4 / §4.5**。
|
||
- **④ 🔴 链路图权威回合标识覆盖到全部编排回合(切片 CODE-TC-2)**:新增 `loop.FlowPublisher.StartRoundWithTurnID(ctx, owner, text)`(权威回合标识取自 ctx 的 `TurnIDFrom`),`run.go` 的**唯一生产建图点**改走它 ⇒ 编排器驱动的**每一路**(**普通单路回合**、队首续跑路、真并行路)的图都带与 `chat_messages.turn_id` **同值的 `turnId`** ⇒ 消息面 **`toolCalls[]` 覆盖率从「只有真并行路」扩大到「全部编排回合」**(普通单路聊天与队首续跑回复现在也能回看工具)。🔴 **唯一形状变化**:`graph.id` 的 `#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`)—— 该键仍**不透明**、仍可能含 `#`;客户端**一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼** `{会话}-{序号}`(自拼的老客户端**查不到新行**)。**缺席语义一字未改**:拿不到权威标识的写入路径(未接编排器的调用方、**后台思考** `sess-backthink`、旧后端)`turnId` 仍缺席、`toolCalls` 仍**整体缺席**(**绝不回空数组**);**v0.79 之前的单路历史行不回填**(`lane_turn_id` 为空且无可回填来源)。**§4.4 / §4.11 / §7.2 / §7.4-23**。
|
||
- **行为变更**:① **有**(面板动作由「全 501」变为**真执行**)+ 新增一批错误码;② 无(新端点;老客户端零影响);③ 无(`voice` 是既有 DTO 的可选键、`GET /media/{name}` 是新端点);④ **有(唯一一处形状变化)**:`graph.id` 对单路也追加后缀 ⇒ 自拼 id 的读取方须改用响应值(**其余形状与缺席语义一字未改**)。
|
||
- **破坏性**:**无**(新端点 / 新可选键 / 新错误码;④的形状变化只影响「自己拼 `graph.id`」的读取方,而本文档自 v0.73 起就写明该键**不透明、应从响应取**)。
|
||
- ⚠️ **诚实边界**:① 面板动作**热重载未做**(保存后运行中的 web-search 工具不换接线,需重启)—— ✅ **已由 v0.80(切片 W3)修复**(见 §6 v0.80 ②)、**探针只用 `httptest` 假上游**(未对真实上游连通,**仍未做**);② 回滚端点与 `design/23 §11` 的 owner-only 口径**有差异**(待裁决)、留痕列表**无分页游标**、`memory_ingest_reviews` 无 TTL/GC;③ 语音**生产端到端未跑**、WS 实时帧/握手回放不带 `voice`、AMR 不落副本(✅ **已由 v0.80 修复**)、删除消息/会话时音频副本引用计数不释放;④ 单路 `toolCalls[]` 的**HTTP 出口端到端未由本切片确证**(写侧已验,网关读侧由主会话补验)、`turns[]` 仍按**路**列条目、真机未验。逐条见 **§7.2 / §7.4**。
|
||
|
||
### v0.78(上一版)
|
||
|
||
**六面增量 —— ① 消息读取面 `toolCalls[]` 落地(覆盖率受限)|② 插件管理面板接入点(`GET /admin/panels` + 插件动作分发端点 + `panels_changed` 帧)|③ 搜索 provider 可变源(`web-search` 插件)|④ 账号上下线落事件 + 渠道事件实时帧 `channel_event`|⑤ 微信 iLink 凭据三态(`authState`/`authDetail`)|⑥ 语音消息落库面(迁移 `0020`)**(依据:安卓侧要「回看这条回复当时调了什么工具」;design/09 §5.2.1 的插件面板接入点 + §5.4 的安全与权限;design/27 的网络搜索提供商 + design/13 §2.1 的插件配置落点;design/26 §2.6/§4.5 的账号状态事件与实时面;`errcode=-14` 被读成「过期」的误判风险;design/24 §10 的语音落库面。)
|
||
|
||
- **① 消息读取面 `toolCalls[]` 落地(切片 CODE-TC)**:`GET /api/v1/sessions/{id}/messages` 的消息对象新增**可选** `toolCalls`(`omitempty`),每条 `{name,status,durationMs?,argsSummary?,resultSummary?}`(`name`/`status` 恒在,`status ∈ {ok,error,unknown}` 闭集三态;后三键图上给不出时**缺席**)。数据源 = **已落盘的轮次链路图** `flow_graphs`;🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**,**绝不回 `[]`**(客户端只做正向断言)。**为打通读侧补齐**:迁移 `0023_flow_graphs_lane_turn_id` 给 `flow_graphs` 加**按路查询键** `lane_turn_id`(等值索引 `idx_flow_graphs_lane_turn_id (session_id, lane_turn_id)`)+ 回填;`FlowGraphStore` 增只读方法 `GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1);`history.go` 的 `handleListMessages` 在编码前调 `attachToolCalls`。**只在该出口填充**(其余 DTO 出口逐字不变,有单测钉住)。⚠️ **覆盖率受限(如实,v0.78 时)**:只有**真并行路**的图带权威 `turnId` ⇒ **普通单路回合的回复查不到图、`toolCalls` 缺席**(补齐在 `internal/runtime/loop/**` 写侧,**不在本版**)。→ **✅ v0.79 已补齐**(切片 CODE-TC-2:编排器驱动的每一路都带权威 `turnId` ⇒ 覆盖率扩大到**全部编排回合**;见 §6 v0.79 ④)。**§4.4 / §7.2 / §7.4-23 / §2.2 B13**。
|
||
- **② 插件管理面板接入点(切片 PLG-2,纯增量为主)**:新增 1 条 viewer 读端点 **`GET /api/v1/admin/panels`**(**按调用者角色过滤**后的接入点快照 `{role,groups[],pluginIds[]}`,viewer 只拿只读区块;未接线 ⇒ `501 panels_unavailable`)与 1 条动作分发端点 **`POST /api/v1/admin/plugins/{id}/actions/{action}`**(**只接受该插件声明过的动作名**,未声明 ⇒ `404 action_not_declared`;写动作 owner/admin 收紧 ⇒ 未授权 `403 forbidden`;停用插件 ⇒ `409 plugin_disabled`;**每次结算都落审计** `what="plugin.action"`;**插件只给动作名、不给 URL** ⇒ 不开 SSRF 面);WS 下行帧 **`panels_changed`**(`{reason,pluginId}`,`reason ∈ {declare,install,uninstall,enable,disable}`,**只发 owner/admin**,**不带内容**⇒ 客户端按角色重拉快照)。权限矩阵:`viewerAuth` 12 → **14**、总数 66 → **68**。🔴 **v0.78 时生产路径硬事实**:**无任何生产动作执行器** ⇒ **当时所有已声明动作都回 `501 action_not_implemented`** —— **已由 v0.79 接线**(见 §6 v0.79 ① 与 §4.14)。**§2.1 A13/A14 / §3.4.5 / §4.14(a)(b)**。
|
||
- **③ 搜索 provider 可变源(切片 PLG-4)**:插件 `web-search` 新增可变源 `$DATA_DIR/config/plugins/web-search.json`(**0600**、temp+rename、文件名 = 插件 id),组合根「**先可变源、缺失回落 `.env` 播种**」;面板数据面 = 四个动作 `plugin.listProviders` / `plugin.saveProvider` / `plugin.testProvider` / `plugin.reauthProvider`(表格行 `{provider,state,enabled}`,`state ∈ {unknown,disabled,not_configured,ok}`;`testProvider` 的 `errorCode` 闭集十项,⚠️ **`no_results` 时「空结果 ≠ 不通」**)。🔴 **不新增任何 env 键**(`.env` 已冻结);**`.env` 的 `WEB_SEARCH_*` 自此只在文件缺失时生效**(运维视角的行为变更,如实登记)。**§4.14(c) / §5**。
|
||
- **④ 账号上下线落事件 + 渠道事件实时帧(切片 E7)**:`internal/runtime/events` 增「账号状态迁移」这一路 —— 三态归一(**未知也照常落行**,不整类丢弃)→ 落一行 `tier=t2` / `category=lifecycle`(**复用迁移 `0021`**)→ 经**既有进程内 bus** 的主题 `channel.event` 推成 WS 下行帧 **`channel_event`**(`data.event` 与 REST `events[]` **同一个对象**;⚠️ **实时帧的 `id` 键可能整缺**;**只发 owner/admin**,**viewer 走 REST 只读档**)。采样常驻(约 60s 一拍,**不看面板也在跑**)。⚠️ **当前只覆盖账号状态迁移这一路**(其余渠道事件的实时装饰器**未接线**,见 §7.2);采样通道目前唯一来源是 `qq-obv11` 的探活。**§3.4.5**。
|
||
- **⑤ 微信 iLink 凭据三态(切片 CODE-F3)**:`GET /api/v1/admin/channels/{platform}/login-status` 新增**可选** `authState` / `authDetail`(`"active"` 有效 / `"rejected"` **被拒且需重扫** / `"unknown"` 未知;**闭集**)。🔴 **文案口径写死**:**`errcode=-14` 是「被拒」不是「过期」**、**未知原因不许写成「过期」/「未连接」**(响应体全文由测试钉住)。**键缺席 = 该账号没有这条状态面**,≠ `unknown`。**无新端点、无新路由**(沿用 v0.39 起的端点)。⚠️ `GET /admin/channels` 的 `accounts[]` **每账号**三态**未实现**(见 §7.2)。**§2.4(c)**。
|
||
- **⑥ 语音消息落库面(切片 CODE-VOICE)**:迁移 `0020_chat_message_voice` 给 `chat_messages` 加 `voice TEXT NOT NULL DEFAULT ''`(JSON 单对象,形状 = DTO `voice` 减去 `url`;**刻意不建索引**);`internal/runtime/voice/**` 新包(`Source`/`Status` 闭集、`Ref` 编解码、格式级可播表 + `amr→wav` 转码归一);`channelbridge` 入站语音「归一 → **闸门之后**落副本 → 落库后写 `voice` 列」全链打通(落盘内容寻址、复用文档存储与 GC、**不进文档抽取/注入链路**)。⚠️ **用户可见面(DTO `voice` + `GET /api/v1/media/{name}`)在 v0.78 时未实现,已由 v0.79 落地**(切片 L45,见 §6 v0.79 ③);**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 那一项**已由 v0.80 修复**)见 §4.5 / §7.2。唯一已生效的**客户端可见**变化:IM 语音在「格式不在可转写白名单内」时正文诚实提示改口径(**形状不变**,客户端本就不该按正文文案分支)。**§4.5 / §7.2**。
|
||
- **行为变更**:① 无(新可选键 + 新迁移列/索引);② 无(新端点 / 新帧 / 新可选键;老客户端零影响)—— ⚠️ 但**新增了 `502` 状态码**(仅插件动作执行器失败路径)与一批插件动作错误码;③ **有**(运维视角:`.env` 的 `WEB_SEARCH_*` 仅在可变源文件缺失时生效);④ 无(新帧 + 新事件行;REST 形状未变);⑤ 无(两个可选键;`status`/`botId`/`botToken`/`err` 与 v0.39 起逐字一致);⑥ **有(轻微)**:IM 语音「不可转写格式」的正文诚实提示**改口径**(**形状不变**)。
|
||
- **破坏性**:**无**。
|
||
- ⚠️ **诚实边界**:① `toolCalls[]` **v0.78 时覆盖率只到真并行路**(普通单路回合查不到图 ⇒ 缺席)—— **已由 v0.79 补齐**(见 §6 v0.79 ④),且**未做真机端到端**(真并行路的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等仍是**代码级**推论);② 插件动作**生产无执行器**(全回 501)—— **已由 v0.79 接线**(见 §6 v0.79 ①)、热装插件的声明来源缺、`panels_changed` 对 viewer 不可见、启用态**不跨重启持久化**、审计持久化记录**未含 pluginId/action**(都在 §7.2);③ `web-search` 声明已就绪但**「面板加一家 provider」未做真机端到端**;④ `channel_event` **只覆盖账号状态迁移这一路**(组合根装饰器未接线),且**未做真机端到端**;⑤ iLink `rejected` 分支**只有单测/httptest 证据**(真实 `errcode=-14` 至今 0 次观测);⑥ 语音**生产端到端未跑** —— **用户可见面已由 v0.79 落地**(见 §6 v0.79 ③),但 WS 帧/握手回放仍不带 `voice`;AMR 不落副本**已由 v0.80 修复**(见 §6 v0.80 ③)。
|
||
|
||
### v0.77(上一版)
|
||
|
||
**链路图按路分桶(FV2)+ 记忆整理护栏抬高 / 诚实截断 / 试算异步落地(MT-A)+ 「入库前审查」env 键登记(MT-B)—— 三批都是纯增量**(依据:主人 2026-10-05 报「并行两路时流程图互相顶掉」、以及「工具回看面要先让链路图带上按路的回合标识」;「护栏被打满 ⇒ 候选数由上限而不是规则决定」要治;`design/23 §15.1 D3` 的入库前审查上线)。
|
||
|
||
- **① `graph_update` 按路分桶(切片 FV2)**:帧 `data` 新增**可选** `turnId`(这张图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳同值)与 `round`(会话轮次号);图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ **单路行为逐字不变**),因此真并行时**每一路各发自己的帧、各落一行**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 不再把另一路整行顶掉)。`GET /api/v1/admin/flow/graph` 响应新增 **`lanes[]`**(最近一轮每一路的图 `[{turnId,graph}]`,按路开始时间升序,恒为非 null 数组;`graph` 仍是「最新一路」⇒ **老的读取方零改动**),前端在 ≥2 路时按**并列泳道**呈现 —— **§2.2 B13 / §3.4.5 / §4.11(a)(d) / §7.2 / §7.4-19**。⚠️ 留白:`turns[]` 仍按**路**列条目(一轮两路显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路。
|
||
- **② 记忆整理护栏抬高 + 诚实截断 + 试算异步落地(切片 MT-A)**:每分区候选上限 `MEMORY_TIDY_CANDIDATE_LIMIT` 默认 **`20 → 200`**;两两比对上限从**写死的 `400`** 改为 env 键 **`MEMORY_TIDY_COMPARE_LIMIT`(默认 `2000`)**;配置事实面 **12 → 13 条**;`truncated` 新增第 4 种标签 **`(budget)`**(被**墙钟时间预算**截断:整分区跳过,或两两比对中途收手 —— 成因是**本次调用的时间上限**而不是配置配额);**v0.76 ③ 的加性异步已落地**(`?async=1` + B15 轮询:并发**复用既有 `runId`**、与同步**同一个 3 分钟**墙钟、`done` 的 `result` 与同步 `data` **逐字节同形**、`running` 时 `result`/`error` **双缺席**、重启后旧 `runId` 如实 `404`)—— **§4.10(a) / §4.12 / §4.12.1 / §5**。
|
||
- **③ 「入库前审查」(D3)的 4 个旋钮(切片 MT-B)**:`MEMORY_REVIEW_ENABLED`(`true`)/ `MEMORY_REVIEW_QUEUE_SIZE`(`256`)/ `MEMORY_REVIEW_SCAN_LIMIT`(`200`)/ `MEMORY_REVIEW_TIMEOUT_SEC`(`20`,⚠️ **默认值已由 v0.93 改为 `90`** —— 键名与文件内容未动,只是代码侧默认值,见 §6 v0.93)—— 队列满 ⇒ **该条不审查但记忆不丢**(只 WARN)、超时 ⇒ **零改写** + WARN、`≤0` = 用默认、召回门**复用** `MEMORY_TIDY_RECALL_THRESHOLD`。🔴 **这 4 个键不进 `.env` 模板**(主人 2026-10-06:`.env` 冻结为「只保证最小化功能可跑」的引导面、**不再新增键**)⇒ **有效行为 = 上面这组代码默认值**,调优入口将由**配置文件 / 面板**提供(**未接线**)。⚠️ 该切片的**面板读侧端点(`GET /admin/memories/reviews`)与回滚端点(`POST /admin/memories/{id}/revert`)目前没有 HTTP 入口**,**本版不登记为可用**(见 §7.2)。
|
||
- **行为变更**:无(① 两个新键可选、`lanes[]` 纯增量、单路行为逐字不变;② 只改默认值 / 新增一个截断标签,同步试算的响应形状未变;③ 只多了一组**以代码默认值生效**的旋钮,**不进 `.env`**、不改变任何既有行为)。
|
||
- **破坏性**:无。
|
||
- **⚠️ 诚实边界**:① 真并行**未做真机端到端**(线上产物是旧构建;真并行是在单测层用「真 `Loop.Run` × 2 + 网关照同的 ctx 通道」复现的)—— 见 §7.4-24;② 上面 ②「抬高后真机 `compared == scanned`」**尚未被真机证实**(只有单测与量级推算支持);③ MT-B 的入库前审查**未在生产库上跑过**。
|
||
|
||
### v0.76(上一版 · ⚠️ **逐条状态不同 —— ①已由 v0.78 实现(覆盖率受限);②③已实现**)
|
||
|
||
**三面增量 —— ① 消息读取面 `toolCalls[]`(工具回看 · **✅ 已由 v0.78 落地**)|② 未读面 `unreadCount`(多端共享单游标 · **已实现**)|③ 记忆整理试算的加性异步(**已由 v0.77 落地校正**)**(依据:安卓侧 `Cyrene-For-Android/devdocs/02` 明确「工具回看面比未读面更重要、且要带工具输出/摘要」,以及主人 2026-10-05 裁定的「抬高 + 治入库太宽」导致试算更慢)。
|
||
|
||
- **① 工具回看面(✅ 已在 v0.78 实现 —— 见 §6 v0.78 ① 与 §4.4;**覆盖率受限**)**:`GET /api/v1/sessions/{id}/messages` 的消息对象加可选 `toolCalls`(`omitempty`)—— 每条含 `name` / `status`(`ok`|`error`|`unknown`)/ `durationMs` / `argsSummary` / `resultSummary`(后两者是**图里已有的、已脱敏**内容并按上限截断)。数据源 = **已落盘的轮次链路图**(`flow_graphs`,迁移 `0022`;**v0.78 补迁移 `0023` 的按路查询键**,保留期 168h),**不从正文反解、不从内存态猜**。🔴 **缺席语义**:图取不到 ⇒ 字段**整体缺席**(**绝不回空数组**冒充「没调过工具」)。修的是安卓侧「回看这条回复时看不到它当时调了什么」。**⚠️ 历史说明(v0.76 登记时未实现的原因,实测确认非推断)**:`chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源**(前者 = 编排器生成的随机路标识 `turn-<16 hex>`,后者 = `trace.FlowGraph.ID`,进程内递增的建图序号)⇒ **无法按消息 join 到它那一轮的图**。⚠️ **v0.78 校正(切片 CODE-TC)**:v0.77 已给链路图加**按路的回合标识**,v0.78 又补上**按路查图的读侧入口**(迁移 `0023` 的 `lane_turn_id` + `GraphsByTurnID`)⇒ `toolCalls[]` **已实现**;但🔴 **v0.78 时只有真并行路能回看**(普通单路回合的图当时不带权威 `turnId`)—— **已由 v0.79(切片 CODE-TC-2)补齐**:编排器驱动的每一路都带权威标识 ⇒ 覆盖率扩大到**全部编排回合**(见 §6 v0.79 ④ / **§4.4**、§7.2、§7.4-23)。
|
||
- **② 未读面(纯增量 · ✅ 已实现)**:`message_persisted` 的 `data` 加可选 `unreadCount`(`omitempty`,**只在 >0 且确实算得出来时**出现;**缺席 = 老行为**),`GET /api/v1/sessions` 的列表项同键(**该键自 D4 起就已在**,本版未改它)。口径 = **绝对未读 + 主人单一已读游标(`chat_sessions.read_at`,迁移 `0010`)⇒ 多端共享**:Web 读了,移动端的红点也清 —— 这是与安卓侧**已对齐**的取舍(他们明确赞成),**不是缺陷**,不许改成「每端独立未读」而不经裁定。形状、缺席语义与三种缺席情形见 **§4.13**;帧表已同步(**§3.4.4**)。
|
||
- **③ 记忆整理试算加性异步**:`POST …/memories/tidy/dry-run?async=1` ⇒ `202` + `runId`(新增 B15 轮询端点)。**不带 `async` 仍是同步** ⇒ 老客户端(含现有面板)逐字不变。结果活在**进程内存**(TTL 30 min、并发 1、重启即失效 ⇒ 如实 `404`)。⚠️ **v0.77 落地校正**:本项登记的时点标注「契约先行」,**切片 MT-A 已实现**,实现口径见 **§4.12.1**(并发=**复用**既有 `runId`、与同步**同一个 3 分钟**墙钟、`result` 与同步 `data` **逐字节同形**、新错误码 **`tidy_run_not_found`**)。
|
||
- **行为变更**:无(②只在既有帧里加可选键;③只在显式带参时改变路径;①未落地 ⇒ 无变更)。
|
||
- **破坏性**:无。
|
||
- **⚠️ 迁移号预告**:本版实现若需新迁移,编号从 **`0023`** 起(`0020` 预留给语音切片、`0021`/`0022` 已用)。
|
||
|
||
### v0.75
|
||
|
||
**记忆整理「试算」端点 —— 新增 `POST /api/v1/admin/memories/tidy/dry-run`:跑同一套整理规则、把候选直接回在响应里,且一行都不写**(依据主人 2026-10-05 要求「现在就想看到在 736 条上它会建议合并哪些,而不等到 23:00」;对齐 `design/23 §4`「低峰定期后台任务 + 手动可触发的只读试算」)。
|
||
|
||
- **纯增量**:新增 1 条 `writeAuth`(owner/admin)管理的**只读语义 POST**(§2.2 B14 / §4.12)。既有端点、既有帧、既有 env 键语义**一字未改**。
|
||
- **同版追加(无形状变更)**:`GET /api/v1/admin/config-facts` 的事实表由 9 条扩到 **12 条** —— 并入 `ENABLE_BACKGROUND_THINKING` / `THINK_INTERVAL_SEC` / **`THINK_QUIET_INTERVAL_SEC`(安静窗口内心跳周期,默认 `3600`;`≤0` = 关闭降频)**,支撑 design/12 §N「静默时段降频」(§2.2 B12 / §4.10(a))。`facts[]` 形状与 `quietHours` **一字未改**,只是多了 3 个元素 ⇒ 老客户端零影响。
|
||
- **零副作用是硬约束**:端点走的是与低峰定时轮**共用**的规则路径(同一召回门 + 同一包含判定 + 同一规则否决 + 同一每分区候选上限),但**候选不落库** —— 不写 `memory_tidy_candidates`、不写 `memory_tidy_runs`、**绝不改 `memory_items` 的任何列**。后端有「调用前后三张表逐行快照比对」的单测钉死(另有一条断言「试算候选 == 真跑一轮落库的候选」)。
|
||
- **失败绝不伪装成「没有建议」**:未接线 → `500 tidy_unavailable`、`MEMORY_TIDY_ENABLED=false` → `409 tidy_disabled`、语义召回(embedding)不可用 → `500 tidy_recall_unavailable`、超时 → `503 tidy_timeout`、其余内部失败 → `500 tidy_dry_run_failed`。**不回** `candidates:[]` 来表示失败 —— 空数组唯一含义是「算过了,这一轮没有建议」。
|
||
- **试算专属的严格**:语义召回不可用时,**试算报错而不静默降级**到词项余弦(否则面板会把「语义路径没跑起来」读成「没有候选」)。低峰定时轮与手动轮的降级口径**不变**。
|
||
- **不审计、不调度**:它是只读计算(不写审计链),**不受安静时段约束**(窗口只是心跳调度口径),也**不改变**心跳节奏与周期。
|
||
- **行为变更**:无。
|
||
- **破坏性**:无(老客户端不发这个请求就零影响)。
|
||
- ⚠️ **本版不做**:apply / reject / revert 候选的**写**端点仍是 `design/23 §11` 的未实现项(见 §7),面板当前只做「看建议」。
|
||
|
||
### 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))**已落地**;**语音消息**当时未实现,那一批的形状**取自设计文档而非代码核对** —— **v0.79 已把用户可见面落地**(切片 L45,见 §6 v0.79 ③ / §4.5 / §7.2),**残留缺口**(WS 实时帧/握手回放不带 `voice`;AMR 的那一项**已由 v0.80 修复**)仍按「未确证/未实现」读。
|
||
|
||
- **纯增量**:
|
||
① 新端点 `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` 键存在**。⚠️ **v0.79 校正(实测覆盖)**:真正会带 `voice` 的读取面**只有** `GET /sessions/{id}/messages`、`GET /admin/channels[/{platform}]/messages`、`PATCH /messages/{id}` 响应与 `message_edited`;**`message_persisted` / `channel_persisted` 与握手 `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);**语音消息的用户可见面已由 v0.79 落地**(仍残留 WS 帧/握手回放不带 `voice`;AMR 不落副本**已由 v0.80 修复** —— 见 §7.2 / §7.4-29),**不在本表**,逐条见 §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` |
|
||
| **保留期清扫的「可分辨键」`retentionBoundaryReached`**(v0.81 新增,切片 X1) | ✅ **已上线(`2bf3b53`)** | 作用:把「更早的轮次**被保留期清扫**」与「分页还有更多 / 本来就没有更早的轮次」**分开**(`truncated` 只答「本次是不是全部」)。形状 / 判据 / 三个盲区见 **§4.11.1(a)(b)-3**;`round` 跨重启单调的同期修复见 **§4.11.1(e)**。⚠️ v0.81 登记时它**仍在工作树**(该版已如实标注);**现已提交** ⇒ 按 `git show 2bf3b53` 复核键名与判据 |
|
||
| **她自排的入口工具 `request_think`**(→ 使 `GET /admin/backthink` 的 `trigger="self_wake"` 与 4 条 `backthink.self_wake.*` 审计动词**真的可能出现**) | ✅ **已接线(切片 X2 `2d211c2`)** —— v0.81 登记时的「🔴 未落地」**已作废** | 判定侧 C1-1 `94da6bc` + 自排本体 C1-2 `ed50053`(窗口上限 + 容量 1 待跑槽 + 4 条审计)+ **入口工具 `request_think` 已注册**(`cmd/cyrene/wire_services.go` 的 `wireSelfWakeTool`,`internal/services/backthink/thinktool.go`)⇒ **生产里会真的产生自排轮**;启动日志自证 `self wake tool registered tool=request_think`。形状见 §4.10(d);⚠️ 自排轮仍要过既有 `decide` 级联 + `PushGate`,**不保证每轮都有**。**读它用了多少额度的出口**见 **§4.10(e-2) / §2.2 B19** |
|
||
| **`tool_execution.end` 的 `resultSummary`**(v0.82 新增,切片 Y1 `215eedf`;**「失败也带」在工作树**) | 🟡 **已实现;「失败也带」登记时仍在工作树、待提交** | 形状与缺席语义见 **§3.4.2**。⚠️ 提交前跑着的后端里**没有**该键;提交后请按 `git show` 复核「失败路径给 error 摘要」这条口径(`internal/runtime/loop/tool_frame_summary.go` 的 `toolEndFrameSummary`) |
|
||
| **`browser` 面板与截图闭环的组合根挂点**(v0.82 新增,切片 Y4 `e0a6bca`) | 🔴 **登记时未接线**(代码已落库,两个生产挂点在工作树) | 声明与六个动作见 **§4.14(d)**;`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)` 与 `wire_runtime.go` 的 `TransformContext: a.browserShotTransform(visionResolver),` 是**登记时**才进工作树的(删掉都**零症状**,各有源码级用例盯着)。🔴 **在跑的那份产物里没有它们**(真机已核:启动日志全篇 0 条 `browser panel declared`)⇒ 那一刻 `pluginIds == ["web-search"]`、`browser` 动作全 `404 action_not_declared`、`turnAttachment` 照报 `queued` 但图不进上下文 |
|
||
|
||
> ⚠️ **工作树另有未提交改动(v0.82 登记时的实测状态)**:**与本版两个面相关**的在途文件共 **5 个**(此外工作树还有同波其它切片的在途改动,与本契约无关、不影响本文档):
|
||
> - **① `resultSummary` 的「失败也带」**:`internal/runtime/loop/tool_frame_summary.go` + `..._test.go`(已按「登记形状 + 明标未提交」进上表);
|
||
> - **③ `browser` 的两个生产挂点**:`cmd/cyrene/wire_runtime.go`、`cmd/cyrene/wire_frontend.go`、`cmd/cyrene/wire_browser_context_test.go`(同样已进上表)。
|
||
>
|
||
> 其余在途项(若有)**尚未冻结、本表不登记** ⇒ 客户端**不要**按当前工作树形态编程。⚠️ v0.81 时代本注举的项(X1 的时间轴两项、`request_think`、浏览器 SSRF 收口、`capacity` / 面板 `Backthink.tsx`)**均已提交**(`2bf3b53` / `2d211c2` / `a820d99`),不再是在途项。
|
||
> 归档版 v0.65 §J 曾把上表前四项列为「未实现」——那是**归档时的代码状态**,**已过期**。
|
||
|
||
### 7.2 确认为「未实现」(别当已有)
|
||
|
||
| 项 | 现状 |
|
||
|---|---|
|
||
| **✅ v0.67 登记的「语音消息面」**(`GET /api/v1/media/{name}` + 消息 DTO `voice` + 入站语音音频副本落盘) | **用户可见面已实现(v0.79,切片 L45)**:端点 / 字段 / 副本口径见 §1.2 U19、§4.4、§4.5。落地:`documents.Store.SaveAudio`(音频白名单 / 内容寻址 / 0600)、`history.Store.UpdateMessageVoice` + `VoiceColumns`(`chat_messages.voice` 列读写两条路)、网关 DTO `voice`(含唯一派生 `url`)与 `GET /api/v1/media/{name}`。🔴 **残留缺口(如实)**:① **`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,**未做**;且 `message_persisted` 在落库那一刻发出,天生拿不到落库后才回填的值)⇒ **客户端不要在实时帧/握手回放上等 `voice`**;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ **AMR 也落副本**(落盘前转成 `.wav`,`mime: audio/wav`);`transcode_not_wired` 只剩「引擎未装配 / 构造失败」一档(**生产装配下不再出现**);③ **消息/会话被删除时音频副本引用计数不被释放**(`refs.go` 只认 `document` 列 ⇒ `ref_count` 偏高,后果是**晚回收**,绝不早删);④ **生产端到端未跑**(未重启后端)。⚠️ 同批的**口径变更**(`/admin/system/storage` 的 `documents` 段含语音副本)仍**尚未生效**;**已生效**的是 IM 语音「不可转写格式」正文提示改口径(**形状不变**,见 §4.5)。权威设计 `design/24 §10` |
|
||
| **✅ 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 注 |
|
||
| **✅ 「工具回看面」**(消息 DTO `toolCalls[]`) | **已实现(v0.78 落地,v0.79 起覆盖全部编排回合)**(切片 CODE-TC + CODE-TC-2):形状、缺席语义与**覆盖率**见 **§4.4**;读侧依赖 = 迁移 `0023` 的 `flow_graphs.lane_turn_id`(等值索引)+ `FlowGraphStore.GraphsByTurnID`(一页一次 `IN` 批量查,禁 N+1)。✅ **v0.79**:编排器驱动的**每一路**(含普通单路回合与队首续跑路)的图都带权威 `turnId` ⇒ **普通单路聊天与队首续跑回复现在也能回看工具**。⚠️ **仍缺席**:后台思考(`sess-backthink`)、未接编排器的调用方、v0.79 之前的单路历史行。⚠️ 单路在 **HTTP 出口**的端到端**未由本切片确证**(写侧已验,gateway 读侧待补)。见 §7.4-23 / §7.4-25 |
|
||
| **✅ v0.76 登记的「未读面」**(`message_persisted.data.unreadCount`) | **已实现**(本切片):形状、缺席语义与三条缺席路径见 **§4.13**,帧表见 §3.4.4。⚠️ `GET /api/v1/sessions` 的同名键**自 D4 起就已在**(`historySessionSummary.UnreadCount`),本片**未改**它 —— 所以「加同键」这句对列表面其实是**既有事实**,不是本版新增 |
|
||
| **`graph_update` 按路分桶** | **✅ 已实现(v0.77,切片 FV2)**:帧 `data` 新增每路的 `turnId` 与 `round`,图按 `{会话}#{路}` 分桶 ⇒ **每一路各发自己的帧、各落一行**(不再互相顶掉);管理面 `GET /admin/flow/graph` 新增 `lanes[]`。形状与缺席语义见 §3.4.5 / §4.11(a)(d),登记见 §7.4-19。⚠️ 真并行**未做真机端到端**(见 §7.4-24) |
|
||
| **第二路进行态 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` 留空属**预期**(契约里该键缺席),不是缺陷 |
|
||
| **记忆整理候选的 apply / reject / revert + 候选列表 + 运行历史**(`design/23 §11`) | ⚠️ **已部分实现(v0.83,切片 TIDY-1)** —— v0.75 只加了「试算」(B14,**不改任何记忆**);v0.83 补上了**执行面**:`GET /admin/memories/candidates`(B20,只按 `status` 筛)、`POST .../candidates/{id}/apply`(B21)、`POST .../candidates/apply`(**批量**,B22)、`POST .../candidates/{id}/revert`(B23)、`POST /admin/memories/tidy/run`(**手动真跑**,B24)**都已实现并推库**。🔴 **仍缺三样(别假定它们存在)**:① **`reject`**(把候选标成「不采纳」而不应用)**没有端点**;② **运行历史读侧** `GET /admin/memories/tidy/runs` **没有**(真跑的留痕行**已经落库**在 `memory_tidy_runs`,只是没有读口);③ 候选列表**不能按 `runId` 筛**(只能按 `status`)—— 按 run 只能走**应用**那一档(B22 的 `runId`)。⚠️ **别与 v0.79 的 `POST /admin/memories/{id}/revert` 混淆**:后者是**入库前审查(D3)软合并**的回滚(按**记忆 id**,§2.2 B17),与「整理候选」(按**候选 id**)是**两件事** |
|
||
| **`GET /api/v1/admin/memories` 的整理相关字段**(`kind` / `tags` / `confidence` / `createdAt` / `updatedAt` / `sourceConversation` / `status` / `mergedInto` / `mergeCount`) | **未实现**:该端点当前仍只回 `{id,scope,text}`(`design/23 §11` 登记的读侧缺口)。v0.75 **未**改它的形状 ⇒ 面板暂时无法在这一页按 kind/status 过滤(试算端点 B14 的候选自带这些分区键与 kind)。⚠️ **v0.83 起服务端已按 status 过滤**(四条列表读路径只回 `active`)⇒ 被软合并 / 被归档的记忆**不再出现在这一页**;但**字段面仍未补齐** ⇒ 面板在这一页**看不到** `status`/`mergedInto`/`mergeCount`,也就**无法显示「哪些已被合并」**(那要看 **B20 候选列表**或 **B16 审查留痕**) |
|
||
| **✅ 记忆「入库前审查」的面板面**(`GET /api/v1/admin/memories/reviews`、`POST /api/v1/admin/memories/{id}/revert`) | **已实现(v0.79,切片 L45;v0.77 登记时无 HTTP 入口)**:两条都走 `writeAuth`(owner\|admin),形状见 §2.2 B16/B17。⚠️ **残留**:① 留痕列表**只有 `limit`、没有分页游标**(无 `before`/`offset`);② `memory_ingest_reviews` **无 TTL / GC**;③ 回滚**只回滚「被吸收行」这一侧**(不恢复存活行被抬高的 `confidence`);④ 门与 `design/23 §11` 表格的 owner-only **有差异**(待裁决)。同样是**新审计动词** `memory.review.revert`(`design/23 §16.4` 表里还没有它,需设计侧补记) |
|
||
| **✅ v0.78 插件动作的「执行器」**(`POST /admin/plugins/{id}/actions/{action}` 的动作本体) | **已接线(v0.79,切片 L1/PLG-2 执行面接线)**:插件管理器新增**动作处理器登记面**(`Manager.RegisterActions`;单一全局 `SetExecutor` 已删除),组合根在 `wirePanelHub` 里登记内置 `web-search` 的四个动作 + 候选来源 `plugin.providers` ⇒ **这些动作真执行**(真读写可变源、探针真发一次 HTTP)。**`501 action_not_implemented` 只在「已声明但宿主未登记处理器」时出现**(不再是兜底档)。✅ **v0.80(切片 W3)**:面板保存后**热重载**(重建工具 + 原子替换注册表条目,**下一轮生效、不需重启**;会让后端下次起不来的配置 ⇒ **按字节回滚** + `409 config_unstartable`);面板**已可配主 provider / 降级链**(`primary` / `fallback[]`)。⚠️ **残留**:**探针只用 `httptest` 假上游**(未对真实上游连通);**启用态不跨重启持久化**;**真机面板端到端未跑**。见 §4.14(b)(c) / §7.4-26 / §7.4-27 |
|
||
| **🔴 v0.78 热装插件的面板声明来源**(`plugins/<id>/manifest.json` 读取) | **未实现**(切片 PLG-2 / PLG-1):目录能键热装插件、生命周期已接,但**没人喂声明** ⇒ 热装插件在 `GET /admin/panels` 的 `pluginIds` / `groups` 里**恒为「未声明管理页面」**。有声明的是**内置**插件(组合根装配期 `Declare`):`web-search`(v0.78 起)、**`browser` / `settings`(v0.82 起,切片 Y4)** |
|
||
| **🔴 v0.82 `browser` 面板与 B3 截图闭环的「生产挂点」** | **登记时未接线**(切片 Y4 `e0a6bca`):声明与六个动作的代码已落库(§4.14(d)),但**组合根两行**(`wire_frontend.go` 的 `wireBrowserPanel(hub, a, a.log)`、`wire_runtime.go` 的 `TransformContext`)**在工作树、尚未提交** ⇒ 提交前:真机上 `pluginIds == ["web-search"]`、`POST /admin/plugins/browser/actions/*` **全部 `404 action_not_declared`**、`browser` 工具的 `screenshot.turnAttachment` 照报 `queued` 而图**不会**进上下文。**客户端不要**按未提交形态编程(同 §7.1 那一行) |
|
||
| **🔴 v0.82 `browser` 面板 / `resultSummary` 的「真机端到端」** | **未确证**:两者都只有测试进程内的证据(`httptest` / 真 hub / 真 loop),`:10942` 上的发布产物是旧构建 ⇒ 真机端到端一律未跑。见 **§7.4-30 / §7.4-31** |
|
||
| **`PUT/POST /api/v1/admin/plugins/{id}/enable\|disable`** | **未实现**(切片 PLG-2):`Manager.SetEnabled` 已实现并有测试,但**没有 HTTP 面** ⇒ 客户端**不得假定**能经 REST 启停插件(`panels_changed` 的 `enable`/`disable` 两种 `reason` 因此当前**不会**在运行期出现)。⚠️ 且启用态**不跨重启持久化**(`PluginConfig` 无 `enabled` 字段,`SetEnabled(false)` 只在进程内生效,重启按「已安装」恢复为启用) |
|
||
| **`panels_changed` 帧对 `viewer` 可见** | **未实现(刻意)**:该帧**未**登记进 `frameReachesAll` 白名单 ⇒ 按 fail-closed 默认档**只发 owner/admin**,**viewer 收不到**(须自行刷新/轮询重拉 A13)。放开需先改 `ws_frame_scope.go` + 在 `design/11 §7.1` 登记理由(**不在本版**) |
|
||
| **插件动作审计的持久化粒度** | **未含 pluginId / action**(切片 PLG-2):持久化审计记录沿用既有 `AuditStore.Append(ctx, who, what, result, ts)` 四字段 ⇒ 只落 `what="plugin.action"`;插件 id 与动作名只在**结构化日志行**里。要进持久化记录需改 `AuditStore` 签名(**不在本版**) |
|
||
| **🔴 v0.78 渠道事件实时面的「其余全部事件」** | **未接线**(切片 E7):覆盖桥接层写入的**全部**渠道事件的 `events.PublishingStore` 装饰器**已实现但组合根未接**(`grep` 全仓无 `NewPublishingStore` 的生产调用点)⇒ 目前**只有账号状态迁移这一路**会产生 `channel_event` 帧;其余渠道事件仍**只能靠 REST 轮询**。接线只需组合根两处(`events.NewPublishingStore(...)` + 把 `wire_channels.go` 的 `EventStore` 换成它),**⚠️ 只装饰这一处**(网关侧 `Deps.EventStore` **不要**也换,否则账号状态行会被推两次) |
|
||
| **🔴 v0.78 `GET /admin/channels` 的 `accounts[]` 每账号凭据三态** | **未实现**(切片 CODE-F3):`authState`/`authDetail` **只**落在 `GET /admin/channels/{platform}/login-status`(§2.4(c));**每账号**版本需要给 `AdminChannelAccount` 加两字段 + 一个合并器 + 一次取数(收集器 `liveAccountAuthStates()` 已实现并有单测,**无调用方**)⇒ **客户端不得假定 A5 的 `accounts[]` 会带这两个键** |
|
||
|
||
> ⚠️ **本表(§7.2)的两条口径**:① 标 **✅** 的行(如 v0.67 的两项、`directMsgPerMin`、`graph_update` 按路分桶、`toolCalls[]`、`unreadCount`)**留在表里仅为与上一版对照**,它们**已经是可用契约面**,见各行指向的章节;② 标 **🔴** 的行**不要当已有**。
|
||
|
||
### 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` **不生效**;**v0.96 起发音人不再是服务端 env 决定**,而是按 `sessionId` 的**生效人格** → `$DATA_DIR/config/voice-tts.json` 的 `personas` 映射(见 §6 v0.96)。产物恒 WAV。
|
||
- `/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))。⇒ 实时行的「穿透安静时段」标记要等重开页面/历史拉取才出现,**这不等于**「这一轮没有穿透」。
|
||
- **`toolCalls` 只在 `GET /sessions/{id}/messages` 填充(v0.78 边界;覆盖率 v0.79 已补齐)**:共享 DTO 上的新键,但**只有该 handler** 会填它;其余出口(`PATCH /messages/{id}` 响应、`GET /admin/channels[/{platform}]/messages`、`message_persisted` / `channel_persisted` / `message_edited`、握手 `history` 回放)**逐字不变**(有单测钉住)。⚠️ **v0.78 时覆盖率只到真并行路** —— **已由 v0.79(切片 CODE-TC-2)补齐至全部编排回合**;仍缺席的是后台思考会话与 v0.79 之前的单路历史行(见 §4.4 / §7.2)。
|
||
- **`voice` 只在 4 个 REST 读取面 + `message_edited` 填充(v0.79 边界)**:**`message_persisted` / `channel_persisted` 帧与握手 `history` 回放仍不带 `voice`**(需给 `history.Message` 加字段,未做);且入站语音的 `voice` 列是**落库之后**回填的 ⇒ 即使加了字段,`message_persisted`(在落库那一刻发出)也拿不到值。**客户端不要在实时帧 / 握手回放上等 `voice`**(见 §3.4.4 / §4.4 / §4.5)。
|
||
- **`memory.review.revert` 是新审计动词(v0.79)**:`design/23 §16.4` 的 `memory.*` 表里还没有它(需设计侧补记);回滚**只记 item id、不记正文**。
|
||
- **`web-search` 的「`.env` 只播种」(v0.78 行为变更)**:可变源文件 `$DATA_DIR/config/plugins/web-search.json` 一旦落盘,`.env` 的 `WEB_SEARCH_*` **只在文件缺失时**起作用 ⇒ **改 `.env` 不再影响运行中的部署**(这是 design/09 §5.7 E 要的「`.env` 只播种、面板是配置面」,但运维视角上是行为变更)。🔴 **不是** env 键新增/删除。
|
||
- **`channel_event` 是 best-effort 增量提示、且当前只覆盖一路(v0.78 边界)**:帧可能在 hub 发送缓冲满时**丢掉该连接的那一帧** ⇒ 客户端**必须**保留 REST 轮询兜底(以轮询结果为准重排)。且目前**只有账号状态迁移**会产帧(其余渠道事件的实时装饰器**未接线**,见 §7.2);帧里那行的 `id` **可能整缺**(刚落库、id 尚未回读)。
|
||
- **`panels_changed` 只发 owner/admin(v0.78 边界)**:该帧**不在** `frameReachesAll` 白名单 ⇒ **viewer 收不到**「插件面板变了」的通知,须自行刷新/轮询重拉 `GET /admin/panels`。且当前运行期只可能在**装配期**推一次(内置声明 `declare`),`enable`/`disable`/`install`/`uninstall` 四种 `reason` 需要**没有 HTTP 面的** `SetEnabled` / 插件安装端点才会出现(见 §7.2)。
|
||
|
||
### 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`(**已接线**:工具面 `transcode` 工具(`cmd/cyrene/wire_transcode.go`)+ 入站语音副本归一;**v0.80 切片 W4 补齐了 `channelbridge` 侧的 `VoiceTranscoder` 注入** ⇒ `amr → wav` 这条路在生产装配下**真的会走**)⇒ 客户端**不要假定 `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.77 更新)图源是**进程内内存**,但**每轮收尾会落盘**(v0.73)**:`loop.FlowPublisher` 的每会话最近一图仍是**内存**(design/19 §1 的实时口径),但 `EndRound` 会把该轮写进 `flow_graphs`(迁移 `0022`)⇒ 后端重启后、该会话还没跑新轮次时,省略 `turnId` 的查询**回退落盘**即可拿到最近一轮(`source:"persisted"`),不再一律 `graph:null`。⚠️ **仍会 `graph:null` 的情形**:该会话本就在**发布会话白名单之外**(隔离渠道会话,从不建图也从不落盘)、或该会话**从未跑过带 span 的轮次**、或该轮已被**保留期 GC 回收**(默认 168h)。**两路互顶已在 v0.77 修复(切片 FV2,登记裁决改为「按路分桶 + 会话级并列泳道」)**:图按 `{会话}#{路}` **归属桶**建(单路的桶逐字等于会话 id ⇒ 单路逐字不变),每一路**各发自己的帧**(帧 `data` 带该路 `turnId` 与 `round`)、**各占一行落盘**(`flow_graphs` 的唯一键 `(session_id, turn_id)` 因此不再把另一路整行顶掉),管理面 `GET /admin/flow/graph` 用 **`lanes[]`** 一次给出**最近一轮每一路**的图、前端 ≥2 路时按**并列泳道**呈现(见 §4.11(a)(d))。⚠️ 仍如实的留白:`turns[]` 仍按**路**列条目(一轮两路会显示两条),按轮分组需新迁移 `0023`(**未做**);Agents 页 / 后台思考页仍只显示最新一路;真并行**未做真机端到端**(见 §7.4-24)。
|
||
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` 会被客户端整帧丢弃)。
|
||
22. **(v0.75 登记)试算端点(B14)是「只读计算」,它**不改**任何状态**:它不写 `memory_tidy_candidates` / `memory_tidy_runs`、不改 `memory_items`、不写审计链、**也不推进心跳**(不写 `lastRun`、不影响 `nextDelay`)。因此 ① 试算**不会**让「候选队列」(尚未实现,见 §7.2)多出任何东西;② 试算结果**不是缓存**:每次调用都重新算一遍,两次调用之间数据变了结果就可能不同(这是「同一套规则」的正常语义,不是不确定性),客户端**不要**把它当成会稳定的列表去 diff 或自动轮询;③ **`recallMode:"lexical"` 是降级信号**:它表示这轮没有走到语义向量(进程没接 embedder),此时召回更保守(命中更少)—— 若进程**接了** embedder 而它不可用,试算**直接报错**(`tidy_recall_unavailable`),**不会**在响应里假装成 lexical。
|
||
|
||
23. **(v0.76 登记 · v0.77 更新 · v0.79 更新)消息 DTO 的 `turnId` 与链路图的轮次键:图**已带上每路的回合标识**,但两者仍是两套标识**:
|
||
- **消息侧**:`chat_messages.turn_id` = 编排器发的那一路的**随机路标识**(`turn-` + 16 位十六进制;同路的 user 行与 assistant 行同值)。
|
||
- **图侧两个键**:① **`graph.turnId`(v0.77 新增)=该图所属**路**的权威回合标识,与 `turn_begin` / `steer_ack` / 流帧帧壳的 `turnId` **同源同值** ⇒ 客户端**可以**用它把图与消息行**按路配对**;**v0.79 起编排器驱动的每一路(含普通单路回合)都带它**;② `graph.id` / `flow_graphs.turn_id` 仍是**图侧轮次键**(形状 `{会话}-{序号}[#{权威回合标识}]`,**不透明**;序号是发布器**进程内**递增的建图序号、重启即从头开始),也就是 `GET /admin/flow/graph?turnId=` 的参数值。
|
||
- **客户端三条硬约束**:① 要路标识就**读 `graph.turnId`**,**不得**解析 `graph.id` 去取它;② **仍不得**拿消息的 `turnId` 当 `?turnId=` 用(两者不同源);③ 也**不得**把图上的序号当成会话里的回合序号(重启后序号会重复)。
|
||
- 🔴 **`graph.id` 形状变化(v0.79,唯一一处)**:`#{权威回合标识}` 后缀现在**单路也追加**(`web-7` → `web-7#turn-…`);**老行**(v0.79 前落盘的单路行)仍是 `{会话}-{序号}`。⇒ **一律使用响应里的 `graph.id` / `turns[].turnId`**,**不要自己拼**(自拼的 `web-7` 查不到新行);该值可能含 `#`,作 query 值须 URL 编码。
|
||
- **`toolCalls[]` 覆盖率(v0.79 已补齐)**:v0.77 起每路各落一行、图带 `graph.turnId`;v0.78 补上**按路查图的读侧入口**(迁移 `0023` 的 `flow_graphs.lane_turn_id` + `FlowGraphStore.GraphsByTurnID`);v0.79(切片 CODE-TC-2)让**每一路**(含普通单路回合与队首续跑路)都带权威标识 ⇒ **普通单路聊天与队首续跑的回复现在也能回看工具**。⚠️ **仍缺席**:后台思考(`sess-backthink`)、未接编排器的调用方、v0.79 之前的单路历史行。⚠️ `graph.id` **仍不等于**消息的 `turn_id`(两者不同源,不能用 `graph.id` 当 `chat_messages.turn_id`)。见 §4.4、§7.2、§7.4-25。
|
||
|
||
24. **(v0.77 登记)「按路分桶」尚未做真机端到端**:真并行(上行 `chat.parallel`)的修复是在**单测层**验证的(真 `Loop.Run` × 2 + 网关照同的 ctx 通道;网关侧走真 HTTP 处理器 + 真发布器),**没有**对运行中的后端发过一次真实 `chat.parallel` —— 因为线上产物是旧构建、且换二进制 / 重启有专门流程。因此**未确证**:真 WS 客户端在真并行下看到的两路帧**时序**(形状与分桶已由单测钉死,时序上等价但无实测)。另有一条**已知的窄竞态**(未观测、也未加锁消除):若一路恰好在另一路起跑的瞬间收尾,第二条路可能被算作**新的一轮**(此时两路仍各有各的图与落盘行,只是面板不把它们并排展示)。
|
||
|
||
25. **(v0.78 登记 · v0.79 更新)`toolCalls[]` 的覆盖率与端到端未确证**:✅ **覆盖率已补齐(v0.79,切片 CODE-TC-2)** —— 编排器驱动的**每一路**(含普通单路回合与队首续跑路)的图都带权威 `turnId` ⇒ 字段不再只覆盖真并行路。⚠️ **仍未确证 / 仍缺席的点**:① **单路在 HTTP 出口的端到端未由本切片确证** —— 写侧(`loop` 的 3 个新用例,含真 `Run` → 真库 → 按消息那个 id 查回)已验,但网关读侧(`internal/services/gateway/history_toolcalls_gap_test.go` 那条「钉住现状缺口」的用例走的是**老入口** `StartRound`,其前提句已失效)由主会话补验;**没有**对运行中的后端发过一次真消息来实测 `GET /sessions/web/messages` 拿到单路 `toolCalls`。② **「存库里的 `lane_turn_id` 与 `chat_messages.turn_id` 逐字相等」仍是代码级结论**(两处都取 ctx 上的权威标识)。③ **老行**(v0.79 之前的单路落盘)该列仍为空,**不可能**补齐(那时的图里没有该字段)。④ **后台思考**(`sess-backthink`)仍缺席。⑤ 生产库 `data/cyrene.db` 现存行的回填命中率**未统计**(未碰生产库)。
|
||
|
||
26. **(v0.78 登记 · v0.79 更新)插件管理面板接入点 / 动作执行面的真机未确证**:`GET /api/v1/admin/panels` 与 `POST /admin/plugins/{id}/actions/{action}` 的**真实运行期响应未在 `:10942` 上验过**(换产物 / 重启有专门流程,本切片禁止)。所有证据来自 `httptest` + 进程内 bus。⇒ **未确证**:真机上 `data.pluginIds == ["web-search"]`、`groups[0].items[0].sections` 的两段声明、`route` 的前缀拼接姿势(相对片段,前端拼 `<prefix>/plugin/<pluginId>/<panelId>`)。**v0.79 新增未确证项**:① 面板动作(`plugin.listProviders` 等)在**真机上**真的回 200 + `result`(单测层已用 `httptest` 真发 HTTP 验过探针);② **真机探针未打过真实上游**(无 key);③ ~~面板保存 provider 后运行中的 web-search 工具不换接线(需重启)~~ ⇒ **v0.80(切片 W3)已改为保存后热重载**(`hotReload:{applied,registered}`),**真机端到端仍未跑**(真机上「保存后下一轮真的走新配置」未验;未换产物前旧二进制里仍是启动期接线)。另:`panels_changed` 的分组文案「插件」是**宿主文案**(design 未规定文案来源),改口径只改一个函数。⚠️ 且**未换产物前**,旧二进制里**没有**这些路由,属预期。**为什么验不了(2026-10-06 复核)**:本波禁换产物 / 禁重启后端,且**取不到 owner 凭据** ⇒ 真机面板 e2e 无法做;代码侧已复核确证「非对象响应**不**追加 `hotReload`」(`cmd/cyrene/websearch_reload.go` 的 `withReloadOutcome`:非 `map[string]any` 原样返回)。
|
||
|
||
27. **(v0.78 登记 · v0.79 更新)`web-search` 可变源的运行期口径未确证**:组合根「先可变源、缺失回落 `.env` 播种」已有单测钉住(含「凭据不复活」「损坏文件不改写」「停用主 provider ⇒ 启动 fail-fast」等 7 条),并有一条**真机 SearXNG e2e**(`provider=searxng` 真返回结果)证明既有链路未被打断。**v0.79**:动作执行器已接线 ⇒ `plugin.saveProvider` 的**落盘链路**在 `cmd/cyrene` 的单测里真跑过(临时目录 + 真 `runtimeconfig.SaveWebSearch`)。⚠️ **仍未确证**:「在**真机面板**上新增一家 provider → **下一次搜索真的走它**」这条**端到端**(✅ v0.80 起**保存后工具热重载**:换注册表条目、**下一轮生效**(见 §4.14(c)),但**真机端到端仍未跑**,见 §7.2 / §6 v0.80 ②)。**为什么验不了(2026-10-06 复核)**:真机面板 e2e 需要 owner 凭据 + 真在面板上改一次配置(本波禁改数据、禁换产物 / 禁重启后端),且**无任何第三方 key** ⇒ 只能停在 `httptest` + 单测级证据。
|
||
|
||
28. **(v0.78 登记)渠道事件实时面未做真机端到端**:`channel_event` 的「采样 → 落行 → 推帧 → 只投 owner/admin」由**真 SQLite + 真 bus + 真 WS 连接**的单测/端到端用例覆盖,但**运行中的 `cyrene.exe` 未重启**(红线:主会话换产物)⇒ **没有真机日志证据**。且当前 QQ 已停连 ⇒ 若真机启动,`qq-obv11` 账号为 `known=false`,首拍会落一行 `账号状态未知(尚未探到)`。另:**心跳通道(`meta_event.heartbeat`/`lifecycle`)驱动的迁移仍未接**(需适配器把状态投影进状态面,属 `plugins/**`),当前**唯一**迁移来源是探活(`qq_obv11/probe.go`)。
|
||
|
||
29. **(v0.78 登记 · v0.79 更新)语音的端到端未确证**:`internal/runtime/voice/**` 与 `channelbridge` 入站副本链有**真 `transcode.Engine`**(真 AMR 夹具 ⇒ RIFF/WAVE + 时长可确证)的走通用例;**v0.79** 补齐了网关侧(`media_test.go` 用真 `httptest` 真发 `GET`/`Range`/`HEAD`/条件请求,断言响应头与字节逐字相符 ⇒ **端点可用是「真跑过」的**),并新增 `documents.SaveAudio`(含 0600 断言)+ `history` 的 `voice` 列读写。⚠️ **仍未确证**:① **生产端到端未跑**(禁止重启后端)⇒ 「真机 IM 语音 → 盘上有 0600 副本 → 客户端取到字节」只有**替身 + 临时库/临时目录**级证据;② ✅ **已修(v0.80,切片 W4 / 裁决 #11)**:组合根**已注入 `VoiceTranscoder`** ⇒ AMR 转 `.wav` 后落副本 ⇒ 「Web 上能点开听 **AMR** 语音」的**接线**已成立(① 的**生产端到端仍未跑**,未重启后端);③ **`message_persisted` / `channel_persisted` / 握手 `history` 回放不带 `voice`**(见 §4.4 / §7.3);④ `amr` / `flac` / `opus` 在部分浏览器内核上**可播放性未逐一实测**(判据是设计登记的 MDN 规范级结论);⑤ **删除消息/会话时音频副本引用计数不释放**(`ref_count` 偏高 ⇒ 晚回收,绝不早删)。`weixin-ilink` 语音**无副本、不可回放**是**刻意**的(平台自带转写就不白下载音频)。
|
||
|
||
30. **(v0.82 登记)`tool_execution.end` 的 `resultSummary` 未做真机端到端**:证据止于**测试进程内**的「真 loop + 真工具 + 真 tracer/flow 图 → 真 bus → 真 relay → 真 hub → **真 WS 连接**」帧(`internal/services/gateway/ws_tool_result_summary_test.go` 用真 `httptest` + `websocket.Dialer` 读回帧),以及 loop 侧「帧上摘要是**从真图快照反读**的同一份文本」的同源钉(`TestRun_ToolEndFrameSummaryMatchesGraphResultSummary`)。⚠️ **仍未确证**:① 对 `:10942` 上的发布产物发一条**真会调工具**的消息、看客户端收到的 `.end` 帧是否带该键(换产物前不可能验);② **失败路径**上「实时帧的错误摘要」与「回看面的图上 `error` 摘要」是否**逐字一致**(同源链是代码级结论:两侧都过 `trace.RedactText`,但没有真机样例);③ 真机上「一次调用拿到超长结果」时帧上那份摘要的长度(代码级是 `RedactText` 的 ≤80 rune;测试造的是 >500 的假值,用来钉网关那道 ≤500 的兜底上限)。**为什么验不了**:本波禁换产物 / 禁重启后端(红线:后端常驻)。
|
||
|
||
31. **(v0.82 登记)`browser` 面板 / B3 截图闭环未做真机端到端**:证据止于 `cmd/cyrene` 的真 hub + 真能力注册表用例(`wire_browser_panel_test.go`:声明进目录、六个动作真能分发、读写面分类、保存真热重载 + 回滚;`wire_browser_context_test.go`:真 loop + 探针适配器 → 模型请求里确实出现 image 部件)。⚠️ **仍未确证**:① 真机面板上点开 `/admin/plugin/browser/settings` 能看到 4 个区块与真实行(需 owner 凭据 + 换产物);② 真机上保存一次浏览器配置后**下一轮**真的用新配置(热重载的真机确认);③ 真机**有头**窗口里「拍一张 → 图真的进下一轮上下文」的端到端(既有 `wire_browser_e2e_test.go` **默认 `Skip`**,它需要真 Chrome + 可执行文件路径 + 真模型);④ **登记时两个挂点尚未提交** ⇒ 上述三项在提交前**不可能**成立。**为什么验不了**:同上(禁换产物 / 禁重启后端),且本机浏览器来源与凭据未在测试环境确证。
|
||
|
||
---
|
||
|
||
*本文件由「以代码为真源」维护(当前 `v0.82`;`v0.81` / `v0.80` / `v0.79` / `v0.78` / `v0.77` / `v0.76` / `v0.75` / `v0.74` / `v0.73` / `v0.72` / `v0.71` / `v0.70` / `v0.69` / `v0.68` 见本文件 §6,`v0.67` 及更早见本文件 §6 与 `Docs/archive/`)。范围:`v0.82`(三面 —— Y1 `resultSummary` / Y3 自排成本可见面(`?trigger=` + `/admin/backthink/self-wake`)/ Y4 `browser` 面板与六个动作)同步了 `internal/runtime/model/stream.go`、`internal/runtime/loop/**`(`tools.go` / `tool_frame_summary.go` / `tool_frame_summary_test.go`)、`internal/services/gateway/**`(`ws_bridge.go` / `ws_tool_result_summary_test.go` / `admin_backthink_filter.go` / `admin_backthink_filter_test.go` / `admin_backthink_selfwake.go` / `admin_query.go` / `routes.go`)、`internal/services/backthink/**`(`backthink.go` / `store.go` / `trigger.go` / `trigger_domain.go` / `selfwake_status.go` 及其测试)、`internal/config/runtimeconfig/backthink.go`、`plugins/tools/browser/**`(`panel.go` / `paneldata.go` / `attach.go` / `tool.go` 及测试)、`internal/capability/browser/**`(`state.go` / `manager.go`)、`internal/runtime/multimodal/vision/**`(`shot.go` / `resolver.go`)、`cmd/cyrene/**`(`wire_browser_panel.go` / `wire_browser_panel_data.go` / `wire_browser_context.go` 及测试)`frontend/web/src/admin/modules/Backthink.tsx`、`frontend/web/src/admin/plugin/nav.ts`、`frontend/web/src/lib/api/adminSystem.ts` 与本文件(§2.0 / §2.1 A13 / §2.2 B8·B19 / §3.4.2 / §4.10(d)(e) / §4.14(a)(d) / §6 / §7.1 / §7.2 / §7.4);`v0.81`(自排轮可见面 + 时间轴 `retentionBoundaryReached` + `round` 跨重启单调)同步了 `internal/services/backthink/**`(`trigger.go` / `selfwake*.go` / `store.go`)、`internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go` / `flowgraph.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` 及测试 / `routes.go`)与本文件(§0.1 / §2.2 B18 / §4.10(d) / §4.11.1 / §6 / §7.1);`v0.80`(三批 —— W1 链路图时间轴出口 / W3 搜索 provider 热重载 + 主 provider·降级链 / W4 入站语音 AMR 转码落副本)同步了 `internal/runtime/flowgraph/**`(`timeline.go` / `timeline_test.go`)、`internal/services/gateway/**`(`admin_flow_timeline.go` / `admin_flow_timeline_test.go` / `history_toolcalls_gap_test.go` / `routes.go`)、`internal/capability/registry/**`(`registry.go` / `replacetool_test.go`)、`plugins/tools/io/search/**`(`panel.go` / `panel_test.go`)、`cmd/cyrene/**`(`websearch_reload.go` / `websearch_reload_test.go` / `websearch_selection.go` / `websearch_selection_test.go` / `websearch_testenv_test.go` / `websearch_action_data.go` / `wire_io_tools.go` / `wire_plugin_actions.go` / `wire_channels.go` / `wire_channels_test.go`)与本文件(§2.0 / §2.2 B18 / §4.1 / §4.5 / §4.11.1 / §4.14 / §6 / §7.1 / §7.2 / §7.3 / §7.4);`v0.79`(四批 —— L1/PLG-2 插件动作执行面接线 / L45 入库前审查管理面 / L45 语音用户可见面 / CODE-TC-2 链路图回合标识覆盖全部编排回合)同步了 `internal/capability/plugin/**`(`actions.go` / `panelactions.go` / `panels.go`)、`internal/services/gateway/**`(`admin_panels.go` / `admin_memory_review.go` / `history.go` / `history_write.go` / `history_voice.go` / `media.go` / `routes.go`)、`internal/runtime/voice/media.go`、`internal/runtime/documents/store_audio.go`、`internal/runtime/history/voice_column.go`、`internal/runtime/loop/**`(`flow.go` / `run.go`)、`internal/runtime/trace/graph.go`、`internal/runtime/flowgraph/flowgraph.go`、`plugins/tools/io/search/**`(`paneldata.go` / `probe.go`)、`cmd/cyrene/**`(`wire_plugin_actions.go` / `websearch_action_data.go` / `websearch_probe.go` / `wire_frontend.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.2 B16-B17 / §3.4.4 / §4.1 / §4.4 / §4.5 / §4.11 / §4.14 / §6 / §7.2 / §7.3 / §7.4);`v0.78`(六面 —— CODE-TC `toolCalls[]` / PLG-2 插件面板接入点 / PLG-4 搜索 provider 可变源 / E7 账号状态事件与 `channel_event` / CODE-F3 iLink 凭据三态 / CODE-VOICE 语音落库面)同步了 `internal/db/migrations/0023_flow_graphs_lane_turn_id.*`、`internal/db/migrations/0020_chat_message_voice.*`、`internal/runtime/flowgraph/**`、`internal/services/gateway/**`(`history.go` / `history_toolcalls.go` / `contracts.go` / `admin_panels.go` / `admin_channels_weixin.go` / `admin_channel_status.go` / `ws_channel_events.go` / `ws_bridge.go` / `ws_hub.go` / `routes.go`)、`internal/capability/plugin/**`(`panels.go` / `manager.go`)、`internal/capability/paneldecl/**`、`internal/config/runtimeconfig/websearch.go`、`internal/config/websearch.go`、`plugins/tools/io/search/**`(`panel.go` / `paneldata.go` / `probe.go`)、`plugins/channels/weixin_ilink/**`(`session_status.go` / `poll.go` / `send.go`)、`internal/runtime/events/**`、`internal/runtime/voice/**`、`internal/services/channelbridge/**`(`inbound_voice*.go`)、`pkg/plugins/sdk/panel.go`、`cmd/cyrene/**`(`wire_frontend.go` / `wire_io_tools.go`)与本文件(§0.3 / §1.2 U19 / §2.0 / §2.1 A13-A14 / §2.4(c) / §3.2 / §3.4.4 / §3.4.5 / §4.1 / §4.4 / §4.5 / §4.14 / §5 / §6 / §7.2 / §7.3 / §7.4);`v0.77`(链路图按路分桶 FV2 + 记忆整理护栏/异步 MT-A + 入库前审查 env 键 MT-B)同步了 `internal/runtime/trace/graph.go`、`internal/runtime/loop/**`(`flow.go` / `flow_branch.go` / `run.go` / `steer.go`)、`internal/runtime/orchestration/**`、`internal/runtime/flowgraph/**`、`internal/runtime/memory/**`(`tidy*.go` / `preinsert*.go`)、`internal/config/**`(`config.go` / `memory_review.go`)、`internal/services/gateway/**`(`admin_flow.go` / `contracts.go` / `admin_config_facts.go` / `admin_memory_tidy*.go` / `routes.go`)、`frontend/web/src/store/**`、`frontend/web/src/admin/modules/flow/**` 与本文件(§2.2 B12/B13/B15 / §3.4.5 / §4.1 / §4.10(a) / §4.11 / §4.12 / §4.12.1 / §5 / §6 / §7.2 / §7.4);`v0.76` 的**未读面(②)**同步了 `internal/services/gateway/**`(`broadcast.go` / `session_read.go`)、`internal/runtime/history/session_read.go` 与本文件(§3.4.4 / §4.13 / §6 / §7.2 / §7.4),并**按代码 + 库中真实数据实测确证**了 `chat_messages.turn_id` 与 `flow_graphs.turn_id` **不同源** —— 该阻塞**已由 v0.78(CODE-TC 的迁移 `0023` + `GraphsByTurnID`)解除**,`toolCalls[]` 已实现(覆盖率**已由 v0.79 的 CODE-TC-2 补齐至全部编排回合**);v0.76 的 ③ 试算加性异步**已由 v0.77(MT-A)落地校正**;`v0.75` 同步了 `internal/services/gateway/**`(`admin_memory_tidy.go` / `routes.go` / `deps.go` / `server.go`)、`internal/runtime/memory/**`(`tidy_dryrun.go` / `tidy_recall.go` / `tidy.go`)、`cmd/cyrene/wire_frontend.go`、`frontend/web/src/**` 与本文件;`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/**` 与本文件。*
|