// 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(); }); }