pediatric-ai-scribe-v3/public/js/learningHub/images.js

76 lines
5.6 KiB
JavaScript

import { renderImageJobs, imageJson, hydrateImage, imageDataUrl, captureImageOwner, assertImageOwner, transientImageUrl, revokeImageUrl } from '../generatedImages.js';
import { sanitizeHtml } from './sanitize.js';
import { validSharingOwner } from '../assistant/sharing.js';
export function generatedImageExtension(T) {
// Reuse the Node class already shipped with StarterKit, without a second Tiptap bundle.
const Node = T.StarterKit.config.addExtensions.call(T.StarterKit).find(e => e.type === 'node').constructor;
return Node.create({ name: 'generatedImage', group: 'block', atom: true, draggable: true,
addAttributes() { return { src: { default: '' }, alt: { default: 'Generated teaching visual' } }; },
parseHTML() { return [{ tag: 'img[src^="/api/generated-images/"]' }]; },
renderHTML({ HTMLAttributes }) { return ['img', HTMLAttributes]; },
addNodeView() { return ({ node }) => {
const img = document.createElement('img'); img.alt = node.attrs.alt; img.style.maxWidth = '100%';
// NodeViews hydrate after attachment, leaving the editor's persistent attributes untouched.
queueMicrotask(() => hydrateImage(img, node.attrs.src).catch(() => { img.alt = 'Private image unavailable'; }));
return { dom: img, ignoreMutation: () => true, destroy() { if (img.src.startsWith('blob:')) revokeImageUrl(img.src); } };
}; }
});
}
export function createLearningImages(getEditor, getMarkdown) {
let panel, list, draft;
function mount() {
if (panel?.isConnected) return;
const parent = document.getElementById('lh-images-section');
if (!parent) return;
panel = document.createElement('section'); panel.setAttribute('aria-label', 'Learning Hub images');
panel.innerHTML = '<h3>Learning Hub images</h3><p>Uses the separately administered Learning image model and instructions. Maximum assembled input: 32,000 UTF-16 code units (or the lower admin budget). The full image request is retained; whole recent authoring-context turns are selected to fit, with counts shown below. Existing content is unchanged. Images remain private until attached to saved content.</p><label>Image prompt<textarea rows="4"></textarea></label><button type="button" data-generate>Generate image</button><button type="button" data-history>Reopen image history</button><button type="button" data-export>Export content with images (HTML)</button><p role="status"></p><div data-jobs></div>';
parent.append(panel); list = panel.querySelector('[data-jobs]');
const status = panel.querySelector('[role=status]');
panel.querySelector('[data-generate]').onclick = async function() {
if (this.disabled) return;
const prompt = panel.querySelector('textarea').value;
const content = getMarkdown()?.value ?? getEditor()?.getHTML() ?? '';
if (!draft || draft.prompt !== prompt || draft.content !== content) draft = { prompt, content, idempotencyKey: crypto.randomUUID() };
this.disabled = true;
try { show([await imageJson('/api/admin/learning/image/jobs', { method: 'POST', body: JSON.stringify(draft) })]); status.textContent = 'Image queued; your draft is preserved.'; }
catch (e) { status.textContent = e.message; } finally { this.disabled = false; }
};
panel.querySelector('[data-history]').onclick = async () => {
try { list.replaceChildren(); show((await imageJson('/api/image-jobs/learning_hub')).jobs); } catch (e) { status.textContent = e.message; }
};
panel.querySelector('[data-export]').onclick = async () => {
let ticket;
try {
ticket = captureImageOwner(); const template = document.createElement('template');
const markdown = getMarkdown();
if (markdown) throw new Error('For presentations use the existing PowerPoint export, which includes attached generated images.');
template.innerHTML = sanitizeHtml(getEditor()?.getHTML() || '');
for (const img of template.content.querySelectorAll('img')) img.src = await imageDataUrl(img.getAttribute('src'), ticket);
assertImageOwner(ticket);
const url = transientImageUrl(new Blob(['<!doctype html><meta charset="utf-8"><title>Learning content</title>' + template.innerHTML], { type: 'text/html' }), ticket);
const a = document.createElement('a'); a.href = url; a.download = 'learning-content.html'; a.click(); setTimeout(() => revokeImageUrl(url), 60000);
} catch (e) { if (validSharingOwner(ticket) && e.name !== 'AbortError') status.textContent = e.message; }
};
}
function show(jobs) {
mount(); if (!list) return;
renderImageJobs(list, jobs, 'learning_hub', (card, data) => {
const img = document.createElement('img'); img.alt = 'Generated teaching visual'; img.style.maxWidth = '100%'; card.append(img);
hydrateImage(img, data.imageUrl).catch(() => { img.alt = 'Private image unavailable'; });
const insert = document.createElement('button'); insert.type = 'button'; insert.textContent = 'Insert image at end of content';
insert.onclick = () => {
captureImageOwner();
const markdown = getMarkdown();
if (markdown) markdown.value += '\n---\n# Generated teaching visual\n![Generated teaching visual](' + data.imageUrl + ')\n';
else {
const editor = getEditor();
if (!editor || editor.isDestroyed) return;
editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'generatedImage', attrs: { src: data.imageUrl, alt: 'Generated teaching visual' } });
}
insert.disabled = true; insert.textContent = 'Inserted — save content to attach';
};
card.append(insert);
});
}
return { mount, show };
}