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' ;
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
import { PROVIDER _DEVICE _FLOWS , formatDeviceFlowError , runProviderDeviceFlow } from './providerDeviceFlow.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
2026-06-05 13:27:10 -05:00
const allowedModels = Array . isArray ( u . privileges && u . privileges . allowed _models )
? u . privileges . allowed _models
: [ ] ;
const allowedSet = new Set ( allowedModels ) ;
const modelsRestricted = ! ! ( u . privileges && u . privileges . allowed _models _restricted ) ;
2026-05-31 23:58:26 +09:00
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 >
2026-06-05 13:27:10 -05:00
< div style = "font-size:10px;opacity:0.4;margin-bottom:4px;" > $ { ! modelsRestricted ? 'All models allowed (no restrictions)' : ( allowedSet . size === 0 ? 'No models allowed' : allowedSet . size + ' model(s) allowed' ) } < / d i v >
2026-05-31 23:58:26 +09:00
< 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 ;
2026-06-05 13:27:10 -05:00
_loadModelsForUser ( u . username , allowedSet , modelsRestricted , privPanel ) ;
2026-05-31 23:58:26 +09:00
}
} ) ;
// 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>' ; }
}
2026-06-05 13:27:10 -05:00
async function _loadModelsForUser ( username , allowedSet , modelsRestricted , privPanel ) {
2026-05-31 23:58:26 +09:00
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 ;
}
2026-06-05 13:27:10 -05:00
let restricted = modelsRestricted ;
2026-06-01 15:26:10 +10:00
listEl . innerHTML = sortModelObjects ( allModels ) . map ( m => {
2026-06-05 13:27:10 -05:00
const checked = ! restricted || allowedSet . has ( m . mid ) ? 'checked' : '' ;
2026-05-31 23:58:26 +09:00
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 ) ;
} ) ;
2026-06-05 13:27:10 -05:00
// All checked means unrestricted; zero checked means explicitly no models.
restricted = checked . length !== allModels . length ;
const value = restricted ? checked : [ ] ;
2026-05-31 23:58:26 +09:00
const hint = privPanel . querySelector ( '.priv-models-list[data-user]' ) ? . previousElementSibling ? . querySelector ( 'div[style*="opacity"]' ) ;
2026-06-05 13:27:10 -05:00
if ( hint ) hint . textContent = ! restricted ? 'All models allowed (no restrictions)' : ( value . length === 0 ? 'No models allowed' : value . length + ' model(s) allowed' ) ;
2026-05-31 23:58:26 +09:00
fetch ( ` /api/auth/users/ ${ encodeURIComponent ( username ) } /privileges ` , {
method : 'PUT' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
2026-06-05 13:27:10 -05:00
body : JSON . stringify ( { allowed _models : value , allowed _models _restricted : restricted } ) ,
2026-05-31 23:58:26 +09:00
} ) . 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 ) {
2026-06-04 00:56:11 -03:00
window . modelsModule . refreshModels ( ) ;
2026-05-31 23:58:26 +09:00
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' : '' ;
2026-06-04 00:56:11 -03:00
const category = ep . category || ( _isLocalEndpoint ( ep . base _url ) ? 'local' : 'api' ) ;
const kindLabel = ep . endpoint _kind && ep . endpoint _kind !== 'auto' ? ep . endpoint _kind . toUpperCase ( ) : '' ;
2026-06-05 15:12:14 -04:00
const keyLabel = ep . has _key
? ( ep . api _key _fingerprint ? ` (key ${ esc ( ep . api _key _fingerprint ) } ) ` : ' (key set)' )
: '' ;
2026-05-31 23:58:26 +09:00
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>' : '' }
2026-06-04 00:56:11 -03:00
$ { kindLabel ? ` <span class="admin-badge"> ${ esc ( kindLabel ) } </span> ` : '' }
2026-05-31 23:58:26 +09:00
$ { 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 >
fix: expose supports_tools toggle for local endpoints in UI (#3195)
* fix: expose supports_tools toggle for local endpoints in UI
Local endpoints (Ollama, vLLM, etc.) default to fenced tool blocks
when supports_tools is not set, which breaks tool calling for models
that support native function calling. The backend already supports
per-endpoint supports_tools overrides via the PATCH API, but there
was no UI to set it.
Add a 'Tools: Auto/On/Off' toggle button for local endpoints that
cycles through the three states:
- Auto (null): use the existing heuristic
- On (true): always use native function calling
- Off (false): always use fenced tool blocks
Fixes #3141
* docs: add screenshot of supports_tools toggle showing Auto/On/Off states
* Add Tools toggle screenshot for PR #3195
* refactor: convert Tools toggle to select dropdown per review feedback
Replace cycle-through button with a <select> dropdown for the
supports_tools tri-state setting. Options: Auto / On / Off with
explicit labels. Uses existing admin select styling. Fires PATCH
on change event. Same API contract (Auto=null, On=true, Off=false).
* Update Tools toggle screenshot (now dropdown select)
* fix: remove orphan screenshot and move Tools dropdown below button row
- Remove docs/screenshots/tools-toggle-three-states.png (unreferenced image causing test_no_orphan_images_in_docs to fail)
- Move Tools dropdown to its own line below Disable/Delete buttons, aligned right
- Keep Disable and Delete buttons grouped together per maintainer feedback
* fix: move Tools select onto same row left of Disable/Delete, use CSS class
Per vdmkenny feedback: move the Tools dropdown select from its own row below
the button group onto the same row, to the left of the Disable and Delete
buttons (which stay adjacent on the right). Replace inline style on the
button row with the existing .admin-ep-actions CSS class, adding
align-items:center for proper vertical alignment.
* chore: remove committed screenshots from tree
Screenshots should be in PR description/comments, not in repo history.
---------
Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-08 05:29:06 +07:00
< div class = "admin-ep-actions" >
$ { _isLocalEndpoint ( ep . base _url ) ? '<select class="admin-tools-select" data-adm-tools-select="' + ep . id + '" title="Native tool calling mode. Auto = use heuristic (fenced blocks for Ollama /v1). On = always use native function calling. Off = always use fenced blocks."><option value="auto"' + ( ep . supports _tools !== true && ep . supports _tools !== false ? ' selected' : '' ) + '>Tools: Auto</option><option value="true"' + ( ep . supports _tools === true ? ' selected' : '' ) + '>Tools: On</option><option value="false"' + ( ep . supports _tools === false ? ' selected' : '' ) + '>Tools: Off</option></select>' : '' }
2026-05-31 23:58:26 +09:00
< 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 >
2026-06-05 15:12:14 -04:00
< div class = "admin-ep-detail" > $ { esc ( ep . base _url ) } $ { category === 'local' ? ` <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> ` : '' } $ { keyLabel } < / d i v >
2026-05-31 23:58:26 +09:00
$ { 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 = [ ] ;
2026-06-04 00:56:11 -03:00
data . forEach ( ( ep , i ) => ( ( ep . category || ( _isLocalEndpoint ( ep . base _url ) ? 'local' : 'api' ) ) === 'local' ? localIdx : apiIdx ) . push ( i ) ) ;
2026-05-31 23:58:26 +09:00
// 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 ( ) ; } ) ;
} ) ;
fix: expose supports_tools toggle for local endpoints in UI (#3195)
* fix: expose supports_tools toggle for local endpoints in UI
Local endpoints (Ollama, vLLM, etc.) default to fenced tool blocks
when supports_tools is not set, which breaks tool calling for models
that support native function calling. The backend already supports
per-endpoint supports_tools overrides via the PATCH API, but there
was no UI to set it.
Add a 'Tools: Auto/On/Off' toggle button for local endpoints that
cycles through the three states:
- Auto (null): use the existing heuristic
- On (true): always use native function calling
- Off (false): always use fenced tool blocks
Fixes #3141
* docs: add screenshot of supports_tools toggle showing Auto/On/Off states
* Add Tools toggle screenshot for PR #3195
* refactor: convert Tools toggle to select dropdown per review feedback
Replace cycle-through button with a <select> dropdown for the
supports_tools tri-state setting. Options: Auto / On / Off with
explicit labels. Uses existing admin select styling. Fires PATCH
on change event. Same API contract (Auto=null, On=true, Off=false).
* Update Tools toggle screenshot (now dropdown select)
* fix: remove orphan screenshot and move Tools dropdown below button row
- Remove docs/screenshots/tools-toggle-three-states.png (unreferenced image causing test_no_orphan_images_in_docs to fail)
- Move Tools dropdown to its own line below Disable/Delete buttons, aligned right
- Keep Disable and Delete buttons grouped together per maintainer feedback
* fix: move Tools select onto same row left of Disable/Delete, use CSS class
Per vdmkenny feedback: move the Tools dropdown select from its own row below
the button group onto the same row, to the left of the Disable and Delete
buttons (which stay adjacent on the right). Replace inline style on the
button row with the existing .admin-ep-actions CSS class, adding
align-items:center for proper vertical alignment.
* chore: remove committed screenshots from tree
Screenshots should be in PR description/comments, not in repo history.
---------
Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-06-08 05:29:06 +07:00
queryAll ( '[data-adm-tools-select]' ) . forEach ( sel => {
sel . addEventListener ( 'change' , async ( e ) => {
e . stopPropagation ( ) ;
const epId = sel . dataset . admToolsSelect ;
const val = sel . value ;
const body = { } ;
if ( val === 'auto' ) body . supports _tools = null ;
else body . supports _tools = val === 'true' ;
await fetch ( ` /api/model-endpoints/ ${ epId } ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( body ) ,
} ) ;
loadEndpoints ( ) ;
} ) ;
} ) ;
2026-05-31 23:58:26 +09:00
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 ( _ ) { } } ;
2026-06-04 00:56:11 -03:00
const _loadingHtml = ( label ) => ` <span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;"> ${ esc ( label ) } </span> ` ;
const renderModels = ( models , warning = '' ) => {
2026-06-01 15:26:10 +10:00
const sortedModels = sortModelObjects ( models ) ;
2026-06-04 00:56:11 -03:00
const warningHtml = warning ? ` <div class="admin-error" style="font-size:11px;margin:6px 0;"> ${ esc ( warning ) } </div> ` : '' ;
const attachRefresh = ( ) => {
panel . querySelector ( ` [data-ep-refresh-models=" ${ epId } "] ` ) ? . addEventListener ( 'click' , async ( e ) => {
e . preventDefault ( ) ;
panel . innerHTML = _loadingHtml ( 'Refreshing models...' ) ;
try {
const res = await fetch ( ` /api/model-endpoints/ ${ epId } /models?refresh=true&refresh_timeout=60 ` , { credentials : 'same-origin' } ) ;
const refreshWarning = res . headers . get ( 'X-Model-Refresh-Warning' ) || '' ;
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` ) ;
const refreshedModels = await res . json ( ) ;
renderModels ( refreshedModels , refreshWarning ) ;
if ( refreshWarning && uiModule ? . showToast ) uiModule . showToast ( refreshWarning , 6000 ) ;
} catch ( _ ) {
renderModels ( sortedModels , 'Model refresh failed; kept cached models.' ) ;
}
} ) ;
} ;
if ( ! sortedModels . length ) {
panel . innerHTML = ` <div class="mcp-tools-header">
< span > Models < / s p a n >
< span style = "display:flex;gap:8px;align-items:center;" >
< span class = "mcp-tools-count" > 0 / 0 enabled < / s p a n >
< a href = "#" data - ep - refresh - models = "${epId}" > Refresh < / a >
< / s p a n >
< / d i v > $ { w a r n i n g H t m l } < s p a n s t y l e = " o p a c i t y : 0 . 5 ; f o n t - s i z e : 1 1 p x ; " > N o m o d e l s < / s p a n > ` ;
attachRefresh ( ) ;
return ;
}
2026-06-01 15:26:10 +10:00
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-06-04 00:56:11 -03:00
< a href = "#" data - ep - refresh - models = "${epId}" > Refresh < / a >
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-04 00:56:11 -03:00
< / d i v > $ { w a r n i n g H t m l } $ { 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' ;
} ) ;
} ;
2026-06-04 00:56:11 -03:00
attachRefresh ( ) ;
2026-05-31 23:58:26 +09:00
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 ) ) ;
} ) ;
2026-06-04 00:56:11 -03:00
} ;
try {
const res = await fetch ( ` /api/model-endpoints/ ${ epId } /models ` , { credentials : 'same-origin' } ) ;
if ( ! res . ok ) throw new Error ( ` HTTP ${ res . status } ` ) ;
const models = await res . json ( ) ;
_stopSpin ( ) ;
renderModels ( models ) ;
2026-05-31 23:58:26 +09:00
} 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' ) ;
2026-06-04 00:56:11 -03:00
const kindSel = el ( 'adm-epKind' ) ;
2026-05-31 23:58:26 +09:00
// 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 ;
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
const DEVICE _AUTH _PROVIDER _VALUES = new Set ( Object . keys ( PROVIDER _DEVICE _FLOWS ) ) ;
let deviceAuthPolling = false ;
function _selectedProviderOption ( ) {
return provider && provider . selectedOptions ? provider . selectedOptions [ 0 ] : null ;
}
function _selectedDeviceAuthProvider ( ) {
const opt = _selectedProviderOption ( ) ;
const flow = opt && opt . dataset ? opt . dataset . authFlow : '' ;
if ( flow && DEVICE _AUTH _PROVIDER _VALUES . has ( flow ) ) return flow ;
return DEVICE _AUTH _PROVIDER _VALUES . has ( provider . value ) ? provider . value : '' ;
}
function _isDeviceAuthSelected ( ) {
return ! ! _selectedDeviceAuthProvider ( ) ;
}
function _setApiFormForProvider ( ) {
const deviceAuthProvider = _selectedDeviceAuthProvider ( ) ;
const deviceAuthConfig = PROVIDER _DEVICE _FLOWS [ deviceAuthProvider ] || null ;
const apiKey = el ( 'adm-epApiKey' ) ;
const testBtn = el ( 'adm-epApiTestBtn' ) ;
const addBtn = el ( 'adm-epAddBtn' ) ;
const status = el ( 'adm-deviceAuthStatus' ) ;
const msg = _endpointMsg ( 'api' ) ;
if ( deviceAuthConfig ) {
urlInput . value = '' ;
urlInput . placeholder = deviceAuthProvider === 'copilot'
? 'GitHub Copilot uses GitHub account sign-in'
: 'ChatGPT Subscription uses OpenAI account sign-in' ;
urlInput . readOnly = true ;
if ( apiKey ) {
apiKey . value = '' ;
apiKey . placeholder = 'No API key needed' ;
apiKey . disabled = true ;
}
if ( testBtn ) {
testBtn . disabled = true ;
testBtn . style . opacity = '0.45' ;
testBtn . style . cursor = 'not-allowed' ;
}
if ( addBtn ) {
addBtn . disabled = false ;
addBtn . textContent = 'Add' ;
addBtn . style . width = '55px' ;
addBtn . style . display = '' ;
}
if ( kindSel ) kindSel . value = 'api' ;
if ( msg ) {
msg . textContent = '' ;
msg . className = '' ;
}
} else {
urlInput . placeholder = 'Base URL or pick provider' ;
urlInput . readOnly = false ;
if ( apiKey ) {
apiKey . placeholder = 'API key' ;
apiKey . disabled = false ;
}
if ( testBtn ) {
testBtn . disabled = false ;
testBtn . style . opacity = '' ;
testBtn . style . cursor = '' ;
}
if ( addBtn ) {
addBtn . disabled = false ;
addBtn . textContent = 'Add' ;
addBtn . style . width = '55px' ;
addBtn . style . display = '' ;
}
if ( msg ) {
msg . textContent = '' ;
msg . className = '' ;
}
if ( ! deviceAuthPolling && status ) status . textContent = '' ;
}
}
2026-05-31 23:58:26 +09:00
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' , ( ) => {
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
if ( _isDeviceAuthSelected ( ) ) {
_setApiFormForProvider ( ) ;
_renderPickerMenu ( ) ;
_syncPickerCurrent ( ) ;
return ;
}
2026-05-31 23:58:26 +09:00
if ( provider . value ) urlInput . value = provider . value ;
else urlInput . value = '' ;
2026-06-04 00:56:11 -03:00
if ( kindSel ) kindSel . value = provider . value ? 'api' : 'proxy' ;
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
_setApiFormForProvider ( ) ;
2026-05-31 23:58:26 +09:00
} ) ;
2026-06-01 10:00:15 +09:00
urlInput . addEventListener ( 'input' , ( ) => {
if ( provider . value && urlInput . value . trim ( ) !== provider . value ) {
provider . value = '' ;
Settings polish: /setup provider subs, Add API defaults to api kind, picker shows offline endpoints, doc library tracks sub-tab
- /setup gains explicit provider subcommands (deepseek, openai,
anthropic, openrouter, groq, gemini, xai, ollama, copilot, local,
endpoint) so the autocomplete popup surfaces "/setup de…" suggestions
with format hints, and bare-provider invocations still prompt for
the key.
- Add API endpoint defaults to kind=api (auto-refresh /v1/models)
instead of kind=proxy. Proxy was a frequent footgun for OpenAI-
compatible endpoints that DO serve /v1/models — the user got an
empty model list and had to flip the dropdown.
- Model picker now includes offline endpoints with stale:true so a
briefly-down local server doesn't vanish from the picker (it dims
and shows the offline pill, clickable anyway). Dedup prefers the
online entry when the same model is exposed by both.
- Document library modal header reflects the active sub-tab via
_TAB_HEADERS so it no longer shows the wrong section name when
switching between Documents / Skills / Templates.
2026-06-05 14:41:54 +09:00
if ( kindSel ) kindSel . value = 'api' ;
2026-06-01 10:00:15 +09:00
_renderPickerMenu ( ) ;
_syncPickerCurrent ( ) ;
}
} ) ;
Settings polish: /setup provider subs, Add API defaults to api kind, picker shows offline endpoints, doc library tracks sub-tab
- /setup gains explicit provider subcommands (deepseek, openai,
anthropic, openrouter, groq, gemini, xai, ollama, copilot, local,
endpoint) so the autocomplete popup surfaces "/setup de…" suggestions
with format hints, and bare-provider invocations still prompt for
the key.
- Add API endpoint defaults to kind=api (auto-refresh /v1/models)
instead of kind=proxy. Proxy was a frequent footgun for OpenAI-
compatible endpoints that DO serve /v1/models — the user got an
empty model list and had to flip the dropdown.
- Model picker now includes offline endpoints with stale:true so a
briefly-down local server doesn't vanish from the picker (it dims
and shows the offline pill, clickable anyway). Dedup prefers the
online entry when the same model is exposed by both.
- Document library modal header reflects the active sub-tab via
_TAB_HEADERS so it no longer shows the wrong section name when
switching between Documents / Skills / Templates.
2026-06-05 14:41:54 +09:00
if ( kindSel ) kindSel . value = kindSel . value || 'api' ;
2026-06-04 00:56:11 -03:00
function _apiEndpointKind ( ) {
Settings polish: /setup provider subs, Add API defaults to api kind, picker shows offline endpoints, doc library tracks sub-tab
- /setup gains explicit provider subcommands (deepseek, openai,
anthropic, openrouter, groq, gemini, xai, ollama, copilot, local,
endpoint) so the autocomplete popup surfaces "/setup de…" suggestions
with format hints, and bare-provider invocations still prompt for
the key.
- Add API endpoint defaults to kind=api (auto-refresh /v1/models)
instead of kind=proxy. Proxy was a frequent footgun for OpenAI-
compatible endpoints that DO serve /v1/models — the user got an
empty model list and had to flip the dropdown.
- Model picker now includes offline endpoints with stale:true so a
briefly-down local server doesn't vanish from the picker (it dims
and shows the offline pill, clickable anyway). Dedup prefers the
online entry when the same model is exposed by both.
- Document library modal header reflects the active sub-tab via
_TAB_HEADERS so it no longer shows the wrong section name when
switching between Documents / Skills / Templates.
2026-06-05 14:41:54 +09:00
return ( kindSel && kindSel . value ) ? kindSel . value : 'api' ;
2026-06-04 00:56:11 -03:00
}
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-07 17:43:00 +03:00
if ( ! u . includes ( 'api.' ) && ! u . includes ( 'openrouter' ) && ! u . includes ( 'opencode.ai' ) && ! 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 ( ) => {
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
if ( _isDeviceAuthSelected ( ) ) {
const msg = _endpointMsg ( 'api' ) ;
msg . textContent = '' ;
msg . className = '' ;
return ;
}
2026-06-01 10:00:15 +09:00
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 ) ;
2026-06-04 00:56:11 -03:00
fd . append ( 'endpoint_kind' , _apiEndpointKind ( ) ) ;
fd . append ( 'model_refresh_timeout' , '30' ) ;
2026-06-01 10:00:15 +09:00
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 ( ) => {
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
const deviceAuthProvider = _selectedDeviceAuthProvider ( ) ;
if ( deviceAuthProvider ) {
await _startProviderDeviceAuth ( deviceAuthProvider , el ( 'adm-epAddBtn' ) ) ;
return ;
}
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 ) ;
2026-06-04 00:56:11 -03:00
const endpointKind = _apiEndpointKind ( ) ;
fd . append ( 'endpoint_kind' , endpointKind ) ;
fd . append ( 'model_refresh_mode' , endpointKind === 'proxy' ? 'manual' : 'auto' ) ;
fd . append ( 'model_refresh_timeout' , '30' ) ;
2026-05-31 23:58:26 +09:00
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 = '' ;
2026-06-04 00:56:11 -03:00
if ( kindSel ) kindSel . value = 'proxy' ;
2026-05-31 23:58:26 +09:00
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' ;
} ) ;
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
async function _startProviderDeviceAuth ( providerKey , triggerEl = null ) {
if ( deviceAuthPolling ) return ;
const config = PROVIDER _DEVICE _FLOWS [ providerKey ] ;
if ( ! config ) return ;
const status = el ( 'adm-deviceAuthStatus' ) || _endpointMsg ( 'api' ) ;
if ( ! status ) return ;
const triggerText = triggerEl ? triggerEl . textContent : '' ;
// Render an error with an inline "Try again" (the top button is hidden for
// device-auth providers, so retry lives here). Built with DOM methods, not
// innerHTML. Call reset() first so the deviceAuthPolling guard is cleared.
const showAuthError = ( text ) => {
status . className = 'admin-error' ;
status . textContent = text + ' ' ;
const retry = document . createElement ( 'button' ) ;
retry . type = 'button' ;
retry . className = 'admin-btn-sm' ;
retry . textContent = 'Try again' ;
retry . addEventListener ( 'click' , ( ) => { _startProviderDeviceAuth ( providerKey , triggerEl ) ; } ) ;
status . appendChild ( retry ) ;
} ;
const reset = ( ) => {
if ( triggerEl ) {
triggerEl . disabled = false ;
triggerEl . textContent = triggerText || 'Add' ;
}
deviceAuthPolling = false ;
_setApiFormForProvider ( ) ;
} ;
status . textContent = '' ;
status . className = 'adm-ep-inline-msg' ;
if ( triggerEl ) {
triggerEl . disabled = true ;
triggerEl . textContent = 'Starting...' ;
}
deviceAuthPolling = true ;
_setApiFormForProvider ( ) ;
status . textContent = ` Starting ${ config . label } sign-in... ` ;
feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth
Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.
Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.
Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.
- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
builders, device-flow start/poll, model discovery, owner-scoped endpoint
provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
/models probe).
- static/, README, tests/test_copilot*.py.
* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
try {
const result = await runProviderDeviceFlow ( providerKey , {
openWindow : ( ) => { } ,
onStart : ( { start , authUrl } ) => {
if ( triggerEl ) triggerEl . textContent = 'Waiting...' ;
status . className = '' ;
const authLabel = providerKey === 'copilot' ? 'Authorize on GitHub' : 'Authorize with OpenAI' ;
const waitLabel = providerKey === 'copilot' ? 'Waiting for GitHub authorization...' : 'Waiting for ChatGPT authorization...' ;
status . innerHTML =
'<div class="adm-copilot-panel">' +
'<div class="adm-copilot-wait"><span class="admin-spinner"></span>' +
'<span>' + esc ( waitLabel ) + '</span></div>' +
'<div class="adm-copilot-coderow">' +
'<span class="adm-copilot-code-label">Code</span>' +
'<code class="adm-copilot-code">' + esc ( start . user _code ) + '</code>' +
'<button type="button" class="admin-btn-sm adm-device-auth-copy">Copy</button>' +
'</div>' +
'<a class="admin-btn-add adm-copilot-auth" href="' + encodeURI ( authUrl || '' ) + '" target="_blank" rel="noopener">' + esc ( authLabel ) + ' ↗</a>' +
'</div>' ;
const copyBtn = status . querySelector ( '.adm-device-auth-copy' ) ;
if ( copyBtn ) copyBtn . addEventListener ( 'click' , async ( ) => {
const code = start . user _code || '' ;
let ok = false ;
try {
if ( navigator . clipboard && window . isSecureContext ) {
await navigator . clipboard . writeText ( code ) ;
ok = true ;
}
} catch ( e ) { }
if ( ! ok ) {
// navigator.clipboard is unavailable in non-secure contexts (HTTP
// self-host over a LAN IP), so fall back to execCommand('copy').
const ta = document . createElement ( 'textarea' ) ;
ta . value = code ;
ta . style . cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px;' ;
document . body . appendChild ( ta ) ;
ta . focus ( ) ;
ta . select ( ) ;
try { ta . setSelectionRange ( 0 , code . length ) ; } catch ( e ) { }
try { ok = document . execCommand ( 'copy' ) ; } catch ( e ) { }
ta . remove ( ) ;
}
copyBtn . textContent = ok ? 'Copied' : 'Failed' ;
setTimeout ( ( ) => { copyBtn . textContent = 'Copy' ; } , 1500 ) ;
} ) ;
} ,
feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth
Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.
Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.
Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.
- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
builders, device-flow start/poll, model discovery, owner-scoped endpoint
provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
/models probe).
- static/, README, tests/test_copilot*.py.
* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00
} ) ;
feat: add ChatGPT Subscription provider (#2876)
* feat: Add ChatGPT Subscription support and related features
- Introduced a new provider option for ChatGPT Subscription in the endpoint selection UI.
- Implemented OAuth flow for ChatGPT Subscription sign-in, including polling for authorization status.
- Updated admin interface to handle ChatGPT Subscription, including disabling API key input and providing user guidance.
- Enhanced cost tracking logic to differentiate between subscription and non-subscription endpoints.
- Added new slash commands for managing skills, including listing, searching, and invoking skills.
- Implemented caching for skill catalog to optimize performance.
- Updated tests to cover new ChatGPT Subscription functionality and ensure proper endpoint probing.
- Refactored existing code to accommodate new features and improve maintainability.
* refactor: share provider device-flow setup
- reuse one device-flow backend for Copilot and ChatGPT Subscription
- add one frontend device-flow helper for Settings and /setup
- put GitHub Copilot back into Add Models, now as a dropdown option
- make provider selection just select; clicking Add starts sign-in
- stop ChatGPT Subscription setup from opening auth tabs automatically
- make /setup copilot and /setup chatgpt-subscription work from chat
- show ChatGPT Subscription in the /setup suggestions
- show the real error message when setup fails
- add focused tests for the shared flow and setup UI
* feat(chatgpt-subscription): harden credential lifecycle and streamline auth UX
Backend:
- Resolve runtime bearer for provider-auth endpoints at probe time via a
shared _resolve_probe_key() that delegates to resolve_endpoint_runtime,
applied across all probe/refresh call sites.
- Skip live completion probes and health pings for discovery-only providers
(centralized behind _is_discovery_only_provider) — the Codex/Responses API
has no such endpoints, so status is derived from cached models.
- Never persist the short lived ChatGPT bearer to the plaintext sessions
table; proactively clear any stale bearer left by an earlier code path.
- Revoke orphaned ProviderAuthSession credentials when the last endpoint
backing them is deleted (_delete_orphaned_provider_auth), surfaced via
cleared_provider_auth in the delete response.
Frontend (admin.js):
- Auto-start the device-auth flow on provider selection so the authorization
panel (code + Authorize) shows immediately instead of behind a "Sign in" click.
- Remove the redundant top button for device auth providers, move retry
into the panel via an inline "Try again".
- Drop the self-evident hint text and add an execCommand clipboard fallback so
Copy works in non-secure (HTTP/LAN) contexts.
* fix: harden chatgpt subscription provider
* chore: remove PR media from branch
* Fix chatgpt subscription recovery and token handling
---------
Co-authored-by: 5p00kyy <admin@5p00ky.dev>
2026-06-08 18:19:18 +10:00
if ( result . status === 'authorized' ) {
const endpoint = result . endpoint || { } ;
const n = ( ( endpoint && endpoint . models ) || [ ] ) . length ;
status . className = 'admin-success' ;
status . textContent = 'Connected - ' + n + ' ' + config . label + ' model' + ( n !== 1 ? 's' : '' ) + ' available.' ;
if ( endpoint && endpoint . id ) _recentlyAddedEpId = String ( endpoint . id ) ;
await loadEndpoints ( ) ;
await _selectAddedModelInChat ( endpoint || { } ) ;
reset ( ) ;
return ;
}
if ( result . status === 'failed' ) {
reset ( ) ;
showAuthError ( 'Authorization failed (' + ( result . error || 'denied' ) + ').' ) ;
return ;
}
if ( result . status === 'expired' ) {
reset ( ) ;
showAuthError ( 'Authorization expired.' ) ;
return ;
}
} catch ( e ) {
reset ( ) ;
showAuthError ( formatDeviceFlowError ( e ) ) ;
}
feat(provider): add GitHub Copilot provider with device-flow auth (#1480)
* feat(provider): add GitHub Copilot provider with device-flow auth
Adds GitHub Copilot as a model provider, so Copilot models (gpt-4o/4.1/5,
Claude, Gemini, …) work through the normal chat + agent loop, incl. native
tool calling and vision.
Auth is one-click via the GitHub OAuth device flow; the access token is stored
as the endpoint's (encrypted) api_key and sent directly as `Authorization:
Bearer` (no Copilot-token exchange, no refresh — matching how editors talk to
the Copilot API). Copilot is a normal ModelEndpoint detected by host; the only
provider-specific behaviour is a small set of required request headers,
injected centrally.
Sign-in is available from Settings → model endpoints ("Connect GitHub
Copilot") and from chat via `/setup copilot`.
- src/copilot.py (new), routes/copilot_routes.py (new): constants, header
builders, device-flow start/poll, model discovery, owner-scoped endpoint
provisioning.
- src/llm_core.py, src/endpoint_resolver.py: detect `copilot`, inject headers,
per-request x-initiator/vision.
- src/agent_loop.py: allowlist api.githubcopilot.com for native tool schemas.
- src/model_context.py: known context windows for Copilot (no unauthenticated
/models probe).
- static/, README, tests/test_copilot*.py.
* Tidy copilot_routes: clarify supports_tools, note _PENDING is per-process
2026-06-04 21:13:14 +02:00
}
2026-05-31 23:58:26 +09:00
// 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 ) ;
2026-06-02 07:36:54 -04:00
const keyEl = el ( 'adm-epLocalApiKey' ) ;
const apiKey = keyEl ? keyEl . value . trim ( ) : '' ;
2026-06-01 10:00:15 +09:00
localTestBtn . disabled = true ;
localTestBtn . textContent = 'Testing...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
2026-06-02 07:36:54 -04:00
if ( apiKey ) fd . append ( 'api_key' , apiKey ) ;
2026-06-01 10:00:15 +09:00
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 ) ;
2026-06-02 07:36:54 -04:00
const keyEl = el ( 'adm-epLocalApiKey' ) ;
const apiKey = keyEl ? keyEl . value . trim ( ) : '' ;
2026-05-31 23:58:26 +09:00
localAddBtn . disabled = true ; localAddBtn . textContent = 'Adding...' ;
try {
const fd = new FormData ( ) ;
fd . append ( 'base_url' , url ) ;
2026-06-02 07:36:54 -04:00
if ( apiKey ) fd . append ( 'api_key' , apiKey ) ;
2026-06-04 00:56:11 -03:00
fd . append ( 'endpoint_kind' , 'local' ) ;
fd . append ( 'model_refresh_mode' , 'auto' ) ;
2026-05-31 23:58:26 +09:00
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 = '' ;
2026-06-02 07:36:54 -04:00
if ( keyEl ) keyEl . value = '' ;
2026-05-31 23:58:26 +09:00
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-04 00:56:11 -03:00
fd . append ( 'endpoint_kind' , 'local' ) ;
fd . append ( 'model_refresh_mode' , 'auto' ) ;
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 : "" } ,
2026-06-04 17:10:23 +00:00
oauthFile : { dir : "gmail" , filename : "gcp-oauth.keys.json" } ,
2026-05-31 23:58:26 +09:00
oauth : {
provider : "google" ,
2026-06-04 17:10:23 +00:00
keys _file : "gmail/gcp-oauth.keys.json" ,
token _file : "gmail/credentials.json" ,
2026-05-31 23:58:26 +09:00
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 {
2026-06-06 23:46:32 +03:00
const text = ( await file . text ( ) ) . replace ( /^\uFEFF/ , '' ) . trim ( ) ;
let data ;
try {
data = JSON . parse ( text ) ;
} catch ( e ) {
throw new Error ( 'Invalid backup file: ' + e . message ) ;
}
2026-05-31 23:58:26 +09:00
const res = await fetch ( '/api/import' , {
method : 'POST' , credentials : 'same-origin' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( data ) ,
} ) ;
2026-06-06 23:46:32 +03:00
const result = await res . json ( ) . catch ( ( ) => null ) ;
if ( ! result ) {
throw new Error ( ` Import failed: server returned ${ res . status } ` ) ;
}
2026-05-31 23:58:26 +09:00
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 ;