diff --git a/docs/global-prompt-administration.md b/docs/global-prompt-administration.md index 0bdb6e08..44548ca3 100644 --- a/docs/global-prompt-administration.md +++ b/docs/global-prompt-administration.md @@ -74,3 +74,15 @@ actual admin middleware/routes, rollback/concurrency, missing schema, old-defaul restore, default byte hashes, object identity and startup races, exact UTF-16 boundaries, legacy DB ignoring, and both image routes. Migration SQL is dry-run through the installed node-pg-migrate engine, not applied to a live database. + +### Fixed image output policy and context assembly + +Image jobs use the separately editable workflow image behavior and its immutable revision. The existing defaults/history are not rewritten. A fixed backend instruction, **output the image only; no citations, reference numbers, footnotes, bibliography or source lists in the image**, is appended last and takes precedence over conflicting editable guidance/context. Normal answers, saved transcripts, educational bodies, citations, tables, source numbers and pages are not cleaned or rewritten. + +The image provider receives the full original request, the tool's image description (when dispatched by a tool), workflow/canvas/layout guidance, the largest **contiguous suffix of whole preceding turns** that fits, and the fixed output policy. Selected turns are emitted oldest to newest. Selection stops at the first non-fitting recent turn; it never skips gaps, slices turns or summarizes them. All separators/instructions count toward the exact UTF-16 budget. Mandatory overflow rejects before image payment. The default 32,000 (configurable 1,000..32,000) is a conservative code-unit allowance, **not** an averaged or computed model token limit. + +Clinical routes bind validated current request/history outside model-controlled tool arguments. The sidebar supplies the current conversation; independent standalone requests have no implicit chat history. Learning supplies only its authorized authoring document/body, never Clinical history. API jobs and history return `context: {includedTurns,totalTurns,used,limit,unit}` and cards visibly show omission metadata; older jobs have `context:null`. Image selection does not change the separate normal-conversation ENV cap or stored/exported history. The encrypted assembled snapshot, exact counts, model and revision are immutable. Tool replays may rephrase tool descriptions, but a changed original request or prior context with the same idempotency key rejects with 409. Apply additive migration `1777900000000_image-context` after the image migration; it does not invent metadata for older jobs. + +### Operational image retention limit + +Back up private S3 assets, PostgreSQL jobs/links and encryption keys together. Deleting an image author currently cascades job/link deletion through the user foreign key, while Learning content survives with a null author; such pages lose those generated images and the S3 objects remain orphaned. This release does not redesign account deletion or retention. Resolve archival/retention with operators before any author-account deletion. Ambiguous paid stages are reported as interrupted/unknown using PostgreSQL alone during external outages and are never automatically paid again. diff --git a/migrations/1777800000000_generated-images.js b/migrations/1777800000000_generated-images.js new file mode 100644 index 00000000..c2c60294 --- /dev/null +++ b/migrations/1777800000000_generated-images.js @@ -0,0 +1,55 @@ +// Durable jobs and private asset grants. No external calls or corpus changes. +exports.up = pgm => pgm.sql(` + CREATE TABLE generated_image_jobs ( + id UUID PRIMARY KEY, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow TEXT NOT NULL CHECK (workflow IN ('clinical_assistant', 'learning_hub')), + idempotency_key TEXT NOT NULL, + input_hash TEXT NOT NULL, + prompt_cipher TEXT NOT NULL CHECK (prompt_cipher LIKE 'enc1:%'), + model TEXT NOT NULL, + prompt_revision INTEGER NOT NULL, + budget INTEGER NOT NULL CHECK (budget BETWEEN 1000 AND 32000), + prompt_units INTEGER NOT NULL, + stage TEXT NOT NULL DEFAULT 'queued' CHECK (stage IN ('queued','generating','storing','done','error','interrupted')), + lease_token UUID, lease_until TIMESTAMPTZ, + staged_bytes BYTEA, mime TEXT, checksum TEXT, byte_length INTEGER, + error_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(owner_id, workflow, idempotency_key) + ); + CREATE INDEX generated_image_claim ON generated_image_jobs(stage, created_at); + CREATE TABLE generated_image_links ( + asset_id UUID NOT NULL REFERENCES generated_image_jobs(id) ON DELETE CASCADE, + content_id INTEGER NOT NULL REFERENCES learning_content(id) ON DELETE CASCADE, + PRIMARY KEY(asset_id, content_id) + ); + CREATE FUNCTION guard_generated_image_job() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF (NEW.owner_id, NEW.workflow, NEW.idempotency_key, NEW.input_hash, NEW.prompt_cipher, NEW.model, NEW.prompt_revision, NEW.budget, NEW.prompt_units) + IS DISTINCT FROM (OLD.owner_id, OLD.workflow, OLD.idempotency_key, OLD.input_hash, OLD.prompt_cipher, OLD.model, OLD.prompt_revision, OLD.budget, OLD.prompt_units) THEN + RAISE EXCEPTION 'Image job input and ownership are immutable'; + END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER generated_image_job_immutable BEFORE UPDATE ON generated_image_jobs FOR EACH ROW EXECUTE FUNCTION guard_generated_image_job(); + CREATE FUNCTION guard_generated_image_link() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM generated_image_jobs WHERE id = NEW.asset_id AND workflow = 'learning_hub' AND stage = 'done') THEN + RAISE EXCEPTION 'Only Learning assets may be attached'; + END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER generated_image_link_guard BEFORE INSERT OR UPDATE ON generated_image_links FOR EACH ROW EXECUTE FUNCTION guard_generated_image_link(); + ALTER TABLE prompt_revisions DROP CONSTRAINT prompt_revisions_prompt_key_check; + ALTER TABLE prompt_revisions ADD CONSTRAINT prompt_revisions_prompt_key_check CHECK (prompt_key IN ('prompt.hpiEncounter','prompt.hpiDictation','prompt.hpiInpatient','prompt.hospitalCourseShort','prompt.hospitalCourseLong','prompt.hospitalCourseICU','prompt.hospitalCoursePsych','prompt.chartReviewOutpatient','prompt.chartReviewSubspecialty','prompt.chartReviewED','prompt.soapFull','prompt.soapSubjective','prompt.milestoneNarrative','prompt.milestoneList','prompt.milestoneSummary','prompt.peGuideNarrative','prompt.peGuideList','prompt.refine','prompt.shortenDocument','prompt.askClarification','prompt.shadessAssessment','prompt.wellVisitNote','prompt.wellVisitShort','prompt.sickVisitNote','prompt.edEncounterStaged','prompt.edConsolidate','prompt.edFinalize','prompt.dontMissTooltip','prompt.patientEducation','clinical_assistant.system_behavior','clinical_assistant.image_behavior','learning_hub.image_behavior')); +`); +// Down preserves append-only Learning prompt history: run only after explicit archival/removal of that history. +exports.down = pgm => pgm.sql(` + DO $$ BEGIN IF EXISTS(SELECT 1 FROM prompt_revisions WHERE prompt_key = 'learning_hub.image_behavior') THEN + RAISE EXCEPTION 'Learning image prompt history exists; retain migration rather than discard history'; END IF; END $$; + ALTER TABLE prompt_revisions DROP CONSTRAINT prompt_revisions_prompt_key_check; + ALTER TABLE prompt_revisions ADD CONSTRAINT prompt_revisions_prompt_key_check CHECK (prompt_key IN ('prompt.hpiEncounter','prompt.hpiDictation','prompt.hpiInpatient','prompt.hospitalCourseShort','prompt.hospitalCourseLong','prompt.hospitalCourseICU','prompt.hospitalCoursePsych','prompt.chartReviewOutpatient','prompt.chartReviewSubspecialty','prompt.chartReviewED','prompt.soapFull','prompt.soapSubjective','prompt.milestoneNarrative','prompt.milestoneList','prompt.milestoneSummary','prompt.peGuideNarrative','prompt.peGuideList','prompt.refine','prompt.shortenDocument','prompt.askClarification','prompt.shadessAssessment','prompt.wellVisitNote','prompt.wellVisitShort','prompt.sickVisitNote','prompt.edEncounterStaged','prompt.edConsolidate','prompt.edFinalize','prompt.dontMissTooltip','prompt.patientEducation','clinical_assistant.system_behavior','clinical_assistant.image_behavior')); + DROP TABLE generated_image_links; DROP TABLE generated_image_jobs; + DROP FUNCTION guard_generated_image_link(); DROP FUNCTION guard_generated_image_job(); +`); diff --git a/migrations/1777900000000_image-context.js b/migrations/1777900000000_image-context.js new file mode 100644 index 00000000..a605376b --- /dev/null +++ b/migrations/1777900000000_image-context.js @@ -0,0 +1,20 @@ +// Existing immutable snapshots retain unknown context metadata; never fabricate old counts. +exports.up = pgm => pgm.sql(` + ALTER TABLE generated_image_jobs ADD context_included INTEGER, ADD context_total INTEGER, + ADD CONSTRAINT generated_image_context_counts CHECK ( + (context_included IS NULL AND context_total IS NULL) OR + (context_included IS NOT NULL AND context_total IS NOT NULL AND context_included >= 0 AND context_total >= context_included)); + CREATE FUNCTION guard_generated_image_context() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF (NEW.context_included, NEW.context_total) IS DISTINCT FROM (OLD.context_included, OLD.context_total) THEN + RAISE EXCEPTION 'Image context metadata is immutable'; + END IF; + RETURN NEW; + END; $$; + CREATE TRIGGER generated_image_context_immutable BEFORE UPDATE ON generated_image_jobs FOR EACH ROW EXECUTE FUNCTION guard_generated_image_context(); +`); +exports.down = pgm => pgm.sql(` + DROP TRIGGER generated_image_context_immutable ON generated_image_jobs; + DROP FUNCTION guard_generated_image_context(); + ALTER TABLE generated_image_jobs DROP context_included, DROP context_total; +`); diff --git a/public/components/admin.html b/public/components/admin.html index aa609c1c..592a6605 100644 --- a/public/components/admin.html +++ b/public/components/admin.html @@ -355,6 +355,12 @@
Separate model-callable authoring image behavior; includes generation and requested refinement images. History and restore affect Learning Hub only.
+Generating image...
'; - 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 = 'Generating image... You can leave the app open or return in a moment.
'; - }); - }) - .then(function (data) { + var button = document.getElementById('btn-assistant-image'); + var prompt = typeof promptOverride === 'string' ? promptOverride : (promptEl ? promptEl.value : ''); + if (!prompt.trim() && lastAnswer) prompt = 'Create a pediatric teaching visual from this conversation.'; + if (!prompt.trim() || button.disabled) return; + var owner; + try { owner = captureSharingOwner(); } catch (_) { return; } + var selection = generatedImageJobs; + button.disabled = true; + startAssistantImageJob(prompt, messages.map(function(m) { return { role: m.role, content: m.content }; })).then(function(data) { + assertSharingOwner(owner); + if (generatedImageJobs !== selection) return; if (!data.success) throw new Error(data.error || 'Image generation failed'); - var src = data.base64 ? ('data:image/png;base64,' + data.base64) : (data.imageUrl || data.url || ''); - if (!src) throw new Error('No image returned'); - lastGeneratedImageSrc = src; + generatedImageJobs = [{ jobId: data.jobId }]; + lastGeneratedImageSrc = ''; exporter.invalidate(); - var downloadUrl = data.downloadUrl || ''; - if (out) out.innerHTML = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); - if (fromChat) { - setBusy(false, 'Ready'); - var html = imageStore.renderGeneratedImage(src, 'Generated clinical visual', downloadUrl); - replaceLoadingMessage(loading, html, [], [], true); - } - }) - .catch(function (err) { - if (fromChat) setBusy(false, 'Error', true); - if (out) out.innerHTML = '' + escapeHtml(err.message) + '
'; - if (fromChat) replaceLoadingMessage(loading, 'Image generation failed: ' + err.message); - if (typeof showToast === 'function') showToast(err.message, 'error'); - }); - } - - 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(); - }); + out.replaceChildren(); + renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, image) { + if (generatedImageJobs[0]?.jobId !== image.jobId) return; + lastGeneratedImageSrc = image.imageUrl; + card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl)); + exporter.invalidate(); + }); + }).catch(function(error) { if (!validSharingOwner(owner) || error.name === 'AbortError') return; if (typeof showToast === 'function') showToast(error.message, 'error'); }) + .finally(function() { if (validSharingOwner(owner)) button.disabled = false; }); } function onAssistantDocumentClick(e) { @@ -581,21 +563,20 @@ import { function clearGeneratedImage() { lastGeneratedImageSrc = ''; + generatedImageJobs = []; imageStore.clear(); var out = document.getElementById('assistant-visual-output'); if (out) out.innerHTML = ''; exporter.invalidate(); } - function prepareSidebarImagePrompt(request) { - var promptEl = document.getElementById('assistant-image-prompt'); - var prompt = buildContextualImagePrompt(request, lastAnswer); - if (promptEl) { - promptEl.value = prompt; - promptEl.focus(); - } - appendMessage('assistant', 'I prepared the image prompt in the **Image / Graph** box on the right. Click **Generate image** there so the visual uses the current answer as context instead of treating this as a separate chat request.'); - setBusy(false, 'Ready'); + function attachImageJobs(row, message, jobs) { + if (!row || !jobs.length) return; + message.imageJobs = jobs.map(function(job) { return { jobId: job.jobId }; }); + renderImageJobs(row.querySelector('.assistant-bubble') || row, message.imageJobs, 'clinical_assistant', function(card, data) { + card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl)); + exporter.invalidate(); + }); } function clearConversation(event) { @@ -679,7 +660,8 @@ import { messages: messages, lastAnswer: lastAnswer, lastSources: lastSources, - lastGeneratedImageSrc: lastGeneratedImageSrc + lastGeneratedImageSrc: lastGeneratedImageSrc, + generatedImageJobs: generatedImageJobs }); } @@ -694,7 +676,8 @@ import { title: title, messages: messages, sources: lastSources, - lastAnswer: lastAnswer + lastAnswer: lastAnswer, + generatedImageJobs: generatedImageJobs }) .then(function (data) { setBusy(false, 'Ready'); @@ -783,7 +766,7 @@ import { function restoreSavedChat(payload) { messages = Array.isArray(payload.messages) ? payload.messages.map(function (m) { - var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [] }; + var message = { role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content || ''), sources: Array.isArray(m.sources) ? m.sources : [], imageJobs: m.imageJobs || [] }; if ((payload.version !== 2 || m.legacyClipped === true) && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { message.legacyClipped = true; if (message.role === 'assistant' && isRetainedLegacyAnswer(message.content, m.retainedAnswer)) message.retainedAnswer = m.retainedAnswer; @@ -796,19 +779,30 @@ import { if (finalMessage && finalMessage.role === 'assistant' && finalMessage.legacyClipped && (!finalMessage.sources.length || JSON.stringify(finalMessage.sources) === JSON.stringify(lastSources)) && isRetainedLegacyAnswer(finalMessage.content, lastAnswer)) finalMessage.retainedAnswer = lastAnswer; - lastGeneratedImageSrc = String(payload.generatedImage || ''); + generatedImageJobs = payload.generatedImageJobs || []; + // A selected job is authoritative, even for older saves containing a stale asset URL. + lastGeneratedImageSrc = generatedImageJobs.length ? '' : String(payload.generatedImage || ''); var wrap = document.getElementById('assistant-messages'); if (wrap) { wrap.innerHTML = ''; messages.forEach(function (m) { var display = savedMessagePresentation(m); - appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display); + var row = appendMessageNode(m.role, display.answer, m.sources && m.sources.length ? m.sources : lastSources, null, false, display); + attachImageJobs(row, m, m.imageJobs); }); wrap.scrollTop = wrap.scrollHeight; } renderSources(lastSources); var out = document.getElementById('assistant-visual-output'); - if (out) out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; + if (out) { + out.innerHTML = lastGeneratedImageSrc ? imageStore.renderGeneratedImage(lastGeneratedImageSrc, 'Generated clinical visual') : ''; + if (generatedImageJobs.length) renderImageJobs(out, generatedImageJobs, 'clinical_assistant', function(card, data) { + if (generatedImageJobs[0]?.jobId !== data.jobId) return; + lastGeneratedImageSrc = data.imageUrl; + card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(data.imageUrl, 'Generated teaching visual', data.downloadUrl)); + exporter.invalidate(); + }); + } exporter.invalidate(); setHandoff(''); updateConversationBudget(); @@ -882,7 +876,7 @@ import { function downloadTranscript() { var payload = { version: 2, title: deriveChatTitle(), messages: messages, sources: lastSources, - lastAnswer: lastAnswer, savedAt: new Date().toISOString() }; + lastAnswer: lastAnswer, generatedImageJobs: generatedImageJobs, savedAt: new Date().toISOString() }; var url = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' })); var link = document.createElement('a'); link.href = url; diff --git a/public/js/generatedImages.js b/public/js/generatedImages.js new file mode 100644 index 00000000..1806e5d6 --- /dev/null +++ b/public/js/generatedImages.js @@ -0,0 +1,123 @@ +import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; +// Authenticated assets: stable URLs are persisted; transient display URLs are realm-owned only. +const ASSET = /^\/api\/generated-images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?:\?download=1)?$/; +const urls = new Map(); +export function assetPath(src) { return ASSET.test(String(src || '')); } +export function captureImageOwner() { + try { return captureSharingOwner(); } + catch (_) { throw new DOMException('Verified account required', 'AbortError'); } +} +export function assertImageOwner(owner) { assertSharingOwner(owner); } +export async function imageJson(url, options = {}, ticket = captureImageOwner()) { + assertImageOwner(ticket); + const response = await fetch(url, { ...options, credentials: 'same-origin', signal: ticket.signal, headers: window.getAuthHeaders() }); + assertImageOwner(ticket); + const data = await response.json(); + assertImageOwner(ticket); + if (!response.ok || !data.success) throw new Error(data.error || 'Image request failed'); + return data; +} +export async function privateImageBlob(src, ticket = captureImageOwner()) { + if (!assetPath(src)) throw new Error('Invalid private image reference'); + assertImageOwner(ticket); + const headers = { ...window.getAuthHeaders() }; delete headers['Content-Type']; + const response = await fetch(src, { headers, credentials: 'same-origin', signal: ticket.signal, redirect: 'error' }); + assertImageOwner(ticket); + const mime = response.headers.get('content-type'); + const size = Number(response.headers.get('content-length')); + const checksum = response.headers.get('x-image-sha256'); + if (!response.ok || !['image/png','image/jpeg','image/webp'].includes(mime) || !Number.isInteger(size) || size < 1 || size > 16 * 1024 * 1024 || + response.headers.get('x-image-owner') !== String(ticket.ticket) || !/^[a-f0-9]{64}$/.test(checksum || '')) throw new Error('Private image headers failed verification'); + const reader = response.body.getReader(); const chunks = []; let count = 0; + while (true) { + const part = await reader.read(); assertImageOwner(ticket); + if (part.done) break; + count += part.value.length; + if (count > size) { await reader.cancel(); throw new Error('Private image exceeds declared size'); } + chunks.push(part.value); + } + const blob = new Blob(chunks, { type: mime }); + const bytes = await blob.arrayBuffer(); + const actual = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join(''); + assertImageOwner(ticket); + if (count !== size || actual !== checksum) throw new Error('Private image checksum failed'); + return blob; +} +export async function imageDataUrl(src, ticket = captureImageOwner()) { + assertImageOwner(ticket); + if (!assetPath(src)) return src; // Legacy saved bitmaps/URLs stay compatible. + const blob = await privateImageBlob(src, ticket); + assertImageOwner(ticket); + const data = await new Promise((resolve, reject) => { + const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error); reader.readAsDataURL(blob); + }); + assertImageOwner(ticket); return data; +} +export function transientImageUrl(blob, ticket = captureImageOwner()) { + assertImageOwner(ticket); + const url = URL.createObjectURL(blob); urls.set(url, ticket); return url; +} +export function revokeImageUrl(url) { URL.revokeObjectURL(url); urls.delete(url); } +export async function hydrateImage(img, src, ticket = captureImageOwner()) { + assertImageOwner(ticket); + if (!assetPath(src)) return; + img.removeAttribute('src'); + const blob = await privateImageBlob(src, ticket); + assertImageOwner(ticket); + if (img.isConnected) img.src = transientImageUrl(blob, ticket); +} +export function imageContextLabel(data) { + const c = data.context; + return c ? 'Image context: ' + c.includedTurns + '/' + c.totalTurns + ' preceding turns included; ' + c.used + '/' + c.limit + ' UTF-16 code units. ' + (c.totalTurns > c.includedTurns ? 'Older turns omitted from image input only. ' : '') + 'Full conversation unchanged.' : 'Image context metadata unavailable for this older job.'; +} +export function renderImageJobs(container, jobs, workflow, onDone) { + const ticket = captureImageOwner(); + (jobs || []).forEach(job => { + if (!/^[0-9a-f-]{36}$/.test(job.jobId || '')) return; + const card = document.createElement('section'); const status = document.createElement('p'); + status.setAttribute('role', 'status'); + const context = document.createElement('p'); context.textContent = imageContextLabel(job); + card.append(status, context); container.append(card); + const base = workflow === 'learning_hub' ? '/api/admin/learning/image/jobs/' : '/api/clinical-assistant/image/jobs/'; + async function poll() { + if (!validSharingOwner(ticket) || !card.isConnected) return; + try { + const data = await imageJson(base + job.jobId, {}, ticket); + assertImageOwner(ticket); + if (!card.isConnected) return; + status.textContent = data.error || ('Image: ' + data.status); + context.textContent = imageContextLabel(data); + if (data.status === 'done') { onDone(card, data); return; } + if (data.status === 'error') return; + } catch (error) { + if (!validSharingOwner(ticket) || error.name === 'AbortError') return; + status.textContent = 'Image status unavailable. Reopen image history to resume; no new paid job was started.'; + return; + } + setTimeout(poll, 2000); + } + poll(); + }); +} +// Learning viewer/Marp and assistant images may be inserted by existing renderers. +if (typeof MutationObserver !== 'undefined') { + const observer = new MutationObserver(records => { + records.forEach(record => record.removedNodes.forEach(node => { + if (node.nodeType !== 1) return; + const images = node.matches('img') ? [node] : node.querySelectorAll('img'); + images.forEach(img => { if (!img.isConnected && urls.has(img.src)) revokeImageUrl(img.src); }); + })); + document.querySelectorAll('img[src^="/api/generated-images/"]').forEach(img => { + if (img.closest('.ProseMirror')) return; // NodeView owns display; editor state retains stable src. + let owner; + try { owner = captureImageOwner(); } catch (_) { img.removeAttribute('src'); return; } + hydrateImage(img, img.getAttribute('src'), owner).catch(error => { if (validSharingOwner(owner) && error.name !== 'AbortError') img.alt = 'Private image unavailable'; }); + }); + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); +} +if (typeof window !== 'undefined') window.addEventListener('account-boundary', () => { + const obsolete = new Set(); + urls.forEach((owner, url) => { if (!validSharingOwner(owner)) { obsolete.add(url); revokeImageUrl(url); } }); + document.querySelectorAll('img[src^="blob:"]').forEach(img => { if (obsolete.has(img.src)) img.removeAttribute('src'); }); +}); diff --git a/public/js/learningHub.js b/public/js/learningHub.js index 9053c1aa..ca15ccc5 100644 --- a/public/js/learningHub.js +++ b/public/js/learningHub.js @@ -1,3 +1,4 @@ +import { createLearningImages } from './learningHub/images.js'; // ============================================================ // LEARNING HUB — User content feed, viewer, quizzes + Admin CMS // ============================================================ @@ -26,6 +27,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; var cmsLoaded = false; var currentContent = null; var currentView = 'feed'; // 'feed' | 'category' | 'viewer' + var learningImages = createLearningImages(function() { return _bodyEditor; }, function() { return (document.getElementById('lh-cms-edit-type') || {}).value === 'presentation' ? document.getElementById('lh-marp-editor') : null; }); var _bodyEditor = null; // Tiptap Editor instance for body var slides = createSlideController({ getMarkdown: getMarpMarkdown, @@ -465,6 +467,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; var slideCount = document.getElementById('lh-ai-slide-count') ? document.getElementById('lh-ai-slide-count').value : ''; var formData = new FormData(); + formData.append('idempotencyKey', crypto.randomUUID()); if (model) formData.append('model', model); formData.append('questionCount', questionCount); formData.append('contentType', contentType); @@ -512,6 +515,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; // Presentation returns marpMarkdown directly; others return content{} var payload = data.contentType === 'presentation' ? data : data.content; return applyAiContent(payload, contentType).then(function() { + learningImages.show(data.imageJobs || []); aiPanel.close(); showToast('Content generated! Review and save.', 'success'); }); @@ -631,8 +635,9 @@ import { createWebdavController } from './learningHub/webdavController.js'; .then(function(data) { hideBusy(); if (!data.success) { showToast(data.error || 'Refine failed', 'error'); return; } - if (_bodyEditor) _bodyEditor.commands.setContent(data.refined); - showToast('Body refined!', 'success'); + if (_bodyEditor && !data.bodyPreserved) _bodyEditor.commands.setContent(data.refined); + learningImages.show(data.imageJobs || []); + showToast(data.bodyPreserved ? 'Body unchanged; image queued for insertion.' : 'Body refined!', 'success'); }) .catch(function(err) { hideBusy(); showToast(err.message, 'error'); }); } @@ -642,6 +647,7 @@ import { createWebdavController } from './learningHub/webdavController.js'; var wrap = document.getElementById('lh-body-editor'); if (!wrap || _bodyEditor) return; _bodyEditor = makeTpEditor(wrap, '', false, false); + learningImages.mount(); } // ── Rich text toggle for questions / options ─────────────── diff --git a/public/js/learningHub/images.js b/public/js/learningHub/images.js new file mode 100644 index 00000000..7655e181 --- /dev/null +++ b/public/js/learningHub/images.js @@ -0,0 +1,76 @@ +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 = '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.
'; + 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(['