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); loadWebSearch(); }) .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-websearch')) saveWebSearch(); if (e.target.closest('#btn-test-websearch')) testWebSearch(); 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'); }); } // ---- WEB SEARCH ---- // Its own endpoints rather than the generic setter: the key is masked on read // and a blank field means "keep what is there", so changing the provider does // not silently wipe a working key. function loadWebSearch() { fetch('/api/admin/websearch', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data || !data.success) return; var cfg = data.config || {}; var set = function(id, value) { var el = document.getElementById(id); if (el) el.value = value || ''; }; set('ws-enabled', cfg['websearch.enabled'] === 'true' ? 'true' : 'false'); set('ws-provider', cfg['websearch.provider'] || 'tavily'); set('ws-base-url', cfg['websearch.base_url']); set('pm-enabled', cfg['pubmed.enabled'] === 'true' ? 'true' : 'false'); set('pm-email', cfg['pubmed.contact_email']); var key = document.getElementById('ws-api-key'); if (key) key.placeholder = cfg['websearch.api_key'] || 'Leave blank to keep the current key'; var pmKey = document.getElementById('pm-api-key'); if (pmKey) pmKey.placeholder = cfg['pubmed.api_key'] || 'Optional — leave blank to keep the current key'; }) .catch(function() {}); } function saveWebSearch() { var status = document.getElementById('ws-status'); if (status) status.textContent = 'Saving...'; fetch('/api/admin/websearch', { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ enabled: (document.getElementById('ws-enabled') || {}).value, provider: (document.getElementById('ws-provider') || {}).value, apiKey: (document.getElementById('ws-api-key') || {}).value, baseUrl: (document.getElementById('ws-base-url') || {}).value, pubmedEnabled: (document.getElementById('pm-enabled') || {}).value, pubmedApiKey: (document.getElementById('pm-api-key') || {}).value, pubmedEmail: (document.getElementById('pm-email') || {}).value }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Save failed'); if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.'; ['ws-api-key', 'pm-api-key'].forEach(function (id) { var field = document.getElementById(id); if (field) field.value = ''; }); showToast('Web search settings saved', 'success'); loadWebSearch(); }) .catch(function(err) { if (status) status.textContent = 'Not saved.'; showToast(err.message, 'error'); }); } function testWebSearch() { var status = document.getElementById('ws-status'); if (status) status.textContent = 'Searching...'; fetch('/api/admin/websearch/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({}) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!status) return; // Both sources, separately, so one press says which of them works. var web = data.web || {}; var pubmed = data.pubmed || {}; status.textContent = 'Web: ' + (web.reason ? web.reason : web.count + ' from ' + web.provider) + ' · PubMed: ' + (pubmed.reason ? pubmed.reason : pubmed.count + ' records'); status.style.color = data.success ? 'var(--green)' : 'var(--red)'; }) .catch(function(err) { if (status) { status.textContent = err.message; status.style.color = 'var(--red)'; } }); } // ---- FEATURE FLAGS ---- function saveFlags() { var readAloud = document.getElementById('cms-flag-read-aloud').value; var nextcloud = document.getElementById('cms-flag-nextcloud').value; var status = document.getElementById('cms-flags-status'); if (status) status.textContent = 'Saving...'; Promise.all([ putConfig('feature.read_aloud', readAloud), putConfig('feature.nextcloud', nextcloud) ]).then(function() { if (status) status.textContent = 'Saved.'; showToast('Feature flags saved', 'success'); }).catch(function() { if (status) status.textContent = 'Not saved.'; 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) { // Disabling removes a model from every picker, not only this list. announceModelsChanged(); 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) + '

