// ============================================================ // 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, safeImageUrl } from './assistant/citations.js'; import { renderSourcesList } from './assistant/sources.js'; import { createAssistantExporter } from './assistant/export.js'; import { createAssistantImageStore } from './assistant/images.js'; import { renderImageJobs, imageJson } from './generatedImages.js'; import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; import { deleteSavedAssistantChat, fetchAssistantChat, fetchAssistantExamples, fetchAssistantStatus, fetchSavedAssistantChat, fetchSavedAssistantChats, openAssistantStream, startAssistantImageJob, saveAssistantChat, translateAssistantMessage, assistantAttachmentLimits, assistantAttachmentPayload , fetchAssistantImageJobs, renameSavedAssistantChat } from './assistant/api.js'; var initialized = false; var messages = []; var attachments = []; // Input-only images: ride the outgoing question, then persist with the sent message. var lastAnswer = ''; var lastSources = []; var mermaidReady = false; var dynamicExamples = []; var lastGeneratedImageSrc = ''; var generatedImageJobs = []; var markdownRenderer = null; var assistantBusy = false; var activeAssistantRequest = null; var conversationChars = null; var STREAM_MARKDOWN_LIMIT = 3500; var AUTOSAVE_DELAY_MS = 800; var currentChatId = null; var autosaveTimer = null; var autosaveErrorShown = false; var translateProvider = 'libretranslate'; var learningViewHtml = null; var lastNonAssistantTab = null; var regenerateMode = false; var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, presentMessage: savedMessagePresentation, showToast: window.showToast }); var imageStore = createAssistantImageStore(); var TRANSLATE_LANGUAGES = [['en', 'English'], ['es', 'Español'], ['fr', 'Français'], ['de', 'Deutsch'], ['it', 'Italiano'], ['pt', 'Português'], ['zh', '中文'], ['ar', 'العربية'], ['ru', 'Русский'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['tr', 'Türkçe'], ['uk', 'Українська'], ['fa', 'فارسی'], ['sw', 'Kiswahili']]; var savedChatCache = []; var translateLanguagesAvailable = null; // { libretranslate: [...], deepl: [...] } var translateLanguagesLoading = false; function refreshTranslateLanguages(pop) { if (translateLanguagesLoading) return; translateLanguagesLoading = true; fetch('/api/clinical-assistant/translate/languages', { headers: getAuthHeaders(), credentials: 'same-origin' }) .then(function(r) { return r.json(); }) .then(function(data) { if (data && data.success && data.languages) translateLanguagesAvailable = data.languages; }) .catch(function() { /* keep the full static list on failure */ }) .finally(function() { translateLanguagesLoading = false; if (typeof document === 'undefined') return; var open = document.querySelector('[data-assistant-translate-pop]'); if (open) renderTranslateLanguages(open, open.assistantMessageRow); }); } function renderTranslateLanguages(pop, row) { var list = pop.querySelector('.assistant-translate-langs'); if (!list) return; var available = translateLanguagesAvailable; var pairs = TRANSLATE_LANGUAGES.filter(function(pair) { if (!available) return true; var codes = available[translateProvider] || available.libretranslate || []; return codes.indexOf(pair[0]) !== -1; }); list.innerHTML = pairs.map(function(pair) { return ''; }).join(''); if (!pairs.length) list.innerHTML = 'No languages available for this provider.'; } var SOURCE_EXCERPT_PREVIEW = 10000; document.addEventListener('tabChanged', function (e) { if (e.detail && e.detail.tab === 'assistant') { initIfNeeded(); // Open WebUI-style: the assistant workspace replaces the main menu; // saved chats become the left navigation and Go back restores the app. document.body.classList.add('assistant-workspace'); } if (e.detail && e.detail.tab && e.detail.tab !== 'assistant' && e.detail.tab !== 'learning') { lastNonAssistantTab = e.detail.tab; document.body.classList.remove('assistant-workspace'); } }); window.addEventListener('account-boundary', function () { cancelAutosave(); if (initialized) closeTranslatePicker(); closeSourceModal(); }); function initIfNeeded() { if (initialized) return; var root = document.getElementById('assistant-tab'); if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') { var downloadBtn = document.getElementById('btn-assistant-download-chat'); if (downloadBtn) downloadBtn.remove(); } 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 exportBtn = document.getElementById('btn-assistant-export-pdf'); var attachInput = document.getElementById('assistant-attach-input'); var input = document.getElementById('assistant-input'); var goBackBtn = document.getElementById('btn-assistant-goback'); var createImageBtn = document.getElementById('btn-assistant-create-image'); if (createImageBtn) createImageBtn.addEventListener('click', openCreateImageDialog); var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu'); if (mobileMenuBtn) mobileMenuBtn.addEventListener('click', function(ev) { ev.stopPropagation(); document.getElementById('assistant-layout')?.classList.toggle('mobile-chats-open'); }); var chatViewBtn = document.getElementById('btn-assistant-chat-view'); var learningViewBtn = document.getElementById('btn-assistant-learning-view'); if (form) form.addEventListener('submit', onAsk); if (clearBtn) clearBtn.addEventListener('click', clearConversation); document.getElementById('btn-assistant-download-chat').addEventListener('click', downloadTranscript); if (goBackBtn) goBackBtn.addEventListener('click', goBackToMainMenu); if (chatViewBtn) chatViewBtn.addEventListener('click', openChatView); if (learningViewBtn) learningViewBtn.addEventListener('click', openLearningView); if (input) { input.addEventListener('input', updateConversationBudget); input.addEventListener('input', resizeAssistantInput); } if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch); if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf); var takehomeBtn = document.getElementById('btn-assistant-takehome'); if (takehomeBtn) takehomeBtn.addEventListener('click', openPatientTakehome); var micBtn = document.getElementById('btn-assistant-mic'); if (micBtn) micBtn.addEventListener('click', toggleAssistantMic); var voiceBtn = document.getElementById('btn-assistant-voice'); if (voiceBtn) voiceBtn.addEventListener('click', openConversationMode); if (attachInput) attachInput.addEventListener('change', onAttachFiles); document.addEventListener('click', onAssistantDocumentClick); document.addEventListener('keydown', onAssistantKeydown); 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; if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider; 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 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. This chat is saved automatically; start a new chat.', 'error'); 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(); request.regenerateMode = regenerateMode; regenerateMode = false; // consumed by this request 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; if (!request.regenerateMode) { var row = appendMessage('user', text, undefined, undefined, undefined, { attachments: attachments.map(function(a) { return { dataBase64: a.dataBase64, mimeType: a.mimeType, name: a.name || '' }; }) }); if (row && loading && loading.parentNode) loading.parentNode.insertBefore(row, loading); } if (input) input.value = ''; // Attached images are input-only until sent, then persist with the message. attachments = []; renderAttachments(); exporter.invalidate(); scheduleAutosave(); updateConversationBudget(); }; var payload = { message: text, idempotencyKey: crypto.randomUUID(), history: history, includeContext: true // clinical retrieval is always on — it is the purpose of the app }; // Images ride the outgoing clinical question for inference; once sent they persist with the saved chat. if (attachments.length) payload.images = assistantAttachmentPayload(attachments); return streamAssistantResponse(payload, 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 || []); attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []); 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); scheduleAutosave(); 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); if (!row.querySelector('.assistant-msg-actions')) { var actions = document.createElement('div'); actions.className = 'assistant-msg-actions'; actions.innerHTML = '' + '' + ''; row.appendChild(actions); } var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; messages.push({ role: 'assistant', content: content, sources: sources || [] }); row.dataset.messageIndex = String(messages.length - 1); updateConversationBudget(); scheduleAutosave(); } 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.assistantRawContent = String(content || ''); var renderOptions = Object.assign({}, options || {}, { notice: '' }); bubble.innerHTML = role === 'assistant' ? renderAssistantBubbleHtml(content, sources, rawHtml, renderOptions) : renderMarkdown(content, [], renderOptions); if (options && options.notice) bubble.innerHTML += '

