diff --git a/internal/gui/assets/app.css b/internal/gui/assets/app.css index 406605f2..061a585f 100644 --- a/internal/gui/assets/app.css +++ b/internal/gui/assets/app.css @@ -1310,26 +1310,28 @@ body.window .ag-sk-icon { width: 22px; height: 22px; border-radius: 6px; } .sheet .find, #view-gateway .find { height: 24px; width: 160px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--card); font-size: 12px; outline: none; transition: border-color .12s; } .sheet .find:focus, #view-gateway .find:focus { border-color: var(--accent); } .sheet .find::placeholder, #view-gateway .find::placeholder { color: var(--faint); } -.sheet .kind { display: flex; align-items: baseline; gap: 8px; padding: 18px 8px 5px; font-size: 12px; } -.sheet .kind b { font-weight: 600; color: var(--fg-2); } +.sheet .kind { display: flex; align-items: baseline; gap: 8px; padding: 22px 8px 6px; font-size: 12px; } +.sheet .kind b { font-size: 13px; font-weight: 600; color: var(--fg); } .sheet .kind span { color: var(--faint); } .sheet .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1px 12px; } .sheet .none { padding: 14px 2px; color: var(--muted); font-size: 12px; } #gwModels .none { padding: 16px; color: var(--muted); font-size: 12px; text-align: center; } #view-gateway .row-head .find { margin-right: 8px; } /* a row of the add sheet: an icon and a name, no frame; a row already - added says so in green on its right rather than fading */ + added has a small green dot after its name rather than fading */ .tile { - display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; + display: flex; align-items: center; gap: 10px; height: 34px; padding: 0 8px; border-radius: 8px; text-align: left; min-width: 0; transition: background .12s; } .tile:hover { background: var(--pill); } .tile.on { background: var(--sel); } .tile .ic { width: 18px; height: 18px; flex: none; } -.tile .n { flex: 1; min-width: 0; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.tile .n .badge { margin-left: 5px; } -.tile .st { flex: none; display: inline-flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); } -.tile .st.added { color: var(--green); } +.tile .nm { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; font-size: 13px; } +.tile .n { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tile .nm .badge { flex: none; } +.tile .have { flex: none; width: 6px; height: 6px; border-radius: 3px; background: var(--green); } +.tile .cnt { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.tile .st { flex: none; font-size: 11px; color: var(--faint); white-space: nowrap; } .sheet .custom-foot { display: flex; align-items: center; gap: 8px; margin-top: 14px; padding: 10px 2px 0; border-top: 1px solid var(--line-2); } .sheet .custom-foot .custom { display: inline-flex; align-items: center; gap: 7px; height: 30px; padding: 0 10px 0 6px; border-radius: 8px; color: var(--accent); font-size: 13px; } .sheet .custom-foot .custom:hover, .sheet .custom-foot .custom.on { background: var(--accent-soft); } @@ -1608,17 +1610,15 @@ html.theming, html.theming *, html.theming *::before, html.theming *::after { display: flex; align-items: center; justify-content: center; padding: 28px 24px; background: rgba(0, 0, 0, .28); -webkit-backdrop-filter: blur(8px) saturate(1.1); backdrop-filter: blur(8px) saturate(1.1); - animation: fade-in .2s var(--ease); } -.modal.out { animation: fade-out .16s var(--ease) forwards; pointer-events: none; } +/* opening and closing are animated from app.js (openModal, closeModal) */ +.modal.out { pointer-events: none; } .modal .dialog { width: min(600px, 100%); min-width: 0; max-height: 100%; overflow-y: auto; border-radius: 14px; background: var(--card); box-shadow: 0 0 0 1px var(--line), 0 24px 60px rgba(0, 0, 0, .32), 0 2px 8px rgba(0, 0, 0, .12); - animation: dialog-in .26s var(--ease); } .modal .dialog.swap { animation: content-in .22s var(--ease); } -.modal.out .dialog { animation: dialog-out .16s var(--ease) forwards; } .modal .dialog::-webkit-scrollbar { width: 0; } /* In a dialog the editor's head and buttons stay put and only the fields between them scroll; both bars are the same height, their contents @@ -1640,9 +1640,6 @@ body.window.mac .modal { top: 50px; padding: 16px 24px 24px; } body.window .modal { padding: 16px 24px 24px; } body.panel .modal { padding: 16px 12px; } @keyframes fade-in { from { opacity: 0; } } -@keyframes fade-out { to { opacity: 0; } } -@keyframes dialog-in { from { opacity: 0; transform: translateY(12px) scale(.97); } } -@keyframes dialog-out { to { opacity: 0; transform: translateY(6px) scale(.98); } } @keyframes content-in { from { opacity: .5; transform: translateY(2px); } } @media (prefers-reduced-motion: reduce) { diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index cab76fea..87a990fb 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -1767,8 +1767,8 @@ function renderProviders() { dialog = renderAdd() || dialog; if (importing) dialog = renderImport(importing); if (importingApps) dialog = renderImportApps(importingApps); + view.scrollTop = top; // first: a closing dialog folds into its row where it is if (dialog) openModal(dialog); else closeModal(); - view.scrollTop = top; } // providerSwitch turns a provider off and on (#163): off, it stays with its @@ -2416,11 +2416,13 @@ function renderAdd() { } } for (const [kind, title, hint] of [["vendor", "Vendors", "the makers' own APIs"], ["relay", "Relays", "one key, many vendors"], ["local", "On this machine", ""]]) { - const ps = providers.presets.filter((p) => p.kind === kind && hit(p)); - if (!ps.length) continue; + // a vendor's China endpoint is a preset of its own: one row with the global one + const rows = providers.presets.filter((p) => p.kind === kind && !globalOf(p)) + .map((p) => [p, chinaOf(p)]).filter(([p, cn]) => hit(p) || (cn && hit(cn))); + if (!rows.length) continue; any = true; const grid = section(title, hint); - for (const pr of ps) grid.append(tile(pr)); + for (const [pr, cn] of rows) grid.append(cn ? pairTile(pr, cn) : tile(pr)); } if (!any) { const none = el("div", "none"); @@ -2434,6 +2436,7 @@ function renderAdd() { const foot = el("div", "custom-foot"); const c = el("button", "custom" + (editing?.custom ? " on" : "")); c.append(svg(PLUS, 13, 1.8), el("span", "", t("Custom provider"))); + c.dataset.pick = "custom"; c.onclick = () => { editing = { custom: true }; draft = null; renderProviders(); }; foot.append(c, el("span", "hint", t("any OpenAI or Anthropic compatible URL"))); tiles.append(foot); @@ -2447,41 +2450,74 @@ function renderAdd() { // them; what more there is to say goes in its title. function pickRow(ic, name, cls = "") { const b = el("button", "tile" + cls); - const n = el("span", "n", name); - b.append(icon(ic), n); + b.dataset.pick = name; // the dialog it opens folds back into it, re-rendered + const nm = el("span", "nm"); + nm.append(el("span", "n", name)); + b.append(icon(ic), nm); return b; } -// the green mark on a row already added: a check and a word -function addedMark(text) { - const m = el("span", "st added"); - m.append(svg(CHECK, 10, 2), el("span", "", text)); - return m; +// a row already added: a green dot after its name, and how many accounts +// when a subscription has more than one +function markAdded(b, n = 1) { + b.classList.add("added"); + const nm = b.querySelector(".nm"); + nm.append(el("span", "have")); + if (n > 1) nm.append(el("span", "cnt", String(n))); } +// the add sheet names a row without what its title tells: a subscription's +// plan in brackets, a preset's long name +const shortName = (name) => name.replace(/\s*[((][^()()]*[))]\s*$/, "") || name; + +// a vendor's China endpoint is a preset of its own, id-cn beside the global id +const chinaOf = (pr) => providers.presets.find((x) => x.id === pr.id + "-cn"); +const globalOf = (pr) => pr.id.endsWith("-cn") ? providers.presets.find((x) => x.id === pr.id.slice(0, -3)) : null; + +// pairTile is a vendor's global and China presets as one row; the editor +// switches between them. A click adds the one not added yet, or opens the +// provider when both are. +function pairTile(pr, cn) { + const both = [pr, cn]; + const b = pickRow(pr.icon || "generic", shortName(pr.short || pr.name), both.some((x) => editing?.preset === x.id) ? " on" : ""); + b.append(el("span", "st", t("Global") + " · " + t("China"))); + const added = both.filter((x) => x.added); + b.title = both.map((x) => t(x === pr ? "Global" : "China") + " " + hostOf(x.chat || x.responses || x.anthropic) + (x.added ? " · " + t("Added") : "")).join("\n"); + if (added.length) markAdded(b); + const next = both.find((x) => !x.added); + b.onclick = () => { + if (next) { editing = { preset: next.id }; draft = null; } + else { editing = presetProvider(pr)?.id ?? pr.id; draft = null; } + renderProviders(); + }; + return b; +} + +// the first provider made from a preset, which may not have the preset's id +const presetProvider = (pr) => providers.providers.find((p) => p.preset === pr.id) || providers.providers.find((p) => p.id === pr.id); + // subTile adds a subscription: one more account when the agent has some. function subTile(x) { const have = providers.providers.find((p) => p.account?.agent === x.agent); const n = have ? (have.account.logins?.length || 1) : 0; - const b = pickRow(x.icon, x.name, signing?.agent === x.agent ? " on" : ""); + const b = pickRow(x.icon, shortName(x.name), signing?.agent === x.agent ? " on" : ""); b.title = t("{name} subscription", { name: x.name }) + " · " + x.plans; if (n) { - b.append(addedMark(x.single ? t("Signed in") : t(n === 1 ? "1 account" : "{n} accounts", { n }))); - b.title += " — " + t(x.single ? "signed in · click to switch account" : "click to add another account"); + markAdded(b, x.single ? 1 : n); + b.title += "\n" + (x.single ? t("Signed in") : t(n === 1 ? "1 account" : "{n} accounts", { n })) + " · " + t(x.single ? "signed in · click to switch account" : "click to add another account"); } b.onclick = () => startSignIn(x.agent); return b; } function tile(pr) { - const b = pickRow(pr.icon || "generic", pr.name, editing?.preset === pr.id ? " on" : ""); - if (pr.sponsored) b.querySelector(".n").append(el("span", "badge", t("sponsored"))); - b.title = pr.note ? t(pr.note) : hostOf(pr.chat || pr.responses || pr.anthropic); + const b = pickRow(pr.icon || "generic", pr.short || pr.name, editing?.preset === pr.id ? " on" : ""); + if (pr.sponsored) b.querySelector(".nm").append(el("span", "badge", t("sponsored"))); + b.title = (pr.short ? pr.name + " · " : "") + (pr.note ? t(pr.note) : hostOf(pr.chat || pr.responses || pr.anthropic)); if (pr.added) { - b.append(addedMark(t("Added"))); + markAdded(b); b.title = t("{name} is already added — open it", { name: pr.name }); - // the first provider made from it, which may not have the preset's id - const have = providers.providers.find((p) => p.preset === pr.id) || providers.providers.find((p) => p.id === pr.id); + const have = presetProvider(pr); b.onclick = () => { editing = have?.id ?? pr.id; draft = null; renderProviders(); }; } else { b.onclick = () => { editing = { preset: pr.id }; draft = null; renderProviders(); }; @@ -2721,20 +2757,60 @@ function iconPicker(ed) { // ---------- modal ---------- // The provider editor opens as a dialog over the page; Escape, the backdrop -// or Cancel close it. -let modalTimer = 0; +// or Cancel close it. As on iOS it grows out of what was pressed, on a +// spring, and closing folds it back into that (still there, re-rendered or +// not); with nothing pressed it rises from a little below. +const SPRING = CSS.supports?.("animation-timing-function", "linear(0, 1)") + // a damped spring (response .42 s, damping .8): 1.5% over, settled at 570 ms + ? "linear(0, 0.0203, 0.0723, 0.1448, 0.2292, 0.3188, 0.4086, 0.4951, 0.576, 0.6498, 0.7157, 0.7735, 0.8232, 0.8654, 0.9005, 0.9293, 0.9525, 0.9708, 0.9849, 0.9956, 1.0033, 1.0087, 1.0122, 1.0142, 1.015, 1.0151, 1.0146, 1.0136, 1.0124, 1.0111, 1.0097, 1.0084, 1.0071, 1.0059, 1.0049, 1.0039, 1.0031, 1.0024, 1.0018, 1.0013, 1)" + : "cubic-bezier(.2, .9, .25, 1.02)"; +const IOS_EASE = "cubic-bezier(.32, .72, 0, 1)"; +const calm = () => matchMedia("(prefers-reduced-motion: reduce)").matches; +// what was last pressed, and a way to find it again once re-rendered +let pressed = null; +document.addEventListener("pointerdown", (e) => { + const at = e.target.closest?.("[data-pick], .row.provider[data-id], button"); + if (!at || at.closest("#modal")) return; + const find = at.dataset.pick ? `[data-pick="${CSS.escape(at.dataset.pick)}"]` : at.matches(".row.provider") ? `.row.provider[data-id="${CSS.escape(at.dataset.id)}"]` : null; + pressed = { el: at, find, time: performance.now() }; +}, true); +let modalOrigin = null, modalDone = null; +// originRect: where the dialog came from, as it is now, or null when it's gone +function originRect(o) { + const at = o && (o.el.isConnected ? o.el : o.find ? document.querySelector(o.find) : null); + if (!at || at.closest("[hidden]")) return null; + const r = at.getBoundingClientRect(); + return r.width && r.height && r.bottom > 0 && r.top < innerHeight ? r : null; +} +// fromRect: the transform putting the dialog (at `to`) over the rect `from` +function fromRect(from, to) { + const s = Math.max(.3, Math.min(1, from.width / to.width)); + 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})`; +} function openModal(content) { const m = $("#modal"), d = m.firstElementChild; - clearTimeout(modalTimer); + const fresh = m.hidden || m.classList.contains("out"); + const top = m.hidden ? 0 : d.querySelector(".ebody")?.scrollTop || 0; m.classList.remove("out"); d.classList.remove("swap"); - const top = m.hidden ? 0 : d.querySelector(".ebody")?.scrollTop || 0; - if (!m.hidden) { void d.offsetWidth; d.classList.add("swap"); } // content changed: a soft refresh, not a re-entrance + if (!fresh) { void d.offsetWidth; d.classList.add("swap"); } // content changed: a soft refresh, not a re-entrance frame(content); d.replaceChildren(content); m.hidden = false; const body = content.querySelector(":scope > .ebody"); if (body) body.scrollTop = top; // a re-render keeps the place + if (!fresh) return; + for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.cancel(); + d.style.opacity = d.style.transform = m.style.opacity = ""; + modalDone = null; + modalOrigin = pressed && performance.now() - pressed.time < 1000 ? pressed : null; + pressed = null; + m.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 280, easing: "ease-out" }); + if (calm()) { d.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 160 }); return; } + const to = d.getBoundingClientRect(), from = originRect(modalOrigin); + d.animate([{ transform: from ? fromRect(from, to) : "translateY(24px) scale(.94)" }, { transform: "none" }], { duration: 570, easing: SPRING }); + d.animate([{ opacity: 0 }, { opacity: 1 }], { duration: from ? 200 : 240, easing: "ease-out" }); } // frame holds an editor's head and its buttons still while the fields // between them scroll. @@ -2746,10 +2822,34 @@ function frame(ed) { ed.classList.add("framed"); } function closeModal() { - const m = $("#modal"); - if (m.hidden || m.classList.contains("out")) return; + const m = $("#modal"), d = m.firstElementChild; + if (m.hidden) return Promise.resolve(); + if (m.classList.contains("out")) return modalDone || Promise.resolve(); m.classList.add("out"); - modalTimer = setTimeout(() => { m.hidden = true; m.classList.remove("out"); m.firstElementChild.replaceChildren(); }, 170); + 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; + d.style.transform = "none"; + const to = d.getBoundingClientRect(), from = calm() ? null : originRect(modalOrigin); + d.style.transform = was; + const shape = { duration: from ? 380 : 260, easing: IOS_EASE, fill: "forwards" }; + const moves = [ + m.animate([{ opacity: 0 }], { ...shape, easing: "ease-out" }), + calm() ? d.animate([{ opacity: 0 }], { duration: 140, fill: "forwards" }) + : d.animate([{ transform: from ? fromRect(from, to) : "translateY(14px) scale(.95)" }], shape), + ]; + // it fades as it lands, the last part of the way + if (!calm()) moves.push(d.animate([{ offset: from ? .45 : .2, opacity: getComputedStyle(d).opacity }, { opacity: 0 }], shape)); + const done = modalDone = Promise.all(moves.map((a) => a.finished)).then(() => { + if (modalDone !== done) return; + modalDone = null; + m.hidden = true; + m.classList.remove("out"); + d.replaceChildren(); + for (const a of [...m.getAnimations(), ...d.getAnimations()]) a.cancel(); + d.style.opacity = d.style.transform = m.style.opacity = ""; + }, () => {}); + return done; } $("#modal").onclick = (e) => { if (e.target === e.currentTarget) cancelEdit(); }; @@ -2817,6 +2917,26 @@ function renderEditor(p, presetID) { if (p?.off) ed.append(el("div", "hint off-note", t("Switched off: agents aren't given its models and no request goes to it. Its keys and settings are kept; switch it on to use it again."))); } + // a vendor's global and China endpoints are presets of their own, one row + // in the add sheet: here the new provider picks between them, the key kept + const pair = isNew && pr ? (globalOf(pr) ? [globalOf(pr), pr] : chinaOf(pr) ? [pr, chinaOf(pr)] : null) : null; + if (pair) { + const seg = el("div", "segs area"); + pair.forEach((x, i) => { + const b = el("button", "opt" + (x.id === pr.id ? " on" : ""), t(i ? "China" : "Global")); + b.title = hostOf(x.chat || x.responses || x.anthropic) + (x.added ? " · " + t("Added") : ""); + b.onclick = () => { + if (x.id === pr.id) return; + editing = { preset: x.id }; + draft = { id: x.id, name: x.name, preset: x.id, key: draft.key, chosen: [], extra: [], headers: [] }; + renderProviders(); + }; + seg.append(b); + }); + queueMicrotask(() => slide(seg, "area")); + ed.append(...field(t("Region"), seg, "")); + } + // who uses it: just the agents already pointed here, so a click changes // one's model. Pointing a new agent at the provider happens in the Agent // tab's picker, not here. @@ -5347,8 +5467,7 @@ function askCodexReset(q) { function closeResetAsk() { if (!resetAsk) return; resetAsk = null; - closeModal(); - setTimeout(() => { if (!resetAsk) $("#modal").classList.remove("lib"); }, 200); + closeModal().then(() => { if (!resetAsk) $("#modal").classList.remove("lib"); }); } // the dialog is the providers page's: while this asks, its backdrop and // Escape close only this (in the panel, Escape would hide the window) diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 14f6fd96..f6e7e03b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -635,6 +635,7 @@ const I18N = { "Region": "区域", "Auto": "自动", "Global": "国际", + "China": "中国", "China Mainland": "中国大陆", "Plan": "套餐", "Pay as you go": "按量付费", diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 8298c4e3..9f98a6ff 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -2411,7 +2411,7 @@ // ---------- the dialog ---------- function openLib(content) { openModal(content); $("#modal").classList.add("lib"); } - function closeLibModal() { modal = null; closeModal(); setTimeout(() => { if (!modal) $("#modal").classList.remove("lib"); }, 200); } + function closeLibModal() { modal = null; closeModal().then(() => { if (!modal) $("#modal").classList.remove("lib"); }); } // The dialog is the providers page's; while the library has it, its // backdrop and Escape close it here. $("#modal").addEventListener("click", (e) => { diff --git a/internal/gui/tests/README.md b/internal/gui/tests/README.md index 47ff56ac..605fa3ba 100644 --- a/internal/gui/tests/README.md +++ b/internal/gui/tests/README.md @@ -112,10 +112,13 @@ and Chinese. `add-sheet.test.cjs` opens the Providers page's add sheet as quiet rows: Subscriptions, Vendors, Relays and On this machine, each with its word on what it is; rows with no border, no second line and no overflow, three to a -line at 900px; an added provider not faded but marked "Added" in green, the -Claude subscription "2 accounts"; the host in a row's title; the custom -provider a line at the foot, gone while searching; a row opening its editor; -in English and Chinese. +line at 900px; an added provider not faded but marked by a small green dot +after its name, a grey count only past one account (the Claude subscription's +2); short names with the full name, plans and host in the title; a vendor's +global and China presets one row marked "Global · China", its editor picking +the region with the key typed kept; the custom provider a line at the foot, +gone while searching; the dialog growing out of the row clicked on a spring +and folding back into it on close; in English and Chinese. `own-signin-remove.test.cjs` opens the Kiro subscription's accounts with kiro-cli's own sign-in behind one of magpie's, then alone and first: its diff --git a/internal/gui/tests/add-sheet.test.cjs b/internal/gui/tests/add-sheet.test.cjs index 92046cec..073669d7 100644 --- a/internal/gui/tests/add-sheet.test.cjs +++ b/internal/gui/tests/add-sheet.test.cjs @@ -1,12 +1,14 @@ // Run with Node's test runner and Playwright on the module path; see README.md. // The Providers page's add sheet as quiet rows (Image #24: 这个页面看起来有点 -// 乱糟糟的): under Subscriptions, Vendors, Relays and On this machine, each -// named with a word on what it is, the choices sit three to a line as an -// icon and a name, with no frame and no second line (the host or plans are -// in the row's title). One already added is not faded: it says so in green -// on its right, a subscription how many accounts it has. A custom provider -// is one line under them all, not a tile, and is gone while searching. -// Clicking a row still opens what it did. English and Chinese; no backend, +// 乱糟糟的, then #25's design A3): under Subscriptions, Vendors, Relays and On +// this machine, each named with a word on what it is, the choices sit three +// to a line as an icon and a short name, with no frame and no second line +// (the plans, a long name and the host are in the row's title). One already +// added is not faded and says no word: a small green dot after its name, a +// subscription's account count beside it when more than one. A vendor's +// global and China presets are one row marked "Global · China", its editor +// switching between them with the key kept. A custom provider is one line +// under them all, gone while searching. English and Chinese; no backend, // the API is faked here. const assert = require("node:assert/strict"); const fs = require("node:fs/promises"); @@ -21,6 +23,9 @@ const presets = [ preset("anthropic", "Anthropic", "vendor", true), preset("openai", "OpenAI", "vendor", false), preset("deepseek", "DeepSeek", "vendor", false), + preset("moonshot", "Kimi", "vendor", false), + preset("moonshot-cn", "Kimi (China)", "vendor", true, { chat: "https://api.moonshot.cn/v1" }), + preset("tencent-token-plan", "Tencent Cloud Token Plan", "vendor", false, { short: "Tencent Cloud" }), preset("openrouter", "OpenRouter", "relay", true), preset("siliconflow", "SiliconFlow", "relay", false), preset("ollama", "Ollama", "local", false, { noKey: true }), @@ -51,11 +56,11 @@ function server(lang) { const L = { en: { kinds: [["Subscriptions", "sign in, no key"], ["Vendors", "the makers' own APIs"], ["Relays", "one key, many vendors"], ["On this machine", ""]], - added: "Added", accounts: "2 accounts", custom: "Custom provider", customHint: "any OpenAI or Anthropic compatible URL", + region: "Global · China", regionField: "Region", china: "China", custom: "Custom provider", customHint: "any OpenAI or Anthropic compatible URL", }, zh: { kinds: [["订阅", "登录即可,无需密钥"], ["供应商", "模型厂商的 API"], ["中转", "一个密钥,多家模型"], ["本机", ""]], - added: "已添加", accounts: "2 个账号", custom: "自定义供应商", customHint: "任意 OpenAI / Anthropic 兼容的 URL", + region: "国际 · 中国", regionField: "区域", china: "中国", custom: "自定义供应商", customHint: "任意 OpenAI / Anthropic 兼容的 URL", }, }; @@ -96,15 +101,25 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", const tops = await sheet.locator(".kind").nth(1).evaluate((k) => [...k.nextElementSibling.children].map((r) => Math.round(r.getBoundingClientRect().top))); assert.equal(tops.filter((y) => y === tops[0]).length, 3, "three vendors on the first line"); - // added in green, a subscription with its accounts; the rest bare + // added: a green dot and no word, a subscription's count beside it const row = (name) => rows.filter({ has: page.locator(".n", { hasText: new RegExp("^" + name + "$") }) }); - assert.equal(await row("Anthropic").locator(".st.added").textContent(), w.added); - assert.equal(await row("OpenRouter").locator(".st.added").textContent(), w.added); - assert.equal(await row("Claude").locator(".st.added").textContent(), w.accounts); - assert.equal(await row("OpenAI").locator(".st").count(), 0); - const green = await row("Anthropic").locator(".st.added").evaluate((e) => getComputedStyle(e).color); - assert.notEqual(green, await row("OpenAI").locator(".n").evaluate((e) => getComputedStyle(e).color)); + for (const name of ["Anthropic", "OpenRouter", "Claude", "Kimi"]) assert.equal(await row(name).locator(".have").count(), 1, name); + assert.equal(await row("OpenAI").locator(".have").count(), 0); + assert.equal(await sheet.locator(".tile .st.added").count(), 0); + assert.equal(await row("Claude").locator(".cnt").textContent(), "2"); + assert.equal(await row("Anthropic").locator(".cnt").count(), 0); + const dot = await row("Anthropic").locator(".have").evaluate((e) => { const r = e.getBoundingClientRect(); return [r.width, r.height, getComputedStyle(e).backgroundColor]; }); + assert.deepEqual(dot.slice(0, 2), [6, 6]); + assert.notEqual(dot[2], "rgba(0, 0, 0, 0)"); assert.match(await row("OpenAI").getAttribute("title"), /api\.openai\.example\.com/); + // short names, the long one in the title + assert.equal(await row("Grok").count(), 1); + assert.match(await row("Grok").getAttribute("title"), /SuperGrok/); + assert.match(await row("Tencent Cloud").getAttribute("title"), /Tencent Cloud Token Plan/); + // a vendor's global and China presets: one row + assert.equal(await sheet.locator(".tile .n", { hasText: "China" }).count(), 0); + assert.equal(await row("Kimi").locator(".st").textContent(), w.region); + assert.match(await row("Kimi").getAttribute("title"), /api\.moonshot\.cn/); // the custom provider: one line at the foot, gone while searching const foot = sheet.locator(".custom-foot"); @@ -113,11 +128,65 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", await sheet.locator(".find").fill("deep"); assert.deepEqual(await rows.locator(".n").allTextContents(), ["DeepSeek"]); assert.equal(await foot.count(), 0); + await sheet.locator(".find").fill("china"); + assert.deepEqual(await rows.locator(".n").allTextContents(), ["Kimi"]); await sheet.locator(".find").fill(""); - // a row opens what it did: a new preset's editor + // the pair's row adds the one not added (global here); its editor + // switches to China keeping the key typed, with the page left where it was + await row("Kimi").click(); + const ed = page.locator(".editor.new"); + await ed.locator(".ehead b", { hasText: /^Kimi$/ }).waitFor(); + const seg = ed.locator(".segs.area"); + assert.equal(await ed.locator("label", { hasText: new RegExp("^" + w.regionField + "$") }).count(), 1); + assert.equal(await seg.locator(".opt.on").textContent(), lang === "zh" ? "国际" : "Global"); + const key = ed.locator("input[type=password]").first(); + await key.fill("sk-test-kept"); + const y = await page.evaluate(() => document.scrollingElement.scrollTop); + await seg.locator(".opt", { hasText: w.china }).click(); + await ed.locator(".ehead b", { hasText: "Kimi (China)" }).waitFor(); + assert.equal(await ed.locator("input[type=password]").first().inputValue(), "sk-test-kept"); + assert.equal(await page.evaluate(() => document.scrollingElement.scrollTop), y); + + // Escape closes it, folding back into Kimi's row (re-rendered meanwhile) + // each move of the dialog as it is started: where it goes, where the + // dialog sits at rest (its layout box, which no transform moves) and + // where the row it came from is then, the sheet re-rendered + await page.evaluate(() => { + window.moves = []; + const animate = Element.prototype.animate; + Element.prototype.animate = function (frames, opts) { + if (this.matches("#modal .dialog") && frames.some((f) => f.transform)) { + const m = this.parentElement.getBoundingClientRect(), rowOf = (name) => [...document.querySelectorAll("#addSheet .tile")].find((r) => r.querySelector(".n").textContent === name)?.getBoundingClientRect().toJSON(); + window.moves.push({ frames: frames.map((f) => f.transform), ms: opts.duration, easing: opts.easing, out: this.parentElement.classList.contains("out"), + box: { x: m.x + this.offsetLeft, y: m.y + this.offsetTop, width: this.offsetWidth, height: this.offsetHeight }, kimi: rowOf("Kimi"), deep: rowOf("DeepSeek") }); + } + return animate.call(this, frames, opts); + }; + }); + await page.keyboard.press("Escape"); + const fold = await page.evaluate(() => window.moves.at(-1)); + if (fold) fold.to = fold.frames.at(-1); + assert(fold?.out, "closing"); + assert(fold.ms >= 300, "folds for " + fold?.ms + " ms"); + const { box, kimi } = fold; + const [dx, dy, sc] = fold.to.match(/-?[\d.]+/g).map(Number); + assert(Math.abs(box.x + box.width / 2 + dx - (kimi.x + kimi.width / 2)) < 2, "to Kimi's row across"); + assert(Math.abs(box.y + box.height / 2 + dy - (kimi.y + kimi.height / 2)) < 2, "to Kimi's row down"); + assert(sc < 1); + await page.locator("#modal").waitFor({ state: "hidden" }); + + // a row opens what it did: a new preset's editor, grown out of the row on a spring await row("DeepSeek").click(); await page.locator(".editor.new .ehead b", { hasText: "DeepSeek" }).waitFor(); + const grow = await page.evaluate(() => window.moves.at(-1)); + if (grow) grow.from = grow.frames[0], grow.at = grow.box; + assert(grow && !grow.out, "the dialog grows"); + assert.match(grow.easing, /^(linear|cubic-bezier)\(/); + const [gx, gy] = grow.from.match(/-?[\d.]+/g).map(Number); + const { at, deep } = grow; + assert(Math.abs(at.x + at.width / 2 + gx - (deep.x + deep.width / 2)) < 2, "from DeepSeek's row across"); + assert(Math.abs(at.y + at.height / 2 + gy - (deep.y + deep.height / 2)) < 2, "from DeepSeek's row down"); assert.deepEqual(errors, []); await page.context().close(); }); diff --git a/internal/provider/presets.go b/internal/provider/presets.go index 7b3da36a..f77523fa 100644 --- a/internal/provider/presets.go +++ b/internal/provider/presets.go @@ -27,6 +27,7 @@ type PresetDef struct { NoKey bool `json:"noKey,omitempty"` // local servers: a key is optional Sponsored bool `json:"sponsored,omitempty"` // shown first, with a tag Note string `json:"note,omitempty"` // one line under the name + Short string `json:"short,omitempty"` // the add sheet's name for it, when Name is long Regions []Region `json:"regions,omitempty"` // base-URL choices (a relay's regional endpoints, a vendor's plans) // RegionLabel names what the Regions choose between, "Region" if unset. RegionLabel string `json:"regionLabel,omitempty"` @@ -173,7 +174,7 @@ var presets = []PresetDef{ // sk-tp- key, served at their own endpoints under /plan, chat completions // and Anthropic messages only (its Codex page asks for wire_api "chat"). // models.dev lists just its Hy models, so the plan's are given here. - {ID: "tencent-token-plan", Name: "Tencent Cloud Token Plan", Icon: "tencentcloud-color", Kind: KindVendor, + {ID: "tencent-token-plan", Name: "Tencent Cloud Token Plan", Short: "Tencent Cloud", Icon: "tencentcloud-color", Kind: KindVendor, Chat: "https://api.lkeap.cloud.tencent.com/plan/v3", Anthropic: "https://api.lkeap.cloud.tencent.com/plan/anthropic", Note: "TokenHub · subscription", Website: "https://cloud.tencent.com/document/product/1823/130060", KeysURL: "https://console.cloud.tencent.com/tokenhub/tokenplan", @@ -183,7 +184,7 @@ var presets = []PresetDef{ // quota spent only at the plan's own endpoints under /plan (v2 for chat // completions, anthropic for messages; its Claude Code, OpenClaw, Cherry // Studio and CodeArts pages), a MaaS key to either. No Responses. - {ID: "huaweicloud", Name: "Huawei Cloud MaaS", Icon: "huaweicloud-color", Kind: KindVendor, + {ID: "huaweicloud", Name: "Huawei Cloud MaaS", Short: "Huawei Cloud", Icon: "huaweicloud-color", Kind: KindVendor, Chat: "https://api.modelarts-maas.com/plan/v2", Anthropic: "https://api.modelarts-maas.com/plan/anthropic", Note: "Token Plan · 西南-贵阳一", Website: "https://support.huaweicloud.com/Token-plan-maas/tokenplan-maas-0001.html", KeysURL: "https://console.huaweicloud.com/modelarts/?#/model-studio/authmanage",