// ============================================================ // 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. // ============================================================ import { EMPTY_PROMPT_SETS } from './assistant/data.js'; import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection } from './assistant/citations.js'; (function () { 'use strict'; var initialized = false; var messages = []; var lastAnswer = ''; var lastSources = []; var mermaidReady = false; var dynamicExamples = []; var generatedImages = {}; var generatedImageSeq = 0; var lastGeneratedImageSrc = ''; var exportCacheKey = ''; var exportCacheItems = null; 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 saveBtn = document.getElementById('btn-assistant-save'); var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm'); var saveCancelBtn = document.getElementById('btn-assistant-save-cancel'); var exportBtn = document.getElementById('btn-assistant-export-pdf'); var imageBtn = document.getElementById('btn-assistant-image'); var imageClearBtn = document.getElementById('btn-assistant-image-clear'); 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 (saveBtn) saveBtn.addEventListener('click', showSavePanel); if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel); if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf); if (imageBtn) imageBtn.addEventListener('click', generateImage); if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage); document.addEventListener('click', onAssistantDocumentClick); if (input) input.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e); }); bindExampleButtons(document); loadSavedChats(); } 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); exportCacheKey = ''; exportCacheItems = null; if (input) input.value = ''; if (isImageRequest(text)) { prepareSidebarImagePrompt(text); return; } setBusy(true, 'Searching indexed resources...'); var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited 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 = '
' + escapeHtml(title || 'Working') + '
' + '
' + escapeHtml(detail || 'Preparing response...') + '
'; 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, sources: sources || [] }); } 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, sources: role === 'assistant' ? (sources || []) : [] }); 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) { return renderAssistantMarkdown(md, sources || [], { marked: window.marked, katex: window.katex, sanitize: sanitize }); } 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 = s.number || idx + 1; var page = s.page || s.page_number || s.pageNumber; var meta = []; if (page) meta.push('page ' + page); if (s.source_type === 'multimodal_page') meta.push('visual PDF page'); if (s.visual_kind || s.source_priority) meta.push(s.visual_kind || s.source_priority); if (s.category) meta.push(s.category); 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') + '' + renderSourceBadges(s) + '
' + escapeHtml(meta.join(' · ') || 'indexed source') + '
' + renderSourceImage(s, n) + (s.excerpt ? '

' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '

' : '') + '
'; }).join(''); } function renderSourceBadges(source) { var badges = []; if (source.source_priority) badges.push(source.source_priority); if (source.visual_kind && badges.indexOf(source.visual_kind) === -1) badges.push(source.visual_kind); if (source.category) badges.push(source.category); if (source.source_boost && Number(source.source_boost) !== 1) badges.push(Number(source.source_boost).toFixed(2) + 'x'); if (!badges.length) return ''; return '
' + badges.slice(0, 4).map(function (badge) { return '' + escapeHtml(badge) + ''; }).join('') + '
'; } function renderSourceImage(source, number) { var src = source && (source.image_url || source.imageUrl); if (!src || source.source_type !== 'multimodal_page') return ''; var id = 'source-img-' + number + '-' + (++generatedImageSeq); generatedImages[id] = src; return '
' + '' + '
'; } 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 = '

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'); lastGeneratedImageSrc = src; exportCacheKey = ''; exportCacheItems = null; if (out) out.innerHTML = renderGeneratedImage(src, 'Generated clinical visual'); if (fromChat) { setBusy(false, 'Ready'); var html = renderGeneratedImage(src, 'Generated clinical visual'); replaceLoadingMessage(loading, html, [], [], true); } }) .catch(function (err) { if (fromChat) setBusy(false, 'Error', true); if (out) out.innerHTML = '

' + escapeHtml(err.message) + '

