// ============================================================ // 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 = ''; if (isImageRequest(text)) { generateImage(text, true); return; } 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, 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 '[' + n + ']'; }); html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; if (block.lang === 'mermaid') return '
Rendering graph...
'; if (block.lang === 'chart' || block.lang === 'chartjs') return ''; return '
' + escapeHtml(block.code) + '
'; }); return sanitize(html); } function fallbackMarkdown(text) { var html = escapeHtml(text) .replace(/^### (.*)$/gm, '

$1

') .replace(/^## (.*)$/gm, '

$1

') .replace(/^# (.*)$/gm, '

$1

') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/`([^`]+)`/g, '$1'); 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 ''; } if (lines.every(function (l) { return /^\s*\d+\. /.test(l) || !l.trim(); })) { return '
    ' + lines.filter(Boolean).map(function (l) { return '
  1. ' + l.replace(/^\s*\d+\. /, '') + '
  2. '; }).join('') + '
'; } return '

' + block.replace(/\n/g, '
') + '

'; }).join(''); return 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 '
' + '[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '' + '
' + escapeHtml(meta.join(' · ') || 'indexed source') + '
' + (s.excerpt ? '
' + renderMarkdown(String(s.excerpt).slice(0, 900), []) + '
' : '') + '
'; }).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...'); if (out) out.innerHTML = '

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 = 'Generated clinical visual Open'; if (fromChat) { setBusy(false, 'Ready'); var html = '
Generated clinical visual Open image
'; appendMessage('assistant', html, [], [], true); } }) .catch(function (err) { if (fromChat) setBusy(false, 'Error', true); if (out) out.innerHTML = '

' + escapeHtml(err.message) + '

'; if (fromChat) appendMessage('assistant', '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 = '

Evidence-first pediatric assistant

Ask a focused clinical question over indexed resources.

'; renderSources([]); } 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 ? ' Searching' : ' Ask'; } } function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); } function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; } })();