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

+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) {