provider, gui: a removed subscription's one account can be signed out, a failed sign-out keeps its error on screen (#874); a custom provider's Max output has the usual sizes under it and More endpoints' labels stay on one line (#875)

#874: Sign out… on a removed built-in with a single account of magpie's always failed with "magpie uses a@x.com first; put another account first". It was the first account and there was no other to put first. Also, the ask closed whatever the answer was, so the error only flashed. forgetSideLogin now lets the first account go when no other account is left, so the subscription ends with none; with two or more it still asks for another to be put first. The ask now closes only when the sign-out succeeded. When it fails, it stays open with the error and its button enabled again. TestForgetOnlyFirstLogin fails on the old code (an antigravity row with one account; ForgetAccount drops it, logins.json keeps none; with two, ForgetLogin of the first still refuses and ForgetAccount takes both). removed-forget.test.cjs adds "a failed sign-out stays with its error", which fails on the old app.js.

#875: Max output now has 8K, 16K, 32K, 64K and 128K picks under it, the same as Context window. A click fills the field, a size typed in lights its pick, a per-model value lights none, and Save sends it. Under More endpoints, the label column is as wide as its longest label, so Anthropic 地址 / Responses 地址 no longer wraps a last 址 under the label. provider-output-picks.test.cjs (en and zh) fails on the old code. Item 3 (section headings) is left for the owner.

Tests: WebKit, en and zh: provider-output-picks, provider-max-output, removed-forget, provider-search, balance-fix, own-signin-remove, deprecated-subscriptions, click-scroll and fallback-more pass. Also passing: go vet; builds for darwin, GOOS=linux nogui and GOOS=windows; and go test -tags nogui -count=1 ./... under a temp HOME, where TestUserPath failed once waiting on the login shell and passed on a re-run.
This commit is contained in:
yetone committed 2026-10-05 14:41:38 +08:00
1 parent 31626c1543
commit 37ec725916
6 files changed
+198 -10

No files matched your search

