| |
| |
|
|
| import uiModule from './ui.js'; |
| import settingsModule from './settings.js'; |
| import { providerLogo } from './providers.js'; |
| import { sortModelObjects } from './modelSort.js'; |
|
|
| let initialized = false; |
| let modalEl = null; |
| |
| |
| |
| let _recentlyAddedEpId = null; |
|
|
| function el(id) { return document.getElementById(id); } |
| function esc(s) { return uiModule.esc(s); } |
|
|
| |
| |
| |
| 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'; |
|
|
| |
| 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)}</div> |
| <div> |
| <span class="admin-user-name">${esc(u.username)}</span> |
| ${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>'} |
| </div> |
| </div> |
| <div style="display:flex;gap:8px;align-items:center;"> |
| <button class="admin-btn-sm" data-adm-rename-user="${esc(u.username)}" style="font-size:11px;">Rename</button> |
| ${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>'} |
| </div> |
| `; |
| row.appendChild(header); |
|
|
| |
| 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;'; |
|
|
| |
| 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}</span> |
| <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"></span></label> |
| </div>`; |
| } |
| |
| 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</span> |
| <div style="font-size:10px;opacity:0.4;">0 = no limit</div> |
| </div> |
| <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;"> |
| </div>`; |
| |
| const allowedSet = new Set((u.privileges && u.privileges.allowed_models) || []); |
| const allEmpty = allowedSet.size === 0; |
| html += `<div style="padding:4px 0;"> |
| <div style="display:flex;align-items:center;justify-content:space-between;"> |
| <span style="font-size:12px;">Allowed models</span> |
| <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> |
| </div> |
| </div> |
| <div style="font-size:10px;opacity:0.4;margin-bottom:4px;">${allEmpty ? 'All models allowed (no restrictions)' : allowedSet.size + ' model(s) allowed'}</div> |
| <div class="priv-models-list" data-user="${esc(u.username)}"> |
| <span style="opacity:0.4;font-size:11px;">Loading models...</span> |
| </div> |
| </div>`; |
| privPanel.innerHTML = html; |
| row.appendChild(privPanel); |
|
|
| |
| let _modelsLoaded = false; |
| header.addEventListener('click', (e) => { |
| if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return; |
| 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'; |
| } |
| |
| if (!_modelsLoaded && !privPanel.classList.contains('hidden')) { |
| _modelsLoaded = true; |
| _loadModelsForUser(u.username, allowedSet, privPanel); |
| } |
| }); |
|
|
| |
| 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); |
| }); |
| } |
|
|
| |
| 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'); |
| } |
| }); |
| } |
|
|
| |
| const delBtn = row.querySelector('[data-adm-del-user]'); |
| if (delBtn) { |
| delBtn.addEventListener('click', async (e) => { |
| e.stopPropagation(); |
| const username = delBtn.dataset.admDelUser; |
| if (!await uiModule.styledConfirm(`Remove user "${username}"?`, { confirmText: 'Remove', danger: true })) return; |
| const res = await fetch('/api/auth/users', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); |
| if (res.ok) loadUsers(); |
| else uiModule.showError('Failed to delete user'); |
| }); |
| } |
|
|
| list.appendChild(row); |
| }); |
| } catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; } |
| } |
|
|
| async function _loadModelsForUser(username, allowedSet, privPanel) { |
| const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`); |
| if (!listEl) return; |
| try { |
| const res = await fetch('/api/models', { credentials: 'same-origin' }); |
| const data = await res.json(); |
| const allModels = []; |
| (data.items || []).forEach(item => { |
| if (item.offline) return; |
| (item.models || []).forEach(mid => { |
| allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() }); |
| }); |
| }); |
| if (!allModels.length) { |
| listEl.innerHTML = '<span style="opacity:0.4;font-size:11px;">No models available</span>'; |
| return; |
| } |
| const allEmpty = allowedSet.size === 0; |
| listEl.innerHTML = sortModelObjects(allModels).map(m => { |
| const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : ''; |
| return `<label> |
| <input type="checkbox" class="priv-model-cb" data-mid="${esc(m.mid)}" ${checked}> |
| <span>${esc(m.display)}</span> |
| <span style="opacity:0.3;font-size:10px;margin-left:auto;">${esc(m.epName)}</span> |
| </label>`; |
| }).join(''); |
|
|
| |
| function _saveModels() { |
| const checked = []; |
| listEl.querySelectorAll('.priv-model-cb').forEach(cb => { |
| if (cb.checked) checked.push(cb.dataset.mid); |
| }); |
| |
| const value = checked.length === allModels.length ? [] : checked; |
| const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]'); |
| if (hint) hint.textContent = value.length === 0 ? 'All models allowed (no restrictions)' : value.length + ' model(s) allowed'; |
| fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, { |
| method: 'PUT', credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ allowed_models: value }), |
| }).catch(() => {}); |
| } |
| listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels)); |
|
|
| |
| 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; |
| }); |
| } |
|
|
| |
| |
| |
| 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; |
| |
| |
| |
| if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true; |
| |
| if (!h.includes('.')) return true; |
| return false; |
| } catch { return false; } |
| } |
|
|
| 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 (_) {} |
| } |
|
|
| async function loadEndpoints() { |
| const listLocal = el('adm-epList-local'); |
| const listApi = el('adm-epList-api'); |
| |
| |
| const listLegacy = el('adm-epList'); |
| |
| if (window.modelsModule && window.modelsModule.refreshModels) { |
| window.modelsModule.refreshModels(); |
| setTimeout(() => { |
| if (window.sessionModule && window.sessionModule.updateModelPicker) { |
| window.sessionModule.updateModelPicker(); |
| } |
| }, 1500); |
| } |
| if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') { |
| settingsModule.refreshAiModelEndpoints(); |
| } |
| try { |
| const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); |
| |
| |
| |
| |
| let data = []; |
| if (res.ok) { |
| try { data = await res.json(); } catch { data = []; } |
| } |
| if (!Array.isArray(data) || data.length === 0) { |
| const empty = '<div class="admin-empty">None</div>'; |
| 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); |
| |
| |
| |
| const hasModels = ep.online && totalCount > 0; |
| 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>'; |
| const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; |
| const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api'); |
| const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : ''; |
| 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)}</span> |
| ${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''} |
| ${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''} |
| ${statusBadge} |
| ${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'} |
| ${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''} |
| </div> |
| <div style="display:flex;gap:4px;align-items:center;"> |
| <button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button> |
| <button class="admin-btn-delete" data-adm-del-ep="${ep.id}" data-adm-ep-online="${ep.online ? '1' : '0'}">Delete</button> |
| ${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>' : ''} |
| </div> |
| </div> |
| <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>` : ''}${ep.has_key ? ' (key set)' : ''}</div> |
| ${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''} |
| </div>`; |
| }); |
| |
| |
| |
| const _renderInto = (container, indices) => { |
| if (!container) return; |
| const section = container.closest('.adm-ep-section'); |
| if (!indices.length) { |
| if (section) section.style.display = 'none'; |
| container.innerHTML = ''; |
| return; |
| } |
| if (section) section.style.display = ''; |
| container.innerHTML = indices.map(i => rowHtml[i]).join(''); |
| }; |
| const localIdx = [], apiIdx = []; |
| data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i)); |
| |
| |
| 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(''); |
| |
| const queryAll = (sel) => { |
| const out = []; |
| [listLocal, listApi, listLegacy].forEach(c => { |
| if (c) c.querySelectorAll(sel).forEach(n => out.push(n)); |
| }); |
| return out; |
| }; |
| queryAll('[data-adm-toggle-ep]').forEach(btn => { |
| btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); }); |
| }); |
| queryAll('[data-adm-copy-url]').forEach(btn => { |
| btn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| const url = btn.dataset.admCopyUrl || ''; |
| if (!url) return; |
| uiModule.copyToClipboard(url).then(() => { |
| |
| |
| 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'; |
| |
| |
| |
| 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) { } |
| 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; |
| } |
| |
| const row = btn.closest('[data-adm-ep-id]'); |
| if (row) row.remove(); |
| fetch('/api/model-endpoints/' + epId, { method: 'DELETE' }) |
| .then(() => _refreshAfterEndpointChange(epId)) |
| .then(() => loadEndpoints()) |
| .catch(() => loadEndpoints()); |
| }); |
| }); |
| |
| |
| |
| if (_recentlyAddedEpId) _recentlyAddedEpId = null; |
| |
| 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) => { |
| |
| |
| |
| 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; |
| |
| 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 (_) {} }; |
| 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 = '') => { |
| const sortedModels = sortModelObjects(models); |
| 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</span> |
| <span style="display:flex;gap:8px;align-items:center;"> |
| <span class="mcp-tools-count">0/0 enabled</span> |
| <a href="#" data-ep-refresh-models="${epId}">Refresh</a> |
| </span> |
| </div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`; |
| attachRefresh(); |
| return; |
| } |
| const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id)); |
| const showSearch = sortedModels.length >= 8; |
| panel.innerHTML = `<div class="mcp-tools-header"> |
| <span>Models</span> |
| <span style="display:flex;gap:8px;align-items:center;"> |
| <span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span> |
| <a href="#" data-ep-refresh-models="${epId}">Refresh</a> |
| <a href="#" data-ep-select-all="${epId}">All</a> |
| <a href="#" data-ep-select-none="${epId}">None</a> |
| </span> |
| </div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m => |
| `<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"></span> |
| <span>${esc(m.display)}</span> |
| </label>` |
| ).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'; |
| }); |
| }; |
| attachRefresh(); |
| 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)); |
| }); |
| }; |
| 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); |
| } 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`; |
| } |
| if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') { |
| settingsModule.refreshAiModelEndpoints(); |
| } |
| } catch (e) { } |
| } |
|
|
| function initEndpointForm() { |
| const provider = el('adm-epProvider'); |
| const urlInput = el('adm-epUrl'); |
| const kindSel = el('adm-epKind'); |
|
|
| |
| |
| |
| const picker = el('adm-provider-picker'); |
| const pickerBtn = el('adm-provider-btn'); |
| const pickerMenu = el('adm-provider-menu'); |
| const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null; |
| function _renderPickerMenu() { |
| if (!pickerMenu) return; |
| pickerMenu.innerHTML = Array.from(provider.options).map(o => { |
| const logo = o.dataset.logo ? (providerLogo(o.dataset.logo) || '') : ''; |
| const active = o.value === provider.value ? ' active' : ''; |
| return `<div class="adm-provider-item${active}" role="option" data-value="${o.value.replace(/"/g, '"')}"> |
| <span class="adm-provider-logo">${logo}</span> |
| <span>${o.textContent}</span> |
| </div>`; |
| }).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(); |
| if (provider.value && !urlInput.value) urlInput.value = provider.value; |
| pickerBtn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| pickerMenu.classList.toggle('hidden'); |
| }); |
| pickerMenu.addEventListener('click', (e) => { |
| const item = e.target.closest('.adm-provider-item'); |
| if (!item) return; |
| provider.value = item.dataset.value; |
| provider.dispatchEvent(new Event('change', { bubbles: true })); |
| pickerMenu.classList.add('hidden'); |
| _renderPickerMenu(); |
| _syncPickerCurrent(); |
| }); |
| document.addEventListener('click', (e) => { |
| if (!picker.contains(e.target)) pickerMenu.classList.add('hidden'); |
| }); |
| } |
| |
| provider.addEventListener('change', () => { |
| if (provider.value) urlInput.value = provider.value; |
| else urlInput.value = ''; |
| if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy'; |
| }); |
| urlInput.addEventListener('input', () => { |
| if (provider.value && urlInput.value.trim() !== provider.value) { |
| provider.value = ''; |
| if (kindSel) kindSel.value = 'proxy'; |
| _renderPickerMenu(); |
| _syncPickerCurrent(); |
| } |
| }); |
| if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy'); |
| function _apiEndpointKind() { |
| return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy'); |
| } |
| 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, ''); |
| u = u.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api'); |
| // Fix double /v1/v1 |
| u = u.replace(/\/v1\/v1$/, '/v1'); |
| // Strip query params and fragments |
| u = u.split('?')[0].split('#')[0]; |
| try { |
| const parsed = new URL(u); |
| if (parsed.hostname.endsWith('ollama.com')) { |
| u = 'https://ollama.com/api'; |
| } |
| } catch(e) {} |
| // Ensure /v1 suffix for bare host:port URLs (not cloud providers) |
| if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) { |
| try { |
| const parsed = new URL(u); |
| if (!parsed.pathname || parsed.pathname === '/') { |
| u += '/v1'; |
| } |
| } catch(e) {} |
| } |
| return u; |
| } |
| |
| async function _defaultOllamaUrl() { |
| try { |
| const res = await fetch('/api/runtime', { credentials: 'same-origin' }); |
| if (res.ok) { |
| const data = await res.json(); |
| if (data && data.ollama_base_url) return data.ollama_base_url; |
| } |
| } catch (_) {} |
| return 'http://127.0.0.1:11434/v1'; |
| } |
| |
| function _renderEndpointTestResult(msg, res, d) { |
| if (res.ok && d.status === 'empty') { |
| msg.textContent = 'Online — no models found'; |
| msg.className = 'admin-success'; |
| return; |
| } |
| if (res.ok && d.online) { |
| const models = d.models || []; |
| const preview = models.slice(0, 3).map(m => esc(String(m).split('/').pop())).join(', '); |
| msg.innerHTML = `Online — found ${models.length} model${models.length !== 1 ? 's' : ''}${preview ? `: ${preview}${models.length > 3 ? ', …' : ''}` : ''}`; |
| msg.className = 'admin-success'; |
| return; |
| } |
| msg.textContent = (d && d.detail) || (d && d.ping_error ? `Offline — ${d.ping_error}` : 'Offline'); |
| msg.className = 'admin-error'; |
| } |
| |
| function _endpointMsg(kind) { |
| return el(kind === 'local' ? 'adm-epLocalMsg' : 'adm-epApiMsg') || el('adm-epMsg'); |
| } |
| |
| let apiTestController = null; |
| const apiTestBtn = el('adm-epApiTestBtn'); |
| const apiCancelTestBtn = el('adm-epApiCancelTestBtn'); |
| if (apiTestBtn) { |
| apiTestBtn.addEventListener('click', async () => { |
| const msg = _endpointMsg('api'); |
| msg.textContent = ''; msg.className = ''; |
| const rawUrl = (urlInput.value || provider.value).trim(); |
| const apiKey = el('adm-epApiKey').value.trim(); |
| if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; } |
| if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; } |
| const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl); |
| apiTestController = new AbortController(); |
| apiTestBtn.disabled = true; |
| apiTestBtn.textContent = 'Testing...'; |
| if (apiCancelTestBtn) apiCancelTestBtn.classList.remove('hidden'); |
| try { |
| const fd = new FormData(); |
| fd.append('base_url', url); |
| fd.append('endpoint_kind', _apiEndpointKind()); |
| fd.append('model_refresh_timeout', '30'); |
| 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(); |
| }); |
| } |
| |
| el('adm-epAddBtn').addEventListener('click', async () => { |
| const msg = _endpointMsg('api'); |
| msg.textContent = ''; msg.className = ''; |
| const rawUrl = (urlInput.value || provider.value).trim(); |
| const apiKey = el('adm-epApiKey').value.trim(); |
| if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; } |
| if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; } |
| // Normalize URL (fix typos, add /v1, strip wrong paths) |
| const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl); |
| const btn = el('adm-epAddBtn'); |
| btn.disabled = true; btn.textContent = 'Adding...'; |
| try { |
| const fd = new FormData(); |
| fd.append('base_url', url); |
| const endpointKind = _apiEndpointKind(); |
| fd.append('endpoint_kind', endpointKind); |
| fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto'); |
| fd.append('model_refresh_timeout', '30'); |
| if (apiKey) fd.append('api_key', apiKey); |
| if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) { |
| fd.append('name', provider.selectedOptions[0].textContent.trim()); |
| } |
| const epType = el('adm-epType'); |
| if (epType) fd.append('model_type', epType.value); |
| if (provider.value && /openrouter\.ai|ollama\.com/i.test(provider.value)) fd.append('require_models', 'true'); |
| else fd.append('skip_probe', 'false'); |
| const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' }); |
| const d = await res.json(); |
| if (res.ok) { |
| const count = d.models ? d.models.length : 0; |
| urlInput.value = ''; urlInput.style.display = ''; |
| el('adm-epApiKey').value = ''; provider.value = ''; |
| if (kindSel) kindSel.value = 'proxy'; |
| if (epType) epType.value = 'llm'; |
| if (d.id) _recentlyAddedEpId = String(d.id); |
| await loadEndpoints(); |
| await _selectAddedModelInChat(d); |
| if (!d.online) { |
| msg.textContent = 'Added (endpoint offline — will retry on next load)'; |
| msg.className = 'admin-error'; |
| } else if (d.status === 'empty') { |
| msg.textContent = 'Added — endpoint reachable, no models found'; |
| msg.className = 'admin-success'; |
| } else { |
| msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`; |
| msg.className = 'admin-success'; |
| } |
| } else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; } |
| } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } |
| btn.disabled = false; btn.textContent = 'Add'; |
| }); |
| |
| // Local "Add" button — sibling form for self-hosted base URLs. |
| const localAddBtn = el('adm-epLocalAddBtn'); |
| 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); |
| const keyEl = el('adm-epLocalApiKey'); |
| const apiKey = keyEl ? keyEl.value.trim() : ''; |
| localTestBtn.disabled = true; |
| localTestBtn.textContent = 'Testing...'; |
| try { |
| const fd = new FormData(); |
| fd.append('base_url', url); |
| if (apiKey) fd.append('api_key', apiKey); |
| const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' }); |
| 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'; |
| }); |
| } |
| if (localAddBtn) { |
| localAddBtn.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 (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; } |
| const url = _normalizeBaseUrl(raw); |
| const keyEl = el('adm-epLocalApiKey'); |
| const apiKey = keyEl ? keyEl.value.trim() : ''; |
| localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...'; |
| try { |
| const fd = new FormData(); |
| fd.append('base_url', url); |
| if (apiKey) fd.append('api_key', apiKey); |
| fd.append('endpoint_kind', 'local'); |
| fd.append('model_refresh_mode', 'auto'); |
| const lt = el('adm-epLocalType'); |
| if (lt) fd.append('model_type', lt.value); |
| fd.append('skip_probe', 'false'); |
| const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' }); |
| const d = await res.json(); |
| if (res.ok) { |
| el('adm-epLocalUrl').value = ''; |
| if (keyEl) keyEl.value = ''; |
| if (lt) lt.value = 'llm'; |
| if (d.id) _recentlyAddedEpId = String(d.id); |
| await loadEndpoints(); |
| await _selectAddedModelInChat(d); |
| const count = (d.models || []).length; |
| msg.textContent = d.status === 'empty' |
| ? 'Added — Ollama is running, no models pulled yet' |
| : d.online |
| ? `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'; |
| }); |
| } |
| |
| 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 = ''; |
| } |
| }); |
| } |
| |
| // Discover local models button |
| const discoverBtn = el('adm-epDiscoverBtn'); |
| if (discoverBtn) { |
| discoverBtn.addEventListener('click', async () => { |
| const msg = _endpointMsg('local'); |
| 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'); |
| txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...'; |
| 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) { |
| 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.'; |
| msg.className = 'admin-error'; |
| } else { |
| // Auto-add each discovered endpoint. Server dedupes on base_url |
| // and returns `existing: true` for already-registered ones. |
| let added = 0; |
| let skipped = 0; |
| for (const item of items) { |
| const base = item.url.replace('/chat/completions', '').replace(/\/$/, ''); |
| const fd = new FormData(); |
| fd.append('base_url', base); |
| fd.append('endpoint_kind', 'local'); |
| fd.append('model_refresh_mode', 'auto'); |
| fd.append('skip_probe', 'false'); |
| const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd }); |
| if (r.ok) { |
| try { |
| const dd = await r.json(); |
| if (dd && dd.existing) { skipped++; } |
| else { added++; if (dd && dd.id) _recentlyAddedEpId = String(dd.id); } |
| } catch (_) { added++; } |
| } |
| } |
| const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0); |
| 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(' — '); |
| 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 {} |
| 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 {} |
| const apply = () => { |
| sec.classList.toggle('collapsed', !open); |
| head.setAttribute('aria-expanded', open ? 'true' : 'false'); |
| }; |
| apply(); |
| const toggle = () => { |
| open = !open; |
| try { localStorage.setItem(key, open ? '1' : '0'); } catch {} |
| apply(); |
| }; |
| head.addEventListener('click', toggle); |
| head.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } |
| }); |
| }); |
| } |
| |
| /* ═══════════════════════════════════════════ |
| TOOLS TAB — MCP |
| ═══════════════════════════════════════════ */ |
| |
| const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials: |
| 1. Go to console.cloud.google.com |
| 2. Click the project dropdown (top left) > New Project > name it > Create |
| 3. APIs & Services > Library > enable the API you need (Gmail, Calendar, Drive, etc.) |
| 4. APIs & Services > OAuth consent screen > configure (External, app name + email) |
| 5. Under Audience, click Add Users > add your Google email as a test user |
| 6. APIs & Services > Credentials > + Create Credentials > OAuth Client ID > Desktop App |
| 7. Copy the Client ID and Client Secret into the fields above |
| 8. After adding the server, click Authorize to sign in with Google |
| 9. If accessing remotely: sign in, then copy the URL from the error page and paste it back`; |
| |
| const MCP_PRESETS = [ |
| { name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" }, |
| oauthFile: { dir: "~/.gmail-mcp", filename: "gcp-oauth.keys.json" }, |
| oauth: { |
| provider: "google", |
| keys_file: "~/.gmail-mcp/gcp-oauth.keys.json", |
| token_file: "~/.gmail-mcp/credentials.json", |
| scopes: ["https://www.googleapis.com/auth/gmail.modify", "https://www.googleapis.com/auth/gmail.settings.basic"], |
| }, |
| help: `Setup: |
| 1. Go to console.cloud.google.com > create or select a project |
| 2. APIs & Services > Library > search "Gmail API" > Enable |
| 3. APIs & Services > OAuth consent screen > set up (External is fine) |
| 4. Under Audience, add your Gmail address as a test user |
| 5. APIs & Services > Credentials > + Create Credentials > OAuth Client ID |
| 6. Application type: Desktop App > Create |
| 7. Copy the Client ID and Client Secret into the fields above |
| 8. Click Add Server, then click the Authorize button |
| 9. Sign in with Google, copy the URL from the error page, paste it back` }, |
| { name: "Email (IMAP/SMTP)", command: "npx", args: ["-y", "@codefuturist/email-mcp", "stdio"], env: { MCP_EMAIL_ADDRESS: "", MCP_EMAIL_PASSWORD: "", MCP_EMAIL_IMAP_HOST: "", MCP_EMAIL_SMTP_HOST: "" }, |
| providerDropdown: { |
| label: "Provider", |
| targets: { MCP_EMAIL_IMAP_HOST: "imap", MCP_EMAIL_SMTP_HOST: "smtp" }, |
| options: [ |
| { name: "Migadu", imap: "imap.migadu.com", smtp: "smtp.migadu.com" }, |
| { name: "Fastmail", imap: "imap.fastmail.com", smtp: "smtp.fastmail.com" }, |
| { name: "Proton Bridge", imap: "127.0.0.1", smtp: "127.0.0.1" }, |
| { name: "Outlook/Hotmail", imap: "outlook.office365.com", smtp: "smtp.office365.com" }, |
| { name: "Yahoo", imap: "imap.mail.yahoo.com", smtp: "smtp.mail.yahoo.com" }, |
| { name: "iCloud", imap: "imap.mail.me.com", smtp: "smtp.mail.me.com" }, |
| { name: "Zoho", imap: "imap.zoho.com", smtp: "smtp.zoho.com" }, |
| { name: "Custom", imap: "", smtp: "" }, |
| ], |
| }, |
| help: "Works with any IMAP/SMTP email provider.\n1. Pick your provider from the dropdown (or choose Custom)\n2. Enter your email address and password (or app password)\n3. Click Add Server" }, |
| { name: "CalDAV (Radicale/Nextcloud)", command: "npx", args: ["-y", "caldav-mcp"], env: { CALDAV_BASE_URL: "http://localhost:5232", CALDAV_USERNAME: "", CALDAV_PASSWORD: "" }, |
| help: "Works with any CalDAV server (Radicale, Nextcloud, etc.).\n1. Enter your CalDAV server URL (e.g. http://localhost:5232)\n2. Enter your username and password\n3. Click Add Server" }, |
| { name: "Google Calendar", command: "npx", args: ["-y", "@cocal/google-calendar-mcp"], env: { GOOGLE_OAUTH_CREDENTIALS: "" }, |
| help: `Setup: |
| 1. Go to console.cloud.google.com > create/select a project |
| 2. APIs & Services > Library > enable Google Calendar API |
| 3. APIs & Services > Credentials > + Create Credentials > OAuth Client ID |
| 4. Application type: Desktop App > Create |
| 5. Click "Download JSON" on the credential you just created |
| 6. Set Google Oauth Credentials to the full path of the downloaded JSON file` }, |
| { name: "Google Drive", command: "npx", args: ["-y", "@modelcontextprotocol/server-gdrive"], env: {}, |
| help: "Google Drive uses browser-based OAuth on first run. No env vars needed — just click Add and authorize when prompted." }, |
| { name: "GitHub", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], env: { GITHUB_PERSONAL_ACCESS_TOKEN: "" }, |
| help: "1. Go to github.com > Settings > Developer Settings > Personal Access Tokens > Fine-grained tokens\n2. Generate a new token with the repo permissions you need\n3. Paste it as Github Personal Access Token" }, |
| { name: "Slack", command: "npx", args: ["-y", "@modelcontextprotocol/server-slack"], env: { SLACK_BOT_TOKEN: "", SLACK_TEAM_ID: "" }, |
| help: "1. Go to api.slack.com/apps > Create New App > From Scratch\n2. Add Bot Token Scopes (channels:read, chat:write, etc.)\n3. Install to workspace, copy the Bot User OAuth Token (xoxb-...)\n4. Team ID is in your workspace URL or Slack admin settings" }, |
| { name: "Notion", command: "npx", args: ["-y", "@notionhq/notion-mcp-server"], env: { OPENAPI_MCP_HEADERS: "" }, |
| help: "1. Go to notion.so/my-integrations\n2. Create a new integration\n3. Copy the Internal Integration Secret\n4. Share the Notion pages/databases you want accessible with the integration\n5. For Openapi Mcp Headers enter:\n {\"Authorization\": \"Bearer YOUR_SECRET\", \"Notion-Version\": \"2022-06-28\"}" }, |
| { name: "Linear", command: "npx", args: ["-y", "mcp-linear"], env: { LINEAR_API_KEY: "" }, |
| help: "1. Go to linear.app > Settings > API\n2. Create a Personal API Key\n3. Paste it as Linear Api Key" }, |
| { name: "Brave Search", command: "npx", args: ["-y", "@modelcontextprotocol/server-brave-search"], env: { BRAVE_API_KEY: "" }, |
| help: "1. Go to brave.com/search/api\n2. Sign up for a free plan (2000 queries/month)\n3. Copy your API key" }, |
| { name: "Browser (Playwright)", command: "npx", args: ["-y", "@playwright/mcp@latest", "--headless"], env: {}, |
| help: "Browser automation via Playwright. The AI can navigate pages, click, fill forms, and read content.\nRuns headless by default. Remove --headless from Args to see the browser window.\nFirst run installs Chromium automatically." }, |
| { name: "Filesystem", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/home"], env: {}, |
| help: "Edit the Args field to change which directory the server has access to." }, |
| { name: "Memory", command: "npx", args: ["-y", "@modelcontextprotocol/server-memory"], env: {} }, |
| { name: "Postgres", command: "npx", args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://user:pass@localhost/db"], env: {}, |
| help: "Replace the connection string in the Args field with your actual Postgres connection URL." }, |
| { name: "Todoist", command: "npx", args: ["-y", "todoist-mcp-server"], env: { TODOIST_API_TOKEN: "" }, |
| help: "1. Go to todoist.com > Settings > Integrations > Developer\n2. Copy your API token" }, |
| ]; |
| // ── Built-in tools management ── |
| const TOOL_META = { |
| bash: { name: 'Shell', desc: 'Execute bash commands', cat: 'Code', ctx: '~200' }, |
| python: { name: 'Python', desc: 'Run Python scripts', cat: 'Code', ctx: '~200' }, |
| read_file: { name: 'Read File', desc: 'Read files from disk', cat: 'Code', ctx: '~150' }, |
| write_file: { name: 'Write File', desc: 'Write/create files', cat: 'Code', ctx: '~150' }, |
| web_search: { name: 'Web Search', desc: 'Search the web via SearXNG', cat: 'Search', ctx: '~300' }, |
| search_chats: { name: 'Search Chats', desc: 'Search conversation history', cat: 'Search', ctx: '~150' }, |
| create_document: { name: 'Create Document', desc: 'Create new documents', cat: 'Documents', ctx: '~200' }, |
| update_document: { name: 'Update Document', desc: 'Modify existing documents', cat: 'Documents', ctx: '~200' }, |
| edit_document: { name: 'Edit Document', desc: 'Find & replace in documents', cat: 'Documents', ctx: '~200' }, |
| suggest_document: { name: 'Suggest Changes', desc: 'Propose document edits', cat: 'Documents', ctx: '~200' }, |
| manage_documents: { name: 'Manage Documents', desc: 'List, delete, organize docs', cat: 'Documents', ctx: '~150' }, |
| generate_image: { name: 'Generate Image', desc: 'Create images via AI', cat: 'Media', ctx: '~150' }, |
| manage_memory: { name: 'Memory', desc: 'Save and recall memories', cat: 'Knowledge', ctx: '~200' }, |
| manage_skills: { name: 'Skills', desc: 'Learn and use procedures', cat: 'Knowledge', ctx: '~200' }, |
| manage_rag: { name: 'RAG / Docs', desc: 'Query indexed documents', cat: 'Knowledge', ctx: '~150' }, |
| chat_with_model: { name: 'Chat with Model', desc: 'Talk to another AI model', cat: 'Multi-Agent', ctx: '~200' }, |
| second_opinion: { name: 'Second Opinion', desc: 'Get another model\'s take', cat: 'Multi-Agent', ctx: '~150' }, |
| pipeline: { name: 'Pipeline', desc: 'Multi-step AI workflows', cat: 'Multi-Agent', ctx: '~200' }, |
| ask_teacher: { name: 'Ask Teacher', desc: 'Query a more capable model', cat: 'Multi-Agent', ctx: '~150' }, |
| send_to_session: { name: 'Send to Session', desc: 'Send message to another chat', cat: 'Sessions', ctx: '~100' }, |
| create_session: { name: 'Create Session', desc: 'Start a new chat session', cat: 'Sessions', ctx: '~100' }, |
| list_sessions: { name: 'List Sessions', desc: 'Browse existing sessions', cat: 'Sessions', ctx: '~100' }, |
| manage_session: { name: 'Manage Session', desc: 'Rename, archive, configure', cat: 'Sessions', ctx: '~100' }, |
| list_models: { name: 'List Models', desc: 'Show available models', cat: 'System', ctx: '~100' }, |
| ui_control: { name: 'UI Control', desc: 'Change theme, layout, settings', cat: 'System', ctx: '~150' }, |
| manage_tasks: { name: 'Tasks', desc: 'Schedule automated tasks', cat: 'System', ctx: '~150' }, |
| api_call: { name: 'API Call', desc: 'Make HTTP requests', cat: 'System', ctx: '~200' }, |
| manage_endpoints: { name: 'Endpoints', desc: 'Add/remove model endpoints', cat: 'System', ctx: '~100' }, |
| manage_mcp: { name: 'MCP Servers', desc: 'Manage MCP connections', cat: 'System', ctx: '~100' }, |
| manage_webhooks: { name: 'Webhooks', desc: 'Configure webhook events', cat: 'System', ctx: '~100' }, |
| manage_tokens: { name: 'API Tokens', desc: 'Manage API access tokens', cat: 'System', ctx: '~100' }, |
| manage_settings: { name: 'Settings', desc: 'Change app settings', cat: 'System', ctx: '~100' }, |
| }; |
| |
| async function loadBuiltinTools() { |
| const list = el('adm-builtin-tools-list'); |
| if (!list) return; |
| try { |
| const res = await fetch('/api/tools', { credentials: 'same-origin' }); |
| const data = await res.json(); |
| const tools = data.tools || []; |
| if (!tools.length) { list.innerHTML = '<div class="admin-empty">No tools found</div>'; return; } |
| |
| // Group by category |
| const groups = {}; |
| for (const t of tools) { |
| const meta = TOOL_META[t.id] || { name: t.id, desc: '', cat: 'Other', ctx: '?' }; |
| const cat = meta.cat; |
| if (!groups[cat]) groups[cat] = []; |
| groups[cat].push({ ...t, ...meta }); |
| } |
| |
| // Category order |
| const catOrder = ['Code', 'Search', 'Documents', 'Media', 'Knowledge', 'Multi-Agent', 'Sessions', 'System', 'Other']; |
| let html = ''; |
| for (const cat of catOrder) { |
| const items = groups[cat]; |
| if (!items) continue; |
| const enabledCount = items.filter(i => i.enabled).length; |
| const totalCount = items.length; |
| const catId = 'tool-cat-' + cat.replace(/[^a-zA-Z]/g, ''); |
| const allEnabled = enabledCount === totalCount; |
| html += `<div class="admin-tool-category"> |
| <div class="admin-tool-cat-header" data-tool-cat="${catId}" style="cursor:pointer;display:flex;align-items:center;justify-content:space-between;"> |
| <span>${esc(cat)}</span> |
| <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}</span> |
| <label class="admin-switch" style="flex-shrink:0;"> |
| <input type="checkbox" data-tool-cat-toggle="${catId}" ${allEnabled ? 'checked' : ''}> |
| <span class="admin-slider"></span> |
| </label> |
| <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"/></svg> |
| </span> |
| </div> |
| <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)}</span> |
| <span class="admin-tool-desc">${esc(t.desc)}</span> |
| </div> |
| <span class="admin-tool-ctx" title="Approximate context tokens used">${esc(t.ctx)}</span> |
| <label class="admin-switch" style="flex-shrink:0;"> |
| <input type="checkbox" data-tool-id="${esc(t.id)}" ${t.enabled ? 'checked' : ''}> |
| <span class="admin-slider"></span> |
| </label> |
| </div>`; |
| } |
| 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)}</span> |
| <span class="admin-badge" style="background:${statusColor}33;color:${statusColor}">${statusText}</span> |
| ${hasTools ? `<span style="font-size:10px;opacity:0.4;">Click to manage tools</span>` : ''} |
| </div> |
| <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</button> |
| <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'}</button> |
| <button class="admin-btn-delete" data-adm-mcp-delete="${s.id}">Delete</button> |
| ${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>' : ''} |
| </div> |
| </div> |
| ${hasTools ? `<div class="mcp-tools-panel hidden" data-adm-mcp-tools-panel="${s.id}"></div>` : ''} |
| </div>`; |
| }).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</span> |
| <span style="display:flex;gap:8px;align-items:center;"> |
| <span class="mcp-tools-count">${tools.length - disabled.size}/${tools.length} enabled</span> |
| <a href="#" data-mcp-select-all="${sid}">All</a> |
| <a href="#" data-mcp-select-none="${sid}">None</a> |
| </span> |
| </div><div class="mcp-tools-list">` + tools.map(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)}</strong> <span style="opacity:0.5;">— ${esc((t.description || '').slice(0, 80))}</span></span> |
| </label>` |
| ).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'; } |
| }); |
| } |
| |
| /* ── Embedding model ── |
| 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)}</span> |
| <span class="admin-badge">${esc(t.token_prefix)}...</span> |
| <span class="admin-badge" title="Allowed API scopes">${esc((t.scopes || ['chat']).join(', '))}</span> |
| ${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>'} |
| </div> |
| <button class="admin-btn-delete" data-adm-del-token="${t.id}">Revoke</button> |
| </div>`).join(''); |
| 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>' : ''}</div> |
| <div class="admin-ep-detail">${esc(w.url)}</div> |
| <div style="margin-top:0.3rem;">${events}</div> |
| <div class="admin-ep-detail">Last: ${lastTriggered} ${statusBadge}</div> |
| ${errorText} |
| </div> |
| <div class="admin-ep-actions"> |
| <button class="admin-btn-sm" data-adm-wh-test="${w.id}">Test</button> |
| <button class="admin-btn-sm" data-adm-wh-toggle="${w.id}">${w.is_active ? 'Disable' : 'Enable'}</button> |
| <button class="admin-btn-delete" data-adm-wh-delete="${w.id}">Delete</button> |
| </div> |
| </div>`; |
| }).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}</div> |
| <label class="admin-switch"><input type="checkbox" data-adm-feature="${key}" ${features[key] ? 'checked' : ''}><span class="admin-slider"></span></label> |
| </div>`).join(''); |
| container.querySelectorAll('input[data-adm-feature]').forEach(toggle => { |
| toggle.addEventListener('change', async () => { |
| const body = {}; body[toggle.dataset.admFeature] = toggle.checked; |
| await fetch('/api/auth/features', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); |
| }); |
| }); |
| } catch (e) { container.innerHTML = '<div class="admin-error">Failed to load features</div>'; } |
| } |
| |
| /* ── CalDAV Config ── */ |
| function initCalDAV() { |
| const urlIn = el('caldav-url'); |
| const userIn = el('caldav-user'); |
| const passIn = el('caldav-pass'); |
| const saveBtn = el('caldav-save-btn'); |
| const testBtn = el('caldav-test-btn'); |
| const status = el('caldav-status'); |
| if (!urlIn || !saveBtn) return; |
| |
| // Load current config |
| fetch(`${API_BASE}/api/calendar/config`, { credentials: 'same-origin' }) |
| .then(r => r.json()).then(d => { |
| urlIn.value = d.caldav_url || ''; |
| userIn.value = d.caldav_username || ''; |
| passIn.value = d.caldav_password || ''; |
| }).catch(() => {}); |
| |
| saveBtn.addEventListener('click', async () => { |
| status.textContent = 'Saving...'; |
| try { |
| const res = await fetch(`${API_BASE}/api/calendar/config`, { |
| method: 'POST', credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), |
| }); |
| const d = await res.json(); |
| status.textContent = d.ok ? 'Saved' : 'Error'; |
| status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; |
| } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } |
| setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 3000); |
| }); |
| |
| testBtn.addEventListener('click', async () => { |
| status.textContent = 'Testing...'; |
| try { |
| // Save first |
| await fetch(`${API_BASE}/api/calendar/config`, { |
| method: 'POST', credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), |
| }); |
| const res = await fetch(`${API_BASE}/api/calendar/test`, { method: 'POST', credentials: 'same-origin' }); |
| const d = await res.json(); |
| status.textContent = d.ok ? `Connected (${d.calendars} calendars)` : `Failed: ${d.error}`; |
| status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; |
| } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } |
| setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 5000); |
| }); |
| } |
| |
| /* ── Data Backup (export/import) ── */ |
| function initBackup() { |
| el('adm-exportDataBtn').addEventListener('click', async () => { |
| const btn = el('adm-exportDataBtn'); |
| const msg = el('adm-backupMsg'); |
| btn.disabled = true; btn.textContent = 'Exporting...'; msg.textContent = ''; |
| try { |
| const res = await fetch('/api/export', { credentials: 'same-origin' }); |
| if (!res.ok) throw new Error('Export failed'); |
| const blob = await res.blob(); |
| const disposition = res.headers.get('Content-Disposition') || ''; |
| const match = disposition.match(/filename=(.+)/); |
| const filename = match ? match[1] : 'odysseus_backup.json'; |
| const a = document.createElement('a'); |
| a.href = URL.createObjectURL(blob); |
| a.download = filename; |
| a.click(); |
| URL.revokeObjectURL(a.href); |
| msg.textContent = 'Export downloaded.'; msg.className = 'admin-success'; |
| } catch (e) { msg.textContent = 'Export failed: ' + e.message; msg.className = 'admin-error'; } |
| btn.disabled = false; btn.textContent = 'Export Data'; |
| }); |
| |
| const fileInput = el('adm-importFile'); |
| el('adm-importDataBtn').addEventListener('click', () => { fileInput.value = ''; fileInput.click(); }); |
| fileInput.addEventListener('change', async () => { |
| const file = fileInput.files[0]; |
| if (!file) return; |
| const msg = el('adm-backupMsg'); |
| const btn = el('adm-importDataBtn'); |
| btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = ''; |
| try { |
| const text = await file.text(); |
| const data = JSON.parse(text); |
| const res = await fetch('/api/import', { |
| method: 'POST', credentials: 'same-origin', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(data), |
| }); |
| const result = await res.json(); |
| if (res.ok && result.ok) { |
| msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success'; |
| } else { |
| msg.textContent = result.message || result.detail || 'Import failed'; msg.className = 'admin-error'; |
| } |
| } catch (e) { msg.textContent = 'Import failed: ' + e.message; msg.className = 'admin-error'; } |
| btn.disabled = false; btn.textContent = 'Import Data'; |
| }); |
| } |
| |
| /* ── Danger Zone ── */ |
| function initDangerZone() { |
| // Per-category Danger Zone wipes. Each button declares its target |
| // via data-wipe-kind; one delegated handler handles double-confirm, |
| // POSTs to /api/admin/wipe/{kind}, and writes the result. |
| const _LABELS = { |
| chats: 'chats', memory: 'memory entries', skills: 'skills', |
| notes: 'notes', tasks: 'tasks', documents: 'documents', |
| gallery: 'gallery images', calendar: 'calendar items', |
| }; |
| const _wipeMsg = el('adm-wipeMsg'); |
| modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => { |
| btn.addEventListener('click', async () => { |
| const kind = btn.dataset.wipeKind; |
| const label = _LABELS[kind] || kind; |
| if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return; |
| if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return; |
| btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping…'; |
| if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; } |
| try { |
| const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' }); |
| const data = await res.json().catch(() => ({})); |
| if (res.ok) { |
| if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; } |
| } else { |
| if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; } |
| } |
| } catch (e) { |
| if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; } |
| } |
| btn.disabled = false; btn.textContent = prev; |
| }); |
| }); |
| } |
| |
| /* ═══════════════════════════════════════════ |
| INIT & REFRESH |
| ═══════════════════════════════════════════ */ |
| function initAll() { |
| modalEl = el('settings-modal'); |
| const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, () => settingsModule.initIntegrations()]; |
| for (const fn of inits) { |
| try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); } |
| } |
| initialized = true; |
| refreshAll(); |
| } |
| |
| function refreshAll() { |
| loadUsers(); |
| loadEndpoints(); |
| loadBuiltinTools(); |
| loadMcpServers(); |
| } |
| |
| /* ═══════════════════════════════════════════ |
| PUBLIC API |
| ═══════════════════════════════════════════ */ |
| export function _initData() { |
| if (!initialized) initAll(); |
| else refreshAll(); |
| } |
| |
| export function open(tab) { |
| _initData(); |
| settingsModule.open(tab || 'services'); |
| } |
| |
| export function close() { |
| settingsModule.close(); |
| } |
| |
| const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; |
| export default adminModule; |
| |