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