providers: the add sheet as A3 — a small green dot after an added name and a grey count past one account, short names with the full name, plans and host in the title, bolder section heads with more room, and a vendor's global and China presets one row marked Global · China whose editor picks the region (keeping the key typed); the editor now grows out of the row clicked on a spring and folds back into it on close, iOS-like, with reduced motion respected (A3; 点击这些供应商,弹出表单Modal以及关闭表单 Modal 的时候,要有一个极其优雅丝滑的动画,类似于iOS的动画) — the list's scroll is restored before the modal closes so the fold lands on the row. Tested with add-sheet.test.cjs in Chromium and WebKit, en and zh, recording the open and close keyframes, and the whole GUI suite (login-import flakes under load on main too).

This commit is contained in:
yetone committed 2026-09-30 00:24:57 +08:00
1 parent 9272fd1f62
commit f18d4e886b
7 files changed
+259 -69

No files matched your search

+12 -15
View File
@@ -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) {
+149 -30
View File
@@ -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)
+1
View File
@@ -635,6 +635,7 @@ const I18N = {
"Region": "区域",
"Auto": "自动",
"Global": "国际",
"China": "中国",
"China Mainland": "中国大陆",
"Plan": "套餐",
"Pay as you go": "按量付费",
+1 -1
View File
@@ -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) => {
+7 -4
View File
@@ -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
+86 -17
View File
@@ -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();
});
+3 -2
View File
@@ -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",