395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
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 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 (
|
||
<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>
|
||
);
|
||
}
|