pediatric-ai-scribe-v3/public/js/generatedImages.js
Daniel 1270899dcb
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: PubMed search for My Resources, and an image tool that actually fires
PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).

Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
Calling callAI with the tool directly produced a correct pubmed_search call, so
the plumbing was never the problem. The search only ever needed the topic, and
the route knows the topic before it calls the model, so both searches now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.

Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).

Three fixes to illustration, which had never once fired:

- The dispatch call had been lost in a refactor. The tool was still offered, the
  model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
  { request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
  markdown — the same suppression that killed the searches. It now says an
  illustration is available and that calling it is not a violation of that rule.

my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.

Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 16:47:08 +02:00

192 lines
10 KiB
JavaScript

import { captureSharingOwner, assertSharingOwner, validSharingOwner } from './assistant/sharing.js';
// Authenticated assets: stable URLs are persisted; transient display URLs are realm-owned only.
// Only the exact shapes the server serves: the asset, its download, or one of
// the two allow-listed preview widths. Anything else is not a private asset URL.
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|\?w=(?:256|640))?$/;
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;
}
// ── Session image cache ─────────────────────────────────────────────────────
// Assets are served `private, no-store`, which is right for a clinical app on a
// shared workstation — but it means every gallery render re-downloaded every
// image. This keeps decoded blobs in MEMORY ONLY for the session, keyed by asset
// and by the owning ticket, so nothing is written to disk and a different
// account can never read another's entry. Cleared on any account boundary
// change, alongside the object URLs.
const blobCache = new Map(); // key -> Blob (full size)
const thumbCache = new Map(); // key -> Blob (downscaled)
const inflight = new Map(); // key -> Promise, so N tiles fetch once
function cacheKey(src, ticket) { return String(ticket && ticket.ticket) + '\u0000' + String(src); }
export function clearImageCache() { blobCache.clear(); thumbCache.clear(); inflight.clear(); }
export async function cachedImageBlob(src, ticket = captureImageOwner()) {
assertImageOwner(ticket);
const key = cacheKey(src, ticket);
const hit = blobCache.get(key);
if (hit) return hit;
// Several tiles asking for the same asset share one request instead of racing.
if (inflight.has(key)) return inflight.get(key);
const pending = privateImageBlob(src, ticket)
.then(blob => { assertImageOwner(ticket); blobCache.set(key, blob); return blob; })
.finally(() => inflight.delete(key));
inflight.set(key, pending);
return pending;
}
// A gallery tile is 56px but was decoding a full 300kB image. Downscaling once
// and reusing the result keeps memory and decode cost proportional to what is
// actually shown. Reusable anywhere a small preview is wanted.
export async function cachedThumbnail(src, edge = 256, ticket = captureImageOwner()) {
assertImageOwner(ticket);
const key = cacheKey(src, ticket) + '\u0000' + edge;
const hit = thumbCache.get(key);
if (hit) return hit;
const blob = await cachedImageBlob(src, ticket);
assertImageOwner(ticket);
if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function') return blob;
try {
const bitmap = await createImageBitmap(blob);
const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height));
if (scale === 1) { bitmap.close(); thumbCache.set(key, blob); return blob; }
const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale));
const context = canvas.getContext('2d');
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
const small = await canvas.convertToBlob({ type: 'image/webp', quality: 0.82 });
assertImageOwner(ticket);
thumbCache.set(key, small);
return small;
} catch (_) {
return blob; // any decode failure just uses the original
}
}
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');
// A tile marked data-image-thumb gets the downscaled copy; full views get the
// original. Both come from the session cache, so a re-render costs nothing.
// The server now stores real previews, so a tile fetches a few kB rather than
// downloading the original and shrinking it here. cachedThumbnail stays as the
// fallback for assets whose preview cannot be produced.
const edge = Number(img.getAttribute('data-image-thumb')) || 0;
const blob = edge
? await cachedImageBlob(src + (src.indexOf('?') === -1 ? '?' : '&') + 'w=' + edge, ticket)
.catch(() => cachedThumbnail(src, edge, ticket))
: await cachedImageBlob(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. ' : '') : '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'); card.className = 'assistant-image-card'; const status = document.createElement('p');
status.setAttribute('role', 'status');
card.append(status); container.append(card);
const base = { learning_hub: '/api/admin/learning/image/jobs/', my_resources: '/api/my-resources/image/jobs/' }[workflow] || '/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);
if (data.status === 'done') { onDone(card, data); return; }
if (data.status === 'error') return;
} catch (error) {
if (!validSharingOwner(ticket) || error.name === 'AbortError') return;
// Transient status failures keep polling; the job is durable server-side.
status.textContent = 'Image: checking status…';
setTimeout(poll, 2500);
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', () => {
clearImageCache(); // cached bytes must not outlive the account that fetched them
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'); });
});