function assistantBudgetMeta(budget, savedOverride) { var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null; if (savedOverride) { return 'Saved override in use. Clear this field to fall back to CLINICAL_ASSISTANT_CONVERSATION_CHARS' + (limit ? ' (' + limit + ' UTF-16 code units).' : '.'); } if (!limit) return 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.'; // conversationBudget() reports 'default' when the env var is unset, so naming // the env var alone would be wrong in the most common deployment. return budget.source === 'environment' ? 'Currently ' + limit + ' UTF-16 code units from CLINICAL_ASSISTANT_CONVERSATION_CHARS. Set a value here to override it.' : 'Currently the built-in default of ' + limit + ' UTF-16 code units (CLINICAL_ASSISTANT_CONVERSATION_CHARS is unset). Set a value here to override it.'; } export function initClinicalAssistantAdmin(adminEscapeHtml) { let configState = 'idle'; // Image models an admin added from the Image Generation card. The list below // offers these, plus anything already allowed or configured so a saved choice // never drops out of view. let imageRosterSaved = []; let savedChatAllowed = []; let savedImageAllowed = []; let chatRoster = []; let imageRoster = []; function parseAssistantList(value) { return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean); } function renderAssistantCheckboxList(containerId, candidates, saved, emptyText) { var container = document.getElementById(containerId); if (!container) return; var ids = candidates.slice(); saved.forEach(function(id) { if (ids.indexOf(id) === -1) ids.push(id); }); ids = ids.filter(Boolean).filter(function(id, i, all) { return all.indexOf(id) === i; }).sort(); container.innerHTML = ''; if (!ids.length) { var empty = document.createElement('p'); empty.style.cssText = 'margin:0;font-size:12px;color:var(--g500);'; empty.textContent = emptyText || 'No models available. Add or enable models first.'; container.appendChild(empty); return; } ids.forEach(function(id) { var row = document.createElement('label'); row.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 0;'; var box = document.createElement('input'); box.type = 'checkbox'; box.value = id; box.checked = saved.indexOf(id) !== -1; var span = document.createElement('span'); span.textContent = id; row.appendChild(box); row.appendChild(span); container.appendChild(row); }); } function checkedAssistantModels(containerId) { var container = document.getElementById(containerId); if (!container) return []; return Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]:checked'), function(box) { return box.value; }); } // Built from the admin's roster, not from discovery: the gateway advertises // dozens of image models, and this used to wait on a dropdown that no longer // exists, so only four hard-coded fallbacks ever appeared. function renderAssistantImageModelCheckboxes() { imageRoster = imageRosterSaved.concat([window._assistantImageModelValue]); renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed, 'No image models added yet. Add them under Image Generation below, then tick them here.'); } // Adding or removing in the Image Generation card updates this list at once, // keeping any ticks made here that have not been saved yet. document.addEventListener('assistant-image-roster-changed', function(e) { imageRosterSaved = (e.detail && Array.isArray(e.detail.roster)) ? e.detail.roster.slice() : imageRosterSaved; if (configState !== 'ready') return; savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models'); renderAssistantImageModelCheckboxes(); }); document.addEventListener('tabChanged', function(e) { if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin(); }); document.addEventListener('click', function(e) { if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin(); if (e.target.closest('#btn-retry-assistant-config')) loadAssistantAdmin(); if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel(); if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool(); if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool(); if (e.target.closest('#btn-save-review-model')) saveReviewModel(); }); // ── Slide review ──────────────────────────────────────────────────── // Its own control rather than part of Save model & retrieval settings: it is // the one setting that spends money on every generation without a user having // asked for anything, so turning it on should be a deliberate act. function saveReviewModel() { var select = document.getElementById('mr-review-model'); var status = document.getElementById('mr-review-status'); if (!select) return; if (status) { status.textContent = 'Saving...'; status.style.color = 'var(--g500)'; } putAssistantConfig('my_resources.review_model', select.value || '') .then(function() { if (!status) return; status.textContent = select.value ? 'Decks will be reviewed by ' + select.value : 'Slide review is off'; status.style.color = 'var(--green)'; }) .catch(function(err) { if (!status) return; status.textContent = err.message; status.style.color = 'var(--red)'; }); } // Any chat model the gateway offers. Whether it can actually see an image is // not something the model list says, so the choice is the administrator's — // a deck reviewed by a text-only model is discarded rather than applied. function renderReviewModel(models, saved) { var select = document.getElementById('mr-review-model'); if (!select) return; select.innerHTML = ''; var off = document.createElement('option'); off.value = ''; off.textContent = 'Off — do not review decks'; select.appendChild(off); (Array.isArray(models) ? models : []).filter(function(m) { return m && m.id; }).forEach(function(m) { var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name || m.id; select.appendChild(opt); }); if (saved && !Array.prototype.some.call(select.options, function(o) { return o.value === saved; })) { var kept = document.createElement('option'); kept.value = saved; kept.textContent = saved + ' (saved/custom)'; select.appendChild(kept); } select.value = saved || ''; } function updateAssistantLoadState() { var save = document.getElementById('btn-save-assistant-config'); if (save) save.disabled = configState !== 'ready'; // The retry lives inside an error message rather than sitting beside Save // looking like an ordinary control, which is how it read before. var errorBox = document.getElementById('assistant-config-error'); if (errorBox) errorBox.hidden = configState !== 'failed'; var status = document.getElementById('assistant-admin-status'); // The failure case is spelled out in the error box above, so repeating it // here would only be noise. if (status && configState !== 'ready') { status.textContent = configState === 'failed' ? '' : 'Loading settings...'; } else if (status && !/^Saved |^Not saved/.test(status.textContent)) { status.textContent = 'Settings loaded. Unsaved changes are kept until you press Save.'; } } function loadAssistantAdmin() { if (configState === 'loading' || configState === 'ready') return; configState = 'loading'; updateAssistantLoadState(); Promise.all([ fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) { if (!r.ok) throw new Error('Model discovery failed'); return r.json(); }).catch(function() { return {}; }), fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) { if (!r.ok) throw new Error('Settings request failed'); return r.json(); }) ]).then(function(results) { var modelsData = results[0] || {}; var configData = results[1]; var budget = configData && configData.conversationBudget; var validBudget = budget && Number.isInteger(budget.limit) && budget.limit >= 1000 && budget.limit <= 1000000 && budget.unit === 'characters' && budget.measure === 'UTF-16 code units' && budget.env === 'CLINICAL_ASSISTANT_CONVERSATION_CHARS' && ['environment', 'default'].includes(budget.source); // Validate the entire response before touching any setting or draft. if (!configData || configData.success !== true || !Array.isArray(configData.config) || !configData.config.every(function(row) { return row && typeof row.key === 'string' && row.key && typeof row.value === 'string'; }) || !validBudget) throw new Error('Invalid settings response'); var cfg = Object.create(null); configData.config.forEach(function(row) { cfg[row.key] = row.value; }); var chatSelect = document.getElementById('assistant-chat-model'); if (chatSelect) { var savedChatModel = cfg['clinical_assistant.chat_model'] || ''; var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default'; chatSelect.innerHTML = ''; var defaultOpt = document.createElement('option'); defaultOpt.value = ''; defaultOpt.textContent = defaultLabel; chatSelect.appendChild(defaultOpt); (Array.isArray(modelsData.models) ? modelsData.models : []).filter(function(m) { return m && typeof m.id === 'string' && m.id; }).forEach(function(m) { var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name || m.id; chatSelect.appendChild(opt); }); if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) { var saved = document.createElement('option'); saved.value = savedChatModel; saved.textContent = savedChatModel + ' (saved/custom)'; chatSelect.appendChild(saved); } chatSelect.value = savedChatModel; } renderReviewModel(modelsData.models, cfg['my_resources.review_model'] || ''); window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || ''; savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']); savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']); imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']); window._assistantImageRoster = imageRosterSaved.slice(); // Only a nudge for the Image Generation card's buttons: it must never be // able to fail the settings load itself. try { document.dispatchEvent(new CustomEvent('assistant-image-roster', { detail: { roster: imageRosterSaved.slice() } })); } catch (e) {} chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']); renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed); renderAssistantImageModelCheckboxes(); setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8'); setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400'); setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts var sourcesBox = document.getElementById('assistant-show-sources'); if (sourcesBox) { var saved = cfg['clinical_assistant.show_sources']; if (saved === undefined || saved === '') saved = cfg['clinical_assistant.citations_enabled']; // legacy key sourcesBox.checked = String(saved) !== 'false'; } var budgetInput = document.getElementById('assistant-conversation-budget'); var cfgBudget = cfg['clinical_assistant.conversation_chars']; if (budgetInput) { // Empty means "no override". Prefilling the environment value here would // turn the next Save into an accidental override, making the documented // "leave empty to use the environment" path unreachable. budgetInput.value = cfgBudget ? String(cfgBudget) : ''; if (budget && Number.isInteger(budget.limit)) budgetInput.placeholder = String(budget.limit); } var budgetMeta = document.getElementById('assistant-conversation-budget-meta'); if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget); configState = 'ready'; updateAssistantLoadState(); loadAssistantPromptPoolStatus(); }).catch(function() { configState = 'failed'; updateAssistantLoadState(); var budgetMetaFailed = document.getElementById('assistant-conversation-budget-meta'); if (budgetMetaFailed) budgetMetaFailed.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.'; }); } function testAssistantChatModel() { var model = getValue('assistant-chat-model'); var result = document.getElementById('assistant-chat-test-result'); if (!model) { var select = document.getElementById('assistant-chat-model'); var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default'; if (result) result.textContent = 'Testing ' + selected + '...'; } else if (result) { result.textContent = 'Testing ' + model + '...'; } fetch('/api/admin/config/models/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() }) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Chat model test failed'); if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim(); showToast('Chat model works', 'success'); }).catch(function(err) { if (result) result.textContent = err.message; showToast(err.message, 'error'); }); } function getGlobalDefaultFromAssistantSelect() { var select = document.getElementById('assistant-chat-model'); if (!select || !select.options[0]) return ''; var match = select.options[0].textContent.match(/\((.+)\)$/); return match ? match[1] : ''; } function loadAssistantPromptPoolStatus() { var result = document.getElementById('assistant-prompt-pool-status'); if (result) result.textContent = 'Checking prompt pool...'; fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { if (!data.success) throw new Error(data.error || 'Prompt pool status failed'); renderAssistantPromptPoolStatus(data.meta); renderAssistantPromptPoolSnapshots(data.snapshots || []); }) .catch(function(err) { if (result) result.textContent = err.message; }); } function regenerateAssistantPromptPool() { var result = document.getElementById('assistant-prompt-pool-status'); var btn = document.getElementById('btn-regenerate-assistant-prompt-pool'); if (btn) btn.disabled = true; if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...'; fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({}) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed'); renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() }); renderAssistantPromptPoolSnapshots(data.snapshots || []); showToast('Prompt pool regenerated', 'success'); }).catch(function(err) { if (result) result.textContent = err.message; showToast(err.message, 'error'); }).finally(function() { if (btn) btn.disabled = false; }); } function renderAssistantPromptPoolStatus(meta) { var result = document.getElementById('assistant-prompt-pool-status'); if (!result) return; if (!meta) { result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.'; return; } var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time'; result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.'; } function renderAssistantPromptPoolSnapshots(snapshots) { var select = document.getElementById('assistant-prompt-pool-snapshots'); if (!select) return; select.innerHTML = ''; if (!snapshots.length) { var empty = document.createElement('option'); empty.value = ''; empty.textContent = 'No saved snapshots'; select.appendChild(empty); return; } snapshots.forEach(function(s) { var opt = document.createElement('option'); opt.value = s.id; var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time'; opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : ''); select.appendChild(opt); }); } function restoreAssistantPromptPool() { var select = document.getElementById('assistant-prompt-pool-snapshots'); var id = select ? select.value : ''; var result = document.getElementById('assistant-prompt-pool-status'); if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; } if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...'; fetch('/api/admin/clinical-assistant/prompt-pool/restore', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) }) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Prompt pool restore failed'); renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id }); renderAssistantPromptPoolSnapshots(data.snapshots || []); showToast('Prompt pool restored', 'success'); }).catch(function(err) { if (result) result.textContent = err.message; showToast(err.message, 'error'); }); } function saveAssistantAdmin() { if (configState !== 'ready') return; var chat = document.getElementById('assistant-chat-model'); if (!chat || chat.selectedIndex < 0) return; var status = document.getElementById('assistant-admin-status'); if (status) status.textContent = 'Saving...'; Promise.all([ putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')), putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')), putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'), putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'), putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'), putAssistantConfig('clinical_assistant.show_sources', (document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true'), putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')), putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(',')) ]).then(function() { // A toast is gone in three seconds. Whether these settings are saved is // exactly the question an admin has when they come back to this page, so // the answer stays on the page. if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.'; showToast('Assistant settings saved', 'success'); }).catch(function(err) { if (status) status.textContent = 'Not saved. Nothing was changed.'; showToast(err.message || 'Save failed', 'error'); }); } function putAssistantConfig(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 || 'Save failed'); return data; }); } function chatOptions() { var chat = document.getElementById('assistant-chat-model'); var out = []; if (chat) Array.prototype.forEach.call(chat.options, function(o) { out.push({ value: o.value }); }); return out; } function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; } function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; } const escAssistant = adminEscapeHtml; // 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. The loader's own state guards make this idempotent with tabChanged. var adminTab = document.getElementById('admin-tab'); if (adminTab && adminTab.classList.contains('active') && adminTab.dataset.loaded === '1') loadAssistantAdmin(); }