pediatric-ai-scribe-v3/public/js/admin.js

1709 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js';
// ============================================================
// ADMIN.JS — Admin panel: users, settings, stats
// ============================================================
function adminEscapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function adminTableMessage(colspan, color, text) {
return '<tr><td colspan="' + colspan + '" style="text-align:center;color:' + color + ';padding:20px;">' + adminEscapeHtml(text) + '</td></tr>';
}
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 <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
}
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 ----
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) { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; }
renderUsers(data.users || []);
})
.catch(function() { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
}
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) {
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() : '—';
var actions = [];
if (!isSelf) {
if (!u.email_verified) {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="verify" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Verify</button>');
}
if (u.disabled) {
actions.push('<button class="btn-sm btn-success admin-action" data-action="enable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Enable</button>');
} else {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="disable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Disable</button>');
}
if (u.role === 'admin') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="demote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Demote</button>');
} else {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
}
if (u.role !== 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-moderator" data-id="' + u.id + '" data-email="' + esc(u.email) + '" style="color:var(--purple);">Set Moderator</button>');
}
if (u.role === 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-user" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Set User</button>');
}
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
} else {
actions.push('<span style="font-size:12px;color:var(--g400);">(you)</span>');
}
return '<tr>' +
'<td><div style="font-weight:600;font-size:13px;">' + esc(u.name) + '</div><div style="font-size:11px;color:var(--g500);">' + esc(u.email) + '</div></td>' +
'<td><span style="font-size:12px;font-weight:600;color:' + roleColor + ';">' + (u.role || 'user') + '</span></td>' +
'<td><span style="font-size:12px;color:' + statusColor + ';">' + statusText + '</span></td>' +
'<td style="font-size:12px;color:var(--g500);">' + joined + '</td>' +
'<td style="white-space:nowrap;">' + actions.join(' ') + '</td>' +
'</tr>';
}).join('');
// Bind action buttons
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 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 = '<input type="email" id="admin-test-email-input" placeholder="recipient@example.com" style="padding:5px 8px;border:1px solid var(--g300);border-radius:6px;font-size:12px;width:220px;">'
+ '<button class="btn-sm btn-primary" id="admin-test-email-submit" style="font-size:11px;">Send</button>'
+ '<button class="btn-sm btn-ghost" id="admin-test-email-cancel" style="font-size:11px;">Cancel</button>';
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')],
['clinical-text', document.getElementById('cms-clinical-text-prompts')],
['clinical-image', document.getElementById('cms-clinical-image-prompts')]
];
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.family === group[0] && p.editable === true; });
if (!prompts.length) {
group[1].textContent = 'No editable prompts available in this family.';
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; }
}
// Compact per-family editor: one prompt select, one draft textarea, one status
// line, save/restore/history actions and a compact revision list for the
// SELECTED prompt. Drafts and revision baselines are kept per key in memory,
// so switching prompts (or families) never loses another prompt's unsaved draft.
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/revision content is assigned as text.
editor.innerHTML =
'<label style="font-size:13px;font-weight:600;">Prompt' +
'<select class="prompt-select" style="display:block;margin-top:4px;max-width:100%;font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;"></select></label>' +
'<textarea class="prompt-draft" rows="8" style="display:block;width:100%;box-sizing:border-box;font-family:inherit;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;"></textarea>' +
'<p class="prompt-baseline" style="margin:0;font-size:12px;color:var(--g500);"></p>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;">' +
'<button type="button" class="btn-sm btn-primary" data-prompt-action="save">Save prompt</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="reset">Restore original</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="history">History (latest 100)</button></div>' +
'<p class="prompt-status" role="status" style="margin:0;font-size:12px;color:var(--g600);"></p>' +
'<div class="prompt-history" hidden>' +
'<label>Saved revisions<select class="prompt-revisions" style="display:block;max-width:100%;"></select></label>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="view">View revision</button>' +
'<textarea class="prompt-revision-text" rows="4" readonly style="display:block;width:100%;box-sizing:border-box;font-family:inherit;font-size:12px;"></textarea>' +
'<p class="prompt-revision-meta"></p>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="restore">Restore viewed revision</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="baseline">Keep draft; use viewed current revision as save baseline</button></div>';
var select = editor.querySelector('.prompt-select');
var text = editor.querySelector('.prompt-draft');
var status = editor.querySelector('.prompt-status');
var history = editor.querySelector('.prompt-history');
var revisions = editor.querySelector('.prompt-revisions');
var preview = editor.querySelector('.prompt-revision-text');
var meta = editor.querySelector('.prompt-revision-meta');
var buttons = {};
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
// Per-key state: server value, optimistic save baseline, current server
// revision (from history) and the live unsaved 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, currentRevision: p.revision, draft: p.value });
});
var key = select.options.length ? select.options[0].value : null;
var viewedRevision = null;
var busy = false;
if (key) text.value = states.get(key).draft;
function state() { return key ? states.get(key) : null; }
function controls() {
Object.values(buttons).forEach(function(button) { button.disabled = busy; });
select.disabled = busy;
revisions.disabled = busy;
buttons.view.disabled = busy || !revisions.value;
buttons.restore.disabled = busy || !viewedRevision;
buttons.baseline.disabled = busy || !viewedRevision || !state() || viewedRevision.id !== state().currentRevision;
var st = state();
editor.querySelector('.prompt-baseline').textContent = st ?
'Save baseline: revision ' + st.revision + (st.revision === 0 ? ' (no history yet).' : '.') : '';
}
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(); }
}
function clearPreview() {
viewedRevision = null; preview.value = ''; meta.textContent = ''; controls();
}
function revisionLabel(item) {
return '#' + item.id + ' — ' + item.createdAt + ' — ' + (item.createdBy == null ? 'unknown actor' : item.createdBy) +
(item.wasDefault ? ' — default snapshot' : '') + (item.restoredFrom == null ? '' : ' — restored from #' + item.restoredFrom);
}
function base() { return '/api/admin/config/prompts/' + encodeURIComponent(key); }
async function mutate(action, revisionId) {
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;
if (action === 'restore') body.revisionId = revisionId;
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : base() + '/' + action,
action === 'save' ? 'PUT' : 'POST', body);
st.value = data.value;
st.revision = data.revision;
st.currentRevision = data.revision;
// Typing while a request is pending must not be overwritten by its response;
// switching away must not leak the response into another prompt's editor.
if (select.value === targetKey && text.value === submitted) {
text.value = data.value;
st.draft = data.value;
}
history.hidden = true; revisions.replaceChildren(); clearPreview();
status.textContent = 'Saved revision ' + data.revision + (text.value === data.value ? '.' : '. Newer draft edits are still unsaved.');
}
select.onchange = function() {
// Stash the outgoing draft before switching: per-key drafts survive switches.
if (key) states.get(key).draft = text.value;
key = select.value;
var st = state();
text.value = st ? st.draft : '';
viewedRevision = null; preview.value = ''; meta.textContent = '';
history.hidden = true; revisions.replaceChildren();
status.textContent = '';
controls();
};
buttons.save.onclick = function() { run(function() { return mutate('save'); }); };
buttons.reset.onclick = function() {
showConfirm('Reset only this prompt to its shipped default? This replaces this editors draft and creates a revision; other editors are unchanged.', function() {
run(function() { return mutate('reset'); });
});
};
buttons.history.onclick = function() { run(async function() {
var st = states.get(key);
var data = await promptRequest(base() + '/history?limit=100');
st.currentRevision = data.revision;
revisions.replaceChildren(); clearPreview();
(data.revisions || []).forEach(function(item) {
var option = document.createElement('option'); option.value = item.id; option.textContent = revisionLabel(item); revisions.appendChild(option);
});
history.hidden = false;
status.textContent = 'Current server revision: ' + st.currentRevision + '. ' + (revisions.options.length ? 'Select a revision to view. Your draft is unchanged.' : 'No saved revisions yet.');
}); };
revisions.onchange = clearPreview;
buttons.view.onclick = function() { run(async function() {
var data = await promptRequest(base() + '/revisions/' + encodeURIComponent(revisions.value));
viewedRevision = data.revision;
preview.value = viewedRevision.value;
meta.textContent = revisionLabel(viewedRevision);
status.textContent = 'Viewing revision ' + viewedRevision.id + '. Your draft is unchanged.';
}); };
buttons.restore.onclick = function() {
var id = viewedRevision && viewedRevision.id;
if (!id) return;
showConfirm('Restore revision ' + id + ' for only this prompt? This replaces this editors draft and creates a new revision.', function() {
run(function() { return mutate('restore', id); });
});
};
buttons.baseline.onclick = function() {
var st = state();
if (!viewedRevision || !st || viewedRevision.id !== st.currentRevision) return;
st.revision = st.currentRevision; controls();
status.textContent = 'Draft kept. The next save will use revision ' + st.revision + ' as its baseline; review your edits before saving.';
};
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);
// ============================================================
// 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-add-custom-model')) addCustomModel();
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 = '<div style="padding:10px 12px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g600);">' +
'<p style="margin:0 0 8px;"><i class="fas fa-info-circle" style="color:var(--blue);"></i> <strong>LiteLLM mode:</strong> No built-in models. ' +
'Use <strong>Search API</strong> below to discover models from your proxy, then add them.</p>' +
'<button id="btn-clear-all-models" class="btn-sm" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;">' +
'<i class="fas fa-trash"></i> Clear all added models</button></div>';
} else if (data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No built-in models for this provider.</p>';
} else {
container.innerHTML = data.models.map(function(m) {
var checked = m.enabled !== false ? 'checked' : '';
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<label style="display:flex;align-items:center;gap:8px;flex:1;cursor:pointer;margin:0;">' +
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'</label>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Test</button>' +
'</div>';
}).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 = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
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 = '<p style="color:var(--red);font-size:13px;">Error: ' + esc(data.error || 'Unknown error') + '</p>';
return;
}
if (!data.models || data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '. Try a different search term.</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models. Click + to add to your model list.</p>' +
data.models.slice(0, 100).map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
btn.addEventListener('click', function() {
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn.dataset.mcost, btn.dataset.mcat, btn);
});
});
})
.catch(function(err) {
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Request failed: ' + esc(err.message) + '</p>';
});
}
function addDiscoveredModel(id, name, cost, category, btn) {
fetch('/api/admin/config/models/add-discovered', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ id: id, name: name, cost: cost, category: category })
})
.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 addCustomModel() {
var id = (document.getElementById('admin-custom-model-id') || {}).value || '';
var name = (document.getElementById('admin-custom-model-name') || {}).value || '';
var cost = (document.getElementById('admin-custom-model-cost') || {}).value || '';
var cat = (document.getElementById('admin-custom-model-cat') || {}).value || 'smart';
if (!id.trim() || !name.trim()) { showToast('Model ID and name required', 'error'); return; }
fetch('/api/admin/config/models/custom', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ id: id.trim(), name: name.trim(), cost: cost.trim(), category: cat })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('Custom model added: ' + name, 'success');
// Clear inputs
var el = document.getElementById('admin-custom-model-id'); if (el) el.value = '';
el = document.getElementById('admin-custom-model-name'); if (el) el.value = '';
el = document.getElementById('admin-custom-model-cost'); if (el) el.value = '';
loadAdminModels();
} 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 = '<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Custom / Discovered Models</label>' +
custom.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
'</div>';
}).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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
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 = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.voices || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
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 ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
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, '<i class="fas fa-spinner fa-spin"></i> 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, '<i class="fas fa-play"></i> Synthesize &amp; 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, '<i class="fas fa-play"></i> Synthesize &amp; 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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
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 = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.models || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-stt-set-btn" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(m.name || m.id) + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
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 = '<i class="fas fa-stop"></i> 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 = '<i class="fas fa-microphone"></i> 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 '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + m.dims + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Set</button>' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
(isCurrent ? '<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--green-light,#d1fae5);color:var(--green);">ACTIVE</span>' : '') +
'</div>';
}).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 = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
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 = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.models || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-embed-set-btn" data-id="' + esc(m.id) + '" data-dims="' + (m.dims || '') + '" style="padding:2px 8px;font-size:11px;">Set</button>' +
'<span style="flex:1;">' + esc(m.name || m.id) + (m.dims && m.dims !== '?' ? ' <span style="color:var(--g500);font-size:11px;">(' + m.dims + 'd)</span>' : '') + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
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, '<i class="fas fa-spinner fa-spin"></i>', 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, '<i class="fas fa-code-branch"></i> Generate', false);
if (!data.success) {
if (resultEl) resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + esc(data.error || 'Failed') + '</span>';
return;
}
if (resultEl) {
resultEl.innerHTML =
'<strong>Dimensions:</strong> ' + data.dimensions + ' &nbsp;|&nbsp; ' +
'<strong>Model:</strong> ' + esc(data.model) + ' &nbsp;|&nbsp; ' +
'<strong>' + data.duration + 'ms</strong>' +
'<div style="margin-top:4px;font-family:monospace;font-size:11px;color:var(--g400);">Sample: [' + (data.sample || []).join(', ') + ', ...]</div>';
}
})
.catch(function(err) {
adminSetButtonHtml(btn, '<i class="fas fa-code-branch"></i> Generate', false);
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
});
}
}