"The model is chosen per request, where the deck is generated" described something that happens nowhere. There is no per-request picker, and asking where it was is what exposed the real bug. My Resources read clinical_assistant.image_model while the admin screen saved my_resources.image_model. That setting was stored, returned by the API and rendered into the form — and never used by anything. Somebody noticed the field did nothing and disabled it rather than finding out why, which left a control that could not be changed and a note explaining a mechanism that does not exist. My note repeating it was wrong too. The generator now reads its own setting and falls back to the Assistant's, so an install that only ever set one model keeps working untouched, and the field is enabled again with "leave blank to use the Clinical Assistant's" — which is now true rather than a rationalisation. Saving also says what it saved. "Saved 8:31:59 PM. Decks will be reviewed by ..." answered a different question from the one an admin actually has, which is whether the model they just picked is the one that will draw. It now names each workflow's model and fallbacks back, and spells out the blank case rather than leaving a gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
169 lines
7.1 KiB
JavaScript
169 lines
7.1 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
|
|
// Both workflows name a model. My Resources' was saved and ignored for a
|
|
// while — the generator read the Assistant's setting instead — so the
|
|
// field was disabled and labelled "set per request", describing something
|
|
// that happens nowhere. It works now, and blank means "use the Clinical
|
|
// Assistant's", which is what every install did before it worked.
|
|
const primary = makeSelect([saved.model], discovered);
|
|
group.appendChild(row('Model', primary));
|
|
if (workflow.key === 'my_resources') {
|
|
const note = document.createElement('p');
|
|
note.style.cssText = 'margin:2px 0 10px;font-size:12px;color:var(--g500);';
|
|
note.textContent = 'Leave blank to use the Clinical Assistant\'s image model above.';
|
|
group.appendChild(note);
|
|
}
|
|
|
|
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.');
|
|
// What was written, for the card's confirmation. "Saved" alone does not
|
|
// answer the question an admin actually has, which is whether the model they
|
|
// just picked is the one that will be used.
|
|
const written = [];
|
|
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)
|
|
};
|
|
// Every workflow sends its model. My Resources' used to be withheld,
|
|
// which is why its field could never be set to anything.
|
|
body.model = control.primary.value;
|
|
await imageJson('/api/admin/image-settings/' + workflow.key,
|
|
{ method: 'PUT', body: JSON.stringify(body) });
|
|
written.push(workflow.label + ': ' +
|
|
(body.model || 'the Clinical Assistant\'s model') +
|
|
(body.fallbacks.length ? ', then ' + body.fallbacks.join(', ') : ', no fallback'));
|
|
}
|
|
return written;
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|