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
153 lines
6.2 KiB
JavaScript
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();
|
|
});
|
|
}
|