450 lines
16 KiB
TypeScript
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
|