diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index 7a02d8c1..f8a94739 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -47,6 +47,22 @@ filter only when that request is excluded. Usage's purpose picker remains a single choice sent to the ledger API. See `purpose-filter.test.cjs` and `routing-purpose-state.test.cjs`. +### Routing key folds + +Three or more API keys of one provider standing next to each other for one +model (same fixed effort, fallback and group-in-group heading) fold into one +stage row, `li.rt-fold` in [`routing.js`](../../internal/gui/assets/routing.js) +(`FOLD_AT`, `rebuild`). It shows the key count and the keys' state together: +the key that is lit and what it is doing, how many are available and how +many rest. Requests that land on a folded key fly to the fold (`shownRow`). +Clicking it (or Enter / Space) shows each key in place, wired from the fold. +Accounts and keys folds the same provider's keys into one `.rt-keys` row +with their summed tally. Which folds are open is kept per reader in +localStorage `magpie.routingKeysOpen` (`provider/model` on the stage, +`provider/*` in the list). Display only: the gateway's order and choice of +key don't change. Two keys stay two rows. See +`routing-keys-fold.test.cjs`. + ### Desktop fonts Settings → General offers independent interface and code fonts, each with diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index df59f8e5..f11d7c79 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -1175,6 +1175,10 @@ const I18N = { "Pause: the gateway uses the other accounts, {agent} stays signed in to this one": "暂停:网关改用其他账号,{agent} 仍登录在这个账号上", "Resume: the gateway uses this account first again": "恢复:网关重新优先使用这个账号", "{n} keys": "{n} 个密钥", + "{n} available": "{n} 个可用", + "{n} resting": "{n} 个休息中", + "Show each key": "展开每个密钥", + "Fold these keys into one row": "把这些密钥合并成一行", "Make first": "设为首选", "{name} tries {key} first": "{name} 会先用 {key}", "Resting · {why} · back at {time}": "暂停使用 · {why} · {time} 恢复", @@ -4940,6 +4944,10 @@ const I18N = { "Pause: the gateway uses the other accounts, {agent} stays signed in to this one": "暫停:閘道改用其他帳號,{agent} 仍登入在這個帳號上", "Resume: the gateway uses this account first again": "恢復:閘道重新優先使用這個帳號", "{n} keys": "{n} 個金鑰", + "{n} available": "{n} 個可用", + "{n} resting": "{n} 個休息中", + "Show each key": "展開每個金鑰", + "Fold these keys into one row": "把這些金鑰合併成一列", "Make first": "設為首選", "{name} tries {key} first": "{name} 會先用 {key}", "Resting · {why} · back at {time}": "暫停使用 · {why} · {time} 恢復", @@ -8681,6 +8689,10 @@ const I18N = { "Pause: the gateway uses the other accounts, {agent} stays signed in to this one": "一時停止:ゲートウェイは他のアカウントを使い、{agent} はこのアカウントにサインインしたままです", "Resume: the gateway uses this account first again": "再開:ゲートウェイは再びこのアカウントを最初に使います", "{n} keys": "{n} 個のキー", + "{n} available": "{n} 個が利用可能", + "{n} resting": "{n} 個が休止中", + "Show each key": "キーを個別に表示", + "Fold these keys into one row": "これらのキーを 1 行にまとめる", "Make first": "最初にする", "{name} tries {key} first": "{name} は最初に {key} を試します", "Resting · {why} · back at {time}": "休止中 · {why} · {time} に復帰", @@ -12441,6 +12453,10 @@ const I18N = { "Pause: the gateway uses the other accounts, {agent} stays signed in to this one": "Pausieren: Der Zugangspunkt verwendet die anderen Konten, {agent} bleibt bei diesem angemeldet", "Resume: the gateway uses this account first again": "Fortsetzen: Der Zugangspunkt verwendet dieses Konto wieder zuerst", "{n} keys": "{n} Schlüssel", + "{n} available": "{n} verfügbar", + "{n} resting": "{n} pausiert", + "Show each key": "Jeden Schlüssel zeigen", + "Fold these keys into one row": "Diese Schlüssel in einer Zeile zusammenfassen", "Make first": "An erste Stelle setzen", "{name} tries {key} first": "{name} versucht zuerst {key}", "Resting · {why} · back at {time}": "Pausiert · {why} · zurück um {time}", diff --git a/internal/gui/assets/routing.css b/internal/gui/assets/routing.css index d57f46cb..66abf8be 100644 --- a/internal/gui/assets/routing.css +++ b/internal/gui/assets/routing.css @@ -87,6 +87,16 @@ @keyframes rthit { 20% { translate: -5px; } 40% { translate: 4px; } 60% { translate: -2px; } 80% { translate: 1px; } } .rt-accts .tag { position: absolute; right: 10px; top: -10px; padding: 1px 7px; border-radius: 6px; background: var(--red); color: #fff; font: var(--code-style, normal) 600 10.5px/1.5 var(--code-font, var(--code)); font-stretch: var(--code-stretch, 100%); opacity: 0; transform: translateY(4px) scale(.9); transition: opacity .25s, transform .25s; pointer-events: none; } .rt-accts li.tagged .tag { opacity: 1; transform: none; } +/* a provider's keys for one model, folded into one row (#1319): it opens + to head them, as a group in the group heads its models */ +.rt-accts li.rt-fold { grid-template-columns: 10px minmax(0, 1fr) auto auto; cursor: pointer; user-select: none; } +.rt-accts li.rt-fold em { white-space: normal; text-align: right; } +.rt-accts li.rt-fold .chev { display: inline-flex; color: var(--muted); transition: transform .2s; } +.rt-accts li.rt-fold:hover .chev { color: var(--fg); } +.rt-accts li.rt-fold.open { background: var(--card-2); } +.rt-accts li.rt-fold.open .chev { transform: rotate(180deg); } +.rt-accts li.rt-fold:focus { outline: none; } +.rt-accts li.rt-fold:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); } .rt-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px 20px; flex-wrap: wrap; padding-top: 10px; border-top: 1px solid var(--line); } .rt-cap { flex: 1 1 20em; margin: 0; min-height: 3em; display: flex; align-items: center; font-size: 13px; line-height: 1.45; color: var(--fg); } .rt-cap.in { animation: rtin .4s ease-out; } @@ -125,6 +135,9 @@ .rt-accts:where(.max460) li { grid-template-columns: 10px minmax(0, 1fr); } .rt-accts:where(.max460) em { grid-column: 2; white-space: normal; } .rt-accts:where(.max460) .bar { grid-column: 2; } +.rt-accts:where(.max460) li.rt-fold { grid-template-columns: 10px minmax(0, 1fr) auto; } +.rt-accts:where(.max460) li.rt-fold em { grid-row: 2; text-align: left; } +.rt-accts:where(.max460) li.rt-fold .chev { grid-column: 3; grid-row: 1; } /* narrow (the tray panel): magpie and the agent side by side, the accounts under them, on a lane down their left */ :where(.rt.max560) .rt-stage { grid-template-columns: 1fr 1fr; column-gap: 28px; row-gap: 26px; padding: 16px 0; } @@ -284,6 +297,13 @@ button.rt-session { cursor: pointer; } .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); } +/* a provider's keys summed up in one row (#1319), opened to each */ +.rt-act.rt-keys { cursor: pointer; user-select: none; } +.rt-act.rt-keys .chev { display: inline-flex; align-self: center; margin-left: auto; color: var(--muted); transition: transform .2s; } +.rt-act.rt-keys:hover .chev { color: var(--fg); } +.rt-act.rt-keys.open .chev { transform: rotate(180deg); } +.rt-act.rt-keys:focus { outline: none; } +.rt-act.rt-keys:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); } .rt-livedot { width: 6px; height: 6px; margin-left: 7px; border-radius: 50%; background: var(--green); animation: rtlive 1.8s infinite; --fg: var(--green); } @media (prefers-reduced-motion: reduce) { .rt-livedot { animation: none; } } /* routing groups, and the providers routing over several of their own */ diff --git a/internal/gui/assets/routing.js b/internal/gui/assets/routing.js index 2f1e9f72..1ef19418 100644 --- a/internal/gui/assets/routing.js +++ b/internal/gui/assets/routing.js @@ -825,6 +825,14 @@ let pinned = null; // a past route picked from the strip let rows = new Map(); // id → { li, wire, st, bi, tg, w, rid, up } const subs = new Map(); // a group in the group's way down → its heading { li, wire, key, up } + // A provider's keys serving one model, FOLD_AT or more of them side by + // side, fold into one row that tells of them all (#1319: 53 keys for one + // model were 53 rows alike); opened, it heads them as a group in the + // group does. Which are open is remembered by provider and model. + const FOLD_AT = 3; + const folds = new Map(); // foldKey → { li, wire, st, tg, n, w, ids, up, open, pk } + let keysOpen = new Set(); + try { keysOpen = new Set(JSON.parse(localStorage.getItem("magpie.routingKeysOpen") || "[]")); } catch {} const agents = new Map(); // agent → { node, ic, name, sub, wire } let sets = []; // the account sets on the stage, in the order they came const playing = new Map(); // the routes being played → the gen playing each @@ -925,8 +933,15 @@ const up = s.up && subs.get(s.up); s.wire.setAttribute("d", up ? fromSub(up, b(s.li)) : fromHub(b(s.li))); } + for (const f of folds.values()) { + const up = f.up && subs.get(f.up); + f.wire.setAttribute("d", up ? fromSub(up, b(f.li)) : fromHub(b(f.li))); + } for (const row of rows.values()) { - const up = row.up && subs.get(row.up), a = b(row.li); + // a key folded away has no wire: its fold's is the way to it + if (!row.li.isConnected) { row.wire.removeAttribute("d"); continue; } + const f = row.fold && folds.get(row.fold); + const up = f?.open ? f : row.up && subs.get(row.up), a = b(row.li); row.wire.setAttribute("d", up ? fromSub(up, a) : fromHub(a)); } } @@ -1056,8 +1071,58 @@ function wiresTo(row) { const via = row.w.via || [], out = []; for (let d = 1; d <= via.length; d++) { const s = subs.get(via.slice(0, d).join(">")); if (s) out.push(s.wire); } + const f = row.fold && folds.get(row.fold); + if (f?.open) out.push(f.wire); return [...out, row.wire]; } + // shownRow is where a seat is on the stage: its own row, or the fold it + // is folded into — where a request to it flies, and its failure is tagged + function shownRow(id) { + const row = rows.get(id), f = row?.fold && folds.get(row.fold); + return f && !f.open ? f : row; + } + + // foldKey: keys side by side that fold together — one provider's, for one + // model at one effort, on one way down a group, all fallbacks or none + const foldKey = (w) => [(w.via || []).join(">"), w.provider, w.model || "", w.fixed || "", w.fallback ? 1 : 0, w.aside ? 1 : 0].join("\u0000"); + // what is remembered open: the provider and model, whichever request + const openKey = (w) => w.provider + "/" + (w.model || "") + (w.fixed ? ":" + w.fixed : ""); + function foldNode(key, w) { + let f = folds.get(key); + if (!f) { + const li = el("li", "rt-fold"), b = el("b"), st = el("em"), tg = el("span", "tag"); + const n = el("span", "n"), chev = el("span", "chev"); + chev.append(svg(CHEV, 12, 1.7)); + li.append(el("i", "dot"), b, st, chev, tg); + li.tabIndex = 0; + li.setAttribute("role", "button"); + li.onclick = () => toggleFold(f); + li.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleFold(f); } }; + f = { li, b, st, tg, n, wire: path(), key }; + folds.set(key, f); + } + f.w = w; + f.pk = openKey(w); + f.open = keysOpen.has(f.pk); + f.li.classList.toggle("open", f.open); + f.li.setAttribute("aria-expanded", String(f.open)); + f.li.title = t(f.open ? "Fold these keys into one row" : "Show each key"); + const name = w.fallback ? w.name : w.name || w.provider; + const head = [icon(w.icon || (w.preset ? w.preset : "generic")), el("span", "who", name), " ", f.n, + el("code", "mdl", w.fixed ? `${w.model}:${w.fixed}` : w.model)]; + if (w.fallback) head.push(el("small", "fb", t("fallback"))); + if (f.b.dataset.head !== openKey(w) + (w.fallback ? "|fb" : "")) { f.b.replaceChildren(...head); f.b.dataset.head = openKey(w) + (w.fallback ? "|fb" : ""); } + return f; + } + // toggleFold opens a fold's keys in place, or folds them again: the row + // clicked stays where it is (app.js holds it under the pointer) + function toggleFold(f) { + if (f.open) keysOpen.delete(f.pk); else keysOpen.add(f.pk); + try { localStorage.setItem("magpie.routingKeysOpen", JSON.stringify([...keysOpen])); } catch {} + const rs = staged(); + if (rs.length) steady(() => rebuild(rs, false)); + render(); + } function makeRow(w) { const li = el("li"), b = el("b"); @@ -1098,6 +1163,8 @@ rows = new Map(); for (const s of subs.values()) s.wire.remove(); subs.clear(); + for (const f of folds.values()) f.wire.remove(); + folds.clear(); list.replaceChildren(); for (const a of agents.values()) a.wire.remove(); agents.clear(); @@ -1126,7 +1193,9 @@ for (const w of seated(r)) if (!ids.includes(seat(w))) ids.push(seat(w)); } for (const r of rs) for (const w of [...r.order, ...(r.left || [])]) { wOf.set(seat(w), w); rOf.set(seat(w), r.id); } - const before = new Map([...rows].map(([id, row]) => [id, row.li.getBoundingClientRect().top])); + // where each was, of those on the stage: a key folded away was nowhere + const before = new Map([...[...rows].map(([id, row]) => [id, row.li]), ...[...folds].map(([k, f]) => ["\u0001" + k, f.li])] + .filter(([, li]) => li.isConnected).map(([id, li]) => [id, li.getBoundingClientRect().top])); for (const [id, row] of rows) if (!ids.includes(id)) { row.li.remove(); row.wire.remove(); rows.delete(id); } if (list.querySelector(".idle")) list.replaceChildren(); for (const id of ids) { @@ -1141,11 +1210,29 @@ } // a group in the group heads its models, which it routes by its own // routing, set in under it + // keys side by side that fold together, FOLD_AT or more + const foldOf = new Map(); + let run = []; + const flush = () => { + if (run.length >= FOLD_AT) { + const f = foldNode(foldKey(rows.get(run[0]).w), rows.get(run[0]).w); + f.ids = run; + for (const id of run) foldOf.set(id, f); + } + run = []; + }; + for (const id of ids) { + const w = rows.get(id).w; + if (w.kind === "key" && run.length && foldKey(rows.get(run[0]).w) === foldKey(w)) run.push(id); + else { flush(); if (w.kind === "key") run = [id]; } + } + flush(); const els = [], want = new Set(), infos = rs.flatMap((r) => r.group?.subs || []); let prev = []; for (const id of ids) { - const row = rows.get(id), via = row.w.via || []; - row.li.style.setProperty("--depth", via.length); + const row = rows.get(id), via = row.w.via || [], f = foldOf.get(id); + row.fold = f ? f.key : null; + row.li.style.setProperty("--depth", via.length + (f ? 1 : 0)); row.up = via.length ? via.join(">") : null; via.forEach((g, d) => { const key = via.slice(0, d + 1).join(">"); @@ -1153,10 +1240,20 @@ want.add(key); els.push(subNode(key, infos.find((x) => x.id === g) || { id: g, name: g }, d).li); }); - els.push(row.li); prev = via; + if (f) { + if (!want.has("\u0001" + f.key)) { + want.add("\u0001" + f.key); + f.up = row.up; + f.li.style.setProperty("--depth", via.length); + els.push(f.li); + } + if (!f.open) continue; + } + els.push(row.li); } for (const [k, s] of subs) if (!want.has(k)) { s.li.remove(); s.wire.remove(); subs.delete(k); } + for (const [k, f] of folds) if (!want.has("\u0001" + k)) { f.li.remove(); f.wire.remove(); folds.delete(k); } const mine = new Set(els); for (const li of [...list.children]) if (!mine.has(li)) li.remove(); // moved only when the order changed: moving a node restarts what it plays @@ -1165,16 +1262,17 @@ } // FLIP: from where each was to its new place let moved = false; - for (const [id, row] of rows) { - const dy = before.has(id) ? before.get(id) - row.li.getBoundingClientRect().top : 0; + const placed = [...[...rows].map(([id, row]) => [id, row.li]), ...[...folds].map(([k, f]) => ["\u0001" + k, f.li])].filter(([, li]) => li.isConnected); + for (const [id, li] of placed) { + const dy = before.has(id) ? before.get(id) - li.getBoundingClientRect().top : 0; if (!dy || still()) continue; moved = true; - row.li.style.transition = "none"; - row.li.style.transform = `translateY(${dy}px)`; + li.style.transition = "none"; + li.style.transform = `translateY(${dy}px)`; } if (moved) { void list.offsetWidth; - for (const row of rows.values()) { row.li.style.transition = ""; row.li.style.transform = ""; } + for (const [, li] of placed) { li.style.transition = ""; li.style.transform = ""; } flipUntil = performance.now() + 520; } header(cur, ags.length); @@ -1251,6 +1349,24 @@ if (on) row.wire.style.setProperty("--agent", hueOf(r.agent)); row.wire.classList.toggle("rest", !!resting || !!w.unlisted); } + // a fold tells of its keys together: the one answering or that + // answered, by name, then how many are there to route to and how many + // rest + for (const f of folds.values()) { + const ms = (f.ids || []).map((id) => rows.get(id)).filter(Boolean), has = (m, c) => m.li.classList.contains(c); + const on = ms.find((m) => has(m, "on")), resting = ms.filter((m) => has(m, "rest")).length; + setText(f.n, t("{n} keys", { n: ms.length })); + const parts = []; + if (on) parts.push(`${who(on.w)}: ${on.st.textContent}`); + if (resting < ms.length) parts.push(t("{n} available", { n: ms.length - resting })); + if (resting) parts.push(t("{n} resting", { n: resting })); + setText(f.st, parts.join(" · ")); + f.li.classList.toggle("on", !!on); + f.li.classList.toggle("rest", !!ms.length && resting === ms.length); + f.wire.classList.toggle("live", !!on); + if (on) f.wire.style.setProperty("--agent", on.wire.style.getPropertyValue("--agent")); + f.wire.classList.toggle("rest", !!ms.length && resting === ms.length); + } for (const s of subs.values()) { const lit = [...rows.values()].find((row) => row.li.classList.contains("on") && (row.up === s.key || row.up?.startsWith(s.key + ">"))); s.li.classList.toggle("on", !!lit); @@ -1973,6 +2089,16 @@ h.append(el("span", "", t(w.kind === "key" && !w.routing ? "Smart" : m[0]))); out.push(h); } + // a provider's keys, FOLD_AT or more, under one row that sums them + // up (#1319), opened to each in place + if (w.kind === "key") { + const ks = list.filter((x) => x.w.provider === w.provider && x.w.kind === "key"); + if (ks.length >= FOLD_AT) { + const pk = w.provider + "/*", open = keysOpen.has(pk); + if (ks[0] === a) out.push(keysRow(ks, pk, open, n)); + if (!open) continue; + } + } const row = el("div", "rt-act"); const name = el("div", "nm"); // a provider's own row: its model, the provider's name is the heading @@ -2028,6 +2154,59 @@ patch(acts, out); } + // keysRow sums a provider's keys up over the requests listed: how many, + // how many rest now, and what they were tried and answered together + // The row is kept from one drawing to the next, as the one clicked is + // held where it is on the screen (app.js) while what's under it opens. + const keysRows = new Map(); // provider/* → its row + function keysRow(ks, pk, open, n) { + let row = keysRows.get(pk); + if (!row) { + row = el("div", "rt-act rt-keys"); + row.tabIndex = 0; + row.setAttribute("role", "button"); + const flip = () => { + if (keysOpen.has(pk)) keysOpen.delete(pk); else keysOpen.add(pk); + try { localStorage.setItem("magpie.routingKeysOpen", JSON.stringify([...keysOpen])); } catch {} + steady(() => renderActs(listed())); + }; + row.onclick = flip; + row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); flip(); } }; + keysRows.set(pk, row); + } + row.classList.toggle("open", open); + row.setAttribute("aria-expanded", String(open)); + row.title = t(open ? "Fold these keys into one row" : "Show each key"); + const chev = el("span", "chev"); + chev.append(svg(CHEV, 12, 1.7)); + const name = el("div", "nm"); + name.append(el("b", "", t("{n} keys", { n: ks.length })), chev); + const resting = ks.filter((a) => a.rest && at(a.rest.until) > n).length; + const st = []; + if (resting < ks.length) st.push(t("{n} available", { n: ks.length - resting })); + if (resting) st.push(t("{n} resting", { n: resting })); + const sum = (f) => ks.reduce((x, a) => x + f(a), 0), fails = {}; + for (const a of ks) for (const [k, v] of Object.entries(a.fails)) fails[k] = (fails[k] || 0) + v; + const last = Math.max(0, ...ks.map((a) => a.last)); + const tally = el("div", "tally"); + const bad = Object.entries(fails).map(([k, v]) => `${v} ${failWord(k)}`); + tally.append( + el("span", "", t("tried {n}", { n: sum((a) => a.tried) })), + el("span", "ok", t("answered {n}", { n: sum((a) => a.ok) })), + ...(bad.length ? [el("span", "bad", bad.join(", "))] : []), + ...(last ? [el("span", "", t("last answered {time}", { time: clock(last) }))] : [])); + const kids = [name, el("div", "st" + (resting === ks.length ? " rest" : ""), st.join(" · ")), tally]; + const mdls = [...new Set(ks.flatMap((a) => [...a.models]))]; + if (mdls.length) { + const ms = el("div", "mdls"); + ms.append(...mdls.map((m) => el("code", "mdl", m))); + kids.push(ms); + } + // drawn again only when what it says changes + if (kids.length !== row.children.length || kids.some((k, i) => !k.isEqualNode(row.children[i]))) row.replaceChildren(...kids); + return row; + } + // patch puts a list's new rows in, keeping each old one that is the same // (and has no button, whose handler is the new one's): the accounts list // emptied and filled again each second (its countdowns) had WebKit scroll @@ -2131,7 +2310,7 @@ await until(() => g !== gen || rt().tries.length > i || rt().done); continue; } - const row = rows.get(tryseat(r, r.tries[i])); + const row = shownRow(tryseat(r, r.tries[i])); // a folded key's fold if (!row) { i++; continue; } flying.set(dot, { id: tryseat(r, r.tries[i]), agent: r.agent }); if (!carrier) carrier = bird("req"); @@ -2485,6 +2664,8 @@ rows = new Map(); for (const s of subs.values()) s.wire.remove(); subs.clear(); + for (const f of folds.values()) f.wire.remove(); + folds.clear(); chip.hidden = true; hubText(); list.replaceChildren(el("li", "idle", t(purpose.length || day ? "No requests match these filters." : "No request yet"))); diff --git a/internal/gui/tests/routing-account-order.test.cjs b/internal/gui/tests/routing-account-order.test.cjs index d0f4d8bd..1388ad05 100644 --- a/internal/gui/tests/routing-account-order.test.cjs +++ b/internal/gui/tests/routing-account-order.test.cjs @@ -7,7 +7,8 @@ // plugin's accounts, weighed least used first (another order again); each // provider's rows read in its list's order, and after the subscription's // accounts are dragged (Alt+↓ in its editor) and the plugin's rearranged, -// the stage, shown again, reads in the new orders. +// the stage, shown again, reads in the new orders. The relay's three keys +// fold into one row (#1319); the test opens it to read their order. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); const path = require("node:path"); @@ -78,7 +79,7 @@ function fixture(lang) { } // the stage's rows, as each provider's seats read top to bottom -const seated = (page) => page.locator(".rt-accts > li[title]").evaluateAll((ls) => { +const seated = (page) => page.locator(".rt-accts > li[title]:not(.rt-fold)").evaluateAll((ls) => { const by = {}; for (const l of ls) { const p = l.title.split(/[@#]/)[0]; (by[p] ||= []).push(l.querySelector(".who").textContent); } return by; @@ -96,7 +97,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", page.on("pageerror", (e) => errors.push(e.message)); await page.route("**/*", f.route); await page.goto("http://magpie.test/?view=routing"); - await page.locator(".rt-accts > li[title]").nth(8).waitFor(); + await page.locator(".rt-accts > li.rt-fold").click(); + await page.locator(".rt-accts > li[title]:not(.rt-fold)").nth(8).waitFor(); assert.deepEqual(await seated(page), listed(f), "each provider's rows in its list's order, not by name nor as weighed"); assert.deepEqual(listed(f).antigravity, ["ag-a@x.test", "ag-c@x.test", "ag-b@x.test"]); @@ -117,7 +119,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await page.locator('#nav button[data-view="routing"]').click(); await page.waitForFunction((want) => { const by = {}; - for (const l of document.querySelectorAll(".rt-accts > li[title]")) { const p = l.title.split(/[@#]/)[0]; (by[p] ||= []).push(l.querySelector(".who").textContent); } + for (const l of document.querySelectorAll(".rt-accts > li[title]:not(.rt-fold)")) { const p = l.title.split(/[@#]/)[0]; (by[p] ||= []).push(l.querySelector(".who").textContent); } return JSON.stringify(by) === JSON.stringify(want); }, listed(f), { timeout: 4000 }).catch(() => {}); assert.deepEqual(await seated(page), listed(f), "the stage follows the drag"); diff --git a/internal/gui/tests/routing-keys-fold.test.cjs b/internal/gui/tests/routing-keys-fold.test.cjs new file mode 100644 index 00000000..e4de4a87 --- /dev/null +++ b/internal/gui/tests/routing-keys-fold.test.cjs @@ -0,0 +1,262 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// A provider's keys serving one model fold into one row on the Routing page +// (#1319, tkhs101: 53 keys for agnes-3.0-flash were 53 rows alike). On the +// stage, three keys or more of one provider for one model are one row: its +// name, how many keys, the one answering or that answered by name, how many +// are there to route to and how many rest. It opens in place to head each +// key, wired from it as a group in the group heads its models, and folds +// again; the row clicked stays where it is, and which are open is +// remembered across a reload. Two keys stay two rows, one key one. A +// request that comes flies to the fold and says there which key it went +// to; a key that fails tags the fold; opened, the key's own row lights. +// Under the requests, Accounts and keys sums a provider's keys up the same +// way and opens to each. At 440px nothing in the rows spills. English, +// Chinese, Japanese and German; 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 ago = (s) => new Date(now.getTime() - s * 1e3).toISOString(); +const ahead = (m) => new Date(now.getTime() + m * 60e3).toISOString(); +const pad = (i) => String(i).padStart(2, "0"); +const M = "agnes-3.0-flash"; +const rest = (k) => ({ why: "rate", until: ahead(3), key: k, for: 180e9 }); +const agnes = (i) => `agnes#k${pad(i)}`; +// the reporter's: 53 keys for one model on one provider (two resting), and +// beside them in the group a provider with two keys and one with one +const order = (resting = [5, 10]) => [ + ...Array.from({ length: 53 }, (_, i) => ({ id: agnes(i + 1), provider: "agnes", name: "Agnes", who: `agnes-key-${pad(i + 1)}`, kind: "key", model: M, + ...(resting.includes(i + 1) ? { rest: rest(agnes(i + 1)) } : {}) })), + ...[1, 2].map((i) => ({ id: `relay#r${i}`, provider: "relay", name: "Relay", who: `relay-${i}`, kind: "key", model: M })), + { id: "solo#s1", provider: "solo", name: "Solo", who: "solo-1", kind: "key", model: M }, +]; +const group = { id: "flash", name: "Flash", routing: "", members: ["agnes/" + M, "relay/" + M, "solo/" + M] }; +const route = (id, tries, extra = {}) => ({ id, seq: id, time: ago(60 - id), agent: "codex", model: "flash", provider: "agnes", done: tries.every((x) => x.done), + status: 200, ms: 900, tokens: 1200, group, order: order(), tries, ...extra }); +const first = route(1, [ + { id: agnes(5), model: M, start: ago(59), done: true, status: 429, fail: "rate", ms: 300, rest: rest(agnes(5)) }, + { id: agnes(1), model: M, start: ago(59), done: true, status: 200, ms: 900 }, +]); + +function serve(lang, live) { + return async (request) => { + const url = new URL(request.request().url()); + const json = (data) => request.fulfill({ json: data }); + if (url.pathname === "/boot.js") return request.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` }); + if (url.pathname === "/wails/runtime.js") return request.fulfill({ contentType: "text/javascript", body: "export const Window = {};" }); + if (url.pathname === "/api/state") return json({ agents: [{ id: "codex", name: "Codex", path: "/test/config.toml", fields: [] }], profiles: [], settings: { lang, theme: "light" } }); + if (url.pathname === "/api/gateway/trace") { + if (url.searchParams.get("wait")) { + // live updates: each waits until the test sends one + if (live.queued.length) return json(live.queued.shift()); + live.pending.push(request); + return; + } + return json({ mine: true, now: now.toISOString(), seq: 1, totals: { requests: 1, rerouted: 1, errors: 0 }, routes: [first] }); + } + if (url.pathname === "/api/gateway/history") return json({ cut: false, days: [], routes: [] }); + if (url.pathname === "/api/groups") return json({ groups: [], pools: [] }); + if (url.pathname === "/api/providers") return json({ providers: [], 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 request.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +async function open(t, engine, lang, width = 1100) { + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + t.after(() => browser.close()); + const page = await browser.newPage({ viewport: { width, height: 900 }, reducedMotion: "reduce" }); + page.setDefaultTimeout(5000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const live = { pending: [], queued: [], seq: 1 }; + await page.route("**/*", serve(lang, live)); + const ready = async () => { + await page.goto("http://magpie.test/?view=routing"); + await page.locator(".rt-accts li.rt-fold").first().waitFor(); + // each row's state is filled on the next frame + await page.waitForFunction(() => document.querySelector(".rt-accts li.rt-fold em")?.textContent.trim()); + }; + await ready(); + // send sends the gateway's next trace update + const send = (...routes) => { + const d = { mine: true, now: new Date().toISOString(), seq: ++live.seq, totals: { requests: live.seq }, routes }; + const r = live.pending.shift(); + if (r) r.fulfill({ json: d }); else live.queued.push(d); + }; + const say = (key, vars = {}) => page.evaluate(([lang, key, vars]) => { + const s = (lang !== "en" && I18N[lang]?.[key]) || key; + return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); + }, [lang, key, vars]); + const fold = page.locator(".rt-accts li.rt-fold"); + const keyRow = (who) => page.locator(".rt-accts li", { has: page.locator(".who", { hasText: new RegExp(`^${who}$`) }) }); + const names = () => page.locator(".rt-accts > li:not(.rt-sub)").evaluateAll((lis) => lis.map((li) => li.classList.contains("rt-fold") + ? "fold:" + li.querySelector(".who").textContent : li.querySelector(".who")?.textContent)); + return { page, errors, send, say, fold, keyRow, names, ready }; +} + +// nothing in a row spills or is cut off at its side +const spills = (page, sel) => page.locator(sel).evaluateAll((els) => els.filter((e) => e.scrollWidth > e.clientWidth + 1 || e.getBoundingClientRect().right > document.documentElement.clientWidth) + .map((e) => e.className + ": " + e.textContent.trim().slice(0, 60))); + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + for (const lang of ["en", "zh", "ja", "de"]) { + test(`${engine} ${lang}: 53 keys for one model are one row on the stage, opened in place and remembered`, async (t) => { + const { page, errors, say, fold, keyRow, names, ready } = await open(t, engine, lang); + // folded: one row for Agnes's 53 keys; Relay's two and Solo's one as they were + assert.deepEqual(await names(), ["fold:Agnes", "relay-1", "relay-2", "solo-1"]); + assert.equal(await fold.locator(".n").textContent(), await say("{n} keys", { n: 53 })); + assert.equal(await fold.locator("code.mdl").textContent(), M); + // what they say together: the key that answered, by name, 51 there to route to, 2 resting + const answered = await say("answered this request"); + assert.equal((await fold.locator("em").textContent()).trim(), + [`agnes-key-01: ${answered}`, await say("{n} available", { n: 51 }), await say("{n} resting", { n: 2 })].join(" · ")); + assert.equal(await fold.evaluate((li) => li.classList.contains("on")), true); + assert.equal(await fold.getAttribute("aria-expanded"), "false"); + assert.equal(await fold.getAttribute("title"), await say("Show each key")); + // its wire lights, and no folded key has one drawn + assert.equal(await page.locator(".rt-wires path.live").count() >= 1, true); + assert.equal(await page.locator(".rt-wires path[d]").count(), 1 /* agent */ + 4 /* fold, relay ×2, solo */); + + // opened in place: the row clicked stays where it is, each key under it + const top = await fold.evaluate((li) => li.getBoundingClientRect().top); + await fold.click(); + await keyRow("agnes-key-53").waitFor(); + const shown = await names(); + assert.equal(shown.length, 1 + 53 + 3); + assert.deepEqual(shown.slice(0, 3), ["fold:Agnes", "agnes-key-01", "agnes-key-02"]); + assert.deepEqual(shown.slice(-4), ["agnes-key-53", "relay-1", "relay-2", "solo-1"]); + assert.equal(await fold.evaluate((li) => li.getBoundingClientRect().top), top); + assert.equal(await fold.getAttribute("aria-expanded"), "true"); + assert.equal(await fold.getAttribute("title"), await say("Fold these keys into one row")); + // set in under it, the one that answered lit, the resting dashed, each saying its own + assert.equal(await keyRow("agnes-key-01").evaluate((li) => getComputedStyle(li).getPropertyValue("--depth").trim()), "1"); + assert.equal(await keyRow("agnes-key-01").evaluate((li) => li.classList.contains("on")), true); + assert.equal(await keyRow("agnes-key-05").evaluate((li) => li.classList.contains("rest")), true); + assert.equal((await keyRow("agnes-key-01").locator("em").textContent()).trim(), answered); + // a wire each, from under the fold's heading + assert.equal(await page.locator(".rt-wires path[d]").count(), 1 + 1 + 53 + 3); + const [hx, kx] = await page.evaluate(() => { + const s = document.querySelector(".rt-stage").getBoundingClientRect(), f = document.querySelector(".rt-fold").getBoundingClientRect(); + return [f.left - s.left, document.querySelectorAll(".rt-accts > li")[1].getBoundingClientRect().left - s.left]; + }); + assert.ok(kx > hx, `keys ${kx} set in from the heading ${hx}`); + + // remembered across a reload + await ready(); + await keyRow("agnes-key-53").waitFor(); + assert.equal(await fold.getAttribute("aria-expanded"), "true"); + // and folded again, by the keyboard as well + await fold.focus(); + await page.keyboard.press("Enter"); + await keyRow("agnes-key-53").waitFor({ state: "detached" }); + assert.deepEqual(await names(), ["fold:Agnes", "relay-1", "relay-2", "solo-1"]); + await ready(); + assert.deepEqual(await names(), ["fold:Agnes", "relay-1", "relay-2", "solo-1"]); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: a request that comes flies to the fold, which says which key; opened, the key's row lights`, async (t) => { + const { page, errors, send, say, fold, keyRow } = await open(t, engine, lang); + const em = () => fold.locator("em").textContent().then((s) => s.trim()); + // agnes-key-07 is answering a new request + const r2 = route(2, [{ id: agnes(7), model: M, start: ago(1), done: false }]); + send(r2); + const answering = await say("answering…"); + await page.waitForFunction((want) => document.querySelector(".rt-fold em").textContent.startsWith(want), `agnes-key-07: ${answering}`); + assert.equal(await fold.evaluate((li) => li.classList.contains("on")), true); + // it answers + send({ ...r2, done: true, tries: [{ ...r2.tries[0], done: true, status: 200, ms: 800 }] }); + const answered = await say("answered this request"); + await page.waitForFunction((want) => document.querySelector(".rt-fold em").textContent.startsWith(want), `agnes-key-07: ${answered}`); + // agnes-key-08 fails and rests, agnes-key-09 is asked next: the fold is tagged with the failure, and counts 3 resting + const r3 = { ...route(3, [ + { id: agnes(8), model: M, start: ago(1), done: true, status: 429, fail: "rate", ms: 200, rest: rest(agnes(8)) }, + { id: agnes(9), model: M, start: ago(1), done: false }, + ]), order: order([5, 10]) }; + send(r3); + await page.waitForFunction((want) => document.querySelector(".rt-fold em").textContent.includes(want), await say("{n} resting", { n: 3 })); + assert.equal(await em(), [`agnes-key-09: ${answering}`, await say("{n} available", { n: 50 }), await say("{n} resting", { n: 3 })].join(" · ")); + assert.match(await fold.locator(".tag").textContent(), /^429 · /); + // opened while it answers: agnes-key-09's own row is lit, its fold's too, the resting dashed + await fold.click(); + await keyRow("agnes-key-09").waitFor(); + assert.equal(await keyRow("agnes-key-09").evaluate((li) => li.classList.contains("on")), true); + assert.equal((await keyRow("agnes-key-09").locator("em").textContent()).trim(), answering); + assert.equal(await keyRow("agnes-key-08").evaluate((li) => li.classList.contains("rest")), true); + assert.equal(await fold.evaluate((li) => li.classList.contains("on")), true); + // and the next one comes to agnes-key-12, flown to its own row + send({ ...r3, done: true, tries: [r3.tries[0], { ...r3.tries[1], done: true, status: 200, ms: 500 }] }); + const r4 = route(4, [{ id: agnes(12), model: M, start: ago(0), done: false }]); + send(r4); + await page.waitForFunction(() => [...document.querySelectorAll(".rt-accts li")].find((li) => li.querySelector(".who")?.textContent === "agnes-key-12")?.classList.contains("on")); + assert.ok((await em()).startsWith(`agnes-key-12: ${answering}`)); + assert.equal(await keyRow("agnes-key-09").evaluate((li) => li.classList.contains("on")), false); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: Accounts and keys sums a provider's keys up in one row, opened to each`, async (t) => { + const { page, errors, say } = await open(t, engine, lang); + const acts = page.locator(".rt-acts"); + const sum = acts.locator(".rt-act.rt-keys"); + await sum.waitFor(); + assert.equal(await sum.count(), 1); + assert.equal((await sum.locator(".nm b").textContent()).trim(), await say("{n} keys", { n: 53 })); + assert.equal((await sum.locator(".st").textContent()).trim(), [await say("{n} available", { n: 51 }), await say("{n} resting", { n: 2 })].join(" · ")); + // tried twice in all, answered once, one rate limited + const tally = await sum.locator(".tally span").allTextContents(); + assert.equal(tally[0], await say("tried {n}", { n: 2 })); + assert.equal(tally[1], await say("answered {n}", { n: 1 })); + assert.equal(tally.length, 4); + // Relay's two keys and Solo's one, each its own row + const rows = () => acts.locator(".rt-act:not(.rt-keys) .nm b").allTextContents(); + assert.deepEqual(await rows(), ["relay-1", "relay-2", "solo-1"]); + // wheeled to, as a reader would (the page holds still for a script's scroll) + await page.mouse.move(300, 300); + for (let i = 0; i < 20 && (await sum.boundingBox()).y > 500; i++) { + await page.mouse.wheel(0, 120); + await page.waitForTimeout(150); + } + await page.waitForTimeout(300); + const top = await sum.evaluate((e) => e.getBoundingClientRect().top); + const sb = await sum.boundingBox(); + await page.mouse.click(sb.x + 40, sb.y + 10); + await page.waitForFunction(() => document.querySelectorAll(".rt-acts .rt-act:not(.rt-keys)").length === 56); + const all = await rows(); + assert.deepEqual(all.slice(0, 2), ["agnes-key-01", "agnes-key-02"]); + assert.deepEqual(all.slice(-3), ["relay-1", "relay-2", "solo-1"]); + assert.equal(await sum.getAttribute("aria-expanded"), "true"); + assert.equal(await sum.evaluate((e) => e.getBoundingClientRect().top), top); + // the list is drawn again each second: it stays open + await page.waitForTimeout(1200); + assert.equal(await acts.locator(".rt-act:not(.rt-keys)").count(), 56); + await sum.click(); + await page.waitForFunction(() => document.querySelectorAll(".rt-acts .rt-act:not(.rt-keys)").length === 3); + assert.deepEqual(errors, []); + }); + + test(`${engine} ${lang}: at 440px the fold and its keys fit`, async (t) => { + const { page, errors, say, fold, keyRow } = await open(t, engine, lang, 440); + // the list is laid out by its own width, once it is measured + await page.locator(".rt-accts.max460").waitFor(); + await page.waitForTimeout(300); + assert.deepEqual(await spills(page, ".rt-accts > li > b, .rt-accts > li"), []); + // what it says goes under its name, the arrow beside the name + const [b, em, chev] = await fold.evaluate((li) => [li.querySelector("b"), li.querySelector("em"), li.querySelector(".chev")].map((e) => e.getBoundingClientRect()).map((r) => ({ top: r.top, bottom: r.bottom, right: r.right }))); + assert.ok(em.top >= b.bottom - 1, `em ${em.top} under the name ${b.bottom}`); + assert.ok(chev.top < b.bottom, "the arrow is beside the name"); + assert.ok((await fold.locator("em").textContent()).includes(await say("{n} resting", { n: 2 }))); + await fold.click(); + await keyRow("agnes-key-53").waitFor(); + assert.deepEqual(await spills(page, ".rt-accts > li"), []); + assert.deepEqual(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth), true); + assert.deepEqual(errors, []); + }); + } +}