@ -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 : 7 px ; height : 7 px ; border-radius : 50 % ; background : currentColor ; margin-right : 6 px ; vertical-align : 1 px ; }
button { background : var ( - - accent ) ; color : #fff ; border : 0 ; border-radius : 8 px ; padding : 8 px 16 px ; cursor : pointer ;
font : inherit ; font-weight : 500 ; transition : background .15 s ; }
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 : 10 px ; padding : 12 px 14 px 12 px 20 px ;
color : #fff ; font-weight : 700 ; font-size : 13 px ; user-select : none ; margin-right : 7 px ;
color : #fff ; font-weight : 700 ; font-size : 13 px ; user-select : none ; margin-right : 0 ;
border-radius : 11 px 11 px 7 px 7 px ; box-shadow : 0 6 px 0 rgba ( 0 , 0 , 0 , .13 ) ,
inset 0 2 px 0 rgba ( 255 , 255 , 255 , .28 ) , inset 0 -5 px 0 rgba ( 0 , 0 , 0 , .07 ) ; transition : opacity .15 s ; }
/* 左侧凸榫: 宽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 .22 s ease-out .24 s 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 .22 s ease-out .24 s 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 : 11 px ; font-weight : 800 ; letter-spacing : .5 px ;
background : rgba ( 0 , 0 , 0 , .2 ) ; }
. scr-block . scr-name { white-space : nowrap ; font-family : ui-monospace , Menlo , Consolas , monospace ; font-size : 12 px ;
max-width : 200 px ; overflow : hidden ; text-overflow : ellipsis ; }
max-width : 200 px ; overflow : hidden ; text-overflow : ellipsis ; display : flex ; flex-direction : column ; line-height : 1.25 ; }
. scr-block . scr-srcname { font-size : 9.5 px ; opacity : .75 ; font-weight : 600 ; max-width : 200 px ; overflow : hidden ; text-overflow : ellipsis ; }
. scr-block . scr-tag { flex : 0 0 auto ; font-family : ui-monospace , Menlo , Consolas , monospace ; font-size : 10.5 px ;
padding : 2 px 7 px ; border-radius : 9 px ; background : rgba ( 0 , 0 , 0 , .24 ) ; color : #ffe9a8 ;
border : 1 px 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.5 deg ) scale ( 1.05 ) ;
filter : drop-shadow ( 0 16 px 20 px 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 : 7 px ; margin : -2 px 0 0 ; border-radius : 8 px ; border : 2 px dashed transparent ; transition : all .12 s ;
display : flex ; align-items : center ; justify-content : center ; color : var ( - - muted ) ; font-size : 11 px ;
letter-spacing : 2 px ; }
@ -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 p riority. Click a model to pin it.' ,
connTitle : 'Connection config (Agent / OpenAI SDK)' , connHint : 'Model defaults to AUTO — follows the AUTO chain from the P riority 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 , perio d: 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 , i d: 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 || [ ] ; } catc h ( 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 ] . pus h ( {
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' ) } < / s p a n > ` } < / t d > < / t r > < / t a b l e >
< div class = "muted" style = "margin-top:10px" > $ { t ( 'kMeHint' ) } < / d i v >
< / d i v > ` ;
}
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 s c = 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=" ${ s c. token _quota ? s c. 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="" ${ ! s c. period ? 'selected' : '' } > ${ t ( 'kPerNothing' ) } </option>
<option value="hour" ${ s c. period === 'hour' ? 'selected' : '' } > ${ t ( 'kPerHour' ) } </option>
<option value="week" ${ s c. period === 'week' ? 'selected' : '' } > ${ t ( 'kPerWeek' ) } </option>
<option value="month" ${ s c. period === 'month' ? 'selected' : '' } > ${ t ( 'kPerMonth' ) } </option>
<option value="nhour" ${ s c. 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=" ${ s c. 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 . findIn dex ( 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 . mo del , 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 ) ;