@tokenbuddy/tb-admin 1.0.14 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/bootstrap-registry.d.ts +1 -0
- package/dist/src/bootstrap-registry.d.ts.map +1 -1
- package/dist/src/bootstrap-registry.js.map +1 -1
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +8 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/server-cmd.d.ts +22 -1
- package/dist/src/server-cmd.d.ts.map +1 -1
- package/dist/src/server-cmd.js +93 -16
- package/dist/src/server-cmd.js.map +1 -1
- package/dist/src/ui-actions.d.ts +88 -0
- package/dist/src/ui-actions.d.ts.map +1 -0
- package/dist/src/ui-actions.js +763 -0
- package/dist/src/ui-actions.js.map +1 -0
- package/dist/src/ui-command.d.ts +4 -0
- package/dist/src/ui-command.d.ts.map +1 -0
- package/dist/src/ui-command.js +37 -0
- package/dist/src/ui-command.js.map +1 -0
- package/dist/src/ui-server.d.ts +23 -0
- package/dist/src/ui-server.d.ts.map +1 -0
- package/dist/src/ui-server.js +245 -0
- package/dist/src/ui-server.js.map +1 -0
- package/dist/src/ui-state.d.ts +134 -0
- package/dist/src/ui-state.d.ts.map +1 -0
- package/dist/src/ui-state.js +407 -0
- package/dist/src/ui-state.js.map +1 -0
- package/dist/src/ui-static.d.ts +2 -0
- package/dist/src/ui-static.d.ts.map +1 -0
- package/dist/src/ui-static.js +144 -0
- package/dist/src/ui-static.js.map +1 -0
- package/dist/src/upstream-balance-probe.d.ts +41 -0
- package/dist/src/upstream-balance-probe.d.ts.map +1 -0
- package/dist/src/upstream-balance-probe.js +379 -0
- package/dist/src/upstream-balance-probe.js.map +1 -0
- package/package.json +1 -1
- package/src/bootstrap-registry.ts +1 -0
- package/src/cli.ts +9 -0
- package/src/server-cmd.ts +118 -19
- package/src/ui-actions.ts +901 -0
- package/src/ui-command.ts +39 -0
- package/src/ui-server.ts +308 -0
- package/src/ui-state.ts +575 -0
- package/src/ui-static.ts +144 -0
- package/src/upstream-balance-probe.ts +505 -0
- package/tests/admin.test.ts +871 -1
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export function adminUiHtml() {
|
|
2
|
+
return `<!doctype html>
|
|
3
|
+
<html lang="en">
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>TokenBuddy Admin</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: light;
|
|
11
|
+
--ink:#17152b;--text:#312a4f;--muted:#665f80;--faint:#837d9b;--page:#fbfaff;
|
|
12
|
+
--panel:rgba(255,255,255,.88);--line:#ddd6f1;--line2:#eee9fb;--purple:#6f3ee8;
|
|
13
|
+
--green:#34d399;--amber:#f6b73c;--red:#ef5b78;--blue:#5b7cfa;--shadow:0 12px 34px rgba(96,70,170,.12);
|
|
14
|
+
font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:0;
|
|
15
|
+
}
|
|
16
|
+
*{box-sizing:border-box}body{margin:0;min-height:100dvh;color:var(--text);background:linear-gradient(135deg,#eef5ff 0%,#fbf7ff 47%,#f8f6ff 100%)}
|
|
17
|
+
button,input,select,textarea{font:inherit}button{cursor:pointer}.topnav{height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 44px;border-bottom:1px solid rgba(205,197,231,.54);position:sticky;top:0;z-index:20}
|
|
18
|
+
.logo{color:var(--ink);font-size:20px;font-weight:900}.top-links{display:flex;gap:26px;font-weight:820}.top-link{border:0;background:transparent;color:#5d5675;min-height:44px}.top-link.active{color:var(--ink)}
|
|
19
|
+
.content{width:min(1280px,calc(100vw - 64px));margin:0 auto;padding:30px 0 48px;display:grid;gap:28px}.page{display:none}.page.active{display:grid;gap:28px}
|
|
20
|
+
.panel,.bootstrap-card{border:1px solid #d9cfef;background:var(--panel);border-radius:8px;box-shadow:var(--shadow);overflow:hidden;backdrop-filter:blur(12px)}
|
|
21
|
+
.panel-head{min-height:62px;padding:14px 18px;display:flex;align-items:center;justify-content:space-between;gap:20px;border-bottom:1px solid var(--line)}
|
|
22
|
+
.list-status{display:flex;align-items:center;gap:12px;min-width:0;color:#756f91;font-size:12px;font-weight:760}.refresh-pill{border:1px solid #d9cfef;border-radius:999px;background:#fff;padding:5px 10px;color:#5b4aa8}.refresh-pill.refreshing{display:inline-flex;align-items:center;gap:7px;color:#5f4f91;background:#f8f6ff}.refresh-pill.error{color:#b42342;background:#fff5f7;border-color:#f4bdc9}.refresh-pill .spinner{width:12px;height:12px;border-width:2px}.last-updated{white-space:nowrap;color:#837d9b}
|
|
23
|
+
.title{margin:0;color:var(--ink);font-size:22px;line-height:1.15;font-weight:850}.btn{border:1px solid #cfc3ef;border-radius:8px;min-height:40px;padding:0 14px;background:white;color:#4c3aa1;font-weight:800}.btn.primary{border:0;background:var(--purple);color:white}.btn.danger{color:#b42342}.btn.icon{width:38px;padding:0;border-radius:999px;display:inline-grid;place-items:center}.btn.icon svg{width:17px;height:17px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}
|
|
24
|
+
.app-list{--seller-grid:22px minmax(130px,1.25fr) minmax(120px,1fr) minmax(64px,.42fr) minmax(76px,.5fr) minmax(104px,.68fr) minmax(96px,.62fr) minmax(82px,.52fr) 36px;display:grid;gap:10px;padding:14px;background:rgba(247,245,255,.72)}#sellerRows{display:grid;gap:10px;width:100%;min-width:0}.app-table-head,.app-row{display:grid;grid-template-columns:var(--seller-grid);align-items:center;gap:12px;width:100%;min-width:0;padding:0 18px}
|
|
25
|
+
.app-table-head{min-height:34px;color:#81799f;font-size:11px;font-weight:900;text-transform:uppercase}.app-row{border:1px solid #d8d0ee;border-radius:8px;background:rgba(255,255,255,.82);min-height:76px;text-align:left}
|
|
26
|
+
.app-dot{width:18px;height:18px;border-radius:999px;background:#c8ced8;box-shadow:inset 0 0 0 6px #edf1f8}.app-dot.active{background:var(--green);box-shadow:0 0 0 7px rgba(52,211,153,.19)}.app-dot.pending,.app-dot.draining{background:var(--amber);box-shadow:0 0 0 7px rgba(246,183,60,.22)}.app-dot.offline,.app-dot.unknown{background:#6d7390;box-shadow:0 0 0 7px rgba(109,115,144,.17)}
|
|
27
|
+
.app-name{display:grid;gap:6px;min-width:0}.seller-title{display:inline-flex;gap:9px;align-items:center}.app-name strong,.field-cell strong,.field-cell span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-name strong{color:#635986;font-size:17px}.field-cell{display:grid;gap:4px;min-width:0;color:#5f5877;font-size:13px}.field-cell strong{color:#29223f}.speed-cell{display:grid;gap:3px;font-size:12px;font-weight:800}.muted-value{color:#8a83a3}.recharge-btn,.detail-btn{width:32px;height:32px;border:1px solid #d7cfef;border-radius:999px;background:#fff;color:#6142db;display:inline-grid;place-items:center;text-decoration:none;font-weight:900}.row-actions{display:flex;gap:8px;justify-content:flex-end}
|
|
28
|
+
.spec-tip{border:1px solid #d8cff0;border-radius:999px;background:#fff;color:#6a55a4;width:24px;height:24px;padding:0;display:inline-grid;place-items:center}.spec-tip svg{width:14px;height:14px;stroke:currentColor;stroke-width:2.2;fill:none;stroke-linecap:round;stroke-linejoin:round}.bootstrap-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;padding:18px}.metric{border:1px solid var(--line2);border-radius:8px;background:#fff;padding:14px}.metric label{display:block;color:#8a83a3;font-size:11px;font-weight:900;text-transform:uppercase}.metric strong{display:block;margin-top:7px;color:var(--ink);font-size:20px}
|
|
29
|
+
.modal{position:fixed;inset:0;background:rgba(24,18,45,.36);z-index:40;display:none}.modal.open{display:grid}.modal-shell{background:#fbfaff;min-height:100dvh;display:grid;grid-template-rows:auto 1fr}.modal-head{height:76px;display:flex;align-items:center;justify-content:space-between;gap:18px;padding:0 28px;border-bottom:1px solid var(--line);background:rgba(255,255,255,.92)}.modal-title{display:flex;align-items:center;gap:12px;color:var(--ink);font-size:22px;font-weight:900}.modal-actions{display:flex;gap:10px;align-items:center}
|
|
30
|
+
.modal-body{padding:24px 28px 40px;display:grid;gap:18px;overflow:auto}.detail-grid{display:grid;grid-template-columns:minmax(260px,.72fr) minmax(0,1fr);gap:18px}.card{border:1px solid #d9cfef;border-radius:8px;background:white;padding:18px}.card h2{margin:0 0 14px;color:var(--ink);font-size:18px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.create-form{display:grid;gap:18px}.create-section{border:1px solid #d9cfef;border-radius:8px;background:white;padding:18px;display:grid;gap:14px}.create-section h2{margin:0;color:var(--ink);font-size:18px}.section-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.payment-tabs{display:flex;gap:8px;flex-wrap:wrap}.payment-tab{border:1px solid #d8cff0;border-radius:999px;background:#fff;color:#5b4aa8;min-height:34px;padding:0 14px;font-weight:900}.payment-tab.active{background:#6f3ee8;color:#fff;border-color:#6f3ee8}.payment-panel{border:1px solid var(--line2);border-radius:8px;background:#faf9ff;padding:14px;display:grid;gap:14px}.payment-head{display:flex;align-items:center;justify-content:space-between;gap:14px}.payment-head h3{margin:0;color:#3b315c;font-size:14px}.pill-switch{border:1px solid #cfc3ef;border-radius:999px;background:#fff;color:#6b6385;min-height:34px;padding:0 12px;display:inline-flex;align-items:center;gap:8px;font-weight:900}.pill-switch:before{content:"";width:22px;height:22px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 4px #fff}.pill-switch.enabled{border-color:#8bd9ba;background:#effdf7;color:#087755}.pill-switch.enabled:before{background:#34d399}.field.full{grid-column:1/-1}.field-help{margin:0;color:#746c8e;font-size:12px;line-height:1.45}.required-star{color:#c0264e}.generated-summary{border:1px solid #e4def6;border-radius:8px;background:#fff;padding:10px 12px;color:#5f5877;font-size:12px;line-height:1.5}.field{display:grid;gap:7px}.field label{font-size:12px;font-weight:900;color:#766f94;text-transform:uppercase}.field input,.field select,.readonly-value{min-height:40px;border:1px solid #cfc3ef;border-radius:8px;padding:0 10px;background:#fff;color:var(--ink)}.field input,.field select{font:inherit}.field input:disabled,.field select:disabled{background:#f3f0fb;color:#958daa}.readonly-value{display:flex;align-items:center;background:#f8f6ff;color:#5f5877;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.field input[readonly]{background:#f8f6ff;color:#5f5877}
|
|
31
|
+
table{width:100%;border-collapse:collapse;font-size:13px}th,td{text-align:left;border-bottom:1px solid var(--line2);padding:10px;color:#50496a}th{color:#81799f;font-size:11px;text-transform:uppercase}.status-line,.loading-row{padding:10px 14px;border:1px solid var(--line);border-radius:8px;background:#fff;color:#5f5877;font-size:13px}.status-line.loading,.loading-row{border-color:#d8cff0;background:#f8f6ff;color:#5f4f91}.loading-row{min-height:76px;display:grid;place-items:center}.status-line.loading{min-height:42px;display:flex;align-items:center;justify-content:center}.form-grid>.loading-row{grid-column:1/-1}.create-progress{position:relative;display:grid;gap:12px;padding-left:30px}.create-progress:before{content:"";position:absolute;left:15px;top:22px;bottom:22px;width:2px;background:#e6def8}.progress-step{position:relative;border:1px solid var(--line2);border-radius:8px;background:#fff;padding:12px 14px;display:grid;gap:6px;text-align:left;color:inherit}.progress-step:before{content:"";position:absolute;left:-23px;top:18px;width:12px;height:12px;border-radius:999px;background:#c7bedf;border:3px solid #fbfaff}.progress-step strong{color:var(--ink);font-size:13px}.progress-step span{color:#756f91;font-size:12px}.progress-step.running{border-color:#d8cff0;background:#f8f6ff}.progress-step.running:before{background:var(--purple)}.progress-step.succeeded{border-color:rgba(52,211,153,.4)}.progress-step.succeeded:before{background:var(--green)}.progress-step.failed{border-color:#f4bdc9;background:#fff5f7}.progress-step.failed:before{background:var(--red)}.progress-step.skipped{background:#faf9ff}.progress-step.skipped:before{background:#b9afd8}.progress-title{display:flex;align-items:center;gap:8px}.progress-title .spinner{width:14px;height:14px;border-width:2px}.progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}.progress-toggle{color:#6f3ee8;font-size:11px;font-weight:900;text-transform:uppercase}.progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--line2);border-radius:8px;background:#17152b;color:#f3f0ff;padding:9px;font-size:11px;white-space:pre-wrap}.spinner{width:19px;height:19px;border:2px solid rgba(111,62,232,.18);border-top-color:var(--purple);border-radius:999px;animation:spin .75s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.hidden{display:none!important}
|
|
32
|
+
@media(max-width:900px){.topnav{padding:0 20px}.content{width:min(100% - 28px,1280px)}.app-table-head{display:none}.app-row{grid-template-columns:18px minmax(0,1fr) auto;gap:12px;padding:14px}.app-row .field-cell,.app-row .speed-cell{display:none}.detail-grid,.form-grid,.section-grid,.bootstrap-grid{grid-template-columns:1fr}.field.full{grid-column:auto}.modal-head{padding:0 16px}.modal-body{padding:18px 16px 34px}}
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<nav class="topnav"><div class="logo">TokenBuddy</div><div class="top-links"><button class="top-link active" data-page="sellers">Sellers</button><button class="top-link" data-page="bootstrap">Bootstrap</button></div></nav>
|
|
37
|
+
<main class="content">
|
|
38
|
+
<section id="page-sellers" class="page active">
|
|
39
|
+
<div class="panel"><div class="panel-head"><div class="list-status"><span id="sellerRefreshState" class="refresh-pill">Starting</span><span id="sellerLastUpdated" class="last-updated">Last updated: never</span></div><button id="createSeller" class="btn primary">Create Seller</button></div>
|
|
40
|
+
<div class="app-list"><div class="app-table-head"><span></span><span>Seller</span><span>Upstream</span><span>Discount</span><span>Capacity</span><span>Latency</span><span>Balance</span><span>Status</span><span></span></div><div id="sellerRows"><div class="loading-row" role="status" aria-label="Loading sellers"><span class="spinner" aria-hidden="true"></span></div></div></div></div>
|
|
41
|
+
</section>
|
|
42
|
+
<section id="page-bootstrap" class="page">
|
|
43
|
+
<div class="bootstrap-card"><div class="panel-head"><h1 class="title">Bootstrap</h1><div class="modal-actions"><button id="openBootstrapConfig" class="btn primary">Edit Bootstrap Config</button><button id="refreshBootstrap" class="btn">Refresh</button></div></div><div id="bootstrapGrid" class="bootstrap-grid"></div></div>
|
|
44
|
+
</section>
|
|
45
|
+
</main>
|
|
46
|
+
<section id="detailModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title"><span id="detailTitle">seller detail</span><button id="deleteSeller" class="btn icon danger" title="Delete deployment" aria-label="Delete deployment"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M6 6l1 16h10l1-16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path></svg></button></div><div class="modal-actions"><button class="btn" data-status-action="drain">Drain</button><button class="btn" data-status-action="offline">Offline</button><button class="btn" data-status-action="activate">Activate</button><button id="editDetail" class="btn primary">Edit config</button><button id="closeDetail" class="btn">Close</button></div></header><div class="modal-body"><div id="detailStatus" class="status-line hidden"></div><div class="detail-grid"><div class="card"><h2>Seller configuration</h2><div id="configFields" class="form-grid"></div></div><div class="card"><h2>Models</h2><div id="modelsTable"></div></div></div></div></div></section>
|
|
47
|
+
<section id="createModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title">New seller</div><div class="modal-actions"><button id="submitCreate" class="btn primary">Create seller</button><button id="closeCreate" class="btn">Close</button></div></header><div class="modal-body"><div id="createStatus" class="status-line hidden"></div><div id="createProgress" class="card create-progress hidden"></div><div id="createFields" class="create-form"></div></div></div></section>
|
|
48
|
+
<section id="bootstrapModal" class="modal"><div class="modal-shell"><header class="modal-head"><div class="modal-title">Bootstrap service</div><div class="modal-actions"><button id="closeBootstrapConfig" class="btn">Close</button></div></header><div class="modal-body"><div id="bootstrapStatus" class="status-line">Ready</div><div class="detail-grid"><div class="card"><h2>Bootstrap status</h2><div id="bootstrapConfigMetrics" class="bootstrap-grid"></div></div><div class="card"><h2>Bootstrap configuration</h2><div id="bootstrapConfigFields" class="form-grid"></div></div></div></div></div></section>
|
|
49
|
+
<script>${adminUiScript()}</script>
|
|
50
|
+
</body>
|
|
51
|
+
</html>`;
|
|
52
|
+
}
|
|
53
|
+
function adminUiScript() {
|
|
54
|
+
return `
|
|
55
|
+
const session = new URLSearchParams(location.search).get("session") || "";
|
|
56
|
+
async function api(path, options={}){
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(path, { ...options, headers: { "Content-Type": "application/json", "X-TokenBuddy-Ui-Session": session, ...(options.headers || {}) } });
|
|
59
|
+
const text = await response.text();
|
|
60
|
+
let data = {};
|
|
61
|
+
try {
|
|
62
|
+
data = text ? JSON.parse(text) : {};
|
|
63
|
+
} catch {
|
|
64
|
+
data = { error: response.ok ? "Request returned an unreadable response." : "Request failed." };
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) throw new Error(uiErrorMessage(data.error || response.statusText));
|
|
67
|
+
return data;
|
|
68
|
+
} catch (err) {
|
|
69
|
+
throw new Error(uiErrorMessage(err));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function uiErrorMessage(err){ const message = err instanceof Error ? err.message : String(err || ""); if (/failed to fetch|networkerror|load failed|fetch failed/i.test(message)) return "Admin UI connection lost. Reopen the latest tb-admin UI URL and reload this page."; if (/operator_auth_required/i.test(message)) return "Admin UI session expired. Reopen the latest tb-admin UI URL."; if (/HTTP Error 401/i.test(message)) return "Authentication failed while loading admin data. Reopen the latest tb-admin UI URL and check the local admin profile."; return message || "Request failed."; }
|
|
73
|
+
const sellerRefreshIntervalMs = 30000;
|
|
74
|
+
let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerRefreshInFlight = false; let sellerRefreshTimer = null; let sellerClockTimer = null; let sellerLastUpdatedAt = null; let sellerNextRefreshAt = null; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
|
|
75
|
+
const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&","<":"<",">":">",'"':""","'":"'" }[c]));
|
|
76
|
+
const fmt = value => value === undefined || value === null || value === "" ? "unknown" : value;
|
|
77
|
+
const missing = label => '<span class="muted-value">'+esc(label)+'</span>';
|
|
78
|
+
const infoIcon = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h7"></path><path d="M15 7h5"></path><path d="M13 5v4"></path><path d="M4 12h3"></path><path d="M11 12h9"></path><path d="M9 10v4"></path><path d="M4 17h10"></path><path d="M18 17h2"></path><path d="M16 15v4"></path></svg>';
|
|
79
|
+
const balanceProbeTemplates = ["openrouter","deepseek","stepfun","siliconflow","novita","newapi_generic","usage_generic","none","auto"];
|
|
80
|
+
const paymentMethods = ["clawtip","mock"];
|
|
81
|
+
const loadingSpinner = label => '<div class="loading-row" role="status" aria-label="'+esc(label)+'"><span class="spinner" aria-hidden="true"></span></div>';
|
|
82
|
+
document.querySelectorAll(".top-link").forEach(btn => btn.onclick = () => { document.querySelectorAll(".top-link").forEach(b => b.classList.remove("active")); btn.classList.add("active"); document.querySelectorAll(".page").forEach(p => p.classList.remove("active")); document.getElementById("page-" + btn.dataset.page).classList.add("active"); if (btn.dataset.page === "bootstrap") loadBootstrap(); });
|
|
83
|
+
async function loadSellers(options={}){ if (sellerRefreshInFlight) return; clearTimeout(sellerRefreshTimer); sellerRefreshTimer = null; sellerNextRefreshAt = null; const initial = Boolean(options.initial); sellerRefreshInFlight = true; sellerRefreshError = ""; updateSellerRefreshMeta(true); if (initial) document.getElementById("sellerRows").innerHTML = loadingSpinner("Loading sellers"); try { const rows = await api("/api/sellers"); renderSellerRows(rows); sellerLastUpdatedAt = new Date(); sellerRefreshError = ""; } catch (err) { sellerRefreshError = err.message || "Refresh failed"; if (initial) document.getElementById("sellerRows").innerHTML = '<div class="status-line">'+esc(sellerRefreshError)+'</div>'; } finally { sellerRefreshInFlight = false; scheduleSellerRefresh(); updateSellerRefreshMeta(false); } }
|
|
84
|
+
function renderSellerRows(rows){ sellerRowsCache = rows; document.getElementById("sellerRows").innerHTML = rows.map(row => sellerRow(row)).join(""); document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
|
|
85
|
+
function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => updateSellerRefreshMeta(sellerRefreshInFlight), 1000); window.addEventListener("beforeunload", () => { clearTimeout(sellerRefreshTimer); clearInterval(sellerClockTimer); }); }
|
|
86
|
+
function scheduleSellerRefresh(){ clearTimeout(sellerRefreshTimer); sellerNextRefreshAt = new Date(Date.now() + sellerRefreshIntervalMs); sellerRefreshTimer = setTimeout(() => loadSellers(), sellerRefreshIntervalMs); }
|
|
87
|
+
function updateSellerRefreshMeta(refreshing){ const state = document.getElementById("sellerRefreshState"); const updated = document.getElementById("sellerLastUpdated"); if (!state || !updated) return; const nextSeconds = sellerNextRefreshAt ? Math.max(0, Math.ceil((sellerNextRefreshAt.getTime() - Date.now()) / 1000)) : 0; state.classList.toggle("refreshing", Boolean(refreshing)); state.classList.toggle("error", Boolean(sellerRefreshError)); state.innerHTML = refreshing ? '<span class="spinner" aria-hidden="true"></span><span>Refreshing</span>' : esc(sellerRefreshError || (sellerLastUpdatedAt ? "Next refresh: " + nextSeconds + "s" : "Starting")); if (!sellerLastUpdatedAt){ updated.textContent = "Last updated: never"; return; } const agoSeconds = Math.max(0, Math.floor((Date.now() - sellerLastUpdatedAt.getTime()) / 1000)); updated.textContent = "Last updated: " + sellerLastUpdatedAt.toLocaleTimeString() + " (" + agoSeconds + "s ago)"; }
|
|
88
|
+
function sellerRow(row){ const tip = [row.description, row.region, row.app, row.specs?.memoryGb ? row.specs.memoryGb + "GB" : "", row.specs?.machines ? row.specs.machines + " machines" : "", row.modelsCount ? row.modelsCount + " models" : ""].filter(Boolean).join(" · ") || "No specs"; const ttft = row.ttftMs === undefined || row.ttftMs === null ? "TTFT n/a" : "TTFT " + row.ttftMs + "ms"; const avgInference = row.avgInferenceMs === undefined || row.avgInferenceMs === null ? missing("avg n/a") : "avg " + esc(row.avgInferenceMs) + "ms"; const balance = row.upstreamBalance === undefined || row.upstreamBalance === null || row.upstreamBalance === "" ? missing("not reported") : '<strong>'+esc(row.upstreamBalance)+'</strong>'; return '<button class="app-row" type="button" data-detail="'+esc(row.id)+'"><span class="app-dot '+esc(row.nodeStatus)+'"></span><span class="app-name"><span class="seller-title"><strong>'+esc(row.name)+'</strong><span class="spec-tip" title="'+esc(tip)+'" aria-label="Seller specs">'+infoIcon+'</span></span></span><span class="field-cell"><strong>'+esc(row.upstreamDomain)+'</strong></span><span class="field-cell"><strong>'+esc(fmt(row.discountRatio))+'</strong></span><span class="field-cell"><strong>'+esc(fmt(row.capacityUsed))+' / '+esc(fmt(row.capacityLimit))+'</strong></span><span class="speed-cell"><strong>'+esc(ttft)+'</strong><span>'+avgInference+'</span></span><span class="field-cell"><span class="balance-line">'+balance+(row.upstreamRechargeUrl ? '<a class="recharge-btn" href="'+esc(row.upstreamRechargeUrl)+'" target="_blank" rel="noreferrer">↗</a>' : '')+'</span></span><span class="field-cell"><strong>'+esc(row.upstreamStatus)+'</strong></span><span class="row-actions"><span class="detail-btn">›</span></span></button>'; }
|
|
89
|
+
async function loadBootstrap(){ try { const data = await api("/api/bootstrap"); const items = [["Status", data.status],["Registry", data.registryVersion],["Seller entries", data.sellerEntries],["Regions", (data.regions || []).join(", ") || "unknown"]]; document.getElementById("bootstrapGrid").innerHTML = items.map(([k,v]) => '<div class="metric"><label>'+esc(k)+'</label><strong>'+esc(fmt(v))+'</strong></div>').join(""); } catch (err) { document.getElementById("bootstrapGrid").innerHTML = '<div class="status-line">'+esc(uiErrorMessage(err))+'</div>'; } }
|
|
90
|
+
async function openBootstrapConfig(){ try { const data = await api("/api/bootstrap/config"); document.getElementById("bootstrapStatus").textContent = uiErrorMessage(data.error || "Ready"); const clawtip = data.clawtip || {}; const metrics = [["service", data.status],["skill", clawtip.skillSlug],["activation", clawtip.activationFeeFen],["micros/fen", clawtip.microsPerFen]]; document.getElementById("bootstrapConfigMetrics").innerHTML = metrics.map(([k,v]) => '<div class="metric"><label>'+esc(k)+'</label><strong>'+esc(fmt(v))+'</strong></div>').join(""); const fields = [["sellerRegistryPath", data.sellerRegistryPath],["bind.host", data.bindHost],["bind.port", data.bindPort],["allowLocalSellerUrls", String(Boolean(data.allowLocalSellerUrls))],["clawtip.payTo", clawtip.payToMasked],["clawtip.sm4KeyBase64", clawtip.sm4KeyMasked],["clawtip.skillId", clawtip.skillId],["clawtip.description", clawtip.description],["clawtip.resourceUrl", clawtip.resourceUrl]]; document.getElementById("bootstrapConfigFields").innerHTML = fields.map(([k,v]) => fieldHtml(k,v,false)).join(""); document.getElementById("bootstrapModal").classList.add("open"); } catch (err) { document.getElementById("bootstrapStatus").textContent = uiErrorMessage(err); document.getElementById("bootstrapConfigMetrics").innerHTML = ""; document.getElementById("bootstrapConfigFields").innerHTML = ""; document.getElementById("bootstrapModal").classList.add("open"); } }
|
|
91
|
+
async function openDetail(id){ editing = false; deleteReady = false; currentDetail = null; document.getElementById("detailTitle").textContent = id + " detail"; showDetailStatus("Loading seller data", true); document.getElementById("configFields").innerHTML = loadingSpinner("Loading configuration"); document.getElementById("modelsTable").innerHTML = loadingSpinner("Loading models"); document.getElementById("detailModal").classList.add("open"); try { currentDetail = await api("/api/sellers/"+encodeURIComponent(id)); renderDetail(); } catch (err) { showDetailStatus(err.message || "Failed to load seller detail", false); } }
|
|
92
|
+
function renderDetail(){ const d = currentDetail; document.getElementById("detailTitle").textContent = d.row.name + " detail"; document.getElementById("editDetail").textContent = editing ? "Save changes" : "Edit config"; document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment"; showDetailStatus(d.row.error || "", false); const c = d.configuration; const fields = [["registryStatus", c.registryStatus],["region", c.region],["upstreamUrl", c.upstreamUrl],["upstreamApiKey", c.upstreamApiKeyMasked],["upstreamStatus", c.upstreamStatus],["ttftMs", msValue(d.row.ttftMs)],["avgInferenceMs", msValue(d.row.avgInferenceMs)],["lastInferenceMs", msValue(d.row.lastInferenceMs)],["latencySamples", d.row.latencySamples],["upstreamBalance", c.upstreamBalance],["upstreamBalanceSource", c.upstreamBalanceSource],["upstreamBalanceFetchedAt", c.upstreamBalanceFetchedAt],["upstreamBalanceError", c.upstreamBalanceError],["upstreamBalanceProbeTemplate", c.upstreamBalanceProbeTemplate],["upstreamBalanceProbeUrl", c.upstreamBalanceProbeUrl],["upstreamBalanceProbeUserId", c.upstreamBalanceProbeUserId],["upstreamBalanceProbeRechargeUrl", c.upstreamBalanceProbeRechargeUrl],["markupRatio", c.markupRatio],["discountRatio", c.discountRatio],["maxConnections", c.maxConnections],["maxQueueDepth", c.maxQueueDepth]]; document.getElementById("configFields").innerHTML = detailFieldsHtml(fields); const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean))); document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>Avg infer</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(fmt(m.inputPrice))+'</td><td>'+esc(fmt(m.outputPrice))+'</td><td>'+metricCell(m.ttftMs)+'</td><td>'+metricCell(m.avgInferenceMs)+'</td><td>'+metricCell(m.latencySamples)+'</td></tr>').join("")+'</tbody></table>'; }
|
|
93
|
+
function showDetailStatus(message, loading){ const el = document.getElementById("detailStatus"); if (loading){ el.innerHTML = '<span class="spinner" aria-hidden="true"></span>'; el.setAttribute("role", "status"); el.setAttribute("aria-label", message || "Loading"); } else { el.textContent = message || ""; el.removeAttribute("role"); el.removeAttribute("aria-label"); } el.classList.toggle("hidden", !message && !loading); el.classList.toggle("loading", Boolean(loading)); }
|
|
94
|
+
function metricCell(value){ return value === undefined || value === null || value === "" ? missing("n/a") : esc(value); }
|
|
95
|
+
function msValue(value){ return value === undefined || value === null || value === "" ? undefined : value + "ms"; }
|
|
96
|
+
function detailFieldsHtml(fields){ return fields.filter(([key,value]) => !isMissingDetailField(key,value)).map(([key,value]) => { const editable = editing && ["upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeRechargeUrl","upstreamBalanceProbeUserId","markupRatio","discountRatio","maxConnections","maxQueueDepth"].includes(key); return fieldHtml(key, value, editable); }).join(""); }
|
|
97
|
+
function isMissingDetailField(key,value){ return value === undefined || value === null || value === "" || (key === "registryStatus" && value === "unknown"); }
|
|
98
|
+
function fieldHtml(key,value, editable, options={}){ const fieldId = "field-" + String(key).replace(/[^a-z0-9_-]/gi, "-") + "-" + Math.random().toString(36).slice(2); const label = options.label || key; const labelText = label + (options.required ? ' <span class="required-star">*</span>' : ''); const display = value === undefined || value === null ? "" : value; const className = "field" + (options.full ? " full" : ""); const help = options.help ? '<p class="field-help">'+esc(options.help)+'</p>' : ""; const hiddenInput = '<input type="hidden" data-field="'+esc(key)+'" value="'+esc(display)+'">'; if (options.hidden) return hiddenInput; if (!editable || options.readonly) return '<div class="'+className+'"><label>'+labelText+'</label><div class="readonly-value" title="'+esc(display)+'" data-readonly-field="'+esc(key)+'">'+esc(display)+'</div>'+(options.submit ? hiddenInput : "")+help+'</div>'; if (key === "upstreamBalanceProbeTemplate") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+balanceProbeTemplates.map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; const type = options.type || (String(key).toLowerCase().includes("key") ? "password" : "text"); const attrs = options.type === "number" ? ' type="number" step="any"' : ' type="'+esc(type)+'"'; const placeholder = options.placeholder ? ' placeholder="'+esc(options.placeholder)+'"' : ""; return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><input'+attrs+placeholder+' id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'" value="'+esc(display)+'">'+help+'</div>'; }
|
|
99
|
+
function selectHtml(upstreamModel, billingModel, options){ const values = options.includes(billingModel) ? options : [billingModel, ...options].filter(Boolean); return '<select name="billingModel" aria-label="billingModel" autocomplete="off" data-model="'+esc(upstreamModel)+'">'+values.map(value => '<option '+(value === billingModel ? "selected" : "")+'>'+esc(value)+'</option>').join("")+'</select>'; }
|
|
100
|
+
document.getElementById("editDetail").onclick = async () => { if (!editing){ editing = true; renderDetail(); return; } const patch = {}; document.querySelectorAll("[data-field]").forEach(input => { const value = input.value; if (value === "" || value === input.dataset.original) return; patch[input.dataset.field] = numeric(value); }); const aliases = {}; document.querySelectorAll("[data-model]").forEach(input => aliases[input.dataset.model] = input.value); patch.modelAliases = aliases; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/config", { method:"PUT", body: JSON.stringify(patch) }); showDetailStatus(result.ok ? "Saved" : (result.stderr || "Save failed"), false); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); editing = false; renderDetail(); loadSellers(); };
|
|
101
|
+
document.getElementById("deleteSeller").onclick = async () => { if (!currentDetail) return; if (deleteReady && !confirm("Destroy deployment for "+currentDetail.row.name+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/deployment", { method:"DELETE", body: JSON.stringify({ confirm: deleteReady }) }); showDetailStatus(result.stdout || (deleteReady ? "Deployment destroy requested." : "Dry-run ready. Click delete again to confirm destroy."), false); deleteReady = !deleteReady; document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment"; };
|
|
102
|
+
document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
|
|
103
|
+
document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; if (!confirm("Set "+currentDetail.row.name+" registry status via "+action+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/"+action, { method:"POST" }); showDetailStatus(result.stdout || (result.ok ? "Registry status updated." : result.stderr || "Status update failed."), false); currentDetail = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)); renderDetail(); loadSellers(); });
|
|
104
|
+
document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
|
|
105
|
+
document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
|
|
106
|
+
function buildCreateForm(){ clearInterval(createJobTimer); createJobTimer = null; currentCreateJob = null; expandedProgressSteps = new Set(); createAppSuffix = randomAppSuffix(); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.add("hidden"); document.getElementById("createProgress").innerHTML = ""; setCreateFormDisabled(false); const defaults = createDefaults(); document.getElementById("createFields").innerHTML = createFormHtml(defaults); setupCreateFormBehavior(); setupPaymentTabs(); }
|
|
107
|
+
function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
|
|
108
|
+
function createSectionHtml(title, fields, defaults){ return '<section class="create-section"><h2>'+esc(title)+'</h2><div class="section-grid">'+fields.map(key => fieldHtml(key, defaults[key], createFieldEditable(key), createFieldOptions(key))).join("")+'</div></section>'; }
|
|
109
|
+
function paymentSectionHtml(defaults){ const enabled = new Set(defaults.paymentMethods || []); const editableFields = [["clawtipPayTo","payTo"],["clawtipSm4KeyBase64","sm4KeyBase64"]]; const fixedFields = ["clawtipSkillSlug","clawtipSkillId","clawtipDescription","clawtipResourceUrl","clawtipActivationFeeFen","clawtipMicrosPerFen"]; return '<section class="create-section"><h2>支付设置</h2><div class="payment-tabs">'+paymentMethods.map((method,index) => '<button type="button" class="payment-tab '+(index === 0 ? "active" : "")+'" data-payment-tab="'+esc(method)+'">'+esc(method)+'</button>').join("")+'</div><div class="payment-panel" data-payment-panel="clawtip" data-enabled="'+String(enabled.has("clawtip"))+'"><div class="payment-head"><h3>ClawTip</h3>'+paymentToggleHtml("clawtip", enabled.has("clawtip"))+'</div><div class="section-grid">'+editableFields.map(([key,label]) => fieldHtml(key, defaults[key], true, { ...createFieldOptions(key), label })).join("")+fixedFields.map(key => fieldHtml(key, defaults[key], false, { hidden:true })).join("")+'<div class="field full"><label>自动生成参数</label><div class="generated-summary" data-generated-summary="clawtip"></div></div></div></div><div class="payment-panel hidden" data-payment-panel="mock" data-enabled="'+String(enabled.has("mock"))+'"><div class="payment-head"><h3>Mock</h3>'+paymentToggleHtml("mock", enabled.has("mock"))+'</div><div class="section-grid">'+fieldHtml("参数规则", "启用即可使用 mock 支付方式,无需额外参数", false, { full:true })+'</div></div></section>'; }
|
|
110
|
+
function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
|
|
111
|
+
function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
|
|
112
|
+
function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
|
|
113
|
+
function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Example: 8.", maxQueueDepth:"Pending request queue limit. Example: 4.", markupRatio:"Seller price multiplier before discount. Example: 1.2.", discountRatio:"Bootstrap registry discount multiplier. Example: 1.", clawtipPayTo:"ClawTip payment target, for example the payTo value issued for this seller.", clawtipSm4KeyBase64:"Base64 SM4 key for ClawTip payment verification." }; const required = ["sellerName","region","upstreamWebsite","upstreamUrl","upstreamApiKey","maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipPayTo","clawtipSm4KeyBase64"].includes(key); const options = { ...numericFieldOptions(key), label: labels[key] || key, help: help[key], required }; if (["app","image","flyConfig"].includes(key)) return { ...options, readonly:true, submit:true }; if (key === "upstreamBalanceProbeUrl") return { ...options, placeholder:"https://code.shoestravel.xin/api/user/self" }; if (key === "upstreamBalanceProbeUserId") return { ...options, placeholder:"user_123 for NewAPI generic" }; if (key === "upstreamBalanceProbeRechargeUrl") return { ...options, placeholder:"https://code.shoestravel.xin/topup" }; if (key === "upstreamWebsite") return { ...options, placeholder:"https://moonshot.cn" }; if (key === "upstreamUrl") return { ...options, placeholder:"https://api.moonshot.cn/v1" }; return options; }
|
|
114
|
+
function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
|
|
115
|
+
function setupCreateFormBehavior(){ ["sellerName","upstreamUrl","upstreamBalanceProbeTemplate"].forEach(key => { const input = document.querySelector('#createFields [data-field="'+key+'"]'); if (!input) return; input.addEventListener("input", updateGeneratedCreateFields); input.addEventListener("change", updateGeneratedCreateFields); }); updateGeneratedCreateFields(); }
|
|
116
|
+
function updateGeneratedCreateFields(){ const sellerName = createFieldString("sellerName") || "seller"; const app = appNameFromSellerName(sellerName); const template = createFieldString("upstreamBalanceProbeTemplate") || "auto"; const upstreamUrl = createFieldString("upstreamUrl") || ""; const balanceInput = document.querySelector('#createFields [data-field="upstreamBalanceProbeUrl"]'); setCreateFieldValue("app", app); if (balanceInput){ const editableBalanceUrl = template === "newapi_generic"; balanceInput.disabled = !editableBalanceUrl; balanceInput.readOnly = !editableBalanceUrl; if (editableBalanceUrl && balanceInput.dataset.autoGenerated === "true") { setCreateFieldValue("upstreamBalanceProbeUrl", ""); balanceInput.dataset.autoGenerated = "false"; } if (!editableBalanceUrl) { setCreateFieldValue("upstreamBalanceProbeUrl", balanceProbeUrlForTemplate(template, upstreamUrl)); balanceInput.dataset.autoGenerated = "true"; } } setCreateFieldValue("clawtipSkillSlug", app); setCreateFieldValue("clawtipSkillId", "si-" + app); setCreateFieldValue("clawtipDescription", "TokenBuddy Seller " + sellerSlugFromName(sellerName)); setCreateFieldValue("clawtipResourceUrl", "https://" + app + ".fly.dev"); setCreateFieldValue("clawtipActivationFeeFen", "1"); setCreateFieldValue("clawtipMicrosPerFen", "10000"); const summary = document.querySelector('[data-generated-summary="clawtip"]'); if (summary) summary.textContent = "skillSlug=" + app + " · skillId=si-" + app + " · resourceUrl=https://" + app + ".fly.dev · activationFeeFen=1"; }
|
|
117
|
+
function createFieldString(key){ const input = document.querySelector('#createFields [data-field="'+key+'"]'); return input ? String(input.value || "").trim() : ""; }
|
|
118
|
+
function setCreateFieldValue(key,value){ document.querySelectorAll('#createFields [data-field="'+key+'"]').forEach(input => { input.value = value; }); document.querySelectorAll('#createFields [data-readonly-field="'+key+'"]').forEach(el => { el.textContent = value; el.title = value; }); }
|
|
119
|
+
function appNameFromSellerName(value){ return "tbs-" + sellerSlugFromName(value) + "-" + createAppSuffix; }
|
|
120
|
+
function sellerSlugFromName(value){ const slug = String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-"); return slug || "seller"; }
|
|
121
|
+
function balanceProbeUrlForTemplate(template, upstreamUrl){ const host = hostName(upstreamUrl); if (template === "none") return ""; if (template === "openrouter") return "https://openrouter.ai/api/v1/credits"; if (template === "deepseek") return "https://api.deepseek.com/user/balance"; if (template === "stepfun") return "https://api.stepfun.com/v1/accounts"; if (template === "novita") return "https://api.novita.ai/v3/user/balance"; if (template === "siliconflow") return host.endsWith(".com") ? "https://api.siliconflow.com/v1/user/info" : "https://api.siliconflow.cn/v1/user/info"; if (template === "usage_generic") return usageUrl(upstreamUrl); if (template === "auto"){ if (host === "api.deepseek.com") return "https://api.deepseek.com/user/balance"; if (host === "api.stepfun.ai" || host === "api.stepfun.com") return "https://api.stepfun.com/v1/accounts"; if (host === "api.siliconflow.cn") return "https://api.siliconflow.cn/v1/user/info"; if (host === "api.siliconflow.com") return "https://api.siliconflow.com/v1/user/info"; if (host === "openrouter.ai") return "https://openrouter.ai/api/v1/credits"; if (host === "api.novita.ai") return "https://api.novita.ai/v3/user/balance"; return usageUrl(upstreamUrl); } return ""; }
|
|
122
|
+
function usageUrl(value){ const base = String(value || "").trim().replace(/\\/+$/,""); if (!base) return ""; return /\\/v1$/i.test(base) ? base + "/usage" : base + "/v1/usage"; }
|
|
123
|
+
function hostName(value){ try { return new URL(String(value || "")).hostname.toLowerCase(); } catch { return ""; } }
|
|
124
|
+
function setupPaymentTabs(){ document.querySelectorAll("[data-payment-tab]").forEach(tab => tab.onclick = () => selectPaymentTab(tab.dataset.paymentTab)); document.querySelectorAll("[data-payment-toggle]").forEach(toggle => toggle.onclick = () => togglePaymentMethod(toggle.dataset.paymentToggle)); selectPaymentTab("clawtip"); updatePaymentPanels(); }
|
|
125
|
+
function selectPaymentTab(method){ document.querySelectorAll("[data-payment-tab]").forEach(tab => tab.classList.toggle("active", tab.dataset.paymentTab === method)); document.querySelectorAll("[data-payment-panel]").forEach(panel => panel.classList.toggle("hidden", panel.dataset.paymentPanel !== method)); }
|
|
126
|
+
function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
|
|
127
|
+
function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
|
|
128
|
+
function updatePaymentPanels(){ document.querySelectorAll("[data-payment-panel]").forEach(panel => { const enabled = panel.dataset.enabled === "true"; const toggle = panel.querySelector("[data-payment-toggle]"); if (toggle){ toggle.classList.toggle("enabled", enabled); toggle.setAttribute("aria-pressed", String(enabled)); toggle.textContent = enabled ? "已启用" : "未启用"; } panel.querySelectorAll("[data-field]").forEach(input => { input.disabled = !enabled; }); }); }
|
|
129
|
+
function createDefaults(){ const regions = sellerRowsCache.map(row => String(row.region || "").toLowerCase()).filter(Boolean); const region = regions[0] || "sin"; const existing = new Set(sellerRowsCache.flatMap(row => [row.id, row.name, row.app].filter(Boolean))); let index = sellerRowsCache.length + 1; let sellerName = "openrouter-ai"; while (existing.has(sellerName)) sellerName = "openrouter-ai-" + index++; const app = appNameFromSellerName(sellerName); return { sellerName, app, region, image:"registry.fly.io/tb-seller:latest", upstreamWebsite:"https://openrouter.ai", upstreamUrl:"https://openrouter.ai/api/v1", upstreamApiKey:"", upstreamBalanceProbeTemplate:"openrouter", upstreamBalanceProbeUrl:"https://openrouter.ai/api/v1/credits", upstreamBalanceProbeUserId:"", upstreamBalanceProbeRechargeUrl:"https://openrouter.ai/settings/credits", maxConnections:8, maxQueueDepth:4, markupRatio:1.2, discountRatio:1, paymentMethods:["clawtip"], clawtipPayTo:"", clawtipSm4KeyBase64:"", clawtipSkillSlug:app, clawtipSkillId:"si-"+app, clawtipDescription:"TokenBuddy Seller "+sellerName, clawtipResourceUrl:"https://"+app+".fly.dev", clawtipActivationFeeFen:1, clawtipMicrosPerFen:10000, flyConfig:"deploy/fly.io/fly.tb-seller.toml" }; }
|
|
130
|
+
document.getElementById("submitCreate").onclick = async () => { const body = { paymentMethods: enabledPaymentMethods() }; document.querySelectorAll("#createFields [data-field]").forEach(input => body[input.dataset.field] = fieldValue(input)); if (!confirm("Create seller deployment "+body.sellerName+" on Fly.io?")) return; try { setCreateFormDisabled(true); document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = "Creating"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.remove("hidden"); document.getElementById("createProgress").innerHTML = '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; const response = await api("/api/sellers", { method:"POST", body: JSON.stringify(body) }); renderCreateJob(response.job); pollCreateJob(response.jobId); } catch (err) { setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } };
|
|
131
|
+
function pollCreateJob(jobId){ clearInterval(createJobTimer); createJobTimer = setInterval(async () => { try { const job = await api("/api/jobs/"+encodeURIComponent(jobId)); renderCreateJob(job); if (job.status !== "running"){ clearInterval(createJobTimer); createJobTimer = null; if (job.status === "succeeded") loadSellers(); } } catch (err) { clearInterval(createJobTimer); createJobTimer = null; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } }, 1200); }
|
|
132
|
+
function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const done = job.status !== "running"; const events = job.events || []; const deploymentStarted = events.some(event => event.stepId === "create_deployment"); const progress = document.getElementById("createProgress"); const status = document.getElementById("createStatus"); progress.classList.remove("hidden"); status.classList.toggle("hidden", !done); status.classList.remove("loading"); if (done){ status.textContent = job.status === "succeeded" ? "Created and published to bootstrap registry." : (job.error || "Create seller failed."); status.removeAttribute("role"); } else { status.textContent = ""; } progress.innerHTML = events.map(event => progressStep(event)).join("") || '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; if (done && job.status === "failed" && !deploymentStarted){ setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; } else if (done) { document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = job.status === "succeeded" ? "Created" : "Create failed"; } }
|
|
133
|
+
function progressStep(event){ const result = event.result || {}; const log = [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n").slice(0, 1600); const expanded = expandedProgressSteps.has(event.stepId); const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<button type="button" class="progress-step '+esc(event.status)+'" data-progress-step="'+esc(event.stepId)+'" aria-expanded="'+String(expanded)+'"><div class="progress-title">'+spinner+'<strong>'+esc(event.title)+'</strong></div><div class="progress-meta"><span>'+esc(event.message || event.status)+'</span>'+(log ? '<span class="progress-toggle">'+(expanded ? "Hide details" : "Show details")+'</span>' : '')+'</div>'+(log && expanded ? '<pre class="progress-log">'+esc(log)+'</pre>' : '')+'</button>'; }
|
|
134
|
+
function setCreateFormDisabled(disabled){ document.querySelectorAll("#createFields [data-field], #createFields [data-payment-tab], #createFields [data-payment-toggle]").forEach(input => { input.disabled = Boolean(disabled); }); if (!disabled) updatePaymentPanels(); }
|
|
135
|
+
document.getElementById("createProgress").onclick = event => { const step = event.target.closest("[data-progress-step]"); if (!step) return; const id = step.dataset.progressStep; if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); };
|
|
136
|
+
document.getElementById("refreshBootstrap").onclick = loadBootstrap;
|
|
137
|
+
document.getElementById("openBootstrapConfig").onclick = openBootstrapConfig;
|
|
138
|
+
document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
|
|
139
|
+
function fieldValue(input){ return numeric(input.value); }
|
|
140
|
+
function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
|
|
141
|
+
startSellerRefresh();
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
//# sourceMappingURL=ui-static.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui-static.js","sourceRoot":"","sources":["../../src/ui-static.ts"],"names":[],"mappings":"AAAA,MAAM,UAAU,WAAW;IACzB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;YA+CG,aAAa,EAAE;;QAEnB,CAAC;AACT,CAAC;AAED,SAAS,aAAa;IACpB,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwFR,CAAC;AACF,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type BalanceCurrency = "USD" | "CNY" | null;
|
|
2
|
+
export type BalanceSource = "deepseek" | "stepfun" | "siliconflow" | "openrouter" | "novita" | "newapi_generic" | "usage_generic" | "unknown";
|
|
3
|
+
export type BalanceProbeTemplate = "auto" | "deepseek" | "stepfun" | "siliconflow" | "openrouter" | "novita" | "newapi_generic" | "usage_generic" | "none";
|
|
4
|
+
export interface BalanceSnapshot {
|
|
5
|
+
rawAmount: number | null;
|
|
6
|
+
amountUsdMicros: number | null;
|
|
7
|
+
currency: BalanceCurrency;
|
|
8
|
+
source: BalanceSource;
|
|
9
|
+
fetchedAt: number;
|
|
10
|
+
error?: {
|
|
11
|
+
httpStatus: number;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export interface BalanceProbeConfig {
|
|
16
|
+
upstreamUrl?: string;
|
|
17
|
+
upstreamBalanceUrl?: string;
|
|
18
|
+
upstreamApiKey?: string;
|
|
19
|
+
upstreamUserId?: string;
|
|
20
|
+
upstreamBalanceProbe?: {
|
|
21
|
+
template?: BalanceProbeTemplate;
|
|
22
|
+
url?: string;
|
|
23
|
+
userId?: string;
|
|
24
|
+
rechargeUrl?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export interface BalanceProbeOptions {
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
cnyUsdRate?: number;
|
|
32
|
+
cache?: BalanceProbeCache;
|
|
33
|
+
}
|
|
34
|
+
export declare class BalanceProbeCache {
|
|
35
|
+
private readonly entries;
|
|
36
|
+
get(key: string, now: number): BalanceSnapshot | undefined;
|
|
37
|
+
setFailure(key: string, snapshot: BalanceSnapshot, now: number): void;
|
|
38
|
+
}
|
|
39
|
+
export declare const defaultBalanceProbeCache: BalanceProbeCache;
|
|
40
|
+
export declare function probeUpstreamBalance(config: BalanceProbeConfig, options?: BalanceProbeOptions): Promise<BalanceSnapshot>;
|
|
41
|
+
//# sourceMappingURL=upstream-balance-probe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"upstream-balance-probe.d.ts","sourceRoot":"","sources":["../../src/upstream-balance-probe.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,KAAK,GAAG,KAAK,GAAG,IAAI,CAAC;AACnD,MAAM,MAAM,aAAa,GACrB,UAAU,GACV,SAAS,GACT,aAAa,GACb,YAAY,GACZ,QAAQ,GACR,gBAAgB,GAChB,eAAe,GACf,SAAS,CAAC;AAEd,MAAM,MAAM,oBAAoB,GAC5B,MAAM,GACN,UAAU,GACV,SAAS,GACT,aAAa,GACb,YAAY,GACZ,QAAQ,GACR,gBAAgB,GAChB,eAAe,GACf,MAAM,CAAC;AAEX,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,QAAQ,EAAE,eAAe,CAAC;IAC1B,MAAM,EAAE,aAAa,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QACN,UAAU,EAAE,MAAM,CAAC;QACnB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE;QACrB,QAAQ,CAAC,EAAE,oBAAoB,CAAC;QAChC,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,iBAAiB,CAAC;CAC3B;AAaD,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuE;IAExF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS;IAW1D,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI;CAM7E;AAED,eAAO,MAAM,wBAAwB,mBAA0B,CAAC;AAEhE,wBAAsB,oBAAoB,CACxC,MAAM,EAAE,kBAAkB,EAC1B,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,eAAe,CAAC,CAsD1B"}
|