' + escapeHtml(options.notice) + '

'; if (Array.isArray(options && options.attachments) && options.attachments.length) bubble.appendChild(renderMessageAttachments(options.attachments)); if (role === 'assistant' && suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions)); renderEmbeddedBlocks(bubble); } function renderMessageAttachments(attachments) { var wrap = document.createElement('div'); wrap.className = 'assistant-message-attachments'; (attachments || []).forEach(function(attachment, index) { var item = document.createElement('figure'); item.className = 'assistant-message-attachment'; var img = document.createElement('img'); img.src = 'data:' + attachment.mimeType + ';base64,' + attachment.dataBase64; img.alt = 'Attached image ' + (index + 1); var caption = document.createElement('figcaption'); caption.textContent = attachment.name || ('Attachment ' + (index + 1)); item.appendChild(img); item.appendChild(caption); wrap.appendChild(item); }); return wrap; } function appendMessage(role, content, sources, suggestions, rawHtml, extras) { var row = appendMessageNode(role, content, sources, suggestions, rawHtml, extras); if (row) { messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [], ...(extras && Array.isArray(extras.attachments) && extras.attachments.length ? { attachments: extras.attachments } : {}) }); row.dataset.messageIndex = String(messages.length - 1); } return row; } function onAttachFiles(e) { var input = e && e.target; var files = Array.prototype.slice.call(input && input.files ? input.files : []); files.forEach(addAttachmentFile); if (input) input.value = ''; // Allow re-selecting the same file later. } function addAttachmentFile(file) { var limits = assistantAttachmentLimits; if (!file) return; if (limits.mimeTypes.indexOf(file.type) === -1) { if (typeof showToast === 'function') showToast('Only PNG, JPEG and WebP images can be attached; "' + String(file.name || 'attachment') + '" was ignored.', 'error'); return; } if (typeof file.size === 'number' && file.size > limits.maxImageBytes) { if (typeof showToast === 'function') showToast('Each image is limited to 5 MiB; "' + String(file.name || 'attachment') + '" was ignored.', 'error'); return; } if (attachments.length >= limits.maxImages) { if (typeof showToast === 'function') showToast('A maximum of 4 images can be attached to one question.', 'error'); return; } var totalBytes = attachments.reduce(function(sum, a) { return sum + (a.size || 0); }, 0); if (typeof file.size === 'number' && totalBytes + file.size > limits.maxTotalBytes) { if (typeof showToast === 'function') showToast('Attached images are limited to 10 MiB in total.', 'error'); return; } var reader = new FileReader(); reader.onload = function() { var dataUrl = String(reader.result || ''); var comma = dataUrl.indexOf(','); if (comma === -1 || dataUrl.indexOf(';base64,') === -1) { if (typeof showToast === 'function') showToast('Could not read "' + String(file.name || 'attachment') + '".', 'error'); return; } attachments.push({ dataBase64: dataUrl.slice(comma + 1), mimeType: file.type, size: file.size, src: dataUrl, name: String(file.name || '') }); renderAttachments(); }; reader.onerror = function() { if (typeof showToast === 'function') showToast('Could not read "' + String(file.name || 'attachment') + '".', 'error'); }; reader.readAsDataURL(file); } function renderAttachments() { var wrap = document.getElementById('assistant-attachments'); if (!wrap) return; wrap.innerHTML = ''; attachments.forEach(function(attachment, index) { var item = document.createElement('div'); item.className = 'assistant-attachment'; var img = document.createElement('img'); img.src = attachment.src || ('data:' + attachment.mimeType + ';base64,' + attachment.dataBase64); img.alt = 'Attached image ' + (index + 1); var remove = document.createElement('button'); remove.type = 'button'; remove.className = 'assistant-attachment-remove'; remove.setAttribute('data-assistant-remove-attachment', String(index)); remove.setAttribute('aria-label', 'Remove attached image ' + (index + 1)); remove.textContent = '✕'; item.appendChild(img); item.appendChild(remove); wrap.appendChild(item); }); wrap.hidden = !attachments.length; } 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); } // ── OWUI-style table actions (Copy raw / Export CSV) ──────────────── function wireTableActions(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll('.assistant-table-scroll').forEach(function(wrapper) { if (wrapper.getAttribute('data-assistant-table-wired') === 'true') return; wrapper.setAttribute('data-assistant-table-wired', 'true'); wrapper.addEventListener('click', function(event) { if (event.target.closest('[data-assistant-table-copy]')) { var markdown = tableToMarkdown(wrapper.querySelector('table')); copyText(markdown, 'Table copied', 'Copy failed'); return; } if (event.target.closest('[data-assistant-table-csv]')) { exportTableCsv(wrapper.querySelector('table')); } }); }); } function tableToMarkdown(table) { if (!table) return ''; var lines = []; var rows = table.querySelectorAll('tr'); rows.forEach(function(row, rowIndex) { var cells = row.querySelectorAll('th, td'); if (rowIndex === 1 && row.parentNode && row.parentNode.tagName === 'THEAD') return; // markdown tables carry one header row if (rowIndex === 0) { lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |'); lines.push('| ' + Array.from(cells).map(function(cell) { var align = cell.getAttribute('align') || (cell.style && cell.style.textAlign) || ''; if (align === 'center') return ':---:'; if (align === 'right') return '---:'; return '---'; }).join(' | ') + ' |'); return; } lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |'); }); return lines.join('\n'); } function cellToText(cell) { return String(cell.textContent || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim(); } function exportTableCsv(table) { if (!table) return; var lines = []; table.querySelectorAll('tr').forEach(function(row) { lines.push(Array.from(row.querySelectorAll('th, td')).map(function(cell) { var text = String(cell.textContent || '').trim(); return /[,"\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text; }).join(',')); }); var csv = '\uFEFF' + lines.join('\r\n'); try { var blob = new Blob([csv], { type: 'text/csv' }); var objectUrl = URL.createObjectURL(blob); var link = document.createElement('a'); link.href = objectUrl; link.download = 'table.csv'; link.rel = 'noopener'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); } catch (e) { if (typeof showToast === 'function') showToast('CSV export failed', 'error'); } } // ── OWUI-style code blocks with a Copy button ─────────────────────── function wireCodeBlocks(root) { if (!root || !root.querySelectorAll) return; root.querySelectorAll('pre').forEach(function(pre) { if (pre.getAttribute('data-assistant-code-wired') === 'true') return; pre.setAttribute('data-assistant-code-wired', 'true'); var wrapper = document.createElement('div'); wrapper.className = 'assistant-codeblock'; var button = document.createElement('button'); button.type = 'button'; button.className = 'assistant-code-copy'; button.setAttribute('data-assistant-code-copy', ''); button.textContent = 'Copy'; pre.parentNode.insertBefore(wrapper, pre); wrapper.appendChild(pre); wrapper.appendChild(button); button.addEventListener('click', function() { var code = pre.querySelector('code') || pre; copyText(code.textContent, 'Code copied', 'Copy failed'); }); }); } function copyText(text, successMessage, failureMessage) { var navigatorRef = typeof navigator !== 'undefined' ? navigator : null; if (!navigatorRef || !navigatorRef.clipboard || typeof navigatorRef.clipboard.writeText !== 'function') { if (typeof showToast === 'function') showToast(failureMessage, 'error'); return; } navigatorRef.clipboard.writeText(String(text)).then(function() { if (typeof showToast === 'function') showToast(successMessage, 'success'); }).catch(function() { if (typeof showToast === 'function') showToast(failureMessage, 'error'); }); } function renderEmbeddedBlocks(root) { wireTableActions(root); wireCodeBlocks(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 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); var number = Number(citation.getAttribute('data-source-number')); var source = bubble.assistantSources[number - 1]; if (source) openSourceModal(source, number); } return; // The native anchor navigates to the matching source in the refreshed panel. } var msgCopy = e.target.closest('[data-assistant-msg-copy]'); if (msgCopy) { var msgRow = msgCopy.closest('.assistant-msg'); var msgBubble = msgRow && msgRow.querySelector('.assistant-bubble'); copyText(msgBubble && msgBubble.assistantRawContent !== undefined ? msgBubble.assistantRawContent : '', 'Message copied', 'Copy failed'); return; } var msgTranslate = e.target.closest('[data-assistant-msg-translate]'); if (msgTranslate) { openTranslatePicker(msgTranslate.closest('.assistant-msg')); return; } var translateLang = e.target.closest('[data-assistant-translate-lang]'); if (translateLang) { var picker = translateLang.closest('[data-assistant-translate-pop]'); var providerButton = picker && picker.querySelector('[data-assistant-translate-provider].active'); requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), providerButton ? providerButton.getAttribute('data-assistant-translate-provider') : translateProvider); closeTranslatePicker(); return; } var translateProviderBtn = e.target.closest('[data-assistant-translate-provider]'); if (translateProviderBtn) { var pop = translateProviderBtn.closest('[data-assistant-translate-pop]'); pop.querySelectorAll('[data-assistant-translate-provider]').forEach(function(b) { b.classList.remove('active'); }); translateProviderBtn.classList.add('active'); return; } var showOriginal = e.target.closest('[data-assistant-msg-show-original]'); if (showOriginal) { var translatedBubble = showOriginal.closest('.assistant-bubble'); if (translatedBubble && translatedBubble.assistantOriginalHtml) { translatedBubble.innerHTML = translatedBubble.assistantOriginalHtml; renderEmbeddedBlocks(translatedBubble); } return; } var msgRegenerate = e.target.closest('[data-assistant-msg-regenerate]'); if (msgRegenerate) { regenerateMessage(msgRegenerate.closest('.assistant-msg')); return; } if (!e.target.closest('[data-assistant-translate-pop]')) closeTranslatePicker(); var removeAttachment = e.target.closest('[data-assistant-remove-attachment]'); if (removeAttachment) { attachments.splice(Number(removeAttachment.getAttribute('data-assistant-remove-attachment')), 1); renderAttachments(); return; } var chatMenuBtn = e.target.closest('[data-assistant-chat-menu]'); if (chatMenuBtn) { e.preventDefault(); e.stopPropagation(); openChatMenu(chatMenuBtn); return; } var chatAction = e.target.closest('[data-chat-action]'); if (chatAction) { e.preventDefault(); e.stopPropagation(); handleChatAction(chatAction.getAttribute('data-chat-action'), chatAction.getAttribute('data-chat-id')); return; } if (!e.target.closest('[data-assistant-chat-menu-pop]')) closeChatMenu(); var deleteBtn = e.target.closest('[data-assistant-delete-chat]'); if (deleteBtn) { e.preventDefault(); e.stopPropagation(); deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat')); return; } var loadBtn = e.target.closest('[data-assistant-load-chat]'); if (loadBtn) { e.preventDefault(); loadSavedChat(loadBtn.getAttribute('data-assistant-load-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(); } } // ── OWUI-style source modal (title, page, 10k excerpt + Show all) ── function openSourceModal(source, number) { closeSourceModal(); var title = source.title || source.resource || 'Source'; var page = source.page || source.page_number || source.pageNumber; var excerpt = String(source.excerpt || ''); var modal = document.createElement('div'); modal.className = 'modal assistant-source-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-labelledby', 'assistant-source-modal-title'); var body = ''; modal.innerHTML = body; modal.addEventListener('click', function(event) { if (event.target === modal || event.target.closest('.assistant-source-modal .modal-close')) closeSourceModal(); }); var showAll = modal.querySelector('[data-assistant-source-show-all]'); if (showAll) { showAll.addEventListener('click', function() { modal.querySelector('[data-assistant-source-excerpt]').textContent = excerpt; showAll.hidden = true; }); } document.body.appendChild(modal); } function closeSourceModal() { var modal = document.querySelector('.assistant-source-modal'); if (modal) modal.remove(); } function onAssistantKeydown(e) { if (e.key === 'Escape') { closeSourceModal(); closeTranslatePicker(); } } // ── Per-message translate with provider choice (local-first) ─────── // ── Voice: mic dictation in the composer + conversation (call) mode ── var assistantMic = { recording: false, recorder: null, recognition: null, finalText: '', sessionFinals: '' }; function toggleAssistantMic() { if (assistantMic.recording) stopAssistantMic(); else startAssistantMic(); } function startAssistantMic() { var input = document.getElementById('assistant-input'); assistantMic.finalText = String(input && input.value || ''); assistantMic.sessionFinals = ''; assistantMic.recorder = new AudioRecorder(); assistantMic.recording = true; assistantMic.recorder.start().then(function() { var btn = document.getElementById('btn-assistant-mic'); if (btn) { btn.classList.add('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-stop'; } assistantMic.recognition = createSpeechRecognition(); if (assistantMic.recognition) { assistantMic.recognition.onresult = function(e) { var interim = ''; for (var i = e.resultIndex; i < e.results.length; i++) { if (e.results[i].isFinal) assistantMic.sessionFinals += e.results[i][0].transcript + ' '; else interim = e.results[i][0].transcript; } var input2 = document.getElementById('assistant-input'); if (input2) { input2.value = (assistantMic.finalText + assistantMic.sessionFinals).trim() + (interim ? ' ' + interim : ''); updateConversationBudget(); } }; assistantMic.recognition.onend = function() { if (assistantMic.recording) { try { assistantMic.recognition.start(); } catch (e) {} } }; try { assistantMic.recognition.start(); } catch (e) {} } }).catch(function() { assistantMic.recording = false; if (typeof showToast === 'function') showToast('Microphone denied', 'error'); }); } function stopAssistantMic() { assistantMic.recording = false; var btn = document.getElementById('btn-assistant-mic'); if (btn) { btn.classList.remove('recording'); var icon = btn.querySelector('i'); if (icon) icon.className = 'fas fa-microphone'; } if (assistantMic.recognition) { try { assistantMic.recognition.stop(); } catch (e) {} assistantMic.recognition = null; } var recorder = assistantMic.recorder; assistantMic.recorder = null; if (!recorder) return; recorder.stop().then(function(blob) { var live = (assistantMic.finalText + assistantMic.sessionFinals).trim(); var finish = function(text) { var input = document.getElementById('assistant-input'); if (!input) return; if (text) { input.value = text; updateConversationBudget(); resizeAssistantInput(); } else if (typeof showToast === 'function') { showToast('No speech detected — try again', 'error'); } }; if (typeof transcribeAudio === 'function' && blob && blob.size > 100) { transcribeAudio(blob).then(function(data) { // Server transcription wins; the live transcript is the fallback, // never lose what the browser already heard. finish(data && data.success && data.text ? data.text : live); }).catch(function() { finish(live); }); } else finish(live); }).catch(function() {}); } var conversationMode = { active: false, listening: false, recorder: null, recognition: null, sessionFinals: '' }; function openConversationMode() { if (conversationMode.active) return; var view = document.getElementById('assistant-chat-view'); if (!view) return; conversationMode.active = true; var overlay = document.createElement('div'); overlay.className = 'assistant-voice-overlay'; overlay.id = 'assistant-voice-overlay'; overlay.innerHTML = '
' + '
Tap to speak
' + '' + '
'; view.appendChild(overlay); overlay.querySelector('#assistant-voice-mic').addEventListener('click', conversationMicToggle); overlay.querySelector('#assistant-voice-end').addEventListener('click', endConversationMode); document.addEventListener('assistant-answer-done', conversationAnswerDone); } function conversationMicToggle() { if (!conversationMode.active) return; if (conversationMode.listening) conversationStopListening(); else conversationStartListening(); } function conversationStartListening() { var status = document.getElementById('assistant-voice-status'); if (status) status.textContent = 'Listening…'; conversationMode.listening = true; conversationMode.sessionFinals = ''; conversationMode.recorder = new AudioRecorder(); conversationMode.recorder.start().then(function() { var mic = document.getElementById('assistant-voice-mic'); if (mic) mic.classList.add('recording'); conversationMode.recognition = createSpeechRecognition(); if (conversationMode.recognition) { conversationMode.recognition.onresult = function(e) { for (var i = e.resultIndex; i < e.results.length; i++) if (e.results[i].isFinal) conversationMode.sessionFinals += e.results[i][0].transcript + ' '; }; conversationMode.recognition.onend = function() { if (conversationMode.listening) { try { conversationMode.recognition.start(); } catch (e) {} } }; try { conversationMode.recognition.start(); } catch (e) {} } }).catch(function() { conversationMode.listening = false; if (status) status.textContent = 'Microphone unavailable'; }); } function conversationStopListening() { conversationMode.listening = false; var mic = document.getElementById('assistant-voice-mic'); if (mic) mic.classList.remove('recording'); if (conversationMode.recognition) { try { conversationMode.recognition.stop(); } catch (e) {} conversationMode.recognition = null; } var recorder = conversationMode.recorder; conversationMode.recorder = null; if (!recorder) return; recorder.stop().then(function(blob) { var status = document.getElementById('assistant-voice-status'); var live = conversationMode.sessionFinals.trim(); var finish = function(text) { if (text) { conversationSend(text); } else if (status) status.textContent = 'Tap to speak'; }; if (typeof transcribeAudio === 'function' && blob && blob.size > 100) { if (status) status.textContent = 'Transcribing…'; transcribeAudio(blob).then(function(data) { finish(data && data.success && data.text ? data.text : live); }) .catch(function() { finish(live); }); } else finish(live); }).catch(function() {}); } function conversationSend(text) { if (!conversationMode.active) return; var status = document.getElementById('assistant-voice-status'); var input = document.getElementById('assistant-input'); if (!input) return; input.value = text; updateConversationBudget(); resizeAssistantInput(); if (status) status.textContent = 'Asking…'; var send = document.getElementById('btn-assistant-send'); if (send) send.click(); } function conversationAnswerDone(e) { if (!conversationMode.active) return; var status = document.getElementById('assistant-voice-status'); var detail = (e && e.detail) || {}; if (detail.isError) { if (status) status.textContent = 'Answer failed — tap to speak'; return; } if (status) status.textContent = 'Speaking…'; speakAnswerVoice(String(detail.answer || '')); } function speakAnswerVoice(text) { var done = function(msg) { var st = document.getElementById('assistant-voice-status'); if (st) st.textContent = msg || 'Tap to speak'; }; if (!text) { done(); return; } fetch('/api/text-to-speech', { method: 'POST', headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), body: JSON.stringify({ text: text }) }).then(function(r) { if (!r.ok) throw new Error('Voice reply unavailable'); return r.blob(); }) .then(function(blob) { var url = URL.createObjectURL(blob); var audio = new Audio(url); audio.onended = function() { URL.revokeObjectURL(url); done(); }; audio.onerror = function() { URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); }; audio.play().catch(function() { URL.revokeObjectURL(url); done('Voice reply unavailable — tap to speak'); }); }).catch(function() { done('Voice reply unavailable — tap to speak'); }); } function endConversationMode() { conversationMode.active = false; conversationStopListening(); if (activeAssistantRequest) activeAssistantRequest.abort(); document.removeEventListener('assistant-answer-done', conversationAnswerDone); var overlay = document.getElementById('assistant-voice-overlay'); if (overlay) overlay.remove(); } // ── Create image: describe it, or base it on any saved chat (latest first) ── function openCreateImageDialog() { var existing = document.getElementById('assistant-create-image-modal'); if (existing) { existing.remove(); return; } var modal = document.createElement('div'); modal.className = 'assistant-takehome-modal'; modal.id = 'assistant-create-image-modal'; var options = [{ id: '', title: 'This chat' }].concat((savedChatCache || []).map(function (chat) { return { id: chat.id, title: chat.title || 'Saved chat' }; })); modal.innerHTML = ''; document.body.appendChild(modal); modal.addEventListener('click', function (event) { if (event.target === modal || event.target.closest('[data-create-image-close]')) modal.remove(); }); modal.querySelector('#btn-create-image-generate').addEventListener('click', function () { var description = document.getElementById('create-image-description'); var select = document.getElementById('create-image-chat'); var prompt = String(description && description.value || '').trim(); var chatId = select ? String(select.value || '') : ''; modal.remove(); startImageFromSelection(prompt, chatId); }); var select = modal.querySelector('#create-image-chat'); if (select) select.selectedIndex = 0; } function startImageFromSelection(prompt, chatId) { var sourceMessages = messages; var load = chatId ? function () { return fetchSavedAssistantChat(chatId).then(function (data) { if (!data.success || !data.chat) throw new Error('Could not load that chat'); sourceMessages = (data.chat.payload && data.chat.payload.messages) || []; }); } : function () { return Promise.resolve(); }; load().then(function () { var full = sourceMessages.map(function (m) { return { role: m.role, content: m.content }; }); var effective = prompt || 'Create a pediatric teaching visual from this conversation.'; var owner; try { owner = captureSharingOwner(); } catch (_) { return; } startAssistantImageJob(effective, full).then(function (data) { assertSharingOwner(owner); if (!data.success) throw new Error(data.error || 'Image generation failed'); generatedImageJobs = [{ jobId: data.jobId }]; lastGeneratedImageSrc = ''; exporter.invalidate(); var out = document.getElementById('assistant-visual-output'); if (out) { out.replaceChildren(); renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function (card, image) { if (generatedImageJobs[0] && generatedImageJobs[0].jobId !== image.jobId) return; lastGeneratedImageSrc = image.imageUrl; card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl)); exporter.invalidate(); }); } loadImageGallery(); if (typeof showToast === 'function') showToast('Image generation started — it appears in the Image / Graph panel', 'success'); }).catch(function (error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); }); }).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); }); } // ── Images gallery: everything this user has created, newest first ── function loadImageGallery() { var wrap = document.getElementById('assistant-image-gallery'); if (!wrap || typeof fetchAssistantImageJobs !== 'function') return; fetchAssistantImageJobs().then(function (data) { if (!data.success || !Array.isArray(data.jobs)) return; var done = data.jobs.filter(function (job) { return job.imageUrl; }); if (!done.length) { wrap.innerHTML = '

Generated images appear here.

'; return; } wrap.innerHTML = done.slice(0, 24).map(function (job) { return ''; }).join(''); }).catch(function () {}); } function openImagePreview(src) { if (!src) return; var modal = document.createElement('div'); modal.className = 'assistant-takehome-modal'; modal.id = 'assistant-image-preview-modal'; modal.innerHTML = ''; document.body.appendChild(modal); modal.addEventListener('click', function (event) { if (event.target === modal || event.target.closest('[data-image-preview-close]')) modal.remove(); }); } // ── Saved-chat options: Rename, Pin, Export, Delete (ChatGPT-style menu) ── function pinnedChatIds() { try { var raw = localStorage.getItem('assistantPinnedChats'); var ids = raw ? JSON.parse(raw) : []; return Array.isArray(ids) ? ids.map(String) : []; } catch (e) { return []; } } function isChatPinned(id) { return pinnedChatIds().indexOf(String(id)) !== -1; } function toggleChatPinned(id) { var ids = pinnedChatIds(); var key = String(id); var idx = ids.indexOf(key); if (idx === -1) ids.push(key); else ids.splice(idx, 1); try { localStorage.setItem('assistantPinnedChats', JSON.stringify(ids)); } catch (e) {} loadSavedChats(); } function renameSavedChat(id, newTitle) { var title = String(newTitle || '').trim().slice(0, 160); if (!title) return Promise.resolve(); return renameSavedAssistantChat(id, title).then(function (data) { if (!data.success) throw new Error(data.error || 'Rename failed'); loadSavedChats(); }).catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function exportSavedChat(id) { fetchSavedAssistantChat(id).then(function (data) { if (!data.success || !data.chat) throw new Error('Could not load that chat'); var payload = data.chat.payload || {}; exportAnswerPdf({ messages: payload.messages || [], lastAnswer: payload.lastAnswer || '', lastSources: payload.sources || [], generatedImageJobs: [], lastGeneratedImageSrc: payload.generatedImage || '' }); }).catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function openChatMenu(menuBtn) { closeChatMenu(); var id = menuBtn.getAttribute('data-assistant-chat-menu'); var pop = document.createElement('div'); pop.className = 'assistant-chat-menu'; pop.setAttribute('data-assistant-chat-menu-pop', ''); var pinned = isChatPinned(id); pop.innerHTML = '' + '' + '' + ''; menuBtn.closest('.assistant-saved-chat').appendChild(pop); // Document-level delegation handles the actions; the row-click guard in the // menu-button branch keeps the row from opening when the menu is used. } function closeChatMenu() { document.querySelectorAll('[data-assistant-chat-menu-pop]').forEach(function (pop) { pop.remove(); }); } function handleChatAction(action, id) { closeChatMenu(); if (action === 'pin') { toggleChatPinned(id); return; } if (action === 'delete') { deleteSavedChat(id); return; } if (action === 'export') { exportSavedChat(id); return; } if (action === 'rename') { var rows = document.querySelectorAll('[data-assistant-load-chat]'); var row = null; for (var i = 0; i < rows.length; i++) { if (String(rows[i].getAttribute('data-assistant-load-chat')) === String(id)) { row = rows[i]; break; } } if (!row) return; var titleEl = row.querySelector('.assistant-saved-chat-title'); var current = String(titleEl ? titleEl.textContent.replace(/^\ufeff/, '').trim() : ''); var input = document.createElement('input'); input.className = 'assistant-saved-chat-rename'; input.value = current; input.maxLength = 160; titleEl.replaceWith(input); input.focus(); input.select(); var commit = function () { renameSavedChat(id, input.value); }; input.addEventListener('blur', commit); input.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); } } // ── Patient take home: plain-language summary + copy/export/email ──── var takehomeBusy = false; var takehomeText = ''; function openPatientTakehome() { if (takehomeBusy) return; if (!lastAnswer || !String(lastAnswer).trim()) { if (typeof showToast === 'function') showToast('Ask a question first, then create a patient take home', 'error'); return; } closePatientTakehomeModal(); takehomeBusy = true; takehomeText = ''; var modal = document.createElement('div'); modal.className = 'assistant-takehome-modal'; modal.id = 'assistant-takehome-modal'; modal.innerHTML = ''; document.body.appendChild(modal); modal.addEventListener('click', function(event) { if (event.target === modal || event.target.closest('[data-assistant-takehome-close]')) closePatientTakehomeModal(); }); fetch('/api/clinical-assistant/patient-takehome', { method: 'POST', headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), body: JSON.stringify({ answer: lastAnswer }) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Take-home generation failed'); takehomeText = String(data.text || '').trim(); var loading = modal.querySelector('.assistant-takehome-loading'); var result = modal.querySelector('.assistant-takehome-result'); var actions = modal.querySelector('.assistant-takehome-actions'); if (loading) loading.remove(); if (result) { // Same universal markdown renderer as chat bubbles; raw text stays // canonical for Copy/Export/email. result.innerHTML = renderMarkdown(takehomeText, [], {}); result.classList.remove('hidden'); } if (actions) actions.classList.remove('hidden'); }).catch(function(err) { var loading = modal.querySelector('.assistant-takehome-loading'); if (loading) { loading.innerHTML = ' ' + escapeHtml(err.message); } }).finally(function() { takehomeBusy = false; }); } function closePatientTakehomeModal() { var modal = document.getElementById('assistant-takehome-modal'); if (modal) modal.remove(); } document.addEventListener('click', function(event) { var galleryItem = event.target.closest('[data-gallery-image]'); if (galleryItem) { openImagePreview(galleryItem.getAttribute('data-gallery-image')); return; } if (event.target.closest('[data-assistant-takehome-copy]')) { copyText(takehomeText, 'Take home copied', 'Copy failed'); return; } if (event.target.closest('[data-assistant-takehome-export]')) { if (!takehomeText) return; var blob = new Blob([takehomeText], { type: 'text/plain;charset=utf-8' }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'patient-take-home.txt'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function() { URL.revokeObjectURL(url); }, 15000); return; } if (event.target.closest('[data-assistant-takehome-send]')) { var input = document.getElementById('assistant-takehome-email'); var to = input ? input.value.trim() : ''; if (!to) { if (typeof showToast === 'function') showToast('Enter an email address', 'error'); return; } var btn = event.target.closest('[data-assistant-takehome-send]'); btn.disabled = true; fetch('/api/clinical-assistant/patient-takehome/email', { method: 'POST', headers: getAuthHeaders(Object.assign({ 'Content-Type': 'application/json' })), body: JSON.stringify({ to: to, text: takehomeText }) }).then(function(r) { return r.json(); }).then(function(data) { if (!data.success) throw new Error(data.error || 'Could not send the email'); if (typeof showToast === 'function') showToast('Take home sent to ' + to, 'success'); if (input) input.value = ''; }).catch(function(err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }).finally(function() { btn.disabled = false; }); return; } }); function openTranslatePicker(row) { if (!row) return; closeTranslatePicker(); var pop = document.createElement('div'); pop.className = 'assistant-translate-pop'; pop.setAttribute('data-assistant-translate-pop', ''); pop.innerHTML = '
' + '' + '' + '
' + '
' + TRANSLATE_LANGUAGES.map(function(pair) { return ''; }).join('') + '
'; pop.assistantMessageRow = row; row.appendChild(pop); if (!translateLanguagesAvailable) refreshTranslateLanguages(pop); } function closeTranslatePicker() { document.querySelectorAll('[data-assistant-translate-pop]').forEach(function(pop) { pop.remove(); }); } function requestMessageTranslation(row, target, provider) { var bubble = row && row.querySelector('.assistant-bubble'); var content = bubble && bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : ''; if (!bubble || !String(content).trim()) return; if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML; translateAssistantMessage(content, target, provider) .then(function(data) { if (!data.success) throw new Error(data.error || 'Translation failed'); bubble.innerHTML = '

' + escapeHtml(data.translated) + '

' + ''; }) .catch(function(err) { if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error'); }); } // ── Regenerate: replay the preceding question (latest answer only) ── function regenerateMessage(row) { if (assistantBusy) { if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error'); return; } var index = Number(row && row.getAttribute('data-message-index')); var message = Number.isInteger(index) ? messages[index] : null; if (!message || message.role !== 'assistant') return; var lastAssistantIndex = -1; for (var i = messages.length - 1; i >= 0; i--) if (messages[i].role === 'assistant') { lastAssistantIndex = i; break; } if (index !== lastAssistantIndex) { if (typeof showToast === 'function') showToast('Only the latest answer can be regenerated in this chat.', 'error'); return; } var question = messages[index - 1]; if (!question || question.role !== 'user') { if (typeof showToast === 'function') showToast('No question precedes this answer to regenerate.', 'error'); return; } messages = messages.slice(0, index - 1); // drop the answer AND its question; the question is replayed as the new message if (row && row.parentNode) row.remove(); var input = document.getElementById('assistant-input'); if (input) input.value = question.content; regenerateMode = true; updateConversationBudget(); onAsk(); } // ── Workspace navigation: Go back, chat / learning view switch ───── function goBackToMainMenu() { document.body.classList.remove('assistant-workspace'); if (typeof window.activateTab === 'function') { window.activateTab(lastNonAssistantTab || 'encounter'); return; } if (typeof document !== 'undefined' && document.dispatchEvent) document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'encounter' } })); } function openChatView() { var chat = document.getElementById('assistant-chat-view'); var learning = document.getElementById('assistant-learning-view'); var chatBtn = document.getElementById('btn-assistant-chat-view'); var learningBtn = document.getElementById('btn-assistant-learning-view'); if (chat) chat.hidden = false; if (learning) learning.hidden = true; if (chatBtn) chatBtn.classList.add('active'); if (learningBtn) learningBtn.classList.remove('active'); var layout = document.getElementById('assistant-layout'); if (layout) layout.classList.remove('learning-mode'); } function openLearningView() { var chat = document.getElementById('assistant-chat-view'); var learning = document.getElementById('assistant-learning-view'); var root = document.getElementById('assistant-learning-root'); var chatBtn = document.getElementById('btn-assistant-chat-view'); var learningBtn = document.getElementById('btn-assistant-learning-view'); if (!learning || !root) return; if (chat) chat.hidden = true; learning.hidden = false; if (chatBtn) chatBtn.classList.remove('active'); if (learningBtn) learningBtn.classList.add('active'); // Learning Hub is its own page: no saved chats, no image rail, no sources column. var layout = document.getElementById('assistant-layout'); if (layout) layout.classList.add('learning-mode'); var load = learningViewHtml ? Promise.resolve(learningViewHtml) : (typeof fetch === 'function' ? fetch('/components/learning.html').then(function(response) { if (!response.ok) throw new Error('Learning Hub unavailable'); return response.text(); }).then(function(html) { learningViewHtml = html; return html; }) : Promise.reject(new Error('Learning Hub unavailable'))); load.then(function(html) { if (!root.hasChildNodes()) root.innerHTML = html; document.dispatchEvent(new window.CustomEvent('tabChanged', { detail: { tab: 'learning' } })); }).catch(function(err) { openChatView(); if (typeof showToast === 'function') showToast(err.message || 'Learning Hub unavailable', 'error'); }); } // ── Debounced autosave (800ms after each completed turn/change) ──── function scheduleAutosave() { if (typeof setTimeout !== 'function' || !messages.length) return; autosaveErrorShown = false; // a new change retries if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer); autosaveTimer = setTimeout(performAutosave, AUTOSAVE_DELAY_MS); } function cancelAutosave() { if (autosaveTimer && typeof clearTimeout === 'function') clearTimeout(autosaveTimer); autosaveTimer = null; } function autosavePayload() { return { title: currentChatId ? undefined : deriveChatTitle(), messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, generatedImage: lastGeneratedImageSrc || undefined, ...(currentChatId ? { id: currentChatId } : {}) }; } function performAutosave() { autosaveTimer = null; if (assistantBusy || !messages.length) return; return saveAssistantChat(autosavePayload()) .then(function(data) { if (!data.success) throw new Error(data.error || 'Autosave failed'); if (data.id) currentChatId = data.id; autosaveErrorShown = false; loadSavedChats(); }) .catch(function(err) { // Surface once per change; never block or retry-loop the chat flow. if (!autosaveErrorShown) { autosaveErrorShown = true; if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Autosave failed', 'error'); } }); } function clearGeneratedImage() { lastGeneratedImageSrc = ''; generatedImageJobs = []; imageStore.clear(); var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = ''; exporter.invalidate(); } function attachImageJobs(row, message, jobs) { if (!row || !jobs.length) return; message.imageJobs = jobs.map(function(job) { return { jobId: job.jobId }; }); renderImageJobs(row.querySelector('.assistant-bubble') || row, message.imageJobs, 'clinical_assistant', function(card, data) { card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl)); exporter.invalidate(); }); } function clearConversation(event) { if (assistantBusy && !activeAssistantRequest) return; if (event && messages.length) { // Use the site's standard inline dialog instead of the native confirm(). if (typeof showConfirm === 'function') { showConfirm('Start a new chat? Your current chat is already saved automatically.', function() { performClearConversation(); }); return; } if (!window.confirm('Start a new chat? Your current chat is already saved automatically.')) return; } performClearConversation(); } function performClearConversation() { if (activeAssistantRequest) cancelAssistantSearch(); cancelAutosave(); currentChatId = null; autosaveErrorShown = false; messages = []; attachments = []; renderAttachments(); lastAnswer = ''; lastSources = []; lastGeneratedImageSrc = ''; exporter.invalidate(); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = renderEmptyState(); bindExampleButtons(wrap); } renderSources([]); clearGeneratedImage(); 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 () { // ChatGPT behavior: one tap asks the example immediately — no separate send. var el = document.getElementById('assistant-input'); if (!el) return; el.value = btn.getAttribute('data-assistant-example') || ''; updateConversationBudget(); resizeAssistantInput(); el.focus(); onAsk(); }); }); } function exportAnswerPdf(customState) { // Click bindings pass the event here; only a real state object is an override. var state = customState && customState.messages ? customState : null; exporter.exportAnswerPdf(state || { messages: messages, lastAnswer: lastAnswer, lastSources: lastSources, lastGeneratedImageSrc: lastGeneratedImageSrc, generatedImageJobs: generatedImageJobs }); } function loadSavedChats() { fetchSavedAssistantChats() .then(function (data) { if (data.success) renderSavedChats(data.chats || []); }) .catch(function () {}); loadImageGallery(); } function renderSavedChats(chats) { savedChatCache = chats || []; var wrap = document.getElementById('assistant-saved-chats'); if (!wrap) return; chats = savedChatCache.slice().sort(function (a, b) { var pa = isChatPinned(a.id) ? 0 : 1; var pb = isChatPinned(b.id) ? 0 : 1; if (pa !== pb) return pa - pb; return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')); }); if (!chats.length) { wrap.innerHTML = '

