gui: protect unsaved edits and confirm destructive actions (#820)
* gui: edited forms ask before they are discarded, removals ask before they take effect, and the header keeps its first layout — Cancel, Escape, the backdrop and a page change could throw away a provider's draft, and several account, key, plugin and library removals took effect at once. Modified provider, settings, library and routing forms now ask before discarding; unmodified forms still close without asking, and refresh or closing the browser tab uses its leave confirmation. Removal and discard confirmations are in-app dialogs over the intact editor, centered, with Cancel focused first, Tab kept inside, Escape canceling and focus returned afterwards; destructive buttons have readable foregrounds in light and dark themes. Text and connection settings have Save/Cancel rather than saving on blur; simple switches still save at once. Editors associate labels and help with fields, make the background inert while open and keep focus through redraws; navigation keeps browser back/forward history and its current-page state, errors remain until dismissed or replaced, and focus and validation can scroll into view. Mobile settings tabs have larger targets, fields use 16px text and page zoom stays available. layout.js applies mode and platform before the header paints, theme.js applies the saved text size before styles, and mode-specific window controls stay hidden while app.js downloads. zh/ja/de strings added. routing-shape's global .rt-day selector selected the hidden By session button on WebKit; its history click now stays in .rt-days. ux-safety covers unchanged and modified closes, canceled and confirmed removals, Escape, backdrop, modal focus, light/dark centering and button contrast, explicit saves, browser history and sync-form discard. Rebased onto upstream/main2f183c4c. Chromium and WebKit each pass 31 related browser tests in en and zh; gui-ja and gui-de pass all 6 browser and translation-parity tests. go vet -tags nogui ./..., go build -tags nogui, go test -tags nogui ./internal/gui, JavaScript syntax checks and git diff --check pass. Co-authored-by: XYenon <register@xyenon.bid> * gui: edited providers, routing groups and library forms ask before their drafts are discarded, and only immediate removals ask before taking effect — Settings keep saving on Enter and blur, switches save at once, and the reader's scroll position is restored after an unasked-for scroll. Header rows, fallback models, group members, patterns and rules stay ordinary reversible draft edits. The confirmation dialog keeps its own readable destructive colors; the window's text selection, muted and faint colors, timed status messages, replaceState navigation and iOS viewport policy stay as before. The keyboard scroll assertion is restored, and click-scroll's date buttons are scoped to .rt-days .rt-day so WebKit cannot pick a hidden request-grouping button. The safety tests cover dirty editor closes and page changes, immediate provider deletion, labels, modal focus, Escape and backdrop cancellation, with the withdrawn settings, history and persistent-error assertions removed. First-paint layout and the saved theme/text size remain early. Co-authored-by: XYenon <register@xyenon.bid> * gui: old WebKit gets its layout before the header paints, and browser tests answer the new discard and removal questions — compat.js runs before layout.js, whose viewport setup uses ES5; the confirmation uses vh rather than Safari 15.4 dynamic viewport units. Opening a provider from its address keeps the editor pending until its page loads, and fields keep their own nested labels when Names & levels is reopened. An empty model preference map is compared as Save sends it, so opening model settings without changing anything closes without a question. Removal tests assert nothing is sent before confirmation; draft tests answer Discard and retain their Save and Cancel assertions. Plugin migration uses the reader’s wheel rather than a scripted scroll, keeping every scroll assertion. Targeted WebKit regressions pass 40/40, Chromium regressions pass 41/41, and the model-setting and editor-safety checks pass in both engines. go vet -tags nogui ./..., go build -tags nogui, JavaScript syntax checks and git diff --check pass. Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 Co-authored-by: XYenon <register@xyenon.bid> * gui tests: the upstream model list keeps folding with the new discard question — models-fold answers Discard only after picking another model; the short list and an unchanged folded list still close without asking. ux-safety waits for its Relay row rather than matching loading placeholders. Rebased onto upstream/mainc353f7bd, including the new model-list expansion. models-fold passes in WebKit and Chromium, as do the English and Chinese editor-safety checks; the related model drafts and first-paint phone tests pass on WebKit. Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 Co-authored-by: XYenon <register@xyenon.bid> * gui: an editor redraw keeps its removal confirmation and menus interactive — Only a fresh modal makes the existing background inert, and closing restores exactly the elements it changed. A redraw no longer disables a confirmation or popup appended over the editor; elements already inert and overlays added later retain their own state. The original background is retained if a new editor interrupts the closing animation. ux-safety redraws Relay twice under its Remove confirmation, clicks Cancel, and verifies no deletion was sent; it also picks a menu item after an editor redraw and checks background restoration. The regression reproduced on WebKit before the fix. All 8 English and Chinese safety checks pass on WebKit and Chromium. The related WebKit removal, draft, routing and phone checks pass, with account-arrange passing separately after a detached-element race in the concurrent run. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass. Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 Co-authored-by: XYenon <register@xyenon.bid> * gui tests: editor safety uses the page's boot preferences, not its settings — gateway in boot.js is now the gateway-mode flag, whereas settings.gateway is the gateway's URL. Spreading settings into the fixture boot data mistook the URL for gateway mode and redirected a dirty editor's attempted page change back to Providers. The fixture now emits the same language, theme, text size and web fields as the server. The 8 English and Chinese editor-safety tests pass on Chromium and WebKit; gateway-mode, plugin middleware and plugin-market pass on WebKit, and phone-web passes separately after a scroll race under concurrent load. Rebased onto upstream/main2f135ed4with gateway-mode navigation and initialization retained. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, JavaScript syntax and git diff --check pass. Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 Co-authored-by: XYenon <register@xyenon.bid> * gui: upstream's new removals and fields follow the editor-safety rules too — Audited GUI changes from the original PR base2f183c4cthrough upstream/main9debc583, including earlier rebases. Bulk group removal uses the in-app consequence dialog rather than a second click, and clearing middleware options asks before deleting saved options. Cancel sends nothing and keeps the selection or editor. Custom group context, Settings' compact threshold, the inline key-weight input and middleware JSON have accessible names; the JSON note/error and group context hint are linked, and invalid input is exposed. The copy-out-of-date skill action is a keyboard button supporting Enter and Space. A skill's immediately saved per-agent transfer choices update the modal baseline, so Done, Escape and navigation no longer ask to discard already saved changes. Settings save behavior, draft-only removals, scroll guard, viewport and themes stay intact. Chinese, Japanese and German consequence strings added; obsolete bulk-removal armed styles and strings removed. The six affected browser files pass across Chromium and WebKit in English and Chinese (54 tests after scoping compact-at to its provider rather than skeletons). Additional provider price, key import/weight, group member/first-token, caller-key, purpose and gateway-mode regressions pass on WebKit; key-weight also passes in Chromium, with a concurrent status-message race passing separately. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass. The Chinese bulk confirmation was rendered and inspected on WebKit. Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 Co-authored-by: XYenon <register@xyenon.bid> * gui: new upstream key removals and nested group jumps keep the editor-safety rules — Bulk deletion of failing, off and matching keys uses the app's consequence dialog, with Cancel and Escape sending nothing and a redraw leaving its buttons interactive. The shared group-editor entry asks before replacing a dirty draft, and nested navigation waits for acceptance before upstream's explicit scroll. Codex's Sign out uses its existing confirmation once. Group and key filters have accessible names. A focused model-price input is included in leave protection before its change/blur parser runs, and reverting the raw value is clean. Settings saving, scroll protection, draft-only removal, viewport and themes retain their upstream behavior. The affected flows pass in Chromium and WebKit, English and Chinese; Names & levels also passes in Japanese and German. The price fixture focuses before filling because the previous blur normalizes sibling fields, a race reproduced on upstream. go vet -tags nogui ./..., go test -tags nogui ./internal/gui, go build -tags nogui, JavaScript syntax and git diff --check pass. The Chinese bulk-key dialog was rendered and inspected on WebKit. Co-authored-by: XYenon <register@xyenon.bid> Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 * gui tests: Codex's newly removable active account waits for the consequence question (#820) — The latest upstream multiaccount Remove path uses accountAction's immediate-removal dialog. Its test now verifies that the first click sends nothing, Cancel keeps the account, and accepting sends exactly one login/forget request. The original account, status and translation assertions remain. Rebased onto upstream/main 8d3cf5f8; the final Codex, nested-group, bulk-key, focused-price and editor-safety tests pass 48/48 across Chromium and WebKit in English and Chinese. Upstream's new quota, skill-group, hit-rate and translation checks pass; search-first's detached-element failure reproduces on isolated upstream. The complete WebKit suite ran 1413 tests before this final upstream delta, with 1289 passes and 124 failures; failing files were compared with upstream or rerun separately. Syntax and diff checks, go vet and the nogui build pass. Co-authored-by: XYenon <register@xyenon.bid> Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 * gui: the Sessions agent strip reports which reused button is selected (#820, #929) — Rebased onto upstream/main1330b966, retaining the strip's own scrolling, visible selected agent and preserved buttons. Each redraw updates aria-pressed for the selected agent and clears it for the previous selection. The regression checks All initially, the unselected alternatives, then Zed selected with All cleared; on unmodified upstream it fails with null instead of true. Both strip files pass 8/8 across Chromium and WebKit in English and Chinese, including narrow and wide layout and scroll assertions. The latest ChatGPT API, Codex and editor-safety browser checks pass 32/32. Final GUI, Agent, Provider and Gateway Go checks, go vet -tags nogui ./..., Linux nogui and Windows builds, syntax and diff checks pass. Linux native GUI build is blocked by the orb's GTK 4.8 lacking APIs used by Wails beta.24. The complete WebKit run finished with 1289 passes and 124 failures; all failing files were checked against upstream or rerun separately, with details retained in the review artifacts. The full Go suite's sole sessions rewrite failure also reproduces on upstream. Co-authored-by: XYenon <register@xyenon.bid> Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 * gui: closing editors immediately give clicks back to the page (#820, #944) — Restore the background when the close animation starts and protect it again when an editor reopens. Both New group buttons and the model entry ask before replacing a dirty routing draft; creating a group from a provider waits for navigation to be accepted. The GUI shell reference describes these transitions. The real mouse click during closing fails without the change in all four engine/language combinations, with null instead of received. The unguarded New group and model entries fail waiting for the discard question; the old model navigation creates a routing draft even after Cancel. ux-safety, provider-remove, click-scroll and group-new-head pass 50/50 in Chromium and WebKit, English and Chinese. Closing/reopening also passes five additional rounds, 20/20. After the final upstream translation and TUI additions, i18n-keys, Japanese/German, ux-safety and group-new-head pass 23/23. Ten Chromium update-wait runs have no no-click-scroll failure; two fail on the Restarting status assertion, also reproduced on upstream. The complete WebKit run finished with 1,307 passes and 141 failures across 1,448 tests, with no skips or cancellations. It is not green: failures in 63 of its 70 failing files also occur on upstream; the other seven files pass separate PR reruns. The complete suite preceded the final upstream translation deduplication, whose resolved keys and values are unchanged, and the TUI/docs additions. go vet and the Linux GUI build with the Makefile's gtk3 tag, Linux nogui and Windows builds, GUI/TUI Go tests, syntax and diff checks pass. Full nogui Go tests are not green: the sessions same-size rewrite check and the final run's WebDAV slow-transfer stall both reproduce on upstream with -race -count=20 under an isolated HOME with pinned caches. Co-authored-by: XYenon <register@xyenon.bid> Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
40 files changed
+929
-136
No files matched your search
@@ -34,6 +34,16 @@ three.
|
||||
- The page must work in Chromium (Windows' WebView2) and WebKit (macOS, and WebKitGTK on Linux). GUI tests run in both engines.
|
||||
- Tests never touch a live agent config. The package's `TestMain` runs under `testenv`'s home of its own. Playwright tests serve `assets/` with isolated `/api` fixtures.
|
||||
|
||||
`openModal` makes the background inert when an editor opens fresh, leaving
|
||||
confirmations and menus interactive through redraws. `closeModal` releases
|
||||
that background as soon as the close animation starts, so clicks reach the
|
||||
page while the editor fades away. Opening an editor during that animation
|
||||
protects the background again. `ux-safety.test.cjs` checks these transitions.
|
||||
Routing's New group buttons share `newGroup`, which asks before replacing a
|
||||
dirty group draft; Cancel retains the draft and Discard opens the new editor.
|
||||
`newGroupWith` applies the same guard when creating a group from a model and
|
||||
waits for `show` to accept navigation before creating its draft.
|
||||
|
||||
## Verification
|
||||
|
||||
```sh
|
||||
|
||||
@@ -162,6 +162,9 @@ body.window.mac .top { padding-left: calc(96px / var(--zoom, 1)); height: calc(5
|
||||
body.window.mac .seg { top: calc(50% + 1px / var(--zoom, 1)); } /* 50% centres in the padding box; the 2px top padding puts the flex row a pixel lower */
|
||||
.brand { display: flex; align-items: center; gap: 6px; font-weight: 600; letter-spacing: .01em; }
|
||||
body.window:not(.mac):not(.linux):not(.web) .brand { display: none; } /* Windows' title bar above already shows the icon and the name; a browser tab has no title bar of the app's */
|
||||
/* app.js removes these controls later; keep them out of the first paint too. */
|
||||
body.window :is(#open, #openMain, #quit),
|
||||
body:not(.window.linux) #winclose { display: none; }
|
||||
/* Linux: the header is the title bar, so it closes the window too — round,
|
||||
as GNOME draws its close button, and set a little apart from the icons */
|
||||
.icon.winclose { align-self: center; width: 22px; height: 22px; margin-left: 8px; border-radius: 50%; background: var(--pill); color: var(--fg-2); }
|
||||
@@ -1845,7 +1848,6 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
|
||||
.accts .keys-tools .grow { flex: 1; }
|
||||
.accts .keys-tools .keys-filter { flex: 1 1 220px; max-width: 320px; height: 26px; padding: 0 8px; font-size: 12px; }
|
||||
.accts .keys-tools .keys-count { white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
.accts .keys-tools .keys-remove[data-armed] { color: var(--red); background: var(--red-soft); }
|
||||
.accts .keys-tools + .acc { border-top: 0; }
|
||||
.accts > .keys-tools:first-child { border-top: 0; }
|
||||
.accts > .keys-none + .acc, .accts > .keys-rest + .acc { border-top: 1px solid var(--line-2); }
|
||||
@@ -2545,6 +2547,17 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after {
|
||||
transition: background-color .35s var(--ease), background .35s var(--ease), color .35s var(--ease), border-color .35s var(--ease), box-shadow .35s var(--ease) !important;
|
||||
}
|
||||
|
||||
/* Confirmation uses the top layer so an open editor remains intact underneath. */
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
.action-confirm { --confirm-fill: #c73535; --confirm-fg: #ffffff; position: fixed; inset: 0; margin: auto; max-height: calc(100vh - 32px); overflow-y: auto; width: min(420px, calc(100vw - 32px)); padding: 22px; border: 1px solid var(--line); border-radius: 14px; background: var(--card); color: var(--fg); box-shadow: 0 24px 60px rgba(0, 0, 0, .32); font: inherit; }
|
||||
.action-confirm .text.danger-fill { background: var(--confirm-fill); color: var(--confirm-fg); }
|
||||
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .action-confirm { --confirm-fill: var(--red); --confirm-fg: #14142a; } }
|
||||
:root[data-theme="dark"] .action-confirm { --confirm-fill: var(--red); --confirm-fg: #14142a; }
|
||||
.action-confirm::backdrop { background: rgba(0, 0, 0, .32); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
|
||||
.action-confirm h2 { margin: 0 0 12px; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.action-confirm p { margin: 0 0 20px; color: var(--fg-2); line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
|
||||
.action-confirm .bar { display: flex; align-items: center; gap: 10px; margin: 0; }
|
||||
@media (pointer: coarse) { .action-confirm button { min-height: 44px; min-width: 44px; } }
|
||||
/* ---------- modal ---------- */
|
||||
.modal {
|
||||
position: fixed; inset: 0; z-index: 20;
|
||||
|
||||
+247
-54
@@ -3,32 +3,18 @@ const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => document.querySelectorAll(s);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const mode = params.get("mode") || "window";
|
||||
document.body.classList.add(mode);
|
||||
// The panel loads Wails for ExecJS readiness, but stays attached to the tray.
|
||||
if (mode === "panel") $("header.top").style.setProperty("--wails-draggable", "no-drag");
|
||||
// `magpie web`: the page in a browser tab, with no window of the app's
|
||||
// around it — it opens links itself, and what is the desktop's is left out
|
||||
const web = !!window.bootPrefs?.web;
|
||||
if (web) document.body.classList.add("web");
|
||||
// layout.js applies the mode and platform before the header can paint.
|
||||
// Gateway mode (gatewaymode.go): magpie web on a server that is only the
|
||||
// gateway for other computers' agents leaves out this computer's agents'
|
||||
// pages — Agents, Sessions, Library — and their settings; Settings ›
|
||||
// General turns it off, and they are back.
|
||||
let gatewayMode = web && !!window.bootPrefs?.gateway;
|
||||
const GATEWAY_HIDES = ["agents", "sessions", "library"];
|
||||
// iOS zooms the page into a field it focuses whose text is under 16px, and
|
||||
// leaves it zoomed; at most 1 stops that, and Safari still lets a pinch zoom
|
||||
if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) {
|
||||
document.querySelector('meta[name="viewport"]')?.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover");
|
||||
}
|
||||
// The Mac window draws its title bar inside the page (the traffic lights);
|
||||
// on Linux the page's header is the whole title bar (plainTitlebar), so it
|
||||
// has the name, the close button and a double-click to maximise.
|
||||
if (!web && /^Mac/.test(navigator.platform)) document.body.classList.add("mac");
|
||||
if (!web && /^Linux/.test(navigator.platform)) document.body.classList.add("linux");
|
||||
// Windows: its own UI faces by name, Chinese in Microsoft YaHei UI rather
|
||||
// than whatever the webview falls back to for it
|
||||
if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win");
|
||||
// The window is dragged by its header, and only where the header says so
|
||||
// (--wails-draggable), so the tabs and buttons in it stay plain clicks.
|
||||
// Outside the app — a browser on the gateway's page, or on `magpie web` —
|
||||
@@ -36,14 +22,8 @@ if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win
|
||||
// console, Jorben on Discord).
|
||||
// The panel also needs runtime readiness to drain Go's queued ExecJS calls.
|
||||
const winRuntime = !web ? import("/wails/runtime.js").catch(() => null) : Promise.resolve(null);
|
||||
if (params.get("theme")) document.documentElement.dataset.theme = params.get("theme");
|
||||
// the saved language and theme from boot.js, so the first paint is in them
|
||||
if (window.bootPrefs) {
|
||||
const b = window.bootPrefs;
|
||||
if (!params.get("theme") && b.theme && b.theme !== "system") document.documentElement.dataset.theme = b.theme;
|
||||
setLocale(b.lang);
|
||||
document.documentElement.style.setProperty("--zoom", b.web ? 1 : (b.textSize || 100) / 100);
|
||||
}
|
||||
// theme.js already applied the theme and text size before the first paint.
|
||||
if (window.bootPrefs) setLocale(window.bootPrefs.lang);
|
||||
|
||||
let state = { agents: [], profiles: [], catalog: "", settings: {} };
|
||||
let prefs = null; // the settings page: theme, lang, version, dir, gateway
|
||||
@@ -211,6 +191,7 @@ function status(msg, kind = "", ms = kind === "err" ? 8000 : 3500) {
|
||||
s.textContent = msg;
|
||||
s.title = msg;
|
||||
s.className = "status " + kind;
|
||||
s.setAttribute("role", kind === "err" ? "alert" : "status");
|
||||
// with a dialog open the footer is under its scrim: the pill floats over both
|
||||
const m = $("#modal");
|
||||
s.classList.toggle("lift", !!msg && !m.hidden && !m.classList.contains("out"));
|
||||
@@ -1187,8 +1168,8 @@ function agentsLead(list) {
|
||||
}
|
||||
}
|
||||
|
||||
function addProviderFromAgents() {
|
||||
show("providers");
|
||||
async function addProviderFromAgents() {
|
||||
if (!await show("providers")) return;
|
||||
$("#addProvider")?.click();
|
||||
}
|
||||
|
||||
@@ -4475,7 +4456,10 @@ function renderProviders() {
|
||||
// a provider on is listed, and tried, in the order the user puts it in
|
||||
// (#499): its logo is the handle, as an agent row's is
|
||||
row.append(p.off ? icon(p.icon || "generic") : providerHandle(p, row), who, uses, key, providerSwitch(p), chev);
|
||||
row.onclick = () => { editing = open ? null : p.id; draft = null; renderProviders(); }; // the preset sheet stays as it is under the dialog
|
||||
row.onclick = async () => {
|
||||
if (providerDirty() && !(await confirmDiscard())) return;
|
||||
editing = open ? null : p.id; draft = null; renderProviders();
|
||||
}; // the preset sheet stays as it is under the dialog
|
||||
list.append(row);
|
||||
if (open) dialog = renderEditor(p);
|
||||
}
|
||||
@@ -4717,7 +4701,7 @@ function askForgetSaved(x) {
|
||||
go.classList.add("busy");
|
||||
for (const [i, user] of x.users.entries()) {
|
||||
const last = i === x.users.length - 1;
|
||||
if (!await accountAction("login/forget", { agent: x.agent, user }, last ? t("{user} removed", { user: x.users.join(", ") }) : "")) {
|
||||
if (!await accountAction("login/forget", { agent: x.agent, user }, last ? t("{user} removed", { user: x.users.join(", ") }) : "", true)) {
|
||||
go.disabled = false;
|
||||
go.classList.remove("busy");
|
||||
return;
|
||||
@@ -4784,7 +4768,7 @@ function askSignOutLogin(a, l) {
|
||||
e.stopPropagation();
|
||||
go.disabled = true;
|
||||
go.classList.add("busy");
|
||||
if (await accountAction("login/forget", { agent: a.agent, user: l.user }, t("{agent} signed out of {user}", { agent: a.agentName, user: l.user }))) closeConfirmAsk();
|
||||
if (await accountAction("login/forget", { agent: a.agent, user: l.user }, t("{agent} signed out of {user}", { agent: a.agentName, user: l.user }), true)) closeConfirmAsk();
|
||||
else go.disabled = false;
|
||||
};
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
@@ -5211,7 +5195,16 @@ function segs(items, current, onPick) {
|
||||
let key = kind + "#" + ++segsMade;
|
||||
for (const [id, name] of items) {
|
||||
const b = el("button", "opt" + (id === current ? " on" : ""), name);
|
||||
b.onclick = () => { for (const x of box.querySelectorAll(".opt")) x.classList.toggle("on", x === b); slide(box, key); onPick(id); };
|
||||
b.type = "button";
|
||||
b.setAttribute("aria-pressed", String(id === current));
|
||||
b.onclick = () => {
|
||||
for (const x of box.querySelectorAll(".opt")) {
|
||||
x.classList.toggle("on", x === b);
|
||||
x.setAttribute("aria-pressed", String(x === b));
|
||||
}
|
||||
slide(box, key);
|
||||
onPick(id);
|
||||
};
|
||||
box.append(b);
|
||||
}
|
||||
queueMicrotask(() => { // once it is in the page
|
||||
@@ -6168,26 +6161,27 @@ function morePluginsTile(q) {
|
||||
}
|
||||
|
||||
// openPlugins: the Plugins tab, looking for q when there is one
|
||||
function openPlugins(q) {
|
||||
async function openPlugins(q) {
|
||||
if (mode !== "window") { api("window/main?view=plugins", {}).catch(() => {}); return; }
|
||||
show("plugins");
|
||||
if (!await show("plugins")) return;
|
||||
window.pluginQuery?.(q || "");
|
||||
}
|
||||
|
||||
// pluginSignIn: a plugin's provider signed in to as every subscription is,
|
||||
// in the Providers add sheet
|
||||
function pluginSignIn(id) {
|
||||
async function pluginSignIn(id) {
|
||||
if (!await show("providers")) return;
|
||||
closeModal();
|
||||
show("providers");
|
||||
adding = true; editing = null; draft = null; presetQuery = "";
|
||||
renderProviders();
|
||||
startSignIn(id);
|
||||
}
|
||||
|
||||
// openProvider: a provider opened in the Providers tab
|
||||
function openProvider(id) {
|
||||
async function openProvider(id) {
|
||||
if (view === "providers" && providerDirty() && !(await confirmDiscard())) return;
|
||||
if (!await show("providers")) return;
|
||||
closeModal();
|
||||
show("providers");
|
||||
adding = false; editing = id; draft = null; presetQuery = "";
|
||||
renderProviders();
|
||||
syncURL();
|
||||
@@ -6270,11 +6264,26 @@ function tile(pr) {
|
||||
return b;
|
||||
}
|
||||
|
||||
let fieldIDs = 0;
|
||||
function field(label, control, hint) {
|
||||
const l = el("label", "", label);
|
||||
const wrap = el("div");
|
||||
wrap.append(control);
|
||||
if (hint) wrap.append(el("div", "hint", hint));
|
||||
const inputSelector = "input, select, textarea";
|
||||
const inputs = control.matches(inputSelector) ? [control] : [...control.querySelectorAll(inputSelector)];
|
||||
if (label && inputs.length) {
|
||||
for (const input of inputs) {
|
||||
if (!input.id) input.id = "field-" + ++fieldIDs;
|
||||
if (!input.closest("label") && !input.hasAttribute("aria-label") && !input.hasAttribute("aria-labelledby")) input.setAttribute("aria-label", label);
|
||||
}
|
||||
l.htmlFor = inputs[0].id;
|
||||
}
|
||||
if (hint) {
|
||||
const help = el("div", "hint", hint);
|
||||
help.id = "field-help-" + ++fieldIDs;
|
||||
wrap.append(help);
|
||||
for (const input of inputs) input.setAttribute("aria-describedby", [input.getAttribute("aria-describedby"), help.id].filter(Boolean).join(" "));
|
||||
}
|
||||
return [l, wrap];
|
||||
}
|
||||
// decimalOf is a number as typed in a price box, a comma taken for the
|
||||
@@ -6311,15 +6320,91 @@ function input(value, placeholder, type = "text") {
|
||||
i.placeholder = placeholder || "";
|
||||
i.spellcheck = false;
|
||||
i.autocomplete = "off";
|
||||
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") cancelEdit(); };
|
||||
i.onkeydown = (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Escape") {
|
||||
if (editing !== null || importing || importingApps) cancelEdit();
|
||||
}
|
||||
};
|
||||
return i;
|
||||
}
|
||||
// keep: leaving the page, a sign-in under way in the editor goes on; the
|
||||
// editor put away (Cancel, Escape, a click outside) takes it with it (#526)
|
||||
function cancelEdit(keep) {
|
||||
let providerDraftRef = null, providerDraftBase = "";
|
||||
function providerDraftValue() {
|
||||
return JSON.stringify({ ...draft, headers: headersOf(draft?.headers), modelPrefs: modelPrefsOfDraft() });
|
||||
}
|
||||
function providerDirty() {
|
||||
// Price boxes parse on change; a page closed while typing hasn't blurred one yet.
|
||||
const active = document.activeElement;
|
||||
return !!draft && draft === providerDraftRef && (providerDraftValue() !== providerDraftBase ||
|
||||
(active?.matches(".editor .mprice input") && active.value !== active.defaultValue));
|
||||
}
|
||||
let confirmationPending = null;
|
||||
function confirmAction(title, message, action) {
|
||||
if (confirmationPending) return Promise.resolve(false);
|
||||
const dialog = el("dialog", "action-confirm");
|
||||
dialog.setAttribute("role", "alertdialog");
|
||||
dialog.setAttribute("aria-labelledby", "actionConfirmTitle");
|
||||
dialog.setAttribute("aria-describedby", "actionConfirmMessage");
|
||||
const heading = el("h2", "", title); heading.id = "actionConfirmTitle";
|
||||
const description = el("p", "", message); description.id = "actionConfirmMessage";
|
||||
const bar = el("div", "bar");
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
const accept = el("button", "text primary danger-fill", action);
|
||||
cancel.type = accept.type = "button";
|
||||
bar.append(el("span", "grow"), cancel, accept);
|
||||
dialog.append(heading, description, bar);
|
||||
const previousFocus = document.activeElement;
|
||||
document.body.append(dialog);
|
||||
return new Promise((resolve) => {
|
||||
confirmationPending = dialog;
|
||||
const finish = (accepted) => {
|
||||
dialog.close(); dialog.remove(); confirmationPending = null;
|
||||
if (previousFocus?.isConnected) previousFocus.focus({ preventScroll: true });
|
||||
resolve(accepted);
|
||||
};
|
||||
cancel.onclick = () => finish(false);
|
||||
accept.onclick = () => finish(true);
|
||||
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(false); });
|
||||
dialog.addEventListener("click", (event) => {
|
||||
const rect = dialog.getBoundingClientRect();
|
||||
if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) finish(false);
|
||||
});
|
||||
// Keep the underlying editor's Escape handlers from seeing this dialog.
|
||||
dialog.addEventListener("keydown", (event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key !== "Tab") return;
|
||||
const boundary = event.shiftKey ? cancel : accept;
|
||||
if (document.activeElement !== boundary) return;
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? accept : cancel).focus();
|
||||
});
|
||||
// Open after the triggering Escape has finished, so it cannot cancel this dialog.
|
||||
requestAnimationFrame(() => {
|
||||
dialog.showModal();
|
||||
cancel.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
function confirmRemoval(name, consequence = "This removes the selected item. Continue?") {
|
||||
return confirmAction(t("Remove {name}?", { name }), t(consequence), t("Remove"));
|
||||
}
|
||||
function confirmDiscard() {
|
||||
return confirmAction(t("Discard"), t("Discard unsaved changes? Your changes will be lost."), t("Discard"));
|
||||
}
|
||||
async function cancelEdit(keep) {
|
||||
if ((providerDirty() || ((importing || importingApps) && modalFormDirty())) && !(await confirmDiscard())) return false;
|
||||
if (keep !== true) dropSigningIn(editing);
|
||||
editing = null; draft = null; importing = null; importingApps = null; renderProviders();
|
||||
return true;
|
||||
}
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (providerDirty() || window.routingDirty?.() || ((view === "library" || importing || importingApps) && modalFormDirty())) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
// priceTypedError: a model with no list price given only part of its
|
||||
// price in Names & levels (its input and output both needed) holds the
|
||||
@@ -6844,6 +6929,45 @@ function fromRect(from, to) {
|
||||
const dx = from.left + from.width / 2 - (to.left + to.width / 2), dy = from.top + from.height / 2 - (to.top + to.height / 2);
|
||||
return `translate(${dx}px, ${dy}px) scale(${s})`;
|
||||
}
|
||||
let modalReturnFocus = null, modalFormBase = "";
|
||||
function formValues(box) {
|
||||
return JSON.stringify([
|
||||
[...box.querySelectorAll("input:not([type=search]), textarea, select")].map((input) =>
|
||||
[input.type, input.type === "checkbox" || input.type === "radio" ? input.checked : input.value]),
|
||||
[...box.querySelectorAll(".segs button")].map((button) => button.getAttribute("aria-pressed")),
|
||||
]);
|
||||
}
|
||||
function modalFormValues() { return formValues($("#modal")); }
|
||||
function modalFormDirty() {
|
||||
return !$("#modal").hidden && !!modalFormBase && modalFormValues() !== modalFormBase;
|
||||
}
|
||||
function markModalSaved() { modalFormBase = modalFormValues(); }
|
||||
let modalBackgroundElements = [];
|
||||
function modalBackground(inert) {
|
||||
if (inert) {
|
||||
// A fresh editor may interrupt its closing animation; keep the original background.
|
||||
if (modalBackgroundElements.length) return;
|
||||
const modal = $("#modal");
|
||||
modalBackgroundElements = [...document.body.children].filter((element) =>
|
||||
element !== modal && element.tagName !== "SCRIPT" && !element.inert);
|
||||
for (const element of modalBackgroundElements) element.inert = true;
|
||||
} else {
|
||||
for (const element of modalBackgroundElements) element.inert = false;
|
||||
modalBackgroundElements = [];
|
||||
}
|
||||
}
|
||||
$("#modal").addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Tab") return;
|
||||
const modal = e.currentTarget;
|
||||
const controls = [...modal.querySelectorAll('button, input, select, textarea, a[href], [tabindex="0"]')]
|
||||
.filter((element) => !element.disabled && element.getClientRects().length && !element.closest("[inert]"));
|
||||
if (!controls.length) { e.preventDefault(); modal.querySelector(".dialog").focus(); return; }
|
||||
const first = controls[0], last = controls.at(-1);
|
||||
const boundary = e.shiftKey ? first : last;
|
||||
if (document.activeElement !== boundary && modal.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
(e.shiftKey ? last : first).focus();
|
||||
});
|
||||
function openModal(content) {
|
||||
const m = $("#modal"), d = m.firstElementChild;
|
||||
const fresh = m.hidden || m.classList.contains("out");
|
||||
@@ -6855,8 +6979,26 @@ function openModal(content) {
|
||||
d.classList.remove("swap");
|
||||
if (!fresh) { void d.offsetWidth; d.classList.add("swap"); } // content changed: a soft refresh, not a re-entrance
|
||||
frame(content);
|
||||
// Keep focus through a redraw of an open editor.
|
||||
const active = d.contains(document.activeElement) ? document.activeElement : null;
|
||||
const focusID = active?.id;
|
||||
const focusLabel = active?.getAttribute("aria-label");
|
||||
const focusIndex = active ? [...d.querySelectorAll("input, textarea, select, button")].indexOf(active) : -1;
|
||||
if (fresh) modalReturnFocus = document.activeElement;
|
||||
d.replaceChildren(content);
|
||||
d.setAttribute("role", "dialog");
|
||||
d.setAttribute("aria-modal", "true");
|
||||
d.tabIndex = -1;
|
||||
const title = content.querySelector(".ehead b, .mk-name, h2, h3");
|
||||
if (title) { title.id = "modalTitle"; d.setAttribute("aria-labelledby", title.id); d.removeAttribute("aria-label"); }
|
||||
else { d.removeAttribute("aria-labelledby"); d.setAttribute("aria-label", t("Settings")); }
|
||||
m.hidden = false;
|
||||
if (fresh) modalBackground(true);
|
||||
if (fresh) markModalSaved();
|
||||
const focus = focusID && content.querySelector("#" + CSS.escape(focusID))
|
||||
|| focusLabel && [...content.querySelectorAll("[aria-label]")].find((element) => element.getAttribute("aria-label") === focusLabel)
|
||||
|| focusIndex >= 0 && content.querySelectorAll("input, textarea, select, button")[focusIndex];
|
||||
(focus || content.querySelector("input:not([disabled]), textarea, select, button:not([disabled])") || d).focus({ preventScroll: true });
|
||||
const body = content.querySelector(":scope > .ebody");
|
||||
if (body) body.scrollTop = top; // a re-render keeps the place
|
||||
const nextNames = content.querySelector(".mnames:not([hidden])");
|
||||
@@ -6887,6 +7029,8 @@ function closeModal() {
|
||||
if (m.hidden) return Promise.resolve();
|
||||
if (m.classList.contains("out")) return modalDone || Promise.resolve();
|
||||
m.classList.add("out");
|
||||
modalBackground(false);
|
||||
modalFormBase = "";
|
||||
for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.commitStyles?.(), a.cancel();
|
||||
// where it would sit at rest, whatever an opening cut short left it at
|
||||
const was = d.style.transform;
|
||||
@@ -6907,6 +7051,10 @@ function closeModal() {
|
||||
m.hidden = true;
|
||||
m.classList.remove("out");
|
||||
d.replaceChildren();
|
||||
modalFormBase = "";
|
||||
if (modalReturnFocus?.isConnected && !modalReturnFocus.closest("[hidden]")) modalReturnFocus.focus({ preventScroll: true });
|
||||
else if (!$("#view-" + view).hidden) $("#view-" + view).focus({ preventScroll: true });
|
||||
modalReturnFocus = null;
|
||||
for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.cancel();
|
||||
d.style.opacity = d.style.transform = m.style.opacity = "";
|
||||
// an editor opened from the add sheet gives the focus back to its option
|
||||
@@ -7006,7 +7154,14 @@ function renderEditor(p, presetID) {
|
||||
// its own icons, not the page's kept ones, which the rows after it take back
|
||||
const kept = keptIcons;
|
||||
keptIcons = null;
|
||||
try { return drawEditor(p, presetID); } finally { keptIcons = kept; }
|
||||
try {
|
||||
const editor = drawEditor(p, presetID);
|
||||
if (draft && draft !== providerDraftRef) {
|
||||
providerDraftRef = draft;
|
||||
providerDraftBase = providerDraftValue();
|
||||
}
|
||||
return editor;
|
||||
} finally { keptIcons = kept; }
|
||||
}
|
||||
function drawEditor(p, presetID) {
|
||||
const pr = presetID ? providers.presets.find((x) => x.id === presetID) : p?.preset ? providers.presets.find((x) => x.id === p.preset) : null;
|
||||
@@ -8669,6 +8824,7 @@ function renderModels(p) {
|
||||
const typed = draft.priceTyped?.[id];
|
||||
const cell = (value, placeholder, k, label) => {
|
||||
const i = input(value, placeholder);
|
||||
i.defaultValue = i.value;
|
||||
i.inputMode = "decimal";
|
||||
i.dataset.part = k;
|
||||
i.setAttribute("aria-label", label);
|
||||
@@ -10879,7 +11035,10 @@ function resetInText(at) {
|
||||
|
||||
// accountAction changes which account a provider uses, or which it has,
|
||||
// and leaves its editor open on the result.
|
||||
async function accountAction(path, body, okMsg) {
|
||||
async function accountAction(path, body, okMsg, confirmed = false) {
|
||||
const key = path === "keys/remove" && providers?.providers?.find((p) => p.id === body.id)?.keyList?.find((k) => k.id === body.ref);
|
||||
if (!confirmed && (path === "login/forget" || path === "keys/remove") && !await confirmRemoval(body.user || key?.name || key?.masked || body.ref,
|
||||
"This account or key will stop being used for requests. You will need to add it again to use it.")) return false;
|
||||
try {
|
||||
providers = await api(path, body);
|
||||
renderProviders();
|
||||
@@ -10962,6 +11121,8 @@ function keyWeight(p, k, w, total) {
|
||||
i.min = "1";
|
||||
i.max = "1000";
|
||||
i.className = "key-weight-in";
|
||||
i.setAttribute("aria-label", t("Key weight"));
|
||||
i.title = b.title;
|
||||
let done = false;
|
||||
const save = (keep) => {
|
||||
if (done) return;
|
||||
@@ -11203,6 +11364,7 @@ function keyFoldRows(p, fold) {
|
||||
const q = input(keysOpen[p.id].q, t("Filter {n} keys — name, key, off, resting…", { n: all.length }));
|
||||
q.className = "keys-filter";
|
||||
q.dataset.provider = p.id;
|
||||
q.setAttribute("aria-label", q.placeholder);
|
||||
q.oninput = () => {
|
||||
keysOpen[p.id].q = q.value;
|
||||
const at = q.selectionStart;
|
||||
@@ -11215,18 +11377,13 @@ function keyFoldRows(p, fold) {
|
||||
fewer.onclick = () => { delete keysOpen[p.id]; renderProviders(); };
|
||||
bar.append(q, count, el("span", "grow"));
|
||||
// removing many at once: those turned off, those failing for good, or
|
||||
// those the filter matches; a second click does it
|
||||
// those the filter matches; the confirmation names them before they go
|
||||
const removeMany = (label, keys) => {
|
||||
if (!keys.length || keys.length === all.length) return null;
|
||||
const b = el("button", "text keys-remove", label);
|
||||
b.onclick = () => {
|
||||
if (!b.dataset.armed) {
|
||||
b.dataset.armed = "1";
|
||||
b.classList.add("danger");
|
||||
b.textContent = t("Remove {n} keys? Click again", { n: keys.length });
|
||||
setTimeout(() => { if (b.isConnected) { delete b.dataset.armed; b.classList.remove("danger"); b.textContent = label; } }, 4000);
|
||||
return;
|
||||
}
|
||||
b.onclick = async () => {
|
||||
if (!await confirmRemoval(keys.map((k) => k.name || k.masked || k.id).join(", "),
|
||||
"This account or key will stop being used for requests. You will need to add it again to use it.")) return;
|
||||
b.classList.add("busy");
|
||||
accountAction("keys/remove-many", { id: p.id, refs: keys.map((k) => k.id) }, t("{n} keys removed", { n: keys.length }));
|
||||
};
|
||||
@@ -11518,6 +11675,8 @@ function renderMove(p) {
|
||||
}
|
||||
|
||||
async function providerAction(action, body, okMsg, base = "provider/") {
|
||||
if (action === "delete" && !await confirmRemoval(providers?.providers.find((p) => p.id === body.id)?.name || body.id,
|
||||
"Its saved configuration will be removed. Agents using it may switch to another provider or their default model.")) return false;
|
||||
try {
|
||||
const was = editing;
|
||||
providers = await api(base + action, body);
|
||||
@@ -13357,7 +13516,7 @@ $("#modal").addEventListener("click", (e) => {
|
||||
if (confirmAsk && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeConfirmAsk(); }
|
||||
}, true);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (confirmAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeConfirmAsk(); }
|
||||
if (!confirmationPending && confirmAsk && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeConfirmAsk(); }
|
||||
}, true);
|
||||
function resetOutcome(out) {
|
||||
switch (out.code) {
|
||||
@@ -15294,7 +15453,10 @@ function renderSessions() {
|
||||
seg.append(b);
|
||||
}
|
||||
}
|
||||
for (const b of seg.querySelectorAll(":scope > .opt")) b.classList.toggle("on", b.dataset.agent === sessAgent);
|
||||
for (const b of seg.querySelectorAll(":scope > .opt")) {
|
||||
b.classList.toggle("on", b.dataset.agent === sessAgent);
|
||||
b.setAttribute("aria-pressed", String(b.dataset.agent === sessAgent));
|
||||
}
|
||||
// and the agent picked is brought into the strip, the page left where it is
|
||||
const picked = seg.querySelector(":scope > .on");
|
||||
if (picked && seg.scrollWidth > seg.clientWidth) {
|
||||
@@ -16877,7 +17039,9 @@ function tick(label, on) {
|
||||
function syncBar(ed, err, ...tools) {
|
||||
const bar = el("div", "bar");
|
||||
bar.append(...tools);
|
||||
ed.append(el("div", "editor-error", ""), bar);
|
||||
const error = el("div", "editor-error", "");
|
||||
error.setAttribute("role", "alert");
|
||||
ed.append(error, bar);
|
||||
return (msg) => { ed.querySelector(".editor-error").textContent = msg || ""; };
|
||||
}
|
||||
|
||||
@@ -17109,6 +17273,7 @@ function renderTrayUsage(s, keep) {
|
||||
compactNum.className = "words compact-num";
|
||||
compactNum.hidden = !!s.fullContext;
|
||||
compactNum.title = t("Compact at");
|
||||
compactNum.setAttribute("aria-label", t("Compact at"));
|
||||
compactNum.onchange = () => {
|
||||
const r = parseContexts(compactNum.value);
|
||||
const n = r.map?.["*"] || 0;
|
||||
@@ -18425,6 +18590,7 @@ addEventListener("click", (e) => {
|
||||
if (held && !holding) { holding = true; requestAnimationFrame(keepHeld); }
|
||||
}, true);
|
||||
for (const v of document.querySelectorAll(".view")) {
|
||||
v.tabIndex = -1;
|
||||
v.addEventListener("scroll", () => {
|
||||
if (v.hidden) return;
|
||||
if (performance.now() < purposeUntil) { fitRoom(v); readerLeaves(v); }
|
||||
@@ -18433,11 +18599,37 @@ for (const v of document.querySelectorAll(".view")) {
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function show(v) {
|
||||
async function show(v) {
|
||||
// a page gateway mode leaves out (a link to it, an address kept) opens Providers
|
||||
if (gatewayMode && GATEWAY_HIDES.includes(v)) v = "providers";
|
||||
if (v !== view) {
|
||||
if (view === "providers" && (editing !== null || importing || importingApps)) {
|
||||
if (!await cancelEdit(true)) return false;
|
||||
} else if (!$("#modal").hidden && !$("#modal").classList.contains("out")) {
|
||||
if (view === "library" && modalFormDirty() && !(await confirmDiscard())) return false;
|
||||
if (view === "library") window.closeLibraryModal?.(); else closeModal();
|
||||
}
|
||||
if (view === "routing" && window.routingDirty?.()) {
|
||||
if (!(await confirmDiscard())) return false;
|
||||
window.discardRouting?.();
|
||||
}
|
||||
if (view === "library" && window.libraryDirty?.()) {
|
||||
if (!(await confirmDiscard())) return false;
|
||||
window.discardLibrary?.();
|
||||
}
|
||||
}
|
||||
view = v;
|
||||
if (mode === "window") { for (const b of $("#nav").querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === v); slide($("#nav"), "nav"); navInSight(); }
|
||||
const title = ({ agents: "Agents", providers: "Providers", gateway: "Gateway", routing: "Routing", usage: "Usage", sessions: "Sessions", library: "Library", plugins: "Plugins", settings: "Settings" })[v];
|
||||
$("#pageTitle").dataset.en = title;
|
||||
$("#pageTitle").textContent = t(title);
|
||||
if (mode === "window") {
|
||||
for (const b of $("#nav").querySelectorAll("button")) {
|
||||
const on = b.dataset.view === v;
|
||||
b.classList.toggle("on", on);
|
||||
if (on) b.setAttribute("aria-current", "page"); else b.removeAttribute("aria-current");
|
||||
}
|
||||
slide($("#nav"), "nav"); navInSight();
|
||||
}
|
||||
$("#prefs").classList.toggle("on", v === "settings");
|
||||
for (const id of ["agents", "providers", "gateway", "routing", "usage", "sessions", "library", "plugins", "settings"]) $("#view-" + id).hidden = v !== id;
|
||||
// back to where the reader was in it, and again once it has what it loads
|
||||
@@ -18455,6 +18647,7 @@ function show(v) {
|
||||
if (v === "sessions") window.loadSessionsPage?.()?.then(back);
|
||||
syncTitle();
|
||||
syncURL();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The tab, and the provider open in it, are kept in the address so a
|
||||
|
||||
@@ -51,6 +51,16 @@ const I18N = {
|
||||
"Replies": "回复",
|
||||
"Name the member in replies": "回复里写明成员",
|
||||
"A reply's model says magpie's provider/model that answered (workbuddy/glm-5.3-flash), not the vendor's own name, for agents that count usage by it. Claude Code, Claude Desktop and Codex keep the vendor's; the X-Magpie-Model header says it to every agent": "回复的 model 写 magpie 里作答的 provider/model(workbuddy/glm-5.3-flash),而不是厂商自己的名字,给按它统计用量的 agent 用。Claude Code、Claude Desktop 和 Codex 仍拿到厂商的名字;X-Magpie-Model 响应头对所有 agent 都会写明",
|
||||
"Discard unsaved changes? Your changes will be lost.": "是否放弃未保存的修改?放弃后,这些修改将丢失。",
|
||||
"This removes the selected item. Continue?": "这将移除所选项目。是否继续?",
|
||||
"Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "这将删除它的已保存配置。使用它的 Agent 可能切换到其他供应商或默认模型。",
|
||||
"This account or key will stop being used for requests. You will need to add it again to use it.": "此账户或密钥将不再用于请求。要再次使用,需要重新添加。",
|
||||
"This routing group will no longer be available to agents.": "此路由组将不再供 Agent 使用。",
|
||||
"These routing groups will no longer be available to agents.": "这些路由组将不再供 Agent 使用。",
|
||||
"Key weight": "密钥权重",
|
||||
"Saved middleware options will be removed. The plugin will run with no options.": "已保存的中间件选项将被移除。插件将不带选项运行。",
|
||||
"It will be removed from the library and the agents it was given to.": "这将从资源库及使用它的 Agent 中移除它。",
|
||||
"Main navigation": "主导航",
|
||||
"Gateway keys": "网关密钥",
|
||||
"Gateway key": "网关密钥",
|
||||
"Limit": "限额",
|
||||
@@ -974,7 +984,6 @@ const I18N = {
|
||||
"Filter {n} keys — name, key, off, resting…": "筛选 {n} 个密钥:名称、密钥、已停用、休息中…",
|
||||
"{n} of {m} keys": "{m} 个密钥中的 {n} 个",
|
||||
"Show fewer": "收起",
|
||||
"Remove {n} keys? Click again": "移除 {n} 个密钥?再点一次",
|
||||
"Hide {n}, of every provider? Click again": "隐藏所有供应商的 {n} 个模型?再点一次",
|
||||
"Show {n} hidden, of every provider? Click again": "显示所有供应商已隐藏的 {n} 个模型?再点一次",
|
||||
"{n} keys removed": "已移除 {n} 个密钥",
|
||||
@@ -2322,7 +2331,6 @@ const I18N = {
|
||||
"Pick several groups to remove together": "选出几个组一起移除",
|
||||
"Select every group": "全选",
|
||||
"Pick the groups to remove": "勾选要移除的组",
|
||||
"Remove {n}? Click again": "移除 {n} 个?再点一次",
|
||||
"{n} groups removed": "已移除 {n} 个组",
|
||||
"No group yet. New group makes one of any models you like.": "还没有路由组。「新建组」可组合任意模型。",
|
||||
"Find groups on their own": "自动创建路由组",
|
||||
@@ -4382,7 +4390,6 @@ const I18N = {
|
||||
"Filter {n} keys — name, key, off, resting…": "{n} 個のキーを絞り込む:名前、キー、オフ、休止中…",
|
||||
"{n} of {m} keys": "{m} 個中 {n} 個のキー",
|
||||
"Show fewer": "折りたたむ",
|
||||
"Remove {n} keys? Click again": "{n} 個のキーを削除?もう一度クリック",
|
||||
"Hide {n}, of every provider? Click again": "全プロバイダーの {n} 個を非表示?もう一度クリック",
|
||||
"Show {n} hidden, of every provider? Click again": "全プロバイダーの非表示 {n} 個を表示?もう一度クリック",
|
||||
"{n} keys removed": "{n} 個のキーを削除しました",
|
||||
@@ -5734,7 +5741,6 @@ const I18N = {
|
||||
"Pick several groups to remove together": "複数のグループを選んでまとめて削除",
|
||||
"Select every group": "すべて選択",
|
||||
"Pick the groups to remove": "削除するグループを選択",
|
||||
"Remove {n}? Click again": "{n} 件を削除?もう一度クリック",
|
||||
"{n} groups removed": "{n} 件のグループを削除しました",
|
||||
"No group yet. New group makes one of any models you like.": "グループはまだありません。「新しいグループ」で好きなモデルをまとめられます。",
|
||||
"Find groups on their own": "グループを自動で作成",
|
||||
@@ -6829,6 +6835,16 @@ const I18N = {
|
||||
"At its cap · {agent} still uses it": "上限に到達 · {agent} は引き続き使用中",
|
||||
"A usage window is at {n}%, past this account's {cap}% cap: requests through magpie are refused with a usage-cap error until it renews": "利用枠が {n}% に達し、このアカウントの上限 {cap}% を超えています。更新されるまで、magpie 経由のリクエストは使用量上限エラーで拒否されます",
|
||||
"{agent} is signed in to this account and asks its vendor itself, not through magpie, so with no other account on to move it to, {agent} goes on using it past the cap. Add another account, or pick {agent}'s models via magpie, for the cap to hold it": "{agent} はこのアカウントでサインインしており、magpie を経由せず自分でベンダーに要求します。切り替え先となる有効なアカウントが他にないため、{agent} は上限を超えても使い続けます。上限を効かせるには、別のアカウントを追加するか、{agent} のモデルを magpie 経由で選んでください",
|
||||
"Discard unsaved changes? Your changes will be lost.": "未保存の変更を破棄しますか?変更は失われます。",
|
||||
"This removes the selected item. Continue?": "選択した項目を削除します。続行しますか?",
|
||||
"Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "保存した設定が削除されます。使用中のエージェントは別のプロバイダーまたは既定のモデルに切り替わる場合があります。",
|
||||
"This account or key will stop being used for requests. You will need to add it again to use it.": "このアカウントまたはキーはリクエストに使用されなくなります。再び使うには追加し直してください。",
|
||||
"This routing group will no longer be available to agents.": "このルーティンググループはエージェントから利用できなくなります。",
|
||||
"These routing groups will no longer be available to agents.": "これらのルーティンググループはエージェントから利用できなくなります。",
|
||||
"Key weight": "キーの重み",
|
||||
"Saved middleware options will be removed. The plugin will run with no options.": "保存したミドルウェアのオプションが削除されます。プラグインはオプションなしで実行されます。",
|
||||
"It will be removed from the library and the agents it was given to.": "ライブラリと使用中のエージェントから削除されます。",
|
||||
"Main navigation": "メインナビゲーション",
|
||||
},
|
||||
de: {
|
||||
"Give skills as": "Skills geben als",
|
||||
@@ -7790,7 +7806,6 @@ const I18N = {
|
||||
"Filter {n} keys — name, key, off, resting…": "{n} Schlüssel filtern — Name, Schlüssel, aus, pausiert…",
|
||||
"{n} of {m} keys": "{n} von {m} Schlüsseln",
|
||||
"Show fewer": "Weniger anzeigen",
|
||||
"Remove {n} keys? Click again": "{n} Schlüssel entfernen? Nochmal klicken",
|
||||
"Hide {n}, of every provider? Click again": "{n} ausblenden, aller Anbieter? Nochmal klicken",
|
||||
"Show {n} hidden, of every provider? Click again": "{n} ausgeblendete aller Anbieter anzeigen? Nochmal klicken",
|
||||
"{n} keys removed": "{n} Schlüssel entfernt",
|
||||
@@ -9142,7 +9157,6 @@ const I18N = {
|
||||
"Pick several groups to remove together": "Mehrere Gruppen auswählen und zusammen entfernen",
|
||||
"Select every group": "Alle Gruppen auswählen",
|
||||
"Pick the groups to remove": "Zu entfernende Gruppen auswählen",
|
||||
"Remove {n}? Click again": "{n} entfernen? Nochmal klicken",
|
||||
"{n} groups removed": "{n} Gruppen entfernt",
|
||||
"No group yet. New group makes one of any models you like.": "Noch keine Gruppe. „Neue Gruppe“ fasst beliebige Modelle zusammen.",
|
||||
"Find groups on their own": "Gruppen automatisch finden",
|
||||
@@ -10235,6 +10249,16 @@ const I18N = {
|
||||
"look for a plugin": "nach einer Erweiterung suchen",
|
||||
", or ": ", oder ",
|
||||
"credits": "Credits",
|
||||
"Discard unsaved changes? Your changes will be lost.": "Nicht gespeicherte Änderungen verwerfen? Ihre Änderungen gehen verloren.",
|
||||
"This removes the selected item. Continue?": "Das ausgewählte Element wird entfernt. Fortfahren?",
|
||||
"Its saved configuration will be removed. Agents using it may switch to another provider or their default model.": "Die gespeicherte Konfiguration wird entfernt. Agenten, die sie verwenden, können zu einem anderen Anbieter oder ihrem Standardmodell wechseln.",
|
||||
"This account or key will stop being used for requests. You will need to add it again to use it.": "Dieses Konto oder dieser Schlüssel wird nicht mehr für Anfragen verwendet. Um es erneut zu verwenden, müssen Sie es wieder hinzufügen.",
|
||||
"This routing group will no longer be available to agents.": "Diese Weiterleitungsgruppe steht Agenten nicht mehr zur Verfügung.",
|
||||
"These routing groups will no longer be available to agents.": "Diese Weiterleitungsgruppen stehen Agenten nicht mehr zur Verfügung.",
|
||||
"Key weight": "Schlüsselgewicht",
|
||||
"Saved middleware options will be removed. The plugin will run with no options.": "Die gespeicherten Middleware-Optionen werden entfernt. Das Plugin läuft ohne Optionen.",
|
||||
"It will be removed from the library and the agents it was given to.": "Es wird aus der Bibliothek und den Agenten entfernt, denen es zugewiesen wurde.",
|
||||
"Main navigation": "Hauptnavigation",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
<link rel="stylesheet" href="omarchy.css">
|
||||
</head>
|
||||
<body>
|
||||
<script src="compat.js"></script>
|
||||
<script src="layout.js"></script>
|
||||
<header class="top" style="--wails-draggable:drag">
|
||||
<div class="brand">
|
||||
<span class="logo" aria-hidden="true"><svg viewBox="0 0 44 44" width="18" height="18"><defs><path id="bird" fill="currentColor" fill-rule="evenodd" d="M1.5 5.5C1.3 5.7 1.2 6.5 1.2 6.9C1.2 6.9 1.2 7 1.2 7.1C1.1 7.4 1.3 8 1.4 8.3C1.4 8.4 1.5 8.5 1.5 8.7C1.5 8.9 1.7 9 1.8 9.3C2.2 9.9 2.2 10 2.3 10.1C2.4 10.2 2.5 10.3 2.6 10.5C2.8 10.8 3 11.1 3.2 11.3C3.3 11.3 3.4 11.6 3.6 11.8C3.8 12 4.1 12.4 4.2 12.5C4.7 13 5.2 13.7 5.4 13.9C6 14.5 6.5 15.1 6.7 15.3C6.7 15.4 6.9 15.5 6.9 15.7C7.3 15.9 7.7 16.5 7.9 16.8C7.9 16.8 8 16.9 8 16.9C8.1 16.9 8.2 17.2 8.2 17.3C8.2 17.3 8.1 17.2 8.1 17.1C7.9 16.9 7.9 16.9 7.6 16.6C7.5 16.5 7.4 16.5 7.4 16.3C7.2 16.2 6.7 15.7 6.3 15.2C6.1 15.1 6 14.9 6 14.9C5.9 14.8 5.7 14.7 5.7 14.6C5.6 14.5 5.4 14.3 5.3 14.2C5.1 14.1 4.9 13.8 4.7 13.5C4.5 13.3 4.2 13.1 4.1 12.9C4.1 12.9 3.8 12.7 3.8 12.5C3.5 12.3 3 11.8 2.8 11.4C1.8 10.5 0.9 9.6 0.7 9.6C0.6 9.7 0.6 9.8 0.5 10C0.5 10.1 0.6 10.2 0.5 10.3C0.5 10.4 0.6 10.5 0.6 10.6C0.5 10.7 0.5 10.9 0.6 11C0.5 11.1 0.6 11.4 0.6 11.5C0.9 12.9 1.3 13.8 2.8 14.9C2.9 15.1 3.3 15.3 3.4 15.4C3.4 15.5 3.6 15.6 3.7 15.7C4.2 16 6.4 17.8 7 18.4C7.4 18.8 7.4 18.8 7.5 18.9C7.6 19 7.7 19 7.7 19.1C7.7 19.2 7.8 19.2 7.9 19.2C7.9 19.3 8.1 19.4 8.1 19.5C8.2 19.6 8.2 19.7 8.3 19.7C8.3 19.7 8.3 19.7 8.2 19.7C8.2 19.6 8.1 19.6 8 19.6C7.9 19.5 7.9 19.4 7.8 19.4C7.7 19.3 7.6 19.2 7.6 19.2C7.6 19.1 7.5 19 7.3 18.9C7.2 18.9 6.9 18.6 6.7 18.4C6.1 17.8 4.5 16.6 3.5 16C3.4 15.8 3 15.6 2.9 15.5C2.6 15.3 1.5 14.6 0.9 14.3C0.7 14.1 0.6 14.1 0.6 14.2C0.5 14.5 0.8 15.9 0.9 16.1C0.9 16.2 1 16.2 0.9 16.3C1.2 16.8 1.9 17.6 2.7 18.3C2.8 18.5 4 19.2 4.7 19.5C6.2 20.5 6.6 20.7 7.4 21.5C8.2 22.2 8.7 22.7 9.3 23.6C9.5 24.1 9.9 24.4 10.3 25.3C10.4 25.5 10.5 25.8 10.5 25.8C10.7 25.8 10.8 25.7 11 25.4C11.6 25 11.9 24.6 12.3 24.3C12.8 24 13 23.8 13 23.7C13.1 23.7 13.1 23.8 13 24C12 24.8 11 26 10.2 26.9C10.1 27.1 9.9 27.5 9.7 27.7C9.5 27.8 9.4 28 9.4 28C9.4 28 9.3 28.3 9.1 28.5C8.6 29.2 7.7 30.8 7.7 30.9C7.7 31 7.8 31 8.3 30.8C9 30.4 9.5 30.2 10.4 30C11.7 29.6 12.5 29.6 11.6 30C10.5 30.2 9.7 30.6 9.2 31.2C8.8 31.7 8.8 31.7 9.3 31.7C10.1 31.8 10.9 31.9 11.6 32.2C12.8 32.7 13.5 33.1 15.5 34.8C16.4 35.5 17.3 36.2 17.8 36.4C17.9 36.5 18.1 36.7 18.2 36.8C19.1 37.3 21.2 38.1 22.3 38.3C22.4 38.3 22.7 38.3 22.8 38.3C22.8 38.5 23 38.5 23 38.5C23.1 38.5 23.2 38.5 23.3 38.5C23.6 38.6 25.9 38.6 26.1 38.5C26.2 38.5 26.3 38.5 26.4 38.5C26.5 38.5 26.6 38.5 26.7 38.3C26.7 38.3 27 38.3 27.1 38.3C28.3 38.1 30.2 37.3 31.4 36.7C31.5 36.7 31.7 36.4 31.8 36.4C32.2 36.2 33.2 35.4 33.4 35.2C34.2 34.5 35.5 33 35.8 32.6C35.8 32.5 36 32.2 36.1 32C36.4 31.8 36.6 31.4 36.6 31.2C36.7 31.1 36.8 30.9 36.9 30.8C37.2 30.4 37.5 29.6 37.7 29.2C37.7 29.1 37.8 28.7 37.9 28.7C38.1 28.5 38.4 27.1 38.5 26.8C39 25.2 39.1 23.7 39 22.4C39 22.2 38.9 21.9 38.9 21.8C38.9 21.7 38.9 21.6 38.9 21.4C38.6 20.5 38.5 19.9 38.2 19C37.9 18.5 37.5 17.5 37.3 17.2C37 16.8 36.8 15.8 36.8 15.2C36.8 13.8 37.7 12.7 39.6 12.2C40.1 12.1 41.3 11.8 42.1 11.7C43.5 11.5 43.5 11.5 43.4 11.3C42.9 10.7 40.9 9.9 39.2 9.7C37.9 9.5 38.1 9.5 37.6 9C37 8.6 36.4 8 36 7.9C36 7.8 35.8 7.8 35.7 7.7C35.1 7.3 34.2 7 33.1 6.9C32.6 6.7 31.6 6.9 31 7C29.6 7.3 28.3 8 27.3 9.2C26.7 9.8 26.2 10.8 25.7 11.7C25.5 12.1 24.8 13.4 24.7 13.7C24.6 13.9 24.5 14.1 24.1 14.7C23.5 15.7 22.1 16.9 20.6 17.8C20.5 18 20.3 18.2 20.1 18.3C19.7 18.5 19.4 18.8 19.1 19C18.9 19.1 18.7 19.3 18.5 19.4C18 19.8 17.2 20.3 17 20.5C16.9 20.5 16.8 20.6 16.7 20.7C16.3 20.8 16.1 20.9 16 20.9C15.5 20.8 14.9 20.5 14.2 20.2C13.7 19.8 12.7 18.9 12.4 18.6C12.3 18.5 12.2 18.4 12.1 18.2C11.9 18 11.2 17.1 11 16.6C10.9 16.5 10.8 16.4 10.7 16.3C10.7 16.2 10.6 16.1 10.6 15.9C10.5 15.9 10.4 15.7 10.4 15.6C10.3 15.5 10.2 15.2 10.1 15C10 14.8 9.8 14.4 9.7 14.2C9.6 14 9.5 13.7 9.4 13.6C9.4 13.4 9.3 13.2 9.2 13C9.2 12.8 9 12.6 9 12.5C8.9 12.3 8.7 12.1 8.7 12C8.7 12 8.5 11.7 8.5 11.7C8.3 11.2 8.2 10.9 7.5 10C6.4 8.3 4.2 6.4 2.7 5.8C2 5.5 1.6 5.4 1.5 5.5ZM28.4 16.7C29.2 16.9 29.7 17.2 29.9 17.7C30.1 18.2 30 18.9 29.6 19.4C29.2 19.9 28.2 20.9 27.6 21.2C26.8 21.7 25.3 22.4 24.2 22.7C23.9 22.8 23.6 22.9 23.5 22.9C22.8 23.2 20.7 23.7 19.6 24C19 24.2 18.4 24.3 18.1 24.4C17.9 24.4 16.1 24.9 15.3 25.1C14.6 25.2 14.7 25.1 15.7 23.6C16.4 22.7 18.5 20.6 19.4 19.9C20.8 18.8 21.4 18.4 23.2 17.6C23.8 17.4 25.6 16.8 25.9 16.8C25.9 16.8 26.3 16.8 26.6 16.7C26.8 16.7 28.2 16.7 28.4 16.7ZM30.4 20.2C30.5 20.3 30.6 20.6 30.6 20.7C31.7 23.1 33.4 24.6 35.6 25.3C36.1 25.5 36.7 25.7 37.2 25.8C37.7 25.8 37.8 25.9 37.8 26.1C37.7 26.2 37.7 26.3 37.7 26.5C37.7 26.6 37.7 26.8 37.6 27C37.4 28.5 36.8 29.9 36.1 31C36.1 31.2 35.9 31.4 35.9 31.6C35.7 31.9 35.2 32.6 34.7 33.3C32.6 35.6 29.7 37.3 26.6 37.8C26.5 37.8 26.3 37.9 26.2 37.9C25.8 37.9 23.7 37.9 23.3 37.9C23.2 37.9 23 37.8 22.9 37.8C21.1 37.6 19.3 36.8 18 35.7C17.7 35.4 17 34.7 16.8 34.5C16.3 33.8 16.1 33 16.3 32.5C16.5 31.8 16.8 31.6 17.9 31.1C19.1 30.5 19.4 30.4 20.2 30C21.4 29.4 23.2 28.4 23.9 27.9C23.9 27.8 24.2 27.7 24.5 27.6C26.3 26.3 28 24.6 28.9 22.9C29.5 22.2 29.7 21.6 29.9 20.7C30.1 20 30.2 19Line truncated
|
||||
<span>magpie</span>
|
||||
</div>
|
||||
<nav class="seg" id="nav" style="--wails-draggable:no-drag">
|
||||
<button data-view="agents" class="on" data-t>Agents</button>
|
||||
<h1 class="sr-only" id="pageTitle" data-t>Agents</h1>
|
||||
<nav class="seg" id="nav" aria-label="Main navigation" data-ta style="--wails-draggable:no-drag">
|
||||
<button data-view="agents" class="on" aria-current="page" data-t>Agents</button>
|
||||
<button data-view="providers" data-t>Providers</button>
|
||||
<button data-view="gateway" data-t>Gateway</button>
|
||||
<button data-view="routing" data-t>Routing</button>
|
||||
@@ -426,7 +429,7 @@
|
||||
<footer class="foot">
|
||||
<button type="button" class="text prof-btn" id="profBtn" hidden aria-haspopup="dialog" aria-expanded="false"><span data-t>Profiles</span><em id="profN"></em></button>
|
||||
<span id="pqAge" class="pq-age" hidden></span>
|
||||
<span id="status" class="status"></span>
|
||||
<span id="status" class="status" role="status" aria-atomic="true"></span>
|
||||
<span class="grow"></span>
|
||||
<button class="text" id="openMain" data-t>Open magpie</button>
|
||||
<button class="text" id="quit" data-t>Quit</button>
|
||||
@@ -453,7 +456,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="compat.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="caller-keys.js"></script>
|
||||
<script src="purposes.js"></script>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Run at the start of the body, before any UI can paint. app.js may still
|
||||
// be downloading when the header appears, so its layout must already match
|
||||
// the browser, desktop window or tray panel.
|
||||
(function () {
|
||||
var mode = new URLSearchParams(location.search).get("mode") || "window";
|
||||
var web = !!(window.bootPrefs && window.bootPrefs.web);
|
||||
document.body.classList.add(mode);
|
||||
if (web) document.body.classList.add("web");
|
||||
// Preserve the existing iOS viewport policy before app.js runs.
|
||||
if (web && (/iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1))) {
|
||||
var viewport = document.querySelector('meta[name="viewport"]');
|
||||
if (viewport) viewport.setAttribute("content", "width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover");
|
||||
}
|
||||
if (!web && /^Mac/.test(navigator.platform)) document.body.classList.add("mac");
|
||||
if (!web && /^Linux/.test(navigator.platform)) document.body.classList.add("linux");
|
||||
if (/^Win/.test(navigator.platform)) document.documentElement.classList.add("win");
|
||||
})();
|
||||
@@ -971,7 +971,8 @@
|
||||
render();
|
||||
}
|
||||
}
|
||||
function discard() {
|
||||
async function discard(force = false) {
|
||||
if (force !== true && dirty() && !(await confirmDiscard())) return;
|
||||
for (const k of Object.keys(texts)) delete texts[k];
|
||||
for (const k of Object.keys(extras)) delete extras[k];
|
||||
render();
|
||||
@@ -1594,7 +1595,8 @@
|
||||
|
||||
const bar = el("div", "bar");
|
||||
if (s) bar.append(button(t("Remove from the library"), "danger", async () => {
|
||||
if (await change("servers/remove", { name: s.name }, t("{name} is out of the library and the agents it was given to", { name: s.name }))) closeLibModal();
|
||||
if (!await confirmRemoval(s.name, "It will be removed from the library and the agents it was given to.")) return;
|
||||
if (await change("servers/remove", { name: s.name }, t("{name} is out of the library and the agents it was given to", { name: s.name }))) closeLibModal(true);
|
||||
}));
|
||||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal));
|
||||
// send saves the form; s is then the server as saved, for a save after
|
||||
@@ -1613,7 +1615,7 @@
|
||||
try {
|
||||
const was = await send();
|
||||
report(lib.result, was ? t("{name} saved", { name: d.name }) : "");
|
||||
closeLibModal();
|
||||
closeLibModal(true);
|
||||
render();
|
||||
} catch (e) { err.textContent = e.message; }
|
||||
};
|
||||
@@ -1650,7 +1652,8 @@
|
||||
status(t("Filled in from what you pasted"), "ok");
|
||||
} else pasteMany(found, d.agents);
|
||||
});
|
||||
modal = { save };
|
||||
const original = JSON.stringify(d);
|
||||
modal = { save, dirty: () => JSON.stringify(d) !== original };
|
||||
openLib(ed);
|
||||
if (!s) requestAnimationFrame(() => name.focus());
|
||||
}
|
||||
@@ -1698,7 +1701,7 @@
|
||||
}
|
||||
take(last);
|
||||
report(lib.result, t("Added {n} servers", { n: pick.size }));
|
||||
closeLibModal();
|
||||
closeLibModal(true);
|
||||
render();
|
||||
});
|
||||
go.disabled = !pick.size;
|
||||
@@ -2709,7 +2712,7 @@
|
||||
const bar = el("div", "bar");
|
||||
const go = button(t("Remove"), "primary danger-fill", async () => {
|
||||
const body = keep.checked ? { dir: p.dir, keep: true } : { dir: p.dir };
|
||||
if (await change("projects/remove", body, t(keep.checked ? "{name} removed, its skills and servers kept" : "{name} removed", { name: p.name }))) { openProjects.delete(p.dir); closeLibModal(); }
|
||||
if (await change("projects/remove", body, t(keep.checked ? "{name} removed, its skills and servers kept" : "{name} removed", { name: p.name }))) { openProjects.delete(p.dir); closeLibModal(true); }
|
||||
});
|
||||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||||
ed.append(bar);
|
||||
@@ -2807,7 +2810,7 @@
|
||||
status(e.message, "err", 6000);
|
||||
}
|
||||
busy = false;
|
||||
if (list.isConnected) draw();
|
||||
if (list.isConnected) { draw(); markModalSaved(); }
|
||||
}));
|
||||
}
|
||||
return row;
|
||||
@@ -2840,7 +2843,7 @@
|
||||
ed.append(head);
|
||||
ed.append(el("p", "lib-confirm", t("Every skill is taken out of {agents}. They stay in the library, to turn on again.", { agents: ids.map(nameOf).join(", ") })));
|
||||
const bar = el("div", "bar");
|
||||
const go = button(t("Turn all off"), "primary danger-fill", async () => { go.disabled = true; if (await everySkill(ids, false)) closeLibModal(); else go.disabled = false; });
|
||||
const go = button(t("Turn all off"), "primary danger-fill", async () => { go.disabled = true; if (await everySkill(ids, false)) closeLibModal(true); else go.disabled = false; });
|
||||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||||
ed.append(bar);
|
||||
modal = { save: () => go.click() };
|
||||
@@ -2971,7 +2974,7 @@
|
||||
if (linked) where.push(t("{n} linked from folders of your own are only unlinked: those folders stay where they are.", { n: linked }));
|
||||
ed.append(el("p", "lib-confirm", where.join(" ")));
|
||||
const bar = el("div", "bar");
|
||||
const go = button(t("Remove all {n}", { n }), "primary danger-fill", async () => { go.disabled = true; if (await removeEverySkill(names)) closeLibModal(); else go.disabled = false; });
|
||||
const go = button(t("Remove all {n}", { n }), "primary danger-fill", async () => { go.disabled = true; if (await removeEverySkill(names)) closeLibModal(true); else go.disabled = false; });
|
||||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||||
ed.append(bar);
|
||||
modal = { save: () => go.click() };
|
||||
@@ -3176,7 +3179,13 @@
|
||||
// sync makes it again
|
||||
if (s.behind?.length) {
|
||||
const b = tag(t("Copy out of date"), "lib-new link lib-behind", t("The copy in {agents} differs from the library's skill. Click to copy it again.", { agents: s.behind.map(nameOf).join(", ") }));
|
||||
b.setAttribute("role", "button");
|
||||
b.tabIndex = 0;
|
||||
b.onclick = (e) => { e.stopPropagation(); change("all/sync", {}, t("Copies updated")); };
|
||||
b.onkeydown = (e) => {
|
||||
if (e.key !== "Enter" && e.key !== " ") return;
|
||||
e.preventDefault(); e.stopPropagation(); b.click();
|
||||
};
|
||||
nm.append(b);
|
||||
}
|
||||
who.append(nm);
|
||||
@@ -3235,7 +3244,7 @@
|
||||
? t("It is taken out of every agent it was given to. The folder it was linked from stays where it is.")
|
||||
: t("It is taken out of every agent it was given to, and its folder is moved to magpie's backups.")));
|
||||
const bar = el("div", "bar");
|
||||
const go = button(t("Remove"), "primary danger-fill", async () => { if (await change("skills/remove", { name: s.name }, t("{name} removed", { name: s.name }))) closeLibModal(); });
|
||||
const go = button(t("Remove"), "primary danger-fill", async () => { if (await change("skills/remove", { name: s.name }, t("{name} removed", { name: s.name }))) closeLibModal(true); });
|
||||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||||
ed.append(bar);
|
||||
modal = { save: () => go.click() };
|
||||
@@ -3553,7 +3562,7 @@
|
||||
if (miss) { err.textContent = t("{what} is needed", { what: t(miss.label) }); ed.querySelector(`[data-key="${CSS.escape(miss.key)}"]`)?.focus(); return; }
|
||||
go.disabled = true;
|
||||
go.textContent = t("Adding…");
|
||||
if (await addServer(x, values, agents)) closeLibModal();
|
||||
if (await addServer(x, values, agents)) closeLibModal(true);
|
||||
else { go.disabled = false; go.textContent = t("Add to the library"); }
|
||||
});
|
||||
bar.append(go);
|
||||
@@ -3637,7 +3646,7 @@
|
||||
const go = button(t("Add to the library"), "primary", async () => {
|
||||
go.disabled = true;
|
||||
go.textContent = t("Adding…");
|
||||
if (await addSkill(x, agents)) closeLibModal();
|
||||
if (await addSkill(x, agents)) closeLibModal(true);
|
||||
else { go.disabled = false; go.textContent = t("Add to the library"); }
|
||||
});
|
||||
bar.append(go);
|
||||
@@ -3651,14 +3660,22 @@
|
||||
// ---------- the dialog ----------
|
||||
|
||||
function openLib(content) { openModal(content); $("#modal").classList.add("lib"); }
|
||||
function closeLibModal() { modal = null; closeModal().then(() => { if (!modal) $("#modal").classList.remove("lib"); }); }
|
||||
async function closeLibModal(saved = false) {
|
||||
if (saved !== true && (modal?.dirty?.() ?? modalFormDirty()) && !(await confirmDiscard())) return false;
|
||||
modal = null;
|
||||
closeModal().then(() => { if (!modal) $("#modal").classList.remove("lib"); });
|
||||
return true;
|
||||
}
|
||||
window.closeLibraryModal = () => { if (modal) closeLibModal(true); else closeModal(); };
|
||||
window.libraryDirty = () => !!lib && dirty();
|
||||
window.discardLibrary = () => discard(true);
|
||||
// The dialog is the providers page's; while the library has it, its
|
||||
// backdrop and Escape close it here.
|
||||
$("#modal").addEventListener("click", (e) => {
|
||||
if (modal && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeLibModal(); }
|
||||
}, true);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (modal && e.key === "Escape") { e.stopImmediatePropagation(); closeLibModal(); }
|
||||
if (!confirmationPending && modal && e.key === "Escape") { e.preventDefault(); e.stopImmediatePropagation(); closeLibModal(); }
|
||||
}, true);
|
||||
// leaving the page with the shared text not saved asks first
|
||||
window.addEventListener("beforeunload", (e) => { if (lib && dirty()) e.preventDefault(); });
|
||||
|
||||
@@ -848,7 +848,14 @@
|
||||
const rm = el("button", "text quiet", b === "remove" ? t(moved.length ? "Moving back…" : "Removing…") : t("Remove"));
|
||||
rm.title = moved.length ? t("{names} goes back to the built-in first, then the plugin is removed", { names }) : t("Removes the plugin and what it installed; its sign-ins are kept until you sign out");
|
||||
rm.disabled = busy.size > 0;
|
||||
rm.onclick = () => { if (moved.length) { asking = { pkg, op: "remove" }; draw(); } else remove(); };
|
||||
rm.onclick = async () => {
|
||||
if (moved.length) {
|
||||
asking = { pkg, op: "remove" };
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (await confirmRemoval(l?.name || pkg, rm.title)) remove();
|
||||
};
|
||||
val.append(onoff, rm);
|
||||
r.append(val);
|
||||
r.onclick = (ev) => { if (!ev.target.closest("button, .pm-opts")) detail(l || (isGit(e.spec) ? { package: e.spec, name: e.package || e.spec, npm: { version: e.version, repository: gitWeb(e.spec) } } : { package: pkg, name: label(e.spec), npm: { version: e.latest } })); };
|
||||
@@ -861,6 +868,9 @@
|
||||
function optionsEditor(e) {
|
||||
const box = el("div", "pm-opts");
|
||||
const ta = document.createElement("textarea");
|
||||
ta.setAttribute("aria-label", t("Options"));
|
||||
ta.setAttribute("aria-describedby", "pluginOptionsNote pluginOptionsError");
|
||||
ta.setAttribute("aria-invalid", String(!!editing.error));
|
||||
ta.spellcheck = false;
|
||||
ta.value = editing.text;
|
||||
ta.rows = Math.min(14, Math.max(4, editing.text.split("\n").length + 1));
|
||||
@@ -870,8 +880,11 @@
|
||||
if (ev.key === "Enter" && (ev.metaKey || ev.ctrlKey)) { ev.preventDefault(); save(); }
|
||||
};
|
||||
const err = el("div", "pm-opts-err", editing.error);
|
||||
err.id = "pluginOptionsError";
|
||||
err.setAttribute("role", "alert");
|
||||
const bar = el("div", "pm-opts-bar");
|
||||
const note = el("span", "", e.options ? t("Applied to the next request") : t("Not set: these are its package's example"));
|
||||
note.id = "pluginOptionsNote";
|
||||
const save = () => {
|
||||
let v;
|
||||
try { v = JSON.parse(editing.text || "{}"); } catch (x) { editing.error = t("Not JSON: {error}", { error: x.message }); return draw(); }
|
||||
@@ -887,7 +900,11 @@
|
||||
if (e.options) {
|
||||
const clr = el("button", "text quiet", t("Clear"));
|
||||
clr.title = t("Runs it with no options");
|
||||
clr.onclick = () => { editing = null; act(name(e.spec), "options", { spec: e.spec, options: null }, () => status(t("{name}'s options cleared", { name: shownName(e) }), "ok")); };
|
||||
clr.onclick = async () => {
|
||||
if (!await confirmAction(t("Clear"), t("Saved middleware options will be removed. The plugin will run with no options."), t("Clear"))) return;
|
||||
editing = null;
|
||||
act(name(e.spec), "options", { spec: e.spec, options: null }, () => status(t("{name}'s options cleared", { name: shownName(e) }), "ok"));
|
||||
};
|
||||
bar.append(clr);
|
||||
}
|
||||
bar.append(no, ok);
|
||||
|
||||
@@ -359,7 +359,6 @@ button.rt-session { cursor: pointer; }
|
||||
.rt-ghidden .rt-gremoved { display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--faint); }
|
||||
.rt-ghidden .rt-gremoved:hover, .rt-ghidden .rt-gremoved.open { color: var(--fg-2); }
|
||||
.rt-gsel { display: flex; align-items: center; gap: 10px; padding: 8px 12px 8px 14px; }
|
||||
.rt-gsel .rt-gremove.armed { font-weight: 600; }
|
||||
.rt-group.rt-gpick { cursor: default; }
|
||||
.rt-group.rt-gpick.on { background: var(--card-2); }
|
||||
.rt-gedit { cursor: default; }
|
||||
|
||||
@@ -2495,7 +2495,19 @@
|
||||
try { groups = await api("groups"); } catch { return; }
|
||||
if (!gEdit && !gsec.contains(document.activeElement)) renderGroups(); // not under someone's hands
|
||||
}
|
||||
const groupDirty = () => !!gEdit && gEdit.was !== undefined &&
|
||||
(JSON.stringify(gEdit.draft) !== gEdit.was || !!gsec.querySelector(".rt-patadd input")?.value.trim());
|
||||
async function cancelGroup() {
|
||||
if (groupDirty() && !(await confirmDiscard())) return false;
|
||||
gEdit = null;
|
||||
renderGroups();
|
||||
return true;
|
||||
}
|
||||
window.routingDirty = groupDirty;
|
||||
window.discardRouting = () => { gEdit = null; renderGroups(); };
|
||||
async function groupAction(action, body, ok) {
|
||||
if (action === "delete" && !await confirmRemoval(groups?.groups.find((g) => g.id === body.id)?.name || body.id,
|
||||
"This routing group will no longer be available to agents.")) return;
|
||||
try {
|
||||
groups = await api("groups/" + action, body);
|
||||
gEdit = null;
|
||||
@@ -2547,9 +2559,11 @@
|
||||
// member takes no reasoning of its own (provider.SaveGroup refuses
|
||||
// "group/x:high"), so its row says where its reasoning is set and goes
|
||||
// there, rather than leaving the slot empty.
|
||||
const openGroupEditor = (g) => {
|
||||
const openGroupEditor = async (g) => {
|
||||
if (groupDirty() && !(await confirmDiscard())) return false;
|
||||
gEdit = { id: g.id, draft: { name: g.name, members: [...g.members], match: [...(g.match || [])], matched: [...(g.matched || [])], fast: [...(g.fast || [])], off: [...(g.off || [])], routing: g.routing || "", pick: g.pick || "", affinity: g.affinity || "", sink: !!g.sink, firstToken: g.firstToken || 0, context: g.context || 0, classifier: g.classifier || "", effort: g.effort || "", levels: [...(g.levels || [])], rules: (g.rules || []).map((r) => ({ ...r, intent: r.intent || "", agents: [...(r.agents || [])], time: r.time ? { ...r.time, days: [...(r.time.days || [])] } : null })) } };
|
||||
renderGroups();
|
||||
return true;
|
||||
};
|
||||
const groupIcons = (g) => [...new Map((g.memberInfo || []).filter((i) => i.icon).map((i) => [i.provider || i.icon, i.icon])).values()];
|
||||
const memberIcon = (id) => { const s = subOf(id); return s ? stackIcon(groupIcons(s)) : icon(modelOf(id)?.icon || "generic"); };
|
||||
@@ -2603,6 +2617,7 @@
|
||||
const typing = document.activeElement === gQ, [a, b] = [gQ.selectionStart, gQ.selectionEnd];
|
||||
if (all.length > 1 || gQ.value) {
|
||||
gQ.placeholder = t("Filter groups and models");
|
||||
gQ.setAttribute("aria-label", gQ.placeholder);
|
||||
head.push(gQ);
|
||||
}
|
||||
if (!gSel) {
|
||||
@@ -2610,7 +2625,10 @@
|
||||
if (all.length > 1) {
|
||||
const pick = el("button", "text rt-gselect", t("Select"));
|
||||
pick.title = t("Pick several groups to remove together");
|
||||
pick.onclick = () => { gEdit = null; gSel = new Set(); renderGroups(); };
|
||||
pick.onclick = async () => {
|
||||
if (groupDirty() && !(await confirmDiscard())) return;
|
||||
gEdit = null; gSel = new Set(); renderGroups();
|
||||
};
|
||||
head.push(pick);
|
||||
}
|
||||
head.push(newBtn);
|
||||
@@ -2652,8 +2670,7 @@
|
||||
renderPools();
|
||||
}
|
||||
// selectBar: over the groups while picking: all or none, how many are
|
||||
// picked, Remove them (a second click, as it can't be undone for the
|
||||
// groups the user made), and Done
|
||||
// picked, Remove them after confirmation, and Done
|
||||
function selectBar(shown) {
|
||||
const bar = el("div", "rt-gsel");
|
||||
const all = el("input");
|
||||
@@ -2665,16 +2682,13 @@
|
||||
const done = el("button", "text", t("Done"));
|
||||
done.type = "button";
|
||||
done.onclick = () => { gSel = null; renderGroups(); };
|
||||
let armed = 0;
|
||||
const sync = () => {
|
||||
const k = gSel.size;
|
||||
all.checked = k > 0 && k === shown.length;
|
||||
all.indeterminate = k > 0 && k < shown.length;
|
||||
n.textContent = k ? t("{n} selected", { n: k }) : t("Pick the groups to remove");
|
||||
rm.disabled = !k;
|
||||
if (!k) armed = 0;
|
||||
rm.textContent = armed ? t("Remove {n}? Click again", { n: k }) : t("Remove");
|
||||
rm.classList.toggle("armed", !!armed);
|
||||
rm.textContent = t("Remove");
|
||||
for (const r of gList.querySelectorAll(".rt-gpick")) {
|
||||
const on = gSel.has(r.dataset.id);
|
||||
r.classList.toggle("on", on);
|
||||
@@ -2682,11 +2696,11 @@
|
||||
}
|
||||
};
|
||||
bar.sync = sync;
|
||||
all.onchange = () => { gSel = new Set(all.checked ? shown.map((g) => g.id) : []); armed = 0; sync(); };
|
||||
all.onchange = () => { gSel = new Set(all.checked ? shown.map((g) => g.id) : []); sync(); };
|
||||
rm.onclick = async () => {
|
||||
if (!gSel.size) return;
|
||||
if (!armed || Date.now() - armed > 5000) { armed = Date.now(); sync(); return; }
|
||||
const ids = shown.map((g) => g.id).filter((id) => gSel.has(id));
|
||||
if (!await confirmRemoval(shown.filter((g) => ids.includes(g.id)).map((g) => g.name).join(t(", ")), "These routing groups will no longer be available to agents.")) return;
|
||||
rm.disabled = true;
|
||||
try {
|
||||
groups = await api("groups/delete", { ids });
|
||||
@@ -2695,7 +2709,6 @@
|
||||
status(ids.length === 1 ? t("{name} removed", { name: ids[0] }) : t("{n} groups removed", { n: ids.length }), "ok");
|
||||
load(); // the gateway's model list, the agents' pickers
|
||||
} catch (e) {
|
||||
armed = 0;
|
||||
sync();
|
||||
status(e.message, "err");
|
||||
}
|
||||
@@ -2753,6 +2766,7 @@
|
||||
gNames.replaceChildren(txt, suffixSegs(() => renderGroups()));
|
||||
}
|
||||
async function setFound(on, s) {
|
||||
if (groupDirty() && !(await confirmDiscard())) return;
|
||||
s.classList.toggle("on", on);
|
||||
s.setAttribute("aria-checked", String(on));
|
||||
try {
|
||||
@@ -2960,7 +2974,7 @@
|
||||
h.append(el("b", "", g ? g.name : t("New group")));
|
||||
if (g?.auto) h.append(el("span", "note", t("found by magpie — saving a change makes it yours")));
|
||||
ed.append(h);
|
||||
const keys = (i) => { i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") { gEdit = null; renderGroups(); } else if (e.key === "Enter" && i === name) saveBtn.onclick(); }; return i; };
|
||||
const keys = (i) => { i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") { cancelGroup(); } else if (e.key === "Enter" && i === name) saveBtn.onclick(); }; return i; };
|
||||
const name = keys(input(d.name, t("e.g. Opus anywhere")));
|
||||
const idHint = el("div", "hint");
|
||||
// an existing group's id can change (an auto- one found by magpie too);
|
||||
@@ -2970,7 +2984,6 @@
|
||||
// from the row (ARNO on Discord) — only while nothing in it has
|
||||
// changed, so a stray click never throws an edit away; Cancel does that
|
||||
if (g) {
|
||||
if (gEdit.was === undefined) gEdit.was = JSON.stringify(d);
|
||||
h.classList.add("fold");
|
||||
h.setAttribute("role", "button");
|
||||
h.tabIndex = 0;
|
||||
@@ -3150,8 +3163,8 @@
|
||||
// the row: the desktop app's member row doesn't wrap
|
||||
hx.append(el("span", "", t("Follows {name}", { name: s.name })));
|
||||
hx.title = t("Its models reason as {name} says: set that up on {name}'s own card, not here", { name: s.name });
|
||||
hx.onclick = (ev) => {
|
||||
openGroupEditor(s);
|
||||
hx.onclick = async (ev) => {
|
||||
if (!(await openGroupEditor(s))) return;
|
||||
const ed = gList.querySelector(".rt-gedit");
|
||||
if (ed && window.scrollOnPurpose?.(ev)) ed.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
};
|
||||
@@ -3248,7 +3261,7 @@
|
||||
return "";
|
||||
};
|
||||
pAdd.onclick = () => { addPattern(); };
|
||||
pin.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") addPattern(); else if (e.key === "Escape") { gEdit = null; renderGroups(); } };
|
||||
pin.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") addPattern(); else if (e.key === "Escape") cancelGroup(); };
|
||||
const padd = el("div", "rt-patadd");
|
||||
padd.append(pin, pAdd);
|
||||
pbox.append(plist, padd);
|
||||
@@ -3286,12 +3299,16 @@
|
||||
const ctxHint = el("div", "hint"), ctxW = el("div", "ctx-wrap");
|
||||
const ctxIn = keys(input(d.context > 0 ? ctxShort(d.context).toLowerCase() : "", t("e.g. 200k")));
|
||||
ctxIn.classList.add("ctx-in");
|
||||
ctxIn.setAttribute("aria-label", t("Context"));
|
||||
ctxHint.id = "groupContextHint";
|
||||
ctxIn.setAttribute("aria-describedby", ctxHint.id);
|
||||
const ctxs = () => d.members.map((id) => groups.models.find((x) => x.id === id)?.context || infoOf(id)?.context || 0).filter((n) => n > 0);
|
||||
const ctxMode = () => d.context === -1 ? "smallest" : d.context > 0 ? "custom" : "largest";
|
||||
let ctxErr = "";
|
||||
const drawCtx = () => {
|
||||
const ns = ctxs(), mode = ctxMode();
|
||||
ctxIn.hidden = mode !== "custom";
|
||||
ctxIn.setAttribute("aria-invalid", String(!!ctxErr));
|
||||
const big = ns.length ? Math.max(...ns) : 0, small = ns.length ? Math.min(...ns) : 0;
|
||||
ctxHint.textContent = ctxErr || (mode === "custom"
|
||||
? t("Agents are told the group takes {n} tokens; a longer conversation still goes on to a member with room for it.", { n: (d.context || 0).toLocaleString() })
|
||||
@@ -3563,7 +3580,7 @@
|
||||
}
|
||||
bar.append(el("span", "grow"));
|
||||
const cancel = el("button", "text", t("Cancel"));
|
||||
cancel.onclick = () => { gEdit = null; renderGroups(); };
|
||||
cancel.onclick = cancelGroup;
|
||||
const saveBtn = el("button", "text primary", t(g ? "Save" : "Add"));
|
||||
const save = () => {
|
||||
if (addPattern() === null) return pin.focus({ preventScroll: true }); // typed, not added: it is meant
|
||||
@@ -3602,6 +3619,7 @@
|
||||
bar.append(cancel, saveBtn);
|
||||
ed.append(bar);
|
||||
if (!g) setTimeout(() => name.focus({ preventScroll: true }), 0); // WebKit would scroll the page to put it mid-view
|
||||
if (gEdit.was == null) gEdit.was = JSON.stringify(d);
|
||||
return ed;
|
||||
}
|
||||
// the providers that route over several accounts or keys of their own
|
||||
@@ -3645,9 +3663,10 @@
|
||||
// ask it (and the tray panel, by ?newgroup= on the window it opens).
|
||||
window.newGroupWith = async (id, name, ev) => {
|
||||
// app.js's show, the page's: this one's own show is the stage's caption
|
||||
if (document.body.classList.contains("window") && $("#view-routing").hidden) window.show("routing");
|
||||
if (document.body.classList.contains("window") && $("#view-routing").hidden && !(await window.show("routing"))) return;
|
||||
if (!groups) await loadGroups();
|
||||
if (!groups) return;
|
||||
if (groupDirty() && !(await confirmDiscard())) return;
|
||||
gEdit = { id: "", draft: { name: name || modelOf(id)?.name || id.split("/").pop(), members: [id], fast: [], routing: "", affinity: "", rules: [] } };
|
||||
renderGroups();
|
||||
const ed = gList.querySelector(".rt-gedit");
|
||||
@@ -3657,7 +3676,8 @@
|
||||
// newGroup: an empty new group's editor. The groups sit below the
|
||||
// requests, out of sight on a first look, so the page's head has a New
|
||||
// group too (mintonight, #944), which brings the editor into view.
|
||||
function newGroup(ev) {
|
||||
async function newGroup(ev) {
|
||||
if (groupDirty() && !(await confirmDiscard())) return;
|
||||
gSel = null;
|
||||
gEdit = { id: "", draft: { name: "", members: [], match: [], matched: [], fast: [], off: [], routing: "", affinity: "", rules: [] } };
|
||||
renderGroups();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// The saved theme (boot.js), or one the page's address asks for, on the page
|
||||
// before its styles first apply: set only once app.js ran, at the foot of the
|
||||
// page, a window kept dark under a light system was first drawn light, and
|
||||
// the header's buttons faded from light to dark as it loaded. app.js sets it
|
||||
// again; this one is for the first paint.
|
||||
// Apply the saved theme and text size (boot.js) before styles first apply.
|
||||
// A theme requested by the page's address takes precedence. Waiting for
|
||||
// app.js at the foot of the page let the header paint in the system theme
|
||||
// and default size first, then change as the page loaded.
|
||||
(function () {
|
||||
var asked = new URLSearchParams(location.search).get("theme");
|
||||
var saved = window.bootPrefs && window.bootPrefs.theme;
|
||||
var theme = asked || (saved && saved !== "system" ? saved : "");
|
||||
if (theme) document.documentElement.dataset.theme = theme;
|
||||
var prefs = window.bootPrefs;
|
||||
if (prefs) document.documentElement.style.setProperty("--zoom", prefs.web ? 1 : (prefs.textSize || 100) / 100);
|
||||
})();
|
||||
@@ -121,8 +121,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(posts.at(-1).action, "rename");
|
||||
await backup().hover();
|
||||
await backup().getByRole("button", { name: w.remove, exact: true }).click();
|
||||
const confirmation = page.getByRole("alertdialog");
|
||||
assert.match(await confirmation.textContent(), /Backup/);
|
||||
await confirmation.getByRole("button", { name: w.remove, exact: true }).click();
|
||||
await page.waitForFunction(() => ![...document.querySelectorAll(".accts .acc")].some((r) => r.textContent.includes("Backup")));
|
||||
|
||||
await page.locator("#modal").getByRole("button", { name: lang === "zh" ? "取消" : "Cancel", exact: true }).click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
await page.locator('nav [data-view="usage"]').click();
|
||||
await page.locator("#usageKeys .row").first().waitFor();
|
||||
assert.deepEqual(await page.locator("#usageKeys .name").allTextContents(), ["Server", "Laptop"]);
|
||||
|
||||
@@ -124,6 +124,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await remove.count(), 1, "the one in use has Remove");
|
||||
assert.ok(await remove.getAttribute("title"));
|
||||
await remove.click();
|
||||
const confirm = page.locator("dialog.action-confirm[open]");
|
||||
await confirm.waitFor();
|
||||
assert.deepEqual(posts, [], "Remove waits for confirmation");
|
||||
await confirm.getByRole("button", { name: w.cancel, exact: true }).click();
|
||||
assert.deepEqual(posts, [], "Cancel keeps the signed-in account");
|
||||
assert.equal(await remove.count(), 1, "Cancel keeps the account's Remove button");
|
||||
await remove.click();
|
||||
await confirm.getByRole("button", { name: w.remove, exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector("#status")?.textContent.includes("me@example.com"));
|
||||
assert.deepEqual(posts, [{ agent: "codex", user: "me@example.com" }]);
|
||||
const missing = await page.evaluate(() => ["Codex is signed in to another of its accounts, and magpie forgets this one; the account itself is untouched"]
|
||||
|
||||
@@ -77,7 +77,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
|
||||
test(`${engine} ${lang}: a provider's compaction threshold is set in its editor`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, "providers");
|
||||
await page.locator(".row.provider").click();
|
||||
await page.locator('.row.provider[data-id="deepseek"]').click();
|
||||
assert(await page.getByText(w.field, { exact: true }).isVisible(), "the field is there");
|
||||
const box = page.locator("input.compacts");
|
||||
assert.equal(await box.inputValue(), "500k, deepseek-v4-flash=272k", "what is set is shown");
|
||||
@@ -107,6 +107,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const row = page.locator("#fullContextSegs");
|
||||
const num = row.locator("input.compact-num");
|
||||
await num.waitFor();
|
||||
assert.equal(await num.getAttribute("aria-label"), w.field);
|
||||
assert.equal(await row.locator("select").count(), 0, "no native select");
|
||||
assert(await row.getByText(w.at("272K"), { exact: true }).isVisible(), "272K by default");
|
||||
assert.equal(await num.inputValue(), "");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// are no longer listed one by one (lc on Discord: "路由组可否关闭自动发现,
|
||||
// 并且可编辑批量移除,已经移除的不要显示"). Select, by New group, puts a box
|
||||
// on each card; a click on a card picks it, the bar's box picks all or
|
||||
// none, and Remove asks for a second click before it posts groups/delete
|
||||
// none, and Remove asks in a dialog before it posts groups/delete
|
||||
// {ids}. The removed found groups are one line under the list, how many,
|
||||
// whose menu (the app's, not a native one) brings one back. No click moves
|
||||
// the page. In English and Chinese, Chromium and WebKit.
|
||||
@@ -29,8 +29,8 @@ const all = [
|
||||
];
|
||||
|
||||
const words = {
|
||||
en: { select: "Select", removed: "2 found groups removed", removed3: "3 found groups removed", picked: "2 selected", again: "Remove 2? Click again", done: "2 groups removed", back: "auto-gone is back" },
|
||||
zh: { select: "选择", removed: "已移除 2 个自动创建的组", removed3: "已移除 3 个自动创建的组", picked: "已选 2 个", again: "移除 2 个?再点一次", done: "已移除 2 个组", back: "auto-gone" },
|
||||
en: { select: "Select", removed: "2 found groups removed", removed3: "3 found groups removed", picked: "2 selected", cancel: "Cancel", done: "2 groups removed", back: "auto-gone is back" },
|
||||
zh: { select: "选择", removed: "已移除 2 个自动创建的组", removed3: "已移除 3 个自动创建的组", picked: "已选 2 个", cancel: "取消", done: "已移除 2 个组", back: "auto-gone" },
|
||||
};
|
||||
|
||||
function serve(lang, posts) {
|
||||
@@ -113,9 +113,16 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await page.locator(".rt-groups .rt-editor, .rt-groups .rt-gedit").count(), 0, "a click on a card doesn't open its editor");
|
||||
|
||||
await rm.click();
|
||||
assert.equal((await rm.textContent()).trim(), w.again);
|
||||
const confirm = page.getByRole("alertdialog");
|
||||
await confirm.waitFor();
|
||||
assert.match(await confirm.textContent(), /Model M/);
|
||||
assert.match(await confirm.textContent(), /Mine/);
|
||||
assert.deepEqual(posts, [], "one click removes nothing");
|
||||
await confirm.getByRole("button", { name: w.cancel, exact: true }).click();
|
||||
assert.deepEqual(posts, [], "Cancel keeps every group");
|
||||
assert.equal(await page.locator(".rt-gpick input:checked").count(), 2);
|
||||
await rm.click();
|
||||
await confirm.locator("button").last().click();
|
||||
await bar.waitFor({ state: "detached" });
|
||||
assert.deepEqual(posts, [["delete", { ids: ["auto-m", "mine"] }]]);
|
||||
await card("Model M").waitFor({ state: "detached" });
|
||||
@@ -137,7 +144,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
|
||||
const missing = await page.evaluate(() => [
|
||||
"1 found group removed", "{n} found groups removed", "magpie doesn't make them again. Click to bring one back.",
|
||||
"Pick several groups to remove together", "Select every group", "Pick the groups to remove", "Remove {n}? Click again", "{n} groups removed",
|
||||
"Pick several groups to remove together", "Select every group", "Pick the groups to remove", "These routing groups will no longer be available to agents.", "{n} groups removed",
|
||||
"Select", "Done", "{n} selected", "Bring it back",
|
||||
].filter((k) => !I18N.zh[k] || !I18N.ja[k] || !I18N.de[k]));
|
||||
assert.deepEqual(missing, [], "every string has its Chinese, Japanese and German");
|
||||
|
||||
@@ -103,10 +103,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await row.locator(".opt.on").textContent(), w.smallest, "the editor opens at what was saved");
|
||||
await row.locator(".opt", { hasText: w.custom }).click();
|
||||
const box = row.locator("input");
|
||||
assert.equal(await box.getAttribute("aria-label"), w.label);
|
||||
assert.equal(await box.inputValue(), "128k", "custom starts at the smallest");
|
||||
await box.fill("abc");
|
||||
assert.equal(await box.getAttribute("aria-invalid"), "true");
|
||||
assert((await hint()).includes(w.bad), await hint());
|
||||
await box.fill("300k");
|
||||
assert.equal(await box.getAttribute("aria-invalid"), "false");
|
||||
assert.equal(await box.getAttribute("aria-describedby"), await row.locator(".hint").getAttribute("id"));
|
||||
assert((await hint()).includes(w.n300), await hint());
|
||||
assert.equal((await save()).context, 300000);
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator("#status").filter({ hasText: w.first }).waitFor();
|
||||
assert.equal(await ed.locator("input").first().inputValue(), "Sol 2", "the edit is kept");
|
||||
await ed.locator("button", { hasText: new RegExp("^" + w.cancel + "$") }).click();
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await closed();
|
||||
|
||||
assert.deepEqual(posts, [], "folding saves nothing");
|
||||
|
||||
@@ -225,7 +225,29 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// a plain member beside it keeps its own picker, not that chip
|
||||
assert.equal(seen[0].follows, "", "the plain member carries no such chip");
|
||||
|
||||
// and clicking it opens that group's card, so going there is one click.
|
||||
// A nested jump must not replace a dirty outer draft before the answer.
|
||||
const name = page.locator(".rt-gedit input").first();
|
||||
await name.fill("Outer changed");
|
||||
const ask = page.locator("dialog.action-confirm[open]");
|
||||
await page.mouse.move(550, 400);
|
||||
await page.mouse.wheel(0, 400);
|
||||
const follows = page.locator(".rt-gedit .rt-inner");
|
||||
await follows.click();
|
||||
await ask.waitFor();
|
||||
await ask.locator("button").first().click();
|
||||
assert.equal(await name.inputValue(), "Outer changed", "Cancel keeps the outer draft");
|
||||
assert.equal(await follows.textContent(), w.follows, "Cancel stays in the outer editor");
|
||||
await follows.click();
|
||||
await ask.locator("button").last().click();
|
||||
await page.waitForFunction(() => document.querySelector(".rt-gedit input")?.value === "Inner");
|
||||
assert.equal(await page.locator(".rt-gedit .rt-inner").count(), 0, "Discard opens the inner editor");
|
||||
|
||||
// A clean editor still opens the nested group in one click.
|
||||
await page.mouse.move(550, 400);
|
||||
await page.mouse.wheel(0, 400);
|
||||
await page.locator(".rt-group[data-id=g3]").click();
|
||||
await page.locator(".rt-gedit .rt-inner").waitFor();
|
||||
assert.equal(await name.inputValue(), "Outer", "the discarded name wasn't saved");
|
||||
// The last row sits under the footer in WebKit, as the test above found:
|
||||
// the wheel brings it up where a reader could click it
|
||||
await page.mouse.move(550, 400);
|
||||
@@ -234,6 +256,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator(".rt-gedit").first().waitFor();
|
||||
const openNames = await page.locator(".rt-gedit input").evaluateAll((is) => is.map((i) => i.value));
|
||||
assert(openNames.includes("Inner"), `the inner group's editor is open: ${JSON.stringify(openNames)}`);
|
||||
assert.equal(await ask.count(), 0, "the clean nested jump asks nothing");
|
||||
|
||||
assert.deepEqual(errors, [], "no page error");
|
||||
});
|
||||
|
||||
@@ -106,6 +106,38 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
});
|
||||
assert.equal(await ed.locator("input").first().inputValue(), "");
|
||||
assert.equal(await ed.evaluate((e) => e.contains(document.activeElement) && document.activeElement.tagName), "INPUT");
|
||||
// Both New group entrances replace the same draft only after Discard.
|
||||
const name = ed.locator("input").first();
|
||||
const ask = page.locator("dialog.action-confirm[open]");
|
||||
for (const entrance of [inList, head]) {
|
||||
await name.fill("Unsaved group");
|
||||
await page.mouse.move(500, 300);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const b = await entrance.boundingBox();
|
||||
if (b.y >= 60 && b.y + b.height < vh - 60) break;
|
||||
await page.mouse.wheel(0, b.y < 60 ? -400 : 400);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
await entrance.click();
|
||||
await ask.waitFor();
|
||||
await ask.locator("button").first().click();
|
||||
assert.equal(await name.inputValue(), "Unsaved group", "Cancel keeps the existing group draft");
|
||||
await entrance.click();
|
||||
await ask.locator("button").last().click();
|
||||
await page.waitForFunction(() => document.querySelector(".rt-gedit input")?.value === "");
|
||||
assert.equal(await ask.count(), 0, "Discard opens a clean new group");
|
||||
}
|
||||
// The model picker's entry must protect the same draft as both buttons.
|
||||
await name.fill("Unsaved group");
|
||||
const fromModel = () => page.evaluate(() => { window.newGroupWith("a/m", "Model A"); });
|
||||
await fromModel();
|
||||
await ask.waitFor();
|
||||
await ask.locator("button").first().click();
|
||||
assert.equal(await name.inputValue(), "Unsaved group", "Cancel keeps the draft when making a group from a model");
|
||||
await fromModel();
|
||||
await ask.locator("button").last().click();
|
||||
await page.waitForFunction(() => document.querySelector(".rt-gedit input")?.value === "Model A");
|
||||
assert.deepEqual(await ed.locator(".fbrow .n").allTextContents(), ["mA"], "Discard opens the group with its chosen model");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// the filter: by name, by a model's name or id, every word
|
||||
const q = page.locator(".rt-gsec .row-head input.rt-gfilter");
|
||||
assert.equal(await q.getAttribute("placeholder"), w.filter);
|
||||
assert.equal(await q.getAttribute("aria-label"), w.filter);
|
||||
await q.click();
|
||||
await page.keyboard.type("three");
|
||||
assert.deepEqual(await listed(), ["fast"], "by a model's name");
|
||||
|
||||
@@ -93,6 +93,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await chip("bbbbbbbbbb").click();
|
||||
const box = page.locator('.acc[data-account-id="bbbbbbbbbb"] .key-weight-in');
|
||||
await box.waitFor();
|
||||
assert.equal(await box.getAttribute("aria-label"), lang === "zh" ? "密钥权重" : "Key weight");
|
||||
assert.equal(await scrolled(), sc, "a click scrolled");
|
||||
await box.fill("2");
|
||||
await box.press("Enter");
|
||||
|
||||
@@ -105,6 +105,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const before = calls.filter((c) => c === "market/servers").length;
|
||||
await view.locator(".lib-row", { hasText: "playwright" }).click();
|
||||
await page.getByRole("button", { name: words[lang].remove, exact: true }).click();
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await card("mcp", "playwright").locator(".mk-add").waitFor({ timeout: 2000 });
|
||||
assert.equal(await card("mcp", "playwright").locator(".mk-have").count(), 0);
|
||||
assert.equal(await card("mcp", "playwright").evaluate((e) => e.classList.contains("have")), false);
|
||||
|
||||
@@ -66,12 +66,13 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const L = { names: zh ? "名称与推理档位" : "Names & levels", input: zh ? "输入" : "Input", output: zh ? "输出" : "Output", cacheRead: zh ? "缓存读取" : "Cache read",
|
||||
oneHour: zh ? "1 小时缓存写入" : "Cache write 1h", long: zh ? "长上下文价格" : "Long-context price", over: zh ? "超过的输入 token 数" : "Over input tokens",
|
||||
save: zh ? "保存" : "Save", badSize: zh ? "长上下文价格要写从多少输入 token 起算,比如 272K" : "A long-context price starts over a number of input tokens, like 272K" };
|
||||
await page.locator(".row.provider").click();
|
||||
await page.locator('.row.provider[data-id="relay"]').click();
|
||||
if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click();
|
||||
const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) });
|
||||
const base = (id) => row(id).locator(".mprice > .mpart input");
|
||||
const tier = (id) => row(id).locator(".mtier input");
|
||||
const typeIn = async (box, v) => { await box.fill(v); await box.press("Enter"); };
|
||||
// Blurring the previous price normalizes its siblings before fill selects their text.
|
||||
const typeIn = async (box, v) => { await box.focus(); await box.fill(v); await box.press("Enter"); };
|
||||
const y = await page.evaluate(() => scrollY);
|
||||
|
||||
// a list with a long-context price shows it in its quiet row, greyed
|
||||
@@ -90,6 +91,22 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert(await row("plain-1").locator(".mtier-add").isVisible());
|
||||
assert.equal(await row("plain-1").locator("input[type=number]").count(), 0, "no number boxes");
|
||||
|
||||
// Closing the browser while typing has not fired change/blur yet.
|
||||
// Both new price kinds must protect the raw input, and reverting it is clean.
|
||||
const leaveAsked = () => page.evaluate(() => {
|
||||
const event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
return event.defaultPrevented;
|
||||
});
|
||||
assert.equal(await leaveAsked(), false, "unchanged prices ask nothing");
|
||||
for (const [box, value] of [[base("claude-opus-5-5").last(), "7,5"], [tier("gpt-6-astra").first(), "350K"], [tier("gpt-6-astra").nth(1), "33"]]) {
|
||||
await box.fill(value);
|
||||
assert.equal(await leaveAsked(), true, "a focused price edit asks before leaving");
|
||||
await box.fill("");
|
||||
assert.equal(await leaveAsked(), false, "reverting the raw edit asks nothing");
|
||||
}
|
||||
assert.deepEqual(posts, [], "typing alone has saved nothing");
|
||||
|
||||
// decimals, with a point and with a comma, each kept as typed
|
||||
await typeIn(row("gpt-6-astra").getByRole("textbox", { name: L.cacheRead, exact: true }), "0,25");
|
||||
await typeIn(row("gpt-6-astra").getByRole("textbox", { name: L.output, exact: true }), "1.5");
|
||||
|
||||
@@ -125,6 +125,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await editor.locator(".mchips .mchip.auto").count(), 0);
|
||||
assert.equal(await scrolled(), before, "the page moved");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
|
||||
// an agent's model picker
|
||||
|
||||
@@ -75,8 +75,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await editor.locator(".models > .mchips .mchip").first().waitFor();
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
};
|
||||
const close = async () => {
|
||||
const close = async (discard = false) => {
|
||||
await page.keyboard.press("Escape");
|
||||
if (discard) await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
};
|
||||
const fold = editor.locator(".mchips-fold");
|
||||
@@ -118,7 +119,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await editor.locator(".models > .mchips .mchip", { hasText: "a-rather-long-model-name-2" }).first().click();
|
||||
await settle();
|
||||
assert.equal((await box()).shown, out.all, "a pick folded the list");
|
||||
await close();
|
||||
await close(true);
|
||||
|
||||
// the next editor opens let out too, and folds back
|
||||
await open("Other Relay");
|
||||
|
||||
@@ -68,7 +68,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", serve(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator(".row.provider").click();
|
||||
const relay = page.locator('.row.provider[data-id="relay"]');
|
||||
await relay.click();
|
||||
const rename = page.locator(".mfoot button", { hasText: w.names });
|
||||
await rename.waitFor();
|
||||
const open = page.locator(".mnames:not([hidden])");
|
||||
@@ -103,7 +104,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await page.locator(".mnames:not([hidden])").evaluate((e) => getComputedStyle(e).borderLeftStyle), "none");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
await page.locator(".row.provider").click();
|
||||
await relay.click();
|
||||
await page.locator(".mfoot button", { hasText: w.names }).waitFor();
|
||||
assert.equal(await page.locator(".mnames:not([hidden])").count(), 0, "a fresh editor opens it folded");
|
||||
assert.equal(await page.locator("#modal select").count(), 0);
|
||||
|
||||
@@ -71,6 +71,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const own = page.locator(".accts .acc", { hasText: "Kiro account" }).getByRole("button", { name: remove[lang], exact: true });
|
||||
assert.equal(await own.getAttribute("title"), title[lang]);
|
||||
await own.click();
|
||||
assert.deepEqual(posts, [], "Remove waits for confirmation");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
for (let i = 0; i < 50 && !posts.length; i++) await page.waitForTimeout(50);
|
||||
assert.deepEqual(posts, [{ path: "/api/login/forget", body: { agent: "kiro", user: "Kiro account" } }]);
|
||||
assert.deepEqual(errors, []);
|
||||
@@ -82,6 +84,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const own = page.locator(".accts .acc", { hasText: "Kiro account" }).getByRole("button", { name: remove[lang], exact: true });
|
||||
assert.equal(await own.getAttribute("title"), title[lang]);
|
||||
await own.click();
|
||||
assert.deepEqual(posts, [], "Remove waits for confirmation");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
for (let i = 0; i < 50 && !posts.length; i++) await page.waitForTimeout(50);
|
||||
assert.deepEqual(posts, [{ path: "/api/login/forget", body: { agent: "kiro", user: "Kiro account" } }]);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
@@ -53,6 +53,37 @@ const box = (page, s) => page.locator(s).first().boundingBox();
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
const phone = { ...devices["iPhone 13"] };
|
||||
if (engine === "chromium") delete phone.defaultBrowserType;
|
||||
test(`${engine}: phone layout is ready while app.js is still downloading`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const context = await browser.newContext({ ...phone, reducedMotion: "reduce" });
|
||||
const page = await context.newPage();
|
||||
let release;
|
||||
const pending = new Promise((resolve) => { release = resolve; });
|
||||
t.after(() => release());
|
||||
const handle = serve(true);
|
||||
await page.route("**/*", async (route) => {
|
||||
if (new URL(route.request().url()).pathname === "/app.js") await pending;
|
||||
await handle(route);
|
||||
});
|
||||
const loaded = page.goto("http://magpie.test/");
|
||||
try {
|
||||
await page.waitForSelector("#nav", { state: "visible" });
|
||||
const [nav, actions] = [await box(page, "#nav"), await box(page, ".top .actions")];
|
||||
assert.ok(nav.y >= actions.y + actions.height - 1, "tabs already have their own row before app.js runs");
|
||||
assert.equal(await page.locator("#open").isVisible(), false, "the window button never flashes");
|
||||
assert.equal(await page.locator("#winclose").isVisible(), false, "the close button never flashes");
|
||||
assert.match(await page.locator('meta[name="viewport"]').getAttribute("content"), /maximum-scale=1/, "the existing iOS viewport policy applies before app.js runs");
|
||||
release();
|
||||
await loaded;
|
||||
const readyNav = await box(page, "#nav");
|
||||
assert.ok(Math.abs(readyNav.y - nav.y) <= 1, "the navigation stays in its initial row after startup");
|
||||
} finally {
|
||||
release();
|
||||
await loaded;
|
||||
}
|
||||
});
|
||||
|
||||
test(`${engine}: magpie web on a phone`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
|
||||
@@ -222,6 +222,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await cp.locator(".pm-chip.up").waitFor({ state: "detached" });
|
||||
assert.deepEqual(asked.find(([k]) => k === "upgrade")[1], { spec: "opencode-copilot-auth" });
|
||||
await rows.filter({ hasText: "opencode-broken" }).locator("button", { hasText: w.remove }).click();
|
||||
assert.deepEqual(asked.filter(([k]) => k === "remove"), [], "Remove waits for confirmation");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll("#view-plugins .pm-row").length === 1);
|
||||
assert.equal(await view.evaluate((e) => e.scrollTop), top, "a click doesn't move the page");
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const state = { bun: true, bunVersion: "1.3.0", plugins: [
|
||||
{ spec: BOTH, providers: ["Acme"], moved: [], isMiddleware: true, middleware: { hooks: ["onResponse"], calls: 0, avgMicros: 0, failures: 0 } },
|
||||
] };
|
||||
|
||||
function server(lang) {
|
||||
function server(lang, pluginState = state) {
|
||||
return async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
@@ -32,7 +32,7 @@ function server(lang) {
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
|
||||
if (url.pathname === "/api/gateway/trace") return json({ routes: [] });
|
||||
if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") return json(url.pathname === "/api/plugins" ? state : url.pathname === "/api/plugins/listings" ? { listings: [] } : { listings: [], state });
|
||||
if (url.pathname === "/api/plugins/market" || url.pathname === "/api/plugins" || url.pathname === "/api/plugins/listings") return json(url.pathname === "/api/plugins" ? pluginState : url.pathname === "/api/plugins/listings" ? { listings: [] } : { listings: [], state: pluginState });
|
||||
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)];
|
||||
@@ -149,8 +149,14 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [], posted = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang));
|
||||
await page.route("**/api/plugins/options", (r) => { posted.push(r.request().postDataJSON()); return r.fulfill({ json: state }); });
|
||||
const pluginState = structuredClone(state);
|
||||
await page.route("**/*", server(lang, pluginState));
|
||||
await page.route("**/api/plugins/options", (r) => {
|
||||
const body = r.request().postDataJSON();
|
||||
posted.push(body);
|
||||
pluginState.plugins[0].options = body.options;
|
||||
return r.fulfill({ json: pluginState });
|
||||
});
|
||||
await page.goto("http://magpie.test/?view=plugins");
|
||||
const view = page.locator("#view-plugins");
|
||||
await view.locator(".lib-tabs .opt", { hasText: w.installed }).click();
|
||||
@@ -163,6 +169,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await alias.locator("button", { hasText: w.options }).click();
|
||||
const ta = alias.locator(".pm-opts textarea");
|
||||
await ta.waitFor();
|
||||
assert.equal(await ta.getAttribute("aria-label"), w.options);
|
||||
assert.equal(await page.evaluate(() => [document.scrollingElement.scrollTop, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop).map((e) => e.scrollTop)].join()), y, "the click scrolled nothing");
|
||||
assert.deepEqual(JSON.parse(await ta.inputValue()), { mapping: { fast: "deepseek-chat" } });
|
||||
assert.equal((await alias.locator(".pm-opts-bar > span").innerText()).trim(), w.note);
|
||||
@@ -170,6 +177,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await ta.fill("{ mapping: ");
|
||||
await alias.locator(".pm-opts button", { hasText: w.save }).click();
|
||||
assert.match((await alias.locator(".pm-opts-err").innerText()).trim(), w.bad);
|
||||
assert.equal(await ta.getAttribute("aria-invalid"), "true");
|
||||
assert.equal(await alias.locator(".pm-opts-err").getAttribute("role"), "alert");
|
||||
await alias.locator(".pm-opts textarea").fill("[1]");
|
||||
await alias.locator(".pm-opts button", { hasText: w.save }).click();
|
||||
assert.equal((await alias.locator(".pm-opts-err").innerText()).trim(), w.obj);
|
||||
@@ -178,6 +187,20 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await alias.locator(".pm-opts button", { hasText: w.save }).click();
|
||||
await alias.locator(".pm-opts").waitFor({ state: "detached" });
|
||||
assert.deepEqual(posted, [{ spec: ALIAS, options: { mapping: { fast: "glm-5" } } }]);
|
||||
await alias.getByRole("button", { name: w.options, exact: true }).click();
|
||||
const clear = alias.getByRole("button", { name: lang === "zh" ? "清空" : "Clear", exact: true });
|
||||
await clear.click();
|
||||
const confirm = page.getByRole("alertdialog");
|
||||
await confirm.waitFor();
|
||||
assert.match(await confirm.textContent(), lang === "zh" ? /不带选项运行/ : /run with no options/);
|
||||
assert.equal(posted.length, 1, "Clear waits for confirmation");
|
||||
await confirm.getByRole("button", { name: lang === "zh" ? "取消" : "Cancel", exact: true }).click();
|
||||
assert.equal(posted.length, 1, "Cancel keeps the saved options");
|
||||
assert.deepEqual(JSON.parse(await ta.inputValue()), { mapping: { fast: "glm-5" } });
|
||||
await clear.click();
|
||||
await confirm.locator("button").last().click();
|
||||
await alias.locator(".pm-opts").waitFor({ state: "detached" });
|
||||
assert.deepEqual(posted[1], { spec: ALIAS, options: null });
|
||||
const borders = await view.locator(".pm-row *").evaluateAll((els) => els.filter((e) => { const s = getComputedStyle(e); return parseFloat(s.borderLeftWidth) > 0 && s.borderLeftStyle !== "none" && parseFloat(s.borderRightWidth) === 0; }).length);
|
||||
assert.equal(borders, 0);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
@@ -97,6 +97,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await button(w.free).click();
|
||||
assert.deepEqual(await picked(), [...free]);
|
||||
await button(w.cancel).click();
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await editor.waitFor({ state: "detached" });
|
||||
await open();
|
||||
assert.deepEqual(await picked(), [], "Free only undone by Cancel");
|
||||
@@ -105,6 +106,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// Free only, then Escape: the same
|
||||
await button(w.free).click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await editor.waitFor({ state: "detached" });
|
||||
await open();
|
||||
assert.deepEqual(await picked(), [], "Free only undone by Escape");
|
||||
|
||||
@@ -50,7 +50,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const zh = lang === "zh";
|
||||
const L = { names: zh ? "名称与推理档位" : "Names & levels", images: zh ? "支持图片输入" : "Accepts images", low: zh ? "低" : "low", high: zh ? "高" : "high", unsaved: zh ? "未保存" : "unsaved", save: zh ? "保存" : "Save", cancel: zh ? "取消" : "Cancel", reset: zh ? "恢复默认" : "Restore default" };
|
||||
const open = async () => {
|
||||
await page.locator(".row.provider").click();
|
||||
await page.locator('.row.provider[data-id="relay"]').click();
|
||||
// it stays open from one opening of the editor to the next
|
||||
if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click();
|
||||
};
|
||||
@@ -75,6 +75,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await row("model-3").getByRole("checkbox", { name: zh ? "中" : "medium", exact: true }).isChecked(), true, "Restore default ticks every level");
|
||||
// Cancel drops them; opened again, the editor is as saved
|
||||
await page.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
await open();
|
||||
assert.equal(await row("model-1").getByRole("checkbox", { name: L.low, exact: true }).isChecked(), true);
|
||||
assert.equal(await row("model-1").getByText(L.unsaved, { exact: true }).isVisible(), false);
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// Hundreds of keys on one provider (361 on Discord: 导入了几百个 key,账号列表
|
||||
// 铺满整屏): the Accounts list shows the first five and All 300 keys; opened,
|
||||
// a filter, a page of 50 rows and how many more, and removing many at once
|
||||
// (those turned off, those failing for good, those the filter matches) on a
|
||||
// second click. Moving a shown key sends every key's place. Several keys
|
||||
// (those turned off, those failing for good, those the filter matches) after
|
||||
// an in-app confirmation. Moving a shown key sends every key's place. Several keys
|
||||
// pasted into a provider's API key field are kept and counted. No click
|
||||
// scrolls, there is no <select>, nothing has a left border. In English and
|
||||
// Chinese.
|
||||
@@ -68,8 +68,8 @@ function serve(lang, posts) {
|
||||
}
|
||||
|
||||
const words = {
|
||||
en: { all: "All 300 keys", fewer: "Show fewer", more: "250 more — type to narrow them down", failing: "Remove 3 failing", armed: "Remove 3 keys? Click again", off: "Remove 6 turned off", matching: /^Remove the \d+ matching$/, count: "3 keys", of: "1 of 300 keys" },
|
||||
zh: { all: "全部 300 个密钥", fewer: "收起", more: "还有 250 个,输入关键词缩小范围", failing: "移除 3 个失效的", armed: "移除 3 个密钥?再点一次", off: "移除 6 个已停用的", matching: /^移除筛出的 \d+ 个$/, count: "3 个密钥", of: "300 个密钥中的 1 个" },
|
||||
en: { all: "All 300 keys", fewer: "Show fewer", more: "250 more — type to narrow them down", failing: "Remove 3 failing", off: "Remove 6 turned off", matching: /^Remove the \d+ matching$/, count: "3 keys", of: "1 of 300 keys" },
|
||||
zh: { all: "全部 300 个密钥", fewer: "收起", more: "还有 250 个,输入关键词缩小范围", failing: "移除 3 个失效的", off: "移除 6 个已停用的", matching: /^移除筛出的 \d+ 个$/, count: "3 个密钥", of: "300 个密钥中的 1 个" },
|
||||
};
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
@@ -119,6 +119,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.locator(".keys-more").click();
|
||||
const filter = page.locator(".keys-tools .keys-filter");
|
||||
await filter.waitFor();
|
||||
assert.equal(await filter.getAttribute("aria-label"), await filter.getAttribute("placeholder"));
|
||||
assert.equal(await scrolled(page), sc, "opening scrolled");
|
||||
assert.ok(await filter.evaluate((i) => document.activeElement === i), "the filter has the focus");
|
||||
assert.equal(await rows(page).count(), 50, "opened, a page of 50 rows");
|
||||
@@ -141,7 +142,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test(`${engine} ${lang}: failing keys go at once, on a second click`, async (t) => {
|
||||
test(`${engine} ${lang}: bulk key removal waits for confirmation across editor redraws`, async (t) => {
|
||||
const { page, errors, posts } = await open(t, "remove");
|
||||
await page.locator(".keys-more").click();
|
||||
await page.locator(".keys-tools").waitFor();
|
||||
@@ -149,12 +150,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// nothing typed, nothing to remove by the filter
|
||||
assert.equal(await page.locator(".keys-tools .keys-remove").filter({ hasText: w.matching }).count(), 0);
|
||||
const sc = await scrolled(page);
|
||||
// a handle, not a locator: the button's name changes as it arms
|
||||
const failing = await page.getByRole("button", { name: w.failing }).elementHandle();
|
||||
const failing = page.getByRole("button", { name: w.failing });
|
||||
const ask = page.locator("dialog.action-confirm[open]");
|
||||
await failing.click();
|
||||
assert.equal((await failing.textContent()).trim(), w.armed);
|
||||
await ask.waitFor();
|
||||
assert.match(await ask.locator("h2").textContent(), /sk-…0010, sk-…0020, sk-…0030/);
|
||||
assert.equal(posts.length, 0, "the first click only asks");
|
||||
await page.locator(".keys-tools .keys-remove[data-armed]").click();
|
||||
await page.evaluate(() => renderProviders());
|
||||
assert.equal(await ask.evaluate((d) => d.inert), false, "redrawing leaves the confirmation interactive");
|
||||
await ask.locator("button").first().click();
|
||||
assert.equal(posts.length, 0, "Cancel removes nothing");
|
||||
assert.match(await page.locator(".keys-count").textContent(), /300/);
|
||||
await failing.click();
|
||||
await ask.locator("button").last().click();
|
||||
const last = await posted(posts, 1);
|
||||
assert.equal(last.path, "/api/keys/remove-many");
|
||||
assert.deepEqual(last.body, { id: "relay", refs: [idOf(10), idOf(20), idOf(30)] });
|
||||
@@ -164,7 +172,23 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
// a typed filter offers removing what it matches
|
||||
await page.locator(".keys-filter").fill("sk-…004");
|
||||
await page.locator(".keys-filter").dispatchEvent("input");
|
||||
await page.locator(".keys-tools .keys-remove").filter({ hasText: w.matching }).waitFor();
|
||||
const matching = page.locator(".keys-tools .keys-remove").filter({ hasText: w.matching });
|
||||
await matching.waitFor();
|
||||
await matching.click();
|
||||
await ask.waitFor();
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(posts.length, 1, "Escape keeps matching keys");
|
||||
await matching.click();
|
||||
await ask.locator("button").last().click();
|
||||
assert.deepEqual((await posted(posts, 2)).body, { id: "relay", refs: Array.from({ length: 10 }, (_, i) => idOf(40 + i)) });
|
||||
await page.waitForFunction(() => /287/.test(document.querySelector(".keys-count")?.textContent || ""));
|
||||
await page.locator(".keys-filter").fill("");
|
||||
const off = page.locator(".keys-tools .keys-remove").first();
|
||||
await off.click();
|
||||
await ask.waitFor();
|
||||
assert.equal(posts.length, 2, "turned-off keys also wait for confirmation");
|
||||
await ask.locator("button").last().click();
|
||||
assert.deepEqual((await posted(posts, 3)).body, { id: "relay", refs: [99, 149, 199, 249, 299].map(idOf) });
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await button(label).click();
|
||||
await until(() => posts.slice(n).some((p) => p.path === "/api/provider/" + action) && gate.waiting > 0);
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
await open("GPT Relay");
|
||||
release();
|
||||
|
||||
@@ -86,6 +86,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.waitForTimeout(150);
|
||||
const top = await del.evaluate((e) => e.getBoundingClientRect().top);
|
||||
await del.click();
|
||||
assert.deepEqual(deletes, [], "Remove waits for confirmation");
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
return { page, errors, deletes, top, del };
|
||||
};
|
||||
const rows = (page) => page.locator(".row.provider").allTextContents();
|
||||
|
||||
@@ -52,7 +52,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const zh = lang === "zh";
|
||||
const L = { names: zh ? "名称与推理档位" : "Names & levels", same: zh ? "等同于" : "Same as", unsaved: zh ? "未保存" : "unsaved", save: zh ? "保存" : "Save", cancel: zh ? "取消" : "Cancel", reset: zh ? "恢复默认" : "Restore default" };
|
||||
const open = async () => {
|
||||
await page.locator(".row.provider").click();
|
||||
await page.locator('.row.provider[data-id="relay"]').click();
|
||||
if (!(await page.locator(".mnames:not([hidden])").count())) await page.getByRole("button", { name: L.names, exact: true }).click();
|
||||
};
|
||||
const row = (id) => page.locator(".mname", { has: page.locator("code", { hasText: id }) });
|
||||
@@ -75,6 +75,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
for (const id of ["deepseek-v4-1-flash-260910", "ep-2026-sol"]) assert(await row(id).getByText(L.unsaved, { exact: true }).isVisible(), id + " says it is unsaved");
|
||||
// Cancel drops them
|
||||
await page.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await page.locator("dialog.action-confirm[open] button").last().click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
await open();
|
||||
assert.equal(await box("deepseek-v4-1-flash-260910").inputValue(), "");
|
||||
assert.equal(await box("ep-2026-sol").inputValue(), "sol");
|
||||
|
||||
@@ -113,6 +113,7 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y, "a click never scrolls the page");
|
||||
// Cancel drops them; opened again, the editor is as saved
|
||||
await page.getByRole("button", { name: L.cancel, exact: true }).click();
|
||||
await page.getByRole("alertdialog").locator("button").last().click();
|
||||
await open();
|
||||
assert.match(await opt(L.routing, zh ? "智能" : "Smart").getAttribute("class"), /\bon\b/);
|
||||
assert.match(await opt(L.stays, zh ? "自动" : "Auto").getAttribute("class"), /\bon\b/);
|
||||
|
||||
@@ -140,7 +140,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
assert.equal((await behind.textContent()).trim(), w.behind);
|
||||
assert.equal(await behind.getAttribute("title"), w.behindTip);
|
||||
assert.equal(await page.locator("#view-library .lib-skill", { hasText: "tdd" }).locator(".lib-behind").count(), 0);
|
||||
await press(page, behind);
|
||||
assert.equal(await behind.getAttribute("role"), "button");
|
||||
await behind.focus();
|
||||
await behind.press(lang === "en" ? "Enter" : "Space");
|
||||
await page.waitForTimeout(300);
|
||||
assert.deepEqual(posts[1], { path: "sync" });
|
||||
assert.equal(await page.locator("#view-library .lib-behind").count(), 0, "no copy is behind after the sync");
|
||||
@@ -178,9 +180,19 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.waitForTimeout(300);
|
||||
await sheet.locator(".bar button.primary").click();
|
||||
await sheet.waitFor({ state: "detached" });
|
||||
assert.equal(await page.getByRole("alertdialog").count(), 0, "saved skill choices close without a discard question");
|
||||
assert.equal((await bar.locator(".lib-howagent").textContent()).trim(), w.byAgentOne, "the button counts the agents with a way of their own");
|
||||
assert.equal(await scrolled(page), before, "the sheet scrolls nothing");
|
||||
|
||||
await press(page, bar.locator(".lib-howagent"));
|
||||
await sheet.waitFor();
|
||||
await press(page, sheet.locator('.lib-how-row[data-agent="codex"] .segs .opt').nth(2));
|
||||
await page.waitForTimeout(300);
|
||||
if (lang === "en") await page.keyboard.press("Escape");
|
||||
else await page.evaluate(() => { show("agents"); });
|
||||
await sheet.waitFor({ state: "detached" });
|
||||
assert.equal(await page.getByRole("alertdialog").count(), 0, "Escape and page changes keep already saved choices");
|
||||
|
||||
assert.equal(await page.locator("select").count(), 0, "no native select");
|
||||
assert.deepEqual(await leftBorders(page, "#lib-skillhow, #view-library .lib-skill"), []);
|
||||
});
|
||||
|
||||
@@ -55,6 +55,9 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
const strip = page.locator("#sessAgent");
|
||||
await page.waitForFunction((n) => document.querySelectorAll("#sessAgent .opt").length === n, names.length + 1);
|
||||
const settle = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
const all = strip.locator('.opt[data-agent="all"]');
|
||||
assert.equal(await all.getAttribute("aria-pressed"), "true", "All starts selected for assistive technology");
|
||||
assert.equal(await strip.getByRole("button", { pressed: false }).count(), names.length);
|
||||
for (const width of [860, 560]) {
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
await settle();
|
||||
@@ -73,6 +76,8 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
|
||||
await page.mouse.click(lb.x + lb.width / 2, lb.y + lb.height / 2);
|
||||
await page.waitForFunction(() => document.querySelector("#sessAgent .opt.on")?.textContent === "Zed");
|
||||
await settle();
|
||||
assert.equal(await strip.getByRole("button", { pressed: true }).textContent(), "Zed", "the reused button exposes its new selection");
|
||||
assert.equal(await all.getAttribute("aria-pressed"), "false", "the previous selection is cleared");
|
||||
const on = await strip.locator(".opt.on").boundingBox();
|
||||
const sb = await strip.boundingBox();
|
||||
const seen = Math.min(on.x + on.width, sb.x + sb.width) - Math.max(on.x, sb.x);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
// Editor safety and accessible confirmations, using isolated APIs and the real frontend.
|
||||
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");
|
||||
|
||||
function fixture(lang, posts) {
|
||||
let settings = { theme: "light", lang, tray: "panel", currency: "usd", textSize: 100,
|
||||
version: "0.1.700", dir: "~/.config/magpie", gateway: "http://127.0.0.1:3999",
|
||||
proxy: "", proxyNow: "none", proxySource: "none", redactWords: [], visionModels: [], imageGenModels: [],
|
||||
workbuddyCheckins: [], lanURLs: [], otel: {}, fx: { rate: 7.2, at: new Date().toISOString(), stale: false } };
|
||||
const relay = { id: "relay", name: "Relay", icon: "generic", host: "relay.test", chat: "https://relay.test/v1",
|
||||
responses: "", anthropic: "", models: [{ id: "fixture-model", name: "Fixture model", on: true }], agents: [], fallback: [], headers: { "X-Fixture": "kept" },
|
||||
key: { set: true, masked: "sk-…1234" }, keyList: [], ready: true };
|
||||
const providers = { providers: [relay], presets: [], excluded: [], gateway: { running: true, window: true } };
|
||||
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 = ${JSON.stringify({ lang: settings.lang, theme: settings.theme, textSize: settings.textSize, web: true })};` });
|
||||
if (req.method() === "POST") posts.push({ path: url.pathname, body: req.postDataJSON() });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings });
|
||||
if (url.pathname === "/api/settings") {
|
||||
if (req.method() === "POST") settings = { ...settings, ...req.postDataJSON() };
|
||||
return json(settings);
|
||||
}
|
||||
if (url.pathname === "/api/providers") return json(providers);
|
||||
if (url.pathname === "/api/provider/delete") { providers.providers = []; return json(providers); }
|
||||
if (url.pathname === "/api/provider/save") return json(providers);
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], models: [] });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname === "/api/plugins") return json({ plugins: [] });
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType: { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)] }); }
|
||||
catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
async function answer(page, action, accept, text, escape = false) {
|
||||
await action();
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await dialog.waitFor();
|
||||
assert.match(await dialog.textContent(), text);
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const layout = await dialog.evaluate((element, theme) => {
|
||||
const root = document.documentElement, previous = root.dataset.theme;
|
||||
root.dataset.theme = theme;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element.querySelector("button:last-child"));
|
||||
const luminance = (color) => color.match(/[\d.]+/g).slice(0, 3).map((value) => {
|
||||
const n = Number(value) / 255; return n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4;
|
||||
}).reduce((sum, n, i) => sum + n * [.2126, .7152, .0722][i], 0);
|
||||
const fg = luminance(style.color), bg = luminance(style.backgroundColor);
|
||||
const result = { x: rect.left + rect.width / 2 - innerWidth / 2, y: rect.top + rect.height / 2 - innerHeight / 2,
|
||||
contrast: (Math.max(fg, bg) + .05) / (Math.min(fg, bg) + .05) };
|
||||
if (previous === undefined) delete root.dataset.theme; else root.dataset.theme = previous;
|
||||
return result;
|
||||
}, theme);
|
||||
assert.ok(Math.abs(layout.x) <= 1 && Math.abs(layout.y) <= 1, `${theme}: confirmation is centered`);
|
||||
assert.ok(layout.contrast >= 4.5, `${theme}: destructive button contrast ${layout.contrast}`);
|
||||
}
|
||||
assert.equal(await dialog.evaluate((element) => element.contains(document.activeElement)), true);
|
||||
await dialog.locator("button").last().focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(await dialog.evaluate((element) => element.contains(document.activeElement)), true, "confirmation traps focus");
|
||||
if (accept) await dialog.locator("button").last().click();
|
||||
else if (escape) await page.keyboard.press("Escape");
|
||||
else await dialog.getByRole("button", { name: /^(Cancel|取消)$/ }).click();
|
||||
await dialog.waitFor({ state: "detached" });
|
||||
}
|
||||
|
||||
for (const engine of process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"]) {
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`${engine} ${lang}: drafts, accessible dialogs and immediate deletion`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(5000);
|
||||
const posts = [], errors = [];
|
||||
page.on("dialog", (dialog) => { errors.push("Unexpected native dialog: " + dialog.type()); dialog.dismiss(); });
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", fixture(lang, posts));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
const dirtyText = lang === "zh" ? /放弃未保存/ : /Discard unsaved/;
|
||||
const saveName = lang === "zh" ? "保存" : "Save";
|
||||
const cancelName = lang === "zh" ? "取消" : "Cancel";
|
||||
const row = page.locator('#providers .row.provider[data-id="relay"]');
|
||||
await row.click();
|
||||
const modal = page.locator("#modal");
|
||||
await page.getByRole("dialog").waitFor();
|
||||
assert.equal(await page.getByRole("dialog").getAttribute("aria-modal"), "true");
|
||||
assert.equal(await page.locator("header").evaluate((e) => e.inert), true);
|
||||
assert.ok(await page.locator("#modal label").evaluateAll((labels) => labels.filter((l) => l.htmlFor).every((l) => !!l.control)), "labels refer to controls");
|
||||
// Focus cycles in both directions, never entering the dimmed background.
|
||||
await page.locator("#modal .bar button:last-child").focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assert.equal(await page.evaluate(() => !!document.activeElement.closest("#modal")), true);
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
assert.equal(await page.evaluate(() => !!document.activeElement.closest("#modal")), true);
|
||||
await modal.getByRole("button", { name: cancelName, exact: true }).click();
|
||||
await modal.waitFor({ state: "hidden" }); // untouched form needs no confirmation
|
||||
await row.click();
|
||||
await modal.getByRole("button", { name: lang === "zh" ? "名称与推理档位" : "Names & levels", exact: true }).click();
|
||||
await modal.getByRole("button", { name: cancelName, exact: true }).click();
|
||||
await modal.waitFor({ state: "hidden" }); // opening model settings alone is not an edit
|
||||
assert.equal(await page.getByRole("alertdialog").count(), 0);
|
||||
await row.click();
|
||||
const url = modal.locator('input[type="url"]').first();
|
||||
const initial = await url.inputValue();
|
||||
await url.fill("https://changed.test/v1");
|
||||
await answer(page, () => url.press("Escape"), false, dirtyText);
|
||||
assert.equal(await url.inputValue(), "https://changed.test/v1");
|
||||
assert.equal(await modal.isVisible(), true);
|
||||
// Clicking the scrim is also guarded.
|
||||
await answer(page, () => page.mouse.click(2, 2), false, dirtyText, true);
|
||||
assert.equal(await modal.isVisible(), true);
|
||||
// A reverted field is clean; saving commits without a discard prompt.
|
||||
await url.fill(initial);
|
||||
await modal.getByRole("button", { name: cancelName, exact: true }).click();
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
await row.click();
|
||||
await modal.locator('input[type="url"]').first().fill("https://saved.test/v1");
|
||||
await modal.getByRole("button", { name: saveName, exact: true }).click();
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
assert.equal(posts.filter((p) => p.path === "/api/provider/save").length, 1);
|
||||
await row.click();
|
||||
await modal.locator('input[type="url"]').first().fill("https://discarded.test/v1");
|
||||
await answer(page, () => modal.getByRole("button", { name: cancelName, exact: true }).click(), true, dirtyText);
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
// Draft-only removals apply without a confirmation; canceling the draft restores them.
|
||||
await row.click();
|
||||
await modal.locator(".headers .pair .danger").click();
|
||||
assert.equal(await modal.locator(".headers .pair").count(), 0);
|
||||
assert.equal(await page.getByRole("alertdialog").count(), 0);
|
||||
await answer(page, () => modal.getByRole("button", { name: cancelName, exact: true }).click(), true, dirtyText);
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
await row.click();
|
||||
assert.equal(await modal.locator(".headers .pair").count(), 1);
|
||||
await modal.locator('input[type="url"]').first().fill("https://leave.test/v1");
|
||||
assert.equal(await page.evaluate(() => {
|
||||
const leaving = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(leaving);
|
||||
return leaving.defaultPrevented;
|
||||
}), true, "a dirty editor guards browser leave");
|
||||
const leave = () => page.evaluate(() => { show("agents"); });
|
||||
await answer(page, leave, false, dirtyText);
|
||||
assert.equal(await modal.locator('input[type="url"]').first().inputValue(), "https://leave.test/v1");
|
||||
const fromModel = () => page.evaluate(() => { window.newGroupWith("relay/fixture-model", "Fixture model"); });
|
||||
await answer(page, fromModel, false, dirtyText);
|
||||
assert.equal(await page.locator("#view-providers").evaluate((e) => e.hidden), false, "Cancel keeps the provider page when creating a group from its model");
|
||||
assert.equal(await page.locator(".rt-gedit").count(), 0, "Cancel creates no routing draft behind the provider editor");
|
||||
assert.equal(await modal.locator('input[type="url"]').first().inputValue(), "https://leave.test/v1");
|
||||
await answer(page, leave, true, dirtyText);
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
await page.waitForFunction(() => !document.querySelector("#view-agents").hidden);
|
||||
await page.locator('#nav button[data-view="providers"]').click();
|
||||
// Removal never mutates anything when declined.
|
||||
await row.click();
|
||||
const remove = modal.getByRole("button", { name: lang === "zh" ? "移除" : "Remove", exact: true }).first();
|
||||
await answer(page, async () => {
|
||||
await remove.click();
|
||||
const dialog = page.locator("dialog.action-confirm[open]");
|
||||
await dialog.waitFor();
|
||||
await page.evaluate(() => { renderProviders(); renderProviders(); });
|
||||
assert.equal(await dialog.evaluate((element) => element.inert), false, "redrawing the editor keeps the confirmation interactive");
|
||||
assert.equal(await page.locator("header").evaluate((element) => element.inert), true, "the background stays inert through redraws");
|
||||
}, false, /Relay/);
|
||||
assert.equal(posts.filter((p) => p.path === "/api/provider/delete").length, 0);
|
||||
assert.equal(await modal.isVisible(), true);
|
||||
await answer(page, () => remove.click(), true, /Relay/);
|
||||
await modal.waitFor({ state: "hidden" });
|
||||
assert.equal(posts.filter((p) => p.path === "/api/provider/delete").length, 1);
|
||||
assert.equal(await page.locator('#nav button[data-view="providers"]').getAttribute("aria-current"), "page");
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
test(`${engine} ${lang}: modal redraw keeps menus interactive and restores only its background`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.route("**/*", fixture(lang, []));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.evaluate(() => {
|
||||
const element = document.createElement("div");
|
||||
element.id = "already-inert";
|
||||
element.inert = true;
|
||||
document.body.append(element);
|
||||
});
|
||||
await page.locator('#providers .row.provider[data-id="relay"]').click();
|
||||
await page.getByRole("dialog").waitFor();
|
||||
await page.evaluate(() => {
|
||||
const element = document.createElement("div");
|
||||
element.id = "later-inert";
|
||||
element.inert = true;
|
||||
document.body.append(element);
|
||||
openProtoMenu(document.querySelector('#modal input[type="url"]'),
|
||||
[{ v: "one", name: "One" }, { v: "two", name: "Two" }], "one",
|
||||
(value) => { document.body.dataset.menuChoice = value; });
|
||||
// The provider list intentionally closes menus; redraw just the editor here.
|
||||
openModal(renderEditor(providers.providers.find((provider) => provider.id === "relay")));
|
||||
});
|
||||
const menu = page.getByRole("menu");
|
||||
assert.equal(await menu.evaluate((element) => element.inert), false);
|
||||
await menu.getByRole("menuitemradio", { name: "Two", exact: true }).click();
|
||||
assert.equal(await page.locator("body").getAttribute("data-menu-choice"), "two");
|
||||
await page.locator("#modal").getByRole("button", { name: lang === "zh" ? "取消" : "Cancel", exact: true }).click();
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
assert.equal(await page.locator("header").evaluate((element) => element.inert), false);
|
||||
for (const id of ["already-inert", "later-inert"]) {
|
||||
assert.equal(await page.locator("#" + id).evaluate((element) => element.inert), true, `${id} keeps its own inert state`);
|
||||
}
|
||||
});
|
||||
test(`${engine} ${lang}: clicks reach the page during modal closing and a reopened editor protects it again`, async (t) => {
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 800 } });
|
||||
await page.route("**/*", fixture(lang, []));
|
||||
await page.goto("http://magpie.test/?view=providers");
|
||||
await page.locator('#providers .row.provider[data-id="relay"]').click();
|
||||
const nav = page.locator('#nav button[data-view="providers"]');
|
||||
const point = await nav.boundingBox();
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#nav button[data-view="providers"]').addEventListener("click", () => {
|
||||
document.body.dataset.closeClick = "received";
|
||||
const modal = document.querySelector("#modal");
|
||||
document.body.dataset.clickedWhileClosing = String(!modal.hidden && modal.classList.contains("out"));
|
||||
}, { once: true, capture: true });
|
||||
cancelEdit();
|
||||
// Hold the real close animation at its start so the click cannot land after it finishes.
|
||||
const modal = document.querySelector("#modal");
|
||||
for (const a of [...modal.getAnimations(), ...modal.firstElementChild.getAnimations()]) a.pause();
|
||||
});
|
||||
await page.mouse.click(point.x + point.width / 2, point.y + point.height / 2);
|
||||
assert.equal(await page.locator("body").getAttribute("data-close-click"), "received", "the closing editor does not swallow the page's click");
|
||||
assert.equal(await page.locator("body").getAttribute("data-clicked-while-closing"), "true", "the click arrived before the close animation finished");
|
||||
await page.evaluate(() => {
|
||||
const modal = document.querySelector("#modal");
|
||||
for (const a of [...modal.getAnimations(), ...modal.firstElementChild.getAnimations()]) a.finish();
|
||||
});
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
const row = page.locator('#providers .row.provider[data-id="relay"]');
|
||||
await row.click();
|
||||
const reopeningPoint = await row.boundingBox();
|
||||
const closing = await page.evaluate(() => {
|
||||
cancelEdit();
|
||||
const modal = document.querySelector("#modal");
|
||||
for (const a of [...modal.getAnimations(), ...modal.firstElementChild.getAnimations()]) a.pause();
|
||||
return !modal.hidden && modal.classList.contains("out");
|
||||
});
|
||||
assert.equal(closing, true);
|
||||
await page.mouse.click(reopeningPoint.x + reopeningPoint.width / 2, reopeningPoint.y + reopeningPoint.height / 2);
|
||||
assert.equal(await page.locator("header").evaluate((e) => e.inert), true, "reopening during close protects the background again");
|
||||
assert.equal(await page.locator("#modal").evaluate((m) => !m.hidden && !m.classList.contains("out")), true);
|
||||
await page.evaluate(() => {
|
||||
cancelEdit();
|
||||
const modal = document.querySelector("#modal");
|
||||
for (const a of [...modal.getAnimations(), ...modal.firstElementChild.getAnimations()]) a.finish();
|
||||
});
|
||||
await page.locator("#modal").waitFor({ state: "hidden" });
|
||||
assert.equal(await page.locator("header").evaluate((e) => e.inert), false, "the reopened editor also releases its background");
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user