feat: unify Admin model/prompt UI — single prompt dropdown, two image-model dropdowns, searchable/scrollable users, remove classification/custom-add, consistent font, concise labels

This commit is contained in:
Daniel 2026-09-07 18:18:48 +02:00
parent bb82295173
commit 13fca7887d
10 changed files with 285 additions and 380 deletions

View file

@ -23,11 +23,12 @@
<div class="card">
<div class="card-header">
<h3><i class="fas fa-users"></i> Users</h3>
<input id="admin-users-search" type="search" placeholder="Search name or email" style="flex:1;max-width:260px;font-size:13px;padding:5px 10px;border:1px solid var(--g300);border-radius:6px;" aria-label="Search users">
<button id="btn-refresh-users" class="btn-sm btn-ghost"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div style="overflow-x:auto;">
<div style="overflow:auto;max-height:420px;border-top:1px solid var(--g100);">
<table class="admin-table" id="admin-users-table">
<thead>
<thead style="position:sticky;top:0;background:white;z-index:1;">
<tr>
<th>Name / Email</th>
<th>Role</th>
@ -160,17 +161,6 @@
</div>
</div>
<!-- ── CMS: AI Scribe Prompts ─────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-robot"></i> AI Scribe — plain-text / JSON instructions</h3>
<span style="font-size:12px;color:var(--g500);">Saved overrides replace shipped defaults immediately</span>
</div>
<div style="padding:16px;">
<div id="cms-scribe-prompts">Loading Scribe prompts...</div>
</div>
</div>
<!-- ── CMS: SMTP Email Server ─────────────────────────────────── -->
<div class="card">
<div class="card-header">
@ -271,24 +261,6 @@
<p id="admin-discover-hint" style="font-size:12px;color:var(--g500);margin:8px 0 0;">Click "Search API" to query your configured provider for all available models. Use the search box to filter results.</p>
</div>
<!-- Custom Model (manual) -->
<div style="border-top:1px solid var(--g100);padding-top:14px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:8px;">Add Custom Model (manual)</label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<input type="text" id="admin-custom-model-id" placeholder="Model ID (e.g. openai/gpt-4.1)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<input type="text" id="admin-custom-model-name" placeholder="Display name" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<input type="text" id="admin-custom-model-cost" placeholder="Cost (e.g. ~$0.01)" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<select id="admin-custom-model-cat" style="font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<option value="fast">Fast & Cheap</option>
<option value="smart" selected>Smart</option>
<option value="premium">Premium</option>
<option value="free">Free</option>
</select>
</div>
<div style="margin-top:8px;">
<button id="btn-add-custom-model" class="btn-sm btn-primary"><i class="fas fa-plus"></i> Add Model</button>
</div>
</div>
<!-- Custom Models List -->
<div id="admin-custom-models-list" style="display:flex;flex-direction:column;gap:4px;">
@ -300,67 +272,46 @@
<!-- ── Clinical Assistant ─────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-brain"></i> Clinical Assistant</h3>
<span style="font-size:12px;color:var(--g500);">Chat and image model overrides for the assistant</span>
<h3><i class="fas fa-brain"></i> Clinical Assistant / Learning</h3>
<span style="font-size:12px;color:var(--g500);">Model and prompt settings</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Chat model:</label>
<select id="assistant-chat-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px;">
<div class="admin-row">
<label for="assistant-chat-model" class="admin-row-label">Chat model</label>
<select id="assistant-chat-model" class="admin-control"></select>
<button id="btn-test-assistant-chat-model" class="btn-sm btn-primary" type="button"><i class="fas fa-vial"></i> Test</button>
</div>
<div id="assistant-chat-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="border:1px solid var(--g100);border-radius:8px;padding:10px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
<div>
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool — suggested questions, not global prompt history</div>
<div style="font-size:13px;font-weight:600;color:var(--g700);">Starter prompt pool</div>
<div id="assistant-prompt-pool-status" style="font-size:12px;color:var(--g500);margin-top:3px;">Checking prompt pool...</div>
</div>
<button id="btn-regenerate-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Regenerate Pool</button>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%;">
<select id="assistant-prompt-pool-snapshots" style="font-size:12px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
<select id="assistant-prompt-pool-snapshots" class="admin-control" style="flex:1;min-width:220px;"><option value="">Loading snapshots...</option></select>
<button id="btn-restore-assistant-prompt-pool" class="btn-sm btn-ghost" type="button"><i class="fas fa-clock-rotate-left"></i> Restore Snapshot</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
<label style="font-size:13px;font-weight:600;min-width:130px;">Image model:</label>
<select id="assistant-image-model" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;"></select>
<button id="btn-refresh-assistant-image-models" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate"></i> Refresh</button>
<button id="btn-test-assistant-image-model" class="btn-sm btn-primary" type="button"><i class="fas fa-image"></i> Test</button>
</div>
<div id="assistant-image-test-result" style="font-size:12px;color:var(--g500);"></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:-6px;">
<label style="font-size:12px;font-weight:600;color:var(--g600);min-width:130px;">Custom image model</label>
<input id="assistant-custom-image-model" type="text" placeholder="e.g. openrouter-gpt-5-image" style="font-size:13px;padding:5px 8px;border:1px solid var(--g300);border-radius:6px;flex:1;max-width:420px;">
<button id="btn-use-custom-assistant-image-model" class="btn-sm btn-ghost" type="button"><i class="fas fa-plus"></i> Use Custom</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;">
<div>
<label for="assistant-search-limit" style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Retrieval result limit</label>
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<div class="admin-row">
<label for="assistant-search-limit" class="admin-row-label">Retrieval result limit</label>
<input id="assistant-search-limit" type="number" min="3" max="20" value="8" class="admin-control">
</div>
<div>
<label for="assistant-context-chars" style="font-size:12px;font-weight:600;color:var(--g600);display:block;margin-bottom:4px;">Context per excerpt (characters)</label>
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" style="width:100%;font-size:13px;padding:6px 10px;border:1px solid var(--g300);border-radius:6px;">
<div class="admin-row">
<label for="assistant-context-chars" class="admin-row-label">Context per excerpt (characters)</label>
<input id="assistant-context-chars" type="number" min="300" max="4000" value="1400" class="admin-control">
</div>
</div>
<div>
<strong>Conversation input budget</strong>
<p id="assistant-conversation-budget" role="status">Loading server budget metadata...</p>
<div class="admin-row">
<strong class="admin-row-label">Conversation input budget</strong>
<p id="assistant-conversation-budget" role="status" style="margin:0;font-size:13px;color:var(--g600);">Loading server budget metadata...</p>
</div>
<section aria-labelledby="assistant-text-prompts-heading">
<h4 id="assistant-text-prompts-heading">Clinical Assistant TEXT — system behavior</h4>
<div id="cms-clinical-text-prompts">Loading clinical text prompt...</div>
<section aria-labelledby="prompts-heading">
<h4 id="prompts-heading">Prompts</h4>
<div id="cms-prompt-editor">Loading prompts...</div>
</section>
<section aria-labelledby="assistant-image-prompts-heading">
<h4 id="assistant-image-prompts-heading">Clinical Assistant IMAGE — poster instructions</h4>
<div id="cms-clinical-image-prompts">Loading clinical image prompt...</div>
</section>
<section aria-labelledby="learning-image-prompts-heading">
<h4 id="learning-image-prompts-heading">Learning Hub IMAGE — authoring instructions</h4>
<p>Separate model-callable authoring image behavior; includes generation and requested refinement images. History and restore affect Learning Hub only.</p>
<div id="cms-learning-image-prompts">Loading Learning image prompt...</div>
</section>
<section id="workflow-image-settings" aria-label="Workflow image budgets"></section>
<section id="workflow-image-settings" aria-label="Workflow image settings"></section>
<div>
<button id="btn-save-assistant-config" class="btn-sm btn-primary" disabled><i class="fas fa-floppy-disk"></i> Save Model &amp; Retrieval Settings</button>
<button id="btn-retry-assistant-config" class="btn-sm btn-ghost" type="button" hidden>Retry loading settings</button>
@ -444,11 +395,12 @@
<!-- ── Embedding Models ───────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-network-wired"></i> Embedding Models</h3>
<h3><i class="fas fa-network-wired"></i> Embedding Models — Learning Hub semantic search</h3>
<span id="admin-embed-provider-badge" style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--g100);color:var(--g600);">Loading...</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
<div id="admin-embed-info" style="font-size:12px;color:var(--g500);">Loading...</div>
<p style="font-size:12px;color:var(--g500);margin:0;">Corpus (MCP) embeddings are configured on the indexing service, not here.</p>
<!-- Search embedding models from provider -->
<div>

