mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
1643 lines
90 KiB
HTML
1643 lines
90 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>ModelRouter</title>
|
||
<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; }
|
||
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; }
|
||
.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); }
|
||
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 ---------- */
|
||
.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-lab { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
|
||
.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:3px; }
|
||
.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; }
|
||
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); }
|
||
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; } }
|
||
.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:7px;
|
||
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.grow-l::before { animation:growlL .22s ease-out .24s both; }
|
||
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } }
|
||
.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; }
|
||
.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 { 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 { 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 { font-size:15px; }
|
||
.brand .sub { display:none; }
|
||
.hd-actions button { padding:5px 9px; }
|
||
nav { gap:4px; padding:10px 12px 0; }
|
||
nav button { padding:6px 10px; font-size:13px; }
|
||
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; }
|
||
.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" data-i="navStatus">状态</button>
|
||
<button data-tab="chat" data-i="navChat">Chat 测试</button>
|
||
<button data-tab="keys" data-i="navKeys">密钥</button>
|
||
<button data-tab="sort" data-i="navSort">优先级</button>
|
||
<button data-tab="sources" data-i="navSources">源</button>
|
||
<button data-tab="adapters" data-i="navAdapters">适配器</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" data-i="logout">退出登录</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:'Chat 测试', 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:'不限',
|
||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 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:'名称(保存为 <名称>.lua)', tbLua:'Lua 脚本(返回 adapter table)', uploadBtn:'上传并加载',
|
||
modalNew:'新增源', modalEdit:'编辑源',
|
||
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
|
||
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
|
||
mModels:'模型列表(优先级越大,AUTO 越优先选择)', mAddModel:'+ 模型',
|
||
mMeta:'Meta(透传给 build_headers 钩子,JSON)', 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(自动选择可用源)',
|
||
sortTitle:'画布排序:拖拽积木配置模型优先级', sortHint:'每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型(kind=image)不参与排序。', sortDragGrip:'拖拽前须按住把手',
|
||
sortSave:'保存排序', sortReset:'重置', sortSaved:'排序已保存并热重载', sortNoChange:'无变更',
|
||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)',
|
||
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 (optional)', 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, unlimited)', 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',
|
||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. 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 (saved as <name>.lua)', tbLua:'Lua script (returns adapter table)', uploadBtn:'Upload & load',
|
||
modalNew:'Add source', modalEdit:'Edit source',
|
||
mName:'Name', mURL:'Base URL', mKey:'API Key', mAlias:'Adapter', mAliasAuto:'Auto', mEp:'Chat endpoint (override)',
|
||
mImgEp:'Image endpoint (override)', mConc:'Max concurrency', mTemp:'Temperature',
|
||
mModels:'Models (higher priority → preferred)', mAddModel:'+ model',
|
||
mMeta:'Meta (passed to build_headers hook, JSON)', 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 (source %s)', connAuto:'# Model "AUTO" picks healthy source by priority',
|
||
sortTitle:'Canvas sorting: 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 (kind=image) stay out.', sortDragGrip:'grab the handle to drag',
|
||
sortSave:'Save order', sortReset:'Reset', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes',
|
||
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 (audit)',
|
||
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 = () => {
|
||
document.querySelectorAll('nav button').forEach(x => x.classList.toggle('active', x === b));
|
||
['status','chat','keys','sort','sources','adapters'].forEach(tn => $('#tab-' + tn).classList.toggle('hidden', tn !== b.dataset.tab));
|
||
lastTab = b.dataset.tab; 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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function escAttr(s) { return esc(s).replace(/"/g, '"'); }
|
||
function copyText(txt, okMsg) {
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(txt).then(() => toast(okMsg || t('toastCopied')), () => toast(t('toastCopyFail')));
|
||
} else { toast(t('toastCopyFail')); }
|
||
}
|
||
|
||
/* ---------- status tab ---------- */
|
||
let statsTimerId = null;
|
||
const fmtN = n => (n ?? 0).toLocaleString();
|
||
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.map(x =>
|
||
`<tr><td><b>${esc(x.name)}</b></td><td>${esc(x.adapter)}</td>
|
||
<td>${x.models.map(m => `<span class="tag tag-blue modelchip" onclick="showModelConfig('${escAttr(x.name)}','${escAttr(m)}')">${esc(m)}</span>`).join('')}</td>
|
||
<td><span class="muted">${esc(x.base_url || '')}</span></td>
|
||
<td>${x.available ? `<span class="tag tag-green">${t('online')}</span>` : `<span class="tag tag-red">${t('offline')}</span>`}</td>
|
||
<td>${x.max_concurrent}</td></tr>`).join('');
|
||
$('#tab-status').innerHTML = `
|
||
<div class="kpis" id="kpi-row"></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>
|
||
<div class="dash-row">
|
||
<div class="card"><h2>${t('dashModel')}</h2><div id="tb-model"></div></div>
|
||
<div class="card"><h2>${t('dashSrc')}</h2><div id="tb-src"></div></div>
|
||
</div>
|
||
<div class="card"><h2>${t('dashKey')}</h2><div id="tb-key"></div></div>
|
||
<div class="card"><h2>${t('dashRecs')}</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>
|
||
<div class="card"><h2>${t('srcTitle')} (${s.sources.length})</h2>
|
||
<table><tr><th>${t('tName')}</th><th>${t('tAdapter')}</th><th>${t('tModels')}</th><th>${t('tURL')}</th><th>${t('tConn')}</th><th>${t('tConc')}</th></tr>${srcRows || `<tr><td colspan="6" class="empty">${t('srcEmpty')}</td></tr>`}</table>
|
||
</div>
|
||
<div class="card"><h2>${t('adTitle')} (${s.adapters.length})</h2>
|
||
<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>`;
|
||
$('#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) {
|
||
const sel = $('#rec-key');
|
||
if (!sel) return;
|
||
const cur = sel.value;
|
||
sel.innerHTML = `<option value="">${esc(t('allKeys'))}</option>` +
|
||
keys.map(k => `<option value="${escAttr(k)}">${esc(k)}</option>`).join('');
|
||
if (cur) sel.value = cur;
|
||
}
|
||
function renderKeyF(v) { statsKeyF = v; paintStats(); }
|
||
async function paintStats() {
|
||
try {
|
||
const q = '/api/stats?limit=500' + (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.innerHTML = `
|
||
<div class="kpi"><div class="k-lab">${t('kpiActive')} <span class="dot"></span></div><div class="k-val">${st.active || 0}</div><div class="k-sub">${statsKeyF ? esc(statsKeyF) : ''}</div></div>
|
||
<div class="kpi"><div class="k-lab">${t('kpiReqs')}</div><div class="k-val">${fmtN(tot.reqs)}</div><div class="k-sub">${t('kpiOk')} <b class="${okr >= 90 ? 'okc' : 'errc'}">${okr}%</b></div></div>
|
||
<div class="kpi"><div class="k-lab">${t('kpiTokens')}</div><div class="k-val">${fmtN(tot.tokens)}</div>
|
||
<div class="k-sub">${t('thPrompt')} ${fmtN(tot.prompt_tokens)} · ${t('thCompl')} ${fmtN(tot.completion_tokens)}</div></div>
|
||
<div class="kpi"><div class="k-lab">${t('kpiLat')}</div><div class="k-val">${fmtMs(avg)}</div><div class="k-sub">${t('kpiMaxLat')} ${fmtMs(tot.latency_max_ms)}</div></div>`;
|
||
paintModelTable(st.by_model || []);
|
||
paintSrcTable(st.by_source || []);
|
||
paintKeyTable(st.by_key || []);
|
||
paintRecords(st.records || []);
|
||
renderKeySelect((st.by_key || []).map(k => k.name));
|
||
} 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; }
|
||
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 maxReq = rows.reduce((m, x) => Math.max(m, x.reqs), 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">${w.toFixed(0)}%</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">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(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">${fmtN(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 paintKeyTable(rows) {
|
||
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(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">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
|
||
}
|
||
function paintRecords(records) {
|
||
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(r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
|
||
<td class="num">${fmtN(r.prompt_tokens)}</td><td class="num">${fmtN(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');
|
||
const opts = ['AUTO', ...(s.models || [])].map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).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">${opts}</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"> </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', '');
|
||
const abuf = arow.querySelector('.bmd');
|
||
const thinkEl = arow.querySelector('.think');
|
||
const hintEl = arow.querySelector('.tk-hint');
|
||
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})">×</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>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}<span class="muted"> ·${m.priority||0}</span></span>`).join('')}</td>
|
||
<td><button class="ghost small" onclick="editSource(${JSON.stringify(s.name).replace(/"/g,'"')})">${t('srcEdit')}</button>
|
||
<button class="danger small" onclick="delSource('${escAttr(s.name)}')">${t('srcDel')}</button></td></tr>`).join('');
|
||
$('#tab-sources').innerHTML = `
|
||
<div class="card"><h2><span>${t('srcTitle')}</span><span class="grow"></span>
|
||
<button class="small" onclick="editSource('')">${t('srcAdd')}</button></h2>
|
||
<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>`;
|
||
}
|
||
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">
|
||
<input data-mi="${i}" class="m-id" placeholder="model-id" value="${escAttr(m.id)}">
|
||
<input data-mi="${i}" class="m-prio" type="number" placeholder="priority" value="${m.priority || 0}" style="width:90px">
|
||
<select data-mi="${i}" class="m-kind"><option ${(m.kind==='image')?'':'selected'} value="chat">chat</option><option ${(m.kind==='image')?'selected':''} value="image">image</option></select>
|
||
<button class="ghost del small" onclick="this.closest('.model-row').remove()">×</button>
|
||
</div>`;
|
||
}
|
||
function addModelRow() {
|
||
const div = $('#s-models');
|
||
div.insertAdjacentHTML('beforeend', modelRow({ id: '', priority: 0, kind: 'chat' }, div.children.length));
|
||
}
|
||
async function saveSource(btn) {
|
||
const models = [...document.querySelectorAll('#s-models .model-row')].map(row => ({
|
||
id: row.querySelector('.m-id').value.trim(),
|
||
priority: parseInt(row.querySelector('.m-prio').value) || 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'],
|
||
];
|
||
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 };
|
||
async function renderSort() {
|
||
const j = await api('/api/sources');
|
||
const map = new Map();
|
||
j.sources.forEach(s => (s.models || []).forEach(m => {
|
||
if (m.kind === 'image') return; // image models never share the chat chain
|
||
const p = m.priority || 0;
|
||
if (!map.has(p)) map.set(p, []);
|
||
map.get(p).push({ src: s.name, id: m.id });
|
||
}));
|
||
sortState.lanes = [...map.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);
|
||
return { prio, models };
|
||
});
|
||
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="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 scrBlockHtml(it, isFirst) {
|
||
const c = srcColor(it.src);
|
||
return `
|
||
<div class="scr-block" data-key="${escAttr(it.src + '|' + it.id)}"
|
||
style="background:linear-gradient(135deg,${c[0]},${c[1]})">
|
||
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
||
<span class="scr-ico">${esc(srcShort(it.src))}</span>
|
||
<span class="scr-name">${esc(it.id)}</span>
|
||
<span class="scr-grip"><i></i><i></i><i></i></span>
|
||
</div>`;
|
||
}
|
||
function paintSort(affected) {
|
||
const cv = $('#scr-canvas');
|
||
if (!cv) return;
|
||
// compute the next lane-leaders so we can retract the outgoing ones first
|
||
const nextFirst = new Set();
|
||
sortState.lanes.forEach(lane => {
|
||
if (lane.models.length) nextFirst.add(lane.models[0].src + '|' + lane.models[0].id);
|
||
});
|
||
// 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 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)).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)) 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
|
||
sortState.lanes[item.li].models.splice(item.idx, 1);
|
||
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: [{ src: item.src, id: item.id }] });
|
||
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(key) {
|
||
for (let i = 0; i < sortState.lanes.length; i++) {
|
||
const idx = sortState.lanes[i].models.findIndex(m => m.src + '|' + m.id === key);
|
||
if (idx >= 0) return { li: i, idx, src: sortState.lanes[i].models[idx].src, id: sortState.lanes[i].models[idx].id };
|
||
}
|
||
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() {
|
||
const snap = await api('/api/sources');
|
||
const byName = {}; snap.sources.forEach(x => byName[x.name] = x);
|
||
const groups = {};
|
||
sortState.lanes.forEach((lane, li) => lane.models.forEach(it => {
|
||
(groups[it.src] = groups[it.src] || []).push({ id: it.id, priority: (sortState.lanes.length - li) * 10 });
|
||
}));
|
||
let dirty = 0;
|
||
for (const name of Object.keys(groups)) {
|
||
const base = byName[name];
|
||
if (!base) continue;
|
||
const seen = new Set();
|
||
const ordered = groups[name].filter(it => { if (seen.has(it.id)) return false; seen.add(it.id); return true; })
|
||
.map(it => ({ id: it.id, priority: it.priority, kind: 'chat' }));
|
||
(base.models || []).forEach(x => {
|
||
if (seen.has(x.id)) return;
|
||
ordered.push({ id: x.id, priority: x.priority || 0, kind: x.kind === 'image' ? 'image' : 'chat' });
|
||
});
|
||
if (JSON.stringify(ordered) !== JSON.stringify(base.models)) {
|
||
base.models = ordered;
|
||
await api('/api/sources', { method: 'POST', body: JSON.stringify(base) });
|
||
dirty++;
|
||
}
|
||
}
|
||
toast(dirty ? t('sortSaved') : t('sortNoChange'));
|
||
sortState.origin = JSON.stringify(sortState.lanes);
|
||
}
|
||
|
||
/* ---------- 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>
|
||
<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 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;
|
||
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);
|
||
}
|
||
async function renderKeysUser(me) {
|
||
$('#tab-keys').innerHTML = `
|
||
<div class="card"><h2>${t('kMeTitle')}</h2>
|
||
<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 => `<span class="tag tag-blue">${esc(m)}</span>`).join('')
|
||
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
|
||
<div class="muted" style="margin-top:10px">${t('kMeHint')}</div>
|
||
</div>`;
|
||
}
|
||
async function renderKeysAdmin() {
|
||
let models = [];
|
||
try { models = (await api('/api/status')).models || []; } catch (e) {}
|
||
allModels = models;
|
||
$('#tab-keys').innerHTML = `
|
||
<div class="card"><h2>${t('kCreate')}</h2>
|
||
<div class="keys-create">
|
||
<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" placeholder="…">
|
||
<button onclick="createKey()">${t('kCreateBtn')}</button>
|
||
</div>
|
||
<div id="k-newbox"></div>
|
||
</div>
|
||
<div class="card"><h2>${t('keysTitle')}</h2><div class="muted">${t('keysHint')}</div><div id="k-list"></div></div>`;
|
||
loadKeys();
|
||
}
|
||
async function loadKeys() {
|
||
const el = $('#k-list'); if (!el) return;
|
||
const j = await api('/api/keys');
|
||
const ks = j.keys || [];
|
||
el.innerHTML = ks.length ? ks.map(k => keyRowHtml(k)).join('') : `<div class="muted">${t('kEmpty')}</div>`;
|
||
}
|
||
function keyRowHtml(k) {
|
||
return `
|
||
<div class="key-row">
|
||
<span class="kr-name">${esc(k.name || '—')}</span>
|
||
${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">${t('kModels')}: ${(k.models && k.models.length) ? k.models.length : t('kAll')}</span>
|
||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||
<button class="ghost small" onclick="toggleScope('${escAttr(k.key)}')">${t('kScope')}</button>
|
||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
|
||
</div>
|
||
<div id="scope-${escAttr(k.key)}" class="scope-box" style="display:none;padding-left:12px;padding-right:12px">
|
||
<div class="muted" style="font-size:12px;margin-bottom:8px">${t('kScopeTitle')}</div>
|
||
<div class="scope-chips">${(k.models && k.models.length)
|
||
? k.models.map(m => scopeChipHtml(k.key, m)).join('')
|
||
: `<span class="scope-unlim">${t('kScopeEmpty')}</span>`}</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap">
|
||
<select class="scope-add" id="add-${escAttr(k.key)}" onchange="scopeAdd('${escAttr(k.key)}', this)">
|
||
<option value="">${t('kScopeSel')}</option>
|
||
${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}
|
||
</select>
|
||
<button class="ghost small" onclick="scopeCopy('${escAttr(k.key)}')">${t('kCopyList')}</button>
|
||
<button class="ghost small" onclick="scopePaste('${escAttr(k.key)}')">${t('kPasteList')}</button>
|
||
<span class="grow"></span>
|
||
<button class="small" onclick="saveScope('${escAttr(k.key)}')">${t('kSaveScope')}</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
function scopeChipHtml(key, m) {
|
||
return `<span class="scope-chip" data-m="${escAttr(m)}" title="${t('kScopeHint')}">${esc(m)}<span class="rm" onclick="scopeRm('${escAttr(key)}', this)">×</span></span>`;
|
||
}
|
||
function toggleScope(key) {
|
||
const box = document.getElementById('scope-' + key);
|
||
if (!box) return;
|
||
box.style.display = box.style.display === 'none' ? 'block' : 'none';
|
||
if (box.style.display === 'block') scopeDragInit(key);
|
||
}
|
||
function scopeModels(key) {
|
||
const box = document.getElementById('scope-' + key);
|
||
if (!box) return [];
|
||
return [...box.querySelectorAll('.scope-chip')].map(x => x.dataset.m).filter(Boolean);
|
||
}
|
||
function scopeDragInit(key) {
|
||
const box = document.getElementById('scope-' + key);
|
||
if (!box) return;
|
||
box.querySelectorAll('.scope-chip').forEach(ch => {
|
||
ch.setAttribute('draggable', 'true');
|
||
ch.addEventListener('dragstart', e => { scopeDragEl = ch; ch.classList.add('drag-over'); e.dataTransfer.effectAllowed = 'move'; });
|
||
ch.addEventListener('dragend', () => { ch.classList.remove('drag-over'); scopeDragEl = null; });
|
||
ch.addEventListener('dragover', e => e.preventDefault());
|
||
ch.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
if (!scopeDragEl || scopeDragEl === ch) return;
|
||
const items = scopeModels(key);
|
||
const from = items.indexOf(scopeDragEl.dataset.m);
|
||
const to = items.indexOf(ch.dataset.m);
|
||
if (from < 0 || to < 0) return;
|
||
items.splice(to, 0, items.splice(from, 1)[0]);
|
||
const chips = box.querySelector('.scope-chips');
|
||
chips.innerHTML = items.map(m => scopeChipHtml(key, m)).join('');
|
||
scopeDragInit(key);
|
||
});
|
||
});
|
||
}
|
||
function scopeAdd(key, sel) {
|
||
const m = sel.value; sel.value = '';
|
||
if (!m) return;
|
||
const box = document.getElementById('scope-' + key); if (!box) return;
|
||
const chips = box.querySelector('.scope-chips');
|
||
if ([...chips.querySelectorAll('.scope-chip')].some(x => x.dataset.m === m)) return;
|
||
if (chips.querySelector('.scope-unlim')) chips.innerHTML = '';
|
||
chips.innerHTML += scopeChipHtml(key, m);
|
||
scopeDragInit(key);
|
||
}
|
||
function scopeRm(key, el) {
|
||
el.closest('.scope-chip').remove();
|
||
const box = document.getElementById('scope-' + key);
|
||
const chips = box ? box.querySelector('.scope-chips') : null;
|
||
if (chips && !chips.querySelector('.scope-chip')) chips.innerHTML = `<span class="scope-unlim">${t('kScopeEmpty')}</span>`;
|
||
}
|
||
function scopeCopy(key) { copyText(scopeModels(key).join('\n')); }
|
||
async function scopePaste(key) {
|
||
let txt = '';
|
||
try { txt = await navigator.clipboard.readText(); } catch (e) {}
|
||
if (!txt) txt = prompt(t('kPasteList'));
|
||
if (!txt) return;
|
||
const ms = txt.split(/[\n,;\s]+/).map(s => s.trim()).filter(Boolean);
|
||
const box = document.getElementById('scope-' + key); if (!box) return;
|
||
const chips = box.querySelector('.scope-chips');
|
||
chips.innerHTML = ms.map(m => scopeChipHtml(key, m)).join('');
|
||
scopeDragInit(key);
|
||
}
|
||
async function saveScope(key) {
|
||
const ms = scopeModels(key);
|
||
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: ms }) });
|
||
toast(t('kScopeSaved'));
|
||
loadKeys();
|
||
}
|
||
async function createKey() {
|
||
const name = $('#kc-name').value.trim();
|
||
if (!name) { toast(t('kName')); return; }
|
||
const j = await api('/api/keys', { method: 'POST', body: JSON.stringify({
|
||
name, role: $('#kc-role').value, note: $('#kc-note').value.trim(),
|
||
}) });
|
||
$('#kc-name').value = ''; $('#kc-note').value = '';
|
||
$('#k-newbox').innerHTML = `
|
||
<div class="key-row" style="margin-top:12px;background:var(--card2)">
|
||
<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>`;
|
||
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;
|
||
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> |