diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index e3aa49d9..e281e5ff 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -146,9 +146,17 @@ .masking .pii { animation: rtpii .35s ease-out both; } @keyframes rtpii { from { filter: blur(0); opacity: 1; } } #view-routing .rt { margin: 0; } -.rt-more { container-type: inline-size; padding-bottom: 12px; } +.rt-more { container: rt-more / inline-size; padding-bottom: 12px; } .rt-cols { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: 16px; } -@container (min-width: 860px) { .rt-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; } } +/* side by side, the accounts end where the requests do and scroll like + them, not run on down the page beside an empty column: of no height of + their own, they take what the requests give the row, and no less than a + few accounts' */ +@container rt-more (min-width: 860px) { + .rt-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .rt-cols:not(.solo) .rt-col:last-child { display: flex; flex-direction: column; } + .rt-cols:not(.solo) .rt-acts { flex: 1 1 auto; height: 0; min-height: 216px; overflow-y: auto; overscroll-behavior: contain; } +} /* each column its own container: a request's row lays itself out by the list's width, not the page's (#273: in two columns of ~470px the rows kept to one line, and the reasoning was drawn over the numbers) */ @@ -216,6 +224,7 @@ .rt-act .st.left { color: var(--muted); } .rt-act .tally { display: flex; flex-wrap: wrap; gap: 2px 12px; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; } .rt-act .tally .bad { color: var(--red); } +.rt-act .mdls { display: flex; flex-wrap: wrap; gap: 4px; } .rt-act .fix { display: flex; flex-wrap: wrap; gap: 2px 12px; font-size: 11px; } .rt-act .bar { height: 3px; border-radius: 2px; background: var(--card-2); overflow: hidden; } .rt-act .bar i { display: block; height: 100%; border-radius: 2px; background: var(--muted); } diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 1efc1cd6..945fe8cc 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -1240,7 +1240,9 @@ } const row = el("div", "rt-act"); const name = el("div", "nm"); - name.append(el("b", "", who(w)), el("span", "", w.kind === "provider" ? w.model : w.plan || (w.kind === "key" ? t("API key") : ""))); + // a provider's own row: its model, the provider's name is the heading + if (w.kind === "provider") name.append(el("b", "", w.model || w.name)); + else name.append(el("b", "", who(w)), el("span", "", w.plan || (w.kind === "key" ? t("API key") : ""))); let st, cls = ""; const resting = a.rest && at(a.rest.until) > n; if (resting) { st = `${failWord(a.rest.why)} · ${restWhen(a.rest)}`; cls = "rest"; } @@ -1257,9 +1259,16 @@ el("span", "", t("tried {n}", { n: a.tried })), el("span", "ok", t("answered {n}", { n: a.ok })), ...(fails.length ? [el("span", "bad", fails.join(", "))] : []), - ...(a.last ? [el("span", "", t("last answered {time}", { time: clock(a.last) }))] : []), - ...[...a.models].map((m) => el("code", "mdl", m))); + ...(a.last ? [el("span", "", t("last answered {time}", { time: clock(a.last) }))] : [])); row.append(name, el("div", "st " + cls, st), tally); + // the models it answered, on a line of their own, not wrapped in + // among the numbers; a provider's one model is already its name + const mdls = [...a.models].filter((m) => !(w.kind === "provider" && m === w.model)); + if (mdls.length) { + const ms = el("div", "mdls"); + ms.append(...mdls.map((m) => el("code", "mdl", m))); + row.append(ms); + } if (resting && a.rest.why === "verify") { // the vendor wants the account verified (#152): where, and a way to // stop its rest once it is @@ -2648,10 +2657,16 @@ if (v.hidden || !reqs.offsetParent) return; const above = reqs.getBoundingClientRect().top - box.getBoundingClientRect().top; const room = v.clientHeight - above - 28; - reqs.style.maxHeight = Math.round(Math.max(216, Math.min(420, room))) + "px"; + const h = Math.round(Math.max(216, Math.min(420, room))) + "px"; + if (reqs.style.maxHeight !== h) reqs.style.maxHeight = h; } - new ResizeObserver(fitReqs).observe($("#view-routing")); - new ResizeObserver(fitReqs).observe(box); + // the accounts beside the requests end where they do, so a new height + // resizes what the other observers have just been told of: size the + // list in the next frame, not inside this round of them + let fitting = 0; + const fitSoon = () => { if (!fitting) fitting = requestAnimationFrame(() => { fitting = 0; fitReqs(); }); }; + new ResizeObserver(fitSoon).observe($("#view-routing")); + new ResizeObserver(fitSoon).observe(box); words(); requestAnimationFrame(frame); poll(); diff --git a/internal/gui/tests/routing-columns.test.cjs b/internal/gui/tests/routing-columns.test.cjs new file mode 100644 index 00000000..9279dc99 --- /dev/null +++ b/internal/gui/tests/routing-columns.test.cjs @@ -0,0 +1,109 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// The Routing page's Requests and Accounts and keys, side by side (a report +// with a screenshot: "这里排版是不是有问题啊?感觉好乱"): the two lists end on one +// line, the accounts scrolling like the requests instead of running on down +// the page beside an empty column; the models an account answered sit on a +// line of their own, not wrapped in among its numbers; a provider's row +// says its model once, under the provider's name, not the name again and the +// model twice. One column, the accounts list is as long as it is. +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 now = new Date(); +const at = (i) => new Date(now.getTime() - (i + 1) * 60e3).toISOString(); +const renew = new Date(now.getTime() + 3 * 864e5).toISOString(); +const W = { + aihub: { id: "aihubmix", provider: "aihubmix", name: "AiHubMix", kind: "provider", model: "glm-5.3-flash", icon: "aihubmix-color", routing: "smart" }, + c1: { id: "codex:a", provider: "codex", name: "Codex", who: "ann@example.com", plan: "plus", kind: "account", model: "gpt-5.6-terra", known: true, used: 9, renews: [renew], icon: "codex-color", routing: "smart" }, + c2: { id: "codex:b", provider: "codex", name: "Codex", who: "bob@example.com", plan: "pro", kind: "account", model: "gpt-5.6-terra", known: true, used: 4, renews: [renew], icon: "codex-color", routing: "smart" }, + cop: { id: "copilot:a", provider: "copilot", name: "Copilot", who: "ann", kind: "account", model: "gpt-6-sol", known: true, used: 1, renews: [renew], icon: "githubcopilot", routing: "smart" }, + ds: { id: "deepseek", provider: "deepseek", name: "DeepSeek", kind: "provider", model: "deepseek-flash", icon: "deepseek-color", routing: "smart" }, + or: { id: "openrouter", provider: "openrouter", name: "OpenRouter", kind: "provider", model: "openai/gpt-6-sol", icon: "openrouter", routing: "smart" }, + wb: { id: "wb:a", provider: "workbuddy", name: "WorkBuddy", who: "ann", kind: "account", model: "glm", known: false, icon: "generic", routing: "smart" }, +}; +const mk = (i, w, model, extra = {}) => ({ + id: 200 - i, seq: 200 - i, time: at(i), agent: i % 2 ? "codex" : "curl", model, provider: w.provider, + order: Object.values(W).filter((x) => x.provider === w.provider), done: true, status: 200, ms: 5000 + i * 900, tokens: 90000 + i * 700, + tries: [{ id: w.id, model: w.model || model.split("/").pop(), start: at(i), done: true, status: 200, ms: 5000 }], ...extra, +}); +const routes = [ + mk(0, W.ds, "deepseek/deepseek-flash"), mk(1, W.ds, "deepseek/deepseek-flash"), + ...[2, 3, 4, 5].map((i) => ({ ...mk(i, W.c1, "codex/gpt-5.6-terra"), tries: [{ id: W.c1.id, model: "gpt-5.6-terra", start: at(i), done: true, status: 200, ms: 8000, effort: "medium" }] })), + { ...mk(6, W.c1, "codex/gpt-6-luna"), tries: [{ id: W.c1.id, model: "gpt-6-luna", start: at(6), done: true, status: 200, ms: 8000 }] }, + { ...mk(7, W.c1, "codex/gpt-5.6-luna"), tries: [{ id: W.c1.id, model: "gpt-5.6-luna", start: at(7), done: true, status: 200, ms: 8000 }] }, + mk(8, W.cop, "copilot/gpt-6-sol"), mk(9, W.aihub, "aihubmix/glm-5.3-flash"), mk(10, W.or, "openrouter/openai/gpt-6-sol"), mk(11, W.wb, "workbuddy/glm"), +]; +function serve(lang) { + const state = { agents: [{ id: "codex", name: "Codex", path: "/t", fields: [] }], profiles: [], settings: { lang, theme: "light" } }; + const assets = path.resolve(__dirname, "../assets"); + return async (route) => { + const url = new URL(route.request().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: 200, totals: { requests: routes.length, rerouted: 0, errors: 0 }, routes }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json({ groups: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true } }); + 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).catch(() => ""), contentType }); + }; +} + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh"]) { + test(`${engine} ${lang}: requests and accounts side by side`, async (t) => { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const page = await browser.newPage({ viewport: { width: 940, height: 1400 } }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.route("**/*", serve(lang)); + t.after(async () => { + if (process.env.ARTIFACT_DIR) { + await fs.mkdir(process.env.ARTIFACT_DIR, { recursive: true }); + await page.locator(".rt-cols").screenshot({ path: path.join(process.env.ARTIFACT_DIR, `${engine}-${lang}-columns.png`) }); + } + await browser.close(); + }); + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-act").first().waitFor(); + await page.waitForTimeout(300); + + const box = (s) => page.locator(s).evaluate((e) => { const b = e.getBoundingClientRect(); return { left: b.left, right: b.right, bottom: b.bottom, scroll: e.scrollHeight > e.clientHeight + 1 }; }); + const reqs = await box(".rt-reqs"), acts = await box(".rt-acts"); + assert(acts.left >= reqs.right, "side by side"); + assert(Math.abs(acts.bottom - reqs.bottom) <= 1, `the lists end on one line: ${reqs.bottom} / ${acts.bottom}`); + assert(acts.scroll, "the rest of the accounts scroll"); + + // the Codex account's three models on their own line, none in the tally + const codex = page.locator(".rt-act", { hasText: "ann@example.com" }); + assert.deepEqual((await codex.locator(".mdls code").allInnerTexts()).sort(), ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-6-luna"]); + assert.equal(await codex.locator(".tally code").count(), 0); + const tally = await codex.locator(".tally").boundingBox(), mdls = await codex.locator(".mdls").boundingBox(); + assert(mdls.y >= tally.y + tally.height - 0.5, "the models under the numbers"); + + // a provider's row: its model once, not its name again + const ai = page.locator(".rt-act").first(); + assert.equal((await ai.locator(".nm").innerText()).trim(), "glm-5.3-flash"); + assert.equal(await ai.locator("code").count(), 0); + + // one column: the accounts as long as they are + await page.setViewportSize({ width: 700, height: 1400 }); + await page.waitForTimeout(300); + const one = await box(".rt-acts"); + assert(!one.scroll, "one column: nothing hidden in the accounts"); + assert.equal(await page.locator(".rt-acts").evaluate((e) => e.style.maxHeight), ""); + assert.deepEqual(errors, []); + }); + } +}