// ============================================================ // 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, requestAssistantHandoff } 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 conversationChars = null; var STREAM_MARKDOWN_LIMIT = 3500; var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, 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); document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript); document.getElementById('btn-assistant-handoff').addEventListener('click', requestHandoff); document.getElementById('btn-assistant-copy-handoff').addEventListener('click', function() { navigator.clipboard.writeText(document.getElementById('assistant-handoff-text').value).catch(function() { if (typeof showToast === 'function') showToast('Could not copy; select the summary text instead.', 'error'); }); }); if (input) input.addEventListener('input', updateConversationBudget); 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() { return fetchAssistantStatus() .then(function (data) { var label = document.getElementById('assistant-model-label'); if (label && data.success) { label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model'; } conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null; updateConversationBudget(); }) .catch(function () { conversationChars = null; updateConversationBudget(); // No guessed cap; the server remains authoritative. }); } 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 : ''; if (assistantBusy) { if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error'); return; } if (!text.trim()) { if (typeof showToast === 'function') showToast('Enter a clinical question', 'error'); return; } if (conversationChars !== null && conversationSize(text) > conversationChars) { updateConversationBudget(); if (typeof showToast === 'function') showToast('Conversation limit reached. Save/download this chat, start a new chat, or explicitly request a handoff summary.', 'error'); return; } if (isImageRequest(text)) { appendMessage('user', text); if (input) input.value = ''; prepareSidebarImagePrompt(text); updateConversationBudget(); return; } // Only text/roles go to inference; the full source maps/images stay in the transcript. var history = messages.map(function(m) { return { role: m.role, content: m.content }; }); var request = createAssistantRequest(); activeAssistantRequest = request; setBusy(true, 'Looking up sources...'); var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); request.loading = loading; request.accept = function() { if (request.accepted || request.cancelled) return; request.accepted = true; var row = appendMessage('user', text); if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading); if (input) input.value = ''; exporter.invalidate(); updateConversationBudget(); }; return streamAssistantResponse({ message: text, history: history, includeContext: !includeContext || includeContext.checked }, loading, request) .catch(function (err) { if (request.cancelled) return; setBusy(false, 'Error', true); if (loading) loading.remove(); // Errors are not invented assistant turns. if (err.budget) conversationChars = validConversationLimit(err.budget.limit) ? err.budget.limit : null; updateConversationBudget(); 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 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 Object.assign(new Error(fallback.error || ('Request failed (' + response.status + ')')), { code: fallback.code, budget: fallback.budget }); } if (request && request.accept) request.accept(); 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.assistantSources = streamSources; 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 Object.assign(new Error(data.error || 'Assistant stream failed'), { code: data.code, budget: data.budget }); } 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(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'); fillMessageBubble(bubble, 'assistant', content, sources, suggestions, rawHtml); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; messages.push({ role: 'assistant', content: content, sources: sources || [] }); updateConversationBudget(); } function renderAssistantBubbleHtml(content, sources, rawHtml, options) { return rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || [], options); } function fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options) { bubble.assistantSources = role === 'assistant' && Array.isArray(sources) ? sources : []; bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, options) : escapeHtml(content); if (role !== 'assistant' && options && options.notice) bubble.innerHTML += '

' + escapeHtml(options.notice) + '

'; if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions)); renderEmbeddedBlocks(bubble); } function appendMessage(role, content, sources, suggestions, rawHtml) { var row = appendMessageNode(role, content, sources, suggestions, rawHtml); if (row) messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] }); return row; } 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, options) { var opts = options || {}; try { return renderAssistantMarkdown(md, sources || [], { marked: window.marked, markdownIt: getMarkdownRenderer(), katex: window.katex, sanitize: sanitize, citationLabel: opts.citationLabel, citationTargetPrefix: opts.citationTargetPrefix, notice: opts.notice }); } catch (e) { console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e); return '
' + escapeHtml(String(md || '')) + '
' + '

Formatting unavailable; retained text is shown unchanged.

' + (opts.notice ? '

' + escapeHtml(opts.notice) + '

