Files
ModelRouter/internal/gateway/ui/index.html
root 12c61317fb feat(ui): loading skeleton for the KPI row so first paint isn't a sudden blank->cards pop
The five KPI cards are built only after /api/stats returns; before that #kpi-row was empty, so the top of the dashboard appeared blank then jumped in. Now the row starts with a shimmer skeleton (5 placeholder cards with a CSS gradient animation) that paintStats replaces with the real cards on first data. Deployed (bak .bak.20260811u), server active.
2026-08-11 17:50:23 +08:00

2389 lines
133 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ModelRouter</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 34 34'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233f6ef5'/%3E%3Cstop offset='1' stop-color='%236a8ffb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='34' height='34' rx='10' fill='url(%23g)'/%3E%3Ctext x='17' y='24' text-anchor='middle' font-family='Arial,sans-serif' font-size='17' font-weight='700' fill='%23fff'%3EM%3C/text%3E%3C/svg%3E">
<style>
:root{
--bg:#f4f6fb; --card:#ffffff; --card2:#f9fafd; --line:#e3e6f0; --fg:#1d2434; --muted:#66708a;
--accent:#3f6ef5; --accent-h:#2f5ae0; --ok:#17a964; --warn:#b7791f; --err:#d64550;
--tag-ok:#e6f6ee; --tag-err:#fdecec; --tag-blue:#e8effc; --tag-warn:#fdf3e2;
--shadow:0 8px 30px rgba(28,44,94,.08);
}
html[data-theme="dark"]{
--bg:#0e121d; --card:#161b2b; --card2:#111522; --line:#242b40; --fg:#e7eaf3; --muted:#8c94ac;
--accent:#4f7cff; --accent-h:#6a8ffb; --ok:#4f7cff;
--tag-ok:#12301f; --tag-err:#3d1a1c; --tag-blue:#182744; --tag-warn:#3b2f14;
--shadow:0 10px 34px rgba(0,0,0,.4);
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
transition:background .2s,color .2s; }
a { color:var(--accent); }
header { display:flex; align-items:center; gap:14px; padding:14px 24px; border-bottom:1px solid var(--line);
background:var(--card); position:sticky; top:0; z-index:20; }
.brand { display:flex; align-items:center; gap:12px; }
.logo { width:34px; height:34px; border-radius:10px; flex:0 0 auto;
background:linear-gradient(135deg,var(--accent),#6a8ffb); color:#fff; font-weight:700; font-size:17px;
display:flex; align-items:center; justify-content:center; box-shadow:0 4px 12px rgba(63,110,245,.35); }
.brand h1 { font-size:17px; margin:0; letter-spacing:.2px; }
.brand .sub { font-size:12px; color:var(--muted); margin-top:1px; }
.spacer { flex:1; }
.hd-actions { display:flex; align-items:center; gap:8px; }
.hd-actions button { background:var(--card2); border:1px solid var(--line); color:var(--muted);
padding:6px 12px; border-radius:8px; cursor:pointer; font:inherit; transition:border .15s,color .15s; }
.hd-actions button:hover { border-color:var(--accent); color:var(--accent); }
nav { display:flex; gap:6px; padding:0; }
nav button { background:transparent; border:1px solid transparent; color:var(--muted); padding:7px 16px;
cursor:pointer; border-radius:9px; font:inherit; font-weight:500; transition:all .15s; }
.ni { font-style:normal; }
nav button .ni { display:none; }
#btn-logout .ni { display:none; }
nav button:hover { color:var(--fg); background:var(--card2); }
nav button.active { background:var(--card2); border-color:var(--line); color:var(--accent); box-shadow:var(--shadow); }
main { padding:20px 24px 48px; max-width:1240px; margin:0 auto; }
.card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:20px; margin-bottom:18px;
box-shadow:var(--shadow); transition:background .2s; }
.card h2 { font-size:14px; margin:0 0 14px; color:var(--muted); font-weight:600;
display:flex; align-items:center; gap:8px; }
.card h2 .grow { flex:1; }
table { width:100%; border-collapse:collapse; }
th,td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--line); font-size:13px; }
th { color:var(--muted); font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:.4px; }
tr:last-child td { border-bottom:0; }
.tag { display:inline-block; padding:2px 9px; border-radius:20px; font-size:12px; margin:2px 3px 2px 0; font-weight:500; }
.src-models { display:grid; grid-template-columns:repeat(4,auto); justify-content:start; gap:2px 6px; min-width:0; }
.src-models .tag { margin:0; }
.tag-green { background:var(--tag-ok); color:var(--ok); }
.tag-red { background:var(--tag-err); color:var(--err); }
.tag-blue { background:var(--tag-blue); color:var(--accent); }
.tag-warn { background:var(--tag-warn); color:var(--warn); }
.net-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:currentColor; margin-right:6px; vertical-align:1px; }
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
font:inherit; font-weight:500; transition:background .15s; }
button:hover { background:var(--accent-h); }
button.ghost { background:transparent; border:1px solid var(--line); color:var(--muted); }
button.ghost:hover { border-color:var(--accent); color:var(--accent); background:transparent; }
button.danger { background:transparent; border:1px solid var(--err); color:var(--err); }
button.danger:hover { background:var(--err); color:#fff; }
button.small { padding:5px 12px; font-size:12px; border-radius:7px; }
input,select,textarea { width:100%; background:var(--card2); border:1px solid var(--line); color:var(--fg);
border-radius:8px; padding:9px 12px; font:inherit; margin-bottom:10px; outline:none;
transition:border .15s,box-shadow .15s; }
input:focus,select:focus,textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(63,110,245,.15); }
textarea { min-height:200px; resize:vertical; font-family: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:8px; align-items:center; }
.model-row input, .model-row select { margin:0 0 8px; }
.model-row .del { flex:0 0 auto; padding:5px 9px; }
.muted { color:var(--muted); }
.hidden { display:none; }
.hidden#tab-chat { display:none; }
#toast { position:fixed; bottom:24px; right:24px; background:var(--card); border:1px solid var(--line);
padding:11px 18px; border-radius:10px; box-shadow:var(--shadow); display:none; animation:fadein .2s; z-index:100; }
@keyframes fadein { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
pre.configbox { background:var(--card2); border:1px solid var(--line); border-radius:10px; padding:14px; margin:10px 0;
overflow:auto; white-space:pre-wrap; word-break:break-all; font-size:12.5px; font-family:ui-monospace,Menlo,Consolas,monospace; }
.chatlog { height:280px; overflow:auto; background:var(--card2); border:1px solid var(--line);
border-radius:10px; padding:14px; margin-bottom:10px; font-size:13.5px; }
.chatlog .u { color:var(--accent); font-weight:500; }
.chatlog .a { color:var(--ok); }
.chatlog .meta { color:var(--muted); font-size:12px; margin-top:4px; }
.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%; }
.modelchip { cursor:pointer; color:var(--accent); text-decoration:none; }
/* ---------- dashboard / usage metrics ---------- */
.kpi-skeletons { grid-column:1 / -1; display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; }
.ksk { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; min-height:150px; display:flex; flex-direction:column; gap:12px; }
.ksk i { display:block; height:12px; 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:40%; } .ksk i:nth-child(2){ width:70%; height:22px; } .ksk i:nth-child(3){ width:100%; height:64px; }
@keyframes shimmer { 0%{background-position:100% 0} 100%{background-position:0 0} }
.kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
.kpi { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px 16px; box-shadow:var(--shadow); }
.kpi .k-hdr { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:6px; }
.kpi .k-lab { font-size:12px; color:var(--muted); font-weight:600; white-space:nowrap; }
.kpi .k-val { font-size:26px; font-weight:800; margin-top:4px; font-variant-numeric:tabular-nums; letter-spacing:.3px; }
.kpi .k-val .u { font-size:13px; font-weight:600; color:var(--muted); margin-left:3px; }
.kpi .k-sub { font-size:11.5px; color:var(--muted); margin-top:4px; }
.kpi .k-sub.k2 { display:flex; flex-direction:column; align-items:flex-start; line-height:1.5; }
.kpi .k-chart { margin-top:8px; position:relative; height:86px; }
.kpi canvas { width:100%; height:86px; display:block; }
.kpi.kpi-status .k-chart-pie { height:110px; }
.kpi.kpi-status canvas { height:110px; }
.status-legend { display:flex; flex-wrap:wrap; gap:5px 9px; margin-top:6px; justify-content:center; }
.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:2px; flex:0 0 auto; }
.status-legend .lg b { color:var(--fg); font-variant-numeric:tabular-nums; }
.kpi.kpi-exit { cursor:pointer; border-color:var(--err); }
.kpi.kpi-exit:hover { border-color:var(--err); box-shadow:0 0 0 3px rgba(244,67,54,.15); }
.kpi.kpi-exit .k-lab::after { content:'×'; color:var(--err); font-weight:800; font-size:15px; margin-left:auto; }
.kpi.kpi-status { display:flex; flex-direction:column; }
.kpi.kpi-status .k-val { font-size:22px; }
.dot { width:7px; height:7px; border-radius:50%; background:var(--ok); display:inline-block; animation:blink 1.6s infinite; }
.dot.err { background:var(--err); }
@keyframes blink { 50% { opacity:.25; } }
.dash-row { display:flex; gap:12px; margin-bottom:18px; }
.dash-row .card { flex:1; min-width:0; display:flex; flex-direction:column; max-height:440px; }
.dash-row .card h2 { flex:0 0 auto; }
.dash-row .card > div { flex:1 1 auto; min-height:0; overflow:auto; -webkit-overflow-scrolling:touch; }
.dash-row .card > div table thead th { position:sticky; top:0; background:var(--card); z-index:1; }
td.num, th.num { text-align:right; font-variant-numeric:tabular-nums; }
td .okc { color:var(--ok); font-weight:600; }
td .errc { color:var(--err); font-weight:600; }
.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:var(--accent); border-radius:3px; }
.bar.ok i { background:var(--ok); }
.bar.err i { background:var(--err); }
.donut-wrap { display:flex; flex-direction:column; align-items:center; gap:10px; padding:4px 0; }
.donut { position:relative; width:108px; height:108px; flex:0 0 auto; }
.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); }
.status-chips { display:flex; flex-wrap:wrap; gap:5px; justify-content:center; }
.status-chips .sc { display:inline-flex; align-items:center; gap:4px; font-size:11px; padding:2px 7px; border-radius:99px;
background:var(--card2); border:1px solid var(--line); cursor:help; white-space:nowrap; }
.status-chips .sc i { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
.status-chips .sc b { font-variant-numeric:tabular-nums; }
table th { white-space:nowrap; }
.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%; }
/* ---------- keys tab ---------- */
.keys-grid { display:flex; flex-direction:column; gap:12px; }
.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:ui-monospace,Menlo,Consolas,monospace; font-size:12px; color:var(--muted);
overflow:hidden; text-overflow:ellipsis; max-width:340px; white-space:nowrap; }
.key-row .grow { flex:1; }
.tag-role { font-size:11px; padding:2px 8px; border-radius:99px; font-weight:600; }
.tag-role.admin { background:#3f6ef5; color:#fff; }
.tag-role.user { background:var(--card2); color:var(--muted); border:1px solid var(--line); }
.scope-box { border-top:1px dashed var(--line); margin-top:8px; padding-top:10px; }
.scope-chips { display:flex; flex-wrap:wrap; gap:8px; align-items:center; min-height:40px; }
.scope-chip { display:inline-flex; align-items:center; gap:6px; padding:5px 10px; border-radius:99px;
background:linear-gradient(135deg,#3f6ef5,#6a8ffb); 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-add { padding:5px 10px; border-radius:99px; border:1px dashed var(--line); background:transparent;
color:var(--muted); font-size:12.5px; cursor:pointer; }
.scope-unlim { color:var(--accent); 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 model bricks per key */
.key-actions { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
.key-canvas { border:1px solid var(--line); border-radius:14px; padding:14px; margin-bottom:14px; background:var(--card); }
.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(--accent); outline-offset:2px; }
.mb { display:inline-flex; align-items:center; gap:8px; padding:9px 13px; border-radius:12px;
background:linear-gradient(135deg,#41a3ff,#3f6ef5); color:#fff; font-size:13px; font-weight:700;
cursor:grab; user-select:none; box-shadow:0 3px 10px rgba(63,110,245,.30); }
.mb:active { cursor:grabbing; }
.mb .mb-ico { font-size:11px; opacity:.85; }
.mb .mb-name { font-family:ui-monospace,Menlo,Consolas,monospace; white-space:nowrap; }
.mb .mb-quota { font-size:11px; font-weight:600; background:rgba(255,255,255,.18); padding:2px 8px;
border-radius:99px; white-space:nowrap; }
.mb .copy-b { cursor:pointer; opacity:.8; 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,.85); background:rgba(0,0,0,.18); user-select:none; }
.mb .mb-x:hover { background:rgba(220,53,69,.85); color:#fff; }
.mb-ghost { position:fixed; pointer-events:none; opacity:.85; 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(--accent); background:transparent; color:var(--accent); font-weight:700;
font-size:12.5px; cursor:pointer; font-family:inherit; }
.add-brick:hover { background:rgba(63,110,245,.08); }
#ctx-wrap { position:fixed; z-index:70; background:var(--card); border:1px solid var(--line); border-radius:10px;
box-shadow:0 10px 30px rgba(15,22,44,.18); padding:5px; min-width:130px; }
.ctx-itm { display:flex; align-items:center; gap:8px; padding:8px 12px; border-radius:7px; cursor:pointer;
font-size:13px; font-weight:600; }
.ctx-itm:hover { background:var(--card2); }
.ctx-itm.danger { color:var(--err); }
.ctx-itm .ic { width:15px; text-align:center; }
.filter-line { display:flex; align-items:center; gap:8px; margin-bottom:10px; }
.filter-line select { width:auto; margin:0; padding:6px 10px; }
/* ---------- chat page (clean) ---------- */
.tab-chat { max-width:840px; margin:0 auto; display:flex; flex-direction:column; height:calc(100vh - 178px); }
.chat-wrap { background:var(--card); border:1px solid var(--line); border-radius:12px; box-shadow:var(--shadow);
display:flex; flex-direction:column; flex:1; min-height:0; overflow:hidden; }
.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:28px; height:28px; border-radius:8px; flex:0 0 auto; display:flex; align-items:center; justify-content:center;
font-size:12px; font-weight:600; color:#fff; letter-spacing:.3px; }
.avatar.u { background:var(--accent); }
.avatar.a { background:var(--ok); }
.bubble { max-width:84%; padding:10px 14px; border-radius:12px; position:relative; word-break:break-word; font-size:13.5px; line-height:1.6; }
.msg.usr .bubble { background:var(--accent); color:#fff; border-top-right-radius:3px; }
.msg.ass .bubble { background:var(--card2); border:1px solid var(--line); border-top-left-radius:3px; }
.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(--bg); border:1px solid var(--line); border-radius:4px; padding:1px 5px; font-size:11.5px;
font-family:ui-monospace,Menlo,Consolas,monospace; }
.msg.usr .bubble .bmd code { background:rgba(0,0,0,.22); border-color:transparent; }
.bubble .bmd pre { background:var(--bg); border:1px solid var(--line); border-radius:8px; 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(--accent); 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(--line); 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; text-transform:none; }
.cursor-dot { display:inline-block; width:2px; height:14px; background:var(--fg); margin-left:3px; vertical-align:-2px;
animation:blink 1s steps(2) infinite; }
@keyframes blink { 50% { opacity:0 } }
.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:7px; }
.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(--accent); }
.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:10px;
font:inherit; line-height:1.5; }
.chat-box .sendbtn { width:auto; padding:10px 20px; border-radius:10px; font-weight:600; white-space:nowrap; }
.chat-box .sendbtn:disabled { opacity:.5; cursor:default; }
.attach-btn { background:transparent; border:1px solid var(--line); color:var(--muted); border-radius:10px;
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(--accent); color:var(--accent); }
.chat-empty { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--muted); gap:6px; }
.chat-empty .ring { width:42px; height:42px; margin-bottom:6px; border-radius:50%;
border:1px solid var(--line); display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:17px; opacity:.8; }
.chat-empty p { margin:0; }
.modelchip:hover { text-decoration:underline; }
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
.dropzone { border:2px dashed var(--line); border-radius:10px; padding:26px; text-align:center; color:var(--muted);
cursor:pointer; transition:all .15s; background:var(--card2); }
.dropzone.dragover, .dropzone:hover { border-color:var(--accent); color:var(--accent); background:#eef2ff; }
html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:hover { background:#1a2138; }
.dropzone b { font-size:14px; display:block; margin-bottom:4px; }
.empty { color:var(--muted); text-align:center; padding:24px 0; }
#modal-wrap { 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; }
.twrap { overflow-x:auto; -webkit-overflow-scrolling:touch; }
/* ---------- scratch-style priority canvas (no numbers, tier lanes) ---------- */
.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) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);
background-size:28px 28px;
border:1px dashed var(--line); border-radius:14px; overflow-x:auto; }
.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(--accent);
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 .15s; }
.scr-row.hover { background:rgba(63,110,245,.14); }
.scr-blocks { display:flex; flex-wrap:wrap; align-items:center; min-width:0; }
/* 块间嵌合:每个块的凸榫插进左侧块的凹槽(平铺同档模型,如积木搭肩) */
.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,.13),
inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; }
/* 左侧凸榫宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */
.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,.2); }
.scr-block .scr-name { white-space:nowrap; font-family:ui-monospace,Menlo,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:.75; font-weight:600; max-width:200px; overflow:hidden; text-overflow:ellipsis; }
.scr-block .scr-tag { flex:0 0 auto; font-family:ui-monospace,Menlo,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:ui-monospace,Menlo,Consolas,monospace; font-size:10px; font-weight:700; cursor:help; }
.scr-htag .ht-cool { padding:2px 6px; border-radius:9px; background:rgba(255,80,80,.28); color:#ffd9d9;
border:1px solid rgba(255,120,120,.5); }
.scr-htag .ht-fail { padding:2px 6px; border-radius:9px; background:rgba(255,160,60,.2); color:#ffd9a8;
border:1px solid rgba(255,180,90,.42); }
.scr-htag .ht-pref { padding:2px 6px; border-radius:9px; background:rgba(120,180,255,.18); color:#cfe3ff;
border:1px solid rgba(150,190,255,.38); }
.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,.18);
box-shadow:inset 0 1px 2px rgba(0,0,0,.18); transition:background .12s; touch-action:none; }
.scr-block .scr-grip:hover { background:rgba(255,255,255,.32); }
.scr-block .scr-grip i { width:12px; height:2px; border-radius:1px; background:rgba(255,255,255,.9);
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,.35)); 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(--accent); background:rgba(63,110,245,.10);
box-shadow:0 0 0 3px rgba(63,110,245,.12); color:var(--accent); }
.scr-gap.hover::after { content:''; font-size:14px; font-weight:800; }
.scr-gap.drophere { border-color:var(--accent); background:var(--accent); 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(--accent);
box-shadow:0 0 8px var(--accent); 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; }
/* ---------- responsive / narrow screens ---------- */
@media (max-width: 900px) {
header { padding:12px 16px; }
nav { overflow-x:auto; max-width:1240px; margin:0 auto; }
nav button { padding:7px 12px; white-space:nowrap; }
main { padding:16px 16px 40px; }
.card { padding:16px; }
}
@media (max-width: 640px) {
header { gap:8px; padding:10px 12px; }
.brand h1 { display:none; }
.brand .sub { display:none; }
.hd-actions button { padding:5px 9px; }
nav { gap:4px; padding:10px 12px 0; flex:1 1 auto; min-width:0; }
nav button { padding:6px 10px; font-size:13px; }
nav button .ni { display:inline-block; }
nav button span { display:none; }
#btn-logout .ni { display:none; }
#btn-lang span, #btn-logout span { display:none; }
#btn-logout .ni { display:inline-block; }
main { padding:12px 12px 32px; }
.card { padding:13px; border-radius:12px; margin-bottom:14px; }
.card h2 { font-size:13px; }
th,td { padding:8px 10px; }
.row { flex-direction:column; gap:0; }
.dash-row { flex-direction:column; }
.model-row { flex-wrap:wrap; }
.model-row input { flex:1 1 120px; }
.model-row select { flex:0 0 auto; }
.tab-chat { height:calc(100vh - 150px); }
.msg { gap:7px; }
.avatar { width:24px; height:24px; font-size:11px; }
.bubble { max-width:90%; padding:8px 11px; font-size:13px; }
.chat-log { padding:14px 12px 6px; gap:14px; }
.chat-tools { flex-wrap:wrap; gap:8px; }
.chat-tools .tl { display:none; }
.chat-tools select { flex:1 1 auto; min-width:0; }
.chat-box { gap:7px; }
.chat-box .sendbtn { padding:10px 13px; }
.attach-btn { width:38px; height:38px; }
.chat-box textarea { font-size:13.5px; }
.chat-composer { padding:8px 9px 10px; }
#modal-wrap { padding:14px 10px; }
.dropzone { padding:18px 14px; }
#toast { left:12px; right:12px; bottom:12px; text-align:center; }
pre.configbox { font-size:11.5px; padding:11px; }
.att img { height:52px; max-width:90px; }
}
</style>
</head>
<body>
<header>
<div class="brand">
<div class="logo">M</div>
<div><h1>ModelRouter</h1><div class="sub" data-i="tagline">统一 LLM 网关</div></div>
</div>
<nav>
<button data-tab="status" class="active"><i class="ni" aria-hidden="true"></i><span data-i="navStatus">状态</span></button>
<button data-tab="chat"><i class="ni" aria-hidden="true"></i><span data-i="navChat">对话</span></button>
<button data-tab="keys"><i class="ni" aria-hidden="true"></i><span data-i="navKeys">密钥</span></button>
<button data-tab="sort"><i class="ni" aria-hidden="true"></i><span data-i="navSort">优先级</span></button>
<button data-tab="sources"><i class="ni" aria-hidden="true"></i><span data-i="navSources"></span></button>
<button data-tab="adapters"><i class="ni" aria-hidden="true"></i><span data-i="navAdapters">适配器</span></button>
</nav>
<div class="spacer"></div>
<div class="hd-actions">
<button id="btn-theme" title="theme"></button>
<button id="btn-lang">EN</button>
<button class="ghost" id="btn-logout"><i class="ni"></i><span data-i="logout">退出登录</span></button>
</div>
</header>
<main>
<div id="tab-status"></div>
<div id="tab-chat" class="hidden"></div>
<div id="tab-keys" class="hidden"></div>
<div id="tab-sort" class="hidden"></div>
<div id="tab-sources" class="hidden"></div>
<div id="tab-adapters" class="hidden"></div>
</main>
<div id="toast"></div>
<script>
/* ---------- i18n ---------- */
const STR = {
zh: {
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
navStatus:'状态', navChat:'对话', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注', kCreateBtn:'创建',
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
kNewKey:'新密钥', kNewOK:'密钥已创建', kScope:'配置模型范围', kScopeTitle:'配置模型范围 —— 拖拽排列、下拉添加、×删除、复制/粘贴到其它用户',
kScopeHint:'拖拽排序 · 下拉添加 · × 删除 · 复制/粘贴列表', kScopeSel:'选择模型…', kCopyList:'复制列表', kPasteList:'粘贴列表', kSaveScope:'保存',
kScopeSaved:'模型范围已保存', kScopeEmpty:'全部模型', kScopeAll:'空即不限',
kMeTitle:'我的密钥', kMeRole:'角色', kMeModels:'我可用的模型', kMeHint:'密钥不可在此新建或删除;需要变更请联系管理员。',
kEmpty:'暂无其他密钥', kDelSelf:'不能删除当前登录所用密钥', 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:'源状态', srcCount:'共 %d 个',
tName:'名称', tAdapter:'适配器', tModels:'模型', tURL:'地址', tConn:'连接', tConc:'并发',
online:'在线', offline:'退避 / 不可用',
adTitle:'已加载适配器', tVersion:'版本',
chatTitle:'Chat 测试', cModel:'模型', cMsg:'Enter 发送Shift+Enter 换行', send:'发送', clear:'清空',
cThinking:'推理', cImg:'图片', 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:'并发上限', 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:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位或调整同档顺序,拖到行与行之间的缝隙 = 提升或降低到新档位。生图模型不参与排序。', sortDragGrip:'拖拽前须按住把手',
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
sortCooling:'冷却', sortFail:'失败', sortHealthTip:'冷却 / 失败次数 / 偏好分 实时状态', sortHealthReset:'链上冷却已复位',
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'该源暂无模型',
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载', expKeysCsv:'导出密钥用量', exitKeyView:'退出该密钥视图',
dashStatus:'状态码分布', thCode:'状态码', 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', langTo:'中',
navStatus:'Status', navChat:'Chat', navSources:'Sources', navAdapters:'Adapters', navSort:'Priority', navKeys:'Keys',
keysTitle:'Key management', 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', kModels:'Allowed models', kCreated:'Created', kActions:'Actions', kCopy:'Copy key', kDel:'Delete',
kNewKey:'New key — copy & save it now', kNewOK:'Key created', kScope:'Model scope', kScopeTitle:'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users',
kScopeHint:'drag to order · add from dropdown · × to remove · copy/paste list', kScopeSel:'Pick a model…', kCopyList:'Copy list', kPasteList:'Paste list', kSaveScope:'Save',
kScopeSaved:'Model scope saved', kScopeEmpty:'all models', kScopeAll:'empty = unlimited',
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', kDelSelf:'cannot delete the key you are logged in with', 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', srcCount:'%d total',
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
online:'online', offline:'backoff / down',
adTitle:'Loaded adapters', tVersion:'Version',
chatTitle:'Chat', cModel:'Model', cMsg:'Enter to send, Shift+Enter for newline', send:'Send', clear:'Clear',
cThinking:'Thinking', cImg:'Image', 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', 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.', sortDragGrip:'grab the handle to drag',
sortSave:'Save order', sortReset:'Reset', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect',
sortCooling:'cooling', sortFail:'fail', sortHealthTip:'live cooldown / failures / preference score', sortHealthReset:'chain cooldowns reset',
sortSource:'source', sortPrio:'priority %s', sortEmpty:'no models in this source',
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', thCode:'Code', 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);
if (el.dataset.i === 'logout') el.textContent = s;
else el.textContent = s;
});
document.getElementById('btn-lang').textContent = LANG === 'zh' ? 'EN' : '中';
document.documentElement.lang = LANG;
}
let lastTab = 'status';
function activeTab(){ return lastTab; }
/* ---------- theme & header ---------- */
const THEME = localStorage.getItem('llms-proxy.theme') || 'light';
document.documentElement.dataset.theme = THEME;
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);
};
document.getElementById('btn-lang').onclick = () => {
LANG = LANG === 'zh' ? 'en' : 'zh';
localStorage.setItem('llms-proxy.lang', LANG);
applyI18n(); refresh(lastTab);
};
document.getElementById('btn-logout').onclick = () => { location.href = '/api/logout'; };
document.querySelectorAll('nav button').forEach(b => {
b.onclick = () => goTab(b.dataset.tab);
});
function goTab(name) {
document.querySelectorAll('nav button').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));
lastTab = name; refresh(lastTab);
}
applyI18n();
/* ---------- helpers ---------- */
const $ = s => document.querySelector(s);
const api = (p, o) => fetch(p, o).then(async r => {
if (r.status === 401) { location.href = '/login?continue=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error((j.error && j.error.message) || r.statusText);
return j;
});
let toastTimer;
function toast(m) { const el = $('#toast'); el.textContent = m; el.style.display = 'block';
clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = 'none', 2600); }
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function escAttr(s) { return esc(s).replace(/"/g, '&quot;'); }
function copyText(txt, okMsg) {
const done = () => toast(okMsg || t('toastCopied'));
const fail = () => toast(t('toastCopyFail'));
if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
navigator.clipboard.writeText(txt).then(done, () => legacyCopy(txt) ? done() : fail());
return;
}
legacyCopy(txt) ? done() : fail();
}
function legacyCopy(txt) {
try {
const ta = document.createElement('textarea');
ta.value = txt;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.appendChild(ta);
ta.select();
ta.setSelectionRange(0, ta.value.length);
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
}
/* ---------- status tab ---------- */
let statsTimerId = null;
const fmtN = n => (n ?? 0).toLocaleString();
const fmtTok = n => {
const v = +n || 0;
if (v >= 1e9) return (v / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B';
if (v >= 1e6) return (v / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (v >= 1e3) return (v / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(v);
};
const fmtMs = ms => (ms == null || ms < 0) ? '—' : (ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : ms + 'ms');
const fmtLat = (sum, n) => (n > 0 ? Math.round(sum / n) : -1);
const fmtTime = ts => {
const d = new Date(ts);
const p = x => String(x).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
let statsKeyF = ''; // active key filter for records ('' = all)
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]) || '';
const srcRows = s.sources ? s.sources.map(x =>
`<tr><td>${x.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</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></tr>`).join('') : '';
$('#tab-status').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></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table></div>
</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>` : ''}`;
$('#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 paintStats();
statsTimerId = setInterval(paintStats, 3000);
}
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();
}
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 = 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();
cv.width = rect.width * dpr; cv.height = 86 * 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, 86, '#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();
cv.width = rect.width * dpr; cv.height = 86 * dpr; ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, cv.width, cv.height);
const rows = (models||[]).slice().sort((a,b)=>b.tokens-a.tokens).slice(0,4);
const tot = rows.reduce((a,x)=>a+x.tokens,0) || 1;
let x = 0;
rows.forEach((m,i) => {
const w = Math.max(2, m.tokens / tot * rect.width);
ctx.fillStyle = tokPalette[i%tokPalette.length];
ctx.fillRect(x, 0, w, 86);
x += w;
});
// color-swatch legend instead of on-chart text
const lg = document.getElementById('tb-tokens-legend');
if (lg) lg.innerHTML = rows.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();
cv.width = rect.width * dpr; cv.height = 86 * 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, 86, '#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; }
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('thLatMs')}</th></tr>` +
records.slice().reverse().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">${fmtMs(r.latency_ms)}</td></tr>`).join('') + '</table>';
}
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" id="ct-send" onclick="sendChat()">${t('send')}</button>
</div>
</div>
</div>
</div>`;
chatHistory = [];
chatImages = [];
updateGrow();
$('#ct-msg').addEventListener('input', updateGrow);
$('#ct-msg').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } });
$('#ct-imgs').style.display = 'none';
$('#ct-msg').focus();
}
function updateGrow() {
const ta = $('#ct-msg');
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 150) + 'px';
}
function addMsg(role, text, reason) {
const empty = $('#ct-empty'); if (empty) empty.remove();
const log = $('#ct-log');
const row = document.createElement('div');
row.className = 'msg ' + (role === 'user' ? 'usr' : 'ass');
const av = role === 'user' ? 'u' : 'a';
const label = role === 'user' ? '你' : 'AI';
let inner = `<div class="avatar ${av}">${label}</div>`;
if (role === 'user') {
inner += `<div class="bubble"><div class="bmd">&nbsp;</div></div>`;
} else {
inner += `<div class="bubble"><div class="bmd"></div>` +
(reason ? `<div class="think"><div class="tk-hint"></div></div>` : ``) + `</div>`;
}
row.innerHTML = inner;
log.appendChild(row);
scrollChat();
return row;
}
function scrollChat() { const log = $('#ct-log'); log.scrollTop = log.scrollHeight; }
function renderMD(el, text) {
// minimal markdown: code blocks, inline code, bold, italic, links, lists, headings, paragraphs
const blocks = text.split(/\n{2,}/);
const out = [];
let inCode = false;
let codeBuf = [];
for (const b of blocks) {
const cm = b.match(/^```([\w-]*)\r?\n([\s\S]*?)(?:```|$)/);
if (cm) {
out.push(`<pre><code>${esc(cm[2].replace(/\n$/, ''))}</code></pre>`);
continue;
}
if (b.trim().startsWith('```')) { inCode = !inCode; codeBuf.push(b); continue; }
if (inCode) { codeBuf.push(b); continue; }
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\r?\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
inCode = false; codeBuf = [];
}
let line = b.trim();
if (!line) continue;
let html = esc(line);
// headings
html = html.replace(/^(#{1,4})\s+(.+)$/gm, (_, h, t) => `<h${h.length}>${t}</h${h.length}>`);
// bullets / lists
html = html.replace(/^(\s*)[-*]\s+(.+)$/gm, (_, ind, t) => `${ind}<li>${t}</li>`);
html = html.replace(/(?:^|\n)<li>/g, '<li>');
out.push(`<p>${linksAndBold(html)}</p>`);
}
if (codeBuf.length) {
out.push(`<pre><code>${esc(codeBuf.join('\n').replace(/^```.*\n?/, '').replace(/\n?```$/, ''))}</code></pre>`);
}
el.innerHTML = out.join('');
}
function linksAndBold(s) {
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
s = s.replace(/https?:\/\/[^\s<)]+/g, '<a href="$&" target="_blank" rel="noopener">$&</a>');
return s;
}
async function sendChat() {
const model = $('#ct-model').value || 'AUTO';
const msgTa = $('#ct-msg');
const msg = msgTa.value.trim();
if (!msg && chatImages.length === 0) return toast(t('toastEmpty'));
let content = msg;
if (chatImages.length > 0) {
content = [{ type: 'text', text: msg }];
chatImages.forEach(img => content.push({ type: 'image_url', image_url: { url: img } }));
}
chatHistory.push({ role: 'user', content });
msgTa.value = '';
msgTa.style.height = 'auto';
clearChatImg();
const urow = addMsg('user', msg);
const uimgBox = urow.querySelector('.bmd');
uimgBox.innerHTML = '';
if (Array.isArray(content)) {
const tx = content.filter(p => p.type === 'text').map(p => p.text).join(' ');
uimgBox.innerHTML = esc(tx);
content.filter(p => p.type === 'image_url').forEach(p => {
const img = document.createElement('img');
img.src = p.image_url.url; img.style.maxWidth = '120px'; img.style.borderRadius = '8px';
img.style.display = 'block'; img.style.marginTop = '6px';
uimgBox.appendChild(img);
});
} else {
uimgBox.textContent = msg;
}
const arow = addMsg('assistant', '', true);
const abuf = arow.querySelector('.bmd');
const thinkEl = arow.querySelector('.think');
const hintEl = arow.querySelector('.tk-hint');
thinkEl.style.display = 'none';
const cursor = document.createElement('span'); cursor.className = 'cursor-dot';
abuf.appendChild(cursor);
const sendBtn = $('#ct-send');
sendBtn.disabled = true;
const started = Date.now(); let text = '', thought = '', firstChunk = Date.now();
try {
const resp = await fetch('/api/chat', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, stream: true, disable_thinking: !$('#ct-thinking').checked, messages: chatHistory })});
if (resp.status === 401) { location.href = '/login'; return; }
if (!resp.ok) {
const j = await resp.json().catch(() => ({}));
cursor.remove();
abuf.innerHTML = esc(tFmt('cErr', j.error.message || resp.statusText || ''));
return;
}
const reader = resp.body.getReader(); const dec = new TextDecoder(); let buf = '';
let firstDelta = true;
while (true) {
const { done, value } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf('\n\n')) >= 0) {
const ev = buf.slice(0, i); buf = buf.slice(i + 2);
const line = ev.split('\n').find(l => l.startsWith('data:'));
if (!line) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') continue;
const ck = JSON.parse(data);
const delta = (ck.choices && ck.choices[0] && ck.choices[0].delta) || {};
if (firstDelta) { firstDelta = false; firstChunk = Date.now(); }
if (delta.reasoning_content) {
thought += delta.reasoning_content;
hintEl.textContent = t('at') + ' · ' + Math.round(thought.length / 3) + '…';
thinkEl.style.display = 'block';
thinkEl.textContent = thought.trim();
scrollChat();
}
if (delta.content) {
text += delta.content;
cursor.remove();
renderMD(abuf, text);
scrollChat();
}
}
}
cursor.remove();
const ms = Date.now() - started;
if (text) {
renderMD(abuf, text);
chatHistory.push({ role: 'assistant', content: text });
} else {
abuf.textContent = (thought ? '(仅思考) ' : '') + t('aEmpty');
}
const meta = document.createElement('div'); meta.className = 'muted';
meta.textContent = tFmt('chatMeta', model, ms, text.length);
abuf.appendChild(meta);
} catch (e) {
cursor.remove();
abuf.textContent = esc(tFmt('aErr', e.message));
} finally {
sendBtn.disabled = false;
}
}
function clearChat() { chatHistory = []; chatImages = []; const log = $('#ct-log'); if (log) log.innerHTML = ''; clearChatImg(); }
function clearChatImg() {
chatImages = [];
const box = $('#ct-imgs');
if (box) { box.innerHTML = ''; box.style.display = 'none'; }
}
function renderChatImgs() {
const box = $('#ct-imgs'); if (!box) return;
box.style.display = chatImages.length ? 'flex' : 'none';
box.innerHTML = chatImages.map((src, i) =>
`<span class="att"><img src="${escAttr(src)}"><button class="ghost small" title="${escAttr(t('cRemoveImg'))}" onclick="removeChatImg(${i})">&times;</button></span>`).join('');
}
function addChatImgs(files) {
for (const f of files) {
const rd = new FileReader();
rd.onload = () => { chatImages.push(rd.result); renderChatImgs(); };
rd.readAsDataURL(f);
}
const inp = $('#ct-file'); if (inp) inp.value = '';
}
function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); }
/* ---------- sources tab ---------- */
async function renderSources() {
const j = await api('/api/sources');
const rows = j.sources.map(s => `<tr><td><b>${esc(s.name)}</b></td><td>${esc(s.base_url)}</td><td>${esc(s.adapter)}</td>
<td><div class="src-models">${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}</span>`).join('')}</div></td>
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'&quot;')})">${t('srcEdit')}</button>
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
$('#tab-sources').innerHTML = `
<div class="card"><h2><span>${t('srcTitle')}</span><span class="grow"></span>
<button class="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>
<label>${t('mModels')}</label>
<div id="s-models"></div>
<button class="ghost small" onclick="addModelRow()">${t('mAddModel')}</button>
<label>${t('mMeta')}</label>
<textarea id="s-meta" style="min-height:80px" placeholder='{"app_id":"x","app_secret":"y"}'>${esc(JSON.stringify(s.meta || {}, null, 2))}</textarea>
<p><button onclick="saveSource(this)">${t('mSave')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
document.body.appendChild(wrap);
const box = $('#s-models');
(s.models && s.models.length ? s.models : [{ id: '', priority: 0, kind: 'chat' }]).forEach((m, i) => box.insertAdjacentHTML('beforeend', modelRow(m, i)));
}).catch(() => {});
}
function modelRow(m, i) {
return `<div class="model-row" data-priority="${m.priority || 0}">
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
<button class="ghost del small" onclick="this.closest('.model-row').remove()">&times;</button>
</div>`;
}
function addModelRow() {
const div = $('#s-models');
div.insertAdjacentHTML('beforeend', modelRow({ id: '', priority: 0, kind: 'chat' }, div.children.length));
}
async function saveSource(btn) {
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
id: row.querySelector('.m-id').value.trim(),
priority: parseInt(row.dataset.priority) || 0,
kind: row.querySelector('.m-kind').value,
})).filter(m => m.id);
let meta = {};
try { meta = JSON.parse($('#s-meta').value || '{}'); } catch (e) { toast(t('toastBadJson')); return; }
const payload = {
name: $('#s-name').value.trim(), base_url: $('#s-url').value.trim(),
api_key: $('#s-key').value.trim(), adapter: $('#s-adapter').value.trim(),
endpoint: $('#s-ep').value.trim(), image_endpoint: $('#s-img').value.trim(),
max_concurrent: parseInt($('#s-conc').value) || 8, temperature: parseFloat($('#s-temp').value) || 0,
models, meta,
};
btn.disabled = true;
try {
await api('/api/sources', { method: 'POST', 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), { method: 'DELETE' });
toast(t('toastDelOk')); renderSources();
}
/* ---------- sort tab: scratch jigsaw chain on canvas ---------- */
const SORT_COLORS = [
['#5b8def', '#3f6ef5'], ['#37b273', '#1f9d5a'], ['#f2a541', '#e08a15'],
['#a466e8', '#8b4dd4'], ['#e86f9c', '#d74f86'], ['#45c4c0', '#2aa8a4'],
['#e8564a', '#d03f34'], ['#8ba3c7', '#6f8ab3'],
];
let sortUid = 0;
function nexUid() { return 's' + (++sortUid); }
function srcColor(name) {
let h = 0; for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return SORT_COLORS[h % SORT_COLORS.length];
}
function srcShort(name) { return (name || '?').slice(0, 2).toUpperCase(); }
const sortState = { lanes: [], origin: null, drag: null };
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 findSlots(li, ji) { return sortState.lanes[li].models[ji]; }
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', { method: 'PUT', 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: '✎', fn: () => sortScopeEdit(li, ji) },
{ label: t('kDupB'), icon: '⧉', 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: '♾', fn: () => {
it.meta = null; paintSort([li]);
persistAuto().then(() => toast(t('sortHintSave'))).catch(e => toast(e.message));
} });
items.push({ label: t('kDelB2'), icon: '✕', 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', { method: 'POST', body: JSON.stringify({ name, code }) });
toast(t('toastUploaded'));
$('#adp-name').value = ''; $('#adp-code').value = '';
renderAdapters();
} catch (e) { toast(tFmt('toastUpFail', e.message)); }
}
async function delAdapter(name) {
if (!confirm(tFmt('confirmDelAdp', name))) return;
await api('/api/adapters/' + encodeURIComponent(name), { method: 'DELETE' });
toast(t('toastDelOk')); renderAdapters();
}
/* ---------- keys tab ---------- */
let allModels = [];
let scopeDragEl = null;
async function loadModelPairs() {
let srcs = [];
try { srcs = (await api('/api/sources')).sources || []; } catch (e) {}
const pairs = [];
(srcs || []).forEach(s => (s.models || []).forEach(m => {
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 || []).filter(k => k.role === 'user');
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">◆</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)}')">⧉</span>
<span class="mb-x" title="${escAttr(t('kDelB'))}" onclick="event.stopPropagation();scopeRm('${escAttr(key)}','${escAttr(comb)}')">×</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 scopeUncomb(comb) { const p = String(comb).split('|'); return { model: p[0], source: p[1] || '' }; }
function fmtQuota(q) { q = +q || 0; if (!q) return '∞';
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
if (q >= 1e3) return (q / 1e3).toFixed(1) + 'K';
return String(q); }
function periodText(p, h) {
h = +h || 0;
if (p === 'hour') return '·1h';
if (p === 'week') return '·7d';
if (p === 'month') return '·30d';
if (p === 'nhour') return '·' + Math.max(1, h) + 'h';
return '';
}
function quantBadge(quota, period, hours) {
quota = +quota || 0;
period = period || '';
if (!quota && !period) return '∞';
return fmtQuota(quota) + periodText(period, hours);
}
function readScopes(canvas) {
return [...canvas.querySelectorAll('.mb')].map(b => {
const comb = b.dataset.model.split('|');
const src = comb.length > 1 ? normSrc(comb[1]) : '';
return {
model: comb[0],
source: src || undefined,
token_quota: parseInt(b.dataset.quota) || 0,
period: b.dataset.period || '',
hours: parseInt(b.dataset.hours) || 0,
};
});
}
async function putScope(key, scopes) {
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', 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: '✎', fn: () => scopeEdit(key, model) },
{ label: t('kDelB'), icon: '✕', danger: true, fn: () => scopeRm(key, model) },
]);
}
let ctxEl = null;
function showCtx(x, y, items) {
hideCtx();
const w = document.createElement('div'); w.id = 'ctx-wrap';
w.innerHTML = items.map((it, i) =>
`<div class="ctx-itm ${it.danger ? 'danger' : ''}" data-i="${i}"><span class="ic">${esc(it.icon)}</span>${esc(it.label)}</div>`).join('');
w.addEventListener('click', e => {
const t = e.target.closest('.ctx-itm'); if (!t) return;
const it = items[+t.dataset.i]; hideCtx(); if (it) it.fn();
});
document.body.appendChild(w);
ctxEl = w;
const r = w.getBoundingClientRect();
w.style.left = Math.max(6, Math.min(x, window.innerWidth - r.width - 6)) + 'px';
w.style.top = Math.max(6, Math.min(y, window.innerHeight - r.height - 6)) + 'px';
}
function hideCtx() { if (ctxEl) { ctxEl.remove(); ctxEl = null; } }
// Close the ctx menu on any primary click/press OUTSIDE the menu. Both
// listeners run in the CAPTURE phase, so they fire even when the clicked
// element stops propagation (priority blocks and key bricks call
// stopPropagation in their own click handlers, which would otherwise keep the
// menu open forever). Presses INSIDE the menu are left alone: the menu's own
// click handler closes it after running the item action.
document.addEventListener('click', e => {
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
}, true);
document.addEventListener('mousedown', e => {
if (e.button === 0 && !(ctxEl && ctxEl.contains(e.target))) hideCtx();
}, true);
/* cross-canvas brick dragging */
function bindBrickDrag(b) {
b.addEventListener('dragstart', e => {
scopeDragEl = { key: b.dataset.key, model: b.dataset.model };
e.dataTransfer.setData('text/plain', b.dataset.model);
e.dataTransfer.effectAllowed = 'move';
const g = b.cloneNode(true); g.classList.add('mb-ghost');
document.body.appendChild(g);
b.addEventListener('dragend', () => { g.remove(); scopeDragEl = null; }, { once: true });
});
b.addEventListener('dragover', e => e.preventDefault());
}
function bindCanvasDrop(cv) {
cv.addEventListener('dragover', e => { e.preventDefault(); cv.classList.add('ovh'); });
cv.addEventListener('dragleave', () => cv.classList.remove('ovh'));
cv.addEventListener('drop', e => {
e.preventDefault(); cv.classList.remove('ovh');
if (!scopeDragEl) return;
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
});
}
async function moveBrick(from, to, comb) {
if (from === to) return;
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
if (!sf || !st) return;
let fs = readScopes(sf), ts = readScopes(st);
const b = fs.find(s => scopeComb(s) === comb);
if (!b) return;
fs = fs.filter(s => scopeComb(s) !== comb);
const dup = ts.some(s => scopeComb(s) === comb);
if (!dup) ts.push(b);
try {
await putScope(from, fs);
await putScope(to, ts);
toast(t('kMovOK'));
} catch (e) { toast(e.message); loadKeys(); }
}
function openKeyModal() {
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kCreate')}</h2>
<label>${t('kName')}</label>
<input id="kc-name" placeholder="alice">
<label>${t('kRole')}</label>
<select id="kc-role">
<option value="user">${t('kRoleUser')}</option>
<option value="admin">${t('kRoleAdmin')}</option>
</select>
<label>${t('kNote')}</label>
<input id="kc-note">
<p><button onclick="createKey(this)">${t('kCreateBtn')}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
</div>`;
document.body.appendChild(wrap);
$('#kc-name').focus();
}
async function createKey(btn) {
const name = $('#kc-name').value.trim();
if (!name) { toast(t('kName')); return; }
if (btn) btn.disabled = true;
let j;
try {
j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
name, role: $('#kc-role').value, note: $('#kc-note').value.trim(),
}) });
} catch (e) { toast(e.message); if (btn) btn.disabled = false; return; }
const w = $('#modal-wrap'); if (w) w.remove();
$('#k-newbox').innerHTML = `
<div class="key-canvas" style="background:var(--card2)">
<div class="kc-head">
<b>${t('kNewKey')}</b>
<span class="kr-key" style="max-width:none">${esc(j.key.key)}</span>
<button class="small" onclick="copyText('${escAttr(j.key.key)}')">${t('kCopy')}</button>
<span class="grow"></span>
<button class="ghost small" onclick="$('#k-newbox').innerHTML=''">×</button>
</div></div>`;
toast(t('kNewOK'));
loadKeys();
}
async function delKey(key, name) {
if (!confirm(tFmt('kDelConfirm', name || key))) return;
try {
await api('/api/keys/' + encodeURIComponent(key), { method: 'DELETE' });
toast(t('toastDelOk'));
loadKeys();
} catch (e) { toast(e.message); }
}
/* ---------- boot ---------- */
function refresh(tab) {
if (tab === 'status') return renderStatus();
if (tab === 'chat') return renderChat();
if (tab === 'keys') return renderKeys();
if (tab === 'sort') return renderSort();
if (tab === 'sources') return renderSources();
return renderAdapters();
}
(async () => {
try {
const me = await api('/api/keys/me');
window._me = me.key;
maybeWarnSeed(me.key);
if (me.key.role !== 'admin') {
['sort', 'sources', 'adapters'].forEach(tn => {
const b = document.querySelector(`nav button[data-tab="${tn}"]`);
if (b) b.style.display = 'none';
});
}
} catch (e) {}
})();
refresh('status');
</script>
</body>
</html>