diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 297e3d84..49657477 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -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; } diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 2ce6e70c..0c66e9e5 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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 diff --git a/internal/gui/tests/provider-output-picks.test.cjs b/internal/gui/tests/provider-output-picks.test.cjs new file mode 100644 index 00000000..25fb4577 --- /dev/null +++ b/internal/gui/tests/provider-output-picks.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/gui/tests/removed-forget.test.cjs b/internal/gui/tests/removed-forget.test.cjs index 9373ee86..738f36fc 100644 --- a/internal/gui/tests/removed-forget.test.cjs +++ b/internal/gui/tests/removed-forget.test.cjs @@ -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(); diff --git a/internal/provider/forget_last_login_test.go b/internal/provider/forget_last_login_test.go new file mode 100644 index 00000000..cabcd508 --- /dev/null +++ b/internal/provider/forget_last_login_test.go @@ -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) + } +} diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go index 74576b5f..fef12bd8 100644 --- a/internal/provider/side_logins.go +++ b/internal/provider/side_logins.go @@ -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)