fix mobile assistant table streaming
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
This commit is contained in:
parent
e1266c6d38
commit
97ddd87449
9 changed files with 105 additions and 18 deletions
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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\(\)/);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue