const BASE_URL: string = import.meta.env.VITE_API_URL ?? '' if (!BASE_URL && import.meta.env.PROD) { console.warn( '[IIIF-Studio] VITE_API_URL non défini en production. ' + 'Les appels API utiliseront des chemins relatifs, ce qui peut échouer ' + 'si le frontend n\'est pas servi par le même domaine que le backend.' ) } // ── Types ───────────────────────────────────────────────────────────────────── export interface ProviderInfo { provider_type: string display_name: string available: boolean model_count: number } export interface ModelInfo { model_id: string display_name: string provider: string supports_vision: boolean input_token_limit: number | null output_token_limit: number | null } export interface IngestResponse { corpus_id: string manuscript_id: string pages_created: number pages_skipped: number page_ids: string[] } export interface CorpusRunResponse { corpus_id: string jobs_created: number job_ids: string[] } export type JobStatus = 'pending' | 'claimed' | 'running' | 'done' | 'failed' export interface Job { id: string corpus_id: string page_id: string | null status: JobStatus started_at: string | null finished_at: string | null error_message: string | null created_at: string } export interface CreateCorpusInput { slug: string title: string profile_id: string } export interface Corpus { id: string slug: string title: string profile_id: string created_at: string updated_at: string } export interface Manuscript { id: string corpus_id: string title: string shelfmark: string | null date_label: string | null total_pages: number } export interface Page { id: string manuscript_id: string folio_label: string sequence: number image_master_path: string | null iiif_service_url: string | null canvas_width: number | null canvas_height: number | null manifest_url: string | null processing_status: string confidence_summary: number | null } export type RegionType = | 'text_block' | 'miniature' | 'decorated_initial' | 'margin' | 'rubric' | 'other' export interface Region { id: string type: RegionType bbox: [number, number, number, number] confidence: number polygon?: number[][] | null parent_region_id?: string | null } export interface OCRResult { diplomatic_text: string blocks: Record[] lines: Record[] language: string confidence: number uncertain_segments: string[] } export interface Translation { fr: string en: string } export interface CommentaryClaim { claim: string evidence_region_ids: string[] certainty: 'high' | 'medium' | 'low' | 'speculative' } export interface Commentary { public: string scholarly: string claims: CommentaryClaim[] } export type EditorialStatus = | 'machine_draft' | 'needs_review' | 'reviewed' | 'validated' | 'published' export interface EditorialInfo { status: EditorialStatus validated: boolean validated_by: string | null version: number notes: string[] } export interface ImageInfo { master: string derivative_web?: string | null thumbnail?: string | null iiif_service_url?: string | null manifest_url?: string | null width: number height: number } export interface PageMaster { schema_version: string page_id: string corpus_profile: string manuscript_id: string folio_label: string sequence: number image: ImageInfo layout: { regions: Region[] } ocr: OCRResult | null translation: Translation | null summary: { short: string; detailed: string } | null commentary: Commentary | null extensions: Record processing: Record | null editorial: EditorialInfo } export interface CorpusProfile { profile_id: string label: string language_hints: string[] script_type: string active_layers: string[] uncertainty_config: { flag_below: number; min_acceptable: number } export_config: { mets: boolean; alto: boolean; tei: boolean } } // ── Errors ──────────────────────────────────────────────────────────────────── export class ApiError extends Error { status: number constructor(status: number, message: string) { super(message) this.name = 'ApiError' this.status = status } } // ── Fetch helpers ───────────────────────────────────────────────────────────── /** * Extract a human-readable error message from a FastAPI error response. * FastAPI may return { detail: "string" } or { detail: [{loc, msg, type}, ...] } * for validation errors (422). This function handles both cases. */ function extractDetail(payload: unknown, fallback: string): string { if (!payload || typeof payload !== 'object') return fallback const detail = (payload as Record).detail if (typeof detail === 'string') return detail if (Array.isArray(detail)) { // FastAPI validation error: [{loc: [...], msg: "...", type: "..."}] const messages = detail .map((e: Record) => { const loc = Array.isArray(e.loc) ? e.loc.join(' → ') : '' return loc ? `${loc} : ${e.msg}` : String(e.msg ?? '') }) .filter(Boolean) return messages.length > 0 ? messages.join(' ; ') : fallback } return fallback } async function get(path: string): Promise { const resp = await fetch(`${BASE_URL}${path}`) if (!resp.ok) { const payload = await resp.json().catch(() => null) throw new ApiError(resp.status, extractDetail(payload, `HTTP ${resp.status} — ${path}`)) } return resp.json() as Promise } async function post(path: string, body?: unknown): Promise { const resp = await fetch(`${BASE_URL}${path}`, { method: 'POST', headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, body: body !== undefined ? JSON.stringify(body) : undefined, }) if (!resp.ok) { const payload = await resp.json().catch(() => null) throw new Error(extractDetail(payload, `HTTP ${resp.status} — ${path}`)) } return resp.json() as Promise } async function put(path: string, body?: unknown): Promise { const resp = await fetch(`${BASE_URL}${path}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: body !== undefined ? JSON.stringify(body) : undefined, }) if (!resp.ok) { const payload = await resp.json().catch(() => null) throw new Error(extractDetail(payload, `HTTP ${resp.status} — ${path}`)) } return resp.json() as Promise } async function del(path: string): Promise { const resp = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' }) if (!resp.ok) { const payload = await resp.json().catch(() => null) throw new Error(extractDetail(payload, `HTTP ${resp.status} — ${path}`)) } } async function postForm(path: string, data: FormData): Promise { const resp = await fetch(`${BASE_URL}${path}`, { method: 'POST', body: data }) if (!resp.ok) { const payload = await resp.json().catch(() => null) throw new Error(extractDetail(payload, `HTTP ${resp.status} — ${path}`)) } return resp.json() as Promise } // ── API functions ───────────────────────────────────────────────────────────── export const fetchCorpora = (): Promise => get('/api/v1/corpora') export const fetchManuscripts = (corpusId: string): Promise => get(`/api/v1/corpora/${corpusId}/manuscripts`) export const fetchPages = (manuscriptId: string): Promise => get(`/api/v1/manuscripts/${manuscriptId}/pages`) export const fetchMasterJson = (pageId: string): Promise => get(`/api/v1/pages/${pageId}/master-json`) export const fetchManifest = (manuscriptId: string): Promise> => get(`/api/v1/manuscripts/${manuscriptId}/iiif-manifest`) export const fetchProfile = (profileId: string): Promise => get(`/api/v1/profiles/${profileId}`) export const listProfiles = (): Promise => get('/api/v1/profiles') export const createCorpus = (input: CreateCorpusInput): Promise => post('/api/v1/corpora', input) export const fetchProviders = (): Promise => get('/api/v1/providers') export const fetchProviderModels = (providerType: string): Promise => get(`/api/v1/providers/${providerType}/models`) export const selectModel = ( corpusId: string, modelId: string, displayName: string, providerType: string, supportsVision: boolean = true, ): Promise => put(`/api/v1/corpora/${corpusId}/model`, { model_id: modelId, display_name: displayName, provider_type: providerType, supports_vision: supportsVision, }) export const deleteCorpus = (id: string): Promise => del(`/api/v1/corpora/${id}`) export interface CorpusModelConfig { corpus_id: string selected_model_id: string selected_model_display_name: string provider_type: string supports_vision: boolean updated_at: string } export const getCorpusModel = async (corpusId: string): Promise => { try { return await get(`/api/v1/corpora/${corpusId}/model`) } catch { return null } } export const ingestImages = ( corpusId: string, urls: string[], folioLabels: string[], ): Promise => post(`/api/v1/corpora/${corpusId}/ingest/iiif-images`, { urls, folio_labels: folioLabels, }) export const ingestManifest = ( corpusId: string, manifestUrl: string, ): Promise => post(`/api/v1/corpora/${corpusId}/ingest/iiif-manifest`, { manifest_url: manifestUrl, }) export const ingestFiles = ( corpusId: string, files: File[], ): Promise => { const data = new FormData() for (const f of files) data.append('files', f) return postForm(`/api/v1/corpora/${corpusId}/ingest/files`, data) } export const runCorpus = (corpusId: string): Promise => post(`/api/v1/corpora/${corpusId}/run`) export const getJob = (jobId: string): Promise => get(`/api/v1/jobs/${jobId}`) export const retryJob = (jobId: string): Promise => post(`/api/v1/jobs/${jobId}/retry`) export interface VersionInfo { version: number saved_at: string status: string } export interface CorrectionsInput { ocr_diplomatic_text?: string editorial_status?: string commentary_public?: string commentary_scholarly?: string region_validations?: Record restore_to_version?: number } export interface SearchResult { page_id: string folio_label: string manuscript_id: string excerpt: string score: number corpus_profile: string } export const fetchPage = (pageId: string): Promise => get(`/api/v1/pages/${pageId}`) export const applyCorrections = ( pageId: string, corrections: CorrectionsInput, ): Promise => post(`/api/v1/pages/${pageId}/corrections`, corrections) export const getHistory = (pageId: string): Promise => get(`/api/v1/pages/${pageId}/history`) export const searchPages = (q: string): Promise => get(`/api/v1/search?q=${encodeURIComponent(q)}`)