pediatric-ai-scribe-v3/public/js/clinicalAssistant.js
2026-05-06 23:04:40 +02:00

476 lines
22 KiB
JavaScript

// ============================================================
// CLINICAL ASSISTANT — evidence-grounded chat UI.
// Backend contract intentionally uses /api/clinical-assistant/* so the
// server can call native MCP directly without routing through mcpo.
// ============================================================
(function () {
'use strict';
var initialized = false;
var messages = [];
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
var dynamicExamples = [];
var EMPTY_PROMPT_SETS = [
[
{ label: 'Status asthma escalation', prompt: 'In a 4-year-old with acute wheeze, when should magnesium sulfate be considered and what dose is recommended?' },
{ label: 'Bilious vomiting', prompt: 'What are the red flags for bilious vomiting in neonates, and what immediate workup is recommended?' },
{ label: 'Bronchiolitis vs asthma', prompt: 'Compare bronchiolitis and asthma management in infants, citing pediatric references.' }
],
[
{ label: 'Febrile neonate', prompt: 'What initial workup and empiric antibiotics are recommended for a well-appearing febrile neonate?' },
{ label: 'DKA fluids', prompt: 'Summarize pediatric DKA fluid strategy and cerebral edema warning signs.' },
{ label: 'Croup escalation', prompt: 'When should a child with croup receive nebulized epinephrine and observation?' }
],
[
{ label: 'Sepsis bolus', prompt: 'In pediatric septic shock, when should fluid boluses be limited and vasoactives started?' },
{ label: 'Head injury CT', prompt: 'Which pediatric head injury features should prompt CT or observation?' },
{ label: 'UTI fever', prompt: 'How should febrile UTI be evaluated and treated in young children?' }
],
[
{ label: 'Sickle pain fever', prompt: 'How should fever and pain crisis be managed in a child with sickle cell disease?' },
{ label: 'Neonatal jaundice', prompt: 'What bilirubin risk factors change phototherapy decisions in a newborn?' },
{ label: 'Status seizure', prompt: 'Outline first- and second-line treatment for pediatric status epilepticus.' }
]
];
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
});
function initIfNeeded() {
if (initialized) return;
var root = document.getElementById('assistant-tab');
if (!root || !root.querySelector('#assistant-form')) return;
initialized = true;
bindEvents();
loadStatus();
loadExamples();
}
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var copyBtn = document.getElementById('btn-assistant-copy');
var imageBtn = document.getElementById('btn-assistant-image');
var input = document.getElementById('assistant-input');
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
bindExampleButtons(document);
}
function loadStatus() {
fetch('/api/clinical-assistant/status', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
var label = document.getElementById('assistant-model-label');
if (label && data.success) {
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
}
})
.catch(function () {
// Endpoint may not exist until backend slice lands; keep UI usable.
});
}
function loadExamples() {
fetch('/api/clinical-assistant/examples', { headers: getAuthHeaders(), credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success || !Array.isArray(data.examples) || !data.examples.length) return;
dynamicExamples = data.examples;
var wrap = document.getElementById('assistant-messages');
if (wrap && wrap.querySelector('.assistant-empty')) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
})
.catch(function () {});
}
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
var includeContext = document.getElementById('assistant-include-context');
var text = input ? input.value.trim() : '';
if (!text) {
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
return;
}
appendMessage('user', text);
if (input) input.value = '';
if (isImageRequest(text)) {
generateImage(text, true);
return;
}
setBusy(true, 'Searching indexed resources...');
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited pediatric references...');
fetch('/api/clinical-assistant/chat', {
method: 'POST',
headers: getAuthHeaders(),
credentials: 'same-origin',
body: JSON.stringify({
message: text,
history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked
})
})
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
setBusy(false, 'Ready');
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
var answer = data.answer || data.markdown || '';
lastAnswer = answer;
lastSources = data.sources || data.citations || [];
replaceLoadingMessage(loading, answer, lastSources, data.suggestions || []);
renderSources(lastSources);
if (data.model) {
var label = document.getElementById('assistant-model-label');
if (label) label.textContent = 'Chat: ' + data.model;
}
})
.catch(function (err) {
setBusy(false, 'Error', true);
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function appendLoadingMessage(title, detail) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return null;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
var row = document.createElement('div');
row.className = 'assistant-msg assistant assistant-loading-msg';
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble assistant-thinking';
bubble.innerHTML = '<div class="assistant-thinking-line"><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><strong>' + escapeHtml(title || 'Working') + '</strong></div>' +
'<div class="assistant-thinking-detail">' + escapeHtml(detail || 'Preparing response...') + '</div>';
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
wrap.scrollTop = wrap.scrollHeight;
return row;
}
function replaceLoadingMessage(row, content, sources, suggestions, rawHtml) {
if (!row || !row.parentNode) {
appendMessage('assistant', content, sources, suggestions, rawHtml);
return;
}
var bubble = row.querySelector('.assistant-bubble');
if (!bubble) return;
row.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || []);
if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
messages.push({ role: 'assistant', content: content });
}
function appendMessage(role, content, sources, suggestions, rawHtml) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
messages.push({ role: role, content: content });
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content));
if (role === 'assistant' && suggestions && suggestions.length) {
bubble.appendChild(renderSuggestionButtons(suggestions));
}
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
renderEmbeddedBlocks(bubble);
wrap.scrollTop = wrap.scrollHeight;
}
function renderSuggestionButtons(suggestions) {
var wrap = document.createElement('div');
wrap.className = 'assistant-suggestion-buttons';
suggestions.forEach(function (s) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = s;
btn.addEventListener('click', function () {
var input = document.getElementById('assistant-input');
if (input) input.value = s;
onAsk(new Event('submit'));
});
wrap.appendChild(btn);
});
return wrap;
}
function renderMarkdown(md, sources) {
var text = String(md || '');
var codeBlocks = [];
text = text.replace(/```(\w+)?\n([\s\S]*?)```/g, function (_, lang, code) {
var idx = codeBlocks.length;
codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code });
return '\n@@CODEBLOCK_' + idx + '@@\n';
});
text = stripSourcesSection(text);
text = renderLatexText(text);
var html;
if (window.marked && typeof window.marked.parse === 'function') {
html = window.marked.parse(text, { breaks: true, gfm: true });
} else {
html = fallbackMarkdown(text);
}
html = html.replace(/\[(\d+)\]/g, function (_, n) {
var source = sources[Number(n) - 1];
if (!source) return '[' + n + ']';
var title = source ? source.title || source.resource || 'Source' : 'Source';
return '<a class="assistant-cite" href="#assistant-source-' + n + '" title="' + escapeHtml(title) + '">[' + n + ']</a>';
});
html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) {
var block = codeBlocks[Number(idx)] || { lang: '', code: '' };
if (block.lang === 'mermaid') return '<div class="assistant-mermaid" data-mermaid="' + escapeAttr(block.code) + '">Rendering graph...</div>';
if (block.lang === 'chart' || block.lang === 'chartjs') return '<canvas class="assistant-chart" data-chart="' + escapeAttr(block.code) + '"></canvas>';
return '<pre><code>' + escapeHtml(block.code) + '</code></pre>';
});
return sanitize(html);
}
function fallbackMarkdown(text) {
var html = escapeHtml(text)
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.split(/\n{2,}/).map(function (block) {
if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block;
var lines = block.split('\n');
if (lines.every(function (l) { return /^\s*[-*] /.test(l) || !l.trim(); })) {
return '<ul>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*[-*] /, '') + '</li>'; }).join('') + '</ul>';
}
if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) {
return '<ol>' + lines.filter(Boolean).map(function (l) { return '<li>' + l.replace(/^\s*\d+\. /, '') + '</li>'; }).join('') + '</ol>';
}
return '<p>' + block.replace(/\n/g, '<br>') + '</p>';
}).join('');
return html;
}
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
if (!sources || sources.length === 0) {
wrap.innerHTML = '<p class="assistant-muted">No citations returned.</p>';
return;
}
wrap.innerHTML = sources.map(function (s, idx) {
var n = s.number || idx + 1;
var page = s.page || s.page_number || s.pageNumber;
var meta = [];
if (page) meta.push('page ' + page);
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(String(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
'</div>';
}).join('');
}
function renderEmbeddedBlocks(root) {
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
if (!window.Chart) return;
try {
var cfg = JSON.parse(canvas.getAttribute('data-chart') || '{}');
new window.Chart(canvas.getContext('2d'), cfg);
} catch (e) {
canvas.replaceWith(document.createTextNode('Invalid chart JSON'));
}
});
}
function ensureMermaid() {
if (window.mermaid && mermaidReady) return Promise.resolve();
return new Promise(function (resolve) {
if (window.mermaid) { configureMermaid(); resolve(); return; }
var script = document.createElement('script');
script.src = '/vendor/mermaid.min.js';
script.onload = function () { configureMermaid(); resolve(); };
script.onerror = resolve;
document.head.appendChild(script);
});
}
function configureMermaid() {
if (!window.mermaid) return;
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { useMaxWidth: true, htmlLabels: true } });
mermaidReady = true;
}
function generateImage(promptOverride, fromChat) {
var promptEl = document.getElementById('assistant-image-prompt');
var out = document.getElementById('assistant-visual-output');
var prompt = typeof promptOverride === 'string' ? promptOverride.trim() : (promptEl ? promptEl.value.trim() : '');
if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000);
if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; }
if (fromChat) setBusy(true, 'Generating image...');
var loading = fromChat ? appendLoadingMessage('Generating image', 'Creating the requested clinical visual...') : null;
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
fetch('/api/clinical-assistant/image', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
body: JSON.stringify({ prompt: prompt })
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Image generation failed');
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (!src) throw new Error('No image returned');
if (out) out.innerHTML = '<img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open</a>';
if (fromChat) {
setBusy(false, 'Ready');
var html = '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open image</a></div>';
replaceLoadingMessage(loading, html, [], [], true);
}
})
.catch(function (err) {
if (fromChat) setBusy(false, 'Error', true);
if (out) out.innerHTML = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
if (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message);
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function clearConversation() {
messages = [];
lastAnswer = '';
lastSources = [];
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
renderSources([]);
}
function renderEmptyState() {
var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
'<h3>Evidence-first pediatric assistant</h3>' +
'<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>' +
'<div class="assistant-examples">' + examples.map(function (item) {
var source = item.sourceTitle ? (' title="Available from: ' + escapeAttr(item.sourceTitle + (item.page ? ', page ' + item.page : '')) + '"') : '';
return '<button type="button" data-assistant-example="' + escapeAttr(item.prompt) + '"' + source + '>' + escapeHtml(item.label) + '</button>';
}).join('') + '</div></div>';
}
function pickExamples() {
var examples = dynamicExamples.length ? dynamicExamples.slice() : EMPTY_PROMPT_SETS[Math.floor(Math.random() * EMPTY_PROMPT_SETS.length)].slice();
for (var i = examples.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = examples[i];
examples[i] = examples[j];
examples[j] = tmp;
}
return examples.slice(0, 3);
}
function bindExampleButtons(root) {
(root || document).querySelectorAll('[data-assistant-example]').forEach(function (btn) {
if (btn.getAttribute('data-assistant-bound') === 'true') return;
btn.setAttribute('data-assistant-bound', 'true');
btn.addEventListener('click', function () {
var el = document.getElementById('assistant-input');
if (el) {
el.value = btn.getAttribute('data-assistant-example') || '';
el.focus();
}
});
});
}
function copyLastAnswer() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
navigator.clipboard.writeText(lastAnswer).then(function () {
if (typeof showToast === 'function') showToast('Copied answer', 'success');
}).catch(function () {
if (typeof showToast === 'function') showToast('Copy failed', 'error');
});
}
function isImageRequest(text) {
return /\b(generate|create|draw|make|show|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
/\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b[\s\S]{0,80}\b(of|for|about|showing)\b/i.test(text);
}
function stripSourcesSection(text) {
return String(text || '').replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '').trim();
}
function renderLatexText(text) {
if (!window.katex) return text;
return String(text || '')
.replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(expr, true); })
.replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(expr, true); })
.replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(expr, false); })
.replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(expr, false); });
}
function safeKatex(expr, displayMode) {
try { return window.katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); }
catch (e) { return escapeHtml(expr); }
}
function setBusy(isBusy, text, isError) {
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
if (status) {
status.classList.toggle('busy', !!isBusy);
status.classList.toggle('error', !!isError);
}
if (label) label.textContent = text || (isBusy ? 'Working...' : 'Ready');
if (send) {
send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
}
}
function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '&#39;'); }
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; }
})();