From 2cc663d0a2bb437cfb7eb5c449db7af690252191 Mon Sep 17 00:00:00 2001 From: XYenon Date: Tue, 6 Oct 2026 06:57:49 +0800 Subject: [PATCH] gui: protect unsaved edits and confirm destructive actions (#820) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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/main 2f183c4c. 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 * 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 * 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 * 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/main c353f7bd, 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 * 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 * 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/main 2f135ed4 with 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 * gui: upstream's new removals and fields follow the editor-safety rules too — Audited GUI changes from the original PR base 2f183c4c through upstream/main 9debc583, 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 * 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 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 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/main 1330b966, 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 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 Amp-Thread-ID: https://ampcode.com/threads/T-01a108b0-c6a0-768d-ad6a-9a571a10a847 --------- Co-authored-by: Amp --- docs/subsystems/gui-shell.md | 10 + internal/gui/assets/app.css | 15 +- internal/gui/assets/app.js | 301 ++++++++++++++---- internal/gui/assets/i18n.js | 36 ++- internal/gui/assets/index.html | 10 +- internal/gui/assets/layout.js | 17 + internal/gui/assets/library.js | 45 ++- internal/gui/assets/plugins.js | 21 +- internal/gui/assets/routing.css | 1 - internal/gui/assets/routing.js | 58 ++-- internal/gui/assets/theme.js | 11 +- internal/gui/tests/api-key-usage.test.cjs | 5 + internal/gui/tests/codex-signout.test.cjs | 8 + internal/gui/tests/compact-at.test.cjs | 3 +- internal/gui/tests/group-bulk-remove.test.cjs | 17 +- internal/gui/tests/group-context.test.cjs | 4 + internal/gui/tests/group-fold.test.cjs | 1 + internal/gui/tests/group-member-info.test.cjs | 25 +- internal/gui/tests/group-new-head.test.cjs | 32 ++ internal/gui/tests/group-pick-filter.test.cjs | 1 + internal/gui/tests/key-weight.test.cjs | 1 + internal/gui/tests/market-have.test.cjs | 1 + internal/gui/tests/model-price-tiers.test.cjs | 21 +- internal/gui/tests/model-rate.test.cjs | 1 + internal/gui/tests/models-fold.test.cjs | 5 +- internal/gui/tests/names-fold.test.cjs | 5 +- internal/gui/tests/own-signin-remove.test.cjs | 4 + internal/gui/tests/phone-web.test.cjs | 31 ++ internal/gui/tests/plugin-market.test.cjs | 2 + internal/gui/tests/plugin-middleware.test.cjs | 31 +- .../gui/tests/provider-draft-picks.test.cjs | 2 + .../gui/tests/provider-levels-save.test.cjs | 4 +- .../gui/tests/provider-many-keys.test.cjs | 44 ++- .../gui/tests/provider-picks-stay.test.cjs | 1 + internal/gui/tests/provider-remove.test.cjs | 2 + internal/gui/tests/provider-same-as.test.cjs | 4 +- internal/gui/tests/routing-save.test.cjs | 1 + internal/gui/tests/skill-how.test.cjs | 14 +- .../tests/usage-sessions-agents-fit.test.cjs | 5 + internal/gui/tests/ux-safety.test.cjs | 265 +++++++++++++++ 40 files changed, 929 insertions(+), 136 deletions(-) create mode 100644 internal/gui/assets/layout.js create mode 100644 internal/gui/tests/ux-safety.test.cjs diff --git a/docs/subsystems/gui-shell.md b/docs/subsystems/gui-shell.md index eef0d708..d62080a5 100644 --- a/docs/subsystems/gui-shell.md +++ b/docs/subsystems/gui-shell.md @@ -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 diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 9b783d08..9df9b893 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2c6b7d07..17cfa996 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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 diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 30516390..dd40b6c3 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", }, }; diff --git a/internal/gui/assets/index.html b/internal/gui/assets/index.html index cd167bce..b3e6b44b 100644 --- a/internal/gui/assets/index.html +++ b/internal/gui/assets/index.html @@ -16,13 +16,16 @@ + +
magpie
-