Files
odysseus/static/js/admin.js
Shaw 8115cb01a2 Models: allow API keys for local endpoints
Self-hosted endpoints on a LAN are sometimes protected by an API key. The admin
"Local" add/test form only sent base_url (+ model_type), so such an endpoint
could not be added — it just errored out — even though the backend
POST /api/model-endpoints and /model-endpoints/test already accept an optional
api_key form field (the cloud "API" form already uses it).

Adds an optional masked "API key" input (adm-epLocalApiKey) to the Local form
and wires it into the local Test and Add handlers, sending api_key only when
filled (an empty value is omitted so we never send a blank Bearer). The field
is cleared after a successful add, matching the cloud form.

Tested: tests/test_local_endpoint_api_key_js.py extracts the two click handlers
and runs them under node with mocked DOM/FormData/fetch, asserting api_key is
sent when the field is filled and omitted when blank, plus that the input
exists as a password field. `node --check static/js/admin.js` passes.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:36:54 +09:00

2081 lines
107 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// static/js/admin.js — Admin panel module (ES6)
// Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features
import uiModule from './ui.js';
import settingsModule from './settings.js';
import { providerLogo } from './providers.js';
import { sortModelObjects } from './modelSort.js';
let initialized = false;
let modalEl = null;
// When the user adds an endpoint, store its id so the next render of
// the endpoints list can flash a glow on that row. Cleared once the
// animation fires.
let _recentlyAddedEpId = null;
function el(id) { return document.getElementById(id); }
function esc(s) { return uiModule.esc(s); }
/* ═══════════════════════════════════════════
USERS TAB
═══════════════════════════════════════════ */
const PRIV_LABELS = {
can_use_agent: 'Agent mode',
can_use_browser: 'Browser automation',
can_use_bash: 'Shell / Python / Files',
can_use_documents: 'Document editor',
can_use_research: 'Deep research',
can_generate_images: 'Image generation',
can_manage_memory: 'Memory & skills',
};
async function loadUsers() {
const list = el('adm-userList');
try {
const res = await fetch('/api/auth/users', { credentials: 'same-origin' });
if (res.status === 401 || res.status === 403) { list.innerHTML = '<div class="admin-empty">Access denied</div>'; return; }
const data = await res.json();
if (!data.users || data.users.length === 0) { list.innerHTML = '<div class="admin-empty">No users found</div>'; return; }
list.innerHTML = '';
data.users.forEach(u => {
const row = document.createElement('div');
row.className = 'admin-user-row';
// Header: name + badges + delete
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;';
const initial = u.username.charAt(0).toUpperCase();
header.innerHTML = `
<div class="admin-user-info">
<div style="width:28px;height:28px;border-radius:50%;background:color-mix(in srgb, var(--accent) 20%, var(--panel));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;flex-shrink:0;color:var(--accent);">${esc(initial)}</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);
// Privileges panel (hidden by default, not for admins)
if (!u.is_admin) {
const privPanel = document.createElement('div');
privPanel.className = 'admin-priv-panel hidden';
privPanel.style.cssText = 'padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;';
// Boolean toggles
let html = '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.35;font-weight:600;margin-bottom:4px;">Features</div>';
for (const [key, label] of Object.entries(PRIV_LABELS)) {
const checked = u.privileges && u.privileges[key] ? 'checked' : '';
html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;">
<span style="font-size:12px;">${label}</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>`;
}
// Rate limit
html += '<div style="font-size:10px;text-transform:uppercase;letter-spacing:0.5px;opacity:0.35;font-weight:600;margin:10px 0 4px;">Limits</div>';
const maxMsg = (u.privileges && u.privileges.max_messages_per_day) || 0;
html += `<div style="display:flex;align-items:center;justify-content:space-between;padding:4px 0;">
<div>
<span style="font-size:12px;">Daily message limit</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>`;
// Allowed models — checkbox list
const allowedSet = new Set((u.privileges && u.privileges.allowed_models) || []);
const allEmpty = allowedSet.size === 0;
html += `<div style="padding:4px 0;">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;">Allowed models</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);
// Toggle panel visibility + rotate chevron + load models
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';
}
// Load models list on first expand
if (!_modelsLoaded && !privPanel.classList.contains('hidden')) {
_modelsLoaded = true;
_loadModelsForUser(u.username, allowedSet, privPanel);
}
});
// Wire privilege changes (boolean + number inputs, not model checkboxes)
privPanel.querySelectorAll('[data-priv]').forEach(input => {
const handler = async () => {
const username = input.dataset.user;
const key = input.dataset.priv;
let value;
if (input.type === 'checkbox') value = input.checked;
else if (input.type === 'number') value = parseInt(input.value) || 0;
else value = input.value;
try {
await fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
});
} catch (e) { uiModule.showError('Failed to update privilege'); }
};
if (input.type === 'checkbox') input.addEventListener('change', handler);
else input.addEventListener('change', handler);
});
}
// Rename button
const renameBtn = row.querySelector('[data-adm-rename-user]');
if (renameBtn) {
renameBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const oldUsername = renameBtn.dataset.admRenameUser;
const next = await uiModule.styledPrompt(`Rename "${oldUsername}"`, {
defaultValue: oldUsername,
placeholder: 'New username',
confirmText: 'Rename',
});
const username = (next || '').trim();
if (!username || username === oldUsername) return;
try {
const res = await fetch(`/api/auth/users/${encodeURIComponent(oldUsername)}/rename`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
uiModule.showError(data.detail || 'Failed to rename user');
return;
}
if (data.renamed_self) {
window.location.reload();
return;
}
loadUsers();
} catch (err) {
uiModule.showError('Failed to rename user');
}
});
}
// Delete button
const delBtn = row.querySelector('[data-adm-del-user]');
if (delBtn) {
delBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const username = delBtn.dataset.admDelUser;
if (!await uiModule.styledConfirm(`Remove user "${username}"?`, { confirmText: 'Remove', danger: true })) return;
const res = await fetch('/api/auth/users', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) });
if (res.ok) loadUsers();
else uiModule.showError('Failed to delete user');
});
}
list.appendChild(row);
});
} catch (e) { list.innerHTML = '<div class="admin-error">Failed to load users</div>'; }
}
async function _loadModelsForUser(username, allowedSet, privPanel) {
const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`);
if (!listEl) return;
try {
const res = await fetch('/api/models', { credentials: 'same-origin' });
const data = await res.json();
const allModels = [];
(data.items || []).forEach(item => {
if (item.offline) return;
(item.models || []).forEach(mid => {
allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() });
});
});
if (!allModels.length) {
listEl.innerHTML = '<span style="opacity:0.4;font-size:11px;">No models available</span>';
return;
}
const allEmpty = allowedSet.size === 0;
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('');
// Save on change
function _saveModels() {
const checked = [];
listEl.querySelectorAll('.priv-model-cb').forEach(cb => {
if (cb.checked) checked.push(cb.dataset.mid);
});
// If all are checked, send empty array (= no restrictions)
const value = checked.length === allModels.length ? [] : checked;
const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]');
if (hint) hint.textContent = value.length === 0 ? 'All models allowed (no restrictions)' : value.length + ' model(s) allowed';
fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, {
method: 'PUT', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowed_models: value }),
}).catch(() => {});
}
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels));
// All / None buttons
privPanel.querySelector(`.priv-models-all[data-user="${username}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = true);
_saveModels();
});
privPanel.querySelector(`.priv-models-none[data-user="${username}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = false);
_saveModels();
});
} catch (e) {
listEl.innerHTML = '<span style="opacity:0.4;font-size:11px;">Failed to load models</span>';
}
}
function initSignupToggle() {
const toggle = el('adm-signupToggle');
fetch('/api/auth/status', { credentials: 'same-origin' })
.then(r => r.json())
.then(d => { toggle.checked = !!d.signup_enabled; })
.catch(e => console.warn('Auth status fetch failed:', e));
toggle.addEventListener('change', async () => {
try {
const res = await fetch('/api/auth/signup-toggle', { method: 'POST', credentials: 'same-origin' });
const data = await res.json();
toggle.checked = data.signup_enabled;
} catch (e) { toggle.checked = !toggle.checked; }
});
}
function initAddUser() {
el('adm-addBtn').addEventListener('click', async () => {
const msg = el('adm-addMsg');
msg.textContent = ''; msg.className = '';
const username = el('adm-newUsername').value.trim();
const password = el('adm-newPassword').value;
const is_admin = el('adm-newIsAdmin').checked;
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; }
el('adm-addBtn').disabled = true;
try {
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
const data = await res.json();
if (res.ok) { msg.textContent = 'User created'; msg.className = 'admin-success'; el('adm-newUsername').value = ''; el('adm-newPassword').value = ''; el('adm-newIsAdmin').checked = false; loadUsers(); }
else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; }
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
el('adm-addBtn').disabled = false;
});
}
/* ═══════════════════════════════════════════
SERVICES TAB — Endpoints
═══════════════════════════════════════════ */
function _isLocalEndpoint(url) {
if (!url) return false;
try {
const u = new URL(url);
const h = u.hostname.toLowerCase();
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true;
if (h.endsWith('.local')) return true;
if (/^10\./.test(h)) return true;
if (/^192\.168\./.test(h)) return true;
if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true;
// Tailscale CGNAT range (100.64.0.0/10 → 100.64.x100.127.x). Servers
// found via "Scan for Servers" come back as tailnet IPs, which are still
// your own machines, so group them under Local rather than API.
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true;
// Single-label hostnames are LAN by convention.
if (!h.includes('.')) return true;
return false;
} catch { return false; }
}
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');
// Fallback to the legacy single list if the split containers don't exist
// (older HTML or third-party embedding).
const listLegacy = el('adm-epList');
// Refresh model picker so new endpoints show up in chat
if (window.modelsModule && window.modelsModule.refreshModels) {
window.modelsModule.refreshModels(true);
setTimeout(() => {
if (window.sessionModule && window.sessionModule.updateModelPicker) {
window.sessionModule.updateModelPicker();
}
}, 1500);
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
try {
const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' });
// Treat a non-OK response (e.g. 401/403 for non-admins, or backend
// returning an error envelope) the same as "no endpoints yet": show the
// empty state, not "Failed to load". The user just installed the app —
// there's literally nothing to load, so the error read as broken UI.
let data = [];
if (res.ok) {
try { data = await res.json(); } catch { data = []; }
}
if (!Array.isArray(data) || data.length === 0) {
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);
// `ep.models` is the *visible* set — when every model is hidden it's
// empty, but we still need to render the expand panel so the user can
// un-hide them. Gate on the total instead.
const hasModels = ep.online && totalCount > 0;
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' : '';
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>' : ''}
${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)}${_isLocalEndpoint(ep.base_url) ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
});
// Partition rows into Local vs API for the split sections.
// Subsections without any rows are hidden entirely (heading + all)
// so empty groups don't take up vertical real estate.
const _renderInto = (container, indices) => {
if (!container) return;
const section = container.closest('.adm-ep-section');
if (!indices.length) {
if (section) section.style.display = 'none';
container.innerHTML = '';
return;
}
if (section) section.style.display = '';
container.innerHTML = indices.map(i => rowHtml[i]).join('');
};
const localIdx = [], apiIdx = [];
data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i));
// Sort each section: enabled endpoints first, disabled at the bottom.
// Preserve original order within each group via stable sort.
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
localIdx.sort(_sortByEnabled);
apiIdx.sort(_sortByEnabled);
_renderInto(listLocal, localIdx);
_renderInto(listApi, apiIdx);
if (listLegacy) listLegacy.innerHTML = rowHtml.join('');
// Iterate matching nodes across both containers.
const queryAll = (sel) => {
const out = [];
[listLocal, listApi, listLegacy].forEach(c => {
if (c) c.querySelectorAll(sel).forEach(n => out.push(n));
});
return out;
};
queryAll('[data-adm-toggle-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); });
});
queryAll('[data-adm-copy-url]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const url = btn.dataset.admCopyUrl || '';
if (!url) return;
uiModule.copyToClipboard(url).then(() => {
// Brief icon swap to a checkmark so the user gets feedback that
// the copy actually happened. Reverts after ~1.4s.
const prev = btn.innerHTML;
btn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
btn.style.opacity = '1';
setTimeout(() => { btn.innerHTML = prev; btn.style.opacity = ''; }, 1400);
}).catch(() => {});
});
});
queryAll('[data-adm-del-ep]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
var epId = btn.dataset.admDelEp;
var isOffline = btn.dataset.admEpOnline === '0';
// Offline endpoints are already broken — skip the confirm dialog
// entirely and delete immediately. The optimistic UI removal makes
// the action feel instant.
if (!isOffline) {
var deps = [];
try {
var depRes = await fetch('/api/model-endpoints/' + epId + '/dependents', { credentials: 'same-origin' });
var depData = await depRes.json();
deps = depData.dependents || [];
} catch (e) { /* proceed without warning */ }
var msg = 'Delete this endpoint?';
if (deps.length) {
msg += '\n\nThe following settings use this endpoint and will be reset:\n— ' + deps.join('\n— ');
}
if (!await uiModule.styledConfirm(msg, { confirmText: 'Delete', danger: true })) return;
}
// Optimistic: remove from UI immediately
const row = btn.closest('[data-adm-ep-id]');
if (row) row.remove();
fetch('/api/model-endpoints/' + epId, { method: 'DELETE' })
.then(() => _refreshAfterEndpointChange(epId))
.then(() => loadEndpoints())
.catch(() => loadEndpoints());
});
});
// Clear the just-added marker now that the row has been rendered
// with the animation class — keeps the glow from re-firing on every
// subsequent loadEndpoints() call (e.g. when toggling a model).
if (_recentlyAddedEpId) _recentlyAddedEpId = null;
// Models expand/collapse (click anywhere on card)
queryAll('[data-adm-ep-id]').forEach(row => {
const header = row.querySelector('[data-adm-ep-header]');
if (!header) return;
let _modelsLoaded = false;
row.style.cursor = 'pointer';
row.addEventListener('click', async (e) => {
// Don't let interactions inside the expanded panel re-fire the
// expand/collapse handler — the search box was getting closed
// because clicking it bubbled up to here.
if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, label')) return;
const epId = header.dataset.admEpHeader;
const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`);
if (!panel) return;
panel.classList.toggle('hidden');
const chevron = row.querySelector('.admin-user-chevron');
const isOpen = !panel.classList.contains('hidden');
if (chevron) {
chevron.style.transform = isOpen ? 'rotate(180deg)' : '';
chevron.style.opacity = isOpen ? '0.7' : '0.3';
}
if (!_modelsLoaded && isOpen) {
_modelsLoaded = true;
// Our shared whirlpool spinner (consistent with the rest of the app).
panel.innerHTML = '';
let _modelsSpin = null;
const _ld = document.createElement('span');
_ld.style.cssText = 'opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;';
_ld.appendChild(document.createTextNode('Loading models…'));
try {
const _sp = (await import('./spinner.js')).default;
_modelsSpin = _sp.createWhirlpool(14);
_modelsSpin.element.style.cssText = 'width:14px;height:14px;margin:0;display:inline-block;';
_ld.appendChild(_modelsSpin.element);
} catch (_) {}
panel.appendChild(_ld);
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
try {
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
const models = await res.json();
_stopSpin();
const sortedModels = sortModelObjects(models);
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
const showSearch = sortedModels.length >= 8;
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-select-all="${epId}">All</a>
<a href="#" data-ep-select-none="${epId}">None</a>
</span>
</div>${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';
});
};
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = true;
});
_saveEpModelState(epId, panel);
});
panel.querySelector(`[data-ep-select-none="${epId}"]`)?.addEventListener('click', (e) => {
e.preventDefault();
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = false;
});
_saveEpModelState(epId, panel);
});
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
});
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
}
});
});
} catch (e) {
const err = '<div class="admin-error">Failed to load</div>';
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
}
}
async function _saveEpModelState(epId, panel) {
const hidden = [];
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
if (!cb.checked) hidden.push(cb.dataset.epModelId);
});
const total = panel.querySelectorAll('input[type=checkbox]').length;
try {
await fetch(`/api/model-endpoints/${epId}/models`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ hidden }),
});
const countLabel = panel.querySelector('.mcp-tools-count');
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
const row = panel.closest('[data-adm-ep-id]');
if (row) {
const badge = row.querySelector('.admin-badge');
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
}
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
settingsModule.refreshAiModelEndpoints();
}
} catch (e) { /* silent */ }
}
function initEndpointForm() {
const provider = el('adm-epProvider');
const urlInput = el('adm-epUrl');
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
// so the rest of this function (which reads provider.value and dispatches
// change events) keeps working unchanged.
const picker = el('adm-provider-picker');
const pickerBtn = el('adm-provider-btn');
const pickerMenu = el('adm-provider-menu');
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
function _renderPickerMenu() {
if (!pickerMenu) return;
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
const logo = o.dataset.logo ? (providerLogo(o.dataset.logo) || '') : '';
const active = o.value === provider.value ? ' active' : '';
return `<div class="adm-provider-item${active}" role="option" data-value="${o.value.replace(/"/g, '&quot;')}">
<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 = '';
});
urlInput.addEventListener('input', () => {
if (provider.value && urlInput.value.trim() !== provider.value) {
provider.value = '';
_renderPickerMenu();
_syncPickerCurrent();
}
});
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);
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);
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 (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);
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('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;