From 04dc3bca720dfd518512e9a916ca693f641cff18 Mon Sep 17 00:00:00 2001 From: yetone Date: Tue, 29 Sep 2026 20:21:38 +0800 Subject: [PATCH] =?UTF-8?q?panel:=20a=20Routing=20tab=20shows=20the=20gate?= =?UTF-8?q?way's=20latest=20requests,=20and=20the=20allowances'=20tab=20is?= =?UTF-8?q?=20named=20Allowances=20(v=20on=20Discord=20#feedback:=20the=20?= =?UTF-8?q?panel's=20Usage=20tab=20and=20the=20window's=20Usage=20page=20m?= =?UTF-8?q?ean=20different=20things,=20and=20the=20panel=20had=20no=20rout?= =?UTF-8?q?ing)=20=E2=80=94=20the=20tray=20panel=20had=20Agents,=20Usage?= =?UTF-8?q?=20and=20Profiles,=20its=20Usage=20being=20each=20subscription'?= =?UTF-8?q?s=20allowance=20while=20the=20window's=20Usage=20counts=20reque?= =?UTF-8?q?sts=20and=20Routing=20shows=20where=20they=20went.=20The=20pane?= =?UTF-8?q?l's=20tab=20now=20reads=20Allowances=20(=E9=A2=9D=E5=BA=A6;=20t?= =?UTF-8?q?he=20window=20keeps=20Usage/=E7=94=A8=E9=87=8F,=20and=20the=20t?= =?UTF-8?q?ab's=20id=20and=20remembered=20choice=20are=20unchanged),=20and?= =?UTF-8?q?=20a=20new=20Routing=20tab=20(=E8=B7=AF=E7=94=B1)=20lists=20the?= =?UTF-8?q?=20gateway's=20last=208=20requests=20as=20they=20come,=20from?= =?UTF-8?q?=20the=20trace=20the=20Routing=20page=20already=20long-polls=20?= =?UTF-8?q?(/api/gateway/trace,=20which=20routing.js=20polled=20in=20the?= =?UTF-8?q?=20panel=20too;=20no=20new=20endpoint):=20each=20a=20two-line?= =?UTF-8?q?=20row=20with=20the=20agent's=20dot,=20icon=20and=20name=20and?= =?UTF-8?q?=20the=20model=20it=20asked=20for,=20the=20time,=20then=20where?= =?UTF-8?q?=20it=20went=20(provider=20=C2=B7=20account=20or=20key),=20the?= =?UTF-8?q?=20model=20that=20try=20sent=20and,=20when=20the=20reply=20name?= =?UTF-8?q?d=20another=20model=20(the=20new=20served/swapped=20fields),=20?= =?UTF-8?q?the=20amber=20"served=20gpt-6-luna"=20(=E5=AE=9E=E9=99=85=20?= =?UTF-8?q?=E2=80=A6)=20tag=20of=20the=20Requests=20list,=20beside=20the?= =?UTF-8?q?=20tokens=20and=20time=20taken;=20a=20failed=20one=20says=20its?= =?UTF-8?q?=20status=20and=20why=20(429=20=C2=B7=20rate=20limited),=20one?= =?UTF-8?q?=20under=20way=20who=20is=20answering.=20Over=20them,=20today's?= =?UTF-8?q?=20calls=20and=20tokens=20(the=20Usage=20page's=20/api/usage=3F?= =?UTF-8?q?period=3Dtoday,=20read=20when=20the=20tab=20is=20shown=20and=20?= =?UTF-8?q?after=20a=20request=20ends,=20at=20most=20every=205=20s)=20and?= =?UTF-8?q?=20Open=20Routing.=20A=20click=20on=20a=20request=20opens=20the?= =?UTF-8?q?=20window's=20Routing=20page=20on=20it:=20POST=20/api/window/ma?= =?UTF-8?q?in=20takes=20req=20beside=20view=3Drouting=20(mainView=20lets?= =?UTF-8?q?=20only=20a=20positive=20id=20through),=20and=20the=20page,=20l?= =?UTF-8?q?oaded=20so,=20picks=20that=20request=20as=20a=20click=20in=20it?= =?UTF-8?q?s=20list=20would=20(its=20story=20told)=20and=20drops=20req=20f?= =?UTF-8?q?rom=20the=20address.=20The=20panel's=20height=20counts=20the=20?= =?UTF-8?q?tab=20as=20its=20tallest,=20as=20the=20others;=20the=20tab=20re?= =?UTF-8?q?draws=20keeping=20the=20panel's=20scroll,=20the=20click=20moves?= =?UTF-8?q?=20nothing,=20no=20border=20stripe.=20Tested=20with=20a=20new?= =?UTF-8?q?=20internal/gui/tests/panel-routing.test.cjs=20in=20Chromium=20?= =?UTF-8?q?and=20WebKit,=20en=20and=20zh:=20the=20Routing=20and=20Allowanc?= =?UTF-8?q?es=20labels,=20four=20fixture=20requests=20listed=20newest=20fi?= =?UTF-8?q?rst=20with=20agent,=20model,=20"Relay=20=C2=B7=20team"=20and=20?= =?UTF-8?q?"Claude=20=C2=B7=20ann@example.com",=20the=20served=20tag=20on?= =?UTF-8?q?=20the=20swapped=20one=20only=20(not=20on=20a=20dated=20name),?= =?UTF-8?q?=20the=20429=20row,=20times,=20no=20row=20overflowing,=20today'?= =?UTF-8?q?s=2042=20calls=20=C2=B7=201.2M=20tokens,=20a=20click=20with=20t?= =?UTF-8?q?he=20panel=20scrolled=20asking=20for=20view=3Drouting&req=3D100?= =?UTF-8?q?=20and=20leaving=20the=20scroll=20as=20it=20was,=20Open=20Routi?= =?UTF-8?q?ng,=20and=20the=20window=20opened=20on=20=3Fview=3Drouting&req?= =?UTF-8?q?=3D101=20with=20that=20request=20picked=20and=20its=20story=20s?= =?UTF-8?q?hown;=20and=20TestMainView.=20go=20build=20-tags=20nogui=20(and?= =?UTF-8?q?=20GOOS=3Dlinux,=20GOOS=3Dwindows),=20go=20test=20-tags=20nogui?= =?UTF-8?q?=20./...=20and=20the=20whole=20GUI=20suite=20(102=20tests)=20pa?= =?UTF-8?q?ss.=20Not=20tried=20in=20the=20real=20tray=20panel=20or=20with?= =?UTF-8?q?=20a=20live=20gateway:=20the=20window=20opening=20on=20the=20re?= =?UTF-8?q?quest=20is=20tested=20in=20the=20page,=20not=20through=20Wails.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/gui/api.go | 2 +- internal/gui/assets/app.css | 7 +- internal/gui/assets/app.js | 9 +- internal/gui/assets/i18n.js | 1 + internal/gui/assets/index.html | 4 +- internal/gui/assets/routing.css | 34 +++++ internal/gui/assets/routing.js | 107 +++++++++++++- internal/gui/tests/README.md | 12 +- internal/gui/tests/panel-routing.test.cjs | 168 ++++++++++++++++++++++ internal/gui/trace.go | 14 ++ internal/gui/trace_test.go | 27 ++++ 11 files changed, 373 insertions(+), 12 deletions(-) create mode 100644 internal/gui/tests/panel-routing.test.cjs create mode 100644 internal/gui/trace_test.go diff --git a/internal/gui/api.go b/internal/gui/api.go index 4f0bfb1b..d4701a7b 100644 --- a/internal/gui/api.go +++ b/internal/gui/api.go @@ -556,7 +556,7 @@ func Handler(w Windows, gw *gateway.Server) http.Handler { case "hide": w.HidePanel() case "main": - w.ShowMain(r.URL.Query().Get("view")) + w.ShowMain(mainView(r.URL.Query())) case "quit": w.Quit() case "fit": diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index a1d33e37..39a3c84b 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -566,9 +566,10 @@ body.panel .view { padding-top: 2px; } .ptabs button.on { color: var(--fg); font-weight: 550; } .ptabs button[hidden] { display: none; } .ptabs em { font-style: normal; font-weight: 400; color: var(--faint); font-variant-numeric: tabular-nums; } -body.panel[data-ptab="usage"] :is(#agents, .profiles), -body.panel[data-ptab="profiles"] :is(#agents, #panelQuota), -body.panel[data-ptab="agents"] :is(.profiles, #panelQuota) { display: none; } +body.panel[data-ptab="usage"] :is(#agents, .profiles, #panelRouting), +body.panel[data-ptab="routing"] :is(#agents, .profiles, #panelQuota), +body.panel[data-ptab="profiles"] :is(#agents, #panelQuota, #panelRouting), +body.panel[data-ptab="agents"] :is(.profiles, #panelQuota, #panelRouting) { display: none; } /* ---- the panel's agents: one quiet line each, opened in place ------------ */ /* icon · name · model · effort · chevron, the last three in columns of their diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2b7f324a..57576942 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -952,7 +952,7 @@ function fit(extra = 0, glide) { const body = document.body, tab = body.dataset.ptab; delete body.dataset.ptab; // extra is only ever the agents' (a row opening, the scroll unrolling) - const tallest = Math.max($("#agents").offsetHeight + extra, $(".profiles").offsetHeight, $("#panelQuota").offsetHeight); + const tallest = Math.max($("#agents").offsetHeight + extra, $(".profiles").offsetHeight, $("#panelQuota").offsetHeight, $("#panelRouting").offsetHeight); if (tab) body.dataset.ptab = tab; const h = $(".top").offsetHeight + $("#ptabs").offsetHeight + tallest + $(".foot").offsetHeight + 4; if (h === fit.last) return; @@ -5048,8 +5048,10 @@ function planSpan(q) { return s; } -// The tray panel is three tabs over the one page: the agents, the usage of -// every subscription and key, and the saved profiles. The tab is remembered. +// The tray panel is four tabs over the one page: the agents, the allowances +// of every subscription and key (the "usage" tab, as it was named before), +// the gateway's latest requests (routing.js draws those) and the saved +// profiles. The tab is remembered. let panelTab = "agents"; try { panelTab = localStorage.getItem("magpie.panelTab") || "agents"; } catch {} function setPanelTab(tab) { @@ -5066,6 +5068,7 @@ function setPanelTab(tab) { // the card under the tab picked glides to it, as on every other pill slide(tabs, "ptabs"); panelAge(); + window.panelRoutingShown?.(); fit(); } if (mode === "panel") { diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 3b1e5947..9e4e7d9e 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1174,6 +1174,7 @@ const I18N = { "{n} tok/s": "{n} token/秒", "requested {sent} · served {served}": "请求 {sent} · 实际 {served}", "served {served}": "实际 {served}", + "Open Routing": "打开路由", "The vendor was asked for {sent}, and its reply says {served} answered it: another model, not just {sent} under a dated name.": "向服务商请求的是 {sent},但它的回复写明由 {served} 作答:这是另一个模型,而不只是 {sent} 带日期的版本名。", "{n} min": "{n} 分钟", "{h} h {m} min": "{h} 小时 {m} 分钟", diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index e5ea0055..f35899c0 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -45,7 +45,8 @@ @@ -57,6 +58,7 @@ +
diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index b25653e8..29ea9c86 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -330,3 +330,37 @@ .rt-day small { font-size: 10.5px; color: var(--faint); font-variant-numeric: tabular-nums; } .rt-day:hover { color: var(--fg); } .rt-day.on { color: var(--fg); border-color: var(--fg); } + +/* the tray panel's Routing tab: the gateway's latest requests, two lines each */ +.panel-routing { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 2px 2px 6px; } +.pr-head { display: flex; align-items: center; gap: 8px; min-height: 22px; padding: 0 2px 0 4px; font-size: 12px; color: var(--muted); } +.pr-head .rt-livedot { margin-left: 0; flex: none; } +.pr-today { font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pr-today b { color: var(--fg); font-weight: 600; } +.pr-head .text { flex: none; font-size: 12px; } +.skeleton.pr-sk { display: inline-block; width: 150px; height: 9px; border-radius: 4px; vertical-align: middle; } +.skeleton.pr-sk-row { height: 40px; border-radius: 11px; } +.pr-none { margin: 0; padding: 12px; border-radius: 11px; background: var(--card); box-shadow: var(--card-edge); font-size: 12px; line-height: 1.55; color: var(--muted); } +.pr-none b { display: block; color: var(--fg); font-weight: 550; } +.pr-list { display: flex; flex-direction: column; padding: 4px; border-radius: 11px; background: var(--card); box-shadow: var(--card-edge); } +.pr-req { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 2px 10px; padding: 6px 8px; border-radius: 8px; font: inherit; font-size: 12px; text-align: left; color: var(--fg-2); background: none; border: 0; cursor: pointer; } +.pr-req:hover { background: var(--card-2); } +.pr-a, .pr-to { display: flex; align-items: center; gap: 6px; min-width: 0; } +.pr-a i.ag { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--agent); } +.pr-a .ic, .pr-a .ic img, .pr-a .ic svg { width: 14px; height: 14px; flex: none; } +.pr-who { flex: none; color: var(--fg); font-weight: 550; } +.pr-a .m { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 500 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg-2); background: var(--card-2); border-radius: 5px; padding: 0 5px; } +.pr-a .kind { flex: 0 100 auto; min-width: 3.2em; max-width: 8em; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--muted); background: var(--card-2); border: 1px solid var(--line); white-space: nowrap; } +.pr-req .at { font-variant-numeric: tabular-nums; color: var(--muted); font-size: 11px; } +.pr-to { padding-left: 2px; color: var(--fg); } +.pr-to > i { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--green); } +.pr-req.moved .pr-to > i { background: var(--accent); } +.pr-req.bad .pr-to { color: var(--red); } +.pr-req.bad .pr-to > i { background: var(--red); } +.pr-req.wait .pr-to > i { background: var(--muted); animation: rtlive 1.8s infinite; } +.pr-where { min-width: 0; flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pr-m { flex: 0 2 auto; min-width: 2em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); } +.pr-m::before { content: "· "; } +.pr-to .swap { flex: none; max-width: 45%; overflow: hidden; text-overflow: ellipsis; font-size: 10.5px; line-height: 16px; padding: 0 5px; border-radius: 5px; color: var(--amber); background: var(--amber-soft); white-space: nowrap; } +.pr-req .meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; } +@media (prefers-reduced-motion: reduce) { .pr-req.wait .pr-to > i { animation: none; } } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index c9823f40..75ecfd8d 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -470,6 +470,9 @@ const routes = new Map(); // id → the latest of each route let seq = 0, mine = true, loaded = false, daysAt = 0; + let offMsg = ""; // why the trace can't be watched here, when it can't + // the request the window was opened on (?req=), from the tray panel + let wanted = document.body.classList.contains("window") && Number(params.get("req")) || 0; let cur = null; // the route the header and the log tell of: the newest played let pinned = null; // a past route picked from the strip let rows = new Map(); // id → { li, wire, st, bi, tg, w, rid, up } @@ -1588,11 +1591,13 @@ statB[2].textContent = d.totals.errors; if (!mine) { const gw = providers?.gateway; - offline(t(!gw?.running ? "The gateway isn't running, so nothing is routed." + offMsg = !gw?.running ? "The gateway isn't running, so nothing is routed." : gw.window ? "Another magpie serves the gateway; its routing plays live in that magpie's window." // magpie serve: its routing isn't shown anywhere - : "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live.")); + : "The gateway is served by a magpie without a window (magpie serve), so its routing can't be watched. Stop it and let this magpie serve the gateway to see routing live."; + offline(t(offMsg)); loaded = false; + renderPanel(); await new Promise((r) => setTimeout(r, 5000)); continue; } @@ -1606,13 +1611,19 @@ } for (const id of [...routes.keys()].sort((a, b) => a - b).slice(0, -60)) routes.delete(id); loaded = true; + offMsg = ""; + renderPanel(first || d.routes.some((r) => r.done)); // a request done is a day's count grown: heard of now and then if (first || (d.routes.some((r) => r.done) && performance.now() - daysAt > 15e3)) { daysAt = performance.now(); loadDays(); } if (first) { offline(""); // the agents' names come with the app's state, which may not be here yet for (let i = 0; i < 30 && !state.agents.length; i++) await new Promise((res) => setTimeout(res, 100)); - const r = newest(); + renderPanel(); + // the window opened from the tray panel's Routing tab on a request + const asked = wanted && routes.get(wanted), r = asked || newest(); + if (wanted) { wanted = 0; params.delete("req"); history.replaceState(null, "", params.size ? "?" + params : location.pathname); } + if (asked && asked.id !== newest().id) pinned = asked; if (r) { cur = r; sync(true); say(affWhy(r, true) || ruleWhy(r, true) || firstWhy(r)); renderAll(); } else empty(); } else { if (cur && routes.has(cur.id) && !rp) cur = routes.get(cur.id); @@ -1636,6 +1647,7 @@ // the caption said before, said again in these words: it was set as text if (loaded) { if (cur) { sync(true); renderAll(); capQ = []; say(affWhy(cur, true) || ruleWhy(cur, true) || firstWhy(cur)); } else empty(); } renderGroups(); + renderPanel(); } new MutationObserver(words).observe(document.documentElement, { attributes: true, attributeFilter: ["lang"] }); @@ -2085,6 +2097,95 @@ window.addEventListener("focus", () => { if (shown()) loadGroups(); }); loadGroups(); + // ---------- the tray panel's Routing tab ---------- + // The gateway's latest requests, as they come, from the same trace the + // page above plays: who sent each, the model asked for, the provider and + // account it went to, the model that answered (marked when the reply + // names another), how it ended and when; today's calls and tokens over + // them, from the Usage page's count. A click opens the window's Routing + // page on that request; nothing here moves the panel's scroll. + const pBox = document.body.classList.contains("panel") ? $("#panelRouting") : null; + if (pBox) pBox.hidden = false; + const P_ROWS = 8; + let today = null, todayAt = 0; + const pShown = () => pBox && document.body.dataset.ptab === "routing"; + async function loadToday() { + todayAt = performance.now(); + try { today = await api("usage?period=today"); } catch { return; } + renderPanel(); + } + window.panelRoutingShown = () => { if (pShown() && performance.now() - todayAt > 5e3) loadToday(); }; + // renderPanel draws the tab; counted is a request just done, for today's + // totals to be read again (now and then) + function renderPanel(counted) { + if (!pBox) return; + if (counted && (!todayAt || performance.now() - todayAt > 5e3)) loadToday(); + const v = $("#view-agents"), keep = v.scrollTop; + const head = el("div", "pr-head"); + const sum = el("span", "pr-today"); + if (today) { + sum.append(el("span", "", t("today")), " ", + el("b", "", String(today.calls || 0)), " ", t(today.calls === 1 ? "call" : "calls"), " · ", + el("b", "", fmtN(tokensOf(today) || 0)), " ", t("tokens")); + } else sum.append(el("span", "skeleton pr-sk")); + const open = el("button", "text", t("Open Routing")); + open.type = "button"; + open.onclick = (e) => { api("window/main?view=routing", {}); e.currentTarget.blur(); }; + head.append(el("span", "rt-livedot"), sum, el("span", "grow"), open); + const out = [head]; + const rs = [...routes.values()].sort((a, b) => b.id - a.id).slice(0, P_ROWS); + if (offMsg) out.push(el("p", "pr-none", t(offMsg))); + else if (!loaded) for (let i = 0; i < 3; i++) out.push(el("span", "skeleton pr-sk-row")); + else if (!rs.length) { + const p = el("div", "pr-none"); + p.append(el("b", "", t("No request yet")), t("Every request an agent sends to magpie shows up here, routed for real.")); + out.push(p); + } else { + const list = el("div", "pr-list"); + for (const r of rs) list.append(panelRow(r)); + out.push(list); + } + pBox.replaceChildren(...out); + if (v.scrollTop !== keep) v.scrollTop = keep; + fit(); + } + function panelRow(r) { + const [, how, tr] = outcome(r); + const w = tr && tried(r, tr); + const b = el("button", "pr-req " + how); + b.type = "button"; + const ag = agentOf(r.agent); + const asked = el("span", "pr-a"); + const sw = el("i", "ag"); + sw.style.setProperty("--agent", hueOf(r.agent)); + asked.append(sw, icon(ag?.icon || "generic"), el("span", "pr-who", agentName(r.agent)), el("code", "m", r.model)); + if (r.kind) asked.append(kindTag(r)); + const when = el("span", "at", new Date(r.time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })); + // where it went: the provider, the account or key, and the model that + // answered — marked when the reply names another than the one sent + const to = el("span", "pr-to"); + to.append(el("i")); + const where = w ? (w.kind === "provider" || !w.who ? w.name || w.provider : `${w.name || w.provider} · ${w.who}`) : r.provider; + if (!r.done) to.append(el("span", "pr-where", w ? t("{who} is answering…", { who: where }) : t("routing…"))); + else if (r.status >= 400) { + const last = r.tries[r.tries.length - 1]; + to.append(el("span", "pr-where", last ? `${r.status} · ${failWord(last.fail)}` : `${r.status || ""} ${r.error || ""}`.trim())); + } else { + to.append(el("span", "pr-where", where)); + const model = tr?.model || w?.model; + if (model) to.append(el("span", "pr-m", model)); + if (tr?.swapped && tr.done) to.append(swapTag(tr, true)); + } + const meta = []; + if (r.tries.length > 1) meta.push(t("{n} tries", { n: r.tries.length })); + if (r.tokens) meta.push(t("{n} tokens", { n: tokens(r.tokens) })); + if (r.done && r.ms) meta.push(took(r.ms)); + b.append(asked, when, to, el("span", "meta", meta.join(" · "))); + b.title = `${agentName(r.agent)} · ${r.model} → ${r.provider}`; + b.onclick = (e) => { api("window/main?view=routing&req=" + r.id, {}); e.currentTarget.blur(); }; + return b; + } + new ResizeObserver(() => layout()).observe(stage); // the list is as tall as leaves the stage in sight above it: the reader // scrolls the list, not the page, and a request picked plays in view. diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index fb2a7cce..4ac7032c 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -55,6 +55,16 @@ list not moving, field and button in sight, one focus ring; the button then reads Save and a click on it saves, as Enter does; an empty name keeps the field; Escape closes it; in English and Chinese. +`panel-routing.test.cjs` opens the tray panel's Routing tab: the gateway's +latest requests from a faked trace, newest first, each with its agent, the +model asked for, the provider and account it went to, the model that +answered ("served gpt-6-luna" on the one a vendor answered with another, not +on a dated name), a failure's status, and the time; today's calls and tokens +over them; the allowances' tab named Allowances. A click, with the panel +scrolled, asks for the window's Routing page on that request and moves +nothing; the window opened so has that request picked. In English and +Chinese. + `login-import.test.cjs` brings ChatGPT accounts in from CLIProxyAPI's auth files: offered from the accounts list and from a browser sign-in under way, the box says the refresh spends the file's sign-in, what is pasted is posted @@ -82,7 +92,7 @@ the WorkBuddy group only with an account signed in; in English and Chinese. With Node.js and Playwright available: ```sh -node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs +node --test internal/gui/tests/menu-scroll.test.cjs internal/gui/tests/click-scroll.test.cjs internal/gui/tests/panel-fold.test.cjs internal/gui/tests/panel-routing.test.cjs internal/gui/tests/gateway-fold.test.cjs internal/gui/tests/routing-kind.test.cjs internal/gui/tests/balance-fix.test.cjs internal/gui/tests/cli-update.test.cjs internal/gui/tests/login-import.test.cjs internal/gui/tests/agy-launch.test.cjs internal/gui/tests/currency.test.cjs ``` If Playwright is installed outside the repository, set `NODE_PATH` to the diff --git a/internal/gui/tests/panel-routing.test.cjs b/internal/gui/tests/panel-routing.test.cjs new file mode 100644 index 00000000..67d4c11f --- /dev/null +++ b/internal/gui/tests/panel-routing.test.cjs @@ -0,0 +1,168 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The tray panel's Routing tab (v, #feedback: the panel's 用量 showed the +// accounts' allowances while the window's 用量 is per-request usage and 路由 +// the gateway's routing): the gateway's latest requests, newest first, from +// the trace the Routing page plays — the agent, the model asked for, the +// provider and account it went to, the model that answered with the amber +// "served …" mark on one a vendor answered with another, how it ended and +// when — under today's calls and tokens. The allowances' tab is named so. +// A click opens the window's Routing page on that request and leaves the +// panel where it is; the window opened so has that request picked. No +// backend: the API is faked here. +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const path = require("node:path"); +const { test } = require("node:test"); +const { chromium, webkit } = require("playwright"); + +const assets = path.resolve(__dirname, "../assets"); +const now = new Date(); +const at = (min) => new Date(now.getTime() - min * 60e3).toISOString(); +const relay = { id: "relay:team", provider: "relay", name: "Relay", kind: "key", who: "team", model: "gpt-6-sol" }; +const claude = { id: "claude:a", provider: "claude", name: "Claude", kind: "account", who: "ann@example.com", model: "claude-opus-5" }; +const ok = (w, min, extra = {}) => ({ id: w.id, model: w.model, start: at(min), done: true, status: 200, ms: 1500, ...extra }); +// not in order: the tab sorts them newest first +const routes = [ + { id: 101, seq: 101, time: at(6), agent: "codex", model: "relay/gpt-6-sol", provider: "relay", order: [relay], + tries: [{ id: relay.id, model: "gpt-6-sol", start: at(6), done: true, status: 429, ms: 300, fail: "rate" }], done: true, status: 429, ms: 300 }, + { id: 103, seq: 103, time: at(1), agent: "codex", model: "relay/gpt-6-sol", provider: "relay", order: [relay], + tries: [ok(relay, 1, { served: "gpt-6-luna", swapped: true })], done: true, status: 200, ms: 1500, tokens: 12400, served: "gpt-6-luna", swapped: true }, + { id: 100, seq: 100, time: at(9), agent: "claude", model: "claude/claude-opus-5", provider: "claude", order: [claude], + tries: [ok(claude, 9, { served: "claude-opus-5-20260901" })], done: true, status: 200, ms: 1500, tokens: 800 }, + { id: 102, seq: 102, time: at(3), agent: "claude", model: "claude/claude-opus-5", provider: "claude", order: [claude], + tries: [ok(claude, 3)], done: true, status: 200, ms: 1500, tokens: 3000 }, +]; + +function serve(lang, opened) { + const state = { agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }, { id: "claude", name: "Claude Code", path: "/test/settings.json", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + return async (route) => { + const req = route.request(), url = new URL(req.url()); + const json = (data) => route.fulfill({ json: data }); + if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json(state); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) await new Promise((r) => setTimeout(r, 20e3)); + return json({ mine: true, now: now.toISOString(), seq: 103, totals: { requests: 4, rerouted: 0, errors: 1 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/usage" && url.searchParams.get("period") === "today") { + return json({ calls: 42, input: 1000000, output: 234000, cache_read: 0, cache_write: 0, reasoning: 0, cost: 0, series: [] }); + } + if (url.pathname === "/api/usage/quotas") return json([]); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true, window: true } }); + if (url.pathname === "/api/window/main") { opened.push(url.search); return route.fulfill({ status: 204 }); } + if (url.pathname === "/api/window/fit") return route.fulfill({ status: 204 }); + if (url.pathname.startsWith("/api/")) return json({}); + const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname); + const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const want = { + en: { tab: "Routing", quota: "Allowances", swap: "served gpt-6-luna", today: /today\s*42\s*calls\s*·\s*1\.2M\s*tokens/, fail: /429 · rate limited/, open: "Open Routing", story: /How the request at/ }, + zh: { tab: "路由", quota: "额度", swap: "实际 gpt-6-luna", today: /今天\s*42\s*次调用\s*·\s*1\.2M\s*token/, fail: /429 · 限流/, open: "打开路由", story: /的请求是怎么路由的/ }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: the tray panel's Routing tab`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = [], pages = []; + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + for (const [i, p] of pages.entries()) await p.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-panel-routing-${i}.png`) }); + } + await browser.close(); + }); + const open = async (url, viewport, opened) => { + const page = await (await browser.newContext({ viewport, reducedMotion: "reduce" })).newPage(); + pages.push(page); + page.setDefaultTimeout(5000); + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang, opened)); + await page.goto(url); + return page; + }; + + // a short panel, so its page scrolls + const opened = []; + const page = await open("http://magpie.test/?mode=panel", { width: 440, height: 300 }, opened); + const tab = page.locator('[data-ptab="routing"]'); + assert.equal((await tab.textContent()).trim(), want[lang].tab); + assert.equal((await page.locator('[data-ptab="usage"]').textContent()).trim(), want[lang].quota, "the allowances' tab says what it shows"); + await tab.click(); + await page.locator(".pr-req").nth(routes.length - 1).waitFor(); + assert(await page.locator("#panelRouting").isVisible()); + assert(!(await page.locator("#agents").isVisible()), "the agents are another tab"); + + // newest first: the agent, the model asked, where it went, the mark + const rows = await page.locator(".pr-req").evaluateAll((rs) => rs.map((r) => ({ + who: r.querySelector(".pr-who").textContent, model: r.querySelector(".pr-a .m").textContent, + where: r.querySelector(".pr-where").textContent, served: r.querySelector(".pr-m")?.textContent || "", + swap: r.querySelector(".swap")?.textContent || "", cls: r.className, at: r.querySelector(".at").textContent, + }))); + assert.deepEqual(rows.map((r) => r.model), ["relay/gpt-6-sol", "claude/claude-opus-5", "relay/gpt-6-sol", "claude/claude-opus-5"]); + assert.deepEqual(rows.map((r) => r.who), ["Codex", "Claude Code", "Codex", "Claude Code"]); + assert.equal(rows[0].where, "Relay · team"); + assert.equal(rows[0].served, "gpt-6-sol"); + assert.equal(rows[1].where, "Claude · ann@example.com"); + assert.deepEqual(rows.map((r) => r.swap), [want[lang].swap, "", "", ""], "only the request answered by another model is marked, not a dated name"); + assert.match(rows[2].where, want[lang].fail); + assert.match(rows[2].cls, /\bbad\b/); + for (const r of rows) assert.match(r.at, /\d{1,2}:\d{2}:\d{2}/); + const mark = await page.locator(".pr-req .swap").evaluate((e) => { + const row = e.closest(".pr-req").getBoundingClientRect(), b = e.getBoundingClientRect(), cs = getComputedStyle(e); + return { inRow: b.left >= row.left && b.right <= row.right + 0.5 && b.width > 0, full: e.clientWidth >= e.scrollWidth, border: cs.borderLeftWidth, colour: cs.color, bg: cs.backgroundColor }; + }); + assert(mark.inRow && mark.full, JSON.stringify(mark)); + assert.equal(mark.border, "0px", "no border stripe"); + assert.notEqual(mark.bg, "rgba(0, 0, 0, 0)"); + // no row runs out of the panel + const over = await page.locator(".pr-req").evaluateAll((rs) => rs.filter((r) => r.scrollWidth > r.clientWidth + 1).length); + assert.equal(over, 0); + + // today's calls and tokens, over the list + await page.waitForFunction((re) => new RegExp(re).test(document.querySelector(".pr-today").textContent), want[lang].today.source); + assert.equal((await page.locator(".pr-head .text").textContent()).trim(), want[lang].open); + + // a click opens the window on that request; the panel stays put + const v = page.locator("#view-agents"); + const box = await v.boundingBox(); + await page.mouse.move(box.x + box.width / 2, box.y + 60); + for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, 40); await page.waitForTimeout(20); } + await page.waitForTimeout(400); + const before = await v.evaluate((e) => e.scrollTop); + assert(before > 0, "the panel must be short enough to scroll"); + const row = page.locator(".pr-req").nth(3); + const top = await row.evaluate((e) => e.getBoundingClientRect().top); + await row.click(); + await page.waitForTimeout(400); + assert.equal(await v.evaluate((e) => e.scrollTop), before, "the click scrolled the panel"); + assert(Math.abs((await row.evaluate((e) => e.getBoundingClientRect().top)) - top) <= 1); + assert.deepEqual(await page.evaluate(() => [scrollX, scrollY]), [0, 0]); + assert.deepEqual(opened, ["?view=routing&req=100"]); + // and Open Routing, over the list, opens the page as it is + for (let i = 0; i < 12; i++) { await page.mouse.wheel(0, -40); await page.waitForTimeout(20); } + await page.waitForTimeout(400); + await page.locator(".pr-head .text").click(); + await page.waitForTimeout(200); + assert.equal(await v.evaluate((e) => e.scrollTop), 0); + assert.deepEqual(opened, ["?view=routing&req=100", "?view=routing"]); + + // the window opened so: that request picked, its story told, and the + // address no longer asking for it + const win = await open("http://magpie.test/?view=routing&req=101", { width: 1100, height: 760 }, []); + await win.locator(".rt-req").nth(routes.length - 1).waitFor(); + await win.waitForFunction(() => !location.search.includes("req=")); + const picked = await win.locator('.rt-req[aria-pressed="true"]').evaluateAll((rs) => rs.map((r) => r.querySelector(".to").textContent)); + assert.equal(picked.length, 1); + assert.match(picked[0], /429/); + assert.match(await win.locator(".rt-log-head").textContent(), want[lang].story); + assert.deepEqual(errors, []); + }); + } +} diff --git a/internal/gui/trace.go b/internal/gui/trace.go index d4337946..a5b2012e 100644 --- a/internal/gui/trace.go +++ b/internal/gui/trace.go @@ -3,6 +3,7 @@ package gui import ( "encoding/json" "net/http" + "net/url" "strconv" "time" @@ -16,6 +17,19 @@ type traceJSON struct { Now time.Time `json:"now"` } +// mainView is the tab the window is asked to open on, as the page's view +// parameter: the Routing page on one request (req, its id) when the tray +// panel's Routing tab asks for it. Anything but an id is dropped. +func mainView(q url.Values) string { + view := q.Get("view") + if req := q.Get("req"); view == "routing" && req != "" { + if id, err := strconv.ParseInt(req, 10, 64); err == nil && id > 0 { + view += "&req=" + strconv.FormatInt(id, 10) + } + } + return view +} + // traceRoutes serves the routing trace for the Gateway view to play: it // waits up to 25 s for something to change after the seq it is given, so // the page hears of a request as it happens. diff --git a/internal/gui/trace_test.go b/internal/gui/trace_test.go new file mode 100644 index 00000000..a421afd3 --- /dev/null +++ b/internal/gui/trace_test.go @@ -0,0 +1,27 @@ +package gui + +import ( + "net/url" + "testing" +) + +// The tray panel's Routing tab opens the window's Routing page on the +// request clicked: the id goes along, and nothing else does. +func TestMainView(t *testing.T) { + for _, c := range []struct { + q url.Values + want string + }{ + {url.Values{}, ""}, + {url.Values{"view": {"settings"}}, "settings"}, + {url.Values{"view": {"routing"}}, "routing"}, + {url.Values{"view": {"routing"}, "req": {"42"}}, "routing&req=42"}, + {url.Values{"view": {"settings"}, "req": {"42"}}, "settings"}, + {url.Values{"view": {"routing"}, "req": {"42&view=x"}}, "routing"}, + {url.Values{"view": {"routing"}, "req": {"-1"}}, "routing"}, + } { + if got := mainView(c.q); got != c.want { + t.Errorf("%v: %q, want %q", c.q, got, c.want) + } + } +}