Files
ModelRouter/internal/gateway/ui/index.html
JianFeeeee 3ddae41f0c fix(gateway): aggregate the full audit history, repair record paging
Two problems reported after the on-demand log work landed.

1. Dashboard totals were wrong. LoadAudit only replayed the last 4 MB of the
   audit file, so requests/tokens/per-key rows reflected a window instead of all
   time — a regression in reported numbers, not just in presentation.

   The aggregates are now built by streaming EVERY audit file (oldest first, so
   the hourly quota buckets keep their intended trailing window) and keeping
   nothing per record: aggregate maps are keyed by key/model/source, so their
   size is bounded by cardinality. Measured on the production host: 29 MB /
   221k lines / 37k requests in ~260 ms at startup.

   What stays bounded is the RAW-record ring: a fixed-size reqRing keeps only the
   newest maxRecs records, so the ~25 MB that used to be spent appending every
   record into a slice is still saved. auditReplayBytes is gone, and
   replayPartial now means "an audit file could not be read", which is the only
   remaining way for the totals to be incomplete.

2. Scrolling to the bottom stopped loading more records. Two independent causes:

   * paintRecords rebuilt the entire table on every 5s poll whenever the row
     count did not exceed the first screen — the "is a paged view live?" test
     compared row counts and matched exactly on the first refresh — wiping loaded
     pages and resetting scroll position.
   * IntersectionObserver only fires on TRANSITIONS. With a short list, or after a
     page whose rows all duplicated the first screen, the sentinel stayed visible
     and never fired again.

   paintRecords now builds once (recsState.built) and later polls PREPEND only
   genuinely new rows; attachRecsObserver adds a scroll-position fallback;
   fillRecordsViewport loads until the list actually overflows; and
   loadMoreRecords chains (bounded) when a page yields no new rows, since the
   first fetch necessarily overlaps the first screen.

TestUIRecordsPagingWiring pins all four mechanisms structurally, since none of
them is reachable from Go. Test names/comments referring to bounded replay are
updated to describe the bounded RING instead, and both READMEs now state that
totals come from the full history while records are paged.
2026-08-30 09:29:42 +08:00

