mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
feat(auto): AUTO-only priority chain with tiered slots + live source probing; CSV export w/ key names; audit persistence; fix prompt token accounting & deepseek thinking
This commit is contained in:
@ -55,6 +55,7 @@ tr:last-child td { border-bottom:0; }
|
||||
.tag-red { background:var(--tag-err); color:var(--err); }
|
||||
.tag-blue { background:var(--tag-blue); color:var(--accent); }
|
||||
.tag-warn { background:var(--tag-warn); color:var(--warn); }
|
||||
.net-dot { display:inline-block; width:7px; height:7px; border-radius:50%; background:currentColor; margin-right:6px; vertical-align:1px; }
|
||||
button { background:var(--accent); color:#fff; border:0; border-radius:8px; padding:8px 16px; cursor:pointer;
|
||||
font:inherit; font-weight:500; transition:background .15s; }
|
||||
button:hover { background:var(--accent-h); }
|
||||
@ -263,7 +264,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
.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;
|
||||
color:#fff; font-weight:700; font-size:13px; user-select:none; margin-right:0;
|
||||
border-radius:11px 11px 7px 7px; box-shadow:0 6px 0 rgba(0,0,0,.13),
|
||||
inset 0 2px 0 rgba(255,255,255,.28), inset 0 -5px 0 rgba(0,0,0,.07); transition:opacity .15s; }
|
||||
/* 左侧凸榫:宽14px 从块左缘伸出,正好填满左侧凹槽(块间距 7px → 榫左半插进凹槽右半仍露 7px 搭肩) */
|
||||
@ -277,8 +278,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
.scr-row .scr-block:last-child { margin-right:0; }
|
||||
.scr-row .scr-block:last-child::after { display:none; }
|
||||
/* 左右卡扣生长动画(块新获得左凸榫/右凹槽身份时)—— 延迟到位移完成后再弹出 */
|
||||
.scr-block.pre-grow-l::before { transform:translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; }
|
||||
.scr-block.grow-l::before { animation:growlL .22s ease-out .24s both; }
|
||||
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; } }
|
||||
@keyframes growlL { from { transform: translateY(-50%) scaleX(0); transform-origin:100% 50%; opacity:0; } to { transform: translateY(-50%) scaleX(1); opacity:1; } }
|
||||
.scr-block.grow-r::after { animation:growrR .22s ease-out .24s both; }
|
||||
@keyframes growrR { from { transform: translateY(-50%) scaleX(0); transform-origin:0 50%; } }
|
||||
/* 上下卡扣生长动画:新行首块的凸榫/凹槽从根部弹出(列位置定后) */
|
||||
@ -290,7 +292,8 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
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; }
|
||||
max-width:200px; overflow:hidden; text-overflow:ellipsis; display:flex; flex-direction:column; line-height:1.25; }
|
||||
.scr-block .scr-srcname { font-size:9.5px; opacity:.75; font-weight:600; max-width:200px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.scr-block .scr-tag { flex:0 0 auto; font-family:ui-monospace,Menlo,Consolas,monospace; font-size:10.5px;
|
||||
padding:2px 7px; border-radius:9px; background:rgba(0,0,0,.24); color:#ffe9a8;
|
||||
border:1px solid rgba(255,220,130,.35); cursor:pointer; }
|
||||
@ -310,10 +313,12 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
.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-block.drag-src .scr-knob, .scr-block.drag-src .scr-slot,
|
||||
.scr-block.drag-src::before, .scr-block.drag-src::after { visibility:hidden; }
|
||||
.scr-ghost { position:fixed; z-index:200; pointer-events:none; transform:rotate(1.5deg) scale(1.05);
|
||||
filter:drop-shadow(0 16px 20px rgba(0,0,0,.35)); cursor:grabbing; }
|
||||
.scr-ghost .scr-knob, .scr-ghost .scr-slot { display:none; }
|
||||
.scr-ghost .scr-knob, .scr-ghost .scr-slot,
|
||||
.scr-ghost::before, .scr-ghost::after { display:none; }
|
||||
.scr-gap { height:7px; margin:-2px 0 0; border-radius:8px; border:2px dashed transparent; transition:all .12s;
|
||||
display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:11px;
|
||||
letter-spacing:2px; }
|
||||
@ -407,7 +412,7 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
|
||||
const STR = {
|
||||
zh: {
|
||||
tagline:'统一 LLM 网关', logout:'退出登录', langTo:'EN',
|
||||
navStatus:'状态', navChat:'Chat 测试', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
|
||||
navStatus:'状态', navChat:'对话', navSources:'源', navAdapters:'适配器', navSort:'优先级', navKeys:'密钥',
|
||||
keysTitle:'密钥管理', keysHint:'管理员密钥可查看与管理全部密钥,并可为每个用户密钥配置可用模型范围;用户密钥只能看到自己。',
|
||||
kCreate:'新建密钥', kName:'名称', kRole:'角色', kRoleAdmin:'管理员', kRoleUser:'用户', kNote:'备注(可选)', kCreateBtn:'创建',
|
||||
kKey:'密钥', kModels:'可用模型', kCreated:'创建时间', kActions:'操作', kCopy:'复制密钥', kDel:'删除',
|
||||
@ -419,11 +424,11 @@ const STR = {
|
||||
kAll:'不限',
|
||||
kBrickH:'点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动',
|
||||
kCopyB:'复制该模型', kEditB:'编辑', kDelB:'删除',
|
||||
kFormTitle:'模型与 Token 配额', kModelB:'模型', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
||||
kFormTitle:'模型与 Token 配额', kModelB:'模型', kSrcHint:'同一模型多源时请选择具体来源', kAnySrc:'任意源', kQuotaB:'Token 配额', kQuotaHintB:'0 / 留空 = 无限',
|
||||
kPeriodB:'重置周期', kPerNothing:'不限(永不过期)', kPerHour:'每 小时', kPerWeek:'每 周', kPerMonth:'每 月', kPerHours:'每 N 小时', kPerNHint:'小时数',
|
||||
kEditQ:'编辑配额', kClearQ:'清空配额', kDupB:'复制档位', kDelB2:'删除(从链中移除)',
|
||||
kDupOK:'已复制该模型', kMovOK:'已移动', kSaved:'已保存', kEmptyB:'(暂无模型 —— 点击 + 添加)', kAddB:'添加模型',
|
||||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级自动选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||
connTitle:'连接配置(Agent / OpenAI SDK)', connHint:'模型名默认 AUTO,按优先级页面的 AUTO 链选择可用源;点击任一模型可生成固定到该模型的配置。',
|
||||
copyCfg:'一键复制配置', copyEnv:'复制为环境变量',
|
||||
srcTitle:'源状态', srcCount:'共 %d 个',
|
||||
tName:'名称', tAdapter:'适配器', tModels:'模型(点击看配置)', tURL:'地址', tConn:'连接', tConc:'并发',
|
||||
@ -440,7 +445,7 @@ const STR = {
|
||||
modalNew:'新增源', modalEdit:'编辑源',
|
||||
mName:'名称', mURL:'Base URL', mKey:'API Key', mAlias:'适配器', mAliasAuto:'自动', mEp:'聊天端点(可选覆盖)',
|
||||
mImgEp:'生图端点(可选覆盖)', mConc:'并发上限', mTemp:'温度',
|
||||
mModels:'模型列表(优先级越大,AUTO 越优先选择)', mAddModel:'+ 模型',
|
||||
mModels:'模型列表', mAddModel:'+ 模型',
|
||||
mMeta:'Meta(透传给 build_headers 钩子,JSON)', mSave:'保存', mCancel:'取消',
|
||||
toastCopied:'已复制', toastCopyFail:'复制失败,请手动选择复制', toastSaved:'已保存并热重载',
|
||||
toastEmpty:'请输入消息', toastBadJson:'Meta 不是合法 JSON', toastSaveFail:'保存失败: %s',
|
||||
@ -452,7 +457,7 @@ const STR = {
|
||||
sortSave:'保存排序', sortReset:'重置', sortAdd:'添加档位', sortSaved:'排序已保存并热重载', sortNoChange:'无变更', sortHintSave:'点击保存排序后生效',
|
||||
sortSource:'源', sortPrio:'优先级 %s', sortEmpty:'(该源暂无模型)',
|
||||
kpiActive:'活跃请求', kpiReqs:'总请求', kpiOk:'成功率', kpiTokens:'Tokens', kpiLat:'平均延迟', kpiMaxLat:'最大延迟',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录(审计)',
|
||||
dashModel:'模型用量', dashSrc:'源用量与延迟', dashKey:'密钥用量', dashRecs:'请求记录', exportCsv:'导出 CSV', expWeek:'近一周', expMonth:'近一月', expYear:'近一年', expRange:'自定义范围', expStart:'开始日期', expEnd:'结束日期', expDownload:'下载',
|
||||
thModel:'模型', thSrc:'源', thKey:'密钥', thReqs:'请求', thOk:'成功', thErr:'失败',
|
||||
thPrompt:'输入 Tokens', thCompl:'输出 Tokens', thAvgLat:'平均延迟', thMaxLat:'最长延迟',
|
||||
thTime:'时间', thType:'类型', thStatus:'状态', thLatMs:'延迟',
|
||||
@ -472,11 +477,11 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
||||
kAll:'All',
|
||||
kBrickH:'Click to edit model & quota · right-click for more · drag to move to another key',
|
||||
kCopyB:'Copy', kEditB:'Edit', kDelB:'Delete',
|
||||
kFormTitle:'Model & token quota', kModelB:'Model', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited',
|
||||
kFormTitle:'Model & token quota', kModelB:'Model', kSrcHint:'pick a source when the same model exists on several sources', kAnySrc:'any source', kQuotaB:'Token quota', kQuotaHintB:'0 / empty = unlimited',
|
||||
kPeriodB:'Reset period', kPerNothing:'Never', kPerHour:'Every hour', kPerWeek:'Every week', kPerMonth:'Every month', kPerHours:'Every N hours', kPerNHint:'hours',
|
||||
kEditQ:'Edit quota', kClearQ:'Clear quota', kDupB:'Duplicate slot', kDelB2:'Delete (remove from chain)',
|
||||
kDupOK:'Copied', kMovOK:'Moved', kSaved:'Saved', kEmptyB:'(no models yet — click + to add)', kAddB:'Add model',
|
||||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — picks the best healthy source by priority. Click a model to pin it.',
|
||||
connTitle:'Connection config (Agent / OpenAI SDK)', connHint:'Model defaults to AUTO — follows the AUTO chain from the Priority page. Click a model to pin it.',
|
||||
copyCfg:'Copy config', copyEnv:'Copy as env vars',
|
||||
srcTitle:'Sources', srcCount:'%d total',
|
||||
tName:'Name', tAdapter:'Adapter', tModels:'Models', tURL:'URL', tConn:'Status', tConc:'Concurrency',
|
||||
@ -493,19 +498,19 @@ kMeTitle:'My key', kMeRole:'Role', kMeModels:'Models I can use', kMeHint:'Keys c
|
||||
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',
|
||||
mModels:'Models', 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',
|
||||
cErr:'Error: %s', chatMeta:'Model=%s · %s ms · %d chars', connPinned:'# Pinned to model: %s (source %s)', connAuto:'# Model "AUTO" follows the Priority page AUTO chain',
|
||||
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', sortAdd:'Add slot', sortSaved:'Order saved & hot-reloaded', sortNoChange:'No changes', sortHintSave:'Click Save for it to take effect',
|
||||
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)',
|
||||
dashModel:'Model usage', dashSrc:'Source usage & latency', dashKey:'Key usage', dashRecs:'Request records', exportCsv:'Export CSV', expWeek:'Last week', expMonth:'Last month', expYear:'Last year', expRange:'Custom range', expStart:'Start date', expEnd:'End date', expDownload:'Download',
|
||||
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',
|
||||
@ -589,7 +594,7 @@ async function renderStatus() {
|
||||
`<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.live_available ? `<span class="tag tag-green"><i class="net-dot"></i>${t('online')}</span>` : `<span class="tag tag-red" title="${esc(x.last_error || '')}"><i class="net-dot"></i>${t('offline')}</span>`}</td>
|
||||
<td>${x.max_concurrent}</td></tr>`).join('');
|
||||
$('#tab-status').innerHTML = `
|
||||
<div class="kpis" id="kpi-row"></div>
|
||||
@ -601,21 +606,21 @@ async function renderStatus() {
|
||||
<label>${t('tModels')}</label>
|
||||
<div class="chips" id="model-chips"></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="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="card"><h2><span>${t('dashRecs')}</span><span class="grow"></span><button class="ghost small" onclick="openExportModal()">${t('exportCsv')}</button></h2>
|
||||
<div class="filter-line">
|
||||
<span class="muted">${t('recFilter')}</span>
|
||||
<select id="rec-key" onchange="renderKeyF(this.value)"></select>
|
||||
</div>
|
||||
<div class="recs-scroll" id="tb-recs"></div>
|
||||
</div>
|
||||
<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>
|
||||
@ -636,6 +641,35 @@ function renderKeySelect(keys) {
|
||||
if (cur) sel.value = cur;
|
||||
}
|
||||
function renderKeyF(v) { statsKeyF = v; paintStats(); }
|
||||
function openExportModal() {
|
||||
const now = new Date();
|
||||
const ago = d => { const x = new Date(now); x.setDate(x.getDate() - d); return x.toISOString().slice(0, 10); };
|
||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||
wrap.innerHTML = `<div class="card" style="width:420px;max-width:100%"><h2>${t('exportCsv')}</h2>
|
||||
<p><button onclick="downloadStatsCsv(${Date.now() - 7*864e5}, ${Date.now()})">${t('expWeek')}</button>
|
||||
<button onclick="downloadStatsCsv(${Date.now() - 30*864e5}, ${Date.now()})">${t('expMonth')}</button>
|
||||
<button onclick="downloadStatsCsv(${Date.now() - 365*864e5}, ${Date.now()})">${t('expYear')}</button></p>
|
||||
<label>${t('expRange')}</label>
|
||||
<div class="row"><div><label>${t('expStart')}</label><input id="exp-from" type="date" value="${ago(7)}"></div>
|
||||
<div><label>${t('expEnd')}</label><input id="exp-to" type="date" value="${now.toISOString().slice(0, 10)}"></div></div>
|
||||
<p><button onclick="downloadStatsCsvFromForm()">${t('expDownload')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
}
|
||||
function downloadStatsCsv(from, to) {
|
||||
const q = new URLSearchParams({ export: 'csv', from: String(Math.floor(from)), to: String(Math.floor(to)) });
|
||||
if (statsKeyF) q.set('key', statsKeyF);
|
||||
location.href = '/api/stats?' + q.toString();
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
}
|
||||
function downloadStatsCsvFromForm() {
|
||||
const f = $('#exp-from').value, t0 = $('#exp-to').value;
|
||||
const from = f ? new Date(f + 'T00:00:00').getTime() : 0;
|
||||
const to = t0 ? new Date(t0 + 'T23:59:59').getTime() : Date.now();
|
||||
downloadStatsCsv(from, to);
|
||||
}
|
||||
async function paintStats() {
|
||||
try {
|
||||
const q = '/api/stats?limit=500' + (statsKeyF ? '&key=' + encodeURIComponent(statsKeyF) : '');
|
||||
@ -652,8 +686,8 @@ async function paintStats() {
|
||||
<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 || []);
|
||||
paintKeyTable(st.by_key || [], st.key_names || {});
|
||||
paintRecords(st.records || [], st.key_names || {});
|
||||
renderKeySelect((st.by_key || []).map(k => k.name));
|
||||
} catch (e) { console.error('[stats]', e); }
|
||||
}
|
||||
@ -682,16 +716,16 @@ function paintSrcTable(rows) {
|
||||
<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) {
|
||||
function paintKeyTable(rows, keyNames) {
|
||||
const el = $('#tb-key'); if (!el) return;
|
||||
if (!rows.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
el.innerHTML = `<div class="tbl-wrap"><table><tr><th>${t('thKey')}</th><th class="num">${t('thReqs')}</th><th class="num">${t('thOk')}</th><th class="num">${t('thErr')}</th>
|
||||
<th class="num">${t('thTokens')}</th><th class="num">${t('thAvgLat')}</th></tr>` +
|
||||
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(r.name)}</button></td>
|
||||
rows.map(r => `<tr><td><button class="ghost small" onclick="renderKeyF('${escAttr(r.name)}')">${esc(keyNames[r.name] ? keyNames[r.name] + ' · ' + r.name : r.name)}</button></td>
|
||||
<td class="num">${fmtN(r.reqs)}</td><td class="num okc">${fmtN(r.ok)}</td><td class="num errc">${fmtN(r.err)}</td>
|
||||
<td class="num">${fmtN(r.tokens)}</td><td class="num">${fmtMs(fmtLat(r.latency_sum_ms, r.reqs))}</td></tr>`).join('') + '</table></div>';
|
||||
}
|
||||
function paintRecords(records) {
|
||||
function paintRecords(records, keyNames) {
|
||||
const el = $('#tb-recs'); if (!el) return;
|
||||
if (!records.length) { el.innerHTML = `<div class="muted">${t('noUsage')}</div>`; return; }
|
||||
el.innerHTML = `<table><tr><th>${t('thTime')}</th><th class="num">${t('thStatus')}</th><th>${t('thKey')}</th><th>${t('thType')}</th><th>${t('thModel')}</th><th>${t('thSrc')}</th>
|
||||
@ -699,7 +733,7 @@ function paintRecords(records) {
|
||||
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>${esc(keyNames[r.key] ? keyNames[r.key] + ' · ' + r.key : r.key)}</td><td class="t-tag">${esc(r.type)}</td><td>${esc(r.model)}</td><td>${esc(r.source || '')}</td>
|
||||
<td class="num">${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) {
|
||||
@ -957,7 +991,7 @@ function removeChatImg(i) { chatImages.splice(i, 1); renderChatImgs(); }
|
||||
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>${s.models.map(m => `<span class="tag tag-blue">${esc(m.id)}</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 = `
|
||||
@ -1006,9 +1040,8 @@ function editSource(name) {
|
||||
}).catch(() => {});
|
||||
}
|
||||
function modelRow(m, i) {
|
||||
return `<div class="model-row">
|
||||
return `<div class="model-row" data-priority="${m.priority || 0}">
|
||||
<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>`;
|
||||
@ -1020,7 +1053,7 @@ function addModelRow() {
|
||||
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,
|
||||
priority: parseInt(row.dataset.priority) || 0,
|
||||
kind: row.querySelector('.m-kind').value,
|
||||
})).filter(m => m.id);
|
||||
let meta = {};
|
||||
@ -1062,41 +1095,44 @@ 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);
|
||||
models.forEach(m => m.uid = nexUid());
|
||||
return { prio, models };
|
||||
});
|
||||
// attach per-block quota meta from the AUTO rules (rules are ordered exactly
|
||||
// like the chain; each block of a model instance gets its own rule)
|
||||
let autoR = [];
|
||||
try { autoR = (await api('/api/auto')).rules || []; } catch (e) {}
|
||||
const un = autoR.slice();
|
||||
sortState.lanes.forEach(lane => lane.models.forEach(m => {
|
||||
const ri = un.findIndex(r => r.model === m.id);
|
||||
if (ri >= 0) {
|
||||
const r = un.splice(ri, 1)[0];
|
||||
m.meta = { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 };
|
||||
} else m.meta = null;
|
||||
const byModel = new Map();
|
||||
const byPair = new Map();
|
||||
const sourceRows = new Map();
|
||||
j.sources.forEach(s => (s.models || []).forEach(m => {
|
||||
if (m.kind === 'image') return;
|
||||
const it = { src: s.name, id: m.id, prio: m.priority || 0 };
|
||||
byPair.set(m.id + '|' + s.name, it);
|
||||
if (!byModel.has(m.id)) byModel.set(m.id, it);
|
||||
if (!sourceRows.has(it.prio)) sourceRows.set(it.prio, []);
|
||||
sourceRows.get(it.prio).push({ src: s.name, id: m.id });
|
||||
}));
|
||||
// orphan slots: rules left over after attaching one rule per source block
|
||||
// (a model may legitimately appear twice in the chain -> its 2nd..nth rules
|
||||
// become extra slots, preserving the multi-tier schedule)
|
||||
un.forEach(r => {
|
||||
sortState.lanes.push({ prio: 0, models: [{ src: '*', id: r.model, uid: nexUid(),
|
||||
meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 } }] });
|
||||
});
|
||||
// model picker for the "add slot" control
|
||||
let addModels = [];
|
||||
try { addModels = (await api('/api/status')).models || []; } catch (e) {}
|
||||
allModels = addModels;
|
||||
if (autoR.length) {
|
||||
const tiers = [];
|
||||
autoR.forEach((r, i) => {
|
||||
const src = normSrc(r.source);
|
||||
const pair = src ? byPair.get(r.model + '|' + src) : null;
|
||||
if (src && !pair) return;
|
||||
const ref = pair || byModel.get(r.model) || { src: '*', id: r.model };
|
||||
const ti = Math.max(1, parseInt(r.tier) || (i + 1)) - 1;
|
||||
if (!tiers[ti]) tiers[ti] = [];
|
||||
tiers[ti].push({
|
||||
src: ref.src || '*', id: r.model, uid: nexUid(),
|
||||
meta: { quota: r.token_quota || 0, period: r.period || '', hours: r.hours || 0 },
|
||||
});
|
||||
});
|
||||
sortState.lanes = tiers.filter(Boolean).map((models, i) => ({ prio: (tiers.length - i) * 10, models }));
|
||||
} else {
|
||||
sortState.lanes = [...sourceRows.entries()].sort((a, b) => b[0] - a[0]).map(([prio, models]) => {
|
||||
models.sort((x, y) => x.src < y.src ? -1 : x.src > y.src ? 1 : 0);
|
||||
models.forEach(m => { m.uid = nexUid(); m.meta = null; });
|
||||
return { prio, models };
|
||||
});
|
||||
}
|
||||
// model picker for the "add slot" control: one option per (source, model)
|
||||
// pair so identical model ids on different sources stay distinguishable
|
||||
await loadModelPairs();
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
$('#tab-sort').innerHTML = `
|
||||
<div class="card"><h2><span>${t('sortTitle')}</span><span class="grow"></span>
|
||||
@ -1112,17 +1148,17 @@ async function renderSort() {
|
||||
</div>`;
|
||||
paintSort();
|
||||
}
|
||||
function scrBlockHtml(it, isFirst, li, ji) {
|
||||
function scrBlockHtml(it, isFirst, li, ji, extraClass) {
|
||||
const c = srcColor(it.src);
|
||||
const s = srcShort(it.src);
|
||||
return `
|
||||
<div class="scr-block" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
|
||||
<div class="scr-block ${escAttr(extraClass || '')}" data-key="${escAttr(it.uid)}" data-li="${li}" data-ji="${ji}"
|
||||
style="background:linear-gradient(135deg,${c[0]},${c[1]})"
|
||||
onclick="scrCtx(event,'${li}','${ji}')"
|
||||
oncontextmenu="scrCtx(event,'${li}','${ji}')">
|
||||
${isFirst ? '<i class="scr-knob"></i><i class="scr-slot"></i>' : ''}
|
||||
<span class="scr-ico">${esc(it.src === '*' ? '+' : s)}</span>
|
||||
<span class="scr-name">${esc(it.id)}</span>
|
||||
<span class="scr-name">${esc(it.id)}<em class="scr-srcname">${esc(it.src === '*' ? t('kAnySrc') : it.src)}</em></span>
|
||||
${it.meta ? `<span class="scr-tag">${esc(quantBadge(it.meta.quota, it.meta.period, it.meta.hours))}</span>` : ''}
|
||||
<span class="scr-x" title="${escAttr(t('kDelB2'))}" onclick="event.stopPropagation();scrDelSlot('${li}','${ji}')">×</span>
|
||||
<span class="scr-grip"><i></i><i></i><i></i></span>
|
||||
@ -1178,6 +1214,10 @@ function paintSortNow(affected) {
|
||||
});
|
||||
cv.querySelectorAll('.scr-lane').forEach(l => prev.set('lane:' + l.dataset.lane, l.getBoundingClientRect()));
|
||||
cv.querySelectorAll('.scr-block').forEach(b => prev.set('blk:' + b.dataset.key, b.getBoundingClientRect()));
|
||||
const preGrowL = new Set();
|
||||
sortState.lanes.forEach(lane => lane.models.forEach((m, i) => {
|
||||
if (i > 0 && (prevFirst.has(m.uid) || !prev.has('blk:' + m.uid))) preGrowL.add(m.uid);
|
||||
}));
|
||||
const html = [];
|
||||
sortState.lanes.forEach((lane, li) => {
|
||||
const n = sortState.lanes.length - li;
|
||||
@ -1185,7 +1225,7 @@ function paintSortNow(affected) {
|
||||
html.push(`<div class="scr-gap" data-gap="${li}"></div>`);
|
||||
html.push(`<div class="${cls}" data-lane="${li}">
|
||||
<div class="scr-tier">${Array.from({ length: n }, () => '<span></span>').join('')}</div>
|
||||
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i)).join('')}</div>
|
||||
<div class="scr-row">${lane.models.map((m, i) => scrBlockHtml(m, i === 0, li, i, preGrowL.has(m.uid) ? 'pre-grow-l' : '')).join('')}</div>
|
||||
</div>`);
|
||||
});
|
||||
html.push(`<div class="scr-gap" data-gap="${sortState.lanes.length}"></div>`);
|
||||
@ -1243,7 +1283,7 @@ function paintSortNow(affected) {
|
||||
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 (!isFirst && (prevFirst.has(key) || !prev.has('blk:' + key))) { b.classList.remove('pre-grow-l'); void b.offsetWidth; b.classList.add('grow-l'); }
|
||||
if (!isLast && prevLast.has(key)) b.classList.add('grow-r');
|
||||
});
|
||||
});
|
||||
@ -1432,41 +1472,22 @@ function sortReset() {
|
||||
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);
|
||||
try { await persistAuto(); } catch (e) { toast(e.message); }
|
||||
try {
|
||||
await persistAuto();
|
||||
sortState.origin = JSON.stringify(sortState.lanes);
|
||||
toast(t('sortSaved'));
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
/* ---------- AUTO quota editing on sort blocks ---------- */
|
||||
async function persistAuto() {
|
||||
const rules = [];
|
||||
sortState.lanes.forEach(lane => lane.models.forEach(it => {
|
||||
sortState.lanes.forEach((lane, li) => lane.models.forEach(it => {
|
||||
const m = it.meta;
|
||||
rules.push({
|
||||
model: it.id,
|
||||
source: it.src === '*' ? undefined : it.src,
|
||||
tier: li + 1,
|
||||
token_quota: m && m.quota ? +m.quota : 0,
|
||||
period: m && m.period ? m.period : '',
|
||||
hours: m && m.hours ? +m.hours : 0,
|
||||
@ -1479,8 +1500,10 @@ function scrAddModal() {
|
||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('sortAdd')}</h2>
|
||||
<label>${t('kModelB')}</label>
|
||||
<select id="a-model">${allModels.map(m => `<option value="${escAttr(m)}">${esc(m)}</option>`).join('')}</select>
|
||||
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
|
||||
<select id="a-model">${allModels.map(m => typeof m === 'string'
|
||||
? `<option value="${escAttr(m)}">${esc(m)}</option>`
|
||||
: `<option value="${escAttr(m.key)}">${esc(m.label)}</option>`).join('')}</select>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="a-quota" type="number" min="0" step="1" placeholder="${escAttr(t('kQuotaHintB'))}">
|
||||
<label>${t('kPeriodB')}</label>
|
||||
@ -1504,14 +1527,15 @@ function scrAddModal() {
|
||||
$('#a-model').focus();
|
||||
}
|
||||
function scrAddFromForm() {
|
||||
const model = $('#a-model').value.trim();
|
||||
if (!model) { toast(t('kName')); return; }
|
||||
const raw = $('#a-model').value.trim();
|
||||
if (!raw) { toast(t('kName')); return; }
|
||||
const [id, src] = raw.split('|');
|
||||
let q = parseInt($('#a-quota').value);
|
||||
if (isNaN(q) || q < 0) q = 0;
|
||||
const p = $('#a-period').value;
|
||||
let h = parseInt($('#a-hours').value);
|
||||
if (isNaN(h) || h < 1) h = 1;
|
||||
sortState.lanes.push({ models: [{ src: '*', id: model, uid: nexUid(), meta: { quota: q, period: p, hours: (p || q) ? h : 0 } }] });
|
||||
sortState.lanes.push({ models: [{ src: src || '*', id: id, uid: nexUid(), meta: { quota: q, period: p, hours: (p || q) ? h : 0 } }] });
|
||||
const li = sortState.lanes.length - 1;
|
||||
const w = $('#modal-wrap'); if (w) w.remove();
|
||||
paintSort([li]);
|
||||
@ -1544,7 +1568,7 @@ function sortScopeEdit(li, ji) {
|
||||
const cur = it.meta || { quota: 0, period: '', hours: 0 };
|
||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}</h2>
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')} · ${esc(it.id)}${it.src !== '*' ? ' · ' + esc(it.src) : ''}</h2>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="q-quota" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.quota || ''}">
|
||||
@ -1654,6 +1678,17 @@ async function delAdapter(name) {
|
||||
/* ---------- keys tab ---------- */
|
||||
let allModels = [];
|
||||
let scopeDragEl = null;
|
||||
async function loadModelPairs() {
|
||||
let srcs = [];
|
||||
try { srcs = (await api('/api/sources')).sources || []; } catch (e) {}
|
||||
const pairs = [];
|
||||
(srcs || []).forEach(s => (s.models || []).forEach(m => {
|
||||
if (m.kind === 'image') return;
|
||||
pairs.push({ src: s.name, id: m.id, label: m.id + ' · ' + s.name, key: m.id + '|' + s.name });
|
||||
}));
|
||||
if (pairs.length) { allModels = pairs; return; }
|
||||
try { allModels = (await api('/api/status')).models || []; } catch (e) { allModels = []; }
|
||||
}
|
||||
function maskKey(k) { return k.length > 12 ? k.slice(0, 6) + '…' + k.slice(-6) : k; }
|
||||
function fmtCreated(ts) { if (!ts) return '—'; const d = new Date(ts * 1000);
|
||||
const p = x => String(x).padStart(2, '0');
|
||||
@ -1674,15 +1709,13 @@ async function renderKeysUser(me) {
|
||||
<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" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>`).join('')
|
||||
? me.models.map(m => { const src = normSrc(m.source); return `<span class="tag tag-blue" title="${m.token_quota ? 'quota ' + fmtQuota(m.token_quota) : t('kQuotaUnlim')}">${esc(m.model)}${src ? ' · ' + esc(src) : ''}${m.token_quota ? ' · ' + esc(fmtQuota(m.token_quota)) : ''}</span>` }).join('')
|
||||
: `<span class="scope-unlim">${t('kAll')}</span>`}</td></tr></table>
|
||||
<div 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;
|
||||
await loadModelPairs();
|
||||
$('#tab-keys').innerHTML = `
|
||||
<div class="key-actions">
|
||||
<span class="grow"></span>
|
||||
@ -1712,6 +1745,7 @@ function keyCanvasHtml(k) {
|
||||
<span class="grow"></span>
|
||||
<span class="muted">${fmtCreated(k.created_at)}</span>
|
||||
<button class="ghost small errc" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">${t('kDel')}</button>
|
||||
<button class="ghost small errc" title="${escAttr(t('kDel'))}" onclick="delKey('${escAttr(k.key)}','${escAttr(k.name || '')}')">×</button>
|
||||
</div>
|
||||
<div class="kc-blocks" data-key="${escAttr(k.key)}">
|
||||
${scopes.length ? '' : `<span class="mb-empty">${t('kEmptyB')}</span>`}
|
||||
@ -1722,17 +1756,23 @@ function keyCanvasHtml(k) {
|
||||
}
|
||||
function scopeHtml(key, m) {
|
||||
const qt = fmtQuota(m.token_quota);
|
||||
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(m.model)}"
|
||||
const comb = scopeComb(m);
|
||||
const src = normSrc(m.source);
|
||||
const attrs = `data-key="${escAttr(key)}" data-model="${escAttr(comb)}"
|
||||
data-quota="${m.token_quota || 0}" data-period="${escAttr(m.period || '')}" data-hours="${m.hours || 0}"`;
|
||||
return `<span class="mb" draggable="true" ${attrs} title="${escAttr(t('kBrickH'))}"
|
||||
onclick="scopeEdit('${escAttr(key)}','${escAttr(m.model)}')"
|
||||
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(m.model)}')">
|
||||
onclick="scopeEdit('${escAttr(key)}','${escAttr(comb)}')"
|
||||
oncontextmenu="scopeCtx(event,'${escAttr(key)}','${escAttr(comb)}')">
|
||||
<span class="mb-ico">◆</span>
|
||||
<span class="mb-name">${esc(m.model)}</span>
|
||||
<span class="mb-name">${esc(m.model)}${src ? `<em class="mb-src">${esc(src)}</em>` : ''}</span>
|
||||
<span class="mb-quota">${esc(quantBadge(m.token_quota, m.period, m.hours))}</span>
|
||||
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(m.model)}')">⧉</span>
|
||||
<span class="copy-b" title="${escAttr(t('kCopyB'))}" onclick="event.stopPropagation();scopeDup('${escAttr(key)}','${escAttr(comb)}')">⧉</span>
|
||||
</span>`;
|
||||
}
|
||||
function normSrc(s) { return (!s || s === 'undefined' || s === 'null') ? '' : s; }
|
||||
function scopeComb(m) { const s = normSrc(m.source); return s ? m.model + '|' + s : m.model; }
|
||||
function splitCombKey(comb) { const p = String(comb).split('|'); return { model: p[0], source: normSrc(p[1]) }; }
|
||||
function scopeUncomb(comb) { const p = String(comb).split('|'); return { model: p[0], source: p[1] || '' }; }
|
||||
function fmtQuota(q) { q = +q || 0; if (!q) return '∞';
|
||||
if (q >= 1e9) return (q / 1e9).toFixed(1) + 'B';
|
||||
if (q >= 1e6) return (q / 1e6).toFixed(1) + 'M';
|
||||
@ -1753,12 +1793,17 @@ function quantBadge(quota, period, hours) {
|
||||
return fmtQuota(quota) + periodText(period, hours);
|
||||
}
|
||||
function readScopes(canvas) {
|
||||
return [...canvas.querySelectorAll('.mb')].map(b => ({
|
||||
model: b.dataset.model,
|
||||
token_quota: parseInt(b.dataset.quota) || 0,
|
||||
period: b.dataset.period || '',
|
||||
hours: parseInt(b.dataset.hours) || 0,
|
||||
}));
|
||||
return [...canvas.querySelectorAll('.mb')].map(b => {
|
||||
const comb = b.dataset.model.split('|');
|
||||
const src = comb.length > 1 ? normSrc(comb[1]) : '';
|
||||
return {
|
||||
model: comb[0],
|
||||
source: src || undefined,
|
||||
token_quota: parseInt(b.dataset.quota) || 0,
|
||||
period: b.dataset.period || '',
|
||||
hours: parseInt(b.dataset.hours) || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
async function putScope(key, scopes) {
|
||||
await api('/api/keys/' + encodeURIComponent(key), { method: 'PUT', body: JSON.stringify({ models: scopes }) });
|
||||
@ -1772,48 +1817,51 @@ async function scopePush(key) {
|
||||
await loadKeys();
|
||||
scopeEdit(key, 'AUTO');
|
||||
}
|
||||
async function scopeDup(key, model) {
|
||||
async function scopeDup(key, comb) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const scopes = readScopes(canvas);
|
||||
const src = scopes.find(s => s.model === model);
|
||||
const src = scopes.find(s => scopeComb(s) === comb);
|
||||
if (!src) return;
|
||||
scopes.push({ model: src.model, token_quota: src.token_quota });
|
||||
scopes.push({ model: src.model, source: src.source, token_quota: src.token_quota });
|
||||
try { await putScope(key, scopes); toast(t('kDupOK')); } catch (e) { toast(e.message); }
|
||||
}
|
||||
async function scopeRm(key, model) {
|
||||
async function scopeRm(key, comb) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const scopes = readScopes(canvas).filter(s => s.model !== model);
|
||||
const scopes = readScopes(canvas).filter(s => scopeComb(s) !== comb);
|
||||
try { await putScope(key, scopes); } catch (e) { toast(e.message); return; }
|
||||
await loadKeys();
|
||||
toast(t('toastDelOk'));
|
||||
}
|
||||
function scopeEdit(key, model) {
|
||||
function scopeEdit(key, comb) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const cur = readScopes(canvas).find(s => s.model === model) || { model: '', token_quota: 0 };
|
||||
const sc = readScopes(canvas).find(s => scopeComb(s) === comb)
|
||||
|| { model: '', source: '', token_quota: 0, period: '', hours: 0 };
|
||||
const curM = sc.model;
|
||||
const wrap = document.createElement('div'); wrap.id = 'modal-wrap';
|
||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(15,22,44,.45);display:flex;align-items:flex-start;justify-content:center;overflow:auto;padding:48px 20px;z-index:50';
|
||||
const opts = ['AUTO', ...allModels];
|
||||
if (cur.model && !opts.includes(cur.model)) opts.unshift(cur.model);
|
||||
const opts = [{ key: 'AUTO', label: 'AUTO' }].concat(allModels.map(m => typeof m === 'string'
|
||||
? { key: m, label: m } : { key: m.key, label: m.label }));
|
||||
if (curM && !opts.some(o => o.key === comb)) opts.unshift({ key: comb, label: comb });
|
||||
wrap.innerHTML = `<div class="card" style="width:400px;max-width:100%"><h2>${t('kFormTitle')}</h2>
|
||||
<label>${t('kModelB')}</label>
|
||||
<select id="sc-model">${opts.map(m => `<option value="${escAttr(m)}" ${m === cur.model ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select>
|
||||
<label>${t('kModelB')} <span class="muted">${t('kSrcHint')}</span></label>
|
||||
<select id="sc-model">${opts.map(o => `<option value="${escAttr(o.key)}" ${o.key === comb ? 'selected' : ''}>${esc(o.label)}</option>`).join('')}</select>
|
||||
<label>${t('kQuotaB')} <span class="muted">${t('kQuotaHintB')}</span></label>
|
||||
<input id="sc-quota" type="number" min="0" step="1"
|
||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${cur.token_quota ? cur.token_quota : ''}">
|
||||
placeholder="${escAttr(t('kQuotaHintB'))}" value="${sc.token_quota ? sc.token_quota : ''}">
|
||||
<label>${t('kPeriodB')}</label>
|
||||
<select id="sc-period">
|
||||
<option value="" ${!cur.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
||||
<option value="hour" ${cur.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
||||
<option value="week" ${cur.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
||||
<option value="month" ${cur.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
||||
<option value="nhour" ${cur.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
||||
<option value="" ${!sc.period ? 'selected' : ''}>${t('kPerNothing')}</option>
|
||||
<option value="hour" ${sc.period === 'hour' ? 'selected' : ''}>${t('kPerHour')}</option>
|
||||
<option value="week" ${sc.period === 'week' ? 'selected' : ''}>${t('kPerWeek')}</option>
|
||||
<option value="month" ${sc.period === 'month' ? 'selected' : ''}>${t('kPerMonth')}</option>
|
||||
<option value="nhour" ${sc.period === 'nhour' ? 'selected' : ''}>${t('kPerHours')}</option>
|
||||
</select>
|
||||
<div id="sc-hours-box" style="display:none"><label>${t('kPerNHint')}</label>
|
||||
<input id="sc-hours" type="number" min="1" step="1" value="${cur.hours || 24}"></div>
|
||||
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(cur.model)}', this)">${t('kSaveScope')}</button>
|
||||
<input id="sc-hours" type="number" min="1" step="1" value="${sc.hours || 24}"></div>
|
||||
<p><button onclick="scopeSave('${escAttr(key)}','${escAttr(comb)}', this)">${t('kSaveScope')}</button>
|
||||
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t('mCancel')}</button></p>
|
||||
</div>`;
|
||||
document.body.appendChild(wrap);
|
||||
@ -1823,20 +1871,22 @@ function scopeEdit(key, model) {
|
||||
});
|
||||
$('#sc-model').focus();
|
||||
}
|
||||
async function scopeSave(key, oldModel, btn) {
|
||||
async function scopeSave(key, oldComb, btn) {
|
||||
const canvas = document.querySelector(`.key-canvas[data-key="${CSS.escape(key)}"]`);
|
||||
if (!canvas) return;
|
||||
const model = $('#sc-model').value.trim();
|
||||
if (!model) { toast(t('kName')); return; }
|
||||
const comb = $('#sc-model').value.trim();
|
||||
if (!comb) { toast(t('kName')); return; }
|
||||
const parts = splitCombKey(comb);
|
||||
let q = parseInt($('#sc-quota').value);
|
||||
if (isNaN(q) || isNaN(q) || q < 0) q = 0;
|
||||
if (isNaN(q) || q < 0) q = 0;
|
||||
let hours = parseInt($('#sc-hours').value);
|
||||
if (isNaN(hours) || hours < 1) hours = 1;
|
||||
const period = $('#sc-period').value;
|
||||
const scopes = readScopes(canvas);
|
||||
const i = scopes.findIndex(s => s.model === oldModel);
|
||||
if (i < 0) scopes.push({ model, token_quota: q, period, hours });
|
||||
else scopes[i] = { model, token_quota: q, period, hours };
|
||||
const entry = { model: parts.model, source: parts.source || undefined, token_quota: q, period, hours };
|
||||
const i = scopes.findIndex(s => scopeComb(s) === oldComb);
|
||||
if (i < 0) scopes.push(entry);
|
||||
else scopes[i] = entry;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await putScope(key, scopes);
|
||||
@ -1892,16 +1942,16 @@ function bindCanvasDrop(cv) {
|
||||
moveBrick(scopeDragEl.key, cv.dataset.key, scopeDragEl.model);
|
||||
});
|
||||
}
|
||||
async function moveBrick(from, to, model) {
|
||||
async function moveBrick(from, to, comb) {
|
||||
if (from === to) return;
|
||||
const sf = document.querySelector(`.key-canvas[data-key="${CSS.escape(from)}"]`);
|
||||
const st = document.querySelector(`.key-canvas[data-key="${CSS.escape(to)}"]`);
|
||||
if (!sf || !st) return;
|
||||
let fs = readScopes(sf), ts = readScopes(st);
|
||||
const b = fs.find(s => s.model === model);
|
||||
const b = fs.find(s => scopeComb(s) === comb);
|
||||
if (!b) return;
|
||||
fs = fs.filter(s => s.model !== model);
|
||||
const dup = ts.some(s => s.model === model);
|
||||
fs = fs.filter(s => scopeComb(s) !== comb);
|
||||
const dup = ts.some(s => scopeComb(s) === comb);
|
||||
if (!dup) ts.push(b);
|
||||
try {
|
||||
await putScope(from, fs);
|
||||
|
||||
Reference in New Issue
Block a user