Spaces:
Build error
fix(api): guard catch-all against /api/* paths in production + refactor Admin UI
Browse filesBUG 1: main.py catch-all now raises 404 for any path starting with "api/" to
prevent the SPA index.html from being served instead of a proper 404 response
when a frontend build is present in /app/static.
BUG 2: Admin.tsx fully refactored from 4-tab layout to sidebar + corpus detail:
- Left sidebar (w-64) lists corpora + "Nouveau corpus" button
- Right panel shows CreateCorpusPanel or CorpusDetail
- CorpusDetail has 3 stacked SectionCards: Modèle IA, Ingestion, Traitement
- ModelPanel auto-loads existing model config on mount (getCorpusModel)
- RunPanel shows page count (via manuscripts→pages) and job polling
- Delete corpus with inline confirmation flow
- No CorpusSelector dropdowns — corpus is always from sidebar selection
Also adds to api.ts: del() helper, deleteCorpus(), getCorpusModel(),
CorpusModelConfig interface.
https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg
- backend/app/main.py +3 -1
- frontend/src/lib/api.ts +25 -0
- frontend/src/pages/Admin.tsx +346 -304
|
@@ -11,7 +11,7 @@ from contextlib import asynccontextmanager
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
# 2. third-party
|
| 14 |
-
from fastapi import FastAPI
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from fastapi.responses import FileResponse, RedirectResponse
|
| 17 |
|
|
@@ -94,6 +94,8 @@ _STATIC_DIR = Path("/app/static")
|
|
| 94 |
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
|
| 95 |
async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse:
|
| 96 |
"""En production sert le frontend React (SPA). En dev redirige vers /docs."""
|
|
|
|
|
|
|
| 97 |
if _STATIC_DIR.is_dir():
|
| 98 |
candidate = _STATIC_DIR / full_path
|
| 99 |
if candidate.is_file():
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
# 2. third-party
|
| 14 |
+
from fastapi import FastAPI, HTTPException
|
| 15 |
from fastapi.middleware.cors import CORSMiddleware
|
| 16 |
from fastapi.responses import FileResponse, RedirectResponse
|
| 17 |
|
|
|
|
| 94 |
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
|
| 95 |
async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse:
|
| 96 |
"""En production sert le frontend React (SPA). En dev redirige vers /docs."""
|
| 97 |
+
if full_path.startswith("api/"):
|
| 98 |
+
raise HTTPException(status_code=404, detail=f"Endpoint not found: /{full_path}")
|
| 99 |
if _STATIC_DIR.is_dir():
|
| 100 |
candidate = _STATIC_DIR / full_path
|
| 101 |
if candidate.is_file():
|
|
@@ -204,6 +204,15 @@ async function put<T>(path: string, body?: unknown): Promise<T> {
|
|
| 204 |
return resp.json() as Promise<T>
|
| 205 |
}
|
| 206 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
async function postForm<T>(path: string, data: FormData): Promise<T> {
|
| 208 |
const resp = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: data })
|
| 209 |
if (!resp.ok) {
|
|
@@ -258,6 +267,22 @@ export const selectModel = (
|
|
| 258 |
provider_type: providerType,
|
| 259 |
})
|
| 260 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
export const ingestImages = (
|
| 262 |
corpusId: string,
|
| 263 |
urls: string[],
|
|
|
|
| 204 |
return resp.json() as Promise<T>
|
| 205 |
}
|
| 206 |
|
| 207 |
+
async function del(path: string): Promise<void> {
|
| 208 |
+
const resp = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' })
|
| 209 |
+
if (!resp.ok) {
|
| 210 |
+
const payload = await resp.json().catch(() => null)
|
| 211 |
+
const detail = (payload as { detail?: string } | null)?.detail
|
| 212 |
+
throw new Error(detail ?? `HTTP ${resp.status} — ${path}`)
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
async function postForm<T>(path: string, data: FormData): Promise<T> {
|
| 217 |
const resp = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: data })
|
| 218 |
if (!resp.ok) {
|
|
|
|
| 267 |
provider_type: providerType,
|
| 268 |
})
|
| 269 |
|
| 270 |
+
export const deleteCorpus = (id: string): Promise<void> =>
|
| 271 |
+
del(`/api/v1/corpora/${id}`)
|
| 272 |
+
|
| 273 |
+
export interface CorpusModelConfig {
|
| 274 |
+
corpus_id: string
|
| 275 |
+
selected_model_id: string
|
| 276 |
+
selected_model_display_name: string
|
| 277 |
+
provider_type: string
|
| 278 |
+
updated_at: string
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
export const getCorpusModel = (corpusId: string): Promise<CorpusModelConfig | null> =>
|
| 282 |
+
fetch(`${BASE_URL}/api/v1/corpora/${corpusId}/model`)
|
| 283 |
+
.then((r) => (r.ok ? (r.json() as Promise<CorpusModelConfig>) : null))
|
| 284 |
+
.catch(() => null)
|
| 285 |
+
|
| 286 |
export const ingestImages = (
|
| 287 |
corpusId: string,
|
| 288 |
urls: string[],
|
|
@@ -1,11 +1,15 @@
|
|
| 1 |
-
import { type FormEvent, useEffect, useState } from 'react'
|
| 2 |
import {
|
| 3 |
fetchCorpora,
|
|
|
|
|
|
|
| 4 |
listProfiles,
|
| 5 |
createCorpus,
|
|
|
|
| 6 |
fetchProviders,
|
| 7 |
fetchProviderModels,
|
| 8 |
selectModel,
|
|
|
|
| 9 |
ingestImages,
|
| 10 |
ingestManifest,
|
| 11 |
ingestFiles,
|
|
@@ -14,55 +18,19 @@ import {
|
|
| 14 |
retryJob,
|
| 15 |
type Corpus,
|
| 16 |
type CorpusProfile,
|
|
|
|
| 17 |
type ProviderInfo,
|
| 18 |
type ModelInfo,
|
| 19 |
type Job,
|
| 20 |
type CreateCorpusInput,
|
| 21 |
} from '../lib/api.ts'
|
| 22 |
|
| 23 |
-
type AdminTab = 'corpus' | 'model' | 'ingest' | 'run'
|
| 24 |
type IngestSubTab = 'urls' | 'manifest' | 'files'
|
| 25 |
|
| 26 |
interface Props {
|
| 27 |
onHome: () => void
|
| 28 |
}
|
| 29 |
|
| 30 |
-
// ── CorpusSelector ─────────────────────────────────────────────────────────
|
| 31 |
-
|
| 32 |
-
interface CorpusSelectorProps {
|
| 33 |
-
corpora: Corpus[]
|
| 34 |
-
value: string
|
| 35 |
-
onChange: (id: string) => void
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
function CorpusSelector({ corpora, value, onChange }: CorpusSelectorProps) {
|
| 39 |
-
if (corpora.length === 0) {
|
| 40 |
-
return (
|
| 41 |
-
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2 mb-6">
|
| 42 |
-
Aucun corpus. Créez-en un dans l'onglet « Nouveau corpus ».
|
| 43 |
-
</p>
|
| 44 |
-
)
|
| 45 |
-
}
|
| 46 |
-
return (
|
| 47 |
-
<div className="mb-6">
|
| 48 |
-
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 49 |
-
Corpus cible
|
| 50 |
-
</label>
|
| 51 |
-
<select
|
| 52 |
-
value={value}
|
| 53 |
-
onChange={(e) => onChange(e.target.value)}
|
| 54 |
-
className="border border-stone-300 rounded px-3 py-2 text-sm w-full max-w-sm bg-white focus:outline-none focus:ring-2 focus:ring-stone-400"
|
| 55 |
-
>
|
| 56 |
-
{corpora.map((c) => (
|
| 57 |
-
<option key={c.id} value={c.id}>
|
| 58 |
-
{c.title} ({c.slug})
|
| 59 |
-
</option>
|
| 60 |
-
))}
|
| 61 |
-
</select>
|
| 62 |
-
</div>
|
| 63 |
-
)
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
// ── Feedback helpers ───────────────────────────────────────────────────────
|
| 67 |
|
| 68 |
function ErrorMsg({ message }: { message: string }) {
|
|
@@ -81,19 +49,26 @@ function SuccessMsg({ message }: { message: string }) {
|
|
| 81 |
)
|
| 82 |
}
|
| 83 |
|
| 84 |
-
// ──
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
interface
|
| 87 |
onCreated: (corpus: Corpus) => void
|
| 88 |
}
|
| 89 |
|
| 90 |
-
function
|
| 91 |
const [profiles, setProfiles] = useState<CorpusProfile[]>([])
|
| 92 |
-
const [form, setForm] = useState<CreateCorpusInput>({
|
| 93 |
-
slug: '',
|
| 94 |
-
title: '',
|
| 95 |
-
profile_id: '',
|
| 96 |
-
})
|
| 97 |
const [loading, setLoading] = useState(false)
|
| 98 |
const [error, setError] = useState<string | null>(null)
|
| 99 |
const [success, setSuccess] = useState<string | null>(null)
|
|
@@ -114,7 +89,7 @@ function CreateCorpusSection({ onCreated }: CreateCorpusSectionProps) {
|
|
| 114 |
setLoading(true)
|
| 115 |
try {
|
| 116 |
const corpus = await createCorpus(form)
|
| 117 |
-
setSuccess(`Corpus « ${corpus.title} » créé
|
| 118 |
setForm((f) => ({ ...f, slug: '', title: '' }))
|
| 119 |
onCreated(corpus)
|
| 120 |
} catch (err) {
|
|
@@ -128,15 +103,13 @@ function CreateCorpusSection({ onCreated }: CreateCorpusSectionProps) {
|
|
| 128 |
'border border-stone-300 rounded px-3 py-2 text-sm w-full focus:outline-none focus:ring-2 focus:ring-stone-400'
|
| 129 |
|
| 130 |
return (
|
| 131 |
-
<
|
| 132 |
-
<h2 className="text-
|
| 133 |
-
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4
|
| 134 |
<div>
|
| 135 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 136 |
Slug{' '}
|
| 137 |
-
<span className="text-stone-400 font-normal normal-case">
|
| 138 |
-
(identifiant unique, sans espaces)
|
| 139 |
-
</span>
|
| 140 |
</label>
|
| 141 |
<input
|
| 142 |
type="text"
|
|
@@ -190,38 +163,36 @@ function CreateCorpusSection({ onCreated }: CreateCorpusSectionProps) {
|
|
| 190 |
{loading ? 'Création…' : 'Créer le corpus'}
|
| 191 |
</button>
|
| 192 |
</form>
|
| 193 |
-
</
|
| 194 |
)
|
| 195 |
}
|
| 196 |
|
| 197 |
-
// ──
|
| 198 |
|
| 199 |
-
interface
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
onSelectCorpus: (id: string) => void
|
| 203 |
}
|
| 204 |
|
| 205 |
-
function
|
| 206 |
-
// Étape 1 : providers détectés automatiquement
|
| 207 |
const [providers, setProviders] = useState<ProviderInfo[]>([])
|
| 208 |
const [loadingProviders, setLoadingProviders] = useState(true)
|
| 209 |
const [providersError, setProvidersError] = useState<string | null>(null)
|
| 210 |
|
| 211 |
-
// Étape 2 : provider sélectionné → charge ses modèles
|
| 212 |
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
| 213 |
const [models, setModels] = useState<ModelInfo[]>([])
|
| 214 |
const [loadingModels, setLoadingModels] = useState(false)
|
| 215 |
const [modelsError, setModelsError] = useState<string | null>(null)
|
| 216 |
const [selectedModelId, setSelectedModelId] = useState('')
|
| 217 |
|
| 218 |
-
|
| 219 |
const [savingModel, setSavingModel] = useState(false)
|
| 220 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 221 |
const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
|
| 222 |
|
| 223 |
-
//
|
| 224 |
useEffect(() => {
|
|
|
|
| 225 |
setLoadingProviders(true)
|
| 226 |
setProvidersError(null)
|
| 227 |
fetchProviders()
|
|
@@ -234,9 +205,9 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 234 |
setProvidersError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 235 |
})
|
| 236 |
.finally(() => setLoadingProviders(false))
|
| 237 |
-
}, [])
|
| 238 |
|
| 239 |
-
//
|
| 240 |
useEffect(() => {
|
| 241 |
if (!selectedProvider) return
|
| 242 |
setModels([])
|
|
@@ -261,15 +232,11 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 261 |
setSavingModel(true)
|
| 262 |
const model = models.find((m) => m.model_id === selectedModelId)
|
| 263 |
try {
|
| 264 |
-
await selectModel(
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
)
|
| 270 |
-
setSaveSuccess(
|
| 271 |
-
`Modèle « ${model?.display_name ?? selectedModelId} » associé au corpus.`,
|
| 272 |
-
)
|
| 273 |
} catch (err) {
|
| 274 |
setSaveError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 275 |
} finally {
|
|
@@ -280,17 +247,21 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 280 |
const availableProviders = providers.filter((p) => p.available)
|
| 281 |
|
| 282 |
return (
|
| 283 |
-
<
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
-
{/* Étape 1 — Providers détectés */}
|
| 288 |
{loadingProviders && (
|
| 289 |
-
<p className="text-sm text-stone-400
|
| 290 |
)}
|
| 291 |
{!loadingProviders && providersError && <ErrorMsg message={providersError} />}
|
| 292 |
{!loadingProviders && providers.length > 0 && (
|
| 293 |
-
<div className="mb-
|
| 294 |
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">
|
| 295 |
Providers IA détectés
|
| 296 |
</p>
|
|
@@ -305,36 +276,27 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 305 |
} ${selectedProvider === p.provider_type ? 'ring-2 ring-stone-500' : ''}`}
|
| 306 |
onClick={() => p.available && setSelectedProvider(p.provider_type)}
|
| 307 |
>
|
| 308 |
-
<span
|
| 309 |
-
className={`w-1.5 h-1.5 rounded-full ${p.available ? 'bg-green-500' : 'bg-stone-300'}`}
|
| 310 |
-
/>
|
| 311 |
{p.display_name}
|
| 312 |
-
{p.available && (
|
| 313 |
-
<span className="text-green-600">({p.model_count})</span>
|
| 314 |
-
)}
|
| 315 |
{!p.available && <span className="text-stone-400">— clé manquante</span>}
|
| 316 |
</span>
|
| 317 |
))}
|
| 318 |
</div>
|
| 319 |
{availableProviders.length === 0 && (
|
| 320 |
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2 mt-3">
|
| 321 |
-
Aucun
|
| 322 |
-
<code className="font-mono">
|
| 323 |
-
<code className="font-mono">VERTEX_API_KEY</code>
|
| 324 |
-
<code className="font-mono">
|
| 325 |
-
<code className="font-mono">MISTRAL_API_KEY</code>) sont bien configurés
|
| 326 |
-
dans les secrets HuggingFace.
|
| 327 |
</p>
|
| 328 |
)}
|
| 329 |
</div>
|
| 330 |
)}
|
| 331 |
|
| 332 |
-
{/* Étape 2 + 3 — Sélection du modèle et enregistrement */}
|
| 333 |
{selectedProvider && (
|
| 334 |
-
<form onSubmit={(e) => void handleSelectModel(e)} className="space-y-
|
| 335 |
-
{loadingModels &&
|
| 336 |
-
<p className="text-sm text-stone-400">Chargement des modèles…</p>
|
| 337 |
-
)}
|
| 338 |
{!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
|
| 339 |
{!loadingModels && models.length > 0 && (
|
| 340 |
<div>
|
|
@@ -348,8 +310,7 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 348 |
>
|
| 349 |
{models.map((m) => (
|
| 350 |
<option key={m.model_id} value={m.model_id}>
|
| 351 |
-
{m.display_name}
|
| 352 |
-
{m.supports_vision ? ' (vision)' : ''}
|
| 353 |
</option>
|
| 354 |
))}
|
| 355 |
</select>
|
|
@@ -360,7 +321,7 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 360 |
{!loadingModels && models.length > 0 && (
|
| 361 |
<button
|
| 362 |
type="submit"
|
| 363 |
-
disabled={savingModel || !
|
| 364 |
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 365 |
>
|
| 366 |
{savingModel ? 'Enregistrement…' : 'Sélectionner ce modèle'}
|
|
@@ -368,35 +329,30 @@ function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectio
|
|
| 368 |
)}
|
| 369 |
</form>
|
| 370 |
)}
|
| 371 |
-
</
|
| 372 |
)
|
| 373 |
}
|
| 374 |
|
| 375 |
-
// ──
|
| 376 |
|
| 377 |
-
interface
|
| 378 |
-
|
| 379 |
-
selectedCorpusId: string
|
| 380 |
-
onSelectCorpus: (id: string) => void
|
| 381 |
}
|
| 382 |
|
| 383 |
-
function
|
| 384 |
const [subTab, setSubTab] = useState<IngestSubTab>('urls')
|
| 385 |
|
| 386 |
-
// URLs tab
|
| 387 |
const [urlsText, setUrlsText] = useState('')
|
| 388 |
const [folioLabelsText, setFolioLabelsText] = useState('')
|
| 389 |
const [urlsLoading, setUrlsLoading] = useState(false)
|
| 390 |
const [urlsError, setUrlsError] = useState<string | null>(null)
|
| 391 |
const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
|
| 392 |
|
| 393 |
-
// Manifest tab
|
| 394 |
const [manifestUrl, setManifestUrl] = useState('')
|
| 395 |
const [manifestLoading, setManifestLoading] = useState(false)
|
| 396 |
const [manifestError, setManifestError] = useState<string | null>(null)
|
| 397 |
const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
|
| 398 |
|
| 399 |
-
// Files tab
|
| 400 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
| 401 |
const [filesLoading, setFilesLoading] = useState(false)
|
| 402 |
const [filesError, setFilesError] = useState<string | null>(null)
|
|
@@ -408,19 +364,14 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 408 |
setUrlsSuccess(null)
|
| 409 |
const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 410 |
const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 411 |
-
if (urls.length === 0) {
|
| 412 |
-
setUrlsError('Aucune URL renseignée.')
|
| 413 |
-
return
|
| 414 |
-
}
|
| 415 |
if (labels.length !== urls.length) {
|
| 416 |
-
setUrlsError(
|
| 417 |
-
`Le nombre de folio_labels (${labels.length}) doit être égal au nombre d'URLs (${urls.length}).`,
|
| 418 |
-
)
|
| 419 |
return
|
| 420 |
}
|
| 421 |
setUrlsLoading(true)
|
| 422 |
try {
|
| 423 |
-
const resp = await ingestImages(
|
| 424 |
setUrlsSuccess(`${resp.pages_created} page(s) ingérée(s).`)
|
| 425 |
setUrlsText('')
|
| 426 |
setFolioLabelsText('')
|
|
@@ -437,7 +388,7 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 437 |
setManifestSuccess(null)
|
| 438 |
setManifestLoading(true)
|
| 439 |
try {
|
| 440 |
-
const resp = await ingestManifest(
|
| 441 |
setManifestSuccess(`${resp.pages_created} page(s) ingérée(s) depuis le manifest.`)
|
| 442 |
setManifestUrl('')
|
| 443 |
} catch (err) {
|
|
@@ -451,13 +402,10 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 451 |
e.preventDefault()
|
| 452 |
setFilesError(null)
|
| 453 |
setFilesSuccess(null)
|
| 454 |
-
if (selectedFiles.length === 0) {
|
| 455 |
-
setFilesError('Aucun fichier sélectionné.')
|
| 456 |
-
return
|
| 457 |
-
}
|
| 458 |
setFilesLoading(true)
|
| 459 |
try {
|
| 460 |
-
const resp = await ingestFiles(
|
| 461 |
setFilesSuccess(`${resp.pages_created} page(s) ingérée(s).`)
|
| 462 |
setSelectedFiles([])
|
| 463 |
} catch (err) {
|
|
@@ -476,68 +424,53 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 476 |
|
| 477 |
const textareaClass =
|
| 478 |
'border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400'
|
| 479 |
-
|
| 480 |
const submitBtnClass =
|
| 481 |
'bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
| 482 |
|
| 483 |
return (
|
| 484 |
-
<
|
| 485 |
-
<
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
<div className="flex border-b border-stone-200 mb-6">
|
| 490 |
-
<button className={subTabClass('urls')} onClick={() => setSubTab('urls')}>
|
| 491 |
-
URLs directes
|
| 492 |
-
</button>
|
| 493 |
-
<button className={subTabClass('manifest')} onClick={() => setSubTab('manifest')}>
|
| 494 |
-
Manifest IIIF
|
| 495 |
-
</button>
|
| 496 |
-
<button className={subTabClass('files')} onClick={() => setSubTab('files')}>
|
| 497 |
-
Fichiers locaux
|
| 498 |
-
</button>
|
| 499 |
</div>
|
| 500 |
|
| 501 |
-
{/* URLs tab */}
|
| 502 |
{subTab === 'urls' && (
|
| 503 |
-
<form onSubmit={(e) => void handleUrlsSubmit(e)} className="space-y-
|
| 504 |
<div>
|
| 505 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 506 |
-
URLs d'images
|
| 507 |
-
<span className="font-normal normal-case text-stone-400">(1 par ligne)</span>
|
| 508 |
</label>
|
| 509 |
<textarea
|
| 510 |
value={urlsText}
|
| 511 |
onChange={(e) => setUrlsText(e.target.value)}
|
| 512 |
-
rows={
|
| 513 |
placeholder="https://gallica.bnf.fr/iiif/ark:/…/f1/full/max/0/native.jpg"
|
| 514 |
className={textareaClass}
|
| 515 |
/>
|
| 516 |
</div>
|
| 517 |
<div>
|
| 518 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 519 |
-
Folio labels
|
| 520 |
-
<span className="font-normal normal-case text-stone-400">(1 par ligne, même ordre)</span>
|
| 521 |
</label>
|
| 522 |
<textarea
|
| 523 |
value={folioLabelsText}
|
| 524 |
onChange={(e) => setFolioLabelsText(e.target.value)}
|
| 525 |
-
rows={
|
| 526 |
placeholder={'001r\n001v\n002r'}
|
| 527 |
className={textareaClass}
|
| 528 |
/>
|
| 529 |
</div>
|
| 530 |
{urlsError && <ErrorMsg message={urlsError} />}
|
| 531 |
{urlsSuccess && <SuccessMsg message={urlsSuccess} />}
|
| 532 |
-
<button type="submit" disabled={urlsLoading
|
| 533 |
{urlsLoading ? 'Ingestion…' : 'Ingérer les images'}
|
| 534 |
</button>
|
| 535 |
</form>
|
| 536 |
)}
|
| 537 |
|
| 538 |
-
{/* Manifest tab */}
|
| 539 |
{subTab === 'manifest' && (
|
| 540 |
-
<form onSubmit={(e) => void handleManifestSubmit(e)} className="space-y-
|
| 541 |
<div>
|
| 542 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 543 |
URL du manifest IIIF
|
|
@@ -553,19 +486,14 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 553 |
</div>
|
| 554 |
{manifestError && <ErrorMsg message={manifestError} />}
|
| 555 |
{manifestSuccess && <SuccessMsg message={manifestSuccess} />}
|
| 556 |
-
<button
|
| 557 |
-
type="submit"
|
| 558 |
-
disabled={manifestLoading || !selectedCorpusId || !manifestUrl}
|
| 559 |
-
className={submitBtnClass}
|
| 560 |
-
>
|
| 561 |
{manifestLoading ? 'Ingestion…' : 'Importer le manifest'}
|
| 562 |
</button>
|
| 563 |
</form>
|
| 564 |
)}
|
| 565 |
|
| 566 |
-
{/* Files tab */}
|
| 567 |
{subTab === 'files' && (
|
| 568 |
-
<form onSubmit={(e) => void handleFilesSubmit(e)} className="space-y-
|
| 569 |
<div>
|
| 570 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 571 |
Fichiers images
|
|
@@ -578,59 +506,59 @@ function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSect
|
|
| 578 |
className="block text-sm text-stone-600 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-stone-100 file:text-stone-700 hover:file:bg-stone-200"
|
| 579 |
/>
|
| 580 |
{selectedFiles.length > 0 && (
|
| 581 |
-
<p className="text-xs text-stone-500 mt-1">
|
| 582 |
-
{selectedFiles.length} fichier(s) sélectionné(s)
|
| 583 |
-
</p>
|
| 584 |
)}
|
| 585 |
</div>
|
| 586 |
{filesError && <ErrorMsg message={filesError} />}
|
| 587 |
{filesSuccess && <SuccessMsg message={filesSuccess} />}
|
| 588 |
-
<button
|
| 589 |
-
type="submit"
|
| 590 |
-
disabled={filesLoading || !selectedCorpusId || selectedFiles.length === 0}
|
| 591 |
-
className={submitBtnClass}
|
| 592 |
-
>
|
| 593 |
{filesLoading ? 'Envoi…' : 'Envoyer les fichiers'}
|
| 594 |
</button>
|
| 595 |
</form>
|
| 596 |
)}
|
| 597 |
-
</
|
| 598 |
)
|
| 599 |
}
|
| 600 |
|
| 601 |
-
// ──
|
| 602 |
|
| 603 |
-
interface
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
onSelectCorpus: (id: string) => void
|
| 607 |
}
|
| 608 |
|
| 609 |
-
function
|
|
|
|
| 610 |
const [launching, setLaunching] = useState(false)
|
| 611 |
const [launchError, setLaunchError] = useState<string | null>(null)
|
| 612 |
const [jobIds, setJobIds] = useState<string[]>([])
|
| 613 |
const [jobs, setJobs] = useState<Record<string, Job>>({})
|
| 614 |
const [polling, setPolling] = useState(false)
|
| 615 |
|
|
|
|
| 616 |
useEffect(() => {
|
| 617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
|
|
|
|
|
|
|
| 619 |
const poll = async () => {
|
| 620 |
try {
|
| 621 |
const results = await Promise.all(jobIds.map((id) => getJob(id)))
|
| 622 |
const map: Record<string, Job> = {}
|
| 623 |
for (const job of results) map[job.id] = job
|
| 624 |
setJobs(map)
|
| 625 |
-
|
| 626 |
-
(j) => j.status === 'done' || j.status === 'failed',
|
| 627 |
-
)
|
| 628 |
-
if (allTerminal) setPolling(false)
|
| 629 |
} catch {
|
| 630 |
// Erreur réseau transitoire — on continue
|
| 631 |
}
|
| 632 |
}
|
| 633 |
-
|
| 634 |
const id = setInterval(() => void poll(), 3000)
|
| 635 |
return () => clearInterval(id)
|
| 636 |
}, [polling, jobIds])
|
|
@@ -641,7 +569,7 @@ function RunSection({ corpora, selectedCorpusId, onSelectCorpus }: RunSectionPro
|
|
| 641 |
setJobs({})
|
| 642 |
setLaunching(true)
|
| 643 |
try {
|
| 644 |
-
const resp = await runCorpus(
|
| 645 |
setJobIds(resp.job_ids)
|
| 646 |
setPolling(true)
|
| 647 |
} catch (err) {
|
|
@@ -652,9 +580,7 @@ function RunSection({ corpora, selectedCorpusId, onSelectCorpus }: RunSectionPro
|
|
| 652 |
}
|
| 653 |
|
| 654 |
const handleRetryFailed = async () => {
|
| 655 |
-
const failedIds = Object.values(jobs)
|
| 656 |
-
.filter((j) => j.status === 'failed')
|
| 657 |
-
.map((j) => j.id)
|
| 658 |
if (failedIds.length === 0) return
|
| 659 |
await Promise.allSettled(failedIds.map((id) => retryJob(id)))
|
| 660 |
setPolling(true)
|
|
@@ -673,99 +599,200 @@ function RunSection({ corpora, selectedCorpusId, onSelectCorpus }: RunSectionPro
|
|
| 673 |
failed: 'bg-red-100 text-red-700',
|
| 674 |
}
|
| 675 |
return (
|
| 676 |
-
<span
|
| 677 |
-
className={`text-xs px-2 py-0.5 rounded font-medium ${classes[status] ?? 'bg-stone-100 text-stone-500'}`}
|
| 678 |
-
>
|
| 679 |
{status}
|
| 680 |
</span>
|
| 681 |
)
|
| 682 |
}
|
| 683 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 684 |
return (
|
| 685 |
-
<
|
| 686 |
-
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
|
| 689 |
-
<div className="
|
| 690 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 691 |
|
| 692 |
-
|
| 693 |
<button
|
| 694 |
-
onClick={() => void
|
| 695 |
-
|
| 696 |
-
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 697 |
>
|
| 698 |
-
{
|
| 699 |
-
? 'Démarrage…'
|
| 700 |
-
: polling
|
| 701 |
-
? 'Traitement en cours…'
|
| 702 |
-
: 'Analyser tout le corpus'}
|
| 703 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
<button
|
| 707 |
-
onClick={() =>
|
| 708 |
-
className="border border-
|
| 709 |
>
|
| 710 |
-
|
| 711 |
</button>
|
| 712 |
)}
|
| 713 |
</div>
|
| 714 |
-
|
| 715 |
-
{totalCount > 0 && (
|
| 716 |
-
<div>
|
| 717 |
-
<p className="text-sm text-stone-600 mb-3">
|
| 718 |
-
Progression : <strong>{doneCount}</strong> / {totalCount} pages traitées
|
| 719 |
-
{failedCount > 0 && (
|
| 720 |
-
<span className="text-red-600 ml-2">· {failedCount} en erreur</span>
|
| 721 |
-
)}
|
| 722 |
-
{polling && (
|
| 723 |
-
<span className="text-blue-600 ml-2">· actualisation toutes les 3 s</span>
|
| 724 |
-
)}
|
| 725 |
-
</p>
|
| 726 |
-
|
| 727 |
-
<ul className="space-y-1 max-h-80 overflow-y-auto border border-stone-200 rounded p-2 bg-white">
|
| 728 |
-
{jobList.map((job) => (
|
| 729 |
-
<li
|
| 730 |
-
key={job.id}
|
| 731 |
-
className="flex items-center justify-between text-xs text-stone-600 py-1 px-2 rounded hover:bg-stone-50"
|
| 732 |
-
>
|
| 733 |
-
<span className="font-mono truncate max-w-xs">
|
| 734 |
-
{job.page_id ?? job.id}
|
| 735 |
-
</span>
|
| 736 |
-
<div className="flex items-center gap-2 ml-2 shrink-0">
|
| 737 |
-
{statusBadge(job.status)}
|
| 738 |
-
{job.error_message && (
|
| 739 |
-
<span
|
| 740 |
-
className="text-red-500 truncate max-w-xs"
|
| 741 |
-
title={job.error_message}
|
| 742 |
-
>
|
| 743 |
-
{job.error_message}
|
| 744 |
-
</span>
|
| 745 |
-
)}
|
| 746 |
-
</div>
|
| 747 |
-
</li>
|
| 748 |
-
))}
|
| 749 |
-
</ul>
|
| 750 |
-
</div>
|
| 751 |
-
)}
|
| 752 |
</div>
|
| 753 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 754 |
)
|
| 755 |
}
|
| 756 |
|
| 757 |
// ── Admin (composant principal) ─────────────────────────────────────────────
|
| 758 |
|
| 759 |
export default function Admin({ onHome }: Props) {
|
| 760 |
-
const [activeTab, setActiveTab] = useState<AdminTab>('corpus')
|
| 761 |
const [corpora, setCorpora] = useState<Corpus[]>([])
|
| 762 |
-
const [selectedCorpusId, setSelectedCorpusId] = useState<string>(
|
|
|
|
|
|
|
| 763 |
|
| 764 |
-
const refreshCorpora = () => {
|
| 765 |
fetchCorpora()
|
| 766 |
.then((cs) => {
|
| 767 |
setCorpora(cs)
|
| 768 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 769 |
})
|
| 770 |
.catch(() => {})
|
| 771 |
}
|
|
@@ -774,16 +801,12 @@ export default function Admin({ onHome }: Props) {
|
|
| 774 |
refreshCorpora()
|
| 775 |
}, [])
|
| 776 |
|
| 777 |
-
const
|
| 778 |
-
`px-5 py-3 text-sm font-medium border-b-2 transition-colors ${
|
| 779 |
-
activeTab === tab
|
| 780 |
-
? 'border-stone-800 text-stone-900'
|
| 781 |
-
: 'border-transparent text-stone-500 hover:text-stone-700'
|
| 782 |
-
}`
|
| 783 |
|
| 784 |
return (
|
| 785 |
-
<div className="
|
| 786 |
-
|
|
|
|
| 787 |
<button
|
| 788 |
onClick={onHome}
|
| 789 |
className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
|
|
@@ -793,54 +816,73 @@ export default function Admin({ onHome }: Props) {
|
|
| 793 |
<h1 className="text-xl font-semibold tracking-tight">Administration</h1>
|
| 794 |
</header>
|
| 795 |
|
| 796 |
-
<
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
</div>
|
| 845 |
)
|
| 846 |
}
|
|
|
|
| 1 |
+
import { type FormEvent, useEffect, useRef, useState } from 'react'
|
| 2 |
import {
|
| 3 |
fetchCorpora,
|
| 4 |
+
fetchManuscripts,
|
| 5 |
+
fetchPages,
|
| 6 |
listProfiles,
|
| 7 |
createCorpus,
|
| 8 |
+
deleteCorpus,
|
| 9 |
fetchProviders,
|
| 10 |
fetchProviderModels,
|
| 11 |
selectModel,
|
| 12 |
+
getCorpusModel,
|
| 13 |
ingestImages,
|
| 14 |
ingestManifest,
|
| 15 |
ingestFiles,
|
|
|
|
| 18 |
retryJob,
|
| 19 |
type Corpus,
|
| 20 |
type CorpusProfile,
|
| 21 |
+
type CorpusModelConfig,
|
| 22 |
type ProviderInfo,
|
| 23 |
type ModelInfo,
|
| 24 |
type Job,
|
| 25 |
type CreateCorpusInput,
|
| 26 |
} from '../lib/api.ts'
|
| 27 |
|
|
|
|
| 28 |
type IngestSubTab = 'urls' | 'manifest' | 'files'
|
| 29 |
|
| 30 |
interface Props {
|
| 31 |
onHome: () => void
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
// ── Feedback helpers ───────────────────────────────────────────────────────
|
| 35 |
|
| 36 |
function ErrorMsg({ message }: { message: string }) {
|
|
|
|
| 49 |
)
|
| 50 |
}
|
| 51 |
|
| 52 |
+
// ── SectionCard ───────────────────────────────────────────────────────────
|
| 53 |
+
|
| 54 |
+
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
|
| 55 |
+
return (
|
| 56 |
+
<div className="bg-white border border-stone-200 rounded-lg p-6 mb-4">
|
| 57 |
+
<h3 className="text-base font-semibold text-stone-800 mb-4">{title}</h3>
|
| 58 |
+
{children}
|
| 59 |
+
</div>
|
| 60 |
+
)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// ── CreateCorpusPanel ─────────────────────────────────────────────────────
|
| 64 |
|
| 65 |
+
interface CreateCorpusPanelProps {
|
| 66 |
onCreated: (corpus: Corpus) => void
|
| 67 |
}
|
| 68 |
|
| 69 |
+
function CreateCorpusPanel({ onCreated }: CreateCorpusPanelProps) {
|
| 70 |
const [profiles, setProfiles] = useState<CorpusProfile[]>([])
|
| 71 |
+
const [form, setForm] = useState<CreateCorpusInput>({ slug: '', title: '', profile_id: '' })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
const [loading, setLoading] = useState(false)
|
| 73 |
const [error, setError] = useState<string | null>(null)
|
| 74 |
const [success, setSuccess] = useState<string | null>(null)
|
|
|
|
| 89 |
setLoading(true)
|
| 90 |
try {
|
| 91 |
const corpus = await createCorpus(form)
|
| 92 |
+
setSuccess(`Corpus « ${corpus.title} » créé.`)
|
| 93 |
setForm((f) => ({ ...f, slug: '', title: '' }))
|
| 94 |
onCreated(corpus)
|
| 95 |
} catch (err) {
|
|
|
|
| 103 |
'border border-stone-300 rounded px-3 py-2 text-sm w-full focus:outline-none focus:ring-2 focus:ring-stone-400'
|
| 104 |
|
| 105 |
return (
|
| 106 |
+
<div className="max-w-lg">
|
| 107 |
+
<h2 className="text-xl font-semibold text-stone-800 mb-6">Créer un corpus</h2>
|
| 108 |
+
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
| 109 |
<div>
|
| 110 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 111 |
Slug{' '}
|
| 112 |
+
<span className="text-stone-400 font-normal normal-case">(identifiant unique, sans espaces)</span>
|
|
|
|
|
|
|
| 113 |
</label>
|
| 114 |
<input
|
| 115 |
type="text"
|
|
|
|
| 163 |
{loading ? 'Création…' : 'Créer le corpus'}
|
| 164 |
</button>
|
| 165 |
</form>
|
| 166 |
+
</div>
|
| 167 |
)
|
| 168 |
}
|
| 169 |
|
| 170 |
+
// ── ModelPanel ────────────────────────────────────────────────────────────
|
| 171 |
|
| 172 |
+
interface ModelPanelProps {
|
| 173 |
+
corpusId: string
|
| 174 |
+
onSaved: () => void
|
|
|
|
| 175 |
}
|
| 176 |
|
| 177 |
+
function ModelPanel({ corpusId, onSaved }: ModelPanelProps) {
|
|
|
|
| 178 |
const [providers, setProviders] = useState<ProviderInfo[]>([])
|
| 179 |
const [loadingProviders, setLoadingProviders] = useState(true)
|
| 180 |
const [providersError, setProvidersError] = useState<string | null>(null)
|
| 181 |
|
|
|
|
| 182 |
const [selectedProvider, setSelectedProvider] = useState<string>('')
|
| 183 |
const [models, setModels] = useState<ModelInfo[]>([])
|
| 184 |
const [loadingModels, setLoadingModels] = useState(false)
|
| 185 |
const [modelsError, setModelsError] = useState<string | null>(null)
|
| 186 |
const [selectedModelId, setSelectedModelId] = useState('')
|
| 187 |
|
| 188 |
+
const [currentModel, setCurrentModel] = useState<CorpusModelConfig | null>(null)
|
| 189 |
const [savingModel, setSavingModel] = useState(false)
|
| 190 |
const [saveError, setSaveError] = useState<string | null>(null)
|
| 191 |
const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
|
| 192 |
|
| 193 |
+
// Load current model config and providers on mount
|
| 194 |
useEffect(() => {
|
| 195 |
+
void getCorpusModel(corpusId).then(setCurrentModel)
|
| 196 |
setLoadingProviders(true)
|
| 197 |
setProvidersError(null)
|
| 198 |
fetchProviders()
|
|
|
|
| 205 |
setProvidersError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 206 |
})
|
| 207 |
.finally(() => setLoadingProviders(false))
|
| 208 |
+
}, [corpusId])
|
| 209 |
|
| 210 |
+
// Load models when provider changes
|
| 211 |
useEffect(() => {
|
| 212 |
if (!selectedProvider) return
|
| 213 |
setModels([])
|
|
|
|
| 232 |
setSavingModel(true)
|
| 233 |
const model = models.find((m) => m.model_id === selectedModelId)
|
| 234 |
try {
|
| 235 |
+
await selectModel(corpusId, selectedModelId, model?.display_name ?? selectedModelId, selectedProvider)
|
| 236 |
+
const updated = await getCorpusModel(corpusId)
|
| 237 |
+
setCurrentModel(updated)
|
| 238 |
+
setSaveSuccess(`Modèle « ${model?.display_name ?? selectedModelId} » associé au corpus.`)
|
| 239 |
+
onSaved()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
} catch (err) {
|
| 241 |
setSaveError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 242 |
} finally {
|
|
|
|
| 247 |
const availableProviders = providers.filter((p) => p.available)
|
| 248 |
|
| 249 |
return (
|
| 250 |
+
<>
|
| 251 |
+
{currentModel && (
|
| 252 |
+
<div className="mb-4 text-sm bg-stone-50 border border-stone-200 rounded px-3 py-2 text-stone-600">
|
| 253 |
+
Modèle actuel :{' '}
|
| 254 |
+
<span className="font-medium text-stone-800">{currentModel.selected_model_display_name}</span>
|
| 255 |
+
{' '}({currentModel.provider_type})
|
| 256 |
+
</div>
|
| 257 |
+
)}
|
| 258 |
|
|
|
|
| 259 |
{loadingProviders && (
|
| 260 |
+
<p className="text-sm text-stone-400">Détection des providers disponibles…</p>
|
| 261 |
)}
|
| 262 |
{!loadingProviders && providersError && <ErrorMsg message={providersError} />}
|
| 263 |
{!loadingProviders && providers.length > 0 && (
|
| 264 |
+
<div className="mb-4">
|
| 265 |
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">
|
| 266 |
Providers IA détectés
|
| 267 |
</p>
|
|
|
|
| 276 |
} ${selectedProvider === p.provider_type ? 'ring-2 ring-stone-500' : ''}`}
|
| 277 |
onClick={() => p.available && setSelectedProvider(p.provider_type)}
|
| 278 |
>
|
| 279 |
+
<span className={`w-1.5 h-1.5 rounded-full ${p.available ? 'bg-green-500' : 'bg-stone-300'}`} />
|
|
|
|
|
|
|
| 280 |
{p.display_name}
|
| 281 |
+
{p.available && <span className="text-green-600">({p.model_count})</span>}
|
|
|
|
|
|
|
| 282 |
{!p.available && <span className="text-stone-400">— clé manquante</span>}
|
| 283 |
</span>
|
| 284 |
))}
|
| 285 |
</div>
|
| 286 |
{availableProviders.length === 0 && (
|
| 287 |
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2 mt-3">
|
| 288 |
+
Aucun provider disponible. Vérifiez les secrets{' '}
|
| 289 |
+
<code className="font-mono">GOOGLE_AI_STUDIO_API_KEY</code>,{' '}
|
| 290 |
+
<code className="font-mono">VERTEX_API_KEY</code> ou{' '}
|
| 291 |
+
<code className="font-mono">MISTRAL_API_KEY</code>.
|
|
|
|
|
|
|
| 292 |
</p>
|
| 293 |
)}
|
| 294 |
</div>
|
| 295 |
)}
|
| 296 |
|
|
|
|
| 297 |
{selectedProvider && (
|
| 298 |
+
<form onSubmit={(e) => void handleSelectModel(e)} className="space-y-3 max-w-sm">
|
| 299 |
+
{loadingModels && <p className="text-sm text-stone-400">Chargement des modèles…</p>}
|
|
|
|
|
|
|
| 300 |
{!loadingModels && modelsError && <ErrorMsg message={modelsError} />}
|
| 301 |
{!loadingModels && models.length > 0 && (
|
| 302 |
<div>
|
|
|
|
| 310 |
>
|
| 311 |
{models.map((m) => (
|
| 312 |
<option key={m.model_id} value={m.model_id}>
|
| 313 |
+
{m.display_name}{m.supports_vision ? ' (vision)' : ''}
|
|
|
|
| 314 |
</option>
|
| 315 |
))}
|
| 316 |
</select>
|
|
|
|
| 321 |
{!loadingModels && models.length > 0 && (
|
| 322 |
<button
|
| 323 |
type="submit"
|
| 324 |
+
disabled={savingModel || !selectedModelId}
|
| 325 |
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 326 |
>
|
| 327 |
{savingModel ? 'Enregistrement…' : 'Sélectionner ce modèle'}
|
|
|
|
| 329 |
)}
|
| 330 |
</form>
|
| 331 |
)}
|
| 332 |
+
</>
|
| 333 |
)
|
| 334 |
}
|
| 335 |
|
| 336 |
+
// ── IngestPanel ───────────────────────────────────────────────────────────
|
| 337 |
|
| 338 |
+
interface IngestPanelProps {
|
| 339 |
+
corpusId: string
|
|
|
|
|
|
|
| 340 |
}
|
| 341 |
|
| 342 |
+
function IngestPanel({ corpusId }: IngestPanelProps) {
|
| 343 |
const [subTab, setSubTab] = useState<IngestSubTab>('urls')
|
| 344 |
|
|
|
|
| 345 |
const [urlsText, setUrlsText] = useState('')
|
| 346 |
const [folioLabelsText, setFolioLabelsText] = useState('')
|
| 347 |
const [urlsLoading, setUrlsLoading] = useState(false)
|
| 348 |
const [urlsError, setUrlsError] = useState<string | null>(null)
|
| 349 |
const [urlsSuccess, setUrlsSuccess] = useState<string | null>(null)
|
| 350 |
|
|
|
|
| 351 |
const [manifestUrl, setManifestUrl] = useState('')
|
| 352 |
const [manifestLoading, setManifestLoading] = useState(false)
|
| 353 |
const [manifestError, setManifestError] = useState<string | null>(null)
|
| 354 |
const [manifestSuccess, setManifestSuccess] = useState<string | null>(null)
|
| 355 |
|
|
|
|
| 356 |
const [selectedFiles, setSelectedFiles] = useState<File[]>([])
|
| 357 |
const [filesLoading, setFilesLoading] = useState(false)
|
| 358 |
const [filesError, setFilesError] = useState<string | null>(null)
|
|
|
|
| 364 |
setUrlsSuccess(null)
|
| 365 |
const urls = urlsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 366 |
const labels = folioLabelsText.split('\n').map((l) => l.trim()).filter(Boolean)
|
| 367 |
+
if (urls.length === 0) { setUrlsError('Aucune URL renseignée.'); return }
|
|
|
|
|
|
|
|
|
|
| 368 |
if (labels.length !== urls.length) {
|
| 369 |
+
setUrlsError(`Le nombre de folio_labels (${labels.length}) doit être égal au nombre d'URLs (${urls.length}).`)
|
|
|
|
|
|
|
| 370 |
return
|
| 371 |
}
|
| 372 |
setUrlsLoading(true)
|
| 373 |
try {
|
| 374 |
+
const resp = await ingestImages(corpusId, urls, labels)
|
| 375 |
setUrlsSuccess(`${resp.pages_created} page(s) ingérée(s).`)
|
| 376 |
setUrlsText('')
|
| 377 |
setFolioLabelsText('')
|
|
|
|
| 388 |
setManifestSuccess(null)
|
| 389 |
setManifestLoading(true)
|
| 390 |
try {
|
| 391 |
+
const resp = await ingestManifest(corpusId, manifestUrl)
|
| 392 |
setManifestSuccess(`${resp.pages_created} page(s) ingérée(s) depuis le manifest.`)
|
| 393 |
setManifestUrl('')
|
| 394 |
} catch (err) {
|
|
|
|
| 402 |
e.preventDefault()
|
| 403 |
setFilesError(null)
|
| 404 |
setFilesSuccess(null)
|
| 405 |
+
if (selectedFiles.length === 0) { setFilesError('Aucun fichier sélectionné.'); return }
|
|
|
|
|
|
|
|
|
|
| 406 |
setFilesLoading(true)
|
| 407 |
try {
|
| 408 |
+
const resp = await ingestFiles(corpusId, selectedFiles)
|
| 409 |
setFilesSuccess(`${resp.pages_created} page(s) ingérée(s).`)
|
| 410 |
setSelectedFiles([])
|
| 411 |
} catch (err) {
|
|
|
|
| 424 |
|
| 425 |
const textareaClass =
|
| 426 |
'border border-stone-300 rounded px-3 py-2 text-sm w-full font-mono focus:outline-none focus:ring-2 focus:ring-stone-400'
|
|
|
|
| 427 |
const submitBtnClass =
|
| 428 |
'bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors'
|
| 429 |
|
| 430 |
return (
|
| 431 |
+
<>
|
| 432 |
+
<div className="flex border-b border-stone-200 mb-4 -mt-1">
|
| 433 |
+
<button className={subTabClass('urls')} onClick={() => setSubTab('urls')}>URLs directes</button>
|
| 434 |
+
<button className={subTabClass('manifest')} onClick={() => setSubTab('manifest')}>Manifest IIIF</button>
|
| 435 |
+
<button className={subTabClass('files')} onClick={() => setSubTab('files')}>Fichiers locaux</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 436 |
</div>
|
| 437 |
|
|
|
|
| 438 |
{subTab === 'urls' && (
|
| 439 |
+
<form onSubmit={(e) => void handleUrlsSubmit(e)} className="space-y-3 max-w-lg">
|
| 440 |
<div>
|
| 441 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 442 |
+
URLs d'images <span className="font-normal normal-case text-stone-400">(1 par ligne)</span>
|
|
|
|
| 443 |
</label>
|
| 444 |
<textarea
|
| 445 |
value={urlsText}
|
| 446 |
onChange={(e) => setUrlsText(e.target.value)}
|
| 447 |
+
rows={4}
|
| 448 |
placeholder="https://gallica.bnf.fr/iiif/ark:/…/f1/full/max/0/native.jpg"
|
| 449 |
className={textareaClass}
|
| 450 |
/>
|
| 451 |
</div>
|
| 452 |
<div>
|
| 453 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 454 |
+
Folio labels <span className="font-normal normal-case text-stone-400">(1 par ligne, même ordre)</span>
|
|
|
|
| 455 |
</label>
|
| 456 |
<textarea
|
| 457 |
value={folioLabelsText}
|
| 458 |
onChange={(e) => setFolioLabelsText(e.target.value)}
|
| 459 |
+
rows={4}
|
| 460 |
placeholder={'001r\n001v\n002r'}
|
| 461 |
className={textareaClass}
|
| 462 |
/>
|
| 463 |
</div>
|
| 464 |
{urlsError && <ErrorMsg message={urlsError} />}
|
| 465 |
{urlsSuccess && <SuccessMsg message={urlsSuccess} />}
|
| 466 |
+
<button type="submit" disabled={urlsLoading} className={submitBtnClass}>
|
| 467 |
{urlsLoading ? 'Ingestion…' : 'Ingérer les images'}
|
| 468 |
</button>
|
| 469 |
</form>
|
| 470 |
)}
|
| 471 |
|
|
|
|
| 472 |
{subTab === 'manifest' && (
|
| 473 |
+
<form onSubmit={(e) => void handleManifestSubmit(e)} className="space-y-3 max-w-lg">
|
| 474 |
<div>
|
| 475 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 476 |
URL du manifest IIIF
|
|
|
|
| 486 |
</div>
|
| 487 |
{manifestError && <ErrorMsg message={manifestError} />}
|
| 488 |
{manifestSuccess && <SuccessMsg message={manifestSuccess} />}
|
| 489 |
+
<button type="submit" disabled={manifestLoading || !manifestUrl} className={submitBtnClass}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
{manifestLoading ? 'Ingestion…' : 'Importer le manifest'}
|
| 491 |
</button>
|
| 492 |
</form>
|
| 493 |
)}
|
| 494 |
|
|
|
|
| 495 |
{subTab === 'files' && (
|
| 496 |
+
<form onSubmit={(e) => void handleFilesSubmit(e)} className="space-y-3 max-w-lg">
|
| 497 |
<div>
|
| 498 |
<label className="block text-xs font-semibold text-stone-500 uppercase tracking-wide mb-1">
|
| 499 |
Fichiers images
|
|
|
|
| 506 |
className="block text-sm text-stone-600 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-medium file:bg-stone-100 file:text-stone-700 hover:file:bg-stone-200"
|
| 507 |
/>
|
| 508 |
{selectedFiles.length > 0 && (
|
| 509 |
+
<p className="text-xs text-stone-500 mt-1">{selectedFiles.length} fichier(s) sélectionné(s)</p>
|
|
|
|
|
|
|
| 510 |
)}
|
| 511 |
</div>
|
| 512 |
{filesError && <ErrorMsg message={filesError} />}
|
| 513 |
{filesSuccess && <SuccessMsg message={filesSuccess} />}
|
| 514 |
+
<button type="submit" disabled={filesLoading || selectedFiles.length === 0} className={submitBtnClass}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
{filesLoading ? 'Envoi…' : 'Envoyer les fichiers'}
|
| 516 |
</button>
|
| 517 |
</form>
|
| 518 |
)}
|
| 519 |
+
</>
|
| 520 |
)
|
| 521 |
}
|
| 522 |
|
| 523 |
+
// ── RunPanel ──────────────────────────────────────────────────────────────
|
| 524 |
|
| 525 |
+
interface RunPanelProps {
|
| 526 |
+
corpusId: string
|
| 527 |
+
hasModel: boolean
|
|
|
|
| 528 |
}
|
| 529 |
|
| 530 |
+
function RunPanel({ corpusId, hasModel }: RunPanelProps) {
|
| 531 |
+
const [pageCount, setPageCount] = useState<number | null>(null)
|
| 532 |
const [launching, setLaunching] = useState(false)
|
| 533 |
const [launchError, setLaunchError] = useState<string | null>(null)
|
| 534 |
const [jobIds, setJobIds] = useState<string[]>([])
|
| 535 |
const [jobs, setJobs] = useState<Record<string, Job>>({})
|
| 536 |
const [polling, setPolling] = useState(false)
|
| 537 |
|
| 538 |
+
// Fetch page count from manuscripts + pages
|
| 539 |
useEffect(() => {
|
| 540 |
+
fetchManuscripts(corpusId)
|
| 541 |
+
.then(async (manuscripts) => {
|
| 542 |
+
if (manuscripts.length === 0) { setPageCount(0); return }
|
| 543 |
+
const pagesArrays = await Promise.all(manuscripts.map((m) => fetchPages(m.id)))
|
| 544 |
+
setPageCount(pagesArrays.reduce((sum, ps) => sum + ps.length, 0))
|
| 545 |
+
})
|
| 546 |
+
.catch(() => setPageCount(null))
|
| 547 |
+
}, [corpusId])
|
| 548 |
|
| 549 |
+
useEffect(() => {
|
| 550 |
+
if (!polling || jobIds.length === 0) return
|
| 551 |
const poll = async () => {
|
| 552 |
try {
|
| 553 |
const results = await Promise.all(jobIds.map((id) => getJob(id)))
|
| 554 |
const map: Record<string, Job> = {}
|
| 555 |
for (const job of results) map[job.id] = job
|
| 556 |
setJobs(map)
|
| 557 |
+
if (results.every((j) => j.status === 'done' || j.status === 'failed')) setPolling(false)
|
|
|
|
|
|
|
|
|
|
| 558 |
} catch {
|
| 559 |
// Erreur réseau transitoire — on continue
|
| 560 |
}
|
| 561 |
}
|
|
|
|
| 562 |
const id = setInterval(() => void poll(), 3000)
|
| 563 |
return () => clearInterval(id)
|
| 564 |
}, [polling, jobIds])
|
|
|
|
| 569 |
setJobs({})
|
| 570 |
setLaunching(true)
|
| 571 |
try {
|
| 572 |
+
const resp = await runCorpus(corpusId)
|
| 573 |
setJobIds(resp.job_ids)
|
| 574 |
setPolling(true)
|
| 575 |
} catch (err) {
|
|
|
|
| 580 |
}
|
| 581 |
|
| 582 |
const handleRetryFailed = async () => {
|
| 583 |
+
const failedIds = Object.values(jobs).filter((j) => j.status === 'failed').map((j) => j.id)
|
|
|
|
|
|
|
| 584 |
if (failedIds.length === 0) return
|
| 585 |
await Promise.allSettled(failedIds.map((id) => retryJob(id)))
|
| 586 |
setPolling(true)
|
|
|
|
| 599 |
failed: 'bg-red-100 text-red-700',
|
| 600 |
}
|
| 601 |
return (
|
| 602 |
+
<span className={`text-xs px-2 py-0.5 rounded font-medium ${classes[status] ?? 'bg-stone-100 text-stone-500'}`}>
|
|
|
|
|
|
|
| 603 |
{status}
|
| 604 |
</span>
|
| 605 |
)
|
| 606 |
}
|
| 607 |
|
| 608 |
+
if (!hasModel) {
|
| 609 |
+
return (
|
| 610 |
+
<p className="text-sm text-amber-600 bg-amber-50 border border-amber-200 rounded px-3 py-2">
|
| 611 |
+
Configurez d'abord un modèle IA pour ce corpus.
|
| 612 |
+
</p>
|
| 613 |
+
)
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
return (
|
| 617 |
+
<div className="space-y-4">
|
| 618 |
+
{pageCount !== null && (
|
| 619 |
+
<p className="text-sm text-stone-600">
|
| 620 |
+
{pageCount === 0
|
| 621 |
+
? 'Aucune page ingérée.'
|
| 622 |
+
: `${pageCount} page(s) dans ce corpus.`}
|
| 623 |
+
</p>
|
| 624 |
+
)}
|
| 625 |
+
|
| 626 |
+
{launchError && <ErrorMsg message={launchError} />}
|
| 627 |
|
| 628 |
+
<div className="flex flex-wrap gap-3 items-center">
|
| 629 |
+
<button
|
| 630 |
+
onClick={() => void handleRun()}
|
| 631 |
+
disabled={launching || polling || pageCount === 0}
|
| 632 |
+
className="bg-stone-800 text-white px-5 py-2 rounded text-sm font-medium hover:bg-stone-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 633 |
+
>
|
| 634 |
+
{launching ? 'Démarrage…' : polling ? 'Traitement en cours…' : 'Analyser tout le corpus'}
|
| 635 |
+
</button>
|
| 636 |
|
| 637 |
+
{failedCount > 0 && !polling && (
|
| 638 |
<button
|
| 639 |
+
onClick={() => void handleRetryFailed()}
|
| 640 |
+
className="border border-stone-300 text-stone-700 px-5 py-2 rounded text-sm font-medium hover:bg-stone-50 transition-colors"
|
|
|
|
| 641 |
>
|
| 642 |
+
Relancer {failedCount} page(s) en erreur
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
</button>
|
| 644 |
+
)}
|
| 645 |
+
</div>
|
| 646 |
+
|
| 647 |
+
{totalCount > 0 && (
|
| 648 |
+
<div>
|
| 649 |
+
<p className="text-sm text-stone-600 mb-3">
|
| 650 |
+
Progression : <strong>{doneCount}</strong> / {totalCount} pages traitées
|
| 651 |
+
{failedCount > 0 && <span className="text-red-600 ml-2">· {failedCount} en erreur</span>}
|
| 652 |
+
{polling && <span className="text-blue-600 ml-2">· actualisation toutes les 3 s</span>}
|
| 653 |
+
</p>
|
| 654 |
+
<ul className="space-y-1 max-h-64 overflow-y-auto border border-stone-200 rounded p-2 bg-white">
|
| 655 |
+
{jobList.map((job) => (
|
| 656 |
+
<li
|
| 657 |
+
key={job.id}
|
| 658 |
+
className="flex items-center justify-between text-xs text-stone-600 py-1 px-2 rounded hover:bg-stone-50"
|
| 659 |
+
>
|
| 660 |
+
<span className="font-mono truncate max-w-xs">{job.page_id ?? job.id}</span>
|
| 661 |
+
<div className="flex items-center gap-2 ml-2 shrink-0">
|
| 662 |
+
{statusBadge(job.status)}
|
| 663 |
+
{job.error_message && (
|
| 664 |
+
<span className="text-red-500 truncate max-w-xs" title={job.error_message}>
|
| 665 |
+
{job.error_message}
|
| 666 |
+
</span>
|
| 667 |
+
)}
|
| 668 |
+
</div>
|
| 669 |
+
</li>
|
| 670 |
+
))}
|
| 671 |
+
</ul>
|
| 672 |
+
</div>
|
| 673 |
+
)}
|
| 674 |
+
</div>
|
| 675 |
+
)
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
// ── CorpusDetail ──────────────────────────────────────────────────────────
|
| 679 |
+
|
| 680 |
+
interface CorpusDetailProps {
|
| 681 |
+
corpus: Corpus
|
| 682 |
+
onDeleted: () => void
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
function CorpusDetail({ corpus, onDeleted }: CorpusDetailProps) {
|
| 686 |
+
const [hasModel, setHasModel] = useState(false)
|
| 687 |
+
const [deleting, setDeleting] = useState(false)
|
| 688 |
+
const [deleteError, setDeleteError] = useState<string | null>(null)
|
| 689 |
+
const [confirmDelete, setConfirmDelete] = useState(false)
|
| 690 |
+
|
| 691 |
+
useEffect(() => {
|
| 692 |
+
getCorpusModel(corpus.id)
|
| 693 |
+
.then((m) => setHasModel(m !== null))
|
| 694 |
+
.catch(() => {})
|
| 695 |
+
}, [corpus.id])
|
| 696 |
+
|
| 697 |
+
const handleDelete = async () => {
|
| 698 |
+
setDeleteError(null)
|
| 699 |
+
setDeleting(true)
|
| 700 |
+
try {
|
| 701 |
+
await deleteCorpus(corpus.id)
|
| 702 |
+
onDeleted()
|
| 703 |
+
} catch (err) {
|
| 704 |
+
setDeleteError(err instanceof Error ? err.message : 'Erreur inconnue')
|
| 705 |
+
setDeleting(false)
|
| 706 |
+
setConfirmDelete(false)
|
| 707 |
+
}
|
| 708 |
+
}
|
| 709 |
|
| 710 |
+
return (
|
| 711 |
+
<div>
|
| 712 |
+
{/* Corpus header */}
|
| 713 |
+
<div className="flex items-start justify-between mb-6">
|
| 714 |
+
<div>
|
| 715 |
+
<h2 className="text-xl font-semibold text-stone-800">{corpus.title}</h2>
|
| 716 |
+
<p className="text-sm text-stone-500 mt-0.5">
|
| 717 |
+
<span className="font-mono">{corpus.slug}</span>
|
| 718 |
+
{' · '}
|
| 719 |
+
<span>{corpus.profile_id}</span>
|
| 720 |
+
</p>
|
| 721 |
+
</div>
|
| 722 |
+
<div className="flex items-center gap-2">
|
| 723 |
+
{deleteError && <span className="text-xs text-red-600">{deleteError}</span>}
|
| 724 |
+
{confirmDelete ? (
|
| 725 |
+
<>
|
| 726 |
+
<span className="text-xs text-stone-600">Confirmer la suppression ?</span>
|
| 727 |
+
<button
|
| 728 |
+
onClick={() => void handleDelete()}
|
| 729 |
+
disabled={deleting}
|
| 730 |
+
className="px-3 py-1.5 bg-red-600 text-white text-xs rounded font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
|
| 731 |
+
>
|
| 732 |
+
{deleting ? 'Suppression…' : 'Supprimer'}
|
| 733 |
+
</button>
|
| 734 |
+
<button
|
| 735 |
+
onClick={() => setConfirmDelete(false)}
|
| 736 |
+
className="px-3 py-1.5 border border-stone-300 text-stone-600 text-xs rounded font-medium hover:bg-stone-50 transition-colors"
|
| 737 |
+
>
|
| 738 |
+
Annuler
|
| 739 |
+
</button>
|
| 740 |
+
</>
|
| 741 |
+
) : (
|
| 742 |
<button
|
| 743 |
+
onClick={() => setConfirmDelete(true)}
|
| 744 |
+
className="px-3 py-1.5 border border-red-200 text-red-600 text-xs rounded font-medium hover:bg-red-50 transition-colors"
|
| 745 |
>
|
| 746 |
+
Supprimer
|
| 747 |
</button>
|
| 748 |
)}
|
| 749 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 750 |
</div>
|
| 751 |
+
|
| 752 |
+
{/* Section cards */}
|
| 753 |
+
<SectionCard title="Modèle IA">
|
| 754 |
+
<ModelPanel
|
| 755 |
+
key={corpus.id}
|
| 756 |
+
corpusId={corpus.id}
|
| 757 |
+
onSaved={() => setHasModel(true)}
|
| 758 |
+
/>
|
| 759 |
+
</SectionCard>
|
| 760 |
+
|
| 761 |
+
<SectionCard title="Ingestion">
|
| 762 |
+
<IngestPanel key={corpus.id} corpusId={corpus.id} />
|
| 763 |
+
</SectionCard>
|
| 764 |
+
|
| 765 |
+
<SectionCard title="Traitement">
|
| 766 |
+
<RunPanel key={corpus.id} corpusId={corpus.id} hasModel={hasModel} />
|
| 767 |
+
</SectionCard>
|
| 768 |
+
</div>
|
| 769 |
)
|
| 770 |
}
|
| 771 |
|
| 772 |
// ── Admin (composant principal) ─────────────────────────────────────────────
|
| 773 |
|
| 774 |
export default function Admin({ onHome }: Props) {
|
|
|
|
| 775 |
const [corpora, setCorpora] = useState<Corpus[]>([])
|
| 776 |
+
const [selectedCorpusId, setSelectedCorpusId] = useState<string | null>(null)
|
| 777 |
+
const [showCreate, setShowCreate] = useState(false)
|
| 778 |
+
const didInit = useRef(false)
|
| 779 |
|
| 780 |
+
const refreshCorpora = (selectId?: string) => {
|
| 781 |
fetchCorpora()
|
| 782 |
.then((cs) => {
|
| 783 |
setCorpora(cs)
|
| 784 |
+
if (selectId) {
|
| 785 |
+
setSelectedCorpusId(selectId)
|
| 786 |
+
setShowCreate(false)
|
| 787 |
+
} else if (!didInit.current) {
|
| 788 |
+
didInit.current = true
|
| 789 |
+
if (cs.length > 0) {
|
| 790 |
+
setSelectedCorpusId(cs[0].id)
|
| 791 |
+
setShowCreate(false)
|
| 792 |
+
} else {
|
| 793 |
+
setShowCreate(true)
|
| 794 |
+
}
|
| 795 |
+
}
|
| 796 |
})
|
| 797 |
.catch(() => {})
|
| 798 |
}
|
|
|
|
| 801 |
refreshCorpora()
|
| 802 |
}, [])
|
| 803 |
|
| 804 |
+
const selectedCorpus = corpora.find((c) => c.id === selectedCorpusId) ?? null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
|
| 806 |
return (
|
| 807 |
+
<div className="h-screen flex flex-col bg-stone-50">
|
| 808 |
+
{/* Top bar */}
|
| 809 |
+
<header className="bg-stone-900 text-stone-100 px-6 py-4 flex items-center gap-4 shrink-0">
|
| 810 |
<button
|
| 811 |
onClick={onHome}
|
| 812 |
className="text-stone-400 hover:text-stone-100 text-sm transition-colors"
|
|
|
|
| 816 |
<h1 className="text-xl font-semibold tracking-tight">Administration</h1>
|
| 817 |
</header>
|
| 818 |
|
| 819 |
+
<div className="flex flex-1 overflow-hidden">
|
| 820 |
+
{/* Sidebar */}
|
| 821 |
+
<aside className="w-64 bg-white border-r border-stone-200 flex flex-col shrink-0 overflow-y-auto">
|
| 822 |
+
<div className="p-3 border-b border-stone-100">
|
| 823 |
+
<button
|
| 824 |
+
onClick={() => { setShowCreate(true); setSelectedCorpusId(null) }}
|
| 825 |
+
className={`w-full text-left px-3 py-2 rounded text-sm font-medium transition-colors ${
|
| 826 |
+
showCreate && !selectedCorpusId
|
| 827 |
+
? 'bg-stone-800 text-white'
|
| 828 |
+
: 'text-stone-600 hover:bg-stone-100'
|
| 829 |
+
}`}
|
| 830 |
+
>
|
| 831 |
+
+ Nouveau corpus
|
| 832 |
+
</button>
|
| 833 |
+
</div>
|
| 834 |
+
<nav className="flex-1 p-3 space-y-0.5">
|
| 835 |
+
{corpora.length === 0 && (
|
| 836 |
+
<p className="text-xs text-stone-400 px-3 py-2">Aucun corpus</p>
|
| 837 |
+
)}
|
| 838 |
+
{corpora.map((c) => (
|
| 839 |
+
<button
|
| 840 |
+
key={c.id}
|
| 841 |
+
onClick={() => { setSelectedCorpusId(c.id); setShowCreate(false) }}
|
| 842 |
+
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
|
| 843 |
+
selectedCorpusId === c.id && !showCreate
|
| 844 |
+
? 'bg-stone-100 text-stone-900 font-medium'
|
| 845 |
+
: 'text-stone-600 hover:bg-stone-50'
|
| 846 |
+
}`}
|
| 847 |
+
>
|
| 848 |
+
<span className="block truncate">{c.title}</span>
|
| 849 |
+
<span className="block truncate text-xs text-stone-400 font-mono">{c.slug}</span>
|
| 850 |
+
</button>
|
| 851 |
+
))}
|
| 852 |
+
</nav>
|
| 853 |
+
</aside>
|
| 854 |
+
|
| 855 |
+
{/* Main panel */}
|
| 856 |
+
<main className="flex-1 overflow-y-auto p-8">
|
| 857 |
+
{showCreate && !selectedCorpusId && (
|
| 858 |
+
<CreateCorpusPanel
|
| 859 |
+
onCreated={(corpus) => {
|
| 860 |
+
refreshCorpora(corpus.id)
|
| 861 |
+
}}
|
| 862 |
+
/>
|
| 863 |
+
)}
|
| 864 |
+
{!showCreate && selectedCorpus && (
|
| 865 |
+
<CorpusDetail
|
| 866 |
+
key={selectedCorpus.id}
|
| 867 |
+
corpus={selectedCorpus}
|
| 868 |
+
onDeleted={() => {
|
| 869 |
+
const remaining = corpora.filter((c) => c.id !== selectedCorpus.id)
|
| 870 |
+
setCorpora(remaining)
|
| 871 |
+
if (remaining.length > 0) {
|
| 872 |
+
setSelectedCorpusId(remaining[0].id)
|
| 873 |
+
setShowCreate(false)
|
| 874 |
+
} else {
|
| 875 |
+
setSelectedCorpusId(null)
|
| 876 |
+
setShowCreate(true)
|
| 877 |
+
}
|
| 878 |
+
}}
|
| 879 |
+
/>
|
| 880 |
+
)}
|
| 881 |
+
{!showCreate && !selectedCorpus && corpora.length > 0 && (
|
| 882 |
+
<p className="text-sm text-stone-400">Sélectionnez un corpus dans la barre latérale.</p>
|
| 883 |
+
)}
|
| 884 |
+
</main>
|
| 885 |
+
</div>
|
| 886 |
</div>
|
| 887 |
)
|
| 888 |
}
|