' +
+ // 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 '
' + escapeHtml(pair[1]) + ' ';
}).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 || '')) +
'
Show original';
+ 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();