Files
MyMobileAgent/app/src/screens/LocalModelsScreen/index.tsx

395 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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>
)}
</View>
{/* Models List */}
<ModelsList
models={localModels}
isLoading={isLoadingLocal}
onRefresh={handleRefresh}
renderItem={renderModelItem}
/>
</View>
);
}