// 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 save; let status; 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 heading = document.createElement('h4'); heading.textContent = 'Image models'; 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(heading, 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); }); save = document.createElement('button'); save.type = 'submit'; save.className = 'btn-sm btn-primary'; save.textContent = 'Save image settings'; status = document.createElement('p'); status.setAttribute('role', 'status'); form.append(save, status); form.onsubmit = async e => { e.preventDefault(); if (save.disabled) return; save.disabled = true; try { 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) }); } status.textContent = 'Saved. New jobs use these settings; existing jobs are unchanged.'; } catch (error) { status.textContent = error.message + ' Nothing was saved.'; } finally { save.disabled = false; } }; root.append(form); loaded = true; } catch (_) { /* Next tab entry retries; no drafts are touched. */ } finally { loading = false; } } 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(); }); }