'; if (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message); if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function renderGeneratedImage(src, alt) { var id = 'img-' + (++generatedImageSeq); generatedImages[id] = src; return '
' + escapeAttr(alt || 'Generated image') + '' + '
' + '' + ' Download' + '
'; } function onAssistantDocumentClick(e) { var loadBtn = e.target.closest('[data-assistant-load-chat]'); if (loadBtn) { e.preventDefault(); loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat')); return; } var deleteBtn = e.target.closest('[data-assistant-delete-chat]'); if (deleteBtn) { e.preventDefault(); deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat')); return; } var openBtn = e.target.closest('[data-assistant-open-image]'); if (openBtn) { e.preventDefault(); openImagePreview(openBtn.getAttribute('data-assistant-open-image')); return; } if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) { closeImagePreview(); } } function openImagePreview(id) { var src = generatedImages[id]; if (!src) return; closeImagePreview(); var modal = document.createElement('div'); modal.className = 'assistant-image-modal'; modal.innerHTML = '
Generated clinical visual
'; document.body.appendChild(modal); } function closeImagePreview() { document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); }); } function clearGeneratedImage() { lastGeneratedImageSrc = ''; generatedImages = {}; var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = ''; closeImagePreview(); exportCacheKey = ''; exportCacheItems = null; } function buildContextualImagePrompt(request) { var prompt = String(request || '').trim(); if (!lastAnswer) return prompt; var sourceText = lastSources.slice(0, 6).map(function (s, idx) { return '[' + (s.number || idx + 1) + '] ' + (s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : ''); }).join('\n'); return prompt + '\n\nUse this clinical answer as the required context. Do not switch topics or introduce unrelated scenes such as gardening. Create a medical teaching visual faithful to the answer.\n\nAnswer:\n' + lastAnswer.slice(0, 3000) + '\n\nSources:\n' + sourceText; } function prepareSidebarImagePrompt(request) { var promptEl = document.getElementById('assistant-image-prompt'); var prompt = buildContextualImagePrompt(request); if (promptEl) { promptEl.value = prompt; promptEl.focus(); } appendMessage('assistant', 'I prepared the image prompt in the **Image / Graph** box on the right. Click **Generate image** there so the visual uses the current answer as context instead of treating this as a separate chat request.'); setBusy(false, 'Ready'); } function clearConversation() { messages = []; lastAnswer = ''; lastSources = []; lastGeneratedImageSrc = ''; exportCacheKey = ''; exportCacheItems = null; var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = renderEmptyState(); bindExampleButtons(wrap); } renderSources([]); clearGeneratedImage(); loadSavedChats(); } function renderEmptyState() { var examples = pickExamples(); return '
' + '

Evidence-first pediatric assistant

' + '

Ask a real clinical question. Citations stay linked to the source cards on the right.

' + '
' + examples.map(function (item) { var source = item.sourceTitle ? (' title="Available from: ' + escapeAttr(item.sourceTitle + (item.page ? ', page ' + item.page : '')) + '"') : ''; return ''; }).join('') + '
'; } 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 exportAnswerPdf() { if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; } var exportItems = collectExportItems(); var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc); if (exportCacheKey === cacheKey && exportCacheItems) { openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc); return; } setBusy(true, 'Preparing PDF...'); fetch('/api/clinical-assistant/export-summary', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin', body: JSON.stringify({ items: exportItems }) }) .then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); }) .then(function (data) { setBusy(false, 'Ready'); var items = data.success && Array.isArray(data.items) && data.items.length ? data.items : exportItems; exportCacheKey = cacheKey; exportCacheItems = items; openPrintableChatExport(items, lastGeneratedImageSrc); }) .catch(function () { setBusy(false, 'Ready'); openPrintableChatExport(exportItems, lastGeneratedImageSrc); }); } function openPrintableChatExport(items, imageSrc) { var doc = window.open('', '_blank', 'width=900,height=1100'); if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; } items = Array.isArray(items) && items.length ? items : collectExportItems(); var imageHtml = imageSrc ? '

Generated Image

Generated clinical visual
' : ''; var sections = items.map(function (item, idx) { var sources = Array.isArray(item.sources) ? item.sources : []; var refs = renderExportRefs(sources, idx + 1); var heading = item.heading || deriveExportHeading(item.question, idx); var summary = item.summary || ''; var answer = stripSourcesSection(item.answer || ''); return '
' + '

' + escapeHtml(heading) + '

' + '
Question: ' + escapeHtml(item.question || '') + '
' + (summary ? '

Summary

' + renderMarkdown(summary, sources) + '
' : '') + '

Full Generated Answer

' + renderMarkdown(answer, sources) + '
' + (refs ? '

References

    ' + refs + '
' : '') + '
'; }).join(''); var html = 'Clinical Assistant Export' + '' + '

Clinical Assistant Export

