feat(gateway,config,web,docs): backthink 实时帧显示打扰原因 + 图片上限可配 + api-reference 端点表补齐
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 前端:`lib/wsParse.ts` 的 `parseBackthinkItem` 现在也读原始 `PushResult` 的 route/detail/bypassed(兼容
  camelCase),与历史 DTO 路径**同名同义**(空串 → undefined、未报告的 bool 不编成 false);
  Backthink 页 Notice 改为如实描述。证据=用后端两条路径的**真实序列化**喂同一个 PushResult 做逐字段比对
- 图片上限可配:`CHAT_IMAGE_MAX_BYTES`(默认 5242880 = 与 /upload 同口径;**未配置/0/负 一律用默认**,
  不存在「禁用上限」);删掉重复的字符常量,换算只留一份 `chatImageCharsFor`;补 3 条用例
  (默认值==upload 预算、配 1 MiB 真生效且只有合法那条驱动回合、非正值回落默认)
- `Docs/api-reference.md`:§1 端点表按 `routes.go` **补齐**(auth refresh/logout、upload、files、三支 audio、
  /admin/plugins/installed、/admin/llm/catalog、observability/logs、notifications、owner-profile、reminders),
  既有编号一律未动(保交叉引用);两张 `historyMessage` 字段表补 `personaId`(+ `clientMsgId`);
  修正 §8.1「主人档案保存仍 501」与实现不符(已真实实现)
- 复核:build/vet 绿 + gateway/config 包测试绿 + 前端 tsc 0 错 + vite build 成功
- 登记:§1 各表的编号跨表重复(不重编号以保交叉引用);`admin_plugins.go:217` 注释写错鉴权档(未改);
  §6.4 的 WS 事件帧表仍未补全
This commit is contained in:
AskaEth committed 2026-10-04 16:24:36 +08:00
1 parent 9b01efd796
commit c96a7db7f1
7 files changed
+195 -38

No files matched your search

