pediatric-ai-scribe-v3/public/js/admin/clinicalAssistant.js
Daniel ae602a1852
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
fix: My Resources uses the image model you chose for it, and says so on save
"The model is chosen per request, where the deck is generated" described
something that happens nowhere. There is no per-request picker, and
asking where it was is what exposed the real bug.

My Resources read clinical_assistant.image_model while the admin screen
saved my_resources.image_model. That setting was stored, returned by the
API and rendered into the form — and never used by anything. Somebody
noticed the field did nothing and disabled it rather than finding out
why, which left a control that could not be changed and a note
explaining a mechanism that does not exist. My note repeating it was
wrong too.

The generator now reads its own setting and falls back to the
Assistant's, so an install that only ever set one model keeps working
untouched, and the field is enabled again with "leave blank to use the
Clinical Assistant's" — which is now true rather than a rationalisation.

Saving also says what it saved. "Saved 8:31:59 PM. Decks will be
reviewed by ..." answered a different question from the one an admin
actually has, which is whether the model they just picked is the one
that will draw. It now names each workflow's model and fallbacks back,
and spells out the blank case rather than leaving a gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:37:57 +02:00

466 lines
25 KiB
JavaScript

import { saveImageSettings } from './imageSettings.js';
function assistantBudgetMeta(budget, savedOverride) {
var limit = budget && Number.isInteger(budget.limit) ? budget.limit.toLocaleString() : null;
if (savedOverride) {
return 'Saved override in use. Clear this field to fall back to CLINICAL_ASSISTANT_CONVERSATION_CHARS' +
(limit ? ' (' + limit + ' UTF-16 code units).' : '.');
}
if (!limit) return 'UTF-16 code units. Leave empty to use CLINICAL_ASSISTANT_CONVERSATION_CHARS.';
// conversationBudget() reports 'default' when the env var is unset, so naming
// the env var alone would be wrong in the most common deployment.
return budget.source === 'environment'
? 'Currently ' + limit + ' UTF-16 code units from CLINICAL_ASSISTANT_CONVERSATION_CHARS. Set a value here to override it.'
: 'Currently the built-in default of ' + limit + ' UTF-16 code units (CLINICAL_ASSISTANT_CONVERSATION_CHARS is unset). Set a value here to override it.';
}
export function initClinicalAssistantAdmin(adminEscapeHtml) {
let configState = 'idle';
// Image models an admin added under Discover & test. The list below offers
// these, plus anything already allowed or configured so a saved choice never
// drops out of view.
let imageRosterSaved = [];
let savedChatAllowed = [];
let savedImageAllowed = [];
let chatRoster = [];
let imageRoster = [];
function parseAssistantList(value) {
return String(value || '').split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
function renderAssistantCheckboxList(containerId, candidates, saved, emptyText) {
var container = document.getElementById(containerId);
if (!container) return;
var ids = candidates.slice();
saved.forEach(function(id) { if (ids.indexOf(id) === -1) ids.push(id); });
ids = ids.filter(Boolean).filter(function(id, i, all) { return all.indexOf(id) === i; }).sort();
container.innerHTML = '';
if (!ids.length) {
var empty = document.createElement('p');
empty.style.cssText = 'margin:0;font-size:12px;color:var(--g500);';
empty.textContent = emptyText || 'No models available. Add or enable models first.';
container.appendChild(empty);
return;
}
ids.forEach(function(id) {
var row = document.createElement('label');
row.style.cssText = 'display:flex;align-items:center;gap:6px;font-size:12px;padding:2px 0;';
var box = document.createElement('input');
box.type = 'checkbox';
box.value = id;
box.checked = saved.indexOf(id) !== -1;
var span = document.createElement('span');
span.textContent = id;
row.appendChild(box);
row.appendChild(span);
container.appendChild(row);
});
}
function checkedAssistantModels(containerId) {
var container = document.getElementById(containerId);
if (!container) return [];
return Array.prototype.map.call(container.querySelectorAll('input[type="checkbox"]:checked'), function(box) { return box.value; });
}
// Built from the admin's roster, not from discovery: the gateway advertises
// dozens of image models, and this used to wait on a dropdown that no longer
// exists, so only four hard-coded fallbacks ever appeared.
function renderAssistantImageModelCheckboxes() {
imageRoster = imageRosterSaved.concat([window._assistantImageModelValue]);
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed,
'No image models on the roster yet. Add them under Discover & test, then tick them here.');
}
// Adding to or removing from the roster updates this list at once, keeping
// any ticks made here that have not been saved yet.
document.addEventListener('assistant-image-roster-changed', function(e) {
imageRosterSaved = (e.detail && Array.isArray(e.detail.roster)) ? e.detail.roster.slice() : imageRosterSaved;
if (configState !== 'ready') return;
savedImageAllowed = checkedAssistantModels('assistant-allowed-image-models');
renderAssistantImageModelCheckboxes();
});
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
});
// The roster changed while this card was already on screen. Its loader is
// guarded so it runs once per visit, which is right for a tab change and
// wrong here — without this, a model added in the Models card did not appear
// in the chat, allowed-models or review pickers until the page was reloaded.
document.addEventListener('models-changed', function() {
// The loader keeps unsaved drafts, so re-running it costs nothing but a
// refreshed list of options.
if (configState === 'loading') return;
configState = 'idle';
loadAssistantAdmin();
});
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantSettings(e.target.closest('#btn-save-assistant-config'));
if (e.target.closest('#btn-save-availability')) saveAvailability();
// Both cards that need these settings carry the notice and its retry.
if (e.target.closest('.btn-retry-assistant-config')) loadAssistantAdmin();
if (e.target.closest('#btn-test-assistant-chat-model')) testAssistantChatModel();
if (e.target.closest('#btn-regenerate-assistant-prompt-pool')) regenerateAssistantPromptPool();
if (e.target.closest('#btn-restore-assistant-prompt-pool')) restoreAssistantPromptPool();
});
// ── Slide review ────────────────────────────────────────────────────
// Saved with the rest of Availability. It is the one setting that spends
// money on every generation without a user having asked for anything, which
// is why the picker defaults to Off and says so: turning it on is still a
// deliberate act, it just does not need a Save button of its own to be one.
//
// Any chat model the gateway offers. Whether it can actually see an image is
// not something the model list says, so the choice is the administrator's —
// a deck reviewed by a text-only model is discarded rather than applied.
function renderReviewModel(models, saved) {
var select = document.getElementById('mr-review-model');
if (!select) return;
select.innerHTML = '';
var off = document.createElement('option');
off.value = '';
off.textContent = 'Off — do not review decks';
select.appendChild(off);
(Array.isArray(models) ? models : []).filter(function(m) { return m && m.id; }).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
select.appendChild(opt);
});
if (saved && !Array.prototype.some.call(select.options, function(o) { return o.value === saved; })) {
var kept = document.createElement('option');
kept.value = saved;
kept.textContent = saved + ' (saved/custom)';
select.appendChild(kept);
}
select.value = saved || '';
}
// One settings load feeds two cards — Availability and Clinical Assistant —
// so each has a Save, a status line and a failure notice of its own.
function updateAssistantLoadState() {
['btn-save-assistant-config', 'btn-save-availability'].forEach(function(id) {
var save = document.getElementById(id);
if (save) save.disabled = configState !== 'ready';
});
// The retry lives inside an error message rather than sitting beside Save
// looking like an ordinary control, which is how it read before.
document.querySelectorAll('.assistant-config-error').forEach(function(errorBox) {
errorBox.hidden = configState !== 'failed';
});
['assistant-admin-status', 'assistant-availability-status'].forEach(function(id) {
var status = document.getElementById(id);
// The failure case is spelled out in the error box above, so repeating it
// here would only be noise.
if (status && configState !== 'ready') {
status.textContent = configState === 'failed' ? '' : 'Loading settings...';
} else if (status && !/^Saved |^Not saved|^Not all/.test(status.textContent)) {
status.textContent = 'Settings loaded. Unsaved changes are kept until you press Save.';
}
});
}
function loadAssistantAdmin() {
if (configState === 'loading' || configState === 'ready') return;
configState = 'loading';
updateAssistantLoadState();
Promise.all([
fetch('/api/models', { headers: getAuthHeaders() }).then(function(r) {
if (!r.ok) throw new Error('Model discovery failed');
return r.json();
}).catch(function() { return {}; }),
fetch('/api/admin/config', { headers: getAuthHeaders() }).then(function(r) {
if (!r.ok) throw new Error('Settings request failed');
return r.json();
})
]).then(function(results) {
var modelsData = results[0] || {};
var configData = results[1];
var budget = configData && configData.conversationBudget;
var validBudget = budget && Number.isInteger(budget.limit) && budget.limit >= 1000 && budget.limit <= 1000000 &&
budget.unit === 'characters' && budget.measure === 'UTF-16 code units' &&
budget.env === 'CLINICAL_ASSISTANT_CONVERSATION_CHARS' && ['environment', 'default'].includes(budget.source);
// Validate the entire response before touching any setting or draft.
if (!configData || configData.success !== true || !Array.isArray(configData.config) ||
!configData.config.every(function(row) { return row && typeof row.key === 'string' && row.key && typeof row.value === 'string'; }) ||
!validBudget) throw new Error('Invalid settings response');
var cfg = Object.create(null);
configData.config.forEach(function(row) { cfg[row.key] = row.value; });
var chatSelect = document.getElementById('assistant-chat-model');
if (chatSelect) {
var savedChatModel = cfg['clinical_assistant.chat_model'] || '';
var defaultLabel = modelsData.defaultModel ? ('Use global default (' + modelsData.defaultModel + ')') : 'Use global default';
chatSelect.innerHTML = '';
var defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = defaultLabel;
chatSelect.appendChild(defaultOpt);
(Array.isArray(modelsData.models) ? modelsData.models : []).filter(function(m) { return m && typeof m.id === 'string' && m.id; }).forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
chatSelect.appendChild(opt);
});
if (savedChatModel && !Array.prototype.some.call(chatSelect.options, function(o) { return o.value === savedChatModel; })) {
var saved = document.createElement('option');
saved.value = savedChatModel;
saved.textContent = savedChatModel + ' (saved/custom)';
chatSelect.appendChild(saved);
}
chatSelect.value = savedChatModel;
}
renderReviewModel(modelsData.models, cfg['my_resources.review_model'] || '');
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']);
savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']);
imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']);
window._assistantImageRoster = imageRosterSaved.slice();
// Only a nudge for the Roster list and the Add/Added buttons under
// Discover & test: it must never be able to fail the settings load itself.
try { document.dispatchEvent(new CustomEvent('assistant-image-roster', { detail: { roster: imageRosterSaved.slice() } })); } catch (e) {}
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
renderAssistantImageModelCheckboxes();
setValue('assistant-search-limit', cfg['clinical_assistant.search_limit'] || '8');
setValue('assistant-context-chars', cfg['clinical_assistant.context_chars'] || '1400');
setValue('assistant-translate-provider', 'libretranslate'); // the only provider the server accepts
var sourcesBox = document.getElementById('assistant-show-sources');
if (sourcesBox) {
var saved = cfg['clinical_assistant.show_sources'];
if (saved === undefined || saved === '') saved = cfg['clinical_assistant.citations_enabled']; // legacy key
sourcesBox.checked = String(saved) !== 'false';
}
var budgetInput = document.getElementById('assistant-conversation-budget');
var cfgBudget = cfg['clinical_assistant.conversation_chars'];
if (budgetInput) {
// Empty means "no override". Prefilling the environment value here would
// turn the next Save into an accidental override, making the documented
// "leave empty to use the environment" path unreachable.
budgetInput.value = cfgBudget ? String(cfgBudget) : '';
if (budget && Number.isInteger(budget.limit)) budgetInput.placeholder = String(budget.limit);
}
var budgetMeta = document.getElementById('assistant-conversation-budget-meta');
if (budgetMeta) budgetMeta.textContent = assistantBudgetMeta(budget, cfgBudget);
configState = 'ready';
updateAssistantLoadState();
loadAssistantPromptPoolStatus();
}).catch(function() {
configState = 'failed';
updateAssistantLoadState();
var budgetMetaFailed = document.getElementById('assistant-conversation-budget-meta');
if (budgetMetaFailed) budgetMetaFailed.textContent = 'Conversation budget unavailable. Check server environment configuration; no fallback limit is assumed.';
});
}
function testAssistantChatModel() {
var model = getValue('assistant-chat-model');
var result = document.getElementById('assistant-chat-test-result');
if (!model) {
var select = document.getElementById('assistant-chat-model');
var selected = select && select.options[select.selectedIndex] ? select.options[select.selectedIndex].textContent : 'global default';
if (result) result.textContent = 'Testing ' + selected + '...';
} else if (result) {
result.textContent = 'Testing ' + model + '...';
}
fetch('/api/admin/config/models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model || getGlobalDefaultFromAssistantSelect() })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Chat model test failed');
if (result) result.textContent = 'Chat model OK (' + data.duration + ' ms): ' + (data.response || '').trim();
showToast('Chat model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function getGlobalDefaultFromAssistantSelect() {
var select = document.getElementById('assistant-chat-model');
if (!select || !select.options[0]) return '';
var match = select.options[0].textContent.match(/\((.+)\)$/);
return match ? match[1] : '';
}
function loadAssistantPromptPoolStatus() {
var result = document.getElementById('assistant-prompt-pool-status');
if (result) result.textContent = 'Checking prompt pool...';
fetch('/api/admin/clinical-assistant/prompt-pool', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool status failed');
renderAssistantPromptPoolStatus(data.meta);
renderAssistantPromptPoolSnapshots(data.snapshots || []);
})
.catch(function(err) {
if (result) result.textContent = err.message;
});
}
function regenerateAssistantPromptPool() {
var result = document.getElementById('assistant-prompt-pool-status');
var btn = document.getElementById('btn-regenerate-assistant-prompt-pool');
if (btn) btn.disabled = true;
if (result) result.textContent = 'Regenerating prompt pool. This can take several minutes...';
fetch('/api/admin/clinical-assistant/prompt-pool/regenerate', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool regeneration failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now() });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool regenerated', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
}).finally(function() {
if (btn) btn.disabled = false;
});
}
function renderAssistantPromptPoolStatus(meta) {
var result = document.getElementById('assistant-prompt-pool-status');
if (!result) return;
if (!meta) {
result.textContent = 'No generated pool found. The assistant will use indexed-topic fallback until you generate one.';
return;
}
var generated = meta.generatedAt ? new Date(meta.generatedAt).toLocaleString() : 'unknown time';
result.textContent = 'Generated pool: ' + (meta.count || 0) + ' prompts, target ' + (meta.target || '?') + ', generated ' + generated + (meta.restoredFrom ? ', restored from snapshot #' + meta.restoredFrom : '') + '.';
}
function renderAssistantPromptPoolSnapshots(snapshots) {
var select = document.getElementById('assistant-prompt-pool-snapshots');
if (!select) return;
select.innerHTML = '';
if (!snapshots.length) {
var empty = document.createElement('option');
empty.value = '';
empty.textContent = 'No saved snapshots';
select.appendChild(empty);
return;
}
snapshots.forEach(function(s) {
var opt = document.createElement('option');
opt.value = s.id;
var date = s.created_at ? new Date(s.created_at).toLocaleString() : 'unknown time';
opt.textContent = '#' + s.id + ' - ' + (s.count || 0) + ' prompts - ' + date + (s.restored_from ? ' (restored from #' + s.restored_from + ')' : '');
select.appendChild(opt);
});
}
function restoreAssistantPromptPool() {
var select = document.getElementById('assistant-prompt-pool-snapshots');
var id = select ? select.value : '';
var result = document.getElementById('assistant-prompt-pool-status');
if (!id) { showToast('Select a prompt pool snapshot', 'error'); return; }
if (result) result.textContent = 'Restoring prompt pool snapshot #' + id + '...';
fetch('/api/admin/clinical-assistant/prompt-pool/restore', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ id: Number(id) })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Prompt pool restore failed');
renderAssistantPromptPoolStatus(data.meta || { count: data.count, generatedAt: Date.now(), restoredFrom: id });
renderAssistantPromptPoolSnapshots(data.snapshots || []);
showToast('Prompt pool restored', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
// Each card saves exactly what it shows, so no card needs a note explaining
// what its Save covers. These were one button writing all eight keys from
// the bottom of a card that also held a second Save for the image settings.
function saveAssistantSettings(button) {
if (configState !== 'ready') return;
var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = 'Saving...';
Promise.all([
putAssistantConfig('clinical_assistant.conversation_chars', getValue('assistant-conversation-budget')),
putAssistantConfig('clinical_assistant.search_limit', getValue('assistant-search-limit') || '8'),
putAssistantConfig('clinical_assistant.context_chars', getValue('assistant-context-chars') || '1400'),
putAssistantConfig('clinical_assistant.translate_provider', getValue('assistant-translate-provider') || 'libretranslate'),
putAssistantConfig('clinical_assistant.show_sources',
(document.getElementById('assistant-show-sources') || {}).checked === false ? 'false' : 'true')
]).then(function() {
// A toast is gone in three seconds. Whether these settings are saved is
// exactly the question an admin has when they come back to this page, so
// the answer stays on the page.
if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.';
showToast('Assistant settings saved', 'success');
closeCard(button);
}).catch(function(err) {
if (status) status.textContent = 'Not saved. Nothing was changed.';
showToast(err.message || 'Save failed', 'error');
});
}
// The chat model, the two allowed lists, the per-workflow image settings and
// the slide reviewer: everything on the Availability card, in one press.
function saveAvailability() {
if (configState !== 'ready') return;
var chat = document.getElementById('assistant-chat-model');
if (!chat || chat.selectedIndex < 0) return;
var status = document.getElementById('assistant-availability-status');
if (status) status.textContent = 'Saving...';
Promise.all([
putAssistantConfig('clinical_assistant.chat_model', getValue('assistant-chat-model')),
putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')),
putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(',')),
putAssistantConfig('my_resources.review_model', getValue('mr-review-model')),
saveImageSettings()
]).then(function(results) {
var reviewer = getValue('mr-review-model');
// The image models named back. "Saved" does not tell an administrator
// that the model they chose is the one that will draw — which is the
// whole question, and the reason this card was hard to trust.
var images = Array.isArray(results[results.length - 1]) ? results[results.length - 1] : [];
if (status) {
status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '. ' +
(images.length ? 'Images — ' + images.join('; ') + '. ' : '') +
(reviewer ? 'Decks reviewed by ' + reviewer + '. ' : 'Slide review is off. ') +
'New image jobs use these settings; existing jobs are unchanged.';
}
showToast('Availability saved', 'success');
}).catch(function(err) {
// Promise.all does not undo the writes that succeeded, so "nothing was
// changed" would be untrue here.
if (status) status.textContent = 'Not all of it was saved: ' + (err.message || 'Save failed');
showToast(err.message || 'Save failed', 'error');
});
}
// Save & Close: the card folds so the page reads as done. The summary keeps
// focus so a keyboard user is not dropped somewhere off screen.
function closeCard(button) {
var card = button && button.closest ? button.closest('details') : null;
if (!card) return;
card.open = false;
var summary = card.querySelector('summary');
if (summary && typeof summary.focus === 'function') summary.focus();
if (typeof card.scrollIntoView === 'function') card.scrollIntoView({ block: 'nearest' });
}
function putAssistantConfig(key, value) {
return fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: value })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Save failed');
return data;
});
}
function chatOptions() {
var chat = document.getElementById('assistant-chat-model');
var out = [];
if (chat) Array.prototype.forEach.call(chat.options, function(o) { out.push({ value: o.value }); });
return out;
}
function getValue(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function setValue(id, value) { var el = document.getElementById(id); if (el) el.value = value; }
const escAssistant = adminEscapeHtml;
// Catch-up: when the admin tab is already active and loaded at module init
// (e.g. restored tab before this module evaluated), tabChanged may never fire
// again. The loader's own state guards make this idempotent with tabChanged.
var adminTab = document.getElementById('admin-tab');
if (adminTab && adminTab.classList.contains('active') && adminTab.dataset.loaded === '1') loadAssistantAdmin();
}