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' : '') }; return '
' + escapeAttr(alt || 'Generated image') + '' + '
' + '' + '' + '
'; } 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 = '
Generated clinical visual
'; 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); }