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 (
void handleSubmit(e)} className="p-3 flex flex-col gap-2"> setForm((f) => ({ ...f, slug: e.target.value }))} required placeholder="ex. beatus-lat8878" /> setForm((f) => ({ ...f, title: e.target.value }))} required placeholder="ex. Beatus de Saint-Sever" /> {profiles.length === 0 ? (
Chargement des profils...
) : ( setForm((f) => ({ ...f, profile_id: e.target.value }))} options={profiles.map((p) => ({ value: p.profile_id, label: `${p.label} (${p.profile_id})` }))} /> )} {error && } {success && }
{loading ? 'Création...' : 'Créer le corpus'}
) } // ── 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 && (
void handleSelectModel(e)} className="flex flex-col gap-2 max-w-sm"> {loadingModels &&
Chargement modèles...
} {!loadingModels && modelsError && } {!loadingModels && models.length > 0 && ( p.provider_type === selectedProvider)?.display_name}`} value={selectedModelId} onChange={(e) => setSelectedModelId(e.target.value)} options={models.map((m) => ({ value: m.model_id, label: `${m.display_name}${m.supports_vision ? ' (vision)' : ''}`, }))} /> )} {saveError && } {saveSuccess && } {!loadingModels && models.length > 0 && ( {savingModel ? 'Enregistrement...' : 'Sélectionner'} )} )}
) } // ── 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' && (
void handleUrlsSubmit(e)} className="flex flex-col gap-2"> setUrlsText(e.target.value)} rows={4} placeholder="https://..." /> setFolioLabelsText(e.target.value)} rows={4} placeholder={'001r\n001v'} /> {urlsError && } {urlsSuccess && } {urlsLoading ? 'Ingestion...' : 'Ingerer'} )} {subTab === 'manifest' && (
void handleManifestSubmit(e)} className="flex flex-col gap-2"> setManifestUrl(e.target.value)} required placeholder="https://.../manifest.json" /> {manifestError && } {manifestSuccess && } {manifestLoading ? 'Ingestion...' : 'Importer'} )} {subTab === 'files' && (
void handleFilesSubmit(e)} className="flex flex-col gap-2">
setSelectedFiles(Array.from(e.target.files ?? []))} className="text-retro-sm font-retro border border-retro-black bg-retro-white p-1" /> {selectedFiles.length > 0 && ( {selectedFiles.length} fichier(s) )}
{filesError && } {filesSuccess && } {filesLoading ? 'Envoi...' : 'Envoyer'} )}
) } // ── 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.
)}
) }