From d04a3fe53b495b692aa2565303c2112629b8725f Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 9 Sep 2026 18:49:58 +0200 Subject: [PATCH] fix: translate as HTML so tables and emphasis survive; stop repeat image generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translation formatting LibreTranslate's text mode destroys markdown syntax. Verified against the live container: table pipes come back as "←", the |---| delimiter row is translated as prose ("Silencio."), and "**bold**" returns as "** bold**" which no longer renders. Its html mode leaves tags — and bare [n] markers — completely intact. Messages and the patient take home are now rendered to HTML, simplified (maths and UI chrome flattened to text), and translated as HTML. Citation chips are re-linked from the returned markers afterwards, which is the step the original html path was missing. A text-mode fallback remains for builds that reject html. Repeat image generation Typing "Окей" or "Nice" after an image turn produced another image every time: the model saw its own "I'll generate an educational image…" in the history and repeated it. Recognising acknowledgements in every language is not possible, so the rule is inverted — a short follow-up (<=3 words) that mentions nothing about a picture does not get the image tool offered at all when the previous assistant turn produced an image. Terse repeat requests ("again", "ещё", "another one") still work. The worst case is that a terse question is answered in text. In-chat images Generated images render as a 320x240 thumbnail instead of filling the bubble, and the image itself opens the full-resolution preview. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq --- public/css/assistant.css | 7 +- public/js/assistant/citations.js | 2 +- public/js/assistant/images.js | 5 +- public/js/clinicalAssistant.js | 123 ++++++++++++++++++---------- src/routes/clinicalAssistant.js | 40 ++++++++- test/assistant-image-intent.test.js | 66 +++++++++++++++ test/assistant-translate.test.js | 15 ++-- test/patient-takehome.test.js | 5 +- 8 files changed, 209 insertions(+), 54 deletions(-) diff --git a/public/css/assistant.css b/public/css/assistant.css index 7f630b8a..b699cc5b 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -121,8 +121,11 @@ /* Sources: a full-page right column; the panel fills the height and scrolls */ .assistant-side { display:flex; flex-direction:column; gap:12px; height:100%; 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-generated-image { display:grid; gap:8px; justify-items:start; } +/* Thumbnail in the transcript; Preview (or clicking it) opens full resolution. */ +.assistant-generated-image img { width:auto; max-width:min(100%,320px); max-height:240px; object-fit:contain; border-radius:10px; border:1px solid var(--g200); background:white; cursor:zoom-in; } +.assistant-generated-image img:focus-visible { outline:2px solid var(--blue); outline-offset:2px; } +@media (max-width:960px) { .assistant-generated-image img { max-width:100%; max-height:200px; } } .assistant-image-actions { display:flex; gap:8px; flex-wrap:wrap; } .assistant-image-preview-open { overflow:hidden; } .assistant-image-modal { position:fixed; inset:0; z-index:9999; background:rgba(15,23,42,.82); display:flex; align-items:center; justify-content:center; padding:24px; } diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index c33db861..1d873979 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -82,7 +82,7 @@ export function renderAssistantMarkdown(md, sources, options) { return typeof opts.sanitize === 'function' ? opts.sanitize(html) : html; } -function wrapTables(html) { +export function wrapTables(html) { return String(html || '') .replace(/]*)?>/g, '
') .replace(/<\/table>/g, '
'); diff --git a/public/js/assistant/images.js b/public/js/assistant/images.js index 171675e3..36e25987 100644 --- a/public/js/assistant/images.js +++ b/public/js/assistant/images.js @@ -10,7 +10,10 @@ export function createAssistantImageStore() { function renderGeneratedImage(src, alt, downloadUrl) { var id = 'img-' + (++generatedImageSeq); generatedImages[id] = { src: src, downloadUrl: downloadUrl || (assetPath(src) ? src + '?download=1' : '') }; - return '
' + escapeAttr(alt || 'Generated image') + '' + + // The in-chat image is a thumbnail; full resolution is one click away, so a + // long answer is not pushed off the screen by the picture that illustrates it. + return '
' + escapeAttr(alt || 'Generated image') +
+      '' + '
' + '' + '' + diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 846c131d..3bf0cca1 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -4,7 +4,7 @@ // server can call native MCP directly without routing through mcpo. // ============================================================ import { EMPTY_PROMPT_SETS } from './assistant/data.js'; -import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl } from './assistant/citations.js'; +import { escapeAttr, escapeHtml, renderAssistantMarkdown, renderCitationLinks, safeImageUrl, wrapTables } from './assistant/citations.js'; import { renderSourcesList } from './assistant/sources.js'; import { createAssistantExporter } from './assistant/export.js'; import { createAssistantImageStore } from './assistant/images.js'; @@ -1506,13 +1506,24 @@ import { // ── Patient take home: plain-language summary + copy/export/email ──── var takehomeBusy = false; - var takehomeText = ''; // canonical original, never overwritten - var takehomeTranslated = ''; // current translation, '' when showing the original + var takehomeText = ''; // canonical original markdown, never overwritten + var takehomeTranslatedHtml = ''; // translated HTML, '' when showing the original var takehomeLang = ''; - // Copy / Export / Email must hand over what the parent is actually reading. + // The take home is translated as HTML for the same reason chat messages are: + // LibreTranslate's text mode destroys tables and emphasis markers. + function takehomeVisibleHtml() { + return takehomeTranslatedHtml + ? wrapTables(sanitize(takehomeTranslatedHtml)) + : renderMarkdown(takehomeText, [], {}); + } + + // Copy / Export / Email must hand over what the caregiver is actually reading. function takehomeVisibleText() { - return takehomeTranslated || takehomeText; + if (!takehomeTranslatedHtml) return takehomeText; + var holder = document.createElement('div'); + holder.innerHTML = sanitize(takehomeTranslatedHtml); + return String(holder.textContent || '').replace(/\n{3,}/g, '\n\n').trim(); } function openPatientTakehome() { @@ -1524,7 +1535,7 @@ import { closePatientTakehomeModal(); takehomeBusy = true; takehomeText = ''; - takehomeTranslated = ''; + takehomeTranslatedHtml = ''; takehomeLang = ''; var modal = document.createElement('div'); modal.className = 'assistant-takehome-modal'; @@ -1560,7 +1571,7 @@ import { if (result) { // Same universal markdown renderer as chat bubbles; raw text stays // canonical for Copy/Export/email. - result.innerHTML = renderMarkdown(takehomeText, [], {}); + result.innerHTML = takehomeVisibleHtml(); result.classList.remove('hidden'); } if (actions) actions.classList.remove('hidden'); @@ -1584,7 +1595,7 @@ import { function renderTakehomeBody() { var result = document.querySelector('#assistant-takehome-modal .assistant-takehome-result'); - if (result) result.innerHTML = renderMarkdown(takehomeVisibleText(), [], {}); + if (result) result.innerHTML = takehomeVisibleHtml(); } function translateTakehome(target) { @@ -1592,16 +1603,16 @@ import { if (!takehomeText) return; if (!target) { // back to the original takehomeLang = ''; - takehomeTranslated = ''; + takehomeTranslatedHtml = ''; renderTakehomeBody(); return; } if (select) select.disabled = true; - translateAssistantMessage(takehomeText, target, translateProvider, 'text') + translateAssistantMessage(simplifyHtmlForTranslation(renderMarkdown(takehomeText, [], {})), target, translateProvider, 'html') .then(function(data) { if (!data.success) throw new Error(data.error || 'Translation failed'); takehomeLang = target; - takehomeTranslated = String(data.translated || ''); + takehomeTranslatedHtml = String(data.translated || ''); renderTakehomeBody(); }) .catch(function(err) { @@ -1621,7 +1632,7 @@ import { return; } assertSharingOwner(owner); - var html = renderMarkdown(takehomeVisibleText(), [], {}); + var html = takehomeVisibleHtml(); doc.document.write('Patient Take Home' + '