+5
View File
@@ -25,6 +25,11 @@ PORT=8080
# 它只影响面板前端路由;REST 仍是 /api/v1/admin/*(API 不藏,安全靠 JWT + 角色门),
# 且该路径串不会出现在用户端页面里。
ADMIN_PATH=admin
# chat.image 的【解码后图像字节】上限(协同文档 G3),默认 5242880(5 MiB,与 POST /api/v1/upload 同口径)。
# 图片有「上传」与「base64 内联」两条入口,两边必须给出同一个「一张图能有多大」的答案,否则内联可绕开上传限制。
# 调大请谨慎:整段 base64 会被落库,并随每页历史(GET /sessions/{id}/messages)与每次 WS history 回放重传一遍。
# ≤0(写 0 或负数)= 用默认值 5 MiB,不是「禁用上限」——一张图能有多大只能有一个答案。
CHAT_IMAGE_MAX_BYTES=5242880
# ---- 认证 / 管理员 ----
# JWT_SECRET 必填,长度 >= 32;缺失或过短会在启动时 panic。
+26 -7
View File
@@ -2,7 +2,7 @@
> **权威归属(先读这条)**:**契约以 `Docs/api-client.md` 为准**——那是版本化的跨平台客户端契约,每个端点的形状 / 语义 / 版本变更都记在那里(当前 `v0.56`)。**本文件是内部与运维参考**:端点全集索引 + 示例 + 部署细节。**凡是与 api-client 重复的契约性描述,一律以 api-client 为准(本文件不再维护第二份契约);发现两者不一致时,改本文件而不是改 api-client。**
> **本文件的正确用法**:查「有哪些端点、大致形状、错误码、部署注意事项」可以;**写客户端代码时请以 `Docs/api-client.md` 为准**,本文件的字段表 / 示例可能滞后。
> **历史包袱(如实)**:本文件长期自称「全部后端 API 的唯一详细参考 / 超详细主文档 / 每变更必同步」,但实际**系统性滞后于实现与 api-client**(2026-10-03 的 16 路 design-vs-code 审计结论)。本次已修正与实现**直接矛盾**的陈述、并把若干复述型段落改为引用 api-client;**仍未登记的端点 / WS 事件清单见 `DevDocs/audit/00-总览.md` §4④**,属后续「文档补全」批次,本文件暂不补全。
> **历史包袱(如实)**:本文件长期自称「全部后端 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)。
---
@@ -45,7 +45,7 @@
### 0.3 上传 / 媒体(重要确认)
- **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/<name>`),可直接作 `chat.image`;也可继续用 **base64 data: URI** 或 http(s) URL 走 `POST /api/v1/chat` / WS `chat` 的 `image` 字段(入视觉路由,见 §6 流式与直连)。**语义(v0.53 修正,见 api-client §1 行 3)**:**主模型能否收图 = 生效 `models.json` 组的 `multimodal`**(不是「有没有配独立视觉模型」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**;主模型不收图时由 vision/OCR 工具返回**文本描述**(另有 `image-crop` 工具可裁区域后重读,便于大图分区精读)。**内联上限(v0.56)**:`chat.image` 解码后 **5 MiB**(字符串上限 `ceil(n/3)*4 + 64`),超限 `400 {"error":{"code":"image_too_large",...}}`;WS `chat` 帧受 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」即按此更正)。
- **`POST /api/v1/upload`**(B1)上传图片 → 返回**同源 URL**(`/api/v1/files/<name>`),可直接作 `chat.image`;也可继续用 **base64 data: URI** 或 http(s) URL 走 `POST /api/v1/chat` / WS `chat` 的 `image` 字段(入视觉路由,见 §6 流式与直连)。**语义(v0.53 修正,见 api-client §1 行 3)**:**主模型能否收图 = 生效 `models.json` 组的 `multimodal`**(不是「有没有配独立视觉模型」);`LLM_VISION_MODEL` / `LLM_OCR_MODEL` **只配视觉/OCR 工具用的模型**;主模型不收图时由 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 数字会自动跟随;WS `chat` 帧受 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」即按此更正)。
- **REST 语音端点已存在**:`POST /api/v1/audio/transcribe`(STT)、`POST /api/v1/audio/speech`(TTS)、`POST /api/v1/audio/voice-clone`(声音复刻),均 owner-only + 审计,绕开 WS 4KB 帧限。语音也可走 WS `voice_input`(上行)/`voice_output`(下行)。
- 管理端导入知识文档(RAG)走 JSON body 文本(`POST /api/v1/admin/rag`),非文件上传。
@@ -53,7 +53,7 @@
## 1. REST 端点总览
> 下表为**全量登记**;下文 §2–§5 每端点给出字段级说明。`鉴权列`:`无` / `JWT`(任意已认证) / `owner|admin` / `owner`。`里程碑` 为代码/设计归属的大致里程碑(M8 网关、M9 前端、M10 补齐写、M16 会话持久化等)。
> 下表为**全量登记**;下文 §2–§5 每端点给出字段级说明。`鉴权列`:`无` / `JWT`(任意已认证) / `owner|admin` / `owner`。`里程碑` 为代码/设计归属的大致里程碑(M8 网关、M9 前端、M10 补齐写、M16 会话持久化等)。**`#` 列是登记序号**:它历史上曾是跨表连续序号,但 §1.3 起就与前后表重复(`§8.1` 等处有 `§1.3 #23` 这类交叉引用,故既有编号一律不动);**补登记的行取本表内下一个可用号**。
### 1.1 公开(无鉴权)
@@ -61,12 +61,14 @@
|---|------|------|:---:|------|:---:|
| 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/409/500/503 | 发消息(同步回复或触发 WS 流式);**v0.56**:`stream:true` 受理成功才回(`202` 续接排队 / `409` 队列满 / `500 chat_unavailable` 未接 loop / `503 chat_dispatch_failed` 兜底派发失败)、`image` 超 5 MiB → `400 image_too_large`、`clientMsgId` 重复 → `200 {duplicate:true}` | M8/M15 |
| 3 | POST | `/api/v1/chat` | owner\|admin | 200/202/400/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}` | 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 | 会话消息热分页 | M16 |
@@ -77,10 +79,15 @@
| 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 |
### 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`,后三条**尚未登记进下表**、见审计 `00-总览.md` §4④)与 **`owner|admin` 档**(含他人数据 / 消息正文 / 凭据面 / 审计流水);**写一律 `owner`** + 全程审计。下表按注册顺序,标注鉴权与状态。
> **鉴权分档(以 `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`** + 全程审计。下表按注册顺序,标注鉴权与状态。
| # | 方法 | 路径 | 鉴权 | 状态 | 说明 | 里程碑 |
|---|------|------|------|:---:|------|:---:|
@@ -88,11 +95,13 @@
| 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 |
@@ -101,11 +110,15 @@
| 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 |
@@ -113,6 +126,7 @@
| 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 |
@@ -140,6 +154,8 @@
**另**:外部 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/*`)同理。**仍未登记的是 WS 事件帧**(见 §6.4 与本文档开头的历史包袱说明)。
**WS**:`GET /ws/chat`(握手,JWT,见 §6)。
---
@@ -231,7 +247,7 @@ curl -s -X POST http://<host>:8080/api/v1/auth/login \
- 流式(`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>}}`(**不落库、不起回合**)。实际文本经 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` 解码后超 **5 MiB**;base64 字符串上限 `ceil(n/3)*4+64`,与 `POST /api/v1/upload` 同口径,文案指路该端点)。
- `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` 同口径,文案指路该端点)。
- `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)。
@@ -347,6 +363,8 @@ curl -s http://<host>:8080/api/v1/personas -H "Authorization: Bearer $TOKEN" -H
| `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 毫秒(兼作分页游标) |
```json
@@ -1182,6 +1200,7 @@ curl -s -X POST http://<host>:8080/api/v1/admin/system/restore \
| `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 毫秒 |
@@ -1271,7 +1290,7 @@ TOKEN=$(curl -s -X POST http://<host>:8080/api/v1/auth/login \
| 404 | `not_found` | 资源不存在 |
| 409 | `chat_busy` / `duplicate` / `device_offline` / `needs_login` | 冲突:队列满 / 人格已存在 / 设备离线 / 渠道未登录 |
| 500 | `internal` / `chat_failed` / `sessions_failed` / … | 服务端错误(含未注入依赖) |
| 501 | `not_implemented` | 后端**确定未实现**(新增用户 / IoT 场景·规则 / 主人档案保存 / 编辑环境变量·重启服务·检查更新 / 完整会话转录导出等)——诚实返回,不伪造成功。**注:插件安装(§5.26)与知识库删除(§1.3 #23)已实现,不再返回 501** |
| 501 | `not_implemented` | 后端**确定未实现**(新增用户 / IoT 场景·规则 / 编辑环境变量·重启服务·检查更新 / 完整会话转录导出等)——诚实返回,不伪造成功。**注:插件安装(§5.26)、知识库删除(§1.3 #23)、主人档案保存(`PUT /api/v1/admin/owner-profile`,§1.3 #67)均已实现,不再返回 501** |
### 8.2 依赖注入与「未配置」行为
+1 -1
View File
@@ -138,7 +138,7 @@ export function BackthinkModule(): React.ReactNode {
</div>
)}
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 由这次历史拉取带回(后端 PushResult 的如实投影);实时帧里同一信息裹在原始 PushResult 内、前端解析器尚未读取,因此本次会话新到的行可能暂时只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 两条路径同名同义:历史拉取取 AdminBackthinkItem 的同名键,实时帧取后端原始 PushResult 的 Route / Detail / Bypassed(解析器已读,见 lib/wsParse.ts#parseBackthinkItem,兼容 camelCase 变体),因此本次会话新到的行同样显示推送路由与推送说明,不再只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
</div>
);
}
+23 -3
View File
@@ -211,8 +211,13 @@ export function parseAdminChannelMessage(v: unknown): AdminChannelMessage | null
/** Decode a background-think `backthink` WS payload into a BackthinkItem. The bus
* publishes the raw `backthink.Record` (Go default field names ID/Trigger/At/
* Conclusion/ShouldPush/PushResult); the admin wire uses camelCase, so this reads
* both defensively and returns null when the id cannot be resolved. */
* Conclusion/ShouldPush/PushResult,后者内含 Pushed/Route/Bypassed/Detail);
* the admin wire uses camelCase, so this reads both defensively and returns
* null when the id cannot be resolved.
*
* route / detail / bypassed 与历史路径(`GET /admin/backthink` →
* AdminBackthinkItem)的同名字段**同义同源**(都来自后端已存的 PushResult),
* 因此实时行与刷新后的行渲染一致;线上没报告时保持缺席,绝不编值。 */
export function parseBackthinkItem(v: unknown): BackthinkItem | null {
const r = toRecord(v);
if (!r) return null;
@@ -230,5 +235,20 @@ export function parseBackthinkItem(v: unknown): BackthinkItem | null {
const pr = toRecord(r.PushResult) ?? {};
const shouldNotify = (r.ShouldPush ?? r.shouldNotify ?? r.should_push) === true;
const pushed = (pr.Pushed ?? pr.pushed ?? r.pushed ?? r.push_pushed) === true;
return { id: String(idRaw), trigger, conclusion, shouldNotify, pushed, at };
// 与历史路径同源字段(后端已存的 PushResult 的如实投影):Route/Detail 为空时
// 留 undefined(等同 AdminBackthinkItem 的 omitempty),Bypassed 只在线上给出
// 布尔时才带值,绝不把「没报告」改写成 false。
const route = firstPushText(pr.Route, pr.route, r.route, r.push_route);
const detail = firstPushText(pr.Detail, pr.detail, r.detail, r.push_detail);
const bypassedRaw = pr.Bypassed ?? pr.bypassed ?? r.bypassed ?? r.push_bypassed;
const bypassed = typeof bypassedRaw === "boolean" ? bypassedRaw : undefined;
return { id: String(idRaw), trigger, conclusion, shouldNotify, pushed, route, detail, bypassed, at };
}
/** 取 PushResult 文本字段的第一个非空值(先 Go 字段名,再 camelCase)。空串按
* 「没有值」处理,与历史 DTO 的 omitempty 一致 —— 实时行与历史行对「没有值」
* 的判定因此相同。 */
function firstPushText(...vals: unknown[]): string | undefined {
for (const v of vals) if (typeof v === "string" && v) return v;
return undefined;
}
+7
View File
@@ -67,6 +67,13 @@ type ServerConfig struct {
// REST 路径**不随它变化**——`/api/v1/admin/*` 照旧,安全靠 JWT + 角色门
// (design/07 §0.1「API 不藏」)。
AdminPath string `env:"ADMIN_PATH" default:"admin"`
// ChatImageMaxBytes 是 POST /api/v1/chat 的 image 允许的**解码后图像字节**
// 上限(协同文档 G3),与 POST /api/v1/upload 的 5 MiB 同口径。默认 5242880
// 与引入这项配置之前的硬编码常量逐字节一致;**≤0(含未配置的零值)视为
// 「用默认」**,不做「禁用上限」的解释——一张图能有多大只能有一个答案。
// 网关只在入口层用换算出的字符上限拦截,绝不把图片内容写进日志。
ChatImageMaxBytes int `env:"CHAT_IMAGE_MAX_BYTES" default:"5242880"`
}
// AdminPathPrefix 返回管理面板前端路由的绝对路径前缀(默认 `/admin`)。
+31 -11
View File
@@ -103,7 +103,8 @@ type loginResponse struct {
// 经 WebSocket 通道触发流式并立即返回。Image 是
// 为视觉路径附带的可选 base64 data: URI 或 http(s) URL;
// loop 把它经视觉/OCR 工具或主模型路由(design/06 §0)。
// Image 受 maxChatImageChars 限制(与 POST /api/v1/upload 的 5 MiB 同口径),
// Image 受配置上限(CHAT_IMAGE_MAX_BYTES,默认与
// POST /api/v1/upload 的 5 MiB 同口径)换算出的字符上限限制,
// 超限一律 400 image_too_large,不被静默吃下。
// DeviceID 是客户端产出的发送浏览器/设备标识
// (design/16 §4.5),例如 "web:chrome:a1b2c3";它可选且按原样持久化。
@@ -131,29 +132,45 @@ type chatResponse struct {
StopReason string `json:"stopReason"`
}
// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限,与
// maxChatImageBytes 是 chat.image 允许的**解码后图像字节**上限的**默认值**,与
// POST /api/v1/upload 的 maxUploadBytes(5 MiB,见 upload.go)同口径:
// 图片有上传与内联两条入口,两边必须给出同一个「一张图能有多大」的答案,
// 否则上传的限制可被 base64 内联绕开 —— 客户端发来多大就落库多大,
// 每页历史(GET /sessions/{id}/messages)与每次 WS history 回放都要
// 把整段 base64 再传一遍(协同文档 G3)。
//
// 它只是默认值:生效上限由配置 CHAT_IMAGE_MAX_BYTES 经 chatImageLimitBytes 解析。
const maxChatImageBytes = 5 << 20
// chatImagePrefixAllowance 是 data: URI 前缀(如 "data:image/png;base64,")
// 所占的字符余量。按常见图片 MIME 的最长前缀取整,不含 base64 载荷本身。
const chatImagePrefixAllowance = 64
// maxChatImageChars 是同一口径换算到 base64 字符串长度的上限。base64 每
// 3 字节编码为 4 字符(膨胀 4/3),因此正好 5 MiB 的整幅图编码后是
// ceil(maxChatImageBytes/3)*4 = 6990508 字符(这就是 (n+2)/3*4 的由来:
// 余 1、2 字节时末组仍需补满 4 字符),再加 data: 前缀余量 —— 恰好 5 MiB
// 的合法图片绝不会被误拒。
// chatImageCharsFor 把「解码后图像字节上限」换算为 image 字符串的字符上限:
// base64 每 3 字节编码为 4 字符(膨胀 4/3),因此恰好 n 字节的整幅图编码后是
// ceil(n/3)*4 = (n+2)/3*4 字符(这就是 (n+2)/3*4 的由来:余 1、2 字节时末组仍需
// 补满 4 字符),再加 data: 前缀余量 —— 恰好 n 字节的合法图片绝不会被误拒。
// 它是**唯一**的换算处:默认上限与配置上限都经它落地,不存在第二份膨胀系数。
//
// 校验按**字符串长度**做,是因为入口层只有这一步是零分配的:vision 层要先
// base64 解码整个字符串才拿到真实字节数(vision.decodeBase64 →
// base64.StdEncoding.DecodeString),也就是先分配再报错。在入口层按长度拦下,
// 超大字符串根本走不到解码那一步。
const maxChatImageChars = ((maxChatImageBytes+2)/3)*4 + chatImagePrefixAllowance
func chatImageCharsFor(bytesLimit int) int {
return ((bytesLimit+2)/3)*4 + chatImagePrefixAllowance
}
// chatImageLimitBytes 解析本次生效的**解码后字节**上限:配置给了正数就用它,
// 否则(未配置的零值、0、负数)回落到默认 maxChatImageBytes。语义被钉成「≤0 =
// 用默认」而**不是**「禁用上限」——一张图能有多大只能有一个答案,配置不该把它
// 变成无限;这同时保证「没配 CHAT_IMAGE_MAX_BYTES」的默认行为与引入该配置之前
// 逐字节一致(同一张图此前能过、现在也能过,反之亦然)。
func chatImageLimitBytes(configured int) int {
if configured > 0 {
return configured
}
return maxChatImageBytes
}
// chatImageTooLargeCode 是 image 超限时返回给客户端的机器可读错误码,
// 与其它 400 文案区分开:客户端只看 code 就能把「图太大,请改走上传」
@@ -164,7 +181,7 @@ const chatImageTooLargeCode = "image_too_large"
// 400 + image_too_large 的显式错误(带上限与本次长度,并指明改用
// POST /api/v1/upload 取 URL 再发)。limit 作为参数传入,使该判定能被
// 用小上限直接单测(边界与比较运算符因此可被钉住),REST 入口传的是
// maxChatImageChars。
// chatImageCharsFor(生效的字节上限)。
//
// 文案里的体积由 limit 反推(base64 膨胀 4/3 → 解码后约 limit*3/4 字节),
// 因此换上限时文案自动跟随,不会出现「写着 5 MiB、实际是别的数」。
@@ -230,9 +247,12 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
// image 上限在**任何**解码/落库之前检查,且对同步与 stream 两条分支
// 都生效(stream 只是把该轮转发给 ws.chat,转发前必须先拒),
// 因此超限的图片既不进模型、也不落库、也不会被历史回放重传。
// 上限来自配置对默认值的解析(CHAT_IMAGE_MAX_BYTES,≤0 = 用默认 5 MiB),
// 换算成字符上限只有 chatImageCharsFor 一处。
// 日志只带长度与上限,绝不带图片内容。
if err := checkChatImageSize(req.Image, maxChatImageChars); err != nil {
s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", maxChatImageChars)
limit := chatImageCharsFor(chatImageLimitBytes(s.cfg.Server.ChatImageMaxBytes))
if err := checkChatImageSize(req.Image, limit); err != nil {
s.log.Warn("chat image rejected", "session", req.SessionID, "chars", len(req.Image), "limit", limit)
writeError(w, err)
return
}
+102 -16
View File
@@ -5,19 +5,22 @@ import (
"encoding/json"
"errors"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件锁定协同文档 G3 的入口层口径:POST /api/v1/chat 的 image 有显式
// 上限(与 POST /api/v1/upload 的 5 MiB 同口径),超限回 400 +
// image_too_large 的明确错误,而不是把几 MB 的 base64 原样吃下、落库、
// 再被每页历史重传一遍。
// 上限(默认与 POST /api/v1/upload 的 5 MiB 同口径,可用 CHAT_IMAGE_MAX_BYTES
// 配置覆盖;≤0 = 用默认),超限回 400 + image_too_large 的明确错误,而不是把
// 几 MB 的 base64 原样吃下、落库、再被每页历史重传一遍。
// chatImageRecordingRunner 记录每次 Run 看到的完整消息(不只是 sessionID),
// 因此既能断言「超限的轮次从未被驱动」,也能逐字节比对正常小图的图片载荷。
@@ -49,14 +52,14 @@ func (r *chatImageRecordingRunner) images() []string {
return out
}
// oversizeImage 返回一个长度恰为 maxChatImageChars+1 的 image 串(内容本身
// 是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
// oversizeImage 返回一个长度恰为「默认上限换算出的字符数 + 1」的 image 串(内容
// 本身是占位符,测试只关心长度不变量;真实图片内容绝不进日志或断言)。
func oversizeImage() string {
return strings.Repeat("A", maxChatImageChars+1)
return strings.Repeat("A", chatImageCharsFor(maxChatImageBytes)+1)
}
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:它必须与
// POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// TestChatImageLimitMatchesUploadBudget 钉住上限的推导口径:**未配置**时生效上限
// 必须与 POST /api/v1/upload 的 maxUploadBytes 同值(两条入口同一个答案),且换算成
// base64 字符串长度后既**装得下**一张满额的 5 MiB 图,也不留下荒谬的余量。
// 这条不变量让「有人改了上限或改歪了膨胀系数」在测试里立刻暴露(反证)。
func TestChatImageLimitMatchesUploadBudget(t *testing.T) {
@@ -64,13 +67,30 @@ func TestChatImageLimitMatchesUploadBudget(t *testing.T) {
if maxChatImageBytes != maxUploadBytes {
t.Fatalf("maxChatImageBytes = %d, want %d (与 POST /upload 同口径)", maxChatImageBytes, maxUploadBytes)
}
// 配置的默认值(env 标签)必须就是 5 MiB:进程环境与 .env 都没给时,网关
// 拿到的正是这个数。这里直接读标签而不是去 Load 配置,是为了不受运行环境
// 里可能存在的同名环境变量影响(那反而会让断言失真)。
field, ok := reflect.TypeOf(config.ServerConfig{}).FieldByName("ChatImageMaxBytes")
if !ok {
t.Fatal("config.ServerConfig 缺 ChatImageMaxBytes 字段(配置契约已断)")
}
if got := field.Tag.Get("default"); got != strconv.Itoa(maxChatImageBytes) {
t.Fatalf("CHAT_IMAGE_MAX_BYTES 的默认值 = %q, want %d", got, maxChatImageBytes)
}
// 未配置(零值)/ 0 / 负数一律解析为默认值 —— 默认行为与引入该配置之前逐字节一致。
for _, configured := range []int{0, -1, -1 << 20} {
if got := chatImageLimitBytes(configured); got != maxChatImageBytes {
t.Fatalf("chatImageLimitBytes(%d) = %d, want 默认 %d", configured, got, maxChatImageBytes)
}
}
chars := chatImageCharsFor(chatImageLimitBytes(0))
// 上限必须装得下一张满额图:base64 长度对应的解码字节数 ≥ 预算。
if got := maxChatImageChars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("maxChatImageChars 只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
if got := chars / 4 * 3; got < maxChatImageBytes {
t.Fatalf("字符上限只装得下 %d 字节,少于预算 %d", got, maxChatImageBytes)
}
// 余量只能是 data: 前缀的余量,不能顺带塞进一整张图。
if maxChatImageChars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("maxChatImageChars = %d 的余量过大(前缀余量应 ≤4096)", maxChatImageChars)
if chars > ((maxChatImageBytes+2)/3)*4+4096 {
t.Fatalf("字符上限 = %d 的余量过大(前缀余量应 ≤4096)", chars)
}
}
@@ -148,16 +168,17 @@ func TestChatImageTooLargeRejectedOnStreamPath(t *testing.T) {
}
}
// TestChatImageAtLimitAccepted 是边界:长度恰好等于上限的 image 照常通过
// TestChatImageAtLimitAccepted 是边界:长度恰好等于**默认**上限的 image 照常通过
// (合法客户端在 5 MiB 整幅图上绝不能吃到误拒)。
func TestChatImageAtLimitAccepted(t *testing.T) {
t.Parallel()
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
tok := env.token(t, auth.RoleOwner)
image := "data:image/png;base64," + strings.Repeat("A", maxChatImageChars-len("data:image/png;base64,"))
if len(image) != maxChatImageChars {
t.Fatalf("构造长度 = %d, want %d", len(image), maxChatImageChars)
limit := chatImageCharsFor(maxChatImageBytes)
image := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(image) != limit {
t.Fatalf("构造长度 = %d, want %d", len(image), limit)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": image}, tok)
@@ -169,6 +190,71 @@ func TestChatImageAtLimitAccepted(t *testing.T) {
}
}
// TestChatImageConfiguredLimitTaken 钉住「配置真的生效」(G3 遗留:上限不再硬编码):
// 把 CHAT_IMAGE_MAX_BYTES 配成 1 MiB 后,恰好 1 MiB 的图仍然通过,而只多一个字符就
// 被 400 image_too_large 拒绝 —— 既证明配置走的是同一条入口校验,也证明上限确实变小了
// (第二个断言若仍按 5 MiB 算,会因远未到默认上限而被放行、测试即红)。
func TestChatImageConfiguredLimitTaken(t *testing.T) {
t.Parallel()
const configured = 1 << 20
limit := chatImageCharsFor(configured)
if limit >= chatImageCharsFor(maxChatImageBytes) {
t.Fatal("1 MiB 的字符上限不应 ≥ 默认 5 MiB 的上限(本用例失去了区分力)")
}
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
// 上限在每次请求时从配置读取,因此这里直接钉住该环境的配置值。
env.srv.cfg.Server.ChatImageMaxBytes = configured
tok := env.token(t, auth.RoleOwner)
atLimit := "data:image/png;base64," + strings.Repeat("A", limit-len("data:image/png;base64,"))
if len(atLimit) != limit {
t.Fatalf("构造长度 = %d, want %d", len(atLimit), limit)
}
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit}, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("恰好 1 MiB 的图必须通过,status = %d (body=%s)", resp.StatusCode, body)
}
// 多一个字符即超限:默认 5 MiB 口径下它会通过,因此这条断言恰好区分了配置是否生效。
resp, body = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": atLimit + "A"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("超过配置上限一个字符必须 400,status = %d (body=%s)", resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
// 两次请求里只有第一条(合法那条)驱动了回合。
if len(runner.calls) != 1 {
t.Fatalf("turn 数 = %d, want 1", len(runner.calls))
}
}
// TestChatImageNonPositiveConfigUsesDefault 在 HTTP 入口钉住「0 / 负数 = 用默认」的语义:
// 配置成 0(或负数)时,超出**默认** 5 MiB 上限一个字符的图仍被拒 —— 上限没有被解成
// 「无限」,默认行为与引入该配置之前逐字节一致。
func TestChatImageNonPositiveConfigUsesDefault(t *testing.T) {
t.Parallel()
for _, configured := range []int{0, -1} {
runner := &chatImageRecordingRunner{}
env := newEnv(t, runner, false)
env.srv.cfg.Server.ChatImageMaxBytes = configured
tok := env.token(t, auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/chat",
map[string]any{"sessionId": "web", "image": oversizeImage()}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("配置 %d 时超出默认上限的图必须 400,status = %d (body=%s)", configured, resp.StatusCode, body)
}
if code, _ := chatImageErrorOf(t, body); code != chatImageTooLargeCode {
t.Fatalf("error.code = %q, want %q", code, chatImageTooLargeCode)
}
if len(runner.calls) != 0 {
t.Fatalf("超限请求驱动了 %d 个回合,want 0", len(runner.calls))
}
}
}
// TestChatSmallImageUnchanged 锁定「正常小图行为与今天逐字节一致」:小
// data: URI 原样进入图片内容部分,文本与图片两部分都在。
func TestChatSmallImageUnchanged(t *testing.T) {