feat(ui): per-key AUTO 链默认复制全局链作为编辑起点

此前给没有独立链的密钥打开编辑器是空白画布,管理员要从 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 <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-03 13:09:45 +08:00
parent dac0b4ce93
commit 130b3f2e8d
2 changed files with 106 additions and 38 deletions

View File

@ -826,12 +826,15 @@
kAutoChainOwn: "独立链", kAutoChainOwn: "独立链",
kAutoChainGlobal: "跟随全局", kAutoChainGlobal: "跟随全局",
kAutoChainTitle: "独立 AUTO 链", kAutoChainTitle: "独立 AUTO 链",
kAutoChainHint: "为空则跟随全局 AUTO 链", kAutoChainHint: "默认复制全局链供你增删;清空并保存即恢复跟随全局",
kAutoChainInherit: "使用全局链", kAutoChainInherit: "使用全局链",
kAutoChainAdd: "+ 添加槽位", kAutoChainAdd: "+ 添加槽位",
kAutoChainSave: "保存", kAutoChainSave: "保存",
kAutoChainTier: "层级", kAutoChainTier: "层级",
kAutoChainEmpty: "该密钥使用全局 AUTO 链", kAutoChainEmpty: "该密钥使用全局 AUTO 链",
kAutoChainSeeded: "已复制全局 AUTO 链作为编辑起点,保存后该密钥将使用这条独立链",
kAutoChainNewOwn: "该密钥当前跟随全局链,保存后改用这里配置的独立链",
kAutoChainWillInherit: "保存后该密钥将恢复跟随全局 AUTO 链",
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败", kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
kName: "名称", kName: "名称",
kRole: "角色", kRole: "角色",
@ -1097,12 +1100,15 @@
kAutoChainOwn: "own", kAutoChainOwn: "own",
kAutoChainGlobal: "global", kAutoChainGlobal: "global",
kAutoChainTitle: "Per-key AUTO chain", 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", kAutoChainInherit: "Use global chain",
kAutoChainAdd: "+ Add slot", kAutoChainAdd: "+ Add slot",
kAutoChainSave: "Save", kAutoChainSave: "Save",
kAutoChainTier: "Tier", kAutoChainTier: "Tier",
kAutoChainEmpty: "This key uses the global AUTO chain", 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", kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
kName: "Name", kName: "Name",
kRole: "Role", kRole: "Role",
@ -5272,48 +5278,66 @@ function afterChainEdit() {
// Park the key on the modal so a click that bubbles up cannot hit the // Park the key on the modal so a click that bubbles up cannot hit the
// global editor behind it. // global editor behind it.
wrap.addEventListener("click", (ev) => ev.stopPropagation()); wrap.addEventListener("click", (ev) => ev.stopPropagation());
try { try {
const j = await api("/api/v1/sources"); const j = await api("/api/v1/sources");
const idx = buildSortIndex(j.sources || []); const idx = buildSortIndex(j.sources || []);
const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto"); const cur = await api("/api/keys/" + encodeURIComponent(key) + "/auto");
keyAutoState.key = key; keyAutoState.key = key;
keyAutoState.inherits = !!cur.inherits; keyAutoState.inherits = !!cur.inherits;
// A key with no own chain shows an EMPTY canvas, never the discovery // A key with no chain of its own starts from a COPY OF THE GLOBAL
// fallback buildLanes uses for an unconfigured global chain: that // CHAIN, not from an empty canvas. The realistic task is "give this
// fallback fills every source's models (226 blocks on a real // user the global chain minus the two sources they must not hit",
// deployment), and showing them inside "this key's chain" dialog // and starting blank forces the admin to retype every tier from a
// says they already belong to this user. They do not. // list of 226 models. This is NOT the discovery fallback (which
sortState.scope = key; // dumps every model of every source): it is the same 10 rules the
sortState.kind = "chat"; // global chain actually runs.
sortState.lanes = let seededFromGlobal = false;
cur.auto && cur.auto.length ? buildLanes(idx, cur.auto, "chat") : []; let rules = cur.auto || [];
sortState.origin = JSON.stringify(sortState.lanes); if (!rules.length) {
await loadModelPairs(); try {
renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" }); rules = ((await api("/api/auto")).rules) || [];
// The notice is the only thing telling the user what an empty seededFromGlobal = rules.length > 0;
// canvas means here. Without it the dialog is just a blank box and } catch (e) {
// "Use global chain" looks like a button that already did rules = [];
// something. It disappears as soon as the chain has slots of its }
// own, which keyAutoSave's reload then confirms. }
if (keyAutoState.inherits) keyAutoInheritNotice(); sortState.scope = key;
} catch (e) { sortState.kind = "chat";
toast(String(e)); 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 }; const keyAutoState = { key: null, inherits: true, seeded: false };
// The notice describes what an EMPTY canvas means, so it must track the // The notice states what the canvas content MEANS relative to the server, which
// canvas rather than the state the dialog opened with: adding the first // is the one thing the dialog cannot show visually: a full canvas can be
// slot makes "this key uses the global chain" false, and leaving it up // either "this key's own chain (already saved)" or "a copy of the global
// reads as a contradiction right next to the slot the user just added. // chain that only becomes this key's chain on Save". Those two look
function keyAutoInheritNotice(showing) { // identical and behave very differently if the admin hits Cancel.
function keyAutoInheritNotice() {
const host = $("#ka-canvas-host"); const host = $("#ka-canvas-host");
if (!host) return; if (!host) return;
let n = host.querySelector(".ka-inherit-note"); let n = host.querySelector(".ka-inherit-note");
const hasSlots = (sortState.lanes || []).some( const hasSlots = (sortState.lanes || []).some(
(l) => (l.models || []).length, (l) => (l.models || []).length,
); );
const want = showing === undefined ? !hasSlots : showing && !hasSlots; let msg = "";
if (!want) { 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(); if (n) n.remove();
return; return;
} }
@ -5323,12 +5347,13 @@ function afterChainEdit() {
n.style.margin = "0 0 10px"; n.style.margin = "0 0 10px";
host.appendChild(n); host.appendChild(n);
} }
n.textContent = t("kAutoChainEmpty"); n.textContent = msg;
} }
// "Use the global chain" empties the lanes; saving then PUTs an empty // "Use the global chain" empties the lanes; saving then PUTs an empty
// list, which the server treats as clearing the override. // list, which the server treats as clearing the override.
function keyAutoInherit() { function keyAutoInherit() {
keyAutoState.inherits = true; keyAutoState.inherits = true;
keyAutoState.seeded = false;
sortState.lanes = []; sortState.lanes = [];
sortState.origin = "[]"; sortState.origin = "[]";
const host = $("#ka-canvas-host"); const host = $("#ka-canvas-host");
@ -5345,6 +5370,7 @@ function afterChainEdit() {
// Leaving scope set would make the global editor save to this key. // Leaving scope set would make the global editor save to this key.
sortState.scope = null; sortState.scope = null;
keyAutoState.key = null; keyAutoState.key = null;
keyAutoState.seeded = false;
} }
async function keyAutoSave(btn) { async function keyAutoSave(btn) {
const key = keyAutoState.key; const key = keyAutoState.key;

View File

@ -287,3 +287,45 @@ func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
"this throws on the day view and leaves the range line empty") "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)
}
}
}