1711 lines
78 KiB
JavaScript
1711 lines
78 KiB
JavaScript
import './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, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
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')],
|
||
['learning-image', document.getElementById('cms-learning-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 editor’s 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 editor’s 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 & 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 & 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 + ' | ' +
|
||
'<strong>Model:</strong> ' + esc(data.model) + ' | ' +
|
||
'<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;
|
||
});
|
||
}
|
||
|
||
}
|