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:
parent
bb82295173
commit
13fca7887d
10 changed files with 285 additions and 380 deletions
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(); });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue