From aa003e84d36ca93c3271bde041b5da8b6924aeda Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 09:21:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20per-key=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E5=99=A8=E7=94=BB=E5=B8=83=E7=94=BB=E9=94=99=E5=AE=B9=E5=99=A8?= =?UTF-8?q?=E3=80=81=E4=BF=9D=E5=AD=98=E6=88=90=E5=8A=9F=E5=8D=B4=E4=B8=8D?= =?UTF-8?q?=E5=85=B3=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CDP 实测发现两个真 bug,都是复用画布时引入的。 一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中 #scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布 同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗 里空空如也,回���全局页还会发现自己的链被覆盖了。 改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用 #scr-canvas。 二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了 keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮 还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求 会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。 判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加 return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了 return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的 所有 return 语句。 CDP 复测(先开全局页制造干扰,再开 per-key 弹窗): - 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染 - 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空 - 重开弹窗读回 [["gpt-5.2@xinjianya"]] Co-Authored-By: ModelRouter --- internal/gateway/ui/index.html | 26 ++++++--- internal/gateway/ui_key_auto_test.go | 81 +++++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 9 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 6f58989..38b573a 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -832,7 +832,7 @@ kAutoChainSave: "保存", kAutoChainTier: "层级", kAutoChainEmpty: "该密钥使用全局 AUTO 链", - kAutoChainBad: "槽位无法解析(模型或源不存在),保存后 AUTO 请求会失败", + kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败", kName: "名称", kRole: "角色", kRoleAdmin: "管理员", @@ -1103,7 +1103,7 @@ kAutoChainSave: "Save", kAutoChainTier: "Tier", kAutoChainEmpty: "This key uses the global AUTO chain", - kAutoChainBad: "Slot does not resolve (unknown model or source); AUTO requests would fail", + kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail", kName: "Name", kRole: "Role", kRoleAdmin: "Admin", @@ -3694,9 +3694,17 @@ const pnote = $("#period-note"); // different id inside its modal, so both share one implementation // instead of a reduced second copy that would drift. function sortCanvasEl() { - const own = document.getElementById("scr-canvas"); - if (own) return own; - return document.getElementById("key-auto-canvas"); + // The modal editor is mounted on top of the settings tab, so BOTH + // canvases can be in the DOM at once — #scr-canvas belongs to the + // hidden tab behind it. Resolve by scope, not by document order: when + // sortState.scope names a key we are editing THAT key's chain, and + // painting into the background tab's canvas both shows an empty + // dialog and corrupts the global chain the user comes back to. + if (sortState.scope) { + const k = document.getElementById("key-auto-canvas"); + if (k) return k; + } + return document.getElementById("scr-canvas"); } function paintSort(affected) { const cv = sortCanvasEl(); @@ -5283,10 +5291,12 @@ const cv = sortCanvasEl(); body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }), }); if (j.inherits) { - toast(t("kAutoChainGlobal")); +toast(t("kAutoChainGlobal")); } else if (!j.slots) { - // Persisted, but nothing resolves — say so now instead of letting - // the user meet it as a 503 on their next AUTO request. + // Persisted, but nothing resolves. Still close: the write + // SUCCEEDED, and leaving the dialog open after a successful save + // makes the user click Save repeatedly. The warning carries the + // real problem — the toast survives the modal closing. toast(t("kAutoChainBad")); } else { toast(t("sortSaved") + " (" + j.slots + ")"); diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index 2755b42..a9a4206 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -124,4 +124,83 @@ func TestUIStatsShowsPeriodWindow(t *testing.T) { if n := strings.Count(src, "periodRange"); n < 2 { t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n) } -} \ No newline at end of file +} +// Both canvases can exist in the DOM at once: the modal is mounted on top of +// the settings tab, whose #scr-canvas stays alive behind it. Resolving the +// canvas by document order (whichever comes first) painted the per-key chain +// into the hidden background tab and left the dialog empty — and worse, +// overwrote the global chain the user returns to. The lookup must follow +// sortState.scope instead. +func TestUISortCanvasResolvesByScope(t *testing.T) { + src := uiSource(t) + el := readFuncBody(t, src, "sortCanvasEl") + if !strings.Contains(el, "if (sortState.scope)") { + t.Fatal("sortCanvasEl must branch on sortState.scope, not document order") + } + if strings.Contains(el, `getElementById("scr-canvas")`) && + strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") { + t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " + + "per-key editor paints into the global canvas") + } +} + +// A successful save must close the dialog even when the slots do not resolve. +// The write DID succeed; leaving the modal open makes the user click Save +// again and again. This regressed once: the !slots branch returned early. +func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) { + src := uiSource(t) + body := readFuncBody(t, src, "keyAutoSave") + // keyAutoClose() must be reached on every success path, not only in the + // happy branch. + iClose := strings.Index(body, "keyAutoClose()") + if iClose < 0 { + t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open") + } + iToast := strings.Index(body, "toast(") + if iToast >= 0 && iClose < iToast { + t.Fatal("keyAutoClose is called before the toast; the warning must be " + + "shown first or it is lost with the modal") + } + // An early `return` inside a branch bypasses keyAutoClose entirely, so + // merely finding the call is not enough: every `return` that sits between + // entering the try block and the close call is a path that leaves the + // dialog open after a successful write. + tryStart := strings.Index(body, "try {") + seg := body[tryStart:iClose] + for _, line := range strings.Split(seg, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") { + t.Fatal("a `return` before keyAutoClose leaves the dialog open after " + + "a successful save: " + trimmed) + } + } +} + +// readFuncBody returns the source of a top-level function by name, using +// brace matching so nested blocks do not truncate it. +func readFuncBody(t *testing.T, src, name string) string { + t.Helper() + idx := strings.Index(src, "function "+name+"(") + if idx < 0 { + t.Fatalf("function %s not found", name) + } + start := strings.Index(src[idx:], "{") + if start < 0 { + t.Fatalf("function %s has no body", name) + } + i := idx + start + depth := 0 + for j := i; j < len(src); j++ { + switch src[j] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return src[i : j+1] + } + } + } + t.Fatalf("function %s body is unterminated", name) + return "" +}