pediatric-ai-scribe-v3/public/js/assistant/images.js
Daniel d04a3fe53b fix: translate as HTML so tables and emphasis survive; stop repeat image generation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkfrkQwA4YGrGw9LZSpeAq
2026-09-09 18:49:58 +02:00

414 lines
17 KiB
JavaScript

import { assetPath, privateImageBlob, hydrateImage, transientImageUrl, revokeImageUrl } from '../generatedImages.js';
import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js';
import { escapeAttr } from './citations.js';
export function createAssistantImageStore() {
var generatedImages = {};
var generatedImageSeq = 0;
var previewClose = null;
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || (assetPath(src) ? src + '?download=1' : '') };
// 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 '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') +
'" data-assistant-open-image="' + escapeAttr(id) + '" title="Click to view full resolution" tabindex="0" role="button">' +
'<div class="assistant-image-actions">' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
'<button type="button" class="btn-sm btn-ghost" data-assistant-download-image="' + escapeAttr(id) + '"><i class="fas fa-download"></i> Download</button>' +
'</div></div>';
}
function openImagePreview(id) {
var item = generatedImages[id];
var src = item && (item.src || item);
if (!src) return;
var owner;
try { owner = captureSharingOwner(); } catch (e) { return; }
closeImagePreview();
var modal = document.createElement('div');
modal.className = 'assistant-image-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.innerHTML = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">&times;</button><img src="' + escapeAttr(assetPath(src) ? '' : src) + '" alt="Generated clinical visual"><button type="button" class="assistant-image-modal-cancel">Close preview</button></div>';
function close() {
modal.remove();
owner.signal.removeEventListener('abort', close);
document.removeEventListener('keydown', keyHandler);
if (previewClose === close) {
previewClose = null;
document.body.classList.remove('assistant-image-preview-open');
}
}
function keyHandler(event) { if (event.key === 'Escape') close(); }
previewClose = close;
owner.signal.addEventListener('abort', close, { once: true });
modal.addEventListener('click', function (event) {
if (event.target === modal || event.target.closest('.assistant-image-modal-close') || event.target.closest('.assistant-image-modal-cancel')) close();
});
document.addEventListener('keydown', keyHandler);
document.body.appendChild(modal);
document.body.classList.add('assistant-image-preview-open');
if (assetPath(src)) hydrateImage(modal.querySelector('img'), src, owner).catch(function(e) {
if (!validSharingOwner(owner) || e.name === 'AbortError') { close(); return; }
modal.querySelector('img').alt = 'Private image unavailable';
});
}
function closeImagePreview() {
if (previewClose) previewClose();
}
async function downloadImage(id) {
var item = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return;
try {
var ticket = captureSharingOwner();
if (downloadUrl) {
await downloadFromServer(downloadUrl, ticket);
assertSharingOwner(ticket);
return;
}
var saved = await saveWithNativeShare(src, ticket);
assertSharingOwner(ticket);
if (saved) return;
if (isNativeApp()) {
if (typeof window.showToast === 'function') window.showToast('Image saving is not available in this app build yet. Update the app and try again.', 'error');
return;
}
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
await downloadWithBrowser(src, ticket);
assertSharingOwner(ticket);
} catch (e) {
if (!validSharingOwner(ticket) || e.name === 'AbortError') return;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Image download failed', 'error');
}
}
function clear() {
generatedImages = {};
closeImagePreview();
}
return {
renderGeneratedImage: renderGeneratedImage,
openImagePreview: openImagePreview,
downloadImage: downloadImage,
closeImagePreview: closeImagePreview,
clear: clear
};
}
async function saveWithNativeShare(src, ticket) {
assertSharingOwner(ticket);
if (isNativeApp()) {
var nativeSaved = await saveWithNativeImageBridge(src, ticket);
assertSharingOwner(ticket);
if (nativeSaved) return true;
} else {
var webShare = await shareWithWebFile(src, ticket);
assertSharingOwner(ticket);
if (webShare !== 'unavailable') return true;
}
var saved = await saveWithCapacitorShare(src, ticket);
assertSharingOwner(ticket);
return saved;
}
async function saveWithNativeImageBridge(src, ticket) {
assertSharingOwner(ticket);
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await imageSourceToBase64(src, ticket);
assertSharingOwner(ticket);
var name = sharingFilename('clinical-visual', 'png');
var result = String(window.NativeFiles.saveImage(name, base64) || '');
assertSharingOwner(ticket);
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
if (result.indexOf('error:') === 0 && typeof window.showToast === 'function') {
window.showToast(result.slice(6) || 'Native image save failed', 'error');
}
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native image save failed', 'error');
}
return false;
}
async function saveWithCapacitorShare(src, ticket) {
assertSharingOwner(ticket);
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins) return false;
if (!plugins.Filesystem && plugins.Share && !/^data:image\//i.test(src)) {
try {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: src, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
return true;
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return isShareCancel(e);
}
}
if (!plugins.Filesystem) return false;
try {
var base64 = await imageSourceToBase64(src, ticket);
assertSharingOwner(ticket);
var name = sharingFilename('clinical-visual', 'png');
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
assertSharingOwner(ticket);
if (plugins.Share && saved && saved.uri) {
try {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (!isShareCancel(e)) throw e;
}
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Use Preview and long-press the image to save it.', 'error');
return false;
}
}
async function shareWithWebFile(src, ticket) {
assertSharingOwner(ticket);
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return 'unavailable';
try {
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var file = new File([blob], 'clinical-visual.png', { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return 'unavailable';
assertSharingOwner(ticket);
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
assertSharingOwner(ticket);
return 'shared';
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return 'unavailable';
}
}
async function downloadWithBrowser(src, ticket) {
assertSharingOwner(ticket);
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Mobile browser download is not supported here. Use Preview and long-press the image to save it.', 'info');
return;
}
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var objectUrl = transientImageUrl(blob, ticket);
var a = document.createElement('a');
a.href = objectUrl;
a.download = 'clinical-visual.png';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { revokeImageUrl(objectUrl); }, 60000);
}
async function downloadFromServer(url, ticket) {
assertSharingOwner(ticket);
var blob;
if (assetPath(url)) {
blob = await privateImageBlob(url, ticket);
assertSharingOwner(ticket);
} else {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin', signal: ticket.signal });
assertSharingOwner(ticket);
if (!response.ok) throw new Error('Image download failed');
blob = await response.blob();
assertSharingOwner(ticket);
}
var name = sharingFilename('clinical-visual', 'png');
var saved = await saveBlobWithNativeImageBridge(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
saved = await saveBlobWithCapacitorShare(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
saved = await shareBlobWithWebFile(blob, name, ticket);
assertSharingOwner(ticket);
if (saved) return;
if (isMobileBrowser()) {
if (typeof window.showToast === 'function') window.showToast('Could not start download here. Use Preview and long-press the image to save it.', 'error');
return;
}
assertSharingOwner(ticket);
downloadBlobWithAnchor(blob, name, ticket);
}
function authHeadersForDownload() {
var headers = window.getAuthHeaders ? window.getAuthHeaders() : {};
var clean = {};
Object.keys(headers || {}).forEach(function(key) {
if (key.toLowerCase() !== 'content-type') clean[key] = headers[key];
});
return clean;
}
async function saveBlobWithNativeImageBridge(blob, name, ticket) {
assertSharingOwner(ticket);
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
var result = String(window.NativeFiles.saveImage(name, base64) || '');
assertSharingOwner(ticket);
if (result.indexOf('saved:') === 0) {
if (typeof window.showToast === 'function') window.showToast('Image saved to Photos', 'success');
return true;
}
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
}
return false;
}
async function saveBlobWithCapacitorShare(blob, name, ticket) {
assertSharingOwner(ticket);
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
assertSharingOwner(ticket);
if (plugins.Share && saved && saved.uri) {
assertSharingOwner(ticket);
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
assertSharingOwner(ticket);
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return false;
}
}
async function shareBlobWithWebFile(blob, name, ticket) {
assertSharingOwner(ticket);
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return false;
try {
var file = new File([blob], name, { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return false;
assertSharingOwner(ticket);
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
assertSharingOwner(ticket);
return true;
} catch (e) {
assertSharingOwner(ticket);
if (e.name === 'AbortError') throw e;
return false;
}
}
function downloadBlobWithAnchor(blob, name, ticket) {
assertSharingOwner(ticket);
var objectUrl = transientImageUrl(blob, ticket);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { revokeImageUrl(objectUrl); }, 60000);
}
function isMobileBrowser() {
return !isNativeApp() && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
}
function isNativeApp() {
return !!(window.Capacitor && (!window.Capacitor.isNativePlatform || window.Capacitor.isNativePlatform()));
}
function isShareCancel(error) {
var message = String(error && (error.message || error.name) || '').toLowerCase();
return /cancel|abort|dismiss|user denied|share canceled/.test(message);
}
async function imageSourceToBase64(src, ticket) {
assertSharingOwner(ticket);
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src, ticket);
assertSharingOwner(ticket);
var base64 = await blobToBase64(blob, ticket);
assertSharingOwner(ticket);
return base64;
}
async function blobToBase64(blob, ticket) {
assertSharingOwner(ticket);
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() {
try { assertSharingOwner(ticket); resolve(String(reader.result || '').split(',')[1] || ''); } catch (e) { reject(e); }
};
reader.onerror = function() { reject(reader.error || new Error('Could not read image')); };
reader.readAsDataURL(blob);
});
}
async function imageSourceToBlob(src, ticket) {
assertSharingOwner(ticket);
if (/^data:image\//i.test(src)) {
var parts = src.split(',');
var meta = parts[0] || 'data:image/png;base64';
var binary = atob(parts[1] || '');
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: (meta.match(/data:([^;]+)/) || [])[1] || 'image/png' });
}
if (assetPath(src)) {
var privateBlob = await privateImageBlob(src, ticket);
assertSharingOwner(ticket);
return privateBlob;
}
var response = await fetch(src, { credentials: 'omit', signal: ticket.signal });
assertSharingOwner(ticket);
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
assertSharingOwner(ticket);
return blob;
}
export function buildContextualImagePrompt(request, lastAnswer) {
var prompt = String(request || '').trim();
if (!lastAnswer) return prompt;
var answer = String(lastAnswer || '').replace(/\[\d+(?:\s*,\s*\d+)*\]/g, '').slice(0, 3000);
return prompt + '\n\nUse this clinical answer as the required context. Do not switch topics or introduce unrelated scenes such as gardening. Create a medical teaching visual faithful to the answer. Do not include citations, reference numbers, footnotes, source lists, or named organizations; do not invent references.\n\nAnswer:\n' + answer;
}
export function isImageRequest(text) {
text = String(text || '').trim();
var visualNoun = /\b(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)\b/i;
return /^(image|photo|picture|visual|illustration|diagram|figure|flowchart|infographic)$/i.test(text) ||
(/\b(show|see|display|view)\b/i.test(text) && visualNoun.test(text) && text.split(/\s+/).length <= 8) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(image|picture|illustration|visual|infographic|diagram|flowchart)\b/i.test(text) ||
/\b(generate|create|draw|make|render)\b[\s\S]{0,80}\b(an?|the)?\s*(algorithm|pathway|poster|teaching visual)\b/i.test(text);
}