Generated assets are served `private, no-store`, which is right for a clinical app on a shared workstation — but it meant every gallery render re-downloaded every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px tiles, on every open of the Create image popup. Two changes, both reusable anywhere in the app: - A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to disk and the no-store posture is unchanged. Entries are keyed by owner ticket as well as asset, and cleared when the account boundary moves, so one account can never read another's bytes out of memory. Concurrent tiles asking for the same asset share one request rather than racing. - Any img carrying data-image-thumb gets a downscaled copy instead of the original, so a 56px tile no longer decodes a 300kB image. The gallery asks for 256px and the in-chat preview for 640px; opening the full view still gets the original. Browsers without OffscreenCanvas, and any decode failure, fall back to the full image rather than showing nothing. This does not reduce the first fetch. Serving genuinely smaller bytes needs server-side resizing, which needs an image library this project does not carry — worth a deliberate decision rather than adding a native dependency in passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
414 lines
17 KiB
JavaScript
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) + '" data-image-thumb="640" 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">×</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);
|
|
}
|