split assistant frontend modules
This commit is contained in:
parent
f54b293d39
commit
326fb726a1
4 changed files with 282 additions and 228 deletions
154
public/js/assistant/export.js
Normal file
154
public/js/assistant/export.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { escapeAttr, escapeHtml } from './citations.js';
|
||||
|
||||
export function createAssistantExporter(options) {
|
||||
options = options || {};
|
||||
var exportCacheKey = '';
|
||||
var exportCacheItems = null;
|
||||
|
||||
function invalidate() {
|
||||
exportCacheKey = '';
|
||||
exportCacheItems = null;
|
||||
}
|
||||
|
||||
function exportAnswerPdf(state) {
|
||||
state = state || {};
|
||||
if (!state.lastAnswer) {
|
||||
if (typeof options.showToast === 'function') options.showToast('No answer to export', 'error');
|
||||
return;
|
||||
}
|
||||
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || []);
|
||||
var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || '');
|
||||
var doc = openExportWindow();
|
||||
if (!doc) {
|
||||
if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error');
|
||||
return;
|
||||
}
|
||||
if (exportCacheKey === cacheKey && exportCacheItems) {
|
||||
writePrintableChatExport(doc, exportCacheItems, state.lastGeneratedImageSrc || '');
|
||||
return;
|
||||
}
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = exportItems;
|
||||
writePrintableChatExport(doc, exportItems, state.lastGeneratedImageSrc || '');
|
||||
}
|
||||
|
||||
function openExportWindow() {
|
||||
var doc = window.open('', '_blank', 'width=900,height=1100');
|
||||
if (!doc) return null;
|
||||
doc.document.open();
|
||||
doc.document.write('<!doctype html><html><head><title>Preparing Clinical Assistant Export</title><style>body{font-family:Arial,sans-serif;color:#111827;margin:36px;line-height:1.5}.spinner{width:18px;height:18px;border:3px solid #e5e7eb;border-top-color:#7c3aed;border-radius:50%;display:inline-block;vertical-align:middle;margin-right:8px;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><p><span class="spinner"></span>Preparing PDF export...</p></body></html>');
|
||||
doc.document.close();
|
||||
return doc;
|
||||
}
|
||||
|
||||
function writePrintableChatExport(doc, items, imageSrc) {
|
||||
if (!doc || doc.closed) return;
|
||||
items = Array.isArray(items) && items.length ? items : [];
|
||||
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 heading = item.heading || deriveExportHeading(item.question, idx);
|
||||
var summary = item.summary || '';
|
||||
var answer = item.answer || '';
|
||||
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
|
||||
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
|
||||
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, { citationLabel: 'number' }) + '</div>' : '') +
|
||||
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '</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;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.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;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
|
||||
'</head><body><button id="assistant-export-print" type="button" 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();
|
||||
try {
|
||||
var printBtn = doc.document.getElementById('assistant-export-print');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
|
||||
} catch (e) {}
|
||||
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
|
||||
}
|
||||
|
||||
function renderMarkdown(md, sources, renderOptions) {
|
||||
if (typeof options.renderMarkdown === 'function') return options.renderMarkdown(md, sources, renderOptions);
|
||||
return escapeHtml(md);
|
||||
}
|
||||
|
||||
return {
|
||||
exportAnswerPdf: exportAnswerPdf,
|
||||
invalidate: invalidate
|
||||
};
|
||||
}
|
||||
|
||||
function collectExportItems(messages, lastAnswer, lastSources) {
|
||||
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: 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: lastAnswer, sources: lastSources });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderExportRefs(sources, sectionNumber, citedNumbers) {
|
||||
var cited = citedNumbers && citedNumbers.length ? new Set(citedNumbers.map(String)) : null;
|
||||
return (sources || []).filter(function (s, idx) {
|
||||
var n = s.number || idx + 1;
|
||||
return !cited || cited.has(String(n));
|
||||
}).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 extractCitedSourceNumbers(text) {
|
||||
var found = new Set();
|
||||
String(text || '').replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, function (_, nums) {
|
||||
nums.split(',').forEach(function (n) {
|
||||
n = String(n || '').trim();
|
||||
if (n) found.add(n);
|
||||
});
|
||||
return _;
|
||||
});
|
||||
return Array.from(found).sort(function (a, b) { return Number(a) - Number(b); });
|
||||
}
|
||||
|
||||
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 || ''));
|
||||
}
|
||||
|
||||
export 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' : '' });
|
||||
}
|
||||
59
public/js/assistant/images.js
Normal file
59
public/js/assistant/images.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { escapeAttr } from './citations.js';
|
||||
|
||||
export function createAssistantImageStore() {
|
||||
var generatedImages = {};
|
||||
var generatedImageSeq = 0;
|
||||
|
||||
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 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 clear() {
|
||||
generatedImages = {};
|
||||
closeImagePreview();
|
||||
}
|
||||
|
||||
return {
|
||||
renderGeneratedImage: renderGeneratedImage,
|
||||
openImagePreview: openImagePreview,
|
||||
closeImagePreview: closeImagePreview,
|
||||
clear: clear
|
||||
};
|
||||
}
|
||||
|
||||
export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
|
||||
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' + String(lastAnswer || '').slice(0, 3000) + '\n\nSources:\n' + sourceText;
|
||||
}
|
||||
|
||||
export function isImageRequest(text) {
|
||||
text = String(text || '').trim();
|
||||
return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) ||
|
||||
/\b(show|see|display|view|image|photo|picture|visual|illustration|diagram|figure)\b/i.test(text) && text.split(/\s+/).length <= 6 ||
|
||||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
|
||||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
|
||||
}
|
||||
47
public/js/assistant/sources.js
Normal file
47
public/js/assistant/sources.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { escapeHtml } from './citations.js';
|
||||
|
||||
export function renderSourcesList(sources) {
|
||||
if (!sources || sources.length === 0) return '<p class="assistant-muted">No citations returned.</p>';
|
||||
return 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_caption_source) meta.push('caption: ' + s.visual_caption_source);
|
||||
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>' +
|
||||
(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.visual_caption_source && badges.indexOf(source.visual_caption_source) === -1) badges.push(source.visual_caption_source);
|
||||
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 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();
|
||||
}
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
// ============================================================
|
||||
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,
|
||||
|
|
@ -26,13 +29,11 @@ import {
|
|||
var lastSources = [];
|
||||
var mermaidReady = false;
|
||||
var dynamicExamples = [];
|
||||
var generatedImages = {};
|
||||
var generatedImageSeq = 0;
|
||||
var lastGeneratedImageSrc = '';
|
||||
var exportCacheKey = '';
|
||||
var exportCacheItems = null;
|
||||
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();
|
||||
|
|
@ -120,8 +121,7 @@ import {
|
|||
}
|
||||
|
||||
appendMessage('user', text);
|
||||
exportCacheKey = '';
|
||||
exportCacheItems = null;
|
||||
exporter.invalidate();
|
||||
if (input) input.value = '';
|
||||
|
||||
if (isImageRequest(text)) {
|
||||
|
|
@ -384,41 +384,7 @@ import {
|
|||
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_caption_source) meta.push('caption: ' + s.visual_caption_source);
|
||||
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>' +
|
||||
(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.visual_caption_source && badges.indexOf(source.visual_caption_source) === -1) badges.push(source.visual_caption_source);
|
||||
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>';
|
||||
wrap.innerHTML = renderSourcesList(sources);
|
||||
}
|
||||
|
||||
function renderEmbeddedBlocks(root) {
|
||||
|
|
@ -475,12 +441,11 @@ import {
|
|||
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');
|
||||
exporter.invalidate();
|
||||
if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
|
||||
if (fromChat) {
|
||||
setBusy(false, 'Ready');
|
||||
var html = renderGeneratedImage(src, 'Generated clinical visual');
|
||||
var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual');
|
||||
replaceLoadingMessage(loading, html, [], [], true);
|
||||
}
|
||||
})
|
||||
|
|
@ -492,16 +457,6 @@ import {
|
|||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -518,50 +473,25 @@ import {
|
|||
var openBtn = e.target.closest('[data-assistant-open-image]');
|
||||
if (openBtn) {
|
||||
e.preventDefault();
|
||||
openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
|
||||
imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
|
||||
closeImagePreview();
|
||||
imageStore.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 = {};
|
||||
imageStore.clear();
|
||||
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;
|
||||
exporter.invalidate();
|
||||
}
|
||||
|
||||
function prepareSidebarImagePrompt(request) {
|
||||
var promptEl = document.getElementById('assistant-image-prompt');
|
||||
var prompt = buildContextualImagePrompt(request);
|
||||
var prompt = buildContextualImagePrompt(request, lastAnswer, lastSources);
|
||||
if (promptEl) {
|
||||
promptEl.value = prompt;
|
||||
promptEl.focus();
|
||||
|
|
@ -575,8 +505,7 @@ import {
|
|||
lastAnswer = '';
|
||||
lastSources = [];
|
||||
lastGeneratedImageSrc = '';
|
||||
exportCacheKey = '';
|
||||
exportCacheItems = null;
|
||||
exporter.invalidate();
|
||||
var wrap = document.getElementById('assistant-messages');
|
||||
if (wrap) {
|
||||
wrap.innerHTML = renderEmptyState();
|
||||
|
|
@ -633,122 +562,12 @@ import {
|
|||
}
|
||||
|
||||
function exportAnswerPdf() {
|
||||
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
|
||||
var exportItems = collectExportItems();
|
||||
var cacheKey = buildExportCacheKey(exportItems, lastGeneratedImageSrc);
|
||||
var doc = openExportWindow();
|
||||
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
|
||||
if (exportCacheKey === cacheKey && exportCacheItems) {
|
||||
writePrintableChatExport(doc, exportCacheItems, lastGeneratedImageSrc);
|
||||
return;
|
||||
}
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = exportItems;
|
||||
writePrintableChatExport(doc, exportItems, lastGeneratedImageSrc);
|
||||
}
|
||||
|
||||
function openExportWindow() {
|
||||
var doc = window.open('', '_blank', 'width=900,height=1100');
|
||||
if (!doc) return null;
|
||||
doc.document.open();
|
||||
doc.document.write('<!doctype html><html><head><title>Preparing Clinical Assistant Export</title><style>body{font-family:Arial,sans-serif;color:#111827;margin:36px;line-height:1.5}.spinner{width:18px;height:18px;border:3px solid #e5e7eb;border-top-color:#7c3aed;border-radius:50%;display:inline-block;vertical-align:middle;margin-right:8px;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}</style></head><body><p><span class="spinner"></span>Preparing PDF export...</p></body></html>');
|
||||
doc.document.close();
|
||||
return doc;
|
||||
}
|
||||
|
||||
function writePrintableChatExport(doc, items, imageSrc) {
|
||||
if (!doc || doc.closed) 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 heading = item.heading || deriveExportHeading(item.question, idx);
|
||||
var summary = item.summary || '';
|
||||
var answer = item.answer || '';
|
||||
var citedNumbers = extractCitedSourceNumbers([summary, answer].filter(Boolean).join('\n\n'));
|
||||
var refs = renderExportRefs(sources, idx + 1, citedNumbers);
|
||||
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, { citationLabel: 'number' }) + '</div>' : '') +
|
||||
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + renderMarkdown(answer, sources, { citationLabel: 'number' }) + '</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;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.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;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
|
||||
'</head><body><button id="assistant-export-print" type="button" 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();
|
||||
try {
|
||||
var printBtn = doc.document.getElementById('assistant-export-print');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
|
||||
} catch (e) {}
|
||||
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: m.content,
|
||||
sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources
|
||||
});
|
||||
pendingQuestion = '';
|
||||
exporter.exportAnswerPdf({
|
||||
messages: messages,
|
||||
lastAnswer: lastAnswer,
|
||||
lastSources: lastSources,
|
||||
lastGeneratedImageSrc: lastGeneratedImageSrc
|
||||
});
|
||||
if (!items.length && lastAnswer) {
|
||||
items.push({ question: 'Clinical question', heading: 'Clinical Answer', summary: '', answer: lastAnswer, sources: lastSources });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderExportRefs(sources, sectionNumber, citedNumbers) {
|
||||
var cited = citedNumbers && citedNumbers.length ? new Set(citedNumbers.map(String)) : null;
|
||||
return (sources || []).filter(function (s, idx) {
|
||||
var n = s.number || idx + 1;
|
||||
return !cited || cited.has(String(n));
|
||||
}).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 extractCitedSourceNumbers(text) {
|
||||
var found = new Set();
|
||||
String(text || '').replace(/\[(\d+(?:\s*,\s*\d+)*)\]/g, function (_, nums) {
|
||||
nums.split(',').forEach(function (n) {
|
||||
n = String(n || '').trim();
|
||||
if (n) found.add(n);
|
||||
});
|
||||
return _;
|
||||
});
|
||||
return Array.from(found).sort(function (a, b) { return Number(a) - Number(b); });
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -859,7 +678,7 @@ import {
|
|||
}
|
||||
renderSources(lastSources);
|
||||
var out = document.getElementById('assistant-visual-output');
|
||||
if (out) out.innerHTML = lastGeneratedImageSrc ? renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
|
||||
if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : '';
|
||||
}
|
||||
|
||||
function appendMessageNode(role, content, sources) {
|
||||
|
|
@ -884,28 +703,11 @@ import {
|
|||
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 '';
|
||||
|
|
@ -920,14 +722,6 @@ import {
|
|||
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function isImageRequest(text) {
|
||||
text = String(text || '').trim();
|
||||
return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) ||
|
||||
/\b(show|see|display|view|image|photo|picture|visual|illustration|diagram|figure)\b/i.test(text) && text.split(/\s+/).length <= 6 ||
|
||||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
|
||||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
|
||||
}
|
||||
|
||||
function setBusy(isBusy, text, isError) {
|
||||
assistantBusy = !!isBusy;
|
||||
var status = document.getElementById('assistant-status');
|
||||
|
|
|
|||
Loading…
Reference in a new issue