- 新端点 GET /admin/llm/catalog 全契约(200/gzip/503 catalog_unavailable/快照副作用/不进热路径/审计 llm.catalog) - models.json 组级三键:reasoning.kind(形状)+ personaModels/personaProviders(角色级模型/协议路由), 含「缺省=保留、给出=整块/整表替换」语义、Group.Validate 的 400 口径、回落与 WARN 规则 - GET /admin/llm 新增恒下发的 reasoning 与两个取值域(reasoningKinds/personaProviderOptions) - 语义修正(此前文档写反):multimodal = 主模型能否收图;LLM_VISION_MODEL/LLM_OCR_MODEL 只配视觉/OCR 工具 - 新工具 image-crop(x/y/width/height、50MiB/16MP/8MiB 上限、越界报错不 clamp、策略默认 owner-only) - 推理开关按形状下发:替换 v0.41「Qwen 只 WARN 不发」;effort 非法值改为丢弃+WARN(不降档)、effort:none 显式关思考 - .env.example 新增 9 键(LLM_REASONING_KIND / LLM_OCR_MODEL / LLM_LOCAL_* / CAPACITY_L3_*),默认均为保守值 - 权限矩阵计数按 routes.go 校正 56→59(owner/admin 只读档 9→10,含 /admin/llm/catalog)
202 KiB
Cyrene v2 客户端 API 文档(用户侧)
完整版见
Docs/api-reference.md(后端全部 API(REST + WebSocket)超详细调用文档,字段/鉴权/状态码以代码为准)。本文档为摘要,契约如有出入以api-reference.md为准。
面向: Web 前端 / 移动端原生客户端 / IM 桥接等跨平台客户端开发者。 维护铁律: 每实现/变更一个 API(REST 端点、WS 事件、admin API)立即更新本文件;契约变更必须版本化,不破坏既有客户端。 状态: gateway 已在 M8 落地核心端点;M9 前端按此开发。
✅= 已实现,🕐= 规划。
0. 基础约定
- Base URL:
http://<host>:8080(网关唯一对外端口;生产一般经反代/HTTPS)。 - 版本: REST 全部挂在
/api/v1/{resource};未来破坏性变更升 v2。 - 认证:
Authorization: Bearer <JWT>(RBAC:owner/admin/viewer,权限矩阵见 §2.0);X-API-Key已接线(v0.51):所有已认证 API(管理/api/v1/admin/*与用户侧/api/v1/*)同时接受X-API-Key;命中签发表即通过,且一律owner级(不再按角色分档,裁决 15.4.3)。请求带该头时只走 key 分支——未知/已吊销/空值回401且不回落 Bearer(fail-closed)。WS 握手仍只认 Bearer。 - 响应统一:
{"data": ...}/{"error": {"code": <string>, "message": <string>}};状态码 200/201/204/400/401/403/404/429/500。 - 错误码:业务错误带
code字符串(如unauthorized/not_found),便于客户端分支。
1. REST 端点(公开)
| # | 端点 | 方法 | 认证 | 状态 | 说明 |
|---|---|---|---|---|---|
| 1 | /api/v1/auth/login |
POST | 否 | ✅ | {username,password} → {data:{token,expiresAt,role,refreshToken?}}(access 24h;新增 refreshToken——不透明随机串,仅哈希落库,AUTH_REFRESH_TTL 默认 720h,用于静默续期;role=owner) |
| 1a | /api/v1/auth/refresh |
POST | refreshToken | ✅ | 静默续期(B2):body {refreshToken} → {data:{token,expiresAt,refreshToken?}}(新 access + 新 refresh,默认单次使用旋转——旧 refresh 立即作废,并发重放 fail-closed 401 invalid_grant)。AUTH_REFRESH_ROTATE_DISABLED=true 关闭轮换时旧 token 保持有效、不返回新 refresh。过期/已撤销/乱串统一 401 {error:{code:"invalid_grant"}};成功才审计 |
| 1b | /api/v1/auth/logout |
POST | refreshToken 或 JWT | ✅ | 登出(B2):body {refreshToken} 撤单个 grant(幂等);带 Authorization: Bearer <jwt> 且无 body → 撤该账号全部 grant(全设备登出,返回 {revokedCount})。refresh 相关操作成功才审计 |
| 2 | /api/v1/health |
GET | 否 | ✅ | 健康检查 {data:{status:"ok"}} |
| 3 | /api/v1/chat |
POST | JWT | ✅ | {text,image?,deviceId?,mode?,stream?} → {data:{reply}};stream:true 时经 WS 触发流式。image 为 base64 data: URI 或 http(s) URL,入视觉路由(design/06 §0;v0.53 语义修正)——主模型能不能收图看生效组的 multimodal(models.json 组键,面板可保存、热更新);LLM_VISION_MODEL/LLM_OCR_MODEL 只配视觉/OCR 工具用的模型,与主模型能否收图无关(裁决 14.4.1);主模型不收图时由视觉·OCR 工具的文本描述替代(图像不占上下文,只有工具结果占);deviceId 为客户端生成的具体端标识(design/16 §4.5,如 web:chrome:a1b2c3,可选),落库到消息、便于主人知道从哪个端发来;mode 为行为模式(design/01 §3 / design/16 §5.1,如 chat/code/home/docs/…,可选),随该轮次落库——IM 恒 chat、缺省回落 chat,供前端按模式渲染气泡(仅 chat 才解析 <p> 拆气泡)。会话编排(design/15):非流式且该会话「正忙」时不阻塞,返回 202 {data:{queued:true,queueDepth}}(稍后按上下文续接);待处理队列满时返回 409 {error:{code:"chat_busy"}} |
| 4 | /api/v1/sessions |
GET | JWT | ✅ | {data:[session...]}。会话持久化(design/16):注入 HistoryStore 后返回持久化会话元数据 `{data:[{id,type("main" |
| 5 | /api/v1/sessions |
POST | JWT(owner/admin) | ✅ | 新建次级会话 → 201 {data:{sessionId,personaId?}}(sessionId="web:<uuid>")。body 可选(可完全省略或空 body):{personaId?}(v0.42)在创建时就把新会话绑到该角色(design/22 §2:渠道级只作"新会话的初始角色提供者";会话角色只在创建时写入、之后不可变,见 §1 行 8 / design/22 §8-1);personaId 为未知角色回 400 bad_request;省略/空 = 不绑(persona_id='',继承全局默认);personaId 非空时响应回显它(省略时响应形状与 v0.41 完全一致);未注入 HistoryStore 回 501 not_implemented;注入了不支持角色绑定的 HistoryStore 且显式传 personaId 时也回 501(不假装写入) |
| 6 | /api/v1/sessions/{id}/messages |
GET | JWT(owner/admin) | ✅ | 热分页拉历史:?limit=&before=&platform=&after=(A5)默认 limit=50(上限 200)。before=上一页最旧 createdAt(毫秒),按时间倒序;after=<createdAt 毫秒>,按时间升序返回 created_at > after 的消息(正向增量,客户端把上次看到的 createdAt 当游标补断线期间漏的,解决重连只回放 50 条的缺口);after 与 before 互斥,同传回 400;负数/非数字回 400。platform 为可选来源平台过滤(design/16 §4.4)——Web 端不传(=全量共享主会话),IM 适配器传本平台(如 telegram)只拉本渠道消息;deviceId 为客户端具体端标识;senderName 为第三方发送者昵称;personaId(v0.42,可选)=该消息说话时的角色 id(design/22 §7-5:逐条记录"谁说的"——会话角色在创建时固定,§8-1;assistant 消息 = 生成它的角色,user 消息 = 当回合角色;空 = 全局默认角色,即 v0.42 之前的全部历史消息),客户端据此按角色渲染名字/头像;q(D2)=会话内内容搜索(大小写不敏感 ASCII LIKE,%/_/\ 已转义为字面量),仍按时间倒序、before/limit 同分页语义;q 与 after 互斥(同传 400),空 q 视为未传(走普通分页),搜索严格限定在 {id} 会话内(绝不跨会话,群聊/陌生人隔离会话不会外泄);非法 limit/before/after 回 400;未注入 HistoryStore 回 501 |
| 6a | /api/v1/messages/{id} |
PATCH | JWT(owner/admin) | ✅ | 编辑单条消息(D3):body {content}(非空、≤4000 rune,空/纯空白/超长回 400)→ {data:<消息DTO>}(data 直接就是更新后的完整消息 DTO,含 id/sessionId/role/content/.../createdAt,无 message 包一层;只改 content,role/时间戳/平台保持不变);消息不存在回 404 not_found;未注入 HistoryStore 回 501。成功后广播下行 message_edited(§3.8,账号其它端免刷新同步)。重发不单独设端点——客户端用「编辑后的内容再 POST /chat」即可(避免重复轮次语义) |
| 6b | /api/v1/messages/{id} |
DELETE | JWT(owner/admin) | ✅ | 删除单条消息(D3)→ {data:{deleted:<id>}};不存在回 404 not_found;未注入 HistoryStore 回 501。成功后广播下行 message_deleted(§3.8,账号其它端按 id 移除) |
| 7 | /api/v1/sessions/{id} |
DELETE | JWT(owner/admin) | ✅ | 删除次级会话及其消息 → {data:{deleted:<id>}};主会话不可删回 403 forbidden;未注入 HistoryStore 回 501 |
| 8 | /api/v1/sessions/{id} |
PATCH | JWT(owner/admin) | ✅ | 会话元数据更新(A4 改名 + D5 置顶):body {title?,pinned?}(至少一个字段,都缺回 400)。title trim、≤80 rune,空/超长回 400;pinned 为 bool(置顶/取消,主会话与次级会话均可);personaId 出现即 400(v0.46,design/22 §8-1:会话角色在创建时确定、之后不可变,要换角色请新开会话)——不是静默忽略、也不是 200 空操作:键出现(任何取值:"yuki" / "" / null / 未知 id / 空白串)一律 400 bad_request,错误消息点名 personaId,且该检查先于会话存在性检查(未知会话 + personaId 也回 400,不是 404;连 title 同传也整体 400、不改任何字段)。响应只回显本次变更的字段:{data:{id,title?}} / {data:{id,pinned?}} / 两项同改则都有(title-only 保持 A4 原 {id,title} 形状);不存在回 404;主会话(id="web")可改名/置顶但不可删;未注入 HistoryStore 回 501。另:会话首条用户消息到达时自动命名 title(取前 20 rune 折叠空白;纯空白回落 主会话/次级会话),列表不再空名 |
| 8a | /api/v1/sessions/{id}/read |
POST | JWT(owner/admin) | ✅ | 推进已读游标(D4 / Android 协同 B3):body {before?}——before 为可选的毫秒 Unix 时间戳,缺省(或空 body)= 现在,即「我看到的都算已读」。→ 200 {data:{sessionId,readAt}}(readAt 为推进后的游标毫秒)。判定:createdAt <= readAt 的消息算已读(列表 unreadCount 只数 createdAt > readAt 的);语义:会话级单一游标、单 owner 账号——读一处 = 该账号所有端已读(不做 per-device 游标,与「同一账号所有浏览器都属于同一 owner」一致);只前进不后退(SQL read_at = MAX(read_at, before)),所以乱序/并发/重试请求幂等安全、绝不会把已读变回未读。before 为 0/负数/非数字、或 body 不是 JSON 对象 → 400;未知会话(含隔离 sess-xxx,不在 chat_sessions)→ 404;未注入 HistoryStore → 501 not_implemented。成功后广播下行 session_read(§3.9)让同账号其它端清红点;失败路径不广播。升级回填:迁移 0010 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读 |
| 9 | /api/v1/personas |
GET | JWT | ✅ | 角色目录(任意已认证角色可读)→ {data:[PersonaSummary...]},每项 {id,name,character,isDefault,mode,memoryNamespace,avatar?,chatBackground?,mood}。mood(v0.43) = 该角色当前情绪状态(角色级,取值见 GET /meta/enums 的 moods;"" = 该角色暂无被跟踪的情绪,不伪造默认值)。语义:情绪跟随角色,与会话无关——同一角色的所有会话看到同一 mood;客户端登录/加载角色目录时用它播种心情徽标,避免刷新后短暂空白(随后以 WS mood 帧为准)。不含 admin 专属的 thinking 旋钮(用户侧不暴露模型调用参数)。 |
| 10 | /api/v1/audio/transcribe |
POST | JWT(owner) | ✅ | 语音→文本:body {audio:"<base64>",format?,deviceId?} → {data:{text, provider?}}(走后端 STT,百炼 qwen-audio-3.0-asr-flash;当结果为空文本/silent 降级时响应体带 provider(如 "silent",非空文本省略),客户端据此提示「没识别到声音」) |
| 11 | /api/v1/audio/speech |
POST | JWT(owner) | ✅ | 文本→音频:body {text,voice?,format?,instruction?} → 200 音频字节(Content-Type: audio/<format>,非 JSON envelope,走后端 TTS,百炼 cosyvoice-v3-flash,复刻音色可带 instruction 情绪指令;静默降级空音频时仍 500 tts_empty,并附 X-Voice-Provider: silent 响应头,客户端据此提示「没生成声音」) |
| 12 | /api/v1/audio/voice-clone |
POST | JWT(owner) | ✅ | 声音复刻建音色:body {url:"<公网参考音频URL>",prefix?} → {data:{voiceId}}(走百炼 voice-enrollment create_voice) |
| 13 | /api/v1/upload |
POST | JWT(owner/admin) | ✅ | 图片上传(B1):multipart/form-data,字段 file(raster image,5MB 上限)→ 201 {data:{url,size,mime,name}};url 为同源访问地址(<host>/api/v1/files/<name>,可直接作 chat.image)。校验:MaxBytesReader 防超大 → DetectContentType 嗅探(不信客户端 Content-Type)→ 白名单 png/jpeg/gif/webp/bmp(拒 SVG/HTML);文件名用 crypto/rand 128-bit 随机名(原始文件名丢弃),存 <dataDir>/uploads/(0700)。错误码:缺文件 400 bad_request、超限 413 payload_too_large、非白名单 415 unsupported_media_type、未授权 401/403 |
| 14 | /api/v1/files/{name} |
GET | 无需(能力URL) | ✅ | 上传文件访问(B1):从 <dataDir>/uploads/ 读取,同名端口服务。name 仅接受「32 位小写 hex + 白名单扩展名」;路径穿越全形态拒绝(../%2f/%2e%2e 等)。返回 Content-Type/Content-Length/Cache-Control: private,max-age=86400,immutable/X-Content-Type-Options: nosniff(支持 Range)。设计取舍:GET 不鉴权——该 URL 是128-bit 随机能力地址(chat.image/<img> 无法带 Bearer),POST 仍 owner/admin+审计;任何持有该 URL 者都能取文件,勿外泄到他人 |
| 15 | /api/v1/meta/enums | GET | JWT(任意角色) | ✅ | 共享枚举权威表(B4):{data:{platforms,moods,modes,unknown}},每项 {id,label}——platform/mood/mode 的唯一事实来源,客户端不必硬编码即可拿到稳定映射(来源角标 / 情绪标签不再漂移)。只读、无敏感信息,与 GET /sessions 同级(owner/admin/viewer 均可读;无 token 回 401)。详见 §1.1 |
语音 REST 源(
/api/v1/audio/*,均 owner-only + 审计):原生/移动端用它上传/获取音频,绕开 WS 4KB 帧限。
POST /api/v1/audio/transcribe:body{audio:"<base64>", format?:"wav", deviceId?}→200 {data:{text}}。audio为 base64(或 data: URI);format为编码(wav/mp3/ogg,缺省wav);空 audio 回400;未注入语音服务500 voice_unavailable、识别失败500 stt_failed。POST /api/v1/audio/speech:body{text, voice?, format?, instruction?}→200原始音频字节(Content-Type: audio/<format>),非 JSON envelope。voice/format目前遵循服务端VOICE_TTS_VOICE_ID/VOICE_TTS_FORMAT配置(后续支持按请求覆盖);instruction为可选 per-request 情绪/语气指令(如"语气开朗俏皮,像女朋友撒娇"),只对复刻/设计音色生效——系统音色longxiaochun_v3被服务端忽略,永不发送自由指令(CosyVoice 系统音色需固定格式);长度 ≤100 字符(汉字记 2);未传时回退到服务端VOICE_TTS_INSTRUCTION。空 text 回400;未注入语音服务500 voice_unavailable、合成失败500 tts_failed、静默空音频500 tts_empty。POST /api/v1/audio/voice-clone:body{url:"<公网参考音频URL>", prefix?:"cyrene"}→200 {data:{voiceId}}。url为公网可访问的参考音频 URL(create_voice 只收input.url,不收 base64),音频要求 10~20s 连续清晰朗读、≤10MB、≥16kHz;prefix为音色名前缀(≤10 字母/数字,缺省cyrene)。返回的voiceId填到VOICE_TTS_VOICE_ID即可用该复刻音色合成(target_model固定为VOICE_TTS_MODEL,须与合成模型一致)。URL 不写审计/日志(红线,绝不打日志)。空 url 回400;未注入语音服务500 voice_unavailable;建音色失败500 voice_clone_failed。⚠️ 真建音色需主人提供公网参考音频 URL(当前无,断言待提供);且需北京地域 API Key(CosyVoice 声音复刻仅华北2北京地域)。
1.1 枚举权威表(GET /api/v1/meta/enums,B4)
背景:
platform/mood/mode此前只在文档里给示例(web/telegram/…;calm;normal),客户端只能自建映射表——来源角标与情绪标签因此会随客户端漂移。本端点发布权威枚举表 + 未知值回落约定:Web / Android / IM bridge 共用同一份,客户端不必硬编码。
- 鉴权:
Authorization: Bearer <JWT>,任意已认证角色可读(owner/admin/viewer),与GET /api/v1/sessions同级——只读、无敏感信息。无 token / 令牌无效回401。无其它错误码(纯内存投影,无存储、无 501)。 - 响应:
200每项固定为{"data":{ "platforms":[{"id":"web","label":"网页"}, ...], "moods":[{"id":"calm","label":"平静"}, ...], "modes":[{"id":"chat","label":"对话"}, ...], "unknown":"display-raw" }}{"id":"<枚举 id>","label":"<默认中文标签>"}。label是默认值,自带 i18n 的客户端可覆盖显示;id才是必须比对的值。 unknown(未知值回落约定,机器可读):恒为"display-raw"。语义见下。
platform(消息来源平台,{id:label} 逐值)
| id | label | 说明 |
|---|---|---|
web |
网页 | Web 主会话来源(chat_messages.platform 默认值) |
kook |
KOOK | KOOK 适配器 |
qq-obv11 |
OneBot v11(QQ)适配器 | |
weixin-ilink |
微信 | 微信 iLink 适配器 |
telegram |
Telegram | Telegram Bot API 适配器 |
webhook |
Webhook | 通用 Webhook 适配器 |
mood(情绪,{id:label} 逐值)
| id | label |
|---|---|
calm |
平静 |
happy |
开心 |
excited |
兴奋 |
sad |
低落 |
angry |
生气 |
⚠️ 情绪状态机可扩展(persona 可注册更多状态),因此上表不是封闭集合——未知 mood 必须按回落约定原样显示。
mode(行为/呈现模式,{id:label} 逐值)
| id | label |
|---|---|
chat |
对话 |
code |
代码 |
home |
家居 |
work |
工作 |
docs |
文档 |
voice |
语音 |
vision |
视觉 |
agents |
智能体 |
settings |
设置 |
未知值回落约定(红线)
- 客户端遇到不在表中的取值(
platform/mood/mode任一):原样显示该原始字符串——不丢弃、不报错、不替换占位符、不猜测。 - 服务端新增枚举取值是非破坏性变更(forward-compatible):表中新增一项不改变既有字段/端点形状,旧客户端无需升级;不要因为读到未知值而清空角标、抛异常或中断渲染。
- 判定只比对
id(大小写敏感,契约 id 为小写);label仅作显示默认值。 - Web 端镜像实现在
frontend/web/src/lib/enums.ts(labelFor(kind, value)对未知值返回原值),权威仍以本端点为准。 - 反漂移保证(后端):后端有测试断言「每个内置适配器的
Name()都在platforms表内」「moods与 persona 情绪状态机的内置状态一致」「modes与前端MODES九模式一致」——漏登记即 CI FAIL,客户端拿到的表不会滞后于代码。
契约版本
v0.53(阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 /image-crop工具 / 按 provider 形状下发推理开关,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① 新增管理只读端点GET /api/v1/admin/llm/catalog(owner|admin 读,?refresh=1强制在线刷新)→200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}};既无本地快照又抓不到 →503 {error:{code:"catalog_unavailable", message}};客户端带Accept-Encoding: gzip时响应带Content-Encoding: gzip(信封形状不变)。副作用只有一处:落盘快照<DATA_DIR>/llm_catalog.json(不含任何凭据、data/已 gitignore、TTL 24h),不改models.json;抓取只发生在本端点内,不进运行时热路径(模型请求链路从不读外网目录)。详见 §2「模型配置契约(v0.53)」。②models.json组级新增键(载体是组,可热更新,未声明时该键不出现):reasoning: {kind: "auto"|"deepseek"|"openai"|"vllm"|"dashscope"}决定该组用哪种提供商开关形状下发推理指令(非法值被Group.Validate直接拒绝;anthropic 组上声明 openai 系形状同样拒绝)、personaModels: {personaId: modelId}与personaProviders: {personaId: "openai"|"anthropic"}为角色级模型/提供商路由(非法 provider 值同样拒绝;未绑定/未知角色回落该组默认)。③ 语义修正(重要):multimodal= 主模型自身能否收图;LLM_VISION_MODEL/LLM_OCR_MODEL只配视觉/OCR 工具用的模型——两者互不代表(见 §1 行 3)。④.env新增 9 键(见 §2「模型配置契约(v0.53)」表与.env.example):LLM_REASONING_KIND(默认空 =auto)、LLM_OCR_MODEL、LLM_LOCAL_ENABLED(默认false)/LLM_LOCAL_PROVIDER/LLM_LOCAL_BASE_URL/LLM_LOCAL_API_KEY/LLM_LOCAL_MODEL、CAPACITY_L3_ENABLED(默认false)/CAPACITY_L3_CACHE_TTL(默认5m)。⑤ 内置工具新增image-crop(按区域裁切图片:x/y/width/height→ PNG data URI + 裁切尺寸;供大图分区精读;策略默认 owner-only)。⑥ 推理开关按形状下发(旧陈述修正):v0.41 的「Qwen 一类只 WARN、请求一字不改」已作废——形状由reasoning.kind声明后真下发(→ 字段名映射表见 changelog v0.53 条),只有形状未知/有歧义时才 WARN 且一个字段都不发;reasoning_effort的非法值由「降级到最近一档」改为丢弃并记 WARN(不降档),effort:none一律翻成显式关思考。未做:DashScope 形状的auto推断(只能在显式声明reasoning.kind=dashscope时下发,绝不从模型名/BaseURL 猜)、Qwen 的/think前导消息软开关(与 design 口径冲突,未实现)。上一版v0.52:(工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① 工具策略(allowInGroup/allowInStranger)落盘 SQLitetool_policies(迁移0012_tool_policy),重启保留——registry.SetPolicy先落库后改内存,落盘失败则内存保持旧值并返回错误(见 §2 行 5);② 新增下行帧approval_notice(§3.11):敏感操作经主代理代审后推一行小字(explain由后端产出、客户端原样渲染),审计口径who=角色名 /what=approval.<工具名>/result=granted|denied,仅owner+admin(fail-closed 默认);③graph_update节点props新增四类纯增量可选键(toolargs_keys/args_summary、guardrailaction/allow/elapsed_ms、memoryaction/scope/hits/bytes/count/failed、orchestrationsubagent/persona),memory/guardrail/orchestration旁路节点 span 首次真进图,值在发布前已脱敏——帧名、事件名、顶层字段集一字未动(见 §3.5)。上一版v0.51:(后台子代理结果登 WS 帧 + 回填父会话,依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):新增下行帧subagent_result({type, sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}},见 §3.10)——subagent_spawn的批次转后台后结算时,编排层发布在 bus 主题subagent.result上的合并结果,此前没有任何消费者(中继既非ws.*也不认该 4 段以外的 subject)⇒ 结果上不了前台、只剩进度帧;现在该 subject 被登记为中继并成形为这一帧,帧壳sessionId= 派生该批次的父会话,结果因此落回父会话的实时流。角色门:新帧属内容帧 → 只发owner+admin(fail-closed 默认,未扩 7 个状态机信号白名单)。向后兼容:纯新增帧,老客户端按未知帧忽略即可,既有帧/端点/鉴权一律不变。诚实边界:结果不落库(父会话转录里没有新消息、重连回放不补发)、未经 design/15 会话消息编排器续接、前端尚无该帧 handler(今天的效果是「帧到达 owner/admin 连接」)。同版另含管理面(D 组,2026-10-04 落实):①X-API-Key真正接线(裁决 15.4.2):管理/用户侧已认证端点同时接受该头,命中签发表即通过且一律owner级(裁决 15.4.3 取消 role 分级),带该头时只走 key 分支、未知/已吊销/空值回401且不回落 Bearer(fail-closed),WS 握手仍只认 Bearer;② 新增主人白名单档案读写GET/PUT /api/v1/admin/owner-profile(裁决 10.4.5,owner-only,写带审计owner.profile.update)——面板的「保存」从此是真保存(落盘configs/users/owner.yaml0600 原子写 + 热替换活档案,渠道 actor 判定立即生效),不再是只改本地 state 的假成功;③ personaidentity.systemPrompt改必填(裁决 08.4.2):缺失/空白回400 bad_request,不再派生默认说明串,写出的 YAML 必能被载入期同一规则重新载入。见下方「管理面板写操作契约」。上一版v0.50:(事件批:流式帧的结构化补齐,依据 design/04 §1.1,裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4,2026-10-04 落实):①tool_execution拆三事件——旧单帧tool_execution.update {id,index,delta}(phase 与工具名拼在delta字符串里)被tool_execution.start(进入执行,phase:"running")/tool_execution.update(运行中进度/局部输出,可多帧)/tool_execution.end(完成或失败,phase:"done"|"error")替换,载荷改为结构化{tool, phase, id?}(tool= 工具名;phase= 细粒度执行阶段,与 §3.5 流程图节点 status 同一套running/done/error枚举;id= provider 工具调用 id,可与tool_call.*帧关联)——phase 不再塞进delta;loop 现已真实发出 start/end(update 的 wire 映射已实现并验证,但工具分发器尚无中途进度上报源,暂无生产者,见 §3.1)。②streamId(模型流帧data内新增可选字段):同一次模型流(一次 ChatStream 调用)的多帧共享同一streamId,多步工具轮的第二次模型调用取新 id;与帧壳的turnId(整个回合恒定)分工——turnId标记「这轮回答」,streamId标记「这轮里的哪一条流式消息」。③turn.start/turn.end(新帧):loop 在回合边界发出、经 ws_bridge 下行;无data载荷(帧壳的sessionId/turnId/seq即边界信号;回合终态信息仍由既有stream_end携带);turn.end覆盖回合的所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错),被静音的内部流程(backthink / 子代理)不发出。④ 向后兼容:turn.start/end与streamId为纯新增,老客户端可安全忽略;破坏性在旧tool_execution.update单帧——loop 不再产生它,按旧形状(解析delta里的phase:工具名拼串)消费的客户端必须迁移到三事件;现有 Web 前端未消费这些帧,行为不变。⑤ 本批新帧全部落在 v0.48 下行帧门的「仅owner+admin」档(fail-closed 默认,无需扩白名单)。⑥graph_update会话范围口径修正(裁决 01.4.5,随实现事实补记):web/web:<uuid>/sess-backthink三范围(见 §3.5)。见 §3.1 与 v0.50 变更记录。上一版v0.49:(WS 握手回放与上行帧的角色门(design/11 §7.2):补上 §7.1 下行门之外的同一类缺口的另两处,对viewer继续收紧,owner/admin行为不变,无端点、无字段形状变更——① 握手history回放改为仅owner+admin:此前任意角色一连上/ws/chat就会收到主会话最近 50 条消息正文(thinking已剥离,但正文本身就是主人的对话),viewer从此不收;② 上行 JSON 帧按角色授权(fail-closed):非owner/admin连接发来的任何上行 JSON 帧一律拒绝,定向(只回发送者)回{type:"error",data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容——被拒帧含chat(此前viewer可真的驱动模型并落库)、interrupt、subagent_ask/task_output_query(其应答是定向帧、能绕过 §7.1 下行门把子代理上下文/任务输出直接回给viewer)、voice_input、mode_switch,且未登记的新上行帧默认拒;协议级帧(ping/pong、close)不受影响。③ 合口径:viewer= 可连接、可读 12 条只读监控 REST(v0.40)、可收 7 个状态机信号帧(v0.48);除此以外 WS 上拿不到任何内容、也发不出任何指令。见 §3 开头的「上行帧与握手回放的角色门」与 v0.49 变更记录。上一版v0.48:(WS 下行帧的角色门(design/11 §7.1):hub 的下行现在是按帧类型分档的——只有 7 个状态机信号(session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full/session_read,均不含任何内容)继续发给所有已认证连接(含viewer),其余全部下行帧(流式内容message.*/thinking.*/tool_call.*/tool_execution.update/stream_chunk/stream_end/thinking;持久化message_persisted/message_edited/message_deleted/channel_persisted;系统内部graph_update/subagent_event/task_output/task_done/tool_used/capacity_awaken/mode_switch;私有状态mood/device_update/reminder)改为仅发owner+admin)。对viewer是收紧:此前它连上/ws/chat就能收到角色回复正文与推理内容,现在收不到;owner/admin行为不变;无端点、无字段形状变更——viewer的 12 条只读监控 REST 端点(viewerAuth,v0.40)与/ws/chat的连接鉴权都照旧;新帧类型默认只发 owner/admin(fail-closed)。见 §3 开头的「下行帧的角色门」与 v0.48 变更记录。上一版v0.47:(graph_update子代理泳道根节点新增可选props.persona:派发子代理时若指定了目标角色,该分支根节点的props就多一个persona键,标注它正在扮演的角色——用途是让流程图能显示「这个子代理是以谁的身份在替班」,而不只是子代理 id;纯增量:不带角色派发时该键不出现,老客户端忽略未知键即可,见 §3.5 与 v0.47 变更记录)。上一版v0.46:(PATCH /api/v1/sessions/{id}的personaId由「可重新绑定该会话的角色」改为「出现即400」:会话角色在创建时确定、之后不可变,要换角色请新开会话(design/22 §8-1)——明确拒绝而非静默忽略/200 空操作;键出现(任何取值:"yuki"/""/null/ 未知 id / 空白串)一律400 bad_request且消息点名personaId,该检查先于会话存在性检查(未知会话 +personaId也回400而不是404)。破坏性:按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色 / 显式""解绑」的客户端必须改为新建会话选角色;title/pinned行为与POST /sessions {personaId}(创建时绑定)完全不变,不再有501/200 {personaId}的 PATCH 路径)。上一版v0.45:PUT /admin/channels/{platform}/config的personaId由「省略/""都 = 解绑」修订为三态(省略(或null)= 保留已存绑定、显式""= 解绑、非空 id = 绑定,未知角色仍400)。
2. REST 端点(管理,/api/v1/admin/*)
仅供管理面板与授权客户端;RBAC:
owner/admin/viewer;写操作全程审计。角色分档见下方「管理侧权限矩阵」(v0.40 起viewer真正可用:只读监控档,其余一律403)。
2.0 管理侧权限矩阵(v0.40 起,与 internal/services/gateway/routes.go 逐条一致)
- 匿名(无 token / token 无效):任意
/api/v1/admin/*→401 {error:{code:"unauthorized"}}(fail-closed,全部 59 条管理路由无一例外 —— 12 条 viewer 档 + 10 条 owner/admin 档 + 37 条 owner-only 档)。 - 角色分档(
✅放行,❌回403 forbidden;owner>admin>viewer 递增):
| 端点组 | owner | admin | viewer |
|---|---|---|---|
| 只读监控档(12 条 GET,见下①) | ✅ | ✅ | ✅ |
| owner/admin 只读档(10 条,见下②) | ✅ | ✅ | ❌ |
| owner-only 写/凭据档(37 条,见下③) | ✅ | ❌ | ❌ |
① viewer 可读的 12 条只读监控档(viewerAuth = owner/admin/viewer)——模块清单 + 运行时指标/日志/通知,全部是非敏感投影(无凭据、无他人账号数据、无消息正文):
GET /admin/personas、GET /admin/sessions、GET /admin/plugins、GET /admin/plugins/installed、GET /admin/channels、GET /admin/iot、GET /admin/automations、GET /admin/rag、GET /admin/observability/metrics、GET /admin/observability/trace、GET /admin/observability/logs、GET /admin/notifications。
② 仅 owner/admin 的 10 条(viewer → 403;理由:含他人数据 / 隐私正文 / 完整导出 / 审计流水 / 全局凭据面):
GET /admin/memories(私人记忆正文)、GET /admin/channels/messages、GET /admin/channels/{platform}/messages(完整 IM 消息流/转录)、GET /admin/llm(provider/模型配置面)、GET /admin/llm/catalog(模型目录候选面,v0.53 新增)、GET /admin/users(账号与 API key 列表)、GET /admin/backthink(后台思考记录)、GET /admin/observability/audit(审计流水)、GET /admin/sessions/{id}/export(会话导出)、POST /admin/rag/query(知识库检索,语义上是读,但按「全局知识面」归此档)。
③ 仅 owner 的 37 条(admin 与 viewer 均 403)——全部写操作,外加凭据获取路径(login-qr/login-status,view/friends,渠道连接/测试/配置)与主人身份档案(决定谁能进 owner 车道,连读都不给 admin):
GET /admin/channels/{platform}/login-qr、login-status、view、friends;POST /admin/channels/{platform}/friend/handle、message/test;PUT|DELETE /admin/channels/{platform}/config;POST /admin/channels/open|close|test;POST /admin/plugins/policy|install|uninstall;POST /admin/personas、PUT /admin/personas/{id};GET|PUT /admin/owner-profile(v0.51);POST /admin/users、POST /admin/users/apikey、DELETE /admin/users/apikey/{id};POST /admin/iot/{id}/control、POST /admin/iot/scenarios、POST /admin/iot/rules;POST /admin/automations、POST /admin/automations/{id}/run、DELETE /admin/automations/{id};POST /admin/llm、POST /admin/llm/test;POST /admin/memories、POST /admin/memories/search;POST /admin/rag、POST /admin/rag/delete;POST /admin/system/revoke-keys|rotate-jwt|backup|restore。
用户侧同级只读(非 admin 前缀,同样任意已认证角色可读):
GET /api/v1/sessions、GET /api/v1/personas、GET /api/v1/meta/enums(§1 行 4/9/15)。 已知遗留:前端管理面板尚未按角色隐藏/禁用分区——viewer打开②③档页面会拿到403,客户端需按本矩阵自行降级(后端契约已定,前端适配后置)。
| # | 端点 | 方法 | 状态 | 说明 |
|---|---|---|---|---|
| 1 | /api/v1/admin/personas |
GET | ✅ | 人格列表(viewer 可读)→ {data:[PersonaSummary...]},每项含用户侧全部字段,另加 admin 专属 thinking?(v0.43):{budgetTokens,effort,showToUser,mode}——见下方「人格 thinking 读侧契约」 |
| 2 | /api/v1/admin/sessions |
GET | ✅ | 持久化会话列表(主会话 + 次级会话,design/16 §2);未注入 HistoryStore 时回退渠道会话视图。v0.42 起每项含可选 personaId(与会话级选角同源,见 §1 行 4) |
| 3 | /api/v1/admin/memories |
GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
| 4 | /api/v1/admin/plugins |
GET | ✅ | 插件/能力列表(含 MCP/渠道) |
| 5 | /api/v1/admin/plugins/policy |
POST | ✅ | 写工具策略(owner-only,审计);策略落盘 SQLite tool_policies,重启保留(迁移 0012_tool_policy)——registry.SetPolicy 先落库后改内存,落盘失败则内存保持旧值并返回错误 |
| 5a | /api/v1/admin/plugins/installed |
GET | ✅ | 已装运行时插件列表(owner/admin 读):{data:[{id,name,kind,transport,endpoint?,command?,args?,apiKey?,env?,createdAt?,live,toolCount}]},apiKey/敏感 env 值掩码(如 ****1234),live = 是否已连接注册,toolCount = 该 server 注册工具数 |
| 6 | /api/v1/admin/channels |
GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 bot 身份(botId/botUsername/botNickname,如 KOOK GET /api/v3/user/me 自动获取);适配器要求重新授权时附 needsRescan + lastError(v0.39,仅微信 iLink 返回 errcode=-14、会话被 iLink 侧拒绝这一种);每账号附可选 personaId(v0.44)=该账号的渠道级角色绑定(design/22 §2 方案 b;缺省 = 未绑定,非敏感、不掩码) |
| 7 | /api/v1/admin/iot |
GET | ✅ | IoT 设备列表 |
| 8 | /api/v1/admin/users |
GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;key 的签发/吊销已实现——POST/DELETE /admin/users/apikey,见下方写操作契约;POST 创建用户仍 501) |
| 9 | /api/v1/admin/llm |
GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 provider 协议与 maxTokens 预算);POST 保存组→落盘 models.json→热更新运行中的 LLM stack(design/04 §3.1,无需重启)。v0.53:组级新增 reasoning / personaModels / personaProviders 三键(见下方「模型配置契约(v0.53)」) |
| 9a | /api/v1/admin/llm/catalog |
GET | ✅ | 模型目录候选(owner/admin 读,v0.53;?refresh=1 强制在线刷新)——面板「添加提供商 / 添加模型」的 baseURL / 模型清单 / 能力位来源,服务端快照 + 离线兜底;失败 503 catalog_unavailable。见下方「模型配置契约(v0.53)」 |
| 10 | /api/v1/admin/automations |
GET/POST | ✅(GET) | 自动化与提醒(GET 已实现;POST 创建 M10) |
| 11 | /api/v1/admin/backthink |
GET | ✅ | 后台思考记录 |
| 12 | /api/v1/admin/rag |
GET | ✅ | 知识库文档列表(分块/嵌入状态);带 ?query= 时并入检索结果 |
| 13 | /api/v1/admin/rag/query |
POST | ✅ | 知识库检索(真实检索;配置向量增强走语义路径) |
| 14 | /api/v1/admin/rag/delete |
POST | ✅(写) | 删除知识库文档(真实删除:项目索引条目 + 级联章节);owner-only,{id} → {id,deleted:true},不存在 404 |
| 15 | /api/v1/admin/observability/metrics |
GET | ✅ | 网关请求指标(总数/QPS/延迟/方法·路径·状态计数/LLM·子代理计数) |
| 16 | /api/v1/admin/observability/trace |
GET | ✅ | 最近 trace span(环形缓冲,默认 512 条;loop/工具/编排的真实 span) |
| 17 | /api/v1/admin/observability/audit |
GET | ✅ | 审计记录(who/what/when/result,SQLite 持久化) |
| 17b | /api/v1/admin/observability/logs |
GET | ✅ | 后端日志 tail(logger 环形缓冲,默认 2000 条;?level=/?limit=,见下方「可观测性契约」) |
| 17a | /api/v1/admin/notifications |
GET | ✅ | 统一通知 feed(审计 + 主动推送聚合为只读 feed,design/10 §3 #16;见下方「通知聚合契约」) |
| 18 | /api/v1/admin/memories |
POST | ✅(写) | 手动添加记忆(owner-only,审计);body {personaId?,content,scope?},无 store 回 501 |
| 19 | /api/v1/admin/rag |
POST | ✅(写) | 导入知识文档(owner-only,审计);body {name,desc?,content},分块后落项目索引可检索;无 store 回 501 |
| 20 | /api/v1/admin/system/revoke-keys |
POST | ✅(写) | 吊销全部 API key(owner-only,审计);返回 {revoked:n}——key 表清空,先前签发的所有 key 立即失效 |
| 21 | /api/v1/admin/system/rotate-jwt |
POST | ✅(写) | 轮换 JWT 密钥(owner-only,审计);body {newSecret}(≥32 位,不强校验熵)→ {rotated:true,note,refreshTokensRevoked}——现有 access token 立即失效需重新登录,且所有 refresh token 一并作废(refreshTokensRevoked 为删除条数);新密钥不回显、不落日志 |
| 22 | /api/v1/admin/system/backup |
POST | ✅(写) | 备份数据(owner-only,审计);用 SQLite VACUUM INTO 快照(含已提交 WAL 内容)到 <dataDir>/backup-<ts>.db → {path,size} |
| 23 | /api/v1/admin/system/restore |
POST | ✅(写) | 恢复数据(owner-only,审计);body {path} → 校验路径在 dataDir 内→先备份当前→替换 DB 文件→{restored:true,backup,note:"需重启后生效"}(运行进程持有 DB 句柄,热替换需重启可见) |
M10 前端契约补充(管理面板):
GET /api/v1/admin/users·GET /api/v1/admin/llm·GET /api/v1/admin/automations·GET /api/v1/admin/backthink·GET /api/v1/admin/rag·POST /api/v1/admin/rag/query·GET /api/v1/admin/observability/metrics·GET /api/v1/admin/observability/trace·GET /api/v1/admin/observability/audit·GET /api/v1/admin/observability/logs·GET /api/v1/admin/notifications已实现,返回真实数据(日志 tail 见下方「可观测性契约」;未注册GET /api/v1/admin/logs)。写操作已补齐(见下方「管理面板写操作契约」):人格/API key/设备控制/自动化/LLM 测试/渠道连接与识别测试/记忆检索/会话导出/插件卸载真实持久化或真实生效;系统设置安全与存储类(吊销 API key / 轮换 JWT 密钥 / 备份 / 恢复)也已真实实现(见下方「系统设置契约」);主人档案保存(v0.51)同样已实现——GET/PUT /api/v1/admin/owner-profile真实落盘 + 热替换活档案,面板的「保存」不再是假成功;仍为后置 M10:新增用户、IoT 场景与规则、编辑环境变量/重启服务/检查更新——后端如实返回501 not_implemented,不伪造成功。(知识库删除已实现:POST /admin/rag/delete真实删除,见下方写操作契约;插件安装已实现:v1 为 MCP server 热安装。)
模型配置契约(v0.53)(依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)与 design/06 §0/§3(裁决 14.4.1 / 14.4.5);判定与校验以 internal/config/runtimeconfig + internal/services/gateway/admin_llm*.go 为准):
-
GET /api/v1/admin/llm/catalog(owner|admin 读,v0.53) →200 {data:{...}}:- 请求:
?refresh=1= 强制在线刷新;不带时,本地快照仍在 24h TTL 内就直接用它(不每次打开面板都打外网)。 - 响应
data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}source说明这份候选从哪来(models.dev= 本次在线抓取,cache= 本地快照);fetchedAt为快照抓取时间(RFC3339,无快照时省略);stale:true+warning只在「在线刷新失败、返回上次快照」时出现(离线兜底,绝不编造候选)。api= 该供应商 baseURL(目录未标注时省略,面板提示需手填);doc= 文档地址、env= 目录标注的官方 key 环境变量名(仅提示,面板不写.env)、npm= SDK 包名。- 每个模型:
attachment(接受图片/文件输入 → 面板预填multimodal)、reasoning、toolCall、contextLimit?/outputLimit?(目录未标注时为0而省略;outputLimit直接作maxTokens建议值)。 - 供应商与模型都按 id 排序(输出确定、便于前端 diff);没有模型清单的供应商不进列表(避免噪声行)。
- 失败:既无本地快照又抓不到 →
503 {error:{code:"catalog_unavailable", message}}(message 给出「可改用粘贴导入」的指引);有快照但刷新失败 → 仍200(source:"cache"+stale:true+warning);快照文件损坏/为空只记 WARN 并按「无快照」继续在线抓取,不影响本次回答。 - 压缩:客户端带
Accept-Encoding: gzip时响应带Content-Encoding: gzip(目录约 1MB 量级);信封{data}形状不变,浏览器与 Go 客户端透明解压;不带该头时返回明文并带Content-Length。 - 副作用(只有这一处):落盘快照
<DATA_DIR>/llm_catalog.json(与models.json同目录、data/已 gitignore、不含任何凭据、TTL 24h);不修改models.json。LLM stack 未注入时快照落到系统临时目录,绝不往工作树写文件。 - 抓取只发生在本端点内(数据源
https://models.dev/api.json,单次超时 20s、体积上限 64MiB):不进运行时热路径——模型请求链路从不读这份外网目录。 - 审计:成功记
llm.catalog/ok,返回过期快照记stale,失败记error(可用GET /admin/observability/audit查)。
- 请求:
-
models.json组级新键(v0.53,可热更新;GET/POST /api/v1/admin/llm已同步这三个键的读与写):reasoning: {kind}:kind∈"auto"(= 缺省)/"deepseek"/"openai"/"vllm"/"dashscope",决定该组用哪种提供商开关形状下发推理指令(vLLM →chat_template_kwargs.enable_thinking;DashScope → 顶层enable_thinking;DeepSeek → 顶层thinking:{type}+reasoning_effort;OpenAI 系 → 只有reasoning_effort;auto= 按模型名/baseURL 推断厂商,形状有歧义就不下发任何字段)。- 校验:不在上列取值 → 组校验失败(
POST /admin/llm回400 bad_request);anthropic 组上声明deepseek/openai/vllm/dashscope同样拒绝(Anthropic 用自己的thinking形状)。 - 请求语义:
reasoning键缺失 = 保留已存形状;给出(哪怕{})= 整块替换(kind缺失/空串 = 回到「未声明」)。 - 响应语义:每组的
reasoning恒下发(kind:""= 未声明形状,不是「后端没报告」);顶层另下发reasoningKinds(合法形状取值域,不含空串——""是「不声明形状」而非一个形状名,面板另行给出该选项)与personaProviderOptions(合法协议名域,即openai/anthropic)供面板渲染下拉框——取值域由后端常量直接生成,面板无需硬编码。
- 校验:不在上列取值 → 组校验失败(
personaModels: {personaId: modelId}= 角色级模型路由(「角色」= 该回合答话的 persona,不是消息作者的 role);personaProviders: {personaId: "openai"|"anthropic"}= 角色级协议路由(跨协议换 adapter,配合personaModels使用)。- 校验:persona id / 值不得为空,
personaProviders的值必须是openai或anthropic,否则组校验失败(400);键值首尾空白会被规整,规整后撞键直接报错(不静默丢一条)。 - 回落:未绑定 / 绑定到未知 persona → 该组默认模型;
personaProviders声明的协议在models.json里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,不半建一条只会失败的链)。 - 请求语义:两个键缺失 = 保留已存表;给出(含
{})= 整表替换(用整表替换而非合并,面板才能删除一条已存路由);未声明时响应中该键不出现(omitempty)。
- 校验:persona id / 值不得为空,
- 语义修正(重要):
multimodal= 主模型自身能否收图(不是「本部署有没有视觉能力」);LLM_VISION_MODEL/LLM_OCR_MODEL只配视觉/OCR 工具用的模型,与multimodal互不代表(裁决 14.4.1,见 §1 行 3)。
-
.env新增 9 键(v0.53;.env是models.json的兜底,对齐.env.example):键 默认 语义 LLM_REASONING_KIND空(= auto)仅供 .env合成组的推理开关形状声明(无有效models.json时才生效,取值同reasoning.kind;有models.json时以该组的reasoning.kind为准)LLM_OCR_MODEL空 OCR 工具用的模型;空 = 复用 LLM_VISION_MODEL,两者都空 = 复用主模型(LLM_MODEL)LLM_LOCAL_ENABLEDfalse降级链**第三级(本地模型)**总开关;默认关,链保持「主 → 备」两级 LLM_LOCAL_PROVIDERopenai本地端点协议( openai/anthropic)LLM_LOCAL_BASE_URL空 本地端点 base URL(如 http://127.0.0.1:8080/v1)LLM_LOCAL_API_KEY空 本地端点 key(本地服务通常为空) LLM_LOCAL_MODEL空 本地模型名 CAPACITY_L3_ENABLEDfalsecapacity 意图判定的 **L3(模型辅助分类)**开关;默认关,只走 L1/L2 启发式 CAPACITY_L3_CACHE_TTL5mL3 意图判定结果缓存 TTL LLM_LOCAL_*三个条件缺一不可:LLM_LOCAL_ENABLED=true且LLM_LOCAL_BASE_URL非空 且LLM_LOCAL_MODEL非空;只开开关而不配端点/模型时记 WARN 并跳过该级(绝不半配一个只会失败的链尾步)。
-
内置工具新增
image-crop(v0.53):按区域裁切图片(参数image+x/y/width/height,0 基像素、区域须完整落在图内)→ 返回{image:"data:image/png;base64,…", format:"image/png", width, height, region:{x,y,width,height}, source:{width,height}};用途是上游可能压缩分辨率、大图细节会丢,让模型自行裁出区域再交给 vision/OCR 工具精读(design/06 §3,裁决 14.4.5)。上限:源图 ≤50MiB、区域 ≤16MP、返回载荷 ≤8MiB(超出报错而非截断/降采样);源可以是本地路径(走 file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。策略与其它工具一致:tool_policies无行时按最严默认 owner-only(群聊/陌生人的调用被策略层拒,见 §2 行 5)。
管理面板写操作契约(POST/PUT/DELETE /api/v1/admin/*,均 owner-only,读为 owner/admin;写操作经 audit 落 audit_logs;成功 {data} / 失败 {error:{code,message}}):
POST /admin/personasbody{id,name,role,systemPrompt,characterRef?,modes?/mode?,memoryNamespace?,thinking?}→201 {data:PersonaSummary}(v0.43 起与读端点同形状:含mood+thinking?;注册进 persona registry + 持久化到configs/personas/<id>.yaml);id 已存在回409 duplicate。systemPrompt自 v0.51 起必填(落的 YAML 键是identity.systemPrompt):缺失/空白 →400 bad_request(消息"systemPrompt is required (identity.systemPrompt must not be empty)"),不再派生默认说明串回落——载入期(loader.Load)执行同一规则,因此写出的 YAML 必能被重新载入。PUT /admin/personas/{id}body 同上(不含 id)→200 {data:PersonaSummary}(v0.43 起与读端点同形状:含mood+thinking?,面板可直接用它回填);不存在回404。systemPrompt与创建同一把门:v0.51 起同样必填,缺失/空白回400 bad_request(同一条消息)——不派生默认串、也不静默沿用旧值,请求被拒即不落盘。thinking(可选,design/01 §2.1){budgetTokens?,effort?,showToUser?,mode?}:写入 YAML 的thinking节,驱动该人格的思考预算/开关/呈现。mode∈default|on|off(缺省default),非法mode或负budgetTokens回400 bad_request(invalid thinking config: …)。showToUser是 bool 且可缺省:缺省 = 保持现行为(thinking 帧照常推客户端);显式false= 只落库不回显。PUT省略整个thinking对象时保留已存的thinking节(不因客户端不认识该字段而抹掉);显式传对象则整体替换(字段全缺省即清空)。该字段不构成能力边界。- 人格
thinking读侧契约(v0.43,回读形状):GET /admin/personas每项按与写侧同构的形状回读thinking,供面板回填:- 未配置的人格:
thinking键完全不出现(不是全零对象)——与写侧「省略thinking对象 = 保留已存」互为镜像,客户端可有样学样地「原样回填 / 原样省略」。 - 已配置时 四个键都出现:
budgetTokens(number,未设为0)、effort(string,未设为"")、showToUser、mode(string,未设为"")。 showToUser是三态、且读侧如实三态:null= 未配置(保持现行为:thinking 帧照常推客户端);true/false= 显式选择(false= 只落库不回显)。服务端不会把null拍平成false(写侧用的是*bool,读侧同样保留指针语义),否则「没配过」会被面板误显示成「关」。- 能力提示由服务端负责,客户端不要假装:
effort的有效白名单随当前生效 provider 而定(OpenAI 兼容系none/minimal/low/medium/high/xhigh/max;DeepSeek 仅none/low/high/max,别名minimal→low/medium→high/xhigh→high/ultra→max;未知 provider 不支持)。服务端在模型层按 provider / 形状校验,非法或该形状不支持的字段一律丢弃并记 WARN(不降档、也不静默无效)(design/04 §3.2 / v0.53);读端点只回读已存配置,不承诺该值在上游真的生效。面板可给出提示与前端枚举校验,但不得把「配了 effort」呈现为「上游已启用」。
- 未配置的人格:
POST /admin/users→501 not_implemented(无用户表:登录仅用.env单 adminADMIN_USERNAME/ADMIN_PASSWORD,角色为owner>admin>viewer常量;多用户存储 + 登录接入需先设计——design/11 单用户为主。不做「建了却登不进」的假用户)。POST /admin/users/apikey(无请求体,v0.51 取消 role 分级)→201 {data:{id:<掩码>, key:<明文·仅此一次>, role:"owner"}};key仅此响应出现一次,任何有效 key 鉴权后即owner(裁决 15.4.3:key 不做角色分档;旧的{"role":…}请求体被忽略,表里也没有角色维度)。DELETE /admin/users/apikey/{id}(id=列表掩码)→200 {data:{id,revoked:true}};未命中404。GET /api/v1/admin/owner-profile(owner-only,读不审计)→200 {data:{id,displayName,defaultPersona,identityFile,notes,accounts:[{platform,accountId}]}}(后三个可选字段为omitempty——为空时键不出现,客户端按缺省处理即可);文件不存在(全新安装)→200+ 空白档案(accounts:[],非null,面板据此渲染空表单让主人首次填写);档案损坏/读不出 →500 {error:{code:"owner_profile_unreadable"}}(如实报错,不返回一份看似正常的空档案——否则「白名单是空的」与「档案读不出来」长得一模一样)。PUT /api/v1/admin/owner-profile(owner-only + 审计owner.profile.update)body 同形状(accounts可空/省略;各字段首尾空白会被去掉——面板里手打的平台名/账号 id 常带空格,带进白名单就永远匹配不上)→200 {data:<落盘后档案>};id/displayName缺失或accounts项半空(只给 platform 或只给 accountId)→400 {error:{code:"bad_request", message:"invalid owner profile: …"}}且不落盘(整条全空的占位项被跳过,不算错);写盘失败 →500 {error:{code:"owner_profile_persist_failed"}}。落盘configs/users/owner.yaml(0600 原子写:临时文件 + rename)+ 热替换活档案,新白名单立即参与渠道层 actor 判定、无需重启;组合根未暴露活档案时只记一条 WARN(已落盘、需重启生效),不假装热重载成功。它不是提示词/LLM 判定的输入——谁能当主人始终由这份档案的accounts白名单 + 每渠道adminIds决定(design/11 §0)。POST /admin/iot/{id}/controlbody{action,params?}→200 {data:{id,name,type,state,online}}(真实下发到 IoT provider);404设备不存在 /400动作不支持或参数错 /409设备离线。POST /admin/iot/scenarios、POST /admin/iot/rules→501 not_implemented(IoT 无场景/规则模型与存储:iot.Provider只有设备列表/控制/状态;通用automations只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器。场景/规则 CRUD 需先新增模型 + 执行器)。POST /admin/automationsbody{name,kind:"cron|event",expression}→201 {data:{id,name,kind}}(真实注册规则);无效 cron/规则回400 invalid_cron|invalid_rule。POST /admin/automations/{id}/run→200 {data:{id,output}}(手动触发);404。DELETE /admin/automations/{id}→200 {data:{id,deleted:true}};404。POST /admin/llmbody{id?,provider?,baseUrl,apiKey?,model,multimodal?,fallback?,maxTokens?,reasoning?,personaModels?,personaProviders?}→201 {data:{provider:{id,name,provider,baseUrl,model,multimodal,enabled,fallback?,maxTokens?,reasoning,personaModels?,personaProviders?}, persisted:true, path}}。真实持久化:读-改-写models.json(本地、gitignore),保存组提升为生效组(index 0),并热更新运行中的 LLM provider(path为落盘文件路径);API key 写入文件但永不回显/不落日志。provider为空默认openai;缺baseUrl/model回400;maxTokens为负回400;未注入 LLM stack 回500 llm_unavailable、写盘失败500 llm_persist_failed、热重载失败500 llm_reload_failed。maxTokens缺省/0= 未设(回落LLM_FALLBACK_MAX_TOKENS/ 适配器默认)。.env为兜底。v0.53 新增三键(reasoning.kind/personaModels/personaProviders,语义与校验见上方「模型配置契约(v0.53)」):三者缺省 = 保留已存值,给出则reasoning整块替换、两张 persona 表整表替换;非法kind/ 非法协议名 / 未知 anthropic 形状 →400 bad_request。POST /admin/llm/testbody{provider?,baseUrl,model,apiKey?,prompt?}→200 {data:{ok,provider,model,reply,tokens}}(真实模型调用);无效/失败回400 invalid_provider/500 llm_test_failed。API key 仅本次请求内使用,不落库、不进 wire。POST /admin/channels/open、POST /admin/channels/closebody{name}→200 {data:{name,connected}}(真实 Connect/Disconnect);404(未注册渠道)/409 session_rejected(微信 iLink 侧拒绝/作废了该会话,重连无法修复,须先「扫码登录」重新绑定,v0.39)/409 needs_login(微信 iLink 尚无 token)/500(其它凭据缺失等连接失败)。POST /admin/channels/testbody{platform?,accountId,isGroup}→200 {data:{role,owner,isGroup,env,verdict}}。结构性判定:channel 层 actor 元数据 ×configs/users/owner.yaml白名单,绝不靠提示词。- 微信 iLink「会话被拒」契约(v0.39,适配器级,非端点):iLink 返回
errcode=-14时适配器进入显式被拒态——IsConnected()恒false;Connect()不再返回成功,而是返回「需要重新扫码」类错误(POST /admin/channels/open对应409 session_rejected);HealthCheck同样把「被拒」与「未连接」区分开。GET /admin/channels该平台行带needsRescan:true+lastError(人类可读原因,绝不含 token)。清除条件:扫码登录拿到新 token(login-status报confirmed)或配置里换了新 token(保存后热重载)。被拒期间:login-status不假报confirmed而是failed+ 被拒原因(避免面板把已作废的凭据当成功重新落盘);login-qr仍可正常出码(不回already_logged_in挡住重新扫码);get_bot_qrcode不再携带已被拒的 token(否则服务端会以「已绑定」应答而不发新凭据)。未实现该能力的平台:needsRescan/lastError不出现(omitempty),客户端按缺省处理。 - 事实说明(勿误读为「过期」):iLink 的 bot token 是长期凭据(实测一个月前扫码得到的 token 仍可用),
errcode=-14表示 iLink 侧拒绝/作废了该会话凭据(解绑、在别处重新登录、协议层拒绝等),不是时间性过期;恢复方式为重新扫码绑定(或配置换新 token)。当前尚未在真实日志中观测到-14,此处按「被拒」语义处理以防静默变哑。 - KOOK 适配器面板端点(design/17 §2,owner-only + 审计,仅
kook平台实现;其它平台回501 not_implemented,账号未找到回404):这些端点均带可选?accountId=定位到具体 channels.json 账号(不传=该平台首个账号)。token/密钥不打印、不落日志;子项拉取失败不伪造、记入errors。GET /admin/channels/{platform}/view?accountId=→200 {data:{platform,accountId?,friends?,userChats?,guilds?,channels?,members?,errors?}}。聚合该账号真实数据(好友/申请/屏蔽、服务器、首个服务器频道与成员、私信会话);errors为{子项:原因}记录失败的子项。GET /admin/channels/{platform}/friends?type=&accountId=→200 {data:{friends?,requests?,blocked?}}。type∈friend|request|block(空=全部),调用 KOOKGET /friend?type=。POST /admin/channels/{platform}/friend/handle?accountId=body{id,accept}→200 {data:{handled:true,id,accept}}。同意/拒绝好友申请(KOOKPOST /friend/handle-request)。POST /admin/channels/{platform}/message/test?accountId=body{targetId,content}→200 {data:{msg_id,msg_timestamp}}。真实向targetId(频道 id)发一条 kmarkdown 测试消息(KOOKPOST /message/create),验证 bot 可达性。GET /admin/channels每条账号附capabilities(可选[]string,如 kook 的["friends","user_chats","channels","guilds","message_test"]),供面板按平台渲染专属功能卡片。
- KOOK「token 即用、其余自动获取」(适配器契约,非端点):填 KOOK 渠道时只必填
token(bot_token)。base_url留空即用官方默认https://www.kookapp.cn/api/v3;receive_mode空默认ws(WS 模式地址由GET /api/v3/gateway/index?compress=0自动获取,已接入);webhook_path空默认/webhook/kook。高级值(改用 webhook、webhook_verify_token、webhook_encrypt_key,仅 webhook 模式需要)经 PUT 的configJSON 传入(前端走「额外 config」,如{"receive_mode":"webhook","webhook_verify_token":"..."})。Connect 拿到 token 后自动调用GET /api/v3/user/me(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随GET /admin/channels账号的botId/botUsername/botNickname显示;user/me 失败仅记日志、bot 身份为空,不中断连接/收发。token 只在 header / 写文件,不打印、不进日志。 PUT /admin/channels/{platform}/config(owner-only,审计)body{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?,personaId?}→201 {data:{persisted:true,path,platform,accountId}}。真实持久化 + 热更新(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→UpsertChannel该平台账号 → 落盘channels.json(本地、gitignore,0600)→bridge.Reload(platform)热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但永不在响应/日志回显(GET 列表为掩码)。platform必填;空配置回400;未注入渠道配置服务回500 channels_unavailable、写盘失败500 channel_persist_failed、热重载失败500 channel_reload_failed。.env(CHANNELS_JSON/CHANNEL_*_TOKEN)为兜底。personaId(v0.44 新增,v0.45 起为三态):=该账号答话时的角色 id(design/22 §2 方案 b,取值来自GET /personas)。取值三态:键省略(或null)= 保留该账号已存绑定(不动);显式""= 解绑(写盘时该账号不带personaId键);非空 id = 绑定该角色。未知角色 id 回400 bad_request(不绑一个答不了话的角色;校验只针对本次真的传了值的情形,省略继承的已存绑定在写入时已校验过)。为什么是三态:面板只在主人动过角色下拉时才发这个键——若把「键不存在」也当成解绑,主人只改 token 就会静默清掉账号的角色绑定(两端语义相反即丢数据)。写盘即生效:保存后Reload(platform)会重建该平台 adapter,新角色在下一批入站消息上生效(无需重启)。它不是身份/权限输入——谁能做什么仍由 channel 层 actor 元数据 + 白名单决定(design/11 §0)。优先级见 §4.2「角色解析优先级」:渠道绑定只作用于该账号的主人私聊,且仅当主会话自身未绑角色;群聊/陌生人会话强制默认角色。
DELETE /admin/channels/{platform}/config?id={accountId}(owner-only,审计)→200 {data:{persisted:true,path,platform,accountId}}���删除该平台指定账号 → 落盘channels.json→bridge.Reload(platform)热更新(该账号 adapter 优雅关停)。id必填;未注入服务回500 channels_unavailable。GET /admin/channels/{platform}/messages?limit=&before=(owner/admin 读)→200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}。IM 适配器日志流(design/16 §4.6 §4):该平台所有非主人私聊/群聊消息(隔离会话sess-xxx,非主会话web)以消息流显示,senderName标发送者昵称;未注入HistoryStore回501。POST /admin/plugins/install(owner-only + 审计plugin.install)body{kind:"mcp",name,config:{transport:"stdio|http|sse",endpoint?|command?,args?,apiKey?,env?}}→200 {data:{installed:true,name,kind:"mcp",transport,toolCount}}。v1 只装 MCP server(单二进制无法热装 Go 插件):真实 connect+discover,把发现的工具热注册进 capability registry(下次模型请求即注入,无需重启)→ 落盘plugins.json(本地、gitignore、0600,apiKey写入但永不回显/不落日志)→ 持有 live server 供后续刷新/卸载。同名重复安装 = 刷新(先卸旧再装新,失败回滚旧)。校验:name必填、kind非mcp回400、transport 非法/stdio缺command/http|sse缺endpoint回400;连接/发现/注册失败回502 plugin_install_failed且不落盘(诚实,不静默装一半)。未注入 plugin manager 回501 not_implemented。POST /admin/plugins/uninstall(owner-only + 审计plugin.uninstall)body{name}→200 {data:{name,uninstalled:true,kind:"mcp"}}(已装 MCP:Unregister移除其工具 + close + 删plugins.json条目;非 MCP/普通能力走原registry.Remove,返回{data:{name,uninstalled:true}});未命中回404。POST /admin/memoriesbody{personaId?,content,scope?}→201 {data:{id,scope,text}}。手动添加记忆(owner 私聊直写);scope∈user|persona|project,缺省persona(与「记住…」同路径);无效非空scope回400;未注入memory.Store回501。POST /admin/memories/searchbody{query,personaId?,topK?}→200 {data:{query,personaId,semantic,hits:[{id,scope,text,score}]}}。真实检索(keyword×衰减);语义/向量路径需主会话注入 vector index(semantic当前恒false,诚实反映未接向量器)。personaId省略时(v0.42 起)回落到库里注册的默认角色(default标记的那个 character → 其 persona),不再写死"cyrene";若既未传personaId又没有任何默认角色 →400 bad_request(诚实报错,而不是拿一个不可能命中的 id 去查)。GET /admin/sessions/{id}/export→200 {data:{session:{platform,channelId,sessionId,actorRole,owner,isGroup,memWritable}}};404。当前导出会话身份元数据(完整消息转录由 M10 提供)。
系统设置契约(POST /api/v1/admin/system/*,均 owner-only,审计;成功 {data} / 失败 {error:{code,message}}):
POST /system/revoke-keys(无 body)→200 {data:{revoked:n}}。吊销全部 API key:清空 key 表(先前签发的所有 key 立即失效),revoked为吊销数量;未注入 API key 服务回500 users_unavailable。POST /system/rotate-jwtbody{newSecret}→200 {data:{rotated:true,note:"现有 token 已全部失效,需重新登录"}}。轮换 JWT 密钥:newSecret≥32 位,替换当前签名密钥,先前 token 全部失效;newSecret缺失/空白回400;长度 <32 回400;认证服务未配置回500 auth_unavailable。新密钥永不回显、不落日志。POST /system/backup(无 body)→200 {data:{path,size}}。备份数据:经 SQLiteVACUUM INTO(含已提交 WAL 内容)快照到<dataDir>/backup-<ts>.db,path为备份文件绝对路径、size为字节数;数据库路径未配置回500 backup_unavailable、快照失败回500 backup_failed。POST /system/restorebody{path}→200 {data:{restored:true,backup,note:"已安排恢复,需重启服务后生效"}}。恢复数据:path须为 dataDir 内的备份文件(路径穿越防护——filepath.Rel校验,..逃逸回400);文件不存在回404;先自动备份当前数据库(backup为快照路径)再替换 DB 文件;运行进程持有 DB 句柄,替换后需重启服务生效;数据库路径未配置回500 backup_unavailable、替换失败回500 restore_failed。
知识库 RAG 契约:
GET /admin/rag→{"data":{"documents":[{id,name,desc?,chunks,embedded}],"hits"?:[{docId,docName,chapter?,text,score,semantic}]}}。hits仅在携带?query=时出现;embedded反映部署是否配置向量增强(未配置为待嵌入)。POST /admin/ragbody{"name","desc?","content"}→201 {"data":{"name"}}。导入文档:后端分块(空行分段,超长段按固定长度切)并落项目索引(项目+章节),立即可被关键词/行上下文检索;未注入RagStore回501。语义/向量嵌入为优雅降级(知识库 Store 不直属向量索引,导入文档走 keyword/line 路径;语义检索仍对既有 project-linked 记忆生效)。POST /admin/rag/querybody{"query":"..."}→{"data":{"query":"...","hits":[{docId,docName,chapter?,text,score,semantic}]}}。检索走项目索引关键词+行上下文;配置 pgvector/向量增强时并入语义/向量命中(semantic:true)。POST /admin/rag/deletebody{"id":"..."}→200 {"data":{"id":"...","deleted":true}}。真实删除:从项目索引删除该文档条目并级联删除其全部章节(单事务),删除后GET /admin/rag不再出现、检索不再命中。id缺失/空白回400;文档不存在回404 not_found;未注入RagStore回501(owner-only + 审计rag.delete)。
可观测性契约(GET /api/v1/admin/observability/*,均需 owner/admin;metrics/trace/audit 的 ?limit=N 缺省 50、上限 500):
GET /metrics→{"data":{"totalRequests","qps","avgLatencyMs","maxLatencyMs","perMethod{}","perPath{}","statusCounts{}","llmCalls","subagentEvents"}}。qps为近 1 秒窗口计数;llmCalls/subagentEvents由实际观测到的 trace span 按kind累计(Tracer 未注入时为 0)。Token/成本后端不统计、不返回(不伪造指标)。GET /trace?limit=100→{"data":[{id,parentId?,name,kind,status,startedAt,endedAt?,durationNs,props?,events?]}。kind∈llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail;durationNs为纳秒;events为[{time,type,props?}]。GET /audit?limit=100→{"data":[{id,who,what,when,result}]}。id为整数自增;when为 RFC3339;来源为 SQLiteaudit_logs表(未注入 AuditStore 时退化为进程内存)。GET /logs?level=&limit=→{"data":[{time,level,source,msg}]}(后端日志 tail,v0.35)。- 来源:logger 进程内有界环形缓冲(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,不额外打印、不重渲染、不落库;控制台/文件输出完全不受影响。
source取自logger.WithSource的source=属性(logger.Scoped的记录回退为module/sub),无来源记""。 - 排序:
time倒序(最新在前)。time为 RFC3339(UTC,秒精度),与trace.startedAt/audit.when一致;level∈debug|info|warn|error;msg为记录消息。 - 查询参数:
level∈debug|info|warn|error(大小写不敏感)为最低等级过滤(如level=warn返回 warn + error),空/缺省 = 全部,未知值回400 invalid_level;limit缺省200、上限1000(小于等于 0 或非法值回落缺省)。日志量超过limit时按时间倒序截断(先按 level 过滤再取limit,缓冲全量 2000 条内扫描)。 - 诚实边界:组合根未注入日志环形缓冲(非二进制主链路启动)时回
501 not_implemented,不返回伪造/空的成功 tail。日志内容为 logger 已产出内容,敏感信息按既定规范本就不落日志(本端点不新增任何打印)。
- 来源:logger 进程内有界环形缓冲(容量 2000,满则丢弃最旧),只存 logger 已产生的记录,不额外打印、不重渲染、不落库;控制台/文件输出完全不受影响。
通知聚合契约(GET /api/v1/admin/notifications,owner/admin 读;design/10 §3 #16):
GET /admin/notifications?type=&before=&limit=→200 {"data":[{id,type,title,body?,source,createdAt}]},按createdAt倒序(同毫秒按id倒序稳定)。- 查询参数:
type∈system|channel|backthink|audit(空/缺省 = 全部,未知值回400 invalid_type);limit缺省50、上限200;before为createdAt(epoch 毫秒)游标,含游标本身(createdAt <= before——两源时间戳精度不同(审计秒/纳秒、backthink 毫秒),页边界可能落在同一毫秒,客户端按id去重)。缺省/非法before= 从最新开始。 - 字段:
id=<source>:<原生 id>(audit:<audit_logs.id>/backthink:<backthink_records.id>),跨页稳定、可去重;source∈audit|backthink;createdAt为 epoch 毫秒(无法解析的审计时间戳记0,不伪造时间)。title/body由各自源派生:审计项title = what(如channel.open)、body = "<who> · <result>";backthink 项title = "主动推送(<trigger> · 已送达|未送达)"、body =结论摘要(≤200 rune,超出加…)。 - 类型归类(审计源按操作前缀派生):
channel.*→channel;system.*/plugin.*/policy.*→system;其余审计操作(persona/user/memory/rag/session/upload/audio/…)→audit;backthink 主动推送 →backthink。 - 数据源与诚实边界:只聚合现有源(审计
audit_logs+ backthink 记录),不新建 notifications 表。backthink 仅shouldPush=true的记录算通知(纯思考记录不推给主人);未注入的源 → 该类型为空,不伪造(无 backthink 服务即无backthink项);源报错回500 notifications_failed(不静默少给)。本版为只读 feed:未读/已读游标后置(不建重表)。
3. WebSocket /ws/chat(实时)
-
握手:
ws://<host>:8080/ws/chat?token=<JWT>或Authorization: Bearer;未认证在握手前回401JSON。 -
下行(服务端→客户端): 经 bus 广播
{type:"<event>", data:..., turnId?, seq?, sessionId?}(turnId/seq为流式关联字段,见 §3.1;sessionId为会话持久化(design/16)新增,能确定会话的事件带上,供客户端按激活会话过滤;v0.36 起结构化流帧message.*/thinking.*/tool_call.*/tool_execution.*也带sessionId+turnId——多会话并发流式时客户端据此把 delta 路由到激活会话,见 §3.1)——message.start/delta/end/thinking.start/delta/end/tool_call.start/delta/end(以上模型流帧的data另带streamId,v0.50)/tool_execution.start/update/end(工具执行三事件,v0.50)/turn.start/turn.end(回合边界帧,v0.50)(结构化流,design/04 §1.1),以及向后兼容的stream_chunk/stream_end/thinking(思维链) /mood(情绪变化,见 §3.7) /reminder/device_update/tool_used(工具回显) /subagent_event(逐代理进度) /subagent_result(后台批次的最终结果,见 §3.10) /approval_notice(主代理代审小字,见 §3.11) /graph_update/message_persisted(会话持久化增量,见 §3.3)/message_edited、message_deleted(消息编辑/删除增量,见 §3.8)/session_read(已读游标推进,见 §3.9)。会话编排状态(design/15,均为广播,data.sessionId区分会话):session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full(见 §3.2)。语音回复走voice_output(定向回请求连接,非广播)。 -
下行帧的角色门(v0.48,design/11 §7.1): 下行帧按类型分两类,由后端唯一一处收口判定(不在白名单里的帧默认只发
owner+admin,fail-closed):① 状态机信号(白名单,不含任何内容)→ 所有已认证连接(含viewer):session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full(§3.2)、session_read(§3.9);② 其余全部 → 仅owner+admin:流式内容message.*/thinking.*/tool_call.*/tool_execution.start/update/end/turn.start/turn.end(v0.50 新增的这几种同属本档——工具进度与回合边界对viewer也是内容相邻信息,fail-closed 默认即只发 owner+admin,未扩白名单)/stream_chunk/stream_end/thinking(§3.1)、持久化message_persisted(§3.3)/channel_persisted(§3.4)/message_edited/message_deleted(§3.8)、系统内部graph_update(§3.5)/subagent_event/subagent_result(§3.10,v0.51 新增的这帧同属本档) /approval_notice(§3.11,v0.52 新增,同属本档) /task_output/task_done/tool_used/capacity_awaken/mode_switch、私有状态mood(§3.7)/device_update/reminder。 -
上行帧与握手回放的角色门(v0.49,design/11 §7.2): 与上面那条下行门成对,收的是同一个缺口的两处残余——① 客户端→服务端(上行)JSON 帧:只有
owner/admin连接可以发;非owner/admin(即viewer)连接发来的任何上行 JSON 帧都默认被拒,回复是定向(只回发送者)的{type:"error", data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容。被拒帧:chat/interrupt/subagent_ask/task_output_query/voice_input/mode_switch——未登记的新上行帧同样默认拒,要放行必须先在 design/11 §7.2 登记白名单(实现上那把「默认拒」也必须同步改为登记表,不能直接删)。② 握手history回放:仅owner+admin连接收到;viewer连接建立后不收回放。协议级帧不受影响:WS ping/pong 与正常 close 照常(否则连接会掉),被拒的上行帧也不会关掉连接。这是「谁被主动推送」的门,不是数据可见性:
viewer的 12 条只读监控端点(viewerAuth,v0.40)照旧(含GET /personas的角色级mood);viewer仍可连/ws/chat(连接鉴权不变),只是收不到第 ② 类帧。定向回请求连接的帧(voice_output等)不受影响。上一条的门只管「推给谁」;「谁能发、谁能拿回放」见上面 v0.49 那条——viewer连上后发不出任何指令、也收不到history回放。history回放事件(design/16,新增):WS 握手升级成功后、该连接下一帧即收到{type:"history", sessionId:"web", data:{messages:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,personaId?,createdAt}]}}——回放主会话最新 50 条(thinking已剥离;默认主会话,次级会话由客户端按激活会话走GET /sessions/{id}/messages拉取)。客户端据此在重连/新开连接后补全上下文。消息体含senderName?(第三方昵称,design/16 §4.6,主人私聊为空)与personaId?(v0.42,说话时的角色,design/22 §7-5,空 = 默认角色)。角色门(v0.49,design/11 §7.2 A):这条回放只发owner+admin连接;viewer连接建立后不会收到history帧(它若需要只读数据,走 §1 的只读 REST 端点)。
3.1 结构化流事件(message.* / thinking.* / tool_call.* / tool_execution.* / turn.start/end)
- 事件壳:
{type, sessionId?, turnId?, seq?, data},data为类型化结构(非any)。seq为 hub 级单调递增序号,供客户端将一次突发的流式事件排序关联。sessionId为事件所属会话、turnId为该轮助手回合 id(均 v0.36 起带上):sessionId由 loop 在回合边界注入(模型适配器在 loop 之下、不感知会话,恒留零值;loop 外无会话上下文的事件该字段为空),turnId由网关每个回合新生成并挂进运行上下文——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的turnId一致。客户端据此把message.delta/thinking.delta/tool_call.*/tool_execution.*过滤到sessionId === activeSessionId的会话,忽略非激活会话的帧(多会话并发流式不再串话)。 streamId(v0.50,模型流帧data内可选字段):message.*/thinking.*/tool_call.*帧的data带streamId(如{"delta":"…","index":0,"streamId":"stream-a1b2…"})——同一次模型流的多帧共享同一 id,loop 在每次模型流(一次 ChatStream 调用)开始时生成;多步工具轮的第二次模型调用取新 id。turnId标记「这轮回答」(整轮恒定),streamId标记「这轮里的哪一条流式消息」;一个回合含工具调用时会有多个streamId。tool_execution.*与turn.*帧不属于任何模型流,不带streamId。message.start({index, streamId?}) /message.delta({delta, index, streamId?}) /message.end({index, streamId?}):正文段。delta为正文 token 增量;客户端对message.delta累加到当前消息正文;message.start/end划定正文段边界,不结束整轮(结束由stream_end负责)。thinking.start({index, streamId?}) /thinking.delta({delta, index, streamId?}) /thinking.end({index, streamId?}):思维链段(前端折叠块)。thinking.delta累加增量。tool_call.start({id, index, streamId?}) /tool_call.delta({id, index, delta, streamId?}) /tool_call.end({id, index, streamId?}):工具调用中间态;tool_call.delta携带参数增量(如 Anthropicinput_json_delta)。tool_execution.start({tool, phase:"running", id?}):工具进入执行(v0.50)。tool为工具名;phase为细粒度执行阶段(枚举与 §3.5 流程图节点 status 同一套running/done/error);id为 provider 工具调用 id(可关联同轮的tool_call.*帧,provider 未给 id 时省略)。tool_execution.update({tool, phase:"running", delta?, id?}):工具运行中的进度/局部输出,一次执行可多帧(v0.50)。现状:wire 映射已实现并验证,但工具分发器尚无中途进度上报源,loop 目前不产出该帧(start/end 有真实生产者)——出现生产者后无需再改契约。tool_execution.end({tool, phase:"done"|"error", id?}):工具执行完成或失败(v0.50);phase:"error"即失败(design/19 流程图的 tool 节点据此翻error态;错误详情不进该帧,走流程图节点日志的脱敏摘要)。turn.start/turn.end(无data载荷):回合边界帧(v0.50),loop 在 Run 的起止发出、经 ws_bridge 下行;帧壳的sessionId/turnId/seq即边界信号,客户端用它括起「一轮回答」的完整区间(含其全部模型流与工具执行)。turn.end覆盖回合所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态信息(stopReason/steps)仍由既有stream_end携带,两帧分工不重叠。内部自主流程(backthink / 子代理)被静音,不发出这两帧。- 向后兼容(v0.50 对
tool_execution的替换说明):v0.49 及之前的单帧tool_execution.update {id,index,delta}(delta形如"start:工具名"/"end:error:工具名"的拼串)不再由 loop 产生——按旧拼串解析的客户端必须迁移到三事件(tool_call.*帧形状不变)。turn.start/end与各帧data里的streamId为纯新增键,老客户端按未知帧/未知键忽略即可。现有 Web 前端未消费这些新帧,行为不变。 - 向后兼容:未映射/无法确定的事件仍走
stream_chunk+stream_end(非流式回退路径)。 - 上行(客户端→服务端):
mode_switch/chat(跑 loop 并广播流式) /voice_input/subagent_ask/task_output_query/interrupt。 interrupt(上行,客户端→服务端):{sessionId}→ 会话编排器中断(design/15 §3)——取消该会话当前在跑的任务(context cancel)、清空其待处理队列、回session_interrupted;用户说「停下/算了/换个话题」时发。命中空闲会话为 no-op。voice_input(上行,客户端→服务端):_{audio?, format?, text?, sessionId?, deviceId?}。audio为 base64(或 data: URI)音频,format为其编码(wav/mp3/ogg);text为已转写文本时跳过 STT;deviceId为客户端具体端标识(design/16 §4.5,如voiceassist:myapp:v1,可选),随该语音轮次落库、聊天气泡可标注来源。服务端走 STT→agent 聊天→TTS(design/06 §1),回复经voice_output定向回该连接。真 STT/TTS 已接通阿里百炼(DASHSCOPE_API_KEY必填:STT=qwen-audio-3.0-asr-flash、TTS=cosyvoice-v3-flash+longxiaochun_v3);无凭据时后端走降级链(cloud → edge-tts/espeak(未实现)→silent空音频)。无注入语音服务时回error帧(voice unavailable)。voice_output(下行,服务端→客户端):{audio, format, text, reply, provider?}。audio为 base64 合成音频,format为其编码,text为识别文本,reply为助手回复文本;当合成音频为空(silent 降级)时带provider(如"silent",非空音频省略)——客户端据此提示「没生成声音」,但该帧仍照常发出、不算业务错误。- 向后兼容:未映射/无法确定的事件仍走
stream_chunk+stream_end(非流式回退路径)。
3.2 会话编排状态事件(design/15)
角色门(v0.48,design/11 §7.1):本节的 6 个状态信号都在白名单内——它们只报「会话忙/闲/排队」,不含任何内容,所以所有已认证连接(含
viewer)都收得到;真正的正文/推理内容走 §3.1 的流帧,那些只发owner+admin。
- 触发:同一会话内,
chat到达时若「她忙」(正在跑 loop/工具循环/子代理/长回复)→ 新消息不阻塞、入该会话 pending;这一层的状态经以下事件推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。 session_busy:会话进入忙碌态({sessionId, mode},mode∈idle|thinking|executing|subagent)。pending_ack:忙时新消息已入队({sessionId, mode, queueDepth})——「收到,稍后回」。pending_drain:忙完开始消费下一待处理消息({sessionId, mode, queueDepth})。session_idle:会话回到空闲、队列清空({sessionId, mode})。session_interrupted:用户interrupt中断,当前任务取消、pending 清空({sessionId, mode, dropped})。pending_full:单会话 pending 达上限(默认 5)拒绝入队({sessionId, mode, max})——防无限积压(design/15 §4)。- 上下文续接:忙完重开时新回合带上一轮上下文(
{mode, queueDepth}之外的续接由服务端完成),非清空重来(design/15 §2.2)。
3.3 消息持久化增量事件(message_persisted,design/16)
- 触发:每一条 user / assistant 消息落库后由编排层广播——Web 主会话路径走
persistUserMessages/persistAssistantReply,IM 渠道主人私聊(design/16 §4.4)由 channelbridge 把消息落主会话web(带platform标记)后复用同一message_persisted广播(同一 hub 的BroadcastPersisted),保证 Web 端实时镜像 IM 消息。 - 载荷:
{type:"message_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, mood?, image?, platform?, deviceId?, senderName?, personaId?, createdAt}}}——role∈user|assistant;id为后端生成的消息 id(客户端据此去重);platform为来源平台(web或渠道如telegram/weixin-ilink/qq-obv11);deviceId为具体端/客户端标识(design/16 §4.5,如web:chrome:a1b2c3/weixin-ilink:conf0:wx_uid);senderName为第三方发送者昵称(design/16 §4.6,群聊/陌生人消息带,主人私聊为空,omitempty);personaId(v0.42,omitempty)=该消息说话时的角色 id(design/22 §7-5;空 = 全局默认角色,即升级前的全部历史),客户端据此按角色渲染名字/头像;thinking已剥离(design/16 §6)。 - 注:群聊/陌生人(非主人)消息只在该 IM 适配器日志界面以消息流显示,不经
message_persisted进 Web 主会话(design/16 §4.6 §3-4);该日志流由GET /api/v1/admin/channels/{platform}/messages拉取,落库后另经channel_persisted(§3.4)实时推送。 - 客户端处理:仅当
data.message.sessionId === activeSessionId(激活会话)时处理;按idupsert——本端已预建的同 role 乐观气泡(本地uid无后端id)被归一(uid→ 后端id),异端无本地气泡则直接追加,两者最终收敛为同一条id相同的消息,「A 发消息、A 与 B 都无需刷新且各出现一次」。
3.4 适配器消息实时事件(channel_persisted,design/16)
- 触发:每一条群聊/陌生人(非主人 / 隔离 sess-xxx)消息落库后由 channelbridge
persistIsolated广播;该消息只写自己的隔离会话,绝不写主会话web,channel_persisted仅是事件通知,不是把内容塞进主会话/Web 上下文(design/16 §4.6 §3-4)。 - 载荷:
{type:"channel_persisted", sessionId, data:{message:{id, sessionId, role, content, mode?, platform?, deviceId?, adapterId?, senderName?, createdAt}}}——与historyMessagewire 一致;adapterId为产生该消息的适配器注册名(如kook:3bef...,adapter != '');sessionId为隔离会话 id(sess-xxx,非web);role∈user|assistant;senderName为第三方昵称。 - 事件类型分流(
BroadcastPersisted按sessionId分流):sessionId == "web"或其web:<uuid>前缀 →message_persisted(Web 聊天实时同步);其它(隔离sess-xxx)→channel_persisted(适配器日志专用)。 - 客户端处理:Web 聊天无
channel_persistedhandler,天然忽略、不进会话;IM 适配器日志页订阅该事件,按adapterId === 当前适配器(或总览adapterId != '')过滤后仅追加(按id去重、不整页重载),首次拉取仍由GET /api/v1/admin/channels/messages+ 轮询兜底。
3.5 实时全链路流程事件(graph_update,design/19)
角色门(v0.48,design/11 §7.1):
graph_update属「系统内部」帧 → 仅owner+admin(viewer收不到,与本节「仅主人 Web 会话发布」及 design/19 §1「flow 可视化仅 owner/admin」一致)。REST 侧可见性不变。
- 触发:每次会话轮次中,loop 的每个 trace span 开始/结束时由
loop.FlowPublisher经 busws.graph_update广播增量 FlowGraph。发布范围为三类会话(v0.50 口径,裁决 01.4.5):web(Web 主会话) /web:<uuid>(Web 子会话,含 IM 主人私聊镜像落主会话的形态) /sess-backthink(后台思考的固定内部会话,design/20 §2 的「思考链路图」由此取数)——其余隔离会话(IM 群聊/陌生人的sess-xxx)与子代理轮次不发布,防敏感信息泄向面板。管理员/主人 Web 端订阅后实时渲染「输入 → 思考(LLM) → 工具 → 输出」主干图。tool 节点的status由细粒度 phase 驱动(v0.50,design/04 §1.1 + design/19 §1):工具执行期间为running,执行结束按成败翻done/error——与 §3.1tool_execution.*帧的phase同一套枚举、同一处决策。 - 载荷:
{type:"graph_update", data:{id, nodes:[{id,label,kind?,status?,startedAt?,endedAt?,props?,log?}], edges:[{from,to}], sessionId, startedAt, endedAt}}。 - 子代理分支节点的
props.persona(v0.47,design/22 §8-2):subagent_spawn指定了目标角色派发时,该子代理在父会话图里的分支根节点props会多一个persona键(值 = 目标角色 id),标注它正在扮演的角色;不指定角色派发时该键不出现(与旧版逐字一致),客户端按未知键忽略即可。id为sessionId-round(每轮一图);sessionId为会话 id;startedAt/endedAt为 RFC3339。kind∈input|llm|tool|subagent|memory|guardrail|orchestration|output。status∈idle|running|done|error(running= span 在跑 /done= 成功 /error= 失败)。props仅含白名单摘要(如stop_reason/error_kind/result_summary),工具原始参数与密钥绝不前送;长文本截断到 ~80 字。- 节点
props的可选键(v0.52,裁决 11.4.2 / 11.4.3)——旁路节点(memory/guardrail/orchestration)的 span 现已真实产生,其props新增下列键。全部为纯增量、可选;值在发布前已脱敏(凭据形状 →[redacted]、超长截断);帧名、事件名、顶层字段集一字未动:- tool 节点:
args_keys: string[](该次调用的全部参数名,字典序,值一律不出现)、args_summary: {[key]: string}(仅放行表内、且非密钥类的键才给值——密钥类键由trace.IsSecretKey判定(凭据形状 /*_key/*token*等),连值都不给;单值 ≤80 rune、最多 24 条,表外键与超出条数的键连条目都没有,客户端按args_keys逐键列表、未放行的显示「值未展示」)。 - guardrail 节点:
action(guardrail.input|guardrail.output)、allow: bool、elapsed_ms;检查本身报错时另带error。 - memory 节点:
action(recall|write)、scope(owner|restricted)、hits/bytes/count/failed、elapsed_ms——只记元数据,不含任何记忆正文。 - orchestration 节点:
subagent(被派发的子代理 id)、persona(指定目标角色时才有)、elapsed_ms。
- tool 节点:
log为该节点累积的 span 事件[{time,type,props?}](设计 §2「节点实时日志」)。
- 客户端处理:
graph_update→setFlowGraph(parseGraph(data))(src/lib/wsParse.ts),管理面板「全链路流程」页渲染FlowGraphView,节点可点开看参数/结果/实时日志;无图时诚实显示「暂无流程」。
3.6 主动推送事件(proactive_message,design/12)
- 触发:后台思考(backthink)自主决定「此刻主动发一条」时,经
SendProactive投递给当前所有在线 owner/admin 连接(viewer / 未登录连接永不投递)。SendProactive返回实际送达的连接数——0 时调用方回退 IM 桥(im_bridge),把该消息经渠道主动推送发给你(KOOK/微信等已接)。 - 载荷:
{type:"proactive_message", data:{platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?}}(由adapter.UnifiedMessage脱敏投影:不含 RawData / 媒体字节 / token,绝不让凭据漏到前端)。 - 客户端处理:仅 owner/admin 收到;渲染成一条「她从某平台主动发来」的气泡(内容 + platform + sender + timestamp)。与
message_persisted的区别:message_persisted是你与她的对话回显;proactive_message是她未经你输入主动推的消息(如 IoT 完成事件、后台思考结论),两路不同、不要混用去重。 - 红线:owner-only;payload 已脱敏;backthink 每次主动推送只发一个决策平台(决策渠道→最近活跃→旋转回退),Web 端与 IM 桥为两条投递路,客户端不要据此断言「所有平台都发了」。
- 推送出口(D1,对客户端无新端点):在 WS/IM 投递之外,后端会把主动消息异步镜像到「推送出口」——主通道是主人自建统一推送服务(
PUSH_EXPORTER_URLwebhook,HTTP POST JSON{source,title,content,platform,timestamp},可带Authorization: Bearer <PUSH_EXPORTER_TOKEN>),回退是个推 Getui(字段预留、SDK 未接入)。该出口只影响出站投递,不改变 WS 事件契约;未配/不可达时只记日志(注明「个推回退待接入」),不影响上面的 WS/IM 投递。移动端接入自建推送服务即可收到主人主动消息。
3.7 情绪事件(mood,design/01 §2 情绪状态机 + 好感度)
角色门(v0.48,design/11 §7.1):
mood属「私有状态」帧 → 仅owner+admin,viewer不再收到它;但角色当前情绪仍可读——viewer照旧调GET /personas拿到该角色的mood字段(REST 只读档不变,见 §1)。
- 触发:每轮对话结束(loop 回合边界,每轮一次、非每步)把该轮用户消息的情绪信号应用到该角色自己的情绪状态机(按 persona 隔离),且状态真的变化时才广播一次
mood;状态不变(含「一直 calm」)不发,避免每轮噪声。 - 载荷:
{type:"mood", data:{mood, personaId, affection}}——mood∈calm|happy|excited|sad|angry(情绪状态机 state id,与消息体mood同源同值);personaId为该角色 id(情绪/好感度跟随角色,不跟随行为模式);affection为该角色当前好感度(受角色配置affection.min/max约束)。角色级数值,不带sessionId。 - 与消息体
mood的关系:assistant 消息体的mood是该轮结束后的当前情绪(每轮都写,见 §3.3),而mood事件只在变化时发——前者答「这条回复时她什么心情」,后者答「她的心情刚刚变了」。 - 群聊/陌生人(
MemWritable=false):情绪只读不写——第三方消息不改变角色情绪与好感度、不发mood事件、该轮 assistant 消息mood为空(与记忆只读同一隔离边界,design/02 §3 / design/11 §2)。 - 状态转移与衰减:按角色配置的转移表(缺省内置表)与
emotion.decayRate——有情绪信号的轮次沿合法转移走一步指向对应状态;无信号轮次衰减一步趋向calm;decayRate ≤ 0关闭衰减。好感度按角色affection.delta随情绪轮次增减(warm+delta/ cold−delta,clamped 到[min,max])。 - 客户端处理:
mood→setMood(data.mood)(src/lib/ws.ts),顶栏/侧栏显示「心情 · 」;消息体内mood由聊天气泡显示(chat.tsx)。 - 状态持久性:情绪与好感度为进程内、按角色状态,重启回到角色配置的
initialState(design/01 未要求落库;如需跨重启保留,待主人确认后加表)。
3.8 消息编辑 / 删除实时事件(message_edited / message_deleted,D3 实时同步)
- 触发:
PATCH /api/v1/messages/{id}(改 content)或DELETE /api/v1/messages/{id}成功写库后由网关广播(与message_persisted同一条 hub 广播路径,全连接广播 + 客户端按会话过滤——hub 无按会话订阅;消息体的唯一真源始终在后端,这两个事件只驱动渲染同步,不携带新真源)。 message_edited:{type:"message_edited", sessionId, data:{id, content, mode?, mood?, createdAt?, ...}}——data为编辑后的完整消息 DTO(与 §3.3 的message.message同形,至少含id/content/sessionId/createdAt,另带role/mode?/mood?/image?/platform?/deviceId?);thinking已剥离(与message_persisted一致)。只改content,role/时间戳/平台等保持原值。message_deleted:{type:"message_deleted", sessionId, data:{id}}——只带被删除消息的id(sessionId为该消息所属会话,由后端在删除前读取后填入;客户端按它过滤,不据此删除别的会话的行)。- 客户端处理:与
message_persisted同一过滤规则——仅当sessionId === activeSessionId时处理(message_deleted的sessionId缺失时按id兜底:消息id全局唯一,只会命中它自己那条)。message_edited→ 按id原位更新该条的content(不新增行:找不到该id即忽略,避免把别的会话的消息注入当前列表);message_deleted→ 按id从列表移除。两个操作都不触发整页重拉——这正是它相对「重拉绕行」的收益。 - 失败不广播:
404(消息不存在)/400(内容非法)/501(未注入 HistoryStore)不产生任何事件,客户端不会因一次失败请求而丢掉本地消息。
3.9 已读游标事件(session_read,D4)
- 触发:
POST /api/v1/sessions/{id}/read成功推进游标后由网关广播(与message_persisted同一条 hub 广播路径:全连接广播 + 客户端按sessionId过滤,hub 无按会话订阅)。用途是同账号多端清红点:一端读了,其它端不用重拉列表就把该会话的未读清零。 - 载荷:
{type:"session_read", sessionId, data:{readAt}}——readAt为该会话推进后的已读游标(毫秒 Unix 时间戳,与该会话消息的createdAt同单位)。 - 客户端处理:仅当
sessionId === activeSessionId(或该会话在自己维护的会话列表里)时处理 → 把该会话unreadCount置 0(更精确的做法是按readAt重算本地未读;本地没有更完整的数据时直接清零也不会错,因为readAt只前进)。它只驱动渲染:唯一真源仍是后端chat_sessions.read_at,漏收该帧的端在下一次GET /api/v1/sessions自然收敛(不需要补发、不需要重连)。 - 失败不广播:
400(before非法)/404(会话不存在)/501(未注入 HistoryStore)不产生事件,其它端不会误清红点。
3.10 后台子代理结果事件(subagent_result,design/05 §9)
角色门(v0.48 下行门的默认档,design/11 §7.1):
subagent_result属内容帧(含子代理的结论正文)→ 仅owner+admin,viewer收不到;它不在那 7 个状态机信号白名单里(要进白名单须先在 design/11 §7.1 登记「不含内容」的理由)。
- 触发:
subagent_spawn派发的批次转后台后(前台只等 2 秒,design/04 §8.5)批次结算时,编排层把合并结果发布到 bus 主题subagent.result,经ws_bridge中继成这一帧。在 2 秒窗口内跑完的批次不发此帧——它的结果已作为工具结果回给了模型(与本节同一份投影)。 - 载荷:
{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}- 帧壳
sessionId= 派生该批次的父会话(data.sessionId同值)——客户端据此把结果归到那个会话的消息流,多会话并发时不串话。派发回合没有会话归属时(如循环外派发)两处都省略(该帧即成无归属帧)。 data.status恒为"done"(帧只在批次结算后发出,无论批次里有没有失败/中止的子代理——逐代理结论看agents[].status);summary是「子代理汇总: 共 N 个, M 成功…」的一行汇总。agents[]与派发工具返回给模型的结果同形同值:id(同时也是subagent_event帧与流程图泳道节点的 id)、goal、role/mode(绑定的角色/行为模式,未指定时省略)、status∈done|failed|aborted、summary(该子代理的执行总结)、text(结论正文,截断到 4000 字)、steps(循环步数)。
- 帧壳
- 与
subagent_event的分工:subagent_event是逐代理进度(派发/开始/完成/失败/中止,{id,name,status,role,mode},不含结论);subagent_result是整批的最终结果(含每个子代理的总结与结论文本),一个批次只发一次。 - 客户端处理(现状):后端已按上述形状推送;现有 Web 前端尚未消费该帧(无 handler,按未知帧忽略),因此这一版的可见效果是「帧到达
owner/admin连接」,而不是「聊天气泡里出现一条后台结果」。要渲染成「后台任务完成」提示,按sessionId过滤后追加即可(读data.summary与data.agents[].summary/text);不要把它写进本地消息列表当持久消息——该结果不落库,后端转录里没有对应行,重连回放不会补发。 - 边界(诚实说明,v0.51):本帧只做「会话作用域的实时推送」——不在父会话的持久化转录中新增消息,也未经 design/15 的会话消息编排器排队/续接;所以 design/05 §9 的「结果回填主会话」目前停在实时可见这一层(此前该结果在
ws_bridge处被静默丢弃,前台只剩进度帧)。
3.11 主代理代审小字事件(approval_notice,design/11 §4)
角色门:内容帧(含角色名/动作/文案)→ 仅
owner+admin;未进 7 个状态机信号白名单(fail-closed 默认)。
- 触发:loop 的工具准入门判定某次调用属敏感操作(design/11 §4 清单,当前实现的三个操作类别:执行命令 / 修改文件 / 网络写方法——
GET/HEAD/OPTIONS与读文件、检索属非敏感)并经主代理代审后广播。非敏感调用不产生该帧。 - 载荷:
{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}}sessionId= 该次 Run 所属会话(子代理的敏感调用归属到派生它的父会话,与 §3.10 同口径)。explain= 固定文案「<actor> 想要干什么,我审查后批准了」/「<actor> 想要干什么,我审查后拒绝了」(由后端产出,客户端原样渲染,不要自拼)。detail= 动作摘要(工具 + 关键参数,目标部分截断到 80 rune);allowed=false表示该敏感调用被拦截未执行(无代审者/代审报错也走 fail-closed 拦截,小字同样是「拒绝了」)。op∈执行命令|修改文件|网络访问;actor= 想动手的角色名;at= RFC3339Nano。
- 审计:每次代审在
audit_logs落一行:who=角色名、what=approval.<工具名>、result=granted|denied;查询走既有GET /api/v1/admin/observability/audit与/admin/notifications。 - 客户端处理:与流式帧同一会话过滤规则(
sessionId === activeSessionId),把explain作为聊天流里的一行小字渲染、detail作副文本(现 Web 前端已按此实现,见frontend/web/src/components/status.tsx的ApprovalNoticeHint)。 - 约束:不新增端点、不改既有帧;老客户端按未知帧忽略即可。
4. 客户端接入指引(Web / 移动 / 原生 / IM bridge 通用)
4.1 一次性接入流程(三步走)
- 拿令牌:
POST /api/v1/auth/login{username,password}→{data:{token,expiresAt,role}}(JWT,默认 24h,role∈owner|admin|viewer)。 - 声明版本(可选/advisory):可带
X-API-Version: 1+Authorization: Bearer <token>;WS 握手用ws://<host>:<port>/ws/chat?token=<token>(或Authorization: Bearer)。X-API-Version是advisory惯例头——服务端当前不校验、不据此协商版本,带不带行为不变;viewer只能读会话/人格元数据;读写会话消息、管理写操作需owner/admin(WS 上viewer也只收 7 个状态机信号帧:既收不到history回放,也发不出任何上行指令,见 §3 顶部两把角色门,v0.48 + v0.49)。 - 带端标识(可选但推荐):每条消息/请求可带
deviceId(见 §4.3),让她知道你从哪个端来。
4.2 会话与消息用法(聊天)
- 列出会话:
GET /api/v1/sessions→[{id,type:"main"|"sub",title?,createdAt,pinned,unreadCount,lastMessage?,personaId?}]。排序:置顶会话(pinned=true)最前,其次主会话id="web"(她与你的主线,最大化继承上下文),再按创建时间升序;次级web:<uuid>由POST /api/v1/sessions新建(临时小事/专业问题,独立上下文回溯)。personaId(v0.42)=该会话绑定的角色;空/缺省 = 继承全局默认角色,客户端渲染激活会话的名字/头像用它(后端唯一真源是chat_sessions.persona_id,localStorage 之类只作缓存)。 - 新建时选角(v0.42,v0.46 收口):
POST /api/v1/sessionsbody{personaId?}在新建时就绑角色(省略/空 = 跟随全局默认角色)。会话角色在创建时确定、之后不可变(design/22 §8-1):没有"会话内换角色"这回事——PATCH /api/v1/sessions/{id}一旦收到personaId键就回400(见 §1 行 8),要换角色请新开会话。记忆按角色隔离:不同角色的会话各记各的,同一角色的多个会话互相可见(design/22 §3 口径 1);群聊/陌生人会话仍只读、且强制默认角色。 - 角色解析优先级(v0.44,design/22 §2 裁决规则 + §7-3/§7-4):一个回合到底以哪个角色答话,自上而下取第一个命中的:
- 请求级覆盖 —— 仍未实现(design/22 §2 裁决规则 1,留待后续批次);
- 会话绑定
chat_sessions.persona_id(POST /sessions {personaId}在创建时写入;之后不可变,design/22 §8-1)—— 非空即赢; - 渠道绑定 —— 该 IM 账号的
personaId(PUT /admin/channels/{platform}/config,v0.44)。仅作用于该账号的主人私聊,且仅当该会话(=镜像目标主会话web)自身未绑角色时; - 全局默认角色
owner.yaml:defaultPersona—— 兜底(''始终 = 当前默认角色)。 群聊 / 陌生人隔离会话强制默认角色(§7-4,更保守):这两类会话跳过 2/3 两条渠道与主会话绑定,一律用默认角色,避免把第三方会话挪进某个角色的记忆域;记忆仍是只读。Web 主会话与 IM 主人私聊共享同一角色(§7-3):主会话绑 Y 时,同一主会话里 IM 镜像进来的主人私聊回合也以 Y 答话。
- 模型上下文里的「谁说的」(v0.44,design/22 §7-5):一回合的上下文由该会话最近 20 条持久化消息(
chat_messages,含persona_id)+ 本回合新消息组成(此前只带新消息本身,模型看不到会话前文);每条 assistant 历史消息按其说话角色标注,模型收到形如[发言归属] 以下发言来自「昔涟」(xilian),不是你说的。的独立 system 标注,以及一条声明当前角色的[会话角色] …段。正文永不被改写:标注只走 system 消息,用户正文与 assistant 正文一字不改(design/11 §10:不给用户正文冒充系统指令的机会)。对客户端透明(无新字段、无新端点)。 - 改名 / 置顶:
PATCH /api/v1/sessions/{id}body{title?}(≤80 rune)和/或{pinned?:bool}(至少一个字段)→{data:{id,title?,pinned?}}(只回显变更字段)。body 里出现personaId键一律400(会话角色不可变,v0.46 / design/22 §8-1)。 - 未读 / 已读游标(D4):列表项的
unreadCount直接用于会话列表红点(恒返回整数,0 = 无未读)。客户端进入会话、或滚到底看到最新消息后调用POST /api/v1/sessions/{id}/read:body{"before":<毫秒>}(如「本次已渲染的最新一条的createdAt」),或省略 body = 标记到现在。后端只前进地推进该会话唯一游标(单 owner 账号:读一处 = 所有端已读),并广播session_read(§3.9)——同账号其它端免刷新清红点。列表 → 读 → 再看列表即可自洽(漏收广播的端下次拉列表自然收敛)。 - 拉取激活会话历史:
GET /api/v1/sessions/{id}/messages?limit=50&before=<ms>&platform=&q=。- 热分页:
limit默认 50(上限 200),before= 当前最旧一条的createdAt(毫秒),向上翻页。platform可选(IM 适配器传本平台只拉本渠道;Web/通用客户端不传 = 全量共享主会话)。 - 搜索:
q非空 = 会话内内容搜索(倒序,仍支持before/limit分页);空q= 普通分页;q与after互斥。不跨会话。 - 返回按时间倒序,需
reverse()成旧→新用于渲染。
- 热分页:
- 编辑 / 删除单条消息:
PATCH /api/v1/messages/{id}{content}(≤4000 rune)→{data:<消息DTO>}(裸 DTO,无message包裹);DELETE /api/v1/messages/{id}→{data:{deleted:<id>}}。不存在回404。重发用「编辑后内容再POST /chat」即可(无独立重发端点)。两者成功后都会广播(message_edited/message_deleted,§3.8),同账号其它端免刷新同步。 - 实时增量:连接 WS 后,消息落库会广播
message_persisted(§3.3)——用其中的id去重,sessionId匹配激活会话才渲染;新开/重连连接首帧即history回放(主会话最近 50 条)。消息被编辑/删除则分别广播message_edited/message_deleted(§3.8)——同一sessionId过滤规则,前者按id原位改content、后者按id移除,都不需要重拉整页。 - 发消息:
POST /api/v1/chat{text, deviceId?, image?, stream:true}。stream:true走 WS 流式;非流式返回完整reply(会话正忙时202 queued、排队满409 chat_busy,见 §3.2)。
4.3 deviceId 与 platform 规范(design/16 §4.4-4.5)
platform(来源平台):web/ 渠道如telegram、weixin-ilink、qq-obv11。Web 端恒web;IM 渠道消息带各自平台。同一平台多个端 → 相同 platform、不同 deviceId。deviceId(具体端,客户端自己生成):Web 每个浏览器localStorage持久化web:<browser>:<随机短id>(如web:chrome:a1b2c3);IM 按<im平台名>:<本地配置唯一id>:<用户平台唯一uid>;某 APP 可自定可读前缀。发送时带上,服务端落库消息,聊天气泡可标注来源。senderName(第三方昵称):只有群聊/陌生人(非主人)消息带,主人私聊恒空(omitempty)。IM 适配器_log:该平台非主人私聊在GET /api/v1/admin/channels/{platform}/messages以消息流显示。
4.4 常见错误码 / 边界
401未认证/令牌失效 → 重新登录。403权限不足(viewer 读管理写/主会话删除)。404资源不存在(消息/会话 id 未找到)或端点未实现;501 not_implemented后端明确未提供(如知识库删除、新增用户)。400请求体/参数非法(limit/before越界、空 content)。409 chat_busy排队满。- 红线:身份判定永远来自后端 channel 层 actor 元数据 + 白名单,客户端不要用提示词/文本判身份;
thinking(思维链)在公开消息/回放中已剥离,客户端不应依赖它。
4.5 离线 / 缓存建议(原生端)
- PWA / 原生端缓存
sessions/personas列表 + 最近一屏消息;重连先本地渲染再拉增量。 - 消息真源在后端,本地只做渲染缓存;用
message_persisted.id去重,不要用本地自增 id。 - 语音:上行
voice_input(base64 音频或已转写 text),下行voice_output(合成音频 base64 + text)。
5. 变更记录
-
v0.53(阶段 5 落地:模型目录端点 + per-persona 路由 / 本地三级 / capacity L3 / 视觉·OCR 独立模型 /image-crop工具 / 按 provider 形状下发推理开关,依据 design/04 §3/§3.1/§3.2(裁决 03.4.1 / 03.4.2 / 03.4.3 / 08.4.1)、design/06 §0/§3(裁决 14.4.1 / 14.4.5)、design/14 §2.2/§3/§4(裁决 14.4.2 / 14.4.3 / 14.4.4),2026-10-04 落实):① 新增管理只读端点GET /api/v1/admin/llm/catalog(owner|admin)——面板「添加提供商 / 添加模型」的候选目录(baseURL / 模型 id / 能力位 / 输出上限),数据源https://models.dev/api.json;?refresh=1强制在线刷新,否则 24h TTL 内的本地快照直接复用。200 {data:{source:"cache"|"models.dev", fetchedAt?, stale?, warning?, providers:[{id,name,api?,doc?,env?,npm?,models:[{id,name,attachment,reasoning,toolCall,contextLimit?,outputLimit?}]}]}};既无快照又抓不到 →503 {error:{code:"catalog_unavailable"}};有快照但刷新失败 → 仍200+stale:true+warning(离线兜底,不编造候选);带Accept-Encoding: gzip时响应带Content-Encoding: gzip(信封形状不变)。副作用只有一处:落盘快照<DATA_DIR>/llm_catalog.json(无凭据、data/已 gitignore、TTL 24h),不改models.json;抓取只发生在本端点内,不进运行时热路径。审计动作llm.catalog(ok/stale/error)。详见 §2「模型配置契约(v0.53)」。②models.json组级新增三键(可热更新,GET/POST /api/v1/admin/llm已同步读写;未声明时该键不出现):reasoning: {kind}声明该组的推理开关形状;personaModels: {personaId: modelId}与personaProviders: {personaId: "openai"|"anthropic"}是角色级模型/协议路由(「角色」= 该回合答话的 persona,不是消息作者 role)。校验在Group.Validate:非法kind(只认auto/deepseek/openai/vllm/dashscope)、anthropic 组上声明 openai 系形状、personaProviders的非法协议名、空 persona id/空值 → 写端点回400 bad_request且不落盘;persona 表键值会 trim,trim 后撞键直接报错(不静默丢条目)。请求语义:三键缺省 = 保留已存值(reasoning整块给出即整块替换,persona 表给出即整表替换——否则面板无法删除一条已存路由);响应语义:每组的reasoning恒下发(kind:""= 未声明形状,不是「后端没报告」),personaModels/personaProviders为空则省略;顶层新增reasoningKinds与personaProviderOptions两个取值域数组(由后端常量直接生成,面板无需硬编码枚举)。回落:未绑定/未知 persona → 该组默认模型;personaProviders声明的协议在models.json里没有对应组 → 记 WARN 并跳过(该角色仍走默认链,不半建一条只会失败的链)。③ 语义修正(重要,此前文档写反了):multimodal= 主模型自身能否收图(不是「本部署有没有视觉能力」);LLM_VISION_MODEL/LLM_OCR_MODEL只配视觉/OCR 工具用的模型(OCR 空则复用视觉模型,两者都空则复用主模型),与multimodal互不代表(裁决 14.4.1)。④.env新增 9 键:LLM_REASONING_KIND(默认空 =auto;只在无有效models.json(走 env 合成组)时生效,有models.json时以该组reasoning.kind为准)、LLM_OCR_MODEL、LLM_LOCAL_ENABLED(默认false)/LLM_LOCAL_PROVIDER(默认openai)/LLM_LOCAL_BASE_URL/LLM_LOCAL_API_KEY/LLM_LOCAL_MODEL(本地第三级:Enabled+BaseURL+Model三者齐备才成为链尾一步,只开开关则 WARN 并跳过)、CAPACITY_L3_ENABLED(默认false)/CAPACITY_L3_CACHE_TTL(默认5m)。⑤ 内置工具新增image-crop:按区域裁切图片(image+x/y/width/height,0 基像素、区域须完整落在图内)→ PNG data URI + 裁切尺寸 + 源图尺寸;供大图被上游压缩后分区精读(design/06 §3)。上限:源图 ≤50MiB、区域 ≤16MP、载荷 ≤8MiB(超出报错,不截断);源可为本地路径(file 工具同一沙箱)/ http(s) URL(同 SSRF 守卫)/ base64 data URI。策略默认 owner-only(tool_policies无行时 fail-closed)。⑥ 推理开关按 provider 形状下发(替换 v0.41 的「只 WARN 不发」)——形状由reasoning.kind声明(未声明 =auto:按模型名/baseURL 推断厂商,形状有歧义就不下发),字段名映射表:形状 kind线上字段 说明 deepseek顶层 thinking:{type:"enabled"|"disabled"}+reasoning_effort思考默认开,「不传」≠「关」:要关必须显式 disabled或effort:"none";none胜过enable:true(关思考优先);budget_tokens在该协议上无对应字段,记 WARN 丢弃openai仅 reasoning_effort该系没有布尔思考开关; thinking.enable记 WARN 丢弃(官方关推理路径 =effort:"none")vllm顶层 chat_template_kwargs.enable_thinking只有布尔开关,无 reasoning_effort;effort:"none"一律翻译成显式关思考(不得降成任何强度档)dashscope顶层 enable_thinking同上;只在显式声明该形状时下发,绝不从模型名/BaseURL 推断 auto/未声明无 deepseek/openai可推断时按其形状下发;形状有歧义(如 Qwen 可能由 vLLM 或 DashScope 承载)或厂商未知 → 一个字段都不发,逐项 WARN 并给出配置指引reasoning_effort校验:按厂商白名单(OpenAI 系none/minimal/low/medium/high/xhigh/max;DeepSeeknone/low/high/max)——非法值丢弃并记 WARN,不再「降级到最近一档」(降档会反转调用方意图);DeepSeek 的别名minimal→low/medium→high/xhigh→high/ultra→max归一化后发送并记一条 WARN。Anthropic 组继续走自己的thinking:{type,budget_tokens}(reasoning.kind在其上被拒)。- 同批另含两条对客户端无端点/帧/字段变更的实现:persona
awakening档位真生效(configs/personas/*.yaml的awakening块归一化为quiet|awake|full并进 capacity 决策;非法档位/越界阈值载入期报错、块缺席 = 无覆盖继续走全局默认,design/14 §3)、工具越出本轮允许集的软判并回(越界调用仍然执行——能力≠边界;结果里带not_allowed提示回灌模型,并记审计tool.reject.not_allowed,design/14 §4)。capacity_awaken/approval_notice等既有帧形状一字未动。 ⑦ 向后兼容:新端点、新组键、新.env键、新工具都是纯增量;既有端点 / WS 帧 / 字段形状无破坏性变更(GET /admin/llm只多出reasoning/personaModels/personaProviders/reasoningKinds/personaProviderOptions这些新键,消费旧键的客户端不受影响)。未做(诚实边界):DashScope 形状的auto推断(需显式声明reasoning.kind=dashscope)、Qwen 的/think前导消息软开关(与 design 口径冲突,本批未实现)。⑧ 实现落点:目录internal/services/gateway/admin_llm_catalog{,_cache,_source}.go+ 路由routes.go;组键与读侧internal/config/runtimeconfig/models.go(ReasoningOptions/PersonaModels/PersonaProviders/Validate)、写读端点internal/services/gateway/admin_llm_provider.go+admin_write_llm_channels.go+admin.go/admin_query.go;形状下发internal/runtime/model/reasoning_shape.go(+reasoning.go的厂商白名单/别名、openai_encode.go的线上字段);per-persona 路由internal/runtime/model/{router,persona_stack,runtime}.go;本地第三级internal/runtime/model/factory.go(localChainStep)+internal/config/config.go;capacity L3internal/runtime/capacity/{capacity,classifier,l3}.go;awakeninginternal/persona/loader/awakening.go;软判internal/runtime/tool/allowset.go+internal/runtime/loop/tool_policy.go;工具plugins/tools/io/crop.go。对应提交:63e5b70(目录端点)、fa33c79(per-persona 路由 / 本地三级 / L3 / OCR 独立模型 / image-crop)、38b4527(工具软判)、71fefe5(persona awakening)、8890c30(按形状下发推理开关 + per-persona 提供商链)。
-
v0.52(阶段 4 三组落地:工具策略落盘 + 主代理代审小字 + 流程图节点参数摘要,依据 design/11 §3/§4/§8(裁决 1.3 / 10.4.2 / 13.4.3)与 design/19 §1/§1.1(裁决 11.4.2 / 11.4.3 / 13.4.1),2026-10-04 落实):① 工具策略落盘:allowInGroup/allowInStranger的载体改为 SQLite 表tool_policies(迁移0012_tool_policy,唯一索引idx_tool_policies_name承载 upsert 冲突目标),POST /api/v1/admin/plugins/policy从此重启保留——不再是「面板保存成功、一重启就复位」的假持久化(见 §2 行 5)。写入路径先落库、后改内存(registry.SetPolicy→ 落盘成功才改内存并记入快照):落盘失败则内存保持旧值并返回错误,不会出现「面板说成功、内存已改、库里没有」的分叉;启动时AttachPolicyStore立即 Load 全量策略套到已注册能力上,此后Register进来的能力(MCP 热装、插件刷新)同样套上落盘值;没有行的能力按默认(两布尔均 false = owner-only)处理(fail-closed)。策略存储不可用/加载失败时保持最严默认并记 WARN,不静默降级成内存态、也不假装读出了策略。对客户端无端点、无字段形状变更。② 新增下行帧approval_notice(§3.11,design/11 §4):loop 的工具准入门(ToolLoopHooks.CallAdmit)判定某次调用属敏感操作(执行命令 / 修改文件 / 网络写方法)→ 经主代理代审后广播{type:"approval_notice", sessionId, data:{actor, tool, op, detail, explain, allowed, at}};explain是后端产出的固定文案「 想要干什么,我审查后批准了」/「…我审查后拒绝了」(客户端原样渲染,不要自拼),detail为动作摘要(工具 + 关键参数,目标部分截断到 80 rune),allowed=false表示该敏感调用被拦截未执行(无代审者 / 代审报错同样 fail-closed 拦截,小字同为「拒绝了」)。非敏感调用不产生该帧;子代理的敏感调用归属到派生它的父会话(与 §3.10 同口径),小字因此落在主人正在看的那个会话里,而不是一个没人看的子代理 id 下。审计:每次代审在audit_logs落一行(批准与拒绝各一行)——who=角色名、what=approval.<工具名>、result=granted|denied,查询走既有GET /api/v1/admin/observability/audit与GET /api/v1/admin/notifications。角色门:内容帧 → 仅owner+admin(fail-closed 默认,未扩那 7 个状态机信号白名单)。前端已渲染(frontend/web/src/lib/ws/handlers/approval.ts+frontend/web/src/components/status.tsx的ApprovalNoticeHint):按同一会话过滤规则把explain作为聊天流里的一行小字、detail作副文本。向后兼容:纯新增帧,不改既有帧 / 端点 / 鉴权,老客户端按未知帧忽略即可。③graph_update节点props新增四类可选键(裁决 11.4.2 / 11.4.3,见 §3.5):旁路节点的 span 首次真进图——memory.recall/memory.write/guardrail.input/guardrail.output/orchestration.spawn现在都由真实生产者产生,kind因此会实际出现memory/guardrail/orchestration;tool 节点另带args_keys(该次调用的全部参数名)与args_summary(仅放行表内、且非密钥类的键才给值,密钥类键连值都不给,单值 ≤80 rune、最多 24 条);旁路节点只带元数据(action/scope/hits/bytes/count/failed/allow/subagent/elapsed_ms),不含记忆正文、不含提示词、不含密钥。值在发布前已脱敏(凭据形状 →[redacted]、长文本截断),前端对未放行的键显示「值未展示」。帧名、事件名、顶层字段集一字未动——以上只是节点props内的纯增量可选键。④ 本版未做 / 诚实边界:代审是「记录并批准」——「敏感与否」与「是否放行」仍由上层策略层判定,orchestration/audit.Approver只做校验、记录、叙述并返回结果模型,不构成新的权限边界(身份/权限判定始终来自 channel 层actor元数据 + 白名单 + 策略引擎);agent.RunStep(静音 / 非流式内部流程,如 backthink)的输出侧护栏仍同步内联(其裁决是StepResult的一部分、不落在主会话关键路径上),只有主会话流式关键路径的输出侧护栏改为后台异步(loop.checkOutputAsync,裁决 13.4.1:真并发、不阻塞回合,拦截仍写审计留痕;输入侧前置门依旧同步拦下)。⑤ 实现落点:策略落盘internal/capability/registry/policy_store.go+registry.go的SetPolicy、迁移internal/db/migrations/0012_tool_policy.{up,down}.sql;代审internal/capability/restrict/{sensitive,approve}.go+internal/runtime/orchestration/audit/approval.go+internal/services/gateway/admin_approval.go+ 组合根cmd/cyrene/wire_runtime.go的toolAdmissionHooks;流程图internal/runtime/loop/{spanlog,memory,stream}.go+internal/runtime/agent/step.go+internal/runtime/orchestration/scheduler_spans.go+internal/runtime/trace/{graph_props,graph_build,redact}.go。 -
v0.51(后台子代理结果登 WS 帧 + 回填父会话:新增下行帧subagent_result;依据 design/05 §9「子代理完成 → 结果回填主会话并提示用户」,裁决 02.4.3,2026-10-04 落实):① 问题:subagent_spawn的批次前台只等 2 秒(design/04 §8.5),转后台后结算时编排层已把合并结果发布到 bus 主题subagent.result,但 gateway 的中继不认这个 subject(既非ws.*,也不是已登记的 4 段广播形态cyrene.agent.*.event)⇒ 结果永远到不了前台,前端唯一可见的是逐代理进度帧subagent_event(「跑起来了/跑完了」,但拿不到结论)。② 新增帧subagent_result:{type:"subagent_result", sessionId, data:{sessionId, status:"done", summary, agents:[{id, goal, role?, mode?, status, summary?, text?, steps?}]}}——data与派发工具返回给模型的结果同形同值(同一处投影、同一处 4000 字截断,text即子代理的结论正文);帧壳sessionId= 派生该批次的父会话,客户端据此把结果归到那个会话的实时流(多会话并发不串话),而不是当成一张全局帧。详见 §3.10。③ 回填边界(诚实说明):本版把「结果可见」做到了会话作用域的实时帧——结果现在真的到达父会话的 WS 流(此前被中继静默丢弃)。未做:不落库(父会话的持久化转录里没有新增一条消息,重连/回放不会补发它),也未经 design/15 的会话消息编排器排队续接;现有 Web 前端也没有该帧的 handler(按未知帧忽略,故今天的可见效果是「帧到达 owner/admin 连接」而非「聊天气泡里出现结果」)。REST/历史接口逐条不变:GET /sessions/{id}/messages不会多出这条结果。④ 角色门(fail-closed):新帧属内容帧 → 只发owner+admin,viewer收不到;未扩那 7 个状态机信号白名单(design/11 §7.1,白名单要进须先在设计登记「不含内容」的理由)。⑤ 向后兼容:纯新增帧——老客户端按未知帧忽略即可;不改任何既有帧的类型/字段、不改端点、不改鉴权;owner/admin只是多收一帧。⑥ 实现落点:生产者internal/runtime/orchestration/spawntool.go(SubagentResultEvent载荷带上派发回合的父会话);中继internal/services/gateway/ws_bridge.go(bridgePatterns订阅该 subject +normalizeEvent专用成形:帧壳sessionId取自载荷父会话;载荷形状不符预期时退回通用映射,两路都只发owner+admin)。⑦ 验证:进程内两端各一条——编排侧断言后台批次发布的载荷类型/键集/父会话/逐代理结果;gateway 侧真 bus Publish → 真/ws/chat连接(真 JWT ×owner/admin/viewer)断言owner与admin各收到恰好 1 帧subagent_result(帧壳sessionId= 父会话,data的汇总与逐代理字段逐项对得上)+ 1 帧哨兵状态信号,而viewer只收到哨兵(收不到结果帧)。变异验证:① 把orchestration.SubagentResultSubject从bridgePatterns去掉 → 「owner 收到subagent_result」必须 FAIL;② 把subagent_result加进frameReachesAll→ 「viewer收不到」必须 FAIL(两条均已跑,还原后复绿)。⑧ 同版另含管理面(D 组,2026-10-04 落实,与上列同属 v0.51 一版):①X-API-Key真正接线(裁决 15.4.2)——Authorize之上叠加 key 分支,管理/用户侧已认证端点同时接受该头,签发出去的 key 真的能用;且 key 一律超管、取消 role 分级(裁决 15.4.3,主人原话「key 始终给超级管理员权限」):签发端点不再读role请求体、响应恒role:"owner"、任何有效 key 鉴权后即owner。fail-closed:请求带该头时只走 key 分支,未知/已吊销/空值(或 key 表未注入)一律401,不回落 Bearer;不带该头时 Bearer 路径一字不变;WS 握手仍只认 Bearer。破坏性边界:此前文档写的{"role":"admin|viewer"}请求体自本版起被忽略——若曾有客户端指望签一把只读 key,它拿到的其实是 owner key(这正是裁决要的效果:取消分级,不给「以为只读、实则超管」的错觉)。② 新增主人档案读写GET/PUT /api/v1/admin/owner-profile(裁决 10.4.5,owner-only——这份档案决定谁能进 owner 车道,故连读都不给admin;写带审计owner.profile.update,读不审计;全新安装GET回200+ 空白档案而非404):消灭前端假保存——面板的「保存」从此真的落盘configs/users/owner.yaml(0600 原子写)并热替换活档案,渠道层 actor 判定立即生效、无需重启;校验失败(id/displayName缺失或 accounts 项半空)不落盘并回400 bad_request,写盘失败回500 owner_profile_persist_failed,档案损坏/读不出回500 owner_profile_unreadable(不返回看似正常的空档案)。③ personaidentity.systemPrompt改必填(裁决 08.4.2):POST/PUT /admin/personas缺失或空白一律400 bad_request("systemPrompt is required (identity.systemPrompt must not be empty)"),不再派生默认说明串回落——载入期执行同一规则,写出的 YAML 必能被重新载入,不会出现「一条没有身份的人格被静默降级成拼凑串」。文档同步:§0 认证、§1 行 8、§2.0 权限矩阵(owner-only 档 35 → 37 条)与本「管理面板写操作契约」已按实现改写。 -
v0.50(事件批:流式帧的结构化补齐——tool_execution三事件 +streamId+turn.start/end+graph_update三范围口径;依据 design/04 §1.1(裁决 01.4.1 / 01.4.2 / 01.4.3 / 03.4.4)与裁决 01.4.5(design/20 §2),2026-10-04 落实):①tool_execution拆三事件(破坏性替换):旧单帧tool_execution.update {id,index,delta}(delta为"start:工具名"/"end:error:工具名"拼串)不再由 loop 产生,替换为tool_execution.start({tool, phase:"running", id?},进入执行)/tool_execution.update({tool, phase:"running", delta?, id?},运行中进度,可多帧)/tool_execution.end({tool, phase:"done"|"error", id?},完成或失败)——phase 与工具名是结构化字段,不再塞进delta;id复用 provider 工具调用 id(可关联tool_call.*帧,无 id 时省略该键)。迁移:按旧delta拼串解析的客户端必须改读phase/tool字段;tool_call.*帧形状不变。诚实边界:start/end 由 loop 在每次工具执行的首尾真实发出;update的 wire 映射已实现并有单测锁定,但工具分发器尚无中途进度上报源,loop 暂不产出 update 帧(未来接入进度上报无需再改契约)。②streamId(新增可选字段,位于模型流帧的data内):message.*/thinking.*/tool_call.*帧的data带streamId——loop 在每次模型流(一次 ChatStream 调用)开始时生成,同流多帧共享,多步工具轮的下一个模型调用取新 id;与帧壳turnId(整轮恒定)分工:turnId= 哪轮回答,streamId= 这轮里的哪条流式消息。tool_execution.*/turn.*帧不带。③turn.start/turn.end(新帧,无data载荷):loop 在 Run 起止发出、经 ws_bridge 下行(model.StreamEvent新 kind → hubbroadcastStream映射);帧壳sessionId/turnId/seq即边界信号;turn.end覆盖所有出口(最终答案 / 工具循环安全网终止 / 预算耗尽 / 步骤出错);回合终态仍由stream_end携带(分工不重叠);被静音的内部流程(backthink / 子代理)不发出。④graph_update三范围口径(01.4.5,随实现事实补记):发布范围为web/web:<uuid>/sess-backthink三类(此前文档只写「web 及其子会话」,漏了 backthink 思考链路图这一实现事实;实现侧cmd/cyrene/wire_runtime.go的 allow 谓词本就如此,本批零代码变更);并补记 tool 节点status由 phase 驱动(与 §3.1tool_execution.*的phase同枚举同决策:trace.FlowStatusForPhase消费 tool span 上的 phase,缺省回落 span 终态)。⑤ 角色门:本批新帧(tool_execution.start/end、turn.start/end)全部落 v0.48 下行门的「仅owner+admin」档(fail-closed 默认,白名单未扩);viewer行为不变。⑥ 向后兼容总结:新增帧与新增键(streamId、data.tool、data.phase)可被老客户端安全忽略;唯一破坏点是旧tool_execution.update单帧消失(见 ①);现有 Web 前端未消费tool_execution.*/turn.*/streamId,本批对它零影响。⑦ 验证:loop 包断言三事件顺序/phase/工具名/id/Delta 为空、同流共享 streamId 跨流相异、turn 边界成对且覆盖错误出口、静音 run 零事件、flow 图 tool 节点 running→done/error 与 phase 一致;gateway 包真 WS 帧端到端(真 loop + 真 dispatcher + bus → relay → broadcastStream):按 seq 排序断言完整帧序turn.start → message.delta → tool_execution.start → tool_execution.end → message.delta → turn.end(+stream_end)、streamId每流独立、tool_execution帧无delta/streamId键、turn 帧无载荷;帧门矩阵补 5 行(三事件 + turn 两帧 × 三角色)。变异验证:去掉 loop 的ev.StreamID = streamID透传 → 「streamId 每流独立/前缀」断言 FAIL(已跑,还原后复绿)。 -
v0.49(WS 握手回放 + 上行帧的角色门:把 design/11 §7.1 下行门落地时查出的同一类缺口的另两处一并收口——对viewer继续收紧,owner/admin行为不变,无端点、无字段形状变更;依据 design/11 §7.2,2026-10-03 落实):① 问题:本质与 §7.1 同一个——viewer是只读监控档,却能通过 WS 拿到内容、甚至发起写入。(a) 握手history回放对任意角色照发(回放主会话最近 50 条消息正文,thinking已剥离但正文本身即主人的对话),viewer一连上就把对话拿走了;(b)route的上行分发完全没有角色检查,viewer发chat能真的驱动模型并把消息落库(只读档拿到写权限),subagent_ask/task_output_query的应答是定向帧(enqueueJSON)因此绕过 §7.1 的下行门,把子代理上下文 / 任务输出直接回给viewer。② 规则(fail-closed,默认拒):A 握手history回放仅owner+admin(ownerAdminClaims,与主动推送同一个判据);B 非owner/admin连接发来的任何上行 JSON 帧一律拒绝——定向(只回发送者)回{type:"error",data:{code:"forbidden"}},不广播、不驱动模型、不落库、不返回任何内容;被拒帧:chat/interrupt/subagent_ask/task_output_query/voice_input/mode_switch(默认拒,未登记的一律拒)。协议级帧不受影响:WS ping/pong 与正常 close 照常(否则连接会掉),被拒帧也不关连接。③ 实现:A 收口在唯一发出回放的hub.replayHistory顶部(不放调用点,没有第二个调用者能绕过);B 收口在wsConn.route的第一条语句,且写成「默认拒」而不是被拒类型黑名单——新加的上行帧类型自动被拒,漏一处也不会放行。④ 合口径(与 §7.1 一起读):viewer= 可以连接、可以读 12 条只读监控 REST(v0.40)、可以收 7 个状态机信号帧(v0.48);除此以外 WS 上拿不到任何内容,也发不出任何指令。⑤ 未来放宽的要求:将来若真需要viewer上行(如手机端以 viewer 凭据做只读订阅之外的交互),必须先在 design/11 §7.2 给它开口并登记白名单;代码里那把「默认拒」必须改造成登记表,不得直接删掉。⑥ 测试与变异验证:真 WS 连接 + 真 JWT(复用 v0.48 的测试装置)。A:viewer连接收不到history回放,owner/admin收得到(且回放里thinking仍为空);B:viewer依次发chat/interrupt/subagent_ask/task_output_query/voice_input/mode_switch,每次都收到定向forbiddenerror,且其后无任何内容帧(对subagent_ask/task_output_query尤其断言「不得收到定向的subagent_event/task_output」),全程计数 runner = 0 且落库 = 0;另一条viewer连接作为观察者不收这些 error(证明是定向而非广播);回归:owner发同一个chat仍然真的驱动 runner(= 1)并把 user + assistant 两条落库;协议级:被拒后 ping/pong 往返与正常 close(服务端注销连接)均正常。变异验证:① 去掉 A 的门 →viewer立刻收到history(A 断言 FAIL,已跑);② 去掉 B 的门 →viewer的chat驱动 runner 1 次并落库 2 条、subagent_ask直接回subagent_event(B 断言 FAIL,已跑)。⑦ 影响面:owner/admin逐字不变;/ws/chat连接鉴权不变(viewer仍可连);无新端点、无字段形状变更;今天没有viewer客户端在跑(/auth/login仍写死签发RoleOwner,除测试外无 viewer 凭据),故线上实际影响为 0。 -
v0.48(WS 下行帧的角色门:/ws/chat的下行帧改为按帧类型分档——对viewer是收紧(此前它连上就能收到角色回复正文与推理内容,现在收不到),owner/admin行为不变,无端点、无字段形状变更;依据 design/11 §7.1,主人 2026-10-03 拍板「按建议收紧」):① 问题:hub 的下行是全连接广播,而/ws/chat只验 JWT(任意角色可连),于是viewer连接会收到message.*/thinking.*/tool_call.*(含角色回复正文与推理内容)、message_persisted/message_edited/message_deleted、graph_update、mood、subagent_event/task_output/device_update/reminder等——与 design/19 §1/§3「flow 可视化仅 owner/admin」直接冲突,也与GET /sessions/{id}/messages的 owner/admin 档位不一致(同一批内容从 WS 旁路漏出)。② 规则(fail-closed,默认拒):只有白名单里的 7 个「状态机信号」(session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full/session_read,均不含任何内容)发给所有已认证连接(含viewer);其余全部(stream_chunk/stream_end/thinking、message_persisted/message_edited/message_deleted/channel_persisted、graph_update/subagent_event/subagent.result/task_output/task_done/tool_used/capacity_awaken/mode_switch、mood/device_update/reminder)只发owner+admin;未登记的新帧类型默认只发 owner/admin(要进白名单须先在 design/11 §7.1 登记理由「不含内容」)。③ 实现:收口为唯一一处「按帧类型分类后投递」的入口(internal/services/gateway/ws_frame_scope.go+hub.broadcast),不给每个调用点加开关(那正是「漏一处即泄漏」的写法)——流式message.*/thinking.*/tool_call.*(broadcastStream)、stream_chunk/stream_end(runTurn/streamChat)、message_persisted/message_edited/message_deleted、mood/capacity_awaken、编排状态信号(StateSink)、graph_update/subagent_event/device_update/reminder(relay归一化)全部经同一入口,不存在第二条投递路径。④ 语义澄清(不是数据从此不可见):这条门管「谁该被主动推送」——viewer的 12 条只读监控端点(v0.40viewerAuth,含GET /personas的角色级mood)逐条照旧;/ws/chat的连接鉴权不变(viewer仍可连,只是收不到上列帧);定向回请求连接的帧(voice_output/error/subagent_ask、task_output_query的应答)不进这道门、行为不变。⑤ 测试与变异验证:真 WS 连接 + 真角色 token 的逐帧表驱动断言(design/11 §7.1 表里每一个帧类型 × 三种角色:owner/admin两类都收得到,viewer只收白名单)+ 帧类型 × 角色的直接矩阵 + 「白名单精确等于 7 个状态信号」与「未登记类型默认拒」的锁定;变异验证:把graph_update改回全广播 → 「viewer 收不到graph_update」必须 FAIL(已跑,见提交说明),把session_idle改走受限路径 → 「viewer 收得到session_idle」必须 FAIL(已跑)。⑥ 已知残留(本次未动,留待主人裁决):/ws/chat的上行仍未按角色授权(viewer仍可发chat/interrupt/subagent_ask/task_output_query/voice_input,其应答是定向帧、不经过本门),且握手时的history回放仍对任意角色照发(thinking已剥离)——二者都在 design/11 §7.1 本次门的作用域之外。(本残留已由v0.49收口:上行帧默认拒、握手回放仅owner/admin,见上一条变更记录与 §3 顶部第二把角色门。) -
v0.47(graph_update子代理泳道根节点新增可选props.persona,对客户端纯增量;实现依据 design/22 §8-2「跨角色派发 = 把其他角色以子代理形式派出去」):①subagent_spawn指定目标角色派发时,该子代理在父会话图里的分支根节点props多一个persona键(值 = 目标角色 id),供流程图显示"这个子代理是以谁的身份在替班",而不只是子代理 id。② 不指定角色派发时该键不出现:帧形状与 v0.46 逐字一致,老客户端忽略未知键即可。③ 无新端点、无字段改名、无鉴权变更;subagent_event帧未新增字段(其role会等于目标角色——既有字段的取值语义变化,非形状变更)。④subagent_spawn的persona参数不在本文档契约内:那是给模型用的工具 schema(工具层以「进程能否按该角色运行」为判据,未知角色整批拒绝且不产生任何子代理),不是给客户端的 HTTP/WS 契约。⑤ 验证:进程内 e2e 断言「子代理实际收到的 system prompt = 目标角色 instructions」+「其记忆写入落在目标角色域」+「未知角色整批拒绝」+「省略persona时结果 JSON 键集与旧版完全相同」;变异验证(把目标角色换回父/默认角色、关掉角色校验)对应断言均 FAIL。 -
v0.46(会话角色不可变:PATCH /sessions/{id}拒绝personaId,对客户端是破坏性变更——按 v0.42–v0.45 旧文实现、依赖「PATCH 换角色」的客户端必须改为新建会话选角;design/22 §8-1 主人拍板定稿):①PATCH /api/v1/sessions/{id}的personaId改为「出现即400」:只要请求体里有personaId键(任何取值:"yuki"/""/null/ 未知 id / 空白串)就回400 bad_request,错误消息点名personaId并说明「会话角色在创建时确定、之后不可变,要换角色请新开会话」。实现取「明确拒绝」而非静默忽略或 200 空操作:静默忽略会让客户端以为绑定已改。② 顺序保证:该检查在解析完 body 后立即执行、先于会话存在性检查 —— 未知会话 +personaId回400(不是404);title+personaId同传也整体400,其余字段一个都不落库。③ 不变的部分:title/pinned的语义、边界(≤80 rune 等)与响应形状逐字不变;POST /api/v1/sessions {personaId}(创建时绑定、未知角色400、非空回显)完全不变;GET /sessions与消息 DTO 的只读personaId不变。④ 移除的路径:PATCH 上再也没有{personaId}成功响应、也没有「不支持角色绑定的 HistoryStore →501」这条路(网关的Server.setSessionPersona及其中间层随之删除;history.Store.SetSessionPersona作为存储能力保留)。⑤ 前端同步:用户端移除「会话行 → 切换角色」菜单与面板「挂角色」下拉,保留「新建会话时选角色」;管理面板「角色」列改为只读展示创建时绑定的角色。⑥ 测试与变异验证:新增断言覆盖「键出现即 400(六种取值)+ 绑定未变 + 未知会话仍 400 +title/pinned不变」,并把「静默忽略」与「改回写绑定」两种反向实现各跑一次(两处断言均必须 FAIL,见提交说明)。 -
v0.45(渠道personaId三态修订:修「省略 = 解绑」会静默清空绑定,对客户端无字段/端点形状变更,但语义收紧——按 v0.44 旧文实现的客户端若靠「省略personaId」来解绑,必须改为显式传"";design/22 §2 方案 b):①PUT /api/v1/admin/channels/{platform}/config的personaId改为三态:键省略(或 JSONnull)= 保留该账号已存绑定(不动);显式""= 解绑(写盘时该账号不带personaId键);非空 id = 绑定该角色;未知角色 id 仍400 bad_request(且只校验本次真的传了值的情形——省略时继承的已存绑定在当初写入时已校验)。② 修的问题(数据安全):管理面板只在主人动过角色下拉时才发这个键(前端channelPersona.ts/channelPersonaRequest),而 v0.44 后端把「键不存在」也当成解绑 ——主人打开一个已绑角色的账号、不动下拉、只改 token 并保存 → 该账号的角色绑定被静默清空(无提示、无审计可见的理由),两端语义相反。现在后端的「省略」与前端一致 = 保留。③ 实现:请求 DTO 的该字段由string变*string(指针区分「省略」与「显式空串」),应用逻辑在既有「读当前配置(channels.json)→UpsertChannel→ 落盘 →bridge.Reload(platform)」路径内按三态取值(省略时从同平台同 id 的已存账号继承,故保存一个账号不会动到同平台其它账号的绑定);无新端点、无新字段、无响应形状变更。④GET /api/v1/admin/channels每账号的personaId回显不变(v0.44 新增,非敏感、不掩码),面板据此回填角色下拉。⑤ 测试:省略 → 绑定保留、显式""→ 清除、非空 id → 绑定、未知 id →400(含「省略时改其它字段后读回确认」与「同平台多账号互不影响」),并把「省略 = 解绑」作为变异反向验证(该断言必须失败)。 -
v0.44(渠道级选角 + 「谁说的」进模型上下文,对客户端新增两个可选字段,向后兼容;design/22 批次 2 后端 = §2 裁决规则 + §4.4 + §7-3/§7-4/§7-5):①PUT /api/v1/admin/channels/{platform}/configbody 新增可选personaId(渠道级绑定,design/22 §2 方案 b):=该账号答话时的角色 id;未知角色400 bad_request,省略/(本条的「省略」一项已由 v0.45 修订为「保留已存绑定」;只有显式""= 解绑(写盘时该账号不带personaId键)""才解绑,见上);沿用既有「读当前配置 →UpsertChannel→ 落盘channels.json(0600) →bridge.Reload(platform)热更新」路径,保存即生效(下一批入站消息),无新端点。②GET /api/v1/admin/channels每账号新增可选personaId(非敏感,不掩码),供面板回填。③ 角色解析优先级明确化(对客户端语义澄清,无形状变更):会话绑定 > 渠道绑定 > 全局默认角色;渠道绑定只作用于该账号的主人私聊且仅当主会话自身未绑角色(§7-3:IM 主人私聊镜像随主会话角色);群聊/陌生人隔离会话强制默认角色(§7-4,更保守,仍只读)。④ 模型上下文新增角色归属标注(§7-5 的剩下一半):一回合上下文改为该会话最近 20 条持久化消息 + 本回合新消息(此前只带新消息本身),每条 assistant 历史消息按其说话角色出具[发言归属] 以下发言来自「<名字>」(<id>),不是你说的。独立 system 标注 + 一条[会话角色] …当前角色声明;消息正文一字不改(标注只走 system 消息,design/11 §10),对客户端透明(无新字段)。前端无需改动即可继续工作;面板可选用 ① 让每个 IM 账号挂角色。⑤ 向后兼容:纯新增可选字段;未配置personaId的账号与不读新字段的客户端行为不变(渠道绑定缺省 = 全局默认角色,与 v0.43 完全一致)。 -
v0.43(人格读侧合并批:admin 回读thinking+ 用户侧角色级mood,对客户端新增两个可选字段,向后兼容;design/01 §2/§2.1、design/04 §3.2):①GET /api/v1/admin/personas每项新增可选thinking({budgetTokens,effort,showToUser,mode}),与写侧同构、供面板回填编辑。未配置的人格不带thinking键(不是全零对象),与PUT的「省略thinking= 保留已存」互为镜像。②showToUser三态原样回读:null= 未配置 → 保持现行为(thinking 帧照常推客户端),true/false= 显式开关;服务端不把null拍平成false(否则面板会把「没配过」误显示为「关」,v0.41 的语义会被读侧丢掉)。③GET /api/v1/personas每项新增mood(角色级):= 该角色当前情绪状态(取值同GET /meta/enums的moods;""= 暂无被跟踪情绪,不伪造)。情绪跟随角色、与会话无关——同一角色的所有会话看到同一mood;客户端登录/加载角色目录时用它播种心情徽标(按角色 id 分桶),消除「刷新后徽标短暂为空」,随后仍以 WSmood帧为准。用户侧不暴露thinking(模型调用旋钮属管理侧)。④effort的能力提示归服务端:读端点只回读已存配置,不承诺该值在当前 provider 上生效——白名单校验/降级/WARN 仍在模型层(design/04 §3.2),客户端不得把「配了」呈现为「已启用」。⑤ 向后兼容:纯新增字段;不读新字段的客户端行为逐字节不变(旧面板仍可编辑并保留已存thinking)。 -
v0.42(多角色并存 + 按会话选角,对客户端新增可选字段/可选请求字段,向后兼容;design/22 批次 1 = 后端会话级绑定):①chat_sessions新增persona_id TEXT NOT NULL DEFAULT ''、chat_messages新增persona_id TEXT NOT NULL DEFAULT ''(同一迁移 0011,down 可逆,不回填)。''= 继承全局默认角色(owner.yaml:defaultPersona),所以既有一切行升级后行为逐字节不变,且默认角色改名后这些行跟着变(design/22 §4.7/§7-6)。②GET /api/v1/sessions每项新增可选personaId(=会话绑定角色);消息 DTO(RESTGET /sessions/{id}/messages、WShistory回放、message_persisted/message_edited广播)新增可选personaId(=说话时的角色,design/22 §7-5)。③POST /api/v1/sessionsbody 新增可选{personaId}(创建即绑定;未知角色400;响应仅在非空时回显personaId,故省略时响应形状与 v0.41 完全一致);(本条的「PATCH 重新绑定/解绑」已由 v0.46 作废:会话角色在创建时确定、之后不可变,PATCH 一旦收到PATCH /api/v1/sessions/{id}body 新增可选{personaId}(重新绑定,显式""解绑,未知角色400;只回显变更字段)personaId即回400,见 §1 行 8 / §8-1)。④ 回合语义:角色决定系统提示词 / 记忆命名空间 / 情绪与好感度 / 思考预算四者同源解析(design/22 §4.2);换角色保留上下文、不动已存消息,下一回合起生效(已由 v0.46 作废:会话内不再换角色——角色在创建时定死,chat_messages.persona_id仍逐条记录"谁说的");记忆口径 = 保持 persona 隔离(design/22 §3):不同角色的会话互不浮现,同角色的多个会话互相可见。⑤ 请求级覆盖(每次发送临时指定角色)本批未实现(design/22 §2 裁决规则 1 仍待批次 2/后续);渠道级绑定(IM 账号/群默认角色)本批未实现(design/22 §4.4 批次 2),故 IM 路径仍按默认角色解析。⑥ 契约内新增内部读接口:网关经窄接口类型断言读取history.Store.SessionPersona/SetSessionPersona/NewSubSessionFor(v0.46 起请求路径不再调用SetSessionPersona——PATCH 的 persona 写路径已整体移除;「注入的 HistoryStore 不支持角色绑定」只在POST /sessions显式传personaId时回501,而非假成功)。 -
v0.41(人格thinking配置 + 推理旋钮按 provider 映射,对客户端新增一个可选请求字段,向后兼容):①POST /admin/personas/PUT /admin/personas/{id}body 新增可选thinking对象{budgetTokens?,effort?,showToUser?,mode?}(design/01 §2.1),原样写入并持久化到configs/personas/<id>.yaml的thinking节;非法mode/负预算回400 bad_request;PUT省略该对象时保留已存thinking(旧面板/脚本不会抹掉它);该字段只描述呈现/资源,不构成能力边界。②showToUser语义:缺省 = 照常把thinking.*流帧推给客户端(现行为),显式false= 仅落库/剥离语义不变、不推帧(展示与落库解耦)。③ 服务端推理映射(对客户端透明,仅影响上游请求):Anthropic 系在 persona 配置budgetTokens/mode: on时发thinking:{type:"enabled",budget_tokens:N},并保证budget_tokens + 正文留量 ≤ max_tokens(不足则抬高max_tokens并记 WARN,绝不把正文吃空);OpenAI 兼容系按识别到的厂商白名单校验reasoning_effort(OpenAI 系none…max、DeepSeek 仅low/high/max),非法值~~降级到最近一档并记 WARN(已由 v0.53 修订为「丢弃并记 WARN、不降档」——降档会反转调用方意图,未知 provider/未识别模型、以及 Qwen 的none= 关思考,绝不翻成任何强度档);enable_thinking硬开关(协议无统一 wire 形状) 一律记 WARN(含 provider/字段/取值)并保持请求不变,绝不静默无效~~(本条已由 v0.53 作废:开关形状改由组键reasoning.kind显式声明并真下发,只有形状未知/有歧义时才 WARN 且一个字段都不发,见 v0.53 变更记录)。详见 design/04 §3.2。 -
v0.40(viewer角色真正接线:只读监控档,对客户端收紧/放宽若干 admin 读的鉴权,无端点/字段形状变更):此前viewer只是常量(internal/auth的RoleViewer注释写着「只读 dashboard/logs/monitoring」)而从未接线——管理侧 24 条 GET 全走 owner/admin,viewer一条都读不到(全 403)。本版新增第四道门viewerAuth = owner+admin+viewer,并把 12 条只读监控 GET 从 owner/admin 放宽到 owner/admin/viewer:GET /admin/personas、/admin/sessions、/admin/plugins、/admin/plugins/installed、/admin/channels、/admin/iot、/admin/automations、/admin/rag、/admin/observability/metrics、/admin/observability/trace、/admin/observability/logs、/admin/notifications。明确不放给 viewer(含凭据获取路径、他人数据、完整消息/导出明文、审计流水,或admin也不给):GET /admin/memories、/admin/llm、/admin/users、/admin/backthink、/admin/observability/audit、/admin/channels/messages、/admin/channels/{platform}/messages、/admin/sessions/{id}/export、POST /admin/rag/query(这 9 条 owner/admin)以及其余 35 条 owner-only(全部写操作 +login-qr/login-status/view/friends/渠道连接与配置/plugins/*/users/*/system/*)。匿名:任意 admin 路由仍401(fail-closed)。向后兼容:owner/admin语义不变(放行集合是超集),仅viewer的可见面从「全 403」变为 12 条只读监控;不依赖viewer的客户端行为不变。完整分档见 §2.0「管理侧权限矩阵」。 -
v0.39(微信 iLink 会话被拒可见化 + 不再假 OK,对客户端新增两个可选字段、一处错误码,向后兼容):①GET /api/v1/admin/channels每平台行新增可选needsRescan(bool,omitempty)+lastError(string,omitempty)——仅当适配器真的要求重新授权时出现(今天只有微信 iLink 返回errcode=-14这一种),取值来自适配器新增的可选能力接口RescanRequired() (bool, string),网关经类型断言读取(主PlatformAdapter契约不变;未实现该能力的平台字段缺省不出现)。用途:把「需要重新扫码」与「主人自己关掉的未连接」区分开——后者仅connected:false。②POST /admin/channels/open对「iLink 已拒绝该会话」回409 session_rejected(message 明确要求用「扫码登录」重新绑定),不再吞掉错误回 200;409 needs_login(尚无 token,v0.8 起)不变。③ 适配器行为:iLinkerrcode=-14进入显式被拒态——IsConnected()恒false;Connect()返回被拒错误(不再对被拒会话报成功);HealthCheck区分「会话被拒」与「未连接」;并以ERROR级日志写明「本次会话已被 iLink 拒绝,需要重新扫码绑定」(只带bot_id等非敏感标识,token 永不入日志)。被拒态在读循环与发送两条路径都会置位,并仅在扫码登录拿到新 token 或配置换了新 token(保存 + 热重载)时清除。④ 扫码登录侧:被拒期间login-status报failed+ 被拒原因(不再假报confirmed,否则面板会把已作废凭据当成功重新落盘);login-qr仍可正常出码(不回already_logged_in,否则重新扫码这条路被堵死);get_bot_qrcode不再携带已被拒的 token(否则服务端以「已绑定」应答、不发新凭据)。向后兼容:只新增可选字段与一处更诚实的错误码;不读新字段、不处理 409 的客户端行为不变(此时面板显示「未连接」,仍可用「扫码登录」恢复)。语义澄清(本次仅措辞,不改契约形状、不另起版本号——v0.39 尚未被任何客户端采用):iLink 的 bot token 是长期凭据(实测一个月前扫码得到的 token 仍可用),errcode=-14表示 iLink 侧拒绝/作废了该会话凭据,不是时间性过期。详见 §2 行 6 与「微信 iLink『会话被拒』契约」。 -
v0.38(B4 客户端共享枚举权威表 + 未知值回落约定,对客户端新增一个只读 REST 端点):新增GET /api/v1/meta/enums(任意已认证角色可读,与GET /sessions同级;无 token401)→200 {data:{platforms,moods,modes,unknown}},每项{id,label}:①platforms(6)=web/kook/qq-obv11/weixin-ilink/telegram/webhook——即各内置适配器Name()+ Web 主会话来源web;②moods(5)=calm/happy/excited/sad/angry,与 persona 情绪状态机内置状态一致(该状态机可扩展,故非封闭集合);③modes(9)=chat/code/home/work/docs/voice/vision/agents/settings(design/07 §1);④unknown恒"display-raw"= 未知值回落约定机器可读:客户端遇不在表中的取值原样显示原始字符串(不丢弃、不报错、不占位符),服务端新增取值视为非破坏性变更(forward-compatible)。单一事实来源在 Go 侧(internal/contract/enums),并有反漂移测试断言适配器Name()/ 情绪内置状态 / 前端九模式与三张表一致,漏登记即 CI FAIL。Web 侧镜像frontend/web/src/lib/enums.ts(labelFor未知值返回原值),顶栏/侧栏/消息气泡的「心情」标签已改用它渲染。向后兼容:仅新增端点,既有契约形状不变。详见 §1 行 15 与 §1.1。 -
v0.37(D4 未读状态 / 已读游标,对客户端新增一个语义字段、一个 REST 端点、一个下行事件):①chat_sessions新增read_at INTEGER NOT NULL DEFAULT 0(迁移 0010,down 可逆;毫秒 Unix 时间戳,与chat_messages.created_at同单位)。② 语义:会话级单一已读游标 + 单 owner 账号——同一账号所有端共用一个游标,读一处 = 全端已读(不做 per-device/per-user 游标,与「同一账号所有浏览器都属于同一 owner」一致);createdAt <= readAt的消息算已读,未读数 =createdAt > readAt的条数。③GET /api/v1/sessions每项新增恒返回的unreadCount(整数,0 = 无未读):与lastMessage同一条 SQL 派生(关联子查询COUNT(*) WHERE session_id = s.id AND created_at > s.read_at,走已有idx_chat_msgs_session_created,无 N+1、不新增索引)。④ 新增POST /api/v1/sessions/{id}/read(owner/admin,与PATCH /sessions/{id}同鉴权):body{before?}(可选毫秒;缺省= 现在)→200 {data:{sessionId,readAt}};before为 0/负数/非数字/body 非对象 →400,未知会话(含隔离sess-xxx)→404,未注入 HistoryStore →501。游标只前进不后退(read_at = MAX(read_at, before)),乱序/并发/重试请求幂等安全。⑤ 新增下行事件session_read:{type:"session_read", sessionId, data:{readAt}},全连接广播 + 客户端按sessionId过滤,用于同账号其它端清红点;失败路径不广播(§3.9)。⑥ 升级回填:0010 把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读(既有历史全部视为已读)。⑦HistoryStore(网关消费侧契约)新增MarkSessionRead(ctx, sessionID, before);SessionMeta新增UnreadCount。向后兼容:仅新增字段/端点/事件,既有契约形状不变;不消费unreadCount/session_read的客户端行为不变。详见 §1 行 4/8a 与 §3.9。 -
v0.36(A1 流式帧附加会话 / 回合标识,对客户端新增两个可选字段、无端点变更):结构化流帧message.start/delta/end、thinking.start/delta/end、tool_call.start/delta/end、tool_execution.update现在带sessionId+turnId(向后兼容:只新增帧字段,data结构与既有事件形状不变)。sessionId由 loop 在回合边界从运行上下文注入(模型适配器在 loop 之下、天然不感知会话,保持零值;loop 外无会话上下文时该字段为空);turnId由网关每回合新生成(newTurnID)并经loop.WithTurnID挂进运行上下文,runTurn是编排层与 legacy 直连路径唯一的回合收口点,两路都覆盖——同一会话同时只有一个 in-flight 回合,故同轮内所有帧的turnId一致。此前这几帧属于 design/16 §4.2 标注的「若知道就带上」缺口(v0.10 起记录):多会话并发流式时前端会把别的会话的 delta 接进当前会话气泡,现在客户端按sessionId === activeSessionId过滤即可路由到激活会话。stream_chunk/stream_end的sessionId/turnId行为不变。详见 §3.1。 -
v0.35(可观测性收尾:后端日志 tail 端点,对客户端新增一个 admin 只读端点):GET /api/v1/admin/observability/logs?level=&limit=(owner/admin)→200 {data:[{time,level,source,msg}]},最新在前。数据源为 logger 新增的有界环形缓冲(internal/logger,容量 2000,满则丢最旧、并发安全、不阻塞、不改动既有控制台/文件输出),组合根在构建 logger 时挂上、在网关就绪后经SetLogBuffer注入;缓冲未注入回501 not_implemented(不伪造空 tail)。time为 RFC3339 UTC(秒精度),与trace.startedAt/audit.when一致;level ∈ debug|info|warn|error(大小写不敏感)为最低等级过滤,空 = 全部,未知值400 invalid_level;limit缺省200、上限1000。source取logger.WithSource的source=(Scoped记录回落module/sub),无来源为空串。详见 §2「可观测性契约」。 -
v0.34(D3 收尾:消息编辑 / 删除的 WS 实时广播,对客户端新增两个下行事件、无 REST 契约变更):PATCH /api/v1/messages/{id}成功后广播message_edited({type, sessionId, data:<更新后的完整消息DTO>},thinking已剥离)、DELETE /api/v1/messages/{id}成功后广播message_deleted({type, sessionId, data:{id}});两者复用message_persisted的同一 hub 广播路径(全连接广播 + 客户端按sessionId过滤,hub 无按会话订阅),客户端按id原位改content/ 按id移除,不再需要「改/删后重拉」的绕行。message_deleted的sessionId由后端在删除前读取消息所属会话填入(HistoryStore新增GetMessage;读不到时客户端按全局唯一的id兜底)。404/400/501等失败路径不广播。Web 端已消费(lib/ws.ts+store/useAppStore新增updateMessageById/removeMessageById)。详见 §3.8。 -
v0.33(A2 记忆异步提取 / A4 可观测性 Tracing 接线 / A6 通知页后端):① A6 新增GET /api/v1/admin/notifications(owner/admin 读,全新端点):把现有源(审计audit_logs+ backthink 主动推送记录)聚合为统一只读通知 feed,type/before/limit过滤与游标见 §2「通知聚合契约」;不新建表,未注入的源该类型为空、不伪造;未读游标后置。② A4 trace 端点由恒空变真实数据(无契约变更,GET /admin/observability/trace与metrics的llmCalls/subagentEvents从此反映 loop/工具/编排的真实 span):此前TraceSink从未接线、环形缓冲无人喂;现在组合根把 loop 产生的 span 扇出到网关观测缓冲。响应字段/形状不变。③ A2 LLM 异步记忆提取接线(对客户端无变更):loop 回合结束后改由 LLM 提取器后台异步抽取记忆(不阻塞回复、失败仅记日志),群聊/陌生人仍只读不写;无可用模型凭据时回落确定性ManualExtractor(记住 X路径不变)。 -
v0.32(A1 情绪系统接线:情绪/好感度真跑起来):后端把既有的persona/emotion状态机接进 loop 回合边界——每轮结束按该轮用户消息的情绪信号移动该角色自己的情绪状态机(沿配置转移表走一步)并调整好感度(按角色affection.delta);无信号轮次衰减一步趋向calm。① 消息mood不再恒空:assistant 消息落库带该轮情绪,message_persisted/history回放 /GET /sessions/{id}/messages的mood为真实值(user 消息不带)。② 新增下行mood事件({data:{mood,personaId,affection}},仅情绪变化时广播,见 §3.7),前端顶栏「心情 · x」与消息气泡接真数据。③ 隔离:群聊/陌生人(MemWritable=false)情绪只读不写——不改情绪/好感度、不发事件、该轮mood空。④ 持久性:情绪/好感度为进程内按角色状态,重启回initialState(未落库;design/01 未要求)。触发词表与「每轮一步」为 design/01 未规定处的最小实现,待主人确认。契约新增(向后兼容):mood事件 + 消息mood变真实值。 -
v0.31(Android 协同批次 ③b:B5 剩余 501 + D1 推送出口钩子):① B5-知识库删除由 501 改为真实实现:POST /api/v1/admin/rag/deletebody{"id"}→200 {data:{id,deleted:true}}——从项目索引删除该文档条目并级联删除全部章节(单事务);不存在回404 not_found,id空回400,未注入 store 回501;路由由 admin 收紧为 owner-only(与其它 admin 写一致)+ 审计rag.delete。② B5-新增用户 / IoT 场景 / IoT 规则仍诚实 501:无用户表(登录仅.env单 admin)、IoT 无场景/规则模型与存储(iot.Provider只有设备列表/控制/状态),错误消息明确缺什么,不伪造 store/假成功。③ D1 推送出口钩子:主动消息在 WS/IM 投递之外异步镜像到外部推送出口,自建统一推送服务优先 → 个推(Getui)兜底(个推 SDK 未接入,仅预留GETUI_*字段,不可达时日志注明待接入);.env新增PUSH_EXPORTER_ENABLED/URL/TOKEN/TIMEOUT_SEC,token 只走Authorization头、不落日志;对客户端无新端点(详见 §3.6)。 -
v0.30(Android 协同批次 ③a:D2 消息搜索 / D3 消息编辑删除 / D5 会话置顶):① D2:GET /api/v1/sessions/{id}/messages新增可选q——会话内内容搜索(大小写不敏感 ASCII LIKE,%/_/\转义为字面量),仍倒序 +before/limit游标;q与after互斥(同传400),空q走普通分页;严格限定单会话,不新增跨会话搜索(避免群聊/陌生人隔离会话外泄,红线)。HistoryStore新增Search。② D3:新增PATCH /api/v1/messages/{id}(body{content},非空 ≤4000 rune →{data:{message}},只改 content)与DELETE /api/v1/messages/{id}(→{data:{deleted:id}});不存在回404 not_found;HistoryStore新增UpdateMessage/DeleteMessage+ErrMessageNotFound。不新增重发端点(客户端用编辑后的 content 再发)。③ D5:chat_sessions新增pinned INTEGER NOT NULL DEFAULT 0(迁移 0009,down 可逆);GET /sessions排序改为pinned DESC→ 主会话优先 →createdAt ASC,会话 DTO 新增恒返回的pinnedbool;PATCH /sessions/{id}扩展为 body{title?,pinned?}(至少一个字段,只回显变更字段,title-only 保持 A4 形状);HistoryStore新增SetPinned。全部为 owner/admin 写、读消息 owner/admin;未注入 HistoryStore 回501。 -
v0.29(/admin/llm 读-改-写合并):POST /api/v1/admin/llm由「全量替换」改为部分编辑合并——请求体字段用指针区分「未给」与「显式零值」;maxTokens/apiKey/provider/baseUrl/model/fallback/multimodal省略即保留原值(不再把省略的maxTokens重置为 0),新组仍要求baseUrl+model、非法 provider / 负 maxTokens 仍400。面板 LLM 页新增每组maxTokens展示/编辑 + 新增/编辑表单;面板插件页新增「已装插件列表」+「安装 MCP 插件」表单(kind/name/transport/endpoint|command+args/apiKey/env),api.ts 请求体带config(修掉旧 body 400)。runtimeconfig/models.go新增Find。 -
v0.28(MCP 插件热安装/卸载):POST /api/v1/admin/plugins/install(由 501 改为真实实现,owner-only + 审计)body{kind:"mcp",name,config:{transport,endpoint?|command?,args?,apiKey?,env?}}→200 {data:{installed,name,kind,transport,toolCount}}——真实连接 MCP server、发现并热注册工具进 capability registry(下一轮模型请求即注入),配置落盘plugins.json(gitignore、0600);连接失败回502且不落盘;同名重复 = 刷新。POST /api/v1/admin/plugins/uninstall扩展:MCP 插件走Unregister(移除工具 + close + 删配置),普通能力保持原registry.Remove。新增GET /api/v1/admin/plugins/installed(owner/admin 读)列出已装插件(apiKey/敏感env掩码 +live/toolCount)。mcp.MCPConfig新增APIKey(http/sse 以Authorization: Bearer发送)与Env(stdio 子进程环境);plugins.json于启动时Restore重新安装。契约版本v0.28。 -
v0.27(LLM token 预算可配置化):models.json模型组新增可选maxTokens(int,缺省/0= 未设),作为该组主/备链的 token 预算;GET /api/v1/admin/llm的AdminLlmProvider与POST /api/v1/admin/llmbody/回显同步该字段(omitempty,未设为 0 时不出现)。.env新增LLM_FALLBACK_MAX_TOKENS(int,默认8192,0= 关闭兜底),只作用于确有回退模型时链末端的回退步。优先级:显式Request.MaxTokens(审查/判定类小预算)> 生效组maxTokens>LLM_FALLBACK_MAX_TOKENS(仅链末端、且组未设时)> 适配器默认(anthropic 8192)。单步链(无 fallback)不套用 env 兜底。maxTokens为负回400 bad_request。契约版本v0.27。 -
v0.26(A5 消息正向增量):GET /api/v1/sessions/{id}/messages新增after查询参数(<createdAt 毫秒>,升序返回created_at > after的消息,默认limit=50/上限 200)——解决「WS 重连只回放主会话最近 50 条、断线超 50 条永久丢」的缺口;before现行为不变;after与before互斥(同传400);负数/非数字400。HistoryStore新增ListAfter(走idx_chat_msgs_session_created,rowid兜底同毫秒并列)。游标契约:客户端用「本页最后一条createdAt」作下一页after;同毫秒消息 > limit 时纯createdAt游标会跨页重读(超罕见,客户端按id去重兜底,与现有绕行一致)。 -
v0.25(B2 refresh token):POST /api/v1/auth/refresh(新增,{refreshToken}→ 新 access + 新 refresh,默认单次使用旋转,并发重放 fail-closedinvalid_grant)+POST /api/v1/auth/logout(新增,撤单个或全设备)。POST /api/v1/auth/login在{token,expiresAt,role}之外新增refreshToken(不透明 256-bit 随机串,仅 SHA-256 哈希落库,AUTH_REFRESH_TTL默认720h,AUTH_REFRESH_ROTATE_DISABLED关旋转)。新表refresh_tokens(迁移 0008,id/user_id/role/token_hash UNIQUE/expires_at/revoked_at;含过期/轮换原子事务);rotate-jwt成功即删除全部 refresh(返回refreshTokensRevoked),revoke-keys不动 refresh。密码/secret 记录见internal/auth;refresh 全文绝不落日志/审计。 -
v0.24(B1 图片上传端点):POST /api/v1/upload(新增,multipartfile,owner/admin+审计 →201 {data:{url,size,mime,name}})+GET /api/v1/files/{name}(新增,同端口静态服务)。安全:MaxBytesReader(5MB)、DetectContentType嗅探(不信客户端 Content-Type)、白名单 raster image(拒 SVG/HTML)、crypto/rand128-bit 随机名(丢弃原始文件名)、非可执行上传目录(0700)、nosniff;路径穿越全形态拒绝(含编码%2f/%2e%2e)。错误码400/413/415(internal/errors增ErrPayloadTooLarge/ErrUnsupportedMediaType)。设计取舍:GET 不鉴权(能力 URL,chat.image/<img>无法带 Bearer),POST 仍 owner/admin。url为绝对地址(X-Forwarded-Proto/TLS +r.Host,因ParseImageRef只认http(s)://)。Deps.DataDir由 wire 注入(resolveDataDir)。 -
v0.23(C2 语音 silent 降级信号):三处统一用provider只在「结果为空」时出现(非空/成功时才省略)——POST /api/v1/audio/transcribe空文本响应体带{text:"", provider:"silent"};WSvoice_output空音频 payload 带provider;POST /api/v1/audio/speech空音频仍500 tts_empty并加X-Voice-Provider: silent响应头(CORSAccess-Control-Expose-Headers已放开)。voice.Chain/Service新增STTWithProvider/TTSWithProvider(返回真实档位 cloud/local/edge-tts/espeak/silent),网关用可选接口voiceProviderReporter透传,自定义实现回退silent。含X-Voice-Provider(apperr包装、providerSignal);DASHSCOPE_API_KEY无/降级时信号为silent。(go test -race在本机受内核 TSAN VMA 限制失败,非本代码问题。) -
v0.22(B3 会话列表 lastMessage 预览):GET /api/v1/sessions与/api/v1/admin/sessions每个会话新增可选lastMessage{content,role,createdAt}(content 折叠空白截前 60 rune +…;空会话省略),单条 SQL(LEFT JOIN 取每会话ORDER BY created_at DESC,rowid DESC LIMIT 1,无 N+1,走idx_chat_msgs_session_created覆盖索引)。unreadCount需已读游标设计,后置。 -
v0.21(A4 会话 title):PATCH /api/v1/sessions/{id}(新增,body{title}≤80 rune →{data:{id,title}};空/超长 400、不存在 404、主会话可改名);会话首条用户消息到达时自动命名(history.append前ensureTitle:折叠空白取前 20 rune,纯空白回落主会话/次级会话),仅首次命名、不覆盖。HistoryStore新增RenameSession;隔离会话(sess-xxx)不在chat_sessions有行、不自动命名(其本不在/sessions列表)。边界:前端createSubSession(title?)的 POST body 含title但后端暂忽略(会被首条消息自动命名覆盖),未处理。 -
v0.20(A3 消息 mode 落库):POST /api/v1/chat与ws.chat上行新增可选mode(行为模式,design/01 §3 / design/16 §5.1)。后端把每条持久化消息的Mode真实写入(IM 路径恒chat;Web 路径经 ctx 透传本轮mode,缺省回落chat;恢复/续接路径沿用发起轮次的mode),消息 DTO、message_persisted、channel_persisted的mode不再恒空,供前端按模式渲染气泡(仅chat解析<p>拆气泡,其它模式原样整条)。 -
v0.19(百炼声音复刻 create_voice + TTS instruction 情绪驱动,主 TTS 用昔涟复刻音色):POST /api/v1/audio/voice-clone(新增,owner-only + 审计,body{url:"<公网参考音频URL>", prefix?}→{data:{voiceId}})——走后端voice.DashscopeCloner(百炼voice-enrollmentcreate_voice,POST {base}/services/audio/tts/customization,input{action:create_voice, target_model:VOICE_TTS_MODEL, prefix, url},只收公网 URL 不收 base64,返回output.voice_id;URL 不打日志不写审计)。POST /api/v1/audio/speech新增可选instruction(per-request 情绪/语气指令,如"语气开朗俏皮,像女朋友撒娇"),经 ctx 透传给 TTS adapter;只对复刻/设计音色生效(cosyvoice-v3-flash复刻音色可任意指令;系统音色longxiaochun_v3需固定格式,服务端canInstruct判定后永不发送)。config 新增VOICE_TTS_INSTRUCTION(默认"")、VOICE_CLONE_PREFIX(默认cyrene,≤10 字母/数字);voice.Chain/voice.Service新增CloneVoice,VoiceCloner接口(cloud rung 提供;未配 cloud 回voice_unavailable)。⚠️ 真建昔涟音色仍待主人提供公网参考音频 URL(当前无),且 CosyVoice 声音复刻仅华北2北京地域(确认 key 地域);TTS instruction 用现有复刻音色或先仅单测请求体,避免改系统音色行为。 -
v0.18(后端语音接通阿里百炼 STT + TTS 主链路):POST /api/v1/audio/transcribe(新增,owner + 审计,body{audio,format?,deviceId?}→{data:{text}},走 STT);POST /api/v1/audio/speech(新增,owner + 审计,body{text,voice?,format?}→ 音频字节audio/<format>,走 TTS)——REST 语音源,原生/移动端经它绕开 WS 4KB 帧限上传/获取音频;WSvoice_input上行新增deviceId(随语音轮次落库,design/16 §4.5)。后端internal/runtime/multimodal/voice的 cloud rung 由空 endpoint stub 改为真实百炼 adapter:STTqwen-audio-3.0-asr-flash(base64 原始字节→文本)、TTScosyvoice-v3-flash(按voice_id合成,取非流式返回的 24h 音频 URL 下载字节)。实测(DASHSCOPE_API_KEY实跑):TTS「你好,我是昔涟」→ 106124 字节 wav;把该 wav 喂给 STT → 文本「你好,我是西联。」(TTS 发音把「昔涟」读得近似「西联」,STT 识别正确);语音链 (cloud→…→silent) 返回真实字节/文本。已实测修正:cosyvoice-v3.5-plus/longxiaochun(v2 音色)走 HTTPSpeechSynthesizer会回418(引擎拒绝),改用cosyvoice-v3-flash+longxiaochun_v3(v3 音色,各模型音色不可混用)。声音复刻(响昔涟音色)仍待:需参考音频(公网 URL)在百炼建自定义音色后把voice_id填入VOICE_TTS_VOICE_ID(本项目不做 create_voice)。config 新增DASHSCOPE_BASE_URL/VOICE_STT_\*/VOICE_TTS_\*(VOICE_TTS_SAMPLE_RATE默认 24000)。 -
v0.17(系统设置安全与存储:吊销全部 API key / 轮换 JWT 密钥 / 备份 / 恢复,design/11 §4 + design/10 §3 模块14):新增POST /api/v1/admin/system/{revoke-keys,rotate-jwt,backup,restore}(均 owner-only + 审计)。revoke-keys走auth.APIKey.RevokeAll()(清空 key 表,返回{revoked:n});rotate-jwt走auth.JWTService.RotateSecret(≥32 位,新密钥不回显/不落日志,现有 token 立即失效);backup用 SQLiteVACUUM INTO快照到<dataDir>/backup-<ts>.db(含已提交 WAL 内容)返回{path,size};restore先校验路径在 dataDir 内(filepath.Rel防路径穿越,逃逸回400)→ 先自动备份当前 DB → 替换 DB 文件 → 返回{restored:true,backup,note:"需重启后生效"}(运行进程持有 DB 句柄,热替换需重启可见)。gateway 新增Deps.DatabasePath(composition root 传入实际打开的 SQLite 路径)。编辑环境变量 / 重启服务 / 检查更新仍为后置,未实现、不伪造。 -
v0.17(KOOK 适配器面板 + 每适配器专属页,design/17 §2):GET /api/v1/admin/channels/{platform}/view、GET /api/v1/admin/channels/{platform}/friends?type=、POST /api/v1/admin/channels/{platform}/friend/handle、POST /api/v1/admin/channels/{platform}/message/test(新增,均 owner-only + 审计,带可选?accountId=,仅kook实现,其它平台501);GET /api/v1/admin/channels每条账号附capabilities(面板按平台渲染专属卡片)。KOOK 面板:好友/申请、服务器/频道/成员、私信会话、发消息测试——经适配器 REST(Authorization: Bot <token>)拉真实数据,token 不打印不落日志,子项失败记入errors不伪造。 -
v0.16(运行时配置体系:渠道适配器配置落盘 + 热更新,design/04 §3.1):PUT /api/v1/admin/channels/{platform}/config(新增,owner-only + 审计)body{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}→201 {persisted:true,path,platform,accountId}——读当前有效配置 →UpsertChannel→ 落盘channels.json(本地、gitignore、0600)→bridge.Reload(platform)热替换运行中 adapter(进行中消息用旧、新消息用新),无需重启;token 写入文件但不回显/不落日志。DELETE /api/v1/admin/channels/{platform}/config?id=(新增)删除账号并热更新。GET /api/v1/admin/channels每条渠道附accounts(每平台已配账号,token 脱敏掩码)。.env(CHANNELS_JSON/CHANNEL_*_TOKEN)退为兜底。 -
v0.15(运行时配置体系:LLM 多厂商多模型独立组落盘 + 热更新,design/04 §3.1):GET /api/v1/admin/llm返回 models.json 的全部组(provider协议字段新增,index 0 = 生效组;APIKey 仍脱敏不回显);POST /api/v1/admin/llm由「临时记录」改为真实持久化 + 热更新(body{id?,provider,baseUrl,apiKey?,model,multimodal?,fallback?}→201 {provider,persisted:true,path},读-改-写models.json、保存组提升为生效组并热重载运行中的 LLM stack,无需重启)。.env退为兜底(无models.json时按LLM_PROVIDER/LLM_*/ANTHROPIC_*合成单组)。 -
v0.14(IM 多会话隔离 + 第三方身份 + 黑白名单,design/16 §4.6):chat_messages增sender_name列(迁移 0006,默认'');消息 DTO(REST 消息、WShistory回放、WSmessage_persisted)均带senderName(第三方发送者昵称,主人私聊为空omitempty)。第三方身份边界:IM 群聊/陌生人会话在上下文构建时对第三方消息注入[第三方 · <昵称>]前缀(不改源消息正文,落库保留原文)+ 前置系统提示词"每个 IM 的非管理员账户 = 第三方人员,和主人无关"——身份判定基于 channel 层 actor 元数据 + 白名单(MemWritable=false即群聊/陌生人),绝不靠 LLM 猜。会话隔离:群聊/陌生人消息落独立隔离会话(sess-xxx,带platform+senderName,不并入主会话web、不走message_persisted广播),供GET /api/v1/admin/channels/{platform}/messages(owner/admin,management 面板渠道日志区)以消息流显示;前端主会话只显示各平台管理员(owner)私聊;群聊/陌生人记忆只读不写主会话(memory_gating已按MemWritable门控)。多账号:CHANNELS_JSON支持同一平台多个接入配置(每个 account 独立 session/账号,见 §2 渠道契约)。黑白名单:每平台每账号可配allowGroups/denyGroups/allowUsers/denyUsers,channelbridge 入站即拦截(黑名单/不在白名单 → 丢弃,audit可查,不进 LLM);白名单按platform+account(群 id / 用户 uid)。 -
v0.13(每条消息带 deviceID/client 标识,design/16 §4.5):chat_messages增device_id列(迁移 0005,默认'');POST /api/v1/chat请求体新增可选deviceId(客户端生成的端标识,如web:chrome:a1b2c3);消息 DTO(REST 消息、WShistory回放、WSmessage_persisted)均带deviceId。platform(平台)与deviceId(具体端)互补:同一平台多个端 → 相同platform、不同deviceId。Web 端由lib/client.ts用localStorage持久化每个浏览器一个的web:<browser>:<随机短id>,发送时自动带上,聊天气泡默认小字标注来源(仅当deviceId存在才显示);IM 端按<im平台名>:<本地配置唯一id>:<用户平台唯一uid>构造(当前UnifiedMessage尚无 deviceId 概念,channelbridge 以<platform>:<channelID>:<senderID>占位,<本地配置唯一id>待适配器提供后替换);Web 端聊天不回流 IM。 -
v0.12(IM 渠道并入主会话 + 平台标记,design/16 §4.4):chat_messages增platform列(迁移 0004,默认'web');GET /api/v1/sessions/{id}/messages支持可选?platform=过滤(Web 端不传=全量共享主会话;IM 适配器传本平台只拉本渠道);消息 DTO(REST 与 WSmessage_persisted)均带platform。IM 渠道主人私聊(!IsGroup && Owner)经 channelbridge 落主会话web(platform=<渠道>)并广播message_persisted→ Web 端免刷新镜像;群聊/陌生人保持独立隔离上下文、不落主会话。Web 端聊天(platform=web)不回流 IM。 -
v0.11(跨端实时同步,design/16 §4.2):WS 下行新增message_persisted事件——每条 user/assistant 消息落库后广播{type, sessionId, data:{message:{id,sessionId,role,content,mode?,mood?,image?,createdAt}}}(id为后端生成,thinking已剥离)。客户端按sessionId === activeSessionId过滤 + 按idupsert(含本端乐观气泡uid→后端id归一)去重,实现「A 发消息、A 与 B 都无需刷新且各出现一次」。后端history.Store新增AppendFull供编排层取含id的落库消息。 -
v0.10(会话消息持久化 — WS 契约,design/16,后端-2):WS 下行帧新增可选sessionId字段(能确定会话的事件带上);新增history回放事件——WS 握手成功即回放主会话最新 50 条({type:"history", sessionId:"web", data:{messages:[...]}},thinking已剥离)。stream_end/stream_chunk/会话编排状态帧(session_busy等)在能确定 session 时带sessionId;message.delta等结构化流帧当时因model.StreamEvent无 session 上下文不带(即"若知道就带上"边界)——该缺口已在 v0.36 补齐(结构化流帧现在带sessionId+turnId,见 §3.1)。 -
v0.9(会话消息持久化 + 主/次级会话,design/16,后端-1):新增POST /api/v1/sessions(新建次级会话)、GET /api/v1/sessions/{id}/messages?limit=&before=(游标热分页拉历史,时间倒序)、DELETE /api/v1/sessions/{id}(删次级会话及其消息);GET /api/v1/sessions在注入HistoryStore后改返回持久化会话元数据数组(主 + 次级,type区分),未注入时保持原渠道会话列表向后兼容。读消息/写会话为 owner/admin(用户侧私有历史,thinking 不达 viewer);会话元数据列表保持任意认证角色可读。 -
v0.8(管理面板写操作 CRUD):补齐管理面板写端点(见 §2「管理面板写操作契约」,均 owner-only + 审计)——POST/PUT /admin/personas(真实注册 + 持久化configs/personas/<id>.yaml)、POST /admin/users/apikey+DELETE /admin/users/apikey/{id}(真实签发/撤销)、POST /admin/iot/{id}/control(真实设备控制)、POST /admin/automations+/run+DELETE(真实注册/触发/移除规则)、POST /admin/llm(持久化models.json+ 热更新,v0.15 起)+/admin/llm/test(真实模型调用)、POST /admin/channels/{open,close,test}(真实连接/断开 + 结构性身份识别)、POST /admin/plugins/uninstall(真实 registry 移除)、POST /admin/memories/search(真实检索)、GET /admin/sessions/{id}/export。无存储的写(新增用户 / IoT 场景·规则 / 插件安装)如实返回501 not_implemented。 -
v0.7(会话消息编排 design/15):POST /api/v1/chat非流式且会话「正忙」时改回202 {data:{queued:true,queueDepth}}(原同步{data:{reply}}仍为空闲时返回);pending 满回409 chat_busy。WS 上行新增interrupt({sessionId},中断当前任务+清 pending);WS 下行新增会话编排状态事件session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full(见 §3.2)。既有stream_chunk/stream_end与结构化流事件不变,契约向后兼容。 -
v0.6(可观测性):新增GET /api/v1/admin/observability/{metrics,trace,audit}(owner/admin)——metrics 返回网关实时请求计数/QPS/延迟/方法·路径·状态分布 + LLM/子代理 span 计数;trace 返回 gateway 环形缓冲里的最近 span(经 TraceSink 对接 runtime tracer,映射为AdminTraceSpan);audit 返回持久化审计(SQLiteaudit_logs,未注入时退化为内存)。llmCalls/subagentEvents由实际观测 span 累计;Token/成本后端不统计、不返回(不伪造指标)。 -
v0.5(批次B·视觉真推理 + 语音端):POST /api/v1/chat增image字段(base64 data: URI 或 URL),入 loop 视觉路由(vision/OCR 工具或主模型多模态,design/06 §0「先看图再对话」);WS 上行voice_input接 STT→聊天→TTS 管道,下行新增voice_output(定向回请求连接,含合成音频 + 识别文本 + 回复文本)。 -
v0.4(结构化流):WS 下行新增message.*/thinking.*/tool_call.*/tool_execution.update结构化流事件(event shell 补turnId/seq,data类型化),对齐 OpenClaw AssistantMessageEvent;保留stream_chunk/stream_end向后兼容。 -
v0.3(M10):管理面板/admin落地——按 §2 契约封装 admin 只读端点(personas/sessions/memories/plugins/channels/iot)+ owner-onlyplugins/policy写;其余 M10 端点以契约路径封装、后端未实现时前端显式"待实现"壳。 -
v0.2(M8):gateway 落地——登录/health/chat/sessions/personas + admin 只读端点 +plugins/policy写 + WS/ws/chat(JWT + bus 广播)。 -
v0.1(契约先行):骨架 + 端点登记表。