@alilis/k-hat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/web-ui.js ADDED
@@ -0,0 +1,323 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ const PAGE = `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>khat 密钥管理</title><script>(function(){try{var t=localStorage.getItem('khatTheme');if(t!=='dark'&&t!=='light')t=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';document.documentElement.setAttribute('data-theme',t)}catch(e){document.documentElement.setAttribute('data-theme','light')}})();</script><style>
3
+ *{box-sizing:border-box}
4
+ :root{color-scheme:light;--bg:#f4f6fa;--surface:#ffffff;--surface-2:var(--surface-2);--border:#e5eaf1;--border-strong:#d5dee9;--text:#1c2540;--muted:#5d6a85;--faint:#93a0b5;--accent:#4f46e5;--accent-hover:#4338ca;--accent-soft:#eef2ff;--ok:#15803d;--ok-bg:#ecfdf5;--ok-border:#bcf0d2;--bad:#b91c1c;--bad-bg:#fef2f2;--bad-border:#fdd8d8;--warn:#92400e;--warn-bg:#fffbeb;--warn-border:#fbe38a;--topbar-bg:rgba(255,255,255,.88);--ring:rgba(79,70,229,.16);--shadow:0 1px 2px rgba(23,32,51,.04);--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,'Courier New',monospace}
5
+ [data-theme=dark]{color-scheme:dark;--bg:#0f1523;--surface:#161d2f;--surface-2:#1d2538;--border:#28324a;--border-strong:#3a4767;--text:#e4eaf5;--muted:#9aa6bd;--faint:#68748f;--accent:#818cf8;--accent-hover:#a5b4fc;--accent-soft:#232c52;--ok:#4ade80;--ok-bg:#122b20;--ok-border:#215239;--bad:#f87171;--bad-bg:#33181a;--bad-border:#5c2a2e;--warn:#fbbf24;--warn-bg:#30260f;--warn-border:#5e4a17;--topbar-bg:rgba(15,21,35,.88);--ring:rgba(129,140,248,.32);--shadow:0 1px 3px rgba(0,0,0,.35)}
6
+ body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,-apple-system,'Segoe UI',Roboto,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif}
7
+ .container{max-width:1180px;margin:0 auto;padding:0 20px}
8
+ .topbar{position:sticky;top:0;z-index:50;background:var(--topbar-bg);backdrop-filter:blur(10px);border-bottom:1px solid var(--border)}
9
+ .topbar-inner{height:56px;display:flex;align-items:center;justify-content:space-between;gap:12px}
10
+ .brand{display:flex;align-items:center;gap:10px;font-weight:700;font-size:16px;min-width:0}
11
+ .logo{width:24px;height:24px;border-radius:7px;background:linear-gradient(135deg,#6366f1,#8b5cf6);box-shadow:inset 0 1px 0 rgba(255,255,255,.35);flex:none}
12
+ .conn-badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;font-weight:500;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:999px;padding:3px 10px;font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
13
+ .conn-badge:empty{display:none}
14
+ .conn-badge .dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 3px rgba(22,163,74,.14);flex:none}
15
+ .topbar-actions{display:flex;align-items:center;gap:12px;flex:none}
16
+ .auto-note{font-size:12px;color:var(--faint)}
17
+ main.container{padding-top:22px;padding-bottom:48px}
18
+ .alert{display:none;margin:0 0 16px;padding:10px 14px;border-radius:10px;font-size:13.5px;line-height:1.5}
19
+ .alert:not(:empty){display:block}
20
+ .alert-error{background:var(--bad-bg);color:var(--bad);border:1px solid var(--bad-border)}
21
+ .alert-warn{background:var(--warn-bg);color:var(--warn);border:1px solid var(--warn-border)}
22
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-bottom:18px}
23
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;box-shadow:var(--shadow)}
24
+ .stat-label{font-size:12px;font-weight:600;color:var(--muted)}
25
+ .stat-value{font-size:23px;font-weight:700;margin-top:4px;font-variant-numeric:tabular-nums}
26
+ .stat-sub{font-size:12px;color:var(--faint);margin-top:2px}
27
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:var(--shadow);padding:20px;margin-bottom:18px}
28
+ .card-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;flex-wrap:wrap}
29
+ .card-title{margin:0;font-size:15.5px;font-weight:700}
30
+ .card-sub{margin:3px 0 0;font-size:12.5px;color:var(--muted)}
31
+ .match-info{font-size:12px;color:var(--faint);white-space:nowrap;align-self:center}
32
+ .toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px}
33
+ .filter-bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:8px 0 14px;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:10px}
34
+ .filter-bar input[type=search]{flex:1 1 220px;min-width:180px}
35
+ .filter-bar .match-info{margin-left:auto}
36
+ input,select{height:34px;padding:4px 10px;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);color:var(--text);font:inherit;font-size:13px;transition:border-color .15s,box-shadow .15s}
37
+ input:focus-visible,select:focus-visible{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--ring)}
38
+ input[readonly]{background:var(--surface-2);color:var(--muted)}
39
+ input[type=number]{width:86px}
40
+ input::placeholder{color:var(--faint)}
41
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:5px;height:34px;padding:4px 14px;border-radius:8px;border:1px solid transparent;font:inherit;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s,border-color .15s,color .15s;user-select:none;white-space:nowrap}
42
+ .btn:focus-visible{outline:none;box-shadow:0 0 0 3px var(--ring)}
43
+ .btn-primary{background:var(--accent);color:#fff}
44
+ .btn-primary:hover{background:var(--accent-hover)}
45
+ .btn-secondary{background:var(--surface);border-color:var(--border-strong);color:var(--text)}
46
+ .btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
47
+ .btn-secondary:disabled:hover{border-color:var(--border-strong);color:var(--text)}
48
+ .btn-danger{background:transparent;border-color:transparent;color:var(--bad)}
49
+ .btn-danger:hover{background:var(--bad-bg);border-color:var(--bad-border)}
50
+ .btn-sm{height:27px;padding:2px 9px;font-size:12px;border-radius:7px}
51
+ .btn-icon{width:34px;padding:0;font-size:15px;line-height:1}
52
+ .btn:disabled{opacity:.45;cursor:not-allowed}
53
+ .table-wrap{overflow-x:auto}
54
+ table{border-collapse:collapse;width:100%;font-size:13px}
55
+ .provider-block table{margin:0}
56
+ th{font-size:12px;font-weight:600;color:var(--muted);text-align:left;padding:8px 10px;border-bottom:1px solid var(--border-strong);background:var(--surface-2);white-space:nowrap}
57
+ td{padding:9px 10px;border-bottom:1px solid var(--border);vertical-align:middle}
58
+ tbody tr:hover td{background:var(--surface-2)}
59
+ .num{text-align:right;font-family:var(--mono);font-size:12.5px;font-variant-numeric:tabular-nums;white-space:nowrap}
60
+ .mono-cell{font-family:var(--mono);font-size:12.5px;word-break:break-all}
61
+ .key-id{font-family:var(--mono);font-size:12.5px;font-weight:600}
62
+ .key-secret{margin-left:7px;color:var(--faint);font-family:var(--mono);font-size:11.5px}
63
+ .err-text{color:var(--bad);font-size:12.5px}
64
+ .err-text.none{color:var(--faint)}
65
+ .row-actions{display:flex;gap:5px;align-items:center}
66
+ .empty-row td{text-align:center;color:var(--faint);padding:24px 10px}
67
+ .empty-state{border:1.5px dashed var(--border-strong);border-radius:10px;padding:30px 16px;text-align:center;color:var(--faint);font-size:13px;background:var(--surface-2)}
68
+ .pill{display:inline-flex;align-items:center;gap:5px;height:21px;padding:1px 9px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid;white-space:nowrap}
69
+ .pill-status::before{content:'';width:6px;height:6px;border-radius:999px;background:currentColor;flex:none}
70
+ .pill-ok{background:var(--ok-bg);color:var(--ok);border-color:var(--ok-border)}
71
+ .pill-bad{background:var(--bad-bg);color:var(--bad);border-color:var(--bad-border)}
72
+ .protocol-pill{background:var(--accent-soft);color:var(--accent);border-color:transparent}
73
+ .legend{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--muted);align-self:center}
74
+ .swatch{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
75
+ .swatch.ok{background:#10b981}
76
+ .swatch.bad{background:#ef4444}
77
+ .chart-box{position:relative;background:var(--surface-2);border:1px solid var(--border);border-radius:10px;padding:8px 6px 2px}
78
+ #traffic{display:block;width:100%;height:150px}
79
+ .chart-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--faint);font-size:13px}
80
+ [hidden]{display:none!important}
81
+ .provider-block{border:1px solid var(--border);border-radius:10px;margin-top:12px;overflow:hidden}
82
+ .provider-block:first-child{margin-top:0}
83
+ .provider-head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--surface-2);border-bottom:1px solid var(--border);flex-wrap:wrap}
84
+ .provider-name{margin:0;font-size:13.5px;font-weight:650}
85
+ .provider-url{flex:1 1 120px;min-width:0;font-family:var(--mono);font-size:12px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right}
86
+ .provider-count{font-size:12px;color:var(--faint);white-space:nowrap}
87
+ #providerForm [name=id]{width:140px}
88
+ #providerForm [name=name]{width:160px}
89
+ #providerForm [name=baseUrl]{flex:1 1 220px;min-width:200px}
90
+ #keyForm [name=id]{width:170px}
91
+ #keyForm [name=value]{flex:1 1 220px;min-width:210px}
92
+ #routeForm [name=model]{width:230px;font-family:var(--mono)}
93
+ #providerForm>button,#keyForm>button,#routeForm>button{margin-left:auto}
94
+ dialog{border:none;border-radius:14px;padding:0;width:min(460px,92vw);box-shadow:0 20px 60px rgba(15,23,42,.28)}
95
+ dialog::backdrop{background:rgba(15,23,42,.45)}
96
+ .dialog-body{padding:22px 24px}
97
+ .dialog-title{margin:0 0 16px;font-size:15.5px;font-weight:700}
98
+ .field{display:flex;flex-direction:column;gap:5px;margin-bottom:12px;font-size:12.5px;font-weight:600;color:var(--muted)}
99
+ .field input{height:36px}
100
+ .field-row{display:flex;gap:12px}
101
+ .field-row .field{flex:1;min-width:0}
102
+ .field-row input{width:100%}
103
+ .dialog-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
104
+ @media(max-width:640px){.auto-note{display:none}.conn-badge{max-width:180px}}
105
+ </style></head><body>
106
+ <header class="topbar"><div class="container topbar-inner">
107
+ <div class="brand"><span class="logo" aria-hidden="true"></span><span>khat</span><span id="connection" class="conn-badge"></span></div>
108
+ <div class="topbar-actions"><button id="themeToggle" class="btn btn-secondary btn-icon" type="button" title="切换深色 / 浅色模式">🌙</button><span class="auto-note">每 30 秒自动刷新</span><button id="refresh" class="btn btn-secondary" type="button">刷新</button></div>
109
+ </div></header>
110
+ <main class="container">
111
+ <p id="error" class="alert alert-error"></p>
112
+ <p id="poolWarn" class="alert alert-warn"></p>
113
+ <div class="stats">
114
+ <div class="stat"><div class="stat-label">Provider 数量</div><div class="stat-value" id="statProviders">—</div><div class="stat-sub">已配置的服务商</div></div>
115
+ <div class="stat"><div class="stat-label">Key 可用率</div><div class="stat-value" id="statKeys">—</div><div class="stat-sub" id="statKeysSub">—</div></div>
116
+ <div class="stat"><div class="stat-label">近期请求成功率</div><div class="stat-value" id="statReq">—</div><div class="stat-sub" id="statReqSub">按最近一次拉取的日志统计</div></div>
117
+ <div class="stat"><div class="stat-label">近期 Tokens 用量</div><div class="stat-value" id="statTokens">—</div><div class="stat-sub">输入 / 输出 合计</div></div>
118
+ </div>
119
+ <section class="card">
120
+ <div class="card-head"><div><h2 class="card-title">Provider / Key 管理</h2><p class="card-sub">维护服务商、密钥池与轮询权重;下方筛选均在本地完成,不会请求服务端。</p></div></div>
121
+ <form id="providerForm" class="toolbar" autocomplete="off"><input name="id" placeholder="provider id" required><input name="name" placeholder="名称"><input name="baseUrl" placeholder="https://api.example.com" required><select name="protocol"><option>openai</option><option>anthropic</option></select><button class="btn btn-primary">添加 Provider</button></form>
122
+ <form id="keyForm" class="toolbar" autocomplete="off"><select name="provider" required></select><input name="id" placeholder="key id" required><input name="weight" type="number" min="1" step="1" value="1" title="轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比,默认 1"><input name="value" type="password" placeholder="API key 明文(加密存入密钥库)" required autocomplete="new-password"><button class="btn btn-primary">添加 Key</button></form>
123
+ <div class="filter-bar"><input id="keySearch" type="search" placeholder="搜索 provider 名称 / baseUrl / key id / 错误原因…" aria-label="筛选 Key"><select id="keyStatus" aria-label="状态筛选"><option value="all">全部状态</option><option value="available">仅可用</option><option value="unavailable">仅不可用</option></select><span class="match-info" id="keyMatchInfo"></span></div>
124
+ <div id="providers"></div>
125
+ </section>
126
+ <section class="card">
127
+ <div class="card-head"><div><h2 class="card-title">Routes</h2><p class="card-sub">模型到 Provider 的转发映射规则。</p></div></div>
128
+ <form id="routeForm" class="toolbar" autocomplete="off"><input name="model" placeholder="model" required><select name="provider" required></select><button class="btn btn-primary">添加 Route</button></form>
129
+ <div class="table-wrap"><table><thead><tr><th>Model</th><th>Provider</th><th></th></tr></thead><tbody id="routes"></tbody></table></div>
130
+ </section>
131
+ <section class="card">
132
+ <div class="card-head"><div><h2 class="card-title">请求成功 / 失败趋势</h2><p class="card-sub">将最近拉取的日志按时间分桶聚合(最多 30 桶),悬停柱体可查看详情。</p></div><span class="legend"><span><i class="swatch ok"></i>成功</span><span><i class="swatch bad"></i>失败</span></span></div>
133
+ <div class="chart-box"><svg id="traffic" role="img" aria-label="request success/failure trend"></svg><div id="chartEmpty" class="chart-empty" hidden>暂无请求记录</div></div>
134
+ </section>
135
+ <section class="card">
136
+ <div class="card-head"><div><h2 class="card-title">Logs</h2><p class="card-sub">最近的转发日志;关键字与结果类型为本地即时筛选,修改“条数”才会重新拉取。</p></div></div>
137
+ <div class="filter-bar"><input id="logFilter" type="search" placeholder="按 model / key / provider / status 筛选…"><select id="logStatus" aria-label="结果筛选"><option value="all">全部结果</option><option value="ok">仅成功</option><option value="fail">仅失败</option></select><select id="logTail" aria-label="日志条数"><option value="100" selected>最近 100 条</option><option value="300">最近 300 条</option><option value="1000">最近 1000 条</option></select><span class="match-info" id="logMatchInfo"></span></div>
138
+ <div class="table-wrap"><table><thead><tr><th>Time</th><th>Status</th><th>Provider</th><th>Key</th><th>Model</th><th class="num">Duration</th><th class="num">Tokens in/out</th></tr></thead><tbody id="logs"></tbody></table></div>
139
+ </section>
140
+ </main>
141
+ <dialog id="keyEdit"><div class="dialog-body"><h3 class="dialog-title">编辑 Key</h3><form id="keyEditForm" autocomplete="off">
142
+ <label class="field">Provider<input name="provider" readonly></label>
143
+ <label class="field">Key id<input name="id" readonly></label>
144
+ <div class="field-row"><label class="field">权重<input name="weight" type="number" min="1" step="1" value="1" title="轮询权重:该 key 在 Provider 内多个 key 间被选中的相对占比"></label><label class="field">新 API key(留空保持不变)<input name="value" type="password" autocomplete="new-password" placeholder="sk-…"></label></div>
145
+ <footer class="dialog-actions"><button type="button" id="keyEditCancel" class="btn btn-secondary">取消</button><button class="btn btn-primary">保存</button></footer>
146
+ </form></div></dialog>
147
+ <script type="module">
148
+ const $=s=>document.querySelector(s), error=$('#error');
149
+ const NS='http://www.w3.org/2000/svg';
150
+ const pad2=n=>String(n).padStart(2,'0');
151
+ const state={status:null,logs:[],logTail:'100',keyKeyword:'',keyStatus:'all',logKeyword:'',logStatus:'all'};
152
+ async function api(path,init={}){const r=await fetch(path,{...init,headers:{authorization:'Bearer '+sessionStorage.khatSession,'content-type':'application/json',...(init.headers||{})}});const body=await r.json().catch(()=>({}));if(!r.ok)throw Error(body.error?.message||r.statusText);return body}
153
+ async function exchange(){const ticket=new URLSearchParams(location.search).get('ticket');if(sessionStorage.khatSession){if(ticket)window.history.replaceState({},'', '/_keys/ui');return}if(ticket){const r=await fetch('/_keys/ticket/session',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ticket})});if(!r.ok)throw Error('票据无效或已过期');sessionStorage.khatSession=(await r.json()).session;window.history.replaceState({},'', '/_keys/ui')}if(!sessionStorage.khatSession)throw Error('请通过 khat ui 打开此页面')}
154
+ function cell(v,cls){const e=document.createElement('td');if(cls)e.className=cls;e.textContent=v==null?'—':String(v);return e}
155
+ function fmtNum(n){return n>=100000?Math.round(n/1000)+'k':n>=10000?(n/1000).toFixed(1)+'k':String(n)}
156
+ function fmtHM(m){const d=new Date(m*60000);return pad2(d.getHours())+':'+pad2(d.getMinutes())}
157
+ function formatTs(ts){const d=new Date(ts);if(isNaN(d.getTime()))return String(ts);const day=d.toDateString()===new Date().toDateString()?'':(d.getMonth()+1)+'-'+d.getDate()+' ';return day+pad2(d.getHours())+':'+pad2(d.getMinutes())+':'+pad2(d.getSeconds())}
158
+ function errText(k){const http=k.lastError&&k.lastError.http;if(http===undefined)return '—';return ({401:'401 未授权(key 无效或被撤销)',402:'402 余额不足',429:'429 限速'})[http]||('HTTP '+http+' 错误')}
159
+ function btn(label,className,onclick){const b=document.createElement('button');b.type='button';b.className=className;b.textContent=label;b.onclick=onclick;return b}
160
+ function run(fn){return async arg=>{try{await fn(arg)}catch(e){error.textContent=e.message}}}
161
+ function statusCell(ok,text,title){const td=document.createElement('td');const sp=document.createElement('span');sp.className='pill pill-status '+(ok?'pill-ok':'pill-bad');sp.textContent=text;if(title)sp.title=title;td.append(sp);return td}
162
+ function syncProviderSelect(sel,providers){const ids=providers.map(p=>p.id).join(',');if(sel.dataset.ids===ids)return;sel.dataset.ids=ids;const prev=sel.value;sel.replaceChildren(...providers.map(p=>{const o=document.createElement('option');o.value=p.id;o.textContent=p.id;return o}));if(ids.split(',').includes(prev))sel.value=prev}
163
+ function openKeyEdit(providerId,keyId,weight){const d=$('#keyEdit');d.querySelector('[name=provider]').value=providerId;d.querySelector('[name=id]').value=keyId;d.querySelector('[name=weight]').value=weight;d.querySelector('[name=value]').value='';d.showModal();d.querySelector('[name=weight]').focus()}
164
+ function renderConnection(){const c=$('#connection');c.replaceChildren(Object.assign(document.createElement('i'),{className:'dot'}),document.createTextNode(' '+state.status.listen))}
165
+ function renderStats(){const st=state.status;if(!st)return;const keys=[];for(const p of st.providers)for(const k of p.keys)keys.push(k);const avail=keys.filter(k=>k.status==='available').length;$('#statProviders').textContent=String(st.providers.length);$('#statKeys').textContent=keys.length?Math.round(avail*100/keys.length)+'%':'—';$('#statKeysSub').textContent='可用 '+avail+' 个 · 共 '+keys.length+' 个';let ok=0,fail=0,tIn=0,tOut=0;for(const x of state.logs){if(Number(x.status)<400)ok++;else fail++;tIn+=x.tokensIn||0;tOut+=x.tokensOut||0}const total=ok+fail;$('#statReq').textContent=total?Math.round(ok*100/total)+'%':'—';$('#statReqSub').textContent='成功 '+ok+' 次 · 失败 '+fail+' 次';$('#statTokens').textContent=total?fmtNum(tIn)+' / '+fmtNum(tOut):'—'}
166
+ function providerHead(p,keys){const head=document.createElement('div');head.className='provider-head';const nm=document.createElement('h3');nm.className='provider-name';nm.textContent=p.name||p.id;nm.title=p.id;head.append(nm);const pp=document.createElement('span');pp.className='pill protocol-pill';pp.textContent=p.protocol;head.append(pp);const url=document.createElement('span');url.className='provider-url';url.textContent=p.baseUrl||'';url.title=p.baseUrl||'';head.append(url);const cnt=document.createElement('span');cnt.className='provider-count';cnt.textContent=keys.length+(keys.length!==p.keys.length?' / '+p.keys.length:'')+' 个 key';head.append(cnt);return head}
167
+ function renderProviders(){
168
+ const box=$('#providers');box.replaceChildren();
169
+ const st=state.status;if(!st)return;
170
+ const kw=state.keyKeyword.trim().toLowerCase(),stSel=state.keyStatus;
171
+ let shownP=0,shownK=0,totalK=0;
172
+ for(const p of st.providers){
173
+ totalK+=p.keys.length;
174
+ const meta=(p.id+' '+(p.name||'')+' '+(p.baseUrl||'')).toLowerCase();
175
+ const hitMeta=!kw||meta.includes(kw);
176
+ const keys=p.keys.filter(k=>(stSel==='all'||k.status===stSel)&&(!kw||hitMeta||String(k.id).toLowerCase().includes(kw)||errText(k).toLowerCase().includes(kw)));
177
+ if(p.keys.length&&!keys.length)continue;
178
+ if(!p.keys.length&&!hitMeta)continue;
179
+ shownP++;shownK+=keys.length;
180
+ const blk=document.createElement('div');blk.className='provider-block';
181
+ blk.append(providerHead(p,keys));
182
+ const tw=document.createElement('div');tw.className='table-wrap';
183
+ const table=document.createElement('table');
184
+ table.innerHTML='<thead><tr><th>Key</th><th>Weight</th><th>Status</th><th class="num">Requests</th><th class="num">Failed</th><th class="num">Tokens in/out</th><th>错误原因</th><th>操作</th></tr></thead>';
185
+ const tb=document.createElement('tbody');
186
+ for(const k of keys){
187
+ const tr=document.createElement('tr');
188
+ const kt=document.createElement('td');
189
+ const idSpan=document.createElement('span');idSpan.className='key-id';idSpan.textContent=k.id;kt.append(idSpan);
190
+ if(k.secret){const sk=document.createElement('span');sk.className='key-secret';sk.textContent=k.secret;kt.append(sk)}
191
+ tr.append(kt);
192
+ tr.append(cell(k.weight,'num'));
193
+ tr.append(statusCell(k.status==='available',k.status==='available'?'可用':'不可用',k.lastError?errText(k)+(k.lastError.at?' · 发生于 '+k.lastError.at:''):''));
194
+ tr.append(cell(k.counters.requests||0,'num'),cell(k.counters.failed||0,'num'),cell((k.counters.tokensIn||0)+' / '+(k.counters.tokensOut||0),'num'));
195
+ const et=document.createElement('td');et.textContent=errText(k);et.className=k.lastError?'err-text':'err-text none';if(k.lastError)et.title='发生时间 '+k.lastError.at;
196
+ tr.append(et);
197
+ const ac=document.createElement('td');
198
+ const rowA=document.createElement('div');rowA.className='row-actions';
199
+ const wInput=document.createElement('input');wInput.type='number';wInput.min='1';wInput.step='1';wInput.value=k.weight;wInput.title='权重:改完失焦即保存';
200
+ wInput.onchange=run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'PUT',body:JSON.stringify({weight:Number(wInput.value)})});load()});
201
+ rowA.append(wInput);
202
+ for(const pair of [['恢复','enable'],['探活','probe']]){
203
+ const b=btn(pair[0],'btn btn-secondary btn-sm',run(async()=>{await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id)+'/'+pair[1],{method:'POST'});load()}));
204
+ b.disabled=pair[0]==='恢复'&&k.status==='available';
205
+ rowA.append(b);
206
+ }
207
+ rowA.append(btn('编辑','btn btn-secondary btn-sm',()=>openKeyEdit(p.id,k.id,k.weight)));
208
+ rowA.append(btn('删除','btn btn-danger btn-sm',run(async()=>{if(confirm('删除 key '+p.id+'/'+k.id+'?')){await api('/_keys/providers/'+encodeURIComponent(p.id)+'/keys/'+encodeURIComponent(k.id),{method:'DELETE'});load()}})));
209
+ ac.append(rowA);tr.append(ac);
210
+ tb.append(tr);
211
+ }
212
+ if(!keys.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=8;ed.textContent=p.keys.length?'没有符合筛选条件的 key':'该 Provider 尚未添加 key,可在上方表单中添加';er.append(ed);tb.append(er)}
213
+ table.append(tb);tw.append(table);blk.append(tw);box.append(blk);
214
+ }
215
+ $('#keyMatchInfo').textContent='匹配 '+shownK+' / '+totalK+' 个 key · 显示 '+shownP+' 个 Provider';
216
+ if(shownP===0){const em=document.createElement('div');em.className='empty-state';em.textContent='没有匹配的 Provider 或 Key,试试调整关键字或状态筛选。';box.append(em)}
217
+ }
218
+ function renderRoutes(){
219
+ const tb=$('#routes');tb.replaceChildren();
220
+ const rs=state.status?state.status.routes:[];
221
+ if(!rs.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=3;ed.textContent='暂无路由配置';er.append(ed);tb.append(er);return}
222
+ for(const rt of rs){
223
+ const tr=document.createElement('tr');
224
+ tr.append(cell(rt.model,'mono-cell'),cell(rt.provider));
225
+ const d=document.createElement('td');
226
+ d.append(btn('删除','btn btn-danger btn-sm',run(async()=>{await api('/_keys/routes?model='+encodeURIComponent(rt.model),{method:'DELETE'});load()})));
227
+ tr.append(d);tb.append(tr);
228
+ }
229
+ }
230
+ function renderLogs(){
231
+ const tb=$('#logs');tb.replaceChildren();
232
+ const kw=state.logKeyword.trim().toLowerCase();
233
+ const rows=state.logs.slice().reverse().filter(x=>{
234
+ if(state.logStatus==='ok'&&!(Number(x.status)<400))return false;
235
+ if(state.logStatus==='fail'&&!(Number(x.status)>=400))return false;
236
+ if(!kw)return true;
237
+ return [x.model,x.key,x.provider,x.status].some(v=>String(v).toLowerCase().includes(kw));
238
+ });
239
+ $('#logMatchInfo').textContent='显示 '+rows.length+' / '+state.logs.length+' 条';
240
+ if(!rows.length){const er=document.createElement('tr');er.className='empty-row';const ed=document.createElement('td');ed.colSpan=7;ed.textContent=state.logs.length?'没有匹配的日志,试试调整筛选条件。':'暂无请求日志';er.append(ed);tb.append(er);return}
241
+ for(const e of rows){
242
+ const r=document.createElement('tr');
243
+ r.append(cell(formatTs(e.ts),'mono-cell'));
244
+ r.append(statusCell(Number(e.status)<400,String(e.status)));
245
+ r.append(cell(e.provider,'mono-cell'),cell(e.key,'mono-cell'),cell(e.model,'mono-cell'));
246
+ r.append(cell((e.durationMs==null?0:e.durationMs)+'ms','num'));
247
+ r.append(cell((e.tokensIn||0)+' / '+(e.tokensOut||0),'num'));
248
+ tb.append(r);
249
+ }
250
+ }
251
+ function mk(tag,attrs){const el=document.createElementNS(NS,tag);for(const k in attrs)el.setAttribute(k,String(attrs[k]));return el}
252
+ function drawTraffic(){
253
+ const svg=$('#traffic');svg.replaceChildren();
254
+ const emptyBox=$('#chartEmpty');
255
+ const times=[];
256
+ for(const x of state.logs){const t=new Date(x.ts).getTime();if(Number.isFinite(t))times.push({m:Math.floor(t/60000),ok:Number(x.status)<400})}
257
+ if(!times.length){emptyBox.hidden=false;return}
258
+ emptyBox.hidden=true;
259
+ let minM=Infinity,maxM=-Infinity;
260
+ for(const x of times){minM=Math.min(minM,x.m);maxM=Math.max(maxM,x.m)}
261
+ const step=Math.max(1,Math.ceil((maxM-minM+1)/30)),slots=Math.floor((maxM-minM)/step)+1;
262
+ const buckets=new Map();
263
+ for(const x of times){const key=minM+Math.floor((x.m-minM)/step)*step;let b=buckets.get(key);if(!b){b={ok:0,bad:0};buckets.set(key,b)}if(x.ok)b.ok++;else b.bad++}
264
+ const W=Math.max(svg.clientWidth||600,160),H=150,padL=10,padR=10,padB=17,topPad=8,axisY=H-padB;
265
+ let peak=1;buckets.forEach(b=>{peak=Math.max(peak,b.ok+b.bad)});
266
+ const slotW=(W-padL-padR)/slots,bw=Math.min(18,slotW*0.66);
267
+ const axis=mk('line',{x1:padL,y1:axisY,x2:W-padR,y2:axisY,'stroke-width':1});axis.style.stroke='var(--border-strong)';svg.append(axis);
268
+ const labelEvery=Math.max(1,Math.ceil(slots/8));
269
+ for(let i=0;i<slots;i++){
270
+ const startM=minM+i*step,cx=padL+i*slotW+slotW/2,b=buckets.get(startM);
271
+ if(i%labelEvery===0||i===slots-1){const tx=mk('text',{x:cx,y:H-4,'text-anchor':'middle','font-size':'10'});tx.style.fill='var(--faint)';tx.textContent=fmtHM(startM);svg.append(tx)}
272
+ if(!b)continue;
273
+ const total=b.ok+b.bad,h=total/peak*(axisY-topPad),badH=b.bad/total*h,rx=Math.min(3,bw/3);
274
+ const g=document.createElementNS(NS,'g');
275
+ const ti=document.createElementNS(NS,'title');ti.textContent=fmtHM(startM)+':成功 '+b.ok+' 次 · 失败 '+b.bad+' 次';g.append(ti);
276
+ if(b.ok)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h+badH).toFixed(1),width:bw.toFixed(1),height:(h-badH).toFixed(1),rx:rx,fill:'#10b981'}));
277
+ if(b.bad)g.append(mk('rect',{x:(cx-bw/2).toFixed(1),y:(axisY-h).toFixed(1),width:bw.toFixed(1),height:badH.toFixed(1),rx:rx,fill:'#ef4444'}));
278
+ svg.append(g);
279
+ }
280
+ }
281
+ async function load(){
282
+ try{
283
+ await exchange();
284
+ const results=await Promise.all([api('/_keys/status'),api('/_keys/logs?tail='+state.logTail)]);
285
+ state.status=results[0];state.logs=results[1].logs||[];
286
+ error.textContent='';
287
+ renderConnection();
288
+ renderStats();
289
+ syncProviderSelect($('#keyForm [name=provider]'),state.status.providers);
290
+ syncProviderSelect($('#routeForm [name=provider]'),state.status.providers);
291
+ renderProviders();
292
+ renderRoutes();
293
+ renderLogs();
294
+ drawTraffic();
295
+ }catch(e){error.textContent=e.message}
296
+ }
297
+ function applyTheme(t){document.documentElement.setAttribute('data-theme',t);try{localStorage.setItem('khatTheme',t)}catch(e){}const b=$('#themeToggle');b.textContent=t==='dark'?'☀️':'🌙';b.setAttribute('aria-label',t==='dark'?'切换浅色模式':'切换深色模式')}
298
+ $('#themeToggle').onclick=()=>applyTheme(document.documentElement.getAttribute('data-theme')==='dark'?'light':'dark');
299
+ applyTheme(document.documentElement.getAttribute('data-theme')||'light');
300
+ $('#providerForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers',{method:'POST',body:JSON.stringify(x)});e.target.reset();load()});
301
+ $('#keyForm').onsubmit=run(async e=>{e.preventDefault();const x=Object.fromEntries(new FormData(e.target));await api('/_keys/providers/'+encodeURIComponent(x.provider)+'/keys',{method:'POST',body:JSON.stringify({id:x.id,value:x.value,weight:Number(x.weight)||1})});e.target.reset();load()});
302
+ $('#routeForm').onsubmit=run(async e=>{e.preventDefault();await api('/_keys/routes',{method:'POST',body:JSON.stringify(Object.fromEntries(new FormData(e.target)))});e.target.reset();load()});
303
+ $('#keyEditForm').onsubmit=run(async e=>{e.preventDefault();const d=$('#keyEdit'),provider=d.querySelector('[name=provider]').value,id=d.querySelector('[name=id]').value,body={weight:Number(d.querySelector('[name=weight]').value)||1},v=d.querySelector('[name=value]').value.trim();if(v)body.value=v;await api('/_keys/providers/'+encodeURIComponent(provider)+'/keys/'+encodeURIComponent(id),{method:'PUT',body:JSON.stringify(body)});d.close();load()});
304
+ $('#keyEditCancel').onclick=()=>$('#keyEdit').close();
305
+ $('#keySearch').oninput=e=>{state.keyKeyword=e.target.value;renderProviders()};
306
+ $('#keyStatus').onchange=e=>{state.keyStatus=e.target.value;renderProviders()};
307
+ $('#logFilter').oninput=e=>{state.logKeyword=e.target.value;renderLogs()};
308
+ $('#logStatus').onchange=e=>{state.logStatus=e.target.value;renderLogs()};
309
+ $('#logTail').onchange=e=>{state.logTail=e.target.value;load()};
310
+ $('#refresh').onclick=()=>load();
311
+ window.addEventListener('resize',drawTraffic);
312
+ load();setInterval(load,30000);
313
+ </script></body></html>`;
314
+ export class UiSessions {
315
+ tickets = new Map();
316
+ sessions = new Set();
317
+ createTicket() { const t = randomBytes(32).toString('base64url'); this.tickets.set(t, Date.now() + 60000); return t; }
318
+ exchange(t) { const expires = this.tickets.get(t); this.tickets.delete(t); if (expires === undefined || expires < Date.now())
319
+ return undefined; const s = randomBytes(32).toString('base64url'); this.sessions.add(s); return s; }
320
+ valid(s) { return s !== undefined && this.sessions.has(s); }
321
+ }
322
+ export function serveUi(req, res) { const path = new URL(req.url ?? '/', 'http://localhost').pathname; if (path !== '/_keys/ui' && path !== '/_keys/ui/')
323
+ return false; res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }); res.end(PAGE); return true; }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@alilis/k-hat",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "本地 API key 代理管理器,统一管理多个 AI 服务商的密钥并自动轮转",
8
+ "license": "MIT",
9
+ "type": "module",
10
+ "bin": {
11
+ "khat": "dist/cli.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "keywords": [
20
+ "llm",
21
+ "proxy",
22
+ "api-key",
23
+ "key-rotation",
24
+ "openai",
25
+ "anthropic",
26
+ "codex"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/aweirdocc/k-hat.git"
31
+ },
32
+ "homepage": "https://github.com/aweirdocc/k-hat#readme",
33
+ "bugs": {
34
+ "url": "https://github.com/aweirdocc/k-hat/issues"
35
+ },
36
+ "scripts": {
37
+ "build": "tsc -p tsconfig.json",
38
+ "prepare": "npm run build",
39
+ "start": "node dist/cli.js start",
40
+ "khat": "node dist/cli.js",
41
+ "test": "npm run build && node --test test/**/*.test.js"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.10.0",
45
+ "@types/react": "^19.2.18",
46
+ "typescript": "^5.7.2"
47
+ },
48
+ "dependencies": {
49
+ "ink": "^7.1.1",
50
+ "react": "^19.2.8"
51
+ }
52
+ }