Initial commit
This commit is contained in:
398
app/src/screens/LocalModelsScreen/index.tsx
Normal file
398
app/src/screens/LocalModelsScreen/index.tsx
Normal 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 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>
|
||||
)}
|
||||
{!hasPermission && (
|
||||
<PermissionMessage onRequestPermission={handleRequestPermission} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Models List */}
|
||||
<ModelsList
|
||||
models={localModels}
|
||||
isLoading={isLoadingLocal}
|
||||
onRefresh={handleRefresh}
|
||||
renderItem={renderModelItem}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user