Claude commited on
Commit
021c7a8
·
unverified ·
1 Parent(s): bf9b809

fix(api): guard catch-all against /api/* paths in production + refactor Admin UI

Browse files

BUG 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 CHANGED
@@ -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():
frontend/src/lib/api.ts CHANGED
@@ -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[],
frontend/src/pages/Admin.tsx CHANGED
@@ -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
- // ── Section 1 — Créer un corpus ─────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
85
 
86
- interface CreateCorpusSectionProps {
87
  onCreated: (corpus: Corpus) => void
88
  }
89
 
90
- function CreateCorpusSection({ onCreated }: CreateCorpusSectionProps) {
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éé (id : ${corpus.id})`)
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
- <section>
132
- <h2 className="text-lg font-semibold text-stone-800 mb-6">Créer un corpus</h2>
133
- <form onSubmit={(e) => void handleSubmit(e)} className="space-y-4 max-w-md">
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
- </section>
194
  )
195
  }
196
 
197
- // ── Section 2 — Configurer le modèle IA ────────────────────────────────────
198
 
199
- interface ModelSectionProps {
200
- corpora: Corpus[]
201
- selectedCorpusId: string
202
- onSelectCorpus: (id: string) => void
203
  }
204
 
205
- function ModelSection({ corpora, selectedCorpusId, onSelectCorpus }: ModelSectionProps) {
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
- // Étape 3 : enregistrement
219
  const [savingModel, setSavingModel] = useState(false)
220
  const [saveError, setSaveError] = useState<string | null>(null)
221
  const [saveSuccess, setSaveSuccess] = useState<string | null>(null)
222
 
223
- // Charge la liste des providers au montage
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
- // Charge les modèles quand le provider change
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
- selectedCorpusId,
266
- selectedModelId,
267
- model?.display_name ?? selectedModelId,
268
- selectedProvider,
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
- <section>
284
- <h2 className="text-lg font-semibold text-stone-800 mb-6">Configurer le modèle IA</h2>
285
- <CorpusSelector corpora={corpora} value={selectedCorpusId} onChange={onSelectCorpus} />
 
 
 
 
 
286
 
287
- {/* Étape 1 — Providers détectés */}
288
  {loadingProviders && (
289
- <p className="text-sm text-stone-400 mb-4">Détection des providers disponibles…</p>
290
  )}
291
  {!loadingProviders && providersError && <ErrorMsg message={providersError} />}
292
  {!loadingProviders && providers.length > 0 && (
293
- <div className="mb-6">
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 modèle détecté. Vérifiez que les secrets{' '}
322
- <code className="font-mono">AI_PROVIDER</code> et{' '}
323
- <code className="font-mono">VERTEX_API_KEY</code> (ou{' '}
324
- <code className="font-mono">GOOGLE_AI_STUDIO_API_KEY</code> ou{' '}
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-4 max-w-md">
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 || !selectedCorpusId || !selectedModelId}
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
- </section>
372
  )
373
  }
374
 
375
- // ── Section 3 — Ingestion ──────────────────────────────────────────────────
376
 
377
- interface IngestSectionProps {
378
- corpora: Corpus[]
379
- selectedCorpusId: string
380
- onSelectCorpus: (id: string) => void
381
  }
382
 
383
- function IngestSection({ corpora, selectedCorpusId, onSelectCorpus }: IngestSectionProps) {
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(selectedCorpusId, urls, labels)
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(selectedCorpusId, manifestUrl)
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(selectedCorpusId, selectedFiles)
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
- <section>
485
- <h2 className="text-lg font-semibold text-stone-800 mb-6">Ingérer des images</h2>
486
- <CorpusSelector corpora={corpora} value={selectedCorpusId} onChange={onSelectCorpus} />
487
-
488
- {/* Sub-tabs */}
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-4 max-w-lg">
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={5}
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={5}
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 || !selectedCorpusId} className={submitBtnClass}>
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-4 max-w-lg">
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-4 max-w-lg">
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
- </section>
598
  )