'; }); } // The model roster changed. Every picker that lists models listens for this, // because otherwise they keep whatever they were given when the tab loaded: // adding a model used to refresh the default-model dropdown alone, and the // Clinical Assistant, review-model and image pickers only caught up on a page // reload. The detail carries nothing — a listener re-reads the list itself, // so there is one source of truth rather than a payload to keep in step. function announceModelsChanged() { try { document.dispatchEvent(new CustomEvent('models-changed')); } catch (e) {} } 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) { announceModelsChanged(); showToast('Added: ' + name + ' — it is now selectable everywhere models are chosen', '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) { announceModelsChanged(); 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) { announceModelsChanged(); 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 CITATION QUALITY // A citation that resolves to nothing is never linked, so it is invisible // unless someone counts it. This is the reading end of that. // ============================================================ { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadCitationSummary(); }); if (adminTabActive()) loadCitationSummary(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-view-citation-audit')) openCitationAudit(); if (e.target.closest('#citation-audit-close') || e.target.id === 'citation-audit-modal') closeCitationAudit(); }); document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeCitationAudit(); }); const esc = adminEscapeHtml; function fetchAudit() { return fetch('/api/admin/citation-audit', { headers: getAuthHeaders() }).then(function(r) { return r.json(); }); } function loadCitationSummary() { fetchAudit().then(function(data) { var badge = document.getElementById('admin-citation-summary'); if (!badge || !data.success) return; var answers = (data.totals && data.totals.answers) || 0; badge.textContent = answers ? answers + ' flagged (30 days)' : 'none flagged'; badge.style.background = answers ? 'var(--amber)' : 'var(--green)'; badge.style.color = 'white'; }).catch(function() {}); } function closeCitationAudit() { var modal = document.getElementById('citation-audit-modal'); if (modal) modal.remove(); } function openCitationAudit() { fetchAudit().then(function(data) { if (!data.success) throw new Error(data.error || 'Could not load'); closeCitationAudit(); var modal = document.createElement('div'); modal.id = 'citation-audit-modal'; modal.className = 'modal'; modal.innerHTML = ''; document.body.appendChild(modal); }).catch(function(err) { showToast(err.message, 'error'); }); } function rows(list) { if (!list.length) { return '

Nothing flagged in the last 30 days — every citation pointed at a source that was returned.

'; } return list.map(function(row) { var missing = (row.unverifiable || []).map(function(n) { return '[' + n + ']'; }).join(' '); var titles = (row.source_titles || []).map(function(t, i) { return '
  • [' + (i + 1) + '] ' + esc(t) + '
  • '; }).join(''); return '
    ' + '
    ' + '' + esc(missing) + ' unmatched' + '' + esc(new Date(row.created_at).toLocaleString()) + ' · ' + esc(String(row.cited_count)) + ' citations written, ' + esc(String(row.source_count)) + ' sources returned' + (row.user_email ? ' · ' + esc(row.user_email) : '') + '' + '
    ' + '
    Question: ' + esc(row.question || '(none)') + '
    ' + (titles ? '
    Sources returned
    ' : '') + '
    '; }).join(''); } } // ============================================================ // ADMIN LOCKDOWN (display) // The server refuses locked writes regardless; this only stops an admin // filling in a field that was never going to save. Settings stay visible, so // the configuration can still be read. // // The state rides along on the invites response rather than a request of its // own: the admin panel already makes enough calls on open. // ============================================================ { window.applyAdminLockdown = function(state) { if (!state || !state.enabled) return; var panel = document.getElementById('admin-tab'); if (!panel || !panel.querySelector('.card')) return; lockdownBanner(panel, state); lockdownFields(panel, state); }; function lockdownBanner(panel, state) { if (document.getElementById('admin-lockdown-banner')) return; var note = document.createElement('div'); note.id = 'admin-lockdown-banner'; note.style.cssText = 'margin:0 0 12px;padding:10px 14px;border:1px solid var(--amber);background:var(--amber-light);border-radius:8px;font-size:13px;color:var(--g800);'; note.innerHTML = ' Admin lockdown is on. ' + adminEscapeHtml(state.reason) + ' Settings are shown but cannot be changed here.'; panel.insertBefore(note, panel.firstChild); } // Everything inside the panel, except the controls that stay operational and // the buttons that only read. function lockdownFields(panel, state) { var editable = ['admin-invite', 'btn-create-invite', 'cms-ann', 'announcement', 'admin-users-search', 'registration']; panel.querySelectorAll('input, select, textarea, button').forEach(function(el) { var id = el.id || ''; if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return; var label = (el.textContent || '') + ' ' + id; if (/search|test|discover|refresh|reload|retry|copy/i.test(label)) return; el.disabled = true; el.title = state.reason; }); } } // ============================================================ // ADMIN REGISTRATION INVITES // The code exists in readable form exactly once: in the response to creating // it. Everything after works from the id and the last four characters. // ============================================================ { document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadInvites(); }); if (adminTabActive()) loadInvites(); document.addEventListener('click', function(e) { if (e.target.closest('#btn-create-invite')) createInvite(); var revoke = e.target.closest('.admin-invite-revoke'); if (revoke) inviteAction(revoke.dataset.id, 'revoke'); var del = e.target.closest('.admin-invite-delete'); if (del) inviteAction(del.dataset.id, 'delete'); if (e.target.closest('#btn-clear-used-invites')) clearUsedInvites(); var copy = e.target.closest('.admin-invite-copy'); if (copy && navigator.clipboard) { navigator.clipboard.writeText(copy.dataset.code) .then(function() { showToast('Invitation code copied', 'success'); }) .catch(function() { showToast('Could not copy; select it by hand', 'error'); }); } }); document.addEventListener('change', function(e) { if (e.target.id === 'admin-invite-only') setInviteOnly(e.target.checked); }); const esc = adminEscapeHtml; function loadInvites() { fetch('/api/admin/invites', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) return; var toggle = document.getElementById('admin-invite-only'); if (toggle) toggle.checked = !!data.inviteOnly; renderInvites(data.invites || []); // Painted after the panel has rendered, from the same response. if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown); }) .catch(function() {}); } function setInviteOnly(on) { fetch('/api/admin/config/' + encodeURIComponent('registration_invite_only'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: on ? 'true' : 'false' }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Could not save'); showToast(on ? 'Registration now requires an invitation' : 'Registration no longer requires an invitation', 'success'); }) .catch(function(err) { showToast(err.message, 'error'); loadInvites(); }); } function createInvite() { var note = (document.getElementById('admin-invite-note') || {}).value || ''; var days = (document.getElementById('admin-invite-days') || {}).value || '7'; var out = document.getElementById('admin-invite-new'); fetch('/api/admin/invites', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ note: note, days: days }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Could not create'); if (out) { out.innerHTML = '
    ' + '' + esc(data.code) + '' + '' + 'Valid ' + esc(String(data.days)) + ' days. This is the only time it is shown.' + '
    '; } var noteEl = document.getElementById('admin-invite-note'); if (noteEl) noteEl.value = ''; loadInvites(); }) .catch(function(err) { showToast(err.message, 'error'); }); } function inviteAction(id, action) { var request = action === 'delete' ? fetch('/api/admin/invites/' + encodeURIComponent(id), { method: 'DELETE', headers: getAuthHeaders() }) : fetch('/api/admin/invites/' + encodeURIComponent(id) + '/revoke', { method: 'POST', headers: getAuthHeaders() }); request.then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Failed'); showToast(action === 'delete' ? 'Invitation deleted' : 'Invitation revoked', 'info'); loadInvites(); }) .catch(function(err) { showToast(err.message, 'error'); }); } // Used, or expired without being redeemed. A revoked code keeps its row: it // records a decision somebody took, and it is not cluttering anything the way // a pile of expired codes does. var SPENT_STATUS = ['used', 'expired']; // Spent invitations in one go, which is what a cluttered list actually wants. // Confirmed first: it is a delete, even if everything it removes is finished. function clearUsedInvites() { showConfirm('Delete every used and expired invitation? Live and revoked ones are kept.', function() { fetch('/api/admin/invites/spent', { method: 'DELETE', headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Could not clear them'); showToast('Removed ' + data.removed + ' spent invitation' + (data.removed === 1 ? '' : 's'), 'success'); loadInvites(); }) .catch(function(err) { showToast(err.message, 'error'); }); }, { danger: true, confirmText: 'Delete' }); } function renderInvites(rows) { var container = document.getElementById('admin-invites-list'); if (!container) return; if (!rows.length) { container.innerHTML = '

    No invitations yet.

    '; return; } var colours = { active: 'var(--green)', used: 'var(--g400)', expired: 'var(--amber)', revoked: 'var(--red)' }; container.innerHTML = rows.map(function(row) { var when = row.status === 'used' ? 'used ' + new Date(row.used_at).toLocaleDateString() : row.status === 'revoked' ? 'revoked' : 'expires ' + new Date(row.expires_at).toLocaleDateString(); var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : ''; return '
    ' + '' + esc(row.status) + '' + // The code itself when it is still recoverable, with a button to copy // it: an invitation has to be given to somebody, usually later than the // moment it was made. A row from before codes were kept shows the four // characters it has. (row.code ? '' + esc(row.code) + '' + '' : '****-' + esc(row.code_hint) + '') + '' + esc(row.note || '') + '' + '' + esc(when) + who + '' + (row.status === 'active' ? '' : '') + // Delete is offered on spent codes only — used, or expired unredeemed. // One that could still be redeemed may be sitting in somebody's inbox: // taking it off this list would not take it out of their hands, and // nothing would then say who held it. Revoking is what stops a live // code, and it leaves the row behind, marked. (SPENT_STATUS.indexOf(row.status) !== -1 ? '' : '') + '
    '; }).join(''); container.querySelectorAll('.admin-invite-copy').forEach(function(btn) { btn.addEventListener('click', function() { var code = btn.dataset.code || ''; // The async clipboard API needs a secure context and permission; the // textarea fallback is what works everywhere else, including plain http // on a local network. var done = function() { var icon = btn.querySelector('i'); if (icon) { icon.className = 'fas fa-check'; setTimeout(function() { icon.className = 'fas fa-copy'; }, 1200); } showToast('Code copied', 'success'); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(code).then(done).catch(fallback); } else { fallback(); } function fallback() { var box = document.createElement('textarea'); box.value = code; box.setAttribute('readonly', ''); box.style.cssText = 'position:fixed;top:-1000px;opacity:0;'; document.body.appendChild(box); box.select(); try { document.execCommand('copy'); done(); } catch (e) { showToast('Could not copy — select the code and copy it', 'error'); } box.remove(); } }); }); var spent = rows.filter(function(row) { return SPENT_STATUS.indexOf(row.status) !== -1; }).length; var clear = document.getElementById('btn-clear-used-invites'); if (clear) { clear.hidden = spent === 0; clear.textContent = 'Clear ' + spent + ' spent'; } } } // ============================================================ // ADMIN IMAGE MODEL MANAGEMENT // ============================================================ // Unlike TTS and STT there is no single default to Set: an image model is // chosen per workflow, so discovery here ends in a Test, and the workflow // pickers in the Clinical Assistant card consume the same discovery call. // Nothing loads on tab entry: the workflow pickers already make the one // discovery call opening Admin needs, so this card asks only when searched. { // + Add puts a model in the Clinical Assistant's Image models list // (clinical_assistant.image_model_roster); ticking it there offers it to users. document.addEventListener('assistant-image-roster', syncImageRows); document.addEventListener('click', function(e) { var add = e.target.closest('.admin-image-add-btn'); if (add) { toggleImageRoster(add.dataset.id, add); return; } if (e.target.closest('#btn-discover-image')) discoverImageModels(); if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || ''); var pick = e.target.closest('.admin-image-test-btn'); if (pick) { var field = document.getElementById('admin-image-test-model'); if (field) field.value = pick.dataset.id; testImageModel(pick.dataset.id, pick); } }); document.addEventListener('keydown', function(e) { if (e.target.id === 'admin-image-search' && e.key === 'Enter') { e.preventDefault(); discoverImageModels(); } if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); } }); const esc = adminEscapeHtml; function discoverImageModels() { var search = (document.getElementById('admin-image-search') || {}).value || ''; var container = document.getElementById('admin-image-discovered'); var hint = document.getElementById('admin-image-discover-hint'); if (!container) return; container.innerHTML = '

    Querying provider...

    '; if (hint) hint.style.display = 'none'; fetch('/api/admin/config/image-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') + '

    '; return; } var items = data.models || []; var badge = document.getElementById('admin-image-provider-badge'); if (badge && !search) badge.textContent = data.count + ' available'; if (items.length === 0) { container.innerHTML = '

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

    '; return; } container.innerHTML = '

    Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '

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

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

    '; }); } function currentImageRoster() { return Array.isArray(window._assistantImageRoster) ? window._assistantImageRoster : []; } function imageAddButton(id) { var added = currentImageRoster().indexOf(id) !== -1; return added ? '' : ''; } // Rows rendered before the roster loaded (or after it changed) catch up here. function syncImageRows() { var container = document.getElementById('admin-image-discovered'); if (!container) return; container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); }); } function toggleImageRoster(id, btn) { if (!id) return; var roster = currentImageRoster(); var added = roster.indexOf(id) !== -1; var next = added ? roster.filter(function(x) { return x !== id; }) : roster.concat([id]); adminSetButtonText(btn, '...', true); fetch('/api/admin/config/' + encodeURIComponent('clinical_assistant.image_model_roster'), { method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Could not update the image model list'); window._assistantImageRoster = next; document.dispatchEvent(new CustomEvent('assistant-image-roster-changed', { detail: { roster: next.slice() } })); syncImageRows(); showToast(added ? id + ' removed from the Clinical Assistant list' : id + ' added. Tick it under Clinical Assistant to offer it to users.', 'success'); }) .catch(function(err) { syncImageRows(); showToast(err.message || 'Request failed', 'error'); }); } function testImageModel(modelId, btn) { var id = String(modelId || '').trim(); var result = document.getElementById('admin-image-test-result'); if (!id) { if (result) result.innerHTML = 'Enter or pick a model id first.'; return; } if (btn) adminSetButtonText(btn, '...', true); if (result) result.innerHTML = ' Generating with ' + esc(id) + '... this can take a minute.'; fetch('/api/admin/config/image-models/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: id }) }) .then(function(r) { return r.json(); }) .then(function(data) { if (btn) adminSetButtonText(btn, 'Test', false); if (!data.success) { if (result) result.innerHTML = '' + esc(data.error || 'Image test failed') + ''; return; } var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); if (result) { result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' + (src ? '
    Test image generated by ' + esc(id) + '
    ' : ''); } }) .catch(function(err) { if (btn) adminSetButtonText(btn, 'Test', false); if (result) result.innerHTML = 'Request failed: ' + esc(err.message) + ''; }); } } // ============================================================ // 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; }); } }