From 2ca4db1e8deca5bae9c172afbe64d25d987b3263 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 6 May 2026 08:02:25 +0200 Subject: [PATCH] fix: improve clinical assistant rendering --- public/components/assistant.html | 30 +++++++---- public/index.html | 3 ++ public/js/clinicalAssistant.js | 88 +++++++++++++++++++++++++------- src/routes/clinicalAssistant.js | 19 +++---- 4 files changed, 98 insertions(+), 42 deletions(-) diff --git a/public/components/assistant.html b/public/components/assistant.html index f7fc179..c8d3ad4 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,7 +1,7 @@

AI Clinical Assistant

-

Ask focused clinical questions over indexed Nextcloud books/resources. Answers should synthesize retrieved evidence and cite book/resource titles with pages.

+

Ask focused clinical questions over indexed Nextcloud books/resources, or ask for a teaching image directly in the chat box.

@@ -26,7 +26,7 @@

Evidence-first pediatric assistant

-

Ask a real clinical question. Short greetings or vague inputs get a short clarifying response instead of unnecessary textbook output.

+

Ask a real clinical question. Citations stay linked to the source cards on the right.

@@ -60,14 +60,6 @@

Citations appear here after an answer.

- -
-

Behavior

-
-

Answers should be concise, clinically practical, and citation-grounded.

-

If the input is only a greeting or too vague, the assistant should ask what you want to look up.

