808 lines
36 KiB
JavaScript
808 lines
36 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, stripSourcesSection } from './assistant/citations.js';
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var initialized = false;
|
|
var messages = [];
|
|
var lastAnswer = '';
|
|
var lastSources = [];
|
|
var mermaidReady = false;
|
|
var dynamicExamples = [];
|
|
var generatedImages = {};
|
|
var generatedImageSeq = 0;
|
|
var lastGeneratedImageSrc = '';
|
|
var exportCacheKey = '';
|
|
var exportCacheItems = null;
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
|
|
});
|
|
|
|
function initIfNeeded() {
|
|
if (initialized) return;
|
|
var root = document.getElementById('assistant-tab');
|
|
if (!root || !root.querySelector('#assistant-form')) return;
|
|
initialized = true;
|
|
bindEvents();
|
|
loadStatus();
|
|
loadExamples();
|
|
}
|
|
|
|
function bindEvents() {
|
|
var form = document.getElementById('assistant-form');
|
|
var clearBtn = document.getElementById('btn-assistant-clear');
|
|
var copyBtn = document.getElementById('btn-assistant-copy');
|
|
var saveBtn = document.getElementById('btn-assistant-save');
|
|
var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm');
|
|
var saveCancelBtn = document.getElementById('btn-assistant-save-cancel');
|
|
var exportBtn = document.getElementById('btn-assistant-export-pdf');
|
|
var imageBtn = document.getElementById('btn-assistant-image');
|
|
var imageClearBtn = document.getElementById('btn-assistant-image-clear');
|
|
var input = document.getElementById('assistant-input');
|
|
|
|
if (form) form.addEventListener('submit', onAsk);
|
|
if (clearBtn) clearBtn.addEventListener('click', clearConversation);
|
|
if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer);
|
|
if (saveBtn) saveBtn.addEventListener('click', showSavePanel);
|
|
if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat);
|
|
if (saveCancelBtn) saveCancelBtn.addEventListener('click', hideSavePanel);
|
|
if (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
|
|
if (imageBtn) imageBtn.addEventListener('click', generateImage);
|
|
if (imageClearBtn) imageClearBtn.addEventListener('click', clearGeneratedImage);
|
|
document.addEventListener('click', onAssistantDocumentClick);
|
|
if (input) input.addEventListener('keydown', function (e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
|
|
});
|
|
|
|
bindExampleButtons(document);
|
|
loadSavedChats();
|
|
}
|
|
|
|
function loadStatus() {
|
|
fetch('/api/clinical-assistant/status', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label && data.success) {
|
|
label.textContent = data.chatModel ? ('Chat: ' + data.chatModel) : 'Admin model';
|
|
}
|
|
})
|
|
.catch(function () {
|
|
// Endpoint may not exist until backend slice lands; keep UI usable.
|
|
});
|
|
}
|
|
|
|
function loadExamples() {
|
|
fetch('/api/clinical-assistant/examples', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.success || !Array.isArray(data.examples) || !data.examples.length) return;
|
|
dynamicExamples = data.examples;
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap && wrap.querySelector('.assistant-empty')) {
|
|
wrap.innerHTML = renderEmptyState();
|
|
bindExampleButtons(wrap);
|
|
}
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
|
|
function onAsk(e) {
|
|
if (e) e.preventDefault();
|
|
var input = document.getElementById('assistant-input');
|
|
var includeContext = document.getElementById('assistant-include-context');
|
|
var text = input ? input.value.trim() : '';
|
|
if (!text) {
|
|
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
|
|
return;
|
|
}
|
|
|
|
appendMessage('user', text);
|
|
exportCacheKey = '';
|
|
exportCacheItems = null;
|
|
if (input) input.value = '';
|
|
|
|
if (isImageRequest(text)) {
|
|
prepareSidebarImagePrompt(text);
|
|
return;
|
|
}
|
|
|
|
setBusy(true, 'Searching indexed resources...');
|
|
var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited references...');
|
|
|
|
fetch('/api/clinical-assistant/chat', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
message: text,
|
|
history: messages.slice(-8),
|
|
includeContext: !includeContext || includeContext.checked
|
|
})
|
|
})
|
|
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
|
.then(function (data) {
|
|
setBusy(false, 'Ready');
|
|
if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')'));
|
|
var answer = data.answer || data.markdown || '';
|
|
lastAnswer = answer;
|
|
lastSources = data.sources || data.citations || [];
|
|
replaceLoadingMessage(loading, answer, lastSources, data.suggestions || []);
|
|
renderSources(lastSources);
|
|
if (data.model) {
|
|
var label = document.getElementById('assistant-model-label');
|
|
if (label) label.textContent = 'Chat: ' + data.model;
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
setBusy(false, 'Error', true);
|
|
replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nIf this is the first run, the backend `/api/clinical-assistant/chat` route still needs to be wired to direct MCP search.');
|
|
if (typeof showToast === 'function') showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function appendLoadingMessage(title, detail) {
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (!wrap) return null;
|
|
var empty = wrap.querySelector('.assistant-empty');
|
|
if (empty) empty.remove();
|
|
var row = document.createElement('div');
|
|
row.className = 'assistant-msg assistant assistant-loading-msg';
|
|
var label = document.createElement('div');
|
|
label.className = 'assistant-msg-label';
|
|
label.textContent = 'Assistant';
|
|
var bubble = document.createElement('div');
|
|
bubble.className = 'assistant-bubble assistant-thinking';
|
|
bubble.innerHTML = '<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 = rawHtml ? sanitize(String(content || '')) : renderMarkdown(content, sources || []);
|
|
if (suggestions && suggestions.length) bubble.appendChild(renderSuggestionButtons(suggestions));
|
|
renderEmbeddedBlocks(bubble);
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
|
messages.push({ role: 'assistant', content: content, sources: sources || [] });
|
|
}
|
|
|
|
function appendMessage(role, content, sources, suggestions, rawHtml) {
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (!wrap) return;
|
|
var empty = wrap.querySelector('.assistant-empty');
|
|
if (empty) empty.remove();
|
|
|
|
messages.push({ role: role, content: content, sources: role === 'assistant' ? (sources || []) : [] });
|
|
var row = document.createElement('div');
|
|
row.className = 'assistant-msg ' + role;
|
|
var label = document.createElement('div');
|
|
label.className = 'assistant-msg-label';
|
|
label.textContent = role === 'user' ? 'You' : 'Assistant';
|
|
var bubble = document.createElement('div');
|
|
bubble.className = 'assistant-bubble';
|
|
bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content));
|
|
if (role === 'assistant' && suggestions && suggestions.length) {
|
|
bubble.appendChild(renderSuggestionButtons(suggestions));
|
|
}
|
|
row.appendChild(label);
|
|
row.appendChild(bubble);
|
|
wrap.appendChild(row);
|
|
renderEmbeddedBlocks(bubble);
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
|
|
function renderSuggestionButtons(suggestions) {
|
|
var wrap = document.createElement('div');
|
|
wrap.className = 'assistant-suggestion-buttons';
|
|
suggestions.forEach(function (s) {
|
|
var btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.textContent = s;
|
|
btn.addEventListener('click', function () {
|
|
var input = document.getElementById('assistant-input');
|
|
if (input) input.value = s;
|
|
onAsk(new Event('submit'));
|
|
});
|
|
wrap.appendChild(btn);
|
|
});
|
|
return wrap;
|
|
}
|
|
|
|
function renderMarkdown(md, sources) {
|
|
return renderAssistantMarkdown(md, sources || [], {
|
|
marked: window.marked,
|
|
katex: window.katex,
|
|
sanitize: sanitize
|
|
});
|
|
}
|
|
|
|
function renderSources(sources) {
|
|
var wrap = document.getElementById('assistant-sources');
|
|
if (!wrap) return;
|
|
if (!sources || sources.length === 0) {
|
|
wrap.innerHTML = '<p class="assistant-muted">No citations returned.</p>';
|
|
return;
|
|
}
|
|
wrap.innerHTML = sources.map(function (s, idx) {
|
|
var n = s.number || idx + 1;
|
|
var page = s.page || s.page_number || s.pageNumber;
|
|
var meta = [];
|
|
if (page) meta.push('page ' + page);
|
|
if (s.source_type === 'multimodal_page') meta.push('visual PDF page');
|
|
if (s.visual_kind || s.source_priority) meta.push(s.visual_kind || s.source_priority);
|
|
if (s.category) meta.push(s.category);
|
|
if (s.doc_type || s.type) meta.push(s.doc_type || s.type);
|
|
if (s.score != null) meta.push('score ' + Number(s.score).toFixed(3));
|
|
return '<div class="assistant-source" id="assistant-source-' + n + '">' +
|
|
'<strong>[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '</strong>' +
|
|
renderSourceBadges(s) +
|
|
'<div class="assistant-source-meta">' + escapeHtml(meta.join(' · ') || 'indexed source') + '</div>' +
|
|
renderSourceImage(s, n) +
|
|
(s.excerpt ? '<div class="assistant-source-excerpt"><p>' + escapeHtml(cleanSourceExcerpt(s.excerpt).slice(0, 900)) + '</p></div>' : '') +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
function renderSourceBadges(source) {
|
|
var badges = [];
|
|
if (source.source_priority) badges.push(source.source_priority);
|
|
if (source.visual_kind && badges.indexOf(source.visual_kind) === -1) badges.push(source.visual_kind);
|
|
if (source.category) badges.push(source.category);
|
|
if (source.source_boost && Number(source.source_boost) !== 1) badges.push(Number(source.source_boost).toFixed(2) + 'x');
|
|
if (!badges.length) return '';
|
|
return '<div class="assistant-source-badges">' + badges.slice(0, 4).map(function (badge) {
|
|
return '<span>' + escapeHtml(badge) + '</span>';
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
function renderSourceImage(source, number) {
|
|
var src = source && (source.image_url || source.imageUrl);
|
|
if (!src || source.source_type !== 'multimodal_page') return '';
|
|
var id = 'source-img-' + number + '-' + (++generatedImageSeq);
|
|
generatedImages[id] = src;
|
|
return '<div class="assistant-source-preview">' +
|
|
'<button type="button" data-assistant-open-image="' + escapeAttr(id) + '" aria-label="Preview visual page source">' +
|
|
'<img src="' + escapeAttr(src) + '" alt="Visual PDF page source preview" loading="lazy">' +
|
|
'</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
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>';
|
|
fetch('/api/clinical-assistant/image', {
|
|
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
|
|
body: JSON.stringify({ prompt: prompt })
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data.success) throw new Error(data.error || 'Image generation failed');
|
|
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
|
if (!src) throw new Error('No image returned');
|
|
lastGeneratedImageSrc = src;
|
|
exportCacheKey = '';
|
|
exportCacheItems = null;
|
|
if (out) out.innerHTML = renderGeneratedImage(src, 'Generated clinical visual');
|
|
if (fromChat) {
|
|
setBusy(false, 'Ready');
|
|
var html = renderGeneratedImage(src, 'Generated clinical visual');
|
|
replaceLoadingMessage(loading, html, [], [], true);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
if (fromChat) setBusy(false, 'Error', true);
|
|
if (out) out.innerHTML = '<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 renderGeneratedImage(src, alt) {
|
|
var id = 'img-' + (++generatedImageSeq);
|
|
generatedImages[id] = src;
|
|
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
|
|
'<div class="assistant-image-actions">' +
|
|
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
|
|
'<a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" download="clinical-visual.png"><i class="fas fa-download"></i> Download</a>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
function onAssistantDocumentClick(e) {
|
|
var loadBtn = e.target.closest('[data-assistant-load-chat]');
|
|
if (loadBtn) {
|
|
e.preventDefault();
|
|
loadSavedChat(loadBtn.getAttribute('data-assistant-load-chat'));
|
|
return;
|
|
}
|
|
var deleteBtn = e.target.closest('[data-assistant-delete-chat]');
|
|
if (deleteBtn) {
|
|
e.preventDefault();
|
|
deleteSavedChat(deleteBtn.getAttribute('data-assistant-delete-chat'));
|
|
return;
|
|
}
|
|
var openBtn = e.target.closest('[data-assistant-open-image]');
|
|
if (openBtn) {
|
|
e.preventDefault();
|
|
openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
|
|
return;
|
|
}
|
|
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
|
|
closeImagePreview();
|
|
}
|
|
}
|
|
|
|
function openImagePreview(id) {
|
|
var src = generatedImages[id];
|
|
if (!src) return;
|
|
closeImagePreview();
|
|
var modal = document.createElement('div');
|
|
modal.className = 'assistant-image-modal';
|
|
modal.innerHTML = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">×</button><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"></div>';
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
function closeImagePreview() {
|
|
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
|
|
}
|
|
|
|
function clearGeneratedImage() {
|
|
lastGeneratedImageSrc = '';
|
|
generatedImages = {};
|
|
var out = document.getElementById('assistant-visual-output');
|
|
if (out) out.innerHTML = '';
|
|
closeImagePreview();
|
|
exportCacheKey = '';
|
|
exportCacheItems = null;
|
|
}
|
|
|
|
function buildContextualImagePrompt(request) {
|
|
var prompt = String(request || '').trim();
|
|
if (!lastAnswer) return prompt;
|
|
var sourceText = lastSources.slice(0, 6).map(function (s, idx) {
|
|
return '[' + (s.number || idx + 1) + '] ' + (s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
|
|
}).join('\n');
|
|
return prompt + '\n\nUse this clinical answer as the required context. Do not switch topics or introduce unrelated scenes such as gardening. Create a medical teaching visual faithful to the answer.\n\nAnswer:\n' + lastAnswer.slice(0, 3000) + '\n\nSources:\n' + sourceText;
|
|
}
|
|
|
|
function prepareSidebarImagePrompt(request) {
|
|
var promptEl = document.getElementById('assistant-image-prompt');
|
|
var prompt = buildContextualImagePrompt(request);
|
|
if (promptEl) {
|
|
promptEl.value = prompt;
|
|
promptEl.focus();
|
|
}
|
|
appendMessage('assistant', 'I prepared the image prompt in the **Image / Graph** box on the right. Click **Generate image** there so the visual uses the current answer as context instead of treating this as a separate chat request.');
|
|
setBusy(false, 'Ready');
|
|
}
|
|
|
|
function clearConversation() {
|
|
messages = [];
|
|
lastAnswer = '';
|
|
lastSources = [];
|
|
lastGeneratedImageSrc = '';
|
|
exportCacheKey = '';
|
|
exportCacheItems = null;
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) {
|
|
wrap.innerHTML = renderEmptyState();
|
|
bindExampleButtons(wrap);
|
|
}
|
|
renderSources([]);
|
|
clearGeneratedImage();
|
|
loadSavedChats();
|
|
}
|
|
|
|
function renderEmptyState() {
|
|
var examples = pickExamples();
|
|
return '<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() {
|
|
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
|
|
var exportItems = collectExportItems();
|
|
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc);
|
|
if (exportCacheKey === cacheKey && exportCacheItems) {
|
|
openPrintableChatExport(exportCacheItems, lastGeneratedImageSrc);
|
|
return;
|
|
}
|
|
setBusy(true, 'Preparing PDF...');
|
|
fetch('/api/clinical-assistant/export-summary', {
|
|
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
|
|
body: JSON.stringify({ items: exportItems })
|
|
})
|
|
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
|
.then(function (data) {
|
|
setBusy(false, 'Ready');
|
|
var items = data.success && Array.isArray(data.items) && data.items.length ? data.items : exportItems;
|
|
exportCacheKey = cacheKey;
|
|
exportCacheItems = items;
|
|
openPrintableChatExport(items, lastGeneratedImageSrc);
|
|
})
|
|
.catch(function () {
|
|
setBusy(false, 'Ready');
|
|
openPrintableChatExport(exportItems, lastGeneratedImageSrc);
|
|
});
|
|
}
|
|
|
|
function openPrintableChatExport(items, imageSrc) {
|
|
var doc = window.open('', '_blank', 'width=900,height=1100');
|
|
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
|
|
items = Array.isArray(items) && items.length ? items : collectExportItems();
|
|
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
|
|
var sections = items.map(function (item, idx) {
|
|
var sources = Array.isArray(item.sources) ? item.sources : [];
|
|
var refs = renderExportRefs(sources, idx + 1);
|
|
var heading = item.heading || deriveExportHeading(item.question, idx);
|
|
var summary = item.summary || '';
|
|
var answer = stripSourcesSection(item.answer || '');
|
|
return '<section class="export-section">' +
|
|
'<h2>' + escapeHtml(heading) + '</h2>' +
|
|
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
|
|
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources) + '</div>' : '') +
|
|
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources) + '</div>' +
|
|
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
|
|
'</section>';
|
|
}).join('');
|
|
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
|
|
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px}h3{font-size:14px;margin:16px 0 6px}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{break-inside:avoid-page;margin-top:20px}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px}.refs li{margin:6px 0}.assistant-cite{color:#4f46e5;text-decoration:none;font-weight:700}@media print{button{display:none}body{margin:24mm}}</style>' +
|
|
'</head><body><button onclick="window.print()" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
|
|
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
|
|
imageHtml +
|
|
sections + '</body></html>';
|
|
doc.document.open();
|
|
doc.document.write(html);
|
|
doc.document.close();
|
|
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
|
|
}
|
|
|
|
function collectExportItems() {
|
|
var items = [];
|
|
var pendingQuestion = '';
|
|
messages.forEach(function (m) {
|
|
if (m.role === 'user') {
|
|
pendingQuestion = m.content || pendingQuestion;
|
|
return;
|
|
}
|
|
if (m.role !== 'assistant' || !m.content) return;
|
|
if (isUtilityAssistantMessage(m.content)) return;
|
|
items.push({
|
|
question: pendingQuestion || 'Clinical question',
|
|
heading: deriveExportHeading(pendingQuestion, items.length),
|
|
summary: '',
|
|
answer: stripSourcesSection(m.content),
|
|
sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources
|
|
});
|
|
pendingQuestion = '';
|
|
});
|
|
if (!items.length && lastAnswer) {
|
|
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: stripSourcesSection(lastAnswer), sources: lastSources });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
function renderExportRefs(sources, sectionNumber) {
|
|
return (sources || []).map(function (s, idx) {
|
|
var n = s.number || idx + 1;
|
|
var title = s.title || s.resource || 'Untitled source';
|
|
var page = s.page || s.page_number || s.pageNumber;
|
|
return '<li id="ref-' + sectionNumber + '-' + n + '"><strong>[' + n + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
|
|
}).join('');
|
|
}
|
|
|
|
function deriveExportHeading(question, idx) {
|
|
var text = String(question || '').replace(/\s+/g, ' ').trim();
|
|
if (!text || /^(what|which|when|why|how|and|also|what dose\??|dose\??)$/i.test(text)) return 'Clinical Question ' + (idx + 1);
|
|
return text.replace(/[?!.]+$/, '').slice(0, 90);
|
|
}
|
|
|
|
function isUtilityAssistantMessage(content) {
|
|
return /^I prepared the image prompt in the \*\*Image \/ Graph\*\* box/i.test(String(content || ''));
|
|
}
|
|
|
|
function saveCurrentChat() {
|
|
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
|
var titleEl = document.getElementById('assistant-save-title');
|
|
var title = String(titleEl && titleEl.value || deriveChatTitle()).trim() || deriveChatTitle();
|
|
setBusy(true, 'Saving chat...');
|
|
fetch('/api/clinical-assistant/chats', {
|
|
method: 'POST',
|
|
headers: getAuthHeaders(),
|
|
credentials: 'same-origin',
|
|
body: JSON.stringify({
|
|
title: title,
|
|
messages: messages,
|
|
sources: lastSources,
|
|
lastAnswer: lastAnswer,
|
|
generatedImage: imageForSavedChatPayload(lastGeneratedImageSrc)
|
|
})
|
|
})
|
|
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
|
.then(function (data) {
|
|
setBusy(false, 'Ready');
|
|
if (!data.success) throw new Error(data.error || 'Save failed');
|
|
hideSavePanel();
|
|
if (typeof showToast === 'function') showToast('Saved chat', 'success');
|
|
loadSavedChats();
|
|
})
|
|
.catch(function (err) {
|
|
setBusy(false, 'Error', true);
|
|
if (typeof showToast === 'function') showToast(err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function showSavePanel() {
|
|
if (!messages.length || !lastAnswer) { if (typeof showToast === 'function') showToast('No assistant chat to save yet', 'error'); return; }
|
|
var panel = document.getElementById('assistant-save-panel');
|
|
var titleEl = document.getElementById('assistant-save-title');
|
|
if (!panel || !titleEl) return saveCurrentChat();
|
|
titleEl.value = deriveChatTitle();
|
|
panel.hidden = false;
|
|
titleEl.focus();
|
|
titleEl.select();
|
|
}
|
|
|
|
function hideSavePanel() {
|
|
var panel = document.getElementById('assistant-save-panel');
|
|
if (panel) panel.hidden = true;
|
|
}
|
|
|
|
function loadSavedChats() {
|
|
fetch('/api/clinical-assistant/chats', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) { if (data.success) renderSavedChats(data.chats || []); })
|
|
.catch(function () {});
|
|
}
|
|
|
|
function renderSavedChats(chats) {
|
|
var wrap = document.getElementById('assistant-saved-chats');
|
|
if (!wrap) return;
|
|
if (!chats.length) {
|
|
wrap.innerHTML = '<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) {
|
|
fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
|
|
.then(function (data) {
|
|
if (!data.success) throw new Error(data.error || 'Load failed');
|
|
restoreSavedChat(data.chat && data.chat.payload || {});
|
|
if (typeof showToast === 'function') showToast('Loaded saved chat', 'success');
|
|
})
|
|
.catch(function (err) { if (typeof showToast === 'function') showToast(err.message, 'error'); });
|
|
}
|
|
|
|
function deleteSavedChat(id) {
|
|
var btn = document.querySelector('[data-assistant-delete-chat="' + cssEscape(id) + '"]');
|
|
if (btn && btn.getAttribute('data-confirm-delete') !== 'true') {
|
|
btn.setAttribute('data-confirm-delete', 'true');
|
|
btn.textContent = 'Confirm delete';
|
|
setTimeout(function () {
|
|
if (btn.getAttribute('data-confirm-delete') === 'true') {
|
|
btn.removeAttribute('data-confirm-delete');
|
|
btn.textContent = 'Delete';
|
|
}
|
|
}, 5000);
|
|
return;
|
|
}
|
|
fetch('/api/clinical-assistant/chats/' + encodeURIComponent(id), {
|
|
method: 'DELETE', headers: getAuthHeaders(), credentials: 'same-origin'
|
|
})
|
|
.then(function (r) { return r.json(); })
|
|
.then(function () { loadSavedChats(); if (typeof showToast === 'function') showToast('Deleted saved chat', 'success'); })
|
|
.catch(function () { if (typeof showToast === 'function') showToast('Delete failed', 'error'); });
|
|
}
|
|
|
|
function restoreSavedChat(payload) {
|
|
messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) {
|
|
return { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] };
|
|
}) : [];
|
|
lastSources = Array.isArray(payload.sources) ? payload.sources : [];
|
|
lastAnswer = String(payload.lastAnswer || lastAssistantMessage(messages) || '');
|
|
lastGeneratedImageSrc = String(payload.generatedImage || '');
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (wrap) {
|
|
wrap.innerHTML = '';
|
|
messages.forEach(function (m) { appendMessageNode(m.role, m.content, m.sources && m.sources.length ? m.sources : lastSources); });
|
|
wrap.scrollTop = wrap.scrollHeight;
|
|
}
|
|
renderSources(lastSources);
|
|
var out = document.getElementById('assistant-visual-output');
|
|
if (out) out.innerHTML = lastGeneratedImageSrc ? renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
|
|
}
|
|
|
|
function appendMessageNode(role, content, sources) {
|
|
var wrap = document.getElementById('assistant-messages');
|
|
if (!wrap) return;
|
|
var row = document.createElement('div');
|
|
row.className = 'assistant-msg ' + role;
|
|
var label = document.createElement('div');
|
|
label.className = 'assistant-msg-label';
|
|
label.textContent = role === 'user' ? 'You' : 'Assistant';
|
|
var bubble = document.createElement('div');
|
|
bubble.className = 'assistant-bubble';
|
|
bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content);
|
|
row.appendChild(label);
|
|
row.appendChild(bubble);
|
|
wrap.appendChild(row);
|
|
renderEmbeddedBlocks(bubble);
|
|
}
|
|
|
|
function deriveChatTitle() {
|
|
var first = messages.find(function (m) { return m.role === 'user' && m.content; });
|
|
return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
}
|
|
|
|
function cleanSourceExcerpt(text) {
|
|
return String(text || '')
|
|
.replace(/^\[Page-image match\]\s*/i, '')
|
|
.replace(/<br\s*\/?>/gi, ' ')
|
|
.replace(/\*\*/g, '')
|
|
.replace(/\|\s*-{2,}\s*/g, ' ')
|
|
.replace(/\|/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function imageForSavedChatPayload(src) {
|
|
src = String(src || '');
|
|
return /^https?:\/\//i.test(src) ? src : '';
|
|
}
|
|
|
|
function buildExportCacheKey(items, imageSrc) {
|
|
return JSON.stringify({ items: items.map(function (item) {
|
|
return { q: item.question, a: item.answer, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) };
|
|
}), image: imageSrc ? '1' : '' });
|
|
}
|
|
|
|
function lastAssistantMessage(items) {
|
|
for (var i = items.length - 1; i >= 0; i--) if (items[i].role === 'assistant') return items[i].content;
|
|
return '';
|
|
}
|
|
|
|
function formatSavedDate(value) {
|
|
try { return new Date(value).toLocaleString(); } catch (e) { return ''; }
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
|
|
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
}
|
|
|
|
function isImageRequest(text) {
|
|
return /\b(generate|create|draw|make|show|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
|
|
/\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b[\s\S]{0,80}\b(of|for|about|showing)\b/i.test(text);
|
|
}
|
|
|
|
function setBusy(isBusy, text, isError) {
|
|
var status = document.getElementById('assistant-status');
|
|
var label = document.getElementById('assistant-status-text');
|
|
var send = document.getElementById('btn-assistant-send');
|
|
if (status) {
|
|
status.classList.toggle('busy', !!isBusy);
|
|
status.classList.toggle('error', !!isError);
|
|
}
|
|
if (label) label.textContent = text || (isBusy ? 'Working...' : 'Ready');
|
|
if (send) {
|
|
send.disabled = !!isBusy;
|
|
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
|
|
}
|
|
}
|
|
|
|
function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; }
|
|
})();
|