4NK_IA_front/src/store/documentSlice.ts
Nicolas Cantu 8e2ac42410 backend
2025-09-16 05:23:17 +02:00

450 lines
16 KiB
TypeScript

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { PayloadAction } from '@reduxjs/toolkit'
import type { Document, ExtractionResult, AnalysisResult, ContextResult, ConseilResult } from '../types'
import { documentApi } from '../services/api'
import { openaiDocumentApi } from '../services/openai'
import { backendDocumentApi, checkBackendHealth } from '../services/backendApi'
import { createDefaultFolder, getDefaultFolder, getFolderResults, uploadFileToFolder, type FolderResult } from '../services/folderApi'
interface DocumentState {
documents: Document[]
currentDocument: Document | null
extractionResult: ExtractionResult | null
extractionById: Record<string, ExtractionResult>
fileById: Record<string, File>
analysisResult: AnalysisResult | null
contextResult: ContextResult | null
conseilResult: ConseilResult | null
loading: boolean
error: string | null
progressById: Record<string, { ocr: number; llm: number }>
bootstrapped: boolean // Flag pour indiquer si le bootstrap a été effectué
// Nouvelles propriétés pour les dossiers
currentFolderHash: string | null
folderResults: FolderResult[]
currentResultIndex: number
// Propriétés pour le système de pending
pendingFiles: Array<{
fileHash: string
folderHash: string
timestamp: string
status: string
}>
hasPending: boolean
pollingInterval: NodeJS.Timeout | null
}
// Fonction pour charger l'état depuis localStorage
const loadStateFromStorage = (): Partial<DocumentState> => {
try {
const savedState = localStorage.getItem('4nk-ia-documents')
if (savedState) {
const parsed = JSON.parse(savedState)
console.log('💾 [STORE] État chargé depuis localStorage:', {
documentsCount: parsed.documents?.length || 0,
extractionsCount: Object.keys(parsed.extractionById || {}).length
})
return parsed
}
} catch (error) {
console.warn('⚠️ [STORE] Erreur lors du chargement depuis localStorage:', error)
}
return {}
}
// Fonction pour sauvegarder l'état dans localStorage
const saveStateToStorage = (state: DocumentState) => {
try {
const stateToSave = {
documents: state.documents,
extractionById: state.extractionById,
currentDocument: state.currentDocument
}
localStorage.setItem('4nk-ia-documents', JSON.stringify(stateToSave))
} catch (error) {
console.warn('⚠️ [STORE] Erreur lors de la sauvegarde dans localStorage:', error)
}
}
const initialState: DocumentState = {
documents: [],
currentDocument: null,
extractionResult: null,
extractionById: {},
fileById: {},
analysisResult: null,
contextResult: null,
conseilResult: null,
loading: false,
error: null,
progressById: {},
bootstrapped: false,
// Nouvelles propriétés pour les dossiers
currentFolderHash: null,
folderResults: [],
currentResultIndex: 0,
// Propriétés pour le système de pending
pendingFiles: [],
hasPending: false,
pollingInterval: null,
...loadStateFromStorage()
}
export const uploadDocument = createAsyncThunk(
'document/upload',
async (file: File) => {
return await documentApi.upload(file)
}
)
export const extractDocument = createAsyncThunk(
'document/extract',
async (documentId: string, thunkAPI) => {
console.log(`🚀 [STORE] Extraction du document: ${documentId}`)
const state = thunkAPI.getState() as { document: DocumentState }
const doc = state.document.documents.find((d) => d.id === documentId)
if (!doc) {
throw new Error(`Document ${documentId} non trouvé`)
}
// Vérifier si une extraction est déjà en cours ou terminée
if (state.document.extractionById[documentId]) {
console.log(`⚠️ [STORE] Extraction déjà en cours/terminée pour ${documentId}`)
return state.document.extractionById[documentId]
}
// Vérifier si le backend est disponible
const backendAvailable = await checkBackendHealth()
if (backendAvailable) {
console.log('🚀 [STORE] Utilisation du backend pour l\'extraction')
// Pas de hooks de progression pour le backend - traitement direct
if (doc?.previewUrl) {
try {
const res = await fetch(doc.previewUrl)
const blob = await res.blob()
const file = new File([blob], doc.name, { type: doc.mimeType })
return await backendDocumentApi.extract(documentId, file)
} catch (error) {
console.error('❌ [STORE] Erreur backend:', error)
throw error
}
}
return await backendDocumentApi.extract(documentId, undefined)
} else {
console.log('⚠️ [STORE] Backend non disponible, utilisation du mode local')
// Fallback vers le mode local (OpenAI ou règles)
const useOpenAI = import.meta.env.VITE_USE_OPENAI === 'true'
if (useOpenAI) {
const state = thunkAPI.getState() as { document: DocumentState }
const doc = state.document.documents.find((d) => d.id === documentId)
// Pas de hooks de progression pour le mode local
if (doc?.previewUrl) {
try {
const res = await fetch(doc.previewUrl)
const blob = await res.blob()
const file = new File([blob], doc.name, { type: doc.mimeType })
return await openaiDocumentApi.extract(documentId, file)
} catch {
return await openaiDocumentApi.extract(documentId, undefined)
}
}
return await openaiDocumentApi.extract(documentId, undefined)
}
return await documentApi.extract(documentId)
}
}
)
export const analyzeDocument = createAsyncThunk(
'document/analyze',
async (documentId: string) => {
return await documentApi.analyze(documentId)
}
)
export const getContextData = createAsyncThunk(
'document/context',
async (documentId: string) => {
return await documentApi.getContext(documentId)
}
)
export const getConseil = createAsyncThunk(
'document/conseil',
async (documentId: string) => {
return await documentApi.getConseil(documentId)
}
)
// Thunks pour la gestion des dossiers
export const createDefaultFolderThunk = createAsyncThunk(
'document/createDefaultFolder',
async () => {
return await getDefaultFolder()
}
)
export const loadFolderResults = createAsyncThunk(
'document/loadFolderResults',
async (folderHash: string) => {
return await getFolderResults(folderHash)
}
)
export const uploadFileToFolderThunk = createAsyncThunk(
'document/uploadFileToFolder',
async ({ file, folderHash }: { file: File; folderHash: string }) => {
return await uploadFileToFolder(file, folderHash)
}
)
const documentSlice = createSlice({
name: 'document',
initialState,
reducers: {
setCurrentDocument: (state, action: PayloadAction<Document | null>) => {
state.currentDocument = action.payload
},
clearResults: (state) => {
state.extractionResult = null
// Ne pas effacer extractionById pour conserver les résultats par document
state.analysisResult = null
state.contextResult = null
state.conseilResult = null
},
addDocuments: (state, action: PayloadAction<Document[]>) => {
const incoming = action.payload
// Évite les doublons par (name,size) pour les bootstraps répétés en dev
const seenKey = new Set(state.documents.map((d) => `${d.name}::${d.size}`))
const merged = [...state.documents]
incoming.forEach((d) => {
const key = `${d.name}::${d.size}`
if (!seenKey.has(key)) {
seenKey.add(key)
merged.push(d)
}
})
state.documents = merged
},
removeDocument: (state, action: PayloadAction<string>) => {
const idToRemove = action.payload
state.documents = state.documents.filter((d) => d.id !== idToRemove)
if (state.currentDocument && state.currentDocument.id === idToRemove) {
state.currentDocument = null
state.extractionResult = null
state.analysisResult = null
state.contextResult = null
state.conseilResult = null
}
delete state.progressById[idToRemove]
},
setOcrProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: Math.max(0, Math.min(100, Math.round(progress * 100))), llm: state.progressById[id]?.llm || 0 }
},
setLlmProgress: (state, action: PayloadAction<{ id: string; progress: number }>) => {
const { id, progress } = action.payload
state.progressById[id] = { ocr: state.progressById[id]?.ocr || 0, llm: Math.max(0, Math.min(100, Math.round(progress * 100))) }
},
setBootstrapped: (state, action: PayloadAction<boolean>) => {
state.bootstrapped = action.payload
},
// Nouveaux reducers pour les dossiers
setCurrentFolderHash: (state, action: PayloadAction<string | null>) => {
state.currentFolderHash = action.payload
},
setCurrentResultIndex: (state, action: PayloadAction<number>) => {
state.currentResultIndex = action.payload
},
clearFolderResults: (state) => {
state.folderResults = []
state.currentResultIndex = 0
},
// Reducers pour le système de pending
setPendingFiles: (state, action: PayloadAction<Array<{
fileHash: string
folderHash: string
timestamp: string
status: string
}>>) => {
state.pendingFiles = action.payload
state.hasPending = action.payload.length > 0
},
setPollingInterval: (state, action: PayloadAction<NodeJS.Timeout | null>) => {
state.pollingInterval = action.payload
},
stopPolling: (state) => {
if (state.pollingInterval) {
clearInterval(state.pollingInterval)
state.pollingInterval = null
}
state.hasPending = false
state.pendingFiles = []
},
},
extraReducers: (builder) => {
builder
.addCase(uploadDocument.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(uploadDocument.fulfilled, (state, action) => {
state.loading = false
const { document, extraction } = action.payload
console.log('📤 [STORE] Upload fulfilled:', {
documentId: document.id,
documentName: document.name,
hasExtraction: !!extraction,
extractionDocumentId: extraction?.documentId
})
state.documents.push(document)
state.currentDocument = document
// Stocker le résultat d'extraction si disponible
if (extraction) {
state.extractionResult = extraction
state.extractionById[document.id] = extraction
console.log('✅ [STORE] Extraction stored for document:', document.id)
}
// Capture le File depuis l'URL blob si disponible
if (document.previewUrl?.startsWith('blob:')) {
// On ne peut pas récupérer l'objet File initial ici sans passer par onDrop;
// il est reconstruit lors de l'extraction via fetch blob.
}
})
.addCase(uploadDocument.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors du téléversement'
})
.addCase(extractDocument.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(extractDocument.fulfilled, (state, action) => {
state.loading = false
state.extractionResult = action.payload
state.extractionById[action.payload.documentId] = action.payload
// Mettre à jour le statut du document courant
if (state.currentDocument && state.currentDocument.id === action.payload.documentId) {
state.currentDocument.status = 'completed'
}
})
.addCase(extractDocument.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de l\'extraction'
// Mettre à jour le statut du document courant en cas d'erreur
if (state.currentDocument) {
state.currentDocument.status = 'error'
}
})
.addCase(analyzeDocument.fulfilled, (state, action) => {
state.analysisResult = action.payload
})
.addCase(getContextData.fulfilled, (state, action) => {
state.contextResult = action.payload
})
.addCase(getConseil.fulfilled, (state, action) => {
state.conseilResult = action.payload
})
// ExtraReducers pour les dossiers
.addCase(createDefaultFolderThunk.fulfilled, (state, action) => {
state.currentFolderHash = action.payload.folderHash
state.loading = false
})
.addCase(createDefaultFolderThunk.pending, (state) => {
state.loading = true
})
.addCase(createDefaultFolderThunk.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de la création du dossier par défaut'
})
.addCase(loadFolderResults.fulfilled, (state, action) => {
console.log(`[STORE] loadFolderResults.fulfilled appelé avec:`, action.payload)
console.log(`[STORE] Nombre de résultats reçus:`, action.payload.results?.length || 0)
state.folderResults = action.payload.results
state.currentFolderHash = action.payload.folderHash
state.loading = false
// Gérer les fichiers pending
state.pendingFiles = action.payload.pending || []
state.hasPending = action.payload.hasPending || false
// Convertir les résultats en documents pour la compatibilité
if (action.payload.results && action.payload.results.length > 0) {
state.documents = action.payload.results.map((result, index) => {
console.log(`[STORE] Mapping résultat ${index}:`, {
fileHash: result.fileHash,
fileName: result.document?.fileName,
mimeType: result.document?.mimeType
})
return {
id: result.fileHash,
name: result.document.fileName,
mimeType: result.document.mimeType,
size: 0, // Taille non disponible dans la structure actuelle
uploadDate: new Date(result.document.uploadTimestamp),
status: 'completed' as const,
previewUrl: `blob:folder-${result.fileHash}`
}
})
} else {
console.log(`[STORE] Aucun résultat à mapper`)
state.documents = []
}
console.log(`[STORE] Dossier chargé: ${action.payload.results.length} résultats, ${action.payload.pending?.length || 0} pending`)
console.log(`[STORE] Documents finaux:`, state.documents.length)
console.log(`[STORE] Documents mappés:`, state.documents.map(d => ({ id: d.id, name: d.name, status: d.status })))
})
.addCase(loadFolderResults.pending, (state) => {
// Ne pas afficher la barre de progression pour le chargement initial des résultats
// state.loading = true
})
.addCase(loadFolderResults.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors du chargement des résultats du dossier'
})
.addCase(uploadFileToFolderThunk.fulfilled, (state, action) => {
// Recharger les résultats du dossier après upload
state.loading = false
})
.addCase(uploadFileToFolderThunk.pending, (state) => {
state.loading = true
})
.addCase(uploadFileToFolderThunk.rejected, (state, action) => {
state.loading = false
state.error = action.error.message || 'Erreur lors de l\'upload du fichier'
})
},
})
export const {
setCurrentDocument,
clearResults,
addDocuments,
removeDocument,
setOcrProgress,
setLlmProgress,
setBootstrapped,
setCurrentFolderHash,
setCurrentResultIndex,
clearFolderResults,
setPendingFiles,
setPollingInterval,
stopPolling
} = documentSlice.actions
export const documentReducer = documentSlice.reducer