Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The card holds more than one Save button and nothing said so. "Save image settings" is injected directly above "Save model & retrieval settings", with no indication of where one stops and the other starts, and the page saves nothing automatically. It now states that up front, and the bottom button says which settings it applies. "Retry loading settings" sat beside Save looking like an ordinary control, because it did: a bare button with the hidden attribute, which the browser's own [hidden] rule could not hide once .btn-sm set a display. It is now inside an error message that exists only on failure, says what failed, and says that nothing typed has been lost. The status line used to read "Settings ready." forever, which answers a question nobody asks. It now reports the thing an admin actually wants to know when they come back: whether the last save went through, and at what time. A toast is gone in three seconds; this stays on the page. The signed-out preview moves to Feature Flags, where it belongs. It was a second checkbox under a row labelled "Sources", followed by two paragraphs, the first about preview and the second about citations — so neither paragraph clearly belonged to either checkbox. It is stored as feature.assistant_preview now, with the old clinical_assistant.preview_enabled still honoured when the new key has never been written. That also means an ordinary admin can toggle it under ADMIN_LOCKDOWN: clinical_assistant.* is locked, and putting a day-to-day switch behind host access was never the intent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2018 lines
93 KiB
JavaScript
2018 lines
93 KiB
JavaScript
import { initImageSettings } from './admin/imageSettings.js';
|
|
import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js';
|
|
|
|
// ============================================================
|
|
// ADMIN.JS — Admin panel: users, settings, stats
|
|
// ============================================================
|
|
|
|
function adminEscapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').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 ----
|
|
|
|
let allUsers = [];
|
|
|
|
function filterUsers() {
|
|
var tbody = document.getElementById('admin-users-body');
|
|
var input = document.getElementById('admin-users-search');
|
|
if (!tbody) return;
|
|
var query = String((input && input.value) || '').trim().toLowerCase();
|
|
if (!allUsers.length) return;
|
|
tbody.innerHTML = allUsers.filter(function(u) {
|
|
return !query || (String(u.name || '').toLowerCase().indexOf(query) !== -1) || (String(u.email || '').toLowerCase().indexOf(query) !== -1);
|
|
}).map(renderUserRow).join('');
|
|
bindUserActions(tbody);
|
|
}
|
|
|
|
function loadUsers() {
|
|
var tbody = document.getElementById('admin-users-body');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'Loading...');
|
|
|
|
fetch('/api/admin/users', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; }
|
|
allUsers = data.users || [];
|
|
filterUsers();
|
|
})
|
|
.catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
|
|
}
|
|
|
|
document.addEventListener('input', function(e) {
|
|
if (e.target && e.target.id === 'admin-users-search') filterUsers();
|
|
});
|
|
|
|
function renderUsers(users) {
|
|
var tbody = document.getElementById('admin-users-body');
|
|
if (!tbody) return;
|
|
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
|
|
if (users.length === 0) {
|
|
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found');
|
|
return;
|
|
}
|
|
tbody.innerHTML = users.map(function(u) { return renderUserRow(u, currentUser); }).join('');
|
|
bindUserActions(tbody);
|
|
}
|
|
|
|
function bindUserActions(tbody) {
|
|
tbody.querySelectorAll('.admin-action').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
handleUserAction(btn.dataset.action, btn.dataset.id, btn.dataset.email, btn.dataset.name);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderUserRow(u, currentUser) {
|
|
currentUser = currentUser || JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
|
|
var isSelf = currentUser.id && u.id === currentUser.id;
|
|
var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)';
|
|
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
|
|
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
|
|
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '\u2014';
|
|
|
|
var actions = [];
|
|
if (!isSelf) {
|
|
if (!u.email_verified) {
|
|
actions.push('<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>';
|
|
}
|
|
|
|
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';
|
|
var flagPreview = document.getElementById('cms-flag-assistant-preview');
|
|
// Falls back to the key this setting used to live under, so the switch
|
|
// shows the real state on an installation that set it before the move.
|
|
if (flagPreview) flagPreview.value =
|
|
cfg['feature.assistant_preview'] !== undefined ? cfg['feature.assistant_preview'] :
|
|
(cfg['clinical_assistant.preview_enabled'] !== undefined ? cfg['clinical_assistant.preview_enabled'] : 'false');
|
|
|
|
// 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;
|
|
|
|
var preview = (document.getElementById('cms-flag-assistant-preview') || {}).value;
|
|
var status = document.getElementById('cms-flags-status');
|
|
if (status) status.textContent = 'Saving...';
|
|
|
|
Promise.all([
|
|
putConfig('feature.read_aloud', readAloud),
|
|
putConfig('feature.nextcloud', nextcloud),
|
|
// A feature flag, stored under feature.* like the others. The assistant
|
|
// still reads the old clinical_assistant.preview_enabled key when this one
|
|
// has never been set, so existing installations keep their setting.
|
|
putConfig('feature.assistant_preview', preview === 'true' ? 'true' : 'false')
|
|
]).then(function() {
|
|
if (status) status.textContent = 'Saved.';
|
|
showToast('Feature flags saved', 'success');
|
|
}).catch(function() {
|
|
if (status) status.textContent = 'Not saved.';
|
|
showToast('Save failed', 'error');
|
|
});
|
|
}
|
|
|
|
// ---- EMAIL TEMPLATES ----
|
|
|
|
function loadEmailFields(template) {
|
|
var cfg = window._adminEmailConfig || {};
|
|
var subject = document.getElementById('cms-email-subject');
|
|
var body = document.getElementById('cms-email-body');
|
|
if (subject) subject.value = cfg['email.' + template + '.subject'] || '';
|
|
if (body) body.value = cfg['email.' + template + '.body'] || '';
|
|
}
|
|
|
|
function saveEmail() {
|
|
var template = document.getElementById('cms-email-template').value;
|
|
var subject = document.getElementById('cms-email-subject').value;
|
|
var body = document.getElementById('cms-email-body').value;
|
|
|
|
Promise.all([
|
|
putConfig('email.' + template + '.subject', subject),
|
|
putConfig('email.' + template + '.body', body)
|
|
]).then(function() {
|
|
// Update local cache
|
|
if (!window._adminEmailConfig) window._adminEmailConfig = {};
|
|
window._adminEmailConfig['email.' + template + '.subject'] = subject;
|
|
window._adminEmailConfig['email.' + template + '.body'] = body;
|
|
showToast('Email template saved', 'success');
|
|
}).catch(function() { showToast('Save failed', 'error'); });
|
|
}
|
|
|
|
function sendTestEmail() {
|
|
var template = document.getElementById('cms-email-template').value;
|
|
var sendBtn = document.getElementById('btn-test-email');
|
|
// Show inline email input instead of prompt()
|
|
var existing = document.getElementById('admin-test-email-row');
|
|
if (existing) { existing.remove(); return; }
|
|
var row = document.createElement('div');
|
|
row.id = 'admin-test-email-row';
|
|
row.style.cssText = 'margin-top:8px;display:flex;gap:6px;align-items:center;';
|
|
row.innerHTML = '<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'), ['scribe']],
|
|
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']],
|
|
['learning', document.getElementById('cms-learning-prompts'), ['learning-image']]
|
|
];
|
|
try {
|
|
var data = await promptRequest('/api/admin/config/prompts');
|
|
groups.forEach(function(group) {
|
|
if (!group[1]) return;
|
|
group[1].replaceChildren();
|
|
var prompts = (data.prompts || []).filter(function(p) { return p.editable === true && group[2].indexOf(p.family) !== -1; });
|
|
if (!prompts.length) {
|
|
group[1].textContent = 'No editable prompts available in this section.';
|
|
return;
|
|
}
|
|
group[1].appendChild(createPromptFamilyEditor(group[0], prompts));
|
|
});
|
|
promptsLoaded = true;
|
|
} catch (error) {
|
|
groups.forEach(function(group) {
|
|
if (!group[1]) return;
|
|
group[1].textContent = 'Could not load prompts: ' + error.message + ' ';
|
|
var retry = document.createElement('button');
|
|
retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts';
|
|
retry.onclick = loadPromptList;
|
|
group[1].appendChild(retry);
|
|
});
|
|
} finally { promptsLoading = false; }
|
|
}
|
|
|
|
// Simple prompt editor: pick a prompt, edit it, save it, or restore the
|
|
// shipped default. Revision tracking stays internal only so the server
|
|
// optimistic concurrency contract keeps working; no history UI is shown.
|
|
function createPromptFamilyEditor(family, prompts) {
|
|
var editor = document.createElement('div');
|
|
editor.className = 'cms-prompt-family';
|
|
editor.dataset.family = family;
|
|
editor.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
|
|
// Only static markup is parsed; all catalogue content is assigned as text.
|
|
editor.innerHTML =
|
|
'<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;font-family:inherit;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>' +
|
|
'<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></div>' +
|
|
'<p class="prompt-status" role="status" style="margin:0;font-size:12px;color:var(--g600);"></p>';
|
|
var select = editor.querySelector('.prompt-select');
|
|
var text = editor.querySelector('.prompt-draft');
|
|
var status = editor.querySelector('.prompt-status');
|
|
var buttons = {};
|
|
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
|
|
|
|
// Per-key state: server value, revision baseline (internal only) and live draft.
|
|
var states = new Map();
|
|
prompts.forEach(function(p) {
|
|
var option = document.createElement('option');
|
|
option.value = p.dbKey;
|
|
option.textContent = p.key;
|
|
select.appendChild(option);
|
|
states.set(p.dbKey, { value: p.value, revision: p.revision, draft: p.value });
|
|
});
|
|
var key = select.options.length ? select.options[0].value : null;
|
|
var busy = false;
|
|
if (key) text.value = states.get(key).draft;
|
|
|
|
function state() { return key ? states.get(key) : null; }
|
|
function controls() {
|
|
buttons.save.disabled = busy;
|
|
buttons.reset.disabled = busy;
|
|
select.disabled = busy;
|
|
}
|
|
async function run(task) {
|
|
if (busy) return;
|
|
busy = true; controls(); status.textContent = 'Working...';
|
|
try { await task(); }
|
|
catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; }
|
|
finally { busy = false; controls(); }
|
|
}
|
|
async function mutate(action) {
|
|
var targetKey = key;
|
|
var st = states.get(targetKey);
|
|
var submitted = text.value;
|
|
st.draft = submitted;
|
|
var body = { expectedRevision: st.revision };
|
|
if (action === 'save') body.value = submitted;
|
|
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : '/api/admin/config/prompts/' + encodeURIComponent(targetKey) + '/reset',
|
|
action === 'save' ? 'PUT' : 'POST', body);
|
|
st.revision = data.revision;
|
|
if (text.value === submitted) text.value = data.value;
|
|
status.textContent = action === 'save' ? 'Saved.' : 'Restored shipped default.';
|
|
}
|
|
select.addEventListener('change', function() {
|
|
if (key) states.get(key).draft = text.value;
|
|
key = select.value;
|
|
text.value = states.get(key).draft;
|
|
status.textContent = '';
|
|
});
|
|
buttons.save.addEventListener('click', function() { run(function() { return mutate('save'); }); });
|
|
buttons.reset.addEventListener('click', function() {
|
|
showConfirm('Restore only this prompt to its shipped default? This replaces the current value.', function() {
|
|
run(function() { return mutate('reset'); });
|
|
});
|
|
});
|
|
controls();
|
|
return editor;
|
|
}
|
|
|
|
// ---- SMTP ----
|
|
|
|
function loadSmtp() {
|
|
fetch('/api/admin/config/smtp/status', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
var badge = document.getElementById('smtp-source-badge');
|
|
if (badge) {
|
|
if (data.source === 'env') badge.textContent = 'Configured via env';
|
|
else if (data.source === 'database') badge.textContent = 'Configured in DB';
|
|
else { badge.textContent = 'Not configured'; badge.style.background = 'var(--red-light)'; badge.style.color = 'var(--red)'; }
|
|
}
|
|
var h = document.getElementById('cms-smtp-host'); if (h) h.value = data.host || '';
|
|
var p = document.getElementById('cms-smtp-port'); if (p) p.value = data.port || '587';
|
|
var u = document.getElementById('cms-smtp-user'); if (u) u.value = data.user || '';
|
|
var f = document.getElementById('cms-smtp-from'); if (f) f.value = data.from || '';
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
function saveSmtp() {
|
|
var host = document.getElementById('cms-smtp-host').value.trim();
|
|
var port = document.getElementById('cms-smtp-port').value.trim();
|
|
var user = document.getElementById('cms-smtp-user').value.trim();
|
|
var pass = document.getElementById('cms-smtp-pass').value.trim();
|
|
var from = document.getElementById('cms-smtp-from').value.trim();
|
|
var secure = document.getElementById('cms-smtp-secure').value;
|
|
if (!host) { showToast('SMTP host required', 'error'); return; }
|
|
|
|
fetch('/api/admin/config/smtp', {
|
|
method: 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ host: host, port: port, user: user, pass: pass, from: from, secure: secure === 'true' })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) { showToast('SMTP settings saved', 'success'); loadSmtp(); }
|
|
else showToast(data.error || 'Save failed', 'error');
|
|
})
|
|
.catch(function() { showToast('Request failed', 'error'); });
|
|
}
|
|
|
|
function clearSmtp() {
|
|
showConfirm('Clear DB SMTP settings? Env vars will be used if set.', function() {
|
|
fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); }
|
|
else showToast(data.error || 'Failed', 'error');
|
|
})
|
|
.catch(function() { showToast('Request failed', 'error'); });
|
|
});
|
|
}
|
|
|
|
// ---- AUTO-DELETE / RESET ----
|
|
|
|
function saveAutoDelete() {
|
|
var days = document.getElementById('cms-auto-delete-days').value;
|
|
putConfig('site.auto_delete_days', days)
|
|
.then(function() {
|
|
var el = document.getElementById('admin-auto-delete-days');
|
|
if (el) el.textContent = days;
|
|
showToast('Auto-delete set to ' + days + ' days', 'success');
|
|
})
|
|
.catch(function() { showToast('Save failed', 'error'); });
|
|
}
|
|
|
|
function resetAllDefaults() {
|
|
showConfirm('Reset all settings to factory defaults? Announcements, feature flags, email templates will be reset. SMTP and custom models are preserved.', function() {
|
|
fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
showToast(data.message || 'Reset to defaults', 'success');
|
|
cmsLoaded = false;
|
|
loadCms();
|
|
} else showToast(data.error || 'Reset failed', 'error');
|
|
})
|
|
.catch(function() { showToast('Request failed', 'error'); });
|
|
}, { danger: true, confirmText: 'Reset' });
|
|
}
|
|
|
|
// ---- HELPERS ----
|
|
|
|
function putConfig(key, value) {
|
|
return fetch('/api/admin/config/' + encodeURIComponent(key), {
|
|
method: 'PUT',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ value: value })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
return data;
|
|
});
|
|
}
|
|
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN CLINICAL ASSISTANT SETTINGS
|
|
// ============================================================
|
|
initClinicalAssistantAdmin(adminEscapeHtml);
|
|
initImageSettings();
|
|
|
|
// ============================================================
|
|
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
|
|
// ============================================================
|
|
{
|
|
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'admin') {
|
|
loadAdminModels();
|
|
}
|
|
});
|
|
|
|
// Catch-up for a tab that is already active and loaded at module init.
|
|
if (adminTabActive()) loadAdminModels();
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('#btn-discover-models')) discoverModels();
|
|
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
|
|
if (e.target.closest('#btn-clear-all-models')) clearAllModels();
|
|
if (e.target.closest('.admin-model-test-btn')) {
|
|
var btn = e.target.closest('.admin-model-test-btn');
|
|
testModel(btn.dataset.mid, btn);
|
|
}
|
|
});
|
|
|
|
// Allow Enter key to trigger search
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.target.id === 'admin-model-search' && e.key === 'Enter') {
|
|
e.preventDefault();
|
|
discoverModels();
|
|
}
|
|
});
|
|
|
|
const esc = adminEscapeHtml;
|
|
|
|
function loadAdminModels() {
|
|
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
|
|
// Provider badge
|
|
var badge = document.getElementById('admin-model-provider-badge');
|
|
if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase();
|
|
|
|
// Default model selector — built-ins + custom models
|
|
var defaultSel = document.getElementById('admin-default-model');
|
|
if (defaultSel) {
|
|
defaultSel.innerHTML = '';
|
|
data.models.forEach(function(m) {
|
|
if (m.enabled === false) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name;
|
|
defaultSel.appendChild(opt);
|
|
});
|
|
(data.custom || []).forEach(function(m) {
|
|
if (m.enabled === false) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name;
|
|
defaultSel.appendChild(opt);
|
|
});
|
|
// Pre-select the saved default
|
|
if (data.defaultModel) defaultSel.value = data.defaultModel;
|
|
}
|
|
|
|
// Built-in models with toggle
|
|
var container = document.getElementById('admin-builtin-models');
|
|
if (container) {
|
|
if (data.litellmHint) {
|
|
container.innerHTML = '<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>' +
|
|
'</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) + '" 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>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn);
|
|
});
|
|
});
|
|
})
|
|
.catch(function(err) {
|
|
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Request failed: ' + esc(err.message) + '</p>';
|
|
});
|
|
}
|
|
|
|
function addDiscoveredModel(id, name, btn) {
|
|
fetch('/api/admin/config/models/add-discovered', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
body: JSON.stringify({ id: id, name: name })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) {
|
|
showToast('Added: ' + name + ' — now select it as default and click Set Default', 'success');
|
|
if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; }
|
|
// Refresh model lists, then auto-select the newly added model
|
|
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(refreshed) {
|
|
if (!refreshed.success) return;
|
|
var defaultSel = document.getElementById('admin-default-model');
|
|
if (defaultSel) {
|
|
defaultSel.innerHTML = '';
|
|
refreshed.models.forEach(function(m) {
|
|
if (m.enabled === false) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name;
|
|
defaultSel.appendChild(opt);
|
|
});
|
|
(refreshed.custom || []).forEach(function(m) {
|
|
if (m.enabled === false) return;
|
|
var opt = document.createElement('option');
|
|
opt.value = m.id;
|
|
opt.textContent = m.name;
|
|
defaultSel.appendChild(opt);
|
|
});
|
|
// Auto-select the model just added
|
|
defaultSel.value = id;
|
|
}
|
|
renderCustomModels(refreshed.custom || []);
|
|
});
|
|
} else {
|
|
showToast(data.error || 'Failed', 'error');
|
|
}
|
|
})
|
|
.catch(function() { showToast('Request failed', 'error'); });
|
|
}
|
|
|
|
function renderCustomModels(custom) {
|
|
var container = document.getElementById('admin-custom-models-list');
|
|
if (!container) return;
|
|
if (!custom || custom.length === 0) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<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>' +
|
|
'<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 CITATION QUALITY
|
|
// A citation that resolves to nothing is never linked, so it is invisible
|
|
// unless someone counts it. This is the reading end of that.
|
|
// ============================================================
|
|
{
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'admin') loadCitationSummary();
|
|
});
|
|
if (adminTabActive()) loadCitationSummary();
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('#btn-view-citation-audit')) openCitationAudit();
|
|
if (e.target.closest('#citation-audit-close') || e.target.id === 'citation-audit-modal') closeCitationAudit();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') closeCitationAudit();
|
|
});
|
|
|
|
const esc = adminEscapeHtml;
|
|
|
|
function fetchAudit() {
|
|
return fetch('/api/admin/citation-audit', { headers: getAuthHeaders() }).then(function(r) { return r.json(); });
|
|
}
|
|
|
|
function loadCitationSummary() {
|
|
fetchAudit().then(function(data) {
|
|
var badge = document.getElementById('admin-citation-summary');
|
|
if (!badge || !data.success) return;
|
|
var answers = (data.totals && data.totals.answers) || 0;
|
|
badge.textContent = answers ? answers + ' flagged (30 days)' : 'none flagged';
|
|
badge.style.background = answers ? 'var(--amber)' : 'var(--green)';
|
|
badge.style.color = 'white';
|
|
}).catch(function() {});
|
|
}
|
|
|
|
function closeCitationAudit() {
|
|
var modal = document.getElementById('citation-audit-modal');
|
|
if (modal) modal.remove();
|
|
}
|
|
|
|
function openCitationAudit() {
|
|
fetchAudit().then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Could not load');
|
|
closeCitationAudit();
|
|
var modal = document.createElement('div');
|
|
modal.id = 'citation-audit-modal';
|
|
modal.className = 'modal';
|
|
modal.innerHTML = '<div class="modal-content" style="width:min(860px,94vw);max-height:84vh;display:flex;flex-direction:column;">' +
|
|
'<div class="modal-header"><h2>Citation quality</h2>' +
|
|
'<button type="button" class="modal-close" id="citation-audit-close" aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
|
|
'<div class="modal-body" style="overflow:auto;">' + rows(data.rows || []) + '</div></div>';
|
|
document.body.appendChild(modal);
|
|
}).catch(function(err) { showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function rows(list) {
|
|
if (!list.length) {
|
|
return '<p style="font-size:13px;color:var(--g500);">Nothing flagged in the last 30 days — every citation pointed at a source that was returned.</p>';
|
|
}
|
|
return list.map(function(row) {
|
|
var missing = (row.unverifiable || []).map(function(n) { return '[' + n + ']'; }).join(' ');
|
|
var titles = (row.source_titles || []).map(function(t, i) {
|
|
return '<li style="margin:0;">[' + (i + 1) + '] ' + esc(t) + '</li>';
|
|
}).join('');
|
|
return '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px 12px;margin-bottom:10px;">' +
|
|
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px;">' +
|
|
'<span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--amber);color:white;">' + esc(missing) + ' unmatched</span>' +
|
|
'<span style="font-size:11px;color:var(--g500);">' + esc(new Date(row.created_at).toLocaleString()) +
|
|
' · ' + esc(String(row.cited_count)) + ' citations written, ' + esc(String(row.source_count)) + ' sources returned' +
|
|
(row.user_email ? ' · ' + esc(row.user_email) : '') + '</span>' +
|
|
'</div>' +
|
|
'<div style="font-size:13px;color:var(--g800);margin-bottom:6px;overflow-wrap:anywhere;"><strong>Question:</strong> ' + esc(row.question || '(none)') + '</div>' +
|
|
(titles ? '<details><summary style="font-size:12px;color:var(--g600);cursor:pointer;">Sources returned</summary><ul style="font-size:12px;color:var(--g600);margin:6px 0 0;padding-left:18px;">' + titles + '</ul></details>' : '') +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN LOCKDOWN (display)
|
|
// The server refuses locked writes regardless; this only stops an admin
|
|
// filling in a field that was never going to save. Settings stay visible, so
|
|
// the configuration can still be read.
|
|
//
|
|
// The state rides along on the invites response rather than a request of its
|
|
// own: the admin panel already makes enough calls on open.
|
|
// ============================================================
|
|
{
|
|
window.applyAdminLockdown = function(state) {
|
|
if (!state || !state.enabled) return;
|
|
var panel = document.getElementById('admin-tab');
|
|
if (!panel || !panel.querySelector('.card')) return;
|
|
lockdownBanner(panel, state);
|
|
lockdownFields(panel, state);
|
|
};
|
|
|
|
function lockdownBanner(panel, state) {
|
|
if (document.getElementById('admin-lockdown-banner')) return;
|
|
var note = document.createElement('div');
|
|
note.id = 'admin-lockdown-banner';
|
|
note.style.cssText = 'margin:0 0 12px;padding:10px 14px;border:1px solid var(--amber);background:var(--amber-light);border-radius:8px;font-size:13px;color:var(--g800);';
|
|
note.innerHTML = '<i class="fas fa-lock"></i> <strong>Admin lockdown is on.</strong> ' +
|
|
adminEscapeHtml(state.reason) + ' Settings are shown but cannot be changed here.';
|
|
panel.insertBefore(note, panel.firstChild);
|
|
}
|
|
|
|
// Everything inside the panel, except the controls that stay operational and
|
|
// the buttons that only read.
|
|
function lockdownFields(panel, state) {
|
|
var editable = ['admin-invite', 'btn-create-invite', 'cms-ann', 'announcement',
|
|
'admin-users-search', 'registration'];
|
|
panel.querySelectorAll('input, select, textarea, button').forEach(function(el) {
|
|
var id = el.id || '';
|
|
if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return;
|
|
var label = (el.textContent || '') + ' ' + id;
|
|
if (/search|test|discover|refresh|reload|retry|copy/i.test(label)) return;
|
|
el.disabled = true;
|
|
el.title = state.reason;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN REGISTRATION INVITES
|
|
// The code exists in readable form exactly once: in the response to creating
|
|
// it. Everything after works from the id and the last four characters.
|
|
// ============================================================
|
|
{
|
|
document.addEventListener('tabChanged', function(e) {
|
|
if (e.detail && e.detail.tab === 'admin') loadInvites();
|
|
});
|
|
if (adminTabActive()) loadInvites();
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.closest('#btn-create-invite')) createInvite();
|
|
var revoke = e.target.closest('.admin-invite-revoke');
|
|
if (revoke) inviteAction(revoke.dataset.id, 'revoke');
|
|
var del = e.target.closest('.admin-invite-delete');
|
|
if (del) inviteAction(del.dataset.id, 'delete');
|
|
var copy = e.target.closest('.admin-invite-copy');
|
|
if (copy && navigator.clipboard) {
|
|
navigator.clipboard.writeText(copy.dataset.code)
|
|
.then(function() { showToast('Invitation code copied', 'success'); })
|
|
.catch(function() { showToast('Could not copy; select it by hand', 'error'); });
|
|
}
|
|
});
|
|
document.addEventListener('change', function(e) {
|
|
if (e.target.id === 'admin-invite-only') setInviteOnly(e.target.checked);
|
|
});
|
|
|
|
const esc = adminEscapeHtml;
|
|
|
|
function loadInvites() {
|
|
fetch('/api/admin/invites', { headers: getAuthHeaders() })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) return;
|
|
var toggle = document.getElementById('admin-invite-only');
|
|
if (toggle) toggle.checked = !!data.inviteOnly;
|
|
renderInvites(data.invites || []);
|
|
// Painted after the panel has rendered, from the same response.
|
|
if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown);
|
|
})
|
|
.catch(function() {});
|
|
}
|
|
|
|
function setInviteOnly(on) {
|
|
fetch('/api/admin/config/' + encodeURIComponent('registration_invite_only'), {
|
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: on ? 'true' : 'false' })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Could not save');
|
|
showToast(on ? 'Registration now requires an invitation' : 'Registration no longer requires an invitation', 'success');
|
|
})
|
|
.catch(function(err) { showToast(err.message, 'error'); loadInvites(); });
|
|
}
|
|
|
|
function createInvite() {
|
|
var note = (document.getElementById('admin-invite-note') || {}).value || '';
|
|
var days = (document.getElementById('admin-invite-days') || {}).value || '7';
|
|
var out = document.getElementById('admin-invite-new');
|
|
fetch('/api/admin/invites', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ note: note, days: days })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Could not create');
|
|
if (out) {
|
|
out.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--green);background:var(--green-light);border-radius:8px;flex-wrap:wrap;">' +
|
|
'<code style="font-size:15px;font-weight:700;letter-spacing:.06em;">' + esc(data.code) + '</code>' +
|
|
'<button type="button" class="btn-sm btn-ghost admin-invite-copy" data-code="' + esc(data.code) + '"><i class="fas fa-copy"></i> Copy</button>' +
|
|
'<span style="font-size:12px;color:var(--g600);">Valid ' + esc(String(data.days)) + ' days. This is the only time it is shown.</span>' +
|
|
'</div>';
|
|
}
|
|
var noteEl = document.getElementById('admin-invite-note');
|
|
if (noteEl) noteEl.value = '';
|
|
loadInvites();
|
|
})
|
|
.catch(function(err) { showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function inviteAction(id, action) {
|
|
var request = action === 'delete'
|
|
? fetch('/api/admin/invites/' + encodeURIComponent(id), { method: 'DELETE', headers: getAuthHeaders() })
|
|
: fetch('/api/admin/invites/' + encodeURIComponent(id) + '/revoke', { method: 'POST', headers: getAuthHeaders() });
|
|
request.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Failed');
|
|
showToast(action === 'delete' ? 'Invitation deleted' : 'Invitation revoked', 'info');
|
|
loadInvites();
|
|
})
|
|
.catch(function(err) { showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function renderInvites(rows) {
|
|
var container = document.getElementById('admin-invites-list');
|
|
if (!container) return;
|
|
if (!rows.length) {
|
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No invitations yet.</p>';
|
|
return;
|
|
}
|
|
var colours = { active: 'var(--green)', used: 'var(--g400)', expired: 'var(--amber)', revoked: 'var(--red)' };
|
|
container.innerHTML = rows.map(function(row) {
|
|
var when = row.status === 'used' ? 'used ' + new Date(row.used_at).toLocaleDateString()
|
|
: row.status === 'revoked' ? 'revoked'
|
|
: 'expires ' + new Date(row.expires_at).toLocaleDateString();
|
|
var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : '';
|
|
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
|
|
'<span style="font-size:10px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:10px;color:white;background:' + (colours[row.status] || 'var(--g400)') + ';">' + esc(row.status) + '</span>' +
|
|
'<code style="font-size:12px;">****-' + esc(row.code_hint) + '</code>' +
|
|
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(row.note || '') + '</span>' +
|
|
'<span style="font-size:11px;color:var(--g500);">' + esc(when) + who + '</span>' +
|
|
(row.status === 'active' ? '<button type="button" class="btn-sm btn-ghost admin-invite-revoke" data-id="' + esc(String(row.id)) + '">Revoke</button>' : '') +
|
|
'<button type="button" class="btn-sm btn-ghost admin-invite-delete" data-id="' + esc(String(row.id)) + '" style="color:var(--red);" title="Remove from this list"><i class="fas fa-trash"></i></button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ADMIN IMAGE MODEL MANAGEMENT
|
|
// ============================================================
|
|
// Unlike TTS and STT there is no single default to Set: an image model is
|
|
// chosen per workflow, so discovery here ends in a Test, and the workflow
|
|
// pickers in the Clinical Assistant card consume the same discovery call.
|
|
// Nothing loads on tab entry: the workflow pickers already make the one
|
|
// discovery call opening Admin needs, so this card asks only when searched.
|
|
{
|
|
// + Add puts a model in the Clinical Assistant's Image models list
|
|
// (clinical_assistant.image_model_roster); ticking it there offers it to users.
|
|
document.addEventListener('assistant-image-roster', syncImageRows);
|
|
document.addEventListener('click', function(e) {
|
|
var add = e.target.closest('.admin-image-add-btn');
|
|
if (add) { toggleImageRoster(add.dataset.id, add); return; }
|
|
if (e.target.closest('#btn-discover-image')) discoverImageModels();
|
|
if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || '');
|
|
var pick = e.target.closest('.admin-image-test-btn');
|
|
if (pick) {
|
|
var field = document.getElementById('admin-image-test-model');
|
|
if (field) field.value = pick.dataset.id;
|
|
testImageModel(pick.dataset.id, pick);
|
|
}
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.target.id === 'admin-image-search' && e.key === 'Enter') { e.preventDefault(); discoverImageModels(); }
|
|
if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); }
|
|
});
|
|
|
|
const esc = adminEscapeHtml;
|
|
|
|
function discoverImageModels() {
|
|
var search = (document.getElementById('admin-image-search') || {}).value || '';
|
|
var container = document.getElementById('admin-image-discovered');
|
|
var hint = document.getElementById('admin-image-discover-hint');
|
|
if (!container) return;
|
|
container.innerHTML = '<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/image-models/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 || [];
|
|
var badge = document.getElementById('admin-image-provider-badge');
|
|
if (badge && !search) badge.textContent = data.count + ' available';
|
|
if (items.length === 0) {
|
|
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No image 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 + ' image model' + (data.count === 1 ? '' : 's') + '</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-ghost admin-image-test-btn" type="button" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
|
|
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(m.name || m.id) + '</span>' +
|
|
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
|
|
imageAddButton(m.id) +
|
|
'</div>';
|
|
}).join('');
|
|
})
|
|
.catch(function(err) {
|
|
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
|
|
});
|
|
}
|
|
|
|
function currentImageRoster() {
|
|
return Array.isArray(window._assistantImageRoster) ? window._assistantImageRoster : [];
|
|
}
|
|
|
|
function imageAddButton(id) {
|
|
var added = currentImageRoster().indexOf(id) !== -1;
|
|
return added
|
|
? '<button class="btn-sm btn-ghost admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="In the Clinical Assistant list. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
|
|
: '<button class="btn-sm btn-primary admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the Clinical Assistant\'s Image models list" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
|
|
}
|
|
|
|
// Rows rendered before the roster loaded (or after it changed) catch up here.
|
|
function syncImageRows() {
|
|
var container = document.getElementById('admin-image-discovered');
|
|
if (!container) return;
|
|
container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); });
|
|
}
|
|
|
|
function toggleImageRoster(id, btn) {
|
|
if (!id) return;
|
|
var roster = currentImageRoster();
|
|
var added = roster.indexOf(id) !== -1;
|
|
var next = added ? roster.filter(function(x) { return x !== id; }) : roster.concat([id]);
|
|
adminSetButtonText(btn, '...', true);
|
|
fetch('/api/admin/config/' + encodeURIComponent('clinical_assistant.image_model_roster'), {
|
|
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data.success) throw new Error(data.error || 'Could not update the image model list');
|
|
window._assistantImageRoster = next;
|
|
document.dispatchEvent(new CustomEvent('assistant-image-roster-changed', { detail: { roster: next.slice() } }));
|
|
syncImageRows();
|
|
showToast(added ? id + ' removed from the Clinical Assistant list'
|
|
: id + ' added. Tick it under Clinical Assistant to offer it to users.', 'success');
|
|
})
|
|
.catch(function(err) { syncImageRows(); showToast(err.message || 'Request failed', 'error'); });
|
|
}
|
|
|
|
function testImageModel(modelId, btn) {
|
|
var id = String(modelId || '').trim();
|
|
var result = document.getElementById('admin-image-test-result');
|
|
if (!id) {
|
|
if (result) result.innerHTML = '<span style="color:var(--red);">Enter or pick a model id first.</span>';
|
|
return;
|
|
}
|
|
if (btn) adminSetButtonText(btn, '...', true);
|
|
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating with ' + esc(id) + '... this can take a minute.';
|
|
fetch('/api/admin/config/image-models/test', {
|
|
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: id })
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (btn) adminSetButtonText(btn, 'Test', false);
|
|
if (!data.success) {
|
|
if (result) result.innerHTML = '<span style="color:var(--red);">' + esc(data.error || 'Image test failed') + '</span>';
|
|
return;
|
|
}
|
|
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
|
if (result) {
|
|
result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' +
|
|
(src ? '<div style="margin-top:8px;"><img src="' + esc(src) + '" alt="Test image generated by ' + esc(id) + '" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
if (btn) adminSetButtonText(btn, 'Test', false);
|
|
if (result) result.innerHTML = '<span style="color:var(--red);">Request failed: ' + esc(err.message) + '</span>';
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
}
|