2136 lines
110 KiB
JavaScript
2136 lines
110 KiB
JavaScript
// 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.x–100.127.x). Servers
|
||
// found via "Scan for Servers" come back as tailnet IPs, which are still
|
||
// your own machines, so group them under Local rather than API.
|
||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true;
|
||
// Single-label hostnames are LAN by convention.
|
||
if (!h.includes('.')) return true;
|
||
return false;
|
||
} catch { return false; }
|
||
}
|
||
|
||
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();
|
||
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' : '';
|
||
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
|
||
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
|
||
return `
|
||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
||
<span class="admin-user-name">${esc(ep.name)}</span>
|
||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||
${statusBadge}
|
||
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
||
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
||
</div>
|
||
<div style="display:flex;gap:4px;align-items:center;">
|
||
<button class="admin-btn-sm" data-adm-toggle-ep="${ep.id}">${ep.is_enabled ? 'Disable' : 'Enable'}</button>
|
||
<button class="admin-btn-delete" data-adm-del-ep="${ep.id}" data-adm-ep-online="${ep.online ? '1' : '0'}">Delete</button>
|
||
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
|
||
</div>
|
||
</div>
|
||
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
|
||
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
|
||
</div>`;
|
||
});
|
||
// 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) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? 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 (_) {} };
|
||
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
||
const renderModels = (models, warning = '') => {
|
||
const sortedModels = sortModelObjects(models);
|
||
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
|
||
const attachRefresh = () => {
|
||
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
|
||
e.preventDefault();
|
||
panel.innerHTML = _loadingHtml('Refreshing models...');
|
||
try {
|
||
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
|
||
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const refreshedModels = await res.json();
|
||
renderModels(refreshedModels, refreshWarning);
|
||
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
|
||
} catch (_) {
|
||
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
|
||
}
|
||
});
|
||
};
|
||
if (!sortedModels.length) {
|
||
panel.innerHTML = `<div class="mcp-tools-header">
|
||
<span>Models</span>
|
||
<span style="display:flex;gap:8px;align-items:center;">
|
||
<span class="mcp-tools-count">0/0 enabled</span>
|
||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||
</span>
|
||
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
|
||
attachRefresh();
|
||
return;
|
||
}
|
||
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||
const showSearch = sortedModels.length >= 8;
|
||
panel.innerHTML = `<div class="mcp-tools-header">
|
||
<span>Models</span>
|
||
<span style="display:flex;gap:8px;align-items:center;">
|
||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||
<a href="#" data-ep-select-all="${epId}">All</a>
|
||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||
</span>
|
||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||
<span>${esc(m.display)}</span>
|
||
</label>`
|
||
).join('') + '</div>';
|
||
const filterRows = (q) => {
|
||
const needle = q.trim().toLowerCase();
|
||
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
|
||
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
|
||
});
|
||
};
|
||
attachRefresh();
|
||
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
|
||
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
|
||
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = true;
|
||
});
|
||
_saveEpModelState(epId, panel);
|
||
});
|
||
panel.querySelector(`[data-ep-select-none="${epId}"]`)?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
panel.querySelectorAll('[data-ep-model-row]').forEach(row => {
|
||
if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = false;
|
||
});
|
||
_saveEpModelState(epId, panel);
|
||
});
|
||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
|
||
});
|
||
};
|
||
try {
|
||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const models = await res.json();
|
||
_stopSpin();
|
||
renderModels(models);
|
||
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
|
||
}
|
||
});
|
||
});
|
||
} catch (e) {
|
||
const err = '<div class="admin-error">Failed to load</div>';
|
||
[listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; });
|
||
}
|
||
}
|
||
|
||
async function _saveEpModelState(epId, panel) {
|
||
const hidden = [];
|
||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||
if (!cb.checked) hidden.push(cb.dataset.epModelId);
|
||
});
|
||
const total = panel.querySelectorAll('input[type=checkbox]').length;
|
||
try {
|
||
await fetch(`/api/model-endpoints/${epId}/models`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({ hidden }),
|
||
});
|
||
const countLabel = panel.querySelector('.mcp-tools-count');
|
||
if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`;
|
||
const row = panel.closest('[data-adm-ep-id]');
|
||
if (row) {
|
||
const badge = row.querySelector('.admin-badge');
|
||
if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`;
|
||
}
|
||
if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') {
|
||
settingsModule.refreshAiModelEndpoints();
|
||
}
|
||
} catch (e) { /* silent */ }
|
||
}
|
||
|
||
function initEndpointForm() {
|
||
const provider = el('adm-epProvider');
|
||
const urlInput = el('adm-epUrl');
|
||
const kindSel = el('adm-epKind');
|
||
|
||
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
|
||
// so the rest of this function (which reads provider.value and dispatches
|
||
// change events) keeps working unchanged.
|
||
const picker = el('adm-provider-picker');
|
||
const pickerBtn = el('adm-provider-btn');
|
||
const pickerMenu = el('adm-provider-menu');
|
||
const pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null;
|
||
function _renderPickerMenu() {
|
||
if (!pickerMenu) return;
|
||
pickerMenu.innerHTML = Array.from(provider.options).map(o => {
|
||
const logo = o.dataset.logo ? (providerLogo(o.dataset.logo) || '') : '';
|
||
const active = o.value === provider.value ? ' active' : '';
|
||
return `<div class="adm-provider-item${active}" role="option" data-value="${o.value.replace(/"/g, '"')}">
|
||
<span class="adm-provider-logo">${logo}</span>
|
||
<span>${o.textContent}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
function _syncPickerCurrent() {
|
||
if (!pickerCurrent) return;
|
||
const opt = provider.selectedOptions[0] || provider.options[0];
|
||
const logo = opt.dataset.logo ? (providerLogo(opt.dataset.logo) || '') : '';
|
||
pickerCurrent.querySelector('.adm-provider-logo').innerHTML = logo;
|
||
pickerCurrent.querySelector('.adm-provider-name').textContent = opt.textContent;
|
||
}
|
||
if (picker && pickerBtn && pickerMenu && pickerCurrent) {
|
||
_renderPickerMenu();
|
||
_syncPickerCurrent();
|
||
if (provider.value && !urlInput.value) urlInput.value = provider.value;
|
||
pickerBtn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
pickerMenu.classList.toggle('hidden');
|
||
});
|
||
pickerMenu.addEventListener('click', (e) => {
|
||
const item = e.target.closest('.adm-provider-item');
|
||
if (!item) return;
|
||
provider.value = item.dataset.value;
|
||
provider.dispatchEvent(new Event('change', { bubbles: true }));
|
||
pickerMenu.classList.add('hidden');
|
||
_renderPickerMenu();
|
||
_syncPickerCurrent();
|
||
});
|
||
document.addEventListener('click', (e) => {
|
||
if (!picker.contains(e.target)) pickerMenu.classList.add('hidden');
|
||
});
|
||
}
|
||
|
||
provider.addEventListener('change', () => {
|
||
if (provider.value) urlInput.value = provider.value;
|
||
else urlInput.value = '';
|
||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||
});
|
||
urlInput.addEventListener('input', () => {
|
||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||
provider.value = '';
|
||
if (kindSel) kindSel.value = 'proxy';
|
||
_renderPickerMenu();
|
||
_syncPickerCurrent();
|
||
}
|
||
});
|
||
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
|
||
function _apiEndpointKind() {
|
||
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
|
||
}
|
||
function _normalizeBaseUrl(raw) {
|
||
let u = raw.trim();
|
||
// Fix common protocol typos
|
||
u = u.replace(/^https?:\/(?!\/)/, m => m + '/'); // https:/ → https://
|
||
u = u.replace(/^htp:/, 'http:').replace(/^htps:/, 'https:');
|
||
u = u.replace(/^http:\/\/\//, 'http://'); // http:/// → http://
|
||
u = u.replace(/^https:\/\/\//, 'https://');
|
||
// Add http:// if no protocol
|
||
if (!/^https?:\/\//.test(u)) u = 'http://' + u;
|
||
// Strip trailing slashes
|
||
u = u.replace(/\/+$/, '');
|
||
// Strip trailing paths that shouldn't be in a base URL
|
||
u = u.replace(/\/v1\/(models|chat\/completions|completions|messages)\/?$/i, '/v1');
|
||
u = u.replace(/\/(models|chat\/completions|completions|v1\/messages)\/?$/i, '');
|
||
u = u.replace(/\/api\/(chat|tags|generate)\/?$/i, '/api');
|
||
// Fix double /v1/v1
|
||
u = u.replace(/\/v1\/v1$/, '/v1');
|
||
// Strip query params and fragments
|
||
u = u.split('?')[0].split('#')[0];
|
||
try {
|
||
const parsed = new URL(u);
|
||
if (parsed.hostname.endsWith('ollama.com')) {
|
||
u = 'https://ollama.com/api';
|
||
}
|
||
} catch(e) {}
|
||
// Ensure /v1 suffix for bare host:port URLs (not cloud providers)
|
||
if (!u.includes('api.') && !u.includes('openrouter') && !u.includes('ollama.com') && !u.endsWith('/v1')) {
|
||
try {
|
||
const parsed = new URL(u);
|
||
if (!parsed.pathname || parsed.pathname === '/') {
|
||
u += '/v1';
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
return u;
|
||
}
|
||
|
||
async function _defaultOllamaUrl() {
|
||
try {
|
||
const res = await fetch('/api/runtime', { credentials: 'same-origin' });
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
if (data && data.ollama_base_url) return data.ollama_base_url;
|
||
}
|
||
} catch (_) {}
|
||
return 'http://127.0.0.1:11434/v1';
|
||
}
|
||
|
||
function _renderEndpointTestResult(msg, res, d) {
|
||
if (res.ok && d.status === 'empty') {
|
||
msg.textContent = 'Online — no models found';
|
||
msg.className = 'admin-success';
|
||
return;
|
||
}
|
||
if (res.ok && d.online) {
|
||
const models = d.models || [];
|
||
const preview = models.slice(0, 3).map(m => esc(String(m).split('/').pop())).join(', ');
|
||
msg.innerHTML = `Online — found ${models.length} model${models.length !== 1 ? 's' : ''}${preview ? `: ${preview}${models.length > 3 ? ', …' : ''}` : ''}`;
|
||
msg.className = 'admin-success';
|
||
return;
|
||
}
|
||
msg.textContent = (d && d.detail) || (d && d.ping_error ? `Offline — ${d.ping_error}` : 'Offline');
|
||
msg.className = 'admin-error';
|
||
}
|
||
|
||
function _endpointMsg(kind) {
|
||
return el(kind === 'local' ? 'adm-epLocalMsg' : 'adm-epApiMsg') || el('adm-epMsg');
|
||
}
|
||
|
||
let apiTestController = null;
|
||
const apiTestBtn = el('adm-epApiTestBtn');
|
||
const apiCancelTestBtn = el('adm-epApiCancelTestBtn');
|
||
if (apiTestBtn) {
|
||
apiTestBtn.addEventListener('click', async () => {
|
||
const msg = _endpointMsg('api');
|
||
msg.textContent = ''; msg.className = '';
|
||
const rawUrl = (urlInput.value || provider.value).trim();
|
||
const apiKey = el('adm-epApiKey').value.trim();
|
||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||
apiTestController = new AbortController();
|
||
apiTestBtn.disabled = true;
|
||
apiTestBtn.textContent = 'Testing...';
|
||
if (apiCancelTestBtn) apiCancelTestBtn.classList.remove('hidden');
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('base_url', url);
|
||
fd.append('endpoint_kind', _apiEndpointKind());
|
||
fd.append('model_refresh_timeout', '30');
|
||
if (apiKey) fd.append('api_key', apiKey);
|
||
const res = await fetch('/api/model-endpoints/test', {
|
||
method: 'POST',
|
||
body: fd,
|
||
credentials: 'same-origin',
|
||
signal: apiTestController.signal,
|
||
});
|
||
const d = await res.json();
|
||
_renderEndpointTestResult(msg, res, d);
|
||
} catch (e) {
|
||
if (e && e.name === 'AbortError') {
|
||
msg.textContent = 'Test canceled';
|
||
msg.className = '';
|
||
} else {
|
||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||
msg.className = 'admin-error';
|
||
}
|
||
}
|
||
apiTestController = null;
|
||
apiTestBtn.disabled = false;
|
||
apiTestBtn.textContent = 'Test';
|
||
if (apiCancelTestBtn) apiCancelTestBtn.classList.add('hidden');
|
||
});
|
||
}
|
||
if (apiCancelTestBtn) {
|
||
apiCancelTestBtn.addEventListener('click', () => {
|
||
if (apiTestController) apiTestController.abort();
|
||
});
|
||
}
|
||
|
||
el('adm-epAddBtn').addEventListener('click', async () => {
|
||
const msg = _endpointMsg('api');
|
||
msg.textContent = ''; msg.className = '';
|
||
const rawUrl = (urlInput.value || provider.value).trim();
|
||
const apiKey = el('adm-epApiKey').value.trim();
|
||
if (!rawUrl) { msg.textContent = 'Select a provider or enter a base URL'; msg.className = 'admin-error'; return; }
|
||
if (provider.value && !apiKey) { msg.textContent = 'API key is required for cloud providers'; msg.className = 'admin-error'; return; }
|
||
// Normalize URL (fix typos, add /v1, strip wrong paths)
|
||
const url = provider.value && rawUrl === provider.value ? rawUrl : _normalizeBaseUrl(rawUrl);
|
||
const btn = el('adm-epAddBtn');
|
||
btn.disabled = true; btn.textContent = 'Adding...';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('base_url', url);
|
||
const endpointKind = _apiEndpointKind();
|
||
fd.append('endpoint_kind', endpointKind);
|
||
fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto');
|
||
fd.append('model_refresh_timeout', '30');
|
||
if (apiKey) fd.append('api_key', apiKey);
|
||
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
||
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
||
}
|
||
const epType = el('adm-epType');
|
||
if (epType) fd.append('model_type', epType.value);
|
||
if (provider.value && /openrouter\.ai|ollama\.com/i.test(provider.value)) fd.append('require_models', 'true');
|
||
else fd.append('skip_probe', 'false');
|
||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||
const d = await res.json();
|
||
if (res.ok) {
|
||
const count = d.models ? d.models.length : 0;
|
||
urlInput.value = ''; urlInput.style.display = '';
|
||
el('adm-epApiKey').value = ''; provider.value = '';
|
||
if (kindSel) kindSel.value = 'proxy';
|
||
if (epType) epType.value = 'llm';
|
||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||
await loadEndpoints();
|
||
await _selectAddedModelInChat(d);
|
||
if (!d.online) {
|
||
msg.textContent = 'Added (endpoint offline — will retry on next load)';
|
||
msg.className = 'admin-error';
|
||
} else if (d.status === 'empty') {
|
||
msg.textContent = 'Added — endpoint reachable, no models found';
|
||
msg.className = 'admin-success';
|
||
} else {
|
||
msg.textContent = `Added — found ${count} model${count !== 1 ? 's' : ''}`;
|
||
msg.className = 'admin-success';
|
||
}
|
||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||
btn.disabled = false; btn.textContent = 'Add';
|
||
});
|
||
|
||
// Local "Add" button — sibling form for self-hosted base URLs.
|
||
const localAddBtn = el('adm-epLocalAddBtn');
|
||
const localTestBtn = el('adm-epLocalTestBtn');
|
||
if (localTestBtn) {
|
||
localTestBtn.addEventListener('click', async () => {
|
||
const msg = _endpointMsg('local');
|
||
msg.textContent = ''; msg.className = '';
|
||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||
if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; }
|
||
const url = _normalizeBaseUrl(raw);
|
||
const keyEl = el('adm-epLocalApiKey');
|
||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||
localTestBtn.disabled = true;
|
||
localTestBtn.textContent = 'Testing...';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('base_url', url);
|
||
if (apiKey) fd.append('api_key', apiKey);
|
||
const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||
const d = await res.json();
|
||
_renderEndpointTestResult(msg, res, d);
|
||
} catch (e) {
|
||
msg.textContent = 'Test failed: ' + (e && e.message ? e.message : 'request failed');
|
||
msg.className = 'admin-error';
|
||
}
|
||
localTestBtn.disabled = false;
|
||
localTestBtn.textContent = 'Test';
|
||
});
|
||
}
|
||
if (localAddBtn) {
|
||
localAddBtn.addEventListener('click', async () => {
|
||
const msg = _endpointMsg('local');
|
||
msg.textContent = ''; msg.className = '';
|
||
const raw = (el('adm-epLocalUrl').value || '').trim();
|
||
if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; }
|
||
const url = _normalizeBaseUrl(raw);
|
||
const keyEl = el('adm-epLocalApiKey');
|
||
const apiKey = keyEl ? keyEl.value.trim() : '';
|
||
localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...';
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('base_url', url);
|
||
if (apiKey) fd.append('api_key', apiKey);
|
||
fd.append('endpoint_kind', 'local');
|
||
fd.append('model_refresh_mode', 'auto');
|
||
const lt = el('adm-epLocalType');
|
||
if (lt) fd.append('model_type', lt.value);
|
||
fd.append('skip_probe', 'false');
|
||
const res = await fetch('/api/model-endpoints', { method: 'POST', body: fd, credentials: 'same-origin' });
|
||
const d = await res.json();
|
||
if (res.ok) {
|
||
el('adm-epLocalUrl').value = '';
|
||
if (keyEl) keyEl.value = '';
|
||
if (lt) lt.value = 'llm';
|
||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||
await loadEndpoints();
|
||
await _selectAddedModelInChat(d);
|
||
const count = (d.models || []).length;
|
||
msg.textContent = d.status === 'empty'
|
||
? 'Added — Ollama is running, no models pulled yet'
|
||
: d.online
|
||
? `Added — found ${count} model${count !== 1 ? 's' : ''}`
|
||
: 'Added (offline — will retry on next load)';
|
||
msg.className = d.online ? 'admin-success' : 'admin-error';
|
||
} else { msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; }
|
||
} catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; }
|
||
localAddBtn.disabled = false; localAddBtn.textContent = 'Add';
|
||
});
|
||
}
|
||
|
||
const ollamaBtn = el('adm-epOllamaBtn');
|
||
if (ollamaBtn) {
|
||
ollamaBtn.addEventListener('click', async () => {
|
||
const input = el('adm-epLocalUrl');
|
||
if (input) {
|
||
input.value = await _defaultOllamaUrl();
|
||
input.focus();
|
||
}
|
||
const msg = _endpointMsg('local');
|
||
if (msg) {
|
||
msg.innerHTML = '<span style="font-size:11px;opacity:0.55;">Ollama ready to test.</span>';
|
||
msg.className = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Discover local models button
|
||
const discoverBtn = el('adm-epDiscoverBtn');
|
||
if (discoverBtn) {
|
||
discoverBtn.addEventListener('click', async () => {
|
||
const msg = _endpointMsg('local');
|
||
discoverBtn.disabled = true;
|
||
// Keep the button's icon as-is while scanning; the whirlpool +
|
||
// status text below is enough feedback. (Two spinning indicators
|
||
// at once looks busy.)
|
||
msg.className = '';
|
||
msg.innerHTML = '';
|
||
try {
|
||
const sp = window.spinnerModule || (await import('./spinner.js')).default;
|
||
const wp = sp.createWhirlpool(20);
|
||
wp.element.style.cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;';
|
||
const wrap = document.createElement('div');
|
||
wrap.style.cssText = 'display:flex;align-items:center;padding:8px 0;';
|
||
wrap.appendChild(wp.element);
|
||
const txt = document.createElement('span');
|
||
txt.textContent = 'Scanning ports 8000-8020 and 11434 for model servers...';
|
||
txt.style.cssText = 'font-size:12px;opacity:0.7;';
|
||
wrap.appendChild(txt);
|
||
msg.appendChild(wrap);
|
||
discoverBtn._wp = wp;
|
||
} catch(e) { msg.textContent = 'Scanning...'; }
|
||
try {
|
||
const res = await fetch('/api/discover');
|
||
const data = await res.json();
|
||
const items = data.items || [];
|
||
if (!items.length) {
|
||
msg.textContent = 'No model servers found. Make sure vLLM, llama.cpp, SGLang, or Ollama is running. Docker users may need Ollama bound to a trusted reachable interface.';
|
||
msg.className = 'admin-error';
|
||
} else {
|
||
// Auto-add each discovered endpoint. Server dedupes on base_url
|
||
// and returns `existing: true` for already-registered ones.
|
||
let added = 0;
|
||
let skipped = 0;
|
||
for (const item of items) {
|
||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||
const fd = new FormData();
|
||
fd.append('base_url', base);
|
||
fd.append('endpoint_kind', 'local');
|
||
fd.append('model_refresh_mode', 'auto');
|
||
fd.append('skip_probe', 'false');
|
||
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
||
if (r.ok) {
|
||
try {
|
||
const dd = await r.json();
|
||
if (dd && dd.existing) { skipped++; }
|
||
else { added++; if (dd && dd.id) _recentlyAddedEpId = String(dd.id); }
|
||
} catch (_) { added++; }
|
||
}
|
||
}
|
||
const totalModels = items.reduce((n, i) => n + (i.models ? i.models.length : 0), 0);
|
||
const parts = [`Found ${items.length} server${items.length !== 1 ? 's' : ''} with ${totalModels} model${totalModels !== 1 ? 's' : ''}`];
|
||
if (added) parts.push(`added ${added} new`);
|
||
if (skipped) parts.push(`${skipped} already added`);
|
||
msg.innerHTML = parts.join(' — ');
|
||
msg.className = 'admin-success';
|
||
loadEndpoints();
|
||
}
|
||
} catch (e) {
|
||
msg.textContent = 'Scan failed: ' + e.message;
|
||
msg.className = 'admin-error';
|
||
}
|
||
if (discoverBtn._wp) { discoverBtn._wp.destroy(); discoverBtn._wp = null; }
|
||
discoverBtn.disabled = false;
|
||
discoverBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" style="vertical-align:-1px;margin-right:4px;"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>Scan for Servers';
|
||
});
|
||
}
|
||
|
||
// Collapsible Add-Models subsections (API / Local). Both start collapsed
|
||
// so the card is compact; the last-used state is remembered per section
|
||
// in localStorage so a frequent API-adder doesn't re-expand every time.
|
||
document.querySelectorAll('#adm-add-api, #adm-add-local').forEach((sec) => {
|
||
const head = sec.querySelector('.adm-section-toggle');
|
||
if (!head) return;
|
||
const key = 'odysseus.addModels.' + sec.id + '.open';
|
||
let open = false;
|
||
try { open = localStorage.getItem(key) === '1'; } catch {}
|
||
const apply = () => {
|
||
sec.classList.toggle('collapsed', !open);
|
||
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
};
|
||
apply();
|
||
const toggle = () => {
|
||
open = !open;
|
||
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
|
||
apply();
|
||
};
|
||
head.addEventListener('click', toggle);
|
||
head.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||
});
|
||
});
|
||
document.querySelectorAll('.adm-quickstart-section').forEach((sec) => {
|
||
const head = sec.querySelector('.adm-quickstart-toggle');
|
||
if (!head) return;
|
||
const key = 'odysseus.addModels.' + sec.id + '.open';
|
||
let open = false;
|
||
try { open = localStorage.getItem(key) === '1'; } catch {}
|
||
const apply = () => {
|
||
sec.classList.toggle('collapsed', !open);
|
||
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
};
|
||
apply();
|
||
const toggle = () => {
|
||
open = !open;
|
||
try { localStorage.setItem(key, open ? '1' : '0'); } catch {}
|
||
apply();
|
||
};
|
||
head.addEventListener('click', toggle);
|
||
head.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); }
|
||
});
|
||
});
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════
|
||
TOOLS TAB — MCP
|
||
═══════════════════════════════════════════ */
|
||
|
||
const _GOOGLE_OAUTH_HELP = `To get Google OAuth credentials:
|
||
1. Go to console.cloud.google.com
|
||
2. Click the project dropdown (top left) > New Project > name it > Create
|
||
3. APIs & Services > Library > enable the API you need (Gmail, Calendar, Drive, etc.)
|
||
4. APIs & Services > OAuth consent screen > configure (External, app name + email)
|
||
5. Under Audience, click Add Users > add your Google email as a test user
|
||
6. APIs & Services > Credentials > + Create Credentials > OAuth Client ID > Desktop App
|
||
7. Copy the Client ID and Client Secret into the fields above
|
||
8. After adding the server, click Authorize to sign in with Google
|
||
9. If accessing remotely: sign in, then copy the URL from the error page and paste it back`;
|
||
|
||
const MCP_PRESETS = [
|
||
{ name: "Gmail", command: "npx", args: ["-y", "@gongrzhe/server-gmail-autoauth-mcp"], env: { GOOGLE_CLIENT_ID: "", GOOGLE_CLIENT_SECRET: "" },
|
||
oauthFile: { dir: "gmail", filename: "gcp-oauth.keys.json" },
|
||
oauth: {
|
||
provider: "google",
|
||
keys_file: "gmail/gcp-oauth.keys.json",
|
||
token_file: "gmail/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;
|