From 9dc2b6401362eb49b8e998b0d80dc9399ee4db73 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 08:08:37 +0800 Subject: [PATCH 1/3] =?UTF-8?q?chore(gui):=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=201.8.0=20=E2=86=92=201.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit per-key AUTO 链(22decf2)与并发判据(c66e1b5)是 v1.8.0 发布后的新特性, v1.8.0 的 tag 已存在,不能复用。package-lock.json 里剩下的一处 1.8.0 是 @electron/fuses 依赖自身的版本,不应改动。 Co-Authored-By: ModelRouter --- cmd/gui/package-lock.json | 4 ++-- cmd/gui/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/gui/package-lock.json b/cmd/gui/package-lock.json index 21f79ad..b233b9f 100644 --- a/cmd/gui/package-lock.json +++ b/cmd/gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "modelrouter-gui", - "version": "1.8.0", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "modelrouter-gui", - "version": "1.8.0", + "version": "1.9.0", "license": "Proprietary", "devDependencies": { "electron": "^33.0.0", diff --git a/cmd/gui/package.json b/cmd/gui/package.json index a3e7392..2f1d068 100644 --- a/cmd/gui/package.json +++ b/cmd/gui/package.json @@ -1,6 +1,6 @@ { "name": "modelrouter-gui", - "version": "1.8.0", + "version": "1.9.0", "description": "ModelRouter Desktop — embedded ModelRouter core with tray", "author": "ModelRouter", "main": "main.js", From 13e2397e9c50a7149c278a8ab257128cecec8c58 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 08:26:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(ui):=20per-key=20=E9=93=BE=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=85=A8=E5=B1=80=20AUTO=20=E7=94=BB=E5=B8=83?= =?UTF-8?q?=EF=BC=9B=E7=BB=9F=E8=AE=A1=E5=91=A8=E6=9C=9F=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两件事。 一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列, 比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。 这是重复实现且必然漂移。改为复用同一套泳道画布: - 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor, 全局编辑器与 per-key 编辑器共用。 - sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto 按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。 - 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。 - keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。 - 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。 判据改为断言"复用"而非断言具体实现,并新增两条: - 关闭弹窗不清 scope(全局编辑器会存错目标) - per-key 编辑器不再挂载共享画布 二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始, 所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03 (周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03 (3 天,4.0B tokens)。 真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD (Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到 月内(一个看起来很自然的"修复")会被两条判据同时抓住。 CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示 "2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个 块,与全局编辑器同款。 Co-Authored-By: ModelRouter --- internal/gateway/stats_period_test.go | 82 ++++++ internal/gateway/ui/index.html | 402 ++++++++++++++++---------- internal/gateway/ui_key_auto_test.go | 95 ++++-- 3 files changed, 399 insertions(+), 180 deletions(-) diff --git a/internal/gateway/stats_period_test.go b/internal/gateway/stats_period_test.go index ae86ae7..389a688 100644 --- a/internal/gateway/stats_period_test.go +++ b/internal/gateway/stats_period_test.go @@ -319,3 +319,85 @@ func TestPeriodFromQuery(t *testing.T) { } } } + +// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and +// gets reported as one: the calendar periods are NOT nested. +// +// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat +// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches +// back into the previous month and its total is legitimately LARGER than the +// month's. Anyone reading only the totals will conclude the aggregator is +// broken; it is not, and this test exists so a future "fix" that makes the +// windows nested (e.g. clamping the week to the month) is caught as the +// regression it would be. +func TestPeriodWeekMayStartBeforeMonth(t *testing.T) { + // Saturday 2026-10-03, UTC. + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) + + weekFrom := periodStart(PeriodWeek, now) + monthFrom := periodStart(PeriodMonth, now) + + weekFromD := time.UnixMilli(weekFrom).UTC() + monthFromD := time.UnixMilli(monthFrom).UTC() + + if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" { + t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got) + } + if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" { + t.Fatalf("month window start = %s, want 2026-10-01", got) + } + if !weekFromD.Before(monthFromD) { + t.Fatal("this test only means anything when the week starts before the month") + } + // The week must reach strictly further back than the month window does, + // otherwise the nesting intuition would hold and the UI note would be noise. + if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 { + t.Fatalf("week should start at least a day before the month window, got %v days back", back) + } +} + +// The same overlap must be visible in the aggregate numbers, not just in the +// window bounds: a week total larger than a month total is the exact symptom +// an operator reported. +func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) { + dir := t.TempDir() + audit := filepath.Join(dir, "audit.jsonl") + + // Two records before the month window (inside this ISO week) and one + // inside October. + pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli() + in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli() + in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli() + + auditPeriod(t, audit, + Req{Time: pre, OK: true, Prompt: 100, Compl: 10}, + Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10}, + Req{Time: in1, OK: true, Prompt: 50, Compl: 5}, + Req{Time: in2, OK: true, Prompt: 50, Compl: 5}, + ) + + s := periodStats(t, audit) + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) + + week := s.PeriodSnapshot(PeriodWeek, "", now) + month := s.PeriodSnapshot(PeriodMonth, "", now) + + if week.Total.Reqs != 4 { + t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs) + } + if month.Total.Reqs != 2 { + t.Fatalf("month reqs = %d, want 2", month.Total.Reqs) + } + if week.Total.Tokens <= month.Total.Tokens { + t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September", + week.Total.Tokens, month.Total.Tokens) + } + // Day must be a strict subset of both, which is the only nesting that holds. + day := s.PeriodSnapshot(PeriodDay, "", now) + if day.Total.Reqs != 1 { + t.Fatalf("day reqs = %d, want 1", day.Total.Reqs) + } + if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens { + t.Fatal("today must be contained in both the week and the month totals") + } +} diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index 5c8c740..6f58989 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1054,6 +1054,7 @@ periodWeek: "本周", periodMonth: "本月", periodAll: "全部", + periodRange: "统计区间:", periodTruncated: "统计周期可能不完整(审计日志已轮转),数值为下限。", recsPartial: @@ -1328,6 +1329,7 @@ periodWeek: "This week", periodMonth: "This month", periodAll: "All time", + periodRange: "Window:", periodTruncated: "This window may be incomplete (audit log rotated); figures are a lower bound.", recsPartial: @@ -1739,9 +1741,10 @@ - + +
`; } +// sortCanvasEl resolves the canvas the editor paints into. The global editor + // owns #scr-canvas; the per-key editor renders the same canvas under a + // 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"); + } function paintSort(affected) { - const cv = $("#scr-canvas"); - if (!cv) return; + const cv = sortCanvasEl(); + if (!cv) return; // compute the next lane-leaders so we can retract the outgoing ones first const nextFirst = new Set(); sortState.lanes.forEach((lane) => { @@ -3696,8 +3752,8 @@ } paintSortNow(affected); } - function paintSortNow(affected) { - const cv = $("#scr-canvas"); +function paintSortNow(affected) { + const cv = sortCanvasEl(); if (!cv) return; // FLIP: capture old positions of lanes and blocks before repaint const prev = new Map(); @@ -3844,9 +3900,9 @@ }); } function onSortMove(e) { - const d = sortState.drag; +const d = sortState.drag; if (!d) return; - const cv = document.getElementById("scr-canvas"); + const cv = sortCanvasEl(); if (!cv) return; const laneEls = [...cv.querySelectorAll(".scr-lane")]; const gapEls = [...cv.querySelectorAll(".scr-gap")]; @@ -3955,7 +4011,8 @@ if (!d) return; const line = document.getElementById("scr-blockline"); if (line) line.remove(); - const cv = document.getElementById("scr-canvas"); +const cv = sortCanvasEl(); + if (!cv) return; cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) => el.classList.remove("hover", "drophere"), ); @@ -4055,10 +4112,42 @@ } /* ---------- AUTO quota editing on sort blocks ---------- */ + // sortState.scope decides where saveSort() persists: null/undefined = + // the global chain, or a gateway key = that key's own chain. The canvas, + // drag/drop and slot metadata all work unchanged in both modes; only the + // persistence endpoint and the chain that gets loaded differ. Keeping one + // editor means a per-key chain gets exactly the features the global one + // has (drag ordering, per-slot quota/period/hours) instead of a reduced + // second implementation that would drift. async function persistAuto() { + const rules = lanesToRules(sortState.lanes); + if (sortState.scope) { + // Empty rules mean "inherit the global chain" server-side, so the + // same PUT both saves a chain and clears the override. + await api("/api/keys/" + encodeURIComponent(sortState.scope) + "/auto", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ auto: rules }), + }); + return; + } + // each kind persists to its own chain; the other kind's saved rules + // are left untouched (omitting the field means "no change") + const body = { rules: undefined, image_rules: undefined }; + if (sortState.kind === "image") body.image_rules = rules; + else body.rules = rules; + await api("/api/auto", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + // lanesToRules flattens the tier lanes into the wire shape shared by + // /api/auto and /api/keys/{key}/auto. + function lanesToRules(lanes) { const rules = []; - sortState.lanes.forEach((lane, li) => - lane.models.forEach((it) => { + (lanes || []).forEach((lane, li) => + (lane.models || []).forEach((it) => { const m = it.meta; rules.push({ model: it.id, @@ -4070,16 +4159,7 @@ }); }), ); - // each kind persists to its own chain; the other kind's saved rules - // are left untouched (omitting the field means "no change") - const body = { rules: undefined, image_rules: undefined }; - if (sortState.kind === "image") body.image_rules = rules; - else body.rules = rules; - await api("/api/auto", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); + return rules; } function scrAddModal() { const cur = { quota: 0, period: "", hours: 0 }; @@ -5113,104 +5193,112 @@ // slots rather than a drag canvas, because an admin usually either // mirrors the global chain or deletes a few tiers, and the canvas layout // for the model scope list would be overkill here. + // Per-key AUTO chain editor. It renders the SAME lane canvas as the + // global editor (drag ordering, per-slot quota/period/hours) by pointing + // sortState.scope at this key: persistAuto then saves to + // /api/keys/{key}/auto instead of /api/auto. A separate reduced editor + // would drift from the global one and lose features. async function openKeyAutoModal(key, name) { const wrap = document.createElement("div"); wrap.id = "modal-wrap"; wrap.style.cssText = "position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50"; - wrap.innerHTML = `
-

${esc(t("kAutoChainTitle"))} · ${esc(name || key)}

+ wrap.innerHTML = `
+

${esc(t("kAutoChainTitle"))} \u00b7 ${esc(name || key)}

${esc(t("kAutoChainHint"))}
-
-

-

-

-

+
+

+ + +

`; document.body.appendChild(wrap); + // 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/keys/" + encodeURIComponent(key) + "/auto"); - const rows = $("#ka-rows"); - if (!j.auto || !j.auto.length) { - rows.innerHTML = `
${esc( - t("kAutoChainEmpty") - )}
`; - } else { - j.auto.forEach((r) => keyAutoRow(r)); - } + 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 rather than the + // discovery fallback: filling it with every available slot would + // suggest those slots are already this user's chain. + sortState.scope = key; + sortState.kind = "chat"; + sortState.lanes = buildLanes(idx, cur.auto || [], "chat"); + sortState.origin = JSON.stringify(sortState.lanes); + await loadModelPairs(); + renderSortEditor($("#ka-canvas-host"), { canvasId: "key-auto-canvas" }); + if (keyAutoState.inherits) keyAutoInheritNotice(); } catch (e) { toast(String(e)); } } - function keyAutoRow(r) { - const el = document.createElement("div"); - el.className = "ka-row"; - el.style.cssText = - "display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap"; - const opts = (sel) => - (allModels || []) - .map((p) => { - // loadModelPairs builds {key,label}; the /api/status fallback - // only guarantees an id, so derive a comb key from whatever is - // present rather than emitting an empty option value. - const val = p.key || (p.id ? p.id + (p.source ? "|" + p.source : "") : ""); - const lab = p.label || p.id || val; - return ``; - }) - .join(""); - el.innerHTML = ` - - `; - $("#ka-rows").appendChild(el); + const keyAutoState = { key: null, inherits: true }; + function keyAutoInheritNotice() { + const host = $("#ka-canvas-host"); + if (!host) return; + let n = host.querySelector(".ka-inherit-note"); + if (!n) { + n = document.createElement("div"); + n.className = "muted ka-inherit-note"; + n.style.margin = "0 0 10px"; + host.appendChild(n); + } + n.textContent = t("kAutoChainEmpty"); } - function keyAutoAddRow() { - keyAutoRow(null); + // "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; + sortState.lanes = []; + sortState.origin = "[]"; + const host = $("#ka-canvas-host"); + if (host) { + const c = document.getElementById("key-auto-canvas"); + if (c) c.innerHTML = ""; + } + paintSort(); + keyAutoInheritNotice(); } - function keyAutoClear() { - $("#ka-rows").innerHTML = ""; + function keyAutoClose() { + const m = document.getElementById("modal-wrap"); + if (m) m.remove(); + // Leaving scope set would make the global editor save to this key. + sortState.scope = null; + keyAutoState.key = null; } - async function keyAutoSave(btn, key) { - const rules = []; - document.querySelectorAll("#ka-rows .ka-row").forEach((row) => { - const comb = row.querySelector(".ka-model").value; - if (!comb) return; - const { model, source } = splitCombKey(comb); - const tier = parseInt(row.querySelector(".ka-tier").value, 10) || 1; - rules.push({ model, source, tier }); - }); + async function keyAutoSave(btn) { + const key = keyAutoState.key; + if (!key) return; btn.disabled = true; try { - // An empty rule set means "inherit global" server-side, so one PUT - // covers both clearing the override and saving a chain. + // An empty lane list means "inherit global" server-side, so the same + // PUT both saves a chain and clears the override. const j = await api("/api/keys/" + encodeURIComponent(key) + "/auto", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ auto: rules }), + body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }), }); if (j.inherits) { toast(t("kAutoChainGlobal")); } else if (!j.slots) { - // Persisted, but nothing resolves — say so instead of letting the - // user discover it as a 503 on their next AUTO request. + // Persisted, but nothing resolves — say so now instead of letting + // the user meet it as a 503 on their next AUTO request. toast(t("kAutoChainBad")); } else { - toast(t("kAutoChainSave") + " (" + j.slots + ")"); + toast(t("sortSaved") + " (" + j.slots + ")"); } - document.getElementById("modal-wrap").remove(); + keyAutoClose(); loadKeys(); } catch (e) { toast(String(e)); btn.disabled = false; } } + async function createKey(btn) { const name = $("#kc-name").value.trim(); if (!name) { diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go index fd02d35..2755b42 100644 --- a/internal/gateway/ui_key_auto_test.go +++ b/internal/gateway/ui_key_auto_test.go @@ -5,13 +5,14 @@ import ( "testing" ) -// Per-key AUTO chain UI contract. The feature spans three layers (API handler, -// config field, WebUI) and the WebUI part is plain inline JS, so a rename or a -// dropped button fails silently at runtime: the admin simply finds no way to -// configure a key's chain and has no error to chase. +// Per-key AUTO chain UI contract. // -// These checks pin the wiring that is easy to break by refactor. - +// The per-key editor deliberately REUSES the global lane canvas instead of +// shipping a second, simpler editor. That is a design choice with teeth: a +// reduced copy would lack drag ordering and per-slot quota/period/hours, and +// the two would drift apart silently. These checks pin the sharing, because +// the only symptom of un-sharing is "the per-key editor got worse" — no error, +// just a smaller feature set that nobody notices until they use it. func TestUIPerKeyAutoWiring(t *testing.T) { src := uiSource(t) @@ -27,38 +28,71 @@ func TestUIPerKeyAutoWiring(t *testing.T) { if kcEnd < 0 { t.Fatal("could not delimit keyCanvasHtml") } - kcBody := src[kcStart : kcStart+kcEnd] - if !strings.Contains(kcBody, "openKeyAutoModal(") { + if !strings.Contains(src[kcStart:kcStart+kcEnd], "openKeyAutoModal(") { t.Fatal("per-key AUTO chain button is missing from the key card header") } for _, fn := range []string{ "function openKeyAutoModal", - "function keyAutoRow", - "function keyAutoAddRow", - "function keyAutoClear", + "function keyAutoInherit", "function keyAutoSave", + "function keyAutoClose", } { if !strings.Contains(src, fn) { t.Fatalf("UI is missing %s — the editor cannot function", fn) } } +} - // The editor must talk to the endpoint the gateway actually serves. - if !strings.Contains(src, "/auto\"") { - t.Fatal("UI never calls the /api/keys/{key}/auto endpoint") +// The per-key editor must render the SAME canvas the global editor uses. +// If this regresses, the per-key chain silently loses drag ordering and +// per-slot quota/period/hours while still looking like it works. +func TestUIPerKeyAutoReusesGlobalCanvas(t *testing.T) { + src := uiSource(t) + + // One canvas implementation, two mount ids. + if !strings.Contains(src, "function renderSortEditor(") { + t.Fatal("no shared canvas renderer — the per-key editor must reuse renderSortEditor") + } + if !strings.Contains(src, `canvasId: "scr-canvas"`) { + t.Fatal("the global editor no longer mounts the shared canvas") + } + if !strings.Contains(src, `canvasId: "key-auto-canvas"`) { + t.Fatal("the per-key editor does not mount the shared canvas") + } + // Canvas lookup must consider both ids, or drag/drop silently no-ops on + // whichever surface is not #scr-canvas. + if !strings.Contains(src, "function sortCanvasEl(") || + !strings.Contains(src, `getElementById("key-auto-canvas")`) { + t.Fatal("sortCanvasEl must resolve both canvas ids") + } + // The rules<->lanes conversion is shared too, so the per-key editor sends + // exactly the fields the server understands (quota/period/hours included). + for _, fn := range []string{"function buildLanes(", "function lanesToRules("} { + if !strings.Contains(src, fn) { + t.Fatalf("%s must be shared between the global and per-key editors", fn) + } } } -// The editor's empty-row path sends {"auto":[]} and relies on the server -// treating that as "clear the override". If the UI instead sent a sentinel, -// a future server change would silently persist a broken empty chain. -func TestUIPerKeyAutoSendsEmptyListToClear(t *testing.T) { +// Saving must route through persistAuto's scope switch, not a hand-rolled +// request, or the two editors will serialize rules differently. +func TestUIPerKeyAutoSavesThroughScope(t *testing.T) { src := uiSource(t) - if !strings.Contains(src, "JSON.stringify({ auto: rules })") { - t.Fatal("keyAutoSave must PUT the collected rule list verbatim") + if !strings.Contains(src, "sortState.scope = key") { + t.Fatal("openKeyAutoModal must set sortState.scope so the save targets the key") } - if !strings.Contains(src, "keyAutoClear") { - t.Fatal("no way to clear a key's own chain from the UI") + if !strings.Contains(src, "sortState.scope = null") { + t.Fatal("closing the modal must clear sortState.scope, or the GLOBAL editor would save to this key") + } + if !strings.Contains(src, "if (sortState.scope)") { + t.Fatal("persistAuto must branch on sortState.scope") + } + if !strings.Contains(src, `"/auto"`) { + t.Fatal("per-key editor must call the /api/keys/{key}/auto endpoint") + } + // An empty lane list is how "inherit the global chain" is expressed. + if !strings.Contains(src, "JSON.stringify({ auto: lanesToRules(sortState.lanes) })") { + t.Fatal("keyAutoSave must PUT lanesToRules(sortState.lanes) verbatim") } } @@ -68,11 +102,26 @@ func TestUIPerKeyAutoHasBothLanguages(t *testing.T) { src := uiSource(t) for _, k := range []string{ "kAutoChainTitle", "kAutoChainHint", "kAutoChainInherit", - "kAutoChainAdd", "kAutoChainSave", "kAutoChainEmpty", + "kAutoChainEmpty", "kAutoChainGlobal", "kAutoChainBad", } { n := strings.Count(src, k+`: "`) if n < 2 { t.Fatalf("i18n key %s defined %d time(s), want both zh and en", k, n) } } +} + +// The statistics dashboard must print the window it actually covers. Calendar +// periods are not nested — "this week" starts on its Monday and therefore +// reaches into the previous month early in a month, making its total LARGER +// than "this month". That is correct arithmetic, but without the dates on +// screen it reads as a bug, and it has been reported as one. +func TestUIStatsShowsPeriodWindow(t *testing.T) { + src := uiSource(t) + if !strings.Contains(src, `id="period-range"`) { + t.Fatal("the stats dashboard has no element for the period window range") + } + 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 From ed9baae8e46e467deb850d73fdce20a59f70ee97 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 3 Oct 2026 08:48:46 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(gui):=20=E6=8F=92=E4=BB=B6=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=A1=A5=E9=BD=90=E5=AE=89=E8=A3=85/=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4=EF=BC=8Con=5Fdisk=20=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面版插件面板此前只有启用/禁用,装不了新插件、读不回源码、删不掉坏的 插件——而 WebUI 有完整能力。两边不一致,且缺失的那三个恰好是出问题时唯一 能用的三个操作。 补齐(复用后端已有契约,未新增端点): - POST /api/plugins 安装并保存源码。保存走安装路径是有意的:它会校验 名称、编译并热重载,而 PUT /{name} 只切 enabled 开关,指过去会静默 什么都没存。 - GET /api/plugins/{name} 读源码(读的是磁盘上的源,与写回可往返)。 - DELETE /api/plugins/{name} 删除。内置插件不显示删除按钮。 - 内置插件的"编辑"改为"查看源码"并置只读:它的文件在二进制里,给一个 保存后被丢弃的文本框比直接说明更糟。 - 三个弹窗复用既有的 overlay / overlay-card,不新造一套弹窗体系。 后端补 DiskEntry.Pages:GUI 读的是 on_disk 而非 List,pages 只在 List 里,所以面板里那行"页面:…"永远不显示。新增 pageNames() 复刻加载期的 合并规则(page + pages,去重保序)。判据覆盖 pages 与单页 ui.page 两种 写法,并断言 JSON 里带 pages——GUI 读的是 HTTP body,不是 Go 结构体。 变异去掉赋值后两条判据都失败。 CDP 实测 10 项全部通过:安装带页面的插件、源码往返一致、禁用/启用、 热重载后版本号变化、坏插件在列表可见并带错误信息、非法名拦截、语法错误 被后端拒绝而非静默写入、路径穿越被 IPC 代理拦截。 Co-Authored-By: ModelRouter --- cmd/gui/renderer/app.js | 178 ++++++++++++++++++++++++++++++++++- cmd/gui/renderer/index.html | 1 + cmd/gui/renderer/style.css | 48 ++++++++++ internal/lua/plugins.go | 32 +++++++ internal/lua/plugins_test.go | 70 ++++++++++++++ 5 files changed, 328 insertions(+), 1 deletion(-) diff --git a/cmd/gui/renderer/app.js b/cmd/gui/renderer/app.js index 553a30b..563d5bc 100644 --- a/cmd/gui/renderer/app.js +++ b/cmd/gui/renderer/app.js @@ -226,6 +226,7 @@ async function loadPlugins() { return; } const rows = j.on_disk || []; + window.__plRows = rows; // read by editPlugin/deletePlugin for the builtin flag const active = rows.filter((p) => p.loaded && !p.disabled).length; const broken = rows.filter((p) => !p.loaded).length; hint.textContent = @@ -250,11 +251,30 @@ async function loadPlugins() { const builtin = p.builtin ? '内置' : ""; + // Edit / view source / delete are the operations an operator + // actually needs after enable/disable: a plugin that cannot be + // read back cannot be fixed, and one that cannot be removed cannot + // be rolled back. + const acts = [ + btn, + ``, + builtin + ? "" + : ``, + ] + .filter(Boolean) + .join(""); + const pages = Array.isArray(p.pages) ? p.pages : []; return `
${esc(p.name)}${builtin}${label}
${p.description ? `
${esc(p.description)}
` : ""} + ${pages.length ? `
页面:${esc(pages.join(", "))}
` : ""} ${p.error ? `
${esc(String(p.error).slice(0, 160))}
` : ""} -
${btn}
+
${acts}
`; }) .join("") @@ -262,6 +282,160 @@ async function loadPlugins() { list.querySelectorAll('button[data-act="toggle"]').forEach((b) => { b.onclick = () => togglePlugin(b.dataset.name, b.dataset.en === "1"); }); + list.querySelectorAll('button[data-act="code"]').forEach((b) => { + b.onclick = () => editPlugin(b.dataset.name); + }); + list.querySelectorAll('button[data-act="del"]').forEach((b) => { + b.onclick = () => deletePlugin(b.dataset.name); + }); +} + +// editPlugin shows the plugin source in a modal. Built-in plugins are +// read-only: their file ships inside the binary, so a save would either be +// ignored or silently do nothing — showing an editable text area that +// discards on save is worse than saying so up front. +function editPlugin(name) { + // The plugin list is the only place `builtin` is reported; keep the last + // fetch so the modal does not need a second round trip (and cannot guess). + const row = (window.__plRows || []).find((p) => p.name === name); + const isBuiltin = !!(row && row.builtin); + const wrap = document.createElement("div"); + wrap.className = "overlay"; + wrap.innerHTML = `
+

${esc(name)}${isBuiltin ? "(内置,只读)" : ""}

+ ${isBuiltin + ? `

内置插件随内核发布,源码在二进制内,不可编辑。

` + : ` +

`} +
+ ${isBuiltin ? "" : ``} + +
+
`; + document.body.appendChild(wrap); + wrap.addEventListener("click", (e) => { + if (e.target === wrap) wrap.remove(); + }); + $("#pl-code-close").onclick = () => wrap.remove(); + const save = $("#pl-code-save"); + if (save) { + save.onclick = async () => { + save.disabled = true; + try { + // Saving writes through POST /api/plugins — the same install path the + // installer uses, which validates the name, compiles the source and + // hot-reloads. PUT /{name} only toggles enable/disable, so pointing the + // save there would silently disable nothing and store nothing. + await window.modelrouter.plugins.request("POST", "/api/plugins", { + name, + code: $("#pl-code").value, + }); + toast(`${name} 已保存`); + wrap.remove(); + await loadPlugins(); + } catch (e) { + $("#pl-code-err").textContent = e.message; + save.disabled = false; + } + }; + } + if (isBuiltin) return; + (async () => { + const ta = $("#pl-code"); + try { + const j = await window.modelrouter.plugins.request( + "GET", + `/api/plugins/${encodeURIComponent(name)}`, + ); + ta.value = j.code || ""; + const err = $("#pl-code-err"); + if (err && j.parse_error) err.textContent = String(j.parse_error); + } catch (e) { + $("#pl-code-err").textContent = e.message; + } + })(); +} + +function deletePlugin(name) { + const wrap = document.createElement("div"); + wrap.className = "overlay"; + wrap.innerHTML = `
+

删除插件

+

确定删除 ${esc(name)}?插件目录中的文件会被移除,核心将不再加载它。

+
+ + +
+
`; + document.body.appendChild(wrap); + wrap.addEventListener("click", (e) => { + if (e.target === wrap) wrap.remove(); + }); + $("#pl-del-no").onclick = () => wrap.remove(); + $("#pl-del-ok").onclick = async () => { + try { + await window.modelrouter.plugins.request( + "DELETE", + `/api/plugins/${encodeURIComponent(name)}`, + ); + toast(`已删除 ${name}`); + wrap.remove(); + await loadPlugins(); + } catch (e) { + toast(e.message, true); + } + }; +} + +// installPlugin creates a new plugin from source. The name becomes the file +// name, so it is validated client-side to save a round trip on the obvious +// mistakes (empty, path separators, non-portable characters). +function installPlugin() { + const wrap = document.createElement("div"); + wrap.className = "overlay"; + wrap.innerHTML = `
+

安装插件

+
+

+ +
+ + +
+
`; + document.body.appendChild(wrap); + wrap.addEventListener("click", (e) => { + if (e.target === wrap) wrap.remove(); + }); + $("#pl-new-no").onclick = () => wrap.remove(); + $("#pl-new-ok").onclick = async () => { + const name = $("#pl-new-name").value.trim(); + const code = $("#pl-new-code").value; + const err = $("#pl-new-err"); + if (!/^[A-Za-z0-9_-]+$/.test(name)) { + err.textContent = "名称只能包含字母、数字、下划线和连字符"; + return; + } + if (!code.trim()) { + err.textContent = "源码不能为空"; + return; + } + $("#pl-new-ok").disabled = true; + try { + await window.modelrouter.plugins.request("POST", "/api/plugins", { + // The backend appends .lua itself (installPlugin writes name+".lua"), + // so passing it here would create name.lua.lua. + name, + code, + }); + toast(`已安装 ${name}`); + wrap.remove(); + await loadPlugins(); + } catch (e) { + err.textContent = e.message; + $("#pl-new-ok").disabled = false; + } + }; } async function togglePlugin(name, disabled) { @@ -318,6 +492,8 @@ function init() { if (plReload) plReload.onclick = loadPlugins; const plAll = document.getElementById("pl-toggle-all"); if (plAll) plAll.onclick = enableAllPlugins; + const plInstall = document.getElementById("pl-install"); + if (plInstall) plInstall.onclick = installPlugin; $("#rail-autostart").onclick = toggleAutoStart; $("#rail-silent").onclick = toggleSilent; $("#rail-theme").onclick = () => { diff --git a/cmd/gui/renderer/index.html b/cmd/gui/renderer/index.html index 86634c0..fea53c5 100644 --- a/cmd/gui/renderer/index.html +++ b/cmd/gui/renderer/index.html @@ -214,6 +214,7 @@
+
diff --git a/cmd/gui/renderer/style.css b/cmd/gui/renderer/style.css index 05dd59c..822aa3f 100644 --- a/cmd/gui/renderer/style.css +++ b/cmd/gui/renderer/style.css @@ -674,3 +674,51 @@ html[data-theme="dark"] .overlay { color: var(--muted); padding: 10px 0; } + +/* ---- plugin editor / installer -------------------------------------------- + The plugin source editor and the install dialog reuse the settings overlay + (overlay / overlay-card) rather than a second popup system. They need three + things the settings card does not: a wider column for code, a monospace + textarea, and a destructive button for delete. */ +.overlay-card.pl-wide { + width: 760px; +} +.pl-wide textarea { + width: 100%; + min-height: 380px; + resize: vertical; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--line-strong); + background: var(--bg-sunken, rgba(127, 127, 127, 0.06)); + color: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12.5px; + line-height: 1.55; + tab-size: 2; +} +.pl-wide textarea:focus { + outline: none; + border-color: var(--primary); +} +.pl-wide .row input { + flex: 1; + padding: 7px 10px; + border-radius: 9px; + border: 1px solid var(--line-strong); + background: var(--bg-sunken, rgba(127, 127, 127, 0.06)); + color: inherit; +} +.pl-editor .hint, +.pl-wide .hint { + min-height: 16px; + color: var(--muted, #888); +} +button.danger { + color: #e5484d; + border-color: rgba(229, 72, 77, 0.4); +} +button.danger:hover { + border-color: #e5484d; + color: #e5484d; +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index ce3f5c8..1e03b2d 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -779,6 +779,34 @@ type DiskEntry struct { Version string `json:"version,omitempty"` Description string `json:"description,omitempty"` Hooks int `json:"hooks"` + // Pages lists the UI page ids the plugin contributes. The desktop panel + // shows it so an operator can tell at a glance that a plugin actually + // renders something, without opening its source. + Pages []string `json:"pages,omitempty"` +} + +// pageNames returns the plugin's contributed UI page ids, de-duplicated. +// A plugin may declare `page` or `pages` or both, and the same id can appear +// twice; the loader's merge keeps the first, so this mirrors that order. +func pageNames(p *Plugin) []string { + if p == nil || p.UI == nil { + return nil + } + declared := make([]*UIPage, 0, len(p.UI.Pages)+1) + if p.UI.Page != nil { + declared = append(declared, p.UI.Page) + } + declared = append(declared, p.UI.Pages...) + var out []string + seen := map[string]bool{} + for _, pg := range declared { + if pg == nil || seen[pg.PageID] { + continue + } + seen[pg.PageID] = true + out = append(out, pg.PageID) + } + return out } // OnDisk lists every .lua file in the plugin directory with its load state. @@ -810,6 +838,10 @@ func (ps *Plugins) OnDisk() []DiskEntry { de.Version = p.Info.Version de.Description = p.Info.Description de.Hooks = len(p.Hooks) + // A plugin may declare a single page or several; both forms end + // up in Pages (see the merge at load time), so reporting that one + // field covers either. + de.Pages = pageNames(p) } else { // On disk but not in the running set: either it failed so badly // that LoadSource never produced a record, or the dir was written diff --git a/internal/lua/plugins_test.go b/internal/lua/plugins_test.go index 5d3d26b..7fdc0f4 100644 --- a/internal/lua/plugins_test.go +++ b/internal/lua/plugins_test.go @@ -375,3 +375,73 @@ return p t.Error("a plugin registered for chain_step is not reported as such") } } + +// TestOnDiskReportsPages pins that the disk listing carries the plugin's UI +// page ids. The desktop plugin panel reads on_disk (not List) and shows the +// pages so an operator can see at a glance that a plugin renders something; +// without this field the GUI reads p.pages === undefined and the line simply +// never appears, which is the kind of omission nobody reports as a bug. +func TestOnDiskReportsPages(t *testing.T) { + _, ps, _ := newPluginVM(t) + + code := ` +local p = { name = "paged", version = "1.0.0" } +p.ui = { pages = {} } +table.insert(p.ui.pages, { page_id = "alpha", title = "Alpha", mount = "a" }) +table.insert(p.ui.pages, { page_id = "beta", title = "Beta", mount = "b" }) +return p +` + if err := loadPlugin(t, ps, "paged", code); err != nil { + t.Fatalf("load: %v", err) + } + + var found *DiskEntry + for _, de := range ps.OnDisk() { + if de.Name == "paged" { + d := de + found = &d + } + } + if found == nil { + t.Fatal("paged plugin missing from OnDisk") + } + if len(found.Pages) != 2 { + t.Fatalf("Pages = %v, want [alpha beta]", found.Pages) + } + if found.Pages[0] != "alpha" || found.Pages[1] != "beta" { + t.Fatalf("Pages = %v, want [alpha beta] in declaration order", found.Pages) + } + + // The field must survive JSON too — the desktop panel reads the HTTP body, + // not the Go struct. + b, err := json.Marshal(found) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"pages":["alpha","beta"]`) { + t.Fatalf("json omits pages: %s", b) + } +} + +// The single-page form (`ui.page`) must also surface, since a plugin using it +// is just as rendered as one using `ui.pages`. +func TestOnDiskReportsSinglePage(t *testing.T) { + _, ps, _ := newPluginVM(t) + code := ` +local p = { name = "single", version = "1.0.0" } +p.ui = { page = { page_id = "solo", title = "Solo", mount = "x" } } +return p +` + if err := loadPlugin(t, ps, "single", code); err != nil { + t.Fatalf("load: %v", err) + } + for _, de := range ps.OnDisk() { + if de.Name == "single" { + if len(de.Pages) != 1 || de.Pages[0] != "solo" { + t.Fatalf("Pages = %v, want [solo]", de.Pages) + } + return + } + } + t.Fatal("single plugin missing from OnDisk") +}