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 @@

Clinical Assistant IMAGE — poster instructions

Loading clinical image prompt...
+
+

Learning Hub IMAGE — authoring instructions

+

Separate model-callable authoring image behavior; includes generation and requested refinement images. History and restore affect Learning Hub only.

+
Loading Learning image prompt...
+
+
diff --git a/public/components/cms.html b/public/components/cms.html index c79836c9..b0577585 100644 --- a/public/components/cms.html +++ b/public/components/cms.html @@ -294,6 +294,9 @@ Subtitle here - Key point two">
+ +
+
diff --git a/public/js/admin.js b/public/js/admin.js index cae0bd1a..1738c170 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -1,3 +1,4 @@ +import './admin/imageSettings.js'; import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js'; // ============================================================ @@ -544,7 +545,8 @@ function adminTabActive() { var groups = [ ['scribe', document.getElementById('cms-scribe-prompts')], ['clinical-text', document.getElementById('cms-clinical-text-prompts')], - ['clinical-image', document.getElementById('cms-clinical-image-prompts')] + ['clinical-image', document.getElementById('cms-clinical-image-prompts')], + ['learning-image', document.getElementById('cms-learning-image-prompts')] ]; try { var data = await promptRequest('/api/admin/config/prompts'); diff --git a/public/js/admin/imageSettings.js b/public/js/admin/imageSettings.js new file mode 100644 index 00000000..5e058d8a --- /dev/null +++ b/public/js/admin/imageSettings.js @@ -0,0 +1,36 @@ +// Separate from the existing clinical model form; loading never replaces a draft. +import { imageJson } from '../generatedImages.js'; +let loading = false; +async function load() { + const root = document.getElementById('workflow-image-settings'); + if (!root || root.children.length || loading) return; + loading = true; + try { + const data = await imageJson('/api/admin/image-settings'); + for (const workflow of ['clinical_assistant', 'learning_hub']) { + const form = document.createElement('form'); + const heading = document.createElement('h4'); heading.textContent = workflow === 'learning_hub' ? 'Learning Hub image model and budget' : 'Clinical Assistant image budget'; + const help = document.createElement('p'); help.textContent = 'Budget: full original request + tool description + workflow/output/layout instructions + selected preceding context, in UTF-16 code units. Default 32,000; allowed 1,000–32,000. Conservative allowance, not an average or computed model token limit. Mandatory overflow is rejected; only whole recent context turns are included. Original chats and drafts are never truncated.'; + const label = document.createElement('label'); label.textContent = 'Image input budget (UTF-16 code units) '; + const budget = document.createElement('input'); budget.type = 'number'; budget.min = '1000'; budget.max = '32000'; budget.step = '1'; budget.required = true; budget.value = data.workflows[workflow].budget; label.append(budget); + form.append(heading, help, label); + let model; + if (workflow === 'learning_hub') { + const ml = document.createElement('label'); ml.textContent = 'Learning image model ID (configured LiteLLM gateway only) '; + model = document.createElement('input'); model.required = true; model.value = data.workflows[workflow].model; model.maxLength = 200; ml.append(model); form.append(ml); + } + const save = document.createElement('button'); save.type = 'submit'; save.textContent = 'Save image settings'; + const status = document.createElement('p'); status.setAttribute('role', 'status'); form.append(save, status); + form.onsubmit = async e => { + e.preventDefault(); if (save.disabled) return; save.disabled = true; + try { + await imageJson('/api/admin/image-settings/' + workflow, { method: 'PUT', body: JSON.stringify({ budget: Number(budget.value), ...(model ? { model: model.value } : {}) }) }); + status.textContent = 'Saved. New jobs snapshot these settings; existing jobs are unchanged.'; + } catch (error) { status.textContent = error.message + ' Draft preserved.'; } finally { save.disabled = false; } + }; + root.append(form); + } + } catch (_) { /* Next tab entry retries, without touching prompt drafts. */ } + finally { loading = false; } +} +document.addEventListener('tabChanged', e => { if (e.detail?.tab === 'admin') load(); }); diff --git a/public/js/assistant/api.js b/public/js/assistant/api.js index c87ed06d..a597d0f8 100644 --- a/public/js/assistant/api.js +++ b/public/js/assistant/api.js @@ -57,12 +57,16 @@ export function requestAssistantImage(prompt) { }).then(function(r) { return r.json(); }); } -export function startAssistantImageJob(prompt) { +var imageDraft = null; +export function startAssistantImageJob(prompt, history) { + const input = { prompt, ...(history === undefined ? {} : { history }) }; + const identity = JSON.stringify(input); + if (!imageDraft || imageDraft.identity !== identity) imageDraft = { identity, body: { ...input, idempotencyKey: crypto.randomUUID() } }; return fetch('/api/clinical-assistant/image/jobs', { method: 'POST', headers: authHeaders(), credentials: 'same-origin', - body: JSON.stringify({ prompt: prompt }) + body: JSON.stringify(imageDraft.body) }).then(function(r) { return r.json(); }); } diff --git a/public/js/assistant/export.js b/public/js/assistant/export.js index 94678417..7095c60d 100644 --- a/public/js/assistant/export.js +++ b/public/js/assistant/export.js @@ -1,3 +1,4 @@ +import { assetPath, imageDataUrl, imageJson } from '../generatedImages.js'; import { escapeAttr, escapeHtml } from './citations.js'; import { captureSharingOwner, assertSharingOwner, validSharingOwner, sharingFilename } from './sharing.js'; @@ -6,6 +7,7 @@ export function createAssistantExporter(options) { var exportCacheKey = ''; var exportCacheItems = null; var inlineExportClose = null; + window.addEventListener('account-boundary', invalidate); function invalidate() { exportCacheKey = ''; @@ -20,6 +22,21 @@ export function createAssistantExporter(options) { if (typeof options.showToast === 'function') options.showToast('No answer to export', 'error'); return; } + if ((state.generatedImageJobs || []).length || assetPath(state.lastGeneratedImageSrc) || (state.messages || []).some(m => (m.imageJobs || []).length)) { + var target = shouldUseInlineExport() ? null : openExportWindow(owner); + function closeTarget() { if (target) { try { target.close(); } catch (_) {} } } + owner.signal.addEventListener('abort', closeTarget, { once: true }); + return preparePrivateExport(state, owner).then(function(prepared) { + assertSharingOwner(owner); + var items = collectExportItems(prepared.messages, prepared.lastAnswer, prepared.lastSources || [], options.presentMessage); + if (target) writePrintableChatExport(target, items, prepared.lastGeneratedImageSrc, owner); + else writeInlineChatExport(items, prepared.lastGeneratedImageSrc, owner); + }).catch(function(error) { + closeTarget(); + if (!validSharingOwner(owner) || error.name === 'AbortError') return; + if (typeof options.showToast === 'function') options.showToast(error.message, 'error'); + }).finally(function() { owner.signal.removeEventListener('abort', closeTarget); }); + } var exportItems = collectExportItems(state.messages || [], state.lastAnswer, state.lastSources || [], options.presentMessage); var cacheKey = buildExportCacheKey(exportItems, state.lastGeneratedImageSrc || ''); if (exportCacheKey === cacheKey && exportCacheItems) { @@ -175,6 +192,7 @@ export function createAssistantExporter(options) { '
Question: ' + escapeHtml(item.question || '') + '
' + (summary ? '

Summary

' + renderMarkdown(summary, sources, renderOptions) + '
' : '') + '

Full Generated Answer

' + answerHtml + '
' + + (item.images || []).map(function(src) { return '
Generated teaching visual
'; }).join('') + (refs ? '

References

    ' + refs + '
