Initial commit

This commit is contained in:
Jonathan Atta
2026-03-03 10:33:56 +01:00
commit da373199e0
139 changed files with 26421 additions and 0 deletions

View File

@@ -0,0 +1,398 @@
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 PermissionMessage from './PermissionMessage';
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<AppDispatch>();
const navigation = useNavigation<
NativeStackNavigationProp<RootStackParamList>
>();
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<string | null>(null);
// Path du modèle en cours de chargement (pour spinner sur la bonne carte)
const [loadingPath, setLoadingPath] = useState<string | null>(null);
// Nom du modèle affiché dans la modal de chargement
const [loadingModelName, setLoadingModelName] = useState<string | null>(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 (
<ModelItem
model={item}
isLoaded={isLoaded}
onPress={(path: string, name: string) => {
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 0100)
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 (
<View style={styles.container}>
{/* Loading modal — même comportement que ModelConfigScreen */}
<Modal visible={loadingModelName !== null} transparent animationType="fade">
<View style={styles.loadingOverlay}>
<View style={styles.loadingCard}>
<Text style={styles.loadingTitle}>Chargement du modèle</Text>
<Text style={styles.loadingSubtitle} numberOfLines={1}>
{loadingModelName}
</Text>
<ActivityIndicator
size="large"
color={colors.primary}
style={styles.activityIndicator}
/>
<View style={styles.progressBarBg}>
<View style={[styles.progressBarFill, progressStyle]} />
</View>
<Text style={styles.loadingPercent}>{loadModelProgress} %</Text>
</View>
</View>
</Modal>
{/* Directory Selection */}
<View style={styles.directorySection}>
<Text style={styles.sectionTitle}>Dossier des modèles</Text>
{editingDirectory ? (
<>
<DirectoryEditor
tempDirectory={tempDirectory}
onChangeText={setTempDirectory}
onCancel={() => {
setTempDirectory(modelsDirectory);
setEditingDirectory(false);
}}
onSave={handleUpdateDirectory}
onSelectCommon={handleSelectCommonDirectory}
/>
<DirectoryPicker
currentDirectory={modelsDirectory}
onSelectDirectory={handleSelectCommonDirectory}
/>
</>
) : (
<View style={styles.directoryDisplay}>
<Text style={styles.directoryText} numberOfLines={2}>
{modelsDirectory}
</Text>
<TouchableOpacity
style={styles.changeButton}
onPress={() => setEditingDirectory(true)}
>
<Text style={styles.changeButtonText}>Modifier</Text>
</TouchableOpacity>
</View>
)}
{/* RAM bar */}
{Platform.OS === 'android' && ramTotal > 0 && (
<View style={styles.ramSection}>
{/* Header row */}
<View style={styles.ramSectionHeader}>
<Text style={styles.ramSectionLabel}>RAM</Text>
<Text style={styles.ramHeaderRight}>
Libre :{' '}
<Text style={[{ color: ramColor }, styles.ramStrong]}>
{fmtGB(ramFree)} ({Math.round(ramPct)} %)
</Text>
{' / '}{fmtGB(ramTotal)}
</Text>
</View>
{/* Progress bar */}
<View style={styles.ramBarTrack}>
{/* Used */}
<View style={[styles.ramBarSeg,
{ flex: usedFlex, backgroundColor: colors.textSecondary }
]} />
{/* Model footprint */}
{modelFlexRaw > 0 && (
<View style={[styles.ramBarSeg, {
flex: modelFlexRaw,
backgroundColor: modelBarColor
}]} />
)}
{/* Remaining free */}
{freeFlex > 0 && (
<View style={[styles.ramBarSeg, { flex: freeFlex, backgroundColor: ramColor }]} />
)}
</View>
{/* Legend when a model is highlighted */}
{highlightedSize !== null && (
<View style={styles.ramLegend}>
<View style={styles.ramLegendRow}>
<View style={[styles.ramLegendDot, { backgroundColor: modelBarColor }]} />
<Text style={styles.ramLegendText}>
Modèle : {fmtGB(highlightedSize)}{' '}
<Text style={[{ color: modelBarColor }, styles.ramStrong]}>
({Math.round((highlightedSize / ramTotal) * 100)} % de la RAM)
</Text>
</Text>
</View>
{modelExceedsRam && (
<Text style={styles.ramWarning}>
Insuffisant fermez d'autres apps ou choisissez une quantification plus légère
</Text>
)}
</View>
)}
{/* Hint when nothing selected */}
{highlightedSize === null && (
<Text style={styles.ramHint}> Appuyez sur un modèle pour voir son impact</Text>
)}
</View>
)}
{!hasPermission && (
<PermissionMessage onRequestPermission={handleRequestPermission} />
)}
</View>
{/* Models List */}
<ModelsList
models={localModels}
isLoading={isLoadingLocal}
onRefresh={handleRefresh}
renderItem={renderModelItem}
/>
</View>
);
}