// ============================================================ // 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; 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(); } 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); }); document.querySelectorAll('[data-assistant-example]').forEach(function (btn) { btn.addEventListener('click', function () { var el = document.getElementById('assistant-input'); if (el) { el.value = btn.getAttribute('data-assistant-example') || ''; el.focus(); } }); }); } 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 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 = ''; setBusy(true, 'Searching indexed resources...'); 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 || []; appendMessage('assistant', 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); appendMessage('assistant', '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 appendMessage(role, content, sources, suggestions) { 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 = 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'; }); var html = escapeHtml(text) .replace(/^### (.*)$/gm, '
$1')
.replace(/\[(\d+)\]/g, function (_, n) {
var source = sources[Number(n) - 1];
var title = source ? source.title || source.resource || 'Source' : 'Source';
return '[' + n + ']';
});
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 '' + block.replace(/\n/g, '
') + '
' + escapeHtml(block.code) + '';
});
return sanitize(html);
}
function renderSources(sources) {
var wrap = document.getElementById('assistant-sources');
if (!wrap) return;
if (!sources || sources.length === 0) {
wrap.innerHTML = 'No citations returned.
'; return; } wrap.innerHTML = sources.map(function (s, idx) { var n = 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 'Generating image...
'; 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 = '' + escapeHtml(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 = 'Ask a focused clinical question over indexed resources.