Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model one, in the same card. Every other picker had been filled when the admin tab loaded, behind a guard that makes its loader run once per visit, so the Clinical Assistant chat model, the allowed-models list, the slide reviewer and the image-model selects all kept the roster they were given. The model was genuinely added; it simply could not be selected until the page was reloaded, which reads as the add having failed. Every mutation of the roster — add, remove, clear-all, enable/disable — now dispatches `models-changed`, and the cards that list models listen and refetch. The event carries no payload: a listener re-reads the list itself, so there is one source of truth rather than a copy to keep in step. Same pattern as the existing `assistant-image-roster` event. Each listener clears its own guard before re-running, and returns early if a load is already in flight. The assistant loader keeps unsaved drafts, so re-running it costs nothing but a refreshed set of options. The add toast said "now select it as default and click Set Default" — advice that only made sense when the default dropdown was the one thing that updated. Verified against a mutation: removing the guard reset fails the propagation test, because the listener then fires into a loader that returns early. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
156 lines
6.3 KiB
JavaScript
156 lines
6.3 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 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();
|
|
});
|
|
}
|