From c59f9f936b49bfed4a1987bff4a20da3d6910c28 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 11 Sep 2026 20:39:38 +0200 Subject: [PATCH] refactor(settings): People and models rebuilt natively; AdminPage is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boxing was structural, not cosmetic. AdminSection wrapped AdminPage in a Settings panel while AdminPage brought its own .card wrappers — a box inside a box — and SettingsPage.css patched the symptom with .set-admin > div > .card { border: 0 }. Both sections are written as Settings sections now, the patch is deleted, and AdminPage with it. People: no more refetch-everything. The old page called loadData() after every change, pulling users, models and settings together and re-rendering the section, which is what read as a page reload — there was never a location.reload() to find. A change now updates the one row it touched from what the server returned. The list searches, scrolls in a bounded box, and will not let you change or delete your own account. Models: the selection is rebuilt rather than restyled. Seven cards of rows, each with Default, Test, Disable and Remove, meant four buttons to express one fact. There are two decisions, so there are two places now — which models the site may use at all is an allow-list you set once and fold away, and which one does a given job is a single choice made where the job is named. A job with one model offers no choice, because there is none to make. The embedding model moved in with the models, since it is one, and Search — which held nothing else — is retired. Settings fills the window: the panel column scrolls inside itself, so a short section no longer ends two thirds of the way down a tall screen and a long one no longer runs off the bottom. Below 700px the page scrolls as one, as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- frontend/src/components/ModelsAdmin.css | 115 +++ frontend/src/components/ModelsAdmin.jsx | 357 +++++++++ frontend/src/components/ModelsAdmin.test.jsx | 109 +++ frontend/src/components/PeopleAdmin.css | 67 ++ frontend/src/components/PeopleAdmin.jsx | 215 ++++++ frontend/src/components/PeopleAdmin.test.jsx | 100 +++ frontend/src/pages/AdminPage.jsx | 715 ------------------- frontend/src/pages/SettingsPage.css | 42 +- frontend/src/pages/SettingsPage.jsx | 43 +- frontend/src/pages/SettingsPage.test.jsx | 4 +- 10 files changed, 1001 insertions(+), 766 deletions(-) create mode 100644 frontend/src/components/ModelsAdmin.css create mode 100644 frontend/src/components/ModelsAdmin.jsx create mode 100644 frontend/src/components/ModelsAdmin.test.jsx create mode 100644 frontend/src/components/PeopleAdmin.css create mode 100644 frontend/src/components/PeopleAdmin.jsx create mode 100644 frontend/src/components/PeopleAdmin.test.jsx delete mode 100644 frontend/src/pages/AdminPage.jsx diff --git a/frontend/src/components/ModelsAdmin.css b/frontend/src/components/ModelsAdmin.css new file mode 100644 index 0000000..0692cf1 --- /dev/null +++ b/frontend/src/components/ModelsAdmin.css @@ -0,0 +1,115 @@ +.mdl { display: flex; flex-direction: column; gap: 16px; } +.mdl-error { margin: 0; font-size: 0.85rem; color: var(--wrong-fg); } +.mdl-notice { margin: 0; font-size: 0.85rem; color: var(--right-fg, #15803d); } +.mdl-empty { padding: 12px 4px; font-size: 0.84rem; color: var(--text-muted); } + +/* ── One job, one choice ──────────────────────────────────────────── */ +.mdl-jobs { list-style: none; margin: 0; padding: 0; border: 1px solid var(--border); border-radius: 10px; } +.mdl-jobs > li { + display: flex; align-items: center; justify-content: space-between; + gap: 14px; flex-wrap: wrap; + padding: 12px 14px; border-bottom: 1px solid var(--border); +} +.mdl-jobs > li:last-child { border-bottom: 0; } +.mdl-job { min-width: 0; } +.mdl-job strong { display: block; font-size: 0.92rem; font-weight: 650; } +.mdl-job small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); } + +.mdl-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.mdl-pick select { + min-width: 200px; max-width: 100%; padding: 6px 10px; + font: inherit; font-size: 0.84rem; + border: 1px solid var(--border); border-radius: 7px; + background: var(--input-bg); color: var(--text); +} +.mdl-pick code { + font-size: 0.82rem; padding: 4px 9px; + background: var(--bg); border-radius: 6px; color: var(--text); +} +.mdl-none { font-size: 0.81rem; color: var(--text-muted); font-style: italic; } +.mdl-test, .mdl-remove { + padding: 5px 11px; font: inherit; font-size: 0.78rem; font-weight: 600; + cursor: pointer; border-radius: 999px; + border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); +} +.mdl-test:disabled, .mdl-remove:disabled { opacity: 0.5; cursor: default; } +.mdl-remove { color: var(--wrong-fg); border-color: var(--wrong-bd); } +.mdl-remove:hover:not(:disabled) { background: var(--wrong-bg); } +.mdl-result { flex-basis: 100%; margin: 2px 0 0; font-size: 0.79rem; } +.mdl-result.is-ok { color: var(--right-fg, #15803d); } +.mdl-result.is-bad { color: var(--wrong-fg); } + +/* ── The allow-list, folded away ──────────────────────────────────── */ +.mdl-allow { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; } +.mdl-allow-head { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + width: 100%; padding: 12px 14px; + font: inherit; text-align: left; cursor: pointer; + background: var(--card-bg); border: 0; color: var(--text); +} +.mdl-allow-head:hover { background: var(--bg); } +.mdl-allow-head strong { display: block; font-size: 0.9rem; font-weight: 650; } +.mdl-allow-head small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); } +.mdl-allow-body { padding: 4px 14px 16px; border-top: 1px solid var(--border); } +.mdl-allow-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0; } +.mdl-allow-bar label { display: inline-flex; align-items: center; gap: 7px; font-size: 0.83rem; } +.mdl-allow-bar select { + padding: 5px 9px; font: inherit; font-size: 0.83rem; + border: 1px solid var(--border); border-radius: 7px; + background: var(--input-bg); color: var(--text); +} +.mdl-allow-body input[type="search"] { + width: 100%; margin: 12px 0 8px; padding: 8px 11px; + /* 16px on touch: iOS zooms in on anything smaller and never zooms back. */ + font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +@media (min-width: 700px) { .mdl-allow-body input[type="search"] { font-size: 0.86rem; } } + +.mdl-current, .mdl-offered { list-style: none; margin: 0; padding: 0; } +.mdl-current > li, .mdl-offered > li { + display: flex; align-items: center; gap: 9px; + padding: 7px 0; border-bottom: 1px solid var(--border); +} +.mdl-current > li:last-child, .mdl-offered > li:last-child { border-bottom: 0; } +.mdl-current code, .mdl-offered code { + flex: 1; min-width: 0; font-size: 0.8rem; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* The proxy answers with hundreds; this one is bounded. */ +.mdl-offered { max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 2px 11px; } +.mdl-offered button { + padding: 4px 11px; font: inherit; font-size: 0.77rem; font-weight: 600; + cursor: pointer; border-radius: 999px; + border: 1px solid var(--primary); background: none; color: var(--primary); +} +.mdl-offered button:disabled { opacity: 0.5; cursor: default; } +.mdl-have { font-size: 0.75rem; color: var(--text-subtle); } +.mdl-badge { + font-size: 0.66rem; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; + padding: 2px 7px; border-radius: 999px; + color: var(--primary); background: var(--option-sel-bg); +} + +@media (max-width: 560px) { + .mdl-jobs > li { align-items: flex-start; } + .mdl-pick { width: 100%; } + .mdl-pick select { flex: 1; min-width: 0; } +} + +/* ── Semantic search ──────────────────────────────────────────────── */ +.mdl-embed { + padding: 13px 14px; + border: 1px solid var(--border); border-radius: 10px; +} +.mdl-embed-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 10px; } +.mdl-embed-row input, .mdl-embed-row select { + flex: 1; min-width: 180px; padding: 7px 10px; + /* 16px on touch: iOS zooms in on anything smaller and never zooms back. */ + font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: 7px; + background: var(--input-bg); color: var(--text); +} +@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); } diff --git a/frontend/src/components/ModelsAdmin.jsx b/frontend/src/components/ModelsAdmin.jsx new file mode 100644 index 0000000..badc3c8 --- /dev/null +++ b/frontend/src/components/ModelsAdmin.jsx @@ -0,0 +1,357 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import api from '../api/client' +import { useDialog } from '../hooks/useDialog' +import Dialog from './Dialog' +import './ModelsAdmin.css' + +//: The jobs, named by what they do rather than by their field name. +const JOBS = [ + { key: 'teach', label: 'Tutor', hint: 'The assistant a learner talks to.' }, + { key: 'extraction', label: 'Extraction', hint: 'Pulling questions out of a PDF.' }, + { key: 'article', label: 'Articles', hint: 'Drafting and refining reading.' }, + { key: 'flashcard', label: 'Cards', hint: 'Generating flashcard decks.' }, + { key: 'keyword', label: 'Classification', hint: 'Suggesting subjects and tags.' }, + { key: 'tts', label: 'Read aloud', hint: 'Turning a question into speech.' }, + { key: 'stt', label: 'Transcription', hint: 'Turning speech into text.' }, +] + +//: Semantic search is a job like the others, but its model is a site setting +//: rather than a row — one vector space, not a choice per request. It sits +//: here because it is a model, and a section holding one field is not a place. +const EMBEDDING = { key: 'embedding', label: 'Semantic search', hint: 'The vectors behind search and recommendations.' } + +const detail = (err, fallback) => { + const value = err?.response?.data?.detail + return typeof value === 'string' ? value : fallback +} + +const truthy = value => value === true || value === 1 + +/** + * Which model does each job. + * + * The old screen made you think in rows: seven cards, each holding model + * records with Default, Test, Disable and Remove, so choosing the tutor's + * model meant finding its card, finding the row, and pressing Set Default on + * it — four buttons per row to express one fact. + * + * There are two decisions here, and they are now two places. Which models this + * site may use at all is an allow-list you set once. Which one does a + * particular job is a single choice, made where the job is named. A job with + * only one model offers no choice at all, because there is none to make. + */ +export default function ModelsAdmin() { + const { dialogProps, openConfirm } = useDialog() + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busy, setBusy] = useState(null) + const [results, setResults] = useState({}) + const [managing, setManaging] = useState(false) + + // The proxy's catalogue, asked for only when the allow-list is opened. + const [offered, setOffered] = useState([]) + const [finding, setFinding] = useState(false) + const [findError, setFindError] = useState('') + const [filter, setFilter] = useState('') + const [allowFor, setAllowFor] = useState('teach') + + // Embeddings: one model for the whole site. + const [embedding, setEmbedding] = useState('') + const [embeddingSaved, setEmbeddingSaved] = useState('') + const [embedOffered, setEmbedOffered] = useState([]) + const [embedBusy, setEmbedBusy] = useState('') + const [embedResult, setEmbedResult] = useState(null) + + const load = useCallback(() => { + Promise.all([api.get('/admin/models'), api.get('/admin/settings')]) + .then(([list, settings]) => { + setModels(list.data || []) + const current = settings.data?.embedding_model || '' + setEmbedding(current) + setEmbeddingSaved(current) + }) + .catch(() => setError('Could not load the models')) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { load() }, [load]) + + const act = async (key, run, failure, message) => { + setBusy(key); setError(''); setNotice('') + try { await run(); if (message) setNotice(message) } + catch (err) { setError(detail(err, failure)) } + finally { setBusy(null) } + } + + /** Assignment: exactly one model does a job, so the rest of that job lose it. */ + const assign = (job, modelId) => act(`job:${job.key}`, + async () => { + await api.put(`/admin/models/${modelId}`, { is_default: true, is_active: true }) + setModels(prev => prev.map(row => (row.task === job.key + ? { ...row, is_default: row.id === Number(modelId), is_active: row.id === Number(modelId) ? true : row.is_active } + : row))) + }, + 'Could not change that') + + const test = async (model) => { + setBusy(`test:${model.id}`) + setResults(r => ({ ...r, [model.id]: null })) + try { + const res = await api.post(`/admin/models/${model.id}/test`) + setResults(r => ({ ...r, [model.id]: { ok: true, message: res.data.message || 'Answered' } })) + } catch (err) { + setResults(r => ({ ...r, [model.id]: { ok: false, message: detail(err, 'No answer') } })) + } finally { setBusy(null) } + } + + const offer = async () => { + setFinding(true); setFindError(''); setFilter('') + try { + const res = await api.post('/admin/litellm/models', {}) + setOffered(res.data.models || []) + } catch (err) { setFindError(detail(err, 'Could not reach the proxy')) } + finally { setFinding(false) } + } + + const allow = (modelId) => act(`allow:${modelId}`, + async () => { + const res = await api.post('/admin/models', + { name: modelId, model_id: modelId, task: allowFor, is_active: true, is_default: false }) + if (res.data?.id) setModels(prev => [...prev, res.data]) + else load() + }, + 'Could not allow that model', + `${modelId} is available for ${JOBS.find(j => j.key === allowFor)?.label}.`) + + const disallow = async (model) => { + const job = JOBS.find(j => j.key === model.task) + const ok = await openConfirm( + truthy(model.is_default) + ? `${model.name} is what does ${job?.label || model.task}. Removing it leaves that job with no model until you pick another.` + : `Stop using ${model.name} for ${job?.label || model.task}?`, + { title: 'Remove this model', confirmLabel: 'Remove', danger: true }) + if (!ok) return + await act(`allow:${model.id}`, + async () => { + await api.delete(`/admin/models/${model.id}`) + setModels(prev => prev.filter(row => row.id !== model.id)) + }, + 'Could not remove that model') + } + + const saveEmbedding = (modelId) => act('embedding', + async () => { + await api.put('/admin/settings', { embedding_model: modelId }) + setEmbedding(modelId) + setEmbeddingSaved(modelId) + }, + 'Could not save that model', + 'Saved. Vectors made by the previous model are not comparable — regenerate them.') + + const testEmbedding = async () => { + setEmbedBusy('test'); setEmbedResult(null) + try { + const res = await api.post('/admin/embedding/test') + setEmbedResult({ ok: true, message: `${res.data.model} · ${res.data.dimensions} dimensions` }) + } catch (err) { setEmbedResult({ ok: false, message: detail(err, 'No answer') }) } + finally { setEmbedBusy('') } + } + + const regenerate = async () => { + const ok = await openConfirm( + 'Re-embed every question with the current model? Search stays usable while it runs.', + { title: 'Regenerate vectors', confirmLabel: 'Start' }) + if (!ok) return + setEmbedBusy('regen'); setError(''); setNotice('') + try { + await api.post('/admin/embedding/regenerate') + setNotice('Regenerating. It runs in the background.') + } catch (err) { setError(detail(err, 'Could not start that')) } + finally { setEmbedBusy('') } + } + + const offerEmbeddings = async () => { + setEmbedBusy('find'); setError('') + try { + const res = await api.post('/admin/litellm/models', { mode: 'embedding' }) + setEmbedOffered(res.data.models || []) + } catch (err) { setError(detail(err, 'Could not reach the proxy')) } + finally { setEmbedBusy('') } + } + + const byJob = useMemo(() => { + const grouped = Object.fromEntries(JOBS.map(job => [job.key, []])) + for (const model of models) (grouped[model.task] ||= []).push(model) + return grouped + }, [models]) + + const alreadyAllowed = useMemo( + () => new Set(models.filter(m => m.task === allowFor).map(m => m.model_id)), + [models, allowFor]) + + const visible = useMemo(() => { + const needle = filter.trim().toLowerCase() + return needle ? offered.filter(id => id.toLowerCase().includes(needle)) : offered + }, [offered, filter]) + + if (loading) return
+ + return ( +
+ + {error &&

{error}

} + {notice &&

{notice}

} + +
    + {JOBS.map(job => { + const options = byJob[job.key] || [] + const chosen = options.find(m => truthy(m.is_default)) || options[0] + const result = chosen && results[chosen.id] + return ( +
  • +
    + {job.label} + {job.hint} +
    +
    + {options.length === 0 ? ( + Falls back to the site default + ) : options.length === 1 ? ( + // One model is not a choice, so it is not offered as one. + {chosen.name} + ) : ( + + )} + {chosen && ( + + )} +
    + {result && ( +

    + {result.ok ? '✓ ' : '✗ '}{result.message} +

    + )} +
  • + ) + })} +
+ +
+
+ {EMBEDDING.label} + {EMBEDDING.hint} +
+
+ {embedOffered.length > 0 ? ( + + ) : ( + setEmbedding(e.target.value)} /> + )} + + + + +
+ {embedResult && ( +

+ {embedResult.ok ? '✓ ' : '✗ '}{embedResult.message} +

+ )} +

+ {/* Vectors from different models are not comparable, so a change here + silently degrades search until everything is re-embedded. */} + Changing this makes every existing vector incomparable. Regenerate after saving. +

+
+ +
+ + + {managing && ( +
+
+ + +
+ {findError &&

{findError}

} + +
    + {(byJob[allowFor] || []).map(model => ( +
  • + {model.model_id} + {truthy(model.is_default) && in use} + +
  • + ))} + {(byJob[allowFor] || []).length === 0 && ( +
  • Nothing allowed for this job yet.
  • + )} +
+ + {offered.length > 0 && ( + <> + setFilter(e.target.value)} /> + {/* Bounded: the proxy answers with hundreds. */} +
    + {visible.map(id => ( +
  • + {id} + {alreadyAllowed.has(id) ? ( + allowed + ) : ( + + )} +
  • + ))} + {visible.length === 0 &&
  • Nothing matches that.
  • } +
+ + )} +
+ )} +
+
+ ) +} diff --git a/frontend/src/components/ModelsAdmin.test.jsx b/frontend/src/components/ModelsAdmin.test.jsx new file mode 100644 index 0000000..d45aa4c --- /dev/null +++ b/frontend/src/components/ModelsAdmin.test.jsx @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ModelsAdmin from './ModelsAdmin' +import api from '../api/client' + +vi.mock('../api/client', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) +vi.mock('../hooks/useDialog', () => ({ + useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }), +})) +vi.mock('./Dialog', () => ({ default: () => null })) + +const MODELS = [ + { id: 1, name: 'Haiku', model_id: 'claude-haiku-4-5', task: 'teach', is_active: true, is_default: true }, + { id: 2, name: 'Sonnet', model_id: 'claude-sonnet-5', task: 'teach', is_active: true, is_default: false }, + { id: 3, name: 'Opus', model_id: 'claude-opus-5', task: 'extraction', is_active: true, is_default: true }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ data: MODELS }) + api.put.mockResolvedValue({ data: {} }) + api.delete.mockResolvedValue({}) + api.post.mockImplementation(url => { + if (url === '/admin/litellm/models') { + return Promise.resolve({ data: { models: ['claude-haiku-4-5', 'gpt-5', 'gemini-3'] } }) + } + if (url === '/admin/models') { + return Promise.resolve({ data: { id: 9, name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false } }) + } + return Promise.resolve({ data: { message: 'Answered' } }) + }) +}) + +describe('model selection', () => { + it('asks one question per job rather than showing rows of buttons', async () => { + render() + // The old screen needed you to find a card, find a row, and press Set + // Default on it — four buttons per row to express one fact. + expect(await screen.findByRole('combobox', { name: 'Model for Tutor' })).toHaveValue('1') + expect(screen.queryByRole('button', { name: 'Set Default' })).toBeNull() + }) + + it('changes a job by choosing, and moves the choice off the old model', async () => { + render() + const pick = await screen.findByRole('combobox', { name: 'Model for Tutor' }) + api.get.mockClear() + await userEvent.selectOptions(pick, '2') + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/models/2', + { is_default: true, is_active: true })) + expect(pick).toHaveValue('2') + // Updated in place, not by refetching the section. + expect(api.get).not.toHaveBeenCalled() + }) + + it('offers no choice for a job with one model, because there is none', async () => { + render() + await screen.findByRole('combobox', { name: 'Model for Tutor' }) + expect(screen.queryByRole('combobox', { name: 'Model for Extraction' })).toBeNull() + expect(screen.getByText('Opus')).toBeInTheDocument() + }) + + it('says when a job has nothing configured instead of showing an empty box', async () => { + render() + await screen.findByRole('combobox', { name: 'Model for Tutor' }) + expect(screen.getAllByText('Falls back to the site default').length).toBeGreaterThan(0) + }) + + it('tests the model a job actually uses', async () => { + render() + await userEvent.click(await screen.findByRole('button', { name: 'Test the model for Tutor' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models/1/test')) + expect(await screen.findByText(/Answered/)).toBeInTheDocument() + }) + + it('keeps the allow-list folded away, since it is set once', async () => { + render() + await screen.findByRole('combobox', { name: 'Model for Tutor' }) + expect(screen.queryByRole('button', { name: /List what the proxy offers/ })).toBeNull() + await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) + expect(screen.getByRole('button', { name: /List what the proxy offers/ })).toBeInTheDocument() + }) + + it('will not offer to allow a model that is already allowed', async () => { + render() + await screen.findByRole('combobox', { name: 'Model for Tutor' }) + await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) + await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ })) + const offered = await screen.findByRole('button', { name: 'Allow gpt-5' }) + expect(offered).toBeInTheDocument() + // Already allowed for Tutor, so there is nothing to press. + expect(screen.queryByRole('button', { name: 'Allow claude-haiku-4-5' })).toBeNull() + }) + + it('adds an allowed model without reloading the section', async () => { + render() + await screen.findByRole('combobox', { name: 'Model for Tutor' }) + await userEvent.click(screen.getByRole('button', { name: /Which models this site may use/ })) + await userEvent.click(screen.getByRole('button', { name: /List what the proxy offers/ })) + api.get.mockClear() + await userEvent.click(await screen.findByRole('button', { name: 'Allow gpt-5' })) + await waitFor(() => expect(api.post).toHaveBeenCalledWith('/admin/models', { + name: 'gpt-5', model_id: 'gpt-5', task: 'teach', is_active: true, is_default: false, + })) + expect(api.get).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/components/PeopleAdmin.css b/frontend/src/components/PeopleAdmin.css new file mode 100644 index 0000000..ecc00f7 --- /dev/null +++ b/frontend/src/components/PeopleAdmin.css @@ -0,0 +1,67 @@ +.ppl { display: flex; flex-direction: column; gap: 12px; } +.ppl-error { margin: 0; font-size: 0.85rem; color: var(--wrong-fg); } +.ppl-notice { margin: 0; font-size: 0.85rem; color: var(--right-fg, #15803d); } + +.ppl-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } +.ppl-bar input[type="search"] { + flex: 1; min-width: 180px; padding: 9px 12px; + /* 16px on touch: iOS zooms in on anything smaller and never zooms back. */ + font-size: 16px; font-family: inherit; + border: 1px solid var(--border); border-radius: 8px; + background: var(--input-bg); color: var(--text); +} +@media (min-width: 700px) { .ppl-bar input[type="search"] { font-size: 0.88rem; } } + +.ppl-new { + padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg); +} +.ppl-new small { display: block; margin-top: 4px; font-size: 0.78rem; color: var(--text-muted); } +.ppl-new-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* Bounded, so two hundred accounts do not push the rest of settings away. */ +.ppl-list { + list-style: none; margin: 0; padding: 0; + max-height: 460px; overflow-y: auto; + border: 1px solid var(--border); border-radius: 10px; +} +.ppl-list > li { + display: flex; align-items: center; justify-content: space-between; + gap: 12px; flex-wrap: wrap; + padding: 11px 14px; border-bottom: 1px solid var(--border); +} +.ppl-list > li:last-child { border-bottom: 0; } +.ppl-list > li.is-busy { opacity: 0.55; } +.ppl-who { min-width: 0; } +.ppl-who strong { display: flex; align-items: center; gap: 7px; font-size: 0.92rem; font-weight: 650; } +.ppl-who small { display: block; margin-top: 2px; font-size: 0.79rem; color: var(--text-muted); } +.ppl-you { + font-size: 0.62rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; + padding: 1px 6px; border-radius: 999px; + color: var(--primary); background: var(--option-sel-bg); +} + +.ppl-controls { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } +.ppl-role select { + padding: 5px 9px; font: inherit; font-size: 0.82rem; + border: 1px solid var(--border); border-radius: 7px; + background: var(--input-bg); color: var(--text); +} +.ppl-role select:disabled { opacity: 0.6; } + +/* Says what is true now, not what pressing it does — the title carries that. */ +.ppl-limit, .ppl-remove { + padding: 5px 11px; font: inherit; font-size: 0.78rem; font-weight: 600; + cursor: pointer; border-radius: 999px; + border: 1px solid var(--border); background: var(--card-bg); color: var(--text-muted); +} +.ppl-limit.is-on { border-color: var(--primary); color: var(--primary); } +.ppl-remove { color: var(--wrong-fg); border-color: var(--wrong-bd); } +.ppl-remove:hover:not(:disabled) { background: var(--wrong-bg); } +.ppl-limit:disabled, .ppl-remove:disabled { opacity: 0.5; cursor: default; } + +.ppl-empty { padding: 20px 14px; font-size: 0.86rem; color: var(--text-muted); } + +@media (max-width: 560px) { + .ppl-list > li { align-items: flex-start; } + .ppl-controls { width: 100%; } +} diff --git a/frontend/src/components/PeopleAdmin.jsx b/frontend/src/components/PeopleAdmin.jsx new file mode 100644 index 0000000..edd361b --- /dev/null +++ b/frontend/src/components/PeopleAdmin.jsx @@ -0,0 +1,215 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import api from '../api/client' +import { useAuth } from '../context/AuthContext' +import { useDialog } from '../hooks/useDialog' +import Dialog from './Dialog' +import './PeopleAdmin.css' + +const ROLES = [ + { value: 'user', label: 'User', hint: 'Studies. Can make their own tests.' }, + { value: 'moderator', label: 'Moderator', hint: 'Edits content and runs courses.' }, + { value: 'admin', label: 'Admin', hint: 'Everything, including this page.' }, +] + +const detail = (err, fallback) => { + const value = err?.response?.data?.detail + return typeof value === 'string' ? value : fallback +} + +const joined = value => (value + ? new Date(value).toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }) + : '') + +/** + * Everyone with an account. + * + * This was the admin dashboard's users tab rendered inside a settings panel — + * its own cards inside our card, in an older visual language, and refetching + * users, models and settings together after every change so the whole section + * blinked and reads as a page reload. + * + * It is a settings section in its own right now, and a change updates the one + * row it touched from what the server sent back. Nothing else moves. + */ +export default function PeopleAdmin() { + const { user: me } = useAuth() + const { dialogProps, openConfirm } = useDialog() + const [people, setPeople] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + const [busyId, setBusyId] = useState(null) + const [query, setQuery] = useState('') + const [adding, setAdding] = useState(false) + const [draft, setDraft] = useState({ name: '', email: '', password: '' }) + const [creating, setCreating] = useState(false) + + const load = useCallback(() => { + api.get('/admin/users') + .then(res => setPeople(res.data || [])) + .catch(() => setError('Could not load the people list')) + .finally(() => setLoading(false)) + }, []) + + useEffect(() => { load() }, [load]) + + /** Replace one row from what the server said, rather than refetching the lot. */ + const patchRow = (id, fields) => + setPeople(prev => prev.map(row => (row.id === id ? { ...row, ...fields } : row))) + + const act = async (id, run, failure, message) => { + setBusyId(id); setError(''); setNotice('') + try { + const result = await run() + if (message) setNotice(message) + return result + } catch (err) { + setError(detail(err, failure)) + return null + } finally { setBusyId(null) } + } + + const setRole = (row, role) => act(row.id, + async () => { + await api.put(`/admin/users/${row.id}/role`, { role }) + patchRow(row.id, { role }) + }, + 'Could not change that role', + `${row.name || row.email} is now ${role}.`) + + const setThrottle = (row) => act(row.id, + async () => { + const next = !row.is_unthrottled + await api.put(`/admin/users/${row.id}/unthrottle`, { unthrottled: next }) + patchRow(row.id, { is_unthrottled: next }) + }, + 'Could not change that limit') + + const remove = async (row) => { + const ok = await openConfirm( + `Delete ${row.name || row.email}? Their attempts, saved questions, notes and settings go with them.`, + { title: 'Delete this account', confirmLabel: 'Delete', danger: true }) + if (!ok) return + await act(row.id, + async () => { + await api.delete(`/admin/users/${row.id}`) + setPeople(prev => prev.filter(p => p.id !== row.id)) + }, + 'Could not delete that account', + `${row.name || row.email} removed.`) + } + + const create = async (e) => { + e.preventDefault() + setCreating(true); setError(''); setNotice('') + try { + const res = await api.post('/admin/users', draft) + // Added where it belongs rather than by reloading everything. + if (res.data?.id) setPeople(prev => [...prev, res.data]) + else load() + setNotice(`${draft.email} created.`) + setDraft({ name: '', email: '', password: '' }) + setAdding(false) + } catch (err) { setError(detail(err, 'Could not create that account')) } + finally { setCreating(false) } + } + + const shown = useMemo(() => { + const needle = query.trim().toLowerCase() + if (!needle) return people + return people.filter(row => + `${row.name || ''} ${row.email || ''} ${row.role || ''}`.toLowerCase().includes(needle)) + }, [people, query]) + + if (loading) return
+ + return ( +
+ + {error &&

{error}

} + {notice &&

{notice}

} + +
+ setQuery(e.target.value)} /> + {!adding && ( + + )} +
+ + {adding && ( +
+
+ + setDraft(d => ({ ...d, name: e.target.value }))} /> +
+
+ + setDraft(d => ({ ...d, email: e.target.value }))} /> +
+
+ + setDraft(d => ({ ...d, password: e.target.value }))} /> + At least 8 characters. They can change it once they are in. +
+
+ + +
+
+ )} + + {/* Bounded and scrolled, so a long list does not push everything else + off the page. */} +
    + {shown.map(row => ( +
  • +
    + + {row.name || row.email} + {row.id === me?.id && you} + + {row.email} · joined {joined(row.created_at)} +
    +
    + + + {row.id !== me?.id && ( + + )} +
    +
  • + ))} + {shown.length === 0 &&
  • Nobody matches that.
  • } +
+
+ ) +} diff --git a/frontend/src/components/PeopleAdmin.test.jsx b/frontend/src/components/PeopleAdmin.test.jsx new file mode 100644 index 0000000..b74017d --- /dev/null +++ b/frontend/src/components/PeopleAdmin.test.jsx @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import PeopleAdmin from './PeopleAdmin' +import api from '../api/client' + +vi.mock('../api/client', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }, +})) +vi.mock('../context/AuthContext', () => ({ + useAuth: () => ({ user: { id: 1, name: 'Admin', role: 'admin' } }), +})) +vi.mock('../hooks/useDialog', () => ({ + useDialog: () => ({ dialogProps: {}, openConfirm: () => Promise.resolve(true) }), +})) +vi.mock('./Dialog', () => ({ default: () => null })) + +const PEOPLE = [ + { id: 1, name: 'Admin', email: 'admin@example.test', role: 'admin', created_at: '2026-01-01', is_unthrottled: true }, + { id: 2, name: 'Maria', email: 'maria@example.test', role: 'user', created_at: '2026-04-27', is_unthrottled: false }, +] + +beforeEach(() => { + vi.clearAllMocks() + api.get.mockResolvedValue({ data: PEOPLE }) + api.put.mockResolvedValue({ data: {} }) + api.delete.mockResolvedValue({}) + api.post.mockResolvedValue({ data: { id: 3, name: 'New', email: 'new@example.test', role: 'user', created_at: '2026-09-11' } }) +}) + +const roleFor = name => screen.getByRole('combobox', { name: `Role for ${name}` }) + +describe('people', () => { + it('changes a role without refetching the whole section', async () => { + render() + await screen.findByText('Maria') + api.get.mockClear() + await userEvent.selectOptions(roleFor('Maria'), 'moderator') + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/users/2/role', { role: 'moderator' })) + // The old page called loadData() after every change, refetching users, + // models and settings together — which is what read as a page reload. + expect(api.get).not.toHaveBeenCalled() + expect(roleFor('Maria')).toHaveValue('moderator') + }) + + it('will not let you change your own role', async () => { + render() + await screen.findByText('Maria') + expect(roleFor('Admin')).toBeDisabled() + expect(screen.getByText('you')).toBeInTheDocument() + }) + + it('offers no way to delete yourself', async () => { + render() + await screen.findByText('Maria') + expect(screen.queryByRole('button', { name: 'Delete Admin' })).toBeNull() + expect(screen.getByRole('button', { name: 'Delete Maria' })).toBeInTheDocument() + }) + + it('takes a deleted person off the list without a refetch', async () => { + render() + await screen.findByText('Maria') + api.get.mockClear() + await userEvent.click(screen.getByRole('button', { name: 'Delete Maria' })) + await waitFor(() => expect(screen.queryByText('Maria')).toBeNull()) + expect(api.get).not.toHaveBeenCalled() + }) + + it('adds a new account where it belongs rather than reloading', async () => { + render() + await screen.findByText('Maria') + api.get.mockClear() + await userEvent.click(screen.getByRole('button', { name: '+ Add someone' })) + await userEvent.type(screen.getByLabelText('Name'), 'New') + await userEvent.type(screen.getByLabelText('Email'), 'new@example.test') + await userEvent.type(screen.getByLabelText('Password'), 'longenough1') + await userEvent.click(screen.getByRole('button', { name: 'Create account' })) + expect(await screen.findByText('New')).toBeInTheDocument() + expect(api.get).not.toHaveBeenCalled() + }) + + it('searches the list it already has', async () => { + render() + await screen.findByText('Maria') + await userEvent.type(screen.getByLabelText('Search people'), 'maria') + // "Admin" is also a role option, so ask about the row rather than the word. + expect(screen.queryByText('admin@example.test')).toBeNull() + expect(screen.getByText(/maria@example.test/)).toBeInTheDocument() + }) + + it('says what the rate limit is now, not what the button does', async () => { + render() + await screen.findByText('Maria') + expect(screen.getByRole('button', { name: 'Limited' })).toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: 'Limited' })) + await waitFor(() => expect(api.put).toHaveBeenCalledWith('/admin/users/2/unthrottle', + { unthrottled: true })) + expect(await screen.findAllByRole('button', { name: 'Unlimited' })).toHaveLength(2) + }) +}) diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx deleted file mode 100644 index 5ed2215..0000000 --- a/frontend/src/pages/AdminPage.jsx +++ /dev/null @@ -1,715 +0,0 @@ -import { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { useAuth } from '../context/AuthContext' -import api from '../api/client' -import Dialog from '../components/Dialog' -import { useDialog } from '../hooks/useDialog' - -const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard', 'article'] - -/** - * Administration. - * - * Rendered inside Settings rather than as a page of its own: there should be - * one place where the site is configured, not a settings page that links to a - * second settings page. `section` names which part to show and comes from the - * Settings nav. /admin now redirects into Settings, so the tab bar below is - * only reached by rendering this without `embedded` — kept because the tabs - * are what the sections are named by, and losing them would lose that. - */ -export default function AdminPage({ section, embedded = false }) { - const { user } = useAuth() - const navigate = useNavigate() - const { dialogProps, openConfirm } = useDialog() - const [ownTab, setOwnTab] = useState('models') - const tab = section || ownTab - const setTab = setOwnTab - const [users, setUsers] = useState([]) - const [models, setModels] = useState([]) - const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' }) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [success, setSuccess] = useState('') - - const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false }) - const [newUser, setNewUser] = useState({ name: '', email: '', password: '' }) - - // LiteLLM search state - const [searchResults, setSearchResults] = useState([]) - const [searchLoading, setSearchLoading] = useState(false) - const [searchError, setSearchError] = useState('') - const [searchFilter, setSearchFilter] = useState('') - const [searchTaskHint, setSearchTaskHint] = useState('extraction') - - // TTS voice discovery state - const [ttsProvider, setTtsProvider] = useState('litellm') - const [ttsVoices, setTtsVoices] = useState([]) - const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false) - const [ttsVoicesError, setTtsVoicesError] = useState('') - const [ttsVoicesFilter, setTtsVoicesFilter] = useState('') - - // TTS preview state per model db id: {text, loading, error, audioUrl} - const [ttsPreviews, setTtsPreviews] = useState({}) - - // Embedding model search state (in settings tab) - const [embedSearchResults, setEmbedSearchResults] = useState([]) - const [embedSearchLoading, setEmbedSearchLoading] = useState(false) - const [embedSearchError, setEmbedSearchError] = useState('') - const [embedSearchFilter, setEmbedSearchFilter] = useState('') - const [embedTestResult, setEmbedTestResult] = useState(null) - const [embedTestLoading, setEmbedTestLoading] = useState(false) - const [originalEmbedModel, setOriginalEmbedModel] = useState(null) - const [embedModelChanged, setEmbedModelChanged] = useState(false) - const [regenLoading, setRegenLoading] = useState(false) - - useEffect(() => { - if (!user?.role || user.role !== 'admin') { navigate('/'); return } - loadData() - }, [user]) - - const loadData = async (showSpinner = true) => { - if (showSpinner) setLoading(true) - try { - const [usersRes, modelsRes, settingsRes] = await Promise.all([ - api.get('/admin/users'), - api.get('/admin/models'), - api.get('/admin/settings'), - ]) - setUsers(usersRes.data) - setModels(modelsRes.data) - setSettings(settingsRes.data) - setOriginalEmbedModel(settingsRes.data.embedding_model || '') - } catch (err) { - setError(err.response?.data?.detail || 'Failed to load data') - } finally { - setLoading(false) - } - } - - const updateRole = async (userId, role) => { - try { - await api.put(`/admin/users/${userId}/role`, { role }) - setSuccess(`Role updated to ${role}`) - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to update role') - } - } - - const deleteUser = async (userId, userName) => { - const ok = await openConfirm(`Delete user "${userName}"? This removes all their data (attempts, favorites, settings).`, { title: 'Delete User', confirmLabel: 'Delete', danger: true }) - if (!ok) return - try { - await api.delete(`/admin/users/${userId}`) - setSuccess(`User "${userName}" deleted`) - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to delete user') - } - } - - const toggleUnthrottle = async (userId, currentVal) => { - try { - await api.put(`/admin/users/${userId}/unthrottle`, { unthrottled: !currentVal }) - setSuccess(currentVal ? 'Rate limits restored for user' : 'Rate limits removed for user') - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to update throttle setting') - } - } - - const createModel = async (e) => { - e.preventDefault() - setError('') - try { - await api.post('/admin/models', newModel) - setSuccess('Model added') - setNewModel({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false }) - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to add model') - } - } - - const setDefault = async (modelId) => { - try { - await api.put(`/admin/models/${modelId}`, { is_default: true }) - setSuccess('Default model updated') - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to update') - } - } - - const toggleActive = async (model) => { - try { - await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active }) - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to update') - } - } - - const deleteModel = async (modelId) => { - const ok = await openConfirm('Remove this model configuration?', { title: 'Remove Model', confirmLabel: 'Remove', danger: true }) - if (!ok) return - try { - await api.delete(`/admin/models/${modelId}`) - loadData(false) - } catch (err) { - setError(err.response?.data?.detail || 'Failed to delete') - } - } - - const [testResults, setTestResults] = useState({}) - const [testingModel, setTestingModel] = useState(null) - - const testModel = async (model) => { - setTestingModel(model.id) - setTestResults(r => ({ ...r, [model.id]: null })) - try { - const res = await api.post(`/admin/models/${model.id}/test`) - setTestResults(r => ({ ...r, [model.id]: { ok: true, message: res.data.message || 'OK' } })) - } catch (err) { - setTestResults(r => ({ ...r, [model.id]: { ok: false, message: err.response?.data?.detail || 'Test failed' } })) - } finally { - setTestingModel(null) - } - } - - const searchLiteLLM = async () => { - setSearchError('') - setSearchResults([]) - setSearchLoading(true) - try { - const res = await api.post('/admin/litellm/models', {}) - setSearchResults(res.data.models) - } catch (err) { - setSearchError(err.response?.data?.detail || 'Failed to query models') - } finally { - setSearchLoading(false) - } - } - - const searchEmbeddingModels = async () => { - setEmbedSearchError('') - setEmbedSearchResults([]) - setEmbedSearchFilter('') - setEmbedSearchLoading(true) - try { - const res = await api.post('/admin/litellm/models', { mode: 'embedding' }) - setEmbedSearchResults(res.data.models || []) - } catch (err) { - setEmbedSearchError(err.response?.data?.detail || 'Failed to query models') - } finally { - setEmbedSearchLoading(false) - } - } - - const testEmbedding = async () => { - setEmbedTestResult(null) - setEmbedTestLoading(true) - try { - const res = await api.post('/admin/embedding/test') - setEmbedTestResult({ ok: true, ...res.data }) - } catch (err) { - setEmbedTestResult({ ok: false, error: err.response?.data?.detail || 'Test failed' }) - } finally { - setEmbedTestLoading(false) - } - } - - const saveEmbeddingModel = async (modelId) => { - try { - await api.put('/admin/settings', { embedding_model: modelId }) - setSettings(s => ({ ...s, embedding_model: modelId })) - setEmbedSearchResults([]) - setSuccess(`Embedding model set to: ${modelId}`) - if (originalEmbedModel && modelId !== originalEmbedModel) { - setEmbedModelChanged(true) - } - } catch (err) { - setError(err.response?.data?.detail || 'Failed to save embedding model') - } - } - - const startRegeneration = async () => { - setRegenLoading(true) - try { - await api.post('/admin/embedding/regenerate') - setEmbedModelChanged(false) - setSuccess('Regeneration started — watch the Jobs badge for progress.') - } catch (err) { - setError(err.response?.data?.detail || 'Failed to start regeneration') - } finally { - setRegenLoading(false) - } - } - - const addFromSearch = (modelId) => { - setNewModel(m => ({ ...m, model_id: modelId, name: modelId, task: searchTaskHint })) - setSearchResults([]) - document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' }) - } - - // ── TTS voice discovery ───────────────────────────────────────── - const searchTtsVoices = async () => { - setTtsVoicesError('') - setTtsVoices([]) - setTtsVoicesLoading(true) - try { - const res = await api.post('/admin/tts/voices', { - provider: ttsProvider, - }) - setTtsVoices(res.data.voices) - } catch (err) { - setTtsVoicesError(err.response?.data?.detail || 'Failed to fetch voices') - } finally { - setTtsVoicesLoading(false) - } - } - - const addTtsFromSearch = (voice) => { - setNewModel(m => ({ ...m, model_id: voice.model_id, name: voice.name, task: 'tts' })) - setTtsVoices([]) - document.getElementById('add-model-form')?.scrollIntoView({ behavior: 'smooth' }) - } - - // ── TTS preview (calls /tts/speak directly) ───────────────────── - const setPreview = (id, patch) => - setTtsPreviews(p => ({ ...p, [id]: { text: 'Hello, this is a voice preview.', ...p[id], ...patch } })) - - const playTtsPreview = async (model) => { - const state = ttsPreviews[model.id] || {} - const text = state.text || 'Hello, this is a voice preview.' - setPreview(model.id, { loading: true, error: null }) - try { - const res = await api.post('/tts/speak', { text, voice: model.model_id }, { responseType: 'blob' }) - const url = URL.createObjectURL(res.data) - const audio = new Audio(url) - audio.onended = () => URL.revokeObjectURL(url) - audio.play() - setPreview(model.id, { loading: false }) - } catch (err) { - setPreview(model.id, { loading: false, error: err.response?.data?.detail || 'Playback failed' }) - } - } - - if (loading) return
Loading...
- - const modelsByTask = TASKS.reduce((acc, t) => { - acc[t] = models.filter(m => m.task === t) - return acc - }, {}) - - const filteredResults = searchFilter - ? searchResults.filter(m => m.toLowerCase().includes(searchFilter.toLowerCase())) - : searchResults - - const adminTabs = [ - { id: 'models', label: 'AI Models' }, - { id: 'users', label: 'Users' }, - { id: 'settings', label: 'More' }, - ] - - return ( -
- - {/* Inside Settings the section is already named by the nav, and a second - row of tabs would be a second way to be somewhere. */} - {!embedded && ( -
-

Admin Dashboard

-
- {adminTabs.map(({ id, label }) => ( - - ))} -
-
- )} - - {error &&
{error}
} - {success &&
{success}
} - - {tab === 'models' && ( - <> - {/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */} -
-

Search LLM Models

-

- Query the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks. -

-
-
- - -
-
- - - {searchError &&
{searchError}
} - - {searchResults.length > 0 && ( -
-
- {searchResults.length} models found - setSearchFilter(e.target.value)} - style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }} - /> -
-
- {filteredResults.map(modelId => ( -
- {modelId} - -
- ))} -
-
- )} -
- - {/* Configured models by task */} - {TASKS.map(task => ( -
-

- {task} Models - - {task === 'extraction' ? '— AI that extracts questions from PDFs' : - task === 'tts' ? '— Text-to-speech voices' : - task === 'stt' ? '— Speech-to-text models' : - task === 'teach' ? '— AI tutor shown in study mode chat' : - task === 'keyword' ? '— Keyword and topic classification' : - '— Flashcard generation'} - -

- - {/* TTS voice discovery — only in the tts card */} - {task === 'tts' && ( -
-
Discover Voices
-
- - -
- {ttsVoicesError &&
{ttsVoicesError}
} - {ttsVoices.length > 0 && ( -
-
- {ttsVoices.length} voices — click to add - setTtsVoicesFilter(e.target.value)} - style={{ padding: '3px 8px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.82rem', width: 160, background: 'var(--input-bg)', color: 'var(--text)' }} /> -
-
- {ttsVoices - .filter(v => !ttsVoicesFilter || v.name.toLowerCase().includes(ttsVoicesFilter.toLowerCase()) || v.model_id.toLowerCase().includes(ttsVoicesFilter.toLowerCase())) - .map(v => ( -
-
- {v.name} - {v.labels && Object.keys(v.labels).length > 0 && ( - - {Object.values(v.labels).filter(Boolean).join(' · ')} - - )} -
{v.model_id}
-
- -
- ))} -
-
- )} -
- )} - - {modelsByTask[task].length === 0 ? ( -
No models configured
- ) : ( - modelsByTask[task].map(m => ( -
-
-
- {m.name} -
{m.model_id}
-
-
- {m.is_default && Default} - {!m.is_default && ( - - )} - {task === 'tts' ? ( - - ) : ( - - )} - - -
-
- - {/* LLM test result */} - {testResults[m.id] && ( -
- {testResults[m.id].ok ? '✓ ' : '✗ '}{testResults[m.id].message} -
- )} - - {/* TTS preview */} - {task === 'tts' && ttsPreviews[m.id]?.open && ( -
-