feat: model selection in chat (composer pill + popup image model), deterministic image dispatch when the model writes text instead of calling the tool, markdown-rendered email, OWUI stacked composer, left mobile drawer, Go back in topbar, DeepL removed, image history inside the Create image popup

This commit is contained in:
Daniel 2026-09-09 02:55:28 +02:00
parent 982a089fcb
commit 73955dd5f9
16 changed files with 493 additions and 385 deletions

View file

@ -1,4 +1,5 @@
<div class="assistant-topbar">
<button id="btn-assistant-goback" class="btn-sm btn-ghost" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-mobile-menu" class="btn-sm btn-ghost assistant-mobile-menu" type="button" title="Saved chats"><i class="fas fa-bars"></i></button>
<div class="assistant-topbar-title">
<h2><i class="fas fa-brain" style="color:var(--purple);"></i> AI Clinical Assistant</h2>
@ -11,7 +12,6 @@
<div class="assistant-layout" id="assistant-layout">
<aside class="assistant-history">
<button id="btn-assistant-goback" class="btn-sm btn-ghost assistant-goback-rail" type="button" title="Back to the main menu"><i class="fas fa-arrow-left"></i> Go back</button>
<button id="btn-assistant-create-image" class="assistant-create-image" type="button"><i class="fas fa-wand-magic-sparkles"></i> Create image</button>
<button id="btn-assistant-clear" class="assistant-new-chat" type="button"><i class="fas fa-plus"></i> New chat</button>
<div class="card">
@ -22,14 +22,6 @@
<p class="assistant-muted">Chats save automatically as you go.</p>
</div>
</div>
<div class="card assistant-gallery-card">
<div class="card-header">
<h3><i class="fas fa-images"></i> Images</h3>
</div>
<div id="assistant-image-gallery" class="assistant-image-gallery">
<p class="assistant-muted">Generated images appear here.</p>
</div>
</div>
</aside>
<section class="assistant-main card">
@ -61,6 +53,9 @@
<form id="assistant-form" class="assistant-composer">
<label for="assistant-input">Clinical question</label>
<div class="assistant-model-pill" id="assistant-model-pill">
<select id="assistant-chat-model-select" class="assistant-model-control" aria-label="Chat model" hidden></select>
</div>
<textarea id="assistant-input" rows="1" placeholder="Ask a focused clinical question..." autocomplete="off" aria-describedby="assistant-context-warning"></textarea>
<div id="assistant-context-warning" role="alert" hidden></div>
<div id="assistant-attachments" class="assistant-attachments" aria-label="Attached images" hidden></div>

View file

