From 130b3f2e8d03b49f4dc8f32d257d3f0466e594f1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 13:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20per-key=20AUTO=20=E9=93=BE=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=A4=8D=E5=88=B6=E5=85=A8=E5=B1=80=E9=93=BE=E4=BD=9C?= =?UTF-8?q?=E4=B8=BA=E7=BC=96=E8=BE=91=E8=B5=B7=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 226 个模型里 一条条重新挑。真实任务是"这个用户拿全局链,但去掉他不该碰的两个源", 所以起点应该是全局链本身。 - 无独立链时读 /api/auto 的 rules 预填(实测 4 泳道 / 10 块,与全局链 一致),不是 discovery 兜底(那会把每个源的每个模型都堆进来)。 - 提示区分三种状态,因为它们在画布上长得一样、点取消的后果完全不同: 复制来的起点 / 该密钥已有的独立链 / 清空后保存将恢复跟随全局。 - seeded 标记随关闭清理,避免下次的提示说错来源。 CDP 实测:打开无独立链的密钥 → 画布 4 泳道 10 块 + 提示"已复制全局链 作为编辑起点";删一个块服务端仍 inherits:true;保存后 inherits:false n=9、弹窗关闭;重开读回 4 泳道 9 块且提示消失(它现在有自己的链了)。 横向滚动可达性一并验证:scrollLeft 172 = scrollWidth-clientWidth, 滚到底后无残留不可达块。 判据 2 条 + 变异(恢复空白起点 / 去掉 seeded 标记均判红)。 Co-Authored-By: ModelRouter --- internal/gateway/ui/index.html | 102 +++++++++++++++++---------- internal/gateway/ui_key_auto_test.go | 42 +++++++++++ 2 files changed, 106 insertions(+), 38 deletions(-) diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 611d9a5..42328f1 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -826,12 +826,15 @@ kAutoChainOwn: "独立链", kAutoChainGlobal: "跟随全局", kAutoChainTitle: "独立 AUTO 链", - kAutoChainHint: "为空则跟随全局 AUTO 链", + kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局", kAutoChainInherit: "使用全局链", kAutoChainAdd: "+ 添加槽位", kAutoChainSave: "保存", kAutoChainTier: "层级", kAutoChainEmpty: "该密钥使用全局 AUTO 链", + kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链", + kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链", + kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链", kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败", kName: "名称", kRole: "角色", @@ -1097,12 +1100,15 @@ kAutoChainOwn: "own", kAutoChainGlobal: "global", kAutoChainTitle: "Per-key AUTO chain", - kAutoChainHint: "Leave empty to inherit the global AUTO chain", + kAutoChainHint: "Starts from a copy of the global chain; clear it and save to follow the global chain again", kAutoChainInherit: "Use global chain", kAutoChainAdd: "+ Add slot", kAutoChainSave: "Save", kAutoChainTier: "Tier", kAutoChainEmpty: "This key uses the global AUTO chain", + kAutoChainSeeded: "Copied the global AUTO chain as a starting point — saving gives this key its own chain", + kAutoChainNewOwn: "This key currently follows the global chain; saving switches it to the chain configured here", + kAutoChainWillInherit: "Saving makes this key follow the global AUTO chain again", kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail", kName: "Name", kRole: "Role", @@ -5272,48 +5278,66 @@ function afterChainEdit() { // Park the key on the modal so a click that bubbles up cannot hit the // global editor behind it. wrap.addEventListener("click", (ev) => ev.stopPropagation()); - try { - const j = await api("/api/v1/sources"); - const idx = buildSortIndex(j.sources || []); - const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto"); - keyAutoState.key = key; - keyAutoState.inherits = !!cur.inherits; - // A key with no own chain shows an EMPTY canvas, never the discovery - // fallback buildLanes uses for an unconfigured global chain: that - // fallback fills every source's models (226 blocks on a real - // deployment), and showing them inside "this key's chain" dialog - // says they already belong to this user. They do not. - sortState.scope = key; - sortState.kind = "chat"; - sortState.lanes = - cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : []; - sortState.origin = JSON.stringify(sortState.lanes); - await loadModelPairs(); - renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" }); - // The notice is the only thing telling the user what an empty - // canvas means here. Without it the dialog is just a blank box and - // "Use global chain" looks like a button that already did - // something. It disappears as soon as the chain has slots of its - // own, which keyAutoSave's reload then confirms. - if (keyAutoState.inherits) keyAutoInheritNotice(); - } catch (e) { - toast(String(e)); - } +try { + const j = await api("/api/v1/sources"); + const idx = buildSortIndex(j.sources || []); + const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto"); + keyAutoState.key = key; + keyAutoState.inherits = !!cur.inherits; + // A key with no chain of its own starts from a COPY OF THE GLOBAL + // CHAIN, not from an empty canvas. The realistic task is "give this + // user the global chain minus the two sources they must not hit", + // and starting blank forces the admin to retype every tier from a + // list of 226 models. This is NOT the discovery fallback (which + // dumps every model of every source): it is the same 10 rules the + // global chain actually runs. + let seededFromGlobal = false; + let rules = cur.auto || []; + if (!rules.length) { + try { + rules = ((await api("/api/auto")).rules) || []; + seededFromGlobal = rules.length > 0; + } catch (e) { + rules = []; + } + } + sortState.scope = key; + sortState.kind = "chat"; + sortState.lanes = buildLanes(idx, rules, "chat"); + sortState.origin = JSON.stringify(sortState.lanes); + await loadModelPairs(); + renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" }); + keyAutoState.seeded = seededFromGlobal; + keyAutoInheritNotice(); + } catch (e) { + toast(String(e)); + } } - const keyAutoState = { key: null, inherits: true }; - // The notice describes what an EMPTY canvas means, so it must track the - // canvas rather than the state the dialog opened with: adding the first - // slot makes "this key uses the global chain" false, and leaving it up - // reads as a contradiction right next to the slot the user just added. - function keyAutoInheritNotice(showing) { + const keyAutoState = { key: null, inherits: true, seeded: false }; + // The notice states what the canvas content MEANS relative to the server, which + // is the one thing the dialog cannot show visually: a full canvas can be + // either "this key's own chain (already saved)" or "a copy of the global + // chain that only becomes this key's chain on Save". Those two look + // identical and behave very differently if the admin hits Cancel. + function keyAutoInheritNotice() { const host = $("#ka-canvas-host"); if (!host) return; let n = host.querySelector(".ka-inherit-note"); const hasSlots = (sortState.lanes || []).some( (l) => (l.models || []).length, ); - const want = showing === undefined ? !hasSlots : showing && !hasSlots; - if (!want) { + let msg = ""; + if (!hasSlots) { + // Empty canvas: saving restores inheritance. + msg = keyAutoState.inherits + ? t("kAutoChainEmpty") + : t("kAutoChainWillInherit"); + } else if (keyAutoState.inherits && keyAutoState.seeded) { + msg = t("kAutoChainSeeded"); + } else if (keyAutoState.inherits) { + msg = t("kAutoChainNewOwn"); + } + if (!msg) { if (n) n.remove(); return; } @@ -5323,12 +5347,13 @@ function afterChainEdit() { n.style.margin = "0 0 10px"; host.appendChild(n); } - n.textContent = t("kAutoChainEmpty"); + n.textContent = msg; } // "Use the global chain" empties the lanes; saving then PUTs an empty // list, which the server treats as clearing the override. function keyAutoInherit() { keyAutoState.inherits = true; + keyAutoState.seeded = false; sortState.lanes = []; sortState.origin = "[]"; const host = $("#ka-canvas-host"); @@ -5345,6 +5370,7 @@ function afterChainEdit() { // Leaving scope set would make the global editor save to this key. sortState.scope = null; keyAutoState.key = null; + keyAutoState.seeded = false; } async function keyAutoSave(btn) { const key = keyAutoState.key; diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index 1caee4c..7ada43d 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -287,3 +287,45 @@ func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) { "this throws on the day view and leaves the range line empty") } } + +// A key with no chain of its own must open on a COPY OF THE GLOBAL CHAIN, not +// on an empty canvas. The realistic admin task is "this user gets the global chain +// minus the sources they must not hit"; starting blank forces retyping every +// tier against a 226-model picker. This is deliberately NOT the discovery +// fallback (which dumps every model of every source) — it is the same rules +// the global chain actually runs. +func TestUIKeyAutoSeedsFromGlobalChain(t *testing.T) { + src := uiSource(t) + body := readFuncBody(t, src, "openKeyAutoModal") + if !strings.Contains(body, `api("/api/auto")`) { + t.Fatal("openKeyAutoModal must read the global chain to seed the editor") + } + if !strings.Contains(body, "seededFromGlobal") { + t.Fatal("the editor must remember whether its content came from the " + + "global chain, so the notice can say so") + } + // And it must not fall back to the discovery path that fills 226 models. + if strings.Contains(body, `buildLanes(idx, [], `) { + t.Fatal("seeding from an empty rule list triggers buildLanes discovery") + } +} + +// The notice must distinguish "already this key's chain" from "a copy that +// only becomes its chain on Save" — those two look identical on the canvas and +// behave very differently if the admin hits Cancel. +func TestUIKeyAutoNoticeDistinguishesSavedFromCopied(t *testing.T) { + src := uiSource(t) + body := readFuncBody(t, src, "keyAutoInheritNotice") + for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} { + if !strings.Contains(body, k) { + t.Fatalf("the notice never uses %s — a copied chain and a saved one "+ + "would look the same", k) + } + } + // Both languages, or a non-Chinese admin sees raw keys. + for _, k := range []string{"kAutoChainSeeded", "kAutoChainNewOwn", "kAutoChainWillInherit"} { + if n := strings.Count(src, k+`: "`); n < 2 { + t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n) + } + } +}