fix clinical assistant mobile exports
This commit is contained in:
parent
2f49841327
commit
0dc71e3c35
6 changed files with 299 additions and 28 deletions
|
|
@ -90,10 +90,11 @@
|
|||
.assistant-status.busy .assistant-dot { background:var(--amber); animation:pulse 1.5s infinite; }
|
||||
.assistant-status.error .assistant-dot { background:var(--red); }
|
||||
.assistant-layout { display:grid; grid-template-columns:minmax(0,1fr) 330px; gap:14px; align-items:start; }
|
||||
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); }
|
||||
.assistant-layout > * { min-width:0; }
|
||||
.assistant-main { display:grid; grid-template-rows:auto minmax(420px,1fr) auto; min-height:calc(100vh - 190px); min-width:0; }
|
||||
.assistant-toolbar { display:flex; justify-content:space-between; align-items:center; gap:10px; padding:10px 14px; border-bottom:1px solid var(--g200); background:var(--g50); }
|
||||
.assistant-toolbar-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.assistant-messages { padding:16px; overflow-y:auto; background:linear-gradient(180deg,#fff,var(--g50)); }
|
||||
.assistant-messages { padding:16px; overflow-y:auto; overflow-x:hidden; background:linear-gradient(180deg,#fff,var(--g50)); min-width:0; }
|
||||
.assistant-empty { max-width:680px; margin:50px auto; text-align:center; color:var(--g500); }
|
||||
.assistant-empty i { font-size:34px; color:var(--purple); margin-bottom:10px; }
|
||||
.assistant-empty h3 { color:var(--g800); font-size:18px; margin-bottom:6px; }
|
||||
|
|
@ -102,10 +103,10 @@
|
|||
.assistant-suggestion-buttons { display:flex; gap:8px; flex-wrap:wrap; margin-top:12px; }
|
||||
.assistant-suggestion-buttons button { border:1px solid var(--purple-light); background:#faf5ff; color:var(--purple); border-radius:999px; padding:7px 10px; font-size:12px; cursor:pointer; text-align:left; }
|
||||
.assistant-suggestion-buttons button:hover { border-color:var(--purple); background:var(--purple-light); }
|
||||
.assistant-msg { max-width:900px; margin:0 0 14px; display:grid; gap:6px; }
|
||||
.assistant-msg { max-width:900px; min-width:0; margin:0 0 14px; display:grid; gap:6px; }
|
||||
.assistant-msg.user { margin-left:auto; max-width:760px; }
|
||||
.assistant-msg-label { font-size:11px; font-weight:700; color:var(--g400); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; }
|
||||
.assistant-bubble { border:1px solid var(--g200); border-radius:14px; padding:12px 14px; background:white; box-shadow:var(--shadow); font-size:13px; line-height:1.75; overflow-wrap:anywhere; min-width:0; max-width:100%; }
|
||||
.assistant-msg.user .assistant-bubble { background:var(--blue); color:white; border-color:var(--blue); }
|
||||
.assistant-bubble h1, .assistant-bubble h2, .assistant-bubble h3 { margin:16px 0 8px; line-height:1.25; color:var(--g900); }
|
||||
.assistant-bubble h1:first-child, .assistant-bubble h2:first-child, .assistant-bubble h3:first-child { margin-top:0; }
|
||||
|
|
@ -117,8 +118,9 @@
|
|||
.assistant-bubble ul, .assistant-bubble ol { padding-left:20px; margin:8px 0; }
|
||||
.assistant-bubble li { margin:4px 0; }
|
||||
.assistant-bubble blockquote { margin:10px 0; padding:8px 12px; border-left:3px solid var(--blue); background:var(--blue-light); color:var(--g700); border-radius:8px; }
|
||||
.assistant-bubble table { width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; }
|
||||
.assistant-bubble table { width:100%; max-width:100%; border-collapse:separate; border-spacing:0; margin:12px 0; overflow:hidden; border:1px solid var(--g200); border-radius:10px; font-size:12px; }
|
||||
.assistant-bubble th, .assistant-bubble td { padding:8px 10px; border-bottom:1px solid var(--g200); vertical-align:top; text-align:left; }
|
||||
.assistant-bubble th, .assistant-bubble td { overflow-wrap:anywhere; }
|
||||
.assistant-bubble th { background:var(--g50); font-weight:700; color:var(--g800); }
|
||||
.assistant-bubble tr:last-child td { border-bottom:0; }
|
||||
.assistant-bubble code { background:var(--g100); border-radius:4px; padding:1px 4px; }
|
||||
|
|
@ -188,7 +190,7 @@
|
|||
.assistant-messages { padding:10px; }
|
||||
.assistant-msg, .assistant-msg.user { max-width:100%; }
|
||||
.assistant-bubble { font-size:13px; padding:11px 12px; }
|
||||
.assistant-bubble table { display:block; overflow-x:auto; white-space:nowrap; max-width:100%; }
|
||||
.assistant-bubble table { display:block; overflow-x:auto; -webkit-overflow-scrolling:touch; }
|
||||
.assistant-composer { position:sticky; bottom:0; z-index:3; }
|
||||
.assistant-composer-footer { flex-direction:column; align-items:stretch; }
|
||||
.assistant-composer-footer .btn-generate { width:100%; }
|
||||
|
|
|
|||
|
|
@ -46,6 +46,22 @@ export function requestAssistantImage(prompt) {
|
|||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
export function startAssistantImageJob(prompt) {
|
||||
return fetch('/api/clinical-assistant/image/jobs', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ prompt: prompt })
|
||||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
export function fetchAssistantImageJob(jobId) {
|
||||
return fetch('/api/clinical-assistant/image/jobs/' + encodeURIComponent(jobId), {
|
||||
headers: authHeaders(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function(r) { return r.json(); });
|
||||
}
|
||||
|
||||
export function saveAssistantChat(payload) {
|
||||
return fetch('/api/clinical-assistant/chats', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -18,18 +18,26 @@ export function createAssistantExporter(options) {
|
|||
}
|
||||
var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || []);
|
||||
var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || '');
|
||||
if (exportCacheKey === cacheKey && exportCacheItems) {
|
||||
showPrintableExport(exportCacheItems, state.lastGeneratedImageSrc || '');
|
||||
return;
|
||||
}
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = exportItems;
|
||||
showPrintableExport(exportItems, state.lastGeneratedImageSrc || '');
|
||||
}
|
||||
|
||||
function showPrintableExport(items, imageSrc) {
|
||||
if (shouldUseInlineExport()) {
|
||||
writeInlineChatExport(items, imageSrc);
|
||||
return;
|
||||
}
|
||||
var doc = openExportWindow();
|
||||
if (!doc) {
|
||||
if (typeof options.showToast === 'function') options.showToast('Allow popups to export PDF', 'error');
|
||||
return;
|
||||
}
|
||||
if (exportCacheKey === cacheKey && exportCacheItems) {
|
||||
writePrintableChatExport(doc, exportCacheItems, state.lastGeneratedImageSrc || '');
|
||||
return;
|
||||
}
|
||||
exportCacheKey = cacheKey;
|
||||
exportCacheItems = exportItems;
|
||||
writePrintableChatExport(doc, exportItems, state.lastGeneratedImageSrc || '');
|
||||
writePrintableChatExport(doc, items, imageSrc);
|
||||
}
|
||||
|
||||
function openExportWindow() {
|
||||
|
|
@ -43,6 +51,50 @@ export function createAssistantExporter(options) {
|
|||
|
||||
function writePrintableChatExport(doc, items, imageSrc) {
|
||||
if (!doc || doc.closed) return;
|
||||
var html = buildPrintableChatHtml(items, imageSrc, false);
|
||||
doc.document.open();
|
||||
doc.document.write(html);
|
||||
doc.document.close();
|
||||
try {
|
||||
var printBtn = doc.document.getElementById('assistant-export-print');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
|
||||
var closeBtn = doc.document.getElementById('assistant-export-close');
|
||||
if (closeBtn) closeBtn.addEventListener('click', function () { try { doc.close(); } catch (e) { doc.location.href = '/'; } });
|
||||
} catch (e) {}
|
||||
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
|
||||
}
|
||||
|
||||
function writeInlineChatExport(items, imageSrc) {
|
||||
closeInlineExport();
|
||||
var modal = document.createElement('div');
|
||||
modal.id = 'assistant-export-modal';
|
||||
modal.innerHTML = '<style>' + inlineExportCss() + '</style><div class="assistant-export-sheet">' + buildPrintableChatBody(items, imageSrc, true) + '</div>';
|
||||
document.body.appendChild(modal);
|
||||
document.body.classList.add('assistant-export-open');
|
||||
var printBtn = modal.querySelector('#assistant-export-print');
|
||||
var closeBtn = modal.querySelector('#assistant-export-close');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { window.print(); });
|
||||
if (closeBtn) closeBtn.addEventListener('click', closeInlineExport);
|
||||
modal.addEventListener('click', function (event) {
|
||||
if (event.target === modal) closeInlineExport();
|
||||
});
|
||||
try { window.history.pushState({ assistantExport: true }, '', window.location.href); } catch (e) {}
|
||||
window.addEventListener('popstate', closeInlineExport, { once: true });
|
||||
}
|
||||
|
||||
function closeInlineExport() {
|
||||
var existing = document.getElementById('assistant-export-modal');
|
||||
if (existing) existing.remove();
|
||||
document.body.classList.remove('assistant-export-open');
|
||||
}
|
||||
|
||||
function buildPrintableChatHtml(items, imageSrc, inline) {
|
||||
return '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
|
||||
'<style>' + exportWindowCss() + '</style>' +
|
||||
'</head><body>' + buildPrintableChatBody(items, imageSrc, inline) + '</body></html>';
|
||||
}
|
||||
|
||||
function buildPrintableChatBody(items, imageSrc, inline) {
|
||||
items = Array.isArray(items) && items.length ? items : [];
|
||||
var imageHtml = imageSrc ? '<h2>Generated Image</h2><div class="export-image"><img src="' + escapeAttr(imageSrc) + '" alt="Generated clinical visual"></div>' : '';
|
||||
var sections = items.map(function (item, idx) {
|
||||
|
|
@ -60,20 +112,10 @@ export function createAssistantExporter(options) {
|
|||
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
|
||||
'</section>';
|
||||
}).join('');
|
||||
var html = '<!doctype html><html><head><title>Clinical Assistant Export</title>' +
|
||||
'<style>body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{button{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}</style>' +
|
||||
'</head><body><button id="assistant-export-print" type="button" style="float:right;padding:8px 12px">Print / Save PDF</button><h1>Clinical Assistant Export</h1>' +
|
||||
return '<div class="assistant-export-actions"><button id="assistant-export-print" type="button">Print / Save PDF</button><button id="assistant-export-close" type="button">Close</button></div><h1>Clinical Assistant Export</h1>' +
|
||||
'<div class="meta">Export generated ' + escapeHtml(new Date().toLocaleString()) + '</div>' +
|
||||
imageHtml +
|
||||
sections + '</body></html>';
|
||||
doc.document.open();
|
||||
doc.document.write(html);
|
||||
doc.document.close();
|
||||
try {
|
||||
var printBtn = doc.document.getElementById('assistant-export-print');
|
||||
if (printBtn) printBtn.addEventListener('click', function () { doc.focus(); doc.print(); });
|
||||
} catch (e) {}
|
||||
setTimeout(function () { try { doc.focus(); doc.print(); } catch (e) {} }, 500);
|
||||
sections;
|
||||
}
|
||||
|
||||
function renderMarkdown(md, sources, renderOptions) {
|
||||
|
|
@ -87,6 +129,20 @@ export function createAssistantExporter(options) {
|
|||
};
|
||||
}
|
||||
|
||||
function shouldUseInlineExport() {
|
||||
var isCapacitor = !!(window.Capacitor && (!window.Capacitor.isNativePlatform || window.Capacitor.isNativePlatform()));
|
||||
var isSmallTouch = window.matchMedia && window.matchMedia('(max-width: 700px), (pointer: coarse)').matches;
|
||||
return isCapacitor || isSmallTouch;
|
||||
}
|
||||
|
||||
function exportWindowCss() {
|
||||
return 'body{font-family:Arial,sans-serif;color:#111827;line-height:1.55;margin:36px;max-width:820px}.assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}.assistant-export-actions button{padding:8px 12px}h1{font-size:22px;margin:0 0 4px}h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}h3{font-size:14px;margin:18px 0 8px;break-after:avoid}.meta,.question{font-size:12px;color:#6b7280;margin-bottom:12px}.answer{font-size:13px}.export-section{margin-top:20px;break-before:auto}.full-answer{page-break-before:auto}.export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}.refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}.refs li{margin:6px 0;break-inside:avoid}.assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}.answer th,.answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}.answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}.answer th:last-child,.answer td:last-child{width:1%;white-space:nowrap}.answer tr{break-inside:avoid;page-break-inside:avoid}.answer thead{display:table-header-group}.answer tbody{display:table-row-group}.answer p{margin:8px 0}.answer ul,.answer ol{padding-left:20px}.answer li{margin:4px 0}@media print{.assistant-export-actions{display:none}body{margin:24mm}.export-section{break-inside:auto}.refs{break-before:avoid}}';
|
||||
}
|
||||
|
||||
function inlineExportCss() {
|
||||
return '#assistant-export-modal{position:fixed;inset:0;z-index:10000;background:rgba(15,23,42,.72);overflow:auto;padding:16px}#assistant-export-modal .assistant-export-sheet{box-sizing:border-box;background:white;color:#111827;font-family:Arial,sans-serif;line-height:1.55;max-width:860px;margin:0 auto 24px;padding:24px;border-radius:14px;box-shadow:0 24px 80px rgba(0,0,0,.35)}#assistant-export-modal .assistant-export-actions{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}#assistant-export-modal .assistant-export-actions button{padding:8px 12px}#assistant-export-modal h1{font-size:22px;margin:0 0 4px}#assistant-export-modal h2{font-size:17px;margin-top:26px;border-bottom:1px solid #e5e7eb;padding-bottom:4px;break-after:avoid}#assistant-export-modal h3{font-size:14px;margin:18px 0 8px;break-after:avoid}#assistant-export-modal .meta,#assistant-export-modal .question{font-size:12px;color:#6b7280;margin-bottom:12px}#assistant-export-modal .answer{font-size:13px}#assistant-export-modal .export-section{margin-top:20px;break-before:auto}#assistant-export-modal .full-answer{page-break-before:auto}#assistant-export-modal .export-image img{max-width:100%;border:1px solid #e5e7eb;border-radius:10px}#assistant-export-modal .refs{font-size:12px;padding-left:20px;margin-top:8px;break-inside:auto}#assistant-export-modal .refs li{margin:6px 0;break-inside:avoid}#assistant-export-modal .assistant-cite{display:inline-flex;align-items:center;justify-content:center;min-width:16px;height:16px;padding:0 5px;margin:0 1px;border-radius:999px;background:#f3e8ff;color:#7c3aed;border:1px solid rgba(124,58,237,.22);font-size:9px;font-weight:800;line-height:16px;text-decoration:none;text-transform:uppercase;letter-spacing:.03em;vertical-align:baseline;white-space:nowrap;-webkit-print-color-adjust:exact;print-color-adjust:exact}#assistant-export-modal .answer table{width:100%;border-collapse:collapse;table-layout:auto;margin:12px 0 18px;border:1px solid #e5e7eb;page-break-inside:auto}#assistant-export-modal .answer th,#assistant-export-modal .answer td{padding:7px 8px;border:1px solid #e5e7eb;text-align:left;vertical-align:top;word-break:normal;overflow-wrap:break-word}#assistant-export-modal .answer th{background:#f9fafb;font-weight:700;-webkit-print-color-adjust:exact;print-color-adjust:exact}#assistant-export-modal .answer th:last-child,#assistant-export-modal .answer td:last-child{width:1%;white-space:nowrap}#assistant-export-modal .answer tr{break-inside:avoid;page-break-inside:avoid}#assistant-export-modal .answer thead{display:table-header-group}#assistant-export-modal .answer tbody{display:table-row-group}#assistant-export-modal .answer p{margin:8px 0}#assistant-export-modal .answer ul,#assistant-export-modal .answer ol{padding-left:20px}#assistant-export-modal .answer li{margin:4px 0}@media print{body.assistant-export-open>*:not(#assistant-export-modal){display:none!important}#assistant-export-modal{position:static!important;inset:auto!important;background:white!important;overflow:visible!important;padding:0!important}#assistant-export-modal .assistant-export-sheet{max-width:none!important;margin:0!important;padding:0!important;border-radius:0!important;box-shadow:none!important}#assistant-export-modal .assistant-export-actions{display:none!important}#assistant-export-modal .export-section{break-inside:auto}#assistant-export-modal .refs{break-before:avoid}}';
|
||||
}
|
||||
|
||||
function collectExportItems(messages, lastAnswer, lastSources) {
|
||||
var items = [];
|
||||
var pendingQuestion = '';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export function createAssistantImageStore() {
|
|||
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>' +
|
||||
'<a class="btn-sm btn-ghost" href="' + escapeAttr(src) + '" download="clinical-visual.png"><i class="fas fa-download"></i> Download</a>' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-assistant-download-image="' + escapeAttr(id) + '"><i class="fas fa-download"></i> Download</button>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,19 @@ export function createAssistantImageStore() {
|
|||
document.querySelectorAll('.assistant-image-modal').forEach(function (el) { el.remove(); });
|
||||
}
|
||||
|
||||
async function downloadImage(id) {
|
||||
var src = generatedImages[id];
|
||||
if (!src) return;
|
||||
if (await saveWithNativeShare(src)) return;
|
||||
var a = document.createElement('a');
|
||||
a.href = src;
|
||||
a.download = 'clinical-visual.png';
|
||||
a.rel = 'noopener';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
function clear() {
|
||||
generatedImages = {};
|
||||
closeImagePreview();
|
||||
|
|
@ -36,11 +49,74 @@ export function createAssistantImageStore() {
|
|||
return {
|
||||
renderGeneratedImage: renderGeneratedImage,
|
||||
openImagePreview: openImagePreview,
|
||||
downloadImage: downloadImage,
|
||||
closeImagePreview: closeImagePreview,
|
||||
clear: clear
|
||||
};
|
||||
}
|
||||
|
||||
async function saveWithNativeShare(src) {
|
||||
if (await shareWithWebFile(src)) return true;
|
||||
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 false; }
|
||||
}
|
||||
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: 'DOCUMENTS' });
|
||||
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 saved to Documents', '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 false;
|
||||
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 false;
|
||||
await navigator.share({ files: [file], title: 'Clinical visual', text: 'Clinical Assistant generated visual' });
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ import {
|
|||
fetchSavedAssistantChat,
|
||||
fetchSavedAssistantChats,
|
||||
openAssistantStream,
|
||||
requestAssistantImage,
|
||||
fetchAssistantImageJob,
|
||||
startAssistantImageJob,
|
||||
saveAssistantChat
|
||||
} from './assistant/api.js';
|
||||
var initialized = false;
|
||||
|
|
@ -431,7 +432,13 @@ import {
|
|||
if (fromChat) setBusy(true, 'Generating image...');
|
||||
var loading = fromChat ? appendLoadingMessage('Generating image', 'Creating the requested clinical visual...') : null;
|
||||
if (out) out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
|
||||
requestAssistantImage(prompt)
|
||||
startAssistantImageJob(prompt)
|
||||
.then(function (data) {
|
||||
if (!data.success || !data.jobId) throw new Error(data.error || 'Image generation failed');
|
||||
return waitForImageJob(data.jobId, function (status) {
|
||||
if (out && status === 'running') out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image... You can leave the app open or return in a moment.</p>';
|
||||
});
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Image generation failed');
|
||||
var src = data.imageUrl || data.url || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
|
||||
|
|
@ -453,6 +460,28 @@ import {
|
|||
});
|
||||
}
|
||||
|
||||
function waitForImageJob(jobId, onStatus) {
|
||||
var started = Date.now();
|
||||
var delay = 1200;
|
||||
return new Promise(function (resolve, reject) {
|
||||
function poll() {
|
||||
fetchAssistantImageJob(jobId).then(function (data) {
|
||||
if (!data.success) throw new Error(data.error || 'Image generation failed');
|
||||
if (data.status === 'done') { resolve(data); return; }
|
||||
if (data.status === 'error') { reject(new Error(data.error || 'Image generation failed')); return; }
|
||||
if (typeof onStatus === 'function') onStatus(data.status || 'pending');
|
||||
if (Date.now() - started > 180000) { reject(new Error('Image generation timed out. Please try again.')); return; }
|
||||
delay = Math.min(delay + 300, 3500);
|
||||
setTimeout(poll, delay);
|
||||
}).catch(function (err) {
|
||||
if (Date.now() - started > 180000) { reject(err); return; }
|
||||
setTimeout(poll, delay);
|
||||
});
|
||||
}
|
||||
poll();
|
||||
});
|
||||
}
|
||||
|
||||
function onAssistantDocumentClick(e) {
|
||||
var loadBtn = e.target.closest('[data-assistant-load-chat]');
|
||||
if (loadBtn) {
|
||||
|
|
@ -472,6 +501,12 @@ import {
|
|||
imageStore.openImagePreview(openBtn.getAttribute('data-assistant-open-image'));
|
||||
return;
|
||||
}
|
||||
var downloadBtn = e.target.closest('[data-assistant-download-image]');
|
||||
if (downloadBtn) {
|
||||
e.preventDefault();
|
||||
imageStore.downloadImage(downloadBtn.getAttribute('data-assistant-download-image'));
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.assistant-image-modal-close') || e.target.classList.contains('assistant-image-modal')) {
|
||||
imageStore.closeImagePreview();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
var express = require('express');
|
||||
var axios = require('axios');
|
||||
var crypto = require('crypto');
|
||||
var router = express.Router();
|
||||
var db = require('../db/database');
|
||||
var { authMiddleware } = require('../middleware/auth');
|
||||
|
|
@ -45,6 +46,8 @@ var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|t
|
|||
var MAX_SAVED_CHATS_PER_USER = 100;
|
||||
var MAX_SAVED_CHAT_PAYLOAD = 250000;
|
||||
var MAX_SAVED_CHAT_TITLE = 160;
|
||||
var IMAGE_JOB_TTL_SECONDS = 15 * 60;
|
||||
var imageJobs = new Map();
|
||||
var promptPool = createClinicalPromptPool({
|
||||
redisCache: redisCache,
|
||||
callAI: callAI,
|
||||
|
|
@ -287,6 +290,46 @@ router.post('/clinical-assistant/image', async function(req, res) {
|
|||
}
|
||||
});
|
||||
|
||||
router.post('/clinical-assistant/image/jobs', async function(req, res) {
|
||||
try {
|
||||
var prompt = String(req.body.prompt || '').trim();
|
||||
if (!prompt) return res.status(400).json({ error: 'Prompt is required' });
|
||||
if (prompt.length > 5000) prompt = prompt.substring(0, 5000);
|
||||
|
||||
var model = await getSetting('clinical_assistant.image_model', '') || process.env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1';
|
||||
var id = crypto.randomBytes(16).toString('hex');
|
||||
var job = { id: id, userId: req.user.id, status: 'pending', model: model, createdAt: Date.now(), updatedAt: Date.now() };
|
||||
await setImageJob(job);
|
||||
res.json({ success: true, jobId: id, status: 'pending' });
|
||||
|
||||
runImageJob(id, req.user.id, prompt, model, req).catch(function(e) {
|
||||
console.warn('[clinical-assistant image job]', e.message);
|
||||
});
|
||||
} catch (e) {
|
||||
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message;
|
||||
res.status(500).json({ error: detail || 'Image job failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/clinical-assistant/image/jobs/:id', async function(req, res) {
|
||||
try {
|
||||
var job = await getImageJob(req.params.id);
|
||||
if (!job || String(job.userId) !== String(req.user.id)) return res.status(404).json({ error: 'Image job not found' });
|
||||
res.json({
|
||||
success: true,
|
||||
jobId: job.id,
|
||||
status: job.status,
|
||||
model: job.model || null,
|
||||
imageUrl: job.imageUrl || null,
|
||||
url: job.url || null,
|
||||
base64: job.base64 || null,
|
||||
error: job.error || null
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: 'Image job status failed' });
|
||||
}
|
||||
});
|
||||
|
||||
async function prepareAssistantChat(body) {
|
||||
body = body || {};
|
||||
var message = String(body.message || '').trim();
|
||||
|
|
@ -563,6 +606,49 @@ async function generateImage(prompt, model) {
|
|||
return { imageUrl: item.url || null, base64: item.b64_json || null, raw: (!item.url && !item.b64_json) ? resp.data : undefined };
|
||||
}
|
||||
|
||||
async function runImageJob(id, userId, prompt, model, req) {
|
||||
await updateImageJob(id, { status: 'running', updatedAt: Date.now() });
|
||||
try {
|
||||
var image = await generateImage(prompt, model);
|
||||
await updateImageJob(id, Object.assign({ status: 'done', updatedAt: Date.now() }, image));
|
||||
logger.audit(userId, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model, async: true });
|
||||
} catch (e) {
|
||||
var detail = e.response && e.response.data ? JSON.stringify(e.response.data).substring(0, 300) : e.message;
|
||||
await updateImageJob(id, { status: 'error', error: detail || 'Image generation failed', updatedAt: Date.now() });
|
||||
}
|
||||
}
|
||||
|
||||
async function getImageJob(id) {
|
||||
var key = imageJobKey(id);
|
||||
var job = await redisCache.getJson(key);
|
||||
if (job) return job;
|
||||
return imageJobs.get(key) || null;
|
||||
}
|
||||
|
||||
async function setImageJob(job) {
|
||||
var key = imageJobKey(job.id);
|
||||
imageJobs.set(key, job);
|
||||
trimImageJobs();
|
||||
await redisCache.setJson(key, job, IMAGE_JOB_TTL_SECONDS).catch(function() { return false; });
|
||||
}
|
||||
|
||||
async function updateImageJob(id, patch) {
|
||||
var existing = await getImageJob(id);
|
||||
if (!existing) return;
|
||||
await setImageJob(Object.assign({}, existing, patch));
|
||||
}
|
||||
|
||||
function imageJobKey(id) {
|
||||
return 'clinical-assistant:image-job:' + String(id || '').replace(/[^a-f0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
function trimImageJobs() {
|
||||
var cutoff = Date.now() - IMAGE_JOB_TTL_SECONDS * 1000;
|
||||
imageJobs.forEach(function(job, key) {
|
||||
if (!job || (job.updatedAt || job.createdAt || 0) < cutoff) imageJobs.delete(key);
|
||||
});
|
||||
}
|
||||
|
||||
function generateImageRequest(model, prompt, size, headers) {
|
||||
return axios.post(gatewayUrl('/images/generations'), {
|
||||
model: model,
|
||||
|
|
|
|||
Loading…
Reference in a new issue