import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, TouchableOpacity, Alert, AppState, Platform, Modal, ActivityIndicator, } from 'react-native'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../../navigation'; import { useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from '../../store'; import { scanLocalModels, updateModelsDirectory, loadModelsDirectory, clearLocalError, setCurrentLoadedModel, } from '../../store/modelsSlice'; import { loadModelConfigs } from '../../store/modelsThunks'; import { refreshHardwareInfo, refreshRamInfo } from '../../store/hardwareSlice'; import { loadModel, releaseModel } from '../../store/chatThunks'; import type { ModelConfig } from '../../store/types'; import ModelItem from './ModelItem'; import DirectoryPicker from './DirectoryPicker'; import DirectoryEditor from './DirectoryEditor'; import ModelsList from './ModelsList'; import { createStyles } from './styles'; import { useTheme } from '../../theme/ThemeProvider'; import { requestStoragePermission, checkStoragePermission, } from '../../utils/permissions'; export default function LocalModelsScreen() { const { colors } = useTheme(); const styles = createStyles(colors); const dispatch = useDispatch(); const navigation = useNavigation< NativeStackNavigationProp >(); const { localModels, modelsDirectory, isLoadingLocal, localError, currentLoadedModel, downloadProgress, } = useSelector((state: RootState) => state.models, ); const ramFree = useSelector((s: RootState) => s.hardware.ramFree); const ramTotal = useSelector((s: RootState) => s.hardware.ramTotal); const isLoadingModel = useSelector((s: RootState) => s.models.isLoadingModel); const loadModelProgress = useSelector((s: RootState) => s.models.loadModelProgress ?? 0); const modelConfigs = useSelector((s: RootState) => s.models.modelConfigs ?? {}); // Highlighted model path — tapping a card shows its footprint in the RAM bar const [highlightedPath, setHighlightedPath] = useState(null); // Path du modèle en cours de chargement (pour spinner sur la bonne carte) const [loadingPath, setLoadingPath] = useState(null); // Nom du modèle affiché dans la modal de chargement const [loadingModelName, setLoadingModelName] = useState(null); const [editingDirectory, setEditingDirectory] = useState(false); const [tempDirectory, setTempDirectory] = useState(modelsDirectory); const [hasPermission, setHasPermission] = useState(false); useEffect(() => { checkStoragePermission().then(setHasPermission); const subscription = AppState.addEventListener('change', async (nextAppState) => { if (nextAppState === 'active') { const permitted = await checkStoragePermission(); setHasPermission(permitted); if (permitted) { dispatch(scanLocalModels(modelsDirectory)); } } }); return () => { subscription.remove(); }; }, [dispatch, modelsDirectory]); useEffect(() => { // Load saved directory and scan on mount const initializeScreen = async () => { const result = await dispatch(loadModelsDirectory()); if (result.payload) { const permitted = await checkStoragePermission(); setHasPermission(permitted); if (permitted) { dispatch(scanLocalModels(result.payload as string)); } } dispatch(loadModelConfigs()); }; initializeScreen(); // Refresh RAM info on mount so the indicator is up to date dispatch(refreshHardwareInfo()); }, [dispatch]); useEffect(() => { setTempDirectory(modelsDirectory); }, [modelsDirectory]); // Polling RAM toutes les secondes quand l'écran est actif useFocusEffect( useCallback(() => { dispatch(refreshRamInfo()); const interval = setInterval(() => { dispatch(refreshRamInfo()); }, 1000); return () => clearInterval(interval); }, [dispatch]), ); useEffect(() => { if (localError) { Alert.alert('Erreur', localError, [ { text: 'OK', onPress: () => dispatch(clearLocalError()) }, ]); } }, [localError, dispatch]); const handleRefresh = () => { dispatch(scanLocalModels(modelsDirectory)); }; const handleRequestPermission = async () => { await requestStoragePermission(); // Attendre un peu puis revérifier setTimeout(async () => { const permitted = await checkStoragePermission(); setHasPermission(permitted); if (permitted) { dispatch(scanLocalModels(modelsDirectory)); } }, 1000); }; const handleUpdateDirectory = () => { if (tempDirectory.trim()) { dispatch(updateModelsDirectory(tempDirectory.trim())); setEditingDirectory(false); } }; const handleSelectCommonDirectory = (dir: string) => { setTempDirectory(dir); dispatch(updateModelsDirectory(dir)); setEditingDirectory(false); }; const handleLoadModel = async (modelPath: string, modelName: string) => { const cfg: ModelConfig = modelConfigs[modelPath] ?? { n_ctx: 2048, n_threads: 4, n_gpu_layers: 0, use_mlock: false, use_mmap: true, }; setLoadingPath(modelPath); setLoadingModelName(modelName); try { dispatch(setCurrentLoadedModel(modelPath)); const result = await dispatch(loadModel({ modelPath, cfg })); if (loadModel.rejected.match(result)) { dispatch(setCurrentLoadedModel(null)); Alert.alert( 'Erreur de chargement', String(result.payload ?? `Impossible de charger ${modelName}`), ); } } finally { setLoadingPath(null); setLoadingModelName(null); } }; const handleUnloadModel = async () => { await dispatch(releaseModel()); dispatch(setCurrentLoadedModel(null)); setHighlightedPath(null); dispatch(refreshRamInfo()); }; const renderModelItem = ({ item }: { item: typeof localModels[0] }) => { const isLoaded = currentLoadedModel === item.path; // Vérifier si ce modèle est en cours de téléchargement const downloadKey = Object.keys(downloadProgress).find(key => { const fileName = downloadProgress[key].modelId.split('/').pop(); return item.name.includes(fileName || ''); }); const modelProgress = downloadKey ? downloadProgress[downloadKey] : null; const isDownloading = !!modelProgress; const progressPercent = modelProgress ? (modelProgress.bytesWritten / modelProgress.contentLength) * 100 : 0; return ( { setHighlightedPath(null); navigation.navigate('ModelConfig', { modelPath: path, modelName: name }); }} onLoad={() => handleLoadModel(item.path, item.name)} onUnload={handleUnloadModel} isLoadingThisModel={isLoadingModel && loadingPath === item.path} onHighlight={() => setHighlightedPath(prev => (prev === item.path ? null : item.path)) } isHighlighted={highlightedPath === item.path} ramFree={ramFree} isDownloading={isDownloading} downloadProgress={progressPercent} bytesWritten={modelProgress?.bytesWritten || 0} contentLength={modelProgress?.contentLength || 0} /> ); }; const fmtGB = (b: number) => b >= 1073741824 ? `${(b / 1073741824).toFixed(2)} GB` : b >= 1048576 ? `${(b / 1048576).toFixed(0)} MB` : '—'; // Highlighted model's size (null when nothing is selected) const highlightedModel = localModels.find(m => m.path === highlightedPath); const highlightedSize = highlightedModel?.size ?? null; // RAM bar proportions (flex values normalized to 0–100) const ramPct = ramTotal > 0 ? (ramFree / ramTotal) * 100 : -1; const ramColor = ramPct < 0 ? colors.textTertiary : ramPct < 20 ? colors.error : ramPct < 40 ? colors.warning : colors.success; const usedFlex = ramTotal > 0 ? ((ramTotal - ramFree) / ramTotal) * 100 : 0; const modelExceedsRam = (highlightedSize ?? 0) > ramFree; const modelFlexRaw = highlightedSize && ramTotal > 0 ? (Math.min(highlightedSize, ramFree) / ramTotal) * 100 : 0; const freeFlex = Math.max(0, 100 - usedFlex - modelFlexRaw); const modelBarColor = modelExceedsRam ? colors.error : colors.warning; const progressStyle = { width: `${loadModelProgress}%` as `${number}%` }; return ( {/* Loading modal — même comportement que ModelConfigScreen */} Chargement du modèle {loadingModelName} {loadModelProgress} % {/* Directory Selection */} Dossier des modèles {editingDirectory ? ( <> { setTempDirectory(modelsDirectory); setEditingDirectory(false); }} onSave={handleUpdateDirectory} onSelectCommon={handleSelectCommonDirectory} /> ) : ( {modelsDirectory} setEditingDirectory(true)} > Modifier )} {/* RAM bar */} {Platform.OS === 'android' && ramTotal > 0 && ( {/* Header row */} RAM Libre :{' '} {fmtGB(ramFree)} ({Math.round(ramPct)} %) {' / '}{fmtGB(ramTotal)} {/* Progress bar */} {/* Used */} {/* Model footprint */} {modelFlexRaw > 0 && ( )} {/* Remaining free */} {freeFlex > 0 && ( )} {/* Legend when a model is highlighted */} {highlightedSize !== null && ( Modèle : {fmtGB(highlightedSize)}{' '} ({Math.round((highlightedSize / ramTotal) * 100)} % de la RAM) {modelExceedsRam && ( ⚠ Insuffisant — fermez d'autres apps ou choisissez une quantification plus légère )} )} {/* Hint when nothing selected */} {highlightedSize === null && ( ↑ Appuyez sur un modèle pour voir son impact )} )} {/* Models List */} ); }