feat: integrate durable image jobs/private assets into current core
This commit is contained in:
parent
3a8dbbcce3
commit
6be2d1375a
43 changed files with 2471 additions and 347 deletions
|
|
@ -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.
|
||||
|
|
|
|||
55
migrations/1777800000000_generated-images.js
Normal file
55
migrations/1777800000000_generated-images.js
Normal file
|
|
@ -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();
|
||||
`);
|
||||
20
migrations/1777900000000_image-context.js
Normal file
20
migrations/1777900000000_image-context.js
Normal file
|
|
@ -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;
|
||||
`);
|
||||
|
|
@ -355,6 +355,12 @@
|
|||
<h4 id="assistant-image-prompts-heading">Clinical Assistant IMAGE — poster instructions</h4>
|
||||
<div id="cms-clinical-image-prompts">Loading clinical image prompt...</div>
|
||||
</section>
|
||||
<section aria-labelledby="learning-image-prompts-heading">
|
||||
<h4 id="learning-image-prompts-heading">Learning Hub IMAGE — authoring instructions</h4>
|
||||
<p>Separate model-callable authoring image behavior; includes generation and requested refinement images. History and restore affect Learning Hub only.</p>
|
||||
<div id="cms-learning-image-prompts">Loading Learning image prompt...</div>
|
||||
</section>
|
||||
<section id="workflow-image-settings" aria-label="Workflow image budgets"></section>
|
||||
<div>
|
||||
<button id="btn-save-assistant-config" class="btn-sm btn-primary" disabled><i class="fas fa-floppy-disk"></i> Save Model & Retrieval Settings</button>
|
||||
<button id="btn-retry-assistant-config" class="btn-sm btn-ghost" type="button" hidden>Retry loading settings</button>
|
||||
|
|
|
|||
|
|
@ -294,6 +294,9 @@ Subtitle here
|
|||
- Key point two"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Shared image controls stay visible for both body and presentation editors. -->
|
||||
<div id="lh-images-section"></div>
|
||||
|
||||
<!-- Quiz builder -->
|
||||
<div class="cms-quiz-section">
|
||||
<div class="cms-quiz-header">
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
36
public/js/admin/imageSettings.js
Normal file
36
public/js/admin/imageSettings.js
Normal file
|
|
@ -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(); });
|
||||
|
|
@ -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(); });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
'<div class="question"><strong>Question:</strong> ' + escapeHtml(item.question || '') + '</div>' +
|
||||
(summary ? '<h3>Summary</h3><div class="answer">' + renderMarkdown(summary, sources, renderOptions) + '</div>' : '') +
|
||||
'<h3>Full Generated Answer</h3><div class="answer full-answer">' + answerHtml + '</div>' +
|
||||
(item.images || []).map(function(src) { return '<div class="export-image"><img alt="Generated teaching visual" src="' + escapeAttr(src) + '"></div>'; }).join('') +
|
||||
(refs ? '<h3>References</h3><ol class="refs">' + refs + '</ol>' : '') +
|
||||
'</section>';
|
||||
}).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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 '<div class="assistant-generated-image"><img src="' + escapeAttr(src) + '" alt="' + escapeAttr(alt || 'Generated image') + '">' +
|
||||
'<div class="assistant-image-actions">' +
|
||||
'<button type="button" class="btn-sm btn-ghost" data-assistant-open-image="' + escapeAttr(id) + '"><i class="fas fa-expand"></i> Preview</button>' +
|
||||
|
|
@ -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 = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">×</button><img src="' + escapeAttr(src) + '" alt="Generated clinical visual"><button type="button" class="assistant-image-modal-cancel">Close preview</button></div>';
|
||||
modal.innerHTML = '<div class="assistant-image-modal-card"><button type="button" class="assistant-image-modal-close" aria-label="Close">×</button><img src="' + escapeAttr(assetPath(src) ? '' : src) + '" alt="Generated clinical visual"><button type="button" class="assistant-image-modal-cancel">Close preview</button></div>';
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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 = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image...</p>';
|
||||
startAssistantImageJob(prompt)
|
||||
.then(function (data) {
|
||||
if (!data.success || !data.jobId) throw new Error(data.error || 'Image generation failed');
|
||||
return waitForImageJob(data.jobId, function (status) {
|
||||
if (out && status === 'running') out.innerHTML = '<p class="assistant-muted"><i class="fas fa-spinner fa-spin"></i> Generating image... You can leave the app open or return in a moment.</p>';
|
||||
});
|
||||
})
|
||||
.then(function (data) {
|
||||
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 = '<p class="assistant-muted">' + escapeHtml(err.message) + '</p>';
|
||||
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;
|
||||
|
|
|
|||
123
public/js/generatedImages.js
Normal file
123
public/js/generatedImages.js
Normal file
|
|
@ -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'); });
|
||||
});
|
||||
|
|
@ -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 ───────────────
|
||||
|
|
|
|||
76
public/js/learningHub/images.js
Normal file
76
public/js/learningHub/images.js
Normal file
|
|
@ -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 = '<h3>Learning Hub images</h3><p>Uses the separately administered Learning image model and instructions. Maximum assembled input: 32,000 UTF-16 code units (or the lower admin budget). The full image request is retained; whole recent authoring-context turns are selected to fit, with counts shown below. Existing content is unchanged. Images remain private until attached to saved content.</p><label>Image prompt<textarea rows="4"></textarea></label><button type="button" data-generate>Generate image</button><button type="button" data-history>Reopen image history</button><button type="button" data-export>Export content with images (HTML)</button><p role="status"></p><div data-jobs></div>';
|
||||
parent.append(panel); list = panel.querySelector('[data-jobs]');
|
||||
const status = panel.querySelector('[role=status]');
|
||||
panel.querySelector('[data-generate]').onclick = async function() {
|
||||
if (this.disabled) return;
|
||||
const prompt = panel.querySelector('textarea').value;
|
||||
const content = getMarkdown()?.value ?? getEditor()?.getHTML() ?? '';
|
||||
if (!draft || draft.prompt !== prompt || draft.content !== content) draft = { prompt, content, idempotencyKey: crypto.randomUUID() };
|
||||
this.disabled = true;
|
||||
try { show([await imageJson('/api/admin/learning/image/jobs', { method: 'POST', body: JSON.stringify(draft) })]); status.textContent = 'Image queued; your draft is preserved.'; }
|
||||
catch (e) { status.textContent = e.message; } finally { this.disabled = false; }
|
||||
};
|
||||
panel.querySelector('[data-history]').onclick = async () => {
|
||||
try { list.replaceChildren(); show((await imageJson('/api/image-jobs/learning_hub')).jobs); } catch (e) { status.textContent = e.message; }
|
||||
};
|
||||
panel.querySelector('[data-export]').onclick = async () => {
|
||||
let ticket;
|
||||
try {
|
||||
ticket = captureImageOwner(); const template = document.createElement('template');
|
||||
const markdown = getMarkdown();
|
||||
if (markdown) throw new Error('For presentations use the existing PowerPoint export, which includes attached generated images.');
|
||||
template.innerHTML = sanitizeHtml(getEditor()?.getHTML() || '');
|
||||
for (const img of template.content.querySelectorAll('img')) img.src = await imageDataUrl(img.getAttribute('src'), ticket);
|
||||
assertImageOwner(ticket);
|
||||
const url = transientImageUrl(new Blob(['<!doctype html><meta charset="utf-8"><title>Learning content</title>' + template.innerHTML], { type: 'text/html' }), ticket);
|
||||
const a = document.createElement('a'); a.href = url; a.download = 'learning-content.html'; a.click(); setTimeout(() => revokeImageUrl(url), 60000);
|
||||
} catch (e) { if (validSharingOwner(ticket) && e.name !== 'AbortError') status.textContent = e.message; }
|
||||
};
|
||||
}
|
||||
function show(jobs) {
|
||||
mount(); if (!list) return;
|
||||
renderImageJobs(list, jobs, 'learning_hub', (card, data) => {
|
||||
const img = document.createElement('img'); img.alt = 'Generated teaching visual'; img.style.maxWidth = '100%'; card.append(img);
|
||||
hydrateImage(img, data.imageUrl).catch(() => { img.alt = 'Private image unavailable'; });
|
||||
const insert = document.createElement('button'); insert.type = 'button'; insert.textContent = 'Insert image at end of content';
|
||||
insert.onclick = () => {
|
||||
captureImageOwner();
|
||||
const markdown = getMarkdown();
|
||||
if (markdown) markdown.value += '\n---\n# Generated teaching visual\n\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 };
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
|
|
|
|||
35
scripts/test-generated-images.sh
Normal file
35
scripts/test-generated-images.sh
Normal file
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
67
src/routes/generatedImages.js
Normal file
67
src/routes/generatedImages.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
34
src/utils/generatedImageLinks.js
Normal file
34
src/utils/generatedImageLinks.js
Normal file
|
|
@ -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 };
|
||||
78
src/utils/generatedImageStorage.js
Normal file
78
src/utils/generatedImageStorage.js
Normal file
|
|
@ -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 };
|
||||
203
src/utils/generatedImages.js
Normal file
203
src/utils/generatedImages.js
Normal file
|
|
@ -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 };
|
||||
31
src/utils/imageTool.js
Normal file
31
src/utils/imageTool.js
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
271
test/assistant-export-owner.test.js
Normal file
271
test/assistant-export-owner.test.js
Normal file
|
|
@ -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('<body><div id="unrelated">Unrelated</div></body>', { 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('<body></body>', { 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('<body></body>', { 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', '<img src="' + oldUrl + '"><img src="' + newUrl + '"><img src="blob:unrelated">');
|
||||
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"]'));
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, '<').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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ const unsafe = ' </textarea><img src=x onerror="alert(1)"><script>alert(2)</scr
|
|||
const catalogue = [
|
||||
...Array.from({ length: 29 }, (_, i) => ({ key: 'SCRIBE_' + i, dbKey: 'prompt.SCRIBE_' + i, family: 'scribe', revision: i ? 0 : 10 })),
|
||||
{ key: 'clinical_assistant.system_behavior', dbKey: 'clinical_assistant.system_behavior', family: 'clinical-text', revision: 10 },
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 }
|
||||
{ key: 'clinical_assistant.image_behavior', dbKey: 'clinical_assistant.image_behavior', family: 'clinical-image', revision: 10 },
|
||||
{ key: 'learning_hub.image_behavior', dbKey: 'learning_hub.image_behavior', family: 'learning-image', revision: 10 }
|
||||
].map(p => ({ ...p, value: unsafe, purpose: unsafe, usedBy: ['Synthetic runtime operation', unsafe], editable: true }));
|
||||
let moduleId = 0;
|
||||
|
||||
|
|
@ -20,10 +21,8 @@ async function browser(t, module, handler) {
|
|||
const component = module.startsWith('admin') ? 'admin' : 'assistant';
|
||||
const dom = new JSDOM('<div id="' + component + '-tab">' + read('public/components/' + component + '.html') + '</div>', { url: 'https://synthetic.invalid', runScripts: 'outside-only' });
|
||||
const { window } = dom;
|
||||
if (component === 'assistant') {
|
||||
window.eval(read('public/js/accountBoundary.js'));
|
||||
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
||||
}
|
||||
window.eval(read('public/js/accountBoundary.js'));
|
||||
assert.equal(window.AccountBoundary.enter({ id: 'synthetic-rendering-owner' }, true), true);
|
||||
const calls = []; const toasts = [];
|
||||
const fetch = async (url, options = {}) => {
|
||||
calls.push({ url, options, body: options.body && JSON.parse(options.body) });
|
||||
|
|
|
|||
65
test/generated-image-storage.test.js
Normal file
65
test/generated-image-storage.test.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const { isPrivateIp } = require('../src/utils/urlSafety');
|
||||
const { decodeBase64, inspect, download, MAX_BYTES } = require('../src/utils/generatedImageStorage');
|
||||
const { args, budgetLimit } = require('../src/utils/generatedImages');
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
|
||||
test('mapped IPv6, noncanonical IPs, internal/special networks and transition ranges fail closed', () => {
|
||||
for (const ip of ['::ffff:127.0.0.1','::ffff:7f00:1','0:0:0:0:0:ffff:a00:1','::ffff:8.8.8.8','64:ff9b::a00:1','2002:7f00:1::','2001:db8::1','fe90::1','fec0::1','ff02::1','::1','10.0.0.1','100.64.0.1','169.254.169.254','172.16.0.1','192.0.0.1','192.0.2.1','198.18.1.1','198.51.100.1','203.0.113.2','127.1','0x7f000001','2130706433']) assert.equal(isPrivateIp(ip), true, ip);
|
||||
assert.equal(isPrivateIp('8.8.8.8'), false); assert.equal(isPrivateIp('2606:4700:4700::1111'), false);
|
||||
});
|
||||
test('base64, byte bound, MIME/magic, assembled argument fields and budget types are strict', () => {
|
||||
assert.equal(decodeBase64(png.toString('base64')).mime, 'image/png');
|
||||
for (const text of ['', 'a', '!!!!', png.toString('base64') + '\n', png.toString('base64').replace(/=$/, ''), 'eHh4']) assert.throws(() => decodeBase64(text));
|
||||
assert.throws(() => inspect(Buffer.alloc(MAX_BYTES + 1))); assert.throws(() => inspect(png, 'text/html'));
|
||||
for (const value of [false, {}, [1000], 999, 32001, '1.5']) assert.throws(() => budgetLimit(value));
|
||||
assert.equal(budgetLimit(null), 32000);
|
||||
for (const input of [null, [], { prompt: 'ok', owner: 7 }, {prompt: 'ok', model: 'paid'}, {prompt: 'ok', layout: 'https://example.test'}]) assert.throws(() => args(input));
|
||||
});
|
||||
test('URL download pins vetted DNS results into HTTPS lookup, never forwards credentials or follows redirect, enforces size', async () => {
|
||||
let calls = 0;
|
||||
const lookup = async () => [{ address:'8.8.8.8',family:4 }];
|
||||
function request(status = 200, body = png, headers = {}) {
|
||||
return (url, options, callback) => {
|
||||
calls++; assert.equal(url.hostname, 'provider-assets.test'); assert.equal(options.agent, false); assert.equal(options.headers.Authorization, undefined);
|
||||
options.lookup(url.hostname, {all:true}, (err, result) => { assert.equal(err,null); assert.deepEqual(result,[{address:'8.8.8.8',family:4}]); });
|
||||
const req = new EventEmitter(); req.destroy = err => req.emit('error',err);
|
||||
process.nextTick(() => { const res = new PassThrough(); res.statusCode=status; res.headers={'content-type':'image/png',...headers}; callback(res); if (!res.destroyed) res.end(body); }); return req;
|
||||
};
|
||||
}
|
||||
const result = await download('https://provider-assets.test/image',{lookup,request:request()}); assert.deepEqual(result.bytes,png);
|
||||
for (const url of ['http://provider-assets.test/x','https://user:secret@provider-assets.test/x','https://provider-assets.test:8443/x']) await assert.rejects(download(url,{lookup,request:request()}));
|
||||
await assert.rejects(download('https://provider-assets.test/x',{lookup:async()=>[{address:'8.8.8.8',family:4},{address:'::ffff:127.0.0.1',family:6}],request:request()}));
|
||||
assert.equal(calls,1);
|
||||
await assert.rejects(download('https://provider-assets.test/x',{lookup,request:request(302,png,{location:'https://127.0.0.1/'})}));
|
||||
await assert.rejects(download('https://provider-assets.test/x',{lookup,request:request(200,png,{'content-length':String(MAX_BYTES+1)})}));
|
||||
await assert.rejects(download('https://provider-assets.test/x',{lookup,request:request(200,Buffer.alloc(MAX_BYTES+1))}));
|
||||
});
|
||||
test('storage preflight verifies read permission as well as writes before authorizing payment', async () => {
|
||||
const fs = require('node:fs'); const vm = require('node:vm'); const sdk = require('@aws-sdk/client-s3');
|
||||
const commands = [];
|
||||
const module = { exports: {} };
|
||||
vm.runInNewContext(fs.readFileSync('src/utils/generatedImageStorage.js', 'utf8'), { module, Buffer, URL, require(name) {
|
||||
if (name === 'fs') return { readFileSync: () => 'synthetic-only' };
|
||||
if (name === './urlSafety') return { isPrivateIp };
|
||||
if (name === '@aws-sdk/client-s3') return { ...sdk, S3Client: class {
|
||||
constructor(options) { assert.equal(options.forcePathStyle, true); }
|
||||
async send(command) { commands.push(command.constructor.name); if (command instanceof sdk.HeadObjectCommand) throw Error('synthetic denied read'); return {}; }
|
||||
} };
|
||||
return require(name);
|
||||
} });
|
||||
const store = module.exports.createStorage({ GENERATED_IMAGES_S3_ENDPOINT: 'http://synthetic.invalid', GENERATED_IMAGES_S3_ACCESS_KEY_FILE: '/synthetic/access', GENERATED_IMAGES_S3_SECRET_KEY_FILE: '/synthetic/secret' });
|
||||
await assert.rejects(store.ready(), /denied read/);
|
||||
assert.deepEqual(commands, ['HeadBucketCommand', 'PutObjectCommand', 'HeadObjectCommand', 'DeleteObjectCommand']);
|
||||
});
|
||||
test('public 2001 IPv6 and mixed public A/AAAA remain usable by images and Nextcloud/WebDAV', async () => {
|
||||
const fs=require('fs'),vm=require('vm');
|
||||
for(const ip of ['2001:4860:4860::8888','2001:200::1','2001:db7::1','2001:db9::1','3fff:1000::1']) assert.equal(isPrivateIp(ip),false,ip);
|
||||
for(const ip of ['2001::1','2001:1ff:ffff::1','2001:2::1','2001:10::1','2001:20::1','2001:db8::1','2002::1','3ffe::1','3fff::1','3fff:fff::1','64:ff9b:1::1','fc00::1']) assert.equal(isPrivateIp(ip),true,ip);
|
||||
let addresses=[{address:'8.8.8.8',family:4},{address:'2001:4860:4860::8888',family:6}];
|
||||
const module={exports:{}};vm.runInNewContext(fs.readFileSync('src/utils/urlSafety.js','utf8'),{module,URL,require:n=>n==='dns'?{promises:{lookup:async()=>addresses}}:require(n)});
|
||||
assert.equal((await module.exports.assertSafeHttpsUrl('https://nextcloud.synthetic.test/files')).hostname,'nextcloud.synthetic.test');
|
||||
addresses=addresses.concat({address:'2001:db8::1',family:6});await assert.rejects(module.exports.assertSafeHttpsUrl('https://nextcloud.synthetic.test/files'),/private IP/);
|
||||
});
|
||||
218
test/generated-image-tools.test.js
Normal file
218
test/generated-image-tools.test.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
function loadAI(provider = 'litellm', chunks, content = 'Body [3].', backend) {
|
||||
const requests = [];
|
||||
class OpenAI { constructor() { this.chat = { completions: { create: async r => {
|
||||
requests.push(r);
|
||||
if (r.stream) return (async function*() { for (const chunk of chunks || []) yield chunk; })();
|
||||
return { choices: [{ message: { content, tool_calls: [{ id: 'one', type: 'function', function: { name: 'generate_image', arguments: '{"prompt":"diagram"}' } }] }, finish_reason: 'tool_calls' }] };
|
||||
} } }; } }
|
||||
const context = { module: { exports: {} }, console: { log() {}, error() {} }, process: { env: { AI_PROVIDER: provider, LITELLM_API_BASE: backend?.url || 'https://synthetic.invalid', OPENROUTER_API_KEY: 'synthetic', ...(provider === 'bedrock' ? { AWS_BEDROCK_REGION: 'synthetic-region' } : {}), ...(provider === 'azure' ? { AZURE_OPENAI_ENDPOINT: 'https://azure.synthetic.invalid', AZURE_OPENAI_API_KEY: 'synthetic', AZURE_DEPLOYMENT_NAME: 'test' } : {}) } }, require(name) {
|
||||
if (name === 'openai') return backend ? require('openai') : { OpenAI };
|
||||
if (name === '@aws-sdk/client-bedrock-runtime') return { BedrockRuntimeClient: class {} };
|
||||
if (name === './models') return { FALLBACK_MODEL: 'fallback', getEffectiveDefaultModel: async () => 'test', getAllowedModelIds: async () => new Set(['test']) };
|
||||
if (name === '../db/database') return { getSetting: async () => 'false' };
|
||||
if (name === './logger') return { apiCall() {}, error() {} };
|
||||
if (name === './generationOptions') return require('../src/utils/generationOptions');
|
||||
throw Error(name);
|
||||
} };
|
||||
vm.runInNewContext(fs.readFileSync('src/utils/ai.js', 'utf8'), context);
|
||||
return { ai: context.module.exports, requests };
|
||||
}
|
||||
const tools = [{ type: 'function', function: { name: 'generate_image', parameters: { type: 'object' } } }];
|
||||
test('compatible call sends tools, returns tool_calls; ordinary request unchanged', async () => {
|
||||
const { ai, requests } = loadAI();
|
||||
const result = await ai.callAI([], { model: 'test', tools });
|
||||
assert.deepEqual(requests[0].tools, tools);
|
||||
assert.equal(result.toolCalls[0].function.name, 'generate_image');
|
||||
await ai.callAI([], { model: 'test' });
|
||||
assert.equal('tools' in requests[1], false);
|
||||
});
|
||||
test('fragmented streaming tool arguments are accumulated without losing body', async () => {
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { content: 'Body [3].', tool_calls: [{ index: 0, id: 'one', type: 'function', function: { name: 'generate_', arguments: '{"prompt":' } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { name: 'image', arguments: '"diagram"}' } }] }, finish_reason: 'tool_calls' }] }
|
||||
];
|
||||
const { ai, requests } = loadAI('litellm', chunks);
|
||||
let text = '';
|
||||
const result = await ai.callAIStream([], { model: 'test', tools }, t => { text += t; });
|
||||
assert.deepEqual(requests[0].tools, tools);
|
||||
assert.equal(text, 'Body [3].');
|
||||
assert.equal(result.toolCalls[0].function.arguments, '{"prompt":"diagram"}');
|
||||
assert.equal(result.toolCalls[0].function.name, 'generate_image');
|
||||
});
|
||||
const imageTool = require('../src/utils/imageTool');
|
||||
const express = require('express');
|
||||
const realImages = require('../src/utils/generatedImages');
|
||||
const id = '12345678-1234-1234-1234-123456789abc';
|
||||
test('legacy direct provider rejects tool mode before calling any provider', async () => {
|
||||
const {ai,requests} = loadAI('bedrock');
|
||||
await assert.rejects(ai.callAI([], {model:'test',tools}), /Tools require/);
|
||||
await assert.rejects(ai.callAIStream([], {model:'test',tools},()=>{}), /Tools require/);
|
||||
assert.equal(requests.length,0);
|
||||
});
|
||||
test('tool validation caps invocations/fields; first-body continuation disables tools and never rewrites an existing body', async () => {
|
||||
const calls=[]; const continuations=[];
|
||||
const opts = { owner:101,workflow:'clinical_assistant',body:{message:'diagram',idempotencyKey:'request'},imageContext:{request:'diagram',history:[]},messages:[{role:'user',content:'diagram'}],options:{model:'test'},
|
||||
images:{enqueue:async(...v)=>{calls.push(v);return {jobId:id,status:'pending'};}},callAI:async(...v)=>{continuations.push(v);return {content:'First body [3].',finishReason:'stop'};} };
|
||||
const call = {id:'one',type:'function',function:{name:'generate_image',arguments:'{"prompt":"diagram","layout":"portrait"}'}};
|
||||
const original = 'Body [3].\n\nTrailing words with';
|
||||
const result = await imageTool.dispatch({content:original,toolCalls:[call],finishReason:'tool_calls'},opts);
|
||||
assert.equal(result.content,original); assert.equal(continuations.length,0); assert.equal(calls.length,1);
|
||||
assert.equal(calls[0][0],101); assert.equal(calls[0][1],'clinical_assistant'); assert.equal(calls[0][3],'tool:request');
|
||||
const first = await imageTool.dispatch({content:null,toolCalls:[call]},opts);
|
||||
assert.equal(first.content,'First body [3].'); assert.equal(continuations.length,1); assert.equal(continuations[0][1].toolChoice,'none');
|
||||
assert.equal(continuations[0][0].at(-1).role,'tool');
|
||||
for (const bad of [[call,call],[{...call,function:{name:'fetch_url',arguments:'{}'}}],[{...call,function:{name:'generate_image',arguments:'{"prompt":"x","model":"bad"}'}}],[{...call,function:{name:'generate_image',arguments:'{'}}]]) {
|
||||
await assert.rejects(imageTool.dispatch({content:original,toolCalls:bad},opts));
|
||||
}
|
||||
await assert.rejects(imageTool.dispatch({content:original,toolCalls:[call]},{...opts,imageContext:undefined}),/original image request/);
|
||||
assert.equal(calls.length,2);
|
||||
});
|
||||
function route(file, ai, jobs) {
|
||||
const mocks = {
|
||||
express, axios:{}, crypto:require('crypto'), multer:require('multer'), path:require('path'),
|
||||
'../utils/ai':ai, '../db/database':{getSetting:async key=>key.includes('model')?'test':null,all:async()=>[]},
|
||||
'../middleware/auth':{authMiddleware(){},moderatorMiddleware(){}},
|
||||
'../utils/crypto':{},'../utils/urlSafety':{},'../utils/policy':{requireFeature:()=>()=>{}},
|
||||
'../utils/logger':{audit(){},error(){}},'../utils/redis':{},'../utils/clinicalPromptPool':{createClinicalPromptPool:()=>({})},
|
||||
'../utils/clinicalPrompts':require('../src/utils/clinicalPrompts'),
|
||||
'../utils/clinicalConversation':require('../src/utils/clinicalConversation'),
|
||||
'../utils/clinicalAnswer':require('../src/utils/clinicalAnswer'),
|
||||
'../utils/generatedImages':realImages, '../utils/generatedImageLinks':require('../src/utils/generatedImageLinks'),
|
||||
'../utils/imageTool':{tools:imageTool.tools,dispatch:(value,options)=>imageTool.dispatch(value,{...options,images:{enqueue:async(...args)=>{jobs.push(args);return {jobId:id,status:'pending'};}}})},
|
||||
'../utils/clinicalMcpClient':{semanticSearch:async()=>({})},
|
||||
'../utils/clinicalRetrieval':{normalizeMcpSearchResponse:()=>[{number:3,title:'Synthetic source',page:17,excerpt:'Synthetic reference'}],dedupeSources:s=>s,
|
||||
normalizeMcpMultimodalResponse:()=>[],cleanSourceExcerpt:s=>s,isVisualSourceQuery:()=>false,classifyAndRerankMultimodalResults:async()=>[]}
|
||||
};
|
||||
const module = {exports:{}};
|
||||
vm.runInNewContext(fs.readFileSync(file,'utf8'),{module,Buffer,console:{info(){},warn(){},error(){}},process:{env:{CLINICAL_ASSISTANT_MCP_WARMUP:'false'}},setTimeout(){},require:n=>{assert.ok(n in mocks,n);return mocks[n];}});
|
||||
return async (path,body) => {
|
||||
const endpoint = module.exports.stack.find(l=>l.route?.path===path).route.stack.at(-1).handle;
|
||||
const response = {statusCode:200,events:'',status(s){this.statusCode=s;return this;},json(d){this.data=d;},setHeader(){},flushHeaders(){},write(t){this.events+=t;},end(){}};
|
||||
await endpoint({user:{id:101},body},response);return response;
|
||||
};
|
||||
}
|
||||
test('actual Clinical chat + fragmented stream send real tools to SDK, dispatch jobs, and preserve citation/body/source/page identity without regeneration', async () => {
|
||||
const body='Exact [3].\n\nSentence ending with'; // Old truncation heuristic would regenerate this.
|
||||
for (const streaming of [false,true]) {
|
||||
const {ai,requests}=loadAI('litellm',[
|
||||
{choices:[{delta:{content:body,tool_calls:[{index:0,id:'one',type:'function',function:{name:'generate_image',arguments:'{"prompt":'}}]}}]},
|
||||
{choices:[{delta:{tool_calls:[{index:0,function:{arguments:'"diagram"}'}}]},finish_reason:'tool_calls'}]}
|
||||
],body);
|
||||
const jobs=[];const request=route('src/routes/clinicalAssistant.js',ai,jobs);
|
||||
const response=await request('/clinical-assistant/chat'+(streaming?'/stream':''),{message:' Create a clinical diagram for the precise current clinical findings 😀\n',history:[{role:'assistant',content:'Prior table [3].'}],idempotencyKey:'same-request'});
|
||||
assert.equal(response.statusCode,200,JSON.stringify(response.data));
|
||||
const result=streaming?JSON.parse(response.events.match(/event: done\ndata: (.*)/)[1]):response.data;
|
||||
assert.equal(result.answer,body);assert.equal(result.sources[0].number,3);assert.equal(result.sources[0].page,17);assert.equal(result.sources[0].title,'Synthetic source');
|
||||
assert.equal(result.imageJobs[0].jobId,id);assert.equal(jobs.length,1);assert.equal(jobs[0][5].request,' Create a clinical diagram for the precise current clinical findings 😀\n');assert.equal(jobs[0][5].history[0].content,'Prior table [3].');assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image');
|
||||
}
|
||||
});
|
||||
test('actual Learning generate/refine use callable tools and bind their own workflow (not sidebar heuristics)', async()=>{
|
||||
for (const [path,content,input] of [
|
||||
['/ai-generate','{"title":"Teaching","body":"<p>Exact body.</p>","questions":[]}',{topic:'Create a diagram',idempotencyKey:'gen'}],
|
||||
['/ai-refine','<p>Exact refined body.</p>',{content:'<p>Prior body.</p>',instructions:'Include an image',idempotencyKey:'ref'}]
|
||||
]) {
|
||||
const {ai,requests}=loadAI('litellm',undefined,content);const jobs=[];
|
||||
const request=route('src/routes/learningAI.js',ai,jobs);const response=await request(path,input);
|
||||
assert.equal(response.statusCode,200,JSON.stringify(response.data));assert.equal(response.data.success,true);assert.equal(response.data.imageJobs[0].jobId,id);
|
||||
assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image');assert.equal(jobs[0][1],'learning_hub');
|
||||
assert.equal(path==='/ai-refine'?response.data.refined:response.data.content.body,path==='/ai-refine'?input.content:'<p>Exact body.</p>');
|
||||
}
|
||||
});
|
||||
test('Learning tool-only refinement retains every existing body/citation/page byte, including edge whitespace', async () => {
|
||||
const content = '\n <p>Exact [3, 1].</p>\n<table><tr><td>5 mg</td><td>page 19 [3]</td></tr></table> \n';
|
||||
const { ai, requests } = loadAI('litellm', undefined, null);
|
||||
const jobs = []; const request = route('src/routes/learningAI.js', ai, jobs);
|
||||
const response = await request('/ai-refine', { content, instructions: 'Create a matching diagram', idempotencyKey: 'exact-refine' });
|
||||
assert.equal(response.statusCode, 200); assert.equal(response.data.refined, content);
|
||||
assert.equal(requests.length, 1); assert.equal(jobs.length, 1);
|
||||
});
|
||||
test('all compatible providers capture tool_choice/parallel cap, fragmented IDs, and unchanged ordinary streams', async () => {
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'to', type: 'function', function: { name: 'generate_', arguments: '{"prompt":' } }] } }] },
|
||||
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'ol', function: { name: 'image', arguments: '"diagram"}' } }], content: 'Body [3].' }, finish_reason: 'tool_calls' }] }
|
||||
];
|
||||
for (const provider of ['litellm', 'openrouter', 'azure']) {
|
||||
const { ai, requests } = loadAI(provider, chunks);
|
||||
await ai.callAI([], { model: 'test', tools, toolChoice: 'none' });
|
||||
assert.equal(requests[0].tool_choice, 'none'); assert.equal(requests[0].parallel_tool_calls, false);
|
||||
const result = await ai.callAIStream([], { model: 'test', tools });
|
||||
assert.equal(result.provider, provider); assert.equal(result.toolCalls[0].id, 'tool');
|
||||
assert.equal(result.toolCalls[0].function.name, 'generate_image'); assert.equal(result.toolCalls[0].function.arguments, '{"prompt":"diagram"}');
|
||||
assert.equal(requests[1].tool_choice, 'auto'); assert.equal(requests[1].parallel_tool_calls, false);
|
||||
const ordinary = loadAI(provider, [{ choices: [{ delta: { content: 'No tools [3].' }, finish_reason: 'stop' }] }]);
|
||||
const text = await ordinary.ai.callAIStream([], { model: 'test' });
|
||||
assert.equal(text.content, 'No tools [3].'); assert.equal('toolCalls' in text, false);
|
||||
assert.equal('tools' in ordinary.requests[0], false); assert.equal('parallel_tool_calls' in ordinary.requests[0], false);
|
||||
}
|
||||
});
|
||||
test('malformed/oversized fragmented tools stop before job dispatch; no repeated continuation', async () => {
|
||||
for (const fragment of [
|
||||
{ index: -1 }, { index: 8 }, { index: 0, type: 'unknown' },
|
||||
{ index: 0, function: { arguments: 'x'.repeat(40001) } },
|
||||
{ index: 0, function: { name: 'x'.repeat(101) } }, { index: 0, id: 'x'.repeat(201) }
|
||||
]) {
|
||||
const { ai } = loadAI('litellm', [{ choices: [{ delta: { tool_calls: [fragment] } }] }]);
|
||||
await assert.rejects(ai.callAIStream([], { model: 'test', tools }));
|
||||
}
|
||||
let paid = 0, continuations = 0;
|
||||
const call = { id: 'one', type: 'function', function: { name: 'generate_image', arguments: '{"prompt":"diagram"}' } };
|
||||
await assert.rejects(imageTool.dispatch({ content: null, toolCalls: [call] }, {
|
||||
owner: 101, workflow: 'learning_hub', body: {}, imageContext:{request:'diagram',history:[]}, messages: [], options: {},
|
||||
images: { enqueue: async () => { paid++; return { jobId: id, status: 'pending' }; } },
|
||||
callAI: async () => { continuations++; return { content: '', toolCalls: [call] }; }
|
||||
}), /did not return educational content/);
|
||||
assert.equal(paid, 1); assert.equal(continuations, 1);
|
||||
});
|
||||
test('actual OpenAI SDK HTTP capture from Clinical routes carries tools and dispatches fragmented SSE once', async () => {
|
||||
const http = require('node:http'); const captured = [];
|
||||
const body = 'Exact transport body [3].\n| Dose | Page |\n| 5 mg | 17 [3] |\n';
|
||||
const call = { id: 'one', type: 'function', function: { name: 'generate_image', arguments: '{"prompt":"Diagram 😀","layout":"portrait"}' } };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const chunks = []; for await (const chunk of req) chunks.push(chunk);
|
||||
const payload = JSON.parse(Buffer.concat(chunks).toString()); captured.push({ path: req.url, payload });
|
||||
if (!payload.stream) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ id: 'synthetic', object: 'chat.completion', choices: [{ index: 0, message: { role: 'assistant', content: body, tool_calls: [call] }, finish_reason: 'tool_calls' }] }));
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
||||
const fragments = [
|
||||
{ choices: [{ index: 0, delta: { content: body, tool_calls: [{ index: 0, id: 'o', type: 'function', function: { name: 'generate_', arguments: '{"prompt":"Diagram ' } }] } }] },
|
||||
{ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'ne', function: { name: 'image', arguments: '😀","layout":"portrait"}' } }] }, finish_reason: 'tool_calls' }] }
|
||||
];
|
||||
const bytes = Buffer.from(fragments.map(f => 'data: ' + JSON.stringify(f) + '\n\n').join('') + 'data: [DONE]\n\n');
|
||||
const split = bytes.indexOf(Buffer.from('😀')) + 2; // Split a UTF-8 character at the transport boundary too.
|
||||
res.write(bytes.subarray(0, split)); setImmediate(() => res.end(bytes.subarray(split)));
|
||||
}
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await new Promise(resolve => server.once('listening', resolve));
|
||||
try {
|
||||
const { ai } = loadAI('litellm', undefined, undefined, { url: 'http://127.0.0.1:' + server.address().port + '/v1' });
|
||||
for (const streaming of [false, true]) {
|
||||
const jobs = []; const request = route('src/routes/clinicalAssistant.js', ai, jobs);
|
||||
const response = await request('/clinical-assistant/chat' + (streaming ? '/stream' : ''), { message: 'Create a diagram', idempotencyKey: 'transport' });
|
||||
assert.equal(response.statusCode, 200);
|
||||
const result = streaming ? JSON.parse(response.events.match(/event: done\ndata: (.*)/)[1]) : response.data;
|
||||
assert.equal(result.answer, body); assert.equal(result.imageJobs[0].jobId, id); assert.equal(result.sources[0].number, 3); assert.equal(result.sources[0].page, 17);
|
||||
assert.equal(jobs.length, 1); assert.equal(jobs[0][2].prompt, 'Diagram 😀'); assert.equal(jobs[0][2].layout, 'portrait');
|
||||
}
|
||||
assert.equal(captured.length, 2);
|
||||
for (const request of captured) {
|
||||
assert.equal(request.path, '/v1/chat/completions'); assert.equal(request.payload.tools[0].function.name, 'generate_image');
|
||||
assert.equal(request.payload.parallel_tool_calls, false); assert.equal(request.payload.tool_choice, 'auto');
|
||||
}
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
test('Learning image refinement ignores accompanying rewritten HTML/citations/table; text-only refinement still works', async () => {
|
||||
const original='\n <p>Original [3, 1].</p><table><tr><td>5 mg</td><td>19 [3]</td></tr></table> \n';
|
||||
for (const withTool of [true,false]) {
|
||||
const jobs=[];const replacement='<p>ALTERED [9].</p>';
|
||||
const request=route('src/routes/learningAI.js',{callAI:async()=>({content:replacement,...(withTool?{toolCalls:[{id:'x',type:'function',function:{name:'generate_image',arguments:'{"prompt":"diagram"}'}}]}:{})})},jobs);
|
||||
const response=await request('/ai-refine',{content:original,instructions:withTool?'Include an image':'Shorten this text'});
|
||||
assert.equal(response.statusCode,200);assert.equal(response.data.refined,withTool?original:replacement);
|
||||
assert.equal(response.data.bodyPreserved,withTool);assert.equal(jobs.length,withTool?1:0);
|
||||
}
|
||||
});
|
||||
212
test/generated-images-ui.test.js
Normal file
212
test/generated-images-ui.test.js
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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 png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
|
||||
const id = '12345678-1234-1234-1234-123456789abc';
|
||||
const src = '/api/generated-images/' + id;
|
||||
function client(options={}) {
|
||||
const dom = new JSDOM('<div id="root"></div>',{url:'https://synthetic.test',runScripts:'outside-only'});
|
||||
let owner='101'; const controller = new AbortController(); const calls=[]; const revoked=[];
|
||||
dom.window.AccountBoundary={capture:()=>owner,valid:t=>!!owner&&t===owner,signal:()=>controller.signal};
|
||||
dom.window.getAuthHeaders=()=>({Authorization:'Bearer synthetic-only'});
|
||||
const context = { window:dom.window,document:dom.window.document,DOMException,AbortController,Blob,TextEncoder,crypto:webcrypto,Uint8Array,console,
|
||||
setTimeout(){}, URL:{createObjectURL:()=> 'blob:synthetic',revokeObjectURL:u=>revoked.push(u)},
|
||||
FileReader:class { readAsDataURL(blob) { blob.arrayBuffer().then(bytes=>{this.result='data:'+blob.type+';base64,'+Buffer.from(bytes).toString('base64');this.onload();}); } },
|
||||
fetch:async(url,init)=>{
|
||||
calls.push({url,init});
|
||||
if (options.fetch) return options.fetch(url,init);
|
||||
if (url.includes('/jobs/')) return new Response(JSON.stringify({success:true,status:'done',jobId:id,imageUrl:src}),{headers:{'content-type':'application/json'}});
|
||||
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'),...options.headers}});
|
||||
}
|
||||
};
|
||||
vm.createContext(context);vm.runInContext(fs.readFileSync('public/js/generatedImages.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),context);
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/sharing.js','utf8').replace(/^export /gm,''),context);
|
||||
return { context,dom,calls,revoked,becomeB(){owner='102';},leave(){owner=null;controller.abort();dom.window.dispatchEvent(new dom.window.Event('account-boundary'));} };
|
||||
}
|
||||
test('private load verifies auth/owner/MIME/length/checksum and blocks late account responses; transient URLs revoked',async()=>{
|
||||
const ui=client();
|
||||
const blob=await ui.context.privateImageBlob(src);assert.equal(blob.size,png.length);assert.equal(ui.calls[0].init.headers.Authorization,'Bearer synthetic-only');assert.equal(ui.calls[0].init.redirect,'error');
|
||||
const img=ui.dom.window.document.createElement('img');ui.dom.window.document.body.append(img);await ui.context.hydrateImage(img,src);assert.equal(img.getAttribute('src'),'blob:synthetic');
|
||||
const data=await ui.context.imageDataUrl(src);assert.equal(data,'data:image/png;base64,'+png.toString('base64'));
|
||||
ui.leave();assert.ok(ui.revoked.includes('blob:synthetic'));assert.equal(img.getAttribute('src'),null);await assert.rejects(ui.context.privateImageBlob(src),/Verified account/);ui.dom.window.close();
|
||||
for (const headers of [{'x-image-owner':'102'},{'content-type':'text/html'},{'content-length':'1'},{'x-image-sha256':'0'.repeat(64)}]) {
|
||||
const bad=client({headers});await assert.rejects(bad.context.privateImageBlob(src));bad.dom.window.close();
|
||||
}
|
||||
let finish;const late=client({fetch:()=>new Promise(r=>finish=r)});const pending=late.context.privateImageBlob(src);late.leave();finish(new Response(png));await assert.rejects(pending,e=>e.name==='AbortError');late.dom.window.close();
|
||||
});
|
||||
test('inline image jobs append DOM without altering answer/citation nodes; reopened jobs use stable reference',async()=>{
|
||||
const ui=client();const root=ui.dom.window.document.getElementById('root');root.innerHTML='<p>Exact body <a href="#source-3">[3]</a></p>';
|
||||
const original=root.firstChild;const html=original.outerHTML;
|
||||
ui.context.renderImageJobs(root,[{jobId:id}],'clinical_assistant',(card,data)=>{const img=ui.dom.window.document.createElement('img');img.setAttribute('src',data.imageUrl);card.append(img);});
|
||||
await new Promise(r=>setImmediate(r));assert.equal(root.firstChild,original);assert.equal(original.outerHTML,html);assert.equal(root.querySelector('img').getAttribute('src'),src);ui.dom.window.close();
|
||||
});
|
||||
test('actual bundled Tiptap persists/reopens stable generated image attributes without modifying educational text',async()=>{
|
||||
const dom=new JSDOM('<div id="editor"></div>',{url:'https://synthetic.test',runScripts:'outside-only'});
|
||||
dom.window.eval(fs.readFileSync('public/vendor/tiptap.bundle.js','utf8'));
|
||||
const context={document:dom.window.document,queueMicrotask(){},hydrateImage(){},revokeImageUrl(){}};
|
||||
vm.createContext(context);
|
||||
const source=fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,'');
|
||||
vm.runInContext(source,context);
|
||||
const T=dom.window.Tiptap;const extension=context.generatedImageExtension(T);
|
||||
const editor=new T.Editor({element:dom.window.document.getElementById('editor'),extensions:[T.StarterKit,extension],content:'<p>Exact <strong>teaching</strong> body.</p>'});
|
||||
const before=editor.getHTML();editor.commands.insertContentAt(editor.state.doc.content.size,{type:'generatedImage',attrs:{src,alt:'Generated teaching visual'}});
|
||||
const saved=editor.getHTML();assert.ok(saved.startsWith(before));assert.ok(saved.includes(src));assert.ok(!saved.includes('blob:'));
|
||||
editor.commands.setContent(saved);assert.equal(editor.getHTML(),saved);editor.destroy();dom.window.close();
|
||||
});
|
||||
test('clinical interception is removed and export path embeds verified images rather than rewriting answer text',()=>{
|
||||
const source=fs.readFileSync('public/js/clinicalAssistant.js','utf8');assert.doesNotMatch(source,/if \(isImageRequest|prepareSidebarImagePrompt/);assert.match(source,/attachImageJobs\(loading/);
|
||||
const exporter=fs.readFileSync('public/js/assistant/export.js','utf8');assert.match(exporter,/images.push\(await imageDataUrl/);assert.match(exporter,/messages.push\(\{ \.\.\.message, images \}\)/);
|
||||
});
|
||||
test('native download cannot fall through to web share after an account transition', async () => {
|
||||
const ui = client(); let shares = 0;
|
||||
ui.context.navigator = { canShare: () => true, share: async () => { shares++; }, userAgent: 'synthetic' };
|
||||
ui.context.File = File;
|
||||
ui.dom.window.NativeFiles = { saveImage() { ui.leave(); return 'error:synthetic native cancellation'; } };
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/images.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
|
||||
await assert.rejects(ui.context.downloadFromServer(src, ui.context.captureSharingOwner()), e => e.name === 'AbortError');
|
||||
assert.equal(shares, 0); ui.dom.window.close();
|
||||
});
|
||||
test('actual assistant export embeds owned bytes, keeps source/page/body identity and closes on account transition', async () => {
|
||||
const ui = client(); const rendered = [];
|
||||
ui.dom.window.matchMedia = () => ({ matches: true });
|
||||
ui.context.escapeHtml = value => String(value).replace(/&/g, '&').replace(/</g, '<');
|
||||
ui.context.escapeAttr = ui.context.escapeHtml;
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/export.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
|
||||
const body = ' Body [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
|
||||
const sources = [{ number: 3, title: 'Synthetic three', page: 19 }, { number: 1, title: 'Synthetic one', page: 4 }];
|
||||
const state = { lastAnswer: body, lastSources: sources, messages: [{ role: 'user', content: 'Diagram' }, { role: 'assistant', content: body, sources, imageJobs: [{ jobId: id }] }] };
|
||||
const original = JSON.stringify(state);
|
||||
const exporter = ui.context.createAssistantExporter({ renderMarkdown(text, refs) { rendered.push({ text, refs }); return '<p>Rendered [3, 1].</p>'; } });
|
||||
await exporter.exportAnswerPdf(state);
|
||||
assert.equal(JSON.stringify(state), original); assert.equal(rendered[0].text, body); assert.deepEqual(rendered[0].refs, sources);
|
||||
const modal = ui.dom.window.document.getElementById('assistant-export-modal');
|
||||
assert.ok(modal); assert.equal(modal.querySelector('img').getAttribute('src'), 'data:image/png;base64,' + png.toString('base64'));
|
||||
assert.match(modal.textContent, /\[3\] Synthetic three, page 19/); assert.match(modal.textContent, /\[1\] Synthetic one, page 4/);
|
||||
ui.leave(); assert.equal(ui.dom.window.document.getElementById('assistant-export-modal'), null); ui.dom.window.close();
|
||||
});
|
||||
test('image-specific omitted turns and exact UTF16 use are visible without touching normal transcript', async () => {
|
||||
const metadata={includedTurns:2,totalTurns:7,used:32000,limit:32000,unit:'UTF-16 code units'};
|
||||
const ui=client({fetch:async()=>new Response(JSON.stringify({success:true,status:'pending',jobId:id,context:metadata}))});
|
||||
const root=ui.dom.window.document.getElementById('root'); root.innerHTML='<p>Original [3, 1].</p>';const original=root.firstChild;
|
||||
ui.context.renderImageJobs(root,[{jobId:id}],'clinical_assistant',()=>{}); await new Promise(r=>setImmediate(r));
|
||||
assert.match(root.textContent,/2\/7 preceding turns included; 32000\/32000 UTF-16 code units/);
|
||||
assert.match(root.textContent,/Older turns omitted from image input only/); assert.equal(root.firstChild,original); ui.dom.window.close();
|
||||
});
|
||||
test('legacy HTTP/data and Share-only fallbacks keep the ORIGINAL owner across conversion and cancellation', async () => {
|
||||
for (const mode of ['native-http-share-only','native-data-filesystem','web-http','browser-http','native-recapture','filesystem-late','native-conversion']) {
|
||||
const ui=client(); let shares=0,writes=0,saves=0,anchors=0;
|
||||
ui.context.File=File; ui.context.atob=atob; ui.context.escapeAttr=String;
|
||||
ui.context.navigator={userAgent:'synthetic',canShare:()=>true,...(mode==='web-http'?{share:async()=>{shares++;}}:{})};
|
||||
const source=mode.includes('data')?'data:image/png;base64,'+png.toString('base64'):'https://synthetic.test/legacy.png';
|
||||
if(mode.startsWith('native') || mode==='filesystem-late') {
|
||||
ui.dom.window.Capacitor={isNativePlatform:()=>true,Plugins:{Share:{share:async()=>{shares++;}},...((mode.includes('filesystem') || mode==='native-recapture')?{Filesystem:{writeFile:async()=>{writes++;if(mode==='filesystem-late'){ui.leave();ui.becomeB();}return {uri:'cache:test'};}}}:{})}};
|
||||
if(mode!=='filesystem-late') ui.dom.window.NativeFiles={saveImage(){saves++;ui.leave();if(mode==='native-recapture')ui.becomeB();return 'error:capability failure';}};
|
||||
} else {
|
||||
ui.context.fetch=async()=>({ok:true,blob:async()=>{ui.leave();return new Blob([png],{type:'image/png'});}});
|
||||
}
|
||||
if(mode==='native-conversion') ui.context.FileReader=class { readAsDataURL(){queueMicrotask(()=>{ui.leave();ui.becomeB();this.result='data:image/png;base64,'+png.toString('base64');this.onload();});} };
|
||||
ui.dom.window.HTMLAnchorElement.prototype.click=()=>{anchors++;};
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
|
||||
const store=ui.context.createAssistantImageStore(); store.renderGeneratedImage(source); await store.downloadImage('img-1');
|
||||
assert.equal(shares,0,mode);assert.equal(writes,mode==='filesystem-late'?1:0,mode);assert.equal(anchors,0,mode);
|
||||
if(mode.startsWith('native')) assert.equal(saves,mode==='native-conversion'?0:1);ui.dom.window.close();
|
||||
}
|
||||
});
|
||||
test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, without removing A from its conversation turn', async () => {
|
||||
const b='22345678-1234-1234-1234-123456789abc';let status='pending',saved;
|
||||
const ui=client();ui.dom.window.document.body.innerHTML=fs.readFileSync('public/components/assistant.html','utf8');
|
||||
Object.assign(ui.context,{escapeAttr:String,escapeHtml:String,EMPTY_PROMPT_SETS:[],renderAssistantMarkdown:text=>'<p>'+text+'</p>',renderSourcesList:()=>'',
|
||||
createAssistantExporter:()=>({invalidate(){}}),createAssistantImageStore:()=>({renderGeneratedImage:url=>'<img src="'+url+'">',clear(){}}),
|
||||
startAssistantImageJob:async()=>({success:true,jobId:b,status:'pending'}),
|
||||
saveAssistantChat:async payload=>{saved=JSON.parse(JSON.stringify(payload));return {success:true};},fetchSavedAssistantChats:async()=>({success:true,chats:[]})});
|
||||
ui.context.imageJson=async url=>{const jobId=url.split('/').at(-1);return {success:true,jobId,status:jobId===id?'done':status,imageUrl:'/api/generated-images/'+jobId};};
|
||||
vm.runInContext(fs.readFileSync('public/js/clinicalAssistant.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
|
||||
const transcript=[{role:'assistant',content:'Original [3, 1].',sources:[{number:3,page:19}],imageJobs:[{jobId:id}]}];
|
||||
ui.context.restoreSavedChat({messages:transcript,lastAnswer:'Original [3, 1].',generatedImage:src});
|
||||
ui.context.generateImage('B request');await new Promise(r=>setImmediate(r));await ui.context.saveCurrentChat();
|
||||
assert.equal(saved.generatedImage,'','B queue must clear stale A asset');assert.equal(saved.generatedImageJobs[0].jobId,b);assert.equal(saved.messages[0].imageJobs[0].jobId,id);
|
||||
ui.context.restoreSavedChat(saved);await new Promise(r=>setImmediate(r));assert.equal(ui.context.lastGeneratedImageSrc,'');
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/export.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
|
||||
ui.context.imageDataUrl=async url=>'data-for:'+url;
|
||||
const state=()=>({messages:ui.context.messages,lastGeneratedImageSrc:ui.context.lastGeneratedImageSrc,generatedImageJobs:ui.context.generatedImageJobs});
|
||||
const exportOwner=ui.context.captureSharingOwner();
|
||||
await assert.rejects(ui.context.preparePrivateExport(state(),exportOwner),/not complete/);
|
||||
// Also repairs already-saved inconsistent A URL/B ID from the previous implementation.
|
||||
await assert.rejects(ui.context.preparePrivateExport({...state(),lastGeneratedImageSrc:src},exportOwner),/not complete/);
|
||||
status='done';ui.context.restoreSavedChat({...saved,generatedImage:src});await new Promise(r=>setImmediate(r));
|
||||
assert.equal(ui.context.lastGeneratedImageSrc,'/api/generated-images/'+b);
|
||||
const exported=await ui.context.preparePrivateExport(state(),exportOwner);assert.equal(exported.lastGeneratedImageSrc,'data-for:/api/generated-images/'+b);
|
||||
assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content);
|
||||
await ui.context.saveCurrentChat();assert.equal(saved.generatedImage,'/api/generated-images/'+b);ui.dom.window.close();
|
||||
});
|
||||
test('actual CMS presentation image controls are visible and insertion/save/reopen preserve Markdown; preserved refinement never resets editor', async () => {
|
||||
const ui=client();ui.dom.window.document.body.innerHTML='<style>.hidden{display:none!important}</style>'+fs.readFileSync('public/components/cms.html','utf8');
|
||||
ui.dom.window.HTMLElement.prototype.scrollIntoView=()=>{};
|
||||
let saved,resets=0,html='<p>Original [3].</p>';
|
||||
const editor={isDestroyed:false,getHTML:()=>html,commands:{setContent(value){resets++;html=value;},clearContent(){html='';}}};
|
||||
Object.assign(ui.context,{getTpHTML:e=>e.getHTML(),makeTpEditor:()=>editor,clearQuestionBlocks:el=>el.replaceChildren(),showToast(){},showLoading(){},hideLoading(){},showBusy(){},hideBusy(){},getSelectedModel:()=>'',
|
||||
createSlideController:()=>({}),createCmsController:()=>({refreshAll(){}}),createQuizController:()=>({}),createWebdavController:()=>({}),createAiPanelController:()=>({}),
|
||||
getJson:async()=>({success:true,content:{...saved,id:1,questions:[]}}),
|
||||
sendJson:async(url,method,payload)=>{if(url.endsWith('ai-refine'))return {success:true,bodyPreserved:true,refined:'SHOULD NOT RESET',imageJobs:[]};saved=payload;return {success:true,id:1};}
|
||||
});
|
||||
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
|
||||
vm.runInContext(fs.readFileSync('public/js/learningHub.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
|
||||
ui.context.openEditor(null,'presentation');const doc=ui.dom.window.document;
|
||||
const panel=doc.querySelector('[aria-label="Learning Hub images"]');assert.ok(panel);
|
||||
for(let el=panel;el;el=el.parentElement) assert.notEqual(ui.dom.window.getComputedStyle(el).display,'none',el.id);
|
||||
const markdown='---\nmarp: true\n---\n# Original [3]\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
|
||||
doc.getElementById('lh-marp-editor').value=markdown;doc.getElementById('lh-cms-edit-title').value='Presentation';
|
||||
ui.context.learningImages.show([{jobId:id}]);await new Promise(r=>setImmediate(r));
|
||||
const insert=[...panel.querySelectorAll('button')].find(b=>b.textContent==='Insert image at end of content');assert.ok(insert);insert.click();
|
||||
const inserted=doc.getElementById('lh-marp-editor').value;assert.ok(inserted.startsWith(markdown));assert.ok(inserted.includes(']('+src+')'));
|
||||
ui.context.saveContent();await new Promise(r=>setImmediate(r));assert.equal(saved.body,inserted);assert.equal(saved.content_type,'presentation');
|
||||
ui.context.openEditor(1,'presentation');await new Promise(r=>setImmediate(r));assert.equal(doc.getElementById('lh-marp-editor').value,inserted);
|
||||
// Same real frontend refine handler: no setContent on bodyPreserved.
|
||||
html='<p>Original [3].</p>';doc.getElementById('lh-ai-refine-input').value='Add an image';const before=resets;
|
||||
ui.context.submitRefineBody();await new Promise(r=>setImmediate(r));assert.equal(resets,before);assert.equal(html,'<p>Original [3].</p>');
|
||||
ui.dom.window.close();
|
||||
});
|
||||
test('same-owner legacy sharing still supports NativeFiles, Filesystem, Share-only, Web Share and browser', async () => {
|
||||
for(const mode of ['native','filesystem','share-only','web','browser']) {
|
||||
const ui=client();let effects=0;
|
||||
ui.context.escapeAttr=String;ui.context.File=File;ui.context.atob=atob;ui.context.navigator={userAgent:'synthetic'};
|
||||
ui.context.fetch=async()=>new Response(png);
|
||||
if(['native','filesystem','share-only'].includes(mode)) {
|
||||
ui.dom.window.Capacitor={isNativePlatform:()=>true,Plugins:{Share:{share:async()=>{effects++;}}}};
|
||||
if(mode==='native')ui.dom.window.NativeFiles={saveImage(){effects++;return 'saved:test';}};
|
||||
if(mode==='filesystem')ui.dom.window.Capacitor.Plugins.Filesystem={writeFile:async()=>({uri:'cache:test'})};
|
||||
}
|
||||
if(mode==='web')ui.context.navigator={...ui.context.navigator,canShare:()=>true,share:async()=>{effects++;}};
|
||||
ui.dom.window.HTMLAnchorElement.prototype.click=()=>{effects++;};
|
||||
vm.runInContext(fs.readFileSync('public/js/assistant/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
|
||||
const store=ui.context.createAssistantImageStore();store.renderGeneratedImage('https://synthetic.test/legacy.png');await store.downloadImage('img-1');
|
||||
assert.equal(effects,1,mode);ui.dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('Learning private export keeps the initial owner between images and has no late UI or download after same-ID replacement', async () => {
|
||||
const ui = client(); let downloads = 0, conversions = 0;
|
||||
ui.dom.window.document.body.innerHTML = '<div id="lh-images-section"></div>';
|
||||
ui.context.sanitizeHtml = value => value;
|
||||
ui.dom.window.HTMLAnchorElement.prototype.click = () => { downloads++; };
|
||||
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
|
||||
const images = ui.context.createLearningImages(() => ({ getHTML: () => '<p>Exact body [1].</p><img src="' + src + '"><img src="' + src + '">' }), () => null);
|
||||
images.mount();
|
||||
const original = ui.context.imageDataUrl;
|
||||
ui.context.imageDataUrl = async (...args) => {
|
||||
if (++conversions === 1) {
|
||||
ui.dom.window.AccountBoundary = { ...ui.dom.window.AccountBoundary };
|
||||
return 'data:image/png;base64,' + png.toString('base64');
|
||||
}
|
||||
return original(...args);
|
||||
};
|
||||
const status = ui.dom.window.document.querySelector('[role=status]'); const before = status.textContent;
|
||||
await ui.dom.window.document.querySelector('[data-export]').onclick();
|
||||
assert.equal(conversions, 2, 'exercise the continuation between images');
|
||||
assert.equal(ui.calls.length, 0, 'do not recapture same-ID B to fetch another image');
|
||||
assert.equal(downloads, 0); assert.equal(status.textContent, before);
|
||||
ui.dom.window.close();
|
||||
});
|
||||
414
test/generated-images.integration.js
Normal file
414
test/generated-images.integration.js
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// Run only with scripts/test-generated-images.sh: disposable internal-network PG + private S3.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { Pool } = require('pg');
|
||||
const { createImageService, requestKey } = require('../src/utils/generatedImages');
|
||||
const { createStorage, inspect } = require('../src/utils/generatedImageStorage');
|
||||
const links = require('../src/utils/generatedImageLinks');
|
||||
const { savedChatPayload } = require('../src/utils/clinicalConversation');
|
||||
const revisions = require('../src/utils/promptRevisions');
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
|
||||
if (!process.env.GENERATED_IMAGES_TEST_DB || new URL(process.env.GENERATED_IMAGES_TEST_DB).hostname !== 'test-pg' || new URL(process.env.GENERATED_IMAGES_TEST_DB).pathname !== '/image_lane') throw Error('Use the disposable test script, never an application database');
|
||||
const pool = new Pool({ connectionString: process.env.GENERATED_IMAGES_TEST_DB });
|
||||
const db = { pool, query: (sql, params) => pool.query(sql, params), async all(sql, params) { return (await pool.query(sql, params)).rows; }, async get(sql, params) { return (await pool.query(sql, params)).rows[0]; } };
|
||||
let paid = 0;
|
||||
const generate = async () => { paid++; return inspect(png); };
|
||||
const storage = createStorage();
|
||||
const service = () => createImageService({ db, storage, generate });
|
||||
let image;
|
||||
test.before(async () => {
|
||||
await pool.query('CREATE TABLE users(id INTEGER PRIMARY KEY); INSERT INTO users VALUES(101),(102),(103); CREATE TABLE app_settings(key TEXT PRIMARY KEY,value TEXT,updated_at TIMESTAMPTZ DEFAULT NOW()); CREATE TABLE learning_content(id SERIAL PRIMARY KEY,body TEXT,published BOOLEAN DEFAULT false);');
|
||||
for (const migration of ['1777700000000_add-prompt-revisions','1777800000000_generated-images','1777900000000_image-context']) {
|
||||
let sql; require('../migrations/' + migration).up({ sql: value => { sql = value; } }); await pool.query(sql);
|
||||
}
|
||||
await pool.query("INSERT INTO app_settings(key,value) VALUES('learning_hub.image_model','synthetic-learning-image'),('clinical_assistant.image_model','synthetic-clinical-image')");
|
||||
});
|
||||
test.after(async () => { storage.close(); await pool.end(); });
|
||||
test('private S3 asset, encrypted durable snapshot, idempotency and owner/workflow isolation', async () => {
|
||||
const jobs = service(); const before = paid;
|
||||
const input = { prompt: 'Synthetic flowchart comparison', layout: 'portrait' };
|
||||
image = await jobs.enqueue(101, 'clinical_assistant', input, 'first');
|
||||
const dup = await jobs.enqueue(101, 'clinical_assistant', input, 'first'); assert.equal(dup.jobId, image.jobId);
|
||||
await assert.rejects(jobs.enqueue(101, 'clinical_assistant', { prompt: 'different' }, 'first'), e => e.statusCode === 409);
|
||||
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [image.jobId]);
|
||||
assert.match(row.prompt_cipher, /^enc1:/); assert.ok(!row.prompt_cipher.includes(input.prompt)); assert.equal(row.budget, 32000); assert.equal(row.model, 'synthetic-clinical-image');
|
||||
await jobs.tick(); assert.equal(paid, before + 1);
|
||||
const done = await jobs.get(image.jobId, 101, 'clinical_assistant'); assert.equal(done.status, 'done'); assert.equal(done.imageUrl, '/api/generated-images/' + image.jobId);
|
||||
assert.deepEqual((await jobs.asset(image.jobId, { id: 101 })).bytes, png);
|
||||
await assert.rejects(jobs.get(image.jobId, 102, 'clinical_assistant'), e => e.statusCode === 404);
|
||||
await assert.rejects(jobs.get(image.jobId, 101, 'learning_hub'), e => e.statusCode === 404);
|
||||
await assert.rejects(jobs.asset(image.jobId, { id: 102, role: 'admin' }), e => e.statusCode === 404);
|
||||
assert.equal((await db.get('SELECT staged_bytes FROM generated_image_jobs WHERE id=$1', [image.jobId])).staged_bytes, null);
|
||||
const anonymous = await fetch('http://test-s3:9000/generated-images/assets/' + image.jobId); assert.equal(anonymous.status, 403);
|
||||
});
|
||||
test('saved chat keeps exact body/citations, validates owned asset and durable job references', async () => {
|
||||
const body = '## Exact\nDose [3, 1].\n| A | Source |\n| --- | --- |\n| 5 mg | [1] |';
|
||||
const sources = [{ number: 3, page: 19, title: 'Synthetic three' }, { number: 1, page: 4, title: 'Synthetic one' }];
|
||||
const payload = savedChatPayload({ messages: [{ role: 'assistant', content: body, sources, imageJobs: [image] }], lastAnswer: body, sources, generatedImage: '/api/generated-images/' + image.jobId });
|
||||
await links.validateChat(db, payload, 101);
|
||||
assert.equal(payload.messages[0].content, body); assert.deepEqual(payload.sources, sources);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(payload)), payload);
|
||||
await assert.rejects(links.validateChat(db, payload, 102), e => e.statusCode === 403);
|
||||
});
|
||||
test('Learning has independent model/prompt revision and authenticated current-publication grants; clinical UUID cannot be published', async () => {
|
||||
const jobs = service();
|
||||
const revision = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Synthetic learning instructions.', expectedRevision: 0, actor: 101 });
|
||||
const job = await jobs.enqueue(101, 'learning_hub', { prompt: 'Learning diagram' }, 'learning');
|
||||
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
|
||||
assert.equal(row.prompt_revision, revision.revision); assert.equal(row.model, 'synthetic-learning-image');
|
||||
assert.match(require('../src/utils/crypto').decryptString(row.prompt_cipher), /Synthetic learning instructions/);
|
||||
const changed = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Second synthetic Learning instructions.', expectedRevision: revision.revision, actor: 101 });
|
||||
const restored = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'restore', revisionId: revision.revision, expectedRevision: changed.revision, actor: 101 });
|
||||
assert.equal(restored.value, revision.value);
|
||||
assert.equal((await revisions.read(db, 'learning_hub.image_behavior', restored.revision)).restoredFrom, revision.revision);
|
||||
assert.equal((await db.get('SELECT prompt_revision FROM generated_image_jobs WHERE id=$1', [job.jobId])).prompt_revision, revision.revision);
|
||||
await jobs.tick();
|
||||
const id = (await db.query("INSERT INTO learning_content(body,published) VALUES('',false) RETURNING id")).rows[0].id;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await assert.rejects(links.validateLearning(client, '<img src="/api/generated-images/' + image.jobId + '">', 101, id), e => e.statusCode === 403);
|
||||
const ids = await links.validateLearning(client, '<img src="/api/generated-images/' + job.jobId + '">', 101, id);
|
||||
await links.setLinks(client, id, ids); await client.query('COMMIT');
|
||||
} finally { client.release(); }
|
||||
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
|
||||
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'moderator' })).bytes, png);
|
||||
await db.query('UPDATE learning_content SET published=true WHERE id=$1', [id]);
|
||||
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'user' })).bytes, png);
|
||||
await db.query('UPDATE learning_content SET published=false WHERE id=$1', [id]);
|
||||
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
|
||||
await db.query('DELETE FROM generated_image_links WHERE content_id=$1', [id]);
|
||||
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'moderator' }), e => e.statusCode === 404);
|
||||
await assert.rejects(links.validateLearning(db, '/api/generated-images/' + job.jobId, 102, id), e => e.statusCode === 403);
|
||||
await assert.rejects(db.query('INSERT INTO generated_image_links VALUES($1,$2)', [image.jobId, id]), /Only Learning assets/);
|
||||
await assert.rejects(db.query("UPDATE generated_image_jobs SET workflow='learning_hub' WHERE id=$1", [image.jobId]), /immutable/);
|
||||
});
|
||||
test('restart resumes queued/storage stages, but never retries ambiguous paid stages; lease fencing and SKIP LOCKED are native', async () => {
|
||||
const jobs = service(); const before = paid;
|
||||
const queued = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Queued across restart' }, 'restart');
|
||||
await service().tick(); assert.equal((await jobs.get(queued.jobId,101,'clinical_assistant')).status, 'done');
|
||||
const unknown = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Crash during provider request' }, 'unknown');
|
||||
const old = await jobs.claim(); assert.equal(old.id, unknown.jobId);
|
||||
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1", [unknown.jobId]);
|
||||
await service().tick(); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome, 'unknown');
|
||||
assert.equal(paid, before + 1);
|
||||
const fenced = await db.query("UPDATE generated_image_jobs SET stage='storing' WHERE id=$1 AND lease_token=$2 AND stage='generating' RETURNING id", [old.id,old.lease_token]); assert.equal(fenced.rows.length, 0);
|
||||
const storing = await jobs.enqueue(101,'clinical_assistant',{ prompt: 'Storage crash' },'storage');
|
||||
await createImageService({ db, generate, storage: { ...storage, put: async () => { throw Error('synthetic unavailable'); } } }).tick();
|
||||
const staged = await db.get('SELECT stage,staged_bytes FROM generated_image_jobs WHERE id=$1', [storing.jobId]);
|
||||
assert.equal(staged.stage, 'storing'); assert.notDeepEqual(staged.staged_bytes, png);
|
||||
assert.deepEqual(require('../src/utils/crypto').decryptBuffer(staged.staged_bytes), png);
|
||||
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[storing.jobId]);
|
||||
await service().tick(); assert.equal((await jobs.get(storing.jobId,101,'clinical_assistant')).status, 'done'); assert.equal(paid,before+2);
|
||||
const first = await jobs.enqueue(101,'clinical_assistant',{ prompt:'lock one' },'lock-one');
|
||||
const second = await jobs.enqueue(102,'clinical_assistant',{ prompt:'lock two' },'lock-two');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN'); await client.query('SELECT id FROM generated_image_jobs WHERE id=$1 FOR UPDATE',[first.jobId]);
|
||||
const claim = await service().claim(); assert.equal(claim.id, second.jobId);
|
||||
await client.query('COMMIT');
|
||||
} finally { client.release(); }
|
||||
await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=ANY($1::uuid[])", [[first.jobId,second.jobId]]);
|
||||
});
|
||||
test('budget exact UTF16 assembly, malformed input and missing storage prevent paid calls; provider timeout is explicit unknown', async () => {
|
||||
const jobs = service(); const before = paid;
|
||||
await db.query("INSERT INTO app_settings(key,value) VALUES('clinical_assistant.image_behavior','X'),('clinical_assistant.image_budget','1000')");
|
||||
const base = await jobs.snapshot('clinical_assistant',{ prompt:'x',layout:'square' });
|
||||
const prompt = '😀'.repeat(Math.floor((1001-base.rendered.length)/2)) + ('x'.repeat((1001-base.rendered.length)%2));
|
||||
const exact = await jobs.snapshot('clinical_assistant',{prompt,layout:'square'}); assert.equal(exact.rendered.length, 1000);
|
||||
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:prompt+'x',layout:'square'},'large'), e => e.statusCode === 413);
|
||||
for (const input of [{prompt:''},{prompt:'x',model:'forbidden'},{prompt:'x',layout:'url'},{prompt:'x'.repeat(32001)}]) await assert.rejects(jobs.enqueue(101,'clinical_assistant',input,'bad'));
|
||||
const unavailable = createImageService({ db, generate, storage: { ready: async () => { throw Error('synthetic storage outage'); } } });
|
||||
await assert.rejects(unavailable.enqueue(101,'clinical_assistant',{prompt:'no call'},'offline'),e => e.statusCode === 503);
|
||||
assert.equal(paid,before);
|
||||
const unknown = await jobs.enqueue(101,'clinical_assistant',{prompt:'ambiguous timeout'},'timeout');
|
||||
await createImageService({ db, storage, generate: async () => { paid++; throw Error('synthetic timeout after possible billing'); } }).tick();
|
||||
await jobs.tick(); assert.equal(paid,before+1); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome,'unknown');
|
||||
});
|
||||
test('actual authenticated asset/settings and Learning content write routes enforce grants and publication atomically', async () => {
|
||||
const express = require('express'); const fs = require('fs'); const vm = require('vm'); const jwt = require('jsonwebtoken');
|
||||
const jobs = service();
|
||||
await pool.query("ALTER TABLE learning_content ADD title TEXT, ADD slug TEXT, ADD category_id INTEGER, ADD subject TEXT, ADD content_type TEXT, ADD author_id INTEGER, ADD updated_at TIMESTAMPTZ DEFAULT NOW()");
|
||||
const convert = sql => { let n=0; return sql.replace(/\?/g,()=>'$'+(++n)); };
|
||||
let lockNotice;
|
||||
const routeDb = { ...db, pool: { async connect() {
|
||||
const client=await pool.connect(); return { release:()=>client.release(), query(sql,params) {
|
||||
if(sql.includes('FOR UPDATE') && lockNotice) { lockNotice(); lockNotice=null; }
|
||||
return client.query(sql,params);
|
||||
} };
|
||||
} }, getSetting: async key => (await db.get('SELECT value FROM app_settings WHERE key=$1',[key]))?.value,
|
||||
async get(sql,params) { return db.get(convert(sql),params); },
|
||||
async all(sql,params) { return db.all(convert(sql),params); },
|
||||
async run(sql,params) { const r=await db.query(convert(sql),params);return {lastInsertRowid:r.rows[0]?.id,changes:r.rowCount}; }
|
||||
};
|
||||
function load(file,mocks) {
|
||||
const module={exports:{}};
|
||||
vm.runInNewContext(fs.readFileSync(file,'utf8'),{module,Buffer,console:{warn(){},error(){}},process:{env:{JWT_SECRET:'synthetic-signing-only',CLINICAL_ASSISTANT_MCP_WARMUP:'false'}},setTimeout(){},require:n=>{assert.ok(n in mocks,n);return mocks[n];}});
|
||||
return module.exports;
|
||||
}
|
||||
const roles={101:'admin',102:'user',103:'moderator'};
|
||||
const auth = load('src/middleware/auth.js',{'jsonwebtoken':jwt,'../db/database':{get:async(sql,params)=>sql.includes('user_sessions')?{id:1,last_activity:new Date()}:roles[params[0]]?{id:params[0],role:roles[params[0]]}:null},'../utils/sessions':{hashToken:()=> 'synthetic-hash'},'../utils/platform':{isMobileClient:()=>false}});
|
||||
const imageRoutes=load('src/routes/generatedImages.js',{'express':express,'../middleware/auth':auth,'../utils/generatedImages':{...require('../src/utils/generatedImages'),service:()=>jobs},'../db/database':routeDb});
|
||||
const learningRoutes=load('src/routes/learningAdmin.js',{'express':express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/embeddings':{isEmbeddingsAvailable:()=>false},'../utils/generatedImageLinks':links});
|
||||
await db.query('CREATE TABLE clinical_assistant_chats(id SERIAL PRIMARY KEY,user_id INTEGER,title TEXT,payload TEXT,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW())');
|
||||
const learningAI=load('src/routes/learningAI.js',{
|
||||
express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'),
|
||||
'../middleware/auth':auth,'../db/database':routeDb,'../utils/crypto':require('../src/utils/crypto'),'../utils/urlSafety':require('../src/utils/urlSafety'),
|
||||
'../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs},'pptxgenjs':require('pptxgenjs')
|
||||
});
|
||||
await db.query('CREATE TABLE learning_categories(id SERIAL PRIMARY KEY,name TEXT);CREATE TABLE learning_questions(id SERIAL PRIMARY KEY,content_id INTEGER,sort_order INTEGER)');
|
||||
const clinicalRoutes = load('src/routes/clinicalAssistant.js', {
|
||||
express, axios: {}, crypto: require('crypto'), '../db/database': routeDb, '../middleware/auth': auth,
|
||||
'../utils/ai': {}, '../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => jobs },
|
||||
'../utils/imageTool': require('../src/utils/imageTool'), '../utils/generatedImageLinks': links,
|
||||
'../utils/logger': { audit() {}, error() {} }, '../utils/crypto': require('../src/utils/crypto'), '../utils/redis': {},
|
||||
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/clinicalMcpClient': {}, '../utils/clinicalRetrieval': {},
|
||||
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), '../utils/clinicalAnswer': require('../src/utils/clinicalAnswer')
|
||||
});
|
||||
// Execute REAL server registrations in order, including the actual blanket adminConfig guard.
|
||||
const configRoutes=load('src/routes/adminConfig.js',{
|
||||
express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/prompts':{getAllPrompts:()=>[]},
|
||||
'../utils/promptCatalog':{},'../utils/promptRevisions':{},'../utils/clinicalConversation':require('../src/utils/clinicalConversation'),
|
||||
'../utils/logger':{},'../utils/errors':{},'../utils/ttsProvider':{},'../utils/litellm':{},'../utils/sttProvider':{},'../utils/embeddings':{}
|
||||
});
|
||||
const app=express();app.use(express.json());
|
||||
const composition=fs.readFileSync('server.js','utf8');
|
||||
vm.runInNewContext(composition.slice(composition.indexOf('// Routes\n'),composition.indexOf('// User-level preference:')),{
|
||||
app,APP_VERSION:'synthetic',process:{env:{}},require(name){
|
||||
if(name==='./src/routes/generatedImages') return imageRoutes;
|
||||
if(name==='./src/routes/learningAdmin') return learningRoutes;
|
||||
if(name==='./src/routes/learningAI') return learningAI;
|
||||
if(name==='./src/routes/clinicalAssistant') return clinicalRoutes;
|
||||
if(name==='./src/routes/adminConfig') return configRoutes;
|
||||
if(name==='./src/middleware/auth') return auth;
|
||||
if(name==='./src/db/database') return routeDb;
|
||||
if(name==='./src/utils/models') return {activeProvider:'synthetic',getAvailableModelsWithOverrides:async()=>[],getEffectiveDefaultModel:async()=>''};
|
||||
if(name==='./src/utils/ai') return {};
|
||||
if(name==='./src/utils/generatedImages') return {service:()=>({start(){}})};
|
||||
assert.match(name,/^\.\/src\/routes\//);return express.Router(); // no unrelated application modules/services
|
||||
}
|
||||
});
|
||||
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
||||
const base='http://127.0.0.1:'+server.address().port;
|
||||
const request=(path,owner,method='GET',body)=>fetch(base+path,{method,headers:{'Content-Type':'application/json',...(owner?{Authorization:'Bearer '+jwt.sign({userId:owner},'synthetic-signing-only')}: {})},body:body?JSON.stringify(body):undefined});
|
||||
try {
|
||||
assert.equal((await request('/api/health')).status,200);
|
||||
assert.equal((await request('/api/models')).status,200);
|
||||
assert.equal((await request('/api/admin/learning/image/jobs',null,'POST',{prompt:'denied'})).status,401);
|
||||
assert.equal((await request('/api/admin/learning/image/jobs',102,'POST',{prompt:'denied'})).status,403);
|
||||
assert.equal((await request('/api/admin/config',103)).status,403);
|
||||
const moderatorJob=await request('/api/admin/learning/image/jobs',103,'POST',{prompt:'Moderator image',idempotencyKey:'moderator-image'});
|
||||
assert.equal(moderatorJob.status,200,await moderatorJob.clone().text());
|
||||
const moderatorId=(await moderatorJob.json()).jobId;
|
||||
assert.equal((await request('/api/admin/learning/image/jobs/'+moderatorId,103)).status,200);await jobs.tick();
|
||||
assert.equal((await request('/api/generated-images/'+image.jobId)).status,401);
|
||||
assert.equal((await request('/api/generated-images/'+image.jobId,102)).status,404);
|
||||
const bytes=await request('/api/generated-images/'+image.jobId+'?download=1',101);
|
||||
assert.equal(bytes.status,200);assert.equal(bytes.headers.get('x-image-owner'),'101');assert.equal(bytes.headers.get('cache-control'),'private, no-store');assert.equal(bytes.headers.get('x-content-type-options'),'nosniff');assert.deepEqual(Buffer.from(await bytes.arrayBuffer()),png);
|
||||
const denied=await request('/api/admin/learning/content',101,'POST',{title:'Forbidden',body:'<img src="/api/generated-images/'+image.jobId+'">',published:true});assert.equal(denied.status,403);
|
||||
assert.equal((await db.get("SELECT COUNT(*)::int AS n FROM learning_content WHERE title='Forbidden'")).n,0);
|
||||
const job=await jobs.enqueue(101,'learning_hub',{prompt:'Attach through actual CMS'},'route-attach');await jobs.tick();
|
||||
const draft=await request('/api/admin/learning/content',101,'POST',{title:'Teaching',body:'<p>Exact body.</p><img src="/api/generated-images/'+job.jobId+'">',published:false});assert.equal(draft.status,200);const contentId=(await draft.json()).id;
|
||||
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
||||
assert.equal((await request('/api/generated-images/'+job.jobId,103)).status,200);
|
||||
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:true})).status,200);
|
||||
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,200);
|
||||
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:false})).status,200);
|
||||
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
||||
// Concurrent unpublish holds the row while a body-only request reaches its lock.
|
||||
await db.query('UPDATE learning_content SET published=true WHERE id=$1',[contentId]);
|
||||
const unpublish=await pool.connect();
|
||||
try {
|
||||
await unpublish.query('BEGIN');await unpublish.query('UPDATE learning_content SET published=false WHERE id=$1',[contentId]);
|
||||
let lockReached;const atLock=new Promise(r=>{lockReached=r;});lockNotice=lockReached;
|
||||
const update=request('/api/admin/learning/content/'+contentId,103,'PUT',{body:'<p>Concurrent body [3].</p><img src="/api/generated-images/'+job.jobId+'">'});
|
||||
await atLock;await unpublish.query('COMMIT');assert.equal((await update).status,200);
|
||||
assert.equal((await db.get('SELECT published FROM learning_content WHERE id=$1',[contentId])).published,false,'body edit must not restore stale publication');
|
||||
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
|
||||
assert.equal((await db.get('SELECT COUNT(*)::int AS n FROM generated_image_links WHERE content_id=$1',[contentId])).n,1);
|
||||
} finally { await unpublish.query('ROLLBACK');unpublish.release(); }
|
||||
const markdown='---\nmarp: true\n---\n# Original slide [3]\nDose 5 mg, page 19 [3].\n---\n# Image\n\n';
|
||||
const presentation=await request('/api/admin/learning/content',101,'POST',{title:'Presentation',content_type:'presentation',body:markdown,published:false});
|
||||
assert.equal(presentation.status,200);const presentationId=(await presentation.json()).id;
|
||||
const reopenedPresentation=await request('/api/admin/learning/content/'+presentationId,103);
|
||||
assert.equal(reopenedPresentation.status,200);assert.equal((await reopenedPresentation.json()).content.body,markdown);
|
||||
const pptx=await request('/api/admin/learning/generate-pptx',103,'POST',{markdown,title:'Synthetic presentation'});
|
||||
assert.equal(pptx.status,200,await pptx.clone().text());
|
||||
const zip=await require('jszip').loadAsync(Buffer.from(await pptx.arrayBuffer()));
|
||||
const media=Object.keys(zip.files).filter(f=>/^ppt\/media\/.+\.png$/.test(f));assert.equal(media.length,1);
|
||||
assert.deepEqual(await zip.files[media[0]].async('nodebuffer'),png);
|
||||
assert.match(await zip.files['ppt/slides/slide1.xml'].async('string'),/Original slide \[3\]/);
|
||||
assert.equal((await request('/api/admin/learning/content',102,'POST',{title:'No permission'})).status,403);
|
||||
assert.equal((await request('/api/admin/image-settings/learning_hub',103,'PUT',{model:'synthetic',budget:32000})).status,403);
|
||||
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic-own-learning',budget:1500})).status,200);
|
||||
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic',budget:32001})).status,400);
|
||||
const listing=await (await request('/api/image-jobs/clinical_assistant',101)).text();assert.ok(!listing.includes('prompt_cipher'));assert.ok(!listing.includes('staged_bytes'));
|
||||
const body = ' Exact [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
|
||||
const payload = { lastAnswer: body, messages: [{ role: 'assistant', content: body, sources: [{ number: 3, page: 19 }], imageJobs: [{ jobId: image.jobId, status: 'forged', imageUrl: 'https://invalid.test' }] }], generatedImage: '/api/generated-images/' + image.jobId };
|
||||
const saved = await request('/api/clinical-assistant/chats', 101, 'POST', payload); assert.equal(saved.status, 200); const savedId = (await saved.json()).id;
|
||||
const reopened = await (await request('/api/clinical-assistant/chats/' + savedId, 101)).json();
|
||||
assert.equal(reopened.chat.payload.lastAnswer, body); assert.equal(reopened.chat.payload.messages[0].content, body);
|
||||
assert.deepEqual(reopened.chat.payload.messages[0].sources, [{ number: 3, page: 19 }]);
|
||||
assert.deepEqual(reopened.chat.payload.messages[0].imageJobs, [{ jobId: image.jobId }]);
|
||||
assert.equal(reopened.chat.payload.generatedImage, payload.generatedImage);
|
||||
assert.match((await db.get('SELECT payload FROM clinical_assistant_chats WHERE id=$1', [savedId])).payload, /^enc1:/);
|
||||
assert.equal((await request('/api/clinical-assistant/chats/' + savedId, 102)).status, 404);
|
||||
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', payload)).status, 403);
|
||||
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', { messages: [], lastAnswer: '' })).status, 403);
|
||||
assert.equal((await request('/api/clinical-assistant/chats', 101, 'POST', { messages: [], generatedImageJobs: [{ jobId: 'bad' }] })).status, 400);
|
||||
} finally { await new Promise(r=>server.close(r)); }
|
||||
});
|
||||
test('both workflows enforce exact 32000 UTF16 assembly and durable snapshots cannot change with admin settings', async () => {
|
||||
const jobs = service(); const before = paid;
|
||||
for (const workflow of ['clinical_assistant', 'learning_hub']) {
|
||||
await db.query("INSERT INTO app_settings(key,value) VALUES($1,'32000') ON CONFLICT(key) DO UPDATE SET value='32000'", [workflow + '.image_budget']);
|
||||
const base = await jobs.snapshot(workflow, { prompt: 'x', layout: 'square' });
|
||||
const units = 32001 - base.rendered.length;
|
||||
const prompt = '😀'.repeat(Math.floor(units / 2)) + 'x'.repeat(units % 2);
|
||||
assert.equal((await jobs.snapshot(workflow, { prompt, layout: 'square' })).rendered.length, 32000);
|
||||
await assert.rejects(jobs.enqueue(101, workflow, { prompt: prompt + 'x', layout: 'square' }, 'over-32000'), e => e.statusCode === 413);
|
||||
const job = await jobs.enqueue(101, workflow, { prompt, layout: 'square' }, 'exact-32000');
|
||||
const original = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
|
||||
assert.equal(original.prompt_units, 32000); assert.match(original.prompt_cipher, /^enc1:/);
|
||||
for (const [field, value] of [['owner_id', 102], ['model', 'replacement'], ['budget', 1000], ['prompt_cipher', 'enc1:replacement'], ['prompt_revision', original.prompt_revision + 1], ['context_total', 9], ['context_included', 1]]) {
|
||||
await assert.rejects(db.query(`UPDATE generated_image_jobs SET ${field}=$2 WHERE id=$1`, [job.jobId, value]), /immutable/);
|
||||
}
|
||||
await db.query("UPDATE app_settings SET value='1000' WHERE key=$1", [workflow + '.image_budget']);
|
||||
let sent;
|
||||
await createImageService({ db, storage, generate: async (snapshot, text) => { sent = { snapshot, text }; paid++; return inspect(png); } }).tick();
|
||||
assert.equal(sent.snapshot.id, job.jobId); assert.equal(sent.snapshot.budget, 32000); assert.equal(sent.snapshot.model, original.model);
|
||||
assert.equal(sent.text, require('../src/utils/crypto').decryptString(original.prompt_cipher));
|
||||
}
|
||||
assert.equal(paid, before + 2);
|
||||
});
|
||||
test('concurrent tool replay, owner/workflow identity and preflight failures never create duplicate paid jobs', async () => {
|
||||
const jobs = service(); const before = paid; const tool = require('../src/utils/imageTool');
|
||||
const opts = { owner: 101, workflow: 'clinical_assistant', body: { idempotencyKey: 'concurrent-tool' }, imageContext:{request:'Concurrent original request',history:[]}, images: jobs };
|
||||
const result = prompt => ({ content: 'Exact body [3].', toolCalls: [{ id: 'one', type: 'function', function: { name: 'generate_image', arguments: JSON.stringify({ prompt }) } }] });
|
||||
const [a, b] = await Promise.all([tool.dispatch(result('First replay diagram'), opts), tool.dispatch(result('Second replay diagram'), opts)]);
|
||||
assert.equal(a.imageJobs[0].jobId, b.imageJobs[0].jobId);
|
||||
const other = await jobs.enqueue(102, 'clinical_assistant', { prompt: 'Other owner' }, 'tool:concurrent-tool');
|
||||
const learning = await jobs.enqueue(101, 'learning_hub', { prompt: 'Other workflow' }, 'tool:concurrent-tool');
|
||||
assert.notEqual(other.jobId, a.imageJobs[0].jobId); assert.notEqual(learning.jobId, a.imageJobs[0].jobId);
|
||||
await jobs.tick(); await jobs.tick(); await jobs.tick(); await jobs.tick(); assert.equal(paid, before + 3);
|
||||
const noMigration = createImageService({ db: { query: async () => { throw Error('synthetic missing migration'); } }, storage, generate });
|
||||
await assert.rejects(noMigration.ready(), e => e.statusCode === 503);
|
||||
const noEncryption = createImageService({ db, storage, generate, encryption: { hasKey: () => false } });
|
||||
await assert.rejects(noEncryption.enqueue(101, 'clinical_assistant', { prompt: 'No encryption' }, 'no-key'), e => e.statusCode === 503);
|
||||
const noGateway = createImageService({ db, storage, env: {}, encryption: require('../src/utils/crypto') });
|
||||
await assert.rejects(noGateway.ready(), e => e.statusCode === 503);
|
||||
assert.equal(paid, before + 3);
|
||||
});
|
||||
test('worker stop during preflight or claim never starts a new paid request; queued work resumes safely', async () => {
|
||||
for (const pauseAt of ['preflight', 'claim']) {
|
||||
const jobs = service(); const before = paid;
|
||||
const job = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Stop before payment' }, 'stop-' + pauseAt);
|
||||
let reached, release;
|
||||
const paused = new Promise(resolve => { reached = resolve; });
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const worker = createImageService({ generate,
|
||||
storage: { ...storage, ready: async () => { await storage.ready(); if (pauseAt === 'preflight') { reached(); await gate; } } },
|
||||
db: { ...db, pool: { async connect() {
|
||||
const client = await pool.connect();
|
||||
return { release: () => client.release(), async query(...args) {
|
||||
const result = await client.query(...args);
|
||||
if (pauseAt === 'claim' && args[0] === 'COMMIT') { reached(); await gate; }
|
||||
return result;
|
||||
} };
|
||||
} } }
|
||||
});
|
||||
worker.start(); await paused;
|
||||
const stopped = worker.stop(); release(); await stopped;
|
||||
assert.equal(paid, before, pauseAt); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'pending');
|
||||
await service().tick(); assert.equal(paid, before + 1); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'done');
|
||||
}
|
||||
});
|
||||
test('exact IMAGE HTTP input binds original request and contiguous whole recent turns; snapshots, UTF16 metadata and replay remain honest', async () => {
|
||||
const http = require('node:http'); const captured = [];
|
||||
const server = http.createServer(async (req,res) => {
|
||||
const chunks=[]; for await (const c of req) chunks.push(c);
|
||||
captured.push({path:req.url,body:JSON.parse(Buffer.concat(chunks))});
|
||||
res.writeHead(200,{'Content-Type':'application/json'}); res.end(JSON.stringify({data:[{b64_json:png.toString('base64')}]}));
|
||||
});
|
||||
server.listen(0,'127.0.0.1'); await new Promise(r=>server.once('listening',r));
|
||||
const old = process.env.LITELLM_API_BASE; process.env.LITELLM_API_BASE='http://127.0.0.1:'+server.address().port+'/v1';
|
||||
try {
|
||||
await db.query("UPDATE app_settings SET value='12000' WHERE key='clinical_assistant.image_budget'");
|
||||
const jobs=createImageService({db,storage});
|
||||
const context={request:' ORIGINAL request: draw the latest corrected dose 😀 [3].\n',history:[
|
||||
{role:'user',content:'Old tiny turn must not jump a gap.'},
|
||||
{role:'assistant',content:'too large boundary '+ '😀'.repeat(7000)},
|
||||
{role:'user',content:'Recent correction '+ '😀'.repeat(1600)},
|
||||
{role:'assistant',content:' Exact table [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n'}]};
|
||||
const original=JSON.stringify(context); const input={prompt:'MODEL DESCRIPTION ONLY',layout:'portrait'};
|
||||
const tool=require('../src/utils/imageTool');
|
||||
const opts={owner:101,workflow:'clinical_assistant',body:{idempotencyKey:'exact-image-input'},imageContext:context,images:jobs};
|
||||
const ai={content:'Unchanged [3, 1].',toolCalls:[{id:'ctx',type:'function',function:{name:'generate_image',arguments:JSON.stringify(input)}}]};
|
||||
const job=(await tool.dispatch(ai,opts)).imageJobs[0];
|
||||
await jobs.tick(); assert.equal(captured.length,1);
|
||||
const sent=captured[0].body.prompt;
|
||||
assert.ok(sent.includes(context.request),'ORIGINAL request must reach IMAGE provider');
|
||||
assert.ok(sent.includes(input.prompt));
|
||||
assert.ok(sent.includes(context.history[2].content)); assert.ok(sent.includes(context.history[3].content));
|
||||
assert.ok(sent.indexOf(context.history[2].content)<sent.indexOf(context.history[3].content));
|
||||
assert.ok(!sent.includes(context.history[0].content)); assert.ok(!sent.includes(context.history[1].content));
|
||||
assert.match(sent,/image only/i); assert.match(sent,/citations, reference numbers, footnotes, bibliography, or source lists/i);
|
||||
assert.equal(captured[0].path,'/v1/images/generations'); assert.equal(captured[0].body.model,'synthetic-clinical-image');
|
||||
assert.deepEqual(job.context,{includedTurns:2,totalTurns:4,used:sent.length,limit:12000,unit:'UTF-16 code units'});
|
||||
assert.deepEqual((await jobs.get(job.jobId,101,'clinical_assistant')).context,job.context);
|
||||
const row=await db.get('SELECT * FROM generated_image_jobs WHERE id=$1',[job.jobId]);
|
||||
assert.equal(require('../src/utils/crypto').decryptString(row.prompt_cipher),sent); assert.equal(row.prompt_units,sent.length);
|
||||
assert.ok(!row.prompt_cipher.includes(context.request)); assert.equal(JSON.stringify(context),original);
|
||||
const replay=await tool.dispatch({...ai,toolCalls:[{...ai.toolCalls[0],function:{name:'generate_image',arguments:'{"prompt":"changed model wording"}'}}]},opts);
|
||||
assert.equal(replay.imageJobs[0].jobId,job.jobId);
|
||||
await assert.rejects(tool.dispatch(ai,{...opts,imageContext:{...context,history:context.history.concat({role:'user',content:'new'})}}),e=>e.statusCode===409);
|
||||
for (const workflow of ['clinical_assistant','learning_hub']) {
|
||||
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
|
||||
const base=await jobs.snapshot(workflow,{prompt:'x'});
|
||||
const text='😀'.repeat(Math.floor((32001-base.rendered.length)/2))+'x'.repeat((32001-base.rendered.length)%2);
|
||||
const exact=await jobs.enqueue(101,workflow,{prompt:text},'http-exact-'+workflow);
|
||||
await jobs.tick(); assert.equal(captured.at(-1).body.prompt.length,32000);
|
||||
assert.match(captured.at(-1).body.prompt,/image only/i); assert.equal(exact.context.totalTurns,0);
|
||||
await assert.rejects(jobs.enqueue(101,workflow,{prompt:text+'x'},'http-over-'+workflow),e=>e.statusCode===413);
|
||||
}
|
||||
assert.equal(captured.length,3,'mandatory overflow never calls IMAGE provider');
|
||||
} finally { if(old===undefined) delete process.env.LITELLM_API_BASE; else process.env.LITELLM_API_BASE=old; await new Promise(r=>server.close(r)); }
|
||||
});
|
||||
test('expired PAID lease becomes explicit unknown even while storage and gateway readiness fail', async () => {
|
||||
const jobs=service(),before=paid;
|
||||
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Crash plus outage'},'combined-outage');
|
||||
const claim=await jobs.claim();assert.equal(claim.id,job.jobId);
|
||||
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[job.jobId]);
|
||||
const down=createImageService({db,env:{},storage:{ready:async()=>{throw Error('synthetic outage');}}});
|
||||
await down.tick().catch(()=>{});
|
||||
assert.equal((await jobs.get(job.jobId,101,'clinical_assistant')).outcome,'unknown');assert.equal(paid,before);
|
||||
await jobs.tick();assert.equal(paid,before);
|
||||
});
|
||||
test('partial schema without links/asset-read relation fails readiness and queued payment against real PG', async () => {
|
||||
const jobs=service(),before=paid;
|
||||
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Do not pay with missing links'},'partial-schema');
|
||||
await db.query('ALTER TABLE generated_image_links RENAME TO unavailable_links');
|
||||
try {
|
||||
await assert.rejects(jobs.ready(),e=>e.statusCode===503);
|
||||
await assert.rejects(jobs.tick(),e=>e.statusCode===503);
|
||||
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:'blocked too'},'partial-new'),e=>e.statusCode===503);
|
||||
assert.equal(paid,before);
|
||||
} finally { await db.query('ALTER TABLE unavailable_links RENAME TO generated_image_links');await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=$1",[job.jobId]); }
|
||||
});
|
||||
test('whole-turn context exactly fills the cap or is omitted in full; mandatory original request overflow never pays', async () => {
|
||||
const jobs=service(),before=paid;
|
||||
for(const workflow of ['clinical_assistant','learning_hub']) {
|
||||
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
|
||||
const input={prompt:'Image description',layout:'square'},context={request:' Original image request 😀\n',history:[{role:'user',content:'x'}]};
|
||||
const base=await jobs.snapshot(workflow,input,context);const remaining=32001-base.rendered.length;
|
||||
context.history[0].content='😀'.repeat(Math.floor(remaining/2))+'x'.repeat(remaining%2);
|
||||
const original=JSON.stringify(context),exact=await jobs.snapshot(workflow,input,context);
|
||||
assert.equal(exact.rendered.length,32000);assert.equal(exact.included,1);assert.ok(exact.rendered.includes(context.history[0].content));
|
||||
context.history[0].content+='x';const over=await jobs.snapshot(workflow,input,context);
|
||||
assert.equal(over.included,0);assert.equal(over.total,1);assert.ok(!over.rendered.includes(context.history[0].content));
|
||||
assert.ok(over.rendered.includes(context.request));assert.ok(over.rendered.endsWith(require('../src/utils/generatedImages').IMAGE_OUTPUT_RULE));
|
||||
context.history[0].content=context.history[0].content.slice(0,-1);assert.equal(JSON.stringify(context),original);
|
||||
await assert.rejects(jobs.enqueue(101,workflow,input,'mandatory-original-overflow',false,{request:'😀'.repeat(16000),history:[]}),e=>e.statusCode===413);
|
||||
}
|
||||
assert.equal(paid,before);
|
||||
});
|
||||
|
|
@ -18,6 +18,7 @@ for (const failure of [null, 'audit', 'db', 'mcp']) {
|
|||
const dbGate = new Promise(resolve => { releaseDb = resolve; });
|
||||
const context = {
|
||||
console: { log() {}, error() {} },
|
||||
imageWorker: { async stop() { events.push('images.stop'); } },
|
||||
server: { close(callback) { events.push('http.close'); httpClosed = callback; } },
|
||||
process: { on(signal, callback) { signals[signal] = callback; }, exit(code) { events.push('exit:' + code); } },
|
||||
setTimeout(callback, ms) { deadline = callback; deadlineMs = ms; return { unref() { events.push('guard.unref'); } }; },
|
||||
|
|
@ -40,16 +41,16 @@ for (const failure of [null, 'audit', 'db', 'mcp']) {
|
|||
vm.runInNewContext(source.slice(source.indexOf('var shuttingDown = false;')), context);
|
||||
signals.SIGTERM();
|
||||
signals.SIGINT();
|
||||
assert.deepEqual(events, ['http.close', 'guard.unref']);
|
||||
assert.deepEqual(events, ['images.stop', 'http.close', 'guard.unref']);
|
||||
assert.equal(deadlineMs, 9000);
|
||||
const done = httpClosed();
|
||||
assert.deepEqual(events.slice(2), ['audit.start']);
|
||||
assert.deepEqual(events.slice(3), ['audit.start']);
|
||||
releaseAudit();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(events.slice(2), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start']);
|
||||
assert.deepEqual(events.slice(3), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start']);
|
||||
releaseDb();
|
||||
await done;
|
||||
assert.deepEqual(events.slice(2), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start', 'db.end', 'mcp.close', 'exit:0']);
|
||||
assert.deepEqual(events.slice(3), ['audit.start', 'audit.end', 'cleanup.clear', 'db.start', 'db.end', 'mcp.close', 'exit:0']);
|
||||
deadline();
|
||||
assert.equal(events.at(-1), 'exit:1');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ test('finite catalogue, actual admin gates, revision API, stale edit, cross-key
|
|||
for (const [method, url, body] of routes) for (const user of [0, 2]) assert.equal((await app.request(method, url, body, user)).status, user ? 403 : 401);
|
||||
assert.equal(svc.state.log.length, 0);
|
||||
const list = (await app.request('GET', '/config/prompts')).body.prompts;
|
||||
assert.equal(list.length, 31); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
|
||||
assert.equal(list.length, 32); assert.equal(list.filter(p => p.family === 'scribe').length, 29);
|
||||
assert.equal(list.filter(p => p.family === 'clinical-text').length, 1); assert.equal(list.filter(p => p.family === 'clinical-image').length, 1);
|
||||
for (const prompt of list) { assert.equal(prompt.revision, 0); assert.equal(prompt.editable, true); assert.ok(prompt.purpose && prompt.usedBy.length && prompt.value); }
|
||||
for (const key of ['prompt.unknown', 'prompt.loadFromDb', 'prompt.updatePrompt', 'prompt.getAllPrompts', 'prompt.getDefaultPrompt', 'prompt.__proto__', 'prompt.smtp.pass']) {
|
||||
|
|
@ -303,7 +303,7 @@ test('migration owns finite append-only schema and emits reversible SQL without
|
|||
}
|
||||
const up = await dryRun('up'); const down = await dryRun('down');
|
||||
const keys = [...up[0].matchAll(/'(prompt\.[^']+|clinical_assistant\.[^']+)'/g)].map(match => match[1]);
|
||||
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries, entry => entry.dbKey).sort());
|
||||
assert.deepEqual(keys.sort(), Array.from(services().catalog.entries.filter(entry => entry.dbKey !== 'learning_hub.image_behavior'), entry => entry.dbKey).sort());
|
||||
assert.match(up[0], /BEFORE UPDATE OR DELETE/); assert.match(up[0], /FOREIGN KEY \(prompt_key, restored_from\)/);
|
||||
assert.match(down[0], /DROP TABLE prompt_revisions/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ test('actual quiz write endpoints reject invalid types before all mutations and
|
|||
const writes = [];
|
||||
const router = load('src/routes/learningAdmin.js', {
|
||||
express, '../middleware/auth': authStub, '../utils/embeddings': {},
|
||||
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
|
||||
'../db/database': {
|
||||
async get(sql) {
|
||||
return sql.includes('MAX(') ? { mx: 0 } : { id: 9, question_text: 'Question', question_type: storedType };
|
||||
|
|
|
|||
Loading…
Reference in a new issue