旧版积了 v0.28→v0.65 的历史条目、「契约版本」标题并列、双变更记录、mtime 落后等结构性问题; 归档保留完整历史(含 §5 变更记录),新文件将从零重写,只留最近若干版并指向本归档。
166 KiB
Cyrene v2 后端 API 参考(api-reference.md)
权威归属(先读这条):契约以
Docs/api-client.md为准——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前v0.65)。本文件是内部与运维参考:端点全集索引 + 示例 + 部署细节。凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。 本文件的正确用法:查「有哪些端点、大致形状、错误码、部署注意事项」可以;写客户端代码时请以Docs/api-client.md为准,本文件的字段表 / 示例可能滞后。 历史包袱(如实):本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际系统性滞后于实现与 api-client(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现直接矛盾的陈述、并把若干复述型段落改为引用 api-client;§1 的 REST 端点表已于 2026-10-04 按internal/services/gateway/routes.go逐条核对并补登记(补上此前缺失的POST /auth/refresh、POST /auth/logout、POST /upload、GET /files/{name}、三支/audio/*、GET /admin/llm/catalog、GET /admin/observability/logs、GET /admin/notifications、GET /admin/plugins/installed、GET|PUT /admin/owner-profile、POST /admin/automations/reminders)。仍未补全的是 WS 事件帧清单:§6.4 自述「并非全表」,DevDocs/audit/00-总览.md§4④ 列出的session_read/message_edited/message_deleted等仍未逐行登记。 其余仍成立:所有字段名、取值、鉴权规则、状态码最终以代码为准(internal/services/gateway/*.go);契约变更版本化(REST 走/api/v1,破坏性变更升 v2),不得破坏既有客户端(版本化承诺本身记在 api-client §5)。
0. 基础约定(全局)
- Base URL:
http://<host>:8080。网关是唯一对外端口(单二进制、单端口:8080、零跨进程 IPC 红线);生产一般经反向代理 + HTTPS。 - 版本:REST 全部挂在
/api/v1/{resource};WS 挂/ws/chat;管理端点挂/api/v1/admin/*。 - 响应统一信封:
- 成功:
{"data": <payload>}(HTTP 200 / 201 / 202 / 204)。 - 失败:
{"error": {"code": <string>, "message": <string>}}(HTTP 400/401/403/404/409/500/501 等)。
- 成功:
- 错误码:业务错误带稳定字符串
code(如unauthorized/not_found/bad_request/chat_busy/not_implemented,v0.56 新增的image_too_large(chat.image超 5 MiB)与chat_dispatch_failed(流式兜底派发失败),v0.59 新增的文档四码unsupported_media_type(415) /document_too_large(413) /document_not_found(404) /too_many_documents(400)),便于客户端分支;message面向人读。v0.61:文档 415 的判定改为「扩展名白名单(23 项,含.pdf)∧ 字节嗅探」的成对裁决(HTML/纯文本改名成.pdf、或 PDF 改名成.txt都回415,见 §3.8)。 - CORS:
Access-Control-Allow-Origin为配置的ALLOWED_ORIGINS白名单(*时放开);Allow-Methods: GET, POST, PUT, DELETE, OPTIONS;Allow-Headers: Authorization, Content-Type, X-API-Version;Access-Control-Expose-Headers: X-Voice-Provider(跨域浏览器可读语音降级信号头);Max-Age: 600。OPTIONS预检直接回204。同源/无 Origin 请求放行。注意:X-API-Version仅作advisory惯例头(见 §0.2)——CORS 已放行但它不参与服务端版本协商**,服务端当前不校验、不据此改变行为。** - 请求日志:每个请求由
logging中间件记录一行(method/path/status/duration_ms),并计入可观测性指标。panic 由recover中间件兜底为500 {error:{code:internal,...}},不崩溃进程。 - 超时:WS 连接 ping 周期 54s、pong 等待 60s、写超时 10s;读上限 4096 字节/帧。
- 数据目录
DATA_DIR(v0.55):默认./data;新增保留字哨兵platform(大小写不敏感) → 平台标准数据目录(Windows%APPDATA%\Cyrene、macOS~/Library/Application Support/Cyrene、Linux~/.local/share/cyrene,internal/platform.DefaultDataDir())。显式路径与显式空值行为均不变(空值仍走组合根原兜底)。 - 管理面板安全入口
ADMIN_PATH(v0.55,env 默认admin):面板前端挂在/<ADMIN_PATH>(可配成xxx/admin;非法值回退/admin,见config.ServerConfig.AdminPathPrefix())。命中前缀(含深链)→ 返回注入了<base href="/">与window.__CYRENE_ADMIN_ENTRY__={"prefix":"..."}的index.html(Cache-Control: no-store);形如面板入口但前缀不符的路径 →404(不回落用户端聊天壳);前缀恰好命中 dist 真实文件则仍走文件服务器。REST/WS 路径不随它变化——/api/v1/*、/api/v1/admin/*、/ws/chat、webhook 一律原样(API 不藏,安全靠 JWT + 角色门,design/07 §0.1);该前缀串只注入面板入口页,用户端 bundle 不含它(design/07 §0.2)。 - 额外监听端口的唯一豁免(v0.55 重述):
qq-obv11渠道的 OneBot 反连 / HTTP 上报模式需要独立net.Listen端口(listen_addr),这是AGENTS.md§9 登记的例外(design/13 §0 原文即写「WS server / HTTP server」);必须由配置显式给出,为空则报错、绝不默认开启。其余渠道/模式一律不得新开监听端口。
0.1 鉴权模型(RBAC)
认证基于 JWT Bearer(internal/auth),角色层级 owner > admin > viewer(owner 满足 admin/viewer,admin 满足 viewer,viewer 仅满足 viewer)。
- 获取 token:
POST /api/v1/auth/login(见 §2.2)。token 有效期 24 小时(tokenTTL),签发角色恒为owner。 - REST 鉴权:请求头
Authorization: Bearer <JWT>。s.auth.Authorize(...)中间件:- 缺失/无效/过期 token →
401 {error:{code:"unauthorized",...}}。 - 有效 token 但角色不足 →
403 {error:{code:"forbidden",...}}。 Authorize()(无参)→ 任意已认证角色通过(only read metadata, e.g. GET /sessions、GET /personas)。Authorize(owner)→ 仅 owner;Authorize(owner, admin)→ owner 或 admin。
- 缺失/无效/过期 token →
- WS 鉴权(连接门与帧门是两回事):
GET /ws/chat通过?token=<JWT>或Authorization: Bearer取 token(wsToken),Verify通过即可升级——连接本身不校验角色(任意有效 token,含viewer,都可连接)。但连接之后的帧按角色分档(design/11 §7.2 上行 + §7.1 下行,2026-10-03 落实;权威描述见 api-client §3 顶部的「两把角色门」,本节不重复维护):- 上行帧:非
owner/admin连接发来的任何上行 JSON 帧一律 fail-closed 默认拒,定向回{type:"error",data:{code:"forbidden"}}——不广播、不驱动模型、不落库(internal/services/gateway/ws_route.go的route首条语句即此门)。协议级帧(WS ping/pong/close)不受影响。 - 握手
history回放:仅owner/admin连接收到(internal/services/gateway/ws_history.go的replayHistory首行门)。 - 下行帧:只有 7 个不含内容的状态机信号(
session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full/session_read)发给所有已认证连接;其余全部(正文 / 推理 / 工具 / 持久化与编辑删除的消息 /graph_update/subagent_event/task_output/mood/device_update/reminder…)仅发owner+admin;未登记的新帧类型默认只发 owner/admin(internal/services/gateway/ws_frame_scope.go的frameReachesAll,fail-closed)。 - 口径:
viewer= 可连接、可读 12 条只读监控 REST、可收 7 个状态机信号帧;除此以外 WS 上拿不到任何内容、也发不出任何指令。
- 上行帧:非
- API Key(
X-API-Key):API key 已接入鉴权(v0.51,见 api-client.md §认证)——已认证端点同时接受该头,命中签发表即通过且一律owner级,带该头时只走 key 分支(未知/已吊销/空值回401、不回落 Bearer);WS 握手仍只认 Bearer。
0.2 X-API-Version 头
Docs/api-client.md 建议客户端可带 X-API-Version: 1。该头是 advisory 惯例头——服务端不校验、不据此协商版本,带不带行为完全不变(CORS Allow-Headers 已放行它,跨域客户端发送不会预检失败)。若未来要真正版本化(/api/v1 → 语义化协商),需另行实现服务端校验;在那之前客户端不应依赖它做任何行为分支。
0.3 上传 / 媒体(重要确认)
POST /api/v1/upload(B1)上传图片 → 返回同源 URL(/api/v1/files/<name>),可直接作chat.image;也可继续用 base64 data: URI 或 http(s) URL 走POST /api/v1/chat/ WSchat的image字段(入视觉路由,见 §6 流式与直连)。语义(v0.53 修正,见 api-client §1 行 3):主模型能否收图 = 生效models.json组的multimodal(不是「有没有配独立视觉模型」);LLM_VISION_MODEL/LLM_OCR_MODEL只配视觉/OCR 工具用的模型;env-only 部署(无有效models.json,走.env合成组)用LLM_MULTIMODAL(默认false,v0.58)声明这个能力——它才是该组multimodal的 env 等价物,且非 bool 值启动报错;主模型不收图时由 vision/OCR 工具返回文本描述(另有image-crop工具可裁区域后重读,便于大图分区精读)。内联上限(v0.56,2026-10-04 起可配):chat.image解码后默认 5 MiB(与POST /api/v1/upload同口径;换算成字符串上限ceil(n/3)*4 + 64字符,已含 data: 前缀余量),超限400 {"error":{"code":"image_too_large",...}};字节上限由.env的CHAT_IMAGE_MAX_BYTES配置(默认5242880;≤0 视为「用默认」,不存在「禁用上限」的取值),换配置时错误文案里的 MiB 数字会自动跟随;WSchat帧受 4096 字节读上限约束(超限断连),故 WS 发大图必须走「上传 → URL」。视觉链路的 URL 取回(v0.56,实现事实):chat.image的 http(s) URL(含/api/v1/files/<name>)由服务端自己取回并内联为data:URI 再交 provider,不再把 URL 原样转给上游(同源 upload 产物优先走本地文件读取,vision.WithUploadsDir;取回沿用 http/https 白名单 + 50MiB 上限 + 取回超时)。类型裁决(同一批实现事实):字节嗅探是唯一裁决权——按真实字节判白名单光栅图(png/jpeg/gif/webp/bmp),声明的 Content-Type / 扩展名只作参考且可被否决;嗅探为文本(HTML/PROXY 错误页、SVG)或判不定且来源没声明image/*→ 明确报错(vision.ErrNotImage),绝不静默回退成image/png(旧文若写「非图片响应会被当 PNG」即按此更正)。- 文档存储端点已存在(v0.59;v0.61 纳入 PDF 文本层):
POST /api/v1/documents(owner/admin + 审计document.upload,multipart 字段file,上限CHAT_DOCUMENT_MAX_BYTES默认 20 MiB)与GET /api/v1/documents/{name}(必须 Bearer owner/admin,attachment+nosniff,与图片读侧刻意相反)。类型裁决 = 字节嗅探(唯一)∧ 扩展名白名单(23 项,含.pdf),v0.61 起为成对裁决:纯文本类嗅探基类型必须为text/plain、.pdf必须为application/pdf(HTML 改名成.pdf、或 PDF 改名成.txt均415);mime为嗅探基类型(data.json→text/plain,真 PDF →application/pdf)、url为相对路径;同字节同扩展名重复上传回200+ 同一name+deduped:true。PDF 文本层抽正文(status:"extracted");扫描件/图片型 PDF 无文本层 ⇒ 诚实拒绝status:"unreadable"(需 OCR,当前未做);加密与损坏可区分;Office 仍诚实拒绝。回合引用走chat的可选documents:[{name}](≤5 条、只收服务端存储名);历史/实时 DTO 是documents数组(元素{name,mime,size,chars,truncated,status},无附件时省略该键)。契约见 §3.8 与 api-client §1.2。 - REST 语音端点已存在:
POST /api/v1/audio/transcribe(STT)、POST /api/v1/audio/speech(TTS)、POST /api/v1/audio/voice-clone(声音复刻),均 owner-only + 审计,绕开 WS 4KB 帧限。语音也可走 WSvoice_input(上行)/voice_output(下行);v0.59 起语音回合进会话编排器(落库 / 流式 / 跨端 / 忙时排队),voice_output增sessionId/deviceId?、voice_input增clientMsgId?,见 §6.3/§6.4 与 api-client §3.15。 - 管理端导入知识文档(RAG)走 JSON body 文本(
POST /api/v1/admin/rag),非文件上传。
1. REST 端点总览
下表为全量登记;下文 §2–§5 每端点给出字段级说明。
鉴权列:无/JWT(任意已认证) /owner|admin/owner。里程碑为代码/设计归属的大致里程碑(M8 网关、M9 前端、M10 补齐写、M16 会话持久化等)。#列是登记序号:它历史上曾是跨表连续序号,但 §1.3 起就与前后表重复(§8.1等处有§1.3 #23这类交叉引用,故既有编号一律不动);补登记的行取本表内下一个可用号。
1.1 公开(无鉴权)
| # | 方法 | 路径 | 状态 | 说明 | 里程碑 |
|---|---|---|---|---|---|
| 1 | GET | /api/v1/health |
200 | 存活探针 {status:"ok"} |
M8 |
| 2 | POST | /api/v1/auth/login |
200 | 换取 JWT(恒 owner) | M8 |
| 3 | POST | /api/v1/auth/refresh |
200/400/401 | 静默续期(B2):body {refreshToken} → 新 access + 新 refresh(默认单次使用旋转;AUTH_REFRESH_ROTATE_DISABLED=true 时不旋转、旧 token 保持有效) |
M17 |
| 4 | POST | /api/v1/auth/logout |
200/400/401 | 登出(B2):body {refreshToken} 撤单个 grant(幂等);带 Bearer 且无 body 撤该账号全部(两种响应形状不同,见 §2.4) |
M17 |
1.2 用户侧(已认证)
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|---|---|---|---|---|---|
| 3 | POST | /api/v1/chat |
owner|admin | 200/202/400/404/409/500/503 | 发消息(同步回复或触发 WS 流式);v0.56:stream:true 受理成功才回(202 续接排队 / 409 队列满 / 500 chat_unavailable 未接 loop / 503 chat_dispatch_failed 兜底派发失败)、image 超上限 → 400 image_too_large(上限 = CHAT_IMAGE_MAX_BYTES,默认 5 MiB 与 /upload 同口径;≤0 视为用默认)、clientMsgId 重复 → 200 {duplicate:true};v0.59:可选 documents:[{name}](≤5 条、只收服务端存储名)→ 超限 400 too_many_documents、名非法/不存在 404 document_not_found(见 §3.8);v0.65:可选 parallel:true(忙时显式声明把本条作为独立一路;照准则同步入口 202 {parallel:true,turnId}、stream:true 的 200 {stream:true,parallel:true,turnId};未照准 / 省略时形状不变) |
M8/M15 |
| 4 | GET | /api/v1/sessions |
JWT | 200 | 会话列表(持久化会话 or 渠道会话回退) | M16 |
| 5 | GET | /api/v1/personas |
JWT | 200 | 人格列表 | M8 |
| 6 | GET | /api/v1/sessions/{id}/messages |
owner|admin | 200/404 | 会话消息热分页;v0.63:会话不存在/已删除 ⇒ 404 session_not_found,存在但没有消息仍 200 {"data":[]}(见 §3.4) |
M16 |
| 7 | POST | /api/v1/sessions |
owner|admin | 201 | 新建次级会话 | M16 |
| 8 | DELETE | /api/v1/sessions/{id} |
owner|admin | 200 | 删除次级会话 | M16 |
| 9 | PATCH | /api/v1/sessions/{id} |
owner|admin | 200 | 会话改名(A4) + 置顶(pinned, D5) body {title?,pinned?}(部分更新,只回显变更字段;见 §3.6a) |
M16 |
| 10 | PATCH | /api/v1/messages/{id} |
owner|admin | 200 | 编辑单条消息(D3) body {content} |
M16 |
| 11 | DELETE | /api/v1/messages/{id} |
owner|admin | 200 | 删除单条消息(D3) | M16 |
| 12 | GET | /api/v1/meta/enums |
JWT | 200 | 客户端共享枚举权威表(B4):platform/mood/mode + unknown 回落约定 |
M8 |
| 13 | POST | /api/v1/sessions/{id}/read |
owner|admin | 200 | 推进已读游标(D4) body {before?}(缺省 = 现在)→ {sessionId,readAt}(见 §3.6b) |
M16 |
| 14 | POST | /api/v1/upload |
owner|admin | 201 | multipart 图片上传(B1):光栅白名单(png/jpeg/gif/webp/bmp)、解码后上限 5 MiB → 同源 URL,可直接作 chat.image(见 §7) |
M17 |
| 15 | GET | /api/v1/files/{name} |
无 | 200/404 | 已存上传的同源静态读(128-bit 随机名即能力 URL,<img>/chat.image 带不了 Bearer);name 只接受 32 位小写 hex + 白名单扩展名,路径穿越全形态拒绝 |
M17 |
| 16 | POST | /api/v1/audio/transcribe |
owner | 200 | 语音转写(STT,绕开 WS 4KB 帧限);静音/空结果时响应带 provider |
M17 |
| 17 | POST | /api/v1/audio/speech |
owner | 200 | 语音合成(TTS);响应头 X-Voice-Provider 报告实际提供方(降级可见) |
M17 |
| 18 | POST | /api/v1/audio/voice-clone |
owner | 200 | 声音复刻(百炼 create_voice) | M17 |
| 19 | POST | /api/v1/documents |
owner|admin | 201/200/400/413/415 | 文档上传(v0.59;v0.61 纳入 PDF):multipart 字段 file(上限 CHAT_DOCUMENT_MAX_BYTES,默认 20 MiB)→ 201 {name,url,mime,size,sha256,deduped:false};同字节同扩展名重复上传 → 200 + 同一 name + deduped:true;字节嗅探(唯一裁决)∧ 扩展名白名单(23 项,含 .pdf),v0.61 起为成对裁决(纯文本类 → text/plain、.pdf → application/pdf;HTML 改名成 .pdf、PDF 改名成 .txt 均 415),mime 为嗅探基类型、url 为相对路径;错误码 400 bad_request/413 document_too_large/415 unsupported_media_type(见 §3.8) |
M-S(design/24 S1 + §7 S6) |
| 20 | GET | /api/v1/documents/{name} |
owner|admin | 200/404 | 文档读取(v0.59;v0.61 增 PDF MIME):必须带 Bearer(与图片读侧免鉴权能力 URL 刻意相反);name = 32 位小写 hex + 白名单扩展名,路径穿越全形态拒绝;Content-Disposition: attachment + X-Content-Type-Options: nosniff;嗅探不出放行类型 → application/octet-stream(真 PDF 按成对裁决如实下发 application/pdf,仍永不可内联);统一 404 document_not_found(见 §3.8) |
M-S(design/24 S1 + §7 S6) |
1.3 管理后台(/api/v1/admin/*)
鉴权分档(以
internal/services/gateway/routes.go为准;权威矩阵见Docs/api-client.md§2.0,本表已按其逐条核对):管理读分两层——只读监控档owner|admin|viewer(12 条非敏感 GET:personas/sessions/plugins/plugins/installed/channels/iot/automations/rag/observability/{metrics,trace,logs}/notifications,现已全部登记进下表)与owner|admin档(含他人数据 / 消息正文 / 凭据面 / 审计流水);写一律owner+ 全程审计。下表按注册顺序,标注鉴权与状态。
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|---|---|---|---|---|---|
| 9 | GET | /api/v1/admin/personas |
owner|admin|viewer | 200 | 人格列表 | M8 |
| 10 | GET | /api/v1/admin/sessions |
owner|admin|viewer | 200 | 会话列表 | M16 |
| 11 | GET | /api/v1/admin/memories |
owner|admin | 200 | 记忆列表 | M8 |
| 12 | GET | /api/v1/admin/plugins |
owner|admin|viewer | 200 | 插件/能力列表 | M8 |
| 62 | GET | /api/v1/admin/plugins/installed |
owner|admin|viewer | 200 | 运行时安装的插件清单(脱敏配置 + 每 server 工具数);鉴权以 routes.go 的注册(viewerAuth)为准 |
M8 |
| 13 | GET | /api/v1/admin/channels |
owner|admin|viewer | 200 | 渠道列表 + 已配账号(掩码+bot 身份+capabilities) | M8/M17 |
| 14 | GET | /api/v1/admin/channels/messages |
owner|admin | 200 | IM 适配器日志流(全量/平台/适配器三粒度) | M16 |
| 15 | GET | /api/v1/admin/channels/{platform}/messages |
owner|admin | 200 | 某平台 IM 适配器日志流 | M16 |
| 16 | GET | /api/v1/admin/iot |
owner|admin|viewer | 200 | IoT 设备列表 | M8 |
| 17 | GET | /api/v1/admin/llm |
owner|admin | 200 | LLM 多厂商多模型组(APIKey 不回显) | M8/M15 |
| 65 | GET | /api/v1/admin/llm/catalog |
owner|admin | 200/503 | 模型目录候选(?refresh=1 强制在线刷新,否则复用 ≤24h 本地快照)→ {source,fetchedAt?,stale?,warning?,providers[]};既无快照又抓不到 → 503 catalog_unavailable(见 §4.9.1) |
M15 |
| 18 | GET | /api/v1/admin/automations |
owner|admin|viewer | 200 | 自动化/提醒列表 | M8 |
| 19 | GET | /api/v1/admin/users |
owner|admin | 200 | 账号/API-key 列表 | M8 |
| 20 | GET | /api/v1/admin/backthink |
owner|admin | 200 | 后台思考记录 | M9/M10 |
| 21 | GET | /api/v1/admin/rag |
owner|admin|viewer | 200 | 知识库文档列表(可带 ?query= 并入命中) |
M10 |
| 22 | POST | /api/v1/admin/rag/query |
owner|admin | 200 | 知识库检索 | M10 |
| 23 | POST | /api/v1/admin/rag/delete |
owner | 200/404 | 删除知识文档(项目索引条目 + 级联章节,真实删除;不存在 404) | 后 M12 |
| 24 | GET | /api/v1/admin/observability/metrics |
owner|admin|viewer | 200 | 网关请求指标 | M10 |
| 25 | GET | /api/v1/admin/observability/trace |
owner|admin|viewer | 200 | 最近 trace span | M10 |
| 63 | GET | /api/v1/admin/observability/logs |
owner|admin|viewer | 200 | 后端日志尾部(?level=/?limit=,最新在前);读取即建立推送订阅(v0.55,见 §4.16) |
M10 |
| 64 | GET | /api/v1/admin/notifications |
owner|admin|viewer | 200 | 统一通知 feed(审计 + 渠道 + 后台思考 + 系统聚合;?type=/?before=/?limit=) |
M10 |
| 26 | GET | /api/v1/admin/observability/audit |
owner|admin | 200 | 审计记录 | M10 |
| 27 | POST | /api/v1/admin/plugins/policy |
owner | 200 | 写工具策略(审计) | M8 |
| 28 | GET | /api/v1/admin/sessions/{id}/export |
owner|admin | 200 | 导出会话元数据 | M8 |
| 29 | POST | /api/v1/admin/personas |
owner | 201 | 新建人格 | M8 |
| 30 | PUT | /api/v1/admin/personas/{id} |
owner | 200 | 编辑人格 | M8 |
| 66 | GET | /api/v1/admin/owner-profile |
owner | 200 | 主人档案(白名单账号 + 显示名/关系备注/默认角色;档案文件不存在返回空白档案而非 404) | M17 |
| 67 | PUT | /api/v1/admin/owner-profile |
owner | 200 | 保存主人档案(0600 原子落盘 + 热替换活档案,新白名单立即参与判主;校验失败 400) | M17 |
| 31 | POST | /api/v1/admin/users |
owner | 501 | 新建用户(M10) | M10 |
| 32 | POST | /api/v1/admin/users/apikey |
owner | 201 | 签发 API key | M8 |
| 33 | DELETE | /api/v1/admin/users/apikey/{id} |
owner | 200 | 吊销 API key | M8 |
| 34 | POST | /api/v1/admin/iot/{id}/control |
owner | 200 | 设备控制 | M8 |
| 35 | POST | /api/v1/admin/iot/scenarios |
owner | 501 | IoT 场景(M10) | M10 |
| 36 | POST | /api/v1/admin/iot/rules |
owner | 501 | IoT 规则(M10) | M10 |
| 37 | POST | /api/v1/admin/automations |
owner | 201 | 新建自动化/提醒 | M8 |
| 68 | POST | /api/v1/admin/automations/reminders |
owner | 201 | 新建定时提醒 body {id?,at(RFC3339),message} → 真实进 ReminderStore,到点发布 reminder.trigger(P0-22) |
后 M12 |
| 38 | POST | /api/v1/admin/automations/{id}/run |
owner | 200 | 手动触发规则 | M8 |
| 39 | DELETE | /api/v1/admin/automations/{id} |
owner | 200 | 删除规则 | M8 |
| 40 | POST | /api/v1/admin/llm |
owner | 201 | 保存 LLM provider 组(落盘 + 热更新) | M8/M15 |
| 41 | POST | /api/v1/admin/llm/test |
owner | 200 | 真实模型调用测试 | M8/M15 |
| 70 | POST | /api/v1/admin/llm/reset-from-env |
owner | 200/400/500 | 重置为 env 默认(v0.62):把面板里 .env 声明过的键覆盖回 env 值(组集合不增减、env 未声明的键原样保留、无键可改时不写盘),body {confirm:true} 缺则 400,只回键名不回显凭据;失败 env_defaults_unreadable/models_unreadable/llm_persist_failed/llm_reload_failed/llm_unavailable(见 §5.13a) |
后 M12 |
| 42 | POST | /api/v1/admin/channels/open |
owner | 200 | 连接渠道 | M8 |
| 43 | POST | /api/v1/admin/channels/close |
owner | 200 | 断开渠道 | M8 |
| 44 | POST | /api/v1/admin/channels/test |
owner | 200 | 结构性身份识别测试 | M8/M16 |
| 45 | GET | /api/v1/admin/channels/{platform}/login-qr |
owner | 200 | 微信 iLink 扫码绑定 QR | M16 |
| 46 | GET | /api/v1/admin/channels/{platform}/login-status |
owner | 200 | 微信 iLink 扫码状态 | M16 |
| 47 | GET | /api/v1/admin/channels/{platform}/view |
owner | 200 | KOOK 账号聚合视图 | M17 |
| 48 | GET | /api/v1/admin/channels/{platform}/friends |
owner | 200 | KOOK 好友/申请/屏蔽 | M17 |
| 49 | POST | /api/v1/admin/channels/{platform}/friend/handle |
owner | 200 | KOOK 处理好友申请 | M17 |
| 50 | POST | /api/v1/admin/channels/{platform}/message/test |
owner | 200 | KOOK 发送测试消息 | M17 |
| 51 | PUT | /api/v1/admin/channels/{platform}/config |
owner | 201 | 保存渠道账号配置(落盘 + 热更新) | M16 |
| 52 | DELETE | /api/v1/admin/channels/{platform}/config |
owner | 200 | 删除渠道账号 | M16 |
| 53 | POST | /api/v1/admin/plugins/install |
owner | 200 | 热安装 MCP server 插件(body {kind:"mcp",name,config:{transport,endpoint?/command?,args?,apiKey?,env?}} → 连接+发现+RegisterTool 进 registry → 落盘 plugins.json → 200 {data:{installed,name,kind,transport,toolCount}});连接/发现失败 502 plugin_install_failed 且不落盘;非法 400;manager 未注入 501 |
后 M12 |
| 54 | POST | /api/v1/admin/plugins/uninstall |
owner | 200 | 移除插件(MCP → Unregister+删 plugins.json 配置;普通能力 → reg.Remove) |
M8 |
| 55 | POST | /api/v1/admin/memories |
owner | 201 | 手动添加记忆 | M8 |
| 56 | POST | /api/v1/admin/memories/search |
owner | 200 | 记忆检索 | M8 |
| 57 | POST | /api/v1/admin/rag |
owner | 201 | 导入知识文档 | M10 |
| 58 | POST | /api/v1/admin/system/revoke-keys |
owner | 200 | 吊销全部 API key | M10/M11 |
| 59 | POST | /api/v1/admin/system/rotate-jwt |
owner | 200 | 轮换 JWT 密钥 | M10/M11 |
| 60 | POST | /api/v1/admin/system/backup |
owner | 200 | 备份数据库 | M10/M11 |
| 61 | POST | /api/v1/admin/system/restore |
owner | 200 | 恢复数据库 | M10/M11 |
| 69 | GET | /api/v1/admin/system/storage |
owner | 200/500 | 存储状态(只读,v0.60):SQLite 文件/WAL/页级统计 + documents 表件数与占用 + 向量增强真假(kind/enabled/ready);不含凭据(DSN 一个字都不回);路径/活连接未接入或读不到表 → 500 storage_unavailable(见 §5.35) |
M10/M11 |
| 71 | GET | /api/v1/admin/llm/usage |
owner|admin | 200/400/500 | LLM 用量汇总(只读,v0.64,切片 U1):?since=&until=&purpose=(RFC3339 或 Unix 毫秒;缺省近 24h;跨度上限 90 天,超过 → 400)→ {window,totals{calls,prompt,completion,cacheHit,cacheMiss,hitRate},byPurpose[],byModel[]};hitRate = cacheHit/(cacheHit+cacheMiss),分母 0 ⇒ 0;绝不含凭据与消息正文;未接线/查库失败 → 500 usage_unavailable(见 §4.19) |
后 M12 |
另:外部 webhook 回调(如 webhook 渠道的 HMAC 回调)由 composition root 通过 Deps.Webhooks 动态挂载,不走网关鉴权(handler 自校验签名/授权);路径由调用方决定,不在上表枚举。
补登记说明(2026-10-04):上表中 #62–#68 是本次按 routes.go 逐条核对后新增的行(plugins/installed / observability/logs / notifications / llm/catalog / owner-profile GET+PUT / automations/reminders),插在各自的鉴权分档里、既有编号一律未动。§1.1 的 #3/#4(auth/refresh、auth/logout)与 §1.2 的 #14–#18(upload、files/{name}、三支 audio/*)同理。v0.59 再补两行:§1.2 的 #19/#20(POST /api/v1/documents、GET /api/v1/documents/{name},均按 routes.go 的 writeAuth 注册核对)——同样取本表下一个可用号、不动既有编号。v0.60 再补一行:#69 GET /api/v1/admin/system/storage(owner-only 只读,按 routes.go 的 ownerAuth 注册核对;无审计动作——它是查询不是动作),同样取下一个可用号、既有编号未动。v0.62 再补一行:#70 POST /api/v1/admin/llm/reset-from-env(owner-only 写,按 routes.go 的 ownerAuth 注册核对,见 §5.13a),同样取下一个可用号、既有编号未动。v0.64 再补一行:#71 GET /api/v1/admin/llm/usage(owner|admin 读,切片 U1,按 routes.go 的 writeAuth 注册核对;无审计动作——它是查询不是动作),同样取下一个可用号、既有编号未动。仍未登记的是 WS 事件帧(见 §6.4 与本文档开头的历史包袱说明)。
WS:GET /ws/chat(握手,JWT,见 §6)。
2. 公开端点
2.1 GET /api/v1/health
- 鉴权:无(探针)。
- 响应:
200{"data":{"status":"ok"}}。 - 备注:仅存活探针;不含依赖健康状态。示例:
curl -s http://<host>:8080/api/v1/health
# => {"data":{"status":"ok"}}
2.2 POST /api/v1/auth/login
- 鉴权:无(换取 JWT)。
- 请求 Body(
loginRequest):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
username |
string | 是 | 管理员用户名(对照 cfg.Auth.AdminUser) |
password |
string | 是 | 管理员密码(对照 cfg.Auth.AdminPass) |
{"username":"admin","password":"s3cret"}
- 响应:
200{"data":{"token":"<JWT>","expiresAt":<unix秒>,"role":"owner","refreshToken":"<opaque>"}}(loginResponse)。
| 字段 | 类型 | 说明 |
|---|---|---|
token |
string | 已签名 JWT(HS256),下文请求头 Authorization: Bearer <token> |
expiresAt |
int64 | 过期时间,Unix 秒 = now + 24h(tokenTTL=24h) |
role |
string | 恒为 "owner"(代码硬编码 auth.RoleOwner) |
refreshToken |
string | 不透明 256-bit 随机串(B2)——服务端仅存其 SHA-256 哈希,明文只在响应出现一次;用于 POST /api/v1/auth/refresh 静默续期,AUTH_REFRESH_TTL 默认 720h |
- 错误:
400{"error":{"code":"bad_request","message":"invalid request body"}}(JSON 解析失败);401{"error":{"code":"unauthorized","message":"invalid username or password"}};500{"error":{"code":"login_failed",...}}(签名失败)。 - 备注:登录响应以代码为准 =
{token, expiresAt, role, refreshToken?}(不是{token,jwt})。refreshToken是 B2 新增的非透明长期凭据(未接线 refresh 存储时该键省略,此时形状与 B2 之前一致);旧api-client.md§1 表内一行写{token,jwt}为历史笔误,以本档为准。curl:
curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}'
# => {"data":{"token":"eyJ...","expiresAt":1799...,"role":"owner","refreshToken":"...base64url..."}}
2.3 POST /api/v1/auth/refresh(B2)
- 鉴权:公开,body 带
refreshToken。 - 请求 Body:
{refreshToken:"<opaque>"}。 - 响应:
200{"data":{"token":"<新JWT>","expiresAt":<unix秒>,"role":"<owner|admin|viewer>","refreshToken":"<新opaque?>"}}(role恒返回,取自该 refresh grant 记录的角色)——默认单次使用旋转:旧 refresh 立即作废并签发新的;AUTH_REFRESH_ROTATE_DISABLED=true时旧 token 保持有效、不返回新 refresh(refreshToken省略)。 - 错误:
400 bad_request(JSON 解析失败 / 空 token);401{"error":{"code":"invalid_grant",...}}(未知/已撤销/已过期,统一口径,防枚举);仅成功才审计。 - curl:
curl -s -X POST http://<host>:8080/api/v1/auth/refresh -H 'Content-Type: application/json' -d '{"refreshToken":"<opaque>"}'
2.4 POST /api/v1/auth/logout(B2)
- 鉴权:公开,两种方式二选一——body
{refreshToken}撤单个 grant(幂等);或无 body 但带Authorization: Bearer <jwt>撤该账号全部 grant(全设备登出)。 - 响应(两种形状,别混):
- body 撤单个:
200{"data":{"revoked":<bool>}}(幂等——未知/已吊销的 token 得revoked:false而不是错误)。 - Bearer 全撤:
200{"data":{"revoked":<bool>,"revokedCount":<n>}}(revokedCount= 本次删除的该账号 refresh 行数,revoked=revokedCount > 0)。
- body 撤单个:
- 错误:不存在的 token 为幂等 no-op(仍
200);非法 body400;refreshToken与Authorization都没给回400{"error":{"code":"bad_request","message":"refreshToken or Authorization bearer token is required"}};Bearer 无效/过期回401{"error":{"code":"invalid_grant",...}};撤销失败回500 logout_failed。
3. 用户侧端点(已认证)
3.1 POST /api/v1/chat
- 鉴权:
Authorization: Bearer <JWT>,Authorize(owner, admin)(owner 或 admin)。viewer 不能写。 - 请求 Body(
chatRequest):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
sessionId |
string | 否 | 目标会话 id;缺省 "web"(主会话)。子会话用 web:<uuid> |
text |
string | 是* | 用户文本。text 与 image 至少一项非空 |
image |
string | 否 | 视觉输入:base64 data: URI 或 http(s) URL;入 vision/OCR 工具或主模型多模态(design/06)。图文并存则为带图 + 文本的 turn。语义(v0.53):主模型能否收图看生效组的 multimodal;LLM_VISION_MODEL/LLM_OCR_MODEL 只配视觉/OCR 工具的模型;env-only 部署(无有效 models.json,走 .env 合成组)用 LLM_MULTIMODAL(默认 false,v0.58)声明该能力(见 api-client §1 行 3) |
deviceId |
string | 否 | 客户端生成的端标识(design/16 §4.5,如 web:chrome:a1b2c3),落库到消息,可选 |
mode |
string | 否 | 行为模式(design/01 §3、design/16 §5.1,如 chat/code/…);随该轮落库供气泡渲染;IM 恒 chat、缺省回落 chat(chatRequest.Mode) |
clientMsgId |
string | 否 | 客户端幂等键(v0.56,G4):同一会话内同一个非空值只承认第一条,重复到达回 200 {"data":{"duplicate":true,"messageId":...,"sessionId":...}} 且不落库、不起回合;省略 = 与加入该字段前逐字相同(老客户端零影响)。原生端用它把本地乐观气泡对齐到服务端真相 |
documents |
array | 否 | 文档附件引用(v0.59):[{"name":"<32位hex>.<ext>"}],≤5 条、只收服务端存储名(POST /api/v1/documents 的响应 name),绝不接受任意 URL;客户端多带的 mime/url/size 一律被服务端覆盖。超出 → 400 too_many_documents(先于任何文件系统访问);名非法/穿越/不存在 → 404 document_not_found;text/image/documents 至少一项非空(纯附件回合成立)。WS chat 帧同形(见 §6.3) |
parallel |
bool | 否 | 独立一路的显式声明(v0.65):true 且会话正忙、单会话并发额度未满 ⇒ 本条升为一条独立的并行路立即起(不排队);空闲时无效果;省略 = 与加入该字段前逐字不变(服务端不做启发式自动升级)。额度:单会话默认 2、硬上限 3、<2 = 关闭真并行(此时仍可传,只是落回排队并回 pending_ack{reason:"parallel_limit"})。WS chat 帧同形(见 §6.3) |
stream |
bool | 否 | true = 直达会话编排器的流式提交(v0.56/G2:接了编排器就直接 Submit,不再经可丢弃的 bus;未接编排器才回退 publishChatStream 发 ws.chat),受理成功后才回响应;缺省 / false = 同步返回完整回复(Go bool 零值即 false,仍无「必填」约束) |
{"sessionId":"web","text":"你好","deviceId":"web:chrome:a1b2c3","stream":false}
- 响应:
- 同步(
stream=false,会话空闲)200{"data":{"reply":"<助手文本>","steps":<int>,"stopReason":"<string>"}}(chatResponse)。steps为工具循环步数,stopReason为停止原因(如stop/idle/error)。 - 同步但会话正忙(
SubmitSync返回Pending)202{"data":{"queued":true,"queueDepth":<int>}}(非阻塞 ack,稍后按上下文续接)。 - 同步、已升为独立并行路(
SubmitSync返回Parallel,v0.65,仅在客户端发了parallel:true且额度未满时出现)202{"data":{"parallel":true,"turnId":"turn-…"}}(不是queued:第二条路立即起、不排队,本次请求拿不到同步reply,文本照旧经 WS 流式到达;turnId= 那一路的权威回合标识、与该路全部流帧同值)。 - 同步且
clientMsgId重复到达(SubmitSync返回Duplicate)200{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}(v0.56/G4:不落库、不起回合,messageId是既有那条的 id)。 - 流式(
stream=true,v0.56 行为变更):受理成功后才回 —— 空闲已起回合200{"data":{"stream":true}};正忙已续接排队202{"data":{"stream":true,"queued":true,"queueDepth":<int>}};待处理满409{"error":{"code":"chat_busy",...}};幂等键重复200{"data":{"duplicate":true,"messageId":<id>,"sessionId":<id>}}(不落库、不起回合)。;v0.65:已升为独立并行路 ⇒200 {"data":{"stream":true,"parallel":true,"turnId":"turn-…"}}(仅parallel:true且额度未满时)。实际文本经 WS 推送(见 §6)。修复前该分支在问编排器之前就回200 {"data":{"stream":true}},故202/409永不出现;未接 loop 时由200 {stream:true}改为500 chat_unavailable(与同步路径同口径)。
- 同步(
- 错误:
400{"error":{"code":"bad_request","message":"text or image is required"}}(空消息)。400{"error":{"code":"image_too_large",...}}(v0.56/G3:image解码后超上限;上限 =CHAT_IMAGE_MAX_BYTES(默认 5 MiB,≤0用默认),base64 字符串上限ceil(n/3)*4+64,与POST /api/v1/upload同口径,文案指路该端点)。400{"error":{"code":"too_many_documents",...}}(v0.59:documents超过 5 条,先于任何文件系统访问判定)。404{"error":{"code":"document_not_found",...}}(v0.59:documents[].name形态非法 / 路径穿越 / 文件不存在——三种情况同一响应)。409{"error":{"code":"chat_busy","message":"会话正忙,待处理消息已满"}}(pending 队列满,PendingFull)。500{"error":{"code":"chat_unavailable",...}}(runner未注入)/{"error":{"code":"chat_failed",...}}(回合运行出错)。503{"error":{"code":"chat_dispatch_failed",...}}(v0.56/G2:仅未接编排器时的总线兜底派发失败——本轮未提交,不再是静默 200)。
- 备注:
- 会话编排(design/15):同会话正忙时新消息不阻塞、入 pending(默认上限 5);满则
409 chat_busy。状态经 WS 状态事件通知(§6.4)。 - 幂等(v0.56/G4):带
clientMsgId的用户轮次由(session_id, client_msg_id)的部分唯一索引原子判重(写在internal/runtime/history/store.go的AppendFull+client_msg_id.go),重复到达不落库、不起回合;该键随后出现在消息 DTO(含GET /sessions/{id}/messages)。 - 主会话
web绑定全量访问 env(loop.OwnerEnv():记忆读写 + 行为不变)。 - 敏感字段(
image可能为 base64)不走日志明文。 - curl:
- 会话编排(design/15):同会话正忙时新消息不阻塞、入 pending(默认上限 5);满则
TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}' | jq -r .data.token)
curl -s -X POST http://<host>:8080/api/v1/chat \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-API-Version: 1' \
-d '{"sessionId":"web","text":"讲个笑话","stream":false}'
# => {"data":{"reply":"...","steps":2,"stopReason":"stop"}}
3.2 GET /api/v1/sessions
- 鉴权:
Authorize()(任意已认证角色可读元数据)。 - 响应:注入
HistoryStore时返回持久化会话(historySessionSummary);否则回退渠道会话列表(SessionSummary,或[])。
A) 持久化会话(history != nil,主 + 子):
{"data":[
{"id":"web","type":"main","title":"主控台","createdAt":1753...,"pinned":false,"unreadCount":2,
"lastMessage":{"role":"assistant","content":"好的,我在。","createdAt":1753...},"personaId":"xilian"},
{"id":"web:<uuid>","type":"sub","title":"临时小事","createdAt":1753...,"pinned":true,"unreadCount":0}
]}
id:主会话恒"web";次级为web:<uuid>。type:"main"|"sub"。title:可选(omitempty;首条用户消息到达时自动命名,无标题时缺该键)。createdAt:int64(Unix 毫秒)。pinned:bool,恒返回(D5 置顶标志;只是排序元数据,客户端无需第二次调用即可排序)。unreadCount:int,恒返回(D4;= 该会话中createdAt >已读游标的消息条数,0= 无未读)。它与lastMessage由同一条 SQL 派生,故查询数不随会话数增长(无 N+1)。lastMessage:可选(omitempty);最后一条消息的纯展示预览{role("user"|"assistant"), content, createdAt}——content已折叠空白并截断到 60 rune 加省略号;无消息的会话缺该键。personaId:可选(omitempty,v0.42);该会话绑定的角色 id(design/22 §2/§4.5)。缺省 = 继承全局默认角色(owner.yaml:defaultPersona),客户端据此渲染激活会话的角色名/头像。
排序(以代码为准 = pinned DESC, (type='main') DESC, created_at ASC):置顶会话最前 → 未置顶的主会话 → 未置顶的子会话(按 createdAt 升序)。⚠️ 置顶的次级会话会排在主会话之前,因此客户端不要假定「index 0 就是主会话」;要定位主会话请按 id == "web"(或 type == "main")查找。
B) 渠道会话回退(history == nil):
{"data":[{"platform":"kook","channelId":"...","sessionId":"...","actorRole":"owner","owner":true,"isGroup":false,"memWritable":true}]}
SessionSummary 字段:platform / channelId / sessionId / actorRole(owner|admin|viewer|...)/ owner(bool) / isGroup(bool) / memWritable(bool)。
- 错误:
500{"error":{"code":"sessions_failed",...}}(列表失败)。 - 备注:
HistoryStore未注入时后端不报错,退化为渠道视图(向后兼容)。curl:
curl -s http://<host>:8080/api/v1/sessions -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
3.3 GET /api/v1/personas
- 鉴权:
Authorize()(任意已认证角色)。 - 响应:
200{"data":[<PersonaSummary>...]}(无 store 时[])。
PersonaSummary 字段(契约见 api-client §1 行 9;用户侧 GET /api/v1/personas 与管理员侧 GET /api/v1/admin/personas 同形,唯一差别是 admin 侧多一个 thinking?):
| 字段 | 类型 | 说明 |
|---|---|---|
id |
string | 人格 id(PersonaID,回退 ID) |
name |
string | 显示名(c.Name) |
character |
string | 角色名(当前恒 = name) |
isDefault |
bool | 是否默认人格 |
mode |
string | 首个行为模式(modes[0],未设置为 "";内置默认人格未配置 modes,故实际为 "") |
memoryNamespace |
string | 记忆命名空间。读侧当前恒为空串 ""(collectPersonas 不填此字段——已知实现事实,客户端不要依赖读侧的该字段);人格写响应(POST/PUT /admin/personas)才填真实值,未显式配置时为派生默认 persona.<id>(loader.Persona.MemoryNamespace()) |
avatar |
string | 视觉头像 URL(omitempty;该人格自己的,缺失则为空) |
chatBackground |
object | omitempty;{landscape, portrait} 双朝向壁纸,仅该人格自己的 |
mood |
string | 该角色当前情绪(design/01 §2;恒返回,无 omitempty)。取值见 GET /meta/enums 的 moods;"" = 该角色暂无被跟踪的情绪(不伪造默认值) |
thinking |
object | 仅 GET /admin/personas 返回(omitempty,未配置则无该键):{budgetTokens,effort,showToUser,mode} 推理/呈现旋钮;用户侧 GET /personas 不返回(详见 api-client「人格 thinking 读侧契约」) |
{"data":[{"id":"cyrene","name":"Cyrene","character":"Cyrene","isDefault":true,"mode":"","memoryNamespace":"","mood":"calm","avatar":"/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png","chatBackground":{"landscape":"/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png","portrait":"/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png"}}]}
上例中
memoryNamespace:""是读侧的当前真实值;mood:"calm"来自默认人格configs/personas/cyrene.yaml的emotion.initialState(其它角色可能为"")。
- 备注:视觉资产按角色作用域读取,绝不返回别的角色的;缺失时前端回退中性默认。curl:
curl -s http://<host>:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
3.4 GET /api/v1/sessions/{id}/messages
- 鉴权:
Authorize(owner, admin)(会话消息/thinking 属主人私密历史,viewer 不可读)。 - Path:
{id}= 会话注册表chat_sessions里可寻址的会话 id(web或web:<uuid>)。v0.63 起:不在chat_sessions里的 id(已删除的次级会话、凭空编造的 id、以及隔离的 IM 会话sess-xxx与内部sess-backthink)⇒404 session_not_found—— 这与POST /sessions/{id}/read(v0.37 起对隔离会话即 404)是同一口径;隔离会话的消息请走GET /api/v1/admin/channels/{platform}/messages(§4.7)。⚠️ 旧文那句「IM 隔离会话sess-xxx亦可用」已随 v0.63 作废。 - Query:
参数 类型 必填 默认 说明 limitint 否 50 页大小,需 >=1,钳制上限 200(maxPageLimit);非法回 400beforeint64 否 0 游标:上一页最旧消息的 createdAt(Unix 毫秒),向更旧翻页afterint64 否 0 正向增量(A5):返回 created_at > after,升序;与before互斥(同传 400);负数/非数字回 400qstring 否 - 会话内搜索(D2): content LIKE大小写不敏感,%/_/\转义为字面量;仍倒序 + before/limit 游标;与after互斥(同传 400);空q走普通分页;严格限定单会话(隔离会话不外泄)platformstring 否 - 来源平台过滤(design/16 §4.4)。Web 端不传 = 全量共享主会话;IM 传本平台(如 telegram)只拉本渠道消息 - 响应:
200{"data":[<historyMessage>...]};before时按时间倒序(newest first,需reverse()渲染);after时按时间升序(最旧在前,可直接顺序渲染/追加)。
historyMessage 全字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
string | 后端生成的消息 id(客户端据此去重) |
sessionId |
string | 会话 id |
role |
string | user / assistant |
content |
string | 正文(纯文本/图像消息文本;图像为 image 字段) |
mode |
string | 该条的模式(omitempty,供渲染气泡不用猜) |
mood |
string | 情绪标记(omitempty) |
image |
string | 图像引用(omitempty) |
documents |
array | 文档附件与抽取统计(omitempty,v0.59):[{name,mime,size,chars,truncated,status,reason?}],≤5 条、顺序 = 客户端发送顺序;0 个附件时该键省略;`status ∈ extracted |
thinking |
string | 思维链(omitempty;本端点 owner/admin-only,故可达;公开回放 / 实时持久化帧中已剥离)。v0.59 口径(有意默认,非缺陷):消息体的 thinking 字段在 message_persisted / channel_persisted / message_edited 与握手 history 回放中恒为空;历史消息的思维链只有本端点(owner|admin)返回。实时思维链另走 thinking.* 流帧(§6.4,owner/admin 档) |
platform |
string | 来源平台(web / telegram / weixin-ilink / qq-obv11...,omitempty) |
deviceId |
string | 客户端端标识(omitempty,如 web:chrome:a1b2c3) |
senderName |
string | 第三方发送者昵称(omitempty;群聊/陌生人消息带,主人私聊为空) |
adapterId |
string | 产生该消息的适配器注册名(omitempty,如 kook:3bef...;Web 消息为空) |
personaId |
string | 该消息说话时的角色 id(omitempty,v0.42,design/22 §7-5):会话角色在创建时固定,assistant 消息 = 生成它的角色、user 消息 = 当回合角色;空 = 全局默认角色(v0.42 之前的全部历史行)。客户端据此按角色渲染名字/头像 |
clientMsgId |
string | 客户端幂等键(omitempty,v0.56/G4):带 clientMsgId 发来的 user 消息回填它;空 = 该消息没有幂等键(老客户端 / IM 镜像 / 历史行)。客户端用它把本地乐观气泡对齐到服务端真相 |
createdAt |
int64 | Unix 毫秒(兼作分页游标) |
{"data":[{"id":"m1","sessionId":"web","role":"assistant","content":"你好","mode":"normal","mood":"calm","platform":"web","deviceId":"web:chrome:a1b2c3","createdAt":1753...}]}
- 错误:
400(limit/before/after非法、before+after同传、q+after同传、id缺失);404{"error":{"code":"session_not_found","message":"session \"<id>\" not found"}}(v0.63,行为变更)——会话不存在 / 已删除(含不在chat_sessions的隔离sess-xxx);存在但确实没有消息仍是200 {"data":[]};参数校验先于存在性判定;501{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}};500{"error":{"code":"messages_failed",...}}(含存在性判定本身读不出来时 —— 不静默退化成空列表)。 - 备注:热分页(design/16 §2.2,§5.1)。
thinking只在本 owner/admin 端点可见;WS 回放/广播已剥离。会话存活判据(v0.63):客户端用 404(session_not_found)判定「当前会话已不在」,据此退回主会话 + 一句提示;不要用「返回空数组」推断存活 —— 空数组现在只表示「这条会话存在但没有消息」。curl:
curl -s 'http://<host>:8080/api/v1/sessions/web/messages?limit=50&before=1753000000000&platform=web' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
3.5 POST /api/v1/sessions
- 鉴权:
Authorize(owner, admin)(写操作,变更持久态)。 - 请求 Body(可选):
{"personaId"?}——body 可完全省略或为空。personaId非空时把新会话绑定到该角色(未知角色回400 bad_request);省略 / 空 = 不绑(persona_id='',继承全局默认角色)。会话角色只在创建时写入、之后不可变(design/22 §8-1;PATCH /sessions/{id}一旦收到personaId键即400)。ownerID从 JWT claims 取作子会话归属。契约见 api-client §1 行 5。 - 响应:
201{"data":{"sessionId":"web:<uuid>"}};personaId非空时回显该字段(省略时响应形状与旧版一致)。 - 另:若注入的
HistoryStore不支持角色绑定且显式传了personaId,回501 not_implemented(不假装写入)。 - 错误:
501{"error":{"code":"not_implemented","message":"会话历史服务未配置(未注入 HistoryStore)"}};500{"error":{"code":"session_failed",...}}。 - 备注:新建次级会话(
NewSubSession),独立上下文回溯。curl:
curl -s -X POST http://<host>:8080/api/v1/sessions \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
# => {"data":{"sessionId":"web:0123abcd..."}}
3.6 DELETE /api/v1/sessions/{id}
- 鉴权:
Authorize(owner, admin)。 - Path:
{id}= 会话 id。 - 响应:
200{"data":{"deleted":"<id>"}}。 - 错误:
403{"error":{"code":"forbidden","message":"the main session cannot be deleted"}}(history.ErrImmutableMain,主会话web不可删)。400(id 缺失);501(无 HistoryStore);500{"error":{"code":"session_failed",...}}。
- 备注:删除次级会话及其全部消息。未知会话 id 也回
200(幂等)——删除只是影响 0 行,不报404(删除目标已不在时「已删除」依然成立)。curl:
curl -s -X DELETE http://<host>:8080/api/v1/sessions/web:0123abcd... \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# => {"data":{"deleted":"web:0123abcd..."}}
3.6a PATCH /api/v1/sessions/{id}
- 鉴权:
Authorize(owner, admin)(历史写;viewer →403)。 - Path:
{id}= 会话 id。主会话web可以改名/置顶,只是不可删。 - 请求 Body:
{"title"?: string, "pinned"?: bool}——两个字段都缺失时回400(不支持「空 PATCH」):title:会话显示名。服务端TrimSpace后校验:空白(""/ 全空白)→400 title must not be blank;超过 80 rune →400 title is too long (max 80 characters)。""不能清空标题——「title存在但为空白」与「title缺键」语义不同,后者才是「不改标题」;因此客户端不提供「恢复自动命名」入口(要换名只能给非空白标题)。pinned:bool,置顶 / 取消置顶(D5)。personaId:不是可设置字段——请求体里出现该键(任何取值:"yuki"/""/null/ 未知 id / 空白串)即400,错误消息点名personaId;该检查在会话存在性检查之前执行,因此未知会话 +personaId回400(不是404),且与title同传时整体400、一个字段都不落库(design/22 §8-1:会话角色创建时固定、之后不可变;v0.46)。
- 语义:部分更新——只改请求中给出的字段;响应只回显本次变更的字段(title-only →
{id,title};pin-only →{id,pinned};两者同给 → 三个键都在)。 - 响应:
200:
{"data":{"id":"web:<uuid>","title":"新名字"}}
- 错误:
400{"error":{"code":"bad_request","message":"at least one of title or pinned is required"}}(title与pinned都缺)。400{"error":{"code":"bad_request","message":"title must not be blank"}}(title存在但 trim 后为空白)/"title is too long (max 80 characters)";personaId出现即400。404{"error":{"code":"not_found","message":"session \"<id>\" not found"}}。501not_implemented(未注入 HistoryStore);500{"error":{"code":"session_failed",...}}(改名/置顶失败)。
- curl:
curl -s -X PATCH http://<host>:8080/api/v1/sessions/web:0123abcd... \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"pinned":true}'
# => {"data":{"id":"web:0123abcd...","pinned":true}}
3.6b POST /api/v1/sessions/{id}/read
- 鉴权:
Authorize(owner, admin)(历史写;viewer →403)。 - 请求 Body(可选):
{"before"?: int64}——毫秒 Unix 时间戳。缺省 / 空 body /{}= 现在(「我看到的都算已读」);给值 = 把游标推进到该时刻。 - 语义:
createdAt <= readAt的消息算已读(GET /sessions的unreadCount只数createdAt > readAt的)。会话级单一游标、单 owner 账号——读一处 = 该账号所有端已读(无 per-device 游标);只前进不后退(SQLread_at = MAX(read_at, before)),因此乱序/并发/重试请求幂等安全,绝不会把已读退回未读。 - 响应:
200{"data":{"sessionId":"<id>","readAt":<推进后的游标毫秒>}}。 - 错误:
before为0/负数 →400 "before must be a positive millisecond timestamp";before非数字或 body 非 JSON 对象 →400 "invalid request body"(空对象{}合法 = 现在);未知会话(含不在chat_sessions里的隔离会话sess-xxx)→404;未注入 HistoryStore →501 not_implemented;推进失败 →500 session_failed。 - 副作用:成功后向下行广播
session_read(见 api-client §3.9),让同账号其它端免刷新清红点;失败路径不广播。 - 升级回填:迁移
0010把既有会话游标回填为「该会话现有最新消息时间」,升级当天不误报未读。 - curl:
curl -s -X POST http://<host>:8080/api/v1/sessions/web/read \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"before":1753000000000}'
# => {"data":{"sessionId":"web","readAt":1753000000000}}
3.7 GET /api/v1/meta/enums
- 鉴权:
Authorize()(任意已认证角色:owner/admin/viewer)——只读、无敏感信息,与GET /api/v1/sessions(§3.2)同级。无 token 回401。 - 用途:客户端共享枚举权威表(Android 协同 B4,design/07 §6)。
platform(消息来源平台)/mood(情绪)/mode(行为·呈现模式)此前只在文档里给示例,客户端各自硬编码映射导致来源角标与情绪标签漂移;本端点把三张表做成机器可读的单一事实来源,Web / Android / IM bridge 共用。 - 响应:
200{"data":{ "platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}], "moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}], "modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}], "unknown":"display-raw" }} - 字段:
字段 类型 说明 platforms[]{id,label}来源平台:各内置适配器 Name()(kook/qq-obv11/weixin-ilink/telegram/webhook)+ Web 主会话来源web(history.PlatformWeb)moods[]{id,label}情绪: internal/persona/emotion内置状态。状态机可扩展(persona 可经AddTransition注册更多状态),此表非封闭集合modes[]{id,label}九模式(design/07 §1);mode 只驱动呈现,不构成能力边界 unknownstring 恒 "display-raw":未知值回落约定(机器可读)labelstring 默认中文标签,客户端可直接显示、也可按 i18n 覆盖;客户端比对应以 id为准(小写、大小写敏感) - 错误:仅
401(未认证)。无存储依赖,不会501/500。 - 备注:
- 未知值回落约定(红线):客户端遇不在表中的取值原样显示原始字符串——不丢弃、不报错、不替换占位符;服务端新增取值是非破坏性变更(forward-compatible),旧客户端无需升级,也不得因未知值清空角标或抛错。
- 单一事实来源在 Go 侧:
internal/contract/enums(Platforms()/Moods()/Modes()/UnknownFallback),网关只做投影(internal/services/gateway/meta.go)。 - 反漂移:
internal/contract/enums的测试断言三张表与「适配器Name()」「emotion内置状态」「前端types.ts的MODES」一致;cmd/cyrene另断言表内每个平台都能被渠道工厂构建——漏登记或幽灵条目即 FAIL。 - Web 侧镜像:
frontend/web/src/lib/enums.ts(labelFor(kind, value)未知值返回原值),顶栏 / 侧栏 / 消息气泡的心情标签已改用。 - curl:
curl -s http://<host>:8080/api/v1/meta/enums -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' # => {"data":{"platforms":[...],"moods":[...],"modes":[...],"unknown":"display-raw"}}
3.8 POST /api/v1/documents · GET /api/v1/documents/{name}(v0.59,v0.61 增 PDF 文本层;design/24 §3/§6 + §7 S6)
一句话:文档 = 内容寻址存储 + 服务端抽取 + 清单/正文注入。读侧要 Bearer(与图片读侧「随机名即能力 URL」刻意相反);客户端只要这两个端点 +
chat的documents字段。权威口径与边界表见 api-client §1.2。
A) 上传 POST /api/v1/documents(owner|admin + 审计 document.upload)
-
请求:
multipart/form-data,字段file(单个)。单文件上限 =CHAT_DOCUMENT_MAX_BYTES(默认 20971520 = 20 MiB;≤0视为用默认,不存在「禁用上限」的取值)。它与图片的CHAT_IMAGE_MAX_BYTES刻意解耦(改一边不影响另一边)。请求体额外留 1 MiB multipart 信封余量,文件本身按确切上限校验两次(部件大小 + 流式写入时多读 1 字节)。 -
响应:
{"data":{"name":"a1b2…(32位hex).md","url":"/api/v1/documents/a1b2….md","mime":"text/plain","size":12345,"sha256":"<64位hex>","deduped":false}}首次
201(deduped:false);同字节同扩展名重复上传 →200+ 同一个name+deduped:true(落盘名 =sha256(bytes)[:32] + ext,内容寻址,盘上只留一份;元数据行ON CONFLICT(name) DO NOTHING—— 首次的 uploader / 时间戳是权威)。客户端可把200当成功处理。 -
落盘位置:
<DataDir>/uploads/documents/(目录 0700、文件 0600);客户端原始文件名只用来取扩展名,永不参与路径。 -
类型裁决(唯一裁决权 = 字节嗅探;v0.61 起 = 成对裁决):
- 扩展名白名单(deny-by-default,23 个):
.txt .md .markdown .csv .tsv .json .jsonl .yaml .yml .log .ini .cfg .toml .go .ts .tsx .java .c .h .cpp .rs .sql,外加.pdf(v0.61 新增,第 23 项); - 字节嗅探(
http.DetectContentType)与扩展名成对判定(internal/runtime/documents/store.go的ContentAllowed,写侧与读侧同一份):纯文本类嗅探基类型必须恰为text/plain;.pdf必须恰为application/pdf; - 二者是合取:
.js/.py即便嗅探为文本也拒(不在白名单);改扩展名也换不了类型,且自 v0.61 起双向:HTML/纯文本改名成.pdf⇒415(嗅探不是 PDF)、PDF 改名成.txt⇒415(PDF 字节也只收在.pdf名下); - 明令拒绝:
text/html、image/svg+xml与 XML、application/zip及封装(docx/xlsx)、可执行/脚本(exe/dll/bat/cmd/ps1/js/mjs/sh/py)、判不定者。 - 响应里的
mime是嗅探所得基类型(去参数),故data.json→text/plain(不是application/json)、真 PDF →application/pdf;url是相对路径(读侧要 Bearer,绝对化没有意义)。
- 扩展名白名单(deny-by-default,23 个):
-
PDF 文本层(v0.61,切片 B1):文本层 PDF 抽正文(
Tj/TJ/'/"文本算子,页间以换行相接)⇒ DTOstatus:"extracted";扫描件/图片型 PDF(无文本层)⇒ 诚实拒绝status:"unreadable",原因文案含「这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)」,绝不假装成功、也绝不返回空正文(ErrNoTextLayer);加密(ErrPDFEncrypted)与损坏/截断(ErrPDFDamaged)是可区分的两类失败。安全阀:单份最多 5000 页、sidecar 正文 ≤8 MiB(撞上即truncated)。抽取器版本内部升到documents/v3⇒ 旧 sidecar 一次性重抽(同一份旧附件的chars/truncated会在某次回合后重算;旧 PDF 若是文本层 PDF 会从unreadable变为extracted)。未做:OCR/扫描件栅格化、PDF 写/生成、.docx/.xlsx等 Office(仍诚实拒绝)。 -
错误:
400 bad_request(缺file部件 / 空文件 / 非 multipart);413 document_too_large(文件或信封超限,文案带生效上限字节数);415 unsupported_media_type(扩展名白名单与字节嗅探的成对裁决任一不过,文案同时点明两条口径);401/403(未认证 / 角色不足)。审计:成功document.upload+ok、失败 +error(成对,不遗漏)。curl -s -X POST http://<host>:8080/api/v1/documents \ -H "Authorization: Bearer $TOKEN" -F 'file=@notes.md' # => 201 {"data":{"name":"…(32hex).md","url":"/api/v1/documents/….md","mime":"text/plain","size":1234,"sha256":"…","deduped":false}} # 再传同一份:=> 200 {"data":{…同一 name…,"deduped":true}}
B) 读取 GET /api/v1/documents/{name}(owner|admin,不审计)
-
鉴权:必须带
Authorization: Bearer <JWT>(owner/admin);无 token →401,角色不足 →403。这是与GET /api/v1/files/{name}的关键差异——文档摄取在服务端完成,不需要被<img>/ provider 直接消费,因此不开「名字即凭据」的免鉴权读侧,也不放开内联服务 HTML/SVG 的口子。 -
name形态:32 位小写十六进制 + '.' + 白名单扩展名;路径穿越全形态拒绝(../%2e%2e/%2f等,含 ServeMux 清理前的守卫)。 -
响应头:
Content-Type= 读时重新嗅探的基类型(同一对裁决:纯文本类 →text/plain、真 PDF 如实下发application/pdf、嗅不出放行类型 →application/octet-stream);Content-Disposition: attachment; filename="<name>";X-Content-Type-Options: nosniff;Cache-Control: private, max-age=86400, immutable(内容寻址 ⇒ 名字变则内容变);支持 Range。⚠️ PDF 也永不内联:attachment+nosniff一字未变。 -
错误:
404 document_not_found—— 名字非法 / 路径逃逸 / 文件不存在三种情况同一响应(不泄露「是否存在」以外的信息)。curl -s -D - -o /tmp/doc.md http://<host>:8080/api/v1/documents/<32hex>.md \ -H "Authorization: Bearer $TOKEN" # => 200,头含 Content-Disposition: attachment、X-Content-Type-Options: nosniff
C) chat.documents(REST 同步 / REST stream:true / WS chat 三处同形)
{"sessionId":"web","text":"帮我看看这份","documents":[{"name":"a1b2….md"},{"name":"c3d4….csv"}]}
- 只给
name(服务端存储名);客户端多带的mime/url/size一律被服务端覆盖(MIME 与大小取自落盘字节),绝不接受任意 http(s) URL(不重开 SSRF 面)。 - ≤5 条/回合:超出 →
400 too_many_documents,先于任何文件系统访问判定;名非法 / 穿越 / 不存在 →404 document_not_found;任一条不合规都让整个请求被拒(绝不静默丢掉附件)。 - 附件部件挂在同一条
user消息上;纯附件回合(无文本、无图片)成立。WS 上行帧仍有 4096 字节硬上限 ⇒ 现实中文档走 REST;两条入口共用同一份解析与校验,不会分叉。 - WS 侧失败:经
error帧下发data.code(too_many_documents/document_not_found/chat_documents_unavailable)并中止本回合,绝不静默。 - 历史 / 实时 DTO(四处同源):
historyMessage.documents(见 §3.4 表)——[{name,mime,size,chars,truncated,status,reason?}],status ∈ extracted|listed|unreadable,0 个附件时省略该键;⚠️chars/truncated是本回合注入侧事实(全文长度只有document-read的totalChars可见);**reason(v0.63)**只在unreadable时出现 = 读不到的原因文案(逐字等于注入模型那份,只有文案、不含路径/凭据)。
D) 内置工具 document-read(模型侧):{name, offset?, limit?} → {name,status,offset,limit,totalChars,truncated,nextOffset,text,extractTruncated?};limit 默认 8000、上限 64K 字符(≤0 用缺省、超上限夹取);offset 为 0 基 rune 偏移、起点 = sidecar 正文(含 CSV/TSV 摘要行);offset<0 或 >totalChars → 工具错误(==totalChars 合法);extractTruncated=true 专指「抽取期撞上 8 MiB sidecar 安全上限」。只读 sidecar、绝不重抽;策略默认 owner-only。诚实边界:design/24 §6.2 列的 document_unreadable(400) 没有对应的 REST 错误码,它只以本工具的错误形态存在(客户端在 DTO 里看到的是 status:"unreadable")。
4. 管理后台 · 读端点(owner|admin)
以下端点
鉴权 = Authorize(owner, admin)(除特别标注 owner)。所有管理读不审计;管理写(§5)全程审计(who/what/when/result,见 §4.15)。
4.1 GET /api/v1/admin/personas
- 同
GET /api/v1/personas(§3.3),返回PersonaSummary[]。adminListPersonas直接复用collectPersonas()。 200 {"data":[<PersonaSummary>...]}。curl:
curl -s http://<host>:8080/api/v1/admin/personas -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.2 GET /api/v1/admin/sessions
- 同
GET /api/v1/sessions(§3.2):注入 HistoryStore 返回持久化会话(主 + 子);否则回退渠道会话视图(collectSessions)。 200 {"data":[...]}。curl:
curl -s http://<host>:8080/api/v1/admin/sessions -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.3 GET /api/v1/admin/memories
- 响应:
200 {"data":[<MemorySummary>...]}(无 store 时[])。MemorySummary={id, scope, text}。scope∈user|persona|project。curl:
curl -s http://<host>:8080/api/v1/admin/memories -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.4 GET /api/v1/admin/plugins
- 响应:
200 {"data":[<PluginSummary>...]}(PluginSummary={name, kind, description, allowInGroup, allowInStranger})。kind为能力类型,实际取值 =tool|skill|channel|mcp|iot(internal/capability/registry的KindTool/KindSkill/KindChannel/KindMCP/KindIoT);allowInGroup/allowInStranger为可达性策略(落盘tool_policies,重启保留——迁移0012_tool_policy;SetPolicy先落库后改内存,落盘失败内存保持旧值并返回错误,无行的能力按默认 owner-only)。curl:
curl -s http://<host>:8080/api/v1/admin/plugins -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.5 GET /api/v1/admin/channels
- 响应:
200 {"data":[<ChannelSummary>...]}。
ChannelSummary = {name, connected, accounts?}。accounts 为 AdminChannelAccount[](掩码无 raw token),仅当 s.channels 注入且平台有已配账号时出现。
-
name:平台名(如kook、telegram、weixin-ilink)。 -
connected:bool,该平台是否已连接。 -
accounts[](AdminChannelAccount)字段:id/config(掩码后的配置 map)/allowGroups/denyGroups/allowUsers/denyUsers/groupMsgPerMin/msgPerMin/maxMessages/botId?/botUsername?/botNickname?/capabilities?(如 kook 的["friends","user_chats","channels","guilds","message_test"])。botId/botUsername/botNickname为已连接 bot 的真实身份(如 KOOK Connect 时调/api/v3/user/me解析),非敏感来自 live adapter。config值已脱敏(token 掩码****+ 尾 4);bot_token等密钥绝不回显。
-
聚合逻辑:
channels.json是账号存在性的真源;一个裸平台 + 多账号合并为一行;无注册 adapter 但已配账号的平台仍出现(供查看配置)。curl:
curl -s http://<host>:8080/api/v1/admin/channels -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.6 GET /api/v1/admin/channels/messages
- 鉴权:
owner|admin。 - Query:
limit(默认50,上限200) /before(游标,Unix 毫秒) /adapter(适配器注册名,如kook:3bef...) /platform。三粒度(互斥,adapter优先,其次platform,都不传=全量 IM 总览)。 - 响应:
200 {"data":[<historyMessage>...]}(newest first)。本视图不过滤主会话(因为它带adapter_id);按adapter_id/platform过滤。historyMessage见 §3.4。 - 错误:
501(无 HistoryStore)。 - 备注:IM 适配器日志页全量数据源 + 轮询兜底;实时增量走 WS
channel_persisted(§6.3)。curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/messages?adapter=kook:3bef...&limit=50' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.7 GET /api/v1/admin/channels/{platform}/messages
- 鉴权:
owner|admin。 - Path:
{platform}= IM 平台(如telegram)。 - Query:
limit(默认50,上限200) /before(Unix 毫秒)。 - 响应:
200 {"data":[<historyMessage>...]}。排除主会话web,只返回该平台非主人消息流(隔离sess-xxx)。senderName标发送者昵称;adapterId标来源适配器。 - 错误:
400(platform 缺失);501(无 HistoryStore);500。 - curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/telegram/messages?limit=50' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.8 GET /api/v1/admin/iot
- 响应:
200 {"data":[<iotDeviceSummary>...]}(iotDeviceSummary={id, name, type, state, online};state为map[string]any)。无 store 时[]。 - 错误:
500{"error":{"code":"iot_failed",...}}。curl:
curl -s http://<host>:8080/api/v1/admin/iot -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.9 GET /api/v1/admin/llm
- 响应:
200 {"data":{"providers":[<AdminLlmProvider>...]}}(AdminLlmConfig)。 AdminLlmProvider:{id, name, provider, baseUrl, model, multimodal, enabled, fallback?, maxTokens?, reasoning, personaModels?, personaProviders?}(v0.53 新增三键,见 api-client 的「模型配置契约(v0.53)」);顶层 response 另含reasoningKinds/personaProviderOptions两个取值域数组(面板表单选项的唯一来源)。multimodal= 主模型自己能否收图(裁决 14.4.1)——它不表示「本部署有视觉能力」;视觉/OCR 工具的模型由.env的LLM_VISION_MODEL/LLM_OCR_MODEL单独配置,与multimodal无关(env-only 部署——无有效models.json、走.env合成组——该字段由LLM_MULTIMODAL声明,默认false,v0.58)。reasoning恒下发({kind},kind:""= 未声明形状)。provider协议 ∈openai|anthropic;name为友好标签(OpenAI/Anthropic 或组 id)。enabled:index 0 的组为生效组 =true。maxTokens:该组的 token 预算(omitempty,缺省/0 = 未设);缺省时链末端回退步回落LLM_FALLBACK_MAX_TOKENS(.env,默认 8192),再回落适配器默认(anthropic 8192)。reasoning(v0.53,恒下发):{kind}——kind∈auto|deepseek|openai|vllm|dashscope,决定该组用哪种提供商开关形状下发推理指令(""/auto= 按模型名/baseURL 推断,形状有歧义就不下发);详见 api-client「模型配置契约(v0.53)」。personaModels/personaProviders(v0.53,omitempty):角色级模型 / 协议路由表({personaId: modelId}/{personaId: "openai"|"anthropic"});未配置 = 该键不出现。multimodal:主模型自己能否收图(= 面板的「多模态」勾选);视觉/OCR 工具的模型是.env的LLM_VISION_MODEL/LLM_OCR_MODEL,与本字段无关(裁决 14.4.1 修语义);env-only 部署(无有效models.json,走.env合成组)由LLM_MULTIMODAL(默认false,v0.58)声明本字段。- API key 永不返回(wire 无 key 字段)。
contextWindow(v0.59,omitempty):该组声明的模型上下文窗口(token)。0 / 键缺失 = 未声明 ⇒ 运行时按回落窗口(DefaultModelContextWindow,131072)保守推导并记一条 WARN;负数在校验阶段被拒。它是.env侧LLM_CONTEXT_WINDOW的对偶(per-group 播种)。语义见 api-client §2「上下文窗口与预算契约(v0.59)」。
- 数据源:
models.json各组(index 0 = 生效组);无models.json时按.env合成单组。curl:
curl -s http://<host>:8080/api/v1/admin/llm -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.9.1 GET /api/v1/admin/llm/catalog(v0.53,此前漏记)
- 鉴权:
owner|admin读(只读)。Query:?refresh=1强制在线刷新;否则复用 24h TTL 内的本地快照。数据源https://models.dev/api.json。 - 响应:
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"}};有快照但刷新失败 →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)。完整契约与「不进运行时热路径」的边界见api-client.md§2「模型配置契约(v0.53)」。
4.10 GET /api/v1/admin/automations
- 响应:
200 {"data":[<AdminAutomationRecord>...]}。 AdminAutomationRecord:{id, name, kind, cron?, enabled, lastRun?, nextRun?}。kind∈cron|event;lastRun/nextRun为 RFC3339(未知省略)。curl:
curl -s http://<host>:8080/api/v1/admin/automations -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.11 GET /api/v1/admin/users
- 响应:
200 {"data":[<AdminUserRecord>...]}。 AdminUserRecord:{id, username, role, createdAt?}(createdAtRFC3339)。无 UserStore 时仅有配置的 admin 账号(id:"admin"、role:"owner");注入 UserStore 后追加 API key 摘要(username:"apikey"、id为掩码)。curl:
curl -s http://<host>:8080/api/v1/admin/users -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.12 GET /api/v1/admin/backthink
- 响应:
200 {"data":[<AdminBackthinkItem>...]}(无 store 时[])。AdminBackthinkItem:{id, trigger, conclusion?, shouldNotify, pushed, at}(at为 Unix 毫秒)。默认取最近 50 条。curl:
curl -s http://<host>:8080/api/v1/admin/backthink -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.13 GET /api/v1/admin/rag
- Query:
?query=<string>(可选;带则并入检索命中)。 - 响应:
200 {"data":{"documents":[<AdminRagDoc>...], "hits"?:[<AdminRagHit>...]}}(AdminRagResponse)。AdminRagDoc:{id, name, desc?, chunks, embedded}(embedded反映是否配置向量增强)。AdminRagHit:{docId, docName, chapter?, text, score, semantic}(semantic布尔,语义/向量命中为 true)。
- 错误:
500。无 store 时返回空文档列表(不 500)。curl:
curl -s 'http://<host>:8080/api/v1/admin/rag?query=部署' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.14 POST /api/v1/admin/rag/query
- 鉴权:
owner|admin。 - Body:
{"query":"<string>"}(必填,非空)。 - 响应:
200 {"data":{"query":"<string>","hits":[<AdminRagHit>...]}}。审计:rag.query(成功ok/ 失败error)。 - 错误:
400(query 缺失/空);501(未注入 RagStore);500。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/rag/query \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"query":"部署要求是什么"}'
4.15 GET /api/v1/admin/observability/audit
- Query:
?limit=N(默认 50,上限 500)。 - 响应:
200 {"data":[<AuditRecord>...]}(newest first)。AuditRecord={id(int64递增), who, what, when(RFC3339), result}。who形如admin:owner;what为审计动作(如system.backup);result∈ok|error。 - 来源:SQLite
audit_logs(SQLiteAuditStore);未注入时退化为进程内存。 - 示例:
{"data":[{"id":1,"who":"admin:owner","what":"system.backup","when":"2026-09-08T00:00:00Z","result":"ok"}]}。curl:
curl -s 'http://<host>:8080/api/v1/admin/observability/audit?limit=100' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.16 GET /api/v1/admin/observability/metrics
- Query:无(不接收 limit)。
- 副作用(v0.55):owner/admin 凭据读取本端点即建立可观测推送订阅(
observability_metrics帧此后由 WS 推来,面板不再轮询;observability_push.go)——该凭据的 WS 连接全断即退订。同理见 §4.17 与GET /api/v1/admin/observability/logs(三者的读取都是订阅握手)。响应形状一字未变。 - 响应:
200 {"data":<AdminMetrics>}。AdminMetrics={totalRequests, qps, avgLatencyMs, maxLatencyMs, perMethod{}, perPath{}, statusCounts{}, llmCalls, subagentEvents}。qps:近 1 秒窗口计数;avgLatencyMs/maxLatencyMs:均值/最大耗时(毫秒)。perMethod/perPath/statusCounts:分布计数。llmCalls/subagentEvents:由实际观测的 trace span 按kind累计(Tracer 未注入为 0)。- Token/成本后端不统计、不返回(不伪造指标)。curl:
curl -s http://<host>:8080/api/v1/admin/observability/metrics -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.17 GET /api/v1/admin/observability/trace
- Query:
?limit=N(默认 50,上限 500)。 - 副作用(v0.55):读取本端点同样建立推送订阅(
observability_trace帧随后推来),详见 §4.16。 - 响应:
200 {"data":[<AdminTraceSpan>...]}(newest first)。AdminTraceSpan={id, parentId?, name, kind, status, startedAt, endedAt?, durationNs, props?, events?}。kind∈llm_call|tool_call|orchestration|state_transition|subagent|memory|guardrail;events为[{time,type,props?}];时间 RFC3339。 - 旁路节点的 span 现已真实产生(裁决 11.4.2 / 11.4.3,2026-10-04):
memory.recall/memory.write/guardrail.input/guardrail.output/orchestration.spawn均由真实生产者开出,因此kind取值集里的memory|guardrail|orchestration不再只是枚举——trace 列表中会真的出现这三类 span(此前三者无生产者,全仓只有llm_call/tool_call/subagent/state_transition)。它们只带元数据(记忆作用域/条数/字节数、护栏裁决与耗时、被派发的子代理 id 与目标角色 id),不含记忆正文、不含提示词、不含密钥;见design/19 §1。 - 来源:环形缓冲(默认 512 条),由
ObserveSpan/TraceSink注入。curl:
curl -s 'http://<host>:8080/api/v1/admin/observability/trace?limit=100' -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.18 GET /api/v1/admin/sessions/{id}/export
- 鉴权:
owner|admin(注册为adminAuth)。 - Path:
{id}= 会话 id。 - 响应:
200 {"data":{"session":<SessionSummary>}}(summaryOfSession:{platform, channelId, sessionId, actorRole, owner, isGroup, memWritable})。 - 错误:
404(会话未找到);500。 - 备注:导出会话身份元数据;完整消息转录由 M10 提供。curl:
curl -s http://<host>:8080/api/v1/admin/sessions/web:0123.../export -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
4.19 GET /api/v1/admin/llm/usage(v0.64,只读;切片 U1)
- 鉴权:
Authorize(owner, admin)(routes.go的writeAuth);无凭据 →401、viewer→403(与同页GET /admin/llm同门:它含成本与模型/用途分解,不是给 viewer 的监控投影)。无审计动作(查询不是动作)。 - 请求:
GET /api/v1/admin/llm/usage?since=&until=&purpose=since/until:RFC3339(2026-10-05T00:00:00Z)或 Unix 毫秒(纯数字,与/sessions/{id}/messages的before/after同口径);其它形态 →400 bad_request。- 缺省窗口 = 近 24h(
until缺省 = 现在,since缺省 =until − 24h);until必须严格晚于since;跨度上限 90 天,超过 →400(不静默裁剪)。 purpose:截断空白后精确匹配;空 = 不过滤。
- 响应:
200
{"data":{
"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},
"totals":{"calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641},
"byPurpose":[{"purpose":"chat","calls":120,"prompt":410000,"completion":37000,"cacheHit":355000,"cacheMiss":55000,"hitRate":0.8659}],
"byModel":[{"model":"deepseek-chat","calls":128,"prompt":412000,"completion":38000,"cacheHit":356000,"cacheMiss":56000,"hitRate":0.8641}]
}}
- 字段(逐字对齐
internal/services/gateway/admin_llm_usage.go):window回显补齐后的 UTC RFC3339 时间窗与purpose(""= 未过滤);totals恒为六个键(calls/prompt/completion/cacheHit/cacheMiss/hitRate);byPurpose[].purpose与byModel[].model是分组键,其余同上;排序 = 调用次数降序、同次数按键升序;无数据时两个数组是[]不是null。 hitRate口径:cacheHit / (cacheHit + cacheMiss);分母为 0 ⇒0(口径与model.Response.CacheHitRate同源)。客户端应把「分母 0」与「真 0% 命中」分开(前者显示—)。- 错误:
400 bad_request(时间形态非法 /since不早于until/ 跨度超 90 天);401;403;500 {"error":{"code":"usage_unavailable","message":"读取 LLM 用量汇总失败"}}(汇总出口未接线或查库失败 —— 绝不回全 0 假汇总)。 - 数据来源:每次成功模型调用把一条非敏感样本异步落进
llm_usage(迁移0017;写入口径见 §4.19 的契约与 changelog v0.64)——有界队列 + 单写者 + 批量事务,队列满丢弃并限流 WARN,绝不阻塞模型调用;凭据与消息正文绝不入库。✅session_id已接线(v0.65 更正):loop 在Run顶部把会话 id 播种进 ctx、适配器落库时带上它(空串映射NULL)——v0.64 写的「恒为NULL」已过期;但本端点仍不按会话过滤 / 分组,响应形状不变。保留期LLM_USAGE_RETENTION(默认720h;≤0= 关闭清理)。 - curl:
curl -s 'http://<host>:8080/api/v1/admin/llm/usage?since=2026-10-04T00:00:00Z&until=2026-10-05T00:00:00Z' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# => {"data":{"window":{"since":"2026-10-04T00:00:00Z","until":"2026-10-05T00:00:00Z","purpose":""},"totals":{...},...}}
5. 管理后台 · 写端点(owner,全程审计)
以下端点鉴权 =
Authorize(owner)(仅主人)。每个写操作都会调用auditClaims(r, "<action>", "ok"|"error")记录 who/what/when/result(落audit_logs+ 网关日志)。敏感值(token/密钥/新 JWT 密钥/DB 路径)不在响应/日志回显。动作名约定:多词动作用 camelCase(llm.addProvider/llm.resetFromEnv/persona.update/memory.search…),与 §8.3 的清单逐字一致。
5.1 POST /api/v1/admin/plugins/policy
- Body(
policyRequest):{"name":"<能力名>","allowInGroup":bool,"allowInStranger":bool}。 - 响应:
200 {"data":{"name":"<能力名>","allowInGroup":bool,"allowInStranger":bool}}。 - 错误:
400(name 缺失);500(cap service 未配置);404(能力不存在);500。 - 审计:
policy.set。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/policy \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"web_search","allowInGroup":true,"allowInStranger":false}'
5.2 POST /api/v1/admin/personas
- Body(
personaUpsertRequest):{"id","name","characterRef?","role","systemPrompt?","personality?","speechStyle?","catchphrase?","background?","values?[]","modes?[]"/"mode?","memoryNamespace?"}。id/name/role必填;systemPrompt空时派生默认;characterRef空时 = id。 - 响应:
201 {"data":<PersonaSummary>}。注册进 persona registry(真实运行效果)+ 持久化到configs/personas/<id>.yaml(重启存活)。 - 错误:
400(id/name/role 缺失);409{"error":{"code":"duplicate","message":"人格已存在,请使用编辑"}};500(registry 未配置 / 持久化失败 / 注册失败)。 - 审计:
persona.create。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/personas \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":"luna","name":"Luna","role":"女仆","modes":["normal"],"memoryNamespace":"luna"}'
# => {"data":{"id":"luna","name":"Luna","character":"Luna","isDefault":false,"mode":"normal","memoryNamespace":"luna"}}
5.3 PUT /api/v1/admin/personas/{id}
- Path:
{id}= 人格 id。Body 同 5.2(不含 id;服务端以 path 覆盖req.ID)。name/role必填。 - 响应:
200 {"data":<PersonaSummary>}。 - 错误:
400(name/role 缺失);404(不存在);500(registry 未配置 / 持久化 / 更新失败)。 - 审计:
persona.update。curl:
curl -s -X PUT http://<host>:8080/api/v1/admin/personas/luna \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"Luna","role":"女仆","modes":["gentle"]}'
5.4 POST /api/v1/admin/users
- 响应:
501 {"error":{"code":"not_implemented","message":"新增用户/角色存储为 M10 提供(当前为单 admin + APIKey 现状)"}}。审计:user.create(error)。 - 备注:诚实不伪造(无持久用户表)。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/users \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{}'
5.5 POST /api/v1/admin/users/apikey
- Body(
apiKeyIssueRequest):{"role":"owner|admin|viewer"}。 - 响应:
201 {"data":{"id":"<掩码>","key":"<明文·仅此一次>","role":"<role>"}}。key为 base64 raw URL 编码的 32 字节随机串,仅此响应可见一次;id为掩码(xxxx....xxxx),用于后续吊销。 - 错误:
400(invalid role/role 缺失);500(user service 未配置 / 生成失败)。 - 审计:
apikey.issue。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/users/apikey \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"role":"admin"}'
# => {"data":{"id":"abcd****wxyz","key":"LQV...","role":"admin"}}
5.6 DELETE /api/v1/admin/users/apikey/{id}
- Path:
{id}= 列表返回的掩码 id。 - 响应:
200 {"data":{"id":"<掩码>","revoked":true}}。 - 错误:
404(掩码未命中);500(user service 未配置)。 - 审计:
apikey.revoke。curl:
curl -s -X DELETE http://<host>:8080/api/v1/admin/users/apikey/abcd****wxyz \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.7 POST /api/v1/admin/iot/{id}/control
- Path:
{id}= 设备 id。Body(iotControlRequest):{"action":"<string>","params":{...}?}。 - 响应:
200 {"data":{<iotDeviceSummary>}}({id,name,type,state,online})——真实下发到 IoT provider,并发布device_update事件。 - 错误:
400(action 缺失 / 动作不支持 / 参数错);404(设备不存在);409(设备离线);500(IoT 服务未配置 / 控制失败)。 - 审计:
iot.control。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/iot/d1/control \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"action":"toggle","params":{"brightness":50}}'
5.8 POST /api/v1/admin/iot/scenarios
- 响应:
501 {"error":{"code":"not_implemented","message":"IoT 场景未实现:IoT 子系统仅有设备列表/控制/状态(Provider 接口无场景模型与存储);场景 CRUD 需先新增模型 + 执行器"}}。审计:iot.scenario(error)。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/iot/scenarios -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
5.9 POST /api/v1/admin/iot/rules
- 响应:
501 {"error":{"code":"not_implemented","message":"IoT 自动化规则未实现:IoT 子系统无规则模型与存储(通用 automations 服务只跑 cron/事件规则,不含设备状态条件 + 设备动作执行器)"}}(同 5.8,审计iot.rule)。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/iot/rules -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
5.10 POST /api/v1/admin/automations
- Body(
automationCreateRequest):{"name","kind":"cron|event","expression"}(均必填)。 - 响应:
201 {"data":{"id":"<id>","name":"<name>","kind":"<kind>"}}——真实注册规则。 - 错误:
400(字段缺失 /invalid_cron/invalid_rule);500(自动化服务未配置 / 添加失败)。 - 审计:
automation.create。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/automations \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"晨间问候","kind":"cron","expression":"0 8 * * *"}'
5.11 POST /api/v1/admin/automations/{id}/run
- Path:
{id}。Body:无。 - 响应:
200 {"data":{"id":"<id>","output":"<string>"}}(手动触发一次)。 - 错误:
404;500。 - 审计:
automation.run。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/automations/<id>/run -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.12 DELETE /api/v1/admin/automations/{id}
- Path:
{id}。响应:200 {"data":{"id":"<id>","deleted":true}}。 - 错误:
404;500。审计:automation.delete。curl:
curl -s -X DELETE http://<host>:8080/api/v1/admin/automations/<id> -H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.13 POST /api/v1/admin/llm
- Body(
llmProviderRequest):{"id?","name?","provider?","baseUrl","apiKey?","model","multimodal?","fallback?","maxTokens?","contextWindow?","reasoning?","personaModels?","personaProviders?"}。baseUrl+model必填;provider空默认openai(仅允许openai|anthropic);maxTokens缺省/0= 未设,负数回400。v0.59contextWindow?:该组声明的模型上下文窗口(token)——缺失 = 保留已存窗口、显式0与缺失同义(都 = 未声明 ⇒ 按回落窗口 131072 推导)、负数回400且不落盘;保存响应的<AdminLlmProvider>会把它回显(omitempty)。v0.53 三键缺省 = 保留已存值(reasoning整块给出即整块替换;两张 persona 表给出即整表替换);非法reasoning.kind、非法协议名、anthropic 组声明 openai 系形状 →400 bad_request(Group.Validate报错,不落盘)。契约见 api-client「模型配置契约(v0.53)」与「上下文窗口与预算契约(v0.59)」。 - 响应:
201 {"data":{"provider":<AdminLlmProvider>,"persisted":true,"path":"<models.json 绝对路径>"}}。真实持久化:读-改-写models.json,保存组提升为生效组(index 0),并热重载运行中的 LLM stack(无需重启)。 - 错误:
400(缺 baseUrl/model / invalid provider /contextWindow负值);500(LLM 服务未注入 / 写盘失败 / 热重载失败)。API key 写入文件但永不回显、不落日志。 - 审计:
llm.addProvider。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/llm \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-...","model":"gpt-4o","multimodal":true}'
5.13a POST /api/v1/admin/llm/reset-from-env(v0.62,「重置为 env 默认」)
- 鉴权:
owner专属(与同组POST /admin/llm、POST /admin/llm/test同门;admin与viewer均403)——它是破坏性写(覆盖面板对若干键的修改),门不比同族写端点更宽。审计:llm.resetFromEnv(成功ok、被拒/失败error)。 - Body(
llmResetRequest):{"confirm":true}—— 必须显式为 true。缺失 /false/ 非法 body 一律400 {"error":{"code":"bad_request"}},且一个字节都不动(本动作用 env 值覆盖面板修改,是破坏性操作,缺护栏的默认必须是「不执行」)。 - 语义:把面板(
models.json)里.env默认层确实声明过(非空值) 的键覆盖回 env 值。它与启动期 materialize 的补齐方向相反、键表同源(runtimeconfig的groupFillSpecs/envLayer),因此逐条是:- 只动
provider/baseUrl/apiKey/model/contextWindow(缺省协议族按组自己的provider选:.env同时配了扁平块与ANTHROPIC_*时两族各取自己的值;contextWindow只认正数)。 - env 未声明的键一律保留面板值(
fallback/multimodal/maxTokens/reasoning/personaModels在.env里没有出处 ⇒ 永不被本动作改动)。 - 组集合不增减(不复活被面板删掉的组、不删面板新增的组,顺序也不动)。
- 面板值已与 env 一致的键不算「改回」(
provider恒属这种情形),不出现在reset里。 - 无键可改时一个字节都不写(幂等;重复调用第二次会返回
reset:[]+ 每组一条「已一致」的skipped)。 - 真的改了盘就热重载运行中的 LLM 栈(无需重启);但
contextWindow的上下文预算推导仍只在进程启动时算一次(与POST /admin/llm同口径,重启后生效)。
- 只动
- 对照基准:请求时刻按既有 config 解析入口读到的
.env默认层(config.LoadLLMDefaults,优先级与启动期config.Load逐字一致:进程环境 >.env> 标签默认);.env路径由组合根经Deps.EnvPath注入(与main.go的config.MustLoad(envPath)同一条)——网关自己绝不 fopen.env。 - 响应:
200 {"data":{"reset":[{"group":"<组 id>","keys":["baseUrl","apiKey",…]}],"skipped":[{"group":"<组 id>","reason":"<中文原因>"}],"path":"<models.json 路径>"}}。keys只有键名(顺序 = 补齐表顺序),凭据值绝不回显;reset/skipped恒为数组(无记录时是[]而非null);reason是两种之一:"面板值已与 `.env` 默认一致,无需重置"/"`.env` 未声明该组的默认键(面板值原样保留)"。 - 错误(都不改文件且带审计
error):.env源不可读 →500 env_defaults_unreadable;面板配置不存在/不可解析 →500 models_unreadable;写盘失败 →500 llm_persist_failed;已写盘但热重载失败 →500 llm_reload_failed(说明文件已改、未生效);LLM 栈未注入 →500 llm_unavailable。 - 面板:LLM 页的「重置为 env 默认」卡片(
frontend/web/src/admin/modules/llm/LlmResetFromEnv.tsx)——先展开中文二次确认文案,确认后才带confirm:true提交。完整契约见api-client.md§2「重置为 env 默认契约(v0.62)」。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/llm/reset-from-env \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"confirm":true}'
# => {"data":{"reset":[{"group":"deepseek","keys":["baseUrl","apiKey","model"]}],"skipped":[],"path":".../models.json"}}
5.14 POST /api/v1/admin/llm/test
- Body(
llmTestRequest):{"provider?","baseUrl","model","apiKey?","prompt?"}。model+baseUrl必填;provider空默认openai;prompt空用探针"Reply with the single word: OK"。 - 响应:
200 {"data":{"ok":true,"provider":"openai","model":"gpt-4o","reply":"<模型回复>","tokens":<int>}}——真实模型调用(超时 30s)。 - 错误:
400(缺 model/baseUrl / invalid provider);500(调用失败)。 - 审计:
llm.test。API key 仅本次请求内使用,不落库、不进 wire。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/llm/test \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"provider":"openai","baseUrl":"https://api.openai.com/v1","model":"gpt-4o","apiKey":"sk-...","prompt":"你好"}'
5.15 POST /api/v1/admin/channels/open
- Body(
channelActionRequest):{"name":"<渠道名>"}(如"kook"/"kook:conf0"/ 裸平台名解析到该平台首个账号)。 - 响应:
200 {"data":{"name":"<name>","connected":true}}——真实 Connect。 - 错误:
400(name 缺失);404(渠道未注册);409{"error":{"code":"needs_login","message":"该渠道尚未登录:请先用「扫码登录」绑定,再连接"}}(weixin-iLink 无 token);500(连接失败)。 - 审计:
channel.open。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/channels/open \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"kook"}'
5.16 POST /api/v1/admin/channels/close
- Body:
{"name":"<渠道名>"}。响应:200 {"data":{"name":"<name>","connected":false}}——真实 Disconnect。 - 错误:
400/404/500。审计:channel.close。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/channels/close \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{"name":"kook"}'
5.17 POST /api/v1/admin/channels/test
- Body(
channelTestRequest):{"platform?","accountId","isGroup"}(accountId必填)。 - 响应:
200 {"data":{"role":"owner|member|...","owner":bool,"isGroup":bool,"env":"owner|...|","verdict":"<人读结论>"}}。结构性身份识别:channel 层 actor 元数据 ×configs/users/owner.yaml白名单,绝不靠提示词。verdict说明:群聊→隔离+记忆只读+仅允许工具;命中白名单→owner(全量访问);未命中→按陌生人(default deny)。 - 错误:
400(accountId 缺失)。审计:channel.identity-test。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/channels/test \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"platform":"weixin-ilink","accountId":"wx_uid_123","isGroup":false}'
# => {"data":{"role":"owner","owner":true,"isGroup":false,"env":"owner","verdict":"匹配主人白名单 → owner(全量访问,记忆可读写)"}}
5.18 GET /api/v1/admin/channels/{platform}/login-qr
- 鉴权:
owner。Path:{platform}=weixin-ilink(其它平台 501)。Query:?accountId=(可选)。 - 响应:
200 {"data":{"qrUrl?","qrCode?","status":"confirmed|<状态>"}}。qrUrl为微信二维码图 URL(前端据此渲染可扫 QR),qrCode为原始扫码标识;已登录时status:"confirmed"。 - 错误:
501(非 weixin-ilink);404(账号未找到,提示先保存账号);500(取码失败)。 - 审计:
channel.weixin.login.qr。curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/weixin-ilink/login-qr?accountId=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.19 GET /api/v1/admin/channels/{platform}/login-status
- 鉴权:
owner。Path:{platform}=weixin-ilink。Query:?accountId=(可选)。 - 响应:
200 {"data":{"status":"confirmed|failed|...","botId?","botToken?","err?"}}。botToken为掩码(****+尾4);status==confirmed时后端已把刚取到的bot_token/bot_id/owner_id写入 channels.json 并热加载该平台(bridge 连上已配置的 adapter,无需重启),且(owner_id 存在时)把扫码者绑定 id 登记进 owner 白名单(owner.yaml,热生效)。status==failed时err带原因。 - 错误:
501/404/500。 - 审计:
channel.weixin.login.status(+channel.weixin.login.persist(确认时))。curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/weixin-ilink/login-status?accountId=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
# 确认后 => {"data":{"status":"confirmed","botId":"wxid_xx","botToken":"****abcd"}}
5.20 GET /api/v1/admin/channels/{platform}/view
- 鉴权:
owner。Path:{platform}=kook(其它 501)。Query:?accountId=(可选)。 - 响应:
200 {"data":{"platform":"kook","accountId?","friends?","userChats?","guilds?","channels?","members?","errors?"}}。聚合该账号真实数据(好友/申请/屏蔽、私信会话、服务器 + 首个服务器频道与成员)。errors为{子项:原因}记录失败子项(不伪造)。token/密钥不打印不落日志。 - 错误:
501(非 kook);404(账号未找到);500。 - 审计:
channel.platform.view。curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/kook/view?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.21 GET /api/v1/admin/channels/{platform}/friends
- 鉴权:
owner。Path:kook。Query:?type=friend|request|block&accountId=(type空=全部)。 - 响应:
200 {"data":<KOOK FriendList>}(含 friends/requests/blocked)。 - 错误:
501/404/500。审计:channel.friends。curl:
curl -s 'http://<host>:8080/api/v1/admin/channels/kook/friends?type=request&accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.22 POST /api/v1/admin/channels/{platform}/friend/handle
- 鉴权:
owner。Path:kook。Query:?accountId=。Body(kookFriendHandleRequest):{"id":<int64>,"accept":bool}(id 必填)。 - 响应:
200 {"data":{"handled":true,"id":<int64>,"accept":bool}}。 - 错误:
400(id 缺失);501/404/500。审计:channel.friend.handle。curl:
curl -s -X POST 'http://<host>:8080/api/v1/admin/channels/kook/friend/handle?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":12345,"accept":true}'
5.23 POST /api/v1/admin/channels/{platform}/message/test
- 鉴权:
owner。Path:kook。Query:?accountId=。Body(kookMessageTestRequest):{"targetId":"<频道id>","content":"<文本>"}(均必填)。 - 响应:
200 {"data":{<KOOK TestMessageResult>}}(如{msg_id,msg_timestamp})——真实向目标发一条 kmarkdown 测试消息。 - 错误:
400(字段缺失);501/404/500。审计:channel.message.test。curl:
curl -s -X POST 'http://<host>:8080/api/v1/admin/channels/kook/message/test?accountId=3bef...' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"targetId":"123456","content":"ping"}'
5.24 PUT /api/v1/admin/channels/{platform}/config
- 鉴权:
owner。Path:{platform}。Body(channelConfigRequest):字段 类型 说明 idstring 账号 id(可选;空则由服务端生成) tokenstring 快捷 token,并入 config["bot_token"](若 config 未显式给)baseURLstring → config["base_url"]remoteURLstring → config["remote_url"]listenAddrstring → config["listen_addr"]modestring → config["mode"]accessTokenstring → config["access_token"]selfIDstring → config["self_id"]configobject 平台专属键值(如 bot_token/base_url/remote_url/endpointPath/receive_mode/webhook_verify_token/transport/api_base_url/report_path...)allowGroups/denyGroupsstring[] 黑白名单(群) allowUsers/denyUsersstring[] 黑白名单(用户) groupMsgPerMinint 群消息限流 msgPerMinint 用户消息限流 maxMessagesint 单账号最大消息数 空配置时:
weixin-ilink豁免(先建账号再扫码),其它平台400。config是补丁(v0.54,与personaId同构):只含本次请求显式给出的键——键省略(config里没有该键 /config整体缺席或null)= 保留已存值、给非空值 = 覆盖、显式null/""= 清除(0/false不算空);顶层便捷字段(token/baseURL/…)是普通 string,只在非空时并入 ⇒ 清除bot_token须走config的显式null/""。allowGroups/denyGroups/allowUsers/denyUsers与三个限流键仍是整值替换(省略 = 清空)。qq-obv11的config新增三键(v0.55,OneBot v11 四端,裁决 1.4 / 12.4.1):transport(ws|http,默认ws,未知值回落ws)、api_base_url(OneBot HTTP API 基址,必须显式给出、不从remote_url推导)、report_path(http+server 收上报的路径,默认/)。mode决定哪一半必需:server必需listen_addr(独立监听端口,AGENTS.md§9 登记的唯一豁免,为空则Connect报错、绝不开端口)、client必需api_base_url;另一半给齐字段就一并启用(单个适配器即可「收上报 + 回消息」)。env 兜底CHANNEL_QQ_OBV11_{TRANSPORT,API_BASE_URL,REPORT_PATH}。四端完整语义见api-client.md§2 的「OneBot v11 四端契约(v0.55)」。 - 响应:
201 {"data":{"persisted":true,"path":"<channels.json 路径>","platform":"<platform>","accountId":"<accountId>"}}。真实持久化 + 热更新(读 channels.json→按 id 打补丁(PatchChannel)→落盘→Reload(platform)热替换 adapter,无需重启;v0.54 起config为 patch 三态(与personaId同构):键省略=保留该账号已存值 / 给出非空值=覆盖该键 / 显式null或""=清除该键,详见api-client.md§2)。token 写入文件但不回显(GET 列表掩码)。 - 错误:
400(platform 缺失 / 空配置);500(渠道配置服务未注入 / 写盘失败 / 热重载失败)。 - 审计:
channel.save。curl:
curl -s -X PUT http://<host>:8080/api/v1/admin/channels/kook/config \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"id":"conf0","token":"<bot_token>","allowUsers":["user_i_like"],"groupMsgPerMin":20}'
5.25 DELETE /api/v1/admin/channels/{platform}/config
- 鉴权:
owner。Path:{platform}。Query:?id=<accountId>(必填)。 - 响应:
200 {"data":{"persisted":true,"path":"<channels.json>","platform":"","accountId":"<id>"}}。删除账号 → 落盘 →Reload(platform)热更新(对应 adapter 优雅关停)。 - 错误:
400(platform/id 缺失);500(未注入 / 写盘 / 重载失败)。 - 审计:
channel.delete。curl:
curl -s -X DELETE 'http://<host>:8080/api/v1/admin/channels/kook/config?id=conf0' \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
5.26 POST /api/v1/admin/plugins/install
- Body(
pluginInstallRequest):{"kind":"mcp","name":"<名>","config":{"transport":"stdio|http|sse","endpoint"?|"command"?, "args"?, "apiKey"?, "env"?}}。name必填;kind缺省"mcp",仅mcp可装(其它回400)。endpoint的别名是url。 - 响应:
200 {"data":{"installed":true,"name":"<名>","kind":"mcp","transport":"<t>","toolCount":<n>}}——真实安装:connect + 发现工具 + 热注册进 capability registry(下一轮模型请求即注入,无需重启)→ 落盘plugins.json(gitignore、0600)→ 持有 live server 供后续刷新/卸载。同名重复安装 = 刷新(先卸旧再装新,失败回滚旧)。 - 错误:
400(缺name/kind非mcp/ transport 非法 /stdio缺command/http|sse缺endpoint);502 plugin_install_failed(连接/发现/注册失败,且不落盘,绝不装一半);501 not_implemented(未注入 plugin manager)。 - 审计:
plugin.install。契约见 api-client §2 的POST /admin/plugins/install条(本节为索引,不重复维护契约)。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/install \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"kind":"mcp","name":"fs","config":{"transport":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","/tmp"]}}'
5.27 POST /api/v1/admin/plugins/uninstall
- Body(
pluginActionRequest):{"name":"<能力名>"}(必填)。 - 响应:已装 MCP →
200 {"data":{"name":"<name>","uninstalled":true,"kind":"mcp"}}(Unregister移除其工具 + close + 删plugins.json条目);非 MCP / 普通能力 →200 {"data":{"name":"<name>","uninstalled":true}}(走registry.Remove)。 - 错误:
400(name 缺失);404(不存在);500。 - 审计:
plugin.uninstall。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/plugins/uninstall \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' -d '{"name":"web_search"}'
5.28 POST /api/v1/admin/memories
- Body(
memoryAddRequest):{"personaId?","content","scope?"}。content必填;personaId缺省"cyrene";scope∈user|persona|project(缺省persona;无效非空回 400)。 - 响应:
201 {"data":{"id":"<id>","scope":"<scope>","text":"<内容>"}}——真实写入 memory store。 - 错误:
400(content 缺失 / scope 非法);501(memory store 未配置 / 未注入memory.Store);500。 - 审计:
memory.add。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/memories \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"personaId":"cyrene","content":"主人喜欢喝美式咖啡","scope":"user"}'
5.29 POST /api/v1/admin/memories/search
- Body(
memorySearchRequest):{"query","personaId?","topK?"}。query必填;personaId省略时回落到库里注册的默认角色(defaultPersonaID(),不再写死"cyrene"),既未传又无默认角色 →400 bad_request(诚实报错,不拿不可能命中的 id 去查);topK≤0 取 10。 - 响应:
200 {"data":{"query":"<query>","personaId":"<personaId>","semantic":false,"hits":[{"id","scope","text","score"}]}}。真实检索(keyword × 衰减)。semantic当前仍恒为false,但原因已变:向量索引已由组合根注入(cmd/cyrene/wire.go的memory.WithVectorIndex),真实原因是索引内当前无向量(生产路径无向索引写入向量,仅测试有Index.Add),故语义打分返回空;这是诚实反映,不是伪造。 - 错误:
400(query 缺失);500(memory store 未配置 / 检索失败)。 - 审计:
memory.search。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/memories/search \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"query":"咖啡","personaId":"cyrene","topK":5}'
5.30 POST /api/v1/admin/rag
- Body(
ragIndexRequest):{"name","desc?","content"}。name+content必填。 - 响应:
201 {"data":{"name":"<name>"}}——真实导入:分块并落项目索引,立即可被关键词/行上下文检索。 - 错误:
400(name/content 缺失);501(未注入 RagStore);500。 - 审计:
rag.index。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/rag \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"name":"部署说明","desc":"v2 部署要求","content":"单二进制...\n单端口 8080..."}'
5.31 POST /api/v1/admin/system/revoke-keys
- Body:无。
- 响应:
200 {"data":{"revoked":<int>}}——清空 API key 表,先前签发的所有 key 立即失效。 - 错误:
500(user service 未配置,users_unavailable)。 - 审计:
system.revoke_keys。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/system/revoke-keys \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
5.32 POST /api/v1/admin/system/rotate-jwt
- Body(
rotateJWTRequest):{"newSecret":"<≥32 位>"}(必填)。 - 响应:
200 {"data":{"rotated":true,"refreshTokensRevoked":<int>,"note":"现有 token 与 refresh token 已全部失效,需重新登录"}}(refreshTokensRevoked= 一并删除的 refresh token 行数)。 - 错误:
400(newSecret 缺失 / 长度 <32,"新密钥长度不足 32 位");500(认证服务未配置 / 轮换失败)。 - 审计:
system.rotate_jwt。新密钥永不回显、不落日志。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/system/rotate-jwt \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"newSecret":"a-32-char-long-random-secret-12345678"}'
5.33 POST /api/v1/admin/system/backup
- Body:无。
- 响应:
200 {"data":{"path":"<dataDir>/backup-<ts>.db","size":<字节数>}}。经 SQLiteVACUUM INTO(含已提交 WAL)快照到数据目录。 - 错误:
500(DB 路径未配置backup_unavailable/ 备份失败)。 - 审计:
system.backup。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/system/backup \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1' -d '{}'
5.34 POST /api/v1/admin/system/restore
- Body(
restoreRequest):{"path":"<dataDir 内的备份文件>"}(必填)。 - 响应:
200 {"data":{"restored":true,"backup":"<恢复前自动快照路径>","note":"已安排恢复,需重启服务后生效"}}。流程:校验 path 在 dataDir 内(filepath.Rel防路径穿越,..逃逸回 400)→ 文件存在校验 → 先自动备份当前 DB → 复制替换 DB 文件 → 返回(替换后需重启服务生效,因运行进程持有 DB 句柄)。 - 错误:
400(path 缺失 / 逃逸数据目录);404(备份文件不存在);500(DB 路径未配置 / 恢复前备份失败 / 替换失败)。 - 审计:
system.restore。curl:
curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -H 'X-API-Version: 1' \
-d '{"path":"/abs/path/data/backup-20260908T000000.000000000Z.db"}'
5.35 GET /api/v1/admin/system/storage(v0.60,只读)
- 鉴权:owner(
ownerAuth,与四条POST /system/*动作端点同门)。无凭据 / 非 owner →401。 - Body / query:无。
- 性质:只读查询,没有审计动作(审计只覆盖动作)。实现只有
SELECT/PRAGMA与os.Stat,且复用组合根注入的活连接(Deps.Database)——不新开连接池、不写、不迁移、不备份。调用前后documents行数与数据目录内文件集合均不变。 - 响应:
200
{"data":{
"main":{"kind":"sqlite","path":"/srv/cyrene/data/cyrene.db","fileBytes":12894208,
"walBytes":107872,"shmBytes":32768,"pageSize":4096,"pageCount":3148,
"freelistPages":12,"freeBytes":49152},
"documents":{"count":37,"bytes":2143567},
"vector":{"kind":"none","enabled":false,"ready":false}
}}
| 字段 | 类型 | 说明 |
|---|---|---|
main.kind |
string | 恒 "sqlite"(当前唯一主库引擎) |
main.path |
string | 组合根实际打开的库文件路径(与备份/恢复端点同源) |
main.fileBytes |
int | 主库文件字节数(os.Stat) |
main.walBytes / main.shmBytes |
int | <db>-wal / <db>-shm 边车字节数;合法地不存在时如实为 0(已 checkpoint / 未开 WAL) |
main.pageSize / main.pageCount / main.freelistPages |
int | PRAGMA page_size / page_count / freelist_count(一条 SELECT 取完) |
main.freeBytes |
int | pageSize × freelistPages(已分配但空闲、VACUUM 可回收的量级) |
documents.count |
int | documents 表行数 |
documents.bytes |
int | SUM(documents.size);空表为 0(COALESCE),不是 null |
vector.kind |
string | "pgvector" = 已配置 DB_PG_URL;"none" = 未配置 |
vector.enabled |
bool | 增强开关在不在(等价于 kind != "none") |
vector.ready |
bool | 活着的向量索引确实是 PostgreSQL+pgvector 后端 |
vector的读法(重要):kind说「配没配DB_PG_URL」,ready说「现在真的在跑什么」。配了 PG 但连接/schema 准备失败 ⇒enabled:true+ready:false(检索已降级),客户端应显示「已配置但未就绪」,不要把enabled当成生效。kind:"none"时前端显示「未启用(未配置DB_PG_URL)」。真源 = 组合根cmd/cyrene/wire_frontend.go的app.vectorState()(DB_PG_URL非空 + 活索引的具体类型),网关不读环境变量、不新开 PG 连接、不自行探测。- 不含凭据(红线):DSN / 用户名 / 密码 /
postgres://全文一个字都不回,vector段只有kind/enabled/ready三个键(有用例断言响应体里搜不到这些串)。 - 错误:
401(无凭据 / 非 owner);500 {"error":{"code":"storage_unavailable","message":…}}—— 数据库路径未配置、活连接未注入、或页级统计 /documents表读不到(例如库没跑迁移)。诚实边界:此时不返回任何全 0 的假快照。 - 前端落点:
frontend/web/src/lib/api/adminSystem.ts的adminSystemStorage()→ 面板首页「存储」卡(admin/modules/dashboard/overviewMetrics.ts):value=SQLite 12.3 MiB、hint= WAL / 页数 / 空闲页、详情两行 =documents N 件 · …与PG 增强 …。取数走与其它已接线卡片同一条加载/错误态(失败显示端点错误,不回退成0)。curl:
curl -s http://<host>:8080/api/v1/admin/system/storage \
-H "Authorization: Bearer $TOKEN" -H 'X-API-Version: 1'
6. WebSocket 实时协议(GET /ws/chat)
唯一实时通道。单二进制、单端口:同一
:8080上,REST/WS/admin 共用。生产者(loop/IoT/task/automation/reminder/subagent)经进程内bus发布,hub 订阅ws.>及桥接模式并规范化成 WS 帧广播给客户端。上行与下行都有角色门(fail-closed):① 上行帧——只有owner/admin能发,非 owner/admin 连接发来的任何上行 JSON 帧被定向拒为{type:"error",data:{code:"forbidden"}}(ws_route.go的route首条语句,默认拒,不广播/不驱动模型/不落库);② 握手history回放——仅owner/admin(ws_history.go的replayHistory首行);③ 下行帧——只有 7 个不含内容的状态机信号(含session_read)发给所有已认证连接,其余全部(含proactive_message)仅owner/admin(ws_frame_scope.go)。权威口径与帧清单见Docs/api-client.md§3 顶部的两把角色门;本节其余部分只描述帧形状,鉴权以 api-client 为准。
6.1 握手与连接
- URL:
ws://<host>:8080/ws/chat?token=<JWT>(或Authorization: Bearer <JWT>头)。 - 方法:必须
GET(否则握手前回400 {"error":{"code":"bad_request","message":"websocket requires GET"}})。 - 鉴权:
s.auth.Verify(wsToken(r))。wsToken先取?token=,再取Authorization: Bearer。失败 → 握手前回401 {"error":{"code":"unauthorized",...}}(浏览器/IM 客户端收到干净拒绝)。连接层不校验角色(任意有效 JWT——含viewer——都可连)。但连接之后的帧按角色分档:上行仅owner/admin可发、history回放仅owner/admin可收、下行除 7 个状态机信号外仅owner/admin(见本节开头与 api-client §3 顶部两把角色门)。 - Origin 校验:
CheckOrigin允许「同一 origin」+「同一 host」+ 白名单;*全放行。同 host 放宽:production 内嵌前端页面 origin 即网关 Host,故PORT改变无需改.env。跨源客户端(外部 IM / 另一 origin)需在ALLOWED_ORIGINS白名单。 - 升级后流程:注册连接 →
replayHistory(c, "web")(首帧推history回放)→ 启动writePump(唯一写者,含 ping/pong)与readPump(读客户端帧)。 - 心跳/限帧:读上限 4096 字节/帧;写超时 10s;pong 等待 60s;ping 周期 54s。发送缓冲 32(慢客户端满则丢帧,生产者不阻塞)。
history回放(首帧):{type:"history", sessionId:"web", data:{messages:[historyMessage...]}}——主会话最近 50 条(wsReplayLimit=50),thinking已剥离;仅owner/admin连接收到(replayHistory首行角色门,design/11 §7.2 A;viewer连接建立后不收回放)。回放仅在history != nil时发生,失败静默跳过。
6.2 帧壳(Frame Shell)
-
下行(服务端→客户端)
wsEvent:字段 类型 必填 说明 typestring 是 事件类型(见 §6.3 表) turnIdstring 否 一轮交换的关联 id(流式; turn-<hex>)。v0.36 起结构化流帧(message.*/thinking.*/tool_call.*)同样带turnId(broadcastStream从model.StreamEvent取,loop 在回合边界注入);v0.50 起这些帧的data内另带streamId(见 §6.6)。旧文曾说这条路径只有seq、没有轮次标识,那是错的,已更正seqint 否 hub 级单调递增序号,供客户端排序关联流式突发 sessionIdstring 否 能确定会话的事件带上,客户端按它把帧路由到该会话的消息桶(v0.54 起非激活会话的帧不再丢弃;只有单槽忙态仍按激活会话判定,见 api-client.md§3.1/§3.2/§3.3);可观测推送三帧(observability_log/observability_trace/observability_metrics,v0.55)不带该字段,帧壳只有{type,data}dataany 否 类型化负载(每个事件见 §6.3) -
上行(客户端→服务端)
wsInbound:字段 类型 必填 说明 typestring 是 事件类型(见 §6.3 上行表) reqIdstring 否 请求关联 id(当前服务端不回填,预留) dataobject 否 每类型负载(raw JSON)
6.3 上行事件(客户端 → 服务端)
由
wsConn.route(in)按in.Type分派。未知类型回{type:"error", data:{message:"unknown event type"}}。角色门(design/11 §7.2,fail-closed):route的第一条语句就是角色检查——非owner/admin连接发来的任何上行帧一律定向回{type:"error",data:{code:"forbidden"}},不广播、不驱动模型、不落库;因此下表只对 owner/admin 连接生效(viewer连上也发不出任何一条)。权威帧清单与版本记录见 api-client §3。
| type | 数据负载 | 服务端行为 | 结果 |
|---|---|---|---|
mode_switch |
任意(如 {"mode":"chat"}) |
发布 ws.mode_switch → hub 规范化广播 |
下行 mode_switch(广播) |
chat |
{sessionId?, text, image?, deviceId?, mode?, clientMsgId?, documents?, parallel?} |
直达会话编排器的非阻塞 Submit(v0.55 起:上行 chat 不再经可丢弃的 bus——会话消息不得被 drop;编排器未注入时回退发布 ws.chat) → streamChat(跑 loop 并流式)。clientMsgId(v0.56/G4)非空且重复到达 → 不落库、不起回合,只回下行 chat_duplicate。documents?(v0.59) = 文档附件引用数组(≤5、只收服务端存储名;解析失败回 error 帧带 data.code 并中止本回合)。parallel?(v0.65) = 显式声明「把本条作为独立一路」:会话正忙且额度未满 ⇒ 立即起第二条路(忙态帧随之带 activeTurns);否则落回排队并在 pending_ack 带 reason:"parallel_limit";空闲时无效果;省略 = 逐字不变 |
下行 stream_chunk/stream_end 或 message.*/thinking.*/tool_call.*(广播);重复时下行 chat_duplicate;并行路照准时本轮帧的 turnId 即该路 |
interrupt |
{sessionId, scope?} |
按 scope 中断(v0.56/A2):省略 / "" / "all" = 取消在跑 turn + 清空 pending(现状语义);"current" = 只取消在跑 turn、保留 pending(队列按编排器续跑规则继续执行);其它取值 → 定向回 error{bad_request},不打断任何会话 |
下行 session_interrupted({sessionId,mode,dropped};dropped = 真的被丢弃的 pending 条数,scope:"current" 时为 0 且 mode 为当时的忙碌通道) |
voice_input |
{audio?, format?, text?, sessionId?, deviceId?, clientMsgId?} |
STT→会话编排器→结算时 TTS(缺省 sessionId=web);deviceId 落库、platform 走存储默认 web;clientMsgId?(v0.59) 非空且重复到达 → 回下行 chat_duplicate,不产生新回合、不发音频 |
下行 voice_output(定向回请求连接,回合结算时才到);与文本同一条编排路径(忙时排队 / 跨端同步 / 落库) |
subagent_ask |
{id, mode?(""/full/tail/keyword), n, query?} |
审计子代理上下文(缺省 mode=full) |
下行 subagent_event(或 error) |
task_output_query |
{taskId} |
查询后台任务输出 | 下行 task_output(或 error) |
上行各负载字段
chat(chatStreamRequest):{sessionId(缺省"web"),text,image?(base64 data: URI 或 URL),deviceId?,mode?(行为模式,缺省回落chat),clientMsgId?(客户端幂等键,v0.56/G4:同一会话内同一非空值只承认第一条,重复到达回下行chat_duplicate{sessionId,clientMsgId,messageId},且不落库、不起回合;省略 = 老行为逐字不变),documents?(文档附件引用数组,v0.59:[{name}],≤5 条、只收服务端存储名;超出too_many_documents、名非法/不存在document_not_found,两处入口共用同一份解析),parallel?(独立一路的显式声明,v0.65:会话正忙且额度未满 ⇒ 立即起第二条路;省略 = 逐字不变,服务端不做启发式升级)}。text / image / documents 至少一项非空;runner未注入或三者皆空 → 直接广播stream_end{stopReason:"idle"}。主会话web绑定全量 env(loop.OwnerEnv())。chat进入时若该会话正忙 → 由编排器入队(见 §6.4),非阻塞。仅 owner/admin 连接可发(见本节角色门)。interrupt(v0.56/A2):{sessionId, scope?}。scope省略 /""/"all"= 取消当前任务 + 清空待处理队列(现状语义);"current"= 只取消当前任务、保留待处理队列(队列按编排器续跑规则继续执行);其它取值 → 定向回{type:"error",data:{code:"bad_request"}},不广播、不打断任何会话。命中空闲会话为 no-op(两种 scope 都是)。voice_input:{audio(base64 或 data: URI),format(wav|mp3|ogg|webm|mpeg,空由 mime 猜,默认wav),text(已转写则跳过 STT),sessionId?,deviceId?,clientMsgId?}。v0.59:deviceId随语音轮次落库(platform走存储默认web)、clientMsgId为可选幂等键(重复到达回下行chat_duplicate:不产生新回合、不发音频);语音回合与文本回合走同一条编排路径(落库 / 流式 / 跨端 / 忙时排队),音频在回合结算时定向回发起连接({audio,format,text,reply,sessionId,deviceId?,provider?})。无voice服务 →{type:"error",data:{message:"voice unavailable"}}。subagent_ask:{id(必填),mode(空/full/tail/keyword),n(tail 查询窗长),query(keyword 查询词)}。无 auditor →error;id 缺失 →error。task_output_query:{taskId}。无 task store →error;运行中任务返回截至目前输出;输出拼接stdout + "\n" + stderr。
6.4 下行事件全表
角色门(design/11 §7.1,fail-closed):下行帧按类型分档,收口在
internal/services/gateway/ws_frame_scope.go的frameReachesAll(唯一一处判定,各调用点不自行决定可见性)。只有 7 个不含内容的状态机信号发给所有已认证连接(含viewer):session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full/session_read。其余全部只发owner+admin:流式内容(message.*/thinking.*/tool_call.*/tool_execution.update/stream_chunk/stream_end)、持久化(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/proactive_message)、可观测推送(observability_log/observability_trace/observability_metrics,v0.55)、chat_duplicate(v0.56/G4:虽由编排器状态通道下发,但未进白名单)、steer_ack(v0.57:忙时排队消息被在飞回合吃进同一回合的回执,携messageId/atStep,未进白名单 ⇒ fail-closed 只发owner+admin;帧壳带sessionId、不带turnId/seq,经 hub 直接广播、不经 bus);未登记的新帧类型默认只发 owner/admin。本表并非全表:session_read/message_edited/message_deleted等已实现事件尚未逐行登记于此;权威清单与逐帧口径见Docs/api-client.md§3。来源标记:
[direct]网关直接 emit;[relay]hub 从 busws.>规范化(任意ws.X主题 → 类型X);[bridge]桥接 producer 主题;[target]仅定向特定连接。
| type | 来源 | 数据(data) |
用途 |
|---|---|---|---|
history |
direct | {messages:[historyMessage]} |
连接首帧回放主会话最近 50 条(thinking 剥离);仅 owner/admin 连接收到(viewer 不收) |
message.start |
direct | {index} |
正文段开始 |
message.delta |
direct | {delta,index} |
正文 token 增量(累加到当前正文) |
message.end |
direct | {index} |
正文段结束(不结束整轮,由 stream_end 收尾) |
thinking.start |
direct | {index} |
思维链段开始(前端折叠块) |
thinking.delta |
direct | {delta,index} |
思维链 token 增量 |
thinking.end |
direct | {index} |
思维链段结束 |
tool_call.start |
direct | {id,index} |
工具调用中间态开始 |
tool_call.delta |
direct | {id,index,delta} |
工具调用参数增量(如 input_json_delta) |
tool_call.end |
direct | {id,index} |
工具调用中间态结束 |
tool_execution.update |
direct | {id,index,delta} |
工具执行进度/局部输出 |
stream_chunk |
direct | {delta} |
向后兼容整段文本(仅非流式回退路径;流式路径不 emit) |
stream_end |
direct | {stopReason, steps?} / {stopReason:"idle"} / {stopReason:"error", error} |
一轮结束 |
session_busy |
direct(编排) | {sessionId, mode, activeTurns} |
会话进入忙碌态(mode∈idle/thinking/executing/subagent)。v0.65:activeTurns = 该会话当前在途路数(含正在收尾的路;单路时非 0 即 1),纯增量 |
pending_ack |
direct(编排) | {sessionId, mode, queueDepth, reason?} |
忙时新消息已入队「收到,稍后回」。v0.65:可选 reason:"parallel_limit",只在客户端显式声明了并行(parallel:true)却落回排队时出现(额度已满 / 真并行被 <2 关掉),其它情况该键缺席 |
pending_drain |
direct(编排) | {sessionId, mode, queueDepth, activeTurns} |
忙完开始消费下一待处理消息。v0.65:增 activeTurns |
session_idle |
direct(编排) | {sessionId, mode, activeTurns} |
会话回到空闲。v0.65:增 activeTurns;时点收紧为「所有在途路收尾且 pending 空」(仅并行时生效,单路逐字不变) |
session_interrupted |
direct(编排) | {sessionId, mode, dropped, cancelledTurns, activeTurns} |
用户 interrupt。v0.56/A2:scope:"all"(省略 / 空串同义)= 任务取消 + pending 清空(mode=idle、dropped=清空条数);scope:"current" = 取消全部在途路、保留 pending(dropped 恒 0、mode 为当时忙碌通道)。v0.65 纯增量两键:cancelledTurns = 本次真的取消了几路;activeTurns = 清点后仍在途的路(scope:"all" 恒 0)。scope:"all" 不发 session_idle(客户端靠本帧收口) |
pending_full |
direct(编排) | {sessionId, mode, max, clientMsgId?, messageId?} |
单会话 pending 达上限(默认 5)拒绝入队。v0.59/G5 纯增量两键:clientMsgId = 被拒那条消息的幂等键(该消息未携带则整个键缺席);messageId = 仅当该消息此前已落库/已认领时才回填——绝不在此处新铸 id(队列满的拒绝发生在幂等键认领之前,代铸会让客户端重试被判成「重复到达」而永远进不来)。另:同一时刻向发送者那条连接定向回 {type:"error",data:{code:"chat_busy",message,sessionId,clientMsgId?,messageId?}}(只发发送者、不广播;code 与 REST 409 chat_busy 同码) |
chat_duplicate |
direct(编排) | {sessionId, clientMsgId, messageId} |
v0.56/G4(新增帧):同一会话内重复到达的 clientMsgId 的明确回执——不落库、不起回合、不发任何内容帧。帧壳带 sessionId;未进 7 个状态机信号白名单 ⇒ fail-closed 只发 owner+admin(发过该 clientMsgId 的客户端用它把本地乐观气泡对齐到既有 messageId,其它客户端按值自行忽略) |
steer_ack |
direct(编排) | {messageId, atStep}(帧壳带 sessionId,data 内不重复该键) |
v0.57(新增帧):忙时会话里新到的用户消息已被在飞回合吃进同一回合(已从 pending 移除、尚未被正面回答)——messageId = 那条用户消息的权威 id(与 message_persisted 的 data.message.id 同源)、atStep = 吃进它的模型步序号(1 基);一批吃进 N 条发 N 帧(不合并)。客户端把对应气泡渲染为「已读、未答」的灰态,直到该回合的 message.*/turn.end 到达(不需要第二个确认帧)。只在回合内边界产生(一次模型步与其工具批次都已结束、下一次模型调用尚未开始;无匹配消息则一帧不发);不带 turnId/seq,经 hub 直接广播、不经 bus;未进 7 个状态机信号白名单 ⇒ fail-closed 只发 owner+admin(viewer 收不到)。详见 api-client §3.14 |
message_persisted |
direct | {message:historyMessage}(sessionId 设) |
每条 user/assistant 消息落库后广播(主会话 web/web:<uuid>),thinking 剥离 |
channel_persisted |
direct | {message:historyMessage}(sessionId=sess-xxx) |
群聊/陌生人(非主人)消息落库后广播,供 IM 适配器日志页 |
voice_output |
direct/target | {audio, format, text, reply, sessionId, deviceId?, provider?} |
语音合成回复(定向回发起 voice_input 的那条连接;不按 deviceId 路由、断线不补投)。v0.59 纯增量:sessionId(恒有)与 deviceId(非空才有)标注归属;provider 在空正文或 TTS 失败/降级为空音频时出现(如 "silent"),该帧仍照常发出、不是业务错误。时点:该回合结算时才到(可能晚于若干状态帧,也可能由续跑或被 steer 注入的那一回合产生) |
subagent_event |
direct+relay | 审计结果 或 {id,name,status,role,mode} |
子代理上下文查询应答 / 子代理进度 |
task_output |
direct+bridge | {taskId, output, status} |
后台任务输出查询应答 / task.output 桥接 |
capacity_awaken |
direct | {fromState, toState, tool?} |
能力状态迁移(design/14 §5,sessionId 设) |
proactive_message |
direct | {platform?, channelId?, channelType?, content, contentType?, sender?, timestamp?, messageId?} |
自主主动推送(仅 owner/admin 连接收到) |
device_update |
bridge | producer payload | IoT 设备状态更新(iot.device_update) |
task_done |
bridge | producer payload | 后台任务完成(task.done) |
automation_result |
bridge | producer payload | 自动化规则结果(automation.result) |
reminder |
bridge | producer payload | 提醒触发(reminder.trigger) |
mode_switch |
direct | 上行原样(任意) | 行为模式切换回显(ws.mode_switch) |
mood/tool_used/graph_update/… |
relay | ws.X 主题负载(ws.mood→mood,ws.tool_used→tool_used,ws.graph_update→graph_update,…) |
生产者在 bus 上发 ws.X 即规范化成类型 X 广播 |
observability_log |
bridge | {entries:[{time,level,source,msg}…]}(最新在前) |
可观测推送(v0.55):订阅后新增日志,≤1s 一拍最多一帧、无新条目不发;单帧上限 200 条;不带 sessionId |
observability_trace |
bridge | {spans:[AdminTraceSpan…]}(最新在前) |
可观测推送(v0.55):订阅后新完成的 span;单帧上限 100 条;不带 sessionId |
observability_metrics |
bridge | <AdminMetrics>(扁平,与 REST 同形) |
可观测推送(v0.55):与上次发出的不同才发;不带 sessionId |
error |
direct | {message} / {code, message, sessionId?, clientMsgId?, messageId?} |
非法 JSON / 未知类型 / 语音·子代理·任务错误等。v0.59 新增一种定向用法:队列满时向发送者回 {code:"chat_busy", …}(G5,见上表 pending_full 行);chat 帧的文档引用被拒时回 `{code:"too_many_documents" |
auth(旧 design/07 列表):当前不由网关直接 emit;若未来有ws.auth生产者主题,则按[relay]规则广播。本文档以代码为准。
message_persisted / channel_persisted / history 的 historyMessage 全字段(含 thinking 剥离规则):
| 字段 | 类型 | 说明 |
|---|---|---|
id |
string | 后端生成消息 id(客户端去重键) |
sessionId |
string | 会话 id |
role |
string | user / assistant |
content |
string | 正文 |
mode |
string | omitempty |
mood |
string | omitempty |
image |
string | omitempty |
documents |
array | omitempty(v0.59):[{name,mime,size,chars,truncated,status}],≤5 条、顺序 = 客户端发送顺序;0 个附件时该键省略;`status ∈ extracted |
thinking |
string | omitempty,但在这些实时帧中恒为空(BroadcastPersisted/replayHistory 均置空;/messages owner/admin 端点才带)。v0.59 口径(有意默认):实时帧与握手回放的该字段一律剥离;实时思维链另走 thinking.* 流帧(owner/admin 档),历史思维链只有 owner/admin 的历史端点返回 |
platform |
string | omitempty(web/telegram/weixin-ilink/qq-obv11...) |
deviceId |
string | omitempty |
senderName |
string | omitempty(第三方昵称,主人私聊为空) |
adapterId |
string | omitempty(仅 channel_persisted 常见,如 kook:3bef...) |
personaId |
string | omitempty(v0.42:该消息说话时的角色 id——assistant 消息 = 生成它的角色、user 消息 = 当回合角色;空 = 全局默认角色。与 GET /sessions/{id}/messages 同源字段,客户端按角色渲染名字/头像) |
clientMsgId |
string | omitempty(v0.56/G4:客户端自带的幂等键——由 chat 上行带 clientMsgId 的 user 消息写入,之后在 message_persisted / channel_persisted / history 回放 / GET /sessions/{id}/messages 中原样回填;空 = 无幂等键,即老客户端 / IM 镜像 / 历史行) |
createdAt |
int64 | Unix 毫秒 |
事件类型分流(BroadcastPersisted):sessionId == "web" 或以 web: 前缀 → message_persisted(Web 聊天实时同步);其它(隔离 sess-xxx)→ channel_persisted(IM 适配器日志专用)。Web 聊天无 channel_persisted handler,天然忽略。
6.5 会话编排状态(design/15,data.sessionId 区分)
- 语义:同一会话
chat到达时若「她忙」(跑 loop/工具/子代理/长回复)→ 新消息不阻塞、入该会话 pending(上限默认 5);状态经上述session_busy/pending_ack/pending_drain/session_idle/session_interrupted/pending_full推给前端,供「正在做 xx… 有新消息稍后回」轻状态展示。 - 回合内 steer(v0.57):pending 里的消息不是只能等下一个回合——在飞回合会在回合内边界(上一次模型步与它那一批工具调用都已结束、下一次模型调用尚未开始)把它那批已落库的消息注入同一回合(绝不新开第二个回合),并逐条下发
steer_ack(§6.4)告知「队列里哪一条已被本回合吃进」(messageId级;吃进=已从 pending 移除,但尚未被正面回答 ⇒ 前端呈灰态)。pending_ack/session_busy描述队列深度与忙闲,steer_ack描述哪一条被吃进;无可注入消息的边界不发任何帧。详见 api-client §3.14。 - 续接:忙完重开时新回合带上上一轮上下文(
tail默认 20 条),非清空重来。 - mode 取值:
idle/thinking/executing/subagent。 - REST 同步路径(§3.1)在忙时返回
202 queued/ 满409 chat_busy,与这些 WS 状态事件一致。 - 忙时并行(v0.65,基础管线):上行
chat.parallel:true(WS 帧 / REST 三入口同形)+ 单会话额度未满(默认 2 / 硬上限 3 /<2关闭)⇒ 本条升为独立一路立即起(不排队)。忙态帧随之带activeTurns、session_interrupted带cancelledTurns、pending_ack在「声明了并行却落回排队」时带reason:"parallel_limit";session_idle时点收紧为「所有在途路收尾且 pending 空」(仅并行时生效,单路逐字不变)。未实现:turn_begin/steer_ack.turnId/interrupt.turnId/chat_messages.turn_id/graph_update按路分桶 / 写类工具文件级互斥 / 第二路进行态 system 摘要;envCHAT_MAX_PARALLEL_TURNS未接线(生产恒为默认 2)。详见 api-client §5 v0.65。 - 队列满的身份回填与定向回执(v0.59/G5):
pending_full的广播载荷增clientMsgId?/messageId?(前者 = 被拒那条消息的幂等键,未携带则整个键缺席;后者仅当该消息此前已落库/已认领时回填,绝不新铸 id);同时向发送者那条连接定向回{type:"error",data:{code:"chat_busy",…}}(code与 REST409 chat_busy同码,客户端一个 code 覆盖两条入口;只发发送者、不广播)。详见 api-client §3.2。
6.6 流式关联(turnId / seq)
turnId:v0.65 起由编排器生成、loop 复用——编排器在启动某一路前生成turn-<hex>(crypto/rand)并经loop.WithTurnID挂进该路运行上下文,runTurnctx 上已有则复用、没有才新铸(未接编排器的回退路径 / 子代理 / backthink 仍新铸)。单路时帧上仍是每回合全新的随机 id ⇒ 对客户端无可见差异。用于关联一轮流式突发;stream_end/stream_chunk(回退路径)带turnId。结构化流路径(message.*/thinking.*/tool_call.*)自 v0.36 起同样带sessionId+turnId——broadcastStream直接把model.StreamEvent的SessionID/TurnID填进帧壳,loop 在回合边界把turnId挂进运行上下文(同轮所有帧一致)。旧文曾说这条路径只有seq、缺轮次标识,是错的,本版按实现更正(与 api-client 的 v0.36 条目一致)。v0.50 起这些帧的data内另带streamId(同一次模型流的多帧共享,多步工具轮的第二次模型调用取新 id);tool_execution.*与turn.start/end帧不带streamId。seq:hub 级单调递增atomic.Uint64,保证一次突发有序,即便 loop 多 goroutine 触发。
6.7 WS 示例
握手 + 收 history + 发 chat 收流式(伪代码/curl-like):
# 1) 拿 token
TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"s3cret"}' | jq -r .data.token)
# 2) 连接(浏览器用 ws = new WebSocket('ws://host:8080/ws/chat?token='+TOKEN))
# 首帧:{"type":"history","sessionId":"web","data":{"messages":[...]}}
# 3) 上行 chat
# {"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"web:chrome:a1b2c3"}}
# 下行(流式回退):{"type":"stream_chunk","turnId":"turn-abc123","seq":1,"sessionId":"web","data":{"delta":"你"}}
# ... {"type":"stream_chunk",...} ...
# {"type":"stream_end","turnId":"turn-abc123","seq":8,"sessionId":"web","data":{"stopReason":"stop","steps":2}}
# 4) 上行 interrupt
# {"type":"interrupt","data":{"sessionId":"web"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"idle","dropped":2}}
# v0.56/A2:只打断当前那一轮、保留待处理队列 → scope:"current"(此时 dropped 恒 0、mode 为当时忙碌通道,如 thinking)
# {"type":"interrupt","data":{"sessionId":"web","scope":"current"}}
# 下行:{"type":"session_interrupted","data":{"sessionId":"web","mode":"thinking","dropped":0}}
# 非法 scope:{"type":"interrupt","data":{"sessionId":"web","scope":"cancel"}}
# 定向回发送者:{"type":"error","data":{"code":"bad_request"}}(不广播、不打断任何会话)
# 5) 上行 voice_input
# {"type":"voice_input","data":{"audio":"<base64>","format":"wav","sessionId":"web"}}
# 下行(定向):{"type":"voice_output","data":{"audio":"<base64>","format":"wav","text":"你好","reply":"你好呀"}}
# 6) 上行 subagent_ask
# {"type":"subagent_ask","data":{"id":"sub-1","mode":"full"}}
# 下行:{"type":"subagent_event","data":{...审计结果...}}
# 7) 上行 task_output_query
# {"type":"task_output_query","data":{"taskId":"task-1"}}
# 下行:{"type":"task_output","data":{"taskId":"task-1","output":"...","status":"done"}}
7. 上传 / 媒体说明
⚠️ 本节曾长期写「无
/upload端点 / 无 REST 语音端点」,与实现恰好相反,已于 2026-10-03 修正——正确口径见上文 §0.3 与 api-client §1 行 10–14。
POST /api/v1/upload存在(owner/admin + 审计):multipartfile(光栅图片,5MB 上限)→201 {"data":{"url","size","mime","name"}};url为同源访问地址/api/v1/files/<name>,可直接作chat.image。GET /api/v1/files/{name}存在(无需鉴权——128-bit 随机能力 URL,<img>/chat.image无法带 Bearer):从<dataDir>/uploads/读该文件;name仅接受「32 位小写 hex + 白名单扩展名」,路径穿越全形态拒绝。契约见 api-client §1 行 14。- REST 语音端点存在(均 owner-only + 审计):
POST /api/v1/audio/{transcribe,speech,voice-clone},供原生/移动端绕开 WS 4KB 帧限;语音也可走 WS 上行voice_input/ 下行voice_output(定向回请求连接)。 - 图片 + 文本并存:
image非空时 turn 变为「带图 + 可选文本」;text空 +image有 → 纯图像 turn。 - 文档导入:走
POST /api/v1/admin/rag的 JSONcontent文本,不是文件上传。 - 敏感面:
image(base64)与bot_token/apiKey/newSecret等概不作为日志明文输出;wire 响应对 token/key 做掩码或不返回。
8. 附录
8.1 通用错误码 / 状态码
| 状态码 | code(示例) | 说明 |
|---|---|---|
| 200 | - | 成功 |
| 201 | - | 创建成功(personas/子会话/apikey/automations/llm/channels config/memories/rag/documents) |
| 202 | - | 已接受(chat 排队 queued:true,queueDepth) |
| 204 | - | CORS 预检 OPTIONS 成功无内容 |
| 400 | bad_request / invalid_cron / invalid_rule / invalid_role / unsupported_action / invalid_param / invalid_provider / invalid_scope / too_many_documents(v0.59) |
请求体/参数非法 |
| 401 | unauthorized |
认证缺失/失效/过期(REST Bearer 与 WS) |
| 403 | forbidden |
角色不足(viewer 读管理/主会话删除/写操作);主会话不可删 |
| 404 | not_found / document_not_found(v0.59) / session_not_found(v0.63) |
资源不存在(文档:名字非法 / 路径穿越 / 文件不存在同一响应;会话:GET /sessions/{id}/messages 对不存在 / 已删除的会话,空会话仍是 200 {"data":[]}) |
| 409 | chat_busy / duplicate / device_offline / needs_login |
冲突:队列满(REST 响应体与 WS 定向 error 帧同码,v0.59/G5)/ 人格已存在 / 设备离线 / 渠道未登录 |
| 413 | document_too_large(v0.59) |
文档超 CHAT_DOCUMENT_MAX_BYTES(默认 20 MiB),文案带生效上限字节数 |
| 415 | unsupported_media_type(v0.59;判定口径 v0.61) |
文档类型不在白名单——扩展名白名单(23 项,含 .pdf)与字节嗅探的成对裁决任一不过(HTML/纯文本改名成 .pdf、或 PDF 改名成 .txt 都走它) |
| 500 | internal / chat_failed / sessions_failed / … |
服务端错误(含未注入依赖) |
| 501 | not_implemented |
后端确定未实现(新增用户 / IoT 场景·规则 / 编辑环境变量·重启服务·检查更新 / 完整会话转录导出等)——诚实返回,不伪造成功。注:插件安装(§5.26)、知识库删除(§1.3 #23)、主人档案保存(PUT /api/v1/admin/owner-profile,§1.3 #67)均已实现,不再返回 501 |
8.2 依赖注入与「未配置」行为
网关依赖(Deps)大多为 nil 容忍,未注入时端点要么回退、要么如实报错,绝不伪造成功:
History未注入:GET /sessions回退渠道会话视图;/messages、POST /sessions、DELETE /sessions/{id}、/channels/messages回501 not_implemented。RAG未注入:GET /admin/rag返回空文档列表;POST /admin/rag、/admin/rag/delete回501。Runner(Loop)未注入:/chat回500 chat_unavailable;WSchat直接广播stream_end{stopReason:"idle"}。Channels未注入:/admin/channels/config回500 channels_unavailable。LLM未注入:/admin/llm按.env合成单组;POST /admin/llm回500 llm_unavailable;POST /admin/llm/reset-from-env(v0.62)同样回500 llm_unavailable(不假装重置成功)。Deps.EnvPath(v0.62):组合根注入的启动用.env路径,只供POST /admin/llm/reset-from-env按既有解析入口重读 env 默认层(网关不 fopen.env);为空 = 只看进程环境变量(生产 systemd/env_file 形态)。.env读不出(例如路径是个目录)→500 env_defaults_unreadable,不改任何文件。Voice未注入:WSvoice_input回error {message:"voice unavailable"}。- IM 入站语音(v0.65):
channelbridge的VoiceSTT由组合根注入(与 Web 侧voice_input同一条voice.Chain,不另建一条);超时旋钮VOICE_STT_TIMEOUT_SEC(秒,默认 20,≤0用默认)只约束 IM 渠道入站语音的这一次转写(流水线要为它一直举着一个入站并发槽;Web 客户端的voice_input不受它约束)。转写不成功(未配置 / 鉴权失败 / 欠费 / 限流 / 超时 / 静默档位)时消息面落一行中文诚实提示(按原因分类、含临时熔断退避),绝不留空消息、绝不假装成功。 Scheduler(auditor)未注入:WSsubagent_ask回error {message:"subagent audit unavailable"}。Tasks未注入:WStask_output_query回error {message:"task output unavailable"}。AuditStore未注入:审计退化为进程内存(端点仍返回数据)。- 管理员只读端点若依赖缺失,大多返回
[],不 500。
8.3 审计动作清单(what 字段,落 audit_logs)
policy.set / persona.create / persona.update / user.create / apikey.issue / apikey.revoke / iot.control / iot.scenario / iot.rule / automation.create / automation.run / automation.delete / llm.addProvider / llm.test / llm.resetFromEnv(v0.62)/ channel.open / channel.close / channel.identity-test / channel.weixin.login.qr / channel.weixin.login.status / channel.weixin.login.persist / channel.platform.view / channel.friends / channel.friend.handle / channel.message.test / channel.save / channel.delete / plugin.install / plugin.uninstall / memory.add / memory.search / rag.index / rag.query / rag.delete / session.export / system.revoke_keys / system.rotate_jwt / system.backup / system.restore。
(what 后缀 _failed/_persist_failed/_reload_failed 等计入 error 分支。)
8.4 里程碑与未实现集合(诚实标注)
- 已真实实现:登录/健康检查/聊天(同步+流式)/会话(列表/新建/删除/消息分页)/人格/文档存储与摄取(v0.59:
POST/GET /api/v1/documents+chat.documents+documentsDTO +document-read工具;v0.61:PDF 文本层抽取,白名单 23 项含.pdf、成对裁决、扫描件诚实拒绝)/语音回合进编排(v0.59:落库 / 流式 / 跨端 / 忙时排队,voice_output归属键)/管理读(personas/sessions/memories/plugins/channels/iot/llm/users/automations/backthink/rag/observability×4:metrics/trace/audit/logs)/管理写(人格/API key/设备控制/自动化/LLM 保存·测试·重置为 env 默认(v0.62,owner-only)/渠道连接·断开·身份测试/渠道配置·删除/KOOK 面板/微信扫码/记忆添加·检索/插件安装·卸载/RAG 导入·删除/主人档案读写(GET/PUT /api/v1/admin/owner-profile,v0.51)/系统设置 4 项)。 thinking可达性(v0.59 口径,不改行为):消息体thinking字段在实时帧(message_persisted/channel_persisted/message_edited)与握手回放中一律剥离(有意默认);实时思维链走thinking.*流帧(owner/admin);历史思维链只有GET /sessions/{id}/messages(owner/admin)返回。- 仍为后置 M10(返回
501 not_implemented,不伪造):新增用户(无用户表/登录)、IoT 场景与规则(无模型/存储)、编辑环境变量/重启服务/检查更新、完整会话转录导出(当前导出身份元数据)。文档侧后置:配额与引用计数/GC(design/24 S7)、Office 解析(.doc/.docx/.xls/.xlsx/.ppt/.pptx/.odt/.ods/.odp/.rtf,仍诚实拒绝)、扫描件/图片型 PDF 的 OCR(v0.61 已做文本层 PDF,无文本层者如实报unreadable)、IM 侧富媒体摄取(v0.65 起channelbridge已摄取入站内联附件字节——图片/文件引用写进用户消息documents列——与入站语音字节(经 ASR 归一为文本,见 §8.2);其余媒体类型仍未做)。已知缺口:design/24 §6.2 的document_unreadable(400) 无 REST 落点(只有document-read工具错误形态;客户端在 DTO 里看到的是documents[].status = "unreadable")。 - 忙时并行(v0.65,基础管线)的未实现集合(诚实标注):
turn_begin帧、steer_ack的帧壳turnId、interrupt.turnId、chat_messages.turn_id列与 DTO 键、graph_update按路分桶、写类工具文件级互斥(internal/runtime/tool/legfile.go的FileClaimer/ErrFileBusy/WithLegID仅定死形状、无实现无接线)、第二路进行态 system 摘要(loop.WithParallelNotice)均未实现;envCHAT_MAX_PARALLEL_TURNS未接线(Deps.MaxParallelTurns无人从 env 读 ⇒ 生产恒为默认 2,.env配了不生效)。已实现的是「基础管线」:上行chat.parallel(WS/REST 三入口同形)、忙态帧activeTurns、session_interrupted.cancelledTurns、pending_ack.reason:"parallel_limit"、两条 REST 应答形状(同步202 {parallel:true,turnId}/stream:true的200 {stream:true,parallel:true,turnId})、session_idle时点收紧(仅并行时生效)。详见 api-client §5 v0.65。 - 语义/向量增强:
memory.search的semantic当前恒为false——向量索引已由组合根注入(cmd/cyrene/wire.go的memory.WithVectorIndex),但索引内当前无向量(生产无写入),故语义打分返回空,属诚实反映而非伪造;RAGembedded反映部署是否配置向量增强;配置了增强必须真实走语义/向量路径(红线,非空壳)。 - API key 认证:
X-API-Key已接入鉴权(v0.51,见 §0.1 注与 api-client.md §认证)——命中签发表即通过、一律owner级(取消 role 分级),带该头时只走 key 分支(坏 key 回401、不回落 Bearer);签发/吊销/列表同样真实生效。
8.5 客户端接入快速索引
POST /api/v1/auth/login→{token, expiresAt, role}(恒owner)。- 之后每个 REST 请求带
Authorization: Bearer <token>;管理写需owner;管理读需owner|admin;GET /sessions、GET /personas任意已认证。 - 实时:
ws://<host>:8080/ws/chat?token=<token>;首帧history回放(仅owner/admin连接收到);上行chat/interrupt/voice_input/subagent_ask/task_output_query/mode_switch(仅owner/admin连接可发,viewer发送一律被定向拒);下行见 §6.4 与 api-client §3。 - 身份判定永远来自后端 channel 层 actor 元数据 + 白名单(
configs/users/owner.yaml),客户端不要用提示词/文本判身份;thinking在公开回放/实时帧中已剥离,客户端不应依赖。 - 发送消息带
deviceId(推荐)以便端标识落库;platform用于 IM 渠道过滤。