import { initImageSettings } from './admin/imageSettings.js'; import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js'; // ============================================================ // ADMIN.JS — Admin panel: users, settings, stats // ============================================================ function adminEscapeHtml(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); } function adminTableMessage(colspan, color, text) { return '' + adminEscapeHtml(text) + ''; } function adminSetButtonText(btn, text, disabled) { if (!btn) return; btn.textContent = text; btn.disabled = !!disabled; } function adminSetButtonHtml(btn, html, disabled) { if (!btn) return; btn.innerHTML = html; btn.disabled = !!disabled; } function adminFlashButtonBackground(btn, color) { if (!btn) return; btn.style.background = color || ''; setTimeout(function() { if (btn) btn.style.background = ''; }, 2000); } // True when the admin tab is already active AND its component markup is loaded, // i.e. tabChanged for admin has already fired (or will never fire again). function adminTabActive() { var tab = document.getElementById('admin-tab'); return !!tab && tab.classList.contains('active') && tab.dataset.loaded === '1'; } { let loaded = false; // Load admin panel when admin tab is activated document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') { if (!loaded) { loadAdmin(); loaded = true; } } }); document.addEventListener('click', function(e) { // Refresh button if (e.target.closest('#btn-refresh-users')) { loadUsers(); } // Toggle registration if (e.target.closest('#btn-toggle-reg')) { toggleRegistration(); } }); function loadAdmin() { loadSettings(); loadUsers(); // loadOidcConfig() is registered in the CMS IIFE below — it's not in scope here } // ---- SETTINGS + STATS ---- function loadSettings() { fetch('/api/admin/settings', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var stats = data.stats || {}; var settings = data.settings || {}; var el = document.getElementById('stat-users'); if (el) el.textContent = stats.totalUsers !== undefined ? stats.totalUsers : '—'; el = document.getElementById('stat-api-total'); if (el) el.textContent = stats.totalApiCalls !== undefined ? stats.totalApiCalls : '—'; el = document.getElementById('stat-api-today'); if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—'; updateRegStatus(settings.registrationEnabled !== false); }) .catch(function(err) { console.error('[Admin] Settings load failed:', err); }); } function updateRegStatus(enabled) { var text = document.getElementById('reg-status-text'); var btn = document.getElementById('btn-toggle-reg'); if (text) { text.innerHTML = 'Registration is currently ' + (enabled ? '✅ Enabled' : '❌ Disabled') + ''; } if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration'; window._adminRegEnabled = enabled; } function toggleRegistration() { var newVal = !window._adminRegEnabled; fetch('/api/admin/settings/registration', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ enabled: newVal }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { updateRegStatus(newVal); showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success'); } else { showToast(data.error || 'Failed', 'error'); } }) .catch(function() { showToast('Request failed', 'error'); }); } // ---- USERS ---- let allUsers = []; function filterUsers() { var tbody = document.getElementById('admin-users-body'); var input = document.getElementById('admin-users-search'); if (!tbody) return; var query = String((input && input.value) || '').trim().toLowerCase(); if (!allUsers.length) return; tbody.innerHTML = allUsers.filter(function(u) { return !query || (String(u.name || '').toLowerCase().indexOf(query) !== -1) || (String(u.email || '').toLowerCase().indexOf(query) !== -1); }).map(renderUserRow).join(''); bindUserActions(tbody); } function loadUsers() { var tbody = document.getElementById('admin-users-body'); if (!tbody) return; tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'Loading...'); fetch('/api/admin/users', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; } allUsers = data.users || []; filterUsers(); }) .catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); }); } document.addEventListener('input', function(e) { if (e.target && e.target.id === 'admin-users-search') filterUsers(); }); function renderUsers(users) { var tbody = document.getElementById('admin-users-body'); if (!tbody) return; var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}'); if (users.length === 0) { tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found'); return; } tbody.innerHTML = users.map(function(u) { return renderUserRow(u, currentUser); }).join(''); bindUserActions(tbody); } function bindUserActions(tbody) { tbody.querySelectorAll('.admin-action').forEach(function(btn) { btn.addEventListener('click', function() { handleUserAction(btn.dataset.action, btn.dataset.id, btn.dataset.email, btn.dataset.name); }); }); } function renderUserRow(u, currentUser) { currentUser = currentUser || JSON.parse(localStorage.getItem('ped_scribe_user') || '{}'); var isSelf = currentUser.id && u.id === currentUser.id; var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)'; var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)'); var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified'); var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '\u2014'; var actions = []; if (!isSelf) { if (!u.email_verified) { actions.push(''); } if (u.disabled) { actions.push(''); } else { actions.push(''); } if (u.role === 'admin') { actions.push(''); } else { actions.push(''); } if (u.role !== 'moderator') { actions.push(''); } if (u.role === 'moderator') { actions.push(''); } actions.push(''); actions.push(''); } else { actions.push('(you)'); } return '' + '
' + esc(u.name) + '
' + esc(u.email) + '
' + '' + (u.role || 'user') + '' + '' + statusText + '' + '' + joined + '' + '' + actions.join(' ') + '' + ''; } function handleUserAction(action, userId, email, name) { switch (action) { case 'verify': showConfirm('Verify email for ' + email + '?', function() { adminPost('/api/admin/users/' + userId + '/verify', {}, function() { showToast(email + ' verified', 'success'); loadUsers(); }); }); break; case 'disable': showConfirm('Disable account for ' + email + '?', function() { adminPost('/api/admin/users/' + userId + '/disable', {}, function() { showToast(email + ' disabled', 'info'); loadUsers(); }); }, { danger: true, confirmText: 'Disable' }); break; case 'enable': adminPost('/api/admin/users/' + userId + '/enable', {}, function() { showToast(email + ' enabled', 'success'); loadUsers(); }); break; case 'promote': showConfirm('Grant admin role to ' + email + '?', function() { adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() { showToast(email + ' is now admin', 'success'); loadUsers(); }); }); break; case 'demote': showConfirm('Remove admin role from ' + email + '?', function() { adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() { showToast(email + ' demoted to user', 'info'); loadUsers(); }); }); break; case 'set-moderator': showConfirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.', function() { adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() { showToast(email + ' is now a moderator', 'success'); loadUsers(); }); }); break; case 'set-user': showConfirm('Set ' + email + ' to regular user role?', function() { adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() { showToast(email + ' is now a user', 'success'); loadUsers(); }); }); break; case 'reset-pw': showConfirm('New password for ' + email + ' (8+ characters):', function(val) { if (val.length < 8) { showToast('Password must be 8+ characters', 'error'); return; } adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: val }, function() { showToast('Password reset for ' + email, 'success'); }); }, { input: true, inputType: 'password', placeholder: 'New password', required: true, requiredMsg: 'Enter a password', confirmText: 'Reset Password' }); break; case 'delete': showConfirm('Permanently delete user ' + (name || email) + '? This cannot be undone.', function() { fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); } else showToast(data.error || 'Delete failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); }, { danger: true, confirmText: 'Delete' }); break; } } function adminPost(url, body, onSuccess) { fetch(url, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { onSuccess(data); } else showToast(data.error || 'Request failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); } const esc = adminEscapeHtml; } // ============================================================ // ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts // ============================================================ { let cmsLoaded = false; let promptsLoaded = false; let promptsLoading = false; function startCms() { if (!cmsLoaded) { loadCms(); loadOidcConfig(); cmsLoaded = true; } } // Load CMS when admin tab is opened (via tabChanged event or click) document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') startCms(); }); // Catch-up: when the admin tab is already active and loaded at module init // (e.g. restored tab before this module evaluated), tabChanged may never fire again. if (adminTabActive()) startCms(); document.addEventListener('click', function(e) { // Save buttons if (e.target.closest('#btn-save-announcement')) saveAnnouncement(); if (e.target.closest('#btn-save-flags')) saveFlags(); if (e.target.closest('#btn-save-email')) saveEmail(); if (e.target.closest('#btn-test-email')) sendTestEmail(); if (e.target.closest('#btn-save-smtp')) saveSmtp(); if (e.target.closest('#btn-clear-smtp')) clearSmtp(); if (e.target.closest('#btn-save-auto-delete')) saveAutoDelete(); if (e.target.closest('#btn-reset-all-defaults')) resetAllDefaults(); }); // When email template selector changes, repopulate fields document.addEventListener('change', function(e) { if (e.target.id === 'cms-email-template') loadEmailFields(e.target.value); }); // ---- LOAD ALL CONFIG ---- function loadCms() { fetch('/api/admin/config', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var cfg = {}; (data.config || []).forEach(function(row) { cfg[row.key] = row.value; }); // Announcement var annEnabled = document.getElementById('cms-ann-enabled'); var annType = document.getElementById('cms-ann-type'); var annText = document.getElementById('cms-ann-text'); if (annEnabled) annEnabled.value = cfg['announcement.enabled'] || 'false'; if (annType) annType.value = cfg['announcement.type'] || 'info'; if (annText) annText.value = cfg['announcement.text'] || ''; // Feature flags var flagReadAloud = document.getElementById('cms-flag-read-aloud'); var flagNextcloud = document.getElementById('cms-flag-nextcloud'); if (flagReadAloud) flagReadAloud.value = cfg['feature.read_aloud'] !== undefined ? cfg['feature.read_aloud'] : 'true'; if (flagNextcloud) flagNextcloud.value = cfg['feature.nextcloud'] !== undefined ? cfg['feature.nextcloud'] : 'true'; // Auto-delete setting var autoDeleteDays = cfg['site.auto_delete_days'] || '7'; var el = document.getElementById('cms-auto-delete-days'); if (el) el.value = autoDeleteDays; el = document.getElementById('admin-auto-delete-days'); if (el) el.textContent = autoDeleteDays; // Email — load verify template by default window._adminEmailConfig = cfg; loadEmailFields('verify'); }) .catch(function(err) { console.error('[AdminCMS] Config load failed:', err); }); loadPromptList(); loadSmtp(); } // ---- ANNOUNCEMENT ---- function saveAnnouncement() { var enabled = document.getElementById('cms-ann-enabled').value; var type = document.getElementById('cms-ann-type').value; var text = document.getElementById('cms-ann-text').value; Promise.all([ putConfig('announcement.enabled', enabled), putConfig('announcement.type', type), putConfig('announcement.text', text) ]).then(function() { showToast('Announcement saved', 'success'); if (typeof loadAnnouncement === 'function') loadAnnouncement(); }).catch(function() { showToast('Save failed', 'error'); }); } // ---- FEATURE FLAGS ---- function saveFlags() { var readAloud = document.getElementById('cms-flag-read-aloud').value; var nextcloud = document.getElementById('cms-flag-nextcloud').value; Promise.all([ putConfig('feature.read_aloud', readAloud), putConfig('feature.nextcloud', nextcloud) ]).then(function() { showToast('Feature flags saved', 'success'); }).catch(function() { showToast('Save failed', 'error'); }); } // ---- EMAIL TEMPLATES ---- function loadEmailFields(template) { var cfg = window._adminEmailConfig || {}; var subject = document.getElementById('cms-email-subject'); var body = document.getElementById('cms-email-body'); if (subject) subject.value = cfg['email.' + template + '.subject'] || ''; if (body) body.value = cfg['email.' + template + '.body'] || ''; } function saveEmail() { var template = document.getElementById('cms-email-template').value; var subject = document.getElementById('cms-email-subject').value; var body = document.getElementById('cms-email-body').value; Promise.all([ putConfig('email.' + template + '.subject', subject), putConfig('email.' + template + '.body', body) ]).then(function() { // Update local cache if (!window._adminEmailConfig) window._adminEmailConfig = {}; window._adminEmailConfig['email.' + template + '.subject'] = subject; window._adminEmailConfig['email.' + template + '.body'] = body; showToast('Email template saved', 'success'); }).catch(function() { showToast('Save failed', 'error'); }); } function sendTestEmail() { var template = document.getElementById('cms-email-template').value; var sendBtn = document.getElementById('btn-test-email'); // Show inline email input instead of prompt() var existing = document.getElementById('admin-test-email-row'); if (existing) { existing.remove(); return; } var row = document.createElement('div'); row.id = 'admin-test-email-row'; row.style.cssText = 'margin-top:8px;display:flex;gap:6px;align-items:center;'; row.innerHTML = '' + '' + ''; if (sendBtn) sendBtn.parentElement.appendChild(row); document.getElementById('admin-test-email-input').focus(); document.getElementById('admin-test-email-submit').onclick = function() { var to = document.getElementById('admin-test-email-input').value.trim(); if (!to) { showToast('Enter an email address', 'error'); return; } fetch('/api/admin/config/test-email', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ to: to, template: template }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('Test email sent to ' + to, 'success'); row.remove(); } else showToast(data.error || 'Send failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); }; document.getElementById('admin-test-email-cancel').onclick = function() { row.remove(); }; } // ---- AI PROMPTS ---- // ---- OIDC / SSO CONFIG ---- function loadOidcConfig() { // Set callback URL display var callbackEl = document.getElementById('oidc-callback-url'); if (callbackEl) callbackEl.textContent = window.location.origin + '/api/auth/oidc/callback'; fetch('/api/auth/oidc/config', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var c = data.config || {}; var el; el = document.getElementById('oidc-enabled'); if (el) el.value = c['oidc.enabled'] || 'false'; el = document.getElementById('oidc-issuer'); if (el) el.value = c['oidc.issuer'] || ''; el = document.getElementById('oidc-client-id'); if (el) el.value = c['oidc.client_id'] || ''; el = document.getElementById('oidc-client-secret'); if (el) el.placeholder = c['oidc.client_secret'] ? c['oidc.client_secret'] : 'Enter client secret'; el = document.getElementById('oidc-button-label'); if (el) el.value = c['oidc.button_label'] || ''; el = document.getElementById('oidc-disable-local'); if (el) el.value = c['oidc.disable_local_auth'] || 'false'; }) .catch(function() {}); } document.addEventListener('click', function(e) { if (e.target.id === 'btn-save-oidc' || e.target.closest('#btn-save-oidc')) { e.preventDefault(); var status = document.getElementById('oidc-save-status'); if (status) { status.textContent = 'Saving...'; status.style.color = 'var(--g500)'; } var body = { 'oidc.enabled': document.getElementById('oidc-enabled').value, 'oidc.issuer': document.getElementById('oidc-issuer').value.trim(), 'oidc.client_id': document.getElementById('oidc-client-id').value.trim(), 'oidc.button_label': document.getElementById('oidc-button-label').value.trim(), 'oidc.disable_local_auth': document.getElementById('oidc-disable-local').value }; // Only send secret if user typed a new one var secretEl = document.getElementById('oidc-client-secret'); if (secretEl && secretEl.value) body['oidc.client_secret'] = secretEl.value; fetch('/api/auth/oidc/config', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('OIDC settings saved', 'success'); if (status) status.textContent = ''; if (secretEl) secretEl.value = ''; loadOidcConfig(); } else { showToast(data.error || 'Save failed', 'error'); if (status) status.textContent = ''; } }) .catch(function() { showToast('Save failed', 'error'); if (status) status.textContent = ''; }); } }); async function promptRequest(url, method, body) { var response = await fetch(url, { headers: getAuthHeaders(), method: method || 'GET', body: body === undefined ? undefined : JSON.stringify(body) }); var data = await response.json(); if (response.status === 409) throw new Error('Conflict: this prompt changed elsewhere. Your draft is unchanged. Open History, view the current revision, then explicitly use it as your save baseline.'); if (!response.ok || !data.success) throw new Error(data.error || 'Prompt request failed'); return data; } async function loadPromptList() { // General settings reloads must not replace any prompt drafts. if (promptsLoaded || promptsLoading) return; promptsLoading = true; var groups = [ ['scribe', document.getElementById('cms-scribe-prompts'), ['scribe']], ['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']], ['learning', document.getElementById('cms-learning-prompts'), ['learning-image']] ]; try { var data = await promptRequest('/api/admin/config/prompts'); groups.forEach(function(group) { if (!group[1]) return; group[1].replaceChildren(); var prompts = (data.prompts || []).filter(function(p) { return p.editable === true && group[2].indexOf(p.family) !== -1; }); if (!prompts.length) { group[1].textContent = 'No editable prompts available in this section.'; return; } group[1].appendChild(createPromptFamilyEditor(group[0], prompts)); }); promptsLoaded = true; } catch (error) { groups.forEach(function(group) { if (!group[1]) return; group[1].textContent = 'Could not load prompts: ' + error.message + ' '; var retry = document.createElement('button'); retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts'; retry.onclick = loadPromptList; group[1].appendChild(retry); }); } finally { promptsLoading = false; } } // Simple prompt editor: pick a prompt, edit it, save it, or restore the // shipped default. Revision tracking stays internal only so the server // optimistic concurrency contract keeps working; no history UI is shown. function createPromptFamilyEditor(family, prompts) { var editor = document.createElement('div'); editor.className = 'cms-prompt-family'; editor.dataset.family = family; editor.style.cssText = 'display:flex;flex-direction:column;gap:10px;'; // Only static markup is parsed; all catalogue content is assigned as text. editor.innerHTML = '' + '' + '
' + '' + '
' + '

'; var select = editor.querySelector('.prompt-select'); var text = editor.querySelector('.prompt-draft'); var status = editor.querySelector('.prompt-status'); var buttons = {}; editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; }); // Per-key state: server value, revision baseline (internal only) and live draft. var states = new Map(); prompts.forEach(function(p) { var option = document.createElement('option'); option.value = p.dbKey; option.textContent = p.key; select.appendChild(option); states.set(p.dbKey, { value: p.value, revision: p.revision, draft: p.value }); }); var key = select.options.length ? select.options[0].value : null; var busy = false; if (key) text.value = states.get(key).draft; function state() { return key ? states.get(key) : null; } function controls() { buttons.save.disabled = busy; buttons.reset.disabled = busy; select.disabled = busy; } async function run(task) { if (busy) return; busy = true; controls(); status.textContent = 'Working...'; try { await task(); } catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; } finally { busy = false; controls(); } } async function mutate(action) { var targetKey = key; var st = states.get(targetKey); var submitted = text.value; st.draft = submitted; var body = { expectedRevision: st.revision }; if (action === 'save') body.value = submitted; var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : '/api/admin/config/prompts/' + encodeURIComponent(targetKey) + '/reset', action === 'save' ? 'PUT' : 'POST', body); st.revision = data.revision; if (text.value === submitted) text.value = data.value; status.textContent = action === 'save' ? 'Saved.' : 'Restored shipped default.'; } select.addEventListener('change', function() { if (key) states.get(key).draft = text.value; key = select.value; text.value = states.get(key).draft; status.textContent = ''; }); buttons.save.addEventListener('click', function() { run(function() { return mutate('save'); }); }); buttons.reset.addEventListener('click', function() { showConfirm('Restore only this prompt to its shipped default? This replaces the current value.', function() { run(function() { return mutate('reset'); }); }); }); controls(); return editor; } // ---- SMTP ---- function loadSmtp() { fetch('/api/admin/config/smtp/status', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var badge = document.getElementById('smtp-source-badge'); if (badge) { if (data.source === 'env') badge.textContent = 'Configured via env'; else if (data.source === 'database') badge.textContent = 'Configured in DB'; else { badge.textContent = 'Not configured'; badge.style.background = 'var(--red-light)'; badge.style.color = 'var(--red)'; } } var h = document.getElementById('cms-smtp-host'); if (h) h.value = data.host || ''; var p = document.getElementById('cms-smtp-port'); if (p) p.value = data.port || '587'; var u = document.getElementById('cms-smtp-user'); if (u) u.value = data.user || ''; var f = document.getElementById('cms-smtp-from'); if (f) f.value = data.from || ''; }) .catch(function() {}); } function saveSmtp() { var host = document.getElementById('cms-smtp-host').value.trim(); var port = document.getElementById('cms-smtp-port').value.trim(); var user = document.getElementById('cms-smtp-user').value.trim(); var pass = document.getElementById('cms-smtp-pass').value.trim(); var from = document.getElementById('cms-smtp-from').value.trim(); var secure = document.getElementById('cms-smtp-secure').value; if (!host) { showToast('SMTP host required', 'error'); return; } fetch('/api/admin/config/smtp', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ host: host, port: port, user: user, pass: pass, from: from, secure: secure === 'true' }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('SMTP settings saved', 'success'); loadSmtp(); } else showToast(data.error || 'Save failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); } function clearSmtp() { showConfirm('Clear DB SMTP settings? Env vars will be used if set.', function() { fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); } else showToast(data.error || 'Failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); }); } // ---- AUTO-DELETE / RESET ---- function saveAutoDelete() { var days = document.getElementById('cms-auto-delete-days').value; putConfig('site.auto_delete_days', days) .then(function() { var el = document.getElementById('admin-auto-delete-days'); if (el) el.textContent = days; showToast('Auto-delete set to ' + days + ' days', 'success'); }) .catch(function() { showToast('Save failed', 'error'); }); } function resetAllDefaults() { showConfirm('Reset all settings to factory defaults? Announcements, feature flags, email templates will be reset. SMTP and custom models are preserved.', function() { fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast(data.message || 'Reset to defaults', 'success'); cmsLoaded = false; loadCms(); } else showToast(data.error || 'Reset failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); }, { danger: true, confirmText: 'Reset' }); } // ---- HELPERS ---- function putConfig(key, value) { return fetch('/api/admin/config/' + encodeURIComponent(key), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Failed'); return data; }); } } // ============================================================ // ADMIN CLINICAL ASSISTANT SETTINGS // ============================================================ initClinicalAssistantAdmin(adminEscapeHtml); initImageSettings(); // ============================================================ // ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models // ============================================================ { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') { loadAdminModels(); } }); // Catch-up for a tab that is already active and loaded at module init. if (adminTabActive()) loadAdminModels(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-discover-models')) discoverModels(); if (e.target.closest('#btn-save-default-model')) saveDefaultModel(); if (e.target.closest('#btn-clear-all-models')) clearAllModels(); if (e.target.closest('.admin-model-test-btn')) { var btn = e.target.closest('.admin-model-test-btn'); testModel(btn.dataset.mid, btn); } }); // Allow Enter key to trigger search document.addEventListener('keydown', function(e) { if (e.target.id === 'admin-model-search' && e.key === 'Enter') { e.preventDefault(); discoverModels(); } }); const esc = adminEscapeHtml; function loadAdminModels() { fetch('/api/admin/config/models', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; // Provider badge var badge = document.getElementById('admin-model-provider-badge'); if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase(); // Default model selector — built-ins + custom models var defaultSel = document.getElementById('admin-default-model'); if (defaultSel) { defaultSel.innerHTML = ''; data.models.forEach(function(m) { if (m.enabled === false) return; var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; defaultSel.appendChild(opt); }); (data.custom || []).forEach(function(m) { if (m.enabled === false) return; var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; defaultSel.appendChild(opt); }); // Pre-select the saved default if (data.defaultModel) defaultSel.value = data.defaultModel; } // Built-in models with toggle var container = document.getElementById('admin-builtin-models'); if (container) { if (data.litellmHint) { container.innerHTML = '
' + '

LiteLLM mode: No built-in models. ' + 'Use Search API below to discover models from your proxy, then add them.

' + '
'; } else if (data.models.length === 0) { container.innerHTML = '

No built-in models for this provider.

'; } else { container.innerHTML = data.models.map(function(m) { var checked = m.enabled !== false ? 'checked' : ''; return '
' + '' + '' + '
'; }).join(''); container.querySelectorAll('.admin-model-toggle').forEach(function(cb) { cb.addEventListener('change', function() { toggleModel(cb.dataset.modelId, cb.checked); }); }); } } // Custom models list renderCustomModels(data.custom || []); }) .catch(function(err) { console.error('[AdminModels] Load failed:', err); }); } function toggleModel(modelId, enabled) { fetch('/api/admin/config/models/toggle', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ modelId: modelId, enabled: enabled }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast(modelId + ' ' + (enabled ? 'enabled' : 'disabled'), 'success'); loadAdminModels(); } else { showToast(data.error || 'Failed', 'error'); // Revert checkbox var cb = document.querySelector('.admin-model-toggle[data-model-id="' + modelId + '"]'); if (cb) cb.checked = !enabled; } }) .catch(function() { showToast('Request failed', 'error'); }); } function discoverModels() { var search = (document.getElementById('admin-model-search') || {}).value || ''; var container = document.getElementById('admin-discovered-models'); var hint = document.getElementById('admin-discover-hint'); if (!container) return; container.innerHTML = '

