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 && ( -
-