diff --git a/frontend/src/components/ModelsAdmin.css b/frontend/src/components/ModelsAdmin.css index 0692cf1..2fe96d9 100644 --- a/frontend/src/components/ModelsAdmin.css +++ b/frontend/src/components/ModelsAdmin.css @@ -113,3 +113,21 @@ } @media (min-width: 700px) { .mdl-embed-row input, .mdl-embed-row select { font-size: 0.84rem; } } .mdl-embed-note { margin: 10px 0 0; font-size: 0.78rem; color: var(--text-muted); } + +/* Typing an id the proxy does not list. */ +.mdl-add { display: flex; gap: 8px; margin: 10px 0; } +.mdl-add input { + flex: 1; min-width: 0; padding: 6px 10px; font: inherit; font-size: .85rem; + color: var(--text); background: var(--input-bg); + border: 1px solid var(--border); border-radius: 6px; +} + +/* Off, not gone: the row stays, dimmed, with its settings intact. */ +.mdl-current li.is-off code { opacity: .55; text-decoration: line-through; } +.mdl-badge.is-off { background: var(--border); color: var(--text-muted); } +.mdl-toggle { + padding: 3px 10px; font-size: .78rem; font-weight: 600; cursor: pointer; + color: var(--text-muted); background: none; + border: 1px solid var(--border); border-radius: 999px; +} +.mdl-toggle:hover { color: var(--primary); border-color: var(--primary); } diff --git a/frontend/src/components/ModelsAdmin.jsx b/frontend/src/components/ModelsAdmin.jsx index e594ae9..4fcd6a8 100644 --- a/frontend/src/components/ModelsAdmin.jsx +++ b/frontend/src/components/ModelsAdmin.jsx @@ -56,6 +56,7 @@ export default function ModelsAdmin() { const [finding, setFinding] = useState(false) const [findError, setFindError] = useState('') const [filter, setFilter] = useState('') + const [typed, setTyped] = useState('') const [allowFor, setAllowFor] = useState('teach') // Embeddings: one model for the whole site. @@ -112,6 +113,28 @@ export default function ModelsAdmin() { finally { setFinding(false) } } + //: Off rather than gone. The row keeps its task, its default flag and any + //: key on it, so turning a model back on is one press rather than finding + //: it in the proxy list again and setting it up from scratch. + const setActive = (model, on) => act(`active:${model.id}`, + async () => { + const res = await api.put(`/admin/models/${model.id}`, { is_active: on }) + setModels(prev => prev.map(row => (row.id === model.id ? res.data : row))) + }, + on ? 'Could not turn that model on' : 'Could not turn that model off', + `${model.model_id} is ${on ? 'on' : 'off'} for ${JOBS.find(j => j.key === allowFor)?.label}.`) + + //: A model id the proxy does not list. Discovery reads /model/info, and a + //: gateway can route a model it does not advertise there — a new alias, a + //: provider added by hand — so the roster must not be limited to what the + //: list happens to say. + const allowTyped = () => { + const id = typed.trim() + if (!id) return + setTyped('') + allow(id) + } + const allow = (modelId) => act(`allow:${modelId}`, async () => { const res = await api.post('/admin/models', @@ -289,9 +312,19 @@ export default function ModelsAdmin() { + {/* Typed, for anything the list does not carry. */} +
+ setTyped(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); allowTyped() } }} /> + +
+ {offered.length > 0 && ( <> { expect(api.get).not.toHaveBeenCalled() }) }) + +it('turns a model off without losing it, and allows an id the proxy never listed', async () => { + // Two things ped-ai's page has and this one did not. Taking a model out + // while the proxy misbehaves used to mean removing it and setting it up + // again afterwards, losing the default flag and any key on it. And the + // roster could only ever hold what /model/info happened to advertise, + // though the gateway will route aliases it does not list. + api.get.mockImplementation(url => Promise.resolve({ + data: url === '/admin/models' + ? [{ id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction', + is_active: true, is_default: true }] + : {}, + })) + api.put.mockResolvedValue({ data: { + id: 1, name: 'ds-flash', model_id: 'ds-flash', task: 'extraction', + is_active: false, is_default: true } }) + api.post.mockResolvedValue({ data: { + id: 2, name: 'brand-new-alias', model_id: 'brand-new-alias', + task: 'extraction', is_active: true, is_default: false } }) + + render() + // The roster is behind its own disclosure. + await userEvent.click(await screen.findByRole('button', { name: /Which models this site may use/ })) + // The roster shows one job at a time; ds-flash is an extraction model. + await userEvent.selectOptions( + await screen.findByLabelText('Job to allow a model for'), 'extraction') + await userEvent.click(await screen.findByRole('button', { name: /Turn off ds-flash/ })) + expect(api.put).toHaveBeenCalledWith('/admin/models/1', { is_active: false }) + // Still listed, still the default — off, not gone. + expect(await screen.findByText('off')).toBeInTheDocument() + expect(screen.getByText('in use')).toBeInTheDocument() + + await userEvent.type( + screen.getByLabelText('A model id the proxy does not list'), 'brand-new-alias') + await userEvent.click(screen.getByRole('button', { name: 'Allow it' })) + expect(api.post).toHaveBeenCalledWith('/admin/models', expect.objectContaining({ + model_id: 'brand-new-alias', task: 'extraction', is_active: true, + })) +})