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