mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
Live testing across the zen pool showed models report prompt_tokens_details.cached_tokens even when the hit count is 0 (e.g. nemotron-3-ultra-free returns cached_tokens:0, audio_tokens:0, cache_write_tokens:0). The previous >0 guard dropped those objects, so a cache-enabled upstream looked identical to one without cache support. - types: PromptTokensDetails.CachedTokens always emitted (drop inner omitempty) so clients see cached_tokens:0 explicitly; dsh reads it as a 0% hit instead of 'no data' - adapters (9): forward prompt_tokens_details whenever the upstream provides it (presence check instead of >0) - Req: add cache_reported flag set when usage carried cache accounting; WebUI shows an amber 0% tag for reported-but-missed rows and keeps the em-dash only for sources that never report cache data
3973 lines
180 KiB
HTML
3973 lines
180 KiB
HTML
<!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}
|
||
.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-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) ---- */
|
||
.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-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: "版本",
|
||
cModel: "模型",
|
||
cMsg: "Enter 发送,Shift+Enter 换行",
|
||
send: "发送",
|
||
cClear: "清空",
|
||
clear: "清空",
|
||
cThinking: "推理",
|
||
cPick: "选择图片",
|
||
cRemoveImg: "移除",
|
||
cWelcome: "开始对话——选择模型,输入消息即可测试上游连接",
|
||
cStream: "调用 /v1/chat/completions 流式 SSE",
|
||
srcEmpty: "还没有配置任何源",
|
||
srcAdd: "+ 新增源",
|
||
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: "冷却",
|
||
sortFail: "失败",
|
||
sortHealthTip: "冷却 / 失败次数 / 偏好分 实时状态",
|
||
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: "密钥筛选",
|
||
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",
|
||
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",
|
||
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",
|
||
sortFail: "fail",
|
||
sortHealthTip: "live cooldown / failures / preference score",
|
||
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",
|
||
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) {
|
||
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) =>
|
||
({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[c],
|
||
);
|
||
}
|
||
function escAttr(s) {
|
||
return esc(s).replace(/"/g, """);
|
||
}
|
||
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)
|
||
|
||
// ---- 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="openExportModal()">${t("exportCsv")}</button></h2>
|
||
<div class="filter-line">
|
||
<span class="muted">${t("recFilter")}</span>
|
||
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
||
</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;
|
||
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 {
|
||
const q =
|
||
"/api/stats?limit=2000" +
|
||
(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 || {});
|
||
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>";
|
||
}
|
||
function paintRecords(records, keyNames) {
|
||
const el = $("#tb-recs");
|
||
if (!el) return;
|
||
if (!records.length) {
|
||
el.innerHTML = `<div class="muted">${t("noUsage")}</div>`;
|
||
return;
|
||
}
|
||
const slice = records.slice().reverse().slice(0, 300);
|
||
el.innerHTML =
|
||
`<table><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>` +
|
||
slice
|
||
.map(
|
||
(r) => `<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>`,
|
||
)
|
||
.join("") +
|
||
"</table>";
|
||
}
|
||
|
||
// 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"> </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", {
|
||
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})">×</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, """)})">${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="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>${esc(name ? t("modalEdit") + ": " + name : t("modalNew"))}</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()">×</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", {
|
||
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;
|
||
}
|
||
}
|
||
async function delSource(name) {
|
||
if (!confirm(tFmt("confirmDelSrc", name))) return;
|
||
await api("/api/sources/" + encodeURIComponent(name), {
|
||
});
|
||
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 };
|
||
let sortStateMap = new Map();
|
||
async function renderSort() {
|
||
const j = await api("/api/sources");
|
||
let autoR = [];
|
||
try {
|
||
const a = await api("/api/auto");
|
||
autoR = a.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) => {
|
||
if (m.kind === "image") return;
|
||
const it = { src: s.name, id: m.id, prio: m.priority || 0 };
|
||
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 });
|
||
}),
|
||
);
|
||
if (autoR.length) {
|
||
const tiers = [];
|
||
autoR.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,
|
||
},
|
||
});
|
||
});
|
||
sortState.lanes = tiers
|
||
.filter(Boolean)
|
||
.map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||
} else {
|
||
sortState.lanes = [...sourceRows.entries()]
|
||
.sort((a, b) => b[0] - a[0])
|
||
.map(([prio, models]) => {
|
||
models.sort((x, y) =>
|
||
x.src < y.src ? -1 : x.src > y.src ? 1 : 0,
|
||
);
|
||
models.forEach((m) => {
|
||
m.uid = nexUid();
|
||
m.meta = null;
|
||
});
|
||
return { prio, models };
|
||
});
|
||
}
|
||
// 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);
|
||
$("#tab-sort").innerHTML = `
|
||
<div class="card"><h2><span>${t("sortTitle")}</span><span class="grow"></span>
|
||
<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 healthTag(it) {
|
||
const st = sortStateMap.get(it.id + "|" + (it.src || "*"));
|
||
if (!st) return "";
|
||
const bits = [];
|
||
if (st.cooling)
|
||
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(t("sortHealthTip"))}">${bits.join("")}</span>`;
|
||
}
|
||
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,
|
||
});
|
||
}),
|
||
);
|
||
await api("/api/auto", {
|
||
body: JSON.stringify({ rules }),
|
||
});
|
||
}
|
||
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";
|
||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t("sortAdd")}</h2>
|
||
<label>${t("kModelB")} <span class="muted">${t("kSrcHint")}</span></label>
|
||
<select id="a-model">${allModels
|
||
.map((m) =>
|
||
typeof m === "string"
|
||
? `<option value="${escAttr(m)}">${esc(m)}</option>`
|
||
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`,
|
||
)
|
||
.join("")}</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 rows = j.adapters
|
||
.map(
|
||
(
|
||
a,
|
||
) => `<tr><td><b>${esc(a.name)}</b></td><td>${esc(a.version || "")}</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></th></tr>
|
||
${rows || `<tr><td colspan="3" class="empty">${t("adEmpty")}</td></tr>`}</table></div>
|
||
</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();
|
||
}
|
||
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", {
|
||
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), {
|
||
});
|
||
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) => {
|
||
if (m.kind === "image") return;
|
||
pairs.push({
|
||
src: s.name,
|
||
id: m.id,
|
||
label: m.id + " · " + s.name,
|
||
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), {
|
||
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", {
|
||
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), {
|
||
});
|
||
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) {}
|
||
})();
|
||
refresh("status");
|
||
</script>
|
||
</body>
|
||
</html>
|