feat: add assistant PDF export

This commit is contained in:
Daniel 2026-05-06 23:29:19 +02:00
parent d50640ad81
commit 605e76b14c
3 changed files with 96 additions and 1 deletions

View file

@ -19,6 +19,7 @@
<div class="assistant-toolbar-actions">
<button id="btn-assistant-clear" class="btn-sm btn-ghost" type="button"><i class="fas fa-rotate-left"></i> Clear</button>
<button id="btn-assistant-copy" class="btn-sm btn-ghost" type="button"><i class="fas fa-copy"></i> Copy answer</button>
<button id="btn-assistant-export-pdf" class="btn-sm btn-ghost" type="button"><i class="fas fa-file-pdf"></i> Export PDF</button>
</div>
</div>

View file

@ -53,12 +53,14 @@
var form = document.getElementById('assistant-form');
var clearBtn = document.getElementById('btn-assistant-clear');
var copyBtn = document.getElementById('btn-assistant-copy');
var exportBtn = document.getElementById('btn-assistant-export-pdf');
var imageBtn = document.getElementById('btn-assistant-image');
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 (exportBtn) exportBtn.addEventListener('click', exportAnswerPdf);
if (imageBtn) imageBtn.addEventListener('click', generateImage);
if (input) input.addEventListener('keydown', function (e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') onAsk(e);
@ -110,7 +112,7 @@
if (input) input.value = '';
if (isImageRequest(text)) {
generateImage(text, true);
prepareSidebarImagePrompt(text);
return;
}
@ -378,6 +380,26 @@
});
}
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 = '';
@ -435,6 +457,45 @@
});
}
function exportAnswerPdf() {
if (!lastAnswer) { if (typeof showToast === 'function') showToast('No answer to export', 'error'); return; }
setBusy(true, 'Preparing PDF...');
fetch('/api/clinical-assistant/export-summary', {
method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin',
body: JSON.stringify({ answer: lastAnswer, sources: lastSources })
})
.then(function (r) { return r.json().then(function (data) { data._status = r.status; return data; }); })
.then(function (data) {
setBusy(false, 'Ready');
openPrintableExport(data.success && data.summary ? data.summary : lastAnswer, lastSources);
})
.catch(function () {
setBusy(false, 'Ready');
openPrintableExport(lastAnswer, lastSources);
});
}
function openPrintableExport(answer, sources) {
var doc = window.open('', '_blank', 'width=900,height=1100');
if (!doc) { if (typeof showToast === 'function') showToast('Allow popups to export PDF', 'error'); return; }
var refs = (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-' + n + '"><strong>[' + n + ']</strong> ' + escapeHtml(title) + (page ? ', page ' + escapeHtml(page) : '') + '.</li>';
}).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:16px;margin-top:24px;border-bottom:1px solid #e5e7eb;padding-bottom:4px}.meta{font-size:12px;color:#6b7280;margin-bottom:20px}.answer{font-size:13px}.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">Abridged export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
'<h2>Summary</h2><div class="answer">' + renderMarkdown(answer, sources || []) + '</div>' +
'<h2>References</h2><ol class="refs">' + refs + '</ol></body></html>';
doc.document.open();
doc.document.write(html);
doc.document.close();
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
}
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);

View file

@ -218,6 +218,39 @@ router.post('/clinical-assistant/image', async function(req, res) {
}
});
router.post('/clinical-assistant/export-summary', async function(req, res) {
try {
var answer = String(req.body.answer || '').trim();
var sources = Array.isArray(req.body.sources) ? req.body.sources.slice(0, 20) : [];
if (!answer) return res.status(400).json({ error: 'Answer is required' });
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var sourceList = sources.map(function(s, idx) {
var n = s.number || idx + 1;
return '[' + n + '] ' + cleanTitle(s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
var ai = await callAI([
{
role: 'system',
content: 'Create an abridged clinical export summary. Preserve factual meaning. Use only citation numbers already present in the original answer and provided source list. Do not invent, renumber, merge, or move citations. If a claim cannot keep its citation, omit the claim. No references section.'
},
{
role: 'user',
content: 'Original answer:\n' + answer.substring(0, 8000) + '\n\nAvailable cited sources:\n' + sourceList + '\n\nReturn a concise export version with the same citation numbers where supported.'
}
], {
model: chatModel || undefined,
temperature: 0.05,
maxTokens: 900
});
var summary = stripModelSourcesSection(String(ai.content || '').trim());
res.json({ success: true, summary: summary || answer, model: ai.model || chatModel || null });
} catch (e) {
res.status(500).json({ error: assistantErrorMessage(e) });
}
});
async function semanticSearch(query, opts) {
var session = await mcpRequest({
jsonrpc: '2.0',