// ============================================================ // 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 } from './assistant/citations.js'; import { renderSourcesList } from './assistant/sources.js'; import { createAssistantExporter } from './assistant/export.js'; import { buildContextualImagePrompt, createAssistantImageStore, isImageRequest } from './assistant/images.js'; import { deleteSavedAssistantChat, fetchAssistantChat, fetchAssistantExamples, fetchAssistantStatus, fetchSavedAssistantChat, fetchSavedAssistantChats, openAssistantStream, fetchAssistantImageJob, startAssistantImageJob, saveAssistantChat } from './assistant/api.js'; var initialized = false; var messages = []; var lastAnswer = ''; var lastSources = []; var mermaidReady = false; var dynamicExamples = []; var lastGeneratedImageSrc = ''; var markdownRenderer = null; var assistantBusy = false; var activeAssistantRequest = null; var STREAM_MARKDOWN_LIMIT = 3500; var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast }); var imageStore = createAssistantImageStore(); 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 cancelBtn = document.getElementById('btn-assistant-cancel'); 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 (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch); 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() { fetchAssistantStatus() .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() { fetchAssistantExamples() .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 (assistantBusy) { if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error'); return; } if (!text) { if (typeof showToast === 'function') showToast('Enter a clinical question', 'error'); return; } appendMessage('user', text); exporter.invalidate(); if (input) input.value = ''; if (isImageRequest(text)) { prepareSidebarImagePrompt(text); return; } var request = createAssistantRequest(); activeAssistantRequest = request; setBusy(true, 'Looking up sources...'); var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); request.loading = loading; streamAssistantResponse({ message: text, history: messages.slice(-8), includeContext: !includeContext || includeContext.checked }, loading, request) .catch(function (err) { if (request.cancelled) return; setBusy(false, 'Error', true); replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.'); if (typeof showToast === 'function') showToast(err.message, 'error'); }) .finally(function () { if (activeAssistantRequest === request) activeAssistantRequest = null; }); } function createAssistantRequest() { var controller = typeof AbortController === 'function' ? new AbortController() : null; return { cancelled: false, signal: controller ? controller.signal : undefined, abort: function () { this.cancelled = true; if (controller) controller.abort(); } }; } async function fetchAssistantResponse(payload, loading) { updateLoadingMessage(loading, 'Looking up sources...'); var data = await fetchAssistantFallback(payload); setBusy(false, 'Ready'); lastAnswer = data.answer || ''; lastSources = data.sources || []; replaceLoadingMessage(loading, lastAnswer, lastSources, data.suggestions || []); renderSources(lastSources); if (data.model) { var label = document.getElementById('assistant-model-label'); if (label) label.textContent = 'Chat: ' + data.model; } } async function streamAssistantResponse(payload, loading, request) { var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined }); if (!response.ok || !response.body) { var fallback = await response.json().catch(function () { return {}; }); throw new Error(fallback.error || ('Request failed (' + response.status + ')')); } var partial = ''; var streamSources = []; var finalData = null; var lastRender = 0; var bubble = loading ? loading.querySelector('.assistant-bubble') : null; var decoder = new TextDecoder(); var buffer = ''; function renderProvisional(force) { var now = Date.now(); if (!force && now - lastRender < 180) return; lastRender = now; if (!bubble) return; loading.classList.remove('assistant-loading-msg'); bubble.classList.remove('assistant-thinking'); bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '
Generating answer...
'; renderEmbeddedBlocks(bubble); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; } function handleEvent(type, data) { if (type === 'status') { updateLoadingMessage(loading, data.message || 'Working...'); return; } if (type === 'sources') { streamSources = data.sources || []; renderSources(streamSources); return; } if (type === 'token') { partial += data.token || ''; renderProvisional(false); return; } if (type === 'done') { finalData = data || {}; return; } if (type === 'error') throw new Error(data.error || 'Assistant stream failed'); } var reader = response.body.getReader(); while (true) { if (request && request.cancelled) return; var chunk = await reader.read(); if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); var parts = buffer.split('\n\n'); buffer = parts.pop() || ''; parts.forEach(function (part) { var parsed = parseSseEvent(part); if (parsed) handleEvent(parsed.type, parsed.data); }); } if (buffer.trim()) { var tail = parseSseEvent(buffer); if (tail) handleEvent(tail.type, tail.data); } if (!finalData) { updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...'); finalData = await fetchAssistantFallback(payload, request); } if (request && request.cancelled) return; setBusy(false, 'Ready'); lastAnswer = finalData.answer || finalData.markdown || ''; lastSources = finalData.sources || finalData.citations || streamSources; replaceLoadingMessage(loading, lastAnswer, lastSources, finalData.suggestions || []); renderSources(lastSources); if (finalData.model) { var label = document.getElementById('assistant-model-label'); if (label) label.textContent = 'Chat: ' + finalData.model; } } function renderStreamingAnswerHtml(text, sources) { if (shouldUseLightweightStreamingRender(text)) { return '' + escapeHtml(text) + ''; } return renderAssistantBubbleHtml(text, sources, false); } function shouldUseLightweightStreamingRender(text) { text = String(text || ''); if (text.length > STREAM_MARKDOWN_LIMIT) return true; var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length; return pipeRows >= 8; } function parseSseEvent(block) { var type = 'message'; var data = ''; String(block || '').split(/\r?\n/).forEach(function (line) { if (line.indexOf('event:') === 0) type = line.substring(6).trim(); if (line.indexOf('data:') === 0) data += line.substring(5).trim(); }); if (!data) return null; try { return { type: type, data: JSON.parse(data) }; } catch (e) { return null; } } async function fetchAssistantFallback(payload, request) { var data; try { data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined }); } catch (e) { if (request && request.cancelled) throw e; if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.'); throw e; } if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')')); return data; } function updateLoadingMessage(row, detail) { if (!row) return; var el = row.querySelector('.assistant-thinking-detail'); if (el) el.textContent = detail; } 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(String(content || '')).replace(/\n/g, '
') + '
Generating image...
'; startAssistantImageJob(prompt) .then(function (data) { if (!data.success || !data.jobId) throw new Error(data.error || 'Image generation failed'); return waitForImageJob(data.jobId, function (status) { if (out && status === 'running') out.innerHTML = 'Generating image... You can leave the app open or return in a moment.
'; }); }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Image generation failed'); var src = data.base64 ? ('data:image/png;base64,' + data.base64) : (data.imageUrl || data.url || ''); if (!src) throw new Error('No image returned'); lastGeneratedImageSrc = src; exporter.invalidate(); var downloadUrl = data.downloadUrl || ''; if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); if (fromChat) { setBusy(false, 'Ready'); var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); 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 waitForImageJob(jobId, onStatus) { var started = Date.now(); var delay = 1200; return new Promise(function (resolve, reject) { function poll() { fetchAssistantImageJob(jobId).then(function (data) { if (!data.success) throw new Error(data.error || 'Image generation failed'); if (data.status === 'done') { resolve(data); return; } if (data.status === 'error') { reject(new Error(data.error || 'Image generation failed')); return; } if (typeof onStatus === 'function') onStatus(data.status || 'pending'); if (Date.now() - started > 180000) { reject(new Error('Image generation timed out. Please try again.')); return; } delay = Math.min(delay + 300, 3500); setTimeout(poll, delay); }).catch(function (err) { if (Date.now() - started > 180000) { reject(err); return; } setTimeout(poll, delay); }); } poll(); }); } 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(); imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image')); return; } var downloadBtn = e.target.closest('[data-assistant-download-image]'); if (downloadBtn) { e.preventDefault(); imageStore.downloadImage(downloadBtn.getAttribute('data-assistant-download-image')); return; } if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) { imageStore.closeImagePreview(); } } function clearGeneratedImage() { lastGeneratedImageSrc = ''; imageStore.clear(); var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = ''; exporter.invalidate(); } function prepareSidebarImagePrompt(request) { var promptEl = document.getElementById('assistant-image-prompt'); var prompt = buildContextualImagePrompt(request, lastAnswer, lastSources); 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 = ''; exporter.invalidate(); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = renderEmptyState(); bindExampleButtons(wrap); } renderSources([]); clearGeneratedImage(); loadSavedChats(); } function cancelAssistantSearch() { if (!activeAssistantRequest) return; var request = activeAssistantRequest; request.abort(); activeAssistantRequest = null; setBusy(false, 'Ready'); if (request.loading && request.loading.parentNode) { replaceLoadingMessage(request.loading, 'Search cancelled.'); } } function renderEmptyState() { var examples = pickExamples(); return 'Ask a real clinical question. Citations stay linked to the source cards on the right.
' + 'Saved chats appear here after you click Save chat.
'; return; } wrap.innerHTML = chats.map(function (chat) { return '