pediatric-ai-scribe-v3/public/js/assistant/images.js
Daniel 2cef65fb1f
Some checks failed
Forgejo Android APK / Build signed APK (push) Has been cancelled
Fix mobile assistant image downloads
2026-05-13 16:07:41 +02:00

218 lines
9.5 KiB
JavaScript

import { escapeAttr } from './citations.js';
export function createAssistantImageStore() {
var generatedImages = {};
var generatedImageSeq = 0;
var previewKeyHandler = null;
function renderGeneratedImage(src, alt) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = src;
return '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
'<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 src = generatedImages[id];
if (!src) 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(src) + '" alt="Generated clinical visual"><button type="button" class="assistant-image-modal-cancel">Close preview</button></div>';
modal.addEventListener('click', function (event) {
if (event.target === modal || event.target.closest('.assistant-image-modal-close') || event.target.closest('.assistant-image-modal-cancel')) closeImagePreview();
});
previewKeyHandler = function (event) { if (event.key === 'Escape') closeImagePreview(); };
document.addEventListener('keydown', previewKeyHandler);
document.body.appendChild(modal);
document.body.classList.add('assistant-image-preview-open');
}
function closeImagePreview() {
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
document.body.classList.remove('assistant-image-preview-open');
if (previewKeyHandler) document.removeEventListener('keydown', previewKeyHandler);
previewKeyHandler = null;
}
async function downloadImage(id) {
var src = generatedImages[id];
if (!src) return;
try {
if (await saveWithNativeShare(src)) 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;
}
await downloadWithBrowser(src);
} catch (e) {
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) {
if (isNativeApp()) {
if (await saveWithNativeImageBridge(src)) return true;
return await saveWithCapacitorShare(src);
}
var webShare = await shareWithWebFile(src);
if (webShare !== 'unavailable') return true;
return await saveWithCapacitorShare(src);
}
async function saveWithNativeImageBridge(src) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
var result = String(window.NativeFiles.saveImage(name, base64) || '');
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) {
if (typeof window.showToast === 'function') window.showToast(e.message || 'Native image save failed', 'error');
}
return false;
}
async function saveWithCapacitorShare(src) {
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 {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: src, dialogTitle: 'Save or share clinical visual' });
return true;
} catch (e) { return isShareCancel(e); }
}
if (!plugins.Filesystem) return false;
try {
var base64 = await imageSourceToBase64(src);
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
try {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
} catch (e) {
if (!isShareCancel(e)) throw e;
}
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) {
if (typeof window.showToast === 'function') window.showToast('Could not save with the app. Opening browser download instead.', 'error');
return false;
}
}
async function shareWithWebFile(src) {
if (!navigator.share || !navigator.canShare || typeof File === 'undefined') return 'unavailable';
try {
var blob = await imageSourceToBlob(src);
var file = new File([blob], 'clinical-visual.png', { type: blob.type || 'image/png' });
if (!navigator.canShare({ files: [file] })) return 'unavailable';
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return 'shared';
} catch (e) { return isShareCancel(e) ? 'cancelled' : 'unavailable'; }
}
async function downloadWithBrowser(src) {
var blob = await imageSourceToBlob(src);
var objectUrl = URL.createObjectURL(blob);
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() { URL.revokeObjectURL(objectUrl); }, 60000);
if (isMobileBrowser()) {
setTimeout(function() {
var opened = window.open(objectUrl, '_blank', 'noopener');
if (opened && typeof window.showToast === 'function') {
window.showToast('If the download did not start, long-press the opened image and save it.', 'success');
}
}, 300);
}
}
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) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return new Promise(function(resolve, reject) {
var reader = new FileReader();
reader.onload = function() { resolve(String(reader.result || '').split(',')[1] || ''); };
reader.onerror = function() { reject(reader.error || new Error('Could not read image')); };
reader.readAsDataURL(blob);
});
}
async function imageSourceToBlob(src) {
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' });
}
var response = await fetch(src, { credentials: 'omit' });
if (!response.ok) throw new Error('Image download failed');
return response.blob();
}
export function buildContextualImagePrompt(request, lastAnswer, lastSources) {
var prompt = String(request || '').trim();
if (!lastAnswer) return prompt;
var sourceText = (lastSources || []).slice(0, 6).map(function (s, idx) {
return '[' + (s.number || idx + 1) + '] ' + (s.title || s.resource || 'Source') + (s.page ? ', page ' + s.page : '');
}).join('\n');
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.\n\nAnswer:\n' + String(lastAnswer || '').slice(0, 3000) + '\n\nSources:\n' + sourceText;
}
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);
}