2026-05-31 23:58:26 +09:00
// static/js/admin.js — Admin panel module (ES6)
// Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features
import uiModule from './ui.js' ;
import settingsModule from './settings.js' ;
import { providerLogo } from './providers.js' ;
2026-06-01 15:26:10 +10:00
import { sortModelObjects } from './modelSort.js' ;
2026-05-31 23:58:26 +09:00
let initialized = false ;
let modalEl = null ;
// When the user adds an endpoint, store its id so the next render of
// the endpoints list can flash a glow on that row. Cleared once the
// animation fires.
let _recentlyAddedEpId = null ;
function el ( id ) { return document . getElementById ( id ) ; }
function esc ( s ) { return uiModule . esc ( s ) ; }
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
USERS TAB
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
const PRIV _LABELS = {
can _use _agent : 'Agent mode' ,
can _use _browser : 'Browser automation' ,
can _use _bash : 'Shell / Python / Files' ,
can _use _documents : 'Document editor' ,
can _use _research : 'Deep research' ,
can _generate _images : 'Image generation' ,
can _manage _memory : 'Memory & skills' ,
} ;
async function loadUsers ( ) {
const list = el ( 'adm-userList' ) ;
try {
const res = await fetch ( '/api/auth/users' , { credentials : 'same-origin' } ) ;
if ( res . status === 401 || res . status === 403 ) { list . innerHTML = '<div class="admin-empty">Access denied</div>' ; return ; }
const data = await res . json ( ) ;
if ( ! data . users || data . users . length === 0 ) { list . innerHTML = '<div class="admin-empty">No users found</div>' ; return ; }
list . innerHTML = '' ;
data . users . forEach ( u => {
const row = document . createElement ( 'div' ) ;
row . className = 'admin-user-row' ;
// Header: name + badges + delete
const header = document . createElement ( 'div' ) ;
header . style . cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;' ;
const initial = u . username . charAt ( 0 ) . toUpperCase ( ) ;
header . innerHTML = `
< div class = "admin-user-info" >
< div style = "width:28px;height:28px;border-radius:50%;background:color-mix(in srgb, var(--accent) 20%, var(--panel));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0;color:var(--accent);" > $ { esc ( initial ) } < / d i v >
< div >
< span class = "admin-user-name" > $ { esc ( u . username ) } < / s p a n >
$ { u . is _admin ? '<span class="admin-badge" style="margin-left:6px;">ADMIN</span>' : '<span style="font-size:10px;opacity:0.4;display:block;">Click to manage privileges</span>' }
< / d i v >
< / d i v >
< div style = "display:flex;gap:8px;align-items:center;" >
2026-06-01 12:52:58 +09:00
< button class = "admin-btn-sm" data - adm - rename - user = "${esc(u.username)}" style = "font-size:11px;" > Rename < / b u t t o n >
2026-05-31 23:58:26 +09:00
$ { u . is _admin ? '' : ` <button class="admin-btn-delete" data-adm-del-user=" ${ esc ( u . username ) } " style="font-size:11px;">Remove</button> ` }
$ { u . is _admin ? '' : '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' }
< / d i v >
` ;
row . appendChild ( header ) ;
// Privileges panel (hidden by default, not for admins)
if ( ! u . is _admin ) {
const privPanel = document . createElement ( 'div' ) ;
privPanel . className = 'admin-priv-panel hidden' ;
privPanel . style . cssText = 'padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;' ;
// Boolean toggles
let html = '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.35;font-weight:600;margin-bottom:4px;">Features</div>' ;
for ( const [ key , label ] of Object . entries ( PRIV _LABELS ) ) {
const checked = u . privileges && u . privileges [ key ] ? 'checked' : '' ;
html += ` <div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;">
< span style = "font-size:12px;" > $ { label } < / s p a n >
< label class = "admin-switch" style = "transform:scale(0.85);" > < input type = "checkbox" data - priv = "${key}" data - user = "${esc(u.username)}" $ { checked } > < span class = "admin-slider" > < / s p a n > < / l a b e l >
< / d i v > ` ;
}
// Rate limit
html += '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.35;font-weight:600;margin:10px 0 4px;">Limits</div>' ;
const maxMsg = ( u . privileges && u . privileges . max _messages _per _day ) || 0 ;
html += ` <div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;">
< div >
< span style = "font-size:12px;" > Daily message limit < / s p a n >
< div style = "font-size:10px;opacity:0.4;" > 0 = no limit < / d i v >
< / d i v >
< input type = "number" min = "0" value = "${maxMsg}" data - priv = "max_messages_per_day" data - user = "${esc(u.username)}" style = "width:70px;padding:4px 6px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-size:12px;text-align:center;" >
< / d i v > ` ;
// Allowed models — checkbox list
const allowedSet = new Set ( ( u . privileges && u . privileges . allowed _models ) || [ ] ) ;
const allEmpty = allowedSet . size === 0 ;
html += ` <div style="padding:4px 0;">
< div style = "display:flex;align-items:center;justify-content:space-between;" >
< span style = "font-size:12px;" > Allowed models < / s p a n >
< div style = "display:flex;gap:8px;" >
< a href = "#" class = "priv-models-all" data - user = "${esc(u.username)}" style = "font-size:10px;opacity:0.5;" > All < / a >
< a href = "#" class = "priv-models-none" data - user = "${esc(u.username)}" style = "font-size:10px;opacity:0.5;" > None < / a >
< / d i v >
< / d i v >
< div style = "font-size:10px;opacity:0.4;margin-bottom:4px;" > $ { allEmpty ? 'All models allowed (no restrictions)' : allowedSet . size + ' model(s) allowed' } < / d i v >
< div class = "priv-models-list" data - user = "${esc(u.username)}" >
< span style = "opacity:0.4;font-size:11px;" > Loading models ... < / s p a n >
< / d i v >
< / d i v > ` ;
privPanel . innerHTML = html ;
row . appendChild ( privPanel ) ;
// Toggle panel visibility + rotate chevron + load models
let _modelsLoaded = false ;
header . addEventListener ( 'click' , ( e ) => {
2026-06-01 12:52:58 +09:00
if ( e . target . closest ( '.admin-btn-delete, [data-adm-rename-user]' ) ) return ;
2026-05-31 23:58:26 +09:00
privPanel . classList . toggle ( 'hidden' ) ;
const chevron = header . querySelector ( '.admin-user-chevron' ) ;
if ( chevron ) {
const isOpen = ! privPanel . classList . contains ( 'hidden' ) ;
chevron . style . transform = isOpen ? 'rotate(180deg)' : '' ;
chevron . style . opacity = isOpen ? '0.7' : '0.3' ;
}
// Load models list on first expand
if ( ! _modelsLoaded && ! privPanel . classList . contains ( 'hidden' ) ) {
_modelsLoaded = true ;
_loadModelsForUser ( u . username , allowedSet , privPanel ) ;
}
} ) ;
// Wire privilege changes (boolean + number inputs, not model checkboxes)
privPanel . querySelectorAll ( '[data-priv]' ) . forEach ( input => {
const handler = async ( ) => {
const username = input . dataset . user ;
const key = input . dataset . priv ;
let value ;
if ( input . type === 'checkbox' ) value = input . checked ;
else if ( input . type === 'number' ) value = parseInt ( input . value ) || 0 ;
else value = input . value ;
try {
await fetch ( ` /api/auth/users/ ${ encodeURIComponent ( username ) } /privileges ` , {
method : 'PUT' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { [ key ] : value } ) ,
} ) ;
} catch ( e ) { uiModule . showError ( 'Failed to update privilege' ) ; }
} ;
if ( input . type === 'checkbox' ) input . addEventListener ( 'change' , handler ) ;
else input . addEventListener ( 'change' , handler ) ;
} ) ;
}
2026-06-01 12:52:58 +09:00
// Rename button
const renameBtn = row . querySelector ( '[data-adm-rename-user]' ) ;
if ( renameBtn ) {
renameBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const oldUsername = renameBtn . dataset . admRenameUser ;
const next = await uiModule . styledPrompt ( ` Rename " ${ oldUsername } " ` , {
defaultValue : oldUsername ,
placeholder : 'New username' ,
confirmText : 'Rename' ,
} ) ;
const username = ( next || '' ) . trim ( ) ;
if ( ! username || username === oldUsername ) return ;
try {
const res = await fetch ( ` /api/auth/users/ ${ encodeURIComponent ( oldUsername ) } /rename ` , {
method : 'PUT' ,
credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { username } ) ,
} ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! res . ok ) {
uiModule . showError ( data . detail || 'Failed to rename user' ) ;
return ;
}
if ( data . renamed _self ) {
window . location . reload ( ) ;
return ;
}
loadUsers ( ) ;
} catch ( err ) {
uiModule . showError ( 'Failed to rename user' ) ;
}
} ) ;
}
2026-05-31 23:58:26 +09:00
// Delete button
const delBtn = row . querySelector ( '[data-adm-del-user]' ) ;
if ( delBtn ) {
delBtn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
const username = delBtn . dataset . admDelUser ;
if ( ! await uiModule . styledConfirm ( ` Remove user " ${ username } "? ` , { confirmText : 'Remove' , danger : true } ) ) return ;
const res = await fetch ( '/api/auth/users' , { method : 'DELETE' , credentials : 'same-origin' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { username } ) } ) ;
if ( res . ok ) loadUsers ( ) ;
else uiModule . showError ( 'Failed to delete user' ) ;
} ) ;
}
list . appendChild ( row ) ;
} ) ;
} catch ( e ) { list . innerHTML = '<div class="admin-error">Failed to load users</div>' ; }
}
async function _loadModelsForUser ( username , allowedSet , privPanel ) {
const listEl = privPanel . querySelector ( ` .priv-models-list[data-user=" ${ username } "] ` ) ;
if ( ! listEl ) return ;
try {
const res = await fetch ( '/api/models' , { credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
const allModels = [ ] ;
( data . items || [ ] ) . forEach ( item => {
if ( item . offline ) return ;
( item . models || [ ] ) . forEach ( mid => {
allModels . push ( { mid , epName : item . endpoint _name || '' , display : mid . split ( '/' ) . pop ( ) } ) ;
} ) ;
} ) ;
if ( ! allModels . length ) {
listEl . innerHTML = '<span style="opacity:0.4;font-size:11px;">No models available</span>' ;
return ;
}
const allEmpty = allowedSet . size === 0 ;
2026-06-01 15:26:10 +10:00
listEl . innerHTML = sortModelObjects ( allModels ) . map ( m => {
2026-05-31 23:58:26 +09:00
const checked = allEmpty || allowedSet . has ( m . mid ) ? 'checked' : '' ;
return ` <label>
< input type = "checkbox" class = "priv-model-cb" data - mid = "${esc(m.mid)}" $ { checked } >
< span > $ { esc ( m . display ) } < / s p a n >
< span style = "opacity:0.3;font-size:10px;margin-left:auto;" > $ { esc ( m . epName ) } < / s p a n >
< / l a b e l > ` ;
} ) . join ( '' ) ;
// Save on change
function _saveModels ( ) {
const checked = [ ] ;
listEl . querySelectorAll ( '.priv-model-cb' ) . forEach ( cb => {
if ( cb . checked ) checked . push ( cb . dataset . mid ) ;
} ) ;
// If all are checked, send empty array (= no restrictions)
const value = checked . length === allModels . length ? [ ] : checked ;
const hint = privPanel . querySelector ( '.priv-models-list[data-user]' ) ? . previousElementSibling ? . querySelector ( 'div[style*="opacity"]' ) ;
if ( hint ) hint . textContent = value . length === 0 ? 'All models allowed (no restrictions)' : value . length + ' model(s) allowed' ;
fetch ( ` /api/auth/users/ ${ encodeURIComponent ( username ) } /privileges ` , {
method : 'PUT' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { allowed _models : value } ) ,
} ) . catch ( ( ) => { } ) ;
}
listEl . querySelectorAll ( '.priv-model-cb' ) . forEach ( cb => cb . addEventListener ( 'change' , _saveModels ) ) ;
// All / None buttons
privPanel . querySelector ( ` .priv-models-all[data-user=" ${ username } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
listEl . querySelectorAll ( '.priv-model-cb' ) . forEach ( cb => cb . checked = true ) ;
_saveModels ( ) ;
} ) ;
privPanel . querySelector ( ` .priv-models-none[data-user=" ${ username } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
listEl . querySelectorAll ( '.priv-model-cb' ) . forEach ( cb => cb . checked = false ) ;
_saveModels ( ) ;
} ) ;
} catch ( e ) {
listEl . innerHTML = '<span style="opacity:0.4;font-size:11px;">Failed to load models</span>' ;
}
}
function initSignupToggle ( ) {
const toggle = el ( 'adm-signupToggle' ) ;
fetch ( '/api/auth/status' , { credentials : 'same-origin' } )
. then ( r => r . json ( ) )
. then ( d => { toggle . checked = ! ! d . signup _enabled ; } )
. catch ( e => console . warn ( 'Auth status fetch failed:' , e ) ) ;
toggle . addEventListener ( 'change' , async ( ) => {
try {
const res = await fetch ( '/api/auth/signup-toggle' , { method : 'POST' , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
toggle . checked = data . signup _enabled ;
} catch ( e ) { toggle . checked = ! toggle . checked ; }
} ) ;
}
function initAddUser ( ) {
el ( 'adm-addBtn' ) . addEventListener ( 'click' , async ( ) => {
const msg = el ( 'adm-addMsg' ) ;
msg . textContent = '' ; msg . className = '' ;
const username = el ( 'adm-newUsername' ) . value . trim ( ) ;
const password = el ( 'adm-newPassword' ) . value ;
const is _admin = el ( 'adm-newIsAdmin' ) . checked ;
if ( ! username ) { msg . textContent = 'Username required' ; msg . className = 'admin-error' ; return ; }
if ( password . length < 8 ) { msg . textContent = 'Password must be at least 8 characters' ; msg . className = 'admin-error' ; return ; }
el ( 'adm-addBtn' ) . disabled = true ;
try {
const res = await fetch ( '/api/auth/users' , { method : 'POST' , credentials : 'same-origin' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { username , password , is _admin } ) } ) ;
const data = await res . json ( ) ;
if ( res . ok ) { msg . textContent = 'User created' ; msg . className = 'admin-success' ; el ( 'adm-newUsername' ) . value = '' ; el ( 'adm-newPassword' ) . value = '' ; el ( 'adm-newIsAdmin' ) . checked = false ; loadUsers ( ) ; }
else { msg . textContent = data . detail || 'Failed' ; msg . className = 'admin-error' ; }
} catch ( e ) { msg . textContent = 'Request failed' ; msg . className = 'admin-error' ; }
el ( 'adm-addBtn' ) . disabled = false ;
} ) ;
}
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
SERVICES TAB — Endpoints
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
function _isLocalEndpoint ( url ) {
if ( ! url ) return false ;
try {
const u = new URL ( url ) ;
const h = u . hostname . toLowerCase ( ) ;
if ( h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' ) return true ;
if ( h . endsWith ( '.local' ) ) return true ;
if ( /^10\./ . test ( h ) ) return true ;
if ( /^192\.168\./ . test ( h ) ) return true ;
if ( /^172\.(1[6-9]|2[0-9]|3[01])\./ . test ( h ) ) return true ;
// Tailscale CGNAT range (100.64.0.0/10 → 100.64.x– 100.127.x). Servers
// found via "Scan for Servers" come back as tailnet IPs, which are still
// your own machines, so group them under Local rather than API.
if ( /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./ . test ( h ) ) return true ;
// Single-label hostnames are LAN by convention.
if ( ! h . includes ( '.' ) ) return true ;
return false ;
} catch { return false ; }
}
2026-06-01 10:00:15 +09:00
async function _refreshAfterEndpointChange ( deletedEndpointId ) {
try {
const sm = window . sessionModule ;
const pending = sm && sm . getPendingChat ? sm . getPendingChat ( ) : null ;
if ( deletedEndpointId && pending && String ( pending . endpointId || '' ) === String ( deletedEndpointId ) ) {
if ( sm . setPendingChat ) sm . setPendingChat ( null ) ;
}
} catch ( _ ) { }
try {
if ( window . modelsModule && window . modelsModule . refreshModels ) {
await window . modelsModule . refreshModels ( true ) ;
}
} catch ( _ ) { }
try {
window . dispatchEvent ( new CustomEvent ( 'ge:model-endpoints-updated' , {
detail : { deletedEndpointId : deletedEndpointId || null }
} ) ) ;
} catch ( _ ) { }
try {
if ( window . sessionModule && window . sessionModule . updateModelPicker ) {
window . sessionModule . updateModelPicker ( ) ;
}
} catch ( _ ) { }
}
async function _selectAddedModelInChat ( endpoint ) {
const modelId = endpoint && Array . isArray ( endpoint . models ) ? endpoint . models [ 0 ] : '' ;
if ( ! modelId ) return ;
try {
if ( window . modelsModule && window . modelsModule . refreshModels ) {
await window . modelsModule . refreshModels ( true ) ;
}
} catch ( _ ) { }
try {
document . dispatchEvent ( new CustomEvent ( 'odysseus:auto-select-model' , {
detail : {
endpointId : endpoint . id || '' ,
endpointName : endpoint . name || '' ,
modelId ,
url : endpoint . base _url || '' ,
}
} ) ) ;
} catch ( _ ) { }
}
2026-05-31 23:58:26 +09:00
async function loadEndpoints ( ) {
const listLocal = el ( 'adm-epList-local' ) ;
const listApi = el ( 'adm-epList-api' ) ;
// Fallback to the legacy single list if the split containers don't exist
// (older HTML or third-party embedding).
const listLegacy = el ( 'adm-epList' ) ;
// Refresh model picker so new endpoints show up in chat
if ( window . modelsModule && window . modelsModule . refreshModels ) {
window . modelsModule . refreshModels ( true ) ;
setTimeout ( ( ) => {
if ( window . sessionModule && window . sessionModule . updateModelPicker ) {
window . sessionModule . updateModelPicker ( ) ;
}
} , 1500 ) ;
}
2026-06-01 15:26:10 +10:00
if ( settingsModule && typeof settingsModule . refreshAiModelEndpoints === 'function' ) {
settingsModule . refreshAiModelEndpoints ( ) ;
}
2026-05-31 23:58:26 +09:00
try {
const res = await fetch ( '/api/model-endpoints' , { credentials : 'same-origin' } ) ;
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
// returning an error envelope) the same as "no endpoints yet": show the
// empty state, not "Failed to load". The user just installed the app —
// there's literally nothing to load, so the error read as broken UI.
let data = [ ] ;
if ( res . ok ) {
try { data = await res . json ( ) ; } catch { data = [ ] ; }
}
if ( ! Array . isArray ( data ) || data . length === 0 ) {
2026-06-01 10:00:15 +09:00
const empty = '<div class="admin-empty">None</div>' ;
2026-05-31 23:58:26 +09:00
if ( listLocal ) listLocal . innerHTML = empty ;
if ( listApi ) listApi . innerHTML = '<div class="admin-empty">None</div>' ;
if ( listLegacy ) listLegacy . innerHTML = empty ;
return ;
}
const rowHtml = data . map ( ep => {
const visibleCount = ep . models . length ;
const totalCount = visibleCount + ( ep . hidden _count || 0 ) ;
// `ep.models` is the *visible* set — when every model is hidden it's
// empty, but we still need to render the expand panel so the user can
// un-hide them. Gate on the total instead.
const hasModels = ep . online && totalCount > 0 ;
2026-06-01 10:00:15 +09:00
const statusBadge = ep . status === 'empty'
? '<span class="admin-badge">no models</span>'
: ep . online
? ` <span class="admin-badge"> ${ visibleCount } / ${ totalCount } models enabled</span> `
: '<span class="admin-badge admin-badge-off">offline</span>' ;
2026-05-31 23:58:26 +09:00
const justAddedClass = ( _recentlyAddedEpId && String ( ep . id ) === _recentlyAddedEpId ) ? ' adm-ep-just-added' : '' ;
return `
< div class = "admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data - adm - ep - id = "${ep.id}" >
< div style = "display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data - adm - ep - header = "${ep.id}" >
< div class = "admin-user-info" style = "flex:1;flex-wrap:wrap;gap:0.3rem;" >
< span class = "admin-user-name" > $ { esc ( ep . name ) } < / s p a n >
$ { ep . model _type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : '' }
$ { statusBadge }
$ { ep . is _enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>' }
$ { hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : '' }
< / d i v >
< div style = "display:flex;gap:4px;align-items:center;" >
< button class = "admin-btn-sm" data - adm - toggle - ep = "${ep.id}" > $ { ep . is _enabled ? 'Disable' : 'Enable' } < / b u t t o n >
< button class = "admin-btn-delete" data - adm - del - ep = "${ep.id}" data - adm - ep - online = "${ep.online ? '1' : '0'}" > Delete < / b u t t o n >
$ { hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : '' }
< / d i v >
< / d i v >
< div class = "admin-ep-detail" > $ { esc ( ep . base _url ) } $ { _isLocalEndpoint ( ep . base _url ) ? ` <button type="button" class="admin-ep-copy-btn" data-adm-copy-url=" ${ esc ( ep . base _url ) } " title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button> ` : '' } $ { ep . has _key ? ' (key set)' : '' } < / d i v >
$ { hasModels ? ` <div class="mcp-tools-panel hidden" data-adm-ep-models-panel=" ${ ep . id } "></div> ` : '' }
< / d i v > ` ;
} ) ;
// Partition rows into Local vs API for the split sections.
// Subsections without any rows are hidden entirely (heading + all)
// so empty groups don't take up vertical real estate.
const _renderInto = ( container , indices ) => {
if ( ! container ) return ;
const section = container . closest ( '.adm-ep-section' ) ;
if ( ! indices . length ) {
if ( section ) section . style . display = 'none' ;
container . innerHTML = '' ;
return ;
}
if ( section ) section . style . display = '' ;
container . innerHTML = indices . map ( i => rowHtml [ i ] ) . join ( '' ) ;
} ;
const localIdx = [ ] , apiIdx = [ ] ;
data . forEach ( ( ep , i ) => ( _isLocalEndpoint ( ep . base _url ) ? localIdx : apiIdx ) . push ( i ) ) ;
// Sort each section: enabled endpoints first, disabled at the bottom.
// Preserve original order within each group via stable sort.
const _sortByEnabled = ( a , b ) => Number ( ! ! data [ b ] . is _enabled ) - Number ( ! ! data [ a ] . is _enabled ) ;
localIdx . sort ( _sortByEnabled ) ;
apiIdx . sort ( _sortByEnabled ) ;
_renderInto ( listLocal , localIdx ) ;
_renderInto ( listApi , apiIdx ) ;
if ( listLegacy ) listLegacy . innerHTML = rowHtml . join ( '' ) ;
// Iterate matching nodes across both containers.
const queryAll = ( sel ) => {
const out = [ ] ;
[ listLocal , listApi , listLegacy ] . forEach ( c => {
if ( c ) c . querySelectorAll ( sel ) . forEach ( n => out . push ( n ) ) ;
} ) ;
return out ;
} ;
queryAll ( '[data-adm-toggle-ep]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( e ) => { e . stopPropagation ( ) ; await fetch ( ` /api/model-endpoints/ ${ btn . dataset . admToggleEp } ` , { method : 'PATCH' } ) ; loadEndpoints ( ) ; } ) ;
} ) ;
queryAll ( '[data-adm-copy-url]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
const url = btn . dataset . admCopyUrl || '' ;
if ( ! url ) return ;
uiModule . copyToClipboard ( url ) . then ( ( ) => {
// Brief icon swap to a checkmark so the user gets feedback that
// the copy actually happened. Reverts after ~1.4s.
const prev = btn . innerHTML ;
btn . innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>' ;
btn . style . opacity = '1' ;
setTimeout ( ( ) => { btn . innerHTML = prev ; btn . style . opacity = '' ; } , 1400 ) ;
} ) . catch ( ( ) => { } ) ;
} ) ;
} ) ;
queryAll ( '[data-adm-del-ep]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
var epId = btn . dataset . admDelEp ;
var isOffline = btn . dataset . admEpOnline === '0' ;
// Offline endpoints are already broken — skip the confirm dialog
// entirely and delete immediately. The optimistic UI removal makes
// the action feel instant.
if ( ! isOffline ) {
var deps = [ ] ;
try {
var depRes = await fetch ( '/api/model-endpoints/' + epId + '/dependents' , { credentials : 'same-origin' } ) ;
var depData = await depRes . json ( ) ;
deps = depData . dependents || [ ] ;
} catch ( e ) { /* proceed without warning */ }
var msg = 'Delete this endpoint?' ;
if ( deps . length ) {
msg += '\n\nThe following settings use this endpoint and will be reset:\n— ' + deps . join ( '\n— ' ) ;
}
if ( ! await uiModule . styledConfirm ( msg , { confirmText : 'Delete' , danger : true } ) ) return ;
}
// Optimistic: remove from UI immediately
const row = btn . closest ( '[data-adm-ep-id]' ) ;
if ( row ) row . remove ( ) ;
2026-06-01 10:00:15 +09:00
fetch ( '/api/model-endpoints/' + epId , { method : 'DELETE' } )
. then ( ( ) => _refreshAfterEndpointChange ( epId ) )
. then ( ( ) => loadEndpoints ( ) )
. catch ( ( ) => loadEndpoints ( ) ) ;
2026-05-31 23:58:26 +09:00
} ) ;
} ) ;
// Clear the just-added marker now that the row has been rendered
// with the animation class — keeps the glow from re-firing on every
// subsequent loadEndpoints() call (e.g. when toggling a model).
if ( _recentlyAddedEpId ) _recentlyAddedEpId = null ;
// Models expand/collapse (click anywhere on card)
queryAll ( '[data-adm-ep-id]' ) . forEach ( row => {
const header = row . querySelector ( '[data-adm-ep-header]' ) ;
if ( ! header ) return ;
let _modelsLoaded = false ;
row . style . cursor = 'pointer' ;
row . addEventListener ( 'click' , async ( e ) => {
// Don't let interactions inside the expanded panel re-fire the
// expand/collapse handler — the search box was getting closed
// because clicking it bubbled up to here.
if ( e . target . closest ( '.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, label' ) ) return ;
const epId = header . dataset . admEpHeader ;
const panel = row . querySelector ( ` [data-adm-ep-models-panel=" ${ epId } "] ` ) ;
if ( ! panel ) return ;
panel . classList . toggle ( 'hidden' ) ;
const chevron = row . querySelector ( '.admin-user-chevron' ) ;
const isOpen = ! panel . classList . contains ( 'hidden' ) ;
if ( chevron ) {
chevron . style . transform = isOpen ? 'rotate(180deg)' : '' ;
chevron . style . opacity = isOpen ? '0.7' : '0.3' ;
}
if ( ! _modelsLoaded && isOpen ) {
_modelsLoaded = true ;
// Our shared whirlpool spinner (consistent with the rest of the app).
panel . innerHTML = '' ;
let _modelsSpin = null ;
const _ld = document . createElement ( 'span' ) ;
_ld . style . cssText = 'opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;' ;
_ld . appendChild ( document . createTextNode ( 'Loading models…' ) ) ;
try {
const _sp = ( await import ( './spinner.js' ) ) . default ;
_modelsSpin = _sp . createWhirlpool ( 14 ) ;
_modelsSpin . element . style . cssText = 'width:14px;height:14px;margin:0;display:inline-block;' ;
_ld . appendChild ( _modelsSpin . element ) ;
} catch ( _ ) { }
panel . appendChild ( _ld ) ;
const _stopSpin = ( ) => { try { _modelsSpin && _modelsSpin . stop ( ) ; } catch ( _ ) { } } ;
try {
const res = await fetch ( ` /api/model-endpoints/ ${ epId } /models ` , { credentials : 'same-origin' } ) ;
const models = await res . json ( ) ;
_stopSpin ( ) ;
2026-06-01 15:26:10 +10:00
const sortedModels = sortModelObjects ( models ) ;
if ( ! sortedModels . length ) { panel . innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>' ; return ; }
const hiddenSet = new Set ( sortedModels . filter ( m => m . is _hidden ) . map ( m => m . id ) ) ;
const showSearch = sortedModels . length >= 8 ;
2026-05-31 23:58:26 +09:00
panel . innerHTML = ` <div class="mcp-tools-header">
< span > Models < / s p a n >
< span style = "display:flex;gap:8px;align-items:center;" >
2026-06-01 15:26:10 +10:00
< span class = "mcp-tools-count" > $ { sortedModels . length - hiddenSet . size } / $ { sortedModels . length } enabled < / s p a n >
2026-05-31 23:58:26 +09:00
< a href = "#" data - ep - select - all = "${epId}" > All < / a >
< a href = "#" data - ep - select - none = "${epId}" > None < / a >
< / s p a n >
2026-06-01 15:26:10 +10:00
< / d i v > $ { s h o w S e a r c h ? ` < i n p u t t y p e = " s e a r c h " c l a s s = " m c p - t o o l s - s e a r c h " p l a c e h o l d e r = " S e a r c h $ { s o r t e d M o d e l s . l e n g t h } m o d e l s . . . " d a t a - e p - s e a r c h = " $ { e p I d } " > ` : ' ' } < d i v c l a s s = " m c p - t o o l s - l i s t " > ` + s o r t e d M o d e l s . m a p ( m = >
2026-05-31 23:58:26 +09:00
` <label title=" ${ esc ( m . id ) } " data-ep-model-row data-search=" ${ esc ( ( m . display + ' ' + m . id ) . toLowerCase ( ) ) } " class="adm-model-row">
< input type = "checkbox" class = "adm-cb-hidden" data - ep - model - id = "${esc(m.id)}" $ { ! m . is _hidden ? 'checked' : '' } >
< span class = "adm-check-dot" aria - hidden = "true" > < / s p a n >
< span > $ { esc ( m . display ) } < / s p a n >
< / l a b e l > `
) . join ( '' ) + '</div>' ;
const filterRows = ( q ) => {
const needle = q . trim ( ) . toLowerCase ( ) ;
panel . querySelectorAll ( '[data-ep-model-row]' ) . forEach ( row => {
row . style . display = ( ! needle || row . dataset . search . includes ( needle ) ) ? '' : 'none' ;
} ) ;
} ;
panel . querySelector ( ` [data-ep-search=" ${ epId } "] ` ) ? . addEventListener ( 'input' , ( e ) => filterRows ( e . target . value ) ) ;
panel . querySelector ( ` [data-ep-select-all=" ${ epId } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
panel . querySelectorAll ( '[data-ep-model-row]' ) . forEach ( row => {
if ( row . style . display !== 'none' ) row . querySelector ( 'input[type=checkbox]' ) . checked = true ;
} ) ;
_saveEpModelState ( epId , panel ) ;
} ) ;
panel . querySelector ( ` [data-ep-select-none=" ${ epId } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
panel . querySelectorAll ( '[data-ep-model-row]' ) . forEach ( row => {
if ( row . style . display !== 'none' ) row . querySelector ( 'input[type=checkbox]' ) . checked = false ;
} ) ;
_saveEpModelState ( epId , panel ) ;
} ) ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => {
cb . addEventListener ( 'change' , ( ) => _saveEpModelState ( epId , panel ) ) ;
} ) ;
} catch ( e ) { _stopSpin ( ) ; panel . innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>' ; }
}
} ) ;
} ) ;
} catch ( e ) {
const err = '<div class="admin-error">Failed to load</div>' ;
[ listLocal , listApi , listLegacy ] . forEach ( c => { if ( c ) c . innerHTML = err ; } ) ;
}
}
async function _saveEpModelState ( epId , panel ) {
const hidden = [ ] ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => {
if ( ! cb . checked ) hidden . push ( cb . dataset . epModelId ) ;
} ) ;
const total = panel . querySelectorAll ( 'input[type=checkbox]' ) . length ;
try {
await fetch ( ` /api/model-endpoints/ ${ epId } /models ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'same-origin' ,
body : JSON . stringify ( { hidden } ) ,
} ) ;
const countLabel = panel . querySelector ( '.mcp-tools-count' ) ;
if ( countLabel ) countLabel . textContent = ` ${ total - hidden . length } / ${ total } enabled ` ;
const row = panel . closest ( '[data-adm-ep-id]' ) ;
if ( row ) {
const badge = row . querySelector ( '.admin-badge' ) ;
if ( badge && ! badge . classList . contains ( 'admin-badge-off' ) ) badge . textContent = ` ${ total - hidden . length } / ${ total } models enabled ` ;
}
2026-06-01 15:26:10 +10:00
if ( settingsModule && typeof settingsModule . refreshAiModelEndpoints === 'function' ) {
settingsModule . refreshAiModelEndpoints ( ) ;
}
2026-05-31 23:58:26 +09:00
} catch ( e ) { /* silent */ }
}
function initEndpointForm ( ) {
const provider = el ( 'adm-epProvider' ) ;
const urlInput = el ( 'adm-epUrl' ) ;
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
// so the rest of this function (which reads provider.value and dispatches
// change events) keeps working unchanged.
const picker = el ( 'adm-provider-picker' ) ;
const pickerBtn = el ( 'adm-provider-btn' ) ;
const pickerMenu = el ( 'adm-provider-menu' ) ;
const pickerCurrent = picker ? picker . querySelector ( '.adm-provider-current' ) : null ;
function _renderPickerMenu ( ) {
if ( ! pickerMenu ) return ;
pickerMenu . innerHTML = Array . from ( provider . options ) . map ( o => {
const logo = o . dataset . logo ? ( providerLogo ( o . dataset . logo ) || '' ) : '' ;
const active = o . value === provider . value ? ' active' : '' ;
return ` <div class="adm-provider-item ${ active } " role="option" data-value=" ${ o . value . replace ( /"/g , '"' ) } ">
< span class = "adm-provider-logo" > $ { logo } < / s p a n >
< span > $ { o . textContent } < / s p a n >
< / d i v > ` ;
} ) . join ( '' ) ;
}
function _syncPickerCurrent ( ) {
if ( ! pickerCurrent ) return ;
const opt = provider . selectedOptions [ 0 ] || provider . options [ 0 ] ;
const logo = opt . dataset . logo ? ( providerLogo ( opt . dataset . logo ) || '' ) : '' ;
pickerCurrent . querySelector ( '.adm-provider-logo' ) . innerHTML = logo ;
pickerCurrent . querySelector ( '.adm-provider-name' ) . textContent = opt . textContent ;
}
if ( picker && pickerBtn && pickerMenu && pickerCurrent ) {
_renderPickerMenu ( ) ;
_syncPickerCurrent ( ) ;
2026-06-01 10:00:15 +09:00
if ( provider . value && ! urlInput . value ) urlInput . value = provider . value ;
2026-05-31 23:58:26 +09:00
pickerBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
pickerMenu . classList . toggle ( 'hidden' ) ;
} ) ;
pickerMenu . addEventListener ( 'click' , ( e ) => {
const item = e . target . closest ( '.adm-provider-item' ) ;
if ( ! item ) return ;
provider . value = item . dataset . value ;
provider . dispatchEvent ( new Event ( 'change' , { bubbles : true } ) ) ;
pickerMenu . classList . add ( 'hidden' ) ;
_renderPickerMenu ( ) ;
_syncPickerCurrent ( ) ;
} ) ;
document . addEventListener ( 'click' , ( e ) => {
if ( ! picker . contains ( e . target ) ) pickerMenu . classList . add ( 'hidden' ) ;
} ) ;
}
provider . addEventListener ( 'change' , ( ) => {
if ( provider . value ) urlInput . value = provider . value ;
else urlInput . value = '' ;
} ) ;
2026-06-01 10:00:15 +09:00
urlInput . addEventListener ( 'input' , ( ) => {
if ( provider . value && urlInput . value . trim ( ) !== provider . value ) {
provider . value = '' ;
_renderPickerMenu ( ) ;
_syncPickerCurrent ( ) ;
}
} ) ;
2026-05-31 23:58:26 +09:00
function _normalizeBaseUrl ( raw ) {
let u = raw . trim ( ) ;
// Fix common protocol typos
u = u . replace ( /^https?:\/(?!\/)/ , m => m + '/' ) ; // https:/ → https://
u = u . replace ( /^htp:/ , 'http:' ) . replace ( /^htps:/ , 'https:' ) ;
u = u . replace ( /^http:\/\/\// , 'http://' ) ; // http:/// → http://
u = u . replace ( /^https:\/\/\// , 'https://' ) ;
// Add http:// if no protocol
if ( ! /^https?:\/\// . test ( u ) ) u = 'http://' + u ;
// Strip trailing slashes
u = u . replace ( /\/+$/ , '' ) ;
// Strip trailing paths that shouldn't be in a base URL
u = u . replace ( /\/v1\/(models|chat\/completions|completions|messages)\/?$/i , '/v1' ) ;
u = u . replace ( /\/(models|chat\/completions|completions|v1\/messages)\/?$/i , '' ) ;
2026-06-01 15:26:10 +10:00
u = u . replace ( /\/api\/(chat|tags|generate)\/?$/i , '/api' ) ;
2026-05-31 23:58:26 +09:00
// Fix double /v1/v1
u = u . replace ( /\/v1\/v1$/ , '/v1' ) ;
// Strip query params and fragments
u = u . split ( '?' ) [ 0 ] . split ( '#' ) [ 0 ] ;
2026-06-01 15:26:10 +10:00
try {
const parsed = new URL ( u ) ;
if ( parsed . hostname . endsWith ( 'ollama.com' ) ) {
u = 'https://ollama.com/api' ;
}
} catch ( e ) { }
2026-05-31 23:58:26 +09:00
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
2026-06-01 15:26:10 +10:00
if ( ! u . includes ( 'api.' ) && ! u . includes ( 'openrouter' ) && ! u . includes ( 'ollama.com' ) && ! u . endsWith ( '/v1' ) ) {
2026-05-31 23:58:26 +09:00
try {
const parsed = new URL ( u ) ;
if ( ! parsed . pathname || parsed . pathname === '/' ) {
u += '/v1' ;
}
} catch ( e ) { }
}
return u ;
}
2026-06-01 10:00:15 +09:00
async function _defaultOllamaUrl ( ) {
try {
const res = await fetch ( '/api/runtime' , { credentials : 'same-origin' } ) ;
if ( res . ok ) {
const data = await res . json ( ) ;
if ( data && data . ollama _base _url ) return data . ollama _base _url ;
}
} catch ( _ ) { }
return 'http://127.0.0.1:11434/v1' ;
}
function _renderEndpointTestResult ( msg , res , d ) {
if ( res . ok && d . status === 'empty' ) {
msg . textContent = 'Online — no models found' ;
msg . className = 'admin-success' ;
return ;
}
if ( res . ok && d . online ) {
const models = d . models || [ ] ;
const preview = models . slice ( 0 , 3 ) . map ( m => esc ( String ( m ) . split ( '/' ) . pop ( ) ) ) . join ( ', ' ) ;
msg . innerHTML = ` Online — found ${ models . length } model ${ models . length !== 1 ? 's' : '' } ${ preview ? ` : ${ preview } ${ models . length > 3 ? ', …' : '' } ` : '' } ` ;
msg . className = 'admin-success' ;
return ;
}
msg . textContent = ( d && d . detail ) || ( d && d . ping _error ? ` Offline — ${ d . ping _error } ` : 'Offline' ) ;
msg . className = 'admin-error' ;
}
function _endpointMsg ( kind ) {
return el ( kind === 'local' ? 'adm-epLocalMsg' : 'adm-epApiMsg' ) || el ( 'adm-epMsg' ) ;
}
let apiTestController = null ;
const apiTestBtn = el ( 'adm-epApiTestBtn' ) ;
const apiCancelTestBtn = el ( 'adm-epApiCancelTestBtn' ) ;
if ( apiTestBtn ) {
apiTestBtn . addEventListener ( 'click' , async ( ) => {
const msg = _endpointMsg ( 'api' ) ;
msg . textContent = '' ; msg . className = '' ;
const rawUrl = ( urlInput . value || provider . value ) . trim ( ) ;
const apiKey = el ( 'adm-epApiKey' ) . value . trim ( ) ;
if ( ! rawUrl ) { msg . textContent = 'Select a provider or enter a base URL' ; msg . className = 'admin-error' ; return ; }
if ( provider . value && ! apiKey ) { msg . textContent = 'API key is required for cloud providers' ; msg . className = 'admin-error' ; return ; }
const url = provider . value && rawUrl === provider . value ? rawUrl : _normalizeBaseUrl ( rawUrl ) ;
apiTestController = new AbortController ( ) ;
apiTestBtn . disabled = true ;
apiTestBtn . textContent = 'Testing...' ;
if ( apiCancelTestBtn ) apiCancelTestBtn . classList . remove ( 'hidden' ) ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
if ( apiKey ) fd . append ( 'api_key' , apiKey ) ;
const res = await fetch ( '/api/model-endpoints/test' , {
method : 'POST' ,
body : fd ,
credentials : 'same-origin' ,
signal : apiTestController . signal ,
} ) ;
const d = await res . json ( ) ;
_renderEndpointTestResult ( msg , res , d ) ;
} catch ( e ) {
if ( e && e . name === 'AbortError' ) {
msg . textContent = 'Test canceled' ;
msg . className = '' ;
} else {
msg . textContent = 'Test failed: ' + ( e && e . message ? e . message : 'request failed' ) ;
msg . className = 'admin-error' ;
}
}
apiTestController = null ;
apiTestBtn . disabled = false ;
apiTestBtn . textContent = 'Test' ;
if ( apiCancelTestBtn ) apiCancelTestBtn . classList . add ( 'hidden' ) ;
} ) ;
}
if ( apiCancelTestBtn ) {
apiCancelTestBtn . addEventListener ( 'click' , ( ) => {
if ( apiTestController ) apiTestController . abort ( ) ;
} ) ;
}
2026-05-31 23:58:26 +09:00
el ( 'adm-epAddBtn' ) . addEventListener ( 'click' , async ( ) => {
2026-06-01 10:00:15 +09:00
const msg = _endpointMsg ( 'api' ) ;
2026-05-31 23:58:26 +09:00
msg . textContent = '' ; msg . className = '' ;
2026-06-01 10:00:15 +09:00
const rawUrl = ( urlInput . value || provider . value ) . trim ( ) ;
2026-05-31 23:58:26 +09:00
const apiKey = el ( 'adm-epApiKey' ) . value . trim ( ) ;
if ( ! rawUrl ) { msg . textContent = 'Select a provider or enter a base URL' ; msg . className = 'admin-error' ; return ; }
if ( provider . value && ! apiKey ) { msg . textContent = 'API key is required for cloud providers' ; msg . className = 'admin-error' ; return ; }
// Normalize URL (fix typos, add /v1, strip wrong paths)
2026-06-01 10:00:15 +09:00
const url = provider . value && rawUrl === provider . value ? rawUrl : _normalizeBaseUrl ( rawUrl ) ;
2026-05-31 23:58:26 +09:00
const btn = el ( 'adm-epAddBtn' ) ;
btn . disabled = true ; btn . textContent = 'Adding...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
if ( apiKey ) fd . append ( 'api_key' , apiKey ) ;
2026-06-01 15:26:10 +10:00
if ( provider . value && provider . selectedOptions && provider . selectedOptions [ 0 ] ) {
fd . append ( 'name' , provider . selectedOptions [ 0 ] . textContent . trim ( ) ) ;
}
2026-05-31 23:58:26 +09:00
const epType = el ( 'adm-epType' ) ;
if ( epType ) fd . append ( 'model_type' , epType . value ) ;
2026-06-01 15:26:10 +10:00
if ( provider . value && /openrouter\.ai|ollama\.com/i . test ( provider . value ) ) fd . append ( 'require_models' , 'true' ) ;
else fd . append ( 'skip_probe' , 'false' ) ;
2026-05-31 23:58:26 +09:00
const res = await fetch ( '/api/model-endpoints' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const d = await res . json ( ) ;
if ( res . ok ) {
const count = d . models ? d . models . length : 0 ;
urlInput . value = '' ; urlInput . style . display = '' ;
el ( 'adm-epApiKey' ) . value = '' ; provider . value = '' ;
if ( epType ) epType . value = 'llm' ;
if ( d . id ) _recentlyAddedEpId = String ( d . id ) ;
2026-06-01 10:00:15 +09:00
await loadEndpoints ( ) ;
await _selectAddedModelInChat ( d ) ;
2026-05-31 23:58:26 +09:00
if ( ! d . online ) {
msg . textContent = 'Added (endpoint offline — will retry on next load)' ;
msg . className = 'admin-error' ;
2026-06-01 10:00:15 +09:00
} else if ( d . status === 'empty' ) {
msg . textContent = 'Added — endpoint reachable, no models found' ;
msg . className = 'admin-success' ;
2026-05-31 23:58:26 +09:00
} else {
2026-06-01 10:00:15 +09:00
msg . textContent = ` Added — found ${ count } model ${ count !== 1 ? 's' : '' } ` ;
2026-05-31 23:58:26 +09:00
msg . className = 'admin-success' ;
}
} else { msg . textContent = d . detail || 'Failed' ; msg . className = 'admin-error' ; }
} catch ( e ) { msg . textContent = 'Request failed' ; msg . className = 'admin-error' ; }
btn . disabled = false ; btn . textContent = 'Add' ;
} ) ;
// Local "Add" button — sibling form for self-hosted base URLs.
const localAddBtn = el ( 'adm-epLocalAddBtn' ) ;
2026-06-01 10:00:15 +09:00
const localTestBtn = el ( 'adm-epLocalTestBtn' ) ;
if ( localTestBtn ) {
localTestBtn . addEventListener ( 'click' , async ( ) => {
const msg = _endpointMsg ( 'local' ) ;
msg . textContent = '' ; msg . className = '' ;
const raw = ( el ( 'adm-epLocalUrl' ) . value || '' ) . trim ( ) ;
if ( ! raw ) { msg . textContent = 'Enter a base URL to test' ; msg . className = 'admin-error' ; return ; }
const url = _normalizeBaseUrl ( raw ) ;
localTestBtn . disabled = true ;
localTestBtn . textContent = 'Testing...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
const res = await fetch ( '/api/model-endpoints/test' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const d = await res . json ( ) ;
_renderEndpointTestResult ( msg , res , d ) ;
} catch ( e ) {
msg . textContent = 'Test failed: ' + ( e && e . message ? e . message : 'request failed' ) ;
msg . className = 'admin-error' ;
}
localTestBtn . disabled = false ;
localTestBtn . textContent = 'Test' ;
} ) ;
}
2026-05-31 23:58:26 +09:00
if ( localAddBtn ) {
localAddBtn . addEventListener ( 'click' , async ( ) => {
2026-06-01 10:00:15 +09:00
const msg = _endpointMsg ( 'local' ) ;
2026-05-31 23:58:26 +09:00
msg . textContent = '' ; msg . className = '' ;
const raw = ( el ( 'adm-epLocalUrl' ) . value || '' ) . trim ( ) ;
if ( ! raw ) { msg . textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)' ; msg . className = 'admin-error' ; return ; }
const url = _normalizeBaseUrl ( raw ) ;
localAddBtn . disabled = true ; localAddBtn . textContent = 'Adding...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
const lt = el ( 'adm-epLocalType' ) ;
if ( lt ) fd . append ( 'model_type' , lt . value ) ;
2026-06-01 10:00:15 +09:00
fd . append ( 'skip_probe' , 'false' ) ;
2026-05-31 23:58:26 +09:00
const res = await fetch ( '/api/model-endpoints' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const d = await res . json ( ) ;
if ( res . ok ) {
el ( 'adm-epLocalUrl' ) . value = '' ;
if ( lt ) lt . value = 'llm' ;
if ( d . id ) _recentlyAddedEpId = String ( d . id ) ;
2026-06-01 10:00:15 +09:00
await loadEndpoints ( ) ;
await _selectAddedModelInChat ( d ) ;
2026-05-31 23:58:26 +09:00
const count = ( d . models || [ ] ) . length ;
2026-06-01 10:00:15 +09:00
msg . textContent = d . status === 'empty'
? 'Added — Ollama is running, no models pulled yet'
: d . online
2026-05-31 23:58:26 +09:00
? ` Added — found ${ count } model ${ count !== 1 ? 's' : '' } `
: 'Added (offline — will retry on next load)' ;
msg . className = d . online ? 'admin-success' : 'admin-error' ;
} else { msg . textContent = d . detail || 'Failed' ; msg . className = 'admin-error' ; }
} catch ( e ) { msg . textContent = 'Request failed' ; msg . className = 'admin-error' ; }
localAddBtn . disabled = false ; localAddBtn . textContent = 'Add' ;
} ) ;
}
2026-06-01 10:00:15 +09:00
const ollamaBtn = el ( 'adm-epOllamaBtn' ) ;
if ( ollamaBtn ) {
ollamaBtn . addEventListener ( 'click' , async ( ) => {
const input = el ( 'adm-epLocalUrl' ) ;
if ( input ) {
input . value = await _defaultOllamaUrl ( ) ;
input . focus ( ) ;
}
const msg = _endpointMsg ( 'local' ) ;
if ( msg ) {
msg . innerHTML = '<span style="font-size:11px;opacity:0.55;">Ollama ready to test.</span>' ;
msg . className = '' ;
}
} ) ;
}
2026-05-31 23:58:26 +09:00
// Discover local models button
const discoverBtn = el ( 'adm-epDiscoverBtn' ) ;
if ( discoverBtn ) {
discoverBtn . addEventListener ( 'click' , async ( ) => {
2026-06-01 10:00:15 +09:00
const msg = _endpointMsg ( 'local' ) ;
2026-05-31 23:58:26 +09:00
discoverBtn . disabled = true ;
// Keep the button's icon as-is while scanning; the whirlpool +
// status text below is enough feedback. (Two spinning indicators
// at once looks busy.)
msg . className = '' ;
msg . innerHTML = '' ;
try {
const sp = window . spinnerModule || ( await import ( './spinner.js' ) ) . default ;
const wp = sp . createWhirlpool ( 20 ) ;
wp . element . style . cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;' ;
const wrap = document . createElement ( 'div' ) ;
wrap . style . cssText = 'display:flex;align-items:center;padding:8px 0;' ;
wrap . appendChild ( wp . element ) ;
const txt = document . createElement ( 'span' ) ;
2026-06-01 10:00:15 +09:00
txt . textContent = 'Scanning ports 8000-8020 and 11434 for model servers...' ;
2026-05-31 23:58:26 +09:00
txt . style . cssText = 'font-size:12px;opacity:0.7;' ;
wrap . appendChild ( txt ) ;
msg . appendChild ( wrap ) ;
discoverBtn . _wp = wp ;
} catch ( e ) { msg . textContent = 'Scanning...' ; }
try {
const res = await fetch ( '/api/discover' ) ;
const data = await res . json ( ) ;
const items = data . items || [ ] ;
if ( ! items . length ) {
2026-06-01 21:27:04 -06:00
msg . textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.' ;
2026-05-31 23:58:26 +09:00
msg . className = 'admin-error' ;
} else {
2026-06-01 15:22:06 +10:00
// Auto-add each discovered endpoint. Server dedupes on base_url
// and returns `existing: true` for already-registered ones.
2026-05-31 23:58:26 +09:00
let added = 0 ;
2026-06-01 15:22:06 +10:00
let skipped = 0 ;
2026-05-31 23:58:26 +09:00
for ( const item of items ) {
const base = item . url . replace ( '/chat/completions' , '' ) . replace ( /\/$/ , '' ) ;
const fd = new FormData ( ) ;
fd . append ( 'base_url' , base ) ;
2026-06-01 10:00:15 +09:00
fd . append ( 'skip_probe' , 'false' ) ;
2026-05-31 23:58:26 +09:00
const r = await fetch ( '/api/model-endpoints' , { method : 'POST' , body : fd } ) ;
if ( r . ok ) {
2026-06-01 15:22:06 +10:00
try {
const dd = await r . json ( ) ;
if ( dd && dd . existing ) { skipped ++ ; }
else { added ++ ; if ( dd && dd . id ) _recentlyAddedEpId = String ( dd . id ) ; }
} catch ( _ ) { added ++ ; }
2026-05-31 23:58:26 +09:00
}
}
const totalModels = items . reduce ( ( n , i ) => n + ( i . models ? i . models . length : 0 ) , 0 ) ;
2026-06-01 15:22:06 +10:00
const parts = [ ` Found ${ items . length } server ${ items . length !== 1 ? 's' : '' } with ${ totalModels } model ${ totalModels !== 1 ? 's' : '' } ` ] ;
if ( added ) parts . push ( ` added ${ added } new ` ) ;
if ( skipped ) parts . push ( ` ${ skipped } already added ` ) ;
msg . innerHTML = parts . join ( ' — ' ) ;
2026-05-31 23:58:26 +09:00
msg . className = 'admin-success' ;
loadEndpoints ( ) ;
}
} catch ( e ) {
msg . textContent = 'Scan failed: ' + e . message ;
msg . className = 'admin-error' ;
}
if ( discoverBtn . _wp ) { discoverBtn . _wp . destroy ( ) ; discoverBtn . _wp = null ; }
discoverBtn . disabled = false ;
discoverBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers' ;
} ) ;
}
// Collapsible Add-Models subsections (API / Local). Both start collapsed
// so the card is compact; the last-used state is remembered per section
// in localStorage so a frequent API-adder doesn't re-expand every time.
document . querySelectorAll ( '#adm-add-api, #adm-add-local' ) . forEach ( ( sec ) => {
const head = sec . querySelector ( '.adm-section-toggle' ) ;
if ( ! head ) return ;
const key = 'odysseus.addModels.' + sec . id + '.open' ;
let open = false ;
try { open = localStorage . getItem ( key ) === '1' ; } catch { }
2026-06-01 10:00:15 +09:00
const apply = ( ) => {
sec . classList . toggle ( 'collapsed' , ! open ) ;
head . setAttribute ( 'aria-expanded' , open ? 'true' : 'false' ) ;
} ;
apply ( ) ;
const toggle = ( ) => {
open = ! open ;
try { localStorage . setItem ( key , open ? '1' : '0' ) ; } catch { }
apply ( ) ;
} ;
head . addEventListener ( 'click' , toggle ) ;
head . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; toggle ( ) ; }
} ) ;
} ) ;
document . querySelectorAll ( '.adm-quickstart-section' ) . forEach ( ( sec ) => {
const head = sec . querySelector ( '.adm-quickstart-toggle' ) ;
if ( ! head ) return ;
const key = 'odysseus.addModels.' + sec . id + '.open' ;
let open = false ;
try { open = localStorage . getItem ( key ) === '1' ; } catch { }
2026-05-31 23:58:26 +09:00
const apply = ( ) => {
sec . classList . toggle ( 'collapsed' , ! open ) ;
head . setAttribute ( 'aria-expanded' , open ? 'true' : 'false' ) ;
} ;
apply ( ) ;
const toggle = ( ) => {
open = ! open ;
try { localStorage . setItem ( key , open ? '1' : '0' ) ; } catch { }
apply ( ) ;
} ;
head . addEventListener ( 'click' , toggle ) ;
head . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; toggle ( ) ; }
} ) ;
} ) ;
}
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
TOOLS TAB — MCP
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
const _GOOGLE _OAUTH _HELP = ` To get Google OAuth credentials:
1. Go to console . cloud . google . com
2. Click the project dropdown ( top left ) > New Project > name it > Create
3. APIs & Services > Library > enable the API you need ( Gmail , Calendar , Drive , etc . )
4. APIs & Services > OAuth consent screen > configure ( External , app name + email )
5. Under Audience , click Add Users > add your Google email as a test user
6. APIs & Services > Credentials > + Create Credentials > OAuth Client ID > Desktop App
7. Copy the Client ID and Client Secret into the fields above
8. After adding the server , click Authorize to sign in with Google
9. If accessing remotely : sign in , then copy the URL from the error page and paste it back ` ;
const MCP _PRESETS = [
{ name : "Gmail" , command : "npx" , args : [ "-y" , "@gongrzhe/server-gmail-autoauth-mcp" ] , env : { GOOGLE _CLIENT _ID : "" , GOOGLE _CLIENT _SECRET : "" } ,
oauthFile : { dir : "~/.gmail-mcp" , filename : "gcp-oauth.keys.json" } ,
oauth : {
provider : "google" ,
keys _file : "~/.gmail-mcp/gcp-oauth.keys.json" ,
token _file : "~/.gmail-mcp/credentials.json" ,
scopes : [ "https://www.googleapis.com/auth/gmail.modify" , "https://www.googleapis.com/auth/gmail.settings.basic" ] ,
} ,
help : ` Setup:
1. Go to console . cloud . google . com > create or select a project
2. APIs & Services > Library > search "Gmail API" > Enable
3. APIs & Services > OAuth consent screen > set up ( External is fine )
4. Under Audience , add your Gmail address as a test user
5. APIs & Services > Credentials > + Create Credentials > OAuth Client ID
6. Application type : Desktop App > Create
7. Copy the Client ID and Client Secret into the fields above
8. Click Add Server , then click the Authorize button
9. Sign in with Google , copy the URL from the error page , paste it back ` },
{ name : "Email (IMAP/SMTP)" , command : "npx" , args : [ "-y" , "@codefuturist/email-mcp" , "stdio" ] , env : { MCP _EMAIL _ADDRESS : "" , MCP _EMAIL _PASSWORD : "" , MCP _EMAIL _IMAP _HOST : "" , MCP _EMAIL _SMTP _HOST : "" } ,
providerDropdown : {
label : "Provider" ,
targets : { MCP _EMAIL _IMAP _HOST : "imap" , MCP _EMAIL _SMTP _HOST : "smtp" } ,
options : [
{ name : "Migadu" , imap : "imap.migadu.com" , smtp : "smtp.migadu.com" } ,
{ name : "Fastmail" , imap : "imap.fastmail.com" , smtp : "smtp.fastmail.com" } ,
{ name : "Proton Bridge" , imap : "127.0.0.1" , smtp : "127.0.0.1" } ,
{ name : "Outlook/Hotmail" , imap : "outlook.office365.com" , smtp : "smtp.office365.com" } ,
{ name : "Yahoo" , imap : "imap.mail.yahoo.com" , smtp : "smtp.mail.yahoo.com" } ,
{ name : "iCloud" , imap : "imap.mail.me.com" , smtp : "smtp.mail.me.com" } ,
{ name : "Zoho" , imap : "imap.zoho.com" , smtp : "smtp.zoho.com" } ,
{ name : "Custom" , imap : "" , smtp : "" } ,
] ,
} ,
help : "Works with any IMAP/SMTP email provider.\n1. Pick your provider from the dropdown (or choose Custom)\n2. Enter your email address and password (or app password)\n3. Click Add Server" } ,
{ name : "CalDAV (Radicale/Nextcloud)" , command : "npx" , args : [ "-y" , "caldav-mcp" ] , env : { CALDAV _BASE _URL : "http://localhost:5232" , CALDAV _USERNAME : "" , CALDAV _PASSWORD : "" } ,
help : "Works with any CalDAV server (Radicale, Nextcloud, etc.).\n1. Enter your CalDAV server URL (e.g. http://localhost:5232)\n2. Enter your username and password\n3. Click Add Server" } ,
{ name : "Google Calendar" , command : "npx" , args : [ "-y" , "@cocal/google-calendar-mcp" ] , env : { GOOGLE _OAUTH _CREDENTIALS : "" } ,
help : ` Setup:
1. Go to console . cloud . google . com > create / select a project
2. APIs & Services > Library > enable Google Calendar API
3. APIs & Services > Credentials > + Create Credentials > OAuth Client ID
4. Application type : Desktop App > Create
5. Click "Download JSON" on the credential you just created
6. Set Google Oauth Credentials to the full path of the downloaded JSON file ` },
{ name : "Google Drive" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-gdrive" ] , env : { } ,
help : "Google Drive uses browser-based OAuth on first run. No env vars needed — just click Add and authorize when prompted." } ,
{ name : "GitHub" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-github" ] , env : { GITHUB _PERSONAL _ACCESS _TOKEN : "" } ,
help : "1. Go to github.com > Settings > Developer Settings > Personal Access Tokens > Fine-grained tokens\n2. Generate a new token with the repo permissions you need\n3. Paste it as Github Personal Access Token" } ,
{ name : "Slack" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-slack" ] , env : { SLACK _BOT _TOKEN : "" , SLACK _TEAM _ID : "" } ,
help : "1. Go to api.slack.com/apps > Create New App > From Scratch\n2. Add Bot Token Scopes (channels:read, chat:write, etc.)\n3. Install to workspace, copy the Bot User OAuth Token (xoxb-...)\n4. Team ID is in your workspace URL or Slack admin settings" } ,
{ name : "Notion" , command : "npx" , args : [ "-y" , "@notionhq/notion-mcp-server" ] , env : { OPENAPI _MCP _HEADERS : "" } ,
help : "1. Go to notion.so/my-integrations\n2. Create a new integration\n3. Copy the Internal Integration Secret\n4. Share the Notion pages/databases you want accessible with the integration\n5. For Openapi Mcp Headers enter:\n {\"Authorization\": \"Bearer YOUR_SECRET\", \"Notion-Version\": \"2022-06-28\"}" } ,
{ name : "Linear" , command : "npx" , args : [ "-y" , "mcp-linear" ] , env : { LINEAR _API _KEY : "" } ,
help : "1. Go to linear.app > Settings > API\n2. Create a Personal API Key\n3. Paste it as Linear Api Key" } ,
{ name : "Brave Search" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-brave-search" ] , env : { BRAVE _API _KEY : "" } ,
help : "1. Go to brave.com/search/api\n2. Sign up for a free plan (2000 queries/month)\n3. Copy your API key" } ,
{ name : "Browser (Playwright)" , command : "npx" , args : [ "-y" , "@playwright/mcp@latest" , "--headless" ] , env : { } ,
help : "Browser automation via Playwright. The AI can navigate pages, click, fill forms, and read content.\nRuns headless by default. Remove --headless from Args to see the browser window.\nFirst run installs Chromium automatically." } ,
{ name : "Filesystem" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-filesystem" , "/home" ] , env : { } ,
help : "Edit the Args field to change which directory the server has access to." } ,
{ name : "Memory" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-memory" ] , env : { } } ,
{ name : "Postgres" , command : "npx" , args : [ "-y" , "@modelcontextprotocol/server-postgres" , "postgresql://user:pass@localhost/db" ] , env : { } ,
help : "Replace the connection string in the Args field with your actual Postgres connection URL." } ,
{ name : "Todoist" , command : "npx" , args : [ "-y" , "todoist-mcp-server" ] , env : { TODOIST _API _TOKEN : "" } ,
help : "1. Go to todoist.com > Settings > Integrations > Developer\n2. Copy your API token" } ,
] ;
// ── Built-in tools management ──
const TOOL _META = {
bash : { name : 'Shell' , desc : 'Execute bash commands' , cat : 'Code' , ctx : '~200' } ,
python : { name : 'Python' , desc : 'Run Python scripts' , cat : 'Code' , ctx : '~200' } ,
read _file : { name : 'Read File' , desc : 'Read files from disk' , cat : 'Code' , ctx : '~150' } ,
write _file : { name : 'Write File' , desc : 'Write/create files' , cat : 'Code' , ctx : '~150' } ,
web _search : { name : 'Web Search' , desc : 'Search the web via SearXNG' , cat : 'Search' , ctx : '~300' } ,
search _chats : { name : 'Search Chats' , desc : 'Search conversation history' , cat : 'Search' , ctx : '~150' } ,
create _document : { name : 'Create Document' , desc : 'Create new documents' , cat : 'Documents' , ctx : '~200' } ,
update _document : { name : 'Update Document' , desc : 'Modify existing documents' , cat : 'Documents' , ctx : '~200' } ,
edit _document : { name : 'Edit Document' , desc : 'Find & replace in documents' , cat : 'Documents' , ctx : '~200' } ,
suggest _document : { name : 'Suggest Changes' , desc : 'Propose document edits' , cat : 'Documents' , ctx : '~200' } ,
manage _documents : { name : 'Manage Documents' , desc : 'List, delete, organize docs' , cat : 'Documents' , ctx : '~150' } ,
generate _image : { name : 'Generate Image' , desc : 'Create images via AI' , cat : 'Media' , ctx : '~150' } ,
manage _memory : { name : 'Memory' , desc : 'Save and recall memories' , cat : 'Knowledge' , ctx : '~200' } ,
manage _skills : { name : 'Skills' , desc : 'Learn and use procedures' , cat : 'Knowledge' , ctx : '~200' } ,
manage _rag : { name : 'RAG / Docs' , desc : 'Query indexed documents' , cat : 'Knowledge' , ctx : '~150' } ,
chat _with _model : { name : 'Chat with Model' , desc : 'Talk to another AI model' , cat : 'Multi-Agent' , ctx : '~200' } ,
second _opinion : { name : 'Second Opinion' , desc : 'Get another model\'s take' , cat : 'Multi-Agent' , ctx : '~150' } ,
pipeline : { name : 'Pipeline' , desc : 'Multi-step AI workflows' , cat : 'Multi-Agent' , ctx : '~200' } ,
ask _teacher : { name : 'Ask Teacher' , desc : 'Query a more capable model' , cat : 'Multi-Agent' , ctx : '~150' } ,
send _to _session : { name : 'Send to Session' , desc : 'Send message to another chat' , cat : 'Sessions' , ctx : '~100' } ,
create _session : { name : 'Create Session' , desc : 'Start a new chat session' , cat : 'Sessions' , ctx : '~100' } ,
list _sessions : { name : 'List Sessions' , desc : 'Browse existing sessions' , cat : 'Sessions' , ctx : '~100' } ,
manage _session : { name : 'Manage Session' , desc : 'Rename, archive, configure' , cat : 'Sessions' , ctx : '~100' } ,
list _models : { name : 'List Models' , desc : 'Show available models' , cat : 'System' , ctx : '~100' } ,
ui _control : { name : 'UI Control' , desc : 'Change theme, layout, settings' , cat : 'System' , ctx : '~150' } ,
manage _tasks : { name : 'Tasks' , desc : 'Schedule automated tasks' , cat : 'System' , ctx : '~150' } ,
api _call : { name : 'API Call' , desc : 'Make HTTP requests' , cat : 'System' , ctx : '~200' } ,
manage _endpoints : { name : 'Endpoints' , desc : 'Add/remove model endpoints' , cat : 'System' , ctx : '~100' } ,
manage _mcp : { name : 'MCP Servers' , desc : 'Manage MCP connections' , cat : 'System' , ctx : '~100' } ,
manage _webhooks : { name : 'Webhooks' , desc : 'Configure webhook events' , cat : 'System' , ctx : '~100' } ,
manage _tokens : { name : 'API Tokens' , desc : 'Manage API access tokens' , cat : 'System' , ctx : '~100' } ,
manage _settings : { name : 'Settings' , desc : 'Change app settings' , cat : 'System' , ctx : '~100' } ,
} ;
async function loadBuiltinTools ( ) {
const list = el ( 'adm-builtin-tools-list' ) ;
if ( ! list ) return ;
try {
const res = await fetch ( '/api/tools' , { credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
const tools = data . tools || [ ] ;
if ( ! tools . length ) { list . innerHTML = '<div class="admin-empty">No tools found</div>' ; return ; }
// Group by category
const groups = { } ;
for ( const t of tools ) {
const meta = TOOL _META [ t . id ] || { name : t . id , desc : '' , cat : 'Other' , ctx : '?' } ;
const cat = meta . cat ;
if ( ! groups [ cat ] ) groups [ cat ] = [ ] ;
groups [ cat ] . push ( { ... t , ... meta } ) ;
}
// Category order
const catOrder = [ 'Code' , 'Search' , 'Documents' , 'Media' , 'Knowledge' , 'Multi-Agent' , 'Sessions' , 'System' , 'Other' ] ;
let html = '' ;
for ( const cat of catOrder ) {
const items = groups [ cat ] ;
if ( ! items ) continue ;
const enabledCount = items . filter ( i => i . enabled ) . length ;
const totalCount = items . length ;
const catId = 'tool-cat-' + cat . replace ( /[^a-zA-Z]/g , '' ) ;
const allEnabled = enabledCount === totalCount ;
html += ` <div class="admin-tool-category">
< div class = "admin-tool-cat-header" data - tool - cat = "${catId}" style = "cursor:pointer;display:flex;align-items:center;justify-content:space-between;" >
< span > $ { esc ( cat ) } < / s p a n >
< span style = "display:flex;align-items:center;gap:6px;" class = "admin-tool-cat-right" >
< span class = "admin-tool-cat-count" style = "font-size:10px;opacity:0.5;" > $ { enabledCount } / $ { totalCount } < / s p a n >
< label class = "admin-switch" style = "flex-shrink:0;" >
< input type = "checkbox" data - tool - cat - toggle = "${catId}" $ { allEnabled ? 'checked' : '' } >
< span class = "admin-slider" > < / s p a n >
< / l a b e l >
< svg class = "admin-tool-cat-chevron" width = "12" height = "12" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2.5" stroke - linecap = "round" stroke - linejoin = "round" style = "opacity:0.3;transition:transform 0.2s,opacity 0.2s;" > < polyline points = "6 9 12 15 18 9" / > < / s v g >
< / s p a n >
< / d i v >
< div class = "admin-tool-cat-body hidden" id = "${catId}" > ` ;
for ( const t of items ) {
html += `
< div class = "admin-tool-row" >
< div class = "admin-tool-info" >
< span class = "admin-tool-name" > $ { esc ( t . name ) } < / s p a n >
< span class = "admin-tool-desc" > $ { esc ( t . desc ) } < / s p a n >
< / d i v >
< span class = "admin-tool-ctx" title = "Approximate context tokens used" > $ { esc ( t . ctx ) } < / s p a n >
< label class = "admin-switch" style = "flex-shrink:0;" >
< input type = "checkbox" data - tool - id = "${esc(t.id)}" $ { t . enabled ? 'checked' : '' } >
< span class = "admin-slider" > < / s p a n >
< / l a b e l >
< / d i v > ` ;
}
html += '</div></div>' ;
}
list . innerHTML = html ;
// Prevent toggle clicks from expanding/collapsing
list . querySelectorAll ( '.admin-tool-cat-right' ) . forEach ( span => {
span . addEventListener ( 'click' , e => e . stopPropagation ( ) ) ;
} ) ;
// Wire category expand/collapse
list . querySelectorAll ( '[data-tool-cat]' ) . forEach ( header => {
header . addEventListener ( 'click' , ( ) => {
const body = el ( header . dataset . toolCat ) ;
if ( ! body ) return ;
body . classList . toggle ( 'hidden' ) ;
const chevron = header . querySelector ( '.admin-tool-cat-chevron' ) ;
const isOpen = ! body . classList . contains ( 'hidden' ) ;
if ( chevron ) {
chevron . style . transform = isOpen ? 'rotate(180deg)' : '' ;
chevron . style . opacity = isOpen ? '0.7' : '0.3' ;
}
} ) ;
} ) ;
// Helper: save disabled tools + update counters
async function _saveToolState ( ) {
const allChecks = list . querySelectorAll ( 'input[data-tool-id]' ) ;
const disabled = [ ] ;
allChecks . forEach ( c => { if ( ! c . checked ) disabled . push ( c . dataset . toolId ) ; } ) ;
await fetch ( '/api/tools' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { disabled } ) ,
credentials : 'same-origin' ,
} ) ;
}
function _updateCatCounter ( catEl ) {
if ( ! catEl ) return ;
const catChecks = catEl . querySelectorAll ( 'input[data-tool-id]' ) ;
const catEnabled = Array . from ( catChecks ) . filter ( c => c . checked ) . length ;
const counter = catEl . querySelector ( '.admin-tool-cat-count' ) ;
if ( counter ) counter . textContent = catEnabled + '/' + catChecks . length ;
const catToggle = catEl . querySelector ( 'input[data-tool-cat-toggle]' ) ;
if ( catToggle ) catToggle . checked = ( catEnabled === catChecks . length ) ;
}
// Wire individual tool toggles
list . querySelectorAll ( 'input[data-tool-id]' ) . forEach ( chk => {
chk . addEventListener ( 'change' , async ( ) => {
await _saveToolState ( ) ;
_updateCatCounter ( chk . closest ( '.admin-tool-category' ) ) ;
} ) ;
} ) ;
// Wire category-level toggle (enable/disable all in category)
list . querySelectorAll ( 'input[data-tool-cat-toggle]' ) . forEach ( chk => {
chk . addEventListener ( 'change' , async ( ) => {
const catEl = chk . closest ( '.admin-tool-category' ) ;
if ( ! catEl ) return ;
const checked = chk . checked ;
catEl . querySelectorAll ( 'input[data-tool-id]' ) . forEach ( c => { c . checked = checked ; } ) ;
await _saveToolState ( ) ;
_updateCatCounter ( catEl ) ;
} ) ;
} ) ;
} catch ( e ) {
console . error ( 'Failed to load tools:' , e ) ;
list . innerHTML = '<div class="admin-empty">Failed to load tools</div>' ;
}
}
async function loadMcpServers ( ) {
const list = el ( 'adm-mcpList' ) ;
if ( ! list ) return ; // MCP section not visible / not yet rendered
try {
const res = await fetch ( '/api/mcp/servers' , { credentials : 'same-origin' } ) ;
const servers = await res . json ( ) ;
if ( ! servers . length ) { list . innerHTML = '<div class="admin-empty">No MCP servers configured</div>' ; return ; }
list . innerHTML = servers . map ( s => {
const statusColor = s . needs _oauth ? '#e5a33a' : s . status === 'connected' ? 'var(--fg)' : s . status === 'error' ? 'var(--red)' : 'color-mix(in srgb, var(--fg) 50%, transparent)' ;
const toolInfo = s . status === 'connected' ? ` ${ s . enabled _tool _count } / ${ s . tool _count } tools enabled ` : '' ;
const statusText = s . needs _oauth ? 'Needs authorization' : s . status === 'connected' ? ` Connected ( ${ toolInfo } ) ` : s . status === 'error' ? ` Error: ${ s . error || 'unknown' } ` : 'Disconnected' ;
const hasTools = s . status === 'connected' && s . tool _count > 0 ;
return ` <div class="admin-user-row" data-adm-mcp-id=" ${ s . id } ">
< div style = "display:flex;align-items:center;justify-content:space-between;${hasTools ? 'cursor:pointer;' : ''}padding:4px 0;" data - adm - mcp - header = "${s.id}" >
< div class = "admin-user-info" style = "flex:1;flex-wrap:wrap;gap:0.3rem;" >
< span class = "admin-user-name" > $ { esc ( s . name ) } < / s p a n >
< span class = "admin-badge" style = "background:${statusColor}33;color:${statusColor}" > $ { statusText } < / s p a n >
$ { hasTools ? ` <span style="font-size:10px;opacity:0.4;">Click to manage tools</span> ` : '' }
< / d i v >
< div style = "display:flex;gap:4px;align-items:center;" >
$ { s . needs _oauth ? ` <a href="/api/mcp/oauth/authorize/ ${ s . id } " target="_blank" class="admin-btn-sm" style="background:var(--red);color:#fff;text-decoration:none;padding:3px 10px;border-radius:4px;font-size:11px;font-weight:600;">Authorize</a> ` : '' }
< button class = "admin-btn-sm" data - adm - mcp - reconnect = "${s.id}" > Reconnect < / b u t t o n >
< button class = "admin-btn-delete" style = "border-color:${s.is_enabled ? 'color-mix(in srgb, var(--red) 30%, transparent)' : 'color-mix(in srgb, var(--fg) 30%, transparent)'};color:${s.is_enabled ? 'var(--red)' : 'var(--fg)'};" data - adm - mcp - toggle = "${s.id}" data - adm - mcp - enable = "${!s.is_enabled}" > $ { s . is _enabled ? 'Disable' : 'Enable' } < / b u t t o n >
< button class = "admin-btn-delete" data - adm - mcp - delete = "${s.id}" > Delete < / b u t t o n >
$ { hasTools ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : '' }
< / d i v >
< / d i v >
$ { hasTools ? ` <div class="mcp-tools-panel hidden" data-adm-mcp-tools-panel=" ${ s . id } "></div> ` : '' }
< / d i v > ` ;
} ) . join ( '' ) ;
list . querySelectorAll ( '[data-adm-mcp-reconnect]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
const msg = el ( 'adm-mcpMsg' ) ; msg . textContent = 'Reconnecting...' ; msg . className = '' ;
try {
const res = await fetch ( ` /api/mcp/servers/ ${ btn . dataset . admMcpReconnect } /reconnect ` , { method : 'POST' , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
msg . textContent = data . connected ? ` Reconnected ( ${ data . tool _count } tools) ` : ` Failed: ${ data . error || 'unknown' } ` ;
msg . className = data . connected ? 'admin-success' : 'admin-error' ;
loadMcpServers ( ) ;
} catch ( e ) { msg . textContent = 'Failed: ' + e . message ; msg . className = 'admin-error' ; }
} ) ;
} ) ;
list . querySelectorAll ( '[data-adm-mcp-toggle]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
const fd = new FormData ( ) ; fd . append ( 'is_enabled' , btn . dataset . admMcpEnable ) ;
await fetch ( ` /api/mcp/servers/ ${ btn . dataset . admMcpToggle } ` , { method : 'PATCH' , body : fd , credentials : 'same-origin' } ) ;
loadMcpServers ( ) ;
} ) ;
} ) ;
list . querySelectorAll ( '[data-adm-mcp-delete]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( 'Delete this MCP server?' , { confirmText : 'Delete' , danger : true } ) ) return ;
await fetch ( ` /api/mcp/servers/ ${ btn . dataset . admMcpDelete } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
loadMcpServers ( ) ;
} ) ;
} ) ;
// Tools expand/collapse (click anywhere on card)
list . querySelectorAll ( '[data-adm-mcp-id]' ) . forEach ( row => {
const header = row . querySelector ( '[data-adm-mcp-header]' ) ;
if ( ! header ) return ;
let _toolsLoaded = false ;
row . style . cursor = 'pointer' ;
row . addEventListener ( 'click' , async ( e ) => {
if ( e . target . closest ( '.admin-btn-sm, .admin-btn-delete, a, .mcp-tools-list, .mcp-tools-header' ) ) return ;
const sid = header . dataset . admMcpHeader ;
const panel = row . querySelector ( ` [data-adm-mcp-tools-panel=" ${ sid } "] ` ) ;
if ( ! panel ) return ;
panel . classList . toggle ( 'hidden' ) ;
const chevron = row . querySelector ( '.admin-user-chevron' ) ;
const isOpen = ! panel . classList . contains ( 'hidden' ) ;
if ( chevron ) {
chevron . style . transform = isOpen ? 'rotate(180deg)' : '' ;
chevron . style . opacity = isOpen ? '0.7' : '0.3' ;
}
if ( ! _toolsLoaded && isOpen ) {
_toolsLoaded = true ;
panel . innerHTML = '<span style="opacity:0.5;font-size:11px;">Loading tools...</span>' ;
try {
const res = await fetch ( ` /api/mcp/servers/ ${ sid } /tools ` , { credentials : 'same-origin' } ) ;
const tools = await res . json ( ) ;
if ( ! tools . length ) { panel . innerHTML = '<span style="opacity:0.5;font-size:11px;">No tools</span>' ; return ; }
const disabled = new Set ( tools . filter ( t => t . is _disabled ) . map ( t => t . name ) ) ;
panel . innerHTML = ` <div class="mcp-tools-header">
< span > Tools < / s p a n >
< span style = "display:flex;gap:8px;align-items:center;" >
< span class = "mcp-tools-count" > $ { tools . length - disabled . size } / $ { tools . length } enabled < / s p a n >
< a href = "#" data - mcp - select - all = "${sid}" > All < / a >
< a href = "#" data - mcp - select - none = "${sid}" > None < / a >
< / s p a n >
< / d i v > < d i v c l a s s = " m c p - t o o l s - l i s t " > ` + t o o l s . m a p ( t = >
` <label title=" ${ esc ( t . description ) } ">
< input type = "checkbox" data - mcp - tool - name = "${esc(t.name)}" $ { ! t . is _disabled ? 'checked' : '' } >
< span > < strong > $ { esc ( t . name ) } < / s t r o n g > < s p a n s t y l e = " o p a c i t y : 0 . 5 ; " > — $ { e s c ( ( t . d e s c r i p t i o n | | ' ' ) . s l i c e ( 0 , 8 0 ) ) } < / s p a n > < / s p a n >
< / l a b e l > `
) . join ( '' ) + '</div>' ;
panel . querySelector ( ` [data-mcp-select-all=" ${ sid } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => cb . checked = true ) ;
_saveMcpToolState ( sid , panel ) ;
} ) ;
panel . querySelector ( ` [data-mcp-select-none=" ${ sid } "] ` ) ? . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => cb . checked = false ) ;
_saveMcpToolState ( sid , panel ) ;
} ) ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => {
cb . addEventListener ( 'change' , ( ) => _saveMcpToolState ( sid , panel ) ) ;
} ) ;
} catch ( e ) { panel . innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load tools</span>' ; }
}
} ) ;
} ) ;
} catch ( e ) { if ( list ) list . innerHTML = '<div class="admin-error">Failed to load MCP servers</div>' ; }
}
async function _saveMcpToolState ( serverId , panel ) {
const disabled = [ ] ;
panel . querySelectorAll ( 'input[type=checkbox]' ) . forEach ( cb => {
if ( ! cb . checked ) disabled . push ( cb . dataset . mcpToolName ) ;
} ) ;
const total = panel . querySelectorAll ( 'input[type=checkbox]' ) . length ;
try {
await fetch ( ` /api/mcp/servers/ ${ serverId } /tools ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
credentials : 'same-origin' ,
body : JSON . stringify ( { disabled } ) ,
} ) ;
// Update the count label in the panel
const countLabel = panel . querySelector ( '.mcp-tools-count' ) ;
if ( countLabel ) countLabel . textContent = ` ${ total - disabled . length } / ${ total } enabled ` ;
// Update badge in the server row
const row = panel . closest ( '[data-adm-mcp-id]' ) ;
if ( row ) {
const badge = row . querySelector ( '.admin-badge' ) ;
if ( badge ) badge . textContent = ` Connected ( ${ total - disabled . length } / ${ total } tools enabled) ` ;
}
} catch ( e ) { /* silent */ }
}
function initMcpForm ( ) {
const cmdEl = el ( 'adm-mcpCommand' ) ;
if ( ! cmdEl ) return ; // MCP form not present in this build — nothing to wire
const transportSel = el ( 'adm-mcpTransport' ) ;
const sseRow = el ( 'adm-mcpSseRow' ) ;
const envRow = el ( 'adm-mcpEnvRow' ) ;
const envFieldsWrap = el ( 'adm-mcpEnvFields' ) ;
const helpBox = el ( 'adm-mcpHelp' ) ;
const cmdRow = cmdEl . parentElement ;
let _activeHelp = null ;
let _envKeys = [ ] ; // track which env keys have dedicated fields
let _activeOauthFile = null ; // preset oauthFile config (for Google servers)
let _activeOauth = null ; // preset OAuth flow config (provider, scopes, etc.)
function _clearEnvFields ( ) {
envFieldsWrap . innerHTML = '' ;
_envKeys = [ ] ;
envRow . style . display = 'none' ;
el ( 'adm-mcpEnv' ) . value = '' ;
_activeOauth = null ;
}
function _buildEnvFields ( envObj , help , preset ) {
_clearEnvFields ( ) ;
const keys = Object . keys ( envObj ) ;
if ( ! keys . length ) return ;
_envKeys = keys ;
// Provider dropdown (e.g. for Email IMAP/SMTP)
if ( preset ? . providerDropdown ) {
const pd = preset . providerDropdown ;
const row = document . createElement ( 'div' ) ;
row . className = 'admin-model-form-row' ;
row . style . cssText = 'gap:6px;align-items:center;' ;
const label = document . createElement ( 'span' ) ;
label . style . cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;' ;
label . textContent = pd . label || 'Provider' ;
const select = document . createElement ( 'select' ) ;
select . style . cssText = 'flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font-size:12px;' ;
pd . options . forEach ( ( opt , i ) => {
const o = document . createElement ( 'option' ) ;
o . value = i ;
o . textContent = opt . name ;
select . appendChild ( o ) ;
} ) ;
select . addEventListener ( 'change' , ( ) => {
const opt = pd . options [ parseInt ( select . value ) ] ;
for ( const [ envKey , field ] of Object . entries ( pd . targets ) ) {
const inp = envFieldsWrap . querySelector ( ` .mcp-env-input[data-env-key=" ${ envKey } "] ` ) ;
if ( inp ) inp . value = opt [ field ] || '' ;
}
} ) ;
row . appendChild ( label ) ;
row . appendChild ( select ) ;
envFieldsWrap . appendChild ( row ) ;
// Auto-fill with first provider after inputs are created
setTimeout ( ( ) => {
const first = pd . options [ 0 ] ;
for ( const [ envKey , field ] of Object . entries ( pd . targets ) ) {
const inp = envFieldsWrap . querySelector ( ` .mcp-env-input[data-env-key=" ${ envKey } "] ` ) ;
if ( inp && ! inp . value ) inp . value = first [ field ] || '' ;
}
} , 0 ) ;
}
for ( const key of keys ) {
const row = document . createElement ( 'div' ) ;
row . className = 'admin-model-form-row' ;
row . style . cssText = 'gap:6px;align-items:center;' ;
const label = document . createElement ( 'span' ) ;
label . style . cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;' ;
label . textContent = key . replace ( /_/g , ' ' ) . replace ( /\b\w/g , c => c . toUpperCase ( ) ) ;
const input = document . createElement ( 'input' ) ;
input . type = key . toLowerCase ( ) . includes ( 'secret' ) || key . toLowerCase ( ) . includes ( 'token' ) || key . toLowerCase ( ) . includes ( 'key' ) || key . toLowerCase ( ) . includes ( 'password' ) ? 'password' : 'text' ;
input . placeholder = key ;
input . dataset . envKey = key ;
input . className = 'mcp-env-input' ;
input . style . cssText = 'flex:1;' ;
if ( envObj [ key ] ) input . value = envObj [ key ] ;
row . appendChild ( label ) ;
row . appendChild ( input ) ;
envFieldsWrap . appendChild ( row ) ;
}
// Help toggle link
if ( help ) {
_activeHelp = help ;
const helpLink = document . createElement ( 'a' ) ;
helpLink . textContent = 'How do I get these?' ;
helpLink . href = '#' ;
helpLink . style . cssText = 'font-size:10.5px;opacity:0.5;margin-top:2px;display:inline-block;' ;
helpLink . addEventListener ( 'click' , ( e ) => {
e . preventDefault ( ) ;
helpBox . style . display = helpBox . style . display === 'none' ? '' : 'none' ;
} ) ;
envFieldsWrap . appendChild ( helpLink ) ;
helpBox . textContent = help ;
helpBox . style . display = 'none' ;
} else {
_activeHelp = null ;
helpBox . style . display = 'none' ;
}
}
// Collect env from either dedicated fields or raw JSON fallback
function _collectEnv ( ) {
if ( _envKeys . length ) {
const obj = { } ;
envFieldsWrap . querySelectorAll ( '.mcp-env-input' ) . forEach ( inp => {
if ( inp . value . trim ( ) ) obj [ inp . dataset . envKey ] = inp . value . trim ( ) ;
} ) ;
return JSON . stringify ( obj ) ;
}
return el ( 'adm-mcpEnv' ) . value . trim ( ) || '{}' ;
}
transportSel . addEventListener ( 'change' , ( ) => {
const isSse = transportSel . value === 'sse' ;
sseRow . style . display = isSse ? '' : 'none' ;
cmdRow . style . display = isSse ? 'none' : '' ;
if ( isSse ) { _clearEnvFields ( ) ; helpBox . style . display = 'none' ; }
} ) ;
// Preset catalog
const presetSel = el ( 'adm-mcpPreset' ) ;
if ( presetSel ) {
MCP _PRESETS . forEach ( ( p , i ) => {
const opt = document . createElement ( 'option' ) ;
opt . value = i ;
opt . textContent = p . name + ( Object . keys ( p . env ) . length ? ' (requires keys)' : '' ) ;
presetSel . appendChild ( opt ) ;
} ) ;
presetSel . addEventListener ( 'change' , ( ) => {
if ( presetSel . value === '' ) return ;
const p = MCP _PRESETS [ parseInt ( presetSel . value ) ] ;
el ( 'adm-mcpName' ) . value = p . name . toLowerCase ( ) . replace ( /\s+/g , '-' ) ;
transportSel . value = 'stdio' ;
el ( 'adm-mcpCommand' ) . value = p . command ;
el ( 'adm-mcpArgs' ) . value = JSON . stringify ( p . args ) ;
sseRow . style . display = 'none' ;
cmdRow . style . display = '' ;
_buildEnvFields ( p . env , p . help || null , p ) ;
_activeOauthFile = p . oauthFile || null ;
_activeOauth = p . oauth || null ;
presetSel . value = '' ;
// Focus first env field if keys are needed
const firstInput = envFieldsWrap . querySelector ( '.mcp-env-input' ) ;
if ( firstInput ) firstInput . focus ( ) ;
else el ( 'adm-mcpAddBtn' ) . focus ( ) ;
} ) ;
}
el ( 'adm-mcpAddBtn' ) . addEventListener ( 'click' , async ( ) => {
const name = el ( 'adm-mcpName' ) . value . trim ( ) ;
const transport = transportSel . value ;
const command = el ( 'adm-mcpCommand' ) . value . trim ( ) ;
const args = el ( 'adm-mcpArgs' ) . value . trim ( ) || '[]' ;
const env = _collectEnv ( ) ;
const url = el ( 'adm-mcpUrl' ) . value . trim ( ) ;
const msg = el ( 'adm-mcpMsg' ) ;
if ( ! name ) { msg . textContent = 'Name is required' ; msg . className = 'admin-error' ; return ; }
if ( transport === 'stdio' && ! command ) { msg . textContent = 'Command is required for stdio' ; msg . className = 'admin-error' ; return ; }
if ( transport === 'sse' && ! url ) { msg . textContent = 'URL is required for SSE' ; msg . className = 'admin-error' ; return ; }
try { JSON . parse ( env ) ; } catch { msg . textContent = 'Env must be valid JSON' ; msg . className = 'admin-error' ; return ; }
const fd = new FormData ( ) ;
fd . append ( 'name' , name ) ; fd . append ( 'transport' , transport ) ; fd . append ( 'command' , command ) ; fd . append ( 'args' , args ) ; fd . append ( 'env' , env ) ; fd . append ( 'url' , url ) ;
// If preset has oauthFile config, send credentials for file generation
if ( _activeOauthFile ) {
const envObj = JSON . parse ( env ) ;
fd . append ( 'oauth_file' , JSON . stringify ( {
dir : _activeOauthFile . dir ,
filename : _activeOauthFile . filename ,
client _id : envObj . GOOGLE _CLIENT _ID || '' ,
client _secret : envObj . GOOGLE _CLIENT _SECRET || '' ,
} ) ) ;
}
// If preset has OAuth flow config, send it so the server can handle authorization
if ( _activeOauth ) {
fd . append ( 'oauth_config' , JSON . stringify ( _activeOauth ) ) ;
}
msg . textContent = 'Adding...' ; msg . className = '' ;
try {
const res = await fetch ( '/api/mcp/servers' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
if ( data . needs _oauth ) {
msg . innerHTML = ` Added ${ esc ( name ) } — <a href="/api/mcp/oauth/authorize/ ${ data . id } " target="_blank" style="color:var(--red);font-weight:600;">Authorize with Google</a> to connect ` ;
msg . className = 'admin-success' ;
} else if ( data . connected ) {
msg . textContent = ` Added ${ name } ( ${ data . tool _count } tools discovered) ` ; msg . className = 'admin-success' ;
} else { msg . textContent = ` Added but connection failed: ${ data . error || 'unknown' } ` ; msg . className = 'admin-error' ; }
el ( 'adm-mcpName' ) . value = '' ; el ( 'adm-mcpCommand' ) . value = '' ; el ( 'adm-mcpArgs' ) . value = '' ; el ( 'adm-mcpUrl' ) . value = '' ;
_clearEnvFields ( ) ; helpBox . style . display = 'none' ; _activeHelp = null ; _activeOauthFile = null ; _activeOauth = null ;
loadMcpServers ( ) ;
} catch ( e ) { msg . textContent = 'Failed: ' + e . message ; msg . className = 'admin-error' ; }
} ) ;
}
/ * ─ ─ E m b e d d i n g m o d e l ─ ─
No settings UI : the embedding model ( RAG , semantic memory , tool selection )
is fixed infrastructure that ships with the app , and swapping it would
invalidate every existing vector . Configure via the FASTEMBED _MODEL /
EMBEDDING _URL env vars if you really need to override it . * /
/* ── RAG ── */
async function loadRag ( ) {
try {
const res = await fetch ( '/api/personal' ) ;
const data = await res . json ( ) ;
const dirList = el ( 'adm-ragDirList' ) ;
const dirs = data . directories || [ ] ;
if ( dirs . length === 0 ) { dirList . innerHTML = '<div class="admin-empty">No directories indexed</div>' ; }
else {
dirList . innerHTML = dirs . map ( d => ` <div class="admin-rag-item"><span class="admin-rag-item-name" title=" ${ esc ( d ) } "> ${ esc ( d ) } </span><button class="admin-btn-delete" data-adm-rag-dir=" ${ esc ( d ) } ">Remove</button></div> ` ) . join ( '' ) ;
dirList . querySelectorAll ( '[data-adm-rag-dir]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( ` Remove directory " ${ btn . dataset . admRagDir } " from RAG? ` , { confirmText : 'Remove' , danger : true } ) ) return ;
btn . disabled = true ; btn . textContent = '...' ;
try {
const res = await fetch ( '/api/personal/remove_directory?directory=' + encodeURIComponent ( btn . dataset . admRagDir ) , { method : 'DELETE' } ) ;
if ( res . ok ) { ragMsg ( 'Directory removed' ) ; loadRag ( ) ; }
else { const e = await res . json ( ) ; ragMsg ( e . detail || 'Failed' , true ) ; }
} catch ( e ) { ragMsg ( 'Error: ' + e . message , true ) ; }
} ) ;
} ) ;
}
const fileList = el ( 'adm-ragFileList' ) ;
const files = data . files || [ ] ;
if ( files . length === 0 ) { fileList . innerHTML = '<div class="admin-empty">No files indexed</div>' ; }
else {
fileList . innerHTML = files . map ( f => {
const size = f . size ? ( f . size > 1024 ? ( f . size / 1024 ) . toFixed ( 1 ) + ' KB' : f . size + ' B' ) : '' ;
return ` <div class="admin-rag-item"><span class="admin-rag-item-name" title=" ${ esc ( f . path || f . name ) } "> ${ esc ( f . name ) } </span><span class="admin-rag-item-meta"> ${ size } </span><button class="admin-btn-delete" data-adm-rag-file=" ${ esc ( f . path || f . name ) } ">Delete</button></div> ` ;
} ) . join ( '' ) ;
fileList . querySelectorAll ( '[data-adm-rag-file]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( ` Delete " ${ btn . dataset . admRagFile } " from RAG? ` , { confirmText : 'Delete' , danger : true } ) ) return ;
btn . disabled = true ; btn . textContent = '...' ;
try {
const res = await fetch ( '/api/personal/file?filepath=' + encodeURIComponent ( btn . dataset . admRagFile ) , { method : 'DELETE' } ) ;
if ( res . ok ) { ragMsg ( 'File removed' ) ; loadRag ( ) ; }
else { const e = await res . json ( ) ; ragMsg ( e . detail || 'Failed' , true ) ; }
} catch ( e ) { ragMsg ( 'Error: ' + e . message , true ) ; }
} ) ;
} ) ;
}
} catch ( e ) {
el ( 'adm-ragDirList' ) . innerHTML = '<div class="admin-error">Failed to load</div>' ;
el ( 'adm-ragFileList' ) . innerHTML = '' ;
}
}
let _ragMsgTimer = null ;
function ragMsg ( text , isError , persist ) {
const s = el ( 'adm-ragStatus' ) ;
s . textContent = text ; s . style . color = isError ? 'var(--red)' : 'var(--fg)' ;
if ( _ragMsgTimer ) { clearTimeout ( _ragMsgTimer ) ; _ragMsgTimer = null ; }
if ( text && ! persist ) _ragMsgTimer = setTimeout ( ( ) => { s . textContent = '' ; } , 5000 ) ;
}
async function ragUpload ( files ) {
if ( ! files || files . length === 0 ) return ;
ragMsg ( 'Uploading ' + files . length + ' file(s)...' , false , true ) ;
const fd = new FormData ( ) ;
for ( const f of files ) fd . append ( 'files' , f ) ;
try {
const res = await fetch ( '/api/personal/upload' , { method : 'POST' , body : fd } ) ;
const data = await res . json ( ) ;
if ( data . success ) { ragMsg ( ` Uploaded ${ data . uploaded . length } file(s), ${ data . indexed _count } chunks indexed ` ) ; loadRag ( ) ; }
else ragMsg ( data . detail || 'Upload failed' , true ) ;
} catch ( e ) { ragMsg ( 'Upload error: ' + e . message , true ) ; }
}
function initRag ( ) {
const dropZone = el ( 'adm-ragDropZone' ) ;
const fileInput = el ( 'adm-ragFileInput' ) ;
dropZone . addEventListener ( 'click' , ( ) => fileInput . click ( ) ) ;
fileInput . addEventListener ( 'change' , ( ) => ragUpload ( fileInput . files ) ) ;
dropZone . addEventListener ( 'dragover' , e => { e . preventDefault ( ) ; dropZone . classList . add ( 'dragover' ) ; } ) ;
dropZone . addEventListener ( 'dragleave' , ( ) => dropZone . classList . remove ( 'dragover' ) ) ;
dropZone . addEventListener ( 'drop' , e => { e . preventDefault ( ) ; dropZone . classList . remove ( 'dragover' ) ; ragUpload ( e . dataTransfer . files ) ; } ) ;
el ( 'adm-ragAddDirBtn' ) . addEventListener ( 'click' , async ( ) => {
const dir = el ( 'adm-ragDirInput' ) . value . trim ( ) ;
if ( ! dir ) return ;
const btn = el ( 'adm-ragAddDirBtn' ) ;
btn . disabled = true ; btn . textContent = 'Indexing...' ;
try {
const res = await fetch ( '/api/personal/add_directory' , { method : 'POST' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { directory : dir } ) } ) ;
const data = await res . json ( ) ;
if ( data . success ) { ragMsg ( ` Indexed ${ data . indexed _count } chunks from directory ` ) ; el ( 'adm-ragDirInput' ) . value = '' ; loadRag ( ) ; }
else ragMsg ( data . detail || data . message || 'Failed' , true ) ;
} catch ( e ) { ragMsg ( 'Error: ' + e . message , true ) ; }
btn . disabled = false ; btn . textContent = 'Add Directory' ;
} ) ;
el ( 'adm-ragReloadBtn' ) . addEventListener ( 'click' , async ( ) => {
const btn = el ( 'adm-ragReloadBtn' ) ;
btn . disabled = true ; btn . textContent = 'Reloading...' ;
try {
const res = await fetch ( '/api/personal/reload' , { method : 'POST' } ) ;
const data = await res . json ( ) ;
ragMsg ( ` Index reloaded: ${ data . count } documents ` ) ;
loadRag ( ) ;
} catch ( e ) { ragMsg ( 'Reload failed: ' + e . message , true ) ; }
btn . disabled = false ; btn . textContent = 'Reload Index' ;
} ) ;
}
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
SYSTEM TAB — Tokens
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
async function loadTokens ( ) {
const list = el ( 'adm-tokenList' ) ;
try {
const res = await fetch ( '/api/tokens' , { credentials : 'same-origin' } ) ;
const tokens = await res . json ( ) ;
if ( ! tokens . length ) { list . innerHTML = '<div class="admin-empty">No API tokens</div>' ; return ; }
list . innerHTML = tokens . map ( t => `
< div class = "admin-user-row" >
< div class = "admin-user-info" style = "flex:1;flex-wrap:wrap;gap:0.3rem;" >
< span class = "admin-user-name" > $ { esc ( t . name ) } < / s p a n >
< span class = "admin-badge" > $ { esc ( t . token _prefix ) } ... < / s p a n >
< span class = "admin-badge" title = "Allowed API scopes" > $ { esc ( ( t . scopes || [ 'chat' ] ) . join ( ', ' ) ) } < / s p a n >
$ { t . owner ? ` <span style="font-size:0.75rem;opacity:0.5;">Owner: ${ esc ( t . owner ) } </span> ` : '' }
$ { t . last _used _at ? ` <span style="font-size:0.75rem;opacity:0.5;">Last used: ${ new Date ( t . last _used _at ) . toLocaleDateString ( ) } </span> ` : '<span style="font-size:0.75rem;opacity:0.4;">Never used</span>' }
< / d i v >
< button class = "admin-btn-delete" data - adm - del - token = "${t.id}" > Revoke < / b u t t o n >
< / d i v > ` ) . j o i n ( ' ' ) ;
list . querySelectorAll ( '[data-adm-del-token]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( 'Revoke this API token? External integrations using it will stop working.' , { confirmText : 'Revoke' , danger : true } ) ) return ;
await fetch ( ` /api/tokens/ ${ btn . dataset . admDelToken } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
loadTokens ( ) ;
} ) ;
} ) ;
} catch ( e ) { list . innerHTML = '<div class="admin-error">Failed to load tokens</div>' ; }
}
function initTokenForm ( ) {
el ( 'adm-tokenAddBtn' ) . addEventListener ( 'click' , async ( ) => {
const msg = el ( 'adm-tokenMsg' ) ;
const reveal = el ( 'adm-tokenReveal' ) ;
msg . textContent = '' ; msg . className = '' ; reveal . style . display = 'none' ;
const name = el ( 'adm-tokenName' ) . value . trim ( ) ;
if ( ! name ) { msg . textContent = 'Token name is required' ; msg . className = 'admin-error' ; return ; }
const fd = new FormData ( ) ; fd . append ( 'name' , name ) ;
try {
const res = await fetch ( '/api/tokens' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
const data = await res . json ( ) ;
if ( res . ok ) { el ( 'adm-tokenValue' ) . textContent = data . token ; reveal . style . display = '' ; el ( 'adm-tokenName' ) . value = '' ; loadTokens ( ) ; }
else { msg . textContent = data . detail || 'Failed' ; msg . className = 'admin-error' ; }
} catch ( e ) { msg . textContent = 'Request failed' ; msg . className = 'admin-error' ; }
} ) ;
el ( 'adm-tokenCopyBtn' ) . addEventListener ( 'click' , ( ) => {
const val = el ( 'adm-tokenValue' ) . textContent ;
navigator . clipboard . writeText ( val ) . then ( ( ) => {
el ( 'adm-tokenCopyBtn' ) . textContent = 'Copied!' ;
setTimeout ( ( ) => { el ( 'adm-tokenCopyBtn' ) . textContent = 'Copy' ; } , 2000 ) ;
} ) ;
} ) ;
}
/* ── Webhooks ── */
async function loadWebhooks ( ) {
const list = el ( 'adm-whList' ) ;
try {
const res = await fetch ( '/api/webhooks' , { credentials : 'same-origin' } ) ;
const hooks = await res . json ( ) ;
if ( ! hooks . length ) { list . innerHTML = '<div class="admin-empty">No webhooks configured</div>' ; return ; }
list . innerHTML = hooks . map ( w => {
const events = ( w . events || [ ] ) . map ( e => ` <span class="admin-badge"> ${ esc ( e ) } </span> ` ) . join ( ' ' ) ;
const statusBadge = w . last _status _code
? ` <span class="admin-badge" style="background: ${ w . last _status _code < 400 ? 'color-mix(in srgb, var(--fg) 20%, transparent)' : 'color-mix(in srgb, var(--red) 20%, transparent)' } ;color: ${ w . last _status _code < 400 ? 'var(--fg)' : 'var(--red)' } ;"> ${ w . last _status _code } </span> `
: '' ;
const lastTriggered = w . last _triggered _at ? new Date ( w . last _triggered _at ) . toLocaleString ( ) : 'Never' ;
const errorText = w . last _error ? ` <div style="font-size:0.75rem;color:var(--red);margin-top:0.2rem;">Error: ${ esc ( w . last _error . substring ( 0 , 80 ) ) } </div> ` : '' ;
return `
< div class = "admin-ep-item" style = "flex-wrap:wrap;" >
< div class = "admin-ep-info" style = "flex:1;min-width:200px;" >
< div class = "admin-ep-name" > $ { esc ( w . name ) } $ { w . is _active ? '' : '<span class="admin-badge admin-badge-off">disabled</span>' } $ { w . has _secret ? '<span class="admin-badge">signed</span>' : '' } < / d i v >
< div class = "admin-ep-detail" > $ { esc ( w . url ) } < / d i v >
< div style = "margin-top:0.3rem;" > $ { events } < / d i v >
< div class = "admin-ep-detail" > Last : $ { lastTriggered } $ { statusBadge } < / d i v >
$ { errorText }
< / d i v >
< div class = "admin-ep-actions" >
< button class = "admin-btn-sm" data - adm - wh - test = "${w.id}" > Test < / b u t t o n >
< button class = "admin-btn-sm" data - adm - wh - toggle = "${w.id}" > $ { w . is _active ? 'Disable' : 'Enable' } < / b u t t o n >
< button class = "admin-btn-delete" data - adm - wh - delete = "${w.id}" > Delete < / b u t t o n >
< / d i v >
< / d i v > ` ;
} ) . join ( '' ) ;
list . querySelectorAll ( '[data-adm-wh-test]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
const msg = el ( 'adm-whMsg' ) ; msg . textContent = 'Sending test...' ; msg . className = '' ;
try {
const res = await fetch ( ` /api/webhooks/ ${ btn . dataset . admWhTest } /test ` , { method : 'POST' , credentials : 'same-origin' } ) ;
msg . textContent = res . ok ? 'Test sent!' : 'Test failed' ; msg . className = res . ok ? 'admin-success' : 'admin-error' ;
setTimeout ( ( ) => loadWebhooks ( ) , 1000 ) ;
} catch ( e ) { msg . textContent = 'Failed: ' + e . message ; msg . className = 'admin-error' ; }
} ) ;
} ) ;
list . querySelectorAll ( '[data-adm-wh-toggle]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => { await fetch ( ` /api/webhooks/ ${ btn . dataset . admWhToggle } ` , { method : 'PATCH' , credentials : 'same-origin' } ) ; loadWebhooks ( ) ; } ) ;
} ) ;
list . querySelectorAll ( '[data-adm-wh-delete]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
if ( ! await uiModule . styledConfirm ( 'Delete this webhook?' , { confirmText : 'Delete' , danger : true } ) ) return ;
await fetch ( ` /api/webhooks/ ${ btn . dataset . admWhDelete } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ; loadWebhooks ( ) ;
} ) ;
} ) ;
} catch ( e ) { list . innerHTML = '<div class="admin-error">Failed to load webhooks</div>' ; }
}
function initWebhookForm ( ) {
el ( 'adm-whAddBtn' ) . addEventListener ( 'click' , async ( ) => {
const msg = el ( 'adm-whMsg' ) ;
msg . textContent = '' ; msg . className = '' ;
const name = el ( 'adm-whName' ) . value . trim ( ) ;
const url = el ( 'adm-whUrl' ) . value . trim ( ) ;
const secret = el ( 'adm-whSecret' ) . value . trim ( ) ;
const events = Array . from ( modalEl . querySelectorAll ( '.adm-wh-event:checked' ) ) . map ( e => e . value ) . join ( ',' ) ;
if ( ! name ) { msg . textContent = 'Name is required' ; msg . className = 'admin-error' ; return ; }
if ( ! url ) { msg . textContent = 'URL is required' ; msg . className = 'admin-error' ; return ; }
if ( ! events ) { msg . textContent = 'Select at least one event' ; msg . className = 'admin-error' ; return ; }
const fd = new FormData ( ) ;
fd . append ( 'name' , name ) ; fd . append ( 'url' , url ) ; fd . append ( 'secret' , secret ) ; fd . append ( 'events' , events ) ;
try {
const res = await fetch ( '/api/webhooks' , { method : 'POST' , body : fd , credentials : 'same-origin' } ) ;
if ( res . ok ) { msg . textContent = 'Webhook added' ; msg . className = 'admin-success' ; el ( 'adm-whName' ) . value = '' ; el ( 'adm-whUrl' ) . value = '' ; el ( 'adm-whSecret' ) . value = '' ; loadWebhooks ( ) ; }
else { const d = await res . json ( ) ; msg . textContent = d . detail || 'Failed' ; msg . className = 'admin-error' ; }
} catch ( e ) { msg . textContent = 'Failed: ' + e . message ; msg . className = 'admin-error' ; }
} ) ;
}
/* ── Features ── */
const featureLabels = {
web _search : 'Web Search' , deep _research : 'Deep Research' ,
memory : 'Memory' , document _editor : 'Document Editor' , rag : 'RAG Knowledge Base' , sensitive _filter : 'Sensitive Info Filter' ,
gallery : 'Gallery'
} ;
async function loadFeatures ( ) {
const container = el ( 'adm-featureToggles' ) ;
try {
const res = await fetch ( '/api/auth/features' , { credentials : 'same-origin' } ) ;
const features = await res . json ( ) ;
container . innerHTML = Object . entries ( featureLabels ) . map ( ( [ key , label ] ) => `
< div class = "admin-toggle-row" style = "padding:0.4rem 0;border-bottom:1px solid var(--border);" >
< div class = "admin-toggle-label" > $ { label } < / d i v >
< label class = "admin-switch" > < input type = "checkbox" data - adm - feature = "${key}" $ { features [ key ] ? 'checked' : '' } > < span class = "admin-slider" > < / s p a n > < / l a b e l >
< / d i v > ` ) . j o i n ( ' ' ) ;
container . querySelectorAll ( 'input[data-adm-feature]' ) . forEach ( toggle => {
toggle . addEventListener ( 'change' , async ( ) => {
const body = { } ; body [ toggle . dataset . admFeature ] = toggle . checked ;
await fetch ( '/api/auth/features' , { method : 'POST' , credentials : 'same-origin' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( body ) } ) ;
} ) ;
} ) ;
} catch ( e ) { container . innerHTML = '<div class="admin-error">Failed to load features</div>' ; }
}
/* ── CalDAV Config ── */
function initCalDAV ( ) {
const urlIn = el ( 'caldav-url' ) ;
const userIn = el ( 'caldav-user' ) ;
const passIn = el ( 'caldav-pass' ) ;
const saveBtn = el ( 'caldav-save-btn' ) ;
const testBtn = el ( 'caldav-test-btn' ) ;
const status = el ( 'caldav-status' ) ;
if ( ! urlIn || ! saveBtn ) return ;
// Load current config
fetch ( ` ${ API _BASE } /api/calendar/config ` , { credentials : 'same-origin' } )
. then ( r => r . json ( ) ) . then ( d => {
urlIn . value = d . caldav _url || '' ;
userIn . value = d . caldav _username || '' ;
passIn . value = d . caldav _password || '' ;
} ) . catch ( ( ) => { } ) ;
saveBtn . addEventListener ( 'click' , async ( ) => {
status . textContent = 'Saving...' ;
try {
const res = await fetch ( ` ${ API _BASE } /api/calendar/config ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { caldav _url : urlIn . value , caldav _username : userIn . value , caldav _password : passIn . value } ) ,
} ) ;
const d = await res . json ( ) ;
status . textContent = d . ok ? 'Saved' : 'Error' ;
status . style . color = d . ok ? 'var(--green)' : 'var(--red)' ;
} catch ( e ) { status . textContent = 'Error' ; status . style . color = 'var(--red)' ; }
setTimeout ( ( ) => { status . textContent = '' ; status . style . color = '' ; } , 3000 ) ;
} ) ;
testBtn . addEventListener ( 'click' , async ( ) => {
status . textContent = 'Testing...' ;
try {
// Save first
await fetch ( ` ${ API _BASE } /api/calendar/config ` , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { caldav _url : urlIn . value , caldav _username : userIn . value , caldav _password : passIn . value } ) ,
} ) ;
const res = await fetch ( ` ${ API _BASE } /api/calendar/test ` , { method : 'POST' , credentials : 'same-origin' } ) ;
const d = await res . json ( ) ;
status . textContent = d . ok ? ` Connected ( ${ d . calendars } calendars) ` : ` Failed: ${ d . error } ` ;
status . style . color = d . ok ? 'var(--green)' : 'var(--red)' ;
} catch ( e ) { status . textContent = 'Error' ; status . style . color = 'var(--red)' ; }
setTimeout ( ( ) => { status . textContent = '' ; status . style . color = '' ; } , 5000 ) ;
} ) ;
}
/* ── Data Backup (export/import) ── */
function initBackup ( ) {
el ( 'adm-exportDataBtn' ) . addEventListener ( 'click' , async ( ) => {
const btn = el ( 'adm-exportDataBtn' ) ;
const msg = el ( 'adm-backupMsg' ) ;
btn . disabled = true ; btn . textContent = 'Exporting...' ; msg . textContent = '' ;
try {
const res = await fetch ( '/api/export' , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( 'Export failed' ) ;
const blob = await res . blob ( ) ;
const disposition = res . headers . get ( 'Content-Disposition' ) || '' ;
const match = disposition . match ( /filename=(.+)/ ) ;
const filename = match ? match [ 1 ] : 'odysseus_backup.json' ;
const a = document . createElement ( 'a' ) ;
a . href = URL . createObjectURL ( blob ) ;
a . download = filename ;
a . click ( ) ;
URL . revokeObjectURL ( a . href ) ;
msg . textContent = 'Export downloaded.' ; msg . className = 'admin-success' ;
} catch ( e ) { msg . textContent = 'Export failed: ' + e . message ; msg . className = 'admin-error' ; }
btn . disabled = false ; btn . textContent = 'Export Data' ;
} ) ;
const fileInput = el ( 'adm-importFile' ) ;
el ( 'adm-importDataBtn' ) . addEventListener ( 'click' , ( ) => { fileInput . value = '' ; fileInput . click ( ) ; } ) ;
fileInput . addEventListener ( 'change' , async ( ) => {
const file = fileInput . files [ 0 ] ;
if ( ! file ) return ;
const msg = el ( 'adm-backupMsg' ) ;
const btn = el ( 'adm-importDataBtn' ) ;
btn . disabled = true ; btn . textContent = 'Importing...' ; msg . textContent = '' ;
try {
const text = await file . text ( ) ;
const data = JSON . parse ( text ) ;
const res = await fetch ( '/api/import' , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( data ) ,
} ) ;
const result = await res . json ( ) ;
if ( res . ok && result . ok ) {
msg . textContent = result . message || 'Import successful.' ; msg . className = 'admin-success' ;
} else {
msg . textContent = result . message || result . detail || 'Import failed' ; msg . className = 'admin-error' ;
}
} catch ( e ) { msg . textContent = 'Import failed: ' + e . message ; msg . className = 'admin-error' ; }
btn . disabled = false ; btn . textContent = 'Import Data' ;
} ) ;
}
/* ── Danger Zone ── */
function initDangerZone ( ) {
// Per-category Danger Zone wipes. Each button declares its target
// via data-wipe-kind; one delegated handler handles double-confirm,
// POSTs to /api/admin/wipe/{kind}, and writes the result.
const _LABELS = {
chats : 'chats' , memory : 'memory entries' , skills : 'skills' ,
notes : 'notes' , tasks : 'tasks' , documents : 'documents' ,
gallery : 'gallery images' , calendar : 'calendar items' ,
} ;
const _wipeMsg = el ( 'adm-wipeMsg' ) ;
modalEl . querySelectorAll ( '[data-wipe-kind]' ) . forEach ( btn => {
btn . addEventListener ( 'click' , async ( ) => {
const kind = btn . dataset . wipeKind ;
const label = _LABELS [ kind ] || kind ;
if ( ! await uiModule . styledConfirm ( ` Wipe ALL ${ label } ? This cannot be undone. ` , { confirmText : 'Wipe' , danger : true } ) ) return ;
if ( ! await uiModule . styledConfirm ( ` Really wipe every one of your ${ label } ? ` , { confirmText : 'Yes, wipe everything' , danger : true } ) ) return ;
btn . disabled = true ; const prev = btn . textContent ; btn . textContent = 'Wiping…' ;
if ( _wipeMsg ) { _wipeMsg . textContent = '' ; _wipeMsg . className = '' ; }
try {
const res = await fetch ( ` /api/admin/wipe/ ${ kind } ` , { method : 'DELETE' , credentials : 'same-origin' } ) ;
const data = await res . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( res . ok ) {
if ( _wipeMsg ) { _wipeMsg . textContent = ` Wiped ${ data . count ? ? 0 } ${ label } . ` ; _wipeMsg . className = 'admin-success' ; }
} else {
if ( _wipeMsg ) { _wipeMsg . textContent = data . detail || 'Failed' ; _wipeMsg . className = 'admin-error' ; }
}
} catch ( e ) {
if ( _wipeMsg ) { _wipeMsg . textContent = 'Request failed: ' + e . message ; _wipeMsg . className = 'admin-error' ; }
}
btn . disabled = false ; btn . textContent = prev ;
} ) ;
} ) ;
}
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
INIT & REFRESH
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
function initAll ( ) {
modalEl = el ( 'settings-modal' ) ;
const inits = [ initSignupToggle , initAddUser , initEndpointForm , initMcpForm , initCalDAV , initBackup , initDangerZone , ( ) => settingsModule . initIntegrations ( ) ] ;
for ( const fn of inits ) {
try { fn ( ) ; } catch ( e ) { console . error ( 'Admin init error in' , fn . name || 'anonymous' , e ) ; }
}
initialized = true ;
refreshAll ( ) ;
}
function refreshAll ( ) {
loadUsers ( ) ;
loadEndpoints ( ) ;
loadBuiltinTools ( ) ;
loadMcpServers ( ) ;
}
/ * ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═
PUBLIC API
═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ ═ * /
export function _initData ( ) {
if ( ! initialized ) initAll ( ) ;
else refreshAll ( ) ;
}
export function open ( tab ) {
_initData ( ) ;
settingsModule . open ( tab || 'services' ) ;
}
export function close ( ) {
settingsModule . close ( ) ;
}
const adminModule = { open , close , _initData , get _initialized ( ) { return initialized ; } } ;
export default adminModule ;