-
-
@@ -95,11 +87,23 @@ .assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; } .assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; } .assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); } +.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); } +.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; } +.assistant-bubble h1 { font-size:20px; } +.assistant-bubble h2 { font-size:17px; border-bottom:1px solid var(--g200); padding-bottom:4px; } +.assistant-bubble h3 { font-size:15px; } .assistant-bubble p { margin:0 0 10px; } .assistant-bubble p:last-child { margin-bottom:0; } .assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; } +.assistant-bubble li { margin:4px 0; } +.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; } +.assistant-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; } +.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; } +.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); } +.assistant-bubble tr:last-child td { border-bottom:0; } .assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; } .assistant-bubble pre { background:var(--g900); color:white; border-radius:8px; padding:10px; overflow:auto; margin:10px 0; } +.assistant-bubble .katex-display { overflow-x:auto; overflow-y:hidden; padding:4px 0; } .assistant-cite { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; background:var(--purple-light); color:var(--purple); font-size:11px; font-weight:700; text-decoration:none; } .assistant-composer { border-top:1px solid var(--g200); padding:12px; background:white; display:grid; gap:8px; } .assistant-composer textarea, .assistant-side textarea { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:10px 12px; resize:vertical; font-family:inherit; font-size:13px; outline:none; } @@ -111,10 +115,14 @@ .assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; } .assistant-visual-output { display:grid; gap:8px; } .assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; } -.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:360px; overflow-y:auto; } +.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; } .assistant-source { border:1px solid var(--g200); border-radius:10px; padding:9px; background:white; font-size:12px; line-height:1.5; } .assistant-source strong { color:var(--g800); } .assistant-source-meta { color:var(--g500); font-size:11px; margin-top:3px; } +.assistant-source-excerpt { margin-top:7px; color:var(--g600); max-height:170px; overflow:auto; } +.assistant-source-excerpt p { margin:0 0 6px; } +.assistant-source-excerpt ul, .assistant-source-excerpt ol { padding-left:16px; margin:4px 0; } +.assistant-source-excerpt strong { color:var(--g700); } .assistant-muted { color:var(--g500); font-size:12px; line-height:1.6; } .assistant-mermaid { background:white; border:1px solid var(--g200); border-radius:10px; padding:10px; margin:10px 0; overflow:auto; } @media (max-width: 960px) { .assistant-layout { grid-template-columns:1fr; } .assistant-main { min-height:auto; grid-template-rows:auto minmax(320px,1fr) auto; } } diff --git a/public/index.html b/public/index.html index 13df0a1..122834d 100644 --- a/public/index.html +++ b/public/index.html @@ -15,6 +15,9 @@ + + + diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 70c6d60..c1485af 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -77,6 +77,12 @@ appendMessage('user', text); if (input) input.value = ''; + + if (isImageRequest(text)) { + generateImage(text, true); + return; + } + setBusy(true, 'Searching indexed resources...'); fetch('/api/clinical-assistant/chat', { @@ -110,7 +116,7 @@ }); } - function appendMessage(role, content, sources, suggestions) { + function appendMessage(role, content, sources, suggestions, rawHtml) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return; var empty = wrap.querySelector('.assistant-empty'); @@ -124,7 +130,7 @@ label.textContent = role === 'user' ? 'You' : 'Assistant'; var bubble = document.createElement('div'); bubble.className = 'assistant-bubble'; - bubble.innerHTML = role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content); + bubble.innerHTML = rawHtml ? sanitize(String(content || '')) : (role === 'assistant' ? renderMarkdown(content, sources || []) : escapeHtml(content)); if (role === 'assistant' && suggestions && suggestions.length) { bubble.appendChild(renderSuggestionButtons(suggestions)); } @@ -160,18 +166,37 @@ codeBlocks.push({ lang: (lang || '').toLowerCase(), code: code }); return '\n@@CODEBLOCK_' + idx + '@@\n'; }); + text = stripSourcesSection(text); + text = renderLatexText(text); + var html; + if (window.marked && typeof window.marked.parse === 'function') { + html = window.marked.parse(text, { breaks: true, gfm: true }); + } else { + html = fallbackMarkdown(text); + } + html = html.replace(/\[(\d+)\]/g, function (_, n) { + var source = sources[Number(n) - 1]; + if (!source) return '[' + n + ']'; + var title = source ? source.title || source.resource || 'Source' : 'Source'; + return '[' + n + ']'; + }); + html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { + var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; + if (block.lang === 'mermaid') return '
Rendering graph...
'; + if (block.lang === 'chart' || block.lang === 'chartjs') return ''; + return '
' + escapeHtml(block.code) + '
'; + }); + return sanitize(html); + } + + function fallbackMarkdown(text) { var html = escapeHtml(text) .replace(/^### (.*)$/gm, '

$1

') .replace(/^## (.*)$/gm, '

$1

') .replace(/^# (.*)$/gm, '

$1

') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') - .replace(/`([^`]+)`/g, '$1') - .replace(/\[(\d+)\]/g, function (_, n) { - var source = sources[Number(n) - 1]; - var title = source ? source.title || source.resource || 'Source' : 'Source'; - return '[' + n + ']'; - }); + .replace(/`([^`]+)`/g, '$1'); html = html.split(/\n{2,}/).map(function (block) { if (/^\s*<(h\d|ul|ol|pre|div)/.test(block)) return block; var lines = block.split('\n'); @@ -183,13 +208,7 @@ } return '

' + block.replace(/\n/g, '
') + '

'; }).join(''); - html = html.replace(/@@CODEBLOCK_(\d+)@@/g, function (_, idx) { - var block = codeBlocks[Number(idx)] || { lang: '', code: '' }; - if (block.lang === 'mermaid') return '
Rendering graph...
'; - if (block.lang === 'chart' || block.lang === 'chartjs') return ''; - return '
' + escapeHtml(block.code) + '
'; - }); - return sanitize(html); + return html; } function renderSources(sources) { @@ -209,7 +228,7 @@ return '
' + '[' + n + '] ' + escapeHtml(s.title || s.resource || 'Untitled source') + '' + '
' + escapeHtml(meta.join(' · ') || 'indexed source') + '
' + - (s.excerpt ? '
' + escapeHtml(s.excerpt).slice(0, 420) + '
' : '') + + (s.excerpt ? '
' + renderMarkdown(String(s.excerpt).slice(0, 900), []) + '
' : '') + '
'; }).join(''); } @@ -253,12 +272,13 @@ mermaidReady = true; } - function generateImage() { + function generateImage(promptOverride, fromChat) { var promptEl = document.getElementById('assistant-image-prompt'); var out = document.getElementById('assistant-visual-output'); - var prompt = promptEl ? promptEl.value.trim() : ''; + 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...'); if (out) out.innerHTML = '

Generating image...

'; fetch('/api/clinical-assistant/image', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin', @@ -270,9 +290,16 @@ var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : ''); if (!src) throw new Error('No image returned'); if (out) out.innerHTML = 'Generated clinical visual Open'; + if (fromChat) { + setBusy(false, 'Ready'); + var html = ''; + appendMessage('assistant', html, [], [], true); + } }) .catch(function (err) { + if (fromChat) setBusy(false, 'Error', true); if (out) out.innerHTML = '

' + escapeHtml(err.message) + '

'; + if (fromChat) appendMessage('assistant', 'Image generation failed: ' + err.message); if (typeof showToast === 'function') showToast(err.message, 'error'); }); } @@ -295,6 +322,29 @@ }); } + 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 stripSourcesSection(text) { + return String(text || '').replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '').trim(); + } + + function renderLatexText(text) { + if (!window.katex) return text; + return String(text || '') + .replace(/\$\$([\s\S]+?)\$\$/g, function(_, expr) { return safeKatex(expr, true); }) + .replace(/\\\[([\s\S]+?)\\\]/g, function(_, expr) { return safeKatex(expr, true); }) + .replace(/\$([^$\n]+?)\$/g, function(_, expr) { return safeKatex(expr, false); }) + .replace(/\\\((.+?)\\\)/g, function(_, expr) { return safeKatex(expr, false); }); + } + + function safeKatex(expr, displayMode) { + try { return window.katex.renderToString(expr, { displayMode: displayMode, throwOnError: false }); } + catch (e) { return escapeHtml(expr); } + } + function setBusy(isBusy, text, isError) { var status = document.getElementById('assistant-status'); var label = document.getElementById('assistant-status-text'); @@ -312,5 +362,5 @@ function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function escapeAttr(s) { return escapeHtml(s).replace(/'/g, '''); } - function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html) : html; } + function sanitize(html) { return window.DOMPurify ? window.DOMPurify.sanitize(html, { ADD_ATTR: ['target'] }) : html; } })(); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 76c7fa3..ca44f62 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -150,7 +150,7 @@ router.post('/clinical-assistant/chat', async function(req, res) { temperature: 0.15, maxTokens: 1800 }); - var answer = ensureSourcesSection(String(ai.content || '').trim(), sources); + var answer = stripModelSourcesSection(String(ai.content || '').trim()); logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, { category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started @@ -379,7 +379,7 @@ function formatSourcesForPrompt(sources) { } function buildSystemPrompt(behavior) { - return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Include a final "Sources" section listing cited source numbers with title and page.\n- This is clinical decision support, not a substitute for clinician judgment.'; + return behavior + '\n\nRules:\n- Answer only the user question; do not dump unrelated textbook content.\n- Use numbered citations [1], [2] for factual claims, matching the provided source numbers.\n- If sources disagree or are insufficient, say so.\n- Do not cite a source number that is not provided.\n- Keep the main answer concise and clinically useful.\n- Use clear markdown with headings, bullets, and tables when useful.\n- Do not add a final Sources or References section; the UI displays all retrieved sources separately.\n- Do not add generic disclaimers about clinician judgment.'; } function buildUserPrompt(question, context, history) { @@ -388,16 +388,11 @@ function buildUserPrompt(question, context, history) { return 'Question:\n' + question + '\n\nRecent conversation, if relevant:\n' + (hist || 'None') + '\n\nRetrieved indexed sources:\n' + context + '\n\nWrite the answer now.'; } -function ensureSourcesSection(answer, sources) { - if (/\n\s*#{0,3}\s*Sources\b/i.test(answer)) return answer; - var cited = new Set(); - var m; - var re = /\[(\d+)\]/g; - while ((m = re.exec(answer))) cited.add(Number(m[1])); - var list = sources.filter(function(s) { return cited.size === 0 ? s.number <= Math.min(5, sources.length) : cited.has(s.number); }) - .map(function(s) { return '[' + s.number + '] ' + s.title + (s.page ? ', p. ' + s.page : ''); }) - .join('\n'); - return answer + '\n\nSources\n' + list; +function stripModelSourcesSection(answer) { + return String(answer || '') + .replace(/\n\s*(---\s*)?(#{1,3}\s*)?(Sources|References)\s*\n[\s\S]*$/i, '') + .replace(/\n\s*>?\s*(⚠️\s*)?Clinical decision support[^\n]*$/gim, '') + .trim(); } async function generateImage(prompt, model) {