pediatric-ai-scribe-v3/public/js/assistant/images.js
Daniel bee9361c1d
All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
Fix authenticated mobile image downloads
2026-06-09 16:02:11 +02:00

301 lines
13 KiB
JavaScript

import { escapeAttr } from './citations.js';
export function createAssistantImageStore() {
var generatedImages = {};
var generatedImageSeq = 0;
var previewKeyHandler = null;
function renderGeneratedImage(src, alt, downloadUrl) {
var id = 'img-' + (++generatedImageSeq);
generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' };
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 item = generatedImages[id];
var src = item && (item.src || item);
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 item = generatedImages[id];
var src = item && (item.src || item);
var downloadUrl = item && item.downloadUrl;
if (!src) return;
try {
if (downloadUrl) {
await downloadFromServer(downloadUrl);
return;
}
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;
}
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);
} 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. Use Preview and long-press the image to save it.', '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) ? 'unavailable' : 'unavailable'; }
}
async function downloadWithBrowser(src) {
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);
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);
}
async function downloadFromServer(url) {
var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin' });
if (!response.ok) throw new Error('Image download failed');
var blob = await response.blob();
var name = 'clinical-visual-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19) + '.png';
if (await saveBlobWithNativeImageBridge(blob, name)) return;
if (await saveBlobWithCapacitorShare(blob, name)) return;
if (await shareBlobWithWebFile(blob, name)) 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;
}
downloadBlobWithAnchor(blob, name);
}
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) {
if (!window.NativeFiles || typeof window.NativeFiles.saveImage !== 'function') return false;
try {
var base64 = await blobToBase64(blob);
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;
}
} catch (e) {}
return false;
}
async function saveBlobWithCapacitorShare(blob, name) {
var plugins = window.Capacitor && window.Capacitor.Plugins ? window.Capacitor.Plugins : null;
if (!plugins || !plugins.Filesystem) return false;
try {
var base64 = await blobToBase64(blob);
var saved = await plugins.Filesystem.writeFile({ path: name, data: base64, directory: 'CACHE' });
if (plugins.Share && saved && saved.uri) {
await plugins.Share.share({ title: 'Clinical visual', text: 'Clinical Assistant generated visual', url: saved.uri, dialogTitle: 'Save or share clinical visual' });
}
if (typeof window.showToast === 'function') window.showToast('Image prepared', 'success');
return true;
} catch (e) { return false; }
}
async function shareBlobWithWebFile(blob, name) {
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;
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
return true;
} catch (e) { return false; }
}
function downloadBlobWithAnchor(blob, name) {
var objectUrl = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = objectUrl;
a.download = name;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(function() { URL.revokeObjectURL(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) {
if (/^data:image\//i.test(src)) return src.split(',')[1] || '';
var blob = await imageSourceToBlob(src);
return blobToBase64(blob);
}
async function blobToBase64(blob) {
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);
}