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:
7 files changed
+259
-69
No files matched your search
+12
-15
@@ -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
@@ -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)
|
||||
|
||||
@@ -635,6 +635,7 @@ const I18N = {
|
||||
"Region": "区域",
|
||||
"Auto": "自动",
|
||||
"Global": "国际",
|
||||
"China": "中国",
|
||||
"China Mainland": "中国大陆",
|
||||
"Plan": "套餐",
|
||||
"Pay as you go": "按量付费",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user