diff --git a/docker-compose.yml b/docker-compose.yml index cb86be6..3e14727 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: LITELLM_TTS_MODEL: local-kokoro-tts LITELLM_TTS_VOICE: sherpa/kokoro:am_adam LITELLM_TTS_VOICES: sherpa/kokoro:am_adam,sherpa/kokoro:am_michael,sherpa/kokoro:af_bella,sherpa/kokoro:af_nicole,sherpa/kokoro:bf_emma,sherpa/kokoro:bm_lewis + CLINICAL_ASSISTANT_PROMPT_POOL_TARGET: 200 volumes: - scribe-logs:/app/data/logs - clinical-assistant-mcp-data:/app/mcp-data:ro diff --git a/package-lock.json b/package-lock.json index 76ad8ac..11f67ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "dependencies": { "@marp-team/marp-cli": "^4.3.1", "@marp-team/marp-core": "^4.3.0", diff --git a/package.json b/package.json index e06b2c7..d93009d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pediatric-ai-scribe", - "version": "7.14.3", + "version": "7.14.4", "description": "AI-powered pediatric clinical documentation platform", "main": "server.js", "scripts": { diff --git a/public/components/assistant.html b/public/components/assistant.html index 53f6638..8c17933 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -41,6 +41,7 @@
diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js index bd5d7cf..b9d6a84 100644 --- a/public/js/assistant/api.js +++ b/public/js/assistant/api.js @@ -19,20 +19,24 @@ export function fetchAssistantExamples() { .then(function(r) { return r.json(); }); } -export function openAssistantStream(payload) { +export function openAssistantStream(payload, options) { + options = options || {}; return fetch('/api/clinical-assistant/chat/stream', { method: 'POST', headers: authHeaders(), credentials: 'same-origin', + signal: options.signal, body: JSON.stringify(payload) }); } -export function fetchAssistantChat(payload) { +export function fetchAssistantChat(payload, options) { + options = options || {}; return fetch('/api/clinical-assistant/chat', { method: 'POST', headers: authHeaders(), credentials: 'same-origin', + signal: options.signal, body: JSON.stringify(payload) }).then(parseJsonWithStatus); } diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index fdee74d..bc9fcb0 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -29,6 +29,8 @@ import { var lastGeneratedImageSrc = ''; var markdownRenderer = null; var assistantBusy = false; + var activeAssistantRequest = null; + var STREAM_MARKDOWN_LIMIT = 3500; var exporter = createAssistantExporter({ renderMarkdown: renderMarkdown, showToast: window.showToast }); var imageStore = createAssistantImageStore(); @@ -49,6 +51,7 @@ import { function bindEvents() { var form = document.getElementById('assistant-form'); var clearBtn = document.getElementById('btn-assistant-clear'); + var cancelBtn = document.getElementById('btn-assistant-cancel'); var copyBtn = document.getElementById('btn-assistant-copy'); var saveBtn = document.getElementById('btn-assistant-save'); var saveConfirmBtn = document.getElementById('btn-assistant-save-confirm'); @@ -60,6 +63,7 @@ import { if (form) form.addEventListener('submit', onAsk); if (clearBtn) clearBtn.addEventListener('click', clearConversation); + if (cancelBtn) cancelBtn.addEventListener('click', cancelAssistantSearch); if (copyBtn) copyBtn.addEventListener('click', copyLastAnswer); if (saveBtn) saveBtn.addEventListener('click', showSavePanel); if (saveConfirmBtn) saveConfirmBtn.addEventListener('click', saveCurrentChat); @@ -128,19 +132,38 @@ import { setBusy(true, 'Looking up sources...'); var loading = appendLoadingMessage('Looking up sources', 'Retrieving and synthesizing references...'); + var request = createAssistantRequest(); + request.loading = loading; + activeAssistantRequest = request; streamAssistantResponse({ message: text, history: messages.slice(-8), includeContext: !includeContext || includeContext.checked - }, loading) + }, loading, request) .catch(function (err) { + if (request.cancelled) return; setBusy(false, 'Error', true); replaceLoadingMessage(loading, 'I could not complete the assistant request. ' + err.message + '\n\nThe indexed search service may be busy or temporarily unavailable. Please try again in a moment.'); if (typeof showToast === 'function') showToast(err.message, 'error'); + }) + .finally(function () { + if (activeAssistantRequest === request) activeAssistantRequest = null; }); } + function createAssistantRequest() { + var controller = typeof AbortController === 'function' ? new AbortController() : null; + return { + cancelled: false, + signal: controller ? controller.signal : undefined, + abort: function () { + this.cancelled = true; + if (controller) controller.abort(); + } + }; + } + async function fetchAssistantResponse(payload, loading) { updateLoadingMessage(loading, 'Looking up sources...'); var data = await fetchAssistantFallback(payload); @@ -155,8 +178,8 @@ import { } } - async function streamAssistantResponse(payload, loading) { - var response = await openAssistantStream(payload); + async function streamAssistantResponse(payload, loading, request) { + var response = await openAssistantStream(payload, { signal: request ? request.signal : undefined }); if (!response.ok || !response.body) { var fallback = await response.json().catch(function () { return {}; }); throw new Error(fallback.error || ('Request failed (' + response.status + ')')); @@ -177,7 +200,7 @@ import { if (!bubble) return; loading.classList.remove('assistant-loading-msg'); bubble.classList.remove('assistant-thinking'); - bubble.innerHTML = partial ? renderAssistantBubbleHtml(partial, streamSources, false) : 'Generating answer...
'; + bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : 'Generating answer...
'; renderEmbeddedBlocks(bubble); var wrap = document.getElementById('assistant-messages'); if (wrap) wrap.scrollTop = wrap.scrollHeight; @@ -207,6 +230,7 @@ import { var reader = response.body.getReader(); while (true) { + if (request && request.cancelled) return; var chunk = await reader.read(); if (chunk.done) break; buffer += decoder.decode(chunk.value, { stream: true }); @@ -224,9 +248,11 @@ import { if (!finalData) { updateLoadingMessage(loading, 'Stream ended early. Retrying without streaming...'); - finalData = await fetchAssistantFallback(payload); + finalData = await fetchAssistantFallback(payload, request); } + if (request && request.cancelled) return; + setBusy(false, 'Ready'); lastAnswer = finalData.answer || finalData.markdown || ''; lastSources = finalData.sources || finalData.citations || streamSources; @@ -238,6 +264,20 @@ import { } } + function renderStreamingAnswerHtml(text, sources) { + if (shouldUseLightweightStreamingRender(text)) { + return '' + escapeHtml(text) + ''; + } + return renderAssistantBubbleHtml(text, sources, false); + } + + function shouldUseLightweightStreamingRender(text) { + text = String(text || ''); + if (text.length > STREAM_MARKDOWN_LIMIT) return true; + var pipeRows = text.split('\n').filter(function (line) { return /^\s*\|.*\|\s*$/.test(line); }).length; + return pipeRows >= 8; + } + function parseSseEvent(block) { var type = 'message'; var data = ''; @@ -250,8 +290,15 @@ import { catch (e) { return null; } } - async function fetchAssistantFallback(payload) { - var data = await fetchAssistantChat(payload); + async function fetchAssistantFallback(payload, request) { + var data; + try { + data = await fetchAssistantChat(payload, { signal: request ? request.signal : undefined }); + } catch (e) { + if (request && request.cancelled) throw e; + if (e && e.name === 'AbortError') throw new Error('Assistant request cancelled.'); + throw e; + } if (!data.success) throw new Error(data.error || ('Request failed (' + data._status + ')')); return data; } @@ -547,6 +594,17 @@ import { loadSavedChats(); } + function cancelAssistantSearch() { + if (!activeAssistantRequest) return; + var request = activeAssistantRequest; + request.abort(); + activeAssistantRequest = null; + setBusy(false, 'Ready'); + if (request.loading && request.loading.parentNode) { + replaceLoadingMessage(request.loading, 'Search cancelled.'); + } + } + function renderEmptyState() { var examples = pickExamples(); return '