- Fix tag filtering (sa_text import shadowing caused UnboundLocalError) - Add TagBrowser component with per-section search - Multi-category selection (OR within categories, AND with tags) - AI image validation: has_figure field in extraction prompt - Skip known branding images by MD5 hash + dimension filters - Fix quiz timer auto-submit (wrong useEffect dependency) - Fix QuizResponse schema: section_id nullable - Fix Question.quiz_id → source_quiz_id attribute name - Fix SQL injection in quizzes.py vector search - Add PDF processing progress steps via Redis - Add delete user from admin panel - Admin page: no spinner flash on data refresh - Upload progress: axios 1.x e.progress, remove manual Content-Type - Duplicate model error: 409 with clear message - Backend startup: retry DDL migration on lock timeout - Replace all silent except:pass with warning logs - Comprehensive multi-page documentation (docs/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
809 lines
38 KiB
JavaScript
809 lines
38 KiB
JavaScript
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', 'teach', 'keyword']
|
||
|
||
export default function AdminPage() {
|
||
const { user } = useAuth()
|
||
const navigate = useNavigate()
|
||
const { dialogProps, openConfirm } = useDialog()
|
||
const [tab, setTab] = useState('models')
|
||
const [users, setUsers] = useState([])
|
||
const [models, setModels] = useState([])
|
||
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState('')
|
||
|
||
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false })
|
||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
|
||
|
||
// LiteLLM search state
|
||
const [searchApiKey, setSearchApiKey] = useState('')
|
||
const [searchApiBase, setSearchApiBase] = useState('')
|
||
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('elevenlabs')
|
||
const [ttsSearchKey, setTtsSearchKey] = useState('')
|
||
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', api_key: '', 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', {
|
||
api_key: searchApiKey || null,
|
||
api_base: searchApiBase || null,
|
||
})
|
||
setSearchResults(res.data.models)
|
||
} catch (err) {
|
||
setSearchError(err.response?.data?.detail || 'Failed to query models')
|
||
} finally {
|
||
setSearchLoading(false)
|
||
}
|
||
}
|
||
|
||
const searchEmbeddingModels = async () => {
|
||
setEmbedSearchError('')
|
||
setEmbedSearchResults([])
|
||
setEmbedSearchLoading(true)
|
||
try {
|
||
const res = await api.post('/admin/litellm/models', {})
|
||
const all = res.data.models || []
|
||
// Filter to likely embedding models
|
||
setEmbedSearchResults(all)
|
||
} 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,
|
||
api_key: ttsSearchKey || null,
|
||
})
|
||
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 <div className="loading"><div className="spinner"></div> Loading...</div>
|
||
|
||
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
|
||
|
||
return (
|
||
<div>
|
||
<Dialog {...dialogProps} />
|
||
<div className="card">
|
||
<h2>Admin Dashboard</h2>
|
||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||
{['models', 'users', 'settings'].map(t => (
|
||
<button key={t} className={`btn ${tab === t ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t)}>
|
||
{t === 'models' ? 'AI Models' : t === 'users' ? 'Users' : 'More'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className="alert alert-error">{error}</div>}
|
||
{success && <div className="alert alert-success">{success}</div>}
|
||
|
||
{tab === 'models' && (
|
||
<>
|
||
{/* LiteLLM / OpenAI-compatible search — for extraction, teach, general */}
|
||
<div className="card">
|
||
<h2>Search LLM Models</h2>
|
||
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
|
||
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks.
|
||
</p>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>API Base URL (optional — uses .env if blank)</label>
|
||
<input
|
||
type="text"
|
||
value={searchApiBase}
|
||
onChange={e => setSearchApiBase(e.target.value)}
|
||
placeholder="e.g. https://litellm.myserver.com"
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>API Key (optional — uses .env if blank)</label>
|
||
<input
|
||
type="password"
|
||
value={searchApiKey}
|
||
onChange={e => setSearchApiKey(e.target.value)}
|
||
placeholder="sk-..."
|
||
/>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Adding model for task</label>
|
||
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
|
||
{TASKS.filter(t => t !== 'tts').map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-primary" onClick={searchLiteLLM} disabled={searchLoading}>
|
||
{searchLoading ? 'Searching...' : 'Search Models'}
|
||
</button>
|
||
|
||
{searchError && <div className="alert alert-error" style={{ marginTop: 12 }}>{searchError}</div>}
|
||
|
||
{searchResults.length > 0 && (
|
||
<div style={{ marginTop: 16 }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||
<strong>{searchResults.length} models found</strong>
|
||
<input
|
||
type="text"
|
||
placeholder="Filter..."
|
||
value={searchFilter}
|
||
onChange={e => setSearchFilter(e.target.value)}
|
||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 200 }}
|
||
/>
|
||
</div>
|
||
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #e2e8f0', borderRadius: 8 }}>
|
||
{filteredResults.map(modelId => (
|
||
<div
|
||
key={modelId}
|
||
style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '8px 12px', borderBottom: '1px solid #f1f5f9',
|
||
fontSize: '0.85rem', fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
<span>{modelId}</span>
|
||
<button
|
||
className="btn btn-primary btn-sm"
|
||
onClick={() => addFromSearch(modelId)}
|
||
style={{ fontFamily: 'inherit' }}
|
||
>
|
||
+ Add
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Configured models by task */}
|
||
{TASKS.map(task => (
|
||
<div className="card" key={task}>
|
||
<h2 style={{ textTransform: 'capitalize', marginBottom: 12 }}>
|
||
{task} Models
|
||
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
|
||
{task === 'extraction' ? '— AI that extracts questions from PDFs' :
|
||
task === 'tts' ? '— Text-to-speech voices' :
|
||
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
||
'— General purpose AI'}
|
||
</span>
|
||
</h2>
|
||
|
||
{/* TTS voice discovery — only in the tts card */}
|
||
{task === 'tts' && (
|
||
<div style={{ marginBottom: 16, padding: '12px 14px', background: 'var(--bg)', borderRadius: 8, border: '1px solid var(--border)' }}>
|
||
<div style={{ fontWeight: 600, fontSize: '0.85rem', marginBottom: 8 }}>Discover Voices</div>
|
||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
|
||
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
|
||
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
|
||
<option value="elevenlabs">ElevenLabs</option>
|
||
<option value="polly">AWS Polly (Neural)</option>
|
||
<option value="openai">OpenAI TTS</option>
|
||
</select>
|
||
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
|
||
placeholder="API key (uses .env if blank)"
|
||
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
|
||
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
|
||
{ttsVoicesLoading ? 'Searching...' : 'Search'}
|
||
</button>
|
||
</div>
|
||
{ttsVoicesError && <div className="alert alert-error" style={{ fontSize: '0.82rem', padding: '6px 12px' }}>{ttsVoicesError}</div>}
|
||
{ttsVoices.length > 0 && (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||
<span style={{ fontSize: '0.82rem', color: 'var(--text-muted)' }}>{ttsVoices.length} voices — click to add</span>
|
||
<input type="text" placeholder="Filter..." value={ttsVoicesFilter} onChange={e => 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)' }} />
|
||
</div>
|
||
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
|
||
{ttsVoices
|
||
.filter(v => !ttsVoicesFilter || v.name.toLowerCase().includes(ttsVoicesFilter.toLowerCase()) || v.model_id.toLowerCase().includes(ttsVoicesFilter.toLowerCase()))
|
||
.map(v => (
|
||
<div key={v.model_id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 12px', borderBottom: '1px solid var(--border)', fontSize: '0.83rem' }}>
|
||
<div>
|
||
<span style={{ fontWeight: 500 }}>{v.name}</span>
|
||
{v.labels && Object.keys(v.labels).length > 0 && (
|
||
<span style={{ color: 'var(--text-muted)', marginLeft: 8, fontSize: '0.75rem' }}>
|
||
{Object.values(v.labels).filter(Boolean).join(' · ')}
|
||
</span>
|
||
)}
|
||
<div style={{ fontFamily: 'monospace', fontSize: '0.75rem', color: 'var(--text-muted)' }}>{v.model_id}</div>
|
||
</div>
|
||
<button className="btn btn-primary btn-sm" onClick={() => addTtsFromSearch(v)}>+ Add</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{modelsByTask[task].length === 0 ? (
|
||
<div className="empty-state" style={{ padding: 16 }}>No models configured</div>
|
||
) : (
|
||
modelsByTask[task].map(m => (
|
||
<div key={m.id}>
|
||
<div className="section-item">
|
||
<div style={{ minWidth: 0 }}>
|
||
<strong>{m.name}</strong>
|
||
<div style={{ fontSize: '0.82rem', color: '#64748b', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.model_id}</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 }}>
|
||
{m.is_default && <span className="badge badge-ready">Default</span>}
|
||
{!m.is_default && (
|
||
<button className="btn btn-secondary btn-sm" onClick={() => setDefault(m.id)}>Set Default</button>
|
||
)}
|
||
{task === 'tts' ? (
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => setPreview(m.id, { open: !(ttsPreviews[m.id]?.open) })}
|
||
>Preview</button>
|
||
) : (
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
onClick={() => testModel(m)}
|
||
disabled={testingModel === m.id}
|
||
>{testingModel === m.id ? 'Testing...' : 'Test'}</button>
|
||
)}
|
||
<button
|
||
className={`btn btn-sm ${m.is_active ? 'btn-secondary' : 'btn-primary'}`}
|
||
onClick={() => toggleActive(m)}
|
||
>{m.is_active ? 'Disable' : 'Enable'}</button>
|
||
<button className="btn btn-danger btn-sm" onClick={() => deleteModel(m.id)}>Remove</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* LLM test result */}
|
||
{testResults[m.id] && (
|
||
<div className={`alert ${testResults[m.id].ok ? 'alert-success' : 'alert-error'}`} style={{ marginTop: 6, marginBottom: 0, padding: '6px 12px', fontSize: '0.82rem' }}>
|
||
{testResults[m.id].ok ? '✓ ' : '✗ '}{testResults[m.id].message}
|
||
</div>
|
||
)}
|
||
|
||
{/* TTS preview */}
|
||
{task === 'tts' && ttsPreviews[m.id]?.open && (
|
||
<div style={{ marginTop: 6, padding: '10px 12px', background: 'var(--bg)', borderRadius: 8, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||
<textarea
|
||
value={ttsPreviews[m.id]?.text ?? 'Hello, this is a voice preview.'}
|
||
onChange={e => setPreview(m.id, { text: e.target.value })}
|
||
rows={2}
|
||
style={{ flex: 1, minWidth: 160, padding: '6px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', resize: 'none', background: 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }}
|
||
/>
|
||
<button className="btn btn-primary btn-sm" style={{ alignSelf: 'flex-end' }}
|
||
onClick={() => playTtsPreview(m)}
|
||
disabled={ttsPreviews[m.id]?.loading}>
|
||
{ttsPreviews[m.id]?.loading ? '⏳' : '▶ Play'}
|
||
</button>
|
||
{ttsPreviews[m.id]?.error && (
|
||
<div className="alert alert-error" style={{ width: '100%', marginTop: 4, padding: '5px 10px', fontSize: '0.82rem' }}>
|
||
{ttsPreviews[m.id].error}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
))}
|
||
|
||
{/* Add model form */}
|
||
<div className="card" id="add-model-form">
|
||
<h2>Add Model</h2>
|
||
<form onSubmit={createModel}>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Task</label>
|
||
<select value={newModel.task} onChange={e => setNewModel(m => ({ ...m, task: e.target.value }))}>
|
||
{TASKS.map(t => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Display Name</label>
|
||
<input type="text" value={newModel.name} onChange={e => setNewModel(m => ({ ...m, name: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Model ID (LiteLLM format)</label>
|
||
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>API Key (optional override)</label>
|
||
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
|
||
</div>
|
||
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
|
||
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
|
||
Set as default
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-primary" type="submit">Add Model</button>
|
||
</form>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'users' && (
|
||
<>
|
||
<div className="card">
|
||
<h2>Add User</h2>
|
||
<form onSubmit={async e => {
|
||
e.preventDefault(); setError(''); setSuccess('')
|
||
try {
|
||
await api.post('/admin/users', newUser)
|
||
setSuccess(`User ${newUser.email} created`)
|
||
setNewUser({ name: '', email: '', password: '' })
|
||
loadData(false)
|
||
} catch (err) { setError(err.response?.data?.detail || 'Failed to create user') }
|
||
}}>
|
||
<div className="grid-2">
|
||
<div className="form-group">
|
||
<label>Name</label>
|
||
<input type="text" value={newUser.name} onChange={e => setNewUser(u => ({ ...u, name: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Email</label>
|
||
<input type="email" value={newUser.email} onChange={e => setNewUser(u => ({ ...u, email: e.target.value }))} required />
|
||
</div>
|
||
<div className="form-group">
|
||
<label>Password</label>
|
||
<input type="password" value={newUser.password} onChange={e => setNewUser(u => ({ ...u, password: e.target.value }))} required minLength={8} />
|
||
</div>
|
||
</div>
|
||
<button className="btn btn-primary" type="submit">Create User</button>
|
||
</form>
|
||
</div>
|
||
<div className="card">
|
||
<h2>Users</h2>
|
||
{users.map(u => (
|
||
<div className="section-item" key={u.id}>
|
||
<div>
|
||
<strong>{u.name}</strong>
|
||
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>{u.email} · joined {new Date(u.created_at).toLocaleDateString()}</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||
<span className={`badge ${u.role === 'admin' ? 'badge-error' : u.role === 'moderator' ? 'badge-processing' : 'badge-ready'}`}>
|
||
{u.role}
|
||
</span>
|
||
{u.is_unthrottled ? (
|
||
<span style={{ fontSize: '0.72rem', padding: '2px 7px', borderRadius: 10, background: '#fef9c3', color: '#92400e', fontWeight: 600 }}>
|
||
unlimited
|
||
</span>
|
||
) : null}
|
||
<select
|
||
value={u.role}
|
||
onChange={e => updateRole(u.id, e.target.value)}
|
||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem' }}
|
||
>
|
||
<option value="user">user</option>
|
||
<option value="moderator">moderator</option>
|
||
<option value="admin">admin</option>
|
||
</select>
|
||
<button
|
||
className="btn btn-secondary btn-sm"
|
||
title={u.is_unthrottled ? 'Restore rate limits' : 'Remove rate limits for this user'}
|
||
onClick={() => toggleUnthrottle(u.id, u.is_unthrottled)}
|
||
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
|
||
>
|
||
{u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'}
|
||
</button>
|
||
{u.id !== user?.id && (
|
||
<button
|
||
className="btn btn-danger btn-sm"
|
||
onClick={() => deleteUser(u.id, u.name || u.email)}
|
||
style={{ fontSize: '0.75rem', padding: '3px 8px' }}
|
||
>
|
||
Delete
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{tab === 'settings' && (
|
||
<div className="card">
|
||
<h2>More Settings</h2>
|
||
<div style={{ marginTop: 16 }}>
|
||
|
||
{/* Public Registration */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||
<div>
|
||
<strong style={{ display: 'block', marginBottom: 4 }}>Public Registration</strong>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||
Allow new users to create accounts. Admins can always create users manually.
|
||
</span>
|
||
</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.registration_enabled}
|
||
onChange={async (e) => {
|
||
const enabled = e.target.checked
|
||
setSettings(s => ({ ...s, registration_enabled: enabled }))
|
||
try {
|
||
await api.put('/admin/settings', { registration_enabled: enabled })
|
||
setSuccess(`Registration ${enabled ? 'enabled' : 'disabled'}`)
|
||
} catch (err) {
|
||
setError(err.response?.data?.detail || 'Failed to update setting')
|
||
setSettings(s => ({ ...s, registration_enabled: !enabled }))
|
||
}
|
||
}}
|
||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||
/>
|
||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{settings.registration_enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* AWS Polly */}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
|
||
<div>
|
||
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
|
||
</span>
|
||
</div>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={settings.polly_enabled}
|
||
onChange={async (e) => {
|
||
const enabled = e.target.checked
|
||
setSettings(s => ({ ...s, polly_enabled: enabled }))
|
||
try {
|
||
await api.put('/admin/settings', { polly_enabled: enabled })
|
||
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
|
||
} catch (err) {
|
||
setError(err.response?.data?.detail || 'Failed to update setting')
|
||
setSettings(s => ({ ...s, polly_enabled: !enabled }))
|
||
}
|
||
}}
|
||
style={{ width: 'auto', accentColor: 'var(--primary)' }}
|
||
/>
|
||
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
|
||
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
{/* Embedding model change warning */}
|
||
{embedModelChanged && (
|
||
<div style={{ padding: '12px 16px', background: '#fef3c7', border: '1px solid #f59e0b', borderRadius: 8, marginBottom: 0, marginTop: 8 }}>
|
||
<strong style={{ color: '#92400e', display: 'block', marginBottom: 6 }}>
|
||
⚠️ Embedding model changed
|
||
</strong>
|
||
<span style={{ fontSize: '0.85rem', color: '#78350f' }}>
|
||
Existing question embeddings were generated with the previous model and may produce inaccurate semantic search results.
|
||
Regenerate them to restore full search quality.
|
||
</span>
|
||
<div style={{ marginTop: 10 }}>
|
||
<button className="btn btn-primary btn-sm" onClick={startRegeneration} disabled={regenLoading}>
|
||
{regenLoading ? 'Starting…' : 'Regenerate All Embeddings'}
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm" style={{ marginLeft: 8 }} onClick={() => setEmbedModelChanged(false)}>
|
||
Dismiss
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Embedding Model */}
|
||
<div style={{ padding: '16px 0' }}>
|
||
<strong style={{ display: 'block', marginBottom: 4 }}>Embedding Model</strong>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 12 }}>
|
||
Model used to generate semantic search vectors. Must output {1024} dimensions to match the database schema.
|
||
Current: <code style={{ background: 'var(--bg-secondary)', padding: '1px 6px', borderRadius: 4 }}>{settings.embedding_model || '(not set)'}</code>
|
||
</span>
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||
<input
|
||
type="text"
|
||
value={settings.embedding_model}
|
||
onChange={e => setSettings(s => ({ ...s, embedding_model: e.target.value }))}
|
||
placeholder="e.g. ge-gemini-embedding-001"
|
||
style={{ flex: 1, minWidth: 0 }}
|
||
/>
|
||
<button className="btn btn-primary btn-sm" onClick={() => saveEmbeddingModel(settings.embedding_model)}>Save</button>
|
||
<button className="btn btn-secondary btn-sm" onClick={searchEmbeddingModels} disabled={embedSearchLoading}>
|
||
{embedSearchLoading ? 'Searching...' : 'Search LiteLLM'}
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm" onClick={testEmbedding} disabled={embedTestLoading}>
|
||
{embedTestLoading ? 'Testing...' : 'Test'}
|
||
</button>
|
||
<button className="btn btn-secondary btn-sm" onClick={startRegeneration} disabled={regenLoading} title="Regenerate embeddings for all questions">
|
||
{regenLoading ? 'Starting…' : 'Regenerate All'}
|
||
</button>
|
||
</div>
|
||
{embedTestResult && (
|
||
<div className={`alert ${embedTestResult.ok ? 'alert-success' : 'alert-error'}`} style={{ marginBottom: 8 }}>
|
||
{embedTestResult.ok
|
||
? `OK — ${embedTestResult.model} · ${embedTestResult.dimensions} dims`
|
||
: embedTestResult.error}
|
||
</div>
|
||
)}
|
||
{embedSearchError && <div className="alert alert-error" style={{ marginBottom: 8 }}>{embedSearchError}</div>}
|
||
{embedSearchResults.length > 0 && (
|
||
<div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>{embedSearchResults.length} embedding models found — click to select</span>
|
||
<input
|
||
type="text"
|
||
placeholder="Filter..."
|
||
value={embedSearchFilter}
|
||
onChange={e => setEmbedSearchFilter(e.target.value)}
|
||
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid #d1d5db', fontSize: '0.85rem', width: 180 }}
|
||
/>
|
||
</div>
|
||
<div style={{ maxHeight: 220, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 8 }}>
|
||
{embedSearchResults
|
||
.filter(m => !embedSearchFilter || m.toLowerCase().includes(embedSearchFilter.toLowerCase()))
|
||
.map(modelId => (
|
||
<div
|
||
key={modelId}
|
||
style={{
|
||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||
padding: '8px 12px', borderBottom: '1px solid var(--border)',
|
||
fontSize: '0.85rem', fontFamily: 'monospace',
|
||
}}
|
||
>
|
||
<span style={{ minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{modelId}</span>
|
||
<button className="btn btn-primary btn-sm" style={{ flexShrink: 0 }} onClick={() => saveEmbeddingModel(modelId)}>
|
||
Select
|
||
</button>
|
||
</div>
|
||
))
|
||
}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|