599
  }
600
 
601
- // ── Section 4 — Lancer le traitement ────────────────────────────────────────
602
 
603
- interface RunSectionProps {
604
- corpora: Corpus[]
605
- selectedCorpusId: string
606
- onSelectCorpus: (id: string) => void
607
  }
608
 
609
- function RunSection({ corpora, selectedCorpusId, onSelectCorpus }: RunSectionProps) {
 
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
- if (!polling || jobIds.length === 0) return
 
 
 
 
 
 
 
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
- const allTerminal = results.every(
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(selectedCorpusId)
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
- <section>
686
- <h2 className="text-lg font-semibold text-stone-800 mb-6">Lancer le traitement</h2>
687
- <CorpusSelector corpora={corpora} value={selectedCorpusId} onChange={onSelectCorpus} />
 
 
 
 
 
 
 
688
 
689
- <div className="space-y-4">
690
- {launchError && <ErrorMsg message={launchError} />}
 
 
 
 
 
 
691
 
692
- <div className="flex flex-wrap gap-3 items-center">
693
  <button
694
- onClick={() => void handleRun()}
695
- disabled={launching || !selectedCorpusId || polling}
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
- {launching
699
- ? 'Démarrage…'
700
- : polling
701
- ? 'Traitement en cours…'
702
- : 'Analyser tout le corpus'}
703
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
704
 
705
- {failedCount > 0 && !polling && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
706
  <button
707
- onClick={() => void handleRetryFailed()}
708
- className="border border-stone-300 text-stone-700 px-5 py-2 rounded text-sm font-medium hover:bg-stone-50 transition-colors"
709
  >
710
- Relancer {failedCount} page(s) en erreur
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
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- setSelectedCorpusId((prev) => prev || (cs.length > 0 ? cs[0].id : ''))
 
 
 
 
 
 
 
 
 
 
 
769
  })
770
  .catch(() => {})
771
  }
@@ -774,16 +801,12 @@ export default function Admin({ onHome }: Props) {
774
  refreshCorpora()
775
  }, [])
776
 
777
- const tabClass = (tab: AdminTab) =>
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="min-h-screen bg-stone-50">
786
- <header className="bg-stone-900 text-stone-100 px-8 py-4 flex items-center gap-4">
 
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
- <nav className="bg-white border-b border-stone-200 px-8">
797
- <div className="flex">
798
- <button className={tabClass('corpus')} onClick={() => setActiveTab('corpus')}>
799
- Nouveau corpus
800
- </button>
801
- <button className={tabClass('model')} onClick={() => setActiveTab('model')}>
802
- Modèle IA
803
- </button>
804
- <button className={tabClass('ingest')} onClick={() => setActiveTab('ingest')}>
805
- Ingestion
806
- </button>
807
- <button className={tabClass('run')} onClick={() => setActiveTab('run')}>
808
- Traitement
809
- </button>
810
- </div>
811
- </nav>
812
-
813
- <main className="max-w-3xl mx-auto py-10 px-8">
814
- {activeTab === 'corpus' && (
815
- <CreateCorpusSection
816
- onCreated={(corpus) => {
817
- setCorpora((prev) => [...prev, corpus])
818
- setSelectedCorpusId(corpus.id)
819
- }}
820
- />
821
- )}
822
- {activeTab === 'model' && (
823
- <ModelSection
824
- corpora={corpora}
825
- selectedCorpusId={selectedCorpusId}
826
- onSelectCorpus={setSelectedCorpusId}
827
- />
828
- )}
829
- {activeTab === 'ingest' && (
830
- <IngestSection
831
- corpora={corpora}
832
- selectedCorpusId={selectedCorpusId}
833
- onSelectCorpus={setSelectedCorpusId}
834
- />
835
- )}
836
- {activeTab === 'run' && (
837
- <RunSection
838
- corpora={corpora}
839
- selectedCorpusId={selectedCorpusId}
840
- onSelectCorpus={setSelectedCorpusId}
841
- />
842
- )}
843
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }