@ -147,6 +147,37 @@ td.t-tag { white-space:nowrap; }
. keys-create label { font-size : 11.5 px ; color : var ( - - muted ) ; margin-bottom : 4 px ; }
. keys-create input , . keys-create select { margin : 0 ; }
@ media ( max-width : 760px ) { . keys-create { grid-template-columns : 1 fr ; } }
/* key canvases — scratch-style model bricks per key */
. key-actions { display : flex ; align-items : center ; gap : 8 px ; margin-bottom : 12 px ; }
. key-canvas { border : 1 px solid var ( - - line ) ; border-radius : 14 px ; padding : 14 px ; margin-bottom : 14 px ; background : var ( - - card ) ; }
. kc-head { display : flex ; align-items : center ; gap : 10 px ; flex-wrap : wrap ; }
. kc-blocks { display : flex ; flex-wrap : wrap ; gap : 10 px ; align-items : center ; margin-top : 12 px ;
background : var ( - - card2 ) ; border : 1 px dashed var ( - - line ) ; border-radius : 12 px ; padding : 14 px ; min-height : 64 px ; }
. kc-blocks . ovh { outline : 2 px dashed var ( - - accent ) ; outline-offset : 2 px ; }
. mb { display : inline-flex ; align-items : center ; gap : 8 px ; padding : 9 px 13 px ; border-radius : 12 px ;
background : linear-gradient ( 135 deg , #41a3ff , #3f6ef5 ) ; color : #fff ; font-size : 13 px ; font-weight : 700 ;
cursor : grab ; user-select : none ; box-shadow : 0 3 px 10 px rgba ( 63 , 110 , 245 , .30 ) ; }
. mb : active { cursor : grabbing ; }
. mb . mb-ico { font-size : 11 px ; opacity : .85 ; }
. mb . mb-name { font-family : ui-monospace , Menlo , Consolas , monospace ; white-space : nowrap ; }
. mb . mb-quota { font-size : 11 px ; font-weight : 600 ; background : rgba ( 255 , 255 , 255 , .18 ) ; padding : 2 px 8 px ;
border-radius : 99 px ; white-space : nowrap ; }
. mb . copy-b { cursor : pointer ; opacity : .8 ; font-size : 12 px ; padding : 0 3 px ; }
. mb . copy-b : hover { opacity : 1 ; }
. mb-ghost { position : fixed ; pointer-events : none ; opacity : .85 ; z-index : 60 ; transform : rotate ( 2 deg ) ; }
. mb-empty { color : var ( - - muted ) ; font-size : 12.5 px ; }
. add-brick { display : inline-flex ; align-items : center ; gap : 6 px ; padding : 9 px 16 px ; border-radius : 12 px ;
border : 1.5 px dashed var ( - - accent ) ; background : transparent ; color : var ( - - accent ) ; font-weight : 700 ;
font-size : 12.5 px ; cursor : pointer ; font-family : inherit ; }
. add-brick : hover { background : rgba ( 63 , 110 , 245 , .08 ) ; }
# ctx-wrap { position : fixed ; z-index : 70 ; background : var ( - - card ) ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ;
box-shadow : 0 10 px 30 px rgba ( 15 , 22 , 44 , .18 ) ; padding : 5 px ; min-width : 130 px ; }
. ctx-itm { display : flex ; align-items : center ; gap : 8 px ; padding : 8 px 12 px ; border-radius : 7 px ; cursor : pointer ;
font-size : 13 px ; font-weight : 600 ; }
. ctx-itm : hover { background : var ( - - card2 ) ; }
. ctx-itm . danger { color : var ( - - err ) ; }
. ctx-itm . ic { width : 15 px ; text-align : center ; }
. filter-line { display : flex ; align-items : center ; gap : 8 px ; margin-bottom : 10 px ; }
. filter-line select { width : auto ; margin : 0 ; padding : 6 px 10 px ; }
@ -260,6 +291,9 @@ html[data-theme="dark"] .dropzone.dragover, html[data-theme="dark"] .dropzone:ho
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 ; }
. 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 ; }
. scr-block . scr-grip { flex : 0 0 auto ; display : flex ; flex-direction : column ; gap : 2 px ; padding : 6 px 4 px ;
margin-left : 2 px ; border-radius : 6 px ; cursor : grab ; background : rgba ( 255 , 255 , 255 , .18 ) ;
box-shadow : inset 0 1 px 2 px rgba ( 0 , 0 , 0 , .18 ) ; transition : background .12 s ; touch-action : none ; }
@ -383,6 +417,12 @@ const STR = {
kMeTitle : '我的密钥' , kMeRole : '角色' , kMeModels : '我可用的模型' , kMeHint : '密钥不可在此新建或删除;需要变更请联系管理员。' ,
kEmpty : '暂无其他密钥' , kDelSelf : '不能删除当前登录所用密钥' , kDelConfirm : '确定删除密钥 %s 吗?此后该密钥立即失效。' ,
kAll : '不限' ,
kBrickH : '点击编辑模型与配额 · 右键更多操作 · 拖动可跨密钥移动' ,
kCopyB : '复制该模型' , kEditB : '编辑' , kDelB : '删除' ,
kFormTitle : '模型与 Token 配额' , kModelB : '模型' , 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, 按优先级自动选择可用源; 点击任一模型可生成固定到该模型的配置。' ,
copyCfg : '一键复制配置' , copyEnv : '复制为环境变量' ,
srcTitle : '源状态' , srcCount : '共 %d 个' ,
@ -409,7 +449,7 @@ const STR = {
u : '用户' , a : '助手' , at : '助手[思考]' , aEmpty : '(空回复)' , aErr : '请求失败: %s' , cErr : '错误: %s' ,
chatMeta : '模型=%s · 耗时 %s ms · %d 字符' , connPinned : '# 固定到模型: %s (源 %s)' , connAuto : '# 模型名 AUTO( 自动选择可用源) ' ,
sortTitle : '画布排序:拖拽积木配置模型优先级' , sortHint : '每行 = 一个优先级档位,行从上到下优先级递减;同一行的模型并排,视为同优先级。按住积木右侧 ⠿ 把手拖动:拖到行内 = 放入该档位(或调整同档顺序),拖到行与行之间的缝隙 = 提升/降低到新档位。生图模型( kind=image) 不参与排序。' , sortDragGrip : '拖拽前须按住把手' ,
sortSave : '保存排序' , sortReset : '重置' , sortSaved : '排序已保存并热重载' , sortNoChange : '无变更' ,
sortSave : '保存排序' , sortReset : '重置' , sortAdd : '添加档位' , sortSaved : '排序已保存并热重载' , sortNoChange : '无变更' , sortHintSave : '点击保存排序后生效' ,
sortSource : '源' , sortPrio : '优先级 %s' , sortEmpty : '(该源暂无模型)' ,
kpiActive : '活跃请求' , kpiReqs : '总请求' , kpiOk : '成功率' , kpiTokens : 'Tokens' , kpiLat : '平均延迟' , kpiMaxLat : '最大延迟' ,
dashModel : '模型用量' , dashSrc : '源用量与延迟' , dashKey : '密钥用量' , dashRecs : '请求记录(审计)' ,
@ -427,9 +467,15 @@ const STR = {
kNewKey : 'New key (copy & save it now)' , kNewOK : 'Key created' , kScope : 'Model scope' , kScopeTitle : 'Model scope — drag to order, add from dropdown, × to remove, copy/paste the list to other users' ,
kScopeHint : 'drag to order · add from dropdown · × to remove · copy/paste list' , kScopeSel : 'Pick a model…' , kCopyList : 'Copy list' , kPasteList : 'Paste list' , kSaveScope : 'Save' ,
kScopeSaved : 'Model scope saved' , kScopeEmpty : '(all models, unlimited)' , kScopeAll : '(empty = unlimited)' ,
kMeTitle : 'My key' , kMeRole : 'Role' , kMeModels : 'Models I can use' , kMeHint : 'Keys cannot be created or deleted here; contact an admin to change. ' ,
kEmpty : 'No other keys' , kDelSelf : 'cannot delete the key you are logged in with' , kDelConfirm : 'Delete key %s? It will stop working immediately.' ,
kMeTitle : 'My key' , kMeRole : 'Role' , kMeModels : 'Models I can use' , kMeHint : 'Keys cannot be created or deleted here; contact an admin to change.' ,
kEmpty : 'No other keys' , kDelSelf : 'cannot delete the key you are logged in with' , kDelConfirm : 'Delete key " %s" ? It will stop working immediately.' ,
kAll : 'All' ,
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' ,
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.' ,
copyCfg : 'Copy config' , copyEnv : 'Copy as env vars' ,
srcTitle : 'Sources' , srcCount : '%d total' ,
@ -456,7 +502,7 @@ const STR = {
u : 'You:' , a : 'Assistant:' , at : 'Assistant [thinking]' , aEmpty : '(empty)' , aErr : 'Request failed: %s' ,
cErr : 'Error: %s' , chatMeta : 'Model=%s · %s ms · %d chars' , connPinned : '# Pinned to model: %s (source %s)' , connAuto : '# Model "AUTO" picks healthy source by priority' ,
sortTitle : 'Canvas sorting: drag blocks to set model priority' , sortHint : 'Each row = one priority tier, rows go high→low; models on the same row sit side by side and share that priority. Grab the ⠿ handle on the right of a block to drag: drop into a row = join that tier (or reorder within it), drop into the gap between rows = move up/down a tier. Image models (kind=image) stay out.' , sortDragGrip : 'grab the handle to drag' ,
sortSave : 'Save order' , sortReset : 'Reset' , sortSaved : 'Order saved & hot-reloaded' , sortNoChange : 'No changes' ,
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)' ,
@ -1006,6 +1052,8 @@ const SORT_COLORS = [
[ '#a466e8' , '#8b4dd4' ] , [ '#e86f9c' , '#d74f86' ] , [ '#45c4c0' , '#2aa8a4' ] ,
[ '#e8564a' , '#d03f34' ] , [ '#8ba3c7' , '#6f8ab3' ] ,
] ;
let sortUid = 0 ;
function nexUid ( ) { return 's' + ( ++ sortUid ) ; }
function srcColor ( name ) {
let h = 0 ; for ( let i = 0 ; i < name . length ; i ++ ) h = ( h * 31 + name . charCodeAt ( i ) ) >>> 0 ;
return SORT _COLORS [ h % SORT _COLORS . length ] ;
@ -1023,11 +1071,36 @@ async function renderSort() {
} ) ) ;
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 ;
} ) ) ;
// 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 ;
sortState . origin = JSON . stringify ( sortState . lanes ) ;
$ ( '#tab-sort' ) . innerHTML = `
<div class="card"><h2><span> ${ t ( 'sortTitle' ) } </span><span class="grow"></span>
<button class="ghost small" onclick="scrAddModal()">+ ${ t ( 'sortAdd' ) } </button>
<button class="ghost small" onclick="sortReset()"> ${ t ( 'sortReset' ) } </button>
<button class="small" onclick="saveSort()"> ${ t ( 'sortSave' ) } </button></h2>
<div class="sort-canvas" id="scr-canvas"></div>
@ -1039,24 +1112,30 @@ async function renderSort() {
</div> ` ;
paintSort ( ) ;
}
function scrBlockHtml ( it , isFirst ) {
function scrBlockHtml ( it , isFirst , li , ji ) {
const c = srcColor ( it . src ) ;
const s = srcShort ( it . src ) ;
return `
<div class="scr-block" data-key=" ${ escAttr ( it . src + '|' + it . id ) } "
style="background:linear-gradient(135deg, ${ c [ 0 ] } , ${ c [ 1 ] } )">
<div class="scr-block" 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 ( srcShort ( it . src ) ) } </span>
<span class="scr-ico"> ${ esc ( it . src === '*' ? '+' : s ) } </span>
<span class="scr-name"> ${ esc ( it . id ) } </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>
</div> ` ;
}
function findSlots ( li , ji ) { return sortState . lanes [ li ] . models [ ji ] ; }
function paintSort ( affected ) {
const cv = $ ( '#scr-canvas' ) ;
if ( ! cv ) return ;
// compute the next lane-leaders so we can retract the outgoing ones first
const nextFirst = new Set ( ) ;
sortState . lanes . forEach ( lane => {
if ( lane . models . length ) nextFirst . add ( lane . models [ 0 ] . src + '|' + lane . models [ 0 ] . id) ;
if ( lane . models . length ) nextFirst . add ( lane . models [ 0 ] . u id) ;
} ) ;
// outgoing leaders: currently first in their row but no longer first post-move
const outgoing = [ ] ;
@ -1106,7 +1185,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 ) ) . join ( '' ) } </div>
<div class="scr-row"> ${ lane . models . map ( ( m , i ) => scrBlockHtml ( m , i === 0 , li , i )) . join ( '' ) } </div>
</div> ` ) ;
} ) ;
html . push ( ` <div class="scr-gap" data-gap=" ${ sortState . lanes . length } "></div> ` ) ;
@ -1285,13 +1364,13 @@ function onSortUp(e) {
let minLi = item . li , maxLi = item . li ;
if ( d . gap !== undefined ) {
// move to a new lane positioned at that gap
sortState . lanes [ item . li ] . models . splice ( item . idx , 1 ) ;
const m = sortState . lanes [ item . li ] . models . splice ( item . idx , 1 ) [ 0 ] ;
let dst = d . gap ;
if ( ! sortState . lanes [ item . li ] . models . length ) {
sortState . lanes . splice ( item . li , 1 ) ;
if ( item . li < dst ) dst -= 1 ;
}
sortState . lanes . splice ( dst , 0 , { models : [ { src : item . src , id : item . id } ] } ) ;
sortState . lanes . splice ( dst , 0 , { models : [ m ] } ) ;
minLi = Math . min ( item . li , dst ) ;
maxLi = Math . max ( item . li , dst ) ;
} else if ( typeof d . ins === 'number' ) {
@ -1316,10 +1395,10 @@ function onSortUp(e) {
sortState . drag = null ;
paintSort ( affected ) ;
}
function findLaneItem ( key ) {
function findLaneItem ( uid ) {
for ( let i = 0 ; i < sortState . lanes . length ; i ++ ) {
const idx = sortState . lanes [ i ] . models . findIndex ( m => m . src + '|' + m . id === key ) ;
if ( idx >= 0 ) return { li : i , idx , src : sortState . lanes [ i ] . models [ idx ] . src , id : sortState . lanes [ i ] . models [ idx ] . id } ;
const idx = sortState . lanes [ i ] . models . findIndex ( m => m . u id === uid ) ;
if ( idx >= 0 ) return { li : i , idx , it : sortState . lanes [ i ] . models [ idx ] } ;
}
return null ;
}
@ -1378,6 +1457,141 @@ async function saveSort() {
}
toast ( dirty ? t ( 'sortSaved' ) : t ( 'sortNoChange' ) ) ;
sortState . origin = JSON . stringify ( sortState . lanes ) ;
try { await persistAuto ( ) ; } catch ( e ) { toast ( e . message ) ; }
}
/* ---------- AUTO quota editing on sort blocks ---------- */
async function persistAuto ( ) {
const rules = [ ] ;
sortState . lanes . forEach ( lane => lane . models . forEach ( it => {
const m = it . meta ;
rules . push ( {
model : it . id ,
token _quota : m && m . quota ? + m . quota : 0 ,
period : m && m . period ? m . period : '' ,
hours : m && m . hours ? + m . hours : 0 ,
} ) ;
} ) ) ;
await api ( '/api/auto' , { method : 'PUT' , body : JSON . stringify ( { rules } ) } ) ;
}
function scrAddModal ( ) {
const cur = { 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 ( 'sortAdd' ) } </h2>
<label> ${ t ( 'kModelB' ) } </label>
<select id="a-model"> ${ allModels . map ( m => ` <option value=" ${ escAttr ( m ) } "> ${ esc ( m ) } </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>
<select id="a-period">
<option value="" selected> ${ t ( 'kPerNothing' ) } </option>
<option value="hour"> ${ t ( 'kPerHour' ) } </option>
<option value="week"> ${ t ( 'kPerWeek' ) } </option>
<option value="month"> ${ t ( 'kPerMonth' ) } </option>
<option value="nhour"> ${ t ( 'kPerHours' ) } </option>
</select>
<div id="a-hours-box" style="display:none"><label> ${ t ( 'kPerNHint' ) } </label>
<input id="a-hours" type="number" min="1" step="1" value="24"></div>
<p><button onclick="scrAddFromForm()"> ${ t ( 'kSaveScope' ) } </button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()"> ${ t ( 'mCancel' ) } </button></p>
</div> ` ;
document . body . appendChild ( wrap ) ;
$ ( '#a-period' ) . addEventListener ( 'change' , ( ) => {
$ ( '#a-hours' ) . closest ( '#a-hours-box' ) . style . display =
$ ( '#a-period' ) . value === 'nhour' ? 'block' : 'none' ;
} ) ;
$ ( '#a-model' ) . focus ( ) ;
}
function scrAddFromForm ( ) {
const model = $ ( '#a-model' ) . value . trim ( ) ;
if ( ! model ) { toast ( t ( 'kName' ) ) ; return ; }
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 } } ] } ) ;
const li = sortState . lanes . length - 1 ;
const w = $ ( '#modal-wrap' ) ; if ( w ) w . remove ( ) ;
paintSort ( [ li ] ) ;
persistAuto ( ) . then ( ( ) => toast ( t ( 'sortHintSave' ) ) ) . catch ( e => toast ( e . message ) ) ;
}
function sortScopeSave ( li , ji ) {
const it = sortState . lanes [ li ] . models [ ji ] ;
if ( ! it ) return ;
let q = parseInt ( $ ( '#q-quota' ) . value ) ;
if ( isNaN ( q ) || q < 0 ) q = 0 ;
const p = $ ( '#q-period' ) . value ;
let h = parseInt ( $ ( '#q-hours' ) . value ) ;
if ( isNaN ( h ) || h < 1 ) h = 1 ;
it . meta = { quota : q , period : p , hours : ( p || q ) ? h : 0 } ;
const w = $ ( '#modal-wrap' ) ; if ( w ) w . remove ( ) ;
paintSort ( [ li ] ) ;
persistAuto ( ) . then ( ( ) => toast ( t ( 'sortHintSave' ) ) ) . catch ( e => toast ( e . message ) ) ;
}
function scrCtx ( e , li , ji ) {
if ( e . button === 2 ) {
sortBlockCtx ( e , li , ji ) ;
return ;
}
e . stopPropagation ( ) ;
sortScopeEdit ( li , ji ) ;
}
function sortScopeEdit ( li , ji ) {
const it = sortState . lanes [ li ] . models [ ji ] ;
if ( ! it ) return ;
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>
<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 || '' } ">
<label> ${ t ( 'kPeriodB' ) } </label>
<select id="q-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>
</select>
<div id="q-hours-box" style="display:none"><label> ${ t ( 'kPerNHint' ) } </label>
<input id="q-hours" type="number" min="1" step="1" value=" ${ cur . hours || 24 } "></div>
<p><button onclick="sortScopeSave( ${ li } , ${ ji } ,this)"> ${ t ( 'kSaveScope' ) } </button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()"> ${ t ( 'mCancel' ) } </button></p>
</div> ` ;
document . body . appendChild ( wrap ) ;
$ ( '#q-period' ) . addEventListener ( 'change' , ( ) => {
$ ( '#q-hours' ) . closest ( '#q-hours-box' ) . style . display =
$ ( '#q-period' ) . value === 'nhour' ? 'block' : 'none' ;
} ) ;
}
function sortBlockCtx ( e , li , ji ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
const it = sortState . lanes [ li ] . models [ ji ] ;
if ( ! it ) return ;
const items = [
{ label : t ( 'kEditQ' ) , icon : '✎' , fn : ( ) => sortScopeEdit ( li , ji ) } ,
{ label : t ( 'kDupB' ) , icon : '⧉' , fn : ( ) => {
sortState . lanes [ li ] . models . splice ( ji + 1 , 0 , { src : it . src , id : it . id , uid : nexUid ( ) , meta : it . meta ? { ... it . meta } : null } ) ;
paintSort ( [ li ] ) ;
persistAuto ( ) . then ( ( ) => toast ( t ( 'sortHintSave' ) ) ) . catch ( e => toast ( e . message ) ) ;
} } ,
] ;
if ( it . meta ) items . push ( {
label : t ( 'kClearQ' ) , icon : '♾' , fn : ( ) => {
it . meta = null ; paintSort ( [ li ] ) ;
persistAuto ( ) . then ( ( ) => toast ( t ( 'sortHintSave' ) ) ) . catch ( e => toast ( e . message ) ) ;
} } ) ;
items . push ( { label : t ( 'kDelB2' ) , icon : '✕' , danger : true , fn : ( ) => scrDelSlot ( li , ji ) } ) ;
showCtx ( e . clientX , e . clientY , items ) ;
}
function scrDelSlot ( li , ji ) {
sortState . lanes [ li ] . models . splice ( ji , 1 ) ;
if ( ! sortState . lanes [ li ] . models . length && sortState . lanes . length > 1 ) sortState . lanes . splice ( li , 1 ) ;
paintSort ( [ li ] ) ;
persistAuto ( ) . then ( ( ) => toast ( t ( 'sortHintSave' ) ) ) . catch ( e => toast ( e . message ) ) ;
}
/* ---------- adapters tab ---------- */
@ -1460,7 +1674,7 @@ 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"> ${ esc ( m ) } </span> ` ) . join ( '' )
? 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 ( '' )
: ` <span class="scope-unlim"> ${ t ( 'kAll' ) } </span> ` } </td></tr></table>
<div class="muted" style="margin-top:10px"> ${ t ( 'kMeHint' ) } </div>
</div> ` ;
@ -1470,140 +1684,270 @@ async function renderKeysAdmin() {
try { models = ( await api ( '/api/status' ) ) . models || [ ] ; } catch ( e ) { }
allModels = models ;
$ ( '#tab-keys' ) . innerHTML = `
<div class="card"><h2> ${ t ( 'kCreate' ) } </h2 >
<div class="keys-create" >
<label > ${ t ( 'kNam e' ) } </label><input id="kc-name" placeholder="alice" >
<label> ${ t ( 'kRole' ) } </label>
<select id="kc-role"><option value="user"> ${ t ( 'kRoleUser' ) } </option><option value="admin"> ${ t ( 'kRoleAdmin' ) } </option></select>
<label> ${ t ( 'kNote' ) } </label><input id="kc-note" placeholder="…">
<button onclick="createKey()"> ${ t ( 'kCreateBtn' ) } </button>
</div>
<div id="k-newbox"></div>
<div class="key-actions" >
<span class="grow"></span >
<button class="small" onclick="openKeyModal()" > ${ t ( 'kCreat e' ) } </button >
</div>
<div class="card"><h2> ${ t ( 'keysTitle' ) } </h2><div class="muted"> ${ t ( 'keysHint' ) } </div><div id="k-list"></div></div> ` ;
<div id="k-newbox"></div>
<div id="k-list"></div> ` ;
loadKeys ( ) ;
}
async function loadKeys ( ) {
const el = $ ( '#k-list' ) ; if ( ! el ) return ;
const j = await api ( '/api/keys' ) ;
const ks = j . keys || [ ] ;
el . innerHTML = ks . length ? ks . map ( k => keyRow Html ( k ) ) . join ( '' ) : ` <div class="muted"> ${ t ( 'kEmpty' ) } </div> ` ;
const ks = ( j . keys || [ ] ) . filter ( k => k . role === 'user' ) ;
el . innerHTML = ks . length ? ks . map ( k => keyCanvas Html ( k ) ) . join ( '' ) : ` <div class="muted"> ${ t ( 'kEmpty' ) } </div> ` ;
el . querySelectorAll ( '.kc-blocks' ) . forEach ( cv => bindCanvasDrop ( cv ) ) ;
el . querySelectorAll ( '.mb' ) . forEach ( b => bindBrickDrag ( b ) ) ;
}
function keyRow Html ( k ) {
function keyCanvas Html ( k ) {
const scopes = k . models || [ ] ;
return `
<div class="key-row ">
<span class="kr-name"> ${ esc ( k . name || '—' ) } </span >
${ roleTag ( k . role ) }
<span class="kr-key"> ${ esc ( maskKey ( k . key ) ) } </span>
<butto n class="ghost small" onclick="copyText(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kCopy' ) } </butto n>
<spa n class="grow"></spa n>
<span class="muted"> ${ t ( 'kModels' ) } : ${ ( k . models && k . models . length ) ? k . models . length : t ( 'kAll' ) } </span>
<span class="muted"> ${ fmtCreated ( k . created _at ) } </span>
<button class="ghost small" onclick="toggleScope(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kScope' ) } </button>
<button class="ghost small errc" onclick="delKey(' ${ escAttr ( k . key ) } ',' ${ escAttr ( k . name || '' ) } ')"> ${ t ( 'kDel' ) } </button>
</div>
<div id="scope- ${ escAttr ( k . key ) } " class="scope-box" style="display:none;padding-left:12px;padding-right:12px">
<div class="muted" style="font-size:12px;margin-bottom:8px"> ${ t ( 'kScopeTitle' ) } </div>
<div class="scope-chips"> ${ ( k . models && k . models . length )
? k . models . map ( m => scopeChipHtml ( k . key , m ) ) . join ( '' )
: ` <span class="scope-unlim"> ${ t ( 'kScopeEmpty' ) } </span> ` } </div>
<div style="display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap">
<select class="scope-add" id="add- ${ escAttr ( k . key ) } " onchange="scopeAdd(' ${ escAttr ( k . key ) } ', this)">
<option value=""> ${ t ( 'kScopeSel' ) } </option>
${ allModels . map ( m => ` <option value=" ${ escAttr ( m ) } "> ${ esc ( m ) } </option> ` ) . join ( '' ) }
</select>
<button class="ghost small" onclick="scopeCopy(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kCopyList' ) } </button>
<button class="ghost small" onclick="scopePaste(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kPasteList' ) } </button>
<div class="key-canvas" data-key=" ${ escAttr ( k . key ) } ">
<div class="kc-head" >
<b> ${ esc ( k . name || '—' ) } </b>
${ roleTag ( k . role ) }
<spa n class="kr-key"> ${ esc ( maskKey ( k . key ) ) } </spa n>
<butto n class="ghost small" onclick="copyText(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kCopy' ) } </butto n>
<span class="grow"></span>
<butto n class="small" onclick="saveScope(' ${ escAttr ( k . key ) } ')"> ${ t ( 'kSaveScope' ) } </butto n>
<spa n class="muted"> ${ fmtCreated ( k . created _at ) } </spa n>
<button class="ghost small errc" onclick="delKey(' ${ escAttr ( k . key ) } ',' ${ escAttr ( k . name || '' ) } ')"> ${ t ( 'kDel' ) } </button>
</div>
<div class="kc-blocks" data-key=" ${ escAttr ( k . key ) } ">
${ scopes . length ? '' : ` <span class="mb-empty"> ${ t ( 'kEmptyB' ) } </span> ` }
${ scopes . map ( m => scopeHtml ( k . key , m ) ) . join ( '' ) }
<button class="add-brick" onclick="scopePush(' ${ escAttr ( k . key ) } ')">+ ${ t ( 'kAddB' ) } </button>
</div>
</div> ` ;
}
function scopeChip Html ( key , m ) {
return ` <span class="scope-chip" data-m=" ${ escAttr ( m ) } " title=" ${ t ( 'kScopeHint' ) } "> ${ esc ( m ) } <span class="rm" onclick="scopeRm(' ${ escAttr ( key ) } ', this)">× </span></span> ` ;
function scopeHtml ( key , m ) {
const qt = fmtQuota ( m . token _quota ) ;
const attrs = ` data-key=" ${ escAttr ( key ) } " data-model=" ${ escAttr ( m . model ) } "
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 ) } ')">
<span class="mb-ico">◆</span>
<span class="mb-name"> ${ esc ( m . model ) } </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> ` ;
}
function toggleScope ( key ) {
const box = document . getElementById ( 'scope-' + key ) ;
if ( ! box ) return ;
box . style . display = box . style . display === 'none' ? 'block' : 'none ' ;
if ( box . style . display === 'block' ) scopeDragInit ( key ) ;
function 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' ;
if ( q > = 1e3 ) return ( q / 1e3 ) . toFixed ( 1 ) + 'K ' ;
return String ( q ) ; }
function periodText ( p , h ) {
h = + h || 0 ;
if ( p === 'hour' ) return '·1h' ;
if ( p === 'week' ) return '·7d' ;
if ( p === 'month' ) return '·30d' ;
if ( p === 'nhour' ) return '·' + Math . max ( 1 , h ) + 'h' ;
return '' ;
}
function scopeModels ( key ) {
const box = document . getElementById ( 'scope-' + key ) ;
if ( ! box ) return [ ] ;
return [ ... box . querySelectorAll ( '.scope-chip' ) ] . map ( x => x . dataset . m ) . filter ( Boolean ) ;
function quantBadge ( quota , period , hours ) {
quota = + quota || 0 ;
period = period || '' ;
if ( ! quota && ! period ) return '∞' ;
return fmtQuota ( quota ) + periodText ( period , hours ) ;
}
function scopeDragInit ( key ) {
const box = document . getElementById ( 'scope-' + key ) ;
if ( ! box ) return ;
box . querySelectorAll ( '.scope-chip' ) . forEach ( ch => {
ch . setAttribute ( 'draggable' , 'true' ) ;
ch . addEventListener ( 'dragstart' , e => { scopeDragEl = ch ; ch . classList . add ( 'drag-over' ) ; e . dataTransfer . effectAllowed = 'move' ; } ) ;
ch . addEventListener ( 'dragend' , ( ) => { ch . classList . remove ( 'drag-over' ) ; scopeDragEl = null ; } ) ;
ch . addEventListener ( 'dragover' , e => e . preventDefault ( ) ) ;
ch . addEventListener ( 'drop' , e => {
e . preventDefault ( ) ;
if ( ! scopeDragEl || scopeDragEl === ch ) return ;
const items = scopeModels ( key ) ;
const from = items . indexOf ( sco peDragEl . dataset . m ) ;
const to = items . indexO f ( ch . dataset . m ) ;
if ( from < 0 || to < 0 ) return ;
item s . splic e ( to , 0 , items . splice ( from , 1 ) [ 0 ] ) ;
const chips = box . querySelector ( '.scope-chips' ) ;
chips . innerHTML = items . map ( m => scopeChipHtml ( key , m ) ) . join ( '' ) ;
scopeDragIn it ( key ) ;
} ) ;
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 ,
}) ) ;
}
async function putScope ( key , scopes ) {
await api ( '/api/keys/' + encodeURIComponent ( key ) , { method : 'PUT' , body : JSON . stringify ( { models : scopes } ) } ) ;
}
async function scopePush ( key ) {
const canvas = document . querySelector ( ` .key-canvas[data-key=" ${ CSS . e sca pe( key ) } "] ` ) ;
i f ( ! canvas ) return ;
const scopes = readScopes ( canvas ) ;
if ( ! scope s. som e ( s => s . model === 'AUTO' ) ) scopes . push ( { model : 'AUTO' , token _quota : 0 } ) ;
try { await putScope ( key , scopes ) ; } catch ( e ) { toast ( e . message ) ; return ; }
await loadKeys ( ) ;
scopeEd it ( key , 'AUTO' );
}
async function scopeDup ( key , model ) {
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 ) ;
if ( ! src ) return ;
scopes . push ( { model : src . model , token _quota : src . token _quota } ) ;
try { await putScope ( key , scopes ) ; toast ( t ( 'kDupOK' ) ) ; } catch ( e ) { toast ( e . message ) ; }
}
async function scopeRm ( key , model ) {
const canvas = document . querySelector ( ` .key-canvas[data-key=" ${ CSS . escape ( key ) } "] ` ) ;
if ( ! canvas ) return ;
const scopes = readScopes ( canvas ) . filter ( s => s . model !== model ) ;
try { await putScope ( key , scopes ) ; } catch ( e ) { toast ( e . message ) ; return ; }
await loadKeys ( ) ;
toast ( t ( 'toastDelOk' ) ) ;
}
function scopeEdit ( key , model ) {
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 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 ) ;
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 ( '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 : '' } ">
<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>
</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>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()"> ${ t ( 'mCancel' ) } </button></p>
</div> ` ;
document . body . appendChild ( wrap ) ;
$ ( '#sc-period' ) . addEventListener ( 'change' , ( ) => {
$ ( '#sc-hours' ) . closest ( '#sc-hours-box' ) . style . display =
$ ( '#sc-period' ) . value === 'nhour' ? 'block' : 'none' ;
} ) ;
$ ( '#sc-model' ) . focus ( ) ;
}
async function scopeSave ( key , oldModel , 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 ; }
let q = parseInt ( $ ( '#sc-quota' ) . value ) ;
if ( isNaN ( q ) || 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 } ;
if ( btn ) btn . disabled = true ;
try {
await putScope ( key , scopes ) ;
const w = $ ( '#modal-wrap' ) ; if ( w ) w . remove ( ) ;
toast ( t ( 'kSaved' ) ) ;
await loadKeys ( ) ;
} catch ( e ) { toast ( e . message ) ; if ( btn ) btn . disabled = false ; }
}
function scopeCtx ( e , key , model ) {
e . preventDefault ( ) ; e . stopPropagation ( ) ;
scopeDragEl = null ;
showCtx ( e . clientX , e . clientY , [
{ label : t ( 'kEditB' ) , icon : '✎' , fn : ( ) => scopeEdit ( key , model ) } ,
{ label : t ( 'kDelB' ) , icon : '✕' , danger : true , fn : ( ) => scopeRm ( key , model ) } ,
] ) ;
}
let ctxEl = null ;
function showCtx ( x , y , items ) {
hideCtx ( ) ;
const w = document . createElement ( 'div' ) ; w . id = 'ctx-wrap' ;
w . innerHTML = items . map ( ( it , i ) =>
` <div class="ctx-itm ${ it . danger ? 'danger' : '' } " data-i=" ${ i } "><span class="ic"> ${ esc ( it . icon ) } </span> ${ esc ( it . label ) } </div> ` ) . join ( '' ) ;
w . addEventListener ( 'click' , e => {
const t = e . target . closest ( '.ctx-itm' ) ; if ( ! t ) return ;
const it = items [ + t . dataset . i ] ; hideCtx ( ) ; if ( it ) it . fn ( ) ;
} ) ;
document . body . appendChild ( w ) ;
const r = w . getBoundingClientRect ( ) ;
w . style . left = Math . max ( 6 , Math . min ( x , window . innerWidth - r . width - 6 ) ) + 'px' ;
w . style . top = Math . max ( 6 , Math . min ( y , window . innerHeight - r . height - 6 ) ) + 'px' ;
setTimeout ( ( ) => document . addEventListener ( 'click' , hideCtx2 , { once : true } ) , 10 ) ;
}
function hideCtx2 ( ) { hideCtx ( ) ; }
function hideCtx ( ) { if ( ctxEl ) { ctxEl . remove ( ) ; ctxEl = null ; } }
/* cross-canvas brick dragging */
function bindBrickDrag ( b ) {
b . addEventListener ( 'dragstart' , e => {
scopeDragEl = { key : b . dataset . key , model : b . dataset . model } ;
e . dataTransfer . setData ( 'text/plain' , b . dataset . model ) ;
e . dataTransfer . effectAllowed = 'move' ;
const g = b . cloneNode ( true ) ; g . classList . add ( 'mb-ghost' ) ;
document . body . appendChild ( g ) ;
b . addEventListener ( 'dragend' , ( ) => { g . remove ( ) ; scopeDragEl = null ; } , { once : true } ) ;
} ) ;
b . addEventListener ( 'dragover' , e => e . preventDefault ( ) ) ;
}
function bindCanvasDrop ( cv ) {
cv . addEventListener ( 'dragover' , e => { e . preventDefault ( ) ; cv . classList . add ( 'ovh' ) ; } ) ;
cv . addEventListener ( 'dragleave' , ( ) => cv . classList . remove ( 'ovh' ) ) ;
cv . addEventListener ( 'drop' , e => {
e . preventDefault ( ) ; cv . classList . remove ( 'ovh' ) ;
if ( ! scopeDragEl ) return ;
moveBrick ( scopeDragEl . key , cv . dataset . key , scopeDragEl . model ) ;
} ) ;
}
function scopeAdd ( key , s el) {
const m = sel . value ; sel . value = '' ;
if ( ! m ) return ;
const box = document . getElementById ( 'scope-' + key ) ; if ( ! box ) return ;
const chips = box . querySelector ( '.scope-chips' ) ;
if ( [ ... chips . querySelectorAll ( '.scope-chip' ) ] . some ( x => x . dataset . m === m ) ) return ;
if ( chips . querySelector ( '.scope-unlim' ) ) chips . innerHTML = '' ;
chips . innerHTML += scopeChipHtml ( key , m ) ;
scopeDragInit ( key ) ;
async function moveBrick ( from , to , mod el) {
if ( fro m === 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 ) ;
if ( ! b ) return ;
fs = fs . filter ( s => s . model !== model ) ;
const dup = ts . some ( s => s . model === model ) ;
if ( ! dup ) ts . push ( b ) ;
try {
await putScope ( from , fs ) ;
await putScope ( to , ts ) ;
toast ( t ( 'kMovOK' ) ) ;
} catch ( e ) { toast ( e . message ) ; loadKeys ( ) ; }
}
function sc opeRm ( key , el ) {
el . closest ( '.scope-chip' ) . remove ( ) ;
const box = document . getElementById ( 'scope-' + key ) ;
const chips = box ? box . querySelector ( '.scope-chips' ) : null ;
if ( chips && ! chips . querySelector ( '.scope-chip' ) ) chips . innerHTML = ` <span class="scope-unlim" > ${ t ( 'kScopeEmpty ' ) } </span> ` ;
function openKeyModal ( ) {
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 ( 'kCreate' ) } </h2>
<label >${ t ( 'kName ' ) } </label>
<input id="kc-name" placeholder="alice">
<label> ${ t ( 'kRole' ) } </label>
<select id="kc-role">
<option value="user"> ${ t ( 'kRoleUser' ) } </option>
<option value="admin"> ${ t ( 'kRoleAdmin' ) } </option>
</select>
<label> ${ t ( 'kNote' ) } </label>
<input id="kc-note">
<p><button onclick="createKey(this)"> ${ t ( 'kCreateBtn' ) } </button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()"> ${ t ( 'mCancel' ) } </button></p>
</div> ` ;
document . body . appendChild ( wrap ) ;
$ ( '#kc-name' ) . focus ( ) ;
}
function scopeCopy ( key ) { copyText ( scopeModels ( key ) . join ( '\n' ) ) ; }
async function scopePaste ( key ) {
let txt = '' ;
try { txt = await navigator . clipboard . readText ( ) ; } catch ( e ) { }
if ( ! txt ) txt = prompt ( t ( 'kPasteList' ) ) ;
if ( ! txt ) return ;
const ms = txt . split ( /[\n,;\s]+/ ) . map ( s => s . trim ( ) ) . filter ( Boolean ) ;
const box = document . getElementById ( 'scope-' + key ) ; if ( ! box ) return ;
const chips = box . querySelector ( '.scope-chips' ) ;
chips . innerHTML = ms . map ( m => scopeChipHtml ( key , m ) ) . join ( '' ) ;
scopeDragInit ( key ) ;
}
async function saveScope ( key ) {
const ms = scopeModels ( key ) ;
await api ( '/api/keys/' + encodeURIComponent ( key ) , { method : 'PUT' , body : JSON . stringify ( { models : ms } ) } ) ;
toast ( t ( 'kScopeSaved' ) ) ;
loadKeys ( ) ;
}
async function createKey ( ) {
async function createKey ( btn ) {
const name = $ ( '#kc-name' ) . value . trim ( ) ;
if ( ! name ) { toast ( t ( 'kName' ) ) ; return ; }
const j = await api ( '/api/keys' , { method : 'POST' , body : JSON . stringify ( {
name , role : $ ( '#kc-role' ) . value , note : $ ( '#kc-note' ) . value . trim ( ) ,
} ) } ) ;
$ ( '#kc-name' ) . value = ' '; $ ( '#kc-note' ) . value = '' ;
if ( btn ) btn . disabled = true ;
let j ;
try {
j = await api ( '/api/keys' , { method : 'POST ', body : JSON . stringify ( {
name , role : $ ( '#kc-role' ) . value , note : $ ( '#kc-note' ) . value . trim ( ) ,
} ) } ) ;
} catch ( e ) { toast ( e . message ) ; if ( btn ) btn . disabled = false ; return ; }
const w = $ ( '#modal-wrap' ) ; if ( w ) w . remove ( ) ;
$ ( '#k-newbox' ) . innerHTML = `
<div class="key-row " style="margin-top:12px; background:var(--card2)">
<b> ${ t ( 'kNewKey' ) } </b >
<span class="kr-key" style="max-width:none" > ${ esc ( j . key . k ey) } </span >
<butto n class="small" onclick="copyText(' ${ escAttr ( j . key . key ) } ')"> ${ t ( 'kCopy' ) } </butto n>
<spa n class="grow"></spa n>
<butto n class="ghost small" onclick=" $ ('#k-newbox').innerHTML='' ">× </butto n>
</div> ` ;
<div class="key-canvas " style="background:var(--card2)">
<div class="kc-head" >
<b > ${ t ( 'kNewK ey' ) } </b >
<spa n class="kr-key" style="max-width:none"> ${ esc ( j . key . key ) } </spa n>
<butto n class="small" onclick="copyText(' ${ escAttr ( j . key . key ) } ')"> ${ t ( 'kCopy' ) } </butto n>
<spa n class="grow "></spa n>
<button class="ghost small" onclick=" $ ('#k-newbox').innerHTML=''">× </button>
</div></div> ` ;
toast ( t ( 'kNewOK' ) ) ;
loadKeys ( ) ;
}