pediatric-ai-scribe-v3/public/js/clinicalAssistant.js
2026-05-08 01:19:12 +02:00

744 lines
30 KiB
JavaScript

// ============================================================
// 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,
requestAssistantImage,
saveAssistantChat
} from './assistant/api.js';
(function () {
'use strict';
var initialized = false;
var messages = [];
var lastAnswer = '';
var lastSources = [];
var mermaidReady = false;
var dynamicExamples = [];
var lastGeneratedImageSrc = '';
var markdownRenderer = null;
var assistantBusy = false;
var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast });
var imageStore = createAssistantImageStore();
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
});
function initIfNeeded() {
if (initialized) return;
var root = document.getElementById('assistant-tab');
if (!root || !root.querySelector('#assistant-form')) return;
initialized = true;
bindEvents();
loadStatus();
loadExamples();
}
function bindEvents() {
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var copyBtn = document.getElementById('btn-assistant-copy');
var saveBtn = document.getElementById('btn-assistant-save');
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
var saveCancelBtn = document.getElementById('btn-assistant-save-cancel');
var exportBtn = document.getElementById('btn-assistant-export-pdf');
var imageBtn = document.getElementById('btn-assistant-image');
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
var input = document.getElementById('assistant-input');
if (form) form.addEventListener('submit', onAsk);
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel);
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
document.addEventListener('click', onAssistantDocumentClick);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
});
bindExampleButtons(document);
loadSavedChats();
}
function loadStatus() {
fetchAssistantStatus()
.then(function (data) {
var label = document.getElementById('assistant-model-label');
if (label && data.success) {
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
}
})
.catch(function () {
// Endpoint may not exist until backend slice lands; keep UI usable.
});
}
function loadExamples() {
fetchAssistantExamples()
.then(function (data) {
if (!data.success || !Array.isArray(data.examples) || !data.examples.length) return;
dynamicExamples = data.examples;
var wrap = document.getElementById('assistant-messages');
if (wrap && wrap.querySelector('.assistant-empty')) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
})
.catch(function () {});
}
function onAsk(e) {
if (e) e.preventDefault();
var input = document.getElementById('assistant-input');
var includeContext = document.getElementById('assistant-include-context');
var text = input ? input.value.trim() : '';
if (assistantBusy) {
if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
return;
}
if (!text) {
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
return;
}
appendMessage('user', text);
exporter.invalidate();
if (input) input.value = '';
if (isImageRequest(text)) {
prepareSidebarImagePrompt(text);
return;
}
setBusy(true, 'Looking up sources...');
var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...');
streamAssistantResponse({
message: text,
history: messages.slice(-8),
includeContext: !includeContext || includeContext.checked
}, loading)
.catch(function (err) {
setBusy(false, 'Error', true);
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.');
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
async function fetchAssistantResponse(payload, loading) {
updateLoadingMessage(loading, 'Looking up sources...');
var data = await fetchAssistantFallback(payload);
setBusy(false, 'Ready');
lastAnswer = data.answer || '';
lastSources = data.sources || [];
replaceLoadingMessage(loading, lastAnswer, lastSources, data.suggestions || []);
renderSources(lastSources);
if (data.model) {
var label = document.getElementById('assistant-model-label');
if (label) label.textContent = 'Chat: ' + data.model;
}
}
async function streamAssistantResponse(payload, loading) {
var response = await openAssistantStream(payload);
if (!response.ok || !response.body) {
var fallback = await response.json().catch(function () { return {}; });
throw new Error(fallback.error || ('Request failed (' + response.status + ')'));
}
var partial = '';
var streamSources = [];
var finalData = null;
var lastRender = 0;
var bubble = loading ? loading.querySelector('.assistant-bubble') : null;
var decoder = new TextDecoder();
var buffer = '';
function renderProvisional(force) {
var now = Date.now();
if (!force && now - lastRender < 180) return;
lastRender = now;
if (!bubble) return;
loading.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.innerHTML = partial ? renderAssistantBubbleHtml(partial, streamSources, false) : '<p class="assistant-muted">Generating answer...</p>';
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
function handleEvent(type, data) {
if (type === 'status') {
updateLoadingMessage(loading, data.message || 'Working...');
return;
}
if (type === 'sources') {
streamSources = data.sources || [];
renderSources(streamSources);
return;
}
if (type === 'token') {
partial += data.token || '';
renderProvisional(false);
return;
}
if (type === 'done') {
finalData = data || {};
return;
}
if (type === 'error') throw new Error(data.error || 'Assistant stream failed');
}
var reader = response.body.getReader();
while (true) {
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);
}
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 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) {
var data = await fetchAssistantChat(payload);
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 = '<div class="assistant-thinking-line"><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><span class="assistant-thinking-dot"></span><strong>' + escapeHtml(title || 'Working') + '</strong></div>' +
'<div class="assistant-thinking-detail">' + escapeHtml(detail || 'Preparing response...') + '</div>';
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
wrap.scrollTop = wrap.scrollHeight;
return row;
}
function replaceLoadingMessage(row, content, sources, suggestions, rawHtml) {
if (!row || !row.parentNode) {
appendMessage('assistant', content, sources, suggestions, rawHtml);
return;
}
var bubble = row.querySelector('.assistant-bubble');
if (!bubble) return;
row.classList.remove('assistant-loading-msg');
bubble.classList.remove('assistant-thinking');
bubble.innerHTML = renderAssistantBubbleHtml(content, sources || [], rawHtml);
if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
renderEmbeddedBlocks(bubble);
var wrap = document.getElementById('assistant-messages');
if (wrap) wrap.scrollTop = wrap.scrollHeight;
messages.push({ role: 'assistant', content: content, sources: sources || [] });
}
function renderAssistantBubbleHtml(content, sources, rawHtml) {
if (rawHtml) return sanitize(String(content || ''));
try {
return renderMarkdown(content, sources || []);
} catch (e) {
console.warn('[clinical-assistant] markdown render failed:', e && e.message ? e.message : e);
return '<p>' + escapeHtml(String(content || '')).replace(/\n/g, '<br>') + '</p>';
}
}
function appendMessage(role, content, sources, suggestions, rawHtml) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var empty = wrap.querySelector('.assistant-empty');
if (empty) empty.remove();
messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] });
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content));
if (role === 'assistant' && suggestions && suggestions.length) {
bubble.appendChild(renderSuggestionButtons(suggestions));
}
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
renderEmbeddedBlocks(bubble);
wrap.scrollTop = wrap.scrollHeight;
}
function renderSuggestionButtons(suggestions) {
var wrap = document.createElement('div');
wrap.className = 'assistant-suggestion-buttons';
suggestions.forEach(function (s) {
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = s;
btn.addEventListener('click', function () {
var input = document.getElementById('assistant-input');
if (input) input.value = s;
onAsk(new Event('submit'));
});
wrap.appendChild(btn);
});
return wrap;
}
function renderMarkdown(md, sources, options) {
var opts = options || {};
return renderAssistantMarkdown(md, sources || [], {
marked: window.marked,
markdownIt: getMarkdownRenderer(),
katex: window.katex,
mathJax: window.MathJax,
sanitize: sanitize,
citationLabel: opts.citationLabel
});
}
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) {
root.querySelectorAll('[data-mermaid]').forEach(function (el) {
ensureMermaid().then(function () {
if (!window.mermaid) { el.textContent = el.getAttribute('data-mermaid'); return; }
var id = 'assistant-mermaid-' + Math.random().toString(16).slice(2);
window.mermaid.render(id, el.getAttribute('data-mermaid') || '')
.then(function (out) { el.innerHTML = out.svg || ''; })
.catch(function () { el.textContent = el.getAttribute('data-mermaid') || ''; });
});
});
root.querySelectorAll('canvas[data-chart]').forEach(function (canvas) {
if (!window.Chart) return;
try {
var cfg = JSON.parse(canvas.getAttribute('data-chart') || '{}');
new window.Chart(canvas.getContext('2d'), cfg);
} catch (e) {
canvas.replaceWith(document.createTextNode('Invalid chart JSON'));
}
});
}
function ensureMermaid() {
if (window.mermaid && mermaidReady) return Promise.resolve();
return new Promise(function (resolve) {
if (window.mermaid) { configureMermaid(); resolve(); return; }
var script = document.createElement('script');
script.src = '/vendor/mermaid.min.js';
script.onload = function () { configureMermaid(); resolve(); };
script.onerror = resolve;
document.head.appendChild(script);
});
}
function configureMermaid() {
if (!window.mermaid) return;
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: 'default', flowchart: { useMaxWidth: true, htmlLabels: true } });
mermaidReady = true;
}
function generateImage(promptOverride, fromChat) {
var promptEl = document.getElementById('assistant-image-prompt');
var out = document.getElementById('assistant-visual-output');
var prompt = typeof promptOverride === 'string' ? promptOverride.trim() : (promptEl ? promptEl.value.trim() : '');
if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000);
if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; }
if (fromChat) setBusy(true, 'Generating image...');
var loading = fromChat ? appendLoadingMessage('Generating image', 'Creating the requested clinical visual...') : null;
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
requestAssistantImage(prompt)
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Image generation failed');
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (!src) throw new Error('No image returned');
lastGeneratedImageSrc = src;
exporter.invalidate();
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
if (fromChat) {
setBusy(false, 'Ready');
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
replaceLoadingMessage(loading, html, [], [], true);
}
})
.catch(function (err) {
if (fromChat) setBusy(false, 'Error', true);
if (out) out.innerHTML = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
if (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message);
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}
function onAssistantDocumentClick(e) {
var loadBtn = e.target.closest('[data-assistant-load-chat]');
if (loadBtn) {
e.preventDefault();
loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat'));
return;
}
var deleteBtn = e.target.closest('[data-assistant-delete-chat]');
if (deleteBtn) {
e.preventDefault();
deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat'));
return;
}
var openBtn = e.target.closest('[data-assistant-open-image]');
if (openBtn) {
e.preventDefault();
imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
return;
}
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
imageStore.closeImagePreview();
}
}
function clearGeneratedImage() {
lastGeneratedImageSrc = '';
imageStore.clear();
var out = document.getElementById('assistant-visual-output');
if (out) out.innerHTML = '';
exporter.invalidate();
}
function prepareSidebarImagePrompt(request) {
var promptEl = document.getElementById('assistant-image-prompt');
var prompt = buildContextualImagePrompt(request, lastAnswer, lastSources);
if (promptEl) {
promptEl.value = prompt;
promptEl.focus();
}
appendMessage('assistant', 'I prepared the image prompt in the **Image / Graph** box on the right. Click **Generate image** there so the visual uses the current answer as context instead of treating this as a separate chat request.');
setBusy(false, 'Ready');
}
function clearConversation() {
messages = [];
lastAnswer = '';
lastSources = [];
lastGeneratedImageSrc = '';
exporter.invalidate();
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = renderEmptyState();
bindExampleButtons(wrap);
}
renderSources([]);
clearGeneratedImage();
loadSavedChats();
}
function renderEmptyState() {
var examples = pickExamples();
return '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
'<h3>Evidence-first pediatric assistant</h3>' +
'<p>Ask a real clinical question. Citations stay linked to the source cards on the right.</p>' +
'<div class="assistant-examples">' + examples.map(function (item) {
var source = item.sourceTitle ? (' title="Available from: ' + escapeAttr(item.sourceTitle + (item.page ? ', page ' + item.page : '')) + '"') : '';
return '<button type="button" data-assistant-example="' + escapeAttr(item.prompt) + '"' + source + '>' + escapeHtml(item.label) + '</button>';
}).join('') + '</div></div>';
}
function pickExamples() {
var examples = dynamicExamples.length ? dynamicExamples.slice() : EMPTY_PROMPT_SETS[Math.floor(Math.random() * EMPTY_PROMPT_SETS.length)].slice();
for (var i = examples.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var tmp = examples[i];
examples[i] = examples[j];
examples[j] = tmp;
}
return examples.slice(0, 3);
}
function bindExampleButtons(root) {
(root || document).querySelectorAll('[data-assistant-example]').forEach(function (btn) {
if (btn.getAttribute('data-assistant-bound') === 'true') return;
btn.setAttribute('data-assistant-bound', 'true');
btn.addEventListener('click', function () {
var el = document.getElementById('assistant-input');
if (el) {
el.value = btn.getAttribute('data-assistant-example') || '';
el.focus();
}
});
});
}
function copyLastAnswer() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to copy', 'error'); return; }
navigator.clipboard.writeText(lastAnswer).then(function () {
if (typeof showToast === 'function') showToast('Copied answer', 'success');
}).catch(function () {
if (typeof showToast === 'function') showToast('Copy failed', 'error');
});
}
function exportAnswerPdf() {
exporter.exportAnswerPdf({
messages: messages,
lastAnswer: lastAnswer,
lastSources: lastSources,
lastGeneratedImageSrc: lastGeneratedImageSrc
});
}
function saveCurrentChat() {
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
var titleEl = document.getElementById('assistant-save-title');
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
setBusy(true, 'Saving chat...');
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 (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
var panel = document.getElementById('assistant-save-panel');
var titleEl = document.getElementById('assistant-save-title');
if (!panel || !titleEl) return saveCurrentChat();
titleEl.value = deriveChatTitle();
panel.hidden = false;
titleEl.focus();
titleEl.select();
}
function hideSavePanel() {
var panel = document.getElementById('assistant-save-panel');
if (panel) panel.hidden = true;
}
function loadSavedChats() {
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 = '<p class="assistant-muted">Saved chats appear here after you click Save chat.</p>';
return;
}
wrap.innerHTML = chats.map(function (chat) {
return '<div class="assistant-saved-chat">' +
'<div class="assistant-saved-chat-title">' + escapeHtml(chat.title || 'Saved chat') + '</div>' +
'<div class="assistant-saved-chat-meta">' + escapeHtml(formatSavedDate(chat.updated_at || chat.created_at)) + '</div>' +
'<div class="assistant-saved-chat-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-load-chat="' + escapeAttr(chat.id) + '">Load</button>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-delete-chat="' + escapeAttr(chat.id) + '">Delete</button>' +
'</div></div>';
}).join('');
}
function loadSavedChat(id) {
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'); });
}
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) {
return { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] };
}) : [];
lastSources = Array.isArray(payload.sources) ? payload.sources : [];
lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || '');
lastGeneratedImageSrc = String(payload.generatedImage || '');
var wrap = document.getElementById('assistant-messages');
if (wrap) {
wrap.innerHTML = '';
messages.forEach(function (m) { appendMessageNode(m.role, m.content, m.sources && m.sources.length ? m.sources : lastSources); });
wrap.scrollTop = wrap.scrollHeight;
}
renderSources(lastSources);
var out = document.getElementById('assistant-visual-output');
if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
}
function appendMessageNode(role, content, sources) {
var wrap = document.getElementById('assistant-messages');
if (!wrap) return;
var row = document.createElement('div');
row.className = 'assistant-msg ' + role;
var label = document.createElement('div');
label.className = 'assistant-msg-label';
label.textContent = role === 'user' ? 'You' : 'Assistant';
var bubble = document.createElement('div');
bubble.className = 'assistant-bubble';
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
row.appendChild(label);
row.appendChild(bubble);
wrap.appendChild(row);
renderEmbeddedBlocks(bubble);
}
function deriveChatTitle() {
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80);
}
function imageForSavedChatPayload(src) {
src = String(src || '');
return /^https?:\/\//i.test(src) ? src : '';
}
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 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 ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
}
if (input) input.disabled = !!isBusy;
}
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; }
})();