' : ''); } } function getMarkdownRenderer() { if (markdownRenderer) return markdownRenderer; if (typeof window.markdownit === 'function') { markdownRenderer = window.markdownit({ html: false, linkify: true, typographer: true, breaks: true }); return markdownRenderer; } return null; } function renderSources(sources) { var wrap = document.getElementById('assistant-sources'); if (!wrap) return; wrap.innerHTML = renderSourcesList(sources); } function renderEmbeddedBlocks(root) { function mermaidSource(el) { var raw = el.getAttribute('data-mermaid') || ''; try { return decodeURIComponent(raw); } catch (e) { return raw; } } root.querySelectorAll('[data-mermaid]').forEach(function (el) { ensureMermaid().then(function () { if (!window.mermaid) { el.textContent = mermaidSource(el); return; } var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2); window.mermaid.render(id, mermaidSource(el)) .then(function (out) { el.innerHTML = out.svg || ''; }) .catch(function () { el.textContent = mermaidSource(el); }); }); }); 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...

'; 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 citation = e.target.closest('#assistant-messages .assistant-cite'); if (citation) { var bubble = citation.closest('.assistant-bubble'); if (bubble && Array.isArray(bubble.assistantSources)) renderSources(bubble.assistantSources); return; // The native anchor navigates to the matching source in the refreshed panel. } 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); 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(event) { if (assistantBusy && !activeAssistantRequest) return; if (event && messages.length && !window.confirm('Start a new chat? Save or download this conversation first if you want to keep it.')) return; if (activeAssistantRequest) cancelAssistantSearch(); messages = []; lastAnswer = ''; lastSources = []; lastGeneratedImageSrc = ''; exporter.invalidate(); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = renderEmptyState(); bindExampleButtons(wrap); } renderSources([]); clearGeneratedImage(); document.getElementById('assistant-handoff-panel').hidden = true; document.getElementById('assistant-handoff-text').value = ''; updateConversationBudget(); loadSavedChats(); } function cancelAssistantSearch() { if (!activeAssistantRequest) return; var request = activeAssistantRequest; request.abort(); activeAssistantRequest = null; setBusy(false, 'Ready'); if (request.loading) request.loading.remove(); } 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') || ''; updateConversationBudget(); 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() { exporter.exportAnswerPdf({ messages: messages, lastAnswer: lastAnswer, lastSources: lastSources, lastGeneratedImageSrc: lastGeneratedImageSrc }); } function saveCurrentChat() { if (assistantBusy) return; if (!messages.length) { 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...'); return saveAssistantChat({ title: title, messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImage: imageForSavedChatPayload(lastGeneratedImageSrc) }) .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 (assistantBusy) return; if (!messages.length) { 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() { fetchSavedAssistantChats() .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) { if (assistantBusy) return; setBusy(true, 'Loading chat...'); return fetchSavedAssistantChat(id) .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'); }) .finally(function() { setBusy(false, 'Ready'); }); } 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; } deleteSavedAssistantChat(id) .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) { var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] }; if ((payload.version !== 2 || m.legacyClipped === true) && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { message.legacyClipped = true; if (message.role === 'assistant' && isRetainedLegacyAnswer(message.content, m.retainedAnswer)) message.retainedAnswer = m.retainedAnswer; } return message; }) : []; lastSources = Array.isArray(payload.sources) ? payload.sources : []; lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || ''); var finalMessage = messages[messages.length - 1]; if (finalMessage && finalMessage.role === 'assistant' && finalMessage.legacyClipped && (!finalMessage.sources.length || JSON.stringify(finalMessage.sources) === JSON.stringify(lastSources)) && isRetainedLegacyAnswer(finalMessage.content, lastAnswer)) finalMessage.retainedAnswer = lastAnswer; lastGeneratedImageSrc = String(payload.generatedImage || ''); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = ''; messages.forEach(function (m) { var display = savedMessagePresentation(m); appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display); }); wrap.scrollTop = wrap.scrollHeight; } renderSources(lastSources); var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; exporter.invalidate(); document.getElementById('assistant-handoff-panel').hidden = true; document.getElementById('assistant-handoff-text').value = ''; updateConversationBudget(); } function isRetainedLegacyAnswer(content, retained) { return typeof retained === 'string' && retained.length > content.length && retained.length <= 30000 && !/[\r\n]/.test(retained) && retained.startsWith(content); } function savedMessagePresentation(message) { if (!message.legacyClipped) return { answer: message.content, notice: '' }; return { answer: message.retainedAnswer || message.content, notice: message.retainedAnswer ? 'Showing the retained lastAnswer that exactly extends this legacy clipped message; the stored transcript is unchanged.' + (message.retainedAnswer.length === 30000 ? ' That retained answer may also have been clipped at 30,000 characters; absent content cannot be recovered.' : '') : 'This legacy message may have been clipped at 12,000 characters. Missing content cannot be recovered from the saved text.' }; } function appendMessageNode(role, content, sources, suggestions, rawHtml, options) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return; var empty = wrap.querySelector('.assistant-empty'); if (empty) empty.remove(); 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'; row.appendChild(label); row.appendChild(bubble); wrap.appendChild(row); fillMessageBubble(bubble, role, content, sources, suggestions, rawHtml, options); wrap.scrollTop = wrap.scrollHeight; return row; } 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 conversationSize(question) { return messages.reduce(function(total, message) { return total + message.content.length; }, String(question || '').length); } function validConversationLimit(limit) { return Number.isInteger(limit) && limit >= 1000 && limit <= 1000000; } function updateConversationBudget() { var input = document.getElementById('assistant-input'); var used = conversationSize(input ? input.value : ''); var label = document.getElementById('assistant-context-budget'); if (label) label.textContent = used.toLocaleString() + (conversationChars === null ? ' conversation characters (UTF-16 code units; includes your draft). Limit unavailable; the server must validate each request.' : ' / ' + conversationChars.toLocaleString() + ' conversation characters (UTF-16 code units; includes your draft).'); var warning = document.getElementById('assistant-context-warning'); if (warning) { warning.hidden = conversationChars === null || used * 10 < conversationChars * 9; warning.textContent = (used > conversationChars ? 'Conversation limit exceeded. Sending is blocked.' : used === conversationChars ? 'At the conversation limit. Any additional input will exceed it.' : 'Approaching the conversation limit (90% or more used).') + ' Nothing is truncated or automatically summarized. Save or download this chat, then choose New chat or explicitly request a handoff summary. A handoff also requires the existing history to fit the budget; your unsent draft is not included in a handoff.'; } } function downloadTranscript() { var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImage: lastGeneratedImageSrc, savedAt: new Date().toISOString() }; var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })); var link = document.createElement('a'); link.href = url; link.download = 'clinical-chat.json'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(function() { URL.revokeObjectURL(url); }, 60000); } function requestHandoff() { if (assistantBusy || !messages.length) return; if (conversationChars !== null && conversationSize('') > conversationChars) { updateConversationBudget(); if (typeof showToast === 'function') showToast('History exceeds the handoff budget. Save/download the full chat; nothing has been changed.', 'error'); return; } setBusy(true, 'Creating requested handoff...'); return requestAssistantHandoff(messages.map(function(m) { return { role: m.role, content: m.content }; })) .then(function(data) { if (!data.success) { if (data.budget) conversationChars = validConversationLimit(data.budget.limit) ? data.budget.limit : null; updateConversationBudget(); throw new Error(data.error || 'Handoff failed. Your conversation is unchanged.'); } document.getElementById('assistant-handoff-text').value = data.summary || ''; document.getElementById('assistant-handoff-panel').hidden = false; }) .catch(function(error) { if (typeof showToast === 'function') showToast(error.message, 'error'); }) .finally(function() { setBusy(false, 'Ready'); }); } function imageForSavedChatPayload(src) { src = String(src || ''); return src; // Preserve inline generated images; the server validates supported image formats. } 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 setBusy(isBusy, text, isError) { assistantBusy = !!isBusy; var status = document.getElementById('assistant-status'); var label = document.getElementById('assistant-status-text'); var send = document.getElementById('btn-assistant-send'); var cancel = document.getElementById('btn-assistant-cancel'); var input = document.getElementById('assistant-input'); 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'; } if (cancel) { var canCancel = !!isBusy && !!activeAssistantRequest; if (canCancel) cancel.removeAttribute('hidden'); else cancel.setAttribute('hidden', ''); cancel.disabled = !canCancel; cancel.style.display = canCancel ? 'inline-flex' : 'none'; } if (input) input.disabled = !!isBusy; } function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : escapeHtml(html); }