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:
root
2026-08-10 00:10:19 +08:00
parent 859d310ad3
commit d48b993010
10 changed files with 663 additions and 210 deletions

View File

@ -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,'&quot;')})">${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()">&times;</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);