pediatric-ai-scribe-v3/public/js/admin/imageSettings.js
Daniel 9df2230479 refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five
cards with a search box each, one Save writing eight keys from the
bottom of a card that also held a second Save for something else, and a
banner apologising that a button "applies only the settings above it".

Now it reads in groups — Accounts, Models, Assistant & prompts, Site —
and every card is a <details> that folds, so Save & Close means
something. The rule is that each card saves exactly what it shows,
which is what removed the need for the banner.

Models is one workflow in three steps. Discover & test has a single
search box and a kind switch (chat / image / speech / transcription /
embedding); the five discovery calls are unchanged, the switch only
decides which one answers. Roster is what has been added, including the
image roster, which had no visible list before. Availability is the
chat model, the two allowed lists, the per-workflow image settings and
the slide reviewer, under one Save.

Splitting the eight-key save follows from that rule: Save & Close writes
the five retrieval and citation keys; Save availability writes the chat
model, both allowed lists, the reviewer and the three image-settings
PUTs. No route, request shape or setting key changed.

Switching kind clears the results first — a row button would otherwise
add an image model to the chat roster.

The kind switch dispatches its event through document.defaultView's
CustomEvent. jsdom refuses one built from another realm, and the
existing announceModelsChanged() has exactly that bug: its event is
built from the Node global, dispatchEvent refuses it, and a try/catch
swallows the error — so models-changed propagation has only ever been
source-grepped, never actually tested. Left alone here to keep this
change to one subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:42:27 +02:00

153 lines
6.2 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: 'learning_hub', label: 'Learning Hub' },
{ 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
// Learning Hub and the Assistant name a primary here.
const primary = makeSelect([saved.model], discovered);
primary.disabled = workflow.key === 'my_resources';
group.appendChild(row(workflow.key === 'my_resources' ? 'Model (set per request)' : '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();
});
}