fix: show assistant progress and order citations

This commit is contained in:
Daniel 2026-05-06 08:19:29 +02:00
parent f485afa282
commit 1e937cf1f8
3 changed files with 86 additions and 5 deletions

View file

@ -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; }

View file

@ -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 = '<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 });
}
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 = '<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',
@ -293,13 +333,13 @@
if (fromChat) {
setBusy(false, 'Ready');
var html = '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" target="_blank" rel="noopener"><i class="fas fa-up-right-from-square"></i> Open image</a></div>';
appendMessage('assistant', html, [], [], true);
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) appendMessage('assistant', 'Image generation failed: ' + err.message);
if (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message);
if (typeof showToast === 'function') showToast(err.message, 'error');
});
}

View file

@ -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' };