' + '
Export generated ' + escapeHtml(new Date().toLocaleString()) + '
' + imageHtml + sections + ''; doc.document.open(); doc.document.write(html); doc.document.close(); setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500); } function collectExportItems() { var items = []; var pendingQuestion = ''; messages.forEach(function (m) { if (m.role === 'user') { pendingQuestion = m.content || pendingQuestion; return; } if (m.role !== 'assistant' || !m.content) return; if (isUtilityAssistantMessage(m.content)) return; items.push({ question: pendingQuestion || 'Clinical question', heading: deriveExportHeading(pendingQuestion, items.length), summary: '', answer: stripSourcesSection(m.content), sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources }); pendingQuestion = ''; }); if (!items.length && lastAnswer) { items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: stripSourcesSection(lastAnswer), sources: lastSources }); } return items; } function renderExportRefs(sources, sectionNumber) { return (sources || []).map(function (s, idx) { var n = s.number || idx + 1; var title = s.title || s.resource || 'Untitled source'; var page = s.page || s.page_number || s.pageNumber; return '
  • [' + n + '] ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.
  • '; }).join(''); } function deriveExportHeading(question, idx) { var text = String(question || '').replace(/\s+/g, ' ').trim(); if (!text || /^(what|which|when|why|how|and|also|what dose\??|dose\??)$/i.test(text)) return 'Clinical Question ' + (idx + 1); return text.replace(/[?!.]+$/, '').slice(0, 90); } function isUtilityAssistantMessage(content) { return /^I prepared the image prompt in the \*\*Image \/ Graph\*\* box/i.test(String(content || '')); } function saveCurrentChat() { if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; } var titleEl = document.getElementById('assistant-save-title'); var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle(); setBusy(true, 'Saving chat...'); fetch('/api/clinical-assistant/chats', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin', body: JSON.stringify({ title: title, messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImage: imageForSavedChatPayload(lastGeneratedImageSrc) }) }) .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 || 'Save failed'); hideSavePanel(); if (typeof showToast === 'function') showToast('Saved chat', 'success'); loadSavedChats(); }) .catch(function (err) { setBusy(false, 'Error', true); if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function showSavePanel() { if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; } var panel = document.getElementById('assistant-save-panel'); var titleEl = document.getElementById('assistant-save-title'); if (!panel || !titleEl) return saveCurrentChat(); titleEl.value = deriveChatTitle(); panel.hidden = false; titleEl.focus(); titleEl.select(); } function hideSavePanel() { var panel = document.getElementById('assistant-save-panel'); if (panel) panel.hidden = true; } function loadSavedChats() { fetch('/api/clinical-assistant/chats', { headers: getAuthHeaders(), credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function (data) { if (data.success) renderSavedChats(data.chats || []); }) .catch(function () {}); } function renderSavedChats(chats) { var wrap = document.getElementById('assistant-saved-chats'); if (!wrap) return; if (!chats.length) { wrap.innerHTML = '

    Saved chats appear here after you click Save chat.

    '; return; } wrap.innerHTML = chats.map(function (chat) { return '
    ' + '
    ' + escapeHtml(chat.title || 'Saved chat') + '
    ' + '
    ' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '
    ' + '
    ' + '' + '' + '
    '; }).join(''); } function loadSavedChat(id) { fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { headers: getAuthHeaders(), credentials: 'same-origin' }) .then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Load failed'); restoreSavedChat(data.chat && data.chat.payload || {}); if (typeof showToast === 'function') showToast('Loaded saved chat', 'success'); }) .catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function deleteSavedChat(id) { var btn = document.querySelector('[data-assistant-delete-chat="' + cssEscape(id) + '"]'); if (btn && btn.getAttribute('data-confirm-delete') !== 'true') { btn.setAttribute('data-confirm-delete', 'true'); btn.textContent = 'Confirm delete'; setTimeout(function () { if (btn.getAttribute('data-confirm-delete') === 'true') { btn.removeAttribute('data-confirm-delete'); btn.textContent = 'Delete'; } }, 5000); return; } fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { method: 'DELETE', headers: getAuthHeaders(), credentials: 'same-origin' }) .then(function (r) { return r.json(); }) .then(function () { loadSavedChats(); if (typeof showToast === 'function') showToast('Deleted saved chat', 'success'); }) .catch(function () { if (typeof showToast === 'function') showToast('Delete failed', 'error'); }); } function restoreSavedChat(payload) { messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) { return { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] }; }) : []; lastSources = Array.isArray(payload.sources) ? payload.sources : []; lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || ''); lastGeneratedImageSrc = String(payload.generatedImage || ''); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = ''; messages.forEach(function (m) { appendMessageNode(m.role, m.content, m.sources && m.sources.length ? m.sources : lastSources); }); wrap.scrollTop = wrap.scrollHeight; } renderSources(lastSources); var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = lastGeneratedImageSrc ? renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; } function appendMessageNode(role, content, sources) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return; 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); row.appendChild(label); row.appendChild(bubble); wrap.appendChild(row); renderEmbeddedBlocks(bubble); } function deriveChatTitle() { var first = messages.find(function (m) { return m.role === 'user' && m.content; }); return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80); } function cleanSourceExcerpt(text) { return String(text || '') .replace(/^\[Page-image match\]\s*/i, '') .replace(//gi, ' ') .replace(/\*\*/g, '') .replace(/\|\s*-{2,}\s*/g, ' ') .replace(/\|/g, ' ') .replace(/\s+/g, ' ') .trim(); } function imageForSavedChatPayload(src) { src = String(src || ''); return /^https?:\/\//i.test(src) ? src : ''; } function buildExportCacheKey(items, imageSrc) { return JSON.stringify({ items: items.map(function (item) { return { q: item.question, a: item.answer, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) }; }), image: imageSrc ? '1' : '' }); } function lastAssistantMessage(items) { for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content; return ''; } function formatSavedDate(value) { try { return new Date(value).toLocaleString(); } catch (e) { return ''; } } function cssEscape(value) { if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || '')); return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"'); } 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 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 sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; } })();