From 13fca7887dd20dc5f95f01e5101fd972a7b7d754 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 7 Sep 2026 18:18:48 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20unify=20Admin=20model/prompt=20UI=20?= =?UTF-8?q?=E2=80=94=20single=20prompt=20dropdown,=20two=20image-model=20d?= =?UTF-8?q?ropdowns,=20searchable/scrollable=20users,=20remove=20classific?= =?UTF-8?q?ation/custom-add,=20consistent=20font,=20concise=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/components/admin.html | 100 +++------- public/css/styles.css | 12 ++ public/js/admin.js | 193 ++++++++----------- public/js/admin/clinicalAssistant.js | 4 +- public/js/admin/imageSettings.js | 56 ++++-- src/routes/adminConfig.js | 14 +- src/utils/models.js | 84 ++++---- test/admin-clinical-assistant-wiring.test.js | 15 +- test/clinical-release-integration.test.js | 2 +- test/frontend-prompt-env.test.js | 185 ++++++++---------- 10 files changed, 285 insertions(+), 380 deletions(-) diff --git a/public/components/admin.html b/public/components/admin.html index 592a660..dce3fb2 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -23,11 +23,12 @@

Users

+
-
+
- + @@ -160,17 +161,6 @@ - -
-
-

AI Scribe — plain-text / JSON instructions

- Saved overrides replace shipped defaults immediately -
-
-
Loading Scribe prompts...
-
-
-
@@ -271,24 +261,6 @@

Click "Search API" to query your configured provider for all available models. Use the search box to filter results.

- -
- -
- - - - -
-
- -
-
@@ -300,67 +272,46 @@
-

Clinical Assistant

- Chat and image model overrides for the assistant +

Clinical Assistant / Learning

+ Model and prompt settings
-
-
- - +
+
+ +
-
Starter prompt pool — suggested questions, not global prompt history
+
Starter prompt pool
Checking prompt pool...
- +
-
- - - - -
-
-
- - - -
-
- - +
+ +
-
- - +
+ +
-
- Conversation input budget -

Loading server budget metadata...

+
+ Conversation input budget +

Loading server budget metadata...

-
-

Clinical Assistant TEXT — system behavior

-
Loading clinical text prompt...
+
+

Prompts

+
Loading prompts...
-
-

Clinical Assistant IMAGE — poster instructions

-
Loading clinical image prompt...
-
-
-

Learning Hub IMAGE — authoring instructions

-

Separate model-callable authoring image behavior; includes generation and requested refinement images. History and restore affect Learning Hub only.

-
Loading Learning image prompt...
-
-
+
@@ -444,11 +395,12 @@
-

Embedding Models

+

Embedding Models — Learning Hub semantic search

Loading...
Loading...
+

Corpus (MCP) embeddings are configured on the indexing service, not here.

