fix mobile assistant table streaming
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled

This commit is contained in:
Daniel 2026-05-11 16:15:45 +02:00
parent e1266c6d38
commit 97ddd87449
9 changed files with 105 additions and 18 deletions

View file

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

4
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -41,6 +41,7 @@
<textarea id="assistant-input" rows="3" placeholder="Ask a focused clinical question..." autocomplete="off"></textarea>
<div class="assistant-composer-footer">
<label class="assistant-check"><input type="checkbox" id="assistant-include-context" checked> retrieve broader context</label>
<button id="btn-assistant-cancel" class="btn-sm btn-ghost" type="button" hidden><i class="fas fa-stop"></i> Cancel search</button>
<button id="btn-assistant-send" class="btn-generate" type="submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div>
</form>

View file

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

View file

@ -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) : '<p class="assistant-muted">Generating answer...</p>';
bubble.innerHTML = partial ? renderStreamingAnswerHtml(partial, streamSources) : '<p class="assistant-muted">Generating answer...</p>';
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 '<pre class="assistant-streaming-text">' + escapeHtml(text) + '</pre>';
}
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 '<div class="assistant-empty"><i class="fas fa-book-medical"></i>' +
@ -758,6 +816,7 @@ import {
var status = document.getElementById('assistant-status');
var label = document.getElementById('assistant-status-text');
var send = document.getElementById('btn-assistant-send');
var cancel = document.getElementById('btn-assistant-cancel');
var input = document.getElementById('assistant-input');
if (status) {
status.classList.toggle('busy', !!isBusy);
@ -768,6 +827,10 @@ import {
send.disabled = !!isBusy;
send.innerHTML = isBusy ? '<i class="fas fa-spinner fa-spin"></i> Searching' : '<i class="fas fa-paper-plane"></i> Ask';
}
if (cancel) {
cancel.hidden = !isBusy;
cancel.disabled = !isBusy;
}
if (input) input.disabled = !!isBusy;
}

View file

@ -73,9 +73,9 @@ function createClinicalPromptPool(opts) {
if (!snippets.length) return fallbackIndexedExamples();
var chatModel = await opts.getSetting('clinical_assistant.prompt_model', '') || process.env.CLINICAL_ASSISTANT_PROMPT_MODEL || await opts.getSetting('clinical_assistant.chat_model', '') || await opts.getSetting('models.default', '');
var generated = [];
var batches = Math.max(1, Math.min(20, Math.ceil(target / 25)));
var batches = Math.max(1, Math.min(40, Math.ceil(target / 25)));
for (var i = 0; i < batches && generated.length < target; i++) {
var batchSnippets = rotateExamples(snippets, Date.now() + i * 997).slice(0, 24);
var batchSnippets = rotateExamples(snippets, i * 24).slice(0, 24);
var sourceText = batchSnippets.map(function(s, idx) {
return '[' + (idx + 1) + '] ' + s.title + (s.page ? ', page ' + s.page : '') + '\n' + clip(s.excerpt, 650);
}).join('\n\n');
@ -142,7 +142,7 @@ function createClinicalPromptPool(opts) {
var out = [];
(Array.isArray(items) ? items : []).forEach(function(item) {
var prompt = clip(String(item.prompt || item.question || '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim(), 220);
if (!isUsefulQuestion(prompt)) return;
if (!isUsefulQuestion(prompt, item)) return;
var key = prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
if (seen.has(key)) return;
seen.add(key);
@ -156,13 +156,20 @@ function createClinicalPromptPool(opts) {
return out.slice(0, target);
}
function isUsefulQuestion(prompt) {
function isUsefulQuestion(prompt, item) {
if (!prompt || prompt.length < 25 || prompt.length > 220) return false;
if (prompt.indexOf('?') === -1) return false;
if (!/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return false;
if (!hasPediatricSignal(prompt, item)) return false;
return !/\b(source|snippet|textbook|chapter|document|database)\b/i.test(prompt);
}
function hasPediatricSignal(prompt, item) {
if (/\b(child|children|pediatric|paediatric|infant|neonate|newborn|adolescent|teen|toddler|baby)\b/i.test(prompt)) return true;
var category = String(item && item.category || '').toLowerCase();
var intent = String(item && item.intent || '').toLowerCase();
return /\b(neonates?|development|respiratory|infectious disease|gi|neurology|cardiology|endocrine|hematology|toxicology|procedures|dosing\/fluids|red flags|differential|admission\/discharge|counseling|emergency|general)\b/.test(category) && /\b(diagnosis|management|red_flags|differential|counseling|dosing|admission|review)\b/.test(intent);
}
function cleanExampleLabel(label) {
label = String(label || '').replace(/[?!.:,;]+$/g, '').replace(/\s+/g, ' ').trim();
if (!label || label.length > 42) label = labelFromQuestion(label || 'Clinical question');
@ -181,7 +188,7 @@ function createClinicalPromptPool(opts) {
function rotateExamples(items, seed) {
if (!items.length) return [];
var copy = items.slice();
var offset = Math.floor(seed / cacheMs) % copy.length;
var offset = Math.abs(Math.floor(seed)) % copy.length;
return copy.slice(offset).concat(copy.slice(0, offset));
}

View file

@ -244,3 +244,11 @@ test('does not repair malformed tab-separated clinical summary rows into a table
assert.match(html, /Initial Imaging/);
assert.match(html, /If you need details/);
});
test('clinical assistant streams long table answers as lightweight text before final render', async () => {
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
assert.match(source, /STREAM_MARKDOWN_LIMIT/);
assert.match(source, /renderStreamingAnswerHtml\(partial, streamSources\)/);
assert.match(source, /assistant-streaming-text/);
assert.match(source, /pipeRows >= 8/);
});

View file

@ -17,6 +17,9 @@ test('clinical assistant starter prompts are Redis or indexed-source backed', ()
assert.match(pool, /opts\.redisCache\.getJson\(redisKey\)/);
assert.match(pool, /opts\.redisCache\.setJson\(redisKey, payload, ttlSeconds\)/);
assert.match(pool, /CLINICAL_ASSISTANT_PROMPT_POOL_TARGET, 200/);
assert.match(pool, /Math\.min\(40, Math\.ceil\(target \/ 25\)\)/);
assert.match(pool, /rotateExamples\(snippets, i \* 24\)/);
assert.match(pool, /function hasPediatricSignal\(prompt, item\)/);
assert.match(pool, /var examples = await fallbackIndexedExamples\(\)/);
});