diff --git a/public/components/assistant.html b/public/components/assistant.html index cb3b5d0..404a516 100644 --- a/public/components/assistant.html +++ b/public/components/assistant.html @@ -1,4 +1,5 @@
+

AI Clinical Assistant

@@ -11,7 +12,6 @@
-
@@ -61,6 +53,9 @@
+
+ +
diff --git a/public/css/assistant.css b/public/css/assistant.css index 3e4aa70..36f64ab 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -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; } diff --git a/public/index.html b/public/index.html index 1d6d3ec..9e0a6ba 100644 --- a/public/index.html +++ b/public/index.html @@ -356,11 +356,6 @@
- '; 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 = '

Your generated images will appear here.

'; + return; + } + wrap.innerHTML = done.map(function (job) { + return ''; + }).join('') + running.map(function (job) { + return ''; + }).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 = '
' + - '' + - '' + - '
' + - '
' + + // Simple for users: pick a language, the server picks the provider + // (local LibreTranslate by default; DeepL only when the admin configured it). + pop.innerHTML = '
' + TRANSLATE_LANGUAGES.map(function(pair) { return ''; }).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 = '

' + escapeHtml(data.translated) + '

' + + bubble.innerHTML = sanitize(String(data.translated || '')) + ''; + 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 = '' + '' + - (role === 'assistant' ? '' : ''); + (role === 'assistant' + ? '' + : ''); row.appendChild(actions); wrap.scrollTop = wrap.scrollHeight; return row; diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 19dd135..454d440 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -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', - '
' + - '

Patient Take Home

' + - '

' + String(text).replace(/&/g,'&').replace(//g,'>') + '

' + - '

This summary was created for a caregiver. Keep following your care team’s instructions.

'); + '
' + + '
Patient Take Home
' + + '
' + bodyHtml + '
' + + '

This summary was created for a caregiver. Keep following your care team’s instructions.

'); 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; diff --git a/src/utils/clinicalTranslation.js b/src/utils/clinicalTranslation.js index 472dbb9..72278ec 100644 --- a/src/utils/clinicalTranslation.js +++ b/src/utils/clinicalTranslation.js @@ -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, diff --git a/src/utils/generatedImages.js b/src/utils/generatedImages.js index 621eb6a..c657649 100644 --- a/src/utils/generatedImages.js +++ b/src/utils/generatedImages.js @@ -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 }; diff --git a/src/utils/imageTool.js b/src/utils/imageTool.js index 6fe09be..26baa8c 100644 --- a/src/utils/imageTool.js +++ b/src/utils/imageTool.js @@ -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. diff --git a/test/assistant-attachment-roundtrip.test.js b/test/assistant-attachment-roundtrip.test.js index 7ab5124..cfa31f3 100644 --- a/test/assistant-attachment-roundtrip.test.js +++ b/test/assistant-attachment-roundtrip.test.js @@ -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'); }); }); diff --git a/test/assistant-message-actions.test.js b/test/assistant-message-actions.test.js index aefd357..ff4065e 100644 --- a/test/assistant-message-actions.test.js +++ b/test/assistant-message-actions.test.js @@ -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; diff --git a/test/assistant-table-actions.test.js b/test/assistant-table-actions.test.js deleted file mode 100644 index fa61759..0000000 --- a/test/assistant-table-actions.test.js +++ /dev/null @@ -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('
' + read('public/components/assistant.html') + '
', { 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'); -}); diff --git a/test/assistant-translate.test.js b/test/assistant-translate.test.js index 70918df..9b5b3a4 100644 --- a/test/assistant-translate.test.js +++ b/test/assistant-translate.test.js @@ -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: '

Ok

' } })); + const html = await translate(baseOptions({ format: 'html', axios: htmlStub.axios })); + assert.equal(html.translated, '

Ok

'); + 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, /

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); diff --git a/test/assistant-workspace-layout.test.js b/test/assistant-workspace-layout.test.js index 08311d4..c3b4c03 100644 --- a/test/assistant-workspace-layout.test.js +++ b/test/assistant-workspace-layout.test.js @@ -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); diff --git a/test/patient-takehome.test.js b/test/patient-takehome.test.js index 283ced9..4ad14fd 100644 --- a/test/patient-takehome.test.js +++ b/test/patient-takehome.test.js @@ -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();