From 1e937cf1f8491c1e309768af1f70ea5dca3117ff Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 6 May 2026 08:19:29 +0200 Subject: [PATCH] fix: show assistant progress and order citations --- public/components/assistant.html | 8 +++++ public/js/clinicalAssistant.js | 50 ++++++++++++++++++++++++++++---- src/routes/clinicalAssistant.js | 33 +++++++++++++++++++++ 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/public/components/assistant.html b/public/components/assistant.html index c8d3ad4..cd43d42 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -104,6 +104,14 @@ .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-thinking { background:linear-gradient(90deg,#fff,#f8fafc,#fff); background-size:220% 100%; animation:assistantShimmer 1.8s ease-in-out infinite; } +.assistant-thinking-line { display:flex; align-items:center; gap:6px; color:var(--g800); } +.assistant-thinking-detail { color:var(--g500); font-size:12px; margin-top:3px; } +.assistant-thinking-dot { width:7px; height:7px; border-radius:50%; background:var(--purple); display:inline-block; animation:assistantBounce 1.2s infinite ease-in-out; } +.assistant-thinking-dot:nth-child(2) { animation-delay:.15s; } +.assistant-thinking-dot:nth-child(3) { animation-delay:.3s; margin-right:3px; } +@keyframes assistantBounce { 0%,80%,100% { transform:scale(.65); opacity:.45; } 40% { transform:scale(1); opacity:1; } } +@keyframes assistantShimmer { 0% { background-position:100% 0; } 100% { background-position:-100% 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; } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index c1485af..966abcc 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -84,6 +84,7 @@ } setBusy(true, 'Searching indexed resources...'); + var loading = appendLoadingMessage('Searching indexed resources', 'Retrieving and synthesizing cited pediatric references...'); fetch('/api/clinical-assistant/chat', { method: 'POST', @@ -102,7 +103,7 @@ var answer = data.answer || data.markdown || ''; lastAnswer = answer; lastSources = data.sources || data.citations || []; - appendMessage('assistant', answer, lastSources, data.suggestions || []); + replaceLoadingMessage(loading, answer, lastSources, data.suggestions || []); renderSources(lastSources); if (data.model) { var label = document.getElementById('assistant-model-label'); @@ -111,11 +112,49 @@ }) .catch(function (err) { setBusy(false, 'Error', true); - appendMessage('assistant', '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.'); + 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 = '
' + escapeHtml(title || 'Working') + '
' + + '
' + escapeHtml(detail || 'Preparing response...') + '
'; + 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 }); + } + function appendMessage(role, content, sources, suggestions, rawHtml) { var wrap = document.getElementById('assistant-messages'); if (!wrap) return; @@ -219,7 +258,7 @@ return; } wrap.innerHTML = sources.map(function (s, idx) { - var n = idx + 1; + var n = s.number || idx + 1; var page = s.page || s.page_number || s.pageNumber; var meta = []; if (page) meta.push('page ' + page); @@ -279,6 +318,7 @@ 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 = '

Generating image...

'; fetch('/api/clinical-assistant/image', { method: 'POST', headers: getAuthHeaders(), credentials: 'same-origin', @@ -293,13 +333,13 @@ if (fromChat) { setBusy(false, 'Ready'); var html = '
Generated clinical visual Open image
'; - appendMessage('assistant', html, [], [], true); + replaceLoadingMessage(loading, 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 (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message); if (typeof showToast === 'function') showToast(err.message, 'error'); }); } diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index ca44f62..63c9fae 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -151,6 +151,9 @@ router.post('/clinical-assistant/chat', async function(req, res) { maxTokens: 1800 }); var answer = stripModelSourcesSection(String(ai.content || '').trim()); + var normalized = normalizeCitationOrder(answer, sources); + answer = normalized.answer; + sources = normalized.sources; logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, { category: 'clinical', model: ai.model || chatModel, duration: Date.now() - started @@ -395,6 +398,36 @@ function stripModelSourcesSection(answer) { .trim(); } +function normalizeCitationOrder(answer, sources) { + sources = Array.isArray(sources) ? sources : []; + var used = []; + var oldToNew = {}; + var re = /\[(\d+)\]/g; + var m; + while ((m = re.exec(answer))) { + var oldNum = Number(m[1]); + if (!sources[oldNum - 1] || oldToNew[oldNum]) continue; + oldToNew[oldNum] = used.length + 1; + used.push(oldNum); + } + + var reordered = []; + used.forEach(function(oldNum) { reordered.push(sources[oldNum - 1]); }); + sources.forEach(function(source, idx) { + if (used.indexOf(idx + 1) === -1) reordered.push(source); + }); + reordered = reordered.map(function(source, idx) { + return Object.assign({}, source, { number: idx + 1 }); + }); + + var rewritten = answer.replace(/\[(\d+)\]/g, function(match, n) { + var oldNum = Number(n); + return oldToNew[oldNum] ? '[' + oldToNew[oldNum] + ']' : match; + }); + + return { answer: rewritten, sources: reordered }; +} + async function generateImage(prompt, model) { if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation'); var headers = { 'Content-Type': 'application/json' };