+4 -1
View File
@@ -2138,7 +2138,10 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; }
.editor .more summary::before { content: "▸"; font-size: 10px; color: var(--faint); }
.editor .more[open] summary::before { content: "▾"; }
.editor .more summary:hover { color: var(--fg); }
.editor .more .inner { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px 14px; align-items: start; padding-top: 8px; }
/* its labels are longer (Anthropic 地址, Responses-URL): their column takes
the longest, on one line, rather than wrap a last 址 under it (#875) */
.editor .more .inner { display: grid; grid-template-columns: minmax(72px, max-content) minmax(0, 1fr); gap: 10px 14px; align-items: start; padding-top: 8px; }
.editor .more .inner label { white-space: nowrap; }
.editor .warnbox { grid-column: 1 / -1; font-size: 12px; line-height: 1.5; color: var(--fg); background: color-mix(in srgb, var(--amber) 12%, transparent); border: 1px solid color-mix(in srgb, var(--amber) 35%, transparent); border-radius: 8px; padding: 8px 10px; }
.editor .warnbox.soft { background: none; color: var(--amber); border: 0; padding: 0 0 0 86px; }
.editor.import .mchips .mchip { cursor: default; }
+23 -4
View File
@@ -4668,8 +4668,10 @@ function askForgetAccount(x) {
e.stopPropagation();
go.disabled = true;
go.classList.add("busy");
await providerAction("forget", { id: x.provider }, t("{name} signed out", { name: x.agentName }));
closeConfirmAsk();
// failed, the ask stays with the error in it rather than close over it
// (#874: the error showed for a blink as the ask closed)
if (await providerAction("forget", { id: x.provider }, t("{name} signed out", { name: x.agentName }))) closeConfirmAsk();
else go.disabled = false;
};
const cancel = el("button", "text", t("Cancel"));
cancel.onclick = (e) => { e.stopPropagation(); closeConfirmAsk(); };
@@ -7416,11 +7418,27 @@ function contextPicks(p, cx) {
// may hold, over what the vendor or models.dev says, one for all of them
// and model=size for one, as the context window is set (ARNO on Discord: a
// model's maxTokens was wrong, and only its window could be set here).
// The usual sizes sit under it, one click each, as under the window (#875).
function outputField() {
const ox = input(draft.outputs || "", t("e.g. 32k · or gpt-6=128k, comma separated"));
ox.classList.add("outputs");
ox.oninput = () => { draft.outputs = ox.value; };
return field(t("Max output"), ox, t("The most a reply may hold, told to the agents as their max tokens; empty leaves it to the vendor and models.dev"));
const sizes = [8e3, 16e3, 32e3, 64e3, 128e3].map((n) => contextsText({ "*": n }));
const row = el("div", "cxpicks");
const same = (a, b) => JSON.stringify(parseContexts(a).map || {}) === JSON.stringify(parseContexts(b).map || {});
const light = () => {
for (const [i, b] of [...row.children].entries()) b.classList.toggle("on", !!ox.value.trim() && same(ox.value, sizes[i]));
};
for (const v of sizes) {
const b = el("button", "cxpick", v.toUpperCase());
b.type = "button";
b.onclick = () => { ox.value = v; draft.outputs = v; light(); };
row.append(b);
}
ox.oninput = () => { draft.outputs = ox.value; light(); };
light();
const wrap = el("div", "cxfield");
wrap.append(ox, row);
return field(t("Max output"), wrap, t("The most a reply may hold, told to the agents as their max tokens; empty leaves it to the vendor and models.dev"));
}
function outputError(ed, v) {
@@ -10844,6 +10862,7 @@ async function providerAction(action, body, okMsg, base = "provider/") {
state = await api("state");
renderAgents();
saidMoved(okMsg);
return true;
} catch (e) {
// a Remove may have gone through before what failed: the list as it is
// now, and the editor of a provider gone closes, rather than stay open
@@ -0,0 +1,85 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #875: a provider's Max output has the usual sizes under it, one click
// each, as its Context window has (8K … 128K): a click fills the box and
// lights, a size typed lights its pick, and the Save sends it. Under More
// endpoints, Anthropic URL and Responses URL stand on one line rather than
// wrap their last character (Anthropic 地址's 址) under them. English and
// Chinese; the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
function serve(lang, posts) {
const state = { agents: [], profiles: [], settings: { lang, theme: "light" } };
const models = [1, 2].map((i) => ({ id: `model-${i}`, name: `Model ${i}`, on: true }));
const provider = { id: "relay", name: "Relay", icon: "generic", chat: "https://relay.example/v1", responses: "", anthropic: "", models, agents: [], key: { set: true, masked: "sk-…1234" }, ready: true };
const providers = { providers: [provider], presets: [], excluded: [], gateway: { running: true, window: true } };
return async (route) => {
const url = new URL(route.request().url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:${JSON.stringify(lang)},theme:"light",web:false};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json(state);
if (url.pathname === "/api/providers") return json(providers);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname.startsWith("/api/provider/")) {
posts.push({ path: url.pathname, body: route.request().postDataJSON() });
return json(providers);
}
if (url.pathname.startsWith("/api/")) return json({});
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
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)] });
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh"]) {
test(`${engine} ${lang}: Max output's picks and More endpoints' labels`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
const page = await (await browser.newContext({ viewport: { width: 900, height: 720 }, reducedMotion: "reduce" })).newPage();
page.setDefaultTimeout(5000);
const errors = [], posts = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang, posts));
await page.goto("http://magpie.test/?view=providers");
const zh = lang === "zh";
await page.locator(".row.provider").click();
const box = page.locator("input.outputs");
await box.waitFor();
const picks = box.locator("xpath=following-sibling::div[contains(@class,'cxpicks')]").locator(".cxpick");
assert.deepEqual(await picks.allInnerTexts(), ["8K", "16K", "32K", "64K", "128K"]);
assert.equal(await page.locator(".cxpicks .cxpick.on").count(), 0, "nothing lit while empty");
// a click fills the box and lights; a size typed lights its pick
await picks.nth(2).click();
assert.equal(await box.inputValue(), "32k");
assert.deepEqual(await box.locator("xpath=following-sibling::div").locator(".cxpick.on").allInnerTexts(), ["32K"]);
await box.fill("64000");
assert.deepEqual(await box.locator("xpath=following-sibling::div").locator(".cxpick.on").allInnerTexts(), ["64K"]);
await box.fill("64k, model-1=8k");
assert.equal(await box.locator("xpath=following-sibling::div").locator(".cxpick.on").count(), 0, "a per-model value lights no pick");
await picks.nth(4).click();
// More endpoints: each label on one line
await page.locator(".editor .more summary").click();
for (const name of zh ? ["Anthropic 地址", "Responses 地址"] : ["Anthropic URL", "Responses URL"]) {
const l = page.locator(".editor .more .inner label", { hasText: name });
await l.waitFor();
const lines = await l.evaluate((e) => Math.round(e.getBoundingClientRect().height / parseFloat(getComputedStyle(e).lineHeight || "16")));
assert.equal(lines, 1, `${name} wraps onto ${lines} lines`);
}
if (process.env.ARTIFACT_DIR) await page.screenshot({ path: path.join(process.env.ARTIFACT_DIR, `provider-output-picks-${engine}-${lang}.png`) });
await page.getByRole("button", { name: zh ? "保存" : "Save", exact: true }).click();
await page.waitForFunction(() => !document.querySelector("input.outputs"));
assert.deepEqual(posts.map((p) => p.path), ["/api/provider/save"]);
assert.deepEqual(posts[0].body.outputs, { "*": 128000 });
assert.deepEqual(errors, []);
});
}
}
+25 -1
View File
@@ -20,7 +20,7 @@ const qoderCN = { id: "qoder-cn", name: "Qoder CN", icon: "qoder", models: [], a
// the removed Qoder, named by its provider as the backend now names it
const removed = (quiet) => ({ agent: "plugin", provider: "qoder-plugin", name: "Qoder", why: "You removed it from magpie.", quiet, agentName: "Qoder", agentIcon: "qoder" });
function server(lang, asked, quiet) {
function server(lang, asked, quiet, fail) {
let gone = false;
return async (route) => {
const url = new URL(route.request().url());
@@ -32,6 +32,7 @@ function server(lang, asked, quiet) {
if (url.pathname === "/api/providers") return json(state());
if (url.pathname.startsWith("/api/provider/")) {
asked.push([url.pathname.slice("/api/provider/".length), route.request().postDataJSON()]);
if (fail && url.pathname.endsWith("/forget")) return route.fulfill({ status: 400, json: { error: fail } });
if (url.pathname.endsWith("/forget")) gone = true;
return json(state());
}
@@ -94,6 +95,29 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
assert.deepEqual(errors, []);
});
// #874: a sign-out that failed showed its error for a blink as the
// ask closed over it; the ask now stays, with the error in it, and
// Sign out can be pressed again
await t.test(lang + ": a failed sign-out stays with its error", async () => {
const w = L[lang];
const page = await (await browser.newContext({ viewport: { width: 900, height: 700 } })).newPage();
page.setDefaultTimeout(5000);
const asked = [];
await page.route("**/*", server(lang, asked, false, "the vendor said no"));
await page.goto("http://magpie.test/?view=providers");
await page.locator("#excluded .excluded button.link", { hasText: new RegExp("^" + w.line + "$") }).click();
const ask = page.locator("#modal .forget-ask");
await ask.waitFor();
const go = ask.locator("button", { hasText: new RegExp("^" + w.go + "$") });
await go.click();
await ask.locator(".editor-error").waitFor();
await new Promise((r) => setTimeout(r, 600)); // past the modal's close
assert.ok(await ask.isVisible(), "the ask closed over its error");
assert.match(await ask.locator(".editor-error").innerText(), /the vendor said no/);
assert.ok(await go.isEnabled(), "Sign out can be pressed again");
assert.deepEqual(asked, [["forget", { id: "qoder-plugin" }]]);
});
await t.test(lang + ": Add it back is still there", async () => {
const w = L[lang];
const page = await (await browser.newContext({ viewport: { width: 900, height: 700 } })).newPage();
@@ -0,0 +1,53 @@
package provider
import (
"encoding/json"
"strings"
"testing"
)
// #874: a subscription signed in to one account of magpie's could never be
// signed out — "magpie uses … first; put another account first", with no
// other account to put first. The only one goes; with another there, the
// first still waits for it to be put first.
func TestForgetOnlyFirstLogin(t *testing.T) {
claudeHome(t)
auth := func(r string) json.RawMessage {
b, _ := json.Marshal(googleAuth{RefreshToken: r})
return b
}
saveLogins(t, savedLogin{Agent: "antigravity", User: "a@x.com", Auth: auth("ra"), On: true, First: true})
ForgetAccounts()
if ls := Logins("antigravity"); len(ls) != 1 || !ls[0].Active {
t.Fatalf("logins before: %+v", ls)
}
if err := ForgetAccount("antigravity"); err != nil {
t.Fatalf("signing out the only account: %v", err)
}
ForgetAccounts()
if ls := Logins("antigravity"); len(ls) != 0 {
t.Fatalf("still listed: %+v", ls)
}
for _, l := range readLogins() {
if l.Agent == "antigravity" {
t.Fatalf("logins.json still keeps %+v", l)
}
}
// two accounts: Remove on the first still asks for the other first,
// and signing the subscription out takes both
saveLogins(t,
savedLogin{Agent: "antigravity", User: "a@x.com", Auth: auth("ra"), On: true, First: true},
savedLogin{Agent: "antigravity", User: "b@x.com", Auth: auth("rb"), On: true})
ForgetAccounts()
if err := ForgetLogin("antigravity", "a@x.com"); err == nil || !strings.Contains(err.Error(), "put another account first") {
t.Fatalf("removing the first of two: %v", err)
}
if err := ForgetAccount("antigravity"); err != nil {
t.Fatalf("signing out both: %v", err)
}
ForgetAccounts()
if ls := Logins("antigravity"); len(ls) != 0 {
t.Fatalf("still listed: %+v", ls)
}
}
+8 -4
View File
@@ -169,13 +169,14 @@ func setSideLoginOn(agent, user string, on bool, ls []sideLogin) error {
// forgetSideLogin drops an account magpie signed in; gone is told what it
// kept. The agent's own sign-in is only hidden: its files stay as they
// are, and it shows again once the agent signs in anew (ownMark). Hidden
// while first, the next account is put first.
// while first, the next account is put first. One of magpie's that is
// first waits for another to be put first, unless it is the only one: then
// there is none to put first, and it goes, leaving the subscription with no
// account (#874: a removed subscription's Sign out… could never take its
// one account).
func forgetSideLogin(agent, user string, ls []sideLogin, gone func(savedLogin)) error {
own := slices.ContainsFunc(ls, func(l sideLogin) bool { return l.Own && strings.EqualFold(l.User, user) })
first := strings.EqualFold(activeOf(ls), user)
if first && !own {
return fmt.Errorf("magpie uses %s first; put another account first", user)
}
next := ""
if first {
for _, l := range ls {
@@ -185,6 +186,9 @@ func forgetSideLogin(agent, user string, ls []sideLogin, gone func(savedLogin))
}
}
}
if first && !own && next != "" {
return fmt.Errorf("magpie uses %s first; put another account first", user)
}
mark := hiddenNoMark
if own {
mark = firstNonEmpty(ownMark(agent), hiddenNoMark)