fix: show assistant progress and order citations
This commit is contained in:
parent
f485afa282
commit
1e937cf1f8
3 changed files with 86 additions and 5 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
Loading…
Reference in a new issue