diff --git a/public/css/styles.css b/public/css/styles.css index 1c8e2e1..90946da 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1185,3 +1185,15 @@ textarea.full-input{resize:vertical;} .btn-bio-login:hover{transform:translateY(-1px);box-shadow:0 4px 12px -3px rgba(37,99,235,.45);} .btn-bio-login:active{transform:translateY(0);} .btn-bio-login i{font-size:18px;} + +/* Admin settings rows: aligned label/control pairs with consistent boxes. */ +.admin-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } +.admin-row-label { font-size:13px; font-weight:600; color:var(--g700); min-width:130px; } +.admin-control { font-size:13px; font-family:inherit; padding:6px 10px; border:1px solid var(--g300); border-radius:6px; flex:1; max-width:420px; min-width:180px; background:white; color:var(--g800); } +.admin-control:focus { outline:none; border-color:var(--blue); box-shadow:0 0 0 2px var(--blue-light); } + +/* One consistent font across the whole Admin panel. */ +#admin-tab select, #admin-tab input, #admin-tab textarea, #admin-tab button, +#admin-tab .cms-prompt-family select, #admin-tab .cms-prompt-family textarea { + font-family: inherit; +} diff --git a/public/js/admin.js b/public/js/admin.js index 1738c17..95c8d0e 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,4 +1,4 @@ -import './admin/imageSettings.js'; +import { initImageSettings } from './admin/imageSettings.js'; import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js'; // ============================================================ @@ -120,6 +120,20 @@ function adminTabActive() { // ---- 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; @@ -128,67 +142,26 @@ function adminTabActive() { fetch('/api/admin/users', { headers: getAuthHeaders() }) .then(function(r) { return r.json(); }) .then(function(data) { - if (!data.success) { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; } - renderUsers(data.users || []); + if (!data.success) { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; } + allUsers = data.users || []; + filterUsers(); }) - .catch(function() { tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); }); + .catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); }); } function renderUsers(users) { var tbody = document.getElementById('admin-users-body'); if (!tbody) return; - var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}'); - if (users.length === 0) { tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found'); return; } + tbody.innerHTML = users.map(function(u) { return renderUserRow(u, currentUser); }).join(''); + bindUserActions(tbody); + } - tbody.innerHTML = users.map(function(u) { - var isSelf = currentUser.id && u.id === currentUser.id; - var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)'; - var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)'); - var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified'); - var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—'; - - var actions = []; - if (!isSelf) { - if (!u.email_verified) { - actions.push(''); - } - if (u.disabled) { - actions.push(''); - } else { - actions.push(''); - } - if (u.role === 'admin') { - actions.push(''); - } else { - actions.push(''); - } - if (u.role !== 'moderator') { - actions.push(''); - } - if (u.role === 'moderator') { - actions.push(''); - } - actions.push(''); - actions.push(''); - } else { - actions.push('(you)'); - } - - return '
' + - '' + - '' + - '' + - '' + - '' + - ''; - }).join(''); - - // Bind action buttons + 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); @@ -196,6 +169,50 @@ function adminTabActive() { }); } + 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(''); + } + if (u.disabled) { + actions.push(''); + } else { + actions.push(''); + } + if (u.role === 'admin') { + actions.push(''); + } else { + actions.push(''); + } + if (u.role !== 'moderator') { + actions.push(''); + } + if (u.role === 'moderator') { + actions.push(''); + } + actions.push(''); + actions.push(''); + } else { + actions.push('(you)'); + } + + return '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + function handleUserAction(action, userId, email, name) { switch (action) { @@ -542,34 +559,23 @@ function adminTabActive() { // General settings reloads must not replace any prompt drafts. if (promptsLoaded || promptsLoading) return; promptsLoading = true; - var groups = [ - ['scribe', document.getElementById('cms-scribe-prompts')], - ['clinical-text', document.getElementById('cms-clinical-text-prompts')], - ['clinical-image', document.getElementById('cms-clinical-image-prompts')], - ['learning-image', document.getElementById('cms-learning-image-prompts')] - ]; + var container = document.getElementById('cms-prompt-editor'); try { var data = await promptRequest('/api/admin/config/prompts'); - groups.forEach(function(group) { - if (!group[1]) return; - group[1].replaceChildren(); - var prompts = (data.prompts || []).filter(function(p) { return p.family === group[0] && p.editable === true; }); - if (!prompts.length) { - group[1].textContent = 'No editable prompts available in this family.'; - return; - } - group[1].appendChild(createPromptFamilyEditor(group[0], prompts)); - }); + if (container) { + container.replaceChildren(); + var prompts = (data.prompts || []).filter(function(p) { return p.editable === true; }); + container.appendChild(createPromptFamilyEditor('all', prompts)); + } promptsLoaded = true; } catch (error) { - groups.forEach(function(group) { - if (!group[1]) return; - group[1].textContent = 'Could not load prompts: ' + error.message + ' '; + if (container) { + container.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); - }); + container.appendChild(retry); + } } finally { promptsLoading = false; } } @@ -831,6 +837,7 @@ function adminTabActive() { // ADMIN CLINICAL ASSISTANT SETTINGS // ============================================================ initClinicalAssistantAdmin(adminEscapeHtml); +initImageSettings(); // ============================================================ // ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models @@ -848,7 +855,6 @@ initClinicalAssistantAdmin(adminEscapeHtml); document.addEventListener('click', function(e) { if (e.target.closest('#btn-discover-models')) discoverModels(); - if (e.target.closest('#btn-add-custom-model')) addCustomModel(); if (e.target.closest('#btn-save-default-model')) saveDefaultModel(); if (e.target.closest('#btn-clear-all-models')) clearAllModels(); if (e.target.closest('.admin-model-test-btn')) { @@ -917,8 +923,6 @@ initClinicalAssistantAdmin(adminEscapeHtml); '' + '' + ''; @@ -983,17 +987,15 @@ initClinicalAssistantAdmin(adminEscapeHtml); container.innerHTML = '

Found ' + data.count + ' models. Click + to add to your model list.

' + data.models.slice(0, 100).map(function(m) { return '
' + - '' + + '' + '' + '' + esc(m.name) + ' (' + esc(m.id) + ')' + - '' + esc(m.cost || '') + '' + - '' + esc(m.category || '') + '' + '
'; }).join(''); container.querySelectorAll('.admin-add-discovered').forEach(function(btn) { btn.addEventListener('click', function() { - addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn.dataset.mcost, btn.dataset.mcat, btn); + addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn); }); }); }) @@ -1002,11 +1004,11 @@ initClinicalAssistantAdmin(adminEscapeHtml); }); } - function addDiscoveredModel(id, name, cost, category, btn) { + function addDiscoveredModel(id, name, btn) { fetch('/api/admin/config/models/add-discovered', { method: 'POST', headers: getAuthHeaders(), - body: JSON.stringify({ id: id, name: name, cost: cost, category: category }) + body: JSON.stringify({ id: id, name: name }) }) .then(function(r) { return r.json(); }) .then(function(data) { @@ -1047,35 +1049,6 @@ initClinicalAssistantAdmin(adminEscapeHtml); .catch(function() { showToast('Request failed', 'error'); }); } - function addCustomModel() { - var id = (document.getElementById('admin-custom-model-id') || {}).value || ''; - var name = (document.getElementById('admin-custom-model-name') || {}).value || ''; - var cost = (document.getElementById('admin-custom-model-cost') || {}).value || ''; - var cat = (document.getElementById('admin-custom-model-cat') || {}).value || 'smart'; - - if (!id.trim() || !name.trim()) { showToast('Model ID and name required', 'error'); return; } - - fetch('/api/admin/config/models/custom', { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ id: id.trim(), name: name.trim(), cost: cost.trim(), category: cat }) - }) - .then(function(r) { return r.json(); }) - .then(function(data) { - if (data.success) { - showToast('Custom model added: ' + name, 'success'); - // Clear inputs - var el = document.getElementById('admin-custom-model-id'); if (el) el.value = ''; - el = document.getElementById('admin-custom-model-name'); if (el) el.value = ''; - el = document.getElementById('admin-custom-model-cost'); if (el) el.value = ''; - loadAdminModels(); - } else { - showToast(data.error || 'Failed', 'error'); - } - }) - .catch(function() { showToast('Request failed', 'error'); }); - } - function renderCustomModels(custom) { var container = document.getElementById('admin-custom-models-list'); if (!container) return; @@ -1088,8 +1061,6 @@ initClinicalAssistantAdmin(adminEscapeHtml); custom.map(function(m) { return '
' + '' + esc(m.name) + ' (' + esc(m.id) + ')' + - '' + esc(m.tag || 'CUSTOM') + '' + - '' + esc(m.cost || '') + '' + '' + '' + '
'; diff --git a/public/js/admin/clinicalAssistant.js b/public/js/admin/clinicalAssistant.js index 63cbe8e..3df12d6 100644 --- a/public/js/admin/clinicalAssistant.js +++ b/public/js/admin/clinicalAssistant.js @@ -296,13 +296,11 @@ export function initClinicalAssistantAdmin(adminEscapeHtml) { function saveAssistantAdmin() { if (configState !== 'ready' || imageModelsLoading) return; var chat = document.getElementById('assistant-chat-model'); - var image = document.getElementById('assistant-image-model'); - if (!chat || chat.selectedIndex < 0 || !image || image.selectedIndex < 0) return; + 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.image_model', getValue('assistant-image-model')), putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'), putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400') ]).then(function() { diff --git a/public/js/admin/imageSettings.js b/public/js/admin/imageSettings.js index a13a757..f7ebb00 100644 --- a/public/js/admin/imageSettings.js +++ b/public/js/admin/imageSettings.js @@ -1,11 +1,12 @@ -// One image-model setting for the whole app: choose from discovered models, applied to both workflows. +// One image model per workflow: Clinical Assistant and Learning, each a dropdown of discovered models. import { imageJson } from '../generatedImages.js'; let loading = false; let loaded = false; -let select; let budgetClinical; let budgetLearning; let save; let status; +let clinicalSelect; let learningSelect; let clinicalBudget; let learningBudget; let save; let status; async function load() { const root = document.getElementById('workflow-image-settings'); + if (root && !root.children.length) loaded = false; // fresh DOM (test/re-import) re-renders if (!root || loaded || loading) return; loading = true; try { @@ -15,29 +16,27 @@ async function load() { ]); root.textContent = ''; const form = document.createElement('form'); - const heading = document.createElement('h4'); heading.textContent = 'Image model'; - select = document.createElement('select'); select.className = 'prompt-select'; - select.style.cssText = 'display:block;max-width:100%;font-size:13px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;'; - const known = new Set([settings.workflows.clinical_assistant.model, settings.workflows.learning_hub.model]); - const options = []; - (Array.isArray(models.models) ? models.models : []).forEach(m => { if (m && typeof m.id === 'string' && m.id) { options.push(m.id); known.add(m.id); } }); - const current = settings.workflows.clinical_assistant.model || settings.workflows.learning_hub.model; - [...new Set([...known])].forEach(id => { const o = document.createElement('option'); o.value = id; o.textContent = id; select.appendChild(o); }); - if (current) select.value = current; - const label1 = document.createElement('label'); label1.textContent = 'Clinical image input budget (UTF-16 code units) '; - budgetClinical = document.createElement('input'); budgetClinical.type = 'number'; budgetClinical.min = '1000'; budgetClinical.max = '32000'; budgetClinical.required = true; budgetClinical.value = settings.workflows.clinical_assistant.budget; label1.append(budgetClinical); - const label2 = document.createElement('label'); label2.textContent = 'Learning image input budget (UTF-16 code units) '; - budgetLearning = document.createElement('input'); budgetLearning.type = 'number'; budgetLearning.min = '1000'; budgetLearning.max = '32000'; budgetLearning.required = true; budgetLearning.value = settings.workflows.learning_hub.budget; label2.append(budgetLearning); + const heading = document.createElement('h4'); heading.textContent = 'Image models'; + clinicalSelect = makeSelect([settings.workflows.clinical_assistant.model], models.models); + learningSelect = makeSelect([settings.workflows.learning_hub.model], models.models); + clinicalBudget = makeBudget(settings.workflows.clinical_assistant.budget); + learningBudget = makeBudget(settings.workflows.learning_hub.budget); save = document.createElement('button'); save.type = 'submit'; save.className = 'btn-sm btn-primary'; save.textContent = 'Save image settings'; status = document.createElement('p'); status.setAttribute('role', 'status'); - form.append(heading, select, label1, label2, save, status); + const row1 = document.createElement('div'); row1.className = 'admin-row'; + const l1 = document.createElement('label'); l1.className = 'admin-row-label'; l1.textContent = 'Clinical Assistant image model'; row1.append(l1, clinicalSelect); + const row2 = document.createElement('div'); row2.className = 'admin-row'; + const l2 = document.createElement('label'); l2.className = 'admin-row-label'; l2.textContent = 'Clinical image input budget'; row2.append(l2, clinicalBudget); + const row3 = document.createElement('div'); row3.className = 'admin-row'; + const l3 = document.createElement('label'); l3.className = 'admin-row-label'; l3.textContent = 'Learning image model'; row3.append(l3, learningSelect); + const row4 = document.createElement('div'); row4.className = 'admin-row'; + const l4 = document.createElement('label'); l4.className = 'admin-row-label'; l4.textContent = 'Learning image input budget'; row4.append(l4, learningBudget); + form.append(heading, row1, row2, row3, row4, save, status); form.onsubmit = async e => { e.preventDefault(); if (save.disabled) return; save.disabled = true; try { - // One model chosen once, applied to both workflows. - for (const workflow of ['clinical_assistant', 'learning_hub']) { - await imageJson('/api/admin/image-settings/' + workflow, { method: 'PUT', body: JSON.stringify({ model: select.value, budget: Number(workflow === 'clinical_assistant' ? budgetClinical.value : budgetLearning.value) }) }); - } + await imageJson('/api/admin/image-settings/clinical_assistant', { method: 'PUT', body: JSON.stringify({ model: clinicalSelect.value, budget: Number(clinicalBudget.value) }) }); + await imageJson('/api/admin/image-settings/learning_hub', { method: 'PUT', body: JSON.stringify({ model: learningSelect.value, budget: Number(learningBudget.value) }) }); status.textContent = 'Saved. New jobs use these settings; existing jobs are unchanged.'; } catch (error) { status.textContent = error.message + ' Nothing was saved.'; } finally { save.disabled = false; } }; @@ -46,4 +45,19 @@ async function load() { } catch (_) { /* Next tab entry retries; no drafts are touched. */ } finally { loading = false; } } -document.addEventListener('tabChanged', e => { if (e.detail?.tab === 'admin') load(); }); +function makeSelect(knownIds, models) { + const select = document.createElement('select'); select.className = 'admin-control'; + const ids = new Set([...(Array.isArray(knownIds) ? knownIds : []), ...((Array.isArray(models) ? models : []).map(m => m && m.id).filter(Boolean))]); + [...ids].sort().forEach(id => { const o = document.createElement('option'); o.value = id; o.textContent = id; select.appendChild(o); }); + if (knownIds[0]) select.value = knownIds[0]; + return select; +} +function makeBudget(value) { + const input = document.createElement('input'); input.className = 'admin-control'; + input.type = 'number'; input.min = '1000'; input.max = '32000'; input.required = true; input.value = value; + return input; +} +export function initImageSettings() { + document.addEventListener('tabChanged', e => { if (e.detail?.tab === 'admin') load(); }); +} + diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 23b26d1..3e6a529 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -379,7 +379,7 @@ router.put('/config/models/default', async function(req, res) { // ── POST add custom model (manual entry) ───────────────────────────────── router.post('/config/models/custom', async function(req, res) { try { - var { id, name, cost, category } = req.body; + var { id, name } = req.body; if (!id || !name) return res.status(400).json({ error: 'id and name required' }); var trimmedId = id.trim(); @@ -395,9 +395,6 @@ router.post('/config/models/custom', async function(req, res) { return res.status(400).json({ error: 'Model ID conflicts with a built-in model. Use the toggle to enable/disable built-in models.' }); } - var validCategories = ['free', 'fast', 'smart', 'premium']; - var cat = validCategories.includes(category) ? category : 'smart'; - var customRaw = await db.getSetting('models.custom') || '[]'; var custom; custom = JSON.parse(customRaw); @@ -405,7 +402,7 @@ router.post('/config/models/custom', async function(req, res) { var existing = custom.find(function(m) { return m.id === trimmedId; }); custom = custom.filter(function(m) { return m.id !== trimmedId; }); - custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'CUSTOM' }); + custom.push({ id: trimmedId, name: name.trim().substring(0, 100) }); await db.setSetting('models.custom', JSON.stringify(custom)); logger.audit(req.user.id, existing ? 'admin_model_update' : 'admin_model_add', (existing ? 'Updated' : 'Added') + ' custom model: ' + trimmedId, req, { category: 'admin' }); @@ -458,22 +455,19 @@ router.get('/config/models/discover', async function(req, res) { // ── POST add discovered model to custom list ────────────────────────────── router.post('/config/models/add-discovered', async function(req, res) { try { - var { id, name, cost, category } = req.body; + var { id, name } = req.body; if (!id || !name) return res.status(400).json({ error: 'id and name required' }); var trimmedId = id.trim(); if (trimmedId.length > 200) return res.status(400).json({ error: 'Model ID too long (max 200 chars)' }); - var validCategories = ['free', 'fast', 'smart', 'premium']; - var cat = validCategories.includes(category) ? category : 'smart'; - var customRaw = await db.getSetting('models.custom') || '[]'; var custom; custom = JSON.parse(customRaw); if (!Array.isArray(custom)) throw new Error('Invalid custom model settings'); custom = custom.filter(function(m) { return m.id !== trimmedId; }); - custom.push({ id: trimmedId, name: name.trim().substring(0, 100), cost: (cost || '?').substring(0, 20), category: cat, tag: 'DISCOVERED' }); + custom.push({ id: trimmedId, name: name.trim().substring(0, 100) }); await db.setSetting('models.custom', JSON.stringify(custom)); logger.audit(req.user.id, 'admin_model_discover_add', 'Added discovered model: ' + trimmedId, req, { category: 'admin' }); diff --git a/src/utils/models.js b/src/utils/models.js index 935be93..3a613a1 100644 --- a/src/utils/models.js +++ b/src/utils/models.js @@ -6,21 +6,21 @@ var activeProvider = process.env.AI_PROVIDER || (process.env.LITELLM_API_BASE ? 'litellm' : 'openrouter'); var OPENROUTER_MODELS = [ - { id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast' }, - { id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5', cost: '~$0.01', tag: 'SMART', category: 'premium' }, - { id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking', cost: '~$0.002', tag: 'REASONING', category: 'smart' }, - { id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3', cost: '~$0.001', tag: 'CHEAP', category: 'fast' }, - { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart' }, - { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free', cost: 'FREE', tag: 'FREE', category: 'free' }, - { id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart' }, - { id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free', cost: 'FREE', tag: 'FREE', category: 'free' }, - { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.002', tag: 'NEW', category: 'smart' }, - { id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B', cost: '~$0.001', tag: 'GOOD', category: 'fast' }, - { id: 'openai/gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'PREMIUM', category: 'premium' }, - { id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'SMART', category: 'smart' }, - { id: 'openai/o4-mini', name: 'o4-mini (Reasoning)', cost: '~$0.01', tag: 'REASONING', category: 'premium' }, - { id: 'mistralai/mistral-large-2411', name: 'Mistral Large', cost: '~$0.006', tag: 'GOOD', category: 'smart' }, - { id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free', cost: 'FREE', tag: 'FREE', category: 'free' } + { id: 'google/gemini-2.5-flash', name: 'Gemini Flash 2.5' }, + { id: 'google/gemini-2.5-pro', name: 'Gemini Pro 2.5' }, + { id: 'google/gemini-2.5-flash:thinking', name: 'Gemini Flash Thinking' }, + { id: 'deepseek/deepseek-chat-v3-0324', name: 'DeepSeek V3' }, + { id: 'deepseek/deepseek-r1', name: 'DeepSeek R1' }, + { id: 'deepseek/deepseek-r1:free', name: 'DeepSeek R1 Free' }, + { id: 'qwen/qwen3-235b-a22b', name: 'Qwen3 235B' }, + { id: 'qwen/qwen3-30b-a3b:free', name: 'Qwen3 30B Free' }, + { id: 'meta-llama/llama-4-maverick', name: 'Llama 4 Maverick' }, + { id: 'meta-llama/llama-3.3-70b-instruct', name: 'Llama 3.3 70B' }, + { id: 'openai/gpt-4.1', name: 'GPT-4.1' }, + { id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 Mini' }, + { id: 'openai/o4-mini', name: 'o4-mini (Reasoning)' }, + { id: 'mistralai/mistral-large-2411', name: 'Mistral Large' }, + { id: 'mistralai/mistral-small-3.2-24b-instruct:free', name: 'Mistral Small Free' } ]; var BEDROCK_MODELS = [ @@ -29,62 +29,62 @@ var BEDROCK_MODELS = [ // maxOut = model's max output token limit (omit if >= 8192) // ── Amazon Nova (inference profiles) ── - { id: 'amazon/nova-pro', name: 'Amazon Nova Pro', cost: '~$0.008', tag: 'SMART', category: 'smart', + { id: 'amazon/nova-pro', name: 'Amazon Nova Pro', bedrockId: 'us.amazon.nova-pro-v1:0' }, - { id: 'amazon/nova-lite', name: 'Amazon Nova Lite', cost: '~$0.001', tag: 'FAST', category: 'fast', + { id: 'amazon/nova-lite', name: 'Amazon Nova Lite', bedrockId: 'us.amazon.nova-lite-v1:0' }, - { id: 'amazon/nova-micro', name: 'Amazon Nova Micro', cost: '~$0.0004', tag: 'CHEAPEST', category: 'fast', + { id: 'amazon/nova-micro', name: 'Amazon Nova Micro', bedrockId: 'us.amazon.nova-micro-v1:0' }, // ── Meta Llama (inference profiles) ── - { id: 'meta/llama-4-maverick', name: 'Llama 4 Maverick', cost: '~$0.005', tag: 'NEW', category: 'smart', + { id: 'meta/llama-4-maverick', name: 'Llama 4 Maverick', bedrockId: 'us.meta.llama4-maverick-17b-instruct-v1:0' }, - { id: 'meta/llama-4-scout', name: 'Llama 4 Scout', cost: '~$0.004', tag: 'NEW', category: 'smart', + { id: 'meta/llama-4-scout', name: 'Llama 4 Scout', bedrockId: 'us.meta.llama4-scout-17b-instruct-v1:0' }, - { id: 'meta/llama-3.3-70b', name: 'Llama 3.3 70B', cost: '~$0.003', tag: 'GOOD', category: 'smart', + { id: 'meta/llama-3.3-70b', name: 'Llama 3.3 70B', bedrockId: 'us.meta.llama3-3-70b-instruct-v1:0' }, // ── DeepSeek (R1 has profile, V3.2 is on-demand) ── - { id: 'deepseek/r1', name: 'DeepSeek R1', cost: '~$0.005', tag: 'REASONING', category: 'smart', + { id: 'deepseek/r1', name: 'DeepSeek R1', bedrockId: 'us.deepseek.r1-v1:0' }, - { id: 'deepseek/v3.2', name: 'DeepSeek V3.2', cost: '~$0.001', tag: 'FAST', category: 'fast', + { id: 'deepseek/v3.2', name: 'DeepSeek V3.2', bedrockId: 'deepseek.v3.2', regions: ['us-east-1', 'us-east-2', 'us-west-2'] }, // ── Mistral AI (on-demand, no profiles available) ── - { id: 'mistral/large-3', name: 'Mistral Large 3 (675B)', cost: '~$0.008', tag: 'PREMIUM', category: 'premium', + { id: 'mistral/large-3', name: 'Mistral Large 3 (675B)', bedrockId: 'mistral.mistral-large-3-675b-instruct', regions: ['us-east-1', 'us-east-2', 'us-west-2'] }, - { id: 'mistral/magistral-small', name: 'Magistral Small', cost: '~$0.003', tag: 'VALUE', category: 'smart', + { id: 'mistral/magistral-small', name: 'Magistral Small', bedrockId: 'mistral.magistral-small-2509', regions: ['us-east-1', 'us-east-2', 'us-west-2'] }, // ── Cohere (on-demand, max 4096 output) ── - { id: 'cohere/command-r-plus', name: 'Command R+', cost: '~$0.005', tag: 'GOOD', category: 'smart', + { id: 'cohere/command-r-plus', name: 'Command R+', bedrockId: 'cohere.command-r-plus-v1:0', regions: ['us-east-1', 'us-west-2'], maxOut: 4096 }, - { id: 'cohere/command-r', name: 'Command R', cost: '~$0.002', tag: 'VALUE', category: 'fast', + { id: 'cohere/command-r', name: 'Command R', bedrockId: 'cohere.command-r-v1:0', regions: ['us-east-1', 'us-west-2'], maxOut: 4096 }, // ── AI21 Labs (on-demand, max 4096 output) ── - { id: 'ai21/jamba-1.5-large', name: 'Jamba 1.5 Large', cost: '~$0.005', tag: 'GOOD', category: 'smart', + { id: 'ai21/jamba-1.5-large', name: 'Jamba 1.5 Large', bedrockId: 'ai21.jamba-1-5-large-v1:0', regions: ['us-east-1'], maxOut: 4096 }, // ── Writer (inference profile) ── - { id: 'writer/palmyra-x5', name: 'Palmyra X5', cost: '~$0.01', tag: 'CLINICAL', category: 'premium', + { id: 'writer/palmyra-x5', name: 'Palmyra X5', bedrockId: 'us.writer.palmyra-x5-v1:0' }, // ── Qwen (on-demand, no profiles) ── - { id: 'qwen/qwen3-235b', name: 'Qwen3 235B', cost: '~$0.005', tag: 'SMART', category: 'smart', + { id: 'qwen/qwen3-235b', name: 'Qwen3 235B', bedrockId: 'qwen.qwen3-235b-a22b-2507-v1:0' }, - { id: 'qwen/qwen3-32b', name: 'Qwen3 32B', cost: '~$0.002', tag: 'VALUE', category: 'fast', + { id: 'qwen/qwen3-32b', name: 'Qwen3 32B', bedrockId: 'qwen.qwen3-32b-v1:0' } ]; var AZURE_MODELS = [ - { id: 'gpt-4o', name: 'GPT-4o', cost: '~$0.01', tag: 'BEST', category: 'premium', + { id: 'gpt-4o', name: 'GPT-4o', deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }, - { id: 'gpt-4o-mini', name: 'GPT-4o Mini', cost: '~$0.003', tag: 'SMART', category: 'smart', + { id: 'gpt-4o-mini', name: 'GPT-4o Mini', deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }, - { id: 'gpt-4.1', name: 'GPT-4.1', cost: '~$0.01', tag: 'NEW', category: 'premium', + { id: 'gpt-4.1', name: 'GPT-4.1', deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' }, - { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', cost: '~$0.003', tag: 'VALUE', category: 'smart', + { id: 'gpt-4.1-mini', name: 'GPT-4.1 Mini', deploymentNote: 'Set AZURE_DEPLOYMENT_NAME in .env' } ]; @@ -93,19 +93,19 @@ var AZURE_MODELS = [ // vertexId = the model name used in Vertex AI API calls // ============================================================ var VERTEX_MODELS = [ - { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', cost: '~$0.001', tag: 'BEST VALUE', category: 'fast', + { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', vertexId: 'gemini-2.5-flash-preview-05-20' }, - { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', cost: '~$0.01', tag: 'SMART', category: 'premium', + { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', vertexId: 'gemini-2.5-pro-preview-05-06' }, - { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', cost: '~$0.001', tag: 'FAST', category: 'fast', + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash', vertexId: 'gemini-2.0-flash' }, - { id: 'gemini-2.0-flash-lite', name: 'Gemini 2.0 Flash Lite', cost: '~$0.0004', tag: 'CHEAPEST', category: 'fast', + { id: 'gemini-2.0-flash-lite', name: 'Gemini 2.0 Flash Lite', vertexId: 'gemini-2.0-flash-lite' }, - { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', cost: '~$0.007', tag: 'RELIABLE', category: 'premium', + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', vertexId: 'gemini-1.5-pro-002' }, - { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', cost: '~$0.001', tag: 'VALUE', category: 'fast', + { id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', vertexId: 'gemini-1.5-flash-002' }, - { id: 'llama-3.1-405b@vertex', name: 'Llama 3.1 405B (Vertex)', cost: '~$0.005', tag: 'OPEN', category: 'smart', + { id: 'llama-3.1-405b@vertex', name: 'Llama 3.1 405B (Vertex)', vertexId: 'meta/llama-3.1-405b-instruct-maas' }, ]; diff --git a/test/admin-clinical-assistant-wiring.test.js b/test/admin-clinical-assistant-wiring.test.js index f4d0caa..7f7ab3a 100644 --- a/test/admin-clinical-assistant-wiring.test.js +++ b/test/admin-clinical-assistant-wiring.test.js @@ -50,8 +50,7 @@ test('native admin initializer preserves lazy navigation, assistant actions and await tick(); await tick(); assert.equal(document.getElementById('admin-tab').dataset.loaded, '1'); assert.equal(document.getElementById('assistant-chat-model').options[0].textContent, 'Use global default (chat)'); - assert.equal(document.getElementById('assistant-image-model').value, 'saved-image'); - assert.equal(window._assistantImageModelValue, 'saved-image'); + assert.equal(document.getElementById('assistant-image-model'), null, 'single image-model dropdown lives in workflow-image-settings'); assert.match(document.getElementById('assistant-prompt-pool-status').textContent, /3 prompts/); const budget = document.getElementById('assistant-conversation-budget'); assert.equal(document.querySelectorAll('#assistant-conversation-chars').length, 0); @@ -66,9 +65,9 @@ test('native admin initializer preserves lazy navigation, assistant actions and const writes = () => calls.filter(c => c.options.method === 'PUT'); const save = document.getElementById('btn-save-assistant-config'); save.click(); await tick(); - assert.equal(writes().length, 4); + assert.equal(writes().length, 3); assert.deepEqual(writes().map(c => c.url.split('/').pop()).sort(), [ - 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.image_model', 'clinical_assistant.search_limit' + 'clinical_assistant.chat_model', 'clinical_assistant.context_chars', 'clinical_assistant.search_limit' ]); assert.ok(toasts.some(([message, kind]) => message === 'Assistant settings saved' && kind === 'success')); @@ -79,12 +78,10 @@ test('native admin initializer preserves lazy navigation, assistant actions and document.getElementById('assistant-prompt-pool-snapshots').value = '7'; document.getElementById('btn-restore-assistant-prompt-pool').click(); await tick(); assert.deepEqual(JSON.parse(calls.find(c => c.url.endsWith('/prompt-pool/restore')).options.body), { id: 7 }); - document.getElementById('assistant-custom-image-model').value = '\">'; - document.getElementById('btn-use-custom-assistant-image-model').click(); - document.getElementById('btn-test-assistant-image-model').click(); - assert.equal(document.querySelector('#assistant-image-test-result svg'), null, 'initializer uses passed admin escape helper'); + assert.equal(document.getElementById('assistant-custom-image-model'), null); + assert.equal(document.getElementById('btn-test-assistant-image-model'), null); + assert.equal(document.getElementById('btn-use-custom-assistant-image-model'), null); await tick(); - assert.ok(calls.some(c => c.url === '/api/admin/config/image-models/test' && c.options.method === 'POST')); }); test('real extracted initializer never invents a cap when metadata is missing, invalid or returns 503', async t => { diff --git a/test/clinical-release-integration.test.js b/test/clinical-release-integration.test.js index 1753e84..42e753d 100644 --- a/test/clinical-release-integration.test.js +++ b/test/clinical-release-integration.test.js @@ -83,7 +83,7 @@ test('native admin and assistant modules retain budget, table/source identity an document.getElementById('btn-save-assistant-config').click(); await tick(); assert.equal(limit, 2000); - assert.equal(calls.filter(call => call.options.method === 'PUT').length, 4, 'one native admin initializer; prompts and ENV budget are not generic setting saves'); + assert.equal(calls.filter(call => call.options.method === 'PUT').length, 3, 'one native admin initializer; prompts and ENV budget are not generic setting saves'); assert.equal(calls.some(call => call.url.endsWith('/config/clinical_assistant.conversation_chars')), false); document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'assistant' } })); await tick(); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index 064c658..4656194 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -50,7 +50,7 @@ async function browser(t, module, handler) { await tick(); return { window, document: window.document, calls, toasts }; } -const familyOf = (ui, name) => ui.document.getElementById('cms-' + name + '-prompts'); +const familyOf = ui => ui.document.getElementById('cms-prompt-editor'); const selectOf = family => family.querySelector('.prompt-select'); const draftOf = family => family.querySelector('.prompt-draft'); const statusOf = family => family.querySelector('.prompt-status'); @@ -63,38 +63,30 @@ async function choosePrompt(family, dbKey) { } async function clickAction(family, name) { actionOf(family, name).click(); await tick(); } -test('compact prompt families separate the whole catalogue, display inert exact text, and save canonical dbKey + expectedRevision', async t => { +test('single prompt editor lists the whole catalogue, displays inert exact text, and saves canonical dbKey + expectedRevision', async t => { const ui = await browser(t, 'admin', (url, options) => { if (options.method === 'PUT') return json({ success: true, value: JSON.parse(options.body).value, revision: 11 }); }); - const scribe = familyOf(ui, 'scribe'); - const textFamily = familyOf(ui, 'clinical-text'); - const imageFamily = familyOf(ui, 'clinical-image'); - const learningFamily = familyOf(ui, 'learning-image'); - assert.equal(selectOf(scribe).options.length, 29); - assert.equal(selectOf(textFamily).options.length, 1); - assert.equal(selectOf(imageFamily).options.length, 1); - assert.equal(selectOf(learningFamily).options.length, 1); + const editor = familyOf(ui); + assert.equal(selectOf(editor).options.length, 32, 'all families in one dropdown'); assert.equal(ui.document.querySelector('script, img, [onerror]'), null); - assert.match(scribe.closest('.card').textContent, /AI Scribe/); - assert.equal(selectOf(scribe).value, catalogue[0].dbKey, 'first prompt selected by default'); - assert.equal(selectOf(scribe).options[0].textContent, 'SCRIBE_0'); - assert.equal(draftOf(scribe).value, unsafe); - for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(scribe, name)); + assert.equal(selectOf(editor).value, catalogue[0].dbKey, 'first prompt selected by default'); + assert.equal(selectOf(editor).options[0].textContent, 'SCRIBE_0'); + assert.equal(draftOf(editor).value, unsafe); + for (const name of ['save', 'history', 'view', 'restore', 'reset', 'baseline']) assert.ok(actionOf(editor, name)); for (const p of [catalogue[0], catalogue[1], ...catalogue.slice(-2)]) { - const f = p.family === 'scribe' ? scribe : p.family === 'clinical-text' ? textFamily : p.family === 'clinical-image' ? imageFamily : learningFamily; - await choosePrompt(f, p.dbKey); - assert.equal(draftOf(f).value, unsafe); - draftOf(f).value = '\n' + unsafe + '\n'; - await clickAction(f, 'save'); + await choosePrompt(editor, p.dbKey); + assert.equal(draftOf(editor).value, unsafe); + draftOf(editor).value = '\n' + unsafe + '\n'; + await clickAction(editor, 'save'); const call = ui.calls.at(-1); assert.equal(call.url, '/api/admin/config/' + p.dbKey); assert.equal(call.options.method, 'PUT'); assert.deepEqual(call.body, { value: '\n' + unsafe + '\n', expectedRevision: p.revision }); - assert.match(statusOf(f).textContent, /Saved revision 11/); + assert.match(statusOf(editor).textContent, /Saved revision 11/); } - await choosePrompt(scribe, catalogue[2].dbKey); - assert.equal(draftOf(scribe).value, unsafe, 'other prompts unchanged'); + await choosePrompt(editor, catalogue[2].dbKey); + assert.equal(draftOf(editor).value, unsafe, 'other prompts unchanged'); }); test('history/view/restore/reset use exact per-key APIs for every family and never replace other drafts', async t => { @@ -105,13 +97,13 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev if (url.endsWith('/restore')) return json({ success: true, value: unsafe, revision: 11 }); if (url.endsWith('/reset')) return json({ success: true, value: 'new shipped default', revision: 12 }); }); - const scribe = familyOf(ui, 'scribe'); - await choosePrompt(scribe, catalogue[1].dbKey); - draftOf(scribe).value = 'Unrelated unsaved Scribe draft'; - await choosePrompt(scribe, catalogue[0].dbKey); + const editor = familyOf(ui); + await choosePrompt(editor, catalogue[1].dbKey); + draftOf(editor).value = 'Unrelated unsaved Scribe draft'; + await choosePrompt(editor, catalogue[0].dbKey); for (const p of [catalogue[0], ...catalogue.slice(-2)]) { - const f = p.family === 'scribe' ? scribe : p.family === 'clinical-text' ? familyOf(ui, 'clinical-text') : p.family === 'clinical-image' ? familyOf(ui, 'clinical-image') : familyOf(ui, 'learning-image'); - await choosePrompt(f, p.dbKey); + await choosePrompt(editor, p.dbKey); + const f = editor; const base = '/api/admin/config/prompts/' + p.dbKey; draftOf(f).value = 'Unsaved clinical/Scribe edits'; await clickAction(f, 'history'); @@ -134,8 +126,8 @@ test('history/view/restore/reset use exact per-key APIs for every family and nev assert.deepEqual(ui.calls.at(-1).body, { expectedRevision: 11 }); assert.equal(draftOf(f).value, 'new shipped default'); } - await choosePrompt(scribe, catalogue[1].dbKey); - assert.equal(draftOf(scribe).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft'); + await choosePrompt(editor, catalogue[1].dbKey); + assert.equal(draftOf(editor).value, 'Unrelated unsaved Scribe draft', 'switch never discards another prompt\'s draft'); }); test('409s preserve drafts; explicit review/rebase resolves conflicts without an automatic overwrite', async t => { @@ -148,7 +140,7 @@ test('409s preserve drafts; explicit review/rebase resolves conflicts without an return json({ success: true, value: JSON.parse(options.body).value, revision: 15 }); } }); - const f = familyOf(ui, 'clinical-text'); + const f = familyOf(ui); await choosePrompt(f, 'clinical_assistant.system_behavior'); draftOf(f).value = 'Keep this draft'; await clickAction(f, 'save'); @@ -179,12 +171,12 @@ test('failed loads can retry; transport failures and in-flight saves preserve ne return new Promise(resolve => { release = () => resolve(json({ success: true, revision: 11, value: JSON.parse(options.body).value })); }); } }); - const scribe = familyOf(ui, 'scribe'); - assert.match(scribe.textContent, /Unavailable catalogue/); - assert.ok(scribe.querySelector('button'), 'retry control stays visible'); + const editor = familyOf(ui); + assert.match(editor.textContent, /Unavailable catalogue/); + assert.ok(editor.querySelector('button'), 'retry control stays visible'); catalogueFailure = false; - scribe.querySelector('button').click(); await tick(); - const f = familyOf(ui, 'clinical-image'); + editor.querySelector('button').click(); await tick(); + const f = editor; await choosePrompt(f, 'clinical_assistant.image_behavior'); draftOf(f).value = 'Preserved draft'; await clickAction(f, 'history'); @@ -211,7 +203,7 @@ test('empty history and failed revision viewing leave drafts intact and restore if (url.endsWith('/history?limit=100')) return json({ success: true, revision: empty ? 0 : 4, revisions: empty ? [] : [{ id: 4, createdAt: 'now' }] }); if (url.endsWith('/revisions/4')) return json({ error: 'Revision unavailable' }, 404); }); - const f = familyOf(ui, 'scribe'); + const f = familyOf(ui); await choosePrompt(f, catalogue[1].dbKey); draftOf(f).value = 'Keep even when no history is available'; await clickAction(f, 'history'); @@ -373,11 +365,10 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, true); assert.match(setting(ui, 'admin-status').textContent, /failed|unavailable/i); assert.equal(setting(ui, 'chat-model').options.length, 0); - assert.equal(setting(ui, 'image-model').options.length, 0); + assert.equal(ui.document.getElementById('workflow-image-settings').children.length, 0, 'image settings load only after config success'); assert.equal(ui.calls.some(c => c.url.endsWith('/image-models/discover')), false); setting(ui, 'search-limit').value = '21'; setting(ui, 'context-chars').value = '3100'; - setting(ui, 'custom-image-model').value = 'Unsent custom draft'; setting(ui, 'chat-model').appendChild(new ui.window.Option('Draft chat', 'draft-chat')); const retry = ui.document.getElementById('btn-retry-assistant-config'); assert.equal(retry.hidden, false); @@ -385,14 +376,12 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves retry.click(); await tick(); assert.equal(setting(ui, 'search-limit').value, '21'); assert.equal(setting(ui, 'context-chars').value, '3100'); - assert.equal(setting(ui, 'custom-image-model').value, 'Unsent custom draft'); assert.equal(setting(ui, 'chat-model').value, 'draft-chat'); await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); assert.equal(ui.calls.filter(c => c.url === '/api/admin/config').length, 2); available = true; adminVisit(ui); await tick(); assert.equal(setting(ui, 'chat-model').value, 'saved-chat'); - assert.equal(setting(ui, 'image-model').value, 'saved-image'); assert.equal(setting(ui, 'search-limit').value, '17'); assert.equal(setting(ui, 'context-chars').value, '2300'); assert.match(setting(ui, 'conversation-budget').textContent, /240,000 characters \(UTF-16 code units\).*environment/); @@ -403,7 +392,7 @@ test('assistant config GET503 plus Save makes zero PUTs; failed retry preserves setting(ui, 'search-limit').value = '19'; ui.document.getElementById('btn-save-assistant-config').click(); await tick(); assert.deepEqual(writes(ui).map(c => [decodeURIComponent(c.url.split('/').pop()), c.body.value]), [ - ['clinical_assistant.chat_model', 'saved-chat'], ['clinical_assistant.image_model', 'saved-image'], + ['clinical_assistant.chat_model', 'saved-chat'], ['clinical_assistant.search_limit', '19'], ['clinical_assistant.context_chars', '2300'] ]); }); @@ -441,45 +430,27 @@ test('assistant config pending, rejected, HTTP failure and malformed payloads ne }); }); -test('image discovery pending/refresh/failure retains saved, custom and explicit default selections without placeholder writes', async t => { - let release; let rejectDiscovery; - const ui = await browser(t, 'admin-settings', url => { +test('single image-model dropdown keeps saved selection through discovery failures and saves both workflows', async t => { + const pending = []; + const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config') return json(assistantConfig()); if (url === '/api/models') throw Error('chat discovery offline'); - if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { release = resolve; rejectDiscovery = reject; }); + if (url === '/api/admin/image-settings') return json({ success: true, workflows: { clinical_assistant: { model: 'saved-image', budget: 32000 }, learning_hub: { model: 'saved-image', budget: 32000 } } }); + if (url.endsWith('/image-models/discover')) return new Promise((resolve, reject) => { pending.push({ resolve, reject }); }); }); - assert.equal(setting(ui, 'chat-model').value, 'saved-chat', 'chat discovery failure retains configured model'); - assert.equal(setting(ui, 'image-model').value, 'saved-image', 'no loading placeholder replaces configured selection'); - await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); - const refresh = ui.document.getElementById('btn-refresh-assistant-image-models'); - refresh.dispatchEvent(new ui.window.MouseEvent('click', { bubbles: true })); - assert.equal(ui.calls.filter(c => c.url.endsWith('/image-models/discover')).length, 1); - release(json({ error: 'unavailable' }, 503)); await tick(); - assert.equal(setting(ui, 'image-model').value, 'saved-image'); - assert.equal(ui.document.getElementById('btn-save-assistant-config').disabled, false); - refresh.click(); await tick(); - setting(ui, 'custom-image-model').value = 'new-custom'; - ui.document.getElementById('btn-use-custom-assistant-image-model').click(); - await forceAssistantSave(ui); assert.equal(writes(ui).length, 0); - release(json({ success: true, models: {} })); await tick(); - assert.equal(setting(ui, 'image-model').value, 'new-custom', 'late malformed discovery cannot revert a newer selection'); - ui.document.getElementById('btn-save-assistant-config').click(); await tick(); - assert.equal(writes(ui).find(c => c.url.endsWith('image_model')).body.value, 'new-custom'); - setting(ui, 'image-model').value = ''; - refresh.click(); await tick(); - const previousWrites = writes(ui).length; - await forceAssistantSave(ui); assert.equal(writes(ui).length, previousWrites); - release(json({ success: true, models: [{ id: 'discovered', name: 'Discovered' }] })); await tick(); - assert.equal(setting(ui, 'image-model').value, '', 'explicit default is not replaced by stale saved/custom fallback'); - ui.document.getElementById('btn-save-assistant-config').click(); await tick(); - assert.equal(writes(ui).filter(c => c.url.endsWith('image_model')).at(-1).body.value, ''); - setting(ui, 'custom-image-model').value = 'custom-offline'; - ui.document.getElementById('btn-use-custom-assistant-image-model').click(); - refresh.click(); await tick(); - rejectDiscovery(Error('synthetic discovery transport failure')); await tick(); - assert.equal(setting(ui, 'image-model').value, 'custom-offline'); - setting(ui, 'image-model').replaceChildren(); - await forceAssistantSave(ui); assert.equal(writes(ui).length, previousWrites + 4, 'empty select is not an intentional default'); + await tick(); await tick(); + assert.equal(pending.length, 1, 'one discovery request issued'); + pending[0].resolve(json({ error: 'unavailable' }, 503)); await tick(); await tick(); + const select = ui.document.querySelector('#workflow-image-settings select'); + assert.ok(select, 'image-model dropdown rendered'); + assert.equal(select.value, 'saved-image', 'saved selection kept while discovery failed'); + ui.document.querySelector('#workflow-image-settings form button').click(); await tick(); + const puts = () => ui.calls.filter(c => c.options.method === 'PUT' && c.url.includes('/api/admin/image-settings/')); + assert.equal(puts().length, 2); + for (const workflow of ['clinical_assistant', 'learning_hub']) { + assert.deepEqual(puts().find(c => c.url.endsWith(workflow)).body, { model: 'saved-image', budget: 32000 }); + } + assert.equal(select.value, 'saved-image', 'save never reverts the selection'); }); test('assistant settings retries leave global prompt drafts/history and starter snapshots untouched', async t => { @@ -487,7 +458,7 @@ test('assistant settings retries leave global prompt drafts/history and starter const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config') return available ? json(assistantConfig()) : json({ error: 'Request failed' }, 503); }); - const f = familyOf(ui, 'scribe'); + const f = familyOf(ui); draftOf(f).value = 'Unsaved global prompt'; f.querySelector('.prompt-revision-text').value = 'Previously viewed revision'; setting(ui, 'prompt-pool-snapshots').innerHTML = ''; @@ -528,45 +499,41 @@ test('admin tab already active at module init triggers loaders exactly once; gua assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'catalogue loader catches up exactly once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'CMS config and assistant settings each load once at init'); assert.equal(calls.filter(c => c.url === '/api/admin/config/models').length, 1, 'model list catches up at init'); - assert.equal(window.document.getElementById('cms-scribe-prompts').querySelectorAll('.prompt-select option').length, 29); + assert.equal(window.document.getElementById('cms-prompt-editor').querySelectorAll('.prompt-select option').length, 32); window.document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'admin' } })); await tick(); assert.equal(calls.filter(c => c.url === '/api/admin/config/prompts').length, 1, 'revisit never double-fires the catalogue loader'); assert.equal(calls.filter(c => c.url === '/api/admin/config').length, 2, 'guarded assistant/CMS loaders never double-fire'); }); -test('switching prompt or family keeps every unsaved draft in memory', async t => { +test('switching prompt keeps every unsaved draft in memory', async t => { const ui = await browser(t, 'admin'); - const scribe = familyOf(ui, 'scribe'); - const textFamily = familyOf(ui, 'clinical-text'); - const imageFamily = familyOf(ui, 'clinical-image'); - await choosePrompt(scribe, catalogue[0].dbKey); - draftOf(scribe).value = 'Scribe draft A'; - await choosePrompt(scribe, catalogue[1].dbKey); - assert.equal(draftOf(scribe).value, unsafe); - draftOf(scribe).value = 'Scribe draft B'; - await choosePrompt(textFamily, 'clinical_assistant.system_behavior'); - draftOf(textFamily).value = 'Text draft'; - await choosePrompt(imageFamily, 'clinical_assistant.image_behavior'); - draftOf(imageFamily).value = 'Image draft'; - await choosePrompt(scribe, catalogue[0].dbKey); - assert.equal(draftOf(scribe).value, 'Scribe draft A'); - await choosePrompt(scribe, catalogue[1].dbKey); - assert.equal(draftOf(scribe).value, 'Scribe draft B'); - await choosePrompt(textFamily, 'clinical_assistant.system_behavior'); - assert.equal(draftOf(textFamily).value, 'Text draft'); - await choosePrompt(imageFamily, 'clinical_assistant.image_behavior'); - assert.equal(draftOf(imageFamily).value, 'Image draft'); + const editor = familyOf(ui); + await choosePrompt(editor, catalogue[0].dbKey); + draftOf(editor).value = 'Scribe draft A'; + await choosePrompt(editor, catalogue[1].dbKey); + assert.equal(draftOf(editor).value, unsafe); + draftOf(editor).value = 'Scribe draft B'; + await choosePrompt(editor, 'clinical_assistant.system_behavior'); + draftOf(editor).value = 'Text draft'; + await choosePrompt(editor, 'clinical_assistant.image_behavior'); + draftOf(editor).value = 'Image draft'; + await choosePrompt(editor, catalogue[0].dbKey); + assert.equal(draftOf(editor).value, 'Scribe draft A'); + await choosePrompt(editor, catalogue[1].dbKey); + assert.equal(draftOf(editor).value, 'Scribe draft B'); + await choosePrompt(editor, 'clinical_assistant.system_behavior'); + assert.equal(draftOf(editor).value, 'Text draft'); + await choosePrompt(editor, 'clinical_assistant.image_behavior'); + assert.equal(draftOf(editor).value, 'Image draft'); }); -test('catalogue loading failure always reaches a visible retry state in every family, never an eternal spinner', async t => { +test('catalogue loading failure always reaches a visible retry state, never an eternal spinner', async t => { const ui = await browser(t, 'admin', url => { if (url === '/api/admin/config/prompts') return json({ error: 'Catalogue offline' }, 503); }); - for (const name of ['scribe', 'clinical-text', 'clinical-image']) { - const f = familyOf(ui, name); - assert.match(f.textContent, /Catalogue offline/); - assert.ok(f.querySelector('button'), 'retry button rendered'); - assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains'); - } + const f = familyOf(ui); + assert.match(f.textContent, /Catalogue offline/); + assert.ok(f.querySelector('button'), 'retry button rendered'); + assert.doesNotMatch(f.textContent, /Loading/, 'no eternal spinner text remains'); });
Name / Email Role
' + esc(u.name) + '
' + esc(u.email) + '
' + (u.role || 'user') + '' + statusText + '' + joined + '' + actions.join(' ') + '
' + esc(u.name) + '
' + esc(u.email) + '
' + (u.role || 'user') + '' + statusText + '' + joined + '' + actions.join(' ') + '