@ -117,11 +117,9 @@
.assistant-composer-footer #btn-assistant-cancel[hidden] { display:none !important; }
.assistant-composer-footer #btn-assistant-cancel:not([hidden]) { display:inline-flex; }
.assistant-check { font-size:12px; color:var(--g500); display:flex; align-items:center; gap:6px; }
.assistant-side { display:grid; gap:12px; }
.assistant-side-body { padding:12px; display:grid; gap:10px; font-size:13px; }
.assistant-visual-output { display:grid; gap:8px; }
.assistant-image-buttons { display:flex; gap:8px; flex-wrap:wrap; }
.assistant-visual-output img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
/* Sources own the full right column now that the image panel is gone */
.assistant-side { display:flex; flex-direction:column; gap:12px; min-height:0; }
.assistant-side .card { flex:1 1 auto; display:flex; flex-direction:column; min-height:0; }
.assistant-generated-image { display:grid; gap:8px; }
.assistant-generated-image img { width:100%; border-radius:10px; border:1px solid var(--g200); background:white; }
.assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; }
@ -131,7 +129,7 @@
.assistant-image-modal-card img { max-width:100%; max-height:92vh; border-radius:14px; background:white; box-shadow:0 24px 80px rgba(0,0,0,.35); }
.assistant-image-modal-close { position:absolute; top:8px; right:8px; z-index:1; width:38px; height:38px; border:0; border-radius:999px; background:white; color:var(--g800); font-size:24px; line-height:1; cursor:pointer; box-shadow:var(--shadow); }
.assistant-image-modal-cancel { justify-self:center; border:0; border-radius:999px; background:white; color:var(--g800); font-weight:700; padding:9px 14px; box-shadow:var(--shadow); cursor:pointer; }
.assistant-sources { padding:10px 12px; display:grid; gap:8px; max-height:520px; overflow-y:auto; }
.assistant-sources { padding:10px 12px; display:flex; flex-direction:column; gap:8px; flex:1 1 auto; min-height:0; overflow-y:auto; }
.assistant-saved-chats { flex:1 1 auto; overflow-y:auto; min-height:0; padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
.assistant-saved-chat { border:1px solid transparent; border-radius:10px; padding:7px 10px; background:transparent; display:flex; flex-direction:column; gap:1px; width:100%; text-align:left; cursor:pointer; color:inherit; font:inherit; min-width:0; }
.assistant-saved-chat:hover { background:var(--g100, #f3f4f6); border-color:transparent; }
@ -171,8 +169,8 @@
.assistant-topbar-title h2 { font-size:15px; }
.assistant-status { margin-left:auto; font-size:11px; }
.assistant-layout { display:block; height:auto; min-height:100dvh; }
/* Saved chats: full-screen drawer behind the hamburger */
.assistant-history { position:fixed; inset:0 0 0 auto; width:min(86vw,360px); z-index:130; background:white; box-shadow:0 0 40px rgba(0,0,0,.25); transform:translateX(105%); transition:transform .18s ease; display:flex; flex-direction:column; padding:14px; overflow-y:auto; }
/* Saved chats: a left drawer, ChatGPT-style, with the menu button as the opener */
.assistant-history { position:fixed; inset:0 auto 0 0; width:min(88vw,340px); z-index:130; background:white; box-shadow:0 0 40px rgba(0,0,0,.25); transform:translateX(-105%); transition:transform .18s ease; display:flex; flex-direction:column; padding:12px; overflow-y:auto; gap:8px; border-radius:0 16px 16px 0; }
.assistant-layout.mobile-chats-open .assistant-history { transform:translateX(0); }
.assistant-history .card { box-shadow:none; border:1px solid var(--g200); }
.assistant-new-chat { align-self:stretch; }
@ -193,11 +191,11 @@
.assistant-toolbar-actions { display:none; }
.assistant-layout.mobile-chats-open .assistant-toolbar-actions { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
/* Floating pill composer */
.assistant-composer { position:static; flex:0 0 auto; margin:0 10px 10px; z-index:60; background:white; border:1px solid var(--g200); border-radius:28px; box-shadow:0 8px 30px rgba(0,0,0,.14); padding:6px 6px 6px 16px; }
.assistant-composer { position:static; flex:0 0 auto; margin:0 10px 10px; z-index:60; background:white; border:1px solid var(--g200); border-radius:20px; box-shadow:0 8px 30px rgba(0,0,0,.14); padding:8px 12px 6px; display:flex; flex-direction:column; gap:6px; }
#assistant-input { font-size:16px; }
.assistant-composer > label { display:none; }
#assistant-context-budget, #assistant-context-warning { display:none; }
.assistant-composer-footer { flex-direction:row; align-items:center; gap:4px; margin-top:2px; }
.assistant-composer-footer { flex-direction:row; align-items:center; justify-content:space-between; gap:4px; }
.assistant-composer-footer .btn-generate { width:44px; height:44px; border-radius:50%; padding:0; font-size:0; flex:0 0 auto; }
.assistant-composer-footer .btn-generate i { font-size:16px; }
.assistant-attach { font-size:0; }
@ -264,11 +262,13 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
#create-image-description, #create-image-chat { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:8px 10px; font-size:13px; }
.assistant-create-image-actions { display:flex; justify-content:flex-end; margin-top:12px; }
/* ChatGPT-style pill composer */
.assistant-composer { border:1px solid var(--g200); border-radius:28px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:6px 8px 6px 18px; display:flex; align-items:flex-end; gap:8px; }
/* Open WebUI-style composer: a prominent input box with a slim action row below */
.assistant-composer { border:1px solid var(--g200); border-radius:20px; box-shadow:0 8px 30px rgba(0,0,0,.08); padding:10px 14px 6px; display:flex; flex-direction:column; gap:2px; }
.assistant-composer > label { display:none; }
#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:44px; max-height:160px; padding:10px 0; }
.assistant-composer-footer { display:flex; align-items:center; gap:2px; flex:0 0 auto; }
#assistant-input { flex:1 1 auto; border:none; outline:none; resize:none; background:transparent; font-size:15px; line-height:1.5; min-height:56px; max-height:180px; padding:6px 0; }
.assistant-composer-footer { display:flex; align-items:center; justify-content:space-between; gap:4px; border-top:1px solid var(--g100); padding-top:4px; margin-top:2px; }
.assistant-composer-footer .assistant-attach, .assistant-composer-footer .assistant-voice-btn, .assistant-composer-footer .assistant-mic { width:30px; height:30px; font-size:14px; }
.assistant-composer-footer .btn-send { width:32px; height:32px; font-size:14px; }
.assistant-attach, .assistant-voice-btn, .assistant-mic { width:38px; height:38px; border:none; background:none; border-radius:50%; display:flex; align-items:center; justify-content:center; color:var(--g600); cursor:pointer; font-size:16px; }
.assistant-attach:hover, .assistant-voice-btn:hover, .assistant-mic:hover { background:var(--g100); }
.assistant-mic.recording { background:var(--danger,#dc2626); color:#fff; }
@ -294,3 +294,14 @@ body.assistant-workspace .assistant-learning-view { min-height: 0; height: 100vh
.assistant-chat-menu button[data-chat-action="delete"] { color:var(--danger,#dc2626); }
.assistant-chat-menu button i { width:14px; text-align:center; }
.assistant-saved-chat-rename { width:100%; border:1.5px solid var(--purple); border-radius:6px; padding:4px 6px; font-size:12px; }
/* Create-image popup: generation progress + full history grid */
.create-image-progress { margin-top:10px; padding:8px 10px; border-radius:8px; background:var(--g50,#f9fafb); color:var(--g600); font-size:13px; }
.create-image-progress[hidden] { display:none; }
.create-image-history { display:grid; grid-template-columns:repeat(auto-fill,minmax(72px,1fr)); gap:8px; max-height:42vh; overflow-y:auto; margin-top:8px; }
.assistant-gallery-running { display:flex; align-items:center; justify-content:center; min-height:72px; border:1px dashed var(--g300); border-radius:8px; color:var(--g500); }
/* Composer model pill (Open WebUI-style, its own line above the actions) */
.assistant-model-pill { display:flex; align-items:center; }
.assistant-model-pill[hidden] { display:none; }
.assistant-model-control { border:1px solid var(--g200); background:white; border-radius:999px; padding:3px 10px; font-size:11px; color:var(--g600); cursor:pointer; max-width:220px; }
.assistant-model-pick { display:block; }

View file

@ -356,11 +356,6 @@
</div><!-- /.app-body -->
<footer style="padding:12px 24px;text-align:center;font-size:12px;color:var(--g400);border-top:1px solid var(--g100);">
<a href="#" id="btn-show-about" style="color:var(--g500);text-decoration:none;">About Pediatric Clinical Tools</a>
&nbsp;&bull;&nbsp;
&copy; <span id="app-year"></span> PedsHub. All rights reserved.
</footer>
<!-- About Modal -->
<div id="about-modal" class="confirm-modal hidden">

View file

@ -60,18 +60,18 @@ export function requestAssistantImage(prompt) {
}).then(function(r) { return r.json(); });
}
export function translateAssistantMessage(message, target, provider) {
export function translateAssistantMessage(message, target, provider, format) {
return fetch('/api/clinical-assistant/translate', {
method: 'POST',
headers: authHeaders(),
credentials: 'same-origin',
body: JSON.stringify({ message: message, target: target, provider: provider })
body: JSON.stringify({ message: message, target: target, provider: provider, format: format || 'text' })
}).then(parseJsonWithStatus);
}
var imageDraft = null;
export function startAssistantImageJob(prompt, history) {
const input = { prompt, ...(history === undefined ? {} : { history }) };
export function startAssistantImageJob(prompt, history, imageModel) {
const input = { prompt, ...(history === undefined ? {} : { history }), ...(imageModel ? { imageModel } : {}) };
const identity = JSON.stringify(input);
if (!imageDraft || imageDraft.identity !== identity) imageDraft = { identity, body: { ...input, idempotencyKey: crypto.randomUUID() } };
return fetch('/api/clinical-assistant/image/jobs', {

View file

@ -80,7 +80,7 @@ export function renderAssistantMarkdown(md, sources, options) {
function wrapTables(html) {
return String(html || '')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><div class="assistant-table-actions"><button type="button" data-assistant-table-copy>Copy raw</button><button type="button" data-assistant-table-csv>Export CSV</button></div><table$1>')
.replace(/<table(\s[^>]*)?>/g, '<div class="assistant-table-scroll" tabindex="0" role="region" aria-label="Scrollable table"><table$1>')
.replace(/<\/table>/g, '</table></div>');
}

View file

@ -35,6 +35,9 @@ import {
var generatedImageJobs = [];
var markdownRenderer = null;
var assistantBusy = false;
var selectedChatModel = '';
var selectedImageModel = '';
var statusChoices = { allowedChatModels: [], allowedImageModels: [], chatModel: '', imageModel: '' };
var activeAssistantRequest = null;
var conversationChars = null;
var STREAM_MARKDOWN_LIMIT = 3500;
@ -115,6 +118,7 @@ import {
if (!root || !root.querySelector('#assistant-form')) return;
initialized = true;
bindEvents();
loadModelSelection();
loadStatus();
loadExamples();
}
@ -128,7 +132,11 @@ import {
var input = document.getElementById('assistant-input');
var goBackBtn = document.getElementById('btn-assistant-goback');
var createImageBtn = document.getElementById('btn-assistant-create-image');
if (createImageBtn) createImageBtn.addEventListener('click', openCreateImageDialog);
if (createImageBtn) createImageBtn.addEventListener('click', function() {
var layout = document.getElementById('assistant-layout');
if (layout) layout.classList.remove('mobile-chats-open');
openCreateImageDialog();
});
var mobileMenuBtn = document.getElementById('btn-assistant-mobile-menu');
if (mobileMenuBtn) mobileMenuBtn.addEventListener('click', function(ev) {
ev.stopPropagation();
@ -171,6 +179,23 @@ import {
}
conversationChars = data.success && validConversationLimit(data.conversationChars) ? data.conversationChars : null;
if (data.success && ['libretranslate', 'deepl'].includes(data.translateProvider)) translateProvider = data.translateProvider;
if (data.success) {
statusChoices = {
allowedChatModels: data.allowedChatModels || [],
allowedImageModels: data.allowedImageModels || [],
chatModel: data.chatModel || '',
imageModel: data.imageModel || ''
};
bindModelSelects();
fillModelSelect(document.getElementById('assistant-chat-model-select'), statusChoices.allowedChatModels, statusChoices.chatModel, selectedChatModel, 'ped_assistant_chat_model');
var chatSel = document.getElementById('assistant-chat-model-select');
selectedChatModel = chatSel ? chatSel.value : selectedChatModel;
var imageSel = document.getElementById('assistant-image-model-select');
if (imageSel) {
fillModelSelect(imageSel, statusChoices.allowedImageModels, statusChoices.imageModel, selectedImageModel, 'ped_assistant_image_model');
selectedImageModel = imageSel.value || selectedImageModel;
}
}
updateConversationBudget();
})
.catch(function () {
@ -246,6 +271,8 @@ import {
history: history,
includeContext: true // clinical retrieval is always on — it is the purpose of the app
};
if (selectedChatModel) payload.chatModel = selectedChatModel;
if (selectedImageModel) payload.imageModel = selectedImageModel;
// Images ride the outgoing clinical question for inference; once sent they persist with the saved chat.
if (attachments.length) payload.images = assistantAttachmentPayload(attachments);
@ -623,77 +650,6 @@ import {
wrap.innerHTML = renderSourcesList(sources);
}
// ── OWUI-style table actions (Copy raw / Export CSV) ────────────────
function wireTableActions(root) {
if (!root || !root.querySelectorAll) return;
root.querySelectorAll('.assistant-table-scroll').forEach(function(wrapper) {
if (wrapper.getAttribute('data-assistant-table-wired') === 'true') return;
wrapper.setAttribute('data-assistant-table-wired', 'true');
wrapper.addEventListener('click', function(event) {
if (event.target.closest('[data-assistant-table-copy]')) {
var markdown = tableToMarkdown(wrapper.querySelector('table'));
copyText(markdown, 'Table copied', 'Copy failed');
return;
}
if (event.target.closest('[data-assistant-table-csv]')) {
exportTableCsv(wrapper.querySelector('table'));
}
});
});
}
function tableToMarkdown(table) {
if (!table) return '';
var lines = [];
var rows = table.querySelectorAll('tr');
rows.forEach(function(row, rowIndex) {
var cells = row.querySelectorAll('th, td');
if (rowIndex === 1 && row.parentNode && row.parentNode.tagName === 'THEAD') return; // markdown tables carry one header row
if (rowIndex === 0) {
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
lines.push('| ' + Array.from(cells).map(function(cell) {
var align = cell.getAttribute('align') || (cell.style && cell.style.textAlign) || '';
if (align === 'center') return ':---:';
if (align === 'right') return '---:';
return '---';
}).join(' | ') + ' |');
return;
}
lines.push('| ' + Array.from(cells).map(cellToText).join(' | ') + ' |');
});
return lines.join('\n');
}
function cellToText(cell) {
return String(cell.textContent || '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
}
function exportTableCsv(table) {
if (!table) return;
var lines = [];
table.querySelectorAll('tr').forEach(function(row) {
lines.push(Array.from(row.querySelectorAll('th, td')).map(function(cell) {
var text = String(cell.textContent || '').trim();
return /[,"\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
}).join(','));
});
var csv = '\uFEFF' + lines.join('\r\n');
try {
var blob = new Blob([csv], { type: 'text/csv' });
var objectUrl = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = objectUrl;
link.download = 'table.csv';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000);
} catch (e) {
if (typeof showToast === 'function') showToast('CSV export failed', 'error');
}
}
// ── OWUI-style code blocks with a Copy button ───────────────────────
function wireCodeBlocks(root) {
if (!root || !root.querySelectorAll) return;
@ -731,7 +687,6 @@ import {
}
function renderEmbeddedBlocks(root) {
wireTableActions(root);
wireCodeBlocks(root);
function mermaidSource(el) {
var raw = el.getAttribute('data-mermaid') || '';
@ -776,6 +731,20 @@ import {
}
function onAssistantDocumentClick(e) {
var msgEdit = e.target.closest('[data-assistant-msg-edit]');
if (msgEdit) {
var editRow = msgEdit.closest('.assistant-msg');
var editBubble = editRow && editRow.querySelector('.assistant-bubble');
var input = document.getElementById('assistant-input');
if (editBubble && input) {
input.value = String(editBubble.assistantRawContent !== undefined ? editBubble.assistantRawContent : editBubble.textContent).trim();
updateConversationBudget();
resizeAssistantInput();
input.focus();
if (typeof input.scrollIntoView === 'function') input.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
return;
}
var citation = e.target.closest('#assistant-messages .assistant-cite');
if (citation) {
var bubble = citation.closest('.assistant-bubble');
@ -802,18 +771,10 @@ import {
var translateLang = e.target.closest('[data-assistant-translate-lang]');
if (translateLang) {
var picker = translateLang.closest('[data-assistant-translate-pop]');
var providerButton = picker && picker.querySelector('[data-assistant-translate-provider].active');
requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), providerButton ? providerButton.getAttribute('data-assistant-translate-provider') : translateProvider);
requestMessageTranslation(picker.assistantMessageRow, translateLang.getAttribute('data-assistant-translate-lang'), translateProvider);
closeTranslatePicker();
return;
}
var translateProviderBtn = e.target.closest('[data-assistant-translate-provider]');
if (translateProviderBtn) {
var pop = translateProviderBtn.closest('[data-assistant-translate-pop]');
pop.querySelectorAll('[data-assistant-translate-provider]').forEach(function(b) { b.classList.remove('active'); });
translateProviderBtn.classList.add('active');
return;
}
var showOriginal = e.target.closest('[data-assistant-msg-show-original]');
if (showOriginal) {
var translatedBubble = showOriginal.closest('.assistant-bubble');
@ -1141,9 +1102,14 @@ import {
return '<option value="' + escapeAttr(opt.id) + '">' + escapeHtml(opt.title) + '</option>';
}).join('') +
'</select>' +
'<label for="assistant-image-model-select" class="assistant-create-image-label">Image model</label>' +
'<select id="assistant-image-model-select" class="assistant-model-control"></select>' +
'<div class="assistant-create-image-actions">' +
'<button type="button" class="btn-generate" id="btn-create-image-generate"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'<div id="create-image-progress" class="create-image-progress" hidden></div>' +
'<div class="assistant-create-image-label">Your images</div>' +
'<div id="create-image-history" class="create-image-history"><p class="assistant-muted">Loading your images…</p></div>' +
'</div></div>';
document.body.appendChild(modal);
modal.addEventListener('click', function (event) {
@ -1154,14 +1120,55 @@ import {
var select = document.getElementById('create-image-chat');
var prompt = String(description && description.value || '').trim();
var chatId = select ? String(select.value || '') : '';
modal.remove();
startImageFromSelection(prompt, chatId);
startImageFromSelection(prompt, chatId, {
onStatus: function (text) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = text; progress.hidden = !text; }
},
onDone: function (url) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = ''; progress.hidden = true; }
renderCreateImageHistory();
openImagePreview(url);
},
onError: function (msg) {
var progress = document.getElementById('create-image-progress');
if (progress) { progress.textContent = msg; progress.hidden = false; }
}
});
});
var select = modal.querySelector('#create-image-chat');
if (select) select.selectedIndex = 0;
bindModelSelects();
fillModelSelect(document.getElementById('assistant-image-model-select'), statusChoices.allowedImageModels, statusChoices.imageModel, selectedImageModel, 'ped_assistant_image_model');
renderCreateImageHistory();
}
function startImageFromSelection(prompt, chatId) {
function renderCreateImageHistory() {
var wrap = document.getElementById('create-image-history');
if (!wrap || typeof fetchAssistantImageJobs !== 'function') return;
fetchAssistantImageJobs().then(function (data) {
if (!wrap.isConnected) return;
if (!data.success || !Array.isArray(data.jobs)) return;
var jobs = data.jobs.slice(0, 200);
var done = jobs.filter(function (job) { return job.imageUrl; });
var running = jobs.filter(function (job) { return !job.imageUrl && job.status !== 'error' && job.status !== 'interrupted'; });
if (!done.length && !running.length) {
wrap.innerHTML = '<p class="assistant-muted">Your generated images will appear here.</p>';
return;
}
wrap.innerHTML = done.map(function (job) {
return '<button type="button" class="assistant-gallery-item" data-gallery-image="' + escapeAttr(job.imageUrl) + '" title="' + escapeAttr(new Date(job.createdAt || '').toLocaleString()) + '"><img src="' + escapeAttr(job.imageUrl) + '" alt="Generated image" loading="lazy"></button>';
}).join('') + running.map(function (job) {
return '<div class="assistant-gallery-item assistant-gallery-running" title="Generating…"><i class="fas fa-spinner fa-spin"></i></div>';
}).join('');
if (running.length) setTimeout(renderCreateImageHistory, 4000);
}).catch(function () {});
}
function startImageFromSelection(prompt, chatId, hooks) {
hooks = hooks || {};
var sourceMessages = messages;
var load = chatId
? function () {
@ -1176,37 +1183,41 @@ import {
var effective = prompt || 'Create a pediatric teaching visual from this conversation.';
var owner;
try { owner = captureSharingOwner(); } catch (_) { return; }
startAssistantImageJob(effective, full).then(function (data) {
startAssistantImageJob(effective, full, selectedImageModel || undefined).then(function (data) {
assertSharingOwner(owner);
if (!data.success) throw new Error(data.error || 'Image generation failed');
generatedImageJobs = [{ jobId: data.jobId }];
lastGeneratedImageSrc = ''; // the queued job replaces any previous completed image
exporter.invalidate();
if (typeof showToast === 'function') showToast('Generating image…', 'info');
// Poll until the job completes, then refresh the gallery and show the image.
if (typeof hooks.onStatus === 'function') hooks.onStatus('Generating image…');
// The job runs server-side; closing the popup never cancels it.
var attempts = 0;
var poll = function () {
if (!validSharingOwner(owner) || owner.signal.aborted) return;
attempts += 1;
if (attempts > 60) return;
if (attempts > 60) { if (typeof hooks.onError === 'function') hooks.onError('Image is still generating — it will appear in your images shortly.'); return; }
fetchAssistantImageJob(data.jobId).then(function (job) {
if (!job || !job.success) return;
if (!job || !job.success) { setTimeout(poll, 2500); return; }
if (job.imageUrl) {
lastGeneratedImageSrc = job.imageUrl;
exporter.invalidate();
loadImageGallery();
openImagePreview(job.imageUrl);
if (typeof hooks.onDone === 'function') hooks.onDone(job.imageUrl); else openImagePreview(job.imageUrl);
return;
}
if (job.status === 'error' || job.status === 'interrupted') {
if (typeof showToast === 'function') showToast(job.error || 'Image generation failed', 'error');
if (typeof hooks.onError === 'function') hooks.onError(job.error || 'Image generation failed');
else if (typeof showToast === 'function') showToast(job.error || 'Image generation failed', 'error');
return;
}
setTimeout(poll, 2500);
}).catch(function () { setTimeout(poll, 2500); });
};
setTimeout(poll, 2500);
}).catch(function (error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); });
}).catch(function (error) {
if (typeof hooks.onError === 'function') hooks.onError(error.message);
if (!validSharingOwner(owner) || error.name === 'AbortError') return;
if (typeof showToast === 'function') showToast(error.message, 'error');
});
}).catch(function (error) { if (typeof showToast === 'function') showToast(error.message, 'error'); });
}
@ -1242,6 +1253,68 @@ import {
}
// ── Per-account model selection (localStorage, admin-allowlist-limited) ──
function modelStorageKey(key) {
try {
if (window.AccountBoundary && typeof window.AccountBoundary.storageKey === 'function') return window.AccountBoundary.storageKey(key);
} catch (e) {}
return key + ':unscoped';
}
function loadModelSelection() {
try {
selectedChatModel = localStorage.getItem(modelStorageKey('ped_assistant_chat_model')) || '';
selectedImageModel = localStorage.getItem(modelStorageKey('ped_assistant_image_model')) || '';
} catch (e) { selectedChatModel = ''; selectedImageModel = ''; }
}
function saveModelSelection(kind, value) {
try {
var key = modelStorageKey(kind);
if (value) localStorage.setItem(key, value);
else localStorage.removeItem(key);
} catch (e) {}
}
function fillModelSelect(select, allowed, configured, saved, saveKind) {
if (!select) return;
select.innerHTML = '';
var def = document.createElement('option');
def.value = '';
def.textContent = configured ? ('Default (' + configured + ')') : 'Default (admin model)';
select.appendChild(def);
(Array.isArray(allowed) ? allowed : []).forEach(function(id) {
if (!id || id === configured) return;
var opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
select.appendChild(opt);
});
var stillAllowed = Array.prototype.some.call(select.options, function(o) { return o.value === saved; });
var chosen = stillAllowed ? saved : '';
select.value = chosen;
if (saveKind && chosen !== saved) saveModelSelection(saveKind, chosen);
var show = Array.isArray(allowed) && allowed.length;
select.hidden = !show;
var pill = document.getElementById('assistant-model-pill');
if (pill) pill.hidden = !show;
}
function bindModelSelects() {
var chatSel = document.getElementById('assistant-chat-model-select');
if (chatSel && !chatSel.dataset.bound) {
chatSel.dataset.bound = '1';
chatSel.addEventListener('change', function() {
selectedChatModel = chatSel.value || '';
saveModelSelection('ped_assistant_chat_model', selectedChatModel);
});
}
var imageSel = document.getElementById('assistant-image-model-select');
if (imageSel && !imageSel.dataset.bound) {
imageSel.dataset.bound = '1';
imageSel.addEventListener('change', function() {
selectedImageModel = imageSel.value || '';
saveModelSelection('ped_assistant_image_model', selectedImageModel);
});
}
}
// ── Saved-chat options: Rename, Pin, Export, Delete (ChatGPT-style menu) ──
function pinnedChatIds() {
try {
@ -1446,11 +1519,9 @@ import {
var pop = document.createElement('div');
pop.className = 'assistant-translate-pop';
pop.setAttribute('data-assistant-translate-pop', '');
pop.innerHTML = '<div class="assistant-translate-providers">' +
'<button type="button" data-assistant-translate-provider="libretranslate" class="' + (translateProvider === 'libretranslate' ? 'active' : '') + '">Local</button>' +
'<button type="button" data-assistant-translate-provider="deepl" class="' + (translateProvider === 'deepl' ? 'active' : '') + '">DeepL</button>' +
'</div>' +
'<div class="assistant-translate-langs">' +
// Simple for users: pick a language, the server picks the provider
// (local LibreTranslate by default; DeepL only when the admin configured it).
pop.innerHTML = '<div class="assistant-translate-langs">' +
TRANSLATE_LANGUAGES.map(function(pair) {
return '<button type="button" data-assistant-translate-lang="' + escapeAttr(pair[0]) + '">' + escapeHtml(pair[1]) + '</button>';
}).join('') +
@ -1466,14 +1537,15 @@ import {
function requestMessageTranslation(row, target, provider) {
var bubble = row && row.querySelector('.assistant-bubble');
var content = bubble && bubble.assistantRawContent !== undefined ? bubble.assistantRawContent : '';
if (!bubble || !String(content).trim()) return;
if (!bubble) return;
if (!bubble.assistantOriginalHtml) bubble.assistantOriginalHtml = bubble.innerHTML;
translateAssistantMessage(content, target, provider)
// Translate the RENDERED HTML so headings, bold, lists and tables keep their shape.
translateAssistantMessage(bubble.innerHTML, target, provider, 'html')
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Translation failed');
bubble.innerHTML = '<p class="assistant-translated-text">' + escapeHtml(data.translated) + '</p>' +
bubble.innerHTML = sanitize(String(data.translated || '')) +
'<button type="button" class="btn-sm btn-ghost" data-assistant-msg-show-original><i class="fas fa-undo"></i> Show original</button>';
renderEmbeddedBlocks(bubble);
})
.catch(function(err) {
if (typeof showToast === 'function') showToast(err && err.message ? err.message : 'Translation failed', 'error');
@ -1815,7 +1887,9 @@ import {
actions.className = 'assistant-msg-actions';
actions.innerHTML = '<button type="button" data-assistant-msg-copy title="Copy raw message"><i class="fas fa-copy"></i></button>' +
'<button type="button" data-assistant-msg-translate title="Translate"><i class="fas fa-language"></i></button>' +
(role === 'assistant' ? '<button type="button" data-assistant-msg-regenerate title="Regenerate"><i class="fas fa-rotate-right"></i></button>' : '');
(role === 'assistant'
? '<button type="button" data-assistant-msg-regenerate title="Regenerate"><i class="fas fa-rotate-right"></i></button>'
: '<button type="button" data-assistant-msg-edit title="Edit question"><i class="fas fa-pen"></i></button>');
row.appendChild(actions);
wrap.scrollTop = wrap.scrollHeight;
return row;

View file

@ -87,8 +87,9 @@ setTimeout(function() {
router.get('/clinical-assistant/status', async function(req, res) {
try {
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var imageModel = await getSetting('clinical_assistant.image_model', '');
var choices = await getAssistantStatusChoices();
var chatModel = choices.chatConfigured;
var imageModel = choices.imageConfigured;
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var translateProvider = String(await getSetting('clinical_assistant.translate_provider', '') || 'libretranslate').toLowerCase();
@ -99,6 +100,8 @@ router.get('/clinical-assistant/status', async function(req, res) {
success: true,
chatModel: chatModel,
imageModel: imageModel,
allowedChatModels: choices.allowedChatModels,
allowedImageModels: choices.allowedImageModels,
searchLimit: searchLimit,
contextChars: contextChars,
conversationChars: budget.limit,
@ -249,6 +252,7 @@ router.post('/clinical-assistant/translate', async function(req, res) {
message: req.body.message,
target: req.body.target,
provider: req.body.provider,
format: req.body.format,
userId: req.user.id,
env: process.env,
getSetting: getSetting,
@ -284,11 +288,16 @@ router.post('/clinical-assistant/patient-takehome/email', async function(req, re
var text = String(req.body.text || '').trim();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(to)) return res.status(400).json({ error: 'Enter a valid email address', code: 'INVALID_EMAIL' });
if (!text || text.length > 12000) return res.status(400).json({ error: 'Nothing to send', code: 'EMPTY_EMAIL_BODY' });
// Render the sheet with the same markdown engine as the app so the email
// keeps headings, bullets and bold instead of raw asterisks.
var MarkdownIt = require('markdown-it');
var md = new MarkdownIt({ html: false, linkify: true, breaks: false });
var bodyHtml = md.render(String(text || ''));
var sent = await require('./auth').__sendEmail(to, 'Patient Take Home — Pediatric AI Scribe',
'<div style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;color:#1f2937;">' +
'<h2 style="color:#0f766e;margin:0 0 12px;">Patient Take Home</h2>' +
'<p style="white-space:pre-wrap;">' + String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '</p>' +
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This summary was created for a caregiver. Keep following your care team&rsquo;s instructions.</p></div>');
'<div style="font-family:Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;color:#1f2937;max-width:620px;">' +
'<div style="background:#0f766e;color:#fff;padding:14px 18px;border-radius:10px 10px 0 0;font-size:17px;font-weight:700;">Patient Take Home</div>' +
'<div style="border:1px solid #e5e7eb;border-top:0;border-radius:0 0 10px 10px;padding:18px;">' + bodyHtml + '</div>' +
'<p style="color:#6b7280;font-size:12px;margin-top:14px;">This summary was created for a caregiver. Keep following your care team&rsquo;s instructions.</p></div>');
if (!sent) return res.status(503).json({ error: 'Email is not configured on this server yet', code: 'SMTP_NOT_CONFIGURED' });
logger.audit(req.user.id, 'patient_takehome_email', 'take-home emailed', req, { category: 'clinical', to: to });
res.json({ success: true });
@ -311,7 +320,8 @@ router.post('/clinical-assistant/chat', async function(req, res) {
images: prepared.images
}));
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
imageContext: prepared.imageContext, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
ai = await dispatchImageRequestFallback(ai, prepared, req);
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
@ -380,7 +390,8 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
});
ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body,
imageContext: prepared.imageContext, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
imageContext: prepared.imageContext, imageModel: prepared.imageModel, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI });
ai = await dispatchImageRequestFallback(ai, prepared, req);
var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, {
messages: prepared.messages,
chatModel: prepared.chatModel,
@ -414,11 +425,44 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) {
}
});
// Model-agnostic guarantee: when the user explicitly asks for an image and
// the model answered with text instead of invoking the tool, run the job from
// the model's answer text anyway. Deterministic — never depends on tool-call
// support of the serving model.
const IMAGE_REQUEST_PATTERN = /(create|generate|draw|make|illustrate|render|visualize)\b[^.!?\n]{0,160}\b(image|figure|diagram|poster|infographic|illustration|chart|visual|graphic)/i;
function isExplicitImageRequest(message) {
return IMAGE_REQUEST_PATTERN.test(String(message || ''));
}
async function dispatchImageRequestFallback(ai, prepared, req) {
if (!ai || ai.imageToolHandled || (ai.toolCalls && ai.toolCalls.length)) return ai;
if (!isExplicitImageRequest(prepared.message)) return ai;
var text = String(ai.content || '').trim();
if (!text || text.length > 32000) return ai;
try {
var job = await generatedImages.service().enqueue(
req.user.id, 'clinical_assistant',
{ prompt: text },
'img:' + generatedImages.requestKey({ prompt: text, ts: Date.now() }),
true,
prepared.imageContext,
prepared.imageModel);
ai.imageJobs = [job];
ai.imageToolHandled = true;
console.log('[clinical-assistant] image requested via text; queued image job', { jobId: job.jobId, model: job.model });
} catch (e) {
console.warn('[clinical-assistant] image fallback enqueue failed', e && e.message);
}
return ai;
}
async function submitImage(req, res, synchronous) {
try {
const body = req.body || {};
const imageModel = await resolveAssistantImageModel(body);
let job = await generatedImages.service().enqueue(req.user.id, 'clinical_assistant', { prompt: body.prompt, ...(body.layout === undefined ? {} : { layout: body.layout }) }, generatedImages.requestKey(body), false, body.history === undefined ? undefined : generatedImages.imageContext(
checkConversation(body.history, body.prompt, await getConversationLimit()).message, body.history));
checkConversation(body.history, body.prompt, await getConversationLimit()).message, body.history), imageModel);
if (synchronous) {
const deadline = Date.now() + 125000;
while (['pending', 'running'].includes(job.status) && Date.now() < deadline && !res.destroyed) {
@ -435,7 +479,7 @@ router.post('/clinical-assistant/image', (req, res) => submitImage(req, res, tru
router.post('/clinical-assistant/image/jobs', (req, res) => submitImage(req, res, false));
router.get('/clinical-assistant/image/jobs', async function(req, res) {
try {
var limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 24, 1), 50);
var limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 1), 500);
var rows = await db.all(
"SELECT id, stage, model, created_at FROM generated_image_jobs WHERE owner_id=$1 AND workflow='clinical_assistant' ORDER BY created_at DESC LIMIT $2",
[req.user.id, limit]);
@ -472,7 +516,8 @@ async function prepareAssistantChat(body) {
// the UTF-16 count. Once sent, attachments persist with saved chats.
var images = validateAttachments(body.images);
var chatModel = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var chatModel = await resolveAssistantChatModel(body);
var imageModel = await resolveAssistantImageModel(body);
var searchLimit = clampInt(await getSetting('clinical_assistant.search_limit', '8'), 3, 20, 8);
var contextChars = clampInt(await getSetting('clinical_assistant.context_chars', '1400'), 300, 4000, 1400);
var behavior = await getSetting('clinical_assistant.system_behavior', DEFAULT_BEHAVIOR) || DEFAULT_BEHAVIOR;
@ -536,6 +581,7 @@ async function prepareAssistantChat(body) {
images: images,
imageContext: generatedImages.imageContext(message, history),
chatModel: chatModel,
imageModel: imageModel,
sources: sources,
messages: [
{ role: 'system', content: buildSystemPrompt(behavior) },
@ -720,6 +766,67 @@ async function getSetting(key, fallback) {
} catch (e) { return fallback; }
}
// ── Per-user model selection + admin allowlists ────────────────────────────
// Settings hold comma-separated model IDs:
// clinical_assistant.allowed_models (chat)
// clinical_assistant.allowed_image_models (image)
// clinical_assistant.fallback_image_model (single retry target)
// An empty allowlist means legacy single-model behavior. A non-empty
// allowlist always includes the configured model; other selections 400.
function parseModelAllowlist(value) {
return String(value == null ? '' : value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
}
function modelNotAllowed() {
var err = new Error('The selected model is not available for the Clinical Assistant. Choose an approved model or clear your selection.');
err.statusCode = 400;
err.code = 'model_not_allowed';
return err;
}
function resolveModelSelection(choices, configured, requested) {
if (!choices.length) return configured;
if (requested === undefined || requested === null || requested === '') return configured;
if (typeof requested !== 'string' || requested.length > 200 || choices.indexOf(requested) === -1) throw modelNotAllowed();
return requested;
}
async function resolveAssistantChatModel(body) {
var configured = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var allowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_models', ''));
var choices = allowed.slice();
if (allowed.length && configured && choices.indexOf(configured) === -1) choices.push(configured);
return resolveModelSelection(choices, configured, body && body.chatModel);
}
async function resolveAssistantImageModel(body) {
var configured = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var allowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_image_models', ''));
var choices = allowed.slice();
if (allowed.length && configured && choices.indexOf(configured) === -1) choices.push(configured);
return resolveModelSelection(choices, configured, body && body.imageModel);
}
async function getAssistantStatusChoices() {
var chatConfigured = await getSetting('clinical_assistant.chat_model', '') || await getSetting('models.default', '');
var imageConfigured = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
var chatAllowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_models', ''));
var imageAllowed = parseModelAllowlist(await getSetting('clinical_assistant.allowed_image_models', ''));
function withConfigured(list, configured) {
if (!list.length) return [];
var out = list.slice();
if (configured && out.indexOf(configured) === -1) out.push(configured);
return out;
}
return {
chatConfigured: chatConfigured,
imageConfigured: imageConfigured,
allowedChatModels: withConfigured(chatAllowed, chatConfigured),
allowedImageModels: withConfigured(imageAllowed, imageConfigured)
};
}
function clampInt(value, min, max, fallback) {
var n = parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;

View file

@ -1,18 +1,11 @@
// Clinical Assistant message translation — local-first, owner-bound.
// Provider 1: LibreTranslate (the user's own local container, default).
// Provider 2: DeepL (admin-configured alternative; never contacted unless
// DEEPL_API_KEY is set and DEEPL_API_BASE is an allowlisted endpoint).
// No patient data ever leaves the local network unless the admin explicitly
// configures DeepL. Validation failures NEVER trigger provider fallback;
// only transient/provider-side failures fall back once to the other provider.
// Clinical Assistant message translation — local-only via the user's own
// LibreTranslate container. Patient text never leaves the local network.
// Validation failures never retry against anything else.
const crypto = require('crypto');
const axios = require('axios');
const TRANSLATE_PROVIDERS = ['libretranslate', 'deepl'];
const TRANSLATE_PROVIDERS = ['libretranslate'];
const TRANSLATE_LANGS = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ar', 'ru', 'hi', 'nl', 'pl', 'tr', 'uk', 'fa', 'sw'];
// DeepL cannot map fa/sw (no such target codes); refusing them is validation, not a provider failure.
const DEEPL_LANGS = ['en', 'es', 'fr', 'de', 'it', 'pt', 'zh', 'ar', 'ru', 'hi', 'nl', 'pl', 'tr', 'uk'];
const DEEPL_BASES = ['https://api.deepl.com/v2', 'https://api-free.deepl.com/v2'];
const MAX_TRANSLATE_CHARS = 20000;
const PROVIDER_TIMEOUT_MS = 15000;
const DEFAULT_CACHE_MAX = 100;
@ -47,10 +40,10 @@ function isTransientError(err) {
return !status || status >= 500 || status === 429;
}
async function callLibreTranslate(message, target, env, http) {
async function callLibreTranslate(message, target, env, http, format) {
const base = String(env.LIBRETRANSLATE_URL || 'http://libretranslate:5000');
if (!/^https?:\/\//.test(base)) throw failure('Local translation service is misconfigured.', 503, 'LIBRETRANSLATE_UNREACHABLE');
const response = await http.post(base + '/translate', { q: message, source: 'auto', target: target, format: 'text' }, { timeout: PROVIDER_TIMEOUT_MS });
const response = await http.post(base + '/translate', { q: message, source: 'auto', target: target, format: format === 'html' ? 'html' : 'text' }, { timeout: PROVIDER_TIMEOUT_MS });
const translated = response && response.data && response.data.translatedText;
if (!translated) throw failure('Local translation service returned no translation.', 502, 'LIBRETRANSLATE_EMPTY');
return String(translated);
@ -70,9 +63,8 @@ async function callDeepL(message, target, env, http) {
return String(translated);
}
async function attemptProvider(provider, message, target, env, http) {
if (provider === 'libretranslate') return callLibreTranslate(message, target, env, http);
return callDeepL(message, target, env, http);
async function attemptProvider(provider, message, target, env, http, format) {
return callLibreTranslate(message, target, env, http, format);
}
async function translateMessage(options) {
@ -88,23 +80,19 @@ async function translateMessage(options) {
if (!TRANSLATE_LANGS.includes(target)) throw failure('Unsupported translation language.', 400, 'INVALID_TRANSLATE');
var provider = opts.provider;
if (provider == null || provider === '') {
var configured = null;
if (typeof opts.getSetting === 'function') {
try { configured = await opts.getSetting('clinical_assistant.translate_provider', ''); } catch (e) { configured = null; }
}
provider = String(configured || 'libretranslate').toLowerCase();
if (opts.provider != null && opts.provider !== '' && !TRANSLATE_PROVIDERS.includes(String(opts.provider).toLowerCase())) {
throw failure('Unsupported translation provider.', 400, 'INVALID_TRANSLATE_PROVIDER');
}
if (!TRANSLATE_PROVIDERS.includes(provider)) throw failure('Unsupported translation provider.', 400, 'INVALID_TRANSLATE_PROVIDER');
if (provider === 'deepl' && !DEEPL_LANGS.includes(target)) throw failure('DeepL cannot translate into this language; choose the local provider.', 400, 'TARGET_UNSUPPORTED');
provider = 'libretranslate'; // the only provider — local translation always
const key = cacheKey(cache, provider, opts.userId, target, message);
const format = opts.format === 'html' ? 'html' : 'text';
const key = cacheKey(cache, provider, opts.userId, target, message + '\u0000' + format);
const hit = cacheGet(cache, key);
if (hit) return { translated: hit, provider: provider };
async function run(p) {
try {
const translated = await attemptProvider(p, message, target, env, http);
const translated = await attemptProvider(p, message, target, env, http, format);
cacheSet(cache, key, translated);
return { translated, provider: p };
} catch (err) {
@ -116,16 +104,7 @@ async function translateMessage(options) {
}
}
try {
return await run(provider);
} catch (firstError) {
// Fall back once, only for transient/provider-side failures of a configured other provider.
if (!firstError.transient) throw firstError;
const other = TRANSLATE_PROVIDERS.find(p => p !== provider);
if (!other || other === 'deepl' && (!String(env.DEEPL_API_KEY || '') || !DEEPL_BASES.includes(String(env.DEEPL_API_BASE || 'https://api.deepl.com/v2')))) throw firstError;
if (other === 'deepl' && !DEEPL_LANGS.includes(target)) throw firstError; // capability is validation, not fallback material
return run(other);
}
return run(provider);
}
@ -165,19 +144,16 @@ async function listAvailableLanguages(opts) {
throw failure('Local translation service rejected the language query.', 502, 'LIBRETRANSLATE_UNREACHABLE');
}
if (!codes.length) throw failure('Local translation service returned no languages.', 502, 'LIBRETRANSLATE_EMPTY');
const value = { libretranslate: codes, deepl: DEEPL_LANGS.slice() };
const value = { libretranslate: codes };
if (cache) languagesCacheSet(cache, provider, value);
return value;
}
if (provider === 'deepl') return { libretranslate: [], deepl: DEEPL_LANGS.slice() };
throw failure('Unknown translation provider.', 400, 'INVALID_PROVIDER');
}
module.exports = {
TRANSLATE_PROVIDERS,
TRANSLATE_LANGS,
DEEPL_LANGS,
DEEPL_BASES,
MAX_TRANSLATE_CHARS,
createTranslationCache,
createLanguageCache,

View file

@ -23,6 +23,23 @@ function imageContext(request, history) {
const checked = require('./clinicalConversation').checkConversation(history, request, Infinity);
return { request: checked.message, history: checked.history };
}
function isDefiniteImageError(err) {
var status = err && err.response ? Number(err.response.status) : NaN;
return Number.isFinite(status) && status >= 400 && status < 500 && status !== 408 && status !== 429;
}
function isAbortImageError(err, signal) {
return !!(signal && signal.aborted) || !!(err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED'));
}
function shouldRetryImageFallback(state) {
state = state || {};
if (state.workflow !== 'clinical_assistant') return false;
if (!state.fallback || state.fallback === state.jobModel) return false;
if (state.stopping) return false;
if (isAbortImageError(state.error, state.signal)) return false;
if (isDefiniteImageError(state.error)) return false;
return true;
}
function publicJob(job) {
const done = job.stage === 'done';
return { success: true, jobId: job.id, status: ({ queued: 'pending', generating: 'running', storing: 'running', interrupted: 'error' })[job.stage] || job.stage,
@ -59,13 +76,14 @@ function createImageService({ db, storage, generate = provider, encryption = req
await getStorage().ready();
} catch (_) { throw failure(503, 'Generated image storage or migration unavailable; no image provider request was sent'); }
}
async function snapshot(workflow, input, context) {
async function snapshot(workflow, input, context, modelOverride) {
if (!workflows.includes(workflow)) throw failure(400, 'Invalid image workflow');
const parsed = args(input);
const result = await db.query(`SELECT keys.key, s.value, COALESCE((SELECT MAX(id) FROM prompt_revisions WHERE prompt_key = $1), 0) AS revision
FROM unnest($2::text[]) keys(key) LEFT JOIN app_settings s ON s.key=keys.key`, [workflow + '.image_behavior', ['image_model', 'image_behavior', 'image_budget'].map(k => workflow + '.' + k)]);
FROM unnest($2::text[]) keys(key) LEFT JOIN app_settings s ON s.key=keys.key`, [workflow + '.image_behavior', ['image_model', 'image_behavior', 'image_budget', 'fallback_image_model'].map(k => workflow + '.' + k)]);
const settings = Object.fromEntries(result.rows.map(r => [r.key, r.value]));
const model = settings[workflow + '.image_model'] || (workflow === 'clinical_assistant' ? env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1' : '');
const model = modelOverride || settings[workflow + '.image_model'] || (workflow === 'clinical_assistant' ? env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1' : '');
const fallback = workflow === 'clinical_assistant' ? String(settings[workflow + '.fallback_image_model'] || '') : '';
if (!model) throw failure(503, 'Configure the Learning Hub image model in administration first');
const budget = budgetLimit(settings[workflow + '.image_budget']);
const bound = context ? imageContext(context.request, context.history) : imageContext(parsed.prompt);
@ -83,10 +101,13 @@ function createImageService({ db, storage, generate = provider, encryption = req
selected.push(turn); used += cost;
}
const rendered = mandatory + (selected.length ? header + selected.reverse().join('') : '') + suffix;
return { rendered, model, budget, included: selected.length, total: bound.history.length, revision: Number(result.rows[0]?.revision || 0) };
return { rendered, model, fallback, budget, included: selected.length, total: bound.history.length, revision: Number(result.rows[0]?.revision || 0) };
}
async function enqueue(owner, workflow, input, key, replay = false, context) {
async function enqueue(owner, workflow, input, key, replay = false, context, model) {
args(input);
if (model !== undefined && model !== null && model !== '') {
if (typeof model !== 'string' || !/^[a-zA-Z0-9_.:/\-]{1,200}$/.test(model)) throw failure(400, 'Invalid image model selection');
}
if (context) context = imageContext(context.request, context.history);
if (typeof key !== 'string' || !/^[a-zA-Z0-9:_-]{1,160}$/.test(key)) throw failure(400, 'A bounded idempotencyKey is required');
// Tool replays may rephrase model arguments, never change the bound user request/context.
@ -96,7 +117,7 @@ function createImageService({ db, storage, generate = provider, encryption = req
if (prior.rows[0].input_hash !== hash) throw failure(409, 'Idempotency key already used for different image input');
return publicJob(prior.rows[0]);
}
const config = await snapshot(workflow, input, context);
const config = await snapshot(workflow, input, context, model || undefined);
await ready();
const result = await db.query(`INSERT INTO generated_image_jobs (id,owner_id,workflow,idempotency_key,input_hash,prompt_cipher,model,prompt_revision,budget,prompt_units,context_included,context_total)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (owner_id,workflow,idempotency_key) DO NOTHING RETURNING *`,
@ -115,6 +136,26 @@ function createImageService({ db, storage, generate = provider, encryption = req
await client.query('COMMIT'); return result.rows[0];
} catch (e) { await client.query('ROLLBACK').catch(() => {}); throw e; } finally { client.release(); }
}
async function generateWithFallback(job, prompt, signal) {
try {
return await generate(job, prompt, signal);
} catch (err) {
var fallback = '';
if (job.workflow === 'clinical_assistant' && !stopping && !isAbortImageError(err, signal) && !isDefiniteImageError(err)) {
try {
const row = await db.query('SELECT value FROM app_settings WHERE key = $1', ['clinical_assistant.fallback_image_model']);
fallback = String(row.rows[0] && row.rows[0].value || '');
} catch (_) { fallback = ''; }
}
if (!shouldRetryImageFallback({ workflow: job.workflow, fallback: fallback, jobModel: job.model, stopping: stopping, error: err, signal: signal })) throw err;
console.warn('[generated-images] image generation failed; retrying once with the fallback image model', { jobId: job.id, model: job.model, fallbackModel: fallback });
const lease = await db.query("UPDATE generated_image_jobs SET lease_until=NOW()+interval '3 minutes' WHERE id=$1 AND lease_token=$2 AND stage='generating' RETURNING id",
[job.id, job.lease_token]);
if (!lease.rows.length) throw err;
return await generate(Object.assign({}, job, { model: fallback }), prompt, signal);
}
}
async function tick() {
// Reconcile paid ambiguity using PG alone, even during gateway/S3 outages. Never requeue payment.
await db.query("UPDATE generated_image_jobs SET stage='interrupted', error_code='provider_unknown', lease_token=NULL, lease_until=NULL, updated_at=NOW() WHERE stage='generating' AND lease_until < NOW()");
@ -134,13 +175,13 @@ function createImageService({ db, storage, generate = provider, encryption = req
let image;
if (job.stage === 'generating') {
try {
image = await generate(job, encryption.decryptString(job.prompt_cipher), controller.signal);
image = await generateWithFallback(job, encryption.decryptString(job.prompt_cipher), controller.signal);
image = storageUtil.inspect(image.bytes, image.mime);
const staged = await db.query("UPDATE generated_image_jobs SET stage='storing',staged_bytes=$3,mime=$4,checksum=$5,byte_length=$6,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='generating' AND lease_until > NOW() RETURNING id",
[job.id, job.lease_token, encryption.encryptBuffer(image.bytes), image.mime, image.checksum, image.bytes.length]);
if (!staged.rows.length) return; // fenced: stale workers cannot publish
} catch (e) {
const definite = e.response && e.response.status >= 400 && e.response.status < 500 && ![408, 429].includes(e.response.status);
const definite = isDefiniteImageError(e);
await db.query("UPDATE generated_image_jobs SET stage=$3,error_code=$4,lease_token=NULL,lease_until=NULL,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='generating'",
[job.id, job.lease_token, definite ? 'error' : 'interrupted', definite ? 'provider_rejected' : 'provider_unknown']);
return;
@ -200,4 +241,4 @@ function requestKey(body) {
}
return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
}
module.exports = { createImageService, service, budgetLimit, args, imageContext, IMAGE_OUTPUT_RULE, publicJob, requestKey, UUID, failure };
module.exports = { createImageService, service, budgetLimit, args, imageContext, IMAGE_OUTPUT_RULE, publicJob, requestKey, UUID, failure, isDefiniteImageError, isAbortImageError, shouldRetryImageFallback };

View file

@ -5,7 +5,7 @@ const tools = [{ type: 'function', function: { name: 'generate_image',
prompt: { type: 'string', minLength: 1, maxLength: 32000 },
layout: { type: 'string', enum: ['auto', 'portrait', 'landscape', 'square'] }
}, required: ['prompt'] } } }];
async function dispatch(ai, { owner, workflow, body, messages, options, callAI, images, imageContext }) {
async function dispatch(ai, { owner, workflow, body, messages, options, callAI, images, imageContext, imageModel }) {
if (!ai.toolCalls || !ai.toolCalls.length) return ai;
if (ai.toolCalls.length !== 1) throw failure(400, 'Only one image tool invocation is permitted per request');
const call = ai.toolCalls[0];
@ -16,7 +16,7 @@ async function dispatch(ai, { owner, workflow, body, messages, options, callAI,
args(input);
if (!imageContext) throw failure(400, 'Validated original image request and context are required for image tools');
images = images || service();
const job = await images.enqueue(owner, workflow, input, 'tool:' + requestKey(body), true, imageContext);
const job = await images.enqueue(owner, workflow, input, 'tool:' + requestKey(body), true, imageContext, imageModel);
let completed = ai;
if (!String(ai.content || '').trim()) {
// One FIRST-body continuation only. Existing body/citations are never sent for rewriting.

View file

@ -148,6 +148,7 @@ test('client restores attachments as thumbnails and saves them plus the generate
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
t.after(() => window.close());
context.bindEvents();
context.appendMessage('user', 'Question with image', [], [], false, { attachments: [png] });
let thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
assert.ok(thumbs, 'thumbnail strip rendered');
@ -163,8 +164,13 @@ test('client restores attachments as thumbnails and saves them plus the generate
context.restoreSavedChat(saves[0]);
thumbs = window.document.querySelector('.assistant-msg.user .assistant-message-attachments');
assert.ok(thumbs, 'attachments restored after reload');
const galleryImg = window.document.querySelector('#assistant-image-gallery img');
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the rail gallery');
// The user's image library lives inside the Create image popup.
window.document.getElementById('btn-assistant-create-image').click();
return new Promise(function(resolve) { setImmediate(resolve); }).then(function() { return new Promise(function(resolve) { setImmediate(resolve); }); }).then(function() { return new Promise(function(resolve) { setImmediate(resolve); }); }).then(function() {
const galleryWrap = window.document.querySelector('#create-image-history');
const galleryImg = window.document.querySelector('#create-image-history img');
assert.ok(galleryImg && galleryImg.getAttribute('src') === asset, 'generated image restored into the image history popup; popup=' + (window.document.getElementById('assistant-create-image-modal') ? 'open' : 'missing') + ' history=' + (galleryWrap ? galleryWrap.innerHTML.slice(0, 120) : 'missing'));
});
assert.equal(context.messages[0].content, 'Question with image', 'raw transcript canonical');
});
});

View file

@ -86,6 +86,17 @@ test('regenerate replays the preceding question for the latest answer and never
assert.match(all[all.length - 1].textContent, /Regenerated answer/);
});
test('editing a user message returns the question to the composer', async t => {
const app = ui(t);
const c = app.context;
c.appendMessage('user', '**Original** question?');
const row = app.document.querySelector('.assistant-msg.user');
const edit = row.querySelector('[data-assistant-msg-edit]');
assert.ok(edit, 'user messages offer Edit');
edit.click();
assert.equal(app.document.getElementById('assistant-input').value, '**Original** question?', 'raw question returns to the input');
});
test('saved-chat rows open on click; the hover trash deletes without opening', async t => {
const app = ui(t);
const c = app.context;

View file

@ -1,102 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { JSDOM } = require('jsdom');
const { marked } = require('marked');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
function ui(t) {
const dom = new JSDOM('<div id="assistant-tab">' + read('public/components/assistant.html') + '</div>', { url: 'https://example.test', runScripts: 'outside-only' });
const window = dom.window;
window.eval(read('public/js/accountBoundary.js'));
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
const style = window.document.createElement('style');
style.textContent = read('public/css/assistant.css');
window.document.head.appendChild(style);
window.marked = marked;
window.DOMPurify = require('dompurify')(window);
window.matchMedia = () => ({ matches: true });
const copies = [];
const downloads = [];
const context = { window, document: window.document, console, Blob, TextDecoder, AbortController,
URL: Object.assign(URL, { createObjectURL: () => 'blob:synthetic', revokeObjectURL() {} }),
navigator: { clipboard: { writeText: async text => { copies.push(text); } } },
setTimeout() {}, clearTimeout() {}, showToast() {}, EMPTY_PROMPT_SETS: [[]],
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }) };
vm.createContext(context);
for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) {
vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context);
}
window.HTMLAnchorElement.prototype.click = function() { downloads.push({ href: this.href, download: this.download }); };
t.after(() => window.close());
return { context, document: window.document, window, copies, downloads };
}
const table = '| Item | Value |\n| :--- | ---: |\n| Alpha | 1.25 |\n| Beta | 2-4 |';
function bubble(app) { return app.document.querySelector('.assistant-msg.assistant .assistant-bubble'); }
test('tables carry OWUI copy-raw and export-csv behavioral hooks outside the table element', t => {
const app = ui(t);
app.context.appendMessage('assistant', table, []);
const wrapper = bubble(app).querySelector('.assistant-table-scroll');
assert.ok(wrapper, 'wrapper present');
const copy = wrapper.querySelector('[data-assistant-table-copy]');
const csv = wrapper.querySelector('[data-assistant-table-csv]');
assert.ok(copy && csv, 'both action buttons present');
assert.equal(wrapper.querySelector('table [data-assistant-table-copy], table [data-assistant-table-csv]'), null, 'buttons live outside the table');
copy.click();
assert.equal(app.copies.length, 1);
assert.match(app.copies[0], /\| Item \| Value \|/);
assert.match(app.copies[0], /\| :?-{3} \| ---: \|/, 'alignment survives the raw reconstruction');
assert.match(app.copies[0], /\| Alpha \| 1\.25 \|/);
assert.match(app.copies[0], /\| Beta \| 2-4 \|/);
csv.click();
assert.equal(app.downloads.length, 1);
assert.equal(app.downloads[0].href, 'blob:synthetic');
assert.equal(app.downloads[0].download, 'table.csv');
});
test('CSV export serializes rendered cells with a BOM', t => {
const app = ui(t);
const created = [];
const OriginalBlob = app.context.Blob;
app.context.Blob = class extends OriginalBlob { constructor(parts, opts) { super(parts, opts); created.push({ parts, opts }); } };
app.context.appendMessage('assistant', table, []);
bubble(app).querySelector('[data-assistant-table-csv]').click();
assert.equal(created.length, 1);
assert.equal(created[0].opts.type, 'text/csv');
const text = String(created[0].parts[0]);
assert.ok(text.startsWith('\uFEFF'), 'CSV starts with a BOM');
assert.match(text, /Item,Value/);
assert.match(text, /Alpha,1\.25/);
});
test('code blocks gain a copy button that copies decoded code text', t => {
const app = ui(t);
app.context.appendMessage('assistant', '```js\nconst ref = "[1]";\n```', []);
const block = bubble(app).querySelector('.assistant-codeblock');
assert.ok(block, 'code block wrapper present');
const button = block.querySelector('[data-assistant-code-copy]');
assert.ok(button);
assert.equal(block.querySelector('code').textContent, 'const ref = "[1]";');
button.click();
assert.equal(app.copies.length, 1);
assert.equal(app.copies[0], 'const ref = "[1]";');
});
test('copy failures never crash the chat flow', t => {
const app = ui(t);
app.context.navigator = { clipboard: { writeText: async () => { throw new Error('denied'); } } };
app.context.appendMessage('assistant', '```md\nx\n```', []);
bubble(app).querySelector('[data-assistant-code-copy]').click();
app.context.appendMessage('assistant', table, []);
var bubbles = app.document.querySelectorAll('.assistant-msg.assistant .assistant-bubble');
bubbles[bubbles.length - 1].querySelector('[data-assistant-table-copy]').click();
assert.equal(bubbles[bubbles.length - 1].querySelectorAll('table').length, 1, 'chat keeps rendering');
});

View file

@ -46,7 +46,12 @@ test('translation validates message, target and provider before any provider con
await assert.rejects(translate(baseOptions({ message: 'x'.repeat(20001) })), e => e.statusCode === 400);
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'google', axios: stub.axios })), e => e.statusCode === 400);
assert.equal(stub.calls.length, 0);
assert.equal(stub.calls.length, 0, 'unknown providers are refused before contact');
// html format passes through to the local provider unchanged
const htmlStub = axiosStub(() => ({ data: { translatedText: '<p>Ok</p>' } }));
const html = await translate(baseOptions({ format: 'html', axios: htmlStub.axios }));
assert.equal(html.translated, '<p>Ok</p>');
assert.equal(htmlStub.calls[0].payload.format, 'html');
});
test('libretranslate is the local-first default and caches per provider+message+lang', async () => {
@ -66,78 +71,16 @@ test('libretranslate is the local-first default and caches per provider+message+
assert.equal(stub.calls.length, 2);
});
test('deepL posts form-encoded with the auth key header and uppercase target', async () => {
const stub = axiosStub(({ url, payload, opts }) => {
assert.equal(url, 'https://api.deepl.com/v2/translate');
assert.equal(opts.headers.Authorization, 'DeepL-Auth-Key synthetic-key');
assert.match(payload, /^text=Chest%20pain/);
assert.match(payload, /target_lang=ES$/);
return { data: { translations: [{ text: 'Dolor de pecho.' }] } };
});
const result = await translate(baseOptions({ provider: 'deepl', axios: stub.axios }));
assert.equal(result.translated, 'Dolor de pecho.');
assert.equal(result.provider, 'deepl');
});
test('deepl refuses languages it cannot map and is never contacted for them', async () => {
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'deepl', target: 'fa', axios: stub.axios })), e => e.statusCode === 400);
assert.equal(stub.calls.length, 0);
// libretranslate still accepts the same language
const ok = axiosStub(() => ({ data: { translatedText: 'ترجمه' } }));
const result = await translate(baseOptions({ target: 'fa', axios: ok.axios }));
assert.equal(result.provider, 'libretranslate');
});
test('transient provider failure falls back once; validation and 4xx never fall back', async () => {
const stub = axiosStub(({ url }) => {
if (url.startsWith('http://libretranslate')) throw new Error('ECONNREFUSED');
return { data: { translations: [{ text: 'DeepL fallback.' }] } };
});
const result = await translate(baseOptions({ axios: stub.axios }));
assert.equal(result.translated, 'DeepL fallback.');
assert.equal(result.provider, 'deepl');
assert.equal(stub.calls.length, 2);
const fourxx = axiosStub(({ url }) => {
if (url.startsWith('http://libretranslate')) { const err = new Error('Bad request'); err.response = { status: 400 }; throw err; }
throw new Error('must not fall back');
});
await assert.rejects(translate(baseOptions({ axios: fourxx.axios })), e => e.statusCode === 400 && /Bad request/.test(e.message));
assert.equal(fourxx.calls.length, 1, 'no fallback on provider 4xx');
const deepL403 = axiosStub(({ url }) => {
if (url.startsWith('https://api.deepl')) { const err = new Error('DeepL auth failed'); err.response = { status: 403 }; throw err; }
throw new Error('must not fall back');
});
await assert.rejects(translate(baseOptions({ provider: 'deepl', axios: deepL403.axios })), e => e.statusCode === 403);
assert.equal(deepL403.calls.length, 1, 'no fallback on DeepL 403');
// deepl down (429) falls back to local libretranslate
const quota = axiosStub(({ url }) => {
if (url.startsWith('https://api.deepl')) { const err = new Error('Too many requests'); err.response = { status: 429 }; throw err; }
return { data: { translatedText: 'Local fallback.' } };
});
const back = await translate(baseOptions({ provider: 'deepl', axios: quota.axios }));
assert.equal(back.provider, 'libretranslate');
assert.equal(back.translated, 'Local fallback.');
});
test('deepl without configuration surfaces 503 and never falls back when explicitly requested', async () => {
const stub = axiosStub(() => { throw new Error('must not be called'); });
await assert.rejects(translate(baseOptions({ provider: 'deepl', env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: stub.axios })), e => e.statusCode === 503 && e.code === 'DEEPL_NOT_CONFIGURED');
assert.equal(stub.calls.length, 0);
// default libretranslate down + deepl unconfigured → provider-side 502
const down = axiosStub(() => { throw new Error('ECONNREFUSED'); });
await assert.rejects(translate(baseOptions({ env: { LIBRETRANSLATE_URL: 'http://libretranslate:5000' }, axios: down.axios })), e => e.statusCode === 502);
});
test('translate route is owner-bound, validated and cached; admin default provider is honored', async () => {
const module = { exports: {} };
const calls = [];
const db = { async getSetting(key) { return key === 'clinical_assistant.translate_provider' ? 'deepl' : null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } };
const db = { async getSetting(key) { return null; }, async get() { return null; }, async run() { return { lastInsertRowid: 1 }; }, async query() { return { rows: [] }; } };
const mocks = {
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translations: [{ text: 'Traducción sintética.' }] } }; } }, crypto: require('node:crypto'),
express, axios: { async post(url, payload) { calls.push(String(url)); return { data: { translatedText: 'Traducción sintética.' } }; } }, crypto: require('node:crypto'),
'../db/database': db, '../middleware/auth': { authMiddleware() {} }, '../utils/ai': { callAI: async () => ({}), callAIStream: async () => ({}) },
'../utils/errors': { gatewayUrl: p => 'http://synthetic.invalid' + p }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} },
'../utils/crypto': { encryptString: v => 'encrypted:' + v, decryptString: v => v.replace(/^encrypted:/, '') },
@ -166,8 +109,8 @@ test('translate route is owner-bound, validated and cached; admin default provid
const ok = await request({ message: 'Chest pain.', target: 'es' });
assert.equal(ok.statusCode, 200);
assert.equal(ok.body.translated, 'Traducción sintética.');
assert.equal(ok.body.provider, 'deepl', 'admin default provider honored');
assert.match(calls[0], /api\.deepl\.com\/v2\/translate$/);
assert.equal(ok.body.provider, 'libretranslate', 'local provider is the only provider');
assert.match(calls[0], /libretranslate:5000\/translate$/);
const cached = await request({ message: 'Chest pain.', target: 'es' });
assert.equal(calls.length, 1, 'route cache hit');
const bad = await request({ message: 'Chest pain.', target: 'nope' });
@ -196,12 +139,13 @@ function client(t, options = {}) {
};
const context = { window, document: window.document, console, URL, Blob, TextDecoder, AbortController,
setTimeout() {}, clearTimeout() {}, showToast: (...args) => toasts.push(args), EMPTY_PROMPT_SETS: [[]],
getAuthHeaders: () => ({ 'Content-Type': 'application/json' }),
createAssistantImageStore: () => ({ clear() {}, renderGeneratedImage: () => '' }),
fetchSavedAssistantChats: async () => ({ success: true, chats: [] }),
saveAssistantChat: async () => ({ success: true }),
fetchAssistantStatus: async () => ({ success: true, translateProvider: 'libretranslate' }),
translateAssistantMessage: (message, target, provider) => apiFetch('/api/clinical-assistant/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider }), failEs: options.failEs
translateAssistantMessage: (message, target, provider, format) => apiFetch('/api/clinical-assistant/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, target, provider, format: format || 'text' }), failEs: options.failEs
}).then(function(r) { return r.json(); }),
fetch: apiFetch };
vm.createContext(context);
@ -222,19 +166,21 @@ test('per-message translate picker offers a provider choice and leaves the raw t
translateBtn.click();
const pop = app.document.querySelector('[data-assistant-translate-pop]');
assert.ok(pop, 'language picker opens');
assert.ok(pop.querySelector('[data-assistant-translate-provider="libretranslate"]'));
const deepl = pop.querySelector('[data-assistant-translate-provider="deepl"]');
assert.ok(deepl);
deepl.click();
assert.equal(pop.querySelector('[data-assistant-translate-provider]'), null, 'no provider choice — users pick a language only');
const es = pop.querySelector('[data-assistant-translate-lang="es"]');
assert.ok(es);
es.click();
await new Promise(r => setImmediate(r));
assert.equal(app.calls.length, 1);
assert.equal(app.calls[0].url, '/api/clinical-assistant/translate');
assert.deepEqual(JSON.parse(app.calls[0].options.body), { message: 'Chest pain in a four year old.', target: 'es', provider: 'deepl' });
const translateCalls = app.calls.filter(c => c.url === '/api/clinical-assistant/translate');
assert.equal(translateCalls.length, 1);
assert.equal(translateCalls[0].url, '/api/clinical-assistant/translate');
const sent = JSON.parse(translateCalls[0].options.body);
assert.equal(sent.target, 'es');
assert.equal(sent.provider, 'libretranslate');
assert.equal(sent.format, 'html');
assert.match(sent.message, /<p>Chest pain in a four year old\.<\/p>/);
const bubble = row.querySelector('.assistant-bubble');
assert.match(bubble.textContent, /Traducción de "Chest pain in a four year old\."/);
assert.match(bubble.textContent, /Traducción de "Chest pain in a four year old/);
assert.equal(c.messages[0].content, 'Chest pain in a four year old.', 'canonical transcript unchanged');
const showOriginal = bubble.querySelector('[data-assistant-msg-show-original]');
assert.ok(showOriginal);

View file

@ -49,14 +49,14 @@ test('assistant area is an OWUI-style three-column workspace with a slim go-back
const left = app.document.querySelector('.assistant-history');
assert.ok(left.querySelector('#assistant-saved-chats'), 'saved chats live in the left rail');
assert.equal(left.querySelector('#btn-assistant-clear') !== null, true, 'New chat sits at the top of the rail');
assert.equal(left.firstElementChild.id, 'btn-assistant-goback', 'Go back is the first rail element (ChatGPT-style), then Create image, then New chat');
assert.equal(left.firstElementChild.id, 'btn-assistant-create-image', 'Create image leads the rail, then New chat');
const right = app.document.querySelector('.assistant-side');
assert.equal(right.querySelector('#assistant-visual-output'), null, 'no image display in the right column — sources only');
assert.ok(right.querySelector('#assistant-sources'), 'sources panel in the right column');
assert.equal(right.querySelector('#assistant-saved-chats'), null, 'saved chats moved out of the right column');
const topbar = app.document.querySelector('.assistant-topbar');
assert.ok(topbar);
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-goback'), 'Go back lives at the top of the left rail (ChatGPT-style sidebar)');
assert.ok(app.document.querySelector('.assistant-topbar #btn-assistant-goback'), 'Go back is visible in the topbar');
assert.ok(app.document.querySelector('.assistant-history #btn-assistant-create-image'), 'Create image entry sits at the rail top');
const actions = app.document.querySelector('.assistant-toolbar-actions');
const buttons = [...actions.querySelectorAll('button')].map(b => b.id);

View file

@ -41,14 +41,15 @@ function server(t, overrides = {}) {
'../utils/crypto': { randomUUID: () => 'synthetic-uuid', encryptString: s => 'enc:' + s, decryptString: s => s.replace(/^enc:/, ''), encryptBuffer: b => b, decryptBuffer: b => b },
'../utils/redis': { get: async () => null, set: async () => {} },
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({ ready: async () => {} }) },
'../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), semanticSearch: async () => ({ sources: [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'),
'../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
'../utils/patientTakehome': require('../src/utils/patientTakehome'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'./auth': { __sendEmail: async (to, subject, html) => { emailCalls.push({ to, subject, html }); return overrides.smtpConfigured !== false; } },
'markdown-it': require('markdown-it'),
'../utils/litellm': { getLiteLLMHeaders: () => ({}) }
};
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
@ -111,6 +112,49 @@ test('patient take-home email reports honestly when SMTP is not configured', asy
assert.equal(res.body.code, 'SMTP_NOT_CONFIGURED');
});
test('explicit image requests enqueue a job even when the model only writes text', async t => {
const fs = require('node:fs'); const path = require('node:path');
const read = file => fs.readFileSync(path.join(__dirname, '..', file), 'utf8');
const vm = require('node:vm');
const module = { exports: {} };
const jobs = [];
const mocks = {
express: require('express'),
axios: {},
'../db/database': { get: async () => null, getSetting: async () => '', all: async () => [], run: async () => ({ changes: 1, lastInsertRowid: 1 }), query: async () => ({ rows: [] }) },
'../middleware/auth': { authMiddleware: (req, res, next) => next() },
'../utils/ai': { callAI: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }), callAIStream: async () => ({ content: 'Create a poster showing oxygen delivery for neonates.' }) },
'../utils/generatedImages': { service: () => ({ enqueue: async (owner, workflow, input, key, replay, context, model) => { jobs.push({ owner, workflow, input, model }); return { jobId: 'job-x', status: 'pending', imageUrl: null }; } }), imageContext: (r, h) => ({ request: r, history: h }), requestKey: b => 'k' + String(b).length },
'../utils/imageTool': { tools: [], dispatch: async ai => ai },
'../utils/generatedImageLinks': { validateChat: () => null },
'../utils/logger': { error() {}, audit() {}, warn() {} },
'../utils/crypto': { randomUUID: () => 'u', encryptString: v => v, decryptString: v => v },
'../utils/redis': { get: async () => null, set: async () => {} },
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) },
'../utils/clinicalMcpClient': { search: async () => ({ sources: [] }), semanticSearch: async () => ({ sources: [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }] }), multimodal: async () => ({ sources: [] }), warmup: async () => {} },
'../utils/clinicalRetrieval': { cleanSourceExcerpt: s => s, normalizeMcpSearchResponse: () => [{ number: 1, title: 'Synthetic', excerpt: 'Synthetic reference.' }], normalizeMcpMultimodalResponse: () => [], dedupeSources: v => v, isVisualSourceQuery: () => false, classifyAndRerankMultimodalResults: async () => [] },
'../utils/clinicalAnswer': require('../src/utils/clinicalAnswer'),
'../utils/clinicalConversation': require('../src/utils/clinicalConversation'),
'../utils/clinicalTranslation': require('../src/utils/clinicalTranslation'),
'../utils/patientTakehome': require('../src/utils/patientTakehome'),
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'),
'./auth': { __sendEmail: async () => false },
'markdown-it': require('markdown-it'),
'../utils/litellm': { getLiteLLMHeaders: () => ({}) }
};
vm.runInNewContext(read('src/routes/clinicalAssistant.js'), {
module, exports: module.exports, console: { log() {}, info() {}, error() {}, warn() {} }, Buffer, Map, TextEncoder,
process: { env: { CLINICAL_ASSISTANT_MCP_WARMUP: 'false', LIBRETRANSLATE_URL: 'http://libretranslate:5000' } },
setTimeout() {}, require(name) { if (!Object.hasOwn(mocks, name)) throw new Error('Unexpected import: ' + name); return mocks[name]; }
});
const layer = module.exports.stack.find(l => l.route && l.route.path === '/clinical-assistant/chat' && l.route.methods.post).route.stack.find(l => l.method === 'post').handle;
const res = { statusCode: 200, json(b) { this.body = b; return this; }, status(c) { this.statusCode = c; return this; }, set() {}, setHeader() {}, flushHeaders() {} };
await layer({ body: { message: 'create an image of oxygen delivery' }, user: { id: 7 }, ip: 'x' }, res);
assert.equal(jobs.length, 1, 'a real image job was enqueued despite the text-only answer — route status ' + res.statusCode + ' body ' + JSON.stringify(res.body));
assert.match(jobs[0].input.prompt, /poster/, 'the model answer text became the image prompt');
assert.equal(jobs[0].owner, 7, 'owner-bound');
});
test('patient take-home email sends plain text wrapped in a simple caregiver note', async t => {
const s = server(t);
const res = await s.request('post', '/clinical-assistant/patient-takehome/email', { to: 'parent@example.com', text: 'Give fluids & rest.' });
@ -233,7 +277,11 @@ test('Create image dialog: describe it or pick a chat; the latest chat is one ta
const job = app.calls.find(c => c.url === 'image-job');
assert.ok(job, 'generation requested');
assert.equal(job.prompt, 'Create a pediatric teaching visual from this conversation.');
// picking an older chat loads its messages
assert.ok(app.document.getElementById('assistant-create-image-modal'), 'the popup stays open while generating');
assert.match(app.document.getElementById('create-image-progress').textContent, /Generating/);
// close the popup, then reopen to pick an older chat
app.document.querySelector('[data-create-image-close]').click();
await new Promise(r => setImmediate(r));
app.document.getElementById('btn-assistant-create-image').click();
app.document.getElementById('create-image-chat').value = '2';
app.document.getElementById('btn-create-image-generate').click();