Querying provider API...

'; if (hint) hint.style.display = 'none'; fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { container.innerHTML = '

Error: ' + esc(data.error || 'Unknown error') + '

'; return; } if (!data.models || data.models.length === 0) { container.innerHTML = '

No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '. Try a different search term.

'; return; } container.innerHTML = '

Found ' + data.count + ' models. Click + to add to your model list.

' + data.models.slice(0, 100).map(function(m) { return '
' + '' + '' + '' + esc(m.name) + ' (' + esc(m.id) + ')' + '
'; }).join(''); container.querySelectorAll('.admin-add-discovered').forEach(function(btn) { btn.addEventListener('click', function() { addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn); }); }); }) .catch(function(err) { container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; }); } function addDiscoveredModel(id, name, btn) { fetch('/api/admin/config/models/add-discovered', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: id, name: name }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('Added: ' + name + ' — now select it as default and click Set Default', 'success'); if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; } // Refresh model lists, then auto-select the newly added model fetch('/api/admin/config/models', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(refreshed) { if (!refreshed.success) return; var defaultSel = document.getElementById('admin-default-model'); if (defaultSel) { defaultSel.innerHTML = ''; refreshed.models.forEach(function(m) { if (m.enabled === false) return; var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; defaultSel.appendChild(opt); }); (refreshed.custom || []).forEach(function(m) { if (m.enabled === false) return; var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name; defaultSel.appendChild(opt); }); // Auto-select the model just added defaultSel.value = id; } renderCustomModels(refreshed.custom || []); }); } else { showToast(data.error || 'Failed', 'error'); } }) .catch(function() { showToast('Request failed', 'error'); }); } function renderCustomModels(custom) { var container = document.getElementById('admin-custom-models-list'); if (!container) return; if (!custom || custom.length === 0) { container.innerHTML = ''; return; } container.innerHTML = '' + custom.map(function(m) { return '
' + '' + esc(m.name) + ' (' + esc(m.id) + ')' + '' + '' + '
'; }).join(''); container.querySelectorAll('.admin-delete-custom').forEach(function(btn) { btn.addEventListener('click', function() { deleteCustomModel(btn.dataset.mid); }); }); } function deleteCustomModel(modelId) { fetch('/api/admin/config/models/custom/' + encodeURIComponent(modelId), { method: 'DELETE', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) { showToast('Removed: ' + modelId, 'info'); loadAdminModels(); } else { showToast(data.error || 'Failed', 'error'); } }) .catch(function() { showToast('Request failed', 'error'); }); } function clearAllModels() { showConfirm('Remove all added models? You will need to re-add them via Search API.', function() { Promise.all([ fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) ]).then(function(results) { if (results[0].success) { showToast('All models cleared', 'info'); loadAdminModels(); } else { showToast(results[0].error || 'Failed', 'error'); } }).catch(function() { showToast('Request failed', 'error'); }); }); } function saveDefaultModel() { var sel = document.getElementById('admin-default-model'); if (!sel || !sel.value) { showToast('Select a model', 'error'); return; } fetch('/api/admin/config/models/default', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ modelId: sel.value }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.success) showToast('Default model set: ' + sel.value, 'success'); else showToast(data.error || 'Failed', 'error'); }) .catch(function() { showToast('Request failed', 'error'); }); } function testModel(modelId, btn) { if (!modelId) return; var origText = btn ? btn.textContent : 'Test'; adminSetButtonText(btn, '...', true); fetch('/api/admin/config/models/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: modelId }) }) .then(function(r) { return r.json(); }) .then(function(data) { adminSetButtonText(btn, origText, false); if (data.success) { showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success'); } else { showToast('Test failed: ' + (data.error || 'Unknown error'), 'error'); } }) .catch(function() { adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } } // ============================================================ // ADMIN TTS MANAGEMENT // ============================================================ { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadTTSConfig(); }); // Catch-up for a tab that is already active and loaded at module init. if (adminTabActive()) loadTTSConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-test-tts')) testTTS(); if (e.target.closest('#btn-discover-tts')) discoverTTS(); if (e.target.closest('.admin-tts-set-btn')) { var btn = e.target.closest('.admin-tts-set-btn'); setTTSDefault(btn.dataset.id, btn.dataset.type, btn); } }); document.addEventListener('keydown', function(e) { if (e.target.id === 'admin-tts-search' && e.key === 'Enter') { e.preventDefault(); discoverTTS(); } }); const esc = adminEscapeHtml; function loadTTSConfig() { fetch('/api/admin/config/tts', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var badge = document.getElementById('admin-tts-provider-badge'); if (badge) { badge.textContent = (data.provider || 'none').toUpperCase(); badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)'; badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)'; } var info = document.getElementById('admin-tts-info'); if (info) { var parts = []; if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider); if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice); else if (data.envVoice) parts.push('Env voice: ' + data.envVoice); if (data.dbModel) parts.push('DB model: ' + data.dbModel); else if (data.envModel) parts.push('Env model: ' + data.envModel); var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; }); if (configured.length) parts.push('Configured: ' + configured.join(', ')); info.textContent = parts.join(' · ') || 'Auto-detected from env'; } var voiceSel = document.getElementById('admin-tts-voice'); if (voiceSel) { voiceSel.innerHTML = ''; var voices = (data.voices && data.voices[data.provider]) || []; if (data.currentVoice && voices.indexOf(data.currentVoice) === -1) voices = [data.currentVoice].concat(voices); if (voices.length === 0) voices = ['default']; voices.forEach(function(v) { var opt = document.createElement('option'); opt.value = v; opt.textContent = v + (v === data.currentVoice ? ' (active)' : ''); if (v === data.currentVoice) opt.selected = true; voiceSel.appendChild(opt); }); } }) .catch(function() {}); } function discoverTTS() { var search = (document.getElementById('admin-tts-search') || {}).value || ''; var container = document.getElementById('admin-tts-discovered'); var hint = document.getElementById('admin-tts-discover-hint'); if (!container) return; container.innerHTML = '

Querying provider...

'; if (hint) hint.style.display = 'none'; fetch('/api/admin/config/tts/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { container.innerHTML = '

Error: ' + esc(data.error || 'Unknown') + '

'; return; } var items = data.voices || []; if (items.length === 0) { container.innerHTML = '

No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; return; } container.innerHTML = '

Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')

' + items.slice(0, 100).map(function(v) { var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1; var setType = isModel ? 'model' : 'voice'; var badge = isModel ? 'MODEL' : 'VOICE'; return '
' + '' + '' + esc(v.name) + badge + '' + '' + esc(v.source || '') + '' + '
'; }).join(''); }) .catch(function(err) { container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; }); } function setTTSDefault(id, type, btn) { var key = type === 'model' ? 'tts.model' : 'tts.voice'; var origText = btn ? btn.textContent : ''; adminSetButtonText(btn, '...', true); fetch('/api/admin/config/' + encodeURIComponent(key), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: id }) }) .then(function(r) { return r.json(); }) .then(function(data) { adminSetButtonText(btn, 'Set', false); adminFlashButtonBackground(btn, 'var(--green)'); if (data.success) { showToast('TTS ' + type + ' set to: ' + id, 'success'); // Update voice selector var voiceSel = document.getElementById('admin-tts-voice'); if (voiceSel && type === 'voice') { var found = Array.from(voiceSel.options).find(function(o) { return o.value === id; }); if (!found) { var opt = document.createElement('option'); opt.value = id; opt.textContent = id + ' (active)'; voiceSel.insertBefore(opt, voiceSel.firstChild); } voiceSel.value = id; } loadTTSConfig(); } else { showToast(data.error || 'Failed', 'error'); } }) .catch(function() { adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } function testTTS() { var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.'; var voice = (document.getElementById('admin-tts-voice') || {}).value || ''; var btn = document.getElementById('btn-test-tts'); var resultEl = document.getElementById('admin-tts-result'); var audioEl = document.getElementById('admin-tts-audio'); adminSetButtonHtml(btn, ' Synthesizing...', true); if (resultEl) resultEl.textContent = ''; if (audioEl) audioEl.style.display = 'none'; fetch('/api/admin/config/tts/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ text: text, voice: voice }) }) .then(function(r) { return r.json(); }) .then(function(data) { adminSetButtonHtml(btn, ' Synthesize & Play', false); if (!data.success) { if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error'); return; } if (audioEl && data.audio) { var blob = base64ToBlob(data.audio, 'audio/mpeg'); audioEl.src = URL.createObjectURL(blob); audioEl.style.display = 'inline-block'; audioEl.play(); } if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?'); }) .catch(function(err) { adminSetButtonHtml(btn, ' Synthesize & Play', false); if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; }); } function base64ToBlob(base64, type) { var binary = atob(base64); var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return new Blob([bytes], { type: type }); } } // ============================================================ // ADMIN STT MANAGEMENT // ============================================================ { let mediaRecorder = null; let audioChunks = []; let recording = false; document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadSTTConfig(); }); // Catch-up for a tab that is already active and loaded at module init. if (adminTabActive()) loadSTTConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-stt-record')) toggleRecording(); if (e.target.closest('#btn-discover-stt')) discoverSTT(); if (e.target.closest('.admin-stt-set-btn')) { var btn = e.target.closest('.admin-stt-set-btn'); setSTTDefault(btn.dataset.id, btn); } }); document.addEventListener('keydown', function(e) { if (e.target.id === 'admin-stt-search' && e.key === 'Enter') { e.preventDefault(); discoverSTT(); } }); const esc = adminEscapeHtml; function loadSTTConfig() { fetch('/api/admin/config/stt', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var badge = document.getElementById('admin-stt-provider-badge'); if (badge) { badge.textContent = (data.provider || 'none').toUpperCase(); badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)'; badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)'; } var info = document.getElementById('admin-stt-info'); if (info) { var parts = []; if (data.envProvider !== 'auto') parts.push('TRANSCRIBE_PROVIDER=' + data.envProvider); if (data.dbModel) parts.push('DB model: ' + data.dbModel); else if (data.envModel) parts.push('Env model: ' + data.envModel); var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; }); if (configured.length) parts.push('Configured: ' + configured.join(', ')); info.textContent = parts.join(' · ') || 'Auto-detected from env'; } }) .catch(function() {}); } function discoverSTT() { var search = (document.getElementById('admin-stt-search') || {}).value || ''; var container = document.getElementById('admin-stt-discovered'); var hint = document.getElementById('admin-stt-discover-hint'); if (!container) return; container.innerHTML = '

Querying provider...

'; if (hint) hint.style.display = 'none'; fetch('/api/admin/config/stt/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { container.innerHTML = '

Error: ' + esc(data.error || 'Unknown') + '

'; return; } var items = data.models || []; if (items.length === 0) { container.innerHTML = '

No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; return; } container.innerHTML = '

Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')

' + items.map(function(m) { return '
' + '' + '' + esc(m.name || m.id) + '' + '' + esc(m.source || '') + '' + '
'; }).join(''); }) .catch(function(err) { container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; }); } function setSTTDefault(modelId, btn) { var origText = btn ? btn.textContent : ''; adminSetButtonText(btn, '...', true); fetch('/api/admin/config/' + encodeURIComponent('stt.model'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId }) }) .then(function(r) { return r.json(); }) .then(function(data) { adminSetButtonText(btn, 'Set', false); adminFlashButtonBackground(btn, data.success ? 'var(--green)' : ''); if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); } else showToast(data.error || 'Failed', 'error'); }) .catch(function() { adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } function toggleRecording() { if (recording) { stopRecording(); } else { startRecording(); } } function startRecording() { var btn = document.getElementById('btn-stt-record'); var status = document.getElementById('admin-stt-recording-status'); var resultEl = document.getElementById('admin-stt-result'); if (resultEl) resultEl.style.display = 'none'; navigator.mediaDevices.getUserMedia({ audio: true }) .then(function(stream) { audioChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.addEventListener('dataavailable', function(e) { if (e.data.size > 0) audioChunks.push(e.data); }); mediaRecorder.addEventListener('stop', function() { stream.getTracks().forEach(function(t) { t.stop(); }); transcribeRecording(); }); mediaRecorder.start(); recording = true; if (btn) { btn.innerHTML = ' Stop Recording'; btn.style.background = 'var(--red)'; btn.style.color = 'white'; } if (status) status.textContent = 'Recording... (click Stop when done)'; }) .catch(function(err) { if (status) status.textContent = 'Microphone access denied: ' + err.message; }); } function stopRecording() { if (mediaRecorder && mediaRecorder.state !== 'inactive') { mediaRecorder.stop(); } recording = false; var btn = document.getElementById('btn-stt-record'); var status = document.getElementById('admin-stt-recording-status'); if (btn) { btn.innerHTML = ' Start Recording'; btn.style.background = ''; btn.style.color = ''; } if (status) status.textContent = 'Processing...'; } function transcribeRecording() { if (audioChunks.length === 0) return; var blob = new Blob(audioChunks, { type: 'audio/webm' }); var reader = new FileReader(); reader.onloadend = function() { var base64 = reader.result.split(',')[1]; fetch('/api/admin/config/stt/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ audioBase64: base64, mimeType: 'audio/webm' }) }) .then(function(r) { return r.json(); }) .then(function(data) { var status = document.getElementById('admin-stt-recording-status'); var resultEl = document.getElementById('admin-stt-result'); var textEl = document.getElementById('admin-stt-text'); var metaEl = document.getElementById('admin-stt-meta'); if (status) status.textContent = ''; if (resultEl) resultEl.style.display = 'block'; if (data.success) { if (textEl) textEl.textContent = data.text || '(empty result)'; if (metaEl) metaEl.textContent = 'Provider: ' + (data.provider || '?') + ' · ' + (data.duration || 0) + 'ms'; } else { if (textEl) textEl.textContent = 'Error: ' + (data.error || 'Unknown error'); if (metaEl) metaEl.textContent = ''; } }) .catch(function(err) { var status = document.getElementById('admin-stt-recording-status'); if (status) status.textContent = 'Request failed: ' + err.message; }); }; reader.readAsDataURL(blob); } } // ============================================================ // ADMIN EMBEDDING MODELS MANAGEMENT // ============================================================ { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadEmbeddingConfig(); }); // Catch-up for a tab that is already active and loaded at module init. if (adminTabActive()) loadEmbeddingConfig(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-test-embedding')) testEmbedding(); if (e.target.closest('#btn-discover-embeddings')) discoverEmbeddings(); if (e.target.closest('.admin-embed-set-btn')) { var btn = e.target.closest('.admin-embed-set-btn'); setEmbeddingDefault(btn.dataset.id, btn.dataset.dims, btn); } }); document.addEventListener('keydown', function(e) { if (e.target.id === 'admin-embed-search' && e.key === 'Enter') { e.preventDefault(); discoverEmbeddings(); } }); const esc = adminEscapeHtml; function loadEmbeddingConfig() { fetch('/api/admin/config/embeddings', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var badge = document.getElementById('admin-embed-provider-badge'); if (badge) { badge.textContent = (data.provider || 'none').toUpperCase(); badge.style.background = data.configured ? 'var(--g100)' : 'var(--red-light)'; badge.style.color = data.configured ? 'var(--g600)' : 'var(--red)'; } var info = document.getElementById('admin-embed-info'); if (info) { var parts = []; if (data.dbModel) parts.push('DB model: ' + data.dbModel); else if (data.envModel) parts.push('Env model: ' + data.envModel); parts.push('Dims: ' + (data.currentDimensions || '?')); if (!data.configured) parts.push('⚠️ Not configured'); info.textContent = parts.join(' · '); } var modelsEl = document.getElementById('admin-embed-models'); if (modelsEl && data.models) { modelsEl.innerHTML = data.models.map(function(m) { var isCurrent = m.id === data.currentModel; return '
' + '' + '' + esc(m.name) + ' (' + m.dims + 'd)' + '' + esc(m.tag || '') + '' + (isCurrent ? 'ACTIVE' : '') + '
'; }).join(''); } }) .catch(function() {}); } function discoverEmbeddings() { var search = (document.getElementById('admin-embed-search') || {}).value || ''; var container = document.getElementById('admin-embed-discovered'); var hint = document.getElementById('admin-embed-discover-hint'); if (!container) return; container.innerHTML = '

Querying provider...

'; if (hint) hint.style.display = 'none'; fetch('/api/admin/config/embeddings/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) { container.innerHTML = '

Error: ' + esc(data.error || 'Unknown') + '

'; return; } var items = data.models || []; if (items.length === 0) { container.innerHTML = '

No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '

'; return; } container.innerHTML = '

Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')

' + items.map(function(m) { return '
' + '' + '' + esc(m.name || m.id) + (m.dims && m.dims !== '?' ? ' (' + m.dims + 'd)' : '') + '' + '' + esc(m.source || '') + '' + '
'; }).join(''); }) .catch(function(err) { container.innerHTML = '

Request failed: ' + esc(err.message) + '

'; }); } function setEmbeddingDefault(modelId, dims, btn) { var origText = btn ? btn.textContent : ''; adminSetButtonText(btn, '...', true); var promises = [ fetch('/api/admin/config/' + encodeURIComponent('embeddings.model'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: modelId }) }).then(function(r) { return r.json(); }) ]; if (dims && dims !== '?') { promises.push( fetch('/api/admin/config/' + encodeURIComponent('embeddings.dimensions'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: String(dims) }) }).then(function(r) { return r.json(); }) ); } Promise.all(promises) .then(function(results) { var ok = results.every(function(r) { return r.success; }); adminSetButtonText(btn, 'Set', false); adminFlashButtonBackground(btn, ok ? 'var(--green)' : ''); if (ok) { showToast('Embedding model set to: ' + modelId + (dims ? ' (' + dims + 'd)' : ''), 'success'); loadEmbeddingConfig(); } else showToast(results[0].error || 'Failed', 'error'); }) .catch(function() { adminSetButtonText(btn, origText, false); showToast('Request failed', 'error'); }); } function testEmbedding() { var text = (document.getElementById('admin-embed-test-text') || {}).value || 'test'; var resultEl = document.getElementById('admin-embed-result'); var btn = document.getElementById('btn-test-embedding'); adminSetButtonHtml(btn, '', true); if (resultEl) resultEl.textContent = 'Generating...'; fetch('/api/admin/config/embeddings/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ text: text }) }) .then(function(r) { return r.json(); }) .then(function(data) { adminSetButtonHtml(btn, ' Generate', false); if (!data.success) { if (resultEl) resultEl.innerHTML = 'Error: ' + esc(data.error || 'Failed') + ''; return; } if (resultEl) { resultEl.innerHTML = 'Dimensions: ' + data.dimensions + '  |  ' + 'Model: ' + esc(data.model) + '  |  ' + '' + data.duration + 'ms' + '
Sample: [' + (data.sample || []).join(', ') + ', ...]
'; } }) .catch(function(err) { adminSetButtonHtml(btn, ' Generate', false); if (resultEl) resultEl.textContent = 'Request failed: ' + err.message; }); } }