All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 2m39s
301 lines
13 KiB
JavaScript
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">×</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);
|
|
}
|