Chats save automatically as you go.

'; return; } // Open WebUI behavior: the whole row opens the chat — no Load/Delete buttons. wrap.innerHTML = chats.map(function (chat) { // Open WebUI behavior: the row opens the chat; a hover trash icon deletes it. var title = chat.title || 'Saved chat'; var pinned = isChatPinned(chat.id); return ''; }).join(''); } function deleteSavedChat(id) { if (assistantBusy) return; return deleteSavedAssistantChat(id) .then(function (data) { if (!data.success) throw new Error(data.error || 'Delete failed'); if (String(currentChatId) === String(id)) performClearConversation(); loadSavedChats(); if (typeof showToast === 'function') showToast('Chat deleted', 'success'); }) .catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); }); } function loadSavedChat(id) { if (assistantBusy) return; var layout = document.getElementById('assistant-layout'); if (layout) layout.classList.remove('mobile-chats-open'); setBusy(true, 'Loading chat...'); return fetchSavedAssistantChat(id) .then(function (data) { if (!data.success) throw new Error(data.error || 'Load failed'); currentChatId = data.chat ? data.chat.id : null; 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 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 : [], imageJobs: m.imageJobs || [], attachments: Array.isArray(m.attachments) ? m.attachments : [] }; 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; generatedImageJobs = payload.generatedImageJobs || []; // A selected job is authoritative, even for older saves containing a stale asset URL. lastGeneratedImageSrc = generatedImageJobs.length ? '' : String(payload.generatedImage || ''); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = ''; messages.forEach(function (m, index) { var display = savedMessagePresentation(m); display.attachments = m.attachments; var row = appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display); row.dataset.messageIndex = String(index); attachImageJobs(row, m, m.imageJobs); }); wrap.scrollTop = wrap.scrollHeight; } renderSources(lastSources); var out = document.getElementById('assistant-visual-output'); if (out) { out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; if (generatedImageJobs.length) renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, data) { if (generatedImageJobs[0]?.jobId !== data.jobId) return; lastGeneratedImageSrc = data.imageUrl; card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl)); exporter.invalidate(); }); } exporter.invalidate(); 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); var actions = document.createElement('div'); actions.className = 'assistant-msg-actions'; actions.innerHTML = '' + '' + (role === 'assistant' ? '' : ''); row.appendChild(actions); 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, 60); } 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 resizeAssistantInput() { var input = document.getElementById('assistant-input'); if (!input) return; input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 160) + 'px'; } 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). Limit unavailable; the server must validate each request.' : ' / ' + conversationChars.toLocaleString() + ' conversation characters (UTF-16 code units).'); 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.'; } } function downloadTranscript() { // Raw transcript JSON is an admin-only artifact. if (typeof window !== 'undefined' && window._userRole && window._userRole !== 'admin') { if (typeof showToast === 'function') showToast('Raw transcript download is for administrators only', 'error'); return; } var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources, lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, generatedImage: lastGeneratedImageSrc || undefined, 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 lastAssistantMessage(items) { for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content; return ''; } function formatSavedDate(value) { // ChatGPT-style relative grouping for the chat list. var d; try { d = new Date(value); if (isNaN(d.getTime())) return ''; } catch (e) { return ''; } var now = new Date(); var startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); var startOfValue = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); var days = Math.round((startOfToday - startOfValue) / 86400000); if (days <= 0) return 'Today'; if (days === 1) return 'Yesterday'; if (days < 7) return 'Previous 7 days'; if (days < 30) return 'Previous 30 days'; return d.toLocaleDateString(); } 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; if (!isBusy) { // createEvent keeps this working in every environment (browser and test harnesses). var doneEvent = document.createEvent('Event'); doneEvent.initEvent('assistant-answer-done', false, false); doneEvent.detail = { isError: !!isError, answer: lastAnswer || '' }; document.dispatchEvent(doneEvent); } 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'); var attachInput = document.getElementById('assistant-attach-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; if (attachInput) attachInput.disabled = !!isBusy; } var imageHookInstalled = false; function sanitize(html) { if (window.DOMPurify) { // Defense in depth for the safe-image allowlist: after sanitization, // drop any whose src is not a data URI or our own origin. if (!imageHookInstalled && typeof window.DOMPurify.addHook === 'function') { imageHookInstalled = true; window.DOMPurify.addHook('uponSanitizeElement', function(node, data) { if (data.tagName === 'img') { var src = node.getAttribute ? (node.getAttribute('src') || '') : ''; if (!safeImageUrl(src)) node.remove(); } }); } return window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }); } return escapeHtml(html); }