' : '') + ''; }).join(''); @@ -286,6 +304,7 @@ function collectExportItems(messages, lastAnswer, lastSources, presentMessage) { summary: '', answer: display.answer, notice: display.notice, + images: m.images || [], sources: Array.isArray(m.sources) && m.sources.length ? m.sources : lastSources }); pendingQuestion = ''; @@ -321,3 +340,32 @@ export function buildExportCacheKey(items, imageSrc) { return { q: item.question, a: item.answer, notice: item.notice, s: (item.sources || []).map(function (s) { return [s.number, s.title, s.page]; }) }; }), image: imageSrc ? '1' : '' }); } + +async function preparePrivateExport(state, owner) { + assertSharingOwner(owner); + const messages = []; + for (const message of state.messages || []) { + const images = []; + for (const job of message.imageJobs || []) { + assertSharingOwner(owner); + const data = await imageJson('/api/clinical-assistant/image/jobs/' + job.jobId, {}, owner); + assertSharingOwner(owner); + if (data.status !== 'done') throw new Error('An image is not complete. Reopen image history before exporting; nothing was omitted.'); + images.push(await imageDataUrl(data.imageUrl, owner)); + assertSharingOwner(owner); + } + messages.push({ ...message, images }); + } + let sidebarImage = state.lastGeneratedImageSrc || ''; + if ((state.generatedImageJobs || []).length) { + assertSharingOwner(owner); + const data = await imageJson('/api/clinical-assistant/image/jobs/' + state.generatedImageJobs[0].jobId, {}, owner); + assertSharingOwner(owner); + if (data.status !== 'done') throw new Error('An image is not complete. Reopen image history before exporting; nothing was omitted.'); + sidebarImage = data.imageUrl; + } + assertSharingOwner(owner); + const lastGeneratedImageSrc = await imageDataUrl(sidebarImage, owner); + assertSharingOwner(owner); + return { ...state, messages, lastGeneratedImageSrc }; +} diff --git a/public/js/assistant/images.js b/public/js/assistant/images.js index 6807846b..171675e3 100644 --- a/public/js/assistant/images.js +++ b/public/js/assistant/images.js @@ -1,16 +1,15 @@ +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 previewKeyHandler = null; - var previewModal = null; - var previewOwner = null; + var previewClose = null; function renderGeneratedImage(src, alt, downloadUrl) { var id = 'img-' + (++generatedImageSeq); - generatedImages[id] = { src: src, downloadUrl: downloadUrl || '' }; + generatedImages[id] = { src: src, downloadUrl: downloadUrl || (assetPath(src) ? src + '?download=1' : '') }; return '
' + escapeAttr(alt || 'Generated image') + '' + '
' + '' + @@ -25,31 +24,37 @@ export function createAssistantImageStore() { var owner; try { owner = captureSharingOwner(); } catch (e) { return; } closeImagePreview(); - previewOwner = owner; - owner.signal.addEventListener('abort', closeImagePreview, { once: true }); var modal = document.createElement('div'); - previewModal = modal; modal.className = 'assistant-image-modal'; modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); - modal.innerHTML = '
Generated clinical visual
'; + modal.innerHTML = '
Generated clinical visual
'; + 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')) closeImagePreview(); + if (event.target === modal || event.target.closest('.assistant-image-modal-close') || event.target.closest('.assistant-image-modal-cancel')) close(); }); - previewKeyHandler = function (event) { if (event.key === 'Escape') closeImagePreview(); }; - document.addEventListener('keydown', previewKeyHandler); + 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 (previewModal) previewModal.remove(); - previewModal = null; - if (previewOwner) previewOwner.signal.removeEventListener('abort', closeImagePreview); - previewOwner = null; - document.body.classList.remove('assistant-image-preview-open'); - if (previewKeyHandler) document.removeEventListener('keydown', previewKeyHandler); - previewKeyHandler = null; + if (previewClose) previewClose(); } async function downloadImage(id) { @@ -208,7 +213,7 @@ async function downloadWithBrowser(src, ticket) { } var blob = await imageSourceToBlob(src, ticket); assertSharingOwner(ticket); - var objectUrl = URL.createObjectURL(blob); + var objectUrl = transientImageUrl(blob, ticket); var a = document.createElement('a'); a.href = objectUrl; a.download = 'clinical-visual.png'; @@ -216,16 +221,22 @@ async function downloadWithBrowser(src, ticket) { document.body.appendChild(a); a.click(); a.remove(); - setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); + setTimeout(function() { revokeImageUrl(objectUrl); }, 60000); } async function downloadFromServer(url, ticket) { assertSharingOwner(ticket); - var response = await fetch(url, { headers: authHeadersForDownload(), credentials: 'same-origin', signal: ticket.signal }); - assertSharingOwner(ticket); - if (!response.ok) throw new Error('Image download failed'); - var blob = await response.blob(); - 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); @@ -314,7 +325,7 @@ async function shareBlobWithWebFile(blob, name, ticket) { function downloadBlobWithAnchor(blob, name, ticket) { assertSharingOwner(ticket); - var objectUrl = URL.createObjectURL(blob); + var objectUrl = transientImageUrl(blob, ticket); var a = document.createElement('a'); a.href = objectUrl; a.download = name; @@ -322,7 +333,7 @@ function downloadBlobWithAnchor(blob, name, ticket) { document.body.appendChild(a); a.click(); a.remove(); - setTimeout(function() { URL.revokeObjectURL(objectUrl); }, 60000); + setTimeout(function() { revokeImageUrl(objectUrl); }, 60000); } function isMobileBrowser() { @@ -370,6 +381,11 @@ async function imageSourceToBlob(src, ticket) { 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'); diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index e323fc9b..5144a4db 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -7,7 +7,9 @@ import { EMPTY_PROMPT_SETS } from './assistant/data.js'; import { escapeAttr, escapeHtml, renderAssistantMarkdown } from './assistant/citations.js'; import { renderSourcesList } from './assistant/sources.js'; import { createAssistantExporter } from './assistant/export.js'; -import { buildContextualImagePrompt, createAssistantImageStore, isImageRequest } from './assistant/images.js'; +import { createAssistantImageStore } from './assistant/images.js'; +import { renderImageJobs, imageJson } from './generatedImages.js'; +import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js'; import { deleteSavedAssistantChat, fetchAssistantChat, @@ -16,7 +18,6 @@ import { fetchSavedAssistantChat, fetchSavedAssistantChats, openAssistantStream, - fetchAssistantImageJob, startAssistantImageJob, saveAssistantChat, requestAssistantHandoff @@ -28,6 +29,7 @@ import { var mermaidReady = false; var dynamicExamples = []; var lastGeneratedImageSrc = ''; + var generatedImageJobs = []; var markdownRenderer = null; var assistantBusy = false; var activeAssistantRequest = null; @@ -88,6 +90,23 @@ import { bindExampleButtons(document); loadSavedChats(); + var historyButton = document.createElement('button'); + historyButton.type = 'button'; historyButton.textContent = 'Reopen image history'; + imageBtn.parentNode.appendChild(historyButton); + historyButton.addEventListener('click', function() { + var owner; + try { owner = captureSharingOwner(); } catch (_) { return; } + imageJson('/api/image-jobs/clinical_assistant', {}, owner).then(function(data) { + assertSharingOwner(owner); + var out = document.getElementById('assistant-visual-output'); out.replaceChildren(); + renderImageJobs(out, data.jobs, 'clinical_assistant', function(card, image) { + card.insertAdjacentHTML('beforeend', imageStore.renderGeneratedImage(image.imageUrl, 'Generated teaching visual', image.downloadUrl)); + var use = document.createElement('button'); use.type = 'button'; use.textContent = 'Include in saved chat'; + use.onclick = function() { if (!validSharingOwner(owner) || !card.isConnected) return; generatedImageJobs = [{ jobId: image.jobId }]; lastGeneratedImageSrc = image.imageUrl; exporter.invalidate(); }; + card.appendChild(use); + }); + }).catch(function(e) { if (validSharingOwner(owner) && e.name !== 'AbortError') showToast(e.message, 'error'); }); + }); } function loadStatus() { @@ -140,14 +159,6 @@ import { return; } - if (isImageRequest(text)) { - appendMessage('user', text); - if (input) input.value = ''; - prepareSidebarImagePrompt(text); - updateConversationBudget(); - return; - } - // Only text/roles go to inference; the full source maps/images stay in the transcript. var history = messages.map(function(m) { return { role: m.role, content: m.content }; }); var request = createAssistantRequest(); @@ -167,6 +178,7 @@ import { return streamAssistantResponse({ message: text, + idempotencyKey: crypto.randomUUID(), history: history, includeContext: !includeContext || includeContext.checked }, loading, request) @@ -276,6 +288,7 @@ import { lastAnswer = finalData.answer || finalData.markdown || ''; lastSources = finalData.sources || finalData.citations || streamSources; replaceLoadingMessage(loading, lastAnswer, lastSources, finalData.suggestions || []); + attachImageJobs(loading, messages[messages.length - 1], finalData.imageJobs || []); renderSources(lastSources); if (finalData.model) { var label = document.getElementById('assistant-model-label'); @@ -483,64 +496,33 @@ import { mermaidReady = true; } - function generateImage(promptOverride, fromChat) { + function generateImage(promptOverride) { var promptEl = document.getElementById('assistant-image-prompt'); var out = document.getElementById('assistant-visual-output'); - var prompt = typeof promptOverride === 'string' ? promptOverride.trim() : (promptEl ? promptEl.value.trim() : ''); - if (!prompt && lastAnswer) prompt = 'Create a concise pediatric clinical teaching visual from this answer:\n\n' + lastAnswer.slice(0, 3000); - if (!prompt) { if (typeof showToast === 'function') showToast('Enter an image prompt or ask a question first', 'error'); return; } - if (fromChat) setBusy(true, 'Generating image...'); - var loading = fromChat ? appendLoadingMessage('Generating image', 'Creating the requested clinical visual...') : null; - if (out) out.innerHTML = '

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 = '

Learning Hub images

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(['Learning content' + 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 }; +} diff --git a/public/js/learningHub/sanitize.js b/public/js/learningHub/sanitize.js index dbd5188e..dd23af8b 100644 --- a/public/js/learningHub/sanitize.js +++ b/public/js/learningHub/sanitize.js @@ -15,8 +15,8 @@ export function sanitizeHtml(html) { return window.DOMPurify.sanitize(html, { ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6', 'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td', - 'hr','div','span','sub','sup','dl','dt','dd'], - ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'], + 'hr','div','span','sub','sup','dl','dt','dd','img'], + ALLOWED_ATTR: ['src','alt','href','colspan','rowspan','class','target','rel'], ADD_ATTR: ['target'], FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'], ALLOW_DATA_ATTR: false diff --git a/public/js/learningHub/tiptapEditor.js b/public/js/learningHub/tiptapEditor.js index a3fb03c5..e555840a 100644 --- a/public/js/learningHub/tiptapEditor.js +++ b/public/js/learningHub/tiptapEditor.js @@ -1,3 +1,4 @@ +import { generatedImageExtension } from './images.js'; function getTiptap() { return window.Tiptap || {}; } @@ -123,7 +124,8 @@ export function makeTpEditor(wrap, existingHtml, mini, isOption) { extensions: [ T.StarterKit, T.Link.configure({ openOnClick: false, autolink: true }), - T.Underline + T.Underline, + generatedImageExtension(T) ], content: existingHtml || '', onUpdate: function() { updateTpState(wrap, ed); }, diff --git a/scripts/test-generated-images.sh b/scripts/test-generated-images.sh new file mode 100644 index 00000000..2806becd --- /dev/null +++ b/scripts/test-generated-images.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Disposable synthetic-only PG/MinIO. No published ports or access to application networks. +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +deps=/tmp/ped-ai-release-deps-20260906/node_modules +run=ped-image-check-$$ +secrets=$(mktemp -d) +chmod 700 "$secrets" +cleanup() { docker rm -f "$run-pg" "$run-s3" >/dev/null 2>&1 || true; docker network rm "$run" >/dev/null 2>&1 || true; rm -rf "$secrets"; } +trap cleanup EXIT +# These are disposable test credentials, not production secrets/identities. +printf '%s' 'synthetic-image-app' > "$secrets/access" +printf '%s' 'synthetic-app-secret-only' > "$secrets/secret" +chmod 600 "$secrets/"* +docker network create --internal "$run" >/dev/null +docker run -d --name "$run-pg" --network "$run" --network-alias test-pg --memory 256m --cpus 1 -e POSTGRES_PASSWORD=synthetic-only -e POSTGRES_DB=image_lane postgres:16 >/dev/null +docker run -d --name "$run-s3" --network "$run" --network-alias test-s3 --tmpfs /data:mode=1777 --memory 512m --cpus 1 -e GOMEMLIMIT=256MiB -e MINIO_ROOT_USER=synthetic-root -e MINIO_ROOT_PASSWORD=synthetic-root-only ped-ai-minio:9e49d5e7a648f-go1.26.1 server /data >/dev/null +for i in $(seq 1 30); do if docker exec "$run-pg" pg_isready -U postgres -d image_lane >/dev/null; then break; fi; sleep 1; done +cat > "$secrets/policy.json" <<'JSON' +{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:ListBucket","s3:GetBucketLocation"],"Resource":["arn:aws:s3:::generated-images"]},{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::generated-images/*"]}]} +JSON +docker run --rm --network "$run" --memory 512m --cpus 1 -e GOMEMLIMIT=256MiB -v "$secrets/policy.json:/policy.json:ro" --entrypoint /bin/sh minio/mc:RELEASE.2025-04-16T18-13-26Z -c ' + set -e + for i in $(seq 1 30); do mc alias set fixture http://test-s3:9000 synthetic-root synthetic-root-only >/dev/null 2>&1 && break; sleep 1; done + for i in $(seq 1 30); do mc ready fixture >/dev/null 2>&1 && break; sleep 1; done + mc mb --ignore-existing fixture/generated-images >/dev/null + mc admin policy create fixture images /policy.json >/dev/null + mc admin user add fixture synthetic-image-app synthetic-app-secret-only >/dev/null + mc admin policy attach fixture images --user synthetic-image-app >/dev/null +' +docker run --rm --network "$run" --memory 768m --cpus 2 -v "$root:$root:ro" -w "$root" -v "$deps:$deps:ro" -v "$secrets:/test-secrets:ro" \ + -e NODE_PATH="$deps" -e GENERATED_IMAGES_TEST_DB=postgresql://postgres:synthetic-only@test-pg:5432/image_lane \ + -e DATA_ENCRYPTION_KEY=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + -e GENERATED_IMAGES_S3_ENDPOINT=http://test-s3:9000 -e GENERATED_IMAGES_S3_ACCESS_KEY_FILE=/test-secrets/access -e GENERATED_IMAGES_S3_SECRET_KEY_FILE=/test-secrets/secret \ + node:24-bookworm node --test test/generated-images.integration.js diff --git a/server.js b/server.js index cc6aea73..5de80778 100644 --- a/server.js +++ b/server.js @@ -233,6 +233,7 @@ app.use('/api/auth', require('./src/routes/oidc')); // Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict // (moderators need access to /api/admin/learning but not other /api/admin routes) +app.use('/api/admin/learning/image', require('./src/routes/generatedImages').learningRouter); app.use('/api/admin/learning', require('./src/routes/learningAdmin')); app.use('/api/admin/learning', require('./src/routes/learningAI')); @@ -298,6 +299,9 @@ app.use('/api', require('./src/routes/memories')); app.use('/api', require('./src/routes/notes')); app.use('/api', require('./src/routes/diagrams')); app.use('/api', require('./src/routes/clinicalAssistant')); +app.use('/api', require('./src/routes/generatedImages')); +var imageWorker = require('./src/utils/generatedImages').service(); +imageWorker.start(); app.use('/api', require('./src/routes/documents')); app.use('/api', require('./src/routes/audioBackups')); app.use('/api', require('./src/routes/billing')); @@ -357,6 +361,7 @@ function shutdown(signal) { if (shuttingDown) return; shuttingDown = true; console.log('[' + signal + '] starting graceful shutdown…'); + var imageWorkerStopped = imageWorker.stop(); // Stop accepting new connections; finish the ones already in-flight. server.close(async function(err) { if (err) console.error('[shutdown] server.close error:', err.message); @@ -369,6 +374,8 @@ function shutdown(signal) { console.log('[shutdown] Audit queues flushed.'); } } catch (e) { console.error('[shutdown] audit drain:', e.message); } + // Stop image claims and abort/drain the active worker before closing its DB. + await imageWorkerStopped; // Close the Postgres pool so pending queries finish/reject cleanly. try { var dbMod = require('./src/db/database'); diff --git a/src/routes/clinicalAssistant.js b/src/routes/clinicalAssistant.js index 6d9339fc..b7490954 100644 --- a/src/routes/clinicalAssistant.js +++ b/src/routes/clinicalAssistant.js @@ -6,14 +6,13 @@ // ============================================================ 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'); var { callAI, callAIStream } = require('../utils/ai'); -var { gatewayUrl } = require('../utils/errors'); -var { getLiteLLMHeaders } = require('../utils/litellm'); +var generatedImages = require('../utils/generatedImages'); +var imageTool = require('../utils/imageTool'); +var imageLinks = require('../utils/generatedImageLinks'); var logger = require('../utils/logger'); var cryptoUtil = require('../utils/crypto'); var redisCache = require('../utils/redis'); @@ -43,15 +42,13 @@ var { var { conversationBudget, checkConversation, savedChatPayload } = require('../utils/clinicalConversation'); -var { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas } = require('../utils/clinicalPrompts'); +var { DEFAULT_BEHAVIOR } = require('../utils/clinicalPrompts'); router.use(authMiddleware); var GREETING_RE = /^(hi|hello|hey|yo|good\s+(morning|afternoon|evening)|thanks|thank you|ok|okay|sup)[\s.!?]*$/i; var MAX_SAVED_CHATS_PER_USER = 100; var MAX_SAVED_CHAT_TITLE = 160; -var IMAGE_JOB_TTL_SECONDS = 15 * 60; -var imageJobs = new Map(); var promptPool = createClinicalPromptPool({ redisCache: redisCache, callAI: callAI, @@ -155,6 +152,7 @@ router.post('/clinical-assistant/chats', async function(req, res) { try { var title = cleanSavedChatTitle(req.body.title || firstUserMessage(req.body.messages) || 'Clinical assistant chat'); var payload = savedChatPayload(req.body); + await imageLinks.validateChat(db, payload, req.user.id); var payloadText = JSON.stringify(payload); var count = await db.get('SELECT COUNT(*) as cnt FROM clinical_assistant_chats WHERE user_id = $1', [req.user.id]); @@ -194,9 +192,12 @@ router.post('/clinical-assistant/chat', async function(req, res) { var ai = await callAI(prepared.messages, assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15, + tools: imageTool.tools, maxTokens: 2600 })); - var finalized = await finalizeAssistantAnswer(ai, { + ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body, + imageContext: prepared.imageContext, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI }); + var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, { messages: prepared.messages, chatModel: prepared.chatModel, callAI: callAI, @@ -212,6 +213,7 @@ router.post('/clinical-assistant/chat', async function(req, res) { res.json({ success: true, answer: answer, + imageJobs: ai.imageJobs || [], sources: sanitizeSourcesForClient(prepared.sources), model: ai.model || prepared.chatModel || null, provider: ai.provider || null, @@ -255,12 +257,15 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) { var ai = await callAIStream(prepared.messages, assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15, + tools: imageTool.tools, maxTokens: 2600 }), function(delta) { sendEvent('token', { token: delta }); }); - var finalized = await finalizeAssistantAnswer(ai, { + ai = await imageTool.dispatch(ai, { owner: req.user.id, workflow: 'clinical_assistant', body: req.body, + imageContext: prepared.imageContext, messages: prepared.messages, options: assistantGenerationOptions({ model: prepared.chatModel || undefined, temperature: 0.15 }), callAI: callAI }); + var finalized = ai.imageToolHandled ? { answer: String(ai.content || ''), ai: ai } : await finalizeAssistantAnswer(ai, { messages: prepared.messages, chatModel: prepared.chatModel, callAI: callAI, @@ -277,6 +282,7 @@ router.post('/clinical-assistant/chat/stream', async function(req, res) { sendEvent('done', { success: true, answer: answer, + imageJobs: ai.imageJobs || [], sources: safeSources, model: ai.model || prepared.chatModel || null, provider: ai.provider || null, @@ -313,77 +319,34 @@ router.post('/clinical-assistant/handoff', async function(req, res) { } }); -router.post('/clinical-assistant/image', async function(req, res) { +async function submitImage(req, res, synchronous) { 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 image = await generateImage(prompt, model); - logger.audit(req.user.id, 'clinical_assistant_image', 'Generated clinical assistant image', req, { category: 'clinical', model: model }); - res.json(Object.assign({ success: true, model: model }, image)); - } 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 generation failed' }); - } -}); - -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' }); - } -}); - + const body = req.body || {}; + let job = await generatedImages.service().enqueue(req.user.id, 'clinical_assistant', { prompt: body.prompt, ...(body.layout === undefined ? {} : { layout: body.layout }) }, generatedImages.requestKey(body), false, body.history === undefined ? undefined : generatedImages.imageContext( + checkConversation(body.history, body.prompt, await getConversationLimit()).message, body.history)); + if (synchronous) { + const deadline = Date.now() + 125000; + while (['pending', 'running'].includes(job.status) && Date.now() < deadline && !res.destroyed) { + job = await generatedImages.service().get(job.jobId, req.user.id, 'clinical_assistant'); + if (['pending', 'running'].includes(job.status)) await new Promise(resolve => setTimeout(resolve, 500)); + } + if (res.destroyed) return; + if (['pending', 'running'].includes(job.status)) res.status(202); // Poll this same job, never regenerate on timeout. + } + res.json(job); + } catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } +} +router.post('/clinical-assistant/image', (req, res) => submitImage(req, res, true)); +router.post('/clinical-assistant/image/jobs', (req, res) => submitImage(req, res, false)); 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, - downloadUrl: job.status === 'done' && job.base64 ? '/api/clinical-assistant/image/jobs/' + encodeURIComponent(job.id) + '/download' : null, - error: job.error || null - }); - } catch (e) { - res.status(500).json({ error: 'Image job status failed' }); - } + try { res.json(await generatedImages.service().get(req.params.id, req.user.id, 'clinical_assistant')); } + catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } }); - router.get('/clinical-assistant/image/jobs/:id/download', async function(req, res) { try { - var job = await getImageJob(req.params.id); - if (!job || String(job.userId) !== String(req.user.id) || job.status !== 'done' || !job.base64) { - return res.status(404).json({ error: 'Image job not found' }); - } - var buffer = Buffer.from(String(job.base64), 'base64'); - res.setHeader('Content-Type', 'image/png'); - res.setHeader('Content-Disposition', 'attachment; filename="clinical-visual.png"'); - res.setHeader('Cache-Control', 'private, max-age=900'); - res.send(buffer); - } catch (e) { - res.status(500).json({ error: 'Image download failed' }); - } + await generatedImages.service().get(req.params.id, req.user.id, 'clinical_assistant'); + await require('./generatedImages').sendAsset(req, res, true); + } catch (e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } }); async function prepareAssistantChat(body) { @@ -453,6 +416,7 @@ async function prepareAssistantChat(body) { var context = formatSourcesForPrompt(sources); return { message: message, + imageContext: generatedImages.imageContext(message, history), chatModel: chatModel, sources: sources, messages: [ @@ -615,84 +579,6 @@ function isUsefulIndexedTopicExample(item) { return true; } -async function generateImage(prompt, model) { - if (!process.env.LITELLM_API_BASE) throw new Error('LiteLLM is required for image generation'); - var headers = getLiteLLMHeaders('application/json'); - var behavior = await getSetting('clinical_assistant.image_behavior', DEFAULT_IMAGE_BEHAVIOR); - var renderedPrompt = imagePromptForCanvas(prompt, behavior); - var size = process.env.CLINICAL_ASSISTANT_IMAGE_SIZE || 'auto'; - var resp = await generateImageRequest(model, renderedPrompt, size, headers).catch(async function(e) { - if (!isInvalidImageSizeError(e) || size === '1024x1024') throw e; - return generateImageRequest(model, renderedPrompt, '1024x1024', headers); - }); - var item = resp.data && resp.data.data && resp.data.data[0] ? resp.data.data[0] : {}; - var base64 = item.b64_json || null; - if (!base64 && item.url) base64 = await fetchImageUrlAsBase64(item.url).catch(function() { return null; }); - return { imageUrl: item.url || null, base64: base64, raw: (!item.url && !base64) ? resp.data : undefined }; -} - -async function fetchImageUrlAsBase64(url) { - if (!/^https?:\/\//i.test(String(url || ''))) return null; - var resp = await axios.get(url, { responseType: 'arraybuffer', timeout: 60000 }); - return Buffer.from(resp.data).toString('base64'); -} - -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, - prompt: prompt, - size: size - }, { headers: headers, timeout: 120000 }); -} - -function isInvalidImageSizeError(e) { - var detail = e && e.response && e.response.data ? JSON.stringify(e.response.data) : (e && e.message ? e.message : ''); - return /invalid size|unsupported size|supported sizes/i.test(detail); -} - function getConversationLimit() { return conversationBudget(process.env).limit; } diff --git a/src/routes/generatedImages.js b/src/routes/generatedImages.js new file mode 100644 index 00000000..0841425c --- /dev/null +++ b/src/routes/generatedImages.js @@ -0,0 +1,67 @@ +const router = require('express').Router(); +const { authMiddleware, moderatorMiddleware, adminMiddleware } = require('../middleware/auth'); +const images = require('../utils/generatedImages'); +const db = require('../db/database'); +router.use(authMiddleware); +function fail(res, e) { res.status(e.statusCode || 503).json({ error: e.statusCode ? e.message : 'Image service unavailable' }); } +async function sendAsset(req, res, download) { + const image = await images.service().asset(req.params.id, req.user); + res.setHeader('Content-Type', image.mime); + res.setHeader('Content-Length', image.bytes.length); + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Cache-Control', 'private, no-store'); + res.setHeader('X-Image-SHA256', image.checksum); + res.setHeader('X-Image-Owner', String(req.user.id)); // requesting account ticket, not original author + res.setHeader('Content-Disposition', (download ? 'attachment' : 'inline') + '; filename="generated-image.' + ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[image.mime] + '"'); + res.send(image.bytes); +} +router.get('/generated-images/:id', async (req, res) => { + try { await sendAsset(req, res, req.query.download === '1'); } catch (e) { fail(res, e); } +}); +router.get('/image-jobs/:workflow', async (req, res) => { + try { + if (!['clinical_assistant', 'learning_hub'].includes(req.params.workflow)) throw images.failure(404, 'Workflow not found'); + const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE owner_id=$1 AND workflow=$2 ORDER BY created_at DESC LIMIT 100', [req.user.id, req.params.workflow]); + res.json({ success: true, jobs: result.rows.map(images.publicJob) }); + } catch (e) { fail(res, e); } +}); +// Mounted narrowly before the server's blanket /api/admin guards. +const learningRouter = require('express').Router(); +learningRouter.use(authMiddleware, moderatorMiddleware); +learningRouter.post('/jobs', async (req, res) => { + try { res.json(await images.service().enqueue(req.user.id, 'learning_hub', { prompt: req.body.prompt, ...(req.body.layout === undefined ? {} : { layout: req.body.layout }) }, images.requestKey(req.body), false, req.body.content === undefined ? undefined : images.imageContext(req.body.prompt, [{ role: 'user', content: req.body.content }]))); } + catch (e) { fail(res, e); } +}); +learningRouter.get('/jobs/:id', async (req, res) => { + try { res.json(await images.service().get(req.params.id, req.user.id, 'learning_hub')); } catch (e) { fail(res, e); } +}); +router.get('/admin/image-settings', adminMiddleware, async (req, res) => { + try { + const workflows = {}; + for (const workflow of ['clinical_assistant', 'learning_hub']) workflows[workflow] = { + model: await db.getSetting(workflow + '.image_model') || '', + budget: images.budgetLimit(await db.getSetting(workflow + '.image_budget')), unit: 'UTF-16 code units' + }; + res.json({ success: true, workflows }); + } catch (e) { fail(res, e); } +}); +router.put('/admin/image-settings/:workflow', adminMiddleware, async (req, res) => { + try { + const workflow = req.params.workflow; + if (!['clinical_assistant', 'learning_hub'].includes(workflow)) throw images.failure(404, 'Workflow not found'); + const budget = images.budgetLimit(req.body.budget); + if (workflow === 'learning_hub' && (typeof req.body.model !== 'string' || !/^[a-zA-Z0-9_.:/-]{1,200}$/.test(req.body.model))) throw images.failure(400, 'Enter an image model ID enabled at the configured image gateway'); + const client = await db.pool.connect(); + try { + await client.query('BEGIN'); + for (const [key, value] of Object.entries(workflow === 'learning_hub' ? { image_model: req.body.model, image_budget: budget } : { image_budget: budget })) { + await client.query('INSERT INTO app_settings(key,value) VALUES($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2,updated_at=NOW()', [workflow + '.' + key, String(value)]); + } + await client.query('COMMIT'); + } catch (e) { await client.query('ROLLBACK').catch(() => {}); throw e; } finally { client.release(); } + res.json({ success: true }); + } catch (e) { fail(res, e); } +}); +module.exports = router; +module.exports.sendAsset = sendAsset; +module.exports.learningRouter = learningRouter; diff --git a/src/routes/learningAI.js b/src/routes/learningAI.js index 3f68abab..5fc56a80 100644 --- a/src/routes/learningAI.js +++ b/src/routes/learningAI.js @@ -8,6 +8,7 @@ var multer = require('multer'); var axios = require('axios'); var path = require('path'); var { callAI } = require('../utils/ai'); +var imageTool = require('../utils/imageTool'); var { authMiddleware, moderatorMiddleware } = require('../middleware/auth'); var db = require('../db/database'); var cryptoUtil = require('../utils/crypto'); @@ -263,6 +264,8 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) var wordCount = parseInt(req.body.wordCount) || 0; var slideCount = parseInt(req.body.slideCount) || 0; + if (typeof topic !== 'string' || typeof refinement !== 'string') return res.status(400).json({ error: 'topic and refinement must be text' }); + var docText = ''; var fileCount = 0; @@ -318,13 +321,14 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) var existingCategories = await db.all('SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC', []); var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories }); - var result = await callAI( - [ + var aiMessages = [ { role: 'system', content: 'You are a medical education content generator. Return ONLY the requested JSON or Marp markdown — no preamble, no commentary, no code fences, no thinking. Start your response with { or --- as appropriate.' }, { role: 'user', content: prompt } - ], - { model: model, temperature: 0.4, maxTokens: 8000 } - ); + ]; + var aiOptions = { model: model, temperature: 0.4, maxTokens: 8000, tools: imageTool.tools }; + var result = await callAI(aiMessages, aiOptions); + result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: { ...req.body, docText }, imageContext: require('../utils/generatedImages').imageContext( + [topic, refinement].filter(Boolean).join('\n\n') || 'Generate educational content from the supplied document.', docText ? [{ role: 'user', content: docText }] : []), messages: aiMessages, options: aiOptions, callAI }); var raw = result.content.trim(); // Strip any leading text before the first { or --- (models sometimes add preamble) @@ -345,12 +349,12 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) try { parsedPres = m ? JSON.parse(m[0]) : null; } catch(e2) { parsedPres = null; } } if (parsedPres && parsedPres.marpMarkdown) { - return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], model: result.model }); + return res.json({ success: true, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], imageJobs: result.imageJobs || [], model: result.model }); } } // Plain Marp markdown (no questions requested, or parse failed) var marpMd = raw.replace(/^```(?:markdown|marp)?\s*/i, '').replace(/\s*```\s*$/, ''); - return res.json({ success: true, contentType: 'presentation', marpMarkdown: marpMd, questions: [], model: result.model, docLength: docText.length }); + return res.json({ success: true, contentType: 'presentation', marpMarkdown: marpMd, questions: [], imageJobs: result.imageJobs || [], model: result.model, docLength: docText.length }); } // Strip code fences and any trailing text after JSON @@ -403,14 +407,14 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) res.json({ success: true, content: parsed, - model: result.model, + imageJobs: result.imageJobs || [], model: result.model, docLength: docText.length, fileCount: fileCount || (webdavPath ? 1 : 0) }); } catch (err) { console.error('[LearningAI]', err.message); - res.status(500).json({ error: 'Generation failed' }); + res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Generation failed' }); } }); @@ -420,27 +424,30 @@ router.post('/ai-generate', upload.array('files', 10), async function(req, res) router.post('/ai-refine', async function(req, res) { try { var { content, instructions, model } = req.body; - if (!content || !instructions) return res.status(400).json({ error: 'content and instructions required' }); + if (typeof content !== 'string' || typeof instructions !== 'string' || !content || !instructions.trim()) return res.status(400).json({ error: 'content and instructions required' }); var prompt = `You are editing educational medical content. Refine the following HTML body according to the instructions below. INSTRUCTIONS: ${instructions} CURRENT CONTENT: -${content.substring(0, 8000)} +${content} -Return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact.`; +For a text-only refinement, return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact. If the instruction calls for an image, invoke generate_image instead; the existing body will be preserved regardless of any accompanying text. Do not combine image insertion with a body rewrite.`; - var result = await callAI( - [{ role: 'user', content: prompt }], - { model: model, temperature: 0.3, maxTokens: 4000 } - ); + var aiMessages = [{ role: 'user', content: prompt }]; + var aiOptions = { model: model, temperature: 0.3, maxTokens: 4000, tools: imageTool.tools }; + var result = await callAI(aiMessages, aiOptions); + // A tool call never authorizes a text rewrite, even if the model also emits HTML. + var imageOnly = Boolean(result.toolCalls?.length); + if (imageOnly) result = { ...result, content }; + result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: req.body, imageContext: require('../utils/generatedImages').imageContext(instructions, [{ role: 'user', content }]), messages: aiMessages, options: aiOptions, callAI }); - var refined = result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, ''); - res.json({ success: true, refined, model: result.model }); + var refined = imageOnly ? content : result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, ''); + res.json({ success: true, refined, bodyPreserved: imageOnly, imageJobs: result.imageJobs || [], model: result.model }); } catch (err) { console.error('[LearningAI]', err.message); - res.status(500).json({ error: 'Request failed' }); + res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Request failed' }); } }); @@ -551,6 +558,11 @@ router.post('/generate-pptx', async function(req, res) { var { markdown, title } = req.body; if (!markdown) return res.status(400).json({ error: 'markdown required' }); + var assetImages = {}; + for (const id of require('../utils/generatedImageLinks').references(markdown)) { + const image = await require('../utils/generatedImages').service().asset(id, req.user); + assetImages['/api/generated-images/' + id] = 'data:' + image.mime + ';base64,' + image.bytes.toString('base64'); + } var PptxGenJS = require('pptxgenjs'); var pptx = new PptxGenJS(); @@ -589,6 +601,7 @@ router.post('/generate-pptx', async function(req, res) { // Classify a line by type function classifyLine(line) { + if (/^!\[[^\]]*\]\(\/api\/generated-images\/[0-9a-f-]{36}\)$/.test(line.trim())) return 'image'; if (/^\|.+\|/.test(line)) return 'table'; if (/^\|[\s:-]+\|/.test(line)) return 'table-sep'; if (/^>\s+/.test(line)) return 'blockquote'; @@ -639,6 +652,13 @@ router.post('/generate-pptx', async function(req, res) { var line = lines[i]; var type = classifyLine(line); + if (type === 'image') { + var src = line.trim().match(/\(([^)]+)\)$/)[1]; + if (!assetImages[src]) throw new Error('Generated image unavailable'); + slide.addImage({ data: assetImages[src], x: 0.5, y: contentY, w: 11.8, h: Math.max(0.5, 5.2 - contentY), sizing: { type: 'contain', w: 11.8, h: Math.max(0.5, 5.2 - contentY) } }); + contentY = 5.2; i++; continue; + } + // ── Code block ── if (type === 'code-fence' || inCodeBlock) { if (type === 'code-fence' && !inCodeBlock) { diff --git a/src/routes/learningAdmin.js b/src/routes/learningAdmin.js index 39162e4e..a546a131 100644 --- a/src/routes/learningAdmin.js +++ b/src/routes/learningAdmin.js @@ -5,6 +5,7 @@ var express = require('express'); var router = express.Router(); var db = require('../db/database'); +var imageLinks = require('../utils/generatedImageLinks'); var { authMiddleware, moderatorMiddleware } = require('../middleware/auth'); var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings'); @@ -149,12 +150,18 @@ router.post('/content', async function(req, res) { var slug = await uniqueSlug('learning_content', title.trim()); - var result = await db.run( - 'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id] - ); - - var contentId = result.lastInsertRowid; + var client = await db.pool.connect(); + var contentId; + try { + await client.query('BEGIN'); + var imageIds = await imageLinks.validateLearning(client, body, req.user.id); + var result = await client.query( + 'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id', + [title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]); + contentId = result.rows[0].id; + await imageLinks.setLinks(client, contentId, imageIds); + await client.query('COMMIT'); + } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } // Generate embedding asynchronously (don't block response) if (isEmbeddingsAvailable() && body && body.trim()) { @@ -170,22 +177,27 @@ router.post('/content', async function(req, res) { } res.json({ success: true, id: contentId, slug: slug }); - } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } + } catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); } }); router.put('/content/:id', async function(req, res) { try { - var item = await db.get('SELECT * FROM learning_content WHERE id = ?', [req.params.id]); - if (!item) return res.status(404).json({ error: 'Content not found' }); - var { title, body, category_id, subject, content_type, published } = req.body; - - var newTitle = title !== undefined ? title : item.title; - var newBody = body !== undefined ? body : item.body; - var newSubject = subject !== undefined ? subject : item.subject; - - await db.run( - 'UPDATE learning_content SET title = ?, body = ?, category_id = ?, subject = ?, content_type = ?, published = ?, updated_at = NOW() WHERE id = ?', + var client = await db.pool.connect(); + try { + await client.query('BEGIN'); + var item = (await client.query('SELECT * FROM learning_content WHERE id=$1 FOR UPDATE', [req.params.id])).rows[0]; + if (!item) { + await client.query('ROLLBACK'); + return res.status(404).json({ error: 'Content not found' }); + } + // All omitted fields must come from the locked row, including publication state. + var newTitle = title !== undefined ? title : item.title; + var newBody = body !== undefined ? body : item.body; + var newSubject = subject !== undefined ? subject : item.subject; + var imageIds = await imageLinks.validateLearning(client, newBody, req.user.id, item.id); + await client.query( + 'UPDATE learning_content SET title = $1, body = $2, category_id = $3, subject = $4, content_type = $5, published = $6, updated_at = NOW() WHERE id = $7', [ newTitle, newBody, @@ -197,6 +209,10 @@ router.put('/content/:id', async function(req, res) { ] ); + await imageLinks.setLinks(client, item.id, imageIds); + await client.query('COMMIT'); + } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } + // Regenerate embedding if title/body/subject changed (async, don't block) if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) { if (newBody && newBody.trim()) { @@ -213,7 +229,7 @@ router.put('/content/:id', async function(req, res) { } res.json({ success: true }); - } catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); } + } catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); } }); router.delete('/content/:id', async function(req, res) { diff --git a/src/utils/ai.js b/src/utils/ai.js index fec4a3eb..a5560c57 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -151,22 +151,38 @@ function normalizeFinishReason(reason) { } } +function assertToolProvider(options) { + if ((options.tools || options.toolChoice) && !['litellm', 'openrouter', 'azure'].includes(activeProvider)) { + throw new Error('Tools require an OpenAI-compatible provider; no provider request was sent'); + } +} + +function addToolOptions(request, generation) { + if (generation && generation.tools) { + request.tools = generation.tools; + request.tool_choice = generation.toolChoice || 'auto'; + request.parallel_tool_calls = false; + } + return request; +} + // ============================================================ // CALL OPENROUTER // ============================================================ -async function callOpenRouter(messages, model, temperature, maxTokens) { +async function callOpenRouter(messages, model, temperature, maxTokens, generation) { if (!openrouter) throw new Error('OpenRouter not configured. Set OPENROUTER_API_KEY in .env'); - var completion = await openrouter.chat.completions.create({ + var completion = await openrouter.chat.completions.create(addToolOptions({ model: model, messages: messages, temperature: temperature, max_tokens: maxTokens - }); + }, generation)); return { success: true, content: completion.choices[0].message.content, + ...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}), model: model, provider: 'openrouter', usage: completion.usage || null, @@ -177,19 +193,20 @@ async function callOpenRouter(messages, model, temperature, maxTokens) { // ============================================================ // CALL AZURE OPENAI // ============================================================ -async function callAzure(messages, model, temperature, maxTokens) { +async function callAzure(messages, model, temperature, maxTokens, generation) { if (!azureClient) throw new Error('Azure OpenAI not configured'); - var completion = await azureClient.chat.completions.create({ + var completion = await azureClient.chat.completions.create(addToolOptions({ model: model, messages: messages, temperature: temperature, max_tokens: maxTokens - }); + }, generation)); return { success: true, content: completion.choices[0].message.content, + ...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}), model: model, provider: 'azure', usage: completion.usage || null, @@ -386,16 +403,17 @@ async function callVertex(messages, model, temperature, maxTokens) { async function callLiteLLM(messages, model, temperature, maxTokens, generation) { if (!litellmClient) throw new Error('LiteLLM not configured. Set LITELLM_API_BASE in .env'); - var completion = await litellmClient.chat.completions.create(addReasoningOptions({ + var completion = await litellmClient.chat.completions.create(addToolOptions(addReasoningOptions({ model: model, messages: messages, temperature: temperature, max_tokens: maxTokens - }, generation || {})); + }, generation || {}), generation)); return { success: true, content: completion.choices[0].message.content, + ...(completion.choices[0].message.tool_calls ? { toolCalls: completion.choices[0].message.tool_calls } : {}), model: model, provider: 'litellm', usage: completion.usage || null, @@ -426,7 +444,8 @@ async function callAIStream(messages, options, onToken) { options = options || {}; var requestedModel = options.model; var model = await resolveModel(requestedModel); - var generation = resolveGenerationOptions(options); + assertToolProvider(options); + var generation = Object.assign(resolveGenerationOptions(options), { tools: options.tools, toolChoice: options.toolChoice }); var temperature = generation.temperature; var maxTokens = generation.maxTokens; var startTime = Date.now(); @@ -451,17 +470,29 @@ async function callAIStream(messages, options, onToken) { if (!client) throw new Error('Streaming is only configured for OpenAI-compatible providers'); var content = ''; + var toolCalls = []; var finishReason = null; - var stream = await client.chat.completions.create(addReasoningOptions({ + var stream = await client.chat.completions.create(addToolOptions(addReasoningOptions({ model: model, messages: messages, temperature: temperature, max_tokens: maxTokens, stream: true - }, generation)); + }, generation), generation)); for await (var part of stream) { var choice = part && part.choices && part.choices[0] ? part.choices[0] : null; if (choice && choice.finish_reason) finishReason = choice.finish_reason; + for (var fragment of (choice && choice.delta && choice.delta.tool_calls) || []) { + if (fragment.type && fragment.type !== 'function') throw new Error('Unsupported tool stream'); + if (!Number.isInteger(fragment.index) || fragment.index < 0 || fragment.index > 7) throw new Error('Invalid tool stream'); + var tool = toolCalls[fragment.index] || (toolCalls[fragment.index] = { id: '', type: 'function', function: { name: '', arguments: '' } }); + if (fragment.id) tool.id += fragment.id; + if (fragment.function) { + tool.function.name += fragment.function.name || ''; + tool.function.arguments += fragment.function.arguments || ''; + } + if (tool.function.arguments.length > 40000 || tool.function.name.length > 100 || tool.id.length > 200) throw new Error('Tool stream exceeds limit'); + } var delta = choice && choice.delta ? choice.delta.content : ''; if (!delta) continue; content += delta; @@ -469,7 +500,7 @@ async function callAIStream(messages, options, onToken) { } var duration = Date.now() - startTime; logger.apiCall(null, provider + '/' + model, { model: model, duration: duration, statusCode: 200 }); - return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason }; + return { success: true, content: content, model: model, provider: provider, duration: duration, finishReason: finishReason, ...(toolCalls.length ? { toolCalls: toolCalls.filter(Boolean) } : {}) }; } // ============================================================ @@ -479,7 +510,8 @@ async function callAI(messages, options) { options = options || {}; var requestedModel = options.model; var model = await resolveModel(requestedModel); - var generation = resolveGenerationOptions(options); + assertToolProvider(options); + var generation = Object.assign(resolveGenerationOptions(options), { tools: options.tools, toolChoice: options.toolChoice }); var temperature = generation.temperature; var maxTokens = generation.maxTokens; var startTime = Date.now(); @@ -502,13 +534,13 @@ async function callAI(messages, options) { if (activeProvider === 'bedrock' && bedrockClient) { result = await callBedrock(messages, model, temperature, maxTokens); } else if (activeProvider === 'azure' && azureClient) { - result = await callAzure(messages, model, temperature, maxTokens); + result = await callAzure(messages, model, temperature, maxTokens, generation); } else if (activeProvider === 'vertex' && vertexClient) { result = await callVertex(messages, model, temperature, maxTokens); } else if (activeProvider === 'litellm' && litellmClient) { result = await callLiteLLM(messages, model, temperature, maxTokens, generation); } else if (openrouter) { - result = await callOpenRouter(messages, model, temperature, maxTokens); + result = await callOpenRouter(messages, model, temperature, maxTokens, generation); } else { throw new Error('No AI provider configured. Set LITELLM_API_BASE plus an API key, or explicitly configure a legacy direct provider.'); } @@ -553,7 +585,7 @@ async function callAI(messages, options) { logger.warn('Trying fallback model: ' + FALLBACK_MODEL); try { await assertModelAllowed(FALLBACK_MODEL, options); - var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens); + var fallbackResult = await callOpenRouter(messages, FALLBACK_MODEL, temperature, maxTokens, generation); fallbackResult.fallback = true; fallbackResult.duration = Date.now() - startTime; logger.info('Fallback success', { model: FALLBACK_MODEL }); diff --git a/src/utils/clinicalConversation.js b/src/utils/clinicalConversation.js index e3182b33..dd4bd13a 100644 --- a/src/utils/clinicalConversation.js +++ b/src/utils/clinicalConversation.js @@ -70,6 +70,7 @@ function savedSources(sources) { function savedImage(image) { if (image == null || image === '') return ''; if (typeof image !== 'string') throw failure('Invalid saved image.', 400, 'INVALID_SAVED_CHAT'); + if (/^\/api\/generated-images\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(image)) return image; const match = image.match(/^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/); if (match && image.length <= MAX_SAVED_CHAT_BYTES) { const bytes = Buffer.from(match[2], 'base64'); @@ -85,10 +86,19 @@ function savedImage(image) { throw failure('Saved images must be PNG, JPEG, WebP or HTTP(S) image URLs.', 400, 'INVALID_SAVED_CHAT'); } +function savedJobs(imageJobs) { + if (imageJobs === undefined) return []; + if (!Array.isArray(imageJobs) || imageJobs.length > 100 || imageJobs.some(job => !job || !require('./generatedImages').UUID.test(job.jobId))) { + throw failure('Invalid saved image jobs.', 400, 'INVALID_SAVED_CHAT'); + } + return imageJobs.map(job => ({ jobId: job.jobId })); +} + function savedChatPayload(body) { const messages = validateMessages(body.messages).map(function(message, index) { const original = body.messages[index]; - const copy = { ...message, sources: savedSources(original.sources) }; + const imageJobs = savedJobs(original.imageJobs); + const copy = { ...message, sources: savedSources(original.sources), ...(imageJobs.length ? { imageJobs } : {}) }; // Preserve legacy loss provenance and an existing retained raw field across // a v2 re-save/follow-up; neither is inference history or replacement text. if (original.legacyClipped === true && message.content.length === 12000 && !/[\r\n]/.test(message.content)) { @@ -107,6 +117,7 @@ function savedChatPayload(body) { messages, sources: savedSources(body.sources), lastAnswer: body.lastAnswer || '', + ...(body.generatedImageJobs !== undefined ? { generatedImageJobs: savedJobs(body.generatedImageJobs) } : {}), savedAt: new Date().toISOString() }; // Sidebar image is session-only: validated when supplied but never stored in saved chats. diff --git a/src/utils/clinicalPrompts.js b/src/utils/clinicalPrompts.js index f624e67d..98e7c318 100644 --- a/src/utils/clinicalPrompts.js +++ b/src/utils/clinicalPrompts.js @@ -11,7 +11,7 @@ function imagePromptForCanvas(prompt, behavior = DEFAULT_IMAGE_BEHAVIOR) { if (/\b(table|matrix|comparison|wide|landscape|side-by-side)\b/i.test(text)) { guidance += ' Use a wide landscape layout with compact columns, ample horizontal spacing, and no text near the edges.'; } - return text.trim() + guidance; + return text + guidance; } module.exports = { DEFAULT_BEHAVIOR, DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas }; diff --git a/src/utils/generatedImageLinks.js b/src/utils/generatedImageLinks.js new file mode 100644 index 00000000..fa6533d5 --- /dev/null +++ b/src/utils/generatedImageLinks.js @@ -0,0 +1,34 @@ +const { UUID, failure } = require('./generatedImages'); +function references(text) { + const ids = new Set(); + for (const match of String(text || '').matchAll(/\/api\/generated-images\/([a-zA-Z0-9%-]+)/g)) { + if (!UUID.test(match[1])) throw failure(400, 'Invalid generated image reference'); + ids.add(match[1]); + } + return [...ids]; +} +async function validateChat(db, payload, owner) { + const ids = new Set([...references(payload.generatedImage), ...references(payload.lastAnswer)]); + for (const job of payload.generatedImageJobs || []) ids.add(job.jobId); + for (const message of payload.messages) { + for (const job of message.imageJobs || []) ids.add(job.jobId); + for (const id of references(message.content)) ids.add(id); + } + if (!ids.size) return; + const rows = await db.query("SELECT id FROM generated_image_jobs WHERE id=ANY($1::uuid[]) AND owner_id=$2 AND workflow='clinical_assistant'", [[...ids], owner]); + if (rows.rows.length !== ids.size) throw failure(403, 'Saved image references must belong to this account and Clinical Assistant'); +} +// Called INSIDE the content write transaction: publication and grants commit together. +async function validateLearning(client, body, owner, contentId) { + const ids = references(body); + if (!ids.length) return ids; + const rows = await client.query(`SELECT j.id FROM generated_image_jobs j WHERE j.id=ANY($1::uuid[]) AND j.workflow='learning_hub' AND j.stage='done' + AND (j.owner_id=$2 OR EXISTS (SELECT 1 FROM generated_image_links l WHERE l.asset_id=j.id AND l.content_id=$3)) FOR SHARE`, [ids, owner, contentId || null]); + if (rows.rows.length !== ids.length) throw failure(403, 'Only your Learning Hub assets, or assets already attached to this content, may be attached. Clinical/private-chat images cannot be published.'); + return ids; +} +async function setLinks(client, contentId, ids) { + await client.query('DELETE FROM generated_image_links WHERE content_id=$1', [contentId]); + for (const id of ids) await client.query('INSERT INTO generated_image_links(asset_id,content_id) VALUES($1,$2)', [id, contentId]); +} +module.exports = { references, validateChat, validateLearning, setLinks }; diff --git a/src/utils/generatedImageStorage.js b/src/utils/generatedImageStorage.js new file mode 100644 index 00000000..56d12e35 --- /dev/null +++ b/src/utils/generatedImageStorage.js @@ -0,0 +1,78 @@ +// Private, bounded assets. The provider download uses a pinned lookup, not a URL precheck alone. +const fs = require('fs'); +const https = require('https'); +const dns = require('dns').promises; +const crypto = require('crypto'); +const { isPrivateIp } = require('./urlSafety'); +const MAX_BYTES = 16 * 1024 * 1024; +function inspect(bytes, declared) { + if (!Buffer.isBuffer(bytes) || !bytes.length || bytes.length > MAX_BYTES) throw new Error('Invalid image size'); + const mime = bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')) ? 'image/png' : + bytes.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex')) ? 'image/jpeg' : + bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp' : null; + if (!mime || (declared && declared.split(';')[0].trim().toLowerCase() !== mime)) throw new Error('Unsupported image content'); + return { bytes, mime, checksum: crypto.createHash('sha256').update(bytes).digest('hex') }; +} +function decodeBase64(value) { + if (typeof value !== 'string' || value.length > Math.ceil(MAX_BYTES / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new Error('Invalid image base64'); + const bytes = Buffer.from(value, 'base64'); + if (bytes.toString('base64') !== value) throw new Error('Noncanonical image base64'); + return inspect(bytes); +} +async function download(url, { lookup = dns.lookup, request = https.get, signal } = {}) { + const u = new URL(url); + if (u.protocol !== 'https:' || u.username || u.password || (u.port && u.port !== '443')) throw new Error('Unsafe image URL'); + const hostname = u.hostname.replace(/^\[|\]$/g, ''); + const addresses = await lookup(hostname, { all: true }); + if (!addresses.length || addresses.some(a => isPrivateIp(a.address))) throw new Error('Unsafe image address'); + const pinned = addresses[0]; + return new Promise((resolve, reject) => { + // No proxy, redirects or authorization. TLS still verifies the original hostname. + const req = request(u, { agent: false, signal, timeout: 30000, lookup: (_host, options, cb) => { + cb(null, options.all ? [pinned] : pinned.address, pinned.family); + }, headers: { Accept: 'image/png, image/jpeg, image/webp' } }, res => { + if (res.statusCode !== 200 || !res.headers['content-type'] || Number(res.headers['content-length']) > MAX_BYTES) { + res.destroy(); reject(new Error('Image download rejected')); return; + } + let size = 0; const chunks = []; + res.on('data', chunk => { + size += chunk.length; + if (size > MAX_BYTES) { res.destroy(new Error('Image exceeds byte limit')); return; } + chunks.push(chunk); + }); + res.on('error', reject); + res.on('end', () => { try { resolve(inspect(Buffer.concat(chunks), res.headers['content-type'])); } catch (e) { reject(e); } }); + }); + req.on('timeout', () => req.destroy(new Error('Image download timeout'))); + req.on('error', reject); + }); +} +function createStorage(env = process.env) { + const { S3Client, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); + for (const key of ['ENDPOINT', 'ACCESS_KEY_FILE', 'SECRET_KEY_FILE']) if (!env['GENERATED_IMAGES_S3_' + key]) throw new Error('Generated image storage is not configured'); + const client = new S3Client({ endpoint: env.GENERATED_IMAGES_S3_ENDPOINT, region: env.GENERATED_IMAGES_S3_REGION || 'us-east-1', forcePathStyle: true, + credentials: { accessKeyId: fs.readFileSync(env.GENERATED_IMAGES_S3_ACCESS_KEY_FILE, 'utf8').trim(), secretAccessKey: fs.readFileSync(env.GENERATED_IMAGES_S3_SECRET_KEY_FILE, 'utf8').trim() }, + maxAttempts: 2, requestHandler: { connectionTimeout: 3000, requestTimeout: 15000 } }); + const Bucket = env.GENERATED_IMAGES_S3_BUCKET || 'generated-images'; + return { + async ready() { + await client.send(new HeadBucketCommand({ Bucket })); + // Fail closed for a read-only credential too, before any image-provider call. + const Key = 'checks/' + crypto.randomUUID(); + await client.send(new PutObjectCommand({ Bucket, Key, Body: Buffer.from('storage-check'), ContentType: 'application/octet-stream' })); + try { await client.send(new HeadObjectCommand({ Bucket, Key })); } // Requires object read permission too. + finally { await client.send(new DeleteObjectCommand({ Bucket, Key })); } + }, + async put(id, image) { await client.send(new PutObjectCommand({ Bucket, Key: 'assets/' + id, Body: image.bytes, ContentType: image.mime, + ChecksumSHA256: Buffer.from(image.checksum, 'hex').toString('base64'), Metadata: { sha256: image.checksum } })); }, + async get(id) { + const response = await client.send(new GetObjectCommand({ Bucket, Key: 'assets/' + id })); + if (response.ContentLength > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } + const chunks = []; let size = 0; + for await (const chunk of response.Body) { size += chunk.length; if (size > MAX_BYTES) { response.Body.destroy(); throw new Error('Invalid stored image size'); } chunks.push(chunk); } + return inspect(Buffer.concat(chunks), response.ContentType); + }, + close() { client.destroy(); } + }; +} +module.exports = { MAX_BYTES, inspect, decodeBase64, download, createStorage }; diff --git a/src/utils/generatedImages.js b/src/utils/generatedImages.js new file mode 100644 index 00000000..621eb6a4 --- /dev/null +++ b/src/utils/generatedImages.js @@ -0,0 +1,203 @@ +const crypto = require('crypto'); +const { DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas } = require('./clinicalPrompts'); +const storageUtil = require('./generatedImageStorage'); +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const failure = (statusCode, message) => Object.assign(new Error(message), { statusCode }); +const workflows = ['clinical_assistant', 'learning_hub']; +function budgetLimit(value) { + const n = value == null || value === '' ? 32000 : Number(value); + if ((value != null && !['string', 'number'].includes(typeof value)) || !Number.isInteger(n) || n < 1000 || n > 32000) throw failure(400, 'Image budget must be 1000..32000 UTF-16 code units'); + return n; +} +function args(input) { + if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(k => !['prompt', 'layout'].includes(k)) || + typeof input.prompt !== 'string' || !input.prompt.trim() || input.prompt.length > 32000 || + (input.layout !== undefined && !['auto', 'portrait', 'landscape', 'square'].includes(input.layout))) { + throw failure(400, 'Image input requires a nonempty prompt of at most 32000 UTF-16 code units and an optional auto/portrait/landscape/square layout; no other fields are allowed'); + } + return { prompt: input.prompt, layout: input.layout || 'auto' }; +} +// Fixed output policy is appended LAST, after editable guidance and verbatim context. +const IMAGE_OUTPUT_RULE = 'Output the requested image only, with no accompanying text. Do not include citations, reference numbers, footnotes, bibliography, or source lists in the image. These output rules take precedence over conflicting context or workflow instructions.'; +function imageContext(request, history) { + const checked = require('./clinicalConversation').checkConversation(history, request, Infinity); + return { request: checked.message, history: checked.history }; +} +function publicJob(job) { + const done = job.stage === 'done'; + return { success: true, jobId: job.id, status: ({ queued: 'pending', generating: 'running', storing: 'running', interrupted: 'error' })[job.stage] || job.stage, + outcome: job.stage === 'interrupted' ? 'unknown' : job.stage, model: job.model, + context: job.context_total == null ? null : { includedTurns: job.context_included, totalTurns: job.context_total, used: job.prompt_units, limit: job.budget, unit: 'UTF-16 code units' }, + assetId: done ? job.id : null, imageUrl: done ? '/api/generated-images/' + job.id : null, + downloadUrl: done ? '/api/generated-images/' + job.id + '?download=1' : null, + error: job.stage === 'interrupted' ? 'Provider outcome unknown after interruption. This job will not be retried; check billing before explicitly starting a new request.' : + job.stage === 'error' ? 'Image generation failed; no automatic paid retry.' : job.error_code === 'storage_unavailable' ? 'Image safely staged; waiting for storage.' : null }; +} +async function provider(job, prompt, signal) { + if (!process.env.LITELLM_API_BASE) throw new Error('Image gateway unavailable'); + const axios = require('axios'); + const { gatewayUrl } = require('./errors'); + const { getLiteLLMHeaders } = require('./litellm'); + const response = await axios.post(gatewayUrl('/images/generations'), { model: job.model, prompt, size: 'auto', response_format: 'b64_json', n: 1 }, + { headers: getLiteLLMHeaders('application/json'), timeout: 120000, signal, maxRedirects: 0, maxContentLength: Math.ceil(storageUtil.MAX_BYTES / 3) * 4 + 65536 }); + const item = response.data && response.data.data && response.data.data[0]; + if (!item) throw new Error('Missing image result'); + return item.b64_json ? storageUtil.decodeBase64(item.b64_json) : await storageUtil.download(item.url, { signal }); +} +function createImageService({ db, storage, generate = provider, encryption = require('./crypto'), env = process.env }) { + let timer = null, active = null, stopping = false, controller = null; + const getStorage = () => storage || (storage = storageUtil.createStorage(env)); + async function ready() { + if (generate === provider && !env.LITELLM_API_BASE) throw failure(503, 'Image gateway is not configured; no image provider request was sent'); + if (!encryption.hasKey()) throw failure(503, 'Generated images require encryption configuration'); + try { + await db.query(`SELECT j.id,j.owner_id,j.workflow,j.idempotency_key,j.input_hash,j.prompt_cipher,j.model,j.prompt_revision, + j.budget,j.prompt_units,j.context_included,j.context_total,j.stage,j.staged_bytes,j.lease_token,j.lease_until, + j.mime,j.checksum,j.byte_length,j.error_code,j.created_at,j.updated_at,l.asset_id,l.content_id,c.id,c.published + FROM generated_image_jobs j LEFT JOIN generated_image_links l ON l.asset_id=j.id + LEFT JOIN learning_content c ON c.id=l.content_id LIMIT 0`); + await getStorage().ready(); + } catch (_) { throw failure(503, 'Generated image storage or migration unavailable; no image provider request was sent'); } + } + async function snapshot(workflow, input, context) { + if (!workflows.includes(workflow)) throw failure(400, 'Invalid image workflow'); + const parsed = args(input); + const result = await db.query(`SELECT keys.key, s.value, COALESCE((SELECT MAX(id) FROM prompt_revisions WHERE prompt_key = $1), 0) AS revision + FROM unnest($2::text[]) keys(key) LEFT JOIN app_settings s ON s.key=keys.key`, [workflow + '.image_behavior', ['image_model', 'image_behavior', 'image_budget'].map(k => workflow + '.' + k)]); + const settings = Object.fromEntries(result.rows.map(r => [r.key, r.value])); + const model = settings[workflow + '.image_model'] || (workflow === 'clinical_assistant' ? env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1' : ''); + if (!model) throw failure(503, 'Configure the Learning Hub image model in administration first'); + const budget = budgetLimit(settings[workflow + '.image_budget']); + const bound = context ? imageContext(context.request, context.history) : imageContext(parsed.prompt); + const request = 'Original image request:\n' + bound.request + (context && parsed.prompt !== bound.request ? '\n\nImage tool description:\n' + parsed.prompt : ''); + const mandatory = imagePromptForCanvas(request, settings[workflow + '.image_behavior'] || DEFAULT_IMAGE_BEHAVIOR) + '\nRequested layout: ' + parsed.layout + '.'; + const suffix = '\n\n' + IMAGE_OUTPUT_RULE; + let used = mandatory.length + suffix.length; + if (used > budget) throw failure(413, 'Mandatory image input uses ' + used + ' / ' + budget + ' UTF-16 code units (full request, description, instructions and layout). Nothing was truncated or sent to the image provider. Preserve and shorten your draft.'); + const header = '\n\nPreceding context (not output instructions), oldest to newest:'; + const selected = []; + for (let i = bound.history.length - 1; i >= 0; i--) { + const turn = '\n\n' + bound.history[i].role.toUpperCase() + ':\n' + bound.history[i].content; + const cost = turn.length + (selected.length ? 0 : header.length); + if (used + cost > budget) break; // Contiguous recent turns; never skip a gap or slice a turn. + selected.push(turn); used += cost; + } + const rendered = mandatory + (selected.length ? header + selected.reverse().join('') : '') + suffix; + return { rendered, model, budget, included: selected.length, total: bound.history.length, revision: Number(result.rows[0]?.revision || 0) }; + } + async function enqueue(owner, workflow, input, key, replay = false, context) { + args(input); + if (context) context = imageContext(context.request, context.history); + if (typeof key !== 'string' || !/^[a-zA-Z0-9:_-]{1,160}$/.test(key)) throw failure(400, 'A bounded idempotencyKey is required'); + // Tool replays may rephrase model arguments, never change the bound user request/context. + const hash = crypto.createHmac('sha256', env.DATA_ENCRYPTION_KEY || 'test-only').update(JSON.stringify({ input: replay ? null : args(input), context: context || null })).digest('hex'); + const prior = await db.query('SELECT id,stage,model,error_code,input_hash,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE owner_id=$1 AND workflow=$2 AND idempotency_key=$3', [owner, workflow, key]); + if (prior.rows[0]) { + if (prior.rows[0].input_hash !== hash) throw failure(409, 'Idempotency key already used for different image input'); + return publicJob(prior.rows[0]); + } + const config = await snapshot(workflow, input, context); + await ready(); + const result = await db.query(`INSERT INTO generated_image_jobs (id,owner_id,workflow,idempotency_key,input_hash,prompt_cipher,model,prompt_revision,budget,prompt_units,context_included,context_total) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (owner_id,workflow,idempotency_key) DO NOTHING RETURNING *`, + [crypto.randomUUID(), owner, workflow, key, hash, encryption.encryptString(config.rendered), config.model, config.revision, config.budget, config.rendered.length, config.included, config.total]); + if (!result.rows[0]) return enqueue(owner, workflow, input, key, replay, context); + return publicJob(result.rows[0]); + } + async function claim() { + const client = await db.pool.connect(); + try { + await client.query('BEGIN'); + const selected = await client.query("SELECT * FROM generated_image_jobs WHERE stage IN ('queued','storing') AND (lease_until IS NULL OR lease_until < NOW()) ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1"); + if (!selected.rows[0]) { await client.query('COMMIT'); return null; } + const row = selected.rows[0]; + const result = await client.query("UPDATE generated_image_jobs SET stage=$2,lease_token=$3,lease_until=NOW()+interval '3 minutes',updated_at=NOW() WHERE id=$1 RETURNING *", [row.id, row.stage === 'queued' ? 'generating' : 'storing', crypto.randomUUID()]); + await client.query('COMMIT'); return result.rows[0]; + } catch (e) { await client.query('ROLLBACK').catch(() => {}); throw e; } finally { client.release(); } + } + async function tick() { + // Reconcile paid ambiguity using PG alone, even during gateway/S3 outages. Never requeue payment. + await db.query("UPDATE generated_image_jobs SET stage='interrupted', error_code='provider_unknown', lease_token=NULL, lease_until=NULL, updated_at=NOW() WHERE stage='generating' AND lease_until < NOW()"); + const candidates = await db.query("SELECT id FROM generated_image_jobs WHERE stage IN ('queued','storing') LIMIT 1"); + if (!candidates.rows.length) return; + await ready(); // storage readiness before claim and before any paid operation + if (stopping) return; + const job = await claim(); + if (!job) return; + if (stopping) { + // No provider request started: safely release a claim acquired during shutdown. + await db.query('UPDATE generated_image_jobs SET stage=$3,lease_token=NULL,lease_until=NULL,updated_at=NOW() WHERE id=$1 AND lease_token=$2', + [job.id, job.lease_token, job.stage === 'generating' ? 'queued' : 'storing']); + return; + } + controller = new AbortController(); + let image; + if (job.stage === 'generating') { + try { + image = await generate(job, encryption.decryptString(job.prompt_cipher), controller.signal); + image = storageUtil.inspect(image.bytes, image.mime); + const staged = await db.query("UPDATE generated_image_jobs SET stage='storing',staged_bytes=$3,mime=$4,checksum=$5,byte_length=$6,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='generating' AND lease_until > NOW() RETURNING id", + [job.id, job.lease_token, encryption.encryptBuffer(image.bytes), image.mime, image.checksum, image.bytes.length]); + if (!staged.rows.length) return; // fenced: stale workers cannot publish + } catch (e) { + const definite = e.response && e.response.status >= 400 && e.response.status < 500 && ![408, 429].includes(e.response.status); + await db.query("UPDATE generated_image_jobs SET stage=$3,error_code=$4,lease_token=NULL,lease_until=NULL,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='generating'", + [job.id, job.lease_token, definite ? 'error' : 'interrupted', definite ? 'provider_rejected' : 'provider_unknown']); + return; + } + } else { + image = storageUtil.inspect(encryption.decryptBuffer(job.staged_bytes), job.mime); + if (image.checksum !== job.checksum) throw new Error('Staged checksum mismatch'); + } + try { + await getStorage().put(job.id, image); + await db.query("UPDATE generated_image_jobs SET stage='done',staged_bytes=NULL,lease_token=NULL,lease_until=NULL,error_code=NULL,updated_at=NOW() WHERE id=$1 AND lease_token=$2 AND stage='storing' AND lease_until > NOW()", [job.id, job.lease_token]); + } catch (_) { + await db.query("UPDATE generated_image_jobs SET error_code='storage_unavailable',lease_until=NOW()+interval '30 seconds' WHERE id=$1 AND lease_token=$2 AND stage='storing'", [job.id, job.lease_token]); + } + } + async function get(id, owner, workflow) { + if (!UUID.test(id)) throw failure(404, 'Image job not found'); + const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE id=$1 AND owner_id=$2 AND workflow=$3', [id, owner, workflow]); + if (!result.rows[0]) throw failure(404, 'Image job not found'); + return publicJob(result.rows[0]); + } + async function asset(id, user) { + if (!UUID.test(id)) throw failure(404, 'Image not found'); + const result = await db.query(`SELECT j.checksum,j.byte_length,j.mime FROM generated_image_jobs j WHERE j.id=$1 AND j.stage='done' AND + (j.owner_id=$2 OR (j.workflow='learning_hub' AND EXISTS(SELECT 1 FROM generated_image_links l JOIN learning_content c ON c.id=l.content_id + WHERE l.asset_id=j.id AND (c.published=true OR $3::boolean))))`, [id, user.id, ['admin','moderator'].includes(user.role)]); + if (!result.rows[0]) throw failure(404, 'Image not found'); + const image = await getStorage().get(id); + if (image.checksum !== result.rows[0].checksum || image.bytes.length !== result.rows[0].byte_length || image.mime !== result.rows[0].mime) throw failure(503, 'Image integrity check failed'); + return image; + } + function start() { + stopping = false; + function run() { + if (stopping) return; + active = tick().catch(() => { /* no prompt/provider/SQL details in logs */ }).finally(() => { + active = null; controller = null; + if (!stopping) { timer = setTimeout(run, 1500); timer.unref(); } + }); + } + if (!active && !timer) run(); + } + async function stop() { + stopping = true; clearTimeout(timer); timer = null; + if (controller) controller.abort(); + if (active) await active; + // The same storage client serves draining HTTP reads; process shutdown owns its lifetime. + } + return { enqueue, get, asset, ready, snapshot, claim, tick, start, stop }; +} +let singleton; +function service() { return singleton || (singleton = createImageService({ db: require('../db/database') })); } +function requestKey(body) { + if (body.idempotencyKey !== undefined) { + if (typeof body.idempotencyKey !== 'string' || !/^[a-zA-Z0-9:_-]{1,150}$/.test(body.idempotencyKey)) throw failure(400, 'Invalid idempotencyKey'); + return body.idempotencyKey; + } + return crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'); +} +module.exports = { createImageService, service, budgetLimit, args, imageContext, IMAGE_OUTPUT_RULE, publicJob, requestKey, UUID, failure }; diff --git a/src/utils/imageTool.js b/src/utils/imageTool.js new file mode 100644 index 00000000..6fe09be1 --- /dev/null +++ b/src/utils/imageTool.js @@ -0,0 +1,31 @@ +const { service, args, requestKey, failure } = require('./generatedImages'); +const tools = [{ type: 'function', function: { name: 'generate_image', + description: 'Generate one medical educational image when the user asks for an image. Supply a self-contained prompt grounded in the current content. The server selects model, credentials and workflow. Return the educational answer separately; never put image markup or invented asset URLs in it.', + parameters: { type: 'object', additionalProperties: false, properties: { + prompt: { type: 'string', minLength: 1, maxLength: 32000 }, + layout: { type: 'string', enum: ['auto', 'portrait', 'landscape', 'square'] } + }, required: ['prompt'] } } }]; +async function dispatch(ai, { owner, workflow, body, messages, options, callAI, images, imageContext }) { + if (!ai.toolCalls || !ai.toolCalls.length) return ai; + if (ai.toolCalls.length !== 1) throw failure(400, 'Only one image tool invocation is permitted per request'); + const call = ai.toolCalls[0]; + if (!call || call.type !== 'function' || call.function?.name !== 'generate_image' || typeof call.id !== 'string' || call.id.length > 200 || + typeof call.function.arguments !== 'string' || call.function.arguments.length > 40000) throw failure(400, 'Invalid image tool call'); + let input; + try { input = JSON.parse(call.function.arguments); } catch (_) { throw failure(400, 'Image tool arguments must be valid JSON'); } + args(input); + if (!imageContext) throw failure(400, 'Validated original image request and context are required for image tools'); + images = images || service(); + const job = await images.enqueue(owner, workflow, input, 'tool:' + requestKey(body), true, imageContext); + let completed = ai; + if (!String(ai.content || '').trim()) { + // One FIRST-body continuation only. Existing body/citations are never sent for rewriting. + completed = await callAI(messages.concat([ + { role: 'assistant', content: null, tool_calls: [call] }, + { role: 'tool', tool_call_id: call.id, content: JSON.stringify({ jobId: job.jobId, status: job.status, instruction: 'Image job queued. Now return the first educational body in the originally requested format. Do not claim the image is complete or insert image URLs.' }) } + ]), { ...options, tools, toolChoice: 'none', maxTokens: Math.min(options.maxTokens || 4000, 8000) }); + if (completed.toolCalls?.length || !String(completed.content || '').trim()) throw failure(502, 'Image job queued but the model did not return educational content. The job is available in image history.'); + } + return { ...completed, imageJobs: [job], imageToolHandled: true }; +} +module.exports = { tools, dispatch }; diff --git a/src/utils/promptCatalog.js b/src/utils/promptCatalog.js index 89e89b20..26416475 100644 --- a/src/utils/promptCatalog.js +++ b/src/utils/promptCatalog.js @@ -40,7 +40,10 @@ const entries = PROMPTS.getAllPrompts().map(({ key }) => ({ usedBy: ['Clinical Assistant chat', 'Clinical Assistant streaming chat'], editable: true }, { key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', purpose: 'Poster instruction appended to image input, before fixed portrait/landscape layout suffixes', - usedBy: ['Clinical Assistant image', 'Clinical Assistant image job'], editable: true } + usedBy: ['Clinical Assistant image', 'Clinical Assistant image job', 'Clinical Assistant generate_image tool'], editable: true }, + { key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image', + purpose: 'Learning Hub authoring image instructions, separately versioned from Clinical Assistant', + usedBy: ['Learning Hub generate_image tool', 'Learning Hub authoring images'], editable: true } ]); entries.forEach(entry => { Object.freeze(entry.usedBy); Object.freeze(entry); }); Object.freeze(entries); diff --git a/src/utils/urlSafety.js b/src/utils/urlSafety.js index a36dd3c0..6d59bec1 100644 --- a/src/utils/urlSafety.js +++ b/src/utils/urlSafety.js @@ -9,15 +9,23 @@ function isPrivateIp(ip) { if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true; if (p[0] === 169 && p[1] === 254) return true; if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true; - if (p[0] === 192 && p[1] === 168) return true; + if (p[0] === 192 && (p[1] === 168 || p[1] === 0 || (p[1] === 88 && p[2] === 99))) return true; + if (p[0] === 198 && (p[1] === 18 || p[1] === 19 || (p[1] === 51 && p[2] === 100))) return true; + if (p[0] === 203 && p[1] === 0 && p[2] === 113) return true; if (p[0] >= 224) return true; return false; } if (net.isIPv6(ip)) { - var low = ip.toLowerCase(); - if (low === '::1' || low === '::') return true; - if (low.startsWith('fe80:') || low.startsWith('fc') || low.startsWith('fd')) return true; - return false; + if (ip.includes('%')) return true; + var low = new URL('http://[' + ip + ']').hostname.slice(1, -1).toLowerCase(); + // Global unicast only: excludes mapped/NAT64/local/multicast addresses. + if (!/^[23][0-9a-f]{3}:/.test(low)) return true; + var parts = low.split(':'); + var second = parseInt(parts[1] || '0', 16); + // IETF special 2001::/23, documentation 2001:db8::/32 and 3fff::/20, + // plus 6to4 and retired 6bone. Ordinary public 2001:: addresses remain usable. + return (parts[0] === '2001' && (second < 0x200 || second === 0xdb8)) || + parts[0] === '2002' || parts[0] === '3ffe' || (parts[0] === '3fff' && second < 0x1000); } return true; } @@ -28,7 +36,7 @@ async function assertSafeHttpsUrl(urlStr, label) { catch (e) { throw new Error((label || 'URL') + ' is invalid'); } if (u.protocol !== 'https:') throw new Error((label || 'URL') + ' must use https://'); if (u.username || u.password) throw new Error((label || 'URL') + ' must not include credentials'); - var addrs = await dns.lookup(u.hostname, { all: true }); + var addrs = await dns.lookup(u.hostname.replace(/^\[|\]$/g, ''), { all: true }); if (!addrs.length) throw new Error((label || 'URL') + ' did not resolve'); for (var i = 0; i < addrs.length; i++) { if (isPrivateIp(addrs[i].address)) throw new Error((label || 'URL') + ' resolves to a private IP'); diff --git a/test/assistant-export-owner.test.js b/test/assistant-export-owner.test.js new file mode 100644 index 00000000..285a0159 --- /dev/null +++ b/test/assistant-export-owner.test.js @@ -0,0 +1,271 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const { JSDOM } = require('jsdom'); +const { webcrypto, createHash } = require('node:crypto'); +const read = file => fs.readFileSync('public/js/' + file, 'utf8'); +const tick = () => new Promise(resolve => setImmediate(resolve)); +const deferred = () => { let resolve, reject; const promise = new Promise((a, b) => { resolve = a; reject = b; }); return { promise, resolve, reject }; }; +const id = '12345678-1234-1234-1234-123456789abc'; +const src = '/api/generated-images/' + id; +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64'); +const image = 'data:image/png;base64,' + png.toString('base64'); + +function ui(t, { privateAsset = true, inline = true } = {}) { + const dom = new JSDOM('
Unrelated
', { url: 'https://synthetic.test', runScripts: 'outside-only' }); + const w = dom.window; + Object.defineProperty(w, 'crypto', { value: webcrypto }); + w.Blob = Blob; w.TextEncoder = TextEncoder; + const observers = []; const Observer = w.MutationObserver; + w.MutationObserver = class extends Observer { constructor(callback) { super(callback); observers.push(this); } }; + // Actual account-boundary implementation with disposable in-memory identity/storage. + w.eval(read('accountBoundary.js')); + assert.equal(w.AccountBoundary.enter({ id: '101' }, true), true); + w.getAuthHeaders = () => ({ Authorization: 'Bearer synthetic-only' }); + const calls = { writes: [], shares: [], prints: [], toasts: [], fetches: [], focus: [] }; + const timers = []; + w.setTimeout = fn => { timers.push(fn); return timers.length; }; + w.clearTimeout = timer => { timers[timer - 1] = () => {}; }; + w.matchMedia = () => ({ matches: inline }); + w.showToast = (...args) => calls.toasts.push(args); + w.print = () => calls.prints.push('browser'); + w.FileReader = class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes => { this.result = 'data:' + blob.type + ';base64,' + Buffer.from(bytes).toString('base64'); this.onload(); }); } }; + function response(url) { + if (url.includes('/jobs/')) return new Response(JSON.stringify({ success: true, status: 'done', jobId: id, imageUrl: src, context: { includedTurns: 1, totalTurns: 2, used: 40, limit: 32000, unit: 'UTF-16 code units' } })); + return new Response(png, { headers: { 'content-type': 'image/png', 'content-length': String(png.length), 'x-image-owner': '101', 'x-image-sha256': createHash('sha256').update(png).digest('hex') } }); + } + w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return response(url); }; + for (const file of ['generatedImages.js', 'assistant/citations.js', 'assistant/sharing.js', 'assistant/export.js']) { + // Allows the failing-first run against the frozen image input without this core helper. + if (file.endsWith('sharing.js') && !fs.existsSync('public/js/' + file)) continue; + vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext()); + } + const exporter = w.createAssistantExporter({ showToast: w.showToast }); + const state = { lastAnswer: 'Exact body [3].', lastSources: [{ number: 3, title: 'Synthetic source', page: 19 }], + lastGeneratedImageSrc: privateAsset ? src : image, + generatedImageJobs: privateAsset ? [{ jobId: id }] : [], + messages: [{ role: 'assistant', content: 'Exact body [3].', ...(privateAsset ? { imageJobs: [{ jobId: id }] } : {}) }] }; + async function exported() { + await exporter.exportAnswerPdf(state); + if (!privateAsset) await exporter.exportAnswerPdf(state); // Exercise the legacy cache-hit branch too. + return w.document.querySelector('#assistant-export-modal'); + } + function plugins() { + w.Capacitor = { isNativePlatform: () => true, Plugins: { + Filesystem: { writeFile: async args => { calls.writes.push(args); return { uri: 'file://' + args.path }; } }, + Share: { share: async args => { calls.shares.push(args); } } + } }; + return w.Capacitor.Plugins; + } + function replace({ freeze = true, sameId = false } = {}) { + if (freeze) w.AccountBoundary.freeze(); + const b = new JSDOM('', { url: 'https://synthetic.test', runScripts: 'outside-only' }); + b.window.eval(read('accountBoundary.js')); + assert.equal(b.window.AccountBoundary.enter({ id: sameId ? '101' : '102' }, true), true); + w.AccountBoundary = b.window.AccountBoundary; + t.after(() => b.window.close()); + } + function popup() { + const p = new JSDOM('', { url: 'https://synthetic.test', runScripts: 'outside-only' }); + let closed = false; + const target = { document: p.window.document, get closed() { return closed; }, focus: () => calls.focus.push('popup'), print: () => calls.prints.push('popup'), close() { closed = true; } }; + w.open = () => target; + t.after(() => p.window.close()); + return target; + } + t.after(() => { observers.forEach(observer => observer.disconnect()); w.close(); }); + return { w, context: dom.getInternalVMContext(), calls, timers, state, exporter, exported, plugins, replace, popup, response }; +} + +for (const privateAsset of [true, false]) { + const branch = privateAsset ? 'private assets' : 'legacy cache'; + for (const freeze of [true, false]) for (const stage of ['filesystem', 'share', 'native resolve', 'native reject']) { + test(`${branch}: deferred ${stage} -> ${freeze ? 'freeze' : 'same-ID object replacement without freeze'} -> resolve/reject has no later effects or UI writes`, async t => { + const app = ui(t, { privateAsset }); const p = app.plugins(); const pending = deferred(); + if (stage === 'filesystem') p.Filesystem.writeFile = args => { app.calls.writes.push(args); return pending.promise; }; + if (stage === 'share') p.Share.share = args => { app.calls.shares.push(args); return pending.promise; }; + if (stage.startsWith('native')) app.w.NativePrint = { printHtml(...args) { app.calls.prints.push(args); pending.promise.catch(() => {}); return pending.promise; } }; + const modal = await app.exported(); const print = modal.querySelector('#assistant-export-print'); + print.click(); await tick(); + assert.equal(stage.startsWith('native') ? app.calls.prints.length : app.calls.writes.length, 1, 'first OS operation admitted'); + assert.equal(print.disabled, true, 'await the admitted operation before updating UI'); + app.replace({ freeze, sameId: !freeze }); + assert.equal(modal.isConnected, !freeze); + const before = JSON.stringify(app.calls); const text = print.textContent; const disabled = print.disabled; + if (stage === 'native reject') pending.reject(new Error('Synthetic native capability failure')); + else pending.resolve({ uri: 'file://synthetic-owned' }); + await tick(); + assert.equal(JSON.stringify(app.calls), before, 'no follow-up native calls, browser fallback, fetches or toasts'); + assert.equal(print.textContent, text, 'no late finally text reset'); assert.equal(print.disabled, disabled, 'no late finally enable'); + }); + } + for (const replacement of ['freeze', 'same-id object']) { + test(`${branch}: original Print callback rejects ${replacement} replacement and cannot close newer preview`, async t => { + const app = ui(t, { privateAsset }); app.plugins(); + const old = await app.exported(); const print = old.querySelector('#assistant-export-print'); + app.replace({ freeze: replacement === 'freeze', sameId: true }); + const newer = await app.exported(); const before = JSON.stringify(app.calls); + print.click(); await tick(); + assert.equal(JSON.stringify(app.calls), before); assert.equal(newer.isConnected, true); + assert.equal(app.w.document.body.classList.contains('assistant-export-open'), true); + assert.ok(app.w.document.querySelector('#unrelated')); + }); + } + for (const stage of ['native sync failure', 'browser fallback', 'native AbortError', 'share AbortError']) { + test(`${branch}: ${stage} does not become a capability fallback after abort`, async t => { + const app = ui(t, { privateAsset }); const p = app.plugins(); + if (stage === 'native sync failure') app.w.NativePrint = { printHtml() { app.replace(); throw new Error('Unavailable'); } }; + if (stage === 'native AbortError') app.w.NativePrint = { printHtml() { throw new app.w.DOMException('Policy abort', 'AbortError'); } }; + if (stage === 'share AbortError') p.Share.share = async args => { app.calls.shares.push(args); throw new app.w.DOMException('Policy abort', 'AbortError'); }; + if (stage === 'browser fallback') app.w.Capacitor.Plugins = {}; + const modal = await app.exported(); modal.querySelector('#assistant-export-print').click(); + if (stage === 'browser fallback') app.replace(); + await tick(); + assert.equal(app.calls.writes.length, stage === 'share AbortError' ? 1 : 0); + assert.deepEqual(app.calls.prints, []); assert.deepEqual(app.calls.toasts, []); + }); + } + for (const mode of ['native', 'filesystem', 'browser', 'native failure fallback', 'share cancel']) { + test(`${branch}: same owner ${mode} still succeeds with exact embedded bytes and source/page`, async t => { + const app = ui(t, { privateAsset }); const p = app.plugins(); + if (mode === 'native') app.w.NativePrint = { printHtml: (...args) => app.calls.prints.push(args) }; + if (mode === 'native failure fallback') app.w.NativePrint = { printHtml: async () => { throw new Error('Unavailable'); } }; + if (mode === 'browser') app.w.Capacitor.Plugins = {}; + if (mode === 'share cancel') p.Share.share = async args => { app.calls.shares.push(args); throw new Error('User canceled'); }; + const original = JSON.stringify(app.state); const modal = await app.exported(); + modal.querySelector('#assistant-export-print').click(); await tick(); + assert.equal(app.calls.prints.length + app.calls.shares.length, 1); + const encoded = mode === 'native' ? app.calls.prints[0][1] : app.calls.writes[0]?.data; + const html = encoded ? Buffer.from(encoded, 'base64').toString() : modal.innerHTML; + assert.ok(html.includes(image)); assert.match(html, /Exact body \[3\]\./); assert.match(html, /Synthetic source, page 19/); + assert.equal(JSON.stringify(app.state), original); assert.equal(modal.querySelector('#assistant-export-print').disabled, false); + if (privateAsset) assert.equal(app.calls.fetches[0].options.headers.Authorization, 'Bearer synthetic-only'); + }); + } + test(`${branch}: delayed desktop print and original popup button keep their owner`, async t => { + const app = ui(t, { privateAsset, inline: false }); const target = app.popup(); + await app.exporter.exportAnswerPdf(app.state); + const print = target.document.querySelector('#assistant-export-print'); + const delayed = app.timers.slice(); app.replace(); + print.click(); delayed.forEach(fn => fn()); + assert.equal(target.closed, true); assert.deepEqual(app.calls.focus, []); assert.deepEqual(app.calls.prints, []); assert.deepEqual(app.calls.toasts, []); + }); + test(`${branch}: same-owner desktop print works`, async t => { + const app = ui(t, { privateAsset, inline: false }); const target = app.popup(); + await app.exporter.exportAnswerPdf(app.state); app.timers.forEach(fn => fn()); + target.document.querySelector('#assistant-export-print').click(); + assert.deepEqual(app.calls.prints, ['popup', 'popup']); assert.equal(target.closed, false); + }); +} + +for (const freeze of [true, false]) for (const stage of ['job resolve', 'job reject', 'asset resolve']) { + for (const inline of [true, false]) { + test(`private prepare ${stage} after boundary replacement is silent (${inline ? 'inline' : 'desktop'})${freeze ? '' : ' without freeze'}`, async t => { + const app = ui(t, { inline }); const target = inline ? null : app.popup(); const pending = deferred(); + let admitted; + app.w.fetch = (url, options) => { app.calls.fetches.push({ url, options }); if (!admitted && ((stage.startsWith('job') && url.includes('/jobs/')) || (stage === 'asset resolve' && url === src))) { admitted = url; return pending.promise; } return Promise.resolve(app.response(url)); }; + const job = app.exporter.exportAnswerPdf(app.state); await tick(); assert.ok(admitted); + app.replace({ freeze, sameId: true }); const before = JSON.stringify(app.calls); + if (stage.endsWith('reject')) pending.reject(new Error('Synthetic transport failure')); + else pending.resolve(app.response(admitted)); + await job; await tick(); + assert.equal(JSON.stringify(app.calls), before); assert.equal(app.w.document.querySelector('#assistant-export-modal'), null); + if (target) assert.equal(target.closed, true); + }); + } +} + +test('late filesystem completion has a unique owned path and cannot close or update a newer export', async t => { + const app = ui(t, { privateAsset: false }); const p = app.plugins(); const pending = deferred(); + app.w.Date = class extends app.w.Date { constructor() { super('2026-01-02T03:04:05Z'); } }; + p.Filesystem.writeFile = args => { app.calls.writes.push(args); return app.calls.writes.length === 1 ? pending.promise : Promise.resolve({ uri: 'file://' + args.path }); }; + const old = await app.exported(); old.querySelector('#assistant-export-print').click(); await tick(); app.replace(); + const newer = await app.exported(); newer.querySelector('#assistant-export-print').click(); await tick(); + assert.equal(app.calls.writes.length, 2); assert.notEqual(app.calls.writes[0].path, app.calls.writes[1].path); + const before = JSON.stringify(app.calls); pending.resolve({ uri: 'file://' + app.calls.writes[0].path }); await tick(); + assert.equal(JSON.stringify(app.calls), before); assert.equal(newer.isConnected, true); +}); + +test('export invocation fails closed without a verified boundary, before even an empty-answer toast', async t => { + const app = ui(t); + for (const boundary of [undefined, {}, { capture: () => '101' }]) { + app.w.AccountBoundary = boundary; + await app.exporter.exportAnswerPdf({}); + assert.equal(app.w.document.querySelector('#assistant-export-modal'), null); assert.deepEqual(app.calls.toasts, []); + } +}); + +for (const freeze of [true, false]) for (const stage of ['fetch', 'body', 'reader', 'filesystem', 'share']) { + test(`private image download ${stage} retains original object/ticket/signal (${freeze ? 'freeze' : 'same-ID replacement without freeze'})`, { timeout: 5000 }, async t => { + const app = ui(t); const pending = deferred(); const admitted = deferred(); const p = app.plugins(); + app.calls.native = []; app.calls.anchors = []; + app.w.HTMLAnchorElement.prototype.click = function() { app.calls.anchors.push(this.download); }; + app.w.NativeFiles = { saveImage(...args) { app.calls.native.push(args); return 'saved:synthetic'; } }; + const originalSignal = app.w.AccountBoundary.signal(); + if (stage === 'fetch' || stage === 'body') app.w.fetch = (url, options) => { + app.calls.fetches.push({ url, options }); + if (stage === 'fetch') { admitted.resolve(); return pending.promise; } + const response = app.response(url); + return Promise.resolve({ ok: true, headers: response.headers, body: { getReader: () => ({ read: () => { admitted.resolve(); return pending.promise; } }) } }); + }; + if (stage === 'reader') app.w.FileReader = class { readAsDataURL() { admitted.resolve(); pending.promise.then(() => { this.result = image; this.onload(); }); } }; + if (stage === 'filesystem' || stage === 'share') delete app.w.NativeFiles; + if (stage === 'filesystem') p.Filesystem.writeFile = args => { app.calls.writes.push(args); admitted.resolve(); return pending.promise; }; + if (stage === 'share') p.Share.share = args => { app.calls.shares.push(args); admitted.resolve(); return pending.promise; }; + vm.runInContext(read('assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), app.context); + const store = app.w.createAssistantImageStore(); store.renderGeneratedImage(src); + const job = store.downloadImage('img-1'); await admitted.promise; + assert.equal(app.calls.fetches.length, 1); assert.equal(app.calls.fetches[0].options.signal, originalSignal); + assert.equal(app.calls.fetches[0].options.redirect, 'error'); + if (stage === 'filesystem' || stage === 'share') assert.equal(app.calls.writes.length, 1, 'paid bytes admitted only under the original owner'); + if (stage === 'share') assert.equal(app.calls.shares.length, 1); + app.replace({ freeze, sameId: true }); const before = JSON.stringify(app.calls); + pending.resolve(stage === 'fetch' ? app.response(src) : stage === 'body' ? { done: false, value: new Uint8Array(png) } : { uri: 'file://original-only' }); + await job; await tick(); + assert.equal(JSON.stringify(app.calls), before, 'no native, Filesystem, Share, anchor or toast after replacement'); + }); +} + +test('late private preview load and detached close cannot hydrate or close a newer same-ID preview', async t => { + const app = ui(t); const pending = deferred(); + app.w.fetch = () => pending.promise; + vm.runInContext(read('assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), app.context); + const store = app.w.createAssistantImageStore(); store.renderGeneratedImage(src); + store.openImagePreview('img-1'); const old = app.w.document.querySelector('.assistant-image-modal'); + assert.ok(old); assert.equal(old.querySelector('img').getAttribute('src'), null); + const close = old.querySelector('.assistant-image-modal-close'); + app.replace({ freeze: false, sameId: true }); + store.renderGeneratedImage(image); store.openImagePreview('img-2'); + const newer = app.w.document.querySelector('.assistant-image-modal'); + pending.resolve(app.response(src)); await tick(); await tick(); close.click(); + assert.equal(newer.isConnected, true); assert.equal(newer.querySelector('img').getAttribute('src'), image); + assert.equal(app.w.document.body.classList.contains('assistant-image-preview-open'), true); + assert.equal(old.isConnected, false); assert.deepEqual(app.calls.toasts, []); +}); + +test('a superseded boundary abort cleans up only its export, never a newer same-ID export', async t => { + const app = ui(t); const originalBoundary = app.w.AccountBoundary; + const old = await app.exported(); + app.replace({ freeze: false, sameId: true }); + const newer = await app.exported(); + originalBoundary.freeze(); + assert.equal(old.isConnected, false); + assert.equal(newer.isConnected, true); + assert.equal(app.w.document.body.classList.contains('assistant-export-open'), true); +}); + +test('private transient cleanup revokes only the obsolete owner URLs and preserves newer and unrelated blobs', async t => { + const app = ui(t); const revoked = []; let seq = 0; + app.w.URL.createObjectURL = () => 'blob:synthetic-' + ++seq; + app.w.URL.revokeObjectURL = url => revoked.push(url); + const originalBoundary = app.w.AccountBoundary; + const oldUrl = app.w.transientImageUrl(new Blob([png])); + app.replace({ freeze: false, sameId: true }); + const newUrl = app.w.transientImageUrl(new Blob([png])); + app.w.document.body.insertAdjacentHTML('beforeend', ''); + originalBoundary.freeze(); + assert.deepEqual(revoked, [oldUrl]); + assert.ok(app.w.document.querySelector('img[src="' + newUrl + '"]')); + assert.ok(app.w.document.querySelector('img[src="blob:unrelated"]')); +}); diff --git a/test/assistant-saved-tables.test.js b/test/assistant-saved-tables.test.js index 0b5d8b0a..9c1f293e 100644 --- a/test/assistant-saved-tables.test.js +++ b/test/assistant-saved-tables.test.js @@ -31,7 +31,7 @@ function ui(t, parser = marked) { saveAssistantChat: async body => { saves.push(JSON.parse(JSON.stringify(savedChatPayload(body)))); return { success: true }; } }; vm.createContext(context); - for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'assistant/export.js', 'clinicalAssistant.js']) { + for (const file of ['assistant/citations.js', 'assistant/sources.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/export.js', 'clinicalAssistant.js']) { vm.runInContext(read('public/js/' + file).replace(/^import[\s\S]*?from ['"][^'"]+['"];\s*/gm, '').replace(/^export /gm, ''), context); } t.after(() => window.close()); @@ -356,3 +356,74 @@ test('comparison angles and compact URLs preserve every source-column link, expo } } }); + +test('durable jobs preserve legacy provenance, provisional/clicked turn sources and private export section navigation', async t => { + const app = ui(t); const c = app.context; + const { webcrypto, createHash } = require('node:crypto'); + const id = '12345678-1234-1234-1234-123456789abc'; + const asset = '/api/generated-images/' + id; + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64'); + const dataUrl = 'data:image/png;base64,' + png.toString('base64'); + const context = { includedTurns: 2, totalTurns: 8, used: 31990, limit: 32000 }; + const tick = async () => { for (let i = 0; i < 8; i++) await new Promise(r => setImmediate(r)); }; + c.crypto = webcrypto; + c.FileReader = class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes => { this.result = 'data:' + blob.type + ';base64,' + Buffer.from(bytes).toString('base64'); this.onload(); }); } }; + app.window.getAuthHeaders = () => ({ Authorization: 'Bearer synthetic-only' }); + c.fetch = async url => url.includes('/jobs/') ? new Response(JSON.stringify({ success: true, status: 'done', imageUrl: asset, jobId: id, context })) : + new Response(png, { headers: { 'content-type': 'image/png', 'content-length': String(png.length), 'x-image-owner': 'synthetic-rendering-owner', 'x-image-sha256': createHash('sha256').update(png).digest('hex') } }); + vm.runInContext(read('public/js/assistant/images.js').replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), c); + c.imageStore = c.createAssistantImageStore(); + const retained = collapse(table + '\n' + Array.from({ length: 450 }, (_, n) => '| Row ' + n + ' | 2 | retained | [1] |').join('\n')); + const raw = retained.slice(0, 12000); + c.restoreSavedChat({ version: 1, messages: [{ role: 'assistant', content: raw, sources, imageJobs: [{ jobId: id }] }], lastAnswer: retained, sources }); + const firstTable = bubble(app).querySelector('table'); const firstHtml = firstTable.outerHTML; + await tick(); + assert.equal(bubble(app).querySelector('table'), firstTable); assert.equal(firstTable.outerHTML, firstHtml); + assert.match(bubble(app).textContent, /2\/8 preceding turns included; 31990\/32000 UTF-16 code units/); + assert.equal(c.messages[0].retainedAnswer, retained); assert.equal(c.messages[0].content, raw); + const secondSources = [{ number: 1, title: 'New turn source', page: 41 }, { number: 2, title: 'New second source', page: 59 }]; + const second = table + '\n\nCompare a < b > c, https://example.test/a?x=1&y=2 [1].'; + let finish, sent; + c.openAssistantStream = async payload => { + sent = payload; + return new Response(new ReadableStream({ start(controller) { + const send = (event, data) => controller.enqueue(new TextEncoder().encode('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n')); + send('sources', { sources: secondSources }); send('token', { token: second }); + finish = () => { send('done', { answer: second, sources: secondSources, imageJobs: [{ jobId: id }] }); controller.close(); }; + } })); + }; + c.bindEvents(); app.document.getElementById('assistant-input').value = 'Create a diagram'; + const request = c.onAsk(); await tick(); + assert.equal(sent.message, 'Create a diagram', 'image requests reach the real tool pathway, not a sidebar heuristic'); + assert.deepEqual(JSON.parse(JSON.stringify(sent.history)), [{ role: 'assistant', content: raw }], 'retained display text and image metadata never enter inference'); + const provisional = app.document.querySelector('.assistant-loading-msg') || app.document.querySelectorAll('.assistant-msg.assistant')[1]; + const current = provisional.querySelector('.assistant-bubble'); + current.querySelector('.assistant-cite').click(); + assert.match(app.document.querySelector('#assistant-source-2').textContent, /New second source.*59/is); + finish(); await request; await tick(); + assert.equal(c.messages.at(-1).content, second); + const lastTable = current.querySelector('table'); const lastHtml = lastTable.outerHTML; + await tick(); assert.equal(current.querySelector('table'), lastTable); assert.equal(lastTable.outerHTML, lastHtml); + bubble(app).querySelector('.assistant-cite').click(); + assert.match(app.document.querySelector('#assistant-source-2').textContent, /Synthetic B.*19/is); + await c.saveCurrentChat(); const saved = app.saves.at(-1); + assert.equal(saved.messages[0].content, raw); assert.equal(saved.messages[0].retainedAnswer, retained); assert.equal(saved.messages[0].legacyClipped, true); + assert.deepEqual(saved.messages[0].sources, sources); assert.deepEqual(saved.sources, secondSources); + assert.deepEqual(saved.messages[0].imageJobs, [{ jobId: id }]); assert.equal(saved.messages.at(-1).content, second); + c.restoreSavedChat(saved); await tick(); + const before = JSON.stringify(c.messages); + await c.exporter.exportAnswerPdf({ messages: c.messages, lastAnswer: c.lastAnswer, lastSources: c.lastSources }); + const modal = app.document.querySelector('#assistant-export-modal'); + assert.equal(modal.querySelectorAll('img').length, 2); + for (const img of modal.querySelectorAll('img')) assert.equal(img.getAttribute('src'), dataUrl); + assert.equal(modal.querySelectorAll('tbody tr').length, 454); + assert.match(modal.textContent, /retained.*lastAnswer/i); assert.match(modal.textContent, /New second source, page 59/); + assert.equal(modal.querySelector('[align=right]').getAttribute('align'), 'right'); + assert.equal(app.window.getComputedStyle(modal.querySelector('[align=right]')).textAlign, 'right'); + const link = modal.querySelector('.assistant-cite[href="#ref-2-2"]'); const reference = modal.querySelector('#ref-2-2'); + let scrolled = 0; reference.scrollIntoView = () => { scrolled++; }; + link.dispatchEvent(new app.window.MouseEvent('click', { bubbles: true, cancelable: true })); + assert.equal(scrolled, 1); assert.equal(app.document.activeElement, reference); assert.equal(modal.isConnected, true); + app.window.dispatchEvent(new app.window.PopStateEvent('popstate')); assert.equal(modal.isConnected, false); + assert.equal(JSON.stringify(c.messages), before, 'private preparation and display provenance leave canonical messages unchanged'); +}); diff --git a/test/assistant-sharing-boundary.test.js b/test/assistant-sharing-boundary.test.js index f0a87a48..52715720 100644 --- a/test/assistant-sharing-boundary.test.js +++ b/test/assistant-sharing-boundary.test.js @@ -28,7 +28,7 @@ function ui(t, { native = false, inline = true } = {}) { w.fetch = async (url, options) => { calls.fetches.push({ url, options }); return { ok: true, blob: async () => new w.Blob(['synthetic'], { type: 'image/png' }) }; }; w.setTimeout = fn => { timers.push(fn); return timers.length; }; w.clearTimeout = id => { timers[id - 1] = () => {}; }; - for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'assistant/images.js', 'assistant/export.js']) { + for (const file of ['assistant/citations.js', 'assistant/sharing.js', 'generatedImages.js', 'assistant/images.js', 'assistant/export.js']) { if (file.endsWith('sharing.js') && !fs.existsSync(path.join(__dirname, '..', 'public/js', file))) continue; vm.runInContext(read(file).replace(/^import[^;]+;\s*/gm, '').replace(/^export /gm, ''), dom.getInternalVMContext()); } diff --git a/test/clinical-conversation.test.js b/test/clinical-conversation.test.js index d77a50b9..c8edc86c 100644 --- a/test/clinical-conversation.test.js +++ b/test/clinical-conversation.test.js @@ -40,6 +40,12 @@ function server(options = {}) { '../utils/errors': { gatewayUrl: path => 'http://synthetic.invalid' + path }, '../utils/litellm': { getLiteLLMHeaders: () => ({}) }, '../utils/logger': { audit() {}, error() {} }, '../utils/crypto': { encryptString: value => 'encrypted:' + value, decryptString: value => value.replace(/^encrypted:/, '') }, '../utils/redis': { async getJson() { return null; }, async setJson() {} }, '../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, + '../utils/generatedImages': options.images || { ...require('../src/utils/generatedImages'), service: () => ({ async get() { return { jobId: 'synthetic', status: 'done', success: true }; }, async enqueue(owner, workflow, input) { + calls.images.push({ prompt: require('../src/utils/clinicalPrompts').imagePromptForCanvas(input.prompt, options.imageBehavior) + '\nRequested layout: auto.' }); + return { success: true, jobId: 'synthetic', status: 'pending' }; + } }) }, + '../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'), + '../utils/imageTool': require('../src/utils/imageTool'), '../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalMcpClient': { async semanticSearch(query) { calls.search.push(query); return {}; }, async getMcpHealth() { calls.health.push('health'); return {}; } @@ -170,7 +176,7 @@ function browserUI(options = {}) { const escapeHtml = text => String(text).replace(/&/g, '&').replace(//g, '>'); const context = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, - console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob, + console: quiet, AbortController, TextDecoder, TextEncoder, URL, Blob, crypto: require("node:crypto").webcrypto, setTimeout() {}, showToast() {}, escapeHtml, escapeAttr: escapeHtml, renderAssistantMarkdown: text => escapeHtml(text), renderSourcesList: () => '', ...options.renderers, EMPTY_PROMPT_SETS: [[]], createAssistantExporter: () => ({ invalidate() {}, exportAnswerPdf() {} }), @@ -397,7 +403,7 @@ test('ENV/default metadata and exact UTF16 boundary ignore legacy DB budget and } }); -test('both actual image routes use the editable poster instruction before unchanged layout suffixes', async () => { +test('both actual image routes delegate to the shared pipeline with full input (poster snapshot covered in PG integration)', async () => { const prompts = require('../src/utils/clinicalPrompts'); for (const imageBehavior of [undefined, ' Synthetic override.', 'Override without leading space.']) { const app = server({ imageBehavior }); @@ -406,11 +412,43 @@ test('both actual image routes use the editable poster instruction before unchan assert.equal(response.statusCode, 200); for (let i = 0; i < 8; i++) await new Promise(resolve => setImmediate(resolve)); const payload = app.calls.images.at(-1); - assert.equal(payload.prompt, prompts.imagePromptForCanvas('flowchart comparison', imageBehavior)); + assert.equal(payload.prompt, prompts.imagePromptForCanvas(' flowchart comparison ', imageBehavior) + '\nRequested layout: auto.'); assert.match(payload.prompt, /tall portrait layout.*wide landscape layout/); - assert.ok(payload.prompt.startsWith('flowchart comparison ')); + assert.ok(payload.prompt.startsWith(' flowchart comparison ')); if (imageBehavior) assert.doesNotMatch(payload.prompt, /single complete medical teaching poster/); } assert.equal(app.calls.images.length, 2); } }); + +test('both actual image routes use the editable poster instruction before unchanged layout suffixes', async () => { + const images = require('../src/utils/generatedImages'); + const prompts = require('../src/utils/clinicalPrompts'); + for (const behavior of [undefined, ' Synthetic override.', 'Override without leading space.']) { + const snapshots = []; + const real = images.createImageService({ + db: { query: async () => ({ rows: [{ key: 'clinical_assistant.image_behavior', value: behavior, revision: 7 }] }) }, + encryption: {}, generate: async () => { throw new Error('No provider allowed in this check'); } + }); + const app = server({ images: { ...images, service: () => ({ + async enqueue(owner, workflow, input) { + assert.equal(workflow, 'clinical_assistant'); + snapshots.push(await real.snapshot(workflow, input)); + return { success: true, jobId: 'synthetic', status: 'pending' }; + }, + async get() { return { success: true, status: 'done', jobId: 'synthetic' }; } + }) } }); + for (const path of ['/clinical-assistant/image', '/clinical-assistant/image/jobs']) { + const response = await app.request('post', path, { prompt: ' flowchart comparison ' }); + assert.equal(response.statusCode, 200); + const snapshot = snapshots.at(-1); + const prefix = prompts.imagePromptForCanvas('Original image request:\n flowchart comparison ', behavior) + '\nRequested layout: auto.'; + assert.ok(snapshot.rendered.startsWith(prefix)); + assert.match(snapshot.rendered, /tall portrait layout.*wide landscape layout/); + assert.ok(snapshot.rendered.endsWith(images.IMAGE_OUTPUT_RULE)); + assert.equal(snapshot.revision, 7); + if (behavior) assert.doesNotMatch(snapshot.rendered, /single complete medical teaching poster/); + } + assert.equal(snapshots.length, 2); + } +}); diff --git a/test/frontend-prompt-env.test.js b/test/frontend-prompt-env.test.js index c3312711..1c9df4e9 100644 --- a/test/frontend-prompt-env.test.js +++ b/test/frontend-prompt-env.test.js @@ -12,7 +12,8 @@ const unsafe = '