export function initClinicalAssistantAdmin(adminEscapeHtml) { let configState = 'idle'; let imageModelsLoading = false; 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-refresh-assistant-image-models')) loadAssistantImageModels(); if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel(); if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel(); }); function updateAssistantLoadState() { var save = document.getElementById('btn-save-assistant-config'); if (save) save.disabled = configState !== 'ready' || imageModelsLoading; var retry = document.getElementById('btn-retry-assistant-config'); if (retry) retry.hidden = configState !== 'failed'; var status = document.getElementById('assistant-admin-status'); if (status) status.textContent = configState === 'failed' ? 'Settings load failed. Drafts are unchanged. Retry loading settings or revisit the Admin tab.' : configState !== 'ready' ? 'Loading assistant settings...' : imageModelsLoading ? 'Loading image models; saving is unavailable until discovery finishes.' : 'Settings ready.'; } 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; } window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || ''; renderAssistantImageModels([], window._assistantImageModelValue); 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', 'deepl'].includes(cfg['clinical_assistant.translate_provider']) ? cfg['clinical_assistant.translate_provider'] : 'libretranslate'); var budgetLabel = document.getElementById('assistant-conversation-budget'); if (budgetLabel) budgetLabel.textContent = budget.limit.toLocaleString() + ' ' + budget.unit + ' (' + budget.measure + ') — ' + budget.env + (budget.source === 'environment' ? ' (environment).' : ' (server default; environment unset).'); configState = 'ready'; updateAssistantLoadState(); loadAssistantImageModels(); loadAssistantPromptPoolStatus(); }).catch(function() { configState = 'failed'; updateAssistantLoadState(); var budgetLabel = document.getElementById('assistant-conversation-budget'); if (budgetLabel) budgetLabel.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.'; }); } function renderAssistantImageModels(models, current) { var sel = document.getElementById('assistant-image-model'); if (!sel) return; sel.innerHTML = ''; models.forEach(function(m) { var opt = document.createElement('option'); opt.value = m.id; opt.textContent = m.name || m.id; sel.appendChild(opt); }); if (current && !Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) { var custom = document.createElement('option'); custom.value = current; custom.textContent = current + ' (saved/custom)'; sel.appendChild(custom); } sel.value = current; } function loadAssistantImageModels() { var sel = document.getElementById('assistant-image-model'); if (!sel || configState !== 'ready' || imageModelsLoading) return; // Keep the real selection, never an empty loading option. Read it again on // completion so a newer custom selection wins over an in-flight discovery. imageModelsLoading = true; sel.disabled = true; updateAssistantLoadState(); fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() }) .then(function(r) { if (!r.ok) throw new Error('Image discovery failed'); return r.json(); }) .then(function(data) { if (!data || data.success === false || !Array.isArray(data.models) || !data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models'); renderAssistantImageModels(data.models, sel.value); }) .catch(function() { renderAssistantImageModels(['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].map(function(id) { return { id: id }; }), sel.value); }) .finally(function() { imageModelsLoading = false; sel.disabled = false; updateAssistantLoadState(); }); } 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 testAssistantImageModel() { var model = getValue('assistant-image-model') || 'openai-gpt-image-1'; var result = document.getElementById('assistant-image-test-result'); if (result) result.innerHTML = ' Testing ' + escAssistant(model) + '...'; fetch('/api/admin/config/image-models/test', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model }) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Image test failed'); var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '