pediatric-ai-scribe-v3/public/js/admin/clinicalAssistant.js
Daniel db83255c58 feat: display-only sources toggle, signed-out preview, and a composer that carries the toolbar
Sources (correcting what I built earlier)
The previous toggle branched the SYSTEM PROMPT, so the same question could get a
different answer depending on a display setting — the bias this was meant to
avoid. The prompt is now unconditional: buildSystemPrompt takes no display
argument and is byte-identical either way. Hiding sources happens on the way out
— the server omits them and strips the now-orphaned [n] markers from the copy it
sends. The answer is generated, stored and exported with citations intact, so
turning the setting back on restores them without re-asking anything. Renamed to
clinical_assistant.show_sources; the old key is still honoured.

Signed-out preview (admin opt-in, default off)
A visitor may try the assistant; reaching for the workspace asks them to sign in.
Deliberately narrow:
- Reachable paths are an exact allow-list, not a pattern, so a new endpoint is
  private unless someone adds it on purpose.
- A preview visitor gets no identity at all (id: null), so nothing can be owned,
  saved, billed or addressed to them.
- The image tool is withheld rather than left to fail on a null owner, and no
  audit rows are written.
- A caller presenting a token is authenticated normally, so preview can never
  downgrade a real session; if the setting cannot be read, authentication is
  required.
- Actions needing an account are hidden rather than offered and refused.

Composer
The bar above the transcript is gone. Patient take home, Export PDF, Download
transcript and Attach images moved into a + menu in the composer, and the model
selector moved beside send — shown only when there is more than one model, as
before. Both views now start at the same top edge, so switching modes cannot
nudge the page up or down. On an empty transcript the tiled ground runs behind
and below the composer, which floats on it above centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
2026-09-10 04:12:36 +02:00

