pediatric-ai-scribe-v3/public/js/admin/imageSettings.js
Daniel c14fa25c3f
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 20s
fix: the image settings card can save again; Learning Hub is out of the admin
"Not all of it was saved: Workflow not found" on every press of Save
availability. The card sent image settings for three workflows, and the
server has only had two since Learning Hub was removed — the DB
constraint allows clinical_assistant and my_resources and nothing else.
One rejection failed the whole save, so the two settings that were valid
looked unsaved as well.

The frontend was the only place that still believed in it. Also gone:
the learning_hub.image_behavior prompt, its Learning prompts section in
the admin — which held that one prompt and nothing else — and the
theme's card tints, which never applied.

While there: My Resources had a Model dropdown labelled "set per
request", permanently disabled and permanently empty. A control that can
never do anything reads as broken rather than as not applicable, so it
is now a sentence saying where the model is actually chosen. Its
fallbacks stay — those apply to whichever model the request picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:39:15 +02:00

164 lines
6.8 KiB
JavaScript

// One image model per workflow, plus the models to try when it fails.
//
// A single model means a refusal, a rate limit or a model the gateway has since
// dropped ends as a missing picture. The chain is primary first, then each
// fallback in order, and it stops at the first one that produces an image —
// every hop being a paid request is why it is capped rather than open-ended.
import { imageJson } from '../generatedImages.js';
const WORKFLOWS = [
{ key: 'clinical_assistant', label: 'Clinical Assistant' },
{ key: 'my_resources', label: 'My Resources' }
];
let loading = false;
let loaded = false;
let controls = {};
let maxModels = 3;
async function load() {
const root = document.getElementById('workflow-image-settings');
if (root && !root.children.length) loaded = false; // fresh DOM (test/re-import) re-renders
if (!root || loaded || loading) return;
loading = true;
try {
const [settings, models] = await Promise.all([
imageJson('/api/admin/image-settings'),
imageJson('/api/admin/config/image-models/discover').catch(() => ({ success: false }))
]);
maxModels = Number(settings.maxModels) || 3;
const discovered = Array.isArray(models.models) ? models.models : [];
root.textContent = '';
const form = document.createElement('form');
const note = document.createElement('p');
note.style.cssText = 'margin:2px 0 10px;font-size:12px;color:var(--g500);';
note.textContent = 'Each workflow tries its model first, then its fallbacks in order, ' +
'stopping at the first that produces an image. A fallback is only tried when another ' +
'model has a real chance — a refusal, a rate limit, a provider fault, or a model the ' +
'gateway does not have. Bad credentials and a malformed request stop immediately.';
form.append(note);
controls = {};
WORKFLOWS.forEach(workflow => {
const saved = settings.workflows[workflow.key] || {};
const group = document.createElement('fieldset');
group.style.cssText = 'border:1px solid var(--g200);border-radius:8px;padding:12px;margin:0 0 12px;';
const legend = document.createElement('legend');
legend.style.cssText = 'font-size:12px;font-weight:600;color:var(--g700);padding:0 6px;';
legend.textContent = workflow.label;
group.appendChild(legend);
// My Resources picks its model per request from the roster, so only the
// Only the Assistant names a primary here. My Resources takes its model
// from the request that asks for the picture, so a disabled, permanently
// empty dropdown labelled "set per request" was a control that could
// never do anything — it read as broken rather than as not applicable.
// Its fallbacks still belong here: those are what runs when the chosen
// model fails, whoever chose it.
const primary = makeSelect([saved.model], discovered);
if (workflow.key === 'my_resources') {
const note = document.createElement('p');
note.style.cssText = 'margin:2px 0 8px;font-size:12px;color:var(--g500);';
note.textContent = 'The model is chosen per request, where the deck is generated. ' +
'The fallbacks below apply to whichever model that request picked.';
group.appendChild(note);
} else {
group.appendChild(row('Model', primary));
}
const fallbacks = [];
for (let i = 0; i < maxModels - 1; i++) {
const select = makeSelect([(saved.fallbacks || [])[i] || ''], discovered, true);
fallbacks.push(select);
group.appendChild(row('Fallback ' + (i + 1), select));
}
const budget = makeBudget(saved.budget);
group.appendChild(row('Image input budget', budget));
controls[workflow.key] = { primary, fallbacks, budget };
form.appendChild(group);
});
// No Save of its own: this form sits inside the Availability card, whose
// one Save button saves it along with the rest. Enter in a field goes
// through that button too, so there is exactly one way to save the card.
form.onsubmit = e => {
e.preventDefault();
const save = document.getElementById('btn-save-availability');
if (save && !save.disabled) save.click();
};
root.append(form);
loaded = true;
} catch (_) { /* Next tab entry retries; no drafts are touched. */ }
finally { loading = false; }
}
// Called by the Availability card's Save. Rejects rather than quietly skipping
// when the form never rendered, so the card does not report "Saved" for
// settings that were not sent.
export async function saveImageSettings() {
if (!loaded) throw new Error('Image settings were not loaded, so they were not saved.');
for (const workflow of WORKFLOWS) {
const control = controls[workflow.key];
const body = {
budget: Number(control.budget.value),
fallbacks: control.fallbacks.map(s => s.value).filter(Boolean)
};
// Only the workflows that own a primary send one; My Resources would
// be overwriting a per-request choice with a form field.
if (workflow.key !== 'my_resources') body.model = control.primary.value;
await imageJson('/api/admin/image-settings/' + workflow.key,
{ method: 'PUT', body: JSON.stringify(body) });
}
}
function row(labelText, control) {
const wrap = document.createElement('div');
wrap.className = 'admin-row';
const label = document.createElement('label');
label.className = 'admin-row-label';
label.textContent = labelText;
wrap.append(label, control);
return wrap;
}
function makeSelect(knownIds, models, optional) {
const select = document.createElement('select');
select.className = 'admin-control';
if (optional) {
const none = document.createElement('option');
none.value = ''; none.textContent = 'None';
select.appendChild(none);
}
const ids = new Set([...(Array.isArray(knownIds) ? knownIds : []).filter(Boolean),
...((Array.isArray(models) ? models : []).map(m => m && m.id).filter(Boolean))]);
[...ids].sort().forEach(id => {
const option = document.createElement('option');
option.value = id; option.textContent = id;
select.appendChild(option);
});
select.value = knownIds[0] || '';
return select;
}
function makeBudget(value) {
const input = document.createElement('input');
input.className = 'admin-control';
input.type = 'number'; input.min = '1000'; input.max = '32000'; input.required = true;
input.value = value;
return input;
}
export function initImageSettings() {
document.addEventListener('tabChanged', e => { if (e.detail?.tab === 'admin') load(); });
// A model added in the Models card has to show up in these dropdowns without
// a page reload. `loaded` exists so a tab revisit does not refetch; a roster
// change is the one case where refetching is the point.
document.addEventListener('models-changed', () => {
if (loading) return;
loaded = false;
load();
});
}