4662 lines
212 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ModelRouter</title>
<link
rel="icon"
type="image/svg+xml"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 34 34'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233f6ef5'/%3E%3Cstop offset='1' stop-color='%236a8ffb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='34' height='34' rx='10' fill='url(%23g)'/%3E%3Ctext x='17' y='24' text-anchor='middle' font-family='Arial,sans-serif' font-size='17' font-weight='700' fill='%23fff'%3EM%3C/text%3E%3C/svg%3E"
/>
<style>
/* =====================================================================
ModelRouter WebUI — sakura × frost × glassmorphism design
===================================================================== */
:root{
--primary:#FF7FAC; --primary-h:#F33B7C; --primary-50:#FFF0F5; --primary-100:#FFE4E9;
--primary-200:#FFCDD9; --primary-300:#FF9EB5; --primary-400:#FF7FAC; --primary-500:#F33B7C;
--secondary:#88C0D0; --secondary-h:#4C8DAE; --secondary-50:#F0F9FC; --secondary-200:#AEE1F2;
--danger:#DB3694; --danger-50:#FEEAF6; --ok:#17a964; --warn:#b7791f; --info:#3f6ef5;
--bg-s1:#eef2ff; --bg-s2:#ffffff; --bg-s3:#ffe9f0;
--card:rgba(255,255,255,.60); --card-solid:#ffffff; --card2:rgba(255,255,255,.42);
--line:rgba(255,127,172,.18); --line-strong:rgba(120,90,150,.22);
--fg:#3b3350; --muted:#7c7a95;
--blob1:rgba(255,127,172,.45); --blob2:rgba(136,192,208,.42); --blob3:rgba(244,114,182,.30);
--sh-sm:0 1px 2px rgba(70,50,110,.06), inset 0 1px 0 rgba(255,255,255,.6);
--sh-md:0 6px 20px rgba(120,90,160,.13);
--sh-lg:0 18px 46px rgba(120,90,160,.22);
--glass:18px;
--ease-spring:cubic-bezier(.34,1.56,.64,1);
}
html[data-accent="sakura"]{
--primary:#FF7FAC; --primary-h:#F33B7C; --primary-50:#FFF0F5; --primary-100:#FFE4E9;
--primary-200:#FFCDD9; --primary-300:#FF9EB5; --primary-400:#FF7FAC; --primary-500:#F33B7C;
--secondary:#88C0D0; --secondary-h:#4C8DAE; --secondary-50:#F0F9FC; --secondary-200:#AEE1F2;
--danger:#DB3694; --danger-50:#FEEAF6;
--blob1:rgba(255,127,172,.45); --blob2:rgba(136,192,208,.42); --blob3:rgba(244,114,182,.30);
--line:rgba(255,127,172,.18); --line-strong:rgba(120,90,150,.22);
}
html[data-accent="ocean"]{
--primary:#00B4D8; --primary-h:#0096C7; --primary-50:#E0F7FA; --primary-100:#B2EBF2;
--primary-200:#80DEEA; --primary-300:#4DD0E1; --primary-400:#26C6DA; --primary-500:#00B4D8;
--secondary:#FF7FAC; --secondary-h:#F33B7C; --secondary-50:#FFF0F5; --secondary-200:#FFCDD9;
--danger:#DB3694; --danger-50:#FEEAF6;
--blob1:rgba(0,180,216,.45); --blob2:rgba(255,127,172,.42); --blob3:rgba(90,200,220,.30);
--line:rgba(0,180,216,.18); --line-strong:rgba(90,200,220,.22);
}
html[data-accent="violet"]{
--primary:#A855F7; --primary-h:#9333EA; --primary-50:#F5F0FF; --primary-100:#EDE0FF;
--primary-200:#D8B4FE; --primary-300:#C084FC; --primary-400:#A855F7; --primary-500:#9333EA;
--secondary:#22D3EE; --secondary-h:#06B6D4; --secondary-50:#F0FDFF; --secondary-200:#A5F3FC;
--danger:#F43F5E; --danger-50:#FEF2F2;
--blob1:rgba(168,85,247,.45); --blob2:rgba(34,211,238,.42); --blob3:rgba(139,92,246,.30);
--line:rgba(168,85,247,.18); --line-strong:rgba(139,92,246,.22);
}
html[data-theme="dark"]{
--primary:#f54180; --primary-h:#f871a0; --primary-50:#310413; --primary-100:#610726;
--primary-200:#920b3a; --primary-300:#c20e4d; --primary-400:#f31260; --primary-500:#f54180;
--secondary:#5E9FBF; --secondary-h:#88C0D0; --secondary-50:#0E2A36; --secondary-200:#2A546A;
--danger:#DB3694; --danger-50:#3d1a24; --ok:#4f7cff; --warn:#e0b25c; --info:#6a8ffb;
--bg-s1:#14121c; --bg-s2:#1a1824; --bg-s3:#221a28;
--card:rgba(34,31,50,.55); --card-solid:#241f38; --card2:rgba(44,39,64,.42);
--line:rgba(255,127,172,.16); --line-strong:rgba(200,180,235,.18);
--fg:#ece6f5; --muted:#a09db8;
--blob1:rgba(255,79,160,.32); --blob2:rgba(100,180,215,.26); --blob3:rgba(219,54,148,.22);
--sh-sm:0 1px 2px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.05);
--sh-md:0 8px 26px rgba(0,0,0,.45);
--sh-lg:0 20px 52px rgba(0,0,0,.55);
}
html[data-theme="dark"][data-accent="ocean"]{
--primary:#00B4D8; --primary-h:#22D3EE; --primary-50:#03202A; --primary-100:#06404D;
--primary-200:#0A6A7A; --primary-300:#0E95AB; --primary-400:#00B4D8; --primary-500:#22D3EE;
--secondary:#FF7FAC; --secondary-h:#F33B7C; --secondary-50:#310413; --secondary-200:#610726;
--danger:#DB3694; --danger-50:#3d1a24;
--blob1:rgba(0,180,216,.32); --blob2:rgba(255,127,172,.26); --blob3:rgba(34,211,238,.22);
--line:rgba(0,180,216,.16); --line-strong:rgba(34,211,238,.18);
}
html[data-theme="dark"][data-accent="violet"]{
--primary:#A855F7; --primary-h:#C084FC; --primary-50:#1A0A2E; --primary-100:#2E104A;
--primary-200:#4C1D70; --primary-300:#7C3AED; --primary-400:#A855F7; --primary-500:#C084FC;
--secondary:#22D3EE; --secondary-h:#06B6D4; --secondary-50:#0A2E2E; --secondary-200:#154A4A;
--danger:#F43F5E; --danger-50:#3D1A1A;
--blob1:rgba(168,85,247,.32); --blob2:rgba(34,211,238,.26); --blob3:rgba(139,92,246,.22);
--line:rgba(168,85,247,.16); --line-strong:rgba(139,92,246,.18);
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);overflow:hidden;
font:14px/1.55 'Quicksand','Nunito','Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'PingFang SC','Microsoft YaHei',sans-serif;
-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility;letter-spacing:.02em;
background:var(--bg-s1)}
a{color:var(--primary-h)}
::selection{background:#ffcdba;color:#fff}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:rgba(255,182,193,.45);border-radius:4px;transition:background .3s}
::-webkit-scrollbar-thumb:hover{background:rgba(255,127,172,.7)}
/* ---- animated gradient-mesh background ---- */
#bgfx{position:fixed;inset:0;z-index:-10;overflow:hidden;
background:linear-gradient(135deg,var(--bg-s1),var(--bg-s2),var(--bg-s3))}
#bgfx .blob{position:absolute;border-radius:50%;filter:blur(64px);will-change:transform;opacity:.6}
#bgfx .b1{top:-10%;left:-8%;width:560px;height:560px;background:var(--blob1);animation:drift1 30s ease-in-out infinite alternate}
#bgfx .b2{top:16%;right:-10%;width:440px;height:440px;background:var(--blob2);animation:drift2 36s ease-in-out infinite alternate}
#bgfx .b3{bottom:-12%;left:24%;width:620px;height:620px;background:var(--blob3);animation:drift3 42s ease-in-out infinite alternate}
@keyframes drift1{from{transform:translate(0,0) scale(1)}to{transform:translate(30px,-20px) scale(1.05)}}
@keyframes drift2{from{transform:translate(0,0) scale(1)}to{transform:translate(-25px,18px) scale(1.03)}}
@keyframes drift3{from{transform:translate(0,0) scale(1)}to{transform:translate(18px,25px) scale(1.04)}}
@media (prefers-reduced-motion: reduce){
#bgfx .blob{animation:none;opacity:.35}
}
/* ---- app shell: sidebar + main ---- */
#app{display:flex;height:100%}
.sidebar{flex:0 0 246px;width:246px;padding:18px 14px;display:flex;flex-direction:column;gap:4px;overflow-y:auto;
background:var(--card);backdrop-filter:blur(calc(var(--glass) * 0.7)) saturate(1.3);-webkit-backdrop-filter:blur(calc(var(--glass) * 0.7)) saturate(1.3);
border-right:1px solid var(--line);box-shadow:var(--sh-md);z-index:30}
@media (prefers-reduced-motion: reduce){
.sidebar{backdrop-filter:none;-webkit-backdrop-filter:none}
}
.sb-brand{display:flex;align-items:center;gap:11px;padding:6px 8px 18px}
.sb-logo{width:40px;height:40px;border-radius:13px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;
color:#fff;font-weight:800;font-size:19px;letter-spacing:.5px;background:linear-gradient(135deg,var(--primary),var(--secondary));
box-shadow:0 6px 18px rgba(255,127,172,.45)}
.sb-brand b{font-size:16.5px;letter-spacing:.2px;display:block;line-height:1.1}
.sb-brand small{display:block;font-size:11px;color:var(--muted);margin-top:3px}
.sb-nav{display:flex;flex-direction:column;gap:3px}
nav button.sb-i,.sb-nav button{display:flex;align-items:center;gap:11px;width:100%;text-align:left;padding:10px 13px;border-radius:13px;cursor:pointer;
color:var(--muted);font-weight:600;font-size:13.5px;background:transparent;border:1px solid transparent;transition:all .18s var(--ease-spring)}
nav button.sb-i svg{width:18px;height:18px;flex:0 0 auto;opacity:.85}
nav button.sb-i:hover{background:var(--card2);color:var(--fg);transform:none;box-shadow:none}
nav button.sb-i.active{background:linear-gradient(120deg,var(--primary-50),var(--secondary-50));color:var(--primary-h);
border-color:var(--line);box-shadow:var(--sh-sm)}
nav button.sb-i.active svg{opacity:1}
.sb-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;padding-top:16px}
.sb-foot button,.sb-foot .hbtn{display:flex;align-items:center;gap:8px;width:100%;justify-content:flex-start;padding:9px 13px;border-radius:99px;border:0;cursor:pointer;
font:inherit;font-weight:600;font-size:12.5px;color:var(--primary-h);background:var(--primary-50);transition:all .2s var(--ease-spring)}
.sb-foot button:hover{filter:brightness(1.04);box-shadow:var(--sh-md);transform:none}
.sb-foot .sb-out{background:var(--danger-50);color:var(--danger)}
.sb-ham{display:none}
.sb-accent{display:flex;flex-wrap:wrap;gap:6px;padding:4px 2px}
.sb-accent button{flex:1 1 calc(50% - 3px);min-width:0;padding:8px 10px;border-radius:10px;border:1px solid var(--line);font-size:12px;font-weight:600;color:var(--muted);background:var(--card2);cursor:pointer}
.sb-accent button.active{background:var(--primary-50);border-color:var(--primary);color:var(--primary-h);box-shadow:var(--sh-sm)}
/* ---- focus visible: no square box ---- */
button:focus-visible,.sb-i:focus-visible,.ctx-itm:focus-visible,.mb:focus-visible,.copy-b:focus-visible,.mb-x:focus-visible,.add-brick:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{a outline:2px solid var(--primary);outline-offset:2px}
button:focus:not(:focus-visible),.sb-i:focus:not(:focus-visible),input:focus:not(:focus-visible),select:focus:not(:focus-visible),textarea:focus:not(:focus-visible){outline:none}
.main{flex:1;min-width:0;display:flex;flex-direction:column;height:100%}
.breadcrumb{display:flex;align-items:center;gap:8px;padding:16px 24px 6px;font-weight:700;font-size:15px}
.breadcrumb .crumb{opacity:.55;transition:opacity .25s}
.breadcrumb .sep{opacity:.3}
.breadcrumb .crumb.cur{opacity:1;color:var(--fg)}
main#main{flex:1;overflow-y:auto;padding:8px 24px 40px}
/* ---- glass card (backdrop-blur + translucent) ---- */
.card{position:relative;background:var(--card);border:1px solid var(--line);border-radius:16px;
padding:20px;margin-bottom:16px;box-shadow:var(--sh-md);
backdrop-filter:blur(calc(var(--glass) * 0.7)) saturate(1.3);-webkit-backdrop-filter:blur(calc(var(--glass) * 0.7)) saturate(1.3);
animation:cardIn .32s var(--ease-spring) both}
@media (prefers-reduced-motion: reduce){
.card{backdrop-filter:none;-webkit-backdrop-filter:none}
}
.card h2{font-size:13.5px;margin:0 0 14px;color:var(--muted);font-weight:700;letter-spacing:.02em;display:flex;align-items:center;gap:10px}
.card h2 .grow{flex:1}
.card h2 .k-ic{width:26px;height:26px;border-radius:9px;display:flex;align-items:center;justify-content:center;font-size:13px;
background:linear-gradient(135deg,var(--primary-50),var(--secondary-50));color:var(--primary-h)}
@keyframes cardIn{from{opacity:0;transform:translateY(10px) scale(.985)}to{opacity:1;transform:none}}
table{width:100%;border-collapse:collapse}
th,td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--line);font-size:13px}
th{color:var(--muted);font-weight:700;font-size:11.5px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap}
tr:last-child td{border-bottom:0}
.tag{display:inline-block;padding:3px 10px;border-radius:99px;font-size:12px;margin:2px 3px 2px 0;font-weight:600}
.tag-green{background:var(--primary-50);color:var(--ok)}
.tag-red{background:var(--danger-50);color:var(--danger)}
.tag-blue{background:var(--secondary-50);color:var(--secondary-h)}
.net-dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:currentColor;margin-right:6px;vertical-align:1px}
button{background:linear-gradient(135deg,var(--primary),var(--danger));color:#fff;border:0;border-radius:11px;padding:8px 16px;cursor:pointer;
font:inherit;font-weight:600;transition:all .18s var(--ease-spring)}
button:hover{background:linear-gradient(135deg,var(--primary-h),var(--danger));transform:translateY(-1px);box-shadow:0 8px 20px rgba(255,127,172,.35)}
button:active{transform:translateY(0)}
button.ghost{background:transparent;border:1px solid var(--line-strong);color:var(--muted)}
button.ghost:hover{border-color:var(--primary);color:var(--primary-h);box-shadow:var(--sh-sm);transform:none;background:transparent}
button.danger{background:transparent;border:1px solid var(--danger);color:var(--danger)}
button.danger:hover{background:var(--danger);color:#fff;box-shadow:0 8px 20px rgba(219,54,148,.3)}
button.small{padding:5px 12px;font-size:12px;border-radius:99px}
input,select,textarea{width:100%;background:var(--card2);border:1px solid var(--line);color:var(--fg);
border-radius:12px;padding:10px 13px;font:inherit;margin-bottom:10px;outline:none;
transition:border .18s,box-shadow .18s,background .18s;backdrop-filter:blur(6px)}
input:focus,select:focus,textarea:focus{border-color:var(--primary);box-shadow:0 0 0 3px rgba(255,127,172,.18);background:var(--card-solid)}
textarea{min-height:200px;resize:vertical;font-family:'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace;font-size:12.5px}
select{cursor:pointer}
label{display:block;font-size:12px;color:var(--muted);margin:12px 0 5px}
.row{display:flex;gap:12px}.row>div{flex:1}
.model-row{display:flex;gap:6px;align-items:center;width:100%}
.model-row .m-id{flex:1;min-width:0;width:0}
.tpl-pick-row{padding:12px 14px;border-radius:13px;cursor:pointer;background:var(--card2);border:1px solid var(--line);margin-bottom:8px;transition:background .15s,border-color .15s}
.tpl-pick-row:hover{background:var(--accent-soft,#1e2a52);border-color:var(--primary)}
.model-row .m-kind{flex:0 0 96px;width:96px}
.model-row .del{flex:0 0 auto;padding:4px 8px}
.muted{color:var(--muted)}
.hidden,.hidden#tab-chat{display:none}
#toast{position:fixed;bottom:24px;right:24px;z-index:100;background:var(--card-solid);border:1px solid var(--line);
border-radius:14px;padding:12px 20px;box-shadow:var(--sh-lg);display:none;animation:fadeup .25s var(--ease-spring);
backdrop-filter:blur(14px);font-weight:600}
@keyframes fadeup{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
pre.configbox{background:var(--card2);border:1px solid var(--line);border-radius:12px;padding:14px;margin:10px 0;
overflow:auto;white-space:pre-wrap;word-break:break-all;font-size:12.5px;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace}
/* ---- dashboard KPI cards ---- */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px;margin-bottom:16px}
.kpi-skeletons{grid-column:1/-1;display:grid;grid-template-columns:repeat(auto-fit,minmax(168px,1fr));gap:14px}
.ksk{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px;height:206px;
display:flex;flex-direction:column;gap:12px;backdrop-filter:blur(var(--glass))}
.ksk i{display:block;height:11px;border-radius:6px;background:linear-gradient(90deg,var(--card2) 25%,var(--line) 37%,var(--card2) 63%);background-size:400% 100%;animation:shimmer 1.2s infinite}
.ksk i:nth-child(1){width:38%;align-self:flex-start}
.ksk i:nth-child(2){width:68%;height:24px}
.ksk i:nth-child(3){width:100%;flex:1 1 auto;margin:6px 0 0}
@keyframes shimmer{0%{background-position:100% 0}100%{background-position:0 0}}
.kpi{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:16px 18px;display:flex;flex-direction:column;
box-shadow:var(--sh-md);backdrop-filter:blur(var(--glass)) saturate(1.6);transition:all .25s var(--ease-spring)}
.kpi:hover{transform:translateY(-2px);box-shadow:var(--sh-lg);border-color:var(--primary-200)}
.kpi.kpi-exit{cursor:pointer;border-color:var(--danger)}
.kpi .k-hdr{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px;flex:0 0 auto}
.kpi .k-lab{font-size:12px;color:var(--muted);font-weight:700;white-space:nowrap;display:flex;align-items:center;gap:6px}
.kpi .k-lab .dot{width:7px;height:7px;border-radius:50%;background:var(--ok);display:inline-block;animation:blink 1.6s infinite}
.kpi .k-val{font-size:27px;font-weight:800;margin-top:4px;font-variant-numeric:tabular-nums;letter-spacing:.3px;flex:0 0 auto}
.kpi .k-sub{font-size:11.5px;color:var(--muted);margin-top:4px;flex:0 0 auto}
.kpi .k-sub.k2{display:flex;flex-direction:column;align-items:flex-start;line-height:1.5}
.kpi .k-chart{position:relative;flex:1 1 auto;min-height:40px;margin-top:8px}
.kpi canvas{position:absolute;inset:0;width:100%;height:100%;display:block}
.dot{width:7px;height:7px;border-radius:50%;background:var(--ok);display:inline-block;animation:blink 1.6s infinite}
.dot.err{background:var(--danger)}
@keyframes blink{50%{opacity:.25}}
.status-legend{display:flex;flex-wrap:wrap;gap:5px 9px;margin-top:6px;justify-content:center;overflow-y:auto;max-height:66px;scrollbar-width:thin}
.status-legend .lg{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--muted);white-space:nowrap}
.status-legend .lg i{width:9px;height:9px;border-radius:3px;flex:0 0 auto}
.status-legend .lg b{color:var(--fg);font-variant-numeric:tabular-nums}
.dash-row{display:flex;gap:14px;margin-bottom:14px}
.dash-row .card{flex:1;min-width:0;display:flex;flex-direction:column;max-height:460px}
.dash-row .card h2{flex:0 0 auto}
.dash-row .card>div{flex:1 1 auto;min-height:0;overflow:auto}
.dash-row .card>div table thead th{position:sticky;top:0;background:var(--card);z-index:1;backdrop-filter:blur(8px)}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td .okc{color:var(--ok);font-weight:700}
td .errc{color:var(--danger);font-weight:700}
.okc{color:var(--ok);font-weight:700}
.errc{color:var(--danger);font-weight:700}
.mini{font-size:11.5px;color:var(--muted)}
.bar{height:5px;border-radius:3px;background:var(--card2);overflow:hidden;min-width:60px}
.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--primary),var(--secondary));border-radius:3px}
.bar.ok i{background:var(--ok)}
.bar.err i{background:var(--danger)}
.recs-scroll{max-height:420px;overflow:auto}
.recs-sentinel{min-height:24px;padding:6px 2px;font-size:12px;color:var(--muted);text-align:center}
.recs-scroll table th{position:sticky;top:0;background:var(--card);z-index:1}
td.t-tag{white-space:nowrap}
.tbl-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch}
.tbl-wrap table{width:max-content;min-width:100%}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.modelchip{cursor:pointer;color:var(--primary-h);text-decoration:none}
.modelchip:hover{text-decoration:underline}
.empty{color:var(--muted);text-align:center;padding:24px 0}
.src-models{display:grid;grid-template-columns:repeat(4,auto);justify-content:start;gap:2px 6px;min-width:0}
.src-models .tag{margin:0}
.filter-line{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.filter-line select{width:auto;margin:0;padding:6px 10px}
/* ---- keys tab ---- */
.key-row{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--card);flex-wrap:wrap}
.key-row .kr-name{font-weight:700;min-width:120px}
.key-row .kr-key{font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;max-width:340px;white-space:nowrap}
.key-row .grow{flex:1}
.kr-key{font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:12px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;max-width:340px;white-space:nowrap}
.tag-role{font-size:11px;padding:3px 10px;border-radius:99px;font-weight:700}
.tag-role.admin{background:linear-gradient(135deg,var(--primary),var(--danger));color:#fff}
.tag-role.user{background:var(--secondary-50);color:var(--secondary-h);border:1px solid var(--line)}
.scope-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:99px;background:linear-gradient(135deg,var(--primary),var(--secondary));color:#fff;font-size:12.5px;font-weight:600;cursor:grab;user-select:none}
.scope-chip[draggable="true"]:active{cursor:grabbing}
.scope-chip .rm{cursor:pointer;opacity:.75;font-size:13px;line-height:1;padding:0 2px}
.scope-chip .rm:hover{opacity:1}
.scope-chip.drag-over{outline:2px dashed #fff;outline-offset:2px}
.scope-unlim{color:var(--primary-h);font-weight:700;font-size:13px}
.keys-create{display:grid;grid-template-columns:1fr 2fr 1fr 2fr;gap:10px;align-items:end}
.keys-create label{font-size:11.5px;color:var(--muted);margin-bottom:4px}
.keys-create input,.keys-create select{margin:0}
@media (max-width:760px){.keys-create{grid-template-columns:1fr}}
/* key canvases — scratch-style bricks */
.key-actions{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.key-canvas{border:1px solid var(--line);border-radius:16px;padding:14px;margin-bottom:14px;background:var(--card);
backdrop-filter:blur(var(--glass));box-shadow:var(--sh-sm)}
.kc-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.kc-blocks{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:12px;background:var(--card2);
border:1px dashed var(--line);border-radius:12px;padding:14px;min-height:64px}
.kc-blocks.ovh{outline:2px dashed var(--primary);outline-offset:2px}
.mb{display:inline-flex;align-items:center;gap:8px;padding:9px 13px;border-radius:12px;
background:linear-gradient(135deg,var(--primary),var(--secondary));color:#fff;font-size:13px;font-weight:700;
cursor:grab;user-select:none;box-shadow:0 3px 12px rgba(255,127,172,.35);transition:box-shadow .2s}
.mb:active{cursor:grabbing}
.mb .mb-ico{font-size:11px;opacity:.85}
.mb .mb-name{font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;white-space:nowrap}
.mb .mb-src{font-style:normal;opacity:.8;font-weight:600;margin-left:4px}
.mb .mb-quota{font-size:11px;font-weight:600;background:rgba(255,255,255,.22);padding:2px 8px;border-radius:99px;white-space:nowrap}
.mb .copy-b{cursor:pointer;opacity:.85;font-size:12px;padding:0 3px}
.mb .copy-b:hover{opacity:1}
.mb .mb-x{cursor:pointer;font-size:15px;line-height:1;padding:4px 5px;margin:0 2px;border-radius:6px;color:rgba(255,255,255,.9);background:rgba(0,0,0,.18);user-select:none}
.mb .mb-x:hover{background:rgba(219,54,148,.85);color:#fff}
.mb-ghost{position:fixed;pointer-events:none;opacity:.9;z-index:60;transform:rotate(2deg)}
.mb-empty{color:var(--muted);font-size:12.5px}
.add-brick{display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:12px;border:1.5px dashed var(--primary);background:transparent;color:var(--primary-h);font-weight:700;font-size:12.5px;cursor:pointer;font-family:inherit}
.add-brick:hover{background:var(--primary-50)}
#ctx-wrap{position:fixed;z-index:70;background:var(--card-solid);border:1px solid var(--line);border-radius:12px;box-shadow:var(--sh-lg);padding:5px;min-width:130px;backdrop-filter:blur(14px)}
.ctx-itm{display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600}
.ctx-itm:hover{background:var(--primary-50)}
.ctx-itm.danger{color:var(--danger)}
.ctx-itm .ic{width:15px;text-align:center}
/* ---- chat page ---- */
.tab-chat{max-width:840px;margin:0 auto;display:flex;flex-direction:column;height:calc(100vh - 190px)}
.chat-wrap{background:var(--card);border:1px solid var(--line);border-radius:18px;box-shadow:var(--sh-lg);
display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;backdrop-filter:blur(var(--glass)) saturate(1.5)}
.chat-log{flex:1;overflow-y:auto;padding:20px 20px 8px;display:flex;flex-direction:column;gap:18px}
.msg{display:flex;gap:10px;max-width:100%}
.msg.usr{flex-direction:row-reverse}
.avatar{width:30px;height:30px;border-radius:10px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;color:#fff;letter-spacing:.3px}
.avatar.u{background:linear-gradient(135deg,var(--primary),var(--danger))}
.avatar.a{background:linear-gradient(135deg,var(--secondary),var(--secondary-h))}
.avatar.d{background:linear-gradient(135deg,#9b8cff,#6a7bff)}
.bubble{max-width:84%;padding:11px 15px;border-radius:16px;position:relative;word-break:break-word;font-size:13.5px;line-height:1.6}
.msg.usr .bubble{background:linear-gradient(135deg,var(--primary),var(--danger));color:#fff;border-top-right-radius:4px;box-shadow:0 6px 18px rgba(255,127,172,.35)}
.msg.ass .bubble{background:var(--card2);border:1px solid var(--line);border-top-left-radius:4px}
.bubble .bmd p{margin:0 0 8px}.bubble .bmd p:last-child{margin:0}
.bubble .bmd h1,.bubble .bmd h2,.bubble .bmd h3,.bubble .bmd h4{margin:9px 0 5px;font-size:1.04em}
.bubble .bmd ul,.bubble .bmd ol{margin:0 0 8px;padding-left:20px}
.bubble .bmd li{margin:2px 0}
.bubble .bmd code{background:var(--card-solid);border:1px solid var(--line);border-radius:5px;padding:1px 5px;font-size:11.5px;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace}
.msg.usr .bubble .bmd code{background:rgba(0,0,0,.22);border-color:transparent}
.bubble .bmd pre{background:var(--card-solid);border:1px solid var(--line);border-radius:10px;padding:10px 12px;overflow:auto;margin:8px 0}
.bubble .bmd pre code{background:transparent;border:0;padding:0}
.bubble .bmd blockquote{margin:8px 0;padding-left:12px;border-left:3px solid var(--line);color:var(--muted)}
.bubble .bmd a{color:var(--primary-h);text-decoration:none}.bubble .bmd a:hover{text-decoration:underline}
.msg .muted{font-size:11.5px;margin-top:4px;opacity:.75}
.think{color:var(--muted);border-left:2px solid var(--primary-200);padding:4px 0 4px 12px;margin-top:8px;font-size:12px;max-height:180px;overflow:auto;white-space:pre-wrap}
.tk-hint{font-size:11px;display:block;margin-bottom:2px;opacity:.85}
.cursor-dot{display:inline-block;width:2px;height:14px;background:var(--fg);margin-left:3px;vertical-align:-2px;animation:blink 1s steps(2) infinite}
.chat-composer{border-top:1px solid var(--line);padding:10px 12px 12px;background:var(--card)}
.chat-tools{display:flex;align-items:center;gap:12px;margin-bottom:8px}
.chat-tools .tl{font-size:12px;color:var(--muted);white-space:nowrap}
.chat-tools select{width:auto;margin:0;padding:4px 10px;font-size:12.5px;border-radius:10px}
.chat-tools select:not([data-plain]){min-width:210px}
.chat-tools .grow{flex:1}
.chk{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--muted);cursor:pointer;white-space:nowrap;user-select:none}
.chk input{width:14px;height:14px;margin:0;accent-color:var(--primary)}
.chat-box{display:flex;gap:9px;align-items:flex-end}
.chat-box textarea{flex:1;margin:0;min-height:42px;max-height:140px;resize:none;padding:10px 13px;border-radius:12px;font:inherit;line-height:1.5}
.chat-box .sendbtn{width:auto;padding:10px 22px;border-radius:99px;font-weight:700;white-space:nowrap;background:linear-gradient(135deg,var(--primary),var(--danger))}
.chat-box .sendbtn:hover{background:linear-gradient(135deg,var(--primary-h),var(--danger))}
.chat-box .sendbtn:disabled{opacity:.5;cursor:default}
.attach-btn{background:transparent;border:1px solid var(--line);color:var(--muted);border-radius:12px;width:42px;height:42px;display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto;transition:all .15s}
.attach-btn:hover{border-color:var(--primary);color:var(--primary)}
.chat-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:6px}
.chat-empty .ring{width:56px;height:56px;margin-bottom:8px;border-radius:50%;background:linear-gradient(135deg,var(--primary-50),var(--secondary-50));border:1px solid var(--line);display:flex;align-items:center;justify-content:center;color:var(--primary-h);font-size:22px;box-shadow:var(--sh-md)}
.chat-empty p{margin:0}
.dropzone{border:2px dashed var(--line);border-radius:14px;padding:26px;text-align:center;color:var(--muted);cursor:pointer;transition:all .2s var(--ease-spring);background:var(--card2)}
.dropzone.dragover,.dropzone:hover{border-color:var(--primary);color:var(--primary);background:var(--primary-50)}
.dropzone b{font-size:14px;display:block;margin-bottom:4px}
#modal-wrap{position:fixed;inset:0;background:rgba(30,20,60,.45);display:none;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50;backdrop-filter:blur(3px)}
#modal-wrap:not(:empty){display:flex}
#modal-wrap .card{width:100%;max-width:480px;animation:cardIn .28s var(--ease-spring) both}
/* ---- scratch-style priority canvas (sakura restyle, same classes) ---- */
.kind-toggle{display:inline-flex;gap:4px;margin-right:8px}
.kind-toggle button{padding:4px 12px;font-size:11.5px}
.sort-canvas{position:relative;min-height:420px;padding:22px 14px 30px;margin:4px 0 10px;background-color:var(--card2);
background-image:linear-gradient(var(--line-strong) 1px,transparent 1px),linear-gradient(90deg,var(--line-strong) 1px,transparent 1px);
background-size:28px 28px;border:1px solid var(--line);border-radius:16px;overflow-x:auto;backdrop-filter:blur(10px)}
.scr-lane{position:relative;display:flex;align-items:stretch;margin:0}
.scr-tier{flex:0 0 auto;width:22px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;padding:4px 2px;margin-right:10px}
.scr-tier span{display:block;width:9px;height:4px;border-radius:2px;background:var(--primary);opacity:.28;transition:opacity .15s}
.scr-lane.scr-top .scr-tier span{opacity:.95}
.scr-row{flex:1 1 auto;display:flex;align-items:center;padding:2px 18px;border-radius:8px;position:relative;transition:background .18s}
.scr-row.hover{background:var(--primary-50)}
.scr-block{position:relative;display:flex;align-items:center;gap:10px;padding:12px 14px 12px 20px;color:#fff;font-weight:700;font-size:13px;user-select:none;margin-right:0;
border-radius:11px 11px 7px 7px;box-shadow:0 6px 0 rgba(0,0,0,.14),inset 0 2px 0 rgba(255,255,255,.30),inset 0 -5px 0 rgba(0,0,0,.08);transition:opacity .15s}
.scr-block::before{content:'';position:absolute;left:-7px;top:50%;transform:translateY(-50%);width:7px;height:30px;border-radius:7px 0 0 7px;background:inherit;z-index:2}
.scr-block::after{content:'';position:absolute;right:-7px;top:50%;transform:translateY(-50%);width:14px;height:30px;background:rgba(0,0,0,.14);border-radius:7px;z-index:1;box-shadow:inset 2px 0 4px rgba(0,0,0,.22)}
.scr-row .scr-block:first-child::before{display:none}
.scr-row .scr-block:last-child{margin-right:0}
.scr-row .scr-block:last-child::after{display:none}
.scr-block.pre-grow-l::before{transform:translateY(-50%) scaleX(0);transform-origin:100% 50%;opacity:0}
.scr-block.grow-l::before{animation:growlL .22s ease-out .24s both}
@keyframes growlL{from{transform:translateY(-50%) scaleX(0);transform-origin:100% 50%;opacity:0}to{transform:translateY(-50%) scaleX(1);opacity:1}}
.scr-block.grow-r::after{animation:growrR .22s ease-out .24s both}
@keyframes growrR{from{transform:translateY(-50%) scaleX(0);transform-origin:0 50%}}
.scr-block.grow-t .scr-knob{animation:growtT .22s ease-out .24s both}
@keyframes growtT{from{transform:scaleY(0);transform-origin:50% 100%}}
.scr-block.grow-b .scr-slot{animation:growbB .22s ease-out .24s both}
@keyframes growbB{from{transform:scaleY(0);transform-origin:50% 0}}
.scr-block .scr-ico{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:800;letter-spacing:.5px;background:rgba(0,0,0,.22)}
.scr-block .scr-name{white-space:nowrap;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:12px;max-width:200px;overflow:hidden;text-overflow:ellipsis;display:flex;flex-direction:column;line-height:1.25}
.scr-block .scr-srcname{font-size:9.5px;opacity:.78;font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis}
.scr-block .scr-tag{flex:0 0 auto;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10.5px;padding:2px 7px;border-radius:9px;background:rgba(0,0,0,.24);color:#ffe9a8;border:1px solid rgba(255,220,130,.35);cursor:pointer}
.scr-block .scr-htag{flex:0 0 auto;display:flex;gap:4px;align-items:center;font-family:'JetBrains Mono',ui-monospace,Consolas,monospace;font-size:10px;font-weight:700;cursor:help}
.scr-htag .ht-cool{padding:2px 6px;border-radius:9px;background:rgba(244,114,182,.32);color:#ffd9e5;border:1px solid rgba(255,127,172,.5)}
.scr-htag .ht-probe{padding:2px 6px;border-radius:9px;background:rgba(251,191,36,.32);color:#fff3d0;border:1px solid rgba(251,191,36,.55)}
.scr-htag .ht-fail{padding:2px 6px;border-radius:9px;background:rgba(255,160,60,.22);color:#ffd9a8;border:1px solid rgba(255,180,90,.42)}
.scr-htag .ht-pref{padding:2px 6px;border-radius:9px;background:rgba(136,192,208,.26);color:#d6f1fa;border:1px solid rgba(136,192,208,.42)}
.scr-block .scr-grip{flex:0 0 auto;display:flex;flex-direction:column;gap:2px;padding:6px 4px;margin-left:2px;border-radius:6px;cursor:grab;background:rgba(255,255,255,.22);box-shadow:inset 0 1px 2px rgba(0,0,0,.2);transition:background .12s;touch-action:none}
.scr-block .scr-grip:hover{background:rgba(255,255,255,.38)}
.scr-block .scr-grip i{width:12px;height:2px;border-radius:1px;background:rgba(255,255,255,.95);display:block}
.scr-block .scr-knob{position:absolute;left:24px;top:-11px;width:30px;height:12px;border-radius:8px 8px 4px 4px;background:inherit;z-index:2}
.scr-block .scr-slot{position:absolute;left:24px;bottom:-9px;width:30px;height:11px;background:rgba(0,0,0,.14);border-radius:4px 4px 12px 12px;z-index:1;box-shadow:inset 0 3px 5px rgba(0,0,0,.18)}
.scr-lane.scr-top .scr-block .scr-knob,.scr-lane.scr-last .scr-block .scr-slot{display:none}
.scr-block.drag-src{opacity:.28}
.scr-block.drag-src .scr-knob,.scr-block.drag-src .scr-slot,.scr-block.drag-src::before,.scr-block.drag-src::after{visibility:hidden}
.scr-ghost{position:fixed;z-index:200;pointer-events:none;transform:rotate(1.5deg) scale(1.05);filter:drop-shadow(0 16px 20px rgba(0,0,0,.4));cursor:grabbing}
.scr-ghost .scr-knob,.scr-ghost .scr-slot,.scr-ghost::before,.scr-ghost::after{display:none}
.scr-gap{height:7px;margin:-2px 0 0;border-radius:8px;border:2px dashed transparent;transition:all .12s;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;letter-spacing:2px}
.scr-gap.hover{border-color:var(--primary);background:var(--primary-50);box-shadow:0 0 0 3px rgba(255,127,172,.14);color:var(--primary)}
.scr-gap.hover::after{content:'';font-size:14px;font-weight:800}
.scr-gap.drophere{border-color:var(--primary);background:var(--primary);color:#fff;height:16px}
.scr-gap.drophere::after{content:'';font-size:16px;font-weight:800}
.scr-blockline{position:fixed;width:3px;border-radius:2px;background:var(--primary);box-shadow:0 0 8px var(--primary);z-index:150;pointer-events:none}
.scr-hint{font-size:12px;color:var(--muted);margin-top:12px;display:flex;gap:16px;flex-wrap:wrap}
.scr-hint span{display:flex;align-items:center;gap:6px}
.scr-hint i{font-style:normal;width:10px;height:10px;border-radius:3px;display:inline-block}
/* ---- attachments in chat (images) + scratch block close btn + misc ---- */
.attbox{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:10px}
.att{position:relative;display:inline-block}
.att img{display:block;height:64px;border-radius:8px;border:1px solid var(--line);max-width:120px;object-fit:cover}
.att button{position:absolute;top:-8px;right:-8px;border-radius:50%}
.kpi.kpi-status{display:flex;flex-direction:column}
.kpi.kpi-status .k-val{font-size:22px}
.kpi .k-chart.k-chart-pie{min-height:70px}
.scr-block .scr-x{position:absolute;top:-6px;right:-6px;cursor:pointer;font-size:13px;line-height:1;padding:3px 5px;border-radius:50%;
color:rgba(255,255,255,.9);background:rgba(0,0,0,.22);z-index:3}
.scr-block .scr-x:hover{background:rgba(219,54,148,.85);color:#fff}
.m-id{margin:0 0 8px}
.m-kind{flex:0 0 auto;margin:0 0 8px}
.donut{position:relative;width:108px;height:108px}
.donut svg{display:block}
.donut .center{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;pointer-events:none}
.donut .center b{font-size:20px;font-weight:800}
.donut .center span{font-size:10px;color:var(--muted)}
/* ---- responsive: collapsible sidebar ---- */
@media (max-width:900px){
.sidebar{position:fixed;left:0;top:0;bottom:0;z-index:50;transform:translateX(-100%);transition:transform .3s var(--ease-spring)}
.sidebar.open{transform:none;box-shadow:var(--sh-lg);z-index:55}
.sidebar{backdrop-filter:none;-webkit-backdrop-filter:none}
.main{width:100%}
.sb-ham{display:flex}
.sb-backdrop{position:fixed;inset:0;background:rgba(20,12,40,.35);z-index:54;backdrop-filter:blur(4px);opacity:0;pointer-events:none;transition:opacity .2s}
.sidebar.open + .sb-backdrop{opacity:1;pointer-events:auto}
main#main{padding:8px 14px 32px}
.breadcrumb{padding:12px 16px 6px}
}
@media (max-width:640px){
.kpi .k-val{font-size:23px}
main#main{padding:6px 10px 24px}
.row{flex-direction:column;gap:0}
.dash-row{flex-direction:column}
.model-row{flex-wrap:wrap}
.model-row input{flex:1 1 120px}
.tab-chat{height:calc(100vh - 170px)}
.bubble{max-width:90%}
#toast{left:12px;right:12px;bottom:12px;text-align:center}
th,td{padding:8px 10px}
}
</style>
</head>
<body>
<div id="bgfx" aria-hidden="true">
<i class="blob b1"></i><i class="blob b2"></i><i class="blob b3"></i>
</div>
<div id="app">
<aside class="sidebar" id="sidebar">
<div class="sb-brand">
<div class="sb-logo">M</div>
<div>
<b>ModelRouter</b><small data-i="tagline">统一 LLM 网关</small>
</div>
</div>
<nav class="sb-nav" id="sb-nav">
<button class="sb-i" data-tab="status">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 3v18h18" />
<path d="m19 9-5 5-4-4-3 3" /></svg
><span data-i="navStatus">状态</span>
</button>
<button class="sb-i" data-tab="chat">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/></svg
><span data-i="navChat">对话</span>
</button>
<button class="sb-i" data-tab="keys">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"
/></svg
><span data-i="navKeys">密钥</span>
</button>
<button class="sb-i" data-tab="sort">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m3 17 2 2 4-4 4 4 8-8" />
<path d="m3 7 2 2 4-4 4 4 8-8" /></svg
><span data-i="navSort">优先级</span>
</button>
<button class="sb-i" data-tab="sources">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 3H3v18h18V3z" />
<path d="M7 8h10M7 12h10M7 16h6" /></svg
><span data-i="navSources"></span>
</button>
<button class="sb-i" data-tab="adapters">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<path d="M14 2v6h6M16 13H8M16 17H8M10 9H8" /></svg
><span data-i="navAdapters">适配器</span>
</button>
</nav>
<div class="sb-foot">
<button id="btn-lang">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="15"
height="15"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/></svg
><span id="lang-lb">EN</span>
</button>
<button id="btn-theme">
<svg
id="theme-ic"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="15"
height="15"
>
<circle cx="12" cy="12" r="5" />
<path
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/></svg
><span data-i="themeBtn">切换主题</span>
</button>
<button class="sb-out hbtn" id="btn-logout">
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" /></svg
><span data-i="logout">退出登录</span>
</button>
<div class="sb-accent" id="accent-picker">
<button data-accent="sakura" title="Sakura (默认)">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="14"
height="14"
>
<path
d="M12 2.5C8.5 2.5 5.7 4.3 4.2 6.8c-.3.5-.2 1.1.3 1.4l3.4 3.4c.3.3.8.3 1.1 0l2.3-2.3c.5-.5 1.3-.5 1.8 0l2.3 2.3c.3.3.8.3 1.1 0l3.4-3.4c.5-.3.6-.9.3-1.4C18.3 4.3 15.5 2.5 12 2.5z"
/>
<path d="M12 2.5v19" /></svg
>Sakura
</button>
<button data-accent="ocean" title="Ocean">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="14"
height="14"
>
<path
d="M12 2.5C8.5 2.5 5.7 4.3 4.2 6.8c-.3.5-.2 1.1.3 1.4l3.4 3.4c.3.3.8.3 1.1 0l2.3-2.3c.5-.5 1.3-.5 1.8 0l2.3 2.3c.3.3.8.3 1.1 0l3.4-3.4c.5-.3.6-.9.3-1.4C18.3 4.3 15.5 2.5 12 2.5z"
/>
<path d="M12 2.5v19" /></svg
>Ocean
</button>
<button data-accent="violet" title="Violet">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="14"
height="14"
>
<path
d="M12 2.5C8.5 2.5 5.7 4.3 4.2 6.8c-.3.5-.2 1.1.3 1.4l3.4 3.4c.3.3.8.3 1.1 0l2.3-2.3c.5-.5 1.3-.5 1.8 0l2.3 2.3c.3.3.8.3 1.1 0l3.4-3.4c.5-.3.6-.9.3-1.4C18.3 4.3 15.5 2.5 12 2.5z"
/>
<path d="M12 2.5v19" /></svg
>Violet
</button>
</div>
</div>
</aside>
<div class="sb-backdrop" id="sb-backdrop"></div>
<div class="main">
<div class="breadcrumb" id="breadcrumb">
<button class="sb-ham ghost small" id="btn-menu">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
width="16"
height="16"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg></button
><span class="crumb cur" data-i="navStatus">状态</span>
</div>
<main id="main">
<div id="tab-status" class="tab-pane"></div>
<div id="tab-chat" class="tab-pane hidden"></div>
<div id="tab-keys" class="tab-pane hidden"></div>
<div id="tab-sort" class="tab-pane hidden"></div>
<div id="tab-sources" class="tab-pane hidden"></div>
<div id="tab-adapters" class="tab-pane hidden"></div>
</main>
</div>
</div>
<div id="toast"></div>
<script>
/* ---------- i18n ---------- */
const STR = {
zh: {
tagline: "统一 LLM 网关",
logout: "退出登录",
navStatus: "状态",
navChat: "对话",
navSources: "源",
navAdapters: "适配器",
navSort: "优先级",
navKeys: "密钥",
keysHint:
"管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。",
kCreate: "新建密钥",
kName: "名称",
kRole: "角色",
kRoleAdmin: "管理员",
kRoleUser: "用户",
kNote: "备注",
kCreateBtn: "创建",
kKey: "密钥",
kCopy: "复制密钥",
kDel: "删除",
kNewKey: "新密钥",
kNewOK: "密钥已创建",
kScopeTitle:
"配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户",
kSaveScope: "保存",
kMeTitle: "我的密钥",
kMeRole: "角色",
kMeModels: "我可用的模型",
kMeHint: "密钥不可在此新建或删除;需要变更请联系管理员。",
kEmpty: "暂无其他密钥",
kDelConfirm: "确定删除密钥 %s 吗?此后该密钥立即失效。",
kAll: "不限",
kBrickH: "点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动",
kCopyB: "复制该模型",
kEditB: "编辑",
kDelB: "删除",
kFormTitle: "模型与 Token 配额",
kModelB: "模型",
kSrcHint: "同一模型多源时请选择具体来源",
kAnySrc: "任意源",
kQuotaB: "Token 配额",
kQuotaHintB: "0 / 留空 = 无限",
kPeriodB: "重置周期",
kPerNothing: "不限",
kPerHour: "每 小时",
kPerWeek: "每 周",
kPerMonth: "每 月",
kPerHours: "每 N 小时",
kPerNHint: "小时数",
kEditQ: "编辑配额",
kClearQ: "清空配额",
kDupB: "复制档位",
kDelB2: "删除",
kDupOK: "已复制该模型",
kMovOK: "已移动",
kSaved: "已保存",
kEmptyB: "暂无模型",
kAddB: "添加模型",
connTitle: "连接配置",
connHint:
"模型名默认 AUTO按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。",
copyCfg: "一键复制配置",
copyEnv: "复制为环境变量",
srcTitle: "源状态",
tName: "名称",
tAdapter: "适配器",
tModels: "模型",
tURL: "地址",
tConn: "连接",
tConc: "并发",
tAvgLat: "首字延迟",
tTokSpd: "Token/s",
online: "在线",
offline: "退避 / 不可用",
adTitle: "已加载适配器",
tVersion: "版本",
adPool: "Lua 状态池",
adPoolHint:
"池按需伸缩:扩容步长由最大并发决定,缩容步长由当前连接数决定(连接为 0 时回收到常驻下限)",
adPoolIdle: "空闲",
adPoolBusy: "占用",
adPoolMax: "上限",
adPoolPeak: "峰值",
adPoolSteps: "步长 (扩/缩)",
cModel: "模型",
cMsg: "Enter 发送Shift+Enter 换行",
send: "发送",
cClear: "清空",
clear: "清空",
cThinking: "推理",
cPick: "选择图片",
cRemoveImg: "移除",
cWelcome: "开始对话——选择模型,输入消息即可测试上游连接",
cStream: "调用 /v1/chat/completions 流式 SSE",
srcEmpty: "还没有配置任何源",
srcAdd: "+ 新增源",
tplManage: "模板管理",
tplFrom: "从模板创建",
tplSave: "存为模板",
tplTitle: "模板管理",
tplEmpty: "还没有模板",
tplNew: "新建模板",
tplNamePrompt: "输入模板名称",
tplConfirmDel: "确认删除模板?",
srcEdit: "编辑",
srcDel: "删除",
adEmpty: "尚未加载适配器",
uploadTitle: "上传 / 拖拽 Lua 适配器",
dropHint: "拖拽 .lua 文件到此处,或点击选择文件",
adName: "名称",
tbLua: "Lua 脚本",
uploadBtn: "上传并加载",
modalNew: "新增源",
modalEdit: "编辑源",
mName: "名称",
mURL: "Base URL",
mKey: "API Key",
mAlias: "适配器",
mAliasAuto: "自动",
mEp: "聊天端点",
mImgEp: "生图端点",
mConc: "并发上限",
mRPM: "RPM 限速 (0=不限)",
mTemp: "温度",
mModels: "模型列表",
mAddModel: "+ 模型",
mMeta: "Meta",
mSave: "保存",
mCancel: "取消",
toastCopied: "已复制",
toastCopyFail: "复制失败,请手动选择复制",
toastSaved: "已保存并热重载",
toastEmpty: "请输入消息",
toastBadJson: "Meta 不是合法 JSON",
toastSaveFail: "保存失败: %s",
toastUploaded: "适配器已加载",
toastUpFail: "上传失败: %s",
toastNeedAll: "需要适配器名称和脚本",
toastDelOk: "已删除",
confirmDelSrc: "确定删除源 %s 吗?",
confirmDelAdp: "确定删除适配器 %s 吗?",
u: "用户",
a: "助手",
at: "助手[思考]",
aEmpty: "(空回复)",
aErr: "请求失败: %s",
cErr: "错误: %s",
chatMeta: "模型=%s · 耗时 %s ms · %d 字符",
connPinned: "# 固定到模型: %s · %s",
connAuto: "# 模型名 AUTO",
seedWarnTitle: "请更换初始管理员密钥",
seedWarnText:
"当前登录的是配置文件中的初始密钥,明文写入 config.yaml、存在泄露风险。请在下方创建新的管理员密钥用新密钥登录后删除此初始密钥。",
seedWarnGo: "去更换密钥",
seedWarnLater: "稍后",
seedWarnDismiss: "本次不再提示",
sortTitle: "拖拽积木配置模型优先级",
sortHint:
"每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。",
sortSave: "保存排序",
sortReset: "重置",
sortAdd: "添加档位",
sortSaved: "排序已保存并热重载",
sortHintSave: "点击保存排序后生效",
sortCooling: "冷却",
sortProbeReady: "待探测",
sortProbing: "探测中",
sortFail: "失败",
sortHealthTip: "冷却 / 失败次数 / 偏好分 实时状态",
sortProbeTip:
"冷却窗口 %s 开始,%s 起放行 1 个探测请求,%s 完全恢复(探测成功则立即恢复)",
sortHealthReset: "链上冷却已复位",
kpiActive: "活跃请求",
kpiReqs: "总请求",
kpiOk: "成功率",
kpiTokens: "Tokens",
kpiLat: "平均延迟",
kpiMaxLat: "最大延迟",
dashModel: "模型用量",
dashSrc: "源用量与延迟",
dashKey: "密钥用量",
dashRecs: "请求记录",
exportCsv: "导出 CSV",
expWeek: "近一周",
expMonth: "近一月",
expYear: "近一年",
expRange: "自定义范围",
expStart: "开始日期",
expEnd: "结束日期",
expDownload: "下载",
expKeysCsv: "导出密钥用量",
exitKeyView: "退出该密钥视图",
dashStatus: "状态码分布",
kOther: "其他",
statusTag:
"状态码分类统计(含 402 欠费 / 400 schema 错误;两者不计入上游退避但单独计数)",
thModel: "模型",
thSrc: "源",
thKey: "密钥",
thReqs: "请求",
thOk: "成功",
thErr: "失败",
thPrompt: "输入 Tokens",
thCompl: "输出 Tokens",
thAvgLat: "平均延迟",
thMaxLat: "最长延迟",
thTime: "时间",
thType: "类型",
thStatus: "状态",
thLatMs: "延迟",
allKeys: "全部密钥",
recFilter: "密钥筛选",
recsLoading: "加载更多…",
recsEnd: "已到最早记录",
recsRotated: "审计日志已轮转,已从最新记录重新加载",
recsNewest: "回到最新",
recsPartial:
"部分审计日志无法读取,统计可能不完整;完整历史请导出 CSV",
thTokens: "Tokens",
noUsage: "暂无用量记录",
},
en: {
tagline: "Unified LLM Gateway",
logout: "Log out",
navStatus: "Status",
navChat: "Chat",
navSources: "Sources",
navAdapters: "Adapters",
navSort: "Priority",
navKeys: "Keys",
keysHint:
"Admin keys can view and manage every key and configure each user key\u0027s allowed models; user keys only see themselves.",
kCreate: "Create key",
kName: "Name",
kRole: "Role",
kRoleAdmin: "Admin",
kRoleUser: "User",
kNote: "Note",
kCreateBtn: "Create",
kKey: "Key",
kCopy: "Copy key",
kDel: "Delete",
kNewKey: "New key — copy & save it now",
kNewOK: "Key created",
kScopeTitle:
"Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users",
kSaveScope: "Save",
kMeTitle: "My key",
kMeRole: "Role",
kMeModels: "Models I can use",
kMeHint:
"Keys cannot be created or deleted here; contact an admin to change.",
kEmpty: "No other keys",
kDelConfirm: 'Delete key "%s"? It will stop working immediately.',
kAll: "All",
kBrickH:
"Click to edit model & quota · right-click for more · drag to move to another key",
kCopyB: "Copy",
kEditB: "Edit",
kDelB: "Delete",
kFormTitle: "Model & token quota",
kModelB: "Model",
kSrcHint:
"pick a source when the same model exists on several sources",
kAnySrc: "any source",
kQuotaB: "Token quota",
kQuotaHintB: "0 / empty = unlimited",
kPeriodB: "Reset period",
kPerNothing: "Never",
kPerHour: "Every hour",
kPerWeek: "Every week",
kPerMonth: "Every month",
kPerHours: "Every N hours",
kPerNHint: "hours",
kEditQ: "Edit quota",
kClearQ: "Clear quota",
kDupB: "Duplicate slot",
kDelB2: "Delete",
kDupOK: "Copied",
kMovOK: "Moved",
kSaved: "Saved",
kEmptyB: "no models yet — click + to add",
kAddB: "Add model",
connTitle: "Connection config",
connHint:
"Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.",
copyCfg: "Copy config",
copyEnv: "Copy as env vars",
srcTitle: "Sources",
tName: "Name",
tAdapter: "Adapter",
tModels: "Models",
tURL: "URL",
tConn: "Status",
tConc: "Concurrency",
tAvgLat: "TTFB",
tTokSpd: "Tokens/s",
online: "online",
offline: "backoff / down",
adTitle: "Loaded adapters",
tVersion: "Version",
adPool: "Lua state pool",
adPoolHint:
"Pools scale on demand: the grow step follows max concurrency, the shrink step follows the live connection count (idle pools collapse to the resident floor)",
adPoolIdle: "Idle",
adPoolBusy: "In use",
adPoolMax: "Max",
adPoolPeak: "Peak",
adPoolSteps: "Steps (grow/shrink)",
cModel: "Model",
cMsg: "Enter to send, Shift+Enter for newline",
send: "Send",
cClear: "Clear",
clear: "Clear",
cThinking: "Thinking",
cPick: "Pick image",
cRemoveImg: "Remove",
cWelcome:
"Start a conversation — pick a model to test the upstream connection",
cStream: "calls /v1/chat/completions streaming SSE",
srcEmpty: "No sources configured yet",
srcAdd: "+ Add source",
tplManage: "Templates",
tplFrom: "From template",
tplSave: "As template",
tplTitle: "Template Manager",
tplEmpty: "No templates yet",
tplNew: "New template",
tplNamePrompt: "Template name",
tplConfirmDel: "Delete this template?",
srcEdit: "Edit",
srcDel: "Delete",
adEmpty: "No adapters loaded",
uploadTitle: "Upload / drag a Lua adapter",
dropHint: "Drag a .lua file here, or click to choose",
adName: "Name",
tbLua: "Lua script",
uploadBtn: "Upload & load",
modalNew: "Add source",
modalEdit: "Edit source",
mName: "Name",
mURL: "Base URL",
mKey: "API Key",
mAlias: "Adapter",
mAliasAuto: "Auto",
mEp: "Chat endpoint",
mImgEp: "Image endpoint",
mConc: "Max concurrency",
mRPM: "RPM limit (0 = unlimited)",
mTemp: "Temperature",
mModels: "Models",
mAddModel: "+ model",
mMeta: "Meta",
mSave: "Save",
mCancel: "Cancel",
toastCopied: "Copied",
toastCopyFail: "Copy failed",
toastSaved: "Saved & hot-reloaded",
toastEmpty: "Enter a message",
toastBadJson: "Meta is not valid JSON",
toastSaveFail: "Save failed: %s",
toastUploaded: "Adapter loaded",
toastUpFail: "Upload failed: %s",
toastNeedAll: "Adapter name and code required",
toastDelOk: "Deleted",
confirmDelSrc: "Delete source %s?",
confirmDelAdp: "Delete adapter %s?",
u: "You:",
a: "Assistant:",
at: "Assistant [thinking]",
aEmpty: "empty",
aErr: "Request failed: %s",
cErr: "Error: %s",
chatMeta: "Model=%s · %s ms · %d chars",
connPinned: "# Pinned to model: %s · %s",
connAuto: '# Model "AUTO" follows the Priority page AUTO chain',
seedWarnTitle: "Replace the initial admin key",
seedWarnText:
"You are logged in with the seed key from config.yaml. It is plaintext in the config file and a security risk. Create a new admin key below, log in with it, then delete this seed key.",
seedWarnGo: "Change my key",
seedWarnLater: "Later",
seedWarnDismiss: "Don't ask again",
sortTitle: "Drag blocks to set model priority",
sortHint:
"Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier or reorder within it, drop into the gap between rows = move up/down a tier. Image models stay out.",
sortSave: "Save order",
sortReset: "Reset",
sortAdd: "Add slot",
sortSaved: "Order saved & hot-reloaded",
sortHintSave: "Click Save for it to take effect",
sortCooling: "cooling",
sortProbeReady: "probe ready",
sortProbing: "probing",
sortFail: "fail",
sortHealthTip: "live cooldown / failures / preference score",
sortProbeTip:
"cooldown opened %s, one probe request is allowed from %s, fully clear at %s (a successful probe restores it immediately)",
sortHealthReset: "chain cooldowns reset",
kpiActive: "Active requests",
kpiReqs: "Requests",
kpiOk: "Success rate",
kpiTokens: "Tokens",
kpiLat: "Avg latency",
kpiMaxLat: "Max latency",
dashModel: "Model usage",
dashSrc: "Source usage & latency",
dashKey: "Key usage",
dashRecs: "Request records",
exportCsv: "Export CSV",
expWeek: "Last week",
expMonth: "Last month",
expYear: "Last year",
expRange: "Custom range",
expStart: "Start date",
expEnd: "End date",
expDownload: "Download",
expKeysCsv: "Export key usage",
exitKeyView: "Exit this key view",
dashStatus: "Status codes",
kOther: "other",
statusTag:
"Per-status aggregates — 402 quota / 400 schema errors are counted here but never back off the provider",
thModel: "Model",
thSrc: "Source",
thKey: "Key",
thReqs: "Requests",
thOk: "OK",
thErr: "Err",
thPrompt: "Prompt Tokens",
thCompl: "Completion Tokens",
thAvgLat: "Avg latency",
thMaxLat: "Max latency",
thTime: "Time",
thType: "Type",
thStatus: "Status",
thLatMs: "Latency",
allKeys: "All keys",
recFilter: "Key filter",
recsLoading: "Loading more…",
recsEnd: "Reached the oldest record",
recsRotated: "The audit log rotated; reloaded from the newest record",
recsNewest: "Back to newest",
recsPartial:
"Some audit files could not be read, so these totals may be incomplete; export CSV for the full history",
thTokens: "Tokens",
noUsage: "No usage data yet",
},
};
let LANG =
localStorage.getItem("llms-proxy.lang") ||
((navigator.language || "").toLowerCase().startsWith("zh")
? "zh"
: "en");
function t(key) {
return (STR[LANG] || STR.zh)[key] !== undefined
? (STR[LANG] || STR.zh)[key]
: key;
}
function tFmt(key) {
const s = String(t(key));
const args = Array.prototype.slice.call(arguments, 1);
return s.replace(/%s|%d/g, (m) => {
const v = args.shift();
return v === undefined ? (m === "%d" ? "0" : "") : v;
});
}
function applyI18n() {
document.querySelectorAll("[data-i]").forEach((el) => {
const s = t(el.dataset.i);
el.textContent = s;
});
const ll = document.getElementById("lang-lb");
if (ll) ll.textContent = LANG === "zh" ? "EN" : "中";
document.documentElement.lang = LANG;
}
let lastTab = "status";
/* ---------- theme & header ---------- */
/* ---------- chrome: theme / lang / sidebar / breadcrumb ---------- */
const THEME = localStorage.getItem("llms-proxy.theme") || "light";
document.documentElement.dataset.theme = THEME;
const ic = document.getElementById("theme-ic");
if (ic)
ic.innerHTML =
THEME === "dark"
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
document.getElementById("btn-theme").onclick = () => {
const nx =
document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = nx;
localStorage.setItem("llms-proxy.theme", nx);
const ic2 = document.getElementById("theme-ic");
if (ic2)
ic2.innerHTML =
nx === "dark"
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
};
document.getElementById("btn-lang").onclick = () => {
LANG = LANG === "zh" ? "en" : "zh";
localStorage.setItem("llms-proxy.lang", LANG);
applyI18n();
updateBreadcrumb(lastTab);
refresh(lastTab);
};
document.getElementById("btn-logout").onclick = () => {
location.href = "/api/logout";
};
/* ---------- helpers (moved up for early use) ---------- */
const $ = (s) => document.querySelector(s);
const NAV_NAME = {
status: "navStatus",
chat: "navChat",
keys: "navKeys",
sort: "navSort",
sources: "navSources",
adapters: "navAdapters",
};
function updateBreadcrumb(name) {
const bc = $("#breadcrumb");
bc.innerHTML = `<button class="sb-ham ghost small" id="btn-menu" title="menu"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button><span class="crumb cur">${esc(t(NAV_NAME[name] || "navStatus"))}</span>`;
const m = $("#btn-menu");
if (m) m.onclick = () => $("#sidebar").classList.toggle("open");
}
document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab);
});
updateBreadcrumb(lastTab);
function goTab(name) {
// leaving the dashboard releases the record buffer, its observer and any
// in-flight page fetch straight away
if (lastTab === "status" && name !== "status") releaseRecords(true);
document
.querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name));
["status", "chat", "keys", "sort", "sources", "adapters"].forEach(
(tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name),
);
updateBreadcrumb(name);
const pane = $("#tab-" + name);
if (pane) {
pane.style.animation = "none";
void pane.offsetHeight;
pane.style.animation = "";
}
lastTab = name;
refresh(lastTab);
}
document.addEventListener("click", (e) => {
const sb = $("#sidebar");
if (
sb &&
sb.classList.contains("open") &&
!sb.contains(e.target) &&
!(e.target.closest && e.target.closest("#btn-menu"))
)
sb.classList.remove("open");
if (e.target.id === "sb-backdrop") sb.classList.remove("open");
});
document
.getElementById("sb-backdrop")
?.addEventListener("click", () =>
$("#sidebar").classList.remove("open"),
);
applyI18n();
/* ---- accent picker ---- */
const ACCENT = localStorage.getItem("llms-proxy.accent") || "sakura";
document.documentElement.dataset.accent = ACCENT;
document.querySelectorAll("#accent-picker button").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.accent === ACCENT);
btn.onclick = () => {
const a = btn.dataset.accent;
document.documentElement.dataset.accent = a;
localStorage.setItem("llms-proxy.accent", a);
document
.querySelectorAll("#accent-picker button")
.forEach((b) =>
b.classList.toggle("active", b.dataset.accent === a),
);
};
});
/* ---------- helpers ---------- */
const api = (p, o) =>
fetch(p, o).then(async (r) => {
if (r.status === 401) {
location.href =
"/login?continue=" + encodeURIComponent(location.pathname);
throw new Error("unauthorized");
}
const j = await r.json().catch(() => ({}));
if (!r.ok)
throw new Error((j.error && j.error.message) || r.statusText);
return j;
});
let toastTimer;
function toast(m) {
const el = $("#toast");
el.textContent = m;
el.style.display = "block";
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (el.style.display = "none"), 2600);
}
function esc(s) {
return String(s ?? "").replace(
/[&<>"']/g,
(c) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[c],
);
}
function escAttr(s) {
return esc(s).replace(/"/g, "&quot;");
}
function copyText(txt, okMsg) {
const done = () => toast(okMsg || t("toastCopied"));
const fail = () => toast(t("toastCopyFail"));
if (
navigator.clipboard &&
navigator.clipboard.writeText &&
window.isSecureContext
) {
navigator.clipboard
.writeText(txt)
.then(done, () => (legacyCopy(txt) ? done() : fail()));
return;
}
legacyCopy(txt) ? done() : fail();
}
function legacyCopy(txt) {
try {
const ta = document.createElement("textarea");
ta.value = txt;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
ta.style.pointerEvents = "none";
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand("copy");
document.body.removeChild(ta);
return ok;
} catch (e) {
return false;
}
}
/* ---------- status tab ---------- */
let statsTimerId = null;
const fmtN = (n) => (n ?? 0).toLocaleString();
const fmtTok = (n) => {
const v = +n || 0;
if (v >= 1e9) return (v / 1e9).toFixed(2).replace(/\.?0+$/, "") + "B";
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
return String(v);
};
const fmtMs = (ms) =>
ms == null || ms < 0
? "—"
: ms >= 1000
? (ms / 1000).toFixed(1) + "s"
: ms + "ms";
const fmtLat = (sum, n) => (n > 0 ? Math.round(sum / n) : -1);
const fmtTime = (ts) => {
const d = new Date(ts);
const p = (x) => String(x).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
let statsKeyF = ""; // active key filter for records ('' = all)
/* ---------- records: on-demand paging ----------
* The records table holds only what is on screen. The first screen comes
* from /api/stats (its records field), and scrolling to the bottom pulls
* the next page from /api/stats/records?before=<cursor>. Nothing is cached
* across page views: leaving the dashboard (tab switch or unload) drops the
* buffer, disconnects the observer and aborts any in-flight fetch, so both
* browser and server go back to holding nothing.
*/
const RECS_PAGE = 100; // rows fetched per scroll page
const RECS_MAX_DOM = 1000; // hard cap on rendered rows (sliding window)
const recsState = {
rows: [], // newest-first, mirrors the DOM
cursor: "", // next-page cursor
hasMore: false,
loading: false,
built: false, // table DOM exists (polls prepend instead of rebuilding)
observer: null, // IntersectionObserver on the bottom sentinel
onScroll: null, // scroll fallback (observers only fire on transitions)
abort: null, // AbortController for the in-flight page fetch
keyNames: {},
};
// releaseRecords drops every record-view resource. Called on tab switch,
// on key-filter change and on unload: "release when the user leaves".
function releaseRecords(clearDom) {
if (recsState.observer) {
recsState.observer.disconnect();
recsState.observer = null;
}
const root = $("#tb-recs");
if (root && recsState.onScroll)
root.removeEventListener("scroll", recsState.onScroll);
recsState.onScroll = null;
if (recsState.abort) {
recsState.abort.abort();
recsState.abort = null;
}
recsState.rows = [];
recsState.cursor = "";
recsState.hasMore = false;
recsState.loading = false;
recsState.built = false;
recsState.keyNames = {};
if (clearDom && root) root.innerHTML = "";
}
// ---- Source status column (refreshable) ----
// The column is driven by a combination of signals so a source that is
// actually serving traffic can never be shown as "down/backoff" just
// because /models probing got rate-limited by the upstream (common with
// free pools). Priority: real recent traffic > live probe > scheduling
// cooldown.
function srcStatusCell(x) {
const ok = x.recent_ok || 0;
const err = x.recent_err || 0;
const cooled = x.backoff_until && x.backoff_until * 1000 > Date.now();
const probeOK = !!x.live_available;
const lastErr = x.last_error ? esc(x.last_error) : "";
let cell;
if (ok + err > 0) {
// real traffic: show a success rate, probe only annotates
const rate = Math.round((ok * 100) / (ok + err));
const cls =
rate >= 95 ? "tag-green" : rate >= 50 ? "tag-amber" : "tag-red";
const tip = `最近流量 ${ok} 成功 / ${err} 失败 (探测: ${probeOK ? "可达" : "探测失败"} ${cooled ? "· 冷却中" : ""})`;
cell = `<span class="tag ${cls}" title="${escAttr(tip)}"><i class="net-dot"></i>${rate}%${ok}/${ok + err}${cooled ? "·冷却" : ""}</span>`;
} else if (probeOK) {
// no recent traffic but probe alive
const tip = `探测正常 ${cooled ? "· 调度冷却中(可自动恢复)" : ""}`;
cell = `<span class="tag tag-green" title="${escAttr(tip)}"><i class="net-dot"></i>${t("online")}${cooled ? "·冷却" : ""}</span>`;
} else if (cooled) {
cell = `<span class="tag tag-amber" title="${escAttr("调度冷却中(不影响探测)" + (lastErr ? " " + lastErr : ""))}"><i class="net-dot"></i>冷却中</span>`;
} else {
cell = `<span class="tag tag-red" title="${escAttr(lastErr || "探测失败")}"><i class="net-dot"></i>${t("offline")}</span>`;
}
return cell;
}
// Re-fetch /api/status and repaint only the source status column. Called
// from the 5s stats loop and the manual refresh button so the column is
// never frozen at the first render.
async function paintSourceStatus() {
const tb = $("#src-body");
if (!tb) return;
try {
const s = await api("/api/status");
const srcs = s.sources || [];
tb.innerHTML = srcs
.map(
(x) =>
`<tr><td>${srcStatusCell(x)}</td>
<td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
<td><div class="src-models">${(x.models || []).map((m) => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join("")}</div></td>
<td><span class="muted">${esc(x.base_url || "")}</span></td>
<td>${x.max_concurrent}</td>
<td>${x.avg_first_byte_ms ? (x.avg_first_byte_ms / 1000).toFixed(1) + "s" : "—"}</td>
<td>${x.avg_tok_per_s ? x.avg_tok_per_s + " tok/s" : "—"}</td></tr>`,
)
.join("");
} catch (e) {
// status fetch failing (e.g. session) is surfaced by other loops
}
}
async function renderStatus() {
const s = await api("/api/status");
const base = (window._base = s.base_url || location.origin + "/v1");
const key = (window._key = (s.gateway_keys && s.gateway_keys[0]) || "");
// If the status page is already built, don't rebuild the whole DOM (tab
// switches would stutter); just refresh the live data via paintStats.
const pane = $("#tab-status");
if (pane && pane.dataset.built) {
if (statsTimerId) clearInterval(statsTimerId);
await paintSourceStatus();
await paintStats();
statsTimerId = setInterval(() => {
if (!document.hidden) {
paintStats();
paintSourceStatus();
}
}, 5000);
return;
}
pane.innerHTML = `
<div class="kpis" id="kpi-row"><div class="kpi-skeletons" aria-hidden="true">${Array(
5,
)
.fill("")
.map(
() => `
<div class="ksk"><i></i><i></i><i></i></div>`,
)
.join("")}</div></div>
<div class="card"><h2>${t("connTitle")}</h2>
<div class="muted">${t("connHint")}</div>
<pre class="configbox" id="conncfg"></pre>
<p><button onclick="copyCfg()">${t("copyCfg")}</button>
<button class="ghost" onclick="copyEnv()">${t("copyEnv")}</button></p>
<label>${t("tModels")}</label>
<div class="chips" id="model-chips"></div>
</div>
${
s.sources
? `<div class="card"><h2>${t("srcTitle")} (${s.sources.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t("tConn")}</th><th>${t("tName")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th>${t("tURL")}</th><th>${t("tConc")}</th><th>${t("tAvgLat")}</th><th>${t("tTokSpd")}</th></tr><tbody id="src-body"></tbody></table></div>
<p class="muted" style="padding:6px 2px 0"><button class="ghost small" onclick="paintSourceStatus()">${t("refreshSrc") || "刷新源状态"}</button> 状态列每 5 秒自动刷新</p>
</div>`
: ""
}
<div class="dash-row">
<div class="card"><h2>${t("dashModel")}</h2><div id="tb-model"></div></div>
${s.sources ? `<div class="card"><h2>${t("dashSrc")}</h2><div id="tb-src"></div></div>` : ""}
</div>
<div class="card"><h2>${t("dashKey")}<span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t("exportCsv")}</button></h2><div id="tb-key"></div></div>
<div class="card"><h2><span>${t("dashRecs")}</span><span class="grow"></span><span id="rec-exit"></span><button class="ghost small" onclick="resetRecords()">${t("recsNewest")}</button><button class="ghost small" onclick="openExportModal()">${t("exportCsv")}</button></h2>
<div class="filter-line">
<span class="muted">${t("recFilter")}</span>
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
<span class="grow"></span>
<span id="rec-partial"></span>
</div>
<div class="recs-scroll" id="tb-recs"></div>
</div>
${
s.adapters
? `<div class="card"><h2>${t("adTitle")} (${s.adapters.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tVersion")}</th></tr>
${s.adapters.map((a) => `<tr><td>${esc(a.name)}</td><td>${esc(a.version || "")}</td></tr>`).join("")}</table></div>
</div>`
: ""
}`;
pane.dataset.built = "1";
$("#conncfg").textContent = "";
showModelConfig(null, "");
$("#model-chips").innerHTML = s.models
.map(
(m) =>
`<span class="tag tag-blue modelchip" onclick="showModelConfig(null,'${escAttr(m)}')">${esc(m)}</span>`,
)
.join("");
if (statsTimerId) clearInterval(statsTimerId);
await paintSourceStatus();
await paintStats();
statsTimerId = setInterval(() => {
if (!document.hidden) {
paintStats();
paintSourceStatus();
}
}, 5000);
}
function renderKeySelect(keys, keyNames) {
const sel = $("#rec-key");
if (!sel) return;
const cur = sel.value;
const nm = (k) =>
keyNames && keyNames[k] ? keyNames[k] + " · " + k : k;
sel.innerHTML =
`<option value="">${esc(t("allKeys"))}</option>` +
keys
.map((k) => `<option value="${escAttr(k)}">${esc(nm(k))}</option>`)
.join("");
if (cur) sel.value = cur;
}
function renderKeyF(v) {
statsKeyF = statsKeyF === v ? "" : v;
// the record set changes entirely: drop the paged buffer and start over
releaseRecords(true);
paintStats();
}
function openExportModal() {
const now = new Date();
const ago = (d) => {
const x = new Date(now);
x.setDate(x.getDate() - d);
return x.toISOString().slice(0, 10);
};
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:420px;max-width:100%"><h2>${t("exportCsv")}</h2>
<p><button onclick="downloadStatsCsv(${Date.now() - 7 * 864e5}, ${Date.now()})">${t("expWeek")}</button>
<button onclick="downloadStatsCsv(${Date.now() - 30 * 864e5}, ${Date.now()})">${t("expMonth")}</button>
<button onclick="downloadStatsCsv(${Date.now() - 365 * 864e5}, ${Date.now()})">${t("expYear")}</button></p>
<label>${t("expRange")}</label>
<div class="row"><div><label>${t("expStart")}</label><input id="exp-from" type="date" value="${ago(7)}"></div>
<div><label>${t("expEnd")}</label><input id="exp-to" type="date" value="${now.toISOString().slice(0, 10)}"></div></div>
<p><button onclick="downloadStatsCsvFromForm()">${t("expDownload")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
<hr style="margin:12px 0">
<p><button onclick="downloadKeysCsv()">${t("expKeysCsv")}</button></p>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
}
function downloadStatsCsv(from, to) {
const q = new URLSearchParams({
export: "csv",
from: String(Math.floor(from)),
to: String(Math.floor(to)),
});
if (statsKeyF) q.set("key", statsKeyF);
location.href = "/api/stats?" + q.toString();
const w = $("#modal-wrap");
if (w) w.remove();
}
// downloadStatsCsvFromForm reads the custom date range from the export
// modal's date inputs and forwards to downloadStatsCsv. (Previously this
// was referenced by the modal button but never defined — clicking
// "download" did nothing.)
function downloadStatsCsvFromForm() {
const f = $("#exp-from");
const t = $("#exp-to");
if (!f || !t) return;
// date inputs give YYYY-MM-DD; interpret as local days, end exclusive
const from = new Date(f.value + "T00:00:00").getTime();
const to = t.value
? new Date(t.value + "T00:00:00").getTime() + 864e5
: Date.now();
if (!isFinite(from) || from <= 0) return toast(t("expRange"));
downloadStatsCsv(from, to);
}
function downloadKeysCsv() {
location.href =
"/api/stats?export=keys-csv" +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
const w = $("#modal-wrap");
if (w) w.remove();
}
async function paintStats() {
try {
// limit=0 -> the server ships one screen of records; the rest is paged
// on demand from /api/stats/records as the user scrolls.
const q =
"/api/stats?limit=" +
RECS_PAGE +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
const st = await api(q);
const tot = st.total || {};
const okr = tot.reqs ? Math.round((tot.ok * 100) / tot.reqs) : 0;
const avg = fmtLat(tot.latency_sum_ms, tot.reqs);
const kpi = $("#kpi-row");
if (kpi && !kpi.dataset.built) {
kpi.dataset.built = "1";
kpi.innerHTML = `
<div class="kpi${statsKeyF ? " kpi-exit" : ""}" title="${statsKeyF ? t("exitKeyView") : ""}" onclick="${statsKeyF ? `renderKeyF('${escAttr(statsKeyF)}')` : ""}">
<div class="k-hdr"><span class="k-lab">${t("kpiActive")} <span class="dot"></span></span></div>
<div class="k-val" data-kpi="active"></div>
<div class="k-sub">reqs / 3s</div>
<div class="k-chart"><canvas id="cv-active"></canvas></div>
</div>
<div class="kpi">
<div class="k-hdr"><span class="k-lab">${t("kpiReqs")}</span></div>
<div class="k-val" data-kpi="reqs"></div>
<div class="k-sub" data-kpi="reqs-sub"></div>
<div class="k-chart"><canvas id="cv-reqs"></canvas></div>
</div>
<div class="kpi">
<div class="k-hdr"><span class="k-lab">${t("kpiTokens")}</span></div>
<div class="k-val" data-kpi="tokens"></div>
<div class="k-sub k2" data-kpi="tokens-sub"></div>
<div class="k-chart"><canvas id="cv-tokens"></canvas></div>
<div class="status-legend" id="tb-tokens-legend"></div>
</div>
<div class="kpi">
<div class="k-hdr"><span class="k-lab">${t("kpiLat")}</span></div>
<div class="k-val" data-kpi="lat"></div>
<div class="k-sub" data-kpi="lat-sub"></div>
<div class="k-chart"><canvas id="cv-lat"></canvas></div>
</div>
<div class="kpi kpi-status">
<div class="k-hdr"><span class="k-lab">${t("dashStatus")}</span><span class="k-sub" id="tb-status-total"></span></div>
<div class="k-chart k-chart-pie"><canvas id="cv-status"></canvas></div>
<div class="status-legend" id="tb-status"></div>
</div>`;
}
// update values without rebuilding the card DOM (prevents the 3s flicker)
const kset = (sel, txt) => {
const e = $(sel);
if (e) e.textContent = txt;
};
kset('[data-kpi="active"]', st.active || 0);
kset('[data-kpi="reqs"]', fmtN(tot.reqs));
const rsub = $('[data-kpi="reqs-sub"]');
if (rsub)
rsub.innerHTML = `${t("kpiOk")} <b class="${okr >= 90 ? "okc" : "errc"}">${okr}%</b>`;
kset('[data-kpi="tokens"]', fmtTok(tot.tokens));
const tsub = $('[data-kpi="tokens-sub"]');
if (tsub)
tsub.innerHTML = `<span>${t("thPrompt")} ${fmtTok(tot.prompt_tokens)}</span><span>${t("thCompl")} ${fmtTok(tot.completion_tokens)}</span>`;
kset('[data-kpi="lat"]', fmtMs(avg));
kset(
'[data-kpi="lat-sub"]',
`${t("kpiMaxLat")} ${fmtMs(tot.latency_max_ms)}`,
);
if (kpi) kpi.classList.toggle("kpi-exit", !!statsKeyF);
paintModelTable(st.by_model || []);
paintSrcTable(st.by_source || []);
paintStatusTable(st.by_status || []);
paintKeyTable(st.by_key || [], st.key_names || {});
paintRecords(st.records || [], st.key_names || {});
// aggregates built from a bounded audit tail: say so instead of
// implying the numbers are all-time totals
const rpart = $("#rec-partial");
if (rpart)
rpart.innerHTML = st.replay_partial
? `<span class="muted" title="${escAttr(t("recsPartial"))}">ⓘ ${esc(t("recsPartial"))}</span>`
: "";
const rex = $("#rec-exit");
if (rex)
rex.innerHTML = statsKeyF
? `<button class="ghost small" onclick="renderKeyF('')">${t("exitKeyView")} ${esc(st.key_names && st.key_names[statsKeyF] ? st.key_names[statsKeyF] : statsKeyF)}</button>`
: "";
renderKeySelect(
(st.by_key || []).map((k) => k.name),
st.key_names || {},
);
// draw charts after DOM ready
drawActiveChart(st);
drawReqsChart(st);
drawTokensChart(st.by_model || []);
drawLatencyChart(st);
drawStatusChart(st.by_status || []);
} catch (e) {
console.error("[stats]", e);
}
}
function paintModelTable(rows) {
const el = $("#tb-model");
if (!el) return;
if (!rows.length) {
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
const totalReq = rows.reduce((m, x) => m + x.reqs, 0);
const maxReq = rows.reduce((m, x) => Math.max(m, x.reqs), 0);
el.innerHTML =
`<div class="tbl-wrap"><table><tr><th>${t("thModel")}</th><th class="num">${t("thReqs")}</th><th class="num">${t("thOk")}</th><th class="num">${t("thErr")}</th>
<th class="num">${t("thPrompt")}</th><th class="num">${t("thCompl")}</th><th class="num">${t("thAvgLat")}</th><th class="num">${t("thMaxLat")}</th></tr>` +
rows
.map((r) => {
const sharePct = totalReq
? ((r.reqs * 100) / totalReq).toFixed(1)
: "0.0";
const w = maxReq ? Math.max(6, (r.reqs * 100) / maxReq) : 0;
return `<tr><td>${esc(r.name)}</td>
<td class="num"><span class="mini" title="${r.reqs} of ${totalReq} requests">${sharePct}%</span><div class="bar"><i style="width:${w}%"></i></div>${fmtN(r.reqs)}</td>
<td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtTok(r.prompt_tokens)}</td><td class="num">${fmtTok(r.completion_tokens)}</td>
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`;
})
.join("") +
"</table></div>";
}
function paintSrcTable(rows) {
const el = $("#tb-src");
if (!el) return;
if (!rows.length) {
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
el.innerHTML =
`<div class="tbl-wrap"><table><tr><th>${t("thSrc")}</th><th class="num">${t("thReqs")}</th><th class="num">${t("thOk")}</th><th class="num">${t("thErr")}</th>
<th class="num">${t("thTokens")}</th><th class="num">${t("thAvgLat")}</th><th class="num">${t("thMaxLat")}</th></tr>` +
rows
.map(
(r) => `<tr><td><b>${esc(r.name)}</b></td>
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtTok(r.tokens)}</td>
<td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td><td class="num">${fmtMs(r.latency_max_ms)}</td></tr>`,
)
.join("") +
"</table></div>";
}
function paintStatusTable(rows) {
const total = rows.reduce((m, x) => m + x.reqs, 0) || 1;
const totalEl = $("#tb-status-total");
if (totalEl) totalEl.textContent = fmtN(total) + " " + t("thReqs");
const el = $("#tb-status");
if (!el) return;
if (!rows.length) {
el.innerHTML = "";
return;
}
const palette = [
"#3fb27f",
"#4fa3d8",
"#f0a45c",
"#e06c6c",
"#b06ce0",
"#8a9bb5",
"#6fbfa0",
"#d0a45c",
];
el.innerHTML = rows
.map(
(
r,
i,
) => `<span class="lg" title="${esc(r.name)}: ${fmtN(r.reqs)} (${((r.reqs / total) * 100).toFixed(1)}%) · ${t("thOk")} ${fmtN(r.ok)} · ${t("thErr")} ${fmtN(r.err)}">
<i style="background:${palette[i % palette.length]}"></i>${esc(r.name)}<b>${fmtN(r.reqs)}</b></span>`,
)
.join("");
}
// sparkline: turn a value array into a compact polyline (area fill under accent)
function sparkline(ctx, vals, w, h, color) {
if (!vals.length) {
ctx.strokeStyle = color;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
ctx.setLineDash([]);
return;
}
const max = Math.max.apply(null, vals),
min = Math.min.apply(null, vals);
const rng = max - min || 1;
const pad = 2;
const px = (i) => (i / (vals.length - 1)) * w;
const py = (v) => pad + (1 - (v - min) / rng) * (h - pad * 2);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.beginPath();
vals.forEach((v, i) =>
i ? ctx.lineTo(px(i), py(v)) : ctx.moveTo(px(i), py(v)),
);
ctx.stroke();
}
// thin vertical bars for daily request counts
function dailyBars(ctx, counts, w, h, color) {
if (!counts.length) {
ctx.strokeStyle = color;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
ctx.setLineDash([]);
return;
}
const max = Math.max.apply(null, counts) || 1;
const n = counts.length,
bw = w / n,
gap = Math.max(1, bw * 0.3);
ctx.fillStyle = color;
counts.forEach((c, i) => {
const bh = Math.max(2, (c / max) * h);
ctx.fillRect(i * bw + gap / 2, h - bh, bw - gap, bh);
});
}
// rolling real-time request-rate buffer (QPS-style sparkline)
window.qpsSeries = window.qpsSeries || { last: Date.now(), data: [] };
function drawActiveChart(st) {
const cv = document.getElementById("cv-active");
if (!cv) return;
const ctx = cv.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement,
rect = parent.getBoundingClientRect();
const H = rect.height || 86;
cv.width = rect.width * dpr;
cv.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
// sample: requests started in the last 3s (approx QPS). Records carry unix-ms time.
const qs = window.qpsSeries;
const now = Date.now();
const recs = st.records || [];
const rate = recs.filter((r) => r.time >= now - 3000).length;
if (now - qs.last >= 2000) {
qs.last = now;
qs.data.push(Math.max(rate, 0));
}
if (qs.data.length > 40) qs.data.shift();
// grid baseline
ctx.strokeStyle = "var(--line)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, H - 1);
ctx.lineTo(rect.width, H - 1);
ctx.stroke();
// area fill under the line
if (qs.data.length > 1) {
const max = Math.max.apply(null, qs.data) || 1;
const px = (i) => (i / (qs.data.length - 1)) * rect.width;
const py = (v) => 2 + (1 - v / max) * (H - 4);
ctx.beginPath();
qs.data.forEach((v, i) =>
i ? ctx.lineTo(px(i), py(v)) : ctx.moveTo(px(i), py(v)),
);
ctx.lineTo(rect.width, H);
ctx.lineTo(px(0), H);
ctx.closePath();
ctx.fillStyle = "rgba(79,163,216,.18)";
ctx.fill();
sparkline(ctx, qs.data, rect.width, H, "#4fa3d8");
}
}
function drawReqsChart(st) {
const cv = document.getElementById("cv-reqs");
if (!cv) return;
const ctx = cv.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement,
rect = parent.getBoundingClientRect();
const H = rect.height || 86;
cv.width = rect.width * dpr;
cv.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const recs = st.records || [];
// bucket by UTC day -> request counts (last 7 non-empty days)
const byDay = new Map();
recs.forEach((r) => {
const d = new Date(r.time).toISOString().slice(5, 10);
byDay.set(d, (byDay.get(d) || 0) + 1);
});
const counts = Array.from(byDay.values()).slice(-7);
dailyBars(ctx, counts, rect.width, H, "#3fb27f");
}
const tokPalette = ["#3fb27f", "#4fa3d8", "#f0a45c", "#b06ce0"];
function drawTokensChart(models) {
const cv = document.getElementById("cv-tokens");
if (!cv) return;
const ctx = cv.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement,
rect = parent.getBoundingClientRect();
const H = rect.height || 86;
cv.width = rect.width * dpr;
cv.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
// all non-zero models sorted desc; total uses the full set so shares are true
const all = (models || [])
.filter((m) => (m.tokens || 0) > 0)
.sort((a, b) => b.tokens - a.tokens);
if (!all.length) return;
const grand = all.reduce((a, x) => a + x.tokens, 0);
// keep the top 4 as visible slices; lump the rest into "other"
const top = all.slice(0, 4);
const rest = all.slice(4).reduce((a, x) => a + x.tokens, 0);
const slices =
rest > 0
? top.concat([
{ name: "… " + t("kOther"), tokens: rest, _other: true },
])
: top;
const tot = grand;
let x = 0;
slices.forEach((m, i) => {
const w = Math.max(2, (m.tokens / tot) * rect.width);
ctx.fillStyle = tokPalette[i % tokPalette.length];
ctx.fillRect(x, 0, w, H);
x += w;
});
// legend: list each individual model (not aggregated) that has tokens
const lg = document.getElementById("tb-tokens-legend");
if (lg)
lg.innerHTML = all
.map(
(m, i) =>
`<span class="lg" title="${esc(m.name)}: ${fmtTok(m.tokens)} (${((m.tokens / tot) * 100).toFixed(1)}%)">
<i style="background:${tokPalette[i % tokPalette.length]}"></i>${esc(m.name)}<b>${fmtTok(m.tokens)}</b></span>`,
)
.join("");
}
function drawLatencyChart(st) {
const cv = document.getElementById("cv-lat");
if (!cv) return;
const ctx = cv.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement,
rect = parent.getBoundingClientRect();
const H = rect.height || 86;
cv.width = rect.width * dpr;
cv.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const recs = st.records || [];
// per-record latency sparkline (last 40)
const lat = recs.slice(-40).map((r) => r.latency_ms || 0);
sparkline(ctx, lat, rect.width, H, "#e06c6c");
}
function drawStatusChart(rows) {
const cv = document.getElementById("cv-status");
if (!cv) return;
const ctx = cv.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const parent = cv.parentElement,
rect = parent.getBoundingClientRect();
const H = rect.height || 110;
cv.width = rect.width * dpr;
cv.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
const total = (rows || []).reduce((a, x) => a + x.reqs, 0);
if (!total) return;
const palette = [
"#3fb27f",
"#4fa3d8",
"#f0a45c",
"#e06c6c",
"#b06ce0",
"#8a9bb5",
"#6fbfa0",
"#d0a45c",
];
const rOut = Math.min(rect.width, H) / 2 - 2;
const rIn = Math.max(rOut * 0.45, 1);
const cx = rect.width / 2,
cy = H / 2;
let a0 = -Math.PI / 2;
rows.forEach((row, i) => {
const sweep = (row.reqs / total) * Math.PI * 2;
const a1 = a0 + sweep;
ctx.beginPath();
// outer arc, then inner arc reversed -> true annulus slice (hollow center)
ctx.arc(cx, cy, rOut, a0, a1);
ctx.arc(cx, cy, rIn, a1, a0, true);
ctx.closePath();
ctx.fillStyle = palette[i % palette.length];
ctx.fill();
a0 = a1;
});
}
function paintKeyTable(rows, keyNames) {
const el = $("#tb-key");
if (!el) return;
if (!rows.length) {
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
el.innerHTML =
`<div class="tbl-wrap"><table><tr><th>${t("thKey")}</th><th class="num">${t("thReqs")}</th><th class="num">${t("thOk")}</th><th class="num">${t("thErr")}</th>
<th class="num">${t("thTokens")}</th><th class="num">${t("thAvgLat")}</th></tr>` +
rows
.map(
(
r,
) => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(keyNames[r.name] ? keyNames[r.name] + " · " + r.name : r.name)}</button></td>
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
<td class="num">${fmtTok(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`,
)
.join("") +
"</table></div>";
}
// resetRecords drops the paged history and returns to the newest screen,
// which is also how the user gets out of a long scroll cheaply.
function resetRecords() {
releaseRecords(true);
paintStats();
}
// recRowHtml renders one record row.
function recRowHtml(r, keyNames) {
return `<tr>
<td class="t-tag">${fmtTime(r.time)}</td>
<td class="num">${r.ok ? `<span class="tag tag-green">${r.status || 200}</span>` : `<span class="tag tag-red" title="${esc(r.error || "")}">${r.status || 500}</span>`}</td>
<td>${esc(keyNames[r.key] ? keyNames[r.key] + " · " + r.key : r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || "")}</td>
<td class="num">${fmtTok(r.prompt_tokens)}</td><td class="num">${fmtTok(r.completion_tokens)}</td>
<td class="num">${cacheCell(r)}</td>
<td class="num">${fmtMs(r.latency_ms)}</td></tr>`;
}
function recHeadHtml() {
return `<tr><th>${t("thTime")}</th><th class="num">${t("thStatus")}</th><th>${t("thKey")}</th><th>${t("thType")}</th><th>${t("thModel")}</th><th>${t("thSrc")}</th>
<th class="num">${t("thPrompt")}</th><th class="num">${t("thCompl")}</th><th class="num">${t("thCache") || "缓存"}</th><th class="num">${t("thLatMs")}</th></tr>`;
}
// paintRecords renders the record table from the dashboard poll.
//
// It builds the table ONCE. Later polls only PREPEND records that arrived
// since the last paint: rebuilding on every 5s refresh used to wipe the
// pages the user had scrolled in and reset the scroll position (which made
// paging look broken), because the "is the paged view live?" check compared
// row counts and matched exactly on the first refresh.
function paintRecords(records, keyNames) {
const el = $("#tb-recs");
if (!el) return;
recsState.keyNames = keyNames || {};
const incoming = (records || []).slice().reverse(); // API sends oldest-first
if (!recsState.built) {
if (!incoming.length) {
recsState.rows = [];
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
return;
}
recsState.rows = incoming;
recsState.cursor = "";
recsState.hasMore = true; // probed on the first page fetch
el.innerHTML =
`<table id="recs-tbl">${recHeadHtml()}<tbody id="recs-body">` +
incoming.map((r) => recRowHtml(r, recsState.keyNames)).join("") +
`</tbody></table><div id="recs-sentinel" class="recs-sentinel"></div>`;
recsState.built = true;
attachRecsObserver();
fillRecordsViewport();
return;
}
// Already built: splice in anything newer than our newest row.
const body = $("#recs-body");
if (!body || !incoming.length) return;
const seen = new Set(recsState.rows.map(recId));
const fresh = incoming.filter((r) => !seen.has(recId(r)));
if (!fresh.length) return;
body.insertAdjacentHTML(
"afterbegin",
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
);
recsState.rows = fresh.concat(recsState.rows);
trimRecordsDom(body);
}
// recId identifies a record for de-duplication across the snapshot and the
// paged endpoint (both can return the same row).
function recId(r) {
return (
r.time + "|" + r.key + "|" + r.model + "|" + r.type + "|" + r.status
);
}
// trimRecordsDom enforces the sliding window: drop the oldest rendered rows
// (bottom of the table) so a long scroll cannot grow the DOM without bound.
function trimRecordsDom(body) {
const over = recsState.rows.length - RECS_MAX_DOM;
if (over <= 0) return;
for (let i = 0; i < over && body.lastElementChild; i++)
body.removeChild(body.lastElementChild);
recsState.rows = recsState.rows.slice(0, RECS_MAX_DOM);
}
// fillRecordsViewport loads pages until the list actually overflows its
// container. Without this, a short table leaves the sentinel permanently
// visible: IntersectionObserver only fires on TRANSITIONS, so it would
// never fire again and scrolling could never trigger a load.
async function fillRecordsViewport() {
const el = $("#tb-recs");
if (!el) return;
let guard = 0;
while (
recsState.built &&
recsState.hasMore !== false &&
el.scrollHeight <= el.clientHeight + 8 &&
guard++ < 5
) {
const before = recsState.rows.length;
await loadMoreRecords();
if (recsState.rows.length === before) break; // no progress: stop
}
}
// attachRecsObserver watches a sentinel below the last row; entering the
// viewport loads the next page. A scroll listener backs the observer up:
// IntersectionObserver only fires on transitions, so a sentinel that is
// already visible (short list, or a page that added no new rows) would
// otherwise never trigger another load. Both are torn down by
// releaseRecords when the view goes away.
function attachRecsObserver() {
if (recsState.observer) {
recsState.observer.disconnect();
recsState.observer = null;
}
const root = $("#tb-recs");
const sentinel = $("#recs-sentinel");
if (!root || !sentinel) return;
if (typeof IntersectionObserver !== "undefined") {
recsState.observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) loadMoreRecords();
},
{ root, rootMargin: "120px" },
);
recsState.observer.observe(sentinel);
}
if (recsState.onScroll) root.removeEventListener("scroll", recsState.onScroll);
recsState.onScroll = () => {
if (root.scrollTop + root.clientHeight >= root.scrollHeight - 120)
loadMoreRecords();
};
root.addEventListener("scroll", recsState.onScroll, { passive: true });
}
// loadMoreRecords fetches the next page and appends it. Concurrent calls
// are ignored, and a stale response (view released meanwhile) is dropped.
//
// A page that yields no NEW rows (the first fetch overlaps the dashboard's
// first screen) chains straight into the following page, bounded by
// maxChain, so the user never has to scroll twice for one page of data.
async function loadMoreRecords(depth) {
const maxChain = 3;
if (recsState.loading || recsState.hasMore === false) return;
recsState.loading = true;
const sentinel = $("#recs-sentinel");
if (sentinel) sentinel.textContent = t("recsLoading");
const ctrl = new AbortController();
recsState.abort = ctrl;
let added = 0;
try {
let q =
"/api/stats/records?limit=" +
RECS_PAGE +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
if (recsState.cursor)
q += "&before=" + encodeURIComponent(recsState.cursor);
const page = await api(q, { signal: ctrl.signal });
if (recsState.abort !== ctrl) return; // released while in flight
added = appendRecords(page);
} catch (e) {
if (e && e.name === "AbortError") return;
console.error("[records]", e);
recsState.hasMore = false;
} finally {
if (recsState.abort === ctrl) recsState.abort = null;
recsState.loading = false;
const s = $("#recs-sentinel");
if (s)
s.textContent = recsState.hasMore === false ? t("recsEnd") : "";
}
if (added === 0 && recsState.hasMore && (depth || 0) < maxChain)
await loadMoreRecords((depth || 0) + 1);
}
// appendRecords merges one page into the table, skipping records already on
// screen and trimming the oldest DOM rows past RECS_MAX_DOM.
//
// The first page fetch necessarily overlaps the dashboard's first screen, so
// it can legitimately yield ZERO new rows. When that happens the cursor has
// still advanced, so we immediately pull the next page instead of waiting
// for another scroll event that may never come.
function appendRecords(page) {
const body = $("#recs-body");
if (!body) return 0;
if (page.key_names) recsState.keyNames = page.key_names;
const seen = new Set(recsState.rows.map(recId));
const fresh = (page.records || []).filter((r) => !seen.has(recId(r)));
if (fresh.length) {
body.insertAdjacentHTML(
"beforeend",
fresh.map((r) => recRowHtml(r, recsState.keyNames)).join(""),
);
recsState.rows = recsState.rows.concat(fresh);
trimRecordsDom(body);
}
recsState.cursor = page.next_cursor || "";
recsState.hasMore = !!page.has_more && !!page.next_cursor;
if (page.rotated) toast(t("recsRotated"));
return fresh.length;
}
// cacheCell renders the per-request cache-hit column: a percentage when
// the upstream reported cache accounting, otherwise an em dash.
function cacheCell(r) {
const hit = r.cache_hit_tokens || 0;
if (!hit && !(r.cache_miss_tokens > 0) && !r.cache_reported)
return '<span class="muted" title="上游未报告缓存数据">—</span>';
const prompt = r.prompt_tokens || 0;
const pct = prompt > 0 ? Math.round((hit * 100) / prompt) : 0;
return `<span class="tag ${pct >= 50 ? "tag-green" : "tag-amber"}" title="命中 ${hit} / 未命中 ${r.cache_miss_tokens || 0}">${pct}%</span>`;
}
function showModelConfig(srcName, model) {
const el = $("#conncfg");
if (!el) return;
const base = window._base,
key = window._key;
const m = model || "AUTO";
const json = JSON.stringify(
{ base_url: base, api_key: key || "YOUR_GATEWAY_KEY", model: m },
null,
2,
);
let head;
if (model) head = tFmt("connPinned", model, srcName || "");
else head = tFmt("connAuto");
el.textContent = head + "\n" + json;
window._lastCfg = {
json,
env:
"OPENAI_BASE_URL=" +
base +
"\nOPENAI_API_KEY=" +
(key || "YOUR_GATEWAY_KEY") +
"\nOPENAI_MODEL=" +
m,
};
}
function copyCfg() {
copyText(window._lastCfg ? window._lastCfg.json : "");
}
function copyEnv() {
copyText(window._lastCfg ? window._lastCfg.env : "");
}
/* ---------- chat tab ---------- */
let chatHistory = [];
let chatImages = [];
async function renderChat() {
const s = await api("/api/status");
// Prefer per-source options ("source:model" routing) when the caller sees
// sources (admin); otherwise fall back to the flat model list (user scope).
let optList;
if (Array.isArray(s.sources) && s.sources.length) {
optList = ["AUTO"];
s.sources.forEach((src) =>
(src.models || []).forEach((m) => {
optList.push(
`<option value="${escAttr(`${src.name}:${m}`)}">${esc(`${src.name} · ${m}`)}</option>`,
);
}),
);
} else {
optList = ["AUTO"].concat(
(s.models || []).map(
(m) => `<option value="${escAttr(m)}">${esc(m)}</option>`,
),
);
}
const finalOpts = optList.join("");
$("#tab-chat").innerHTML = `
<div class="tab-chat">
<div class="chat-wrap">
<div class="chat-log" id="ct-log">
<div class="chat-empty" id="ct-empty">
<div class="ring">M</div>
<p>${esc(t("cWelcome"))}</p>
<p class="muted" style="font-size:12px">${esc(t("cStream"))}</p>
</div>
</div>
<div class="chat-composer">
<div class="chat-tools">
<span class="tl">${t("cModel")}</span>
<select id="ct-model">${finalOpts}</select>
<span class="grow"></span>
<label class="chk"><input id="ct-thinking" type="checkbox" checked>${t("cThinking")}</label>
</div>
<div class="attbox" id="ct-imgs"></div>
<div class="chat-box">
<button class="attach-btn" id="ct-pin" title="${escAttr(t("cPick"))}" onclick="$('#ct-file').click()">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="3"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</button>
<input id="ct-file" type="file" accept="image/*" multiple hidden onchange="addChatImgs(this.files)">
<textarea id="ct-msg" rows="1" placeholder="${escAttr(t("cMsg"))}"></textarea>
<button class="sendbtn ghost" id="ct-clear" title="${escAttr(t("cClear"))}" onclick="clearChat()">${esc(t("cClear"))}</button>
<button class="sendbtn" id="ct-send" onclick="sendChat()">${t("send")}</button>
</div>
</div>
</div>
</div>`;
chatHistory = [];
chatImages = [];
updateGrow();
$("#ct-msg").addEventListener("input", updateGrow);
$("#ct-msg").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendChat();
}
});
$("#ct-imgs").style.display = "none";
$("#ct-msg").focus();
}
function updateGrow() {
const ta = $("#ct-msg");
if (!ta) return;
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, 150) + "px";
}
function addMsg(role, text, reason) {
const empty = $("#ct-empty");
if (empty) empty.remove();
const log = $("#ct-log");
const row = document.createElement("div");
row.className = "msg " + (role === "user" ? "usr" : "ass");
const av = role === "user" ? "u" : "a";
const label = role === "user" ? "你" : "AI";
let inner = `<div class="avatar ${av}">${label}</div>`;
if (role === "user") {
inner += `<div class="bubble"><div class="bmd">&nbsp;</div></div>`;
} else {
inner +=
`<div class="bubble"><div class="bmd"></div>` +
(reason
? `<div class="think"><div class="tk-hint"></div></div>`
: ``) +
`</div>`;
}
row.innerHTML = inner;
log.appendChild(row);
scrollChat();
return row;
}
function scrollChat() {
const log = $("#ct-log");
log.scrollTop = log.scrollHeight;
}
function renderMD(el, text) {
// minimal markdown: code blocks, inline code, bold, italic, links, lists, headings, paragraphs
const blocks = text.split(/\n{2,}/);
const out = [];
let inCode = false;
let codeBuf = [];
for (const b of blocks) {
const cm = b.match(/^```([\w-]*)\r?\n([\s\S]*?)(?:```|$)/);
if (cm) {
out.push(
`<pre><code>${esc(cm[2].replace(/\n$/, ""))}</code></pre>`,
);
continue;
}
if (b.trim().startsWith("```")) {
inCode = !inCode;
codeBuf.push(b);
continue;
}
if (inCode) {
codeBuf.push(b);
continue;
}
if (codeBuf.length) {
out.push(
`<pre><code>${esc(
codeBuf
.join("\n")
.replace(/^```.*\r?\n?/, "")
.replace(/\n?```$/, ""),
)}</code></pre>`,
);
inCode = false;
codeBuf = [];
}
let line = b.trim();
if (!line) continue;
let html = esc(line);
// headings
html = html.replace(
/^(#{1,4})\s+(.+)$/gm,
(_, h, t) => `<h${h.length}>${t}</h${h.length}>`,
);
// bullets / lists
html = html.replace(
/^(\s*)[-*]\s+(.+)$/gm,
(_, ind, t) => `${ind}<li>${t}</li>`,
);
html = html.replace(/(?:^|\n)<li>/g, "<li>");
out.push(`<p>${linksAndBold(html)}</p>`);
}
if (codeBuf.length) {
out.push(
`<pre><code>${esc(
codeBuf
.join("\n")
.replace(/^```.*\n?/, "")
.replace(/\n?```$/, ""),
)}</code></pre>`,
);
}
el.innerHTML = out.join("");
}
function linksAndBold(s) {
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
s = s.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
s = s.replace(
/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener">$1</a>',
);
s = s.replace(
/https?:\/\/[^\s<)]+/g,
'<a href="$&" target="_blank" rel="noopener">$&</a>',
);
return s;
}
async function sendChat() {
const model = $("#ct-model").value || "AUTO";
const msgTa = $("#ct-msg");
const msg = msgTa.value.trim();
if (!msg && chatImages.length === 0) return toast(t("toastEmpty"));
let content = msg;
if (chatImages.length > 0) {
content = [{ type: "text", text: msg }];
chatImages.forEach((img) =>
content.push({ type: "image_url", image_url: { url: img } }),
);
}
chatHistory.push({ role: "user", content });
msgTa.value = "";
msgTa.style.height = "auto";
clearChatImg();
const urow = addMsg("user", msg);
const uimgBox = urow.querySelector(".bmd");
uimgBox.innerHTML = "";
if (Array.isArray(content)) {
const tx = content
.filter((p) => p.type === "text")
.map((p) => p.text)
.join(" ");
uimgBox.innerHTML = esc(tx);
content
.filter((p) => p.type === "image_url")
.forEach((p) => {
const img = document.createElement("img");
img.src = p.image_url.url;
img.style.maxWidth = "120px";
img.style.borderRadius = "8px";
img.style.display = "block";
img.style.marginTop = "6px";
uimgBox.appendChild(img);
});
} else {
uimgBox.textContent = msg;
}
const arow = addMsg("assistant", "", true);
const abuf = arow.querySelector(".bmd");
const thinkEl = arow.querySelector(".think");
const hintEl = arow.querySelector(".tk-hint");
thinkEl.style.display = "none";
const cursor = document.createElement("span");
cursor.className = "cursor-dot";
abuf.appendChild(cursor);
const sendBtn = $("#ct-send");
sendBtn.disabled = true;
const started = Date.now();
let text = "",
thought = "",
firstChunk = Date.now();
try {
const resp = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
stream: true,
disable_thinking: !$("#ct-thinking").checked,
messages: chatHistory,
}),
});
if (resp.status === 401) {
location.href = "/login";
return;
}
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
cursor.remove();
abuf.innerHTML = esc(
tFmt("cErr", j.error.message || resp.statusText || ""),
);
return;
}
const reader = resp.body.getReader();
const dec = new TextDecoder();
let buf = "";
let firstDelta = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const ev = buf.slice(0, i);
buf = buf.slice(i + 2);
const line = ev.split("\n").find((l) => l.startsWith("data:"));
if (!line) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
const ck = JSON.parse(data);
const delta =
(ck.choices && ck.choices[0] && ck.choices[0].delta) || {};
if (firstDelta) {
firstDelta = false;
firstChunk = Date.now();
}
if (delta.reasoning_content) {
thought += delta.reasoning_content;
hintEl.textContent =
t("at") + " · " + Math.round(thought.length / 3) + "…";
thinkEl.style.display = "block";
thinkEl.textContent = thought.trim();
scrollChat();
}
if (delta.content) {
text += delta.content;
cursor.remove();
renderMD(abuf, text);
scrollChat();
}
}
}
cursor.remove();
const ms = Date.now() - started;
if (text) {
renderMD(abuf, text);
chatHistory.push({ role: "assistant", content: text });
} else {
abuf.textContent = (thought ? "(仅思考) " : "") + t("aEmpty");
}
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = tFmt("chatMeta", model, ms, text.length);
abuf.appendChild(meta);
} catch (e) {
cursor.remove();
abuf.textContent = esc(tFmt("aErr", e.message));
} finally {
sendBtn.disabled = false;
}
}
function clearChat() {
chatHistory = [];
chatImages = [];
const log = $("#ct-log");
if (log) log.innerHTML = "";
clearChatImg();
}
function clearChatImg() {
chatImages = [];
const box = $("#ct-imgs");
if (box) {
box.innerHTML = "";
box.style.display = "none";
}
}
function renderChatImgs() {
const box = $("#ct-imgs");
if (!box) return;
box.style.display = chatImages.length ? "flex" : "none";
box.innerHTML = chatImages
.map(
(src, i) =>
`<span class="att"><img src="${escAttr(src)}"><button class="ghost small" title="${escAttr(t("cRemoveImg"))}" onclick="removeChatImg(${i})">&times;</button></span>`,
)
.join("");
}
function addChatImgs(files) {
for (const f of files) {
const rd = new FileReader();
rd.onload = () => {
chatImages.push(rd.result);
renderChatImgs();
};
rd.readAsDataURL(f);
}
const inp = $("#ct-file");
if (inp) inp.value = "";
}
function removeChatImg(i) {
chatImages.splice(i, 1);
renderChatImgs();
}
/* ---------- sources tab ---------- */
async function renderSources() {
const j = await api("/api/sources");
const rows = j.sources
.map(
(
s,
) => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td><div class="src-models">${s.models.map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g, "&quot;")})">${t("srcEdit")}</button>
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t("srcDel")}</button></td></tr>`,
)
.join("");
$("#tab-sources").innerHTML = `
<div class="card"><h2><span>${t("srcTitle")}</span><span class="grow"></span>
<button class="ghost small" onclick="openTemplateModal()">${t("tplManage")}</button>
<button class="small" onclick="editSource('')">${t("srcAdd")}</button></h2>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>
${rows || `<tr><td colspan="5" class="empty">${t("srcEmpty")}</td></tr>`}</table></div>
</div>`;
}
function editSource(name) {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
Promise.all([api("/api/sources"), api("/api/status")])
.then(([src, st]) => {
const s = src.sources.find((x) => x.name === name) || {
name: name,
models: [{ id: "", priority: 0, kind: "chat" }],
};
const cur = s.adapter || "openai";
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
if (cur && !apps.includes(cur)) apps.push(cur);
const adSel =
`<select id="s-adapter"><option value="" ${!cur ? "selected" : ""}>${esc(t("mAliasAuto"))}</option>` +
apps
.filter((n) => n)
.map(
(n) =>
`<option value="${escAttr(n)}" ${n === cur ? "selected" : ""}>${esc(n)}</option>`,
)
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2><span>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</span><span class="grow"></span>
${!name ? `<button class="ghost small" onclick="srcFromTemplate()">${t("tplFrom")}</button>` : ""}
<button class="ghost small" onclick="srcSaveAsTemplate()">${t("tplSave")}</button></h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<label>${t("mKey")}</label><input id="s-key" type="password" value="${escAttr(s.api_key || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
</div>
<div class="row">
<div><label>${t("mConc")}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>${t("mTemp")}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
</div>
<div class="row">
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
<div></div>
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveSource(this)">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
document.body.appendChild(wrap);
const box = $("#s-models");
(s.models && s.models.length
? s.models
: [{ id: "", priority: 0, kind: "chat" }]
).forEach((m, i) =>
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
);
})
.catch(() => {});
}
function modelRow(m, i) {
return `<div class="model-row" data-priority="${m.priority || 0}">
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
<select data-mi="${i}" class="m-kind"><option ${m.kind === "image" ? "" : "selected"} value="chat">chat</option><option ${m.kind === "image" ? "selected" : ""} value="image">image</option></select>
<button class="ghost del small" onclick="this.closest('.model-row').remove()">&times;</button>
</div>`;
}
function addModelRow() {
const div = $("#s-models");
div.insertAdjacentHTML(
"beforeend",
modelRow({ id: "", priority: 0, kind: "chat" }, div.children.length),
);
}
async function saveSource(btn) {
const models = [...document.querySelectorAll("#s-models .model-row")]
.map((row) => ({
id: row.querySelector(".m-id").value.trim(),
priority: parseInt(row.dataset.priority) || 0,
kind: row.querySelector(".m-kind").value,
}))
.filter((m) => m.id);
let meta = {};
try {
meta = JSON.parse($("#s-meta").value || "{}");
} catch (e) {
toast(t("toastBadJson"));
return;
}
const payload = {
name: $("#s-name").value.trim(),
base_url: $("#s-url").value.trim(),
api_key: $("#s-key").value.trim(),
adapter: $("#s-adapter").value.trim(),
endpoint: $("#s-ep").value.trim(),
image_endpoint: $("#s-img").value.trim(),
max_concurrent: parseInt($("#s-conc").value) || 8,
rpm: parseInt($("#s-rpm").value) || 0,
temperature: parseFloat($("#s-temp").value) || 0,
models,
meta,
};
btn.disabled = true;
try {
await api("/api/sources", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
toast(t("toastSaved"));
const w = $("#modal-wrap");
if (w) w.remove();
else if (window._modal) window._modal.remove();
renderSources();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
btn.disabled = false;
}
}
// collect the source form fields except name & api_key — the fields a
// template reuses. Returns the cleaned object or null on invalid JSON.
function readSourceFormExceptKeys() {
const models = [...document.querySelectorAll("#s-models .model-row")]
.map((row) => ({
id: row.querySelector(".m-id").value.trim(),
priority: parseInt(row.dataset.priority) || 0,
kind: row.querySelector(".m-kind").value,
}))
.filter((m) => m.id);
let meta = {};
try {
meta = JSON.parse($("#s-meta").value || "{}");
} catch (e) {
toast(t("toastBadJson"));
return null;
}
return {
base_url: $("#s-url").value.trim(),
adapter: $("#s-adapter").value.trim(),
endpoint: $("#s-ep").value.trim(),
image_endpoint: $("#s-img").value.trim(),
max_concurrent: parseInt($("#s-conc").value) || 8,
rpm: parseInt($("#s-rpm").value) || 0,
temperature: parseFloat($("#s-temp").value) || 0,
models,
meta,
};
}
// "存为模板": save every non-key/non-name form field as a template.
async function srcSaveAsTemplate() {
const body = readSourceFormExceptKeys();
if (!body) return;
const dflt = ($("#s-name").value || "").trim() || "tpl";
const wrap = document.createElement("div");
wrap.id = "tpl-save-modal";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplSave")}</h2>
<label>${t("tplNamePrompt")}</label>
<input id="tpl-save-name" value="${escAttr(dflt)}">
<p><button onclick="confirmSaveTemplate()">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#tpl-save-modal').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#tpl-save-name").focus();
window._templateSaveBody = body;
}
async function confirmSaveTemplate() {
const nm = ($("#tpl-save-name").value || "").trim();
if (!nm) { toast(t("tplNamePrompt")); return; }
const body = window._templateSaveBody;
if (!body) return;
try {
const r = await api("/api/source_templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nm, ...body }),
});
toast(t("toastSaved"));
$("#tpl-save-modal").remove();
} catch (e) {
toast(e.message);
}
}
// "从模板创建": pick a template and fill the form (except name/key).
async function srcFromTemplate() {
let tpls = [];
try {
tpls = (await api("/api/source_templates")).templates || [];
} catch (e) {
toast(e.message);
return;
}
if (!tpls.length) {
toast(t("tplEmpty"));
return;
}
const wrap = document.createElement("div");
wrap.id = "template-pick";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
const rows = tpls
.map(
(tpl) =>
`<div class="tpl-pick-row" data-tpl="${escAttr(tpl.name)}">
<div><b>${esc(tpl.name)}</b><span class="muted"> · ${esc(tpl.base_url || "")}</span></div>
<div class="muted">${esc((tpl.adapter || "") + " · " + (tpl.models || []).map((m) => m.id).join(", "))}</div>
</div>`,
)
.join("");
wrap.innerHTML = `<div class="card" style="width:420px"><h2>${t("tplFrom")}</h2>
${rows}
<p><button class="ghost" onclick="this.closest('#template-pick').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
// delegate click to avoid inline-handler quoting issues
wrap.addEventListener("click", (e) => {
const row = e.target.closest(".tpl-pick-row");
if (!row) return;
pickTemplate(row.dataset.tpl);
});
}
// applies a template's fields onto the open source form (name + key stay).
function pickTemplate(name) {
api("/api/source_templates")
.then((r) => {
const tpl = (r.templates || []).find((t) => t.name === name);
if (!tpl) return;
$("#s-url").value = tpl.base_url || "";
$("#s-adapter").value = tpl.adapter || "";
$("#s-ep").value = tpl.endpoint || "";
$("#s-img").value = tpl.image_endpoint || "";
$("#s-conc").value = tpl.max_concurrent || 8;
$("#s-rpm").value = tpl.rpm || 0;
$("#s-temp").value = tpl.temperature ?? 0.7;
$("#s-meta").value = JSON.stringify(tpl.meta || {}, null, 2);
const box = $("#s-models");
box.innerHTML = "";
(tpl.models && tpl.models.length
? tpl.models
: [{ id: "", priority: 0, kind: "chat" }]
).forEach((m, i) =>
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
);
const w = $("#template-pick");
if (w) w.remove();
toast(t("toastSaved"));
})
.catch((e) => toast(e.message));
}
// template manager: list all templates with edit + delete.
async function openTemplateModal() {
let tpls = [];
try {
tpls = (await api("/api/source_templates")).templates || [];
} catch (e) {
toast(e.message);
return;
}
const wrap = document.createElement("div");
wrap.id = "tpl-manage";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:60";
wrap.innerHTML = `<div class="card" style="width:560px;max-width:96%"><h2>${t("tplTitle")}<span class="grow"></span>
<button class="small" onclick="editTemplate('')">${t("tplNew")}</button></h2>
${tpls.length
? `<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>` +
tpls
.map(
(t) =>
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(t.name).replace(/["<>]/g, (c) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;" }[c]))})">${t("srcEdit")}</button>
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
)
.join("") +
"</table></div>"
: `<div class="empty">${t("tplEmpty")}</div>`}
<p><button class="ghost" onclick="this.closest('#tpl-manage').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
}
async function delTemplate(name) {
if (!confirm(tFmt("tplConfirmDel", name))) return;
try {
await api("/api/source_templates/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
openTemplateModal();
} catch (e) {
toast(e.message);
}
}
// edit or create a template (no name / api_key fields).
async function editTemplate(name) {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
Promise.all([api("/api/source_templates"), api("/api/status")])
.then(([tplsR, st]) => {
const tlist = tplsR.templates || [];
const s = tlist.find((x) => x.name === name) || {
name: name,
models: [{ id: "", priority: 0, kind: "chat" }],
};
const cur = s.adapter || "openai";
const apps = ["", ...(st.adapters || []).map((a) => a.name)];
if (cur && !apps.includes(cur)) apps.push(cur);
const adSel =
`<select id="s-adapter"><option value="" ${!cur ? "selected" : ""}>${esc(t("mAliasAuto"))}</option>` +
apps
.filter((n) => n)
.map(
(n) =>
`<option value="${escAttr(n)}" ${n === cur ? "selected" : ""}>${esc(n)}</option>`,
)
.join("") +
"</select>";
wrap.innerHTML = `<div class="card"><h2>${esc(name ? t("tplTitle") + ": " + name : t("tplNew"))}</h2>
<div class="row">
<div><label>${t("mName")}</label><input id="s-name" value="${escAttr(s.name)}" ${name ? "disabled" : ""}></div>
<div><label>${t("mAlias")}</label>${adSel}</div>
</div>
<label>${t("mURL")}</label><input id="s-url" value="${escAttr(s.base_url || "")}">
<div class="row">
<div><label>${t("mEp")}</label><input id="s-ep" value="${escAttr(s.endpoint || "")}"></div>
<div><label>${t("mImgEp")}</label><input id="s-img" value="${escAttr(s.image_endpoint || "")}"></div>
</div>
<div class="row">
<div><label>${t("mConc")}</label><input id="s-conc" type="number" value="${s.max_concurrent || 8}"></div>
<div><label>${t("mTemp")}</label><input id="s-temp" type="number" step="0.1" value="${s.temperature ?? 0.7}"></div>
</div>
<div class="row">
<div><label>${t("mRPM")}</label><input id="s-rpm" type="number" min="0" placeholder="0 = 不限" value="${s.rpm || 0}"></div>
<div></div>
</div>
<label>${t("mModels")}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t("mAddModel")}</button>
<label>${t("mMeta")}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveTemplate(this)">${t("mSave")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:70";
document.body.appendChild(wrap);
const box = $("#s-models");
(s.models && s.models.length
? s.models
: [{ id: "", priority: 0, kind: "chat" }]
).forEach((m, i) =>
box.insertAdjacentHTML("beforeend", modelRow(m, i)),
);
})
.catch(() => {});
}
async function saveTemplate(btn) {
const nm = $("#s-name").value.trim();
if (!nm) {
toast(t("tplNamePrompt"));
return;
}
const body = readSourceFormExceptKeys();
if (!body) return;
btn.disabled = true;
try {
await api("/api/source_templates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: nm, ...body }),
});
toast(t("toastSaved"));
const w = $("#modal-wrap");
if (w) w.remove();
openTemplateModal();
} catch (e) {
toast(e.message);
btn.disabled = false;
}
}
async function delSource(name) {
if (!confirm(tFmt("confirmDelSrc", name))) return;
await api("/api/sources/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderSources();
}
/* ---------- sort tab: scratch jigsaw chain on canvas ---------- */
const SORT_COLORS = [
["#5b8def", "#3f6ef5"],
["#37b273", "#1f9d5a"],
["#f2a541", "#e08a15"],
["#a466e8", "#8b4dd4"],
["#e86f9c", "#d74f86"],
["#45c4c0", "#2aa8a4"],
["#e8564a", "#d03f34"],
["#8ba3c7", "#6f8ab3"],
];
let sortUid = 0;
function nexUid() {
return "s" + ++sortUid;
}
function srcColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++)
h = (h * 31 + name.charCodeAt(i)) >>> 0;
return SORT_COLORS[h % SORT_COLORS.length];
}
function srcShort(name) {
return (name || "?").slice(0, 2).toUpperCase();
}
const sortState = { lanes: [], origin: null, drag: null, kind: "chat" };
// per-kind editor state: chat lanes and image lanes are independent;
// the toggle switches which chain the canvas edits
const sortLanes = { chat: [], image: [] };
let sortStateMap = new Map();
async function renderSort() {
const j = await api("/api/sources");
let autoR = [];
let autoImg = [];
try {
const a = await api("/api/auto");
autoR = a.rules || [];
autoImg = a.image_rules || [];
sortStateMap = new Map(
(a.states || []).map((st) => [
st.model + "|" + (st.source || "*"),
st,
]),
);
} catch (e) {}
const byModel = new Map();
const byPair = new Map();
const sourceRows = new Map();
j.sources.forEach((s) =>
(s.models || []).forEach((m) => {
const it = { src: s.name, id: m.id, prio: m.priority || 0, kind: m.kind };
byPair.set(m.id + "|" + s.name, it);
if (!byModel.has(m.id)) byModel.set(m.id, it);
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
}),
);
// build a lane structure for one kind from its saved rules; when
// rules are empty, fall back to discovery from sourceRows filtered by
// kind so an unconfigured chain still shows all available slots
const buildLanes = (rules, kind) => {
if (rules.length) {
const tiers = [];
rules.forEach((r, i) => {
const src = normSrc(r.source);
const pair = src ? byPair.get(r.model + "|" + src) : null;
if (src && !pair) return;
const ref = pair ||
byModel.get(r.model) || { src: "*", id: r.model };
const ti = Math.max(1, parseInt(r.tier) || i + 1) - 1;
if (!tiers[ti]) tiers[ti] = [];
tiers[ti].push({
src: ref.src || "*",
id: r.model,
uid: nexUid(),
meta: {
quota: r.token_quota || 0,
period: r.period || "",
hours: r.hours || 0,
},
});
});
return tiers
.filter(Boolean)
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
}
// discovery: group slots of this kind by their source priority
return [...sourceRows.entries()]
.sort((a, b) => b[0] - a[0])
.map(([prio, models]) => {
const same = models.filter((m) => {
const it = byPair.get(m.id + "|" + m.src);
const k = it ? it.kind : "chat";
return k === kind;
});
if (!same.length) return null;
same.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
same.forEach((m) => { m.uid = nexUid(); m.meta = null; });
return { prio, models: same };
})
.filter(Boolean);
};
sortLanes.chat = buildLanes(autoR, "chat");
sortLanes.image = buildLanes(autoImg, "image");
sortState.lanes = sortLanes[sortState.kind] || [];
// model picker for the "add slot" control: one option per (source, model)
// pair so identical model ids on different sources stay distinguishable
await loadModelPairs();
sortState.origin = JSON.stringify(sortState.lanes);
const kindToggle =
`<div class="kind-toggle"><button class="${sortState.kind === "chat" ? "" : "ghost"} small" onclick="switchSortKind('chat')">${t("kChat") || "聊天"}</button><button class="${sortState.kind === "image" ? "" : "ghost"} small" onclick="switchSortKind('image')">${t("kImage") || "生图"}</button></div>`;
$("#tab-sort").innerHTML = `
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>${kindToggle}
<button class="ghost small" onclick="scrAddModal()">+ ${t("sortAdd")}</button>
<button class="ghost small" onclick="sortReset()">${t("sortReset")}</button>
<button class="small" onclick="saveSort()">${t("sortSave")}</button></h2>
<div class="sort-canvas" id="scr-canvas"></div>
<div class="scr-hint">
${
sortState.lanes
.map((l) => l.models[0])
.map((it) => {
const c = srcColor(it.src);
return `<span><i style="background:linear-gradient(135deg,${c[0]},${c[1]})"></i>${esc(it.src)}</span>`;
})
.filter((v, i, a) => a.indexOf(v) === i)
.join("") || ""
}
</div>
</div>`;
paintSort();
}
function switchSortKind(k) {
if (sortState.kind === k) return;
sortLanes[sortState.kind] = sortState.lanes;
sortState.kind = k;
sortState.lanes = sortLanes[k] || [];
sortState.origin = JSON.stringify(sortState.lanes);
sortState.drag = null;
const h = $("#tab-sort").querySelector("h2");
if (h) {
const tg = h.querySelector(".kind-toggle");
if (tg) {
tg.querySelectorAll("button").forEach((b) => {
const isK = (b.getAttribute("onclick") || "").indexOf("'" + k + "'") >= 0;
b.className = isK ? "small" : "ghost small";
});
}
}
paintSort();
}
function healthTag(it) {
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
if (!st) return "";
const bits = [];
if (st.cooling) {
// A cooling slot past its window midpoint still takes one probe
// request, so show that instead of a flat "cooling".
if (st.probing)
bits.push(
`<span class="ht-probe">${esc(t("sortProbing"))}</span>`,
);
else if (st.probeable)
bits.push(
`<span class="ht-probe">${esc(t("sortProbeReady"))}</span>`,
);
else bits.push(`<span class="ht-cool">${esc(t("sortCooling"))}</span>`);
}
if (st.fail_count > 0)
bits.push(
`<span class="ht-fail">${esc(t("sortFail") + "×" + st.fail_count)}</span>`,
);
if (st.pref !== 0)
bits.push(
`<span class="ht-pref">${esc(st.pref > 0 ? "+" + st.pref : "" + st.pref)}</span>`,
);
if (!bits.length) return "";
return `<span class="scr-htag" title="${escAttr(healthTip(st))}">${bits.join("")}</span>`;
}
// healthTip explains the cooldown/probe window in the tooltip: when the
// window opened, when probing starts and when it fully expires.
function healthTip(st) {
const base = t("sortHealthTip");
if (!st.cooling || !st.cooldown_until) return base;
const hms = (ts) =>
ts ? new Date(ts * 1000).toLocaleTimeString() : "—";
return (
base +
"\n" +
tFmt(
"sortProbeTip",
hms(st.cooldown_from),
hms(st.probe_after),
hms(st.cooldown_until),
)
);
}
function scrBlockHtml(it, isFirst, li, ji, extraClass) {
const c = srcColor(it.src);
const s = srcShort(it.src);
return `
<div class="scr-block ${escAttr(extraClass || "")}" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
onclick="scrCtx(event,'${li}','${ji}')"
oncontextmenu="scrCtx(event,'${li}','${ji}')">
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ""}
<span class="scr-ico">${esc(it.src === "*" ? "+" : s)}</span>
<span class="scr-name">${esc(it.id)}<em class="scr-srcname">${esc(it.src === "*" ? t("kAnySrc") : it.src)}</em></span>
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ""}
${healthTag(it)}
<span class="scr-x" title="${escAttr(t("kDelB2"))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
<span class="scr-grip"><i></i><i></i><i></i></span>
</div>`;
}
function paintSort(affected) {
const cv = $("#scr-canvas");
if (!cv) return;
// compute the next lane-leaders so we can retract the outgoing ones first
const nextFirst = new Set();
sortState.lanes.forEach((lane) => {
if (lane.models.length) nextFirst.add(lane.models[0].uid);
});
// outgoing leaders: currently first in their row but no longer first post-move
const outgoing = [];
cv.querySelectorAll(".scr-row").forEach((row) => {
const blk = row.querySelector(".scr-block");
if (blk && !nextFirst.has(blk.dataset.key)) outgoing.push(blk);
});
if (outgoing.length) {
// clone old tenons/sockets as detached overlays, shrink them away, and
// rebuild the DOM immediately so nothing stalls
outgoing.forEach((b) => {
b.querySelectorAll(".scr-knob, .scr-slot").forEach((el) => {
const rect = el.getBoundingClientRect();
const ghost = el.cloneNode(true);
const cs = getComputedStyle(el);
ghost.style.cssText =
"position:fixed;left:" +
rect.left +
"px;top:" +
rect.top +
"px;width:" +
rect.width +
"px;height:" +
rect.height +
"px;margin:0;" +
"background:" +
cs.background +
";border-radius:" +
cs.borderRadius +
";box-shadow:" +
cs.boxShadow +
";pointer-events:none;";
document.body.appendChild(ghost);
ghost
.animate(
[
{ transform: "scaleY(1)", opacity: 1 },
{ transform: "scaleY(.4)", opacity: 0 },
],
{ duration: 220, easing: "ease-in" },
)
.finished.then(() => ghost.remove());
});
});
}
paintSortNow(affected);
}
function paintSortNow(affected) {
const cv = $("#scr-canvas");
if (!cv) return;
// FLIP: capture old positions of lanes and blocks before repaint
const prev = new Map();
const prevFirst = new Set(); // keys that used to be the first block of their lane
const prevLast = new Set(); // keys that used to be the last block of their lane
cv.querySelectorAll(".scr-row").forEach((row) => {
const blks = row.querySelectorAll(".scr-block");
if (blks[0]) prevFirst.add(blks[0].dataset.key);
if (blks.length) prevLast.add(blks[blks.length - 1].dataset.key);
});
cv.querySelectorAll(".scr-lane").forEach((l) =>
prev.set("lane:" + l.dataset.lane, l.getBoundingClientRect()),
);
cv.querySelectorAll(".scr-block").forEach((b) =>
prev.set("blk:" + b.dataset.key, b.getBoundingClientRect()),
);
const preGrowL = new Set();
sortState.lanes.forEach((lane) =>
lane.models.forEach((m, i) => {
if (i > 0 && (prevFirst.has(m.uid) || !prev.has("blk:" + m.uid)))
preGrowL.add(m.uid);
}),
);
const html = [];
sortState.lanes.forEach((lane, li) => {
const n = sortState.lanes.length - li;
const cls =
"scr-lane" +
(li === 0 ? " scr-top" : "") +
(li === sortState.lanes.length - 1 ? " scr-last" : "");
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
html.push(`<div class="${cls}" data-lane="${li}">
<div class="scr-tier">${Array.from({ length: n }, () => "<span></span>").join("")}</div>
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i, preGrowL.has(m.uid) ? "pre-grow-l" : "")).join("")}</div>
</div>`);
});
html.push(
`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`,
);
cv.innerHTML = html.join("");
// FLIP: animate lanes near the affected area (and follow-up rows sliding up);
// only animate lanes listed in `affected` (or all movable ones when a lane
// vanishes, since every row below shifts).
requestAnimationFrame(() => {
const animLanes = new Set(affected === undefined ? [] : affected);
if (affected === undefined) {
// initial render / reset: no animation at all
return;
}
prev.forEach((r0, key) => {
const isLane = key.startsWith("lane:");
const el = isLane
? cv.querySelector(`.scr-lane[data-lane="${key.slice(5)}"]`)
: cv.querySelector(
`.scr-block[data-key="${CSS.escape(key.slice(4))}"]`,
);
if (!el) return;
// blocks/slide-up for any lane below a removed lane must still animate
let laneIdx = isLane ? +key.slice(5) : null;
if (laneIdx === null) {
const lane = el.closest(".scr-lane");
if (lane) laneIdx = +lane.dataset.lane;
}
if (laneIdx !== null && animLanes.has(laneIdx)) {
const r1 = el.getBoundingClientRect();
const dx = r0.left - r1.left,
dy = r0.top - r1.top;
if (dx || dy) {
el.animate(
[
{ transform: `translate(${dx}px,${dy}px)` },
{ transform: "none" },
],
{ duration: 240, easing: "ease-out" },
);
}
}
});
// grow-in animations, scheduled AFTER the 240ms slide so the block settles
// into place first, then its knobs pop out:
// grow-t/b : brand-new lane-leader gets its top knob / bottom socket
// grow-l : block leaves the leader slot, now gains a left tenon
// grow-r : block reaches the lane tail, now gains the right notch end
cv.querySelectorAll(".scr-lane .scr-block").forEach((b) => {
const lane = b.closest(".scr-lane");
if (!lane || !animLanes.has(+lane.dataset.lane)) return;
const key = b.dataset.key;
const blks = b.parentElement.querySelectorAll(".scr-block");
const isFirst = blks[0] === b;
const isLast = blks[blks.length - 1] === b;
if (isFirst && !prevFirst.has(key)) {
b.classList.add("grow-t");
b.classList.add("grow-b");
// the vertical joint is shared across rows: re-etch the socket of the
// leader above (receives this knob) and the knob of the leader below
// (rises into this socket), so the whole chain animates together
const li = +lane.dataset.lane;
const peer = cv.querySelector(
`.scr-lane[data-lane="${li - 1}"] .scr-block`,
);
if (peer) peer.classList.add("grow-b");
const peer2 = cv.querySelector(
`.scr-lane[data-lane="${li + 1}"] .scr-block`,
);
if (peer2) peer2.classList.add("grow-t");
}
if (!isFirst && (prevFirst.has(key) || !prev.has("blk:" + key))) {
b.classList.remove("pre-grow-l");
void b.offsetWidth;
b.classList.add("grow-l");
}
if (!isLast && prevLast.has(key)) b.classList.add("grow-r");
});
});
cv.querySelectorAll(".scr-grip").forEach((grip) => {
grip.addEventListener("pointerdown", (e) => {
const block = grip.closest(".scr-block");
if (!block) return;
e.preventDefault();
e.stopPropagation();
const key = block.dataset.key;
const li = cv
.querySelector(
`.scr-lane .scr-block[data-key="${CSS.escape(key)}"]`,
)
.closest(".scr-lane").dataset.lane;
sortState.drag = { key, li: +li, px: e.clientX, py: e.clientY };
block.classList.add("drag-src");
makeGhost(block, e.clientX, e.clientY);
const mv = (ev) => onSortMove(ev);
const up = (ev) => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
onSortUp(ev);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
try {
block.setPointerCapture(e.pointerId);
} catch (err) {}
});
});
}
function onSortMove(e) {
const d = sortState.drag;
if (!d) return;
const cv = document.getElementById("scr-canvas");
if (!cv) return;
const laneEls = [...cv.querySelectorAll(".scr-lane")];
const gapEls = [...cv.querySelectorAll(".scr-gap")];
const y = e.clientY,
x = e.clientX;
const laneRect = laneEls.map((el) => el.getBoundingClientRect());
const gapRect = gapEls.map((el) => el.getBoundingClientRect());
// 1) drop in a gap -> new priority lane between rows
// narrow hit zone: only the middle band of the gap counts, so a pointer
// sweeping near a row edge stays committed to that row
let gapIdx = -1;
for (let i = 0; i < gapRect.length; i++) {
const r = gapRect[i];
const midY = (r.top + r.bottom) / 2;
if (Math.abs(y - midY) <= 5 && x >= r.left && x <= r.right) {
gapIdx = +gapEls[i].dataset.gap;
break;
}
}
// 2) drop inside a lane -> reorder within that lane / move between lanes
let li = -1;
if (gapIdx < 0) {
for (let i = 0; i < laneRect.length; i++) {
const r = laneRect[i];
if (y >= r.top && y <= r.bottom) {
li = +laneEls[i].dataset.lane;
break;
}
}
}
// 3) else: nearest lane edge (top/bottom half of canvas) -> gap around it
if (gapIdx < 0 && li < 0 && laneRect.length) {
const first = laneRect[0],
last = laneRect[laneRect.length - 1];
if (y < first.top) gapIdx = 0;
else if (y > last.bottom) gapIdx = laneRect.length;
}
// 4) outside the canvas -> cancel target, block snaps back
const cvr = cv.getBoundingClientRect();
if (y < cvr.top || y > cvr.bottom) {
gapIdx = -1;
li = -1;
}
// clear previous hover state
cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) => {
el.classList.remove("hover", "drophere");
});
const hline = $("#scr-blockline");
if (hline) hline.remove();
if (gapIdx >= 0) {
const gap = cv.querySelector(`.scr-gap[data-gap="${gapIdx}"]`);
if (gap) gap.classList.add("drophere");
d.gap = gapIdx;
d.li = undefined;
d.ins = undefined;
} else if (li >= 0) {
const laneEl = cv.querySelector(`.scr-lane[data-lane="${li}"]`);
laneEl.classList.add("hover");
const rowEl = laneEl.querySelector(".scr-row");
const blocks = rowEl.querySelectorAll(".scr-block");
let ins = 0; // insertion index inside lane
if (blocks.length) {
const targets = [...blocks].filter((b) => b.dataset.key !== d.key);
// find insertion point by x position among visible blocks
let idx = 0;
for (const b of targets) {
const r = b.getBoundingClientRect();
if (x > r.left + r.width / 2) idx++;
else break;
}
ins = idx;
}
d.li = li;
d.ins = ins;
d.gap = undefined;
// vertical guide line at insertion point
const before = [...blocks][ins];
const after = [...blocks][ins - 1];
const g = document.createElement("div");
g.id = "scr-blockline";
g.className = "scr-blockline";
let gx;
if (before) gx = before.getBoundingClientRect().left - 6;
else if (after) gx = after.getBoundingClientRect().right + 6;
else {
const wr = rowEl.getBoundingClientRect();
gx = wr.left;
}
g.style.left = gx + "px";
const laneR = laneEl.getBoundingClientRect();
g.style.top = laneR.top + "px";
g.style.height = laneR.height + "px";
document.body.appendChild(g);
} else if (typeof d.ins === "number") {
// lost between rows: drop the previous target so the block snaps back
d.li = undefined;
d.ins = undefined;
d.gap = undefined;
}
d.px = x;
d.py = y;
placeGhostXY(x, y);
}
function onSortUp(e) {
const d = sortState.drag;
if (!d) return;
const line = document.getElementById("scr-blockline");
if (line) line.remove();
const cv = document.getElementById("scr-canvas");
cv.querySelectorAll(".scr-gap, .scr-lane").forEach((el) =>
el.classList.remove("hover", "drophere"),
);
const item = findLaneItem(d.key);
if (!item) {
removeGhost();
sortState.drag = null;
return;
}
let minLi = item.li,
maxLi = item.li;
if (d.gap !== undefined) {
// move to a new lane positioned at that gap
const m = sortState.lanes[item.li].models.splice(item.idx, 1)[0];
let dst = d.gap;
if (!sortState.lanes[item.li].models.length) {
sortState.lanes.splice(item.li, 1);
if (item.li < dst) dst -= 1;
}
sortState.lanes.splice(dst, 0, { models: [m] });
minLi = Math.min(item.li, dst);
maxLi = Math.max(item.li, dst);
} else if (typeof d.ins === "number") {
// reorder within lane li
const srcL = item.li;
const m = sortState.lanes[srcL].models.splice(item.idx, 1)[0];
let tgt = d.li === undefined ? srcL : d.li;
if (!sortState.lanes[srcL].models.length && srcL !== tgt) {
// source lane became empty: remove it, target index shifts accordingly
sortState.lanes.splice(srcL, 1);
if (srcL < tgt) tgt -= 1;
}
sortState.lanes[tgt].models.splice(d.ins, 0, m);
minLi = Math.min(srcL, tgt);
maxLi = Math.max(srcL, tgt);
}
// affected lane range: from the earliest changed lane to the very end
// (rows below an emptied lane slide up to re-seal the chain)
const affected = [];
for (let i = minLi; i < sortState.lanes.length; i++) affected.push(i);
removeGhost();
sortState.drag = null;
paintSort(affected);
}
function findLaneItem(uid) {
for (let i = 0; i < sortState.lanes.length; i++) {
const idx = sortState.lanes[i].models.findIndex((m) => m.uid === uid);
if (idx >= 0)
return { li: i, idx, it: sortState.lanes[i].models[idx] };
}
return null;
}
let gEl = null;
function makeGhost(block, x, y) {
removeGhost();
gEl = block.cloneNode(true);
gEl.classList.remove("drag-src");
gEl.classList.add("scr-ghost");
gEl.style.position = "fixed";
gEl.style.width = block.offsetWidth + "px";
gEl.style.margin = "0";
gEl.style.left = x - 20 + "px";
gEl.style.top = y - 20 + "px";
gEl.style.pointerEvents = "none";
document.body.appendChild(gEl);
const br = block.getBoundingClientRect();
gEl.dataset.dx = x - br.left;
gEl.dataset.dy = y - br.top;
}
function placeGhostXY(x, y) {
if (!gEl) return;
gEl.style.left = x - gEl.dataset.dx + "px";
gEl.style.top = y - gEl.dataset.dy + "px";
}
function removeGhost() {
if (gEl) {
gEl.remove();
gEl = null;
}
}
function sortReset() {
if (sortState.origin) sortState.lanes = JSON.parse(sortState.origin);
sortState.drag = null;
removeGhost();
const all = [];
for (let i = 0; i < sortState.lanes.length; i++) all.push(i);
paintSort(all);
}
async function saveSort() {
try {
await persistAuto();
sortState.origin = JSON.stringify(sortState.lanes);
toast(t("sortSaved") + " · " + t("sortHealthReset"));
} catch (e) {
toast(e.message);
}
}
/* ---------- AUTO quota editing on sort blocks ---------- */
async function persistAuto() {
const rules = [];
sortState.lanes.forEach((lane, li) =>
lane.models.forEach((it) => {
const m = it.meta;
rules.push({
model: it.id,
source: it.src === "*" ? undefined : it.src,
tier: li + 1,
token_quota: m && m.quota ? +m.quota : 0,
period: m && m.period ? m.period : "",
hours: m && m.hours ? +m.hours : 0,
});
}),
);
// each kind persists to its own chain; the other kind's saved rules
// are left untouched (omitting the field means "no change")
const body = { rules: undefined, image_rules: undefined };
if (sortState.kind === "image") body.image_rules = rules;
else body.rules = rules;
await api("/api/auto", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
function scrAddModal() {
const cur = { quota: 0, period: "", hours: 0 };
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
// filter the model picker to the kind currently edited (image models
// in image mode, chat models in chat mode)
const kind = sortState.kind;
const pickModels = (allModels || []).filter((m) => {
const k = typeof m === "string" ? "chat" : m.kind || "chat";
return kind === "image" ? k === "image" : k !== "image";
});
const optsHtml = pickModels.length
? pickModels
.map((m) =>
typeof m === "string"
? `<option value="${escAttr(m)}">${esc(m)}</option>`
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
)
.join("")
: `<option value="" disabled selected>${esc(t("kEmpty") || "No models")}</option>`;
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")} ${kind === "image" ? "· 生图" : "· 聊天"}</h2>
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
<select id="a-model">${optsHtml}</select>
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t("kQuotaHintB"))}">
<label>${t("kPeriodB")}</label>
<select id="a-period">
<option value="" selected>${t("kPerNothing")}</option>
<option value="hour">${t("kPerHour")}</option>
<option value="week">${t("kPerWeek")}</option>
<option value="month">${t("kPerMonth")}</option>
<option value="nhour">${t("kPerHours")}</option>
</select>
<div id="a-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="a-hours" type="number" min="1" step="1" value="24"></div>
<p><button onclick="scrAddFromForm()">${t("kSaveScope")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#a-period").addEventListener("change", () => {
$("#a-hours").closest("#a-hours-box").style.display =
$("#a-period").value === "nhour" ? "block" : "none";
});
$("#a-model").focus();
}
function scrAddFromForm() {
const raw = $("#a-model").value.trim();
if (!raw) {
toast(t("kName"));
return;
}
const [id, src] = raw.split("|");
let q = parseInt($("#a-quota").value);
if (isNaN(q) || q < 0) q = 0;
const p = $("#a-period").value;
let h = parseInt($("#a-hours").value);
if (isNaN(h) || h < 1) h = 1;
sortState.lanes.push({
models: [
{
src: src || "*",
id: id,
uid: nexUid(),
meta: { quota: q, period: p, hours: p || q ? h : 0 },
},
],
});
const li = sortState.lanes.length - 1;
const w = $("#modal-wrap");
if (w) w.remove();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
}
function sortScopeSave(li, ji) {
const it = sortState.lanes[li].models[ji];
if (!it) return;
let q = parseInt($("#q-quota").value);
if (isNaN(q) || q < 0) q = 0;
const p = $("#q-period").value;
let h = parseInt($("#q-hours").value);
if (isNaN(h) || h < 1) h = 1;
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
const w = $("#modal-wrap");
if (w) w.remove();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
}
function scrCtx(e, li, ji) {
if (e.button === 2) {
sortBlockCtx(e, li, ji);
return;
}
e.stopPropagation();
sortScopeEdit(li, ji);
}
function sortScopeEdit(li, ji) {
const it = sortState.lanes[li].models[ji];
if (!it) return;
const cur = it.meta || { quota: 0, period: "", hours: 0 };
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("kFormTitle")} · ${esc(it.id)}${it.src !== "*" ? " · " + esc(it.src) : ""}</h2>
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="q-quota" type="number" min="0" step="1"
placeholder="${escAttr(t("kQuotaHintB"))}" value="${cur.quota || ""}">
<label>${t("kPeriodB")}</label>
<select id="q-period">
<option value="" ${!cur.period ? "selected" : ""}>${t("kPerNothing")}</option>
<option value="hour" ${cur.period === "hour" ? "selected" : ""}>${t("kPerHour")}</option>
<option value="week" ${cur.period === "week" ? "selected" : ""}>${t("kPerWeek")}</option>
<option value="month" ${cur.period === "month" ? "selected" : ""}>${t("kPerMonth")}</option>
<option value="nhour" ${cur.period === "nhour" ? "selected" : ""}>${t("kPerHours")}</option>
</select>
<div id="q-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="q-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
<p><button onclick="sortScopeSave(${li},${ji},this)">${t("kSaveScope")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#q-period").addEventListener("change", () => {
$("#q-hours").closest("#q-hours-box").style.display =
$("#q-period").value === "nhour" ? "block" : "none";
});
}
function sortBlockCtx(e, li, ji) {
e.preventDefault();
e.stopPropagation();
const it = sortState.lanes[li].models[ji];
if (!it) return;
const items = [
{
label: t("kEditQ"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
fn: () => sortScopeEdit(li, ji),
},
{
label: t("kDupB"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
fn: () => {
sortState.lanes[li].models.splice(ji + 1, 0, {
src: it.src,
id: it.id,
uid: nexUid(),
meta: it.meta ? { ...it.meta } : null,
});
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
},
},
];
if (it.meta)
items.push({
label: t("kClearQ"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M12 4v16M4 12h16"/><path d="M4 4l16 16M4 20l16-16"/></svg>',
fn: () => {
it.meta = null;
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
},
});
items.push({
label: t("kDelB2"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
danger: true,
fn: () => scrDelSlot(li, ji),
});
showCtx(e.clientX, e.clientY, items);
}
function scrDelSlot(li, ji) {
sortState.lanes[li].models.splice(ji, 1);
if (!sortState.lanes[li].models.length && sortState.lanes.length > 1)
sortState.lanes.splice(li, 1);
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
}
/* ---------- adapters tab ---------- */
async function renderAdapters() {
const j = await api("/api/status");
const pools = {};
(j.adapter_pools || []).forEach((p) => {
pools[p.name] = p;
});
const rows = j.adapters
.map((a) => {
const p = pools[a.name];
return `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || "")}</td>
<td>${poolCell(p)}</td>
<td><button class="danger small" onclick="delAdapter('${escAttr(a.name)}')">${t("srcDel")}</button></td></tr>`;
})
.join("");
$("#tab-adapters").innerHTML = `
<div class="card"><h2>${t("adTitle")} (${j.adapters.length})</h2>
<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tVersion")}</th><th>${t("adPool")}</th><th></th></tr>
${rows || `<tr><td colspan="4" class="empty">${t("adEmpty")}</td></tr>`}</table></div>
<p class="muted">${t("adPoolHint")}</p>
</div>
<div class="card"><h2>${t("uploadTitle")}</h2>
<div class="dropzone" id="dz">${t("dropHint")}</div>
<input type="file" id="adp-file" accept=".lua,text/x-lua" class="hidden">
<label>${t("adName")}</label><input id="adp-name" placeholder="mysrc">
<label>${t("tbLua")}</label>
<textarea id="adp-code" spellcheck="false" placeholder="return { name='mysrc', endpoint='/chat/completions', transform_request=function(raw) return raw end, transform_response=function(raw) return raw end }"></textarea>
<p><button onclick="uploadAdapter()">${t("uploadBtn")}</button></p>
</div>`;
bindDropzone();
}
// poolCell renders one adapter's elastic Lua state pool: how many states
// exist vs the ceiling, how they split between idle and checked out, and
// the current grow/shrink steps. A pool that has never been used shows a
// dash: zero resident states is the healthy default, not an error.
function poolCell(p) {
if (!p) return '<span class="muted">—</span>';
if (!p.created)
return `<span class="muted" title="${escAttr(t("adPoolMax") + " " + p.max)}">0 / ${p.max}</span>`;
const busy = p.in_use > 0;
const title = [
`${t("adPoolIdle")} ${p.idle}`,
`${t("adPoolBusy")} ${p.in_use}`,
`${t("adPoolMax")} ${p.max}`,
`${t("adPoolPeak")} ${p.peak_in_use}`,
`${t("adPoolSteps")} +${p.grow_step} / -${p.shrink_step}`,
].join(" · ");
return `<span class="tag ${busy ? "tag-green" : "tag-amber"}" title="${escAttr(title)}">${p.created} / ${p.max}</span>
<span class="muted"> ${p.in_use}${p.idle}○ +${p.grow_step}/-${p.shrink_step}</span>`;
}
function bindDropzone() {
const dz = $("#dz"),
file = $("#adp-file"),
name = $("#adp-name"),
code = $("#adp-code");
["dragenter", "dragover"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
dz.classList.add("dragover");
}),
);
["dragleave", "drop"].forEach((ev) =>
dz.addEventListener(ev, (e) => {
e.preventDefault();
dz.classList.remove("dragover");
}),
);
dz.addEventListener("drop", (e) => {
const f =
e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
if (f) loadFile(f);
});
dz.onclick = () => file.click();
file.onchange = () => {
if (file.files[0]) {
loadFile(file.files[0]);
file.value = "";
}
};
function loadFile(f) {
if (!f.name.toLowerCase().endsWith(".lua")) {
toast(tFmt("toastUpFail", "not a .lua"));
return;
}
const reader = new FileReader();
reader.onload = () => {
name.value = f.name.replace(/\.lua$/i, "");
code.value = String(reader.result || "");
toast(f.name);
};
reader.readAsText(f);
}
}
async function uploadAdapter() {
const name = $("#adp-name").value.trim(),
code = $("#adp-code").value;
if (!name || !code) return toast(t("toastNeedAll"));
try {
await api("/api/adapters", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, code }),
});
toast(t("toastUploaded"));
$("#adp-name").value = "";
$("#adp-code").value = "";
renderAdapters();
} catch (e) {
toast(tFmt("toastUpFail", e.message));
}
}
async function delAdapter(name) {
if (!confirm(tFmt("confirmDelAdp", name))) return;
await api("/api/adapters/" + encodeURIComponent(name), {
method: "DELETE",
});
toast(t("toastDelOk"));
renderAdapters();
}
/* ---------- keys tab ---------- */
let allModels = [];
let scopeDragEl = null;
async function loadModelPairs() {
let srcs = [];
try {
srcs = (await api("/api/sources")).sources || [];
} catch (e) {}
const pairs = [];
(srcs || []).forEach((s) =>
(s.models || []).forEach((m) => {
pairs.push({
src: s.name,
id: m.id,
label: m.id + " · " + s.name + (m.kind === "image" ? " (image)" : ""),
kind: m.kind,
key: m.id + "|" + s.name,
});
}),
);
if (pairs.length) {
allModels = pairs;
return;
}
try {
allModels = (await api("/api/status")).models || [];
} catch (e) {
allModels = [];
}
}
function maskKey(k) {
return k.length > 12 ? k.slice(0, 6) + "…" + k.slice(-6) : k;
}
function fmtCreated(ts) {
if (!ts) return "—";
const d = new Date(ts * 1000);
const p = (x) => String(x).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function roleTag(r) {
return r === "admin"
? `<span class="tag-role admin">${t("kRoleAdmin")}</span>`
: `<span class="tag-role user">${t("kRoleUser")}</span>`;
}
async function renderKeys() {
const me = await api("/api/keys/me");
window._me = me.key;
return me.key.role === "admin"
? renderKeysAdmin(me.key)
: renderKeysUser(me.key);
}
function maybeWarnSeed(k) {
if (!k || !k.seed) return;
if (localStorage.getItem("llms-proxy.seedWarn") === "1") return;
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:460px;max-width:100%">
<h2>${esc(t("seedWarnTitle"))}</h2>
<p class="muted" style="line-height:1.6">${esc(t("seedWarnText"))}</p>
<p><button onclick="this.closest('#modal-wrap').remove();goTab('keys')">${esc(t("seedWarnGo"))}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${esc(t("seedWarnLater"))}</button>
<button class="ghost" onclick="localStorage.setItem('llms-proxy.seedWarn','1');this.closest('#modal-wrap').remove()">${esc(t("seedWarnDismiss"))}</button></p>
</div>`;
document.body.appendChild(wrap);
}
async function renderKeysUser(me) {
$("#tab-keys").innerHTML = `
<div class="card"><h2>${t("kMeTitle")}</h2>
<div class="tbl-wrap"><table><tr><th>${t("kName")}</th><th>${t("kMeRole")}</th><th>${t("kKey")}</th><th>${t("kMeModels")}</th></tr>
<tr><td><b>${esc(me.name || "—")}</b></td><td>${roleTag(me.role)}</td>
<td><span class="kr-key">${esc(me.key)}</span>
<button class="ghost small" onclick="copyText('${escAttr(me.key)}')">${t("kCopy")}</button></td>
<td>${
me.models && me.models.length
? me.models
.map((m) => {
const src = normSrc(m.source);
return `<span class="tag tag-blue" title="${m.token_quota ? "quota " + fmtQuota(m.token_quota) : t("kQuotaUnlim")}">${esc(m.model)}${src ? " · " + esc(src) : ""}${m.token_quota ? " · " + esc(fmtQuota(m.token_quota)) : ""}</span>`;
})
.join("")
: `<span class="scope-unlim">${t("kAll")}</span>`
}</td></tr></table></div>
<div class="muted" style="margin-top:10px">${t("kMeHint")}</div>
</div>`;
}
async function renderKeysAdmin() {
await loadModelPairs();
$("#tab-keys").innerHTML = `
<div class="key-actions">
<span class="grow"></span>
<button class="small" onclick="openKeyModal()">${t("kCreate")}</button>
</div>
<div id="k-newbox"></div>
<div id="k-list"></div>`;
loadKeys();
}
async function loadKeys() {
const el = $("#k-list");
if (!el) return;
const j = await api("/api/keys");
const ks = j.keys || [];
el.innerHTML = ks.length
? ks.map((k) => keyCanvasHtml(k)).join("")
: `<div class="muted">${t("kEmpty")}</div>`;
el.querySelectorAll(".kc-blocks").forEach((cv) => bindCanvasDrop(cv));
el.querySelectorAll(".mb").forEach((b) => bindBrickDrag(b));
}
function keyCanvasHtml(k) {
const scopes = k.models || [];
return `
<div class="key-canvas" data-key="${escAttr(k.key)}">
<div class="kc-head">
<b>${esc(k.name || "—")}</b>
${roleTag(k.role)}
<span class="kr-key">${esc(maskKey(k.key))}</span>
<button class="ghost small" onclick="copyText('${escAttr(k.key)}')">${t("kCopy")}</button>
<span class="grow"></span>
<span class="muted">${fmtCreated(k.created_at)}</span>
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">${t("kDel")}</button>
<button class="ghost small errc" title="${escAttr(t("kDel"))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || "")}')">×</button>
</div>
<div class="kc-blocks" data-key="${escAttr(k.key)}">
${scopes.length ? "" : `<span class="mb-empty">${t("kEmptyB")}</span>`}
${scopes.map((m) => scopeHtml(k.key, m)).join("")}
<button class="add-brick" onclick="scopePush('${escAttr(k.key)}')">+ ${t("kAddB")}</button>
</div>
</div>`;
}
function scopeHtml(key, m) {
const qt = fmtQuota(m.token_quota);
const comb = scopeComb(m);
const src = normSrc(m.source);
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}"
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || "")}" data-hours="${m.hours || 0}"`;
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t("kBrickH"))}"
onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')"
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')">
<span class="mb-ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="10" height="10"><circle cx="12" cy="12" r="10"/></svg></span>
<span class="mb-name">${esc(m.model)}${src ? `<em class="mb-src">${esc(src)}</em>` : ""}</span>
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
<span class="copy-b" title="${escAttr(t("kCopyB"))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(comb)}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></span>
<span class="mb-x" title="${escAttr(t("kDelB"))}" onclick="event.stopPropagation();scopeRm('${escAttr(key)}','${escAttr(comb)}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="12" height="12"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>
</span>`;
}
function normSrc(s) {
return !s || s === "undefined" || s === "null" ? "" : s;
}
function scopeComb(m) {
const s = normSrc(m.source);
return s ? m.model + "|" + s : m.model;
}
function splitCombKey(comb) {
const p = String(comb).split("|");
return { model: p[0], source: normSrc(p[1]) };
}
function fmtQuota(q) {
q = +q || 0;
if (!q) return "∞";
if (q >= 1e9) return (q / 1e9).toFixed(1) + "B";
if (q >= 1e6) return (q / 1e6).toFixed(1) + "M";
if (q >= 1e3) return (q / 1e3).toFixed(1) + "K";
return String(q);
}
function periodText(p, h) {
h = +h || 0;
if (p === "hour") return "·1h";
if (p === "week") return "·7d";
if (p === "month") return "·30d";
if (p === "nhour") return "·" + Math.max(1, h) + "h";
return "";
}
function quantBadge(quota, period, hours) {
quota = +quota || 0;
period = period || "";
if (!quota && !period) return "∞";
return fmtQuota(quota) + periodText(period, hours);
}
function readScopes(canvas) {
return [...canvas.querySelectorAll(".mb")].map((b) => {
const comb = b.dataset.model.split("|");
const src = comb.length > 1 ? normSrc(comb[1]) : "";
return {
model: comb[0],
source: src || undefined,
token_quota: parseInt(b.dataset.quota) || 0,
period: b.dataset.period || "",
hours: parseInt(b.dataset.hours) || 0,
};
});
}
async function putScope(key, scopes) {
await api("/api/keys/" + encodeURIComponent(key), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ models: scopes }),
});
}
async function scopePush(key) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const scopes = readScopes(canvas);
let added;
if (!scopes.some((s) => s.model === "AUTO")) {
added = { model: "AUTO", token_quota: 0 };
scopes.push(added);
} else {
const pick = (allModels || []).find(
(m) =>
m.id &&
m.id !== "AUTO" &&
!scopes.some(
(s) => s.model === m.id && normSrc(s.source) === m.src,
),
);
added = pick
? { model: pick.id, source: pick.src, token_quota: 0 }
: { model: "AUTO", token_quota: 0 };
scopes.push(added);
}
try {
await putScope(key, scopes);
} catch (e) {
toast(e.message);
return;
}
await loadKeys();
scopeEdit(key, scopeComb(added));
}
async function scopeDup(key, comb) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const scopes = readScopes(canvas);
const src = scopes.find((s) => scopeComb(s) === comb);
if (!src) return;
scopes.push({
model: src.model,
source: src.source,
token_quota: src.token_quota,
});
try {
await putScope(key, scopes);
toast(t("kDupOK"));
} catch (e) {
toast(e.message);
}
}
async function scopeRm(key, comb) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const scopes = readScopes(canvas).filter((s) => scopeComb(s) !== comb);
try {
await putScope(key, scopes);
} catch (e) {
toast(e.message);
return;
}
await loadKeys();
toast(t("toastDelOk"));
}
function scopeEdit(key, comb) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const sc = readScopes(canvas).find((s) => scopeComb(s) === comb) || {
model: "",
source: "",
token_quota: 0,
period: "",
hours: 0,
};
const curM = sc.model;
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
const opts = [{ key: "AUTO", label: "AUTO" }].concat(
allModels.map((m) =>
typeof m === "string"
? { key: m, label: m }
: { key: m.key, label: m.label },
),
);
if (curM && !opts.some((o) => o.key === comb))
opts.unshift({ key: comb, label: comb });
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("kFormTitle")}</h2>
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
<select id="sc-model">${opts.map((o) => `<option value="${escAttr(o.key)}" ${o.key === comb ? "selected" : ""}>${esc(o.label)}</option>`).join("")}</select>
<label>${t("kQuotaB")} <span class="muted">${t("kQuotaHintB")}</span></label>
<input id="sc-quota" type="number" min="0" step="1"
placeholder="${escAttr(t("kQuotaHintB"))}" value="${sc.token_quota ? sc.token_quota : ""}">
<label>${t("kPeriodB")}</label>
<select id="sc-period">
<option value="" ${!sc.period ? "selected" : ""}>${t("kPerNothing")}</option>
<option value="hour" ${sc.period === "hour" ? "selected" : ""}>${t("kPerHour")}</option>
<option value="week" ${sc.period === "week" ? "selected" : ""}>${t("kPerWeek")}</option>
<option value="month" ${sc.period === "month" ? "selected" : ""}>${t("kPerMonth")}</option>
<option value="nhour" ${sc.period === "nhour" ? "selected" : ""}>${t("kPerHours")}</option>
</select>
<div id="sc-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="sc-hours" type="number" min="1" step="1" value="${sc.hours || 24}"></div>
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(comb)}', this)">${t("kSaveScope")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#sc-period").addEventListener("change", () => {
$("#sc-hours").closest("#sc-hours-box").style.display =
$("#sc-period").value === "nhour" ? "block" : "none";
});
$("#sc-model").focus();
}
async function scopeSave(key, oldComb, btn) {
const canvas = document.querySelector(
`.key-canvas[data-key="${CSS.escape(key)}"]`,
);
if (!canvas) return;
const comb = $("#sc-model").value.trim();
if (!comb) {
toast(t("kName"));
return;
}
const parts = splitCombKey(comb);
let q = parseInt($("#sc-quota").value);
if (isNaN(q) || q < 0) q = 0;
let hours = parseInt($("#sc-hours").value);
if (isNaN(hours) || hours < 1) hours = 1;
const period = $("#sc-period").value;
const scopes = readScopes(canvas);
const entry = {
model: parts.model,
source: parts.source || undefined,
token_quota: q,
period,
hours,
};
const i = scopes.findIndex((s) => scopeComb(s) === oldComb);
if (i < 0) scopes.push(entry);
else scopes[i] = entry;
if (btn) btn.disabled = true;
try {
await putScope(key, scopes);
const w = $("#modal-wrap");
if (w) w.remove();
toast(t("kSaved"));
await loadKeys();
} catch (e) {
toast(e.message);
if (btn) btn.disabled = false;
}
}
function scopeCtx(e, key, model) {
e.preventDefault();
e.stopPropagation();
scopeDragEl = null;
showCtx(e.clientX, e.clientY, [
{
label: t("kEditB"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
fn: () => scopeEdit(key, model),
},
{
label: t("kDelB"),
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
danger: true,
fn: () => scopeRm(key, model),
},
]);
}
let ctxEl = null;
function showCtx(x, y, items) {
hideCtx();
const w = document.createElement("div");
w.id = "ctx-wrap";
w.innerHTML = items
.map(
(it, i) =>
`<div class="ctx-itm ${it.danger ? "danger" : ""}" data-i="${i}"><span class="ic">${esc(it.icon)}</span>${esc(it.label)}</div>`,
)
.join("");
w.addEventListener("click", (e) => {
const t = e.target.closest(".ctx-itm");
if (!t) return;
const it = items[+t.dataset.i];
hideCtx();
if (it) it.fn();
});
document.body.appendChild(w);
ctxEl = w;
const r = w.getBoundingClientRect();
w.style.left =
Math.max(6, Math.min(x, window.innerWidth - r.width - 6)) + "px";
w.style.top =
Math.max(6, Math.min(y, window.innerHeight - r.height - 6)) + "px";
}
function hideCtx() {
if (ctxEl) {
ctxEl.remove();
ctxEl = null;
}
}
// Close the ctx menu on any primary click/press OUTSIDE the menu. Both
// listeners run in the CAPTURE phase, so they fire even when the clicked
// element stops propagation (priority blocks and key bricks call
// stopPropagation in their own click handlers, which would otherwise keep the
// menu open forever). Presses INSIDE the menu are left alone: the menu's own
// click handler closes it after running the item action.
document.addEventListener(
"click",
(e) => {
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
},
true,
);
document.addEventListener(
"mousedown",
(e) => {
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
},
true,
);
/* cross-canvas brick dragging */
function bindBrickDrag(b) {
b.addEventListener("dragstart", (e) => {
scopeDragEl = { key: b.dataset.key, model: b.dataset.model };
e.dataTransfer.setData("text/plain", b.dataset.model);
e.dataTransfer.effectAllowed = "move";
const g = b.cloneNode(true);
g.classList.add("mb-ghost");
document.body.appendChild(g);
b.addEventListener(
"dragend",
() => {
g.remove();
scopeDragEl = null;
},
{ once: true },
);
});
b.addEventListener("dragover", (e) => e.preventDefault());
}
function bindCanvasDrop(cv) {
cv.addEventListener("dragover", (e) => {
e.preventDefault();
cv.classList.add("ovh");
});
cv.addEventListener("dragleave", () => cv.classList.remove("ovh"));
cv.addEventListener("drop", (e) => {
e.preventDefault();
cv.classList.remove("ovh");
if (!scopeDragEl) return;
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
});
}
async function moveBrick(from, to, comb) {
if (from === to) return;
const sf = document.querySelector(
`.key-canvas[data-key="${CSS.escape(from)}"]`,
);
const st = document.querySelector(
`.key-canvas[data-key="${CSS.escape(to)}"]`,
);
if (!sf || !st) return;
let fs = readScopes(sf),
ts = readScopes(st);
const b = fs.find((s) => scopeComb(s) === comb);
if (!b) return;
fs = fs.filter((s) => scopeComb(s) !== comb);
const dup = ts.some((s) => scopeComb(s) === comb);
if (!dup) ts.push(b);
try {
await putScope(from, fs);
await putScope(to, ts);
toast(t("kMovOK"));
} catch (e) {
toast(e.message);
loadKeys();
}
}
function openKeyModal() {
const wrap = document.createElement("div");
wrap.id = "modal-wrap";
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50";
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("kCreate")}</h2>
<label>${t("kName")}</label>
<input id="kc-name" placeholder="alice">
<label>${t("kRole")}</label>
<select id="kc-role">
<option value="user">${t("kRoleUser")}</option>
<option value="admin">${t("kRoleAdmin")}</option>
</select>
<label>${t("kNote")}</label>
<input id="kc-note">
<p><button onclick="createKey(this)">${t("kCreateBtn")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
$("#kc-name").focus();
}
async function createKey(btn) {
const name = $("#kc-name").value.trim();
if (!name) {
toast(t("kName"));
return;
}
if (btn) btn.disabled = true;
let j;
try {
j = await api("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
role: $("#kc-role").value,
note: $("#kc-note").value.trim(),
}),
});
} catch (e) {
toast(e.message);
if (btn) btn.disabled = false;
return;
}
const w = $("#modal-wrap");
if (w) w.remove();
$("#k-newbox").innerHTML = `
<div class="key-canvas" style="background:var(--card2)">
<div class="kc-head">
<b>${t("kNewKey")}</b>
<span class="kr-key" style="max-width:none">${esc(j.key.key)}</span>
<button class="small" onclick="copyText('${escAttr(j.key.key)}')">${t("kCopy")}</button>
<span class="grow"></span>
<button class="ghost small" onclick="$('#k-newbox').innerHTML=''">×</button>
</div></div>`;
toast(t("kNewOK"));
loadKeys();
}
async function delKey(key, name) {
if (!confirm(tFmt("kDelConfirm", name || key))) return;
try {
await api("/api/keys/" + encodeURIComponent(key), {
method: "DELETE",
});
toast(t("toastDelOk"));
loadKeys();
} catch (e) {
toast(e.message);
}
}
/* ---------- boot ---------- */
function refresh(tab) {
if (tab === "status") return renderStatus();
if (tab === "chat") return renderChat();
if (tab === "keys") return renderKeys();
if (tab === "sort") return renderSort();
if (tab === "sources") return renderSources();
return renderAdapters();
}
(async () => {
try {
const me = await api("/api/keys/me");
window._me = me.key;
maybeWarnSeed(me.key);
if (me.key.role !== "admin") {
["sort", "sources", "adapters"].forEach((tn) => {
const b = document.querySelector(
`nav button.sb-i[data-tab="${tn}"]`,
);
if (b) b.style.display = "none";
});
}
} catch (e) {}
})();
// page unload / navigation away: release the record view immediately
window.addEventListener("pagehide", () => releaseRecords(false));
window.addEventListener("beforeunload", () => releaseRecords(false));
refresh("status");
</script>
</body>
</html>