mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a3b5d357c | |||
| a9d1bdeafb | |||
| d547bed457 | |||
| 31090fec81 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.9.0",
|
||||
"version": "1.9.2",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -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",
|
||||
@ -2016,16 +2022,41 @@ const pnote = $("#period-note");
|
||||
// like a bug unless the dates are on screen — so print them.
|
||||
const prange = $("#period-range");
|
||||
if (prange && st.period && st.period !== "all" && st.from) {
|
||||
const d0 = new Date(st.from);
|
||||
const d1 = st.buckets && st.buckets.length
|
||||
? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
|
||||
: d0;
|
||||
const f = (d) =>
|
||||
d.toISOString().slice(0, 10);
|
||||
prange.textContent =
|
||||
t("periodRange") +
|
||||
" " + f(d0) + " → " + f(d1) +
|
||||
(st.buckets ? " (" + st.buckets.length + "d)" : "");
|
||||
// Bucket labels are hourly for a day ("2026-10-03T09") and daily
|
||||
// for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
|
||||
// the hourly form builds "2026-10-03T09T00:00:00Z", which Date
|
||||
// rejects — toISOString() then throws and the whole assignment is
|
||||
// lost, leaving the range line silently empty on the day view (the
|
||||
// one people look at most).
|
||||
const f = (d) => d.toISOString().slice(0, 10);
|
||||
const label = st.buckets && st.buckets.length
|
||||
? st.buckets[st.buckets.length - 1].bucket
|
||||
: "";
|
||||
let endMs = 0;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
|
||||
endMs = new Date(label + "T00:00:00Z").getTime();
|
||||
} else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) {
|
||||
// An hourly bucket covers its hour, so the day it belongs to is
|
||||
// what the range should end on.
|
||||
endMs = new Date(label + ":00:00Z").getTime();
|
||||
}
|
||||
const from = new Date(st.from);
|
||||
const end = endMs ? new Date(endMs) : from;
|
||||
// The bucket count is not a day count: the day view buckets by
|
||||
// HOUR, so "(5d)" for five hours of today was simply wrong. Days
|
||||
// covered comes from the window itself, which is what the reader
|
||||
// is trying to establish.
|
||||
let days = 1;
|
||||
if (endMs) {
|
||||
days = Math.max(
|
||||
1,
|
||||
Math.round((endMs - from.getTime()) / 86400000) + 1,
|
||||
);
|
||||
}
|
||||
const tail = " (" + days + "d)";
|
||||
prange.textContent = endMs
|
||||
? t("periodRange") + " " + f(from) + " → " + f(end) + tail
|
||||
: t("periodRange") + " " + f(from) + tail;
|
||||
} else if (prange) {
|
||||
prange.textContent = "";
|
||||
}
|
||||
@ -5247,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;
|
||||
}
|
||||
@ -5298,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");
|
||||
@ -5320,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;
|
||||
|
||||
@ -266,3 +266,66 @@ func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The stats range line formats its end date from the LAST bucket label.
|
||||
// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
|
||||
// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
|
||||
// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
|
||||
// throws, and the assignment is lost. The symptom is an empty range line on
|
||||
// exactly the view people look at most, with no error anywhere.
|
||||
func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
|
||||
src := uiSource(t)
|
||||
el := readFuncBody(t, src, "paintStats")
|
||||
// It must branch on the label shape instead of concatenating blindly.
|
||||
if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
|
||||
t.Fatal("paintStats must recognise the hourly bucket label shape " +
|
||||
"(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
|
||||
}
|
||||
// And it must not contain the naive form that broke it.
|
||||
if strings.Contains(el, `bucket + "T00:00:00Z"`) {
|
||||
t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
|
||||
"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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user