430 lines
22 KiB
JavaScript

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';
const IMAGE_MODEL_FALLBACKS = ['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'];
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) {
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 = '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; });
}
function renderAssistantImageModelCheckboxes(models) {
imageRoster = (Array.isArray(models) ? models : []).map(function(m) { return m.id; }).concat(IMAGE_MODEL_FALLBACKS, [window._assistantImageModelValue]);
renderAssistantCheckboxList('assistant-allowed-image-models', imageRoster, savedImageAllowed);
}
let imageModelsLoading = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadAssistantAdmin();
});
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-assistant-config')) saveAssistantAdmin();
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();
if (e.target.closest('#btn-refresh-assistant-image-models')) loadAssistantImageModels();
if (e.target.closest('#btn-test-assistant-image-model')) testAssistantImageModel();
if (e.target.closest('#btn-use-custom-assistant-image-model')) useCustomAssistantImageModel();
});
function updateAssistantLoadState() {
var save = document.getElementById('btn-save-assistant-config');
if (save) save.disabled = configState !== 'ready' || imageModelsLoading;
var retry = document.getElementById('btn-retry-assistant-config');
if (retry) retry.hidden = configState !== 'failed';
var status = document.getElementById('assistant-admin-status');
if (status) status.textContent = configState === 'failed' ?
'Settings load failed. Drafts are unchanged. Retry loading settings or revisit the Admin tab.' :
configState !== 'ready' ? 'Loading assistant settings...' :
imageModelsLoading ? 'Loading image models; saving is unavailable until discovery finishes.' : 'Settings ready.';
}
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;
}
window._assistantImageModelValue = cfg['clinical_assistant.image_model'] || '';
savedChatAllowed = parseAssistantList(cfg['clinical_assistant.allowed_models']);
savedImageAllowed = parseAssistantList(cfg['clinical_assistant.allowed_image_models']);
chatRoster = chatOptions().map(function(o) { return o.value; }).filter(Boolean).concat([cfg['clinical_assistant.chat_model'] || '']);
renderAssistantCheckboxList('assistant-allowed-chat-models', chatRoster, savedChatAllowed);
renderAssistantImageModelCheckboxes([]);
renderAssistantImageModels([], window._assistantImageModelValue);
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 previewBox = document.getElementById('assistant-preview-enabled');
if (previewBox) previewBox.checked = String(cfg['clinical_assistant.preview_enabled']) === 'true';
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();
loadAssistantImageModels();
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 renderAssistantImageModels(models, current) {
var sel = document.getElementById('assistant-image-model');
if (!sel) return;
sel.innerHTML = '<option value="">Use default (openai-gpt-image-1)</option>';
models.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name || m.id;
sel.appendChild(opt);
});
if (current && !Array.prototype.some.call(sel.options, function(o) { return o.value === current; })) {
var custom = document.createElement('option');
custom.value = current;
custom.textContent = current + ' (saved/custom)';
sel.appendChild(custom);
}
sel.value = current;
}
function loadAssistantImageModels() {
var sel = document.getElementById('assistant-image-model');
if (!sel || configState !== 'ready' || imageModelsLoading) return;
// Keep the real selection, never an empty loading option. Read it again on
// completion so a newer custom selection wins over an in-flight discovery.
imageModelsLoading = true;
sel.disabled = true;
updateAssistantLoadState();
fetch('/api/admin/config/image-models/discover', { headers: getAuthHeaders() })
.then(function(r) {
if (!r.ok) throw new Error('Image discovery failed');
return r.json();
})
.then(function(data) {
if (!data || data.success === false || !Array.isArray(data.models) ||
!data.models.every(function(m) { return m && typeof m.id === 'string' && m.id; })) throw new Error('Invalid image models');
renderAssistantImageModelCheckboxes(data.models);
renderAssistantImageModels(data.models, sel.value);
})
.catch(function() {
renderAssistantImageModels(['openai-gpt-image-1', 'openai-gpt-image-1-mini', 'openai-gpt-image-1.5', 'openai-dall-e-3'].map(function(id) { return { id: id }; }), sel.value);
})
.finally(function() {
imageModelsLoading = false;
sel.disabled = false;
updateAssistantLoadState();
});
}
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');
});
}
function testAssistantImageModel() {
var model = getValue('assistant-image-model') || 'openai-gpt-image-1';
var result = document.getElementById('assistant-image-test-result');
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Testing ' + escAssistant(model) + '...';
fetch('/api/admin/config/image-models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: model })
}).then(function(r) { return r.json(); }).then(function(data) {
if (!data.success) throw new Error(data.error || 'Image test failed');
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) result.innerHTML = 'Image model OK (' + data.duration + ' ms)' + (src ? '<div style="margin-top:8px;"><img src="' + escAssistant(src) + '" alt="test image" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
showToast('Image model works', 'success');
}).catch(function(err) {
if (result) result.textContent = err.message;
showToast(err.message, 'error');
});
}
function useCustomAssistantImageModel() {
var input = document.getElementById('assistant-custom-image-model');
var sel = document.getElementById('assistant-image-model');
var model = input ? input.value.trim() : '';
if (!model) { showToast('Enter an image model ID', 'error'); return; }
if (sel && !Array.prototype.some.call(sel.options, function(o) { return o.value === model; })) {
var opt = document.createElement('option');
opt.value = model;
opt.textContent = model + ' (custom)';
sel.appendChild(opt);
}
if (sel) sel.value = model;
window._assistantImageModelValue = model;
showToast('Custom image model selected. Save settings to keep it.', 'info');
}
function saveAssistantAdmin() {
if (configState !== 'ready' || imageModelsLoading) return;
var chat = document.getElementById('assistant-chat-model');
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.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'),
putAssistantConfig('clinical_assistant.preview_enabled',
(document.getElementById('assistant-preview-enabled') || {}).checked === true ? 'true' : 'false'),
putAssistantConfig('clinical_assistant.allowed_models', checkedAssistantModels('assistant-allowed-chat-models').join(',')),
putAssistantConfig('clinical_assistant.allowed_image_models', checkedAssistantModels('assistant-allowed-image-models').join(','))
]).then(function() {
if (status) status.textContent = '';
showToast('Assistant settings saved', 'success');
}).catch(function(err) {
if (status) status.textContent = '';
showToast(err.message || 'Save failed', 'error');
});
}
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();
}