diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js
index 926c402..74aee4e 100644
--- a/public/js/clinicalAssistant.js
+++ b/public/js/clinicalAssistant.js
@@ -21,6 +21,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var exportCacheKey = '';
var exportCacheItems = null;
var markdownRenderer = null;
+ var assistantBusy = false;
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'assistant') initIfNeeded();
@@ -100,6 +101,10 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var input = document.getElementById('assistant-input');
var includeContext = document.getElementById('assistant-include-context');
var text = input ? input.value.trim() : '';
+ if (assistantBusy) {
+ if (typeof showToast === 'function') showToast('Assistant is still finishing the current answer', 'error');
+ return;
+ }
if (!text) {
if (typeof showToast === 'function') showToast('Enter a clinical question', 'error');
return;
@@ -202,6 +207,10 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
var tail = parseSseEvent(buffer);
if (tail) handleEvent(tail.type, tail.data);
}
+ if (!doneData) {
+ updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...');
+ doneData = await fetchAssistantFallback(payload);
+ }
setBusy(false, 'Ready');
var answer = (doneData && (doneData.answer || doneData.markdown)) || partial;
@@ -215,6 +224,18 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
}
}
+ async function fetchAssistantFallback(payload) {
+ var response = await fetch('/api/clinical-assistant/chat', {
+ method: 'POST',
+ headers: getAuthHeaders(),
+ credentials: 'same-origin',
+ body: JSON.stringify(payload)
+ });
+ var data = await response.json().catch(function () { return {}; });
+ if (!response.ok || !data.success) throw new Error(data.error || ('Request failed (' + response.status + ')'));
+ return data;
+ }
+
function parseSseEvent(block) {
var type = 'message';
var data = '';
@@ -899,9 +920,11 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
}
function setBusy(isBusy, text, isError) {
+ assistantBusy = !!isBusy;
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
+ var input = document.getElementById('assistant-input');
if (status) {
status.classList.toggle('busy', !!isBusy);
status.classList.toggle('error', !!isError);
@@ -911,6 +934,7 @@ import { escapeAttr, escapeHtml, renderAssistantMarkdown, stripSourcesSection }
send.disabled = !!isBusy;
send.innerHTML = isBusy ? ' Searching' : ' Ask';
}
+ if (input) input.disabled = !!isBusy;
}
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 3c6351c..7646a06 100644
--- a/src/routes/clinicalAssistant.js
+++ b/src/routes/clinicalAssistant.js
@@ -241,6 +241,18 @@ router.post('/clinical-assistant/chat', async function(req, res) {
maxTokens: 2600
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
+ if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
+ console.warn('[clinical-assistant] non-stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
+ var completed = await callAI(prepared.messages, {
+ model: prepared.chatModel || undefined,
+ temperature: 0.15,
+ maxTokens: 5000
+ });
+ answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
+ ai.model = completed.model || ai.model;
+ ai.provider = completed.provider || ai.provider;
+ ai.finishReason = completed.finishReason || ai.finishReason;
+ }
logger.audit(req.user.id, 'clinical_assistant_query', 'Clinical assistant query', req, {
category: 'clinical', model: ai.model || prepared.chatModel, duration: Date.now() - started
@@ -294,11 +306,12 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
});
var answer = stripModelSourcesSection(String(ai.content || '').trim());
if (shouldRegenerateTruncatedAnswer(answer, ai.finishReason)) {
+ console.warn('[clinical-assistant] stream answer looked truncated; regenerating', { finishReason: ai.finishReason, chars: answer.length });
sendEvent('status', { message: 'Completing answer...' });
var completed = await callAI(prepared.messages, {
model: prepared.chatModel || undefined,
temperature: 0.15,
- maxTokens: 3200
+ maxTokens: 5000
});
answer = stripModelSourcesSection(String(completed.content || '').trim()) || answer;
ai.model = completed.model || ai.model;
diff --git a/src/utils/ai.js b/src/utils/ai.js
index d6bf8a4..d38589e 100644
--- a/src/utils/ai.js
+++ b/src/utils/ai.js
@@ -167,7 +167,8 @@ async function callOpenRouter(messages, model, temperature, maxTokens) {
content: completion.choices[0].message.content,
model: model,
provider: 'openrouter',
- usage: completion.usage || null
+ usage: completion.usage || null,
+ finishReason: completion.choices[0].finish_reason || null
};
}
@@ -189,7 +190,8 @@ async function callAzure(messages, model, temperature, maxTokens) {
content: completion.choices[0].message.content,
model: process.env.AZURE_DEPLOYMENT_NAME || model,
provider: 'azure',
- usage: completion.usage || null
+ usage: completion.usage || null,
+ finishReason: completion.choices[0].finish_reason || null
};
}
@@ -391,7 +393,8 @@ async function callLiteLLM(messages, model, temperature, maxTokens) {
content: completion.choices[0].message.content,
model: model,
provider: 'litellm',
- usage: completion.usage || null
+ usage: completion.usage || null,
+ finishReason: completion.choices[0].finish_reason || null
};
}