import { type FormEvent, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
fetchCorpora,
fetchManuscripts,
fetchPages,
listProfiles,
createCorpus,
deleteCorpus,
fetchProviders,
fetchProviderModels,
selectModel,
getCorpusModel,
ingestImages,
ingestManifest,
ingestFiles,
runCorpus,
getJob,
retryJob,
type Corpus,
type CorpusProfile,
type CorpusModelConfig,
type ProviderInfo,
type ModelInfo,
type Job,
type CreateCorpusInput,
} from '../lib/api.ts'
import {
RetroMenuBar,
RetroWindow,
RetroButton,
RetroInput,
RetroTextarea,
RetroSelect,
RetroBadge,
} from '../components/retro'
type IngestSubTab = 'urls' | 'manifest' | 'files'
// ── Feedback helpers ───────────────────────────────────────────────────────
function ErrorMsg({ message }: { message: string }) {
return (
Erreur: {message}
)
}
function SuccessMsg({ message }: { message: string }) {
return (
OK: {message}
)
}
// ── CreateCorpusPanel ─────────────────────────────────────────────────────
interface CreateCorpusPanelProps {
onCreated: (corpus: Corpus) => void
}
function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
const [profiles, setProfiles] = useState([])
const [form, setForm] = useState({ slug: '', title: '', profile_id: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
useEffect(() => {
listProfiles()
.then((ps) => {
setProfiles(ps)
if (ps.length > 0) setForm((f) => ({ ...f, profile_id: ps[0].profile_id }))
})
.catch(() => {})
}, [])
const handleSubmit = async (e: FormEvent) => {
e.preventDefault()
setError(null)
setSuccess(null)
setLoading(true)
try {
const corpus = await createCorpus(form)
setSuccess(`Corpus "${corpus.title}" créé.`)
setForm((f) => ({ ...f, slug: '', title: '' }))
onCreated(corpus)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue')
} finally {
setLoading(false)
}
}
return (
)
}
// ── ModelPanel ────────────────────────────────────────────────────────────
interface ModelPanelProps {
corpusId: string
onSaved: () => void
}
function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
const [providers, setProviders] = useState([])
const [loadingProviders, setLoadingProviders] = useState(true)
const [providersError, setProvidersError] = useState(null)
const [selectedProvider, setSelectedProvider] = useState('')
const [models, setModels] = useState([])
const [loadingModels, setLoadingModels] = useState(false)
const [modelsError, setModelsError] = useState(null)
const [selectedModelId, setSelectedModelId] = useState('')
const [currentModel, setCurrentModel] = useState(null)
const [savingModel, setSavingModel] = useState(false)
const [saveError, setSaveError] = useState(null)
const [saveSuccess, setSaveSuccess] = useState(null)
useEffect(() => {
void getCorpusModel(corpusId).then(setCurrentModel)
setLoadingProviders(true)
setProvidersError(null)
fetchProviders()
.then((ps) => {
setProviders(ps)
const first = ps.find((p) => p.available)
if (first) setSelectedProvider(first.provider_type)
})
.catch((err) => setProvidersError(err instanceof Error ? err.message : 'Erreur'))
.finally(() => setLoadingProviders(false))
}, [corpusId])
useEffect(() => {
if (!selectedProvider) return
setModels([])
setSelectedModelId('')
setModelsError(null)
setLoadingModels(true)
fetchProviderModels(selectedProvider)
.then((ms) => { setModels(ms); if (ms.length > 0) setSelectedModelId(ms[0].model_id) })
.catch((err) => setModelsError(err instanceof Error ? err.message : 'Erreur'))
.finally(() => setLoadingModels(false))
}, [selectedProvider])
const handleSelectModel = async (e: FormEvent) => {
e.preventDefault()
setSaveError(null)
setSaveSuccess(null)
setSavingModel(true)
const model = models.find((m) => m.model_id === selectedModelId)
try {
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider, model?.supports_vision ?? true)
const updated = await getCorpusModel(corpusId)
setCurrentModel(updated)
setSaveSuccess(`Modèle "${model?.display_name ?? selectedModelId}" associé.`)
onSaved()
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Erreur')
} finally {
setSavingModel(false)
}
}
return (
{currentModel && (
Modèle actuel: {currentModel.selected_model_display_name}
{' '}({currentModel.provider_type})
)}
{loadingProviders &&
Détection providers...
}
{!loadingProviders && providersError &&
}
{!loadingProviders && providers.length > 0 && (
{providers.map((p) => (
p.available && setSelectedProvider(p.provider_type)}
>
{p.display_name} {p.available ? `(${p.model_count})` : '— N/A'}
))}
)}
{selectedProvider && (
)}
)
}
// ── IngestPanel ───────────────────────────────────────────────────────────
function IngestPanel({ corpusId }: { corpusId: string }) {
const [subTab, setSubTab] = useState('urls')
const [urlsText, setUrlsText] = useState('')
const [folioLabelsText, setFolioLabelsText] = useState('')
const [urlsLoading, setUrlsLoading] = useState(false)
const [urlsError, setUrlsError] = useState(null)
const [urlsSuccess, setUrlsSuccess] = useState(null)
const [manifestUrl, setManifestUrl] = useState('')
const [manifestLoading, setManifestLoading] = useState(false)
const [manifestError, setManifestError] = useState(null)
const [manifestSuccess, setManifestSuccess] = useState(null)
const [selectedFiles, setSelectedFiles] = useState([])
const [filesLoading, setFilesLoading] = useState(false)
const [filesError, setFilesError] = useState(null)
const [filesSuccess, setFilesSuccess] = useState(null)
const handleUrlsSubmit = async (e: FormEvent) => {
e.preventDefault()
setUrlsError(null); setUrlsSuccess(null)
const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
if (urls.length === 0) { setUrlsError('Aucune URL.'); return }
if (labels.length !== urls.length) { setUrlsError(`Labels (${labels.length}) != URLs (${urls.length})`); return }
setUrlsLoading(true)
try {
const resp = await ingestImages(corpusId, urls, labels)
setUrlsSuccess(`${resp.pages_created} page(s) ingérée(s).`)
setUrlsText(''); setFolioLabelsText('')
} catch (err) { setUrlsError(err instanceof Error ? err.message : 'Erreur') }
finally { setUrlsLoading(false) }
}
const handleManifestSubmit = async (e: FormEvent) => {
e.preventDefault()
setManifestError(null); setManifestSuccess(null); setManifestLoading(true)
try {
const resp = await ingestManifest(corpusId, manifestUrl)
setManifestSuccess(`${resp.pages_created} page(s) ingérée(s).`)
setManifestUrl('')
} catch (err) { setManifestError(err instanceof Error ? err.message : 'Erreur') }
finally { setManifestLoading(false) }
}
const handleFilesSubmit = async (e: FormEvent) => {
e.preventDefault()
setFilesError(null); setFilesSuccess(null)
if (selectedFiles.length === 0) { setFilesError('Aucun fichier.'); return }
setFilesLoading(true)
try {
const resp = await ingestFiles(corpusId, selectedFiles)
setFilesSuccess(`${resp.pages_created} page(s) ingérée(s).`)
setSelectedFiles([])
} catch (err) { setFilesError(err instanceof Error ? err.message : 'Erreur') }
finally { setFilesLoading(false) }
}
return (
{(['urls', 'manifest', 'files'] as IngestSubTab[]).map((t) => (
setSubTab(t)}>
{t === 'urls' ? 'URLs' : t === 'manifest' ? 'Manifest' : 'Fichiers'}
))}
{subTab === 'urls' && (
)}
{subTab === 'manifest' && (
)}
{subTab === 'files' && (
)}
)
}
// ── RunPanel ──────────────────────────────────────────────────────────────
function RunPanel({ corpusId, hasModel }: { corpusId: string; hasModel: boolean }) {
const [pageCount, setPageCount] = useState(null)
const [launching, setLaunching] = useState(false)
const [launchError, setLaunchError] = useState(null)
const [jobIds, setJobIds] = useState([])
const [jobs, setJobs] = useState>({})
const [polling, setPolling] = useState(false)
useEffect(() => {
fetchManuscripts(corpusId)
.then(async (manuscripts) => {
if (manuscripts.length === 0) { setPageCount(0); return }
const pagesArrays = await Promise.all(manuscripts.map((m) => fetchPages(m.id)))
setPageCount(pagesArrays.reduce((sum, ps) => sum + ps.length, 0))
})
.catch(() => setPageCount(null))
}, [corpusId])
useEffect(() => {
if (!polling || jobIds.length === 0) return
const poll = async () => {
try {
const results = await Promise.all(jobIds.map((id) => getJob(id)))
const map: Record = {}
for (const job of results) map[job.id] = job
setJobs(map)
if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
} catch { /* transient */ }
}
const id = setInterval(() => void poll(), 3000)
return () => clearInterval(id)
}, [polling, jobIds])
const handleRun = async () => {
setLaunchError(null); setJobIds([]); setJobs({}); setLaunching(true)
try {
const resp = await runCorpus(corpusId)
setJobIds(resp.job_ids); setPolling(true)
} catch (err) { setLaunchError(err instanceof Error ? err.message : 'Erreur') }
finally { setLaunching(false) }
}
const handleRetryFailed = async () => {
if (polling || launching) return
const failedIds = Object.values(jobs).filter((j) => j.status === 'failed').map((j) => j.id)
if (failedIds.length === 0) return
await Promise.allSettled(failedIds.map((id) => retryJob(id)))
setPolling(true)
}
const jobList = Object.values(jobs)
const doneCount = jobList.filter((j) => j.status === 'done').length
const failedCount = jobList.filter((j) => j.status === 'failed').length
const totalCount = jobList.length
const statusVariant = (s: string): 'default' | 'success' | 'warning' | 'error' | 'info' => {
if (s === 'done') return 'success'
if (s === 'failed') return 'error'
if (s === 'running') return 'info'
return 'default'
}
if (!hasModel) {
return Configurez d'abord un modèle IA.
}
return (
{pageCount !== null && (
{pageCount === 0 ? 'Aucune page ingérée.' : `${pageCount} page(s).`}
)}
{launchError &&
}
void handleRun()} disabled={launching || polling || pageCount === 0}>
{launching ? 'Démarrage...' : polling ? 'En cours...' : 'Analyser tout'}
{failedCount > 0 && !polling && (
void handleRetryFailed()}>
Relancer {failedCount} erreur(s)
)}
{totalCount > 0 && (
{doneCount}/{totalCount} traitées
{failedCount > 0 && {failedCount} erreur(s)}
{polling && (actualisation 3s)}
{jobList.map((job) => (
{job.page_id ?? job.id}
{job.status}
{job.error_message && {job.error_message}}
))}
)}
)
}
// ── CorpusDetail ──────────────────────────────────────────────────────────
function CorpusDetail({ corpus, onDeleted }: { corpus: Corpus; onDeleted: () => void }) {
const [hasModel, setHasModel] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState(null)
const [confirmDelete, setConfirmDelete] = useState(false)
useEffect(() => {
getCorpusModel(corpus.id).then((m) => setHasModel(m !== null)).catch(() => {})
}, [corpus.id])
const handleDelete = async () => {
setDeleteError(null); setDeleting(true)
try { await deleteCorpus(corpus.id); onDeleted() }
catch (err) { setDeleteError(err instanceof Error ? err.message : 'Erreur'); setDeleting(false); setConfirmDelete(false) }
}
return (
{/* Header */}
{corpus.title}
{corpus.slug} — {corpus.profile_id}
{deleteError && {deleteError}}
{confirmDelete ? (
<>
Confirmer?
void handleDelete()} disabled={deleting}>
{deleting ? '...' : 'Oui'}
setConfirmDelete(false)}>Non
>
) : (
setConfirmDelete(true)}>Supprimer
)}
setHasModel(true)} />
)
}
// ── Admin (main component) ─────────────────────────────────────────────────
export default function Admin() {
const navigate = useNavigate()
const onHome = () => navigate('/')
const [corpora, setCorpora] = useState([])
const [selectedCorpusId, setSelectedCorpusId] = useState(null)
const [showCreate, setShowCreate] = useState(false)
const didInit = useRef(false)
const refreshCorpora = (selectId?: string) => {
fetchCorpora()
.then((cs) => {
setCorpora(cs)
if (selectId) { setSelectedCorpusId(selectId); setShowCreate(false) }
else if (!didInit.current) {
didInit.current = true
if (cs.length > 0) { setSelectedCorpusId(cs[0].id); setShowCreate(false) }
else setShowCreate(true)
}
})
.catch(() => {})
}
useEffect(() => { refreshCorpora() }, [])
const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
return (
{/* Sidebar */}
{corpora.length === 0 && (
Aucun corpus
)}
{corpora.map((c) => (
))}
{/* Main panel */}
{showCreate && !selectedCorpusId && (
refreshCorpora(corpus.id)} />
)}
{!showCreate && selectedCorpus && (
{
const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
setCorpora(remaining)
if (remaining.length > 0) { setSelectedCorpusId(remaining[0].id); setShowCreate(false) }
else { setSelectedCorpusId(null); setShowCreate(true) }
}}
/>
)}
{!showCreate && !selectedCorpus && corpora.length > 0 && (
Sélectionnez un corpus.
)}
)
}