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 '
' +
- '' + esc(u.name) + ' ' + esc(u.email) + ' | ' +
- '' + (u.role || 'user') + ' | ' +
- '' + statusText + ' | ' +
- '' + joined + ' | ' +
- '' + actions.join(' ') + ' | ' +
- '
';
- }).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 '
' +
+ '' + esc(u.name) + ' ' + esc(u.email) + ' | ' +
+ '' + (u.role || 'user') + ' | ' +
+ '' + statusText + ' | ' +
+ '' + joined + ' | ' +
+ '' + actions.join(' ') + ' | ' +
+ '
';
+ }
+
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 = '\">