pediatric-ai-scribe-v3/public/js/admin/clinicalAssistant.js
Daniel fed4bd154f
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
fix: recordings that produced nothing, and the boxes that zoomed on iOS
Measured in a real browser against the app rather than reasoned about.

The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.

Three ways a recording could end with nothing to show for it:

  - Safari supports none of the audio/webm types and throws NotSupportedError
    when handed one. Six modules built their own recorder on resume with
    "opus, else audio/webm", so resuming threw there and the recording stopped.
    There is now one codec chain in the app, and no module constructs a
    MediaRecorder of its own.

  - audio-recorder-failed is dispatched on document, and the encounter tab
    stopped its recording on any of them. The assistant's microphone failing
    ended a consultation being recorded in another tab. The recorder now
    travels with the event and the listener checks it is its own.

  - The server answers {success:true, text:''} for silence, and five modules
    assigned that straight into the transcript — emptying the box the browser
    had been filling live. It reads as a recording that vanished. Text is now
    required before overwriting, and a recording that captured nothing says so
    instead of resetting the button over an empty box.

Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.

And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.

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

362 lines
19 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';
// Image models an admin added from the Image Generation card. 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 added yet. Add them under Image Generation below, then tick them here.');
}
// Adding or removing in the Image Generation card 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();
});
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();
});
function updateAssistantLoadState() {
var save = document.getElementById('btn-save-assistant-config');
if (save) save.disabled = configState !== 'ready';
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...' :
'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']);
imageRosterSaved = parseAssistantList(cfg['clinical_assistant.image_model_roster']);
window._assistantImageRoster = imageRosterSaved.slice();
// Only a nudge for the Image Generation card's buttons: 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 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();
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');
});
}
function saveAssistantAdmin() {
if (configState !== 'ready') 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();
}