View file

@ -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;
}

View file

@ -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('<button class="btn-sm btn-primary admin-action" data-action="verify" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Verify</button>');
}
if (u.disabled) {
actions.push('<button class="btn-sm btn-success admin-action" data-action="enable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Enable</button>');
} else {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="disable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Disable</button>');
}
if (u.role === 'admin') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="demote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Demote</button>');
} else {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
}
if (u.role !== 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-moderator" data-id="' + u.id + '" data-email="' + esc(u.email) + '" style="color:var(--purple);">Set Moderator</button>');
}
if (u.role === 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-user" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Set User</button>');
}
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
} else {
actions.push('<span style="font-size:12px;color:var(--g400);">(you)</span>');
}
return '<tr>' +
'<td><div style="font-weight:600;font-size:13px;">' + esc(u.name) + '</div><div style="font-size:11px;color:var(--g500);">' + esc(u.email) + '</div></td>' +
'<td><span style="font-size:12px;font-weight:600;color:' + roleColor + ';">' + (u.role || 'user') + '</span></td>' +
'<td><span style="font-size:12px;color:' + statusColor + ';">' + statusText + '</span></td>' +
'<td style="font-size:12px;color:var(--g500);">' + joined + '</td>' +
'<td style="white-space:nowrap;">' + actions.join(' ') + '</td>' +
'</tr>';
}).join('');
// Bind action buttons
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('<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) {
@ -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);
'<label style="display:flex;align-items:center;gap:8px;flex:1;cursor:pointer;margin:0;">' +
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--g100);color:var(--g600);">' + esc(m.tag || '') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'</label>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Test</button>' +
'</div>';
@ -983,17 +987,15 @@ initClinicalAssistantAdmin(adminEscapeHtml);
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models. Click + to add to your model list.</p>' +
data.models.slice(0, 100).map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" data-mcost="' + esc(m.cost) + '" data-mcat="' + esc(m.category) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
'<button class="btn-sm 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>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g600);">' + esc(m.category || '') + '</span>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
btn.addEventListener('click', function() {
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn.dataset.mcost, btn.dataset.mcat, btn);
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 '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:var(--blue-light, #e0f0ff);color:var(--blue);">' + esc(m.tag || 'CUSTOM') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(m.cost || '') + '</span>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
'</div>';

View file

@ -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() {

View file

@ -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(); });
}

View file

@ -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' });

View file

@ -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' },
];

View file

@ -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 = '\"><svg onload=alert(1)>';
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 => {

View file

@ -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();

View file

@ -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 = '<option value="7">Existing snapshot</option>';
@ -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');
});