Initial commit
This commit is contained in:
105
app/src/navigation/index.tsx
Normal file
105
app/src/navigation/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
import SettingsScreen from '../screens/SettingsScreen';
|
||||
import ModelsScreen from '../screens/ModelsScreen';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import HardwareInfoScreen from '../screens/HardwareInfoScreen';
|
||||
import AgentsScreen from '../screens/AgentsScreen';
|
||||
import ModelConfigScreen from '../screens/LocalModelsScreen/ModelConfigScreen';
|
||||
import MessageDetails from '../screens/ChatScreen/MessageDetails';
|
||||
import LandingScreen from '../screens/LandingScreen';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Landing: undefined;
|
||||
MainTabs: { screen?: keyof TabParamList } | undefined;
|
||||
ModelConfig: { modelPath: string; modelName: string };
|
||||
MessageDetails: { messageId: string };
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
Chat: undefined;
|
||||
Models: undefined;
|
||||
Agents: undefined;
|
||||
Hardware: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
function TabNavigator() {
|
||||
const { colors, scheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: scheme === 'neon' ? colors.textSecondary ?? '#A7F3D0' : colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
borderTopColor: colors.surfaceSecondary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Models"
|
||||
component={ModelsScreen}
|
||||
options={{ title: 'Modèles' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={{ title: 'Chat' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Agents"
|
||||
component={AgentsScreen}
|
||||
options={{ title: 'Agents' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Hardware"
|
||||
component={HardwareInfoScreen}
|
||||
options={{ title: 'Matériel' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: 'Réglages' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<RootStack.Navigator
|
||||
screenOptions={{ headerShown: false }}
|
||||
initialRouteName="Landing"
|
||||
>
|
||||
<RootStack.Screen
|
||||
name="Landing"
|
||||
component={LandingScreen}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="MainTabs"
|
||||
component={TabNavigator}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="ModelConfig"
|
||||
component={ModelConfigScreen}
|
||||
options={{ headerShown: true, title: 'Configuration du modèle' }}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="MessageDetails"
|
||||
component={MessageDetails}
|
||||
options={{ headerShown: true, title: 'Détails du message' }}
|
||||
/>
|
||||
</RootStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
344
app/src/screens/AgentsScreen/AgentEditor.tsx
Normal file
344
app/src/screens/AgentsScreen/AgentEditor.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { pick, types } from '@react-native-documents/picker';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { AppDispatch } from '../../store';
|
||||
import { saveAgent } from '../../store/agentsSlice';
|
||||
import type { Agent } from '../../store/types';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agent?: Agent | null; // null = new agent
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function makeId() {
|
||||
return `agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const [name, setName] = useState(agent?.name ?? '');
|
||||
const [prompt, setPrompt] = useState(
|
||||
agent?.systemPrompt ?? 'Tu es un assistant utile et concis.',
|
||||
);
|
||||
const [avatarUri, setAvatarUri] = useState<string | null>(
|
||||
agent?.avatarUri ?? null,
|
||||
);
|
||||
const [pickingImage, setPickingImage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset fields when modal opens with a new agent
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setName(agent?.name ?? '');
|
||||
setPrompt(agent?.systemPrompt ?? 'Tu es un assistant utile et concis.');
|
||||
setAvatarUri(agent?.avatarUri ?? null);
|
||||
}
|
||||
}, [visible, agent]);
|
||||
|
||||
const handlePickImage = async () => {
|
||||
try {
|
||||
setPickingImage(true);
|
||||
const [result] = await pick({ type: [types.images] });
|
||||
if (!result?.uri) return;
|
||||
|
||||
// Copy into app-local storage so the URI survives
|
||||
const ext = result.name?.split('.').pop() ?? 'jpg';
|
||||
const destName = `agent_avatar_${Date.now()}.${ext}`;
|
||||
const destPath = `${RNFS.DocumentDirectoryPath}/${destName}`;
|
||||
await RNFS.copyFile(result.uri, destPath);
|
||||
setAvatarUri(`file://${destPath}`);
|
||||
} catch (e: unknown) {
|
||||
// User cancelled or error
|
||||
if (!String(e).includes('cancelled') && !String(e).includes('DOCUMENT_PICKER_CANCELED')) {
|
||||
Alert.alert('Erreur', "Impossible de charger l'image");
|
||||
}
|
||||
} finally {
|
||||
setPickingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Nom requis', "Donne un nom à l'agent");
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
Alert.alert('Prompt requis', 'Le prompt système ne peut pas être vide');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated: Agent = {
|
||||
id: agent?.id ?? makeId(),
|
||||
name: name.trim(),
|
||||
systemPrompt: prompt.trim(),
|
||||
avatarUri: avatarUri ?? null,
|
||||
};
|
||||
await dispatch(saveAgent(updated));
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitial = name.trim()[0]?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.sheet} onPress={() => {}}>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
<Text style={styles.title}>
|
||||
{agent ? "Modifier l'agent" : 'Nouvel agent'}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Avatar picker */}
|
||||
<View style={styles.avatarRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarBtn}
|
||||
onPress={handlePickImage}
|
||||
disabled={pickingImage}
|
||||
>
|
||||
{pickingImage ? (
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
) : avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.avatarImg} />
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarInitial}>{avatarInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.avatarEditBadge}>
|
||||
<Text style={styles.avatarEditBadgeText}>✎</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{avatarUri && (
|
||||
<TouchableOpacity
|
||||
style={styles.removeAvatarBtn}
|
||||
onPress={() => setAvatarUri(null)}
|
||||
>
|
||||
<Text style={styles.removeAvatarText}>Supprimer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={styles.label}>Nom de l'agent</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Ex: Expert Python"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
maxLength={60}
|
||||
/>
|
||||
|
||||
{/* System prompt */}
|
||||
<Text style={styles.label}>Prompt système</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.multiline]}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="Tu es un expert en…"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.cancelBtn} onPress={onClose}>
|
||||
<Text style={styles.cancelText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, saving && styles.saveBtnSaving]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color={colors.surface} size="small" />
|
||||
) : (
|
||||
<Text style={styles.saveText}>
|
||||
{agent ? 'Mettre à jour' : 'Créer'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = 72;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xxl,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
alignSelf: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarBtn: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImg: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarInitial: {
|
||||
fontSize: 28,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.surface,
|
||||
},
|
||||
avatarEditBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.textSecondary,
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarEditBadgeText: {
|
||||
fontSize: 11,
|
||||
color: colors.surface,
|
||||
},
|
||||
removeAvatarBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
removeAvatarText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.error,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
multiline: {
|
||||
height: 140,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
cancelBtn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
saveBtn: {
|
||||
flex: 2,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveBtnSaving: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
276
app/src/screens/AgentsScreen/index.tsx
Normal file
276
app/src/screens/AgentsScreen/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import { loadAgents, deleteAgent } from '../../store/agentsSlice';
|
||||
import type { Agent } from '../../store/types';
|
||||
import AgentEditor from './AgentEditor';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
export default function AgentsScreen() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const agents = useSelector((s: RootState) => s.agents.agents);
|
||||
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadAgents());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingAgent(null);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (agent: Agent) => {
|
||||
setEditingAgent(agent);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (agent: Agent) => {
|
||||
Alert.alert(
|
||||
'Supprimer l\'agent',
|
||||
`Supprimer "${agent.name}" ?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => dispatch(deleteAgent(agent.id)),
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Agent }) => {
|
||||
const initial = item.name[0]?.toUpperCase() ?? '?';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => handleEdit(item)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{item.avatarUri ? (
|
||||
<Image source={{ uri: item.avatarUri }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarInitial}>{initial}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.agentName}>{item.name}</Text>
|
||||
<Text style={styles.agentPrompt} numberOfLines={2}>
|
||||
{item.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.editBtn}
|
||||
onPress={() => handleEdit(item)}
|
||||
>
|
||||
<Text style={styles.editBtnText}>✎</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => handleDelete(item)}
|
||||
>
|
||||
<Text style={styles.deleteBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Agents</Text>
|
||||
<TouchableOpacity style={styles.addBtn} onPress={handleNew}>
|
||||
<Text style={styles.addBtnText}>+ Nouvel agent</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={agents}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🤖</Text>
|
||||
<Text style={styles.emptyTitle}>Aucun agent</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Créez un agent avec un nom, une image et un prompt système
|
||||
personnalisé.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.emptyBtn} onPress={handleNew}>
|
||||
<Text style={styles.emptyBtnText}>Créer mon premier agent</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
<AgentEditor
|
||||
visible={editorVisible}
|
||||
agent={editingAgent}
|
||||
onClose={() => setEditorVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = 52;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: typography.sizes.xl,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
addBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
addBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
list: {
|
||||
padding: spacing.md,
|
||||
flexGrow: 1,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
shadowColor: colors.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 2,
|
||||
},
|
||||
avatar: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarInitial: {
|
||||
fontSize: 22,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.surface,
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
agentName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 3,
|
||||
},
|
||||
agentPrompt: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
cardActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
editBtn: {
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
editBtnText: {
|
||||
fontSize: 16,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
deleteBtnText: {
|
||||
fontSize: 14,
|
||||
color: colors.error,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
marginTop: 80,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 56,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
emptyBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
emptyBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
481
app/src/screens/ChatScreen.tsx
Normal file
481
app/src/screens/ChatScreen.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../store';
|
||||
import {
|
||||
addMessage,
|
||||
setSelectedModel,
|
||||
startAssistantMessage,
|
||||
updateLastMessage,
|
||||
clearError,
|
||||
createConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
setActiveAgent,
|
||||
setMessageMeta,
|
||||
} from '../store/chatSlice';
|
||||
import { generateResponse, stopGeneration } from '../store/chatThunks';
|
||||
import MessageBubble from './ChatScreen/MessageBubble';
|
||||
import ChatInput from './ChatScreen/ChatInput';
|
||||
import ChatDrawer from './ChatScreen/ChatDrawer';
|
||||
import AgentPickerModal from './ChatScreen/AgentPickerModal';
|
||||
import MultiAgentPickerModal from './ChatScreen/MultiAgentPickerModal';
|
||||
import { spacing, typography } from '../theme/lightTheme';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const {
|
||||
conversations, activeConversationId, selectedModel,
|
||||
error, temperature, maxTokens, isInferring,
|
||||
} = useSelector((state: RootState) => state.chat);
|
||||
const { localModels, currentLoadedModel } = useSelector(
|
||||
(s: RootState) => s.models,
|
||||
);
|
||||
const agents = useSelector((s: RootState) => s.agents.agents);
|
||||
const activeConv = conversations.find(c => c.id === activeConversationId);
|
||||
const messages = activeConv?.messages ?? [];
|
||||
const activeTitle = activeConv?.title ?? 'Nouveau chat';
|
||||
const activeAgentId = activeConv?.activeAgentId ?? null;
|
||||
const activeAgent = activeAgentId
|
||||
? (agents.find(a => a.id === activeAgentId) ?? null) : null;
|
||||
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agentPickerVisible, setAgentPickerVisible] = useState(false);
|
||||
const [roundtableMode, setRoundtableMode] = useState(false);
|
||||
const [multiPickerVisible, setMultiPickerVisible] = useState(false);
|
||||
const [roundtableSelectedIds, setRoundtableSelectedIds] = useState<string[]>([]);
|
||||
const [roundtableRunning, setRoundtableRunning] = useState(false);
|
||||
const roundtableStopRef = useRef(false);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentLoadedModel) { dispatch(setSelectedModel(currentLoadedModel)); }
|
||||
}, [currentLoadedModel, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
Alert.alert("Erreur d'inférence", error, [
|
||||
{ text: 'OK', onPress: () => dispatch(clearError()) },
|
||||
]);
|
||||
}
|
||||
}, [error, dispatch]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedModel || isInferring || roundtableRunning) { return; }
|
||||
// If roundtable mode is active, start the roundtable flow instead of a single send
|
||||
if (roundtableMode) {
|
||||
await runRoundtable(inputText.trim());
|
||||
return;
|
||||
}
|
||||
if (!inputText.trim()) { return; }
|
||||
const userMessage = {
|
||||
id: `msg-${Date.now()}`, role: 'user' as const,
|
||||
content: inputText.trim(), timestamp: Date.now(),
|
||||
};
|
||||
const assistantMessageId = `msg-${Date.now() + 1}`;
|
||||
dispatch(addMessage(userMessage));
|
||||
dispatch(startAssistantMessage(assistantMessageId));
|
||||
dispatch(setMessageMeta({
|
||||
id: assistantMessageId,
|
||||
meta: { agentName: activeAgent?.name ?? null, modelName },
|
||||
}));
|
||||
setInputText('');
|
||||
dispatch(generateResponse({
|
||||
modelPath: selectedModel, messages: [...messages, userMessage],
|
||||
temperature, maxTokens, assistantMessageId, activeAgentId,
|
||||
onToken: (token: string) => { dispatch(updateLastMessage(token)); },
|
||||
}));
|
||||
};
|
||||
|
||||
// Run roundtable: sequentially generate assistant responses for selected agents.
|
||||
const runRoundtable = async (initialUserText: string) => {
|
||||
if (!roundtableSelectedIds.length || !selectedModel) { return; }
|
||||
// Capture ids + agents at start so closures remain stable
|
||||
const selectedIds = [...roundtableSelectedIds];
|
||||
const participantsNames = selectedIds
|
||||
.map(id => agents.find(a => a.id === id)?.name ?? id)
|
||||
.join(', ');
|
||||
|
||||
// Context message injected into every generation but never stored in UI.
|
||||
// Keeping participants OUT of the system prompt lets system stay focused on persona.
|
||||
const contextMsg = {
|
||||
id: 'rt-context',
|
||||
role: 'user' as const,
|
||||
content: `[Roundtable] Participants: ${participantsNames}. Respond in character.`,
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
setRoundtableRunning(true);
|
||||
roundtableStopRef.current = false;
|
||||
|
||||
// Build the initial message list; optionally add user seed message
|
||||
let currentMessages = [...(activeConv?.messages ?? [])];
|
||||
if (initialUserText) {
|
||||
const userMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'user' as const,
|
||||
content: initialUserText,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch(addMessage(userMessage));
|
||||
currentMessages = [...currentMessages, userMessage];
|
||||
}
|
||||
|
||||
// Rotate infinitely through selected agents until user stops
|
||||
let agentIndex = 0;
|
||||
let previousAssistantContent: string | null = null;
|
||||
let previousAgentName: string | null = null;
|
||||
|
||||
while (!roundtableStopRef.current) {
|
||||
const agentId = selectedIds[agentIndex];
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
if (!agent) {
|
||||
agentIndex = (agentIndex + 1) % selectedIds.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMessageId = `msg-rt-${Date.now()}-${agentIndex}`;
|
||||
dispatch(startAssistantMessage(assistantMessageId));
|
||||
dispatch(setMessageMeta({
|
||||
id: assistantMessageId,
|
||||
meta: { agentName: agent.name, modelName },
|
||||
}));
|
||||
|
||||
// System = agent identity header + persona; previous turn appended with correct name.
|
||||
const { systemPrompt: agentPrompt, name: agentName } = agent;
|
||||
let systemPrompt = `You are ${agentName}.\n\n${agentPrompt}`;
|
||||
if (previousAssistantContent && previousAgentName) {
|
||||
systemPrompt += `\n\n${previousAgentName} just said:\n${previousAssistantContent}`;
|
||||
}
|
||||
|
||||
// Prepend context message so the model knows the roundtable setup.
|
||||
// It is not stored in the Redux state so it never appears in the UI.
|
||||
const result = await dispatch(generateResponse({
|
||||
modelPath: selectedModel,
|
||||
messages: [contextMsg, ...currentMessages],
|
||||
temperature,
|
||||
maxTokens,
|
||||
assistantMessageId,
|
||||
activeAgentId: null,
|
||||
overrideConfig: { systemPrompt },
|
||||
onToken: (token: string) => { dispatch(updateLastMessage(token)); },
|
||||
}));
|
||||
|
||||
if (generateResponse.rejected.match(result)) { break; }
|
||||
|
||||
const payload = result.payload as unknown as { content?: string } | undefined;
|
||||
previousAssistantContent = payload?.content ?? null;
|
||||
previousAgentName = agent.name;
|
||||
|
||||
// Append assistant reply to local message list so next agent sees it
|
||||
if (previousAssistantContent) {
|
||||
currentMessages = [...currentMessages, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: previousAssistantContent,
|
||||
timestamp: Date.now(),
|
||||
}];
|
||||
}
|
||||
|
||||
agentIndex = (agentIndex + 1) % selectedIds.length;
|
||||
|
||||
// 2-second pause before the next agent
|
||||
if (!roundtableStopRef.current) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
setRoundtableRunning(false);
|
||||
};
|
||||
|
||||
const handleAgentSelect = (id: string | null) => {
|
||||
dispatch(setActiveAgent(id));
|
||||
setAgentPickerVisible(false);
|
||||
};
|
||||
|
||||
const renderMessage = ({ item }: { item: typeof messages[0] }) => (
|
||||
<MessageBubble message={item} />
|
||||
);
|
||||
|
||||
const renderEmpty = () => (
|
||||
<View style={themeStyles.emptyContainer}>
|
||||
<Text style={themeStyles.emptyIcon}>💬</Text>
|
||||
<Text style={themeStyles.emptyText}>Aucun message</Text>
|
||||
<Text style={themeStyles.emptySubtext}>
|
||||
{selectedModel ? 'Commencez une conversation'
|
||||
: 'Sélectionnez un modèle pour commencer'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const modelName = localModels.find(m => m.path === selectedModel)?.name
|
||||
?? 'Aucun modèle';
|
||||
|
||||
return (
|
||||
<View style={themeStyles.container}>
|
||||
<KeyboardAvoidingView
|
||||
style={themeStyles.flex}
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 60}
|
||||
>
|
||||
<View style={themeStyles.headerBar}>
|
||||
<TouchableOpacity style={themeStyles.iconBtn} onPress={() => setDrawerOpen(true)}>
|
||||
<Text style={themeStyles.iconBtnText}>☰</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={themeStyles.headerTitle} numberOfLines={1}>{activeTitle}</Text>
|
||||
<TouchableOpacity
|
||||
style={themeStyles.iconBtn}
|
||||
onPress={() => dispatch(createConversation())}
|
||||
>
|
||||
<Text style={themeStyles.newChatBtnText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={themeStyles.modelBand}>
|
||||
<View style={themeStyles.modelBandLeft}>
|
||||
<Text style={themeStyles.modelBandLabel}>Modèle</Text>
|
||||
<Text style={themeStyles.modelBandValue} numberOfLines={1}>{modelName}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.agentPill, activeAgent ? themeStyles.agentPillActive : null]}
|
||||
onPress={() => setAgentPickerVisible(true)}
|
||||
>
|
||||
{activeAgent?.avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: activeAgent.avatarUri }}
|
||||
style={themeStyles.agentPillAvatar}
|
||||
/>
|
||||
) : activeAgent
|
||||
? (
|
||||
<View style={themeStyles.agentPillAvatarPlaceholder}>
|
||||
<Text style={themeStyles.agentPillAvatarText}>
|
||||
{activeAgent.name[0]?.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
: <Text style={themeStyles.agentPillIcon}>🤖</Text>
|
||||
}
|
||||
<Text
|
||||
style={activeAgent ? themeStyles.agentPillName : themeStyles.agentPillNameInactive}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{activeAgent ? activeAgent.name : 'Agent'}
|
||||
</Text>
|
||||
<Text style={themeStyles.agentPillChevron}>▾</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.roundtableBtn, roundtableMode && themeStyles.roundtableBtnActive]}
|
||||
onPress={() => {
|
||||
// open multi-picker when enabling roundtable
|
||||
if (!roundtableMode) { setMultiPickerVisible(true); return; }
|
||||
// disabling roundtable
|
||||
setRoundtableMode(false);
|
||||
setRoundtableSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
<Text style={themeStyles.roundtableBtnText}>{roundtableMode ? `RT (${roundtableSelectedIds.length})` : 'RT'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={themeStyles.messagesList}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
<ChatInput
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onSend={handleSend}
|
||||
onStop={() => { roundtableStopRef.current = true; dispatch(stopGeneration()); }}
|
||||
disabled={isInferring || !selectedModel}
|
||||
isGenerating={isInferring}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
<ChatDrawer
|
||||
visible={drawerOpen}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNewChat={() => dispatch(createConversation())}
|
||||
onSelectConversation={(id) => dispatch(switchConversation(id))}
|
||||
onDeleteConversation={(id) => dispatch(deleteConversation(id))}
|
||||
/>
|
||||
<AgentPickerModal
|
||||
visible={agentPickerVisible}
|
||||
agents={agents}
|
||||
activeAgentId={activeAgentId}
|
||||
onSelect={handleAgentSelect}
|
||||
onClose={() => setAgentPickerVisible(false)}
|
||||
/>
|
||||
<MultiAgentPickerModal
|
||||
visible={multiPickerVisible}
|
||||
agents={agents}
|
||||
selectedIds={roundtableSelectedIds}
|
||||
onToggle={(id: string) => {
|
||||
setRoundtableSelectedIds(prev => (
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
));
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setRoundtableMode(true);
|
||||
setMultiPickerVisible(false);
|
||||
}}
|
||||
onClose={() => setMultiPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, string>) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
headerBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingHorizontal: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
iconBtn: { padding: spacing.md },
|
||||
iconBtnText: { fontSize: 20, color: colors.textSecondary },
|
||||
newChatBtnText: {
|
||||
fontSize: 22,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
modelBand: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
modelBandLeft: { flex: 1 },
|
||||
modelBandLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginBottom: 1,
|
||||
},
|
||||
modelBandValue: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
agentPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
agentPillActive: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
agentPillIcon: { fontSize: 14 },
|
||||
agentPillAvatar: { width: 20, height: 20, borderRadius: 10 },
|
||||
agentPillAvatarPlaceholder: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
agentPillAvatarText: {
|
||||
fontSize: 10,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
agentPillName: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
agentPillNameInactive: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
agentPillChevron: { fontSize: 10, color: colors.textTertiary },
|
||||
roundtableBtn: {
|
||||
marginLeft: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
roundtableBtnActive: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
roundtableBtnText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
messagesList: { flexGrow: 1, paddingVertical: spacing.md },
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: spacing.md },
|
||||
emptyText: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
199
app/src/screens/ChatScreen/AgentPickerModal.tsx
Normal file
199
app/src/screens/ChatScreen/AgentPickerModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
avatarUri?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agents: Agent[];
|
||||
activeAgentId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
agent: Agent;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.agentRow, isSelected && styles.agentRowSelected]}
|
||||
onPress={onPress}
|
||||
>
|
||||
{agent.avatarUri ? (
|
||||
<Image source={{ uri: agent.avatarUri }} style={styles.agentRowAvatar} />
|
||||
) : (
|
||||
<View style={styles.agentRowAvatarPlaceholder}>
|
||||
<Text style={styles.agentRowAvatarText}>
|
||||
{agent.name[0]?.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.agentRowInfo}>
|
||||
<Text style={[styles.agentRowName, isSelected && styles.agentRowNameSelected]}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
<Text style={styles.agentRowPrompt} numberOfLines={1}>
|
||||
{agent.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && <Text style={styles.agentRowCheck}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentPickerModal({
|
||||
visible,
|
||||
agents,
|
||||
activeAgentId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>Choisir un agent</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.agentRow, !activeAgentId && styles.agentRowSelected]}
|
||||
onPress={() => onSelect(null)}
|
||||
>
|
||||
<Text style={styles.noneIcon}>🚫</Text>
|
||||
<View style={styles.agentRowInfo}>
|
||||
<Text style={[styles.agentRowName, !activeAgentId && styles.agentRowNameSelected]}>
|
||||
Aucun agent
|
||||
</Text>
|
||||
<Text style={styles.agentRowPrompt} numberOfLines={1}>
|
||||
Utiliser uniquement le prompt système du modèle
|
||||
</Text>
|
||||
</View>
|
||||
{!activeAgentId && <Text style={styles.agentRowCheck}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
{agents.map(agent => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.id}
|
||||
onPress={() => onSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<Text style={styles.empty}>
|
||||
Aucun agent créé. Allez dans l'onglet Agents pour en créer un.
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
empty: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
agentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.sm,
|
||||
borderRadius: 10,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.transparent,
|
||||
},
|
||||
agentRowSelected: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
agentRowAvatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
},
|
||||
agentRowAvatarPlaceholder: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
agentRowAvatarText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
noneIcon: {
|
||||
width: 36,
|
||||
textAlign: 'center',
|
||||
fontSize: 22,
|
||||
},
|
||||
agentRowInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
agentRowName: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
agentRowNameSelected: {
|
||||
color: colors.agentAccent,
|
||||
},
|
||||
agentRowPrompt: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
agentRowCheck: {
|
||||
fontSize: 16,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
});
|
||||
278
app/src/screens/ChatScreen/ChatDrawer.tsx
Normal file
278
app/src/screens/ChatScreen/ChatDrawer.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import type { Conversation } from '../../store/chatTypes';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
const DRAWER_WIDTH = Math.min(Dimensions.get('window').width * 0.80, 310);
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onClose: () => void;
|
||||
onNewChat: () => void;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const diffDays = Math.floor((Date.now() - ts) / 86400000);
|
||||
if (diffDays === 0) return "Aujourd'hui";
|
||||
if (diffDays === 1) return 'Hier';
|
||||
if (diffDays < 7) return `Il y a ${diffDays} j`;
|
||||
return new Date(ts).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export default function ChatDrawer({
|
||||
visible,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onClose,
|
||||
onNewChat,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
}: Props) {
|
||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
// Keep mounted during the close animation
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMounted(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 0.5,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: -DRAWER_WIDTH,
|
||||
duration: 210,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 0,
|
||||
duration: 210,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => setMounted(false));
|
||||
}
|
||||
}, [visible, translateX, backdropOpacity]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const renderItem = ({ item }: { item: Conversation }) => {
|
||||
const isActive = item.id === activeConversationId;
|
||||
const lastMsg = item.messages[item.messages.length - 1];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.convItem, isActive && styles.convItemActive]}
|
||||
onPress={() => { onSelectConversation(item.id); onClose(); }}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.convItemContent}>
|
||||
<Text
|
||||
style={[styles.convTitle, isActive && styles.convTitleActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={styles.convMeta}>
|
||||
{item.messages.length} msg · {formatDate(item.updatedAt)}
|
||||
</Text>
|
||||
{lastMsg ? (
|
||||
<Text style={styles.convPreview} numberOfLines={1}>
|
||||
{lastMsg.role === 'user' ? '👤 ' : '🤖 '}
|
||||
{lastMsg.content}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.convPreviewEmpty}>Vide</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => onDeleteConversation(item.id)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.deleteBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents="box-none">
|
||||
{/* Backdrop */}
|
||||
<Animated.View
|
||||
style={[styles.backdrop, { opacity: backdropOpacity }]}
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<Animated.View style={[styles.drawer, { transform: [{ translateX }] }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.drawerHeader}>
|
||||
<Text style={styles.drawerTitle}>Conversations</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.closeBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* New chat button */}
|
||||
<TouchableOpacity
|
||||
style={styles.newChatBtn}
|
||||
onPress={() => { onNewChat(); onClose(); }}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.newChatBtnText}>+ Nouveau chat</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Conversations list */}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.emptyText}>Aucune conversation</Text>
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.textPrimary,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: DRAWER_WIDTH,
|
||||
backgroundColor: colors.surface,
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 4, height: 0 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
elevation: 16,
|
||||
},
|
||||
drawerHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
drawerTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
closeBtnText: {
|
||||
fontSize: 18,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
newChatBtn: {
|
||||
margin: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
newChatBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
convItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convItemActive: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary,
|
||||
},
|
||||
convItemContent: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
convTitle: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convTitleActive: {
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
convMeta: {
|
||||
fontSize: 11,
|
||||
color: colors.textTertiary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convPreview: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
convPreviewEmpty: {
|
||||
fontSize: 12,
|
||||
color: colors.textTertiary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
deleteBtnText: {
|
||||
fontSize: 14,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
color: colors.textTertiary,
|
||||
marginTop: spacing.xl,
|
||||
fontSize: typography.sizes.sm,
|
||||
},
|
||||
});
|
||||
113
app/src/screens/ChatScreen/ChatInput.tsx
Normal file
113
app/src/screens/ChatScreen/ChatInput.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
disabled: boolean;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
onStop,
|
||||
disabled,
|
||||
isGenerating,
|
||||
}: ChatInputProps) {
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={themeStyles.container}>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="Écrivez votre message..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<TouchableOpacity
|
||||
style={themeStyles.stopButton}
|
||||
onPress={onStop}
|
||||
>
|
||||
<View style={themeStyles.stopIcon} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.sendButton, disabled && themeStyles.sendButtonDisabled]}
|
||||
onPress={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
>
|
||||
<Text style={themeStyles.sendButtonText}>➤</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, unknown>) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: 20,
|
||||
color: colors.surface,
|
||||
},
|
||||
stopButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.error,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stopIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// module-level static styles intentionally omitted — runtime themeStyles used
|
||||
147
app/src/screens/ChatScreen/MessageBubble.tsx
Normal file
147
app/src/screens/ChatScreen/MessageBubble.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { Message } from '../../store/chatSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const navigation = useNavigation();
|
||||
const time = new Date(message.timestamp).toLocaleTimeString(
|
||||
'fr-FR',
|
||||
{ hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
const liveRate = !isUser
|
||||
? (message.metadata?.liveRate as number | undefined) : undefined;
|
||||
const agentName = !isUser
|
||||
? (message.metadata?.agentName as string | undefined) : undefined;
|
||||
const modelName = !isUser
|
||||
? (message.metadata?.modelName as string | undefined) : undefined;
|
||||
|
||||
const [showModel, setShowModel] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={[themeStyles.container, isUser && themeStyles.userContainer]}>
|
||||
{agentName && (
|
||||
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onLongPress={() =>
|
||||
navigation.navigate('MessageDetails', { messageId: message.id })
|
||||
}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [
|
||||
themeStyles.bubble,
|
||||
isUser ? themeStyles.userBubble : themeStyles.assistantBubble,
|
||||
pressed && themeStyles.pressedBubble,
|
||||
]}
|
||||
>
|
||||
<Text style={[themeStyles.text, isUser && themeStyles.userText]}>
|
||||
{message.content || '...'}
|
||||
</Text>
|
||||
<Text style={[themeStyles.time, isUser && themeStyles.userTime]}>
|
||||
{time}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={themeStyles.metaRow}>
|
||||
{liveRate !== undefined && (
|
||||
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
||||
)}
|
||||
{modelName && (
|
||||
<TouchableOpacity onPress={() => setShowModel(v => !v)}>
|
||||
<Text style={themeStyles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{showModel && modelName && (
|
||||
<Text style={themeStyles.modelTooltip}>{modelName}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, unknown>) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
userContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
agentLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.semibold,
|
||||
marginBottom: 3,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '80%',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
assistantBubble: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
pressedBubble: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
text: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
userText: {
|
||||
color: colors.surface,
|
||||
},
|
||||
time: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 4,
|
||||
},
|
||||
userTime: {
|
||||
color: colors.userTimeText,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: 2,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
rate: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
modelTooltip: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 2,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
});
|
||||
675
app/src/screens/ChatScreen/MessageDetails.tsx
Normal file
675
app/src/screens/ChatScreen/MessageDetails.tsx
Normal file
@@ -0,0 +1,675 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import type { RootStackParamList } from '../../navigation';
|
||||
import {
|
||||
startAssistantMessage,
|
||||
updateLastMessage,
|
||||
} from '../../store/chatSlice';
|
||||
import { generateResponse } from '../../store/chatThunks';
|
||||
import type { ModelConfig } from '../../store/types';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'MessageDetails'>;
|
||||
|
||||
// ─── InfoRow ──────────────────────────────────────────────────────────────────
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | boolean | undefined | null;
|
||||
}) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const display =
|
||||
typeof value === 'boolean' ? (value ? 'oui' : 'non') : String(value);
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.label}>{label}</Text>
|
||||
<Text style={rowStyles.value}>{display}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 5,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
value: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Collapsible Section ──────────────────────────────────────────────────────
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<View style={secStyles.wrap}>
|
||||
<TouchableOpacity
|
||||
style={secStyles.header}
|
||||
onPress={() => setOpen(p => !p)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={secStyles.title}>{title}</Text>
|
||||
<Text style={secStyles.chevron}>{open ? '▲' : '▼'}</Text>
|
||||
</TouchableOpacity>
|
||||
{open && <View style={secStyles.body}>{children}</View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const secStyles = StyleSheet.create({
|
||||
wrap: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
chevron: { fontSize: 11, color: colors.textTertiary },
|
||||
body: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Editable fork param row ──────────────────────────────────────────────────
|
||||
function ForkNum({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={forkStyles.row}>
|
||||
<Text style={forkStyles.label}>{label}</Text>
|
||||
<TextInput
|
||||
style={forkStyles.input}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numeric"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const forkStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 5,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
width: 90,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
export default function MessageDetails({ route, navigation }: Props) {
|
||||
const { messageId } = route.params;
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const message = useSelector((s: RootState) => {
|
||||
const conv = s.chat.conversations.find(c => c.id === s.chat.activeConversationId);
|
||||
return conv?.messages.find(m => m.id === messageId);
|
||||
});
|
||||
const messages = useSelector((s: RootState) => {
|
||||
const conv = s.chat.conversations.find(c => c.id === s.chat.activeConversationId);
|
||||
return conv?.messages ?? [];
|
||||
});
|
||||
const currentLoadedModel = useSelector(
|
||||
(s: RootState) => s.models.currentLoadedModel,
|
||||
);
|
||||
|
||||
// ── Derive config early (safe even if message is undefined) ───────────────
|
||||
const meta = (message?.metadata || {}) as Record<string, unknown>;
|
||||
const rawMetaContent = meta.rawContent as string | undefined;
|
||||
const reasoningContent = meta.reasoningContent as string | null | undefined;
|
||||
const config = (meta.config as ModelConfig) || {};
|
||||
const timings = meta.timings as
|
||||
| {
|
||||
predicted_n?: number;
|
||||
predicted_ms?: number;
|
||||
predicted_per_second?: number;
|
||||
prompt_n?: number;
|
||||
prompt_ms?: number;
|
||||
prompt_per_second?: number;
|
||||
}
|
||||
| undefined;
|
||||
const modelPath =
|
||||
(meta.modelPath as string) || currentLoadedModel || '';
|
||||
const prompt = meta.prompt as string | undefined;
|
||||
|
||||
// ── Stop-reason metadata ──────────────────────────────────────────────────
|
||||
const stoppedWord = meta.stoppedWord as string | null | undefined;
|
||||
const stoppingWord = meta.stoppingWord as string | null | undefined;
|
||||
const stoppedEos = meta.stoppedEos as boolean | undefined;
|
||||
const stoppedLimit = meta.stoppedLimit as number | undefined;
|
||||
const interrupted = meta.interrupted as boolean | undefined;
|
||||
const truncated = meta.truncated as boolean | undefined;
|
||||
|
||||
// ── Fork state (always called, safe defaults) ─────────────────────────────
|
||||
const [isForkMode, setIsForkMode] = useState(false);
|
||||
const [forkSystemPrompt, setForkSystemPrompt] = useState(
|
||||
config.systemPrompt ?? '',
|
||||
);
|
||||
const [forkTemp, setForkTemp] = useState(
|
||||
String(config.temperature ?? 0.8),
|
||||
);
|
||||
const [forkTopK, setForkTopK] = useState(String(config.top_k ?? 40));
|
||||
const [forkTopP, setForkTopP] = useState(String(config.top_p ?? 0.95));
|
||||
const [forkMinP, setForkMinP] = useState(String(config.min_p ?? 0.05));
|
||||
const [forkNPredict, setForkNPredict] = useState(
|
||||
String(config.n_predict ?? -1),
|
||||
);
|
||||
const [forkSeed, setForkSeed] = useState(String(config.seed ?? -1));
|
||||
const [forkPenaltyRepeat, setForkPenaltyRepeat] = useState(
|
||||
String(config.penalty_repeat ?? 1.0),
|
||||
);
|
||||
const [forkPenaltyLastN, setForkPenaltyLastN] = useState(
|
||||
String(config.penalty_last_n ?? 64),
|
||||
);
|
||||
const [forkStop, setForkStop] = useState(
|
||||
(config.stop ?? []).join(', '),
|
||||
);
|
||||
|
||||
const handleFork = useCallback(() => {
|
||||
if (!modelPath) return;
|
||||
const msgIndex = messages.findIndex(m => m.id === messageId);
|
||||
if (msgIndex < 0) return;
|
||||
// Messages up to (but not including) this assistant reply
|
||||
const history = messages.slice(0, msgIndex);
|
||||
|
||||
const overrideConfig: Partial<ModelConfig> = {
|
||||
systemPrompt: forkSystemPrompt || undefined,
|
||||
temperature: Number(forkTemp),
|
||||
top_k: Number(forkTopK),
|
||||
top_p: Number(forkTopP),
|
||||
min_p: Number(forkMinP),
|
||||
n_predict: Number(forkNPredict),
|
||||
seed: Number(forkSeed),
|
||||
penalty_repeat: Number(forkPenaltyRepeat),
|
||||
penalty_last_n: Number(forkPenaltyLastN),
|
||||
stop: forkStop
|
||||
? forkStop
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const newId = `fork-${Date.now()}`;
|
||||
dispatch(startAssistantMessage(newId));
|
||||
dispatch(
|
||||
generateResponse({
|
||||
modelPath,
|
||||
messages: history,
|
||||
temperature: Number(forkTemp),
|
||||
maxTokens:
|
||||
Number(forkNPredict) > 0 ? Number(forkNPredict) : 512,
|
||||
assistantMessageId: newId,
|
||||
overrideConfig,
|
||||
onToken: (token: string) => dispatch(updateLastMessage(token)),
|
||||
}),
|
||||
);
|
||||
navigation.goBack();
|
||||
}, [
|
||||
modelPath,
|
||||
messages,
|
||||
messageId,
|
||||
forkSystemPrompt,
|
||||
forkTemp,
|
||||
forkTopK,
|
||||
forkTopP,
|
||||
forkMinP,
|
||||
forkNPredict,
|
||||
forkSeed,
|
||||
forkPenaltyRepeat,
|
||||
forkPenaltyLastN,
|
||||
forkStop,
|
||||
dispatch,
|
||||
navigation,
|
||||
]);
|
||||
|
||||
// ── Early return AFTER all hooks ──────────────────────────────────────────
|
||||
if (!message) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.empty}>Message introuvable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header row */}
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.pageTitle}>Détails du message</Text>
|
||||
{isAssistant && (
|
||||
<TouchableOpacity
|
||||
style={[styles.forkBtn, isForkMode && styles.forkBtnActive]}
|
||||
onPress={() => setIsForkMode(p => !p)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.forkBtnText,
|
||||
isForkMode && styles.forkBtnTextActive,
|
||||
]}
|
||||
>
|
||||
{isForkMode ? '✕ Annuler' : '🔀 Fork'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Message content ─────────────────────────────────────────────── */}
|
||||
<Section title="💬 Contenu propre (affiché dans le chat)">
|
||||
<Text style={styles.contentText}>
|
||||
{message.content || '(vide)'}
|
||||
</Text>
|
||||
<Text style={styles.metaSmall}>
|
||||
{message.role} ·{' '}
|
||||
{new Date(message.timestamp).toLocaleString('fr-FR')}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* ── Thinking / Reasoning ──────────────────────────────────────── */}
|
||||
{reasoningContent ? (
|
||||
<Section title="🧠 Thinking (raisonnement)">
|
||||
<Text style={styles.rawText}>{reasoningContent}</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Raw streaming content (all tokens, no cleanup) ─────────────── */}
|
||||
{rawMetaContent !== undefined && (
|
||||
<Section title="🧹 Stream brut (avec marqueurs)" defaultOpen={true}>
|
||||
<Text style={styles.rawText}>
|
||||
{rawMetaContent || '(vide)'}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Performance timings ─────────────────────────────────────────── */}
|
||||
{timings && (
|
||||
<Section title="⏱ Performance">
|
||||
{timings.predicted_n !== null && (
|
||||
<InfoRow label="Tokens générés" value={timings.predicted_n} />
|
||||
)}
|
||||
{timings.predicted_per_second !== null && (
|
||||
<InfoRow
|
||||
label="Vitesse génération"
|
||||
value={`${timings.predicted_per_second.toFixed(2)} tok/s`}
|
||||
/>
|
||||
)}
|
||||
{timings.prompt_n !== null && (
|
||||
<InfoRow label="Tokens prompt" value={timings.prompt_n} />
|
||||
)}
|
||||
{timings.prompt_per_second !== null && (
|
||||
<InfoRow
|
||||
label="Vitesse prompt eval"
|
||||
value={`${timings.prompt_per_second.toFixed(2)} tok/s`}
|
||||
/>
|
||||
)}
|
||||
{timings.predicted_ms !== null && timings.prompt_ms !== null && (
|
||||
<InfoRow
|
||||
label="Temps total"
|
||||
value={`${(
|
||||
(timings.predicted_ms + timings.prompt_ms) /
|
||||
1000
|
||||
).toFixed(2)} s`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Model & loading params ──────────────────────────────────────── */}
|
||||
<Section title="🔧 Modèle & Chargement">
|
||||
<InfoRow
|
||||
label="Modèle"
|
||||
value={modelPath ? modelPath.split('/').pop() : '—'}
|
||||
/>
|
||||
<InfoRow label="n_ctx" value={config.n_ctx} />
|
||||
<InfoRow label="n_threads" value={config.n_threads} />
|
||||
<InfoRow label="n_gpu_layers" value={config.n_gpu_layers} />
|
||||
<InfoRow label="flash_attn" value={config.flash_attn} />
|
||||
<InfoRow
|
||||
label="cache K / V"
|
||||
value={
|
||||
config.cache_type_k
|
||||
? `${config.cache_type_k} / ${config.cache_type_v ?? 'f16'}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow label="use_mlock" value={config.use_mlock} />
|
||||
<InfoRow label="use_mmap" value={config.use_mmap} />
|
||||
<InfoRow label="rope_freq_base" value={config.rope_freq_base} />
|
||||
<InfoRow label="rope_freq_scale" value={config.rope_freq_scale} />
|
||||
</Section>
|
||||
|
||||
{/* ── Sampling params ─────────────────────────────────────────────── */}
|
||||
<Section title="🎲 Sampling">
|
||||
{isForkMode ? (
|
||||
<>
|
||||
<ForkNum
|
||||
label="Temperature"
|
||||
value={forkTemp}
|
||||
onChange={setForkTemp}
|
||||
/>
|
||||
<ForkNum label="Top-K" value={forkTopK} onChange={setForkTopK} />
|
||||
<ForkNum label="Top-P" value={forkTopP} onChange={setForkTopP} />
|
||||
<ForkNum label="Min-P" value={forkMinP} onChange={setForkMinP} />
|
||||
<ForkNum
|
||||
label="n_predict (max tokens)"
|
||||
value={forkNPredict}
|
||||
onChange={setForkNPredict}
|
||||
/>
|
||||
<ForkNum label="Seed" value={forkSeed} onChange={setForkSeed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow label="temperature" value={config.temperature} />
|
||||
<InfoRow label="top_k" value={config.top_k} />
|
||||
<InfoRow label="top_p" value={config.top_p} />
|
||||
<InfoRow label="min_p" value={config.min_p} />
|
||||
<InfoRow label="n_predict" value={config.n_predict} />
|
||||
<InfoRow label="seed" value={config.seed} />
|
||||
<InfoRow label="typical_p" value={config.typical_p} />
|
||||
<InfoRow label="top_n_sigma" value={config.top_n_sigma} />
|
||||
<InfoRow label="mirostat" value={config.mirostat} />
|
||||
<InfoRow label="mirostat_tau" value={config.mirostat_tau} />
|
||||
<InfoRow label="mirostat_eta" value={config.mirostat_eta} />
|
||||
<InfoRow
|
||||
label="xtc_probability"
|
||||
value={config.xtc_probability}
|
||||
/>
|
||||
<InfoRow label="xtc_threshold" value={config.xtc_threshold} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Penalties ───────────────────────────────────────────────────── */}
|
||||
<Section title="🚫 Pénalités" defaultOpen={false}>
|
||||
{isForkMode ? (
|
||||
<>
|
||||
<ForkNum
|
||||
label="penalty_repeat"
|
||||
value={forkPenaltyRepeat}
|
||||
onChange={setForkPenaltyRepeat}
|
||||
/>
|
||||
<ForkNum
|
||||
label="penalty_last_n"
|
||||
value={forkPenaltyLastN}
|
||||
onChange={setForkPenaltyLastN}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow label="penalty_repeat" value={config.penalty_repeat} />
|
||||
<InfoRow label="penalty_last_n" value={config.penalty_last_n} />
|
||||
<InfoRow label="penalty_freq" value={config.penalty_freq} />
|
||||
<InfoRow
|
||||
label="penalty_present"
|
||||
value={config.penalty_present}
|
||||
/>
|
||||
<InfoRow
|
||||
label="dry_multiplier"
|
||||
value={config.dry_multiplier}
|
||||
/>
|
||||
<InfoRow label="dry_base" value={config.dry_base} />
|
||||
<InfoRow
|
||||
label="dry_allowed_length"
|
||||
value={config.dry_allowed_length}
|
||||
/>
|
||||
<InfoRow
|
||||
label="dry_penalty_last_n"
|
||||
value={config.dry_penalty_last_n}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Output / Stop ───────────────────────────────────────────────── */}
|
||||
<Section title="📤 Sortie" defaultOpen={false}>
|
||||
{isForkMode ? (
|
||||
<View>
|
||||
<Text style={styles.inputLabel}>
|
||||
Stop strings (séparés par virgule)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={forkStop}
|
||||
onChangeText={setForkStop}
|
||||
placeholder="</s>, User:, ..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
label="stop"
|
||||
value={(config.stop ?? []).join(', ') || '(défaut)'}
|
||||
/>
|
||||
<InfoRow label="n_probs" value={config.n_probs} />
|
||||
<InfoRow label="ignore_eos" value={config.ignore_eos} />
|
||||
</>
|
||||
)}
|
||||
{/* Stop-reason info — filled after generation, always visible */}
|
||||
<View style={styles.stopReasonBox}>
|
||||
<Text style={styles.stopReasonTitle}>Raison d'arrêt</Text>
|
||||
<InfoRow label="stopped_eos" value={stoppedEos} />
|
||||
<InfoRow label="stopped_word" value={stoppedWord || undefined} />
|
||||
<InfoRow label="stopping_word" value={stoppingWord || undefined} />
|
||||
<InfoRow label="stopped_limit" value={stoppedLimit} />
|
||||
<InfoRow label="interrupted" value={interrupted} />
|
||||
<InfoRow label="truncated" value={truncated} />
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
{/* ── System prompt ───────────────────────────────────────────────── */}
|
||||
<Section title="🗒 Prompt système">
|
||||
{isForkMode ? (
|
||||
<TextInput
|
||||
style={[styles.textInput, styles.multiline]}
|
||||
value={forkSystemPrompt}
|
||||
onChangeText={setForkSystemPrompt}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
placeholder="(aucun)"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.promptText}>
|
||||
{config.systemPrompt || '(aucun)'}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Raw prompt sent ─────────────────────────────────────────────── */}
|
||||
{prompt ? (
|
||||
<Section title="📋 Prompt brut envoyé" defaultOpen={false}>
|
||||
<Text style={styles.promptText}>{prompt}</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Fork / Regenerate button ─────────────────────────────────────── */}
|
||||
{isForkMode && isAssistant && (
|
||||
<TouchableOpacity
|
||||
style={styles.regenBtn}
|
||||
onPress={handleFork}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.regenBtnText}>
|
||||
🔀 Regénérer avec ces paramètres
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={{ height: spacing.xl * 2 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
content: { padding: spacing.md },
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
forkBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
forkBtnActive: { backgroundColor: colors.primary },
|
||||
forkBtnText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
forkBtnTextActive: { color: colors.surface },
|
||||
contentText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
metaSmall: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
promptText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
rawText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
stopReasonBox: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.surfaceSecondary,
|
||||
},
|
||||
stopReasonTitle: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
multiline: { minHeight: 80, textAlignVertical: 'top' },
|
||||
regenBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
regenBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
59
app/src/screens/ChatScreen/ModelSelector.tsx
Normal file
59
app/src/screens/ChatScreen/ModelSelector.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string | null;
|
||||
availableModels: Array<{ name: string; path: string }>;
|
||||
}
|
||||
|
||||
export default function ModelSelector({
|
||||
selectedModel,
|
||||
availableModels,
|
||||
}: ModelSelectorProps) {
|
||||
const selectedModelName = availableModels.find(
|
||||
m => m.path === selectedModel
|
||||
)?.name || 'Aucun modèle';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Modèle actif</Text>
|
||||
<View style={styles.display}>
|
||||
<Text style={styles.selectedText} numberOfLines={1}>
|
||||
{selectedModelName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
Changez le modèle depuis l'onglet Models
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
display: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
hint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
165
app/src/screens/ChatScreen/MultiAgentPickerModal.tsx
Normal file
165
app/src/screens/ChatScreen/MultiAgentPickerModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
export interface AgentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
avatarUri?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agents: AgentItem[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function MultiAgentPickerModal({
|
||||
visible, agents, selectedIds, onToggle, onConfirm, onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>Roundtable — sélectionner des agents</Text>
|
||||
<ScrollView style={styles.list}>
|
||||
{agents.map(a => {
|
||||
const selected = selectedIds.includes(a.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={a.id}
|
||||
style={[styles.row, selected && styles.rowSelected]}
|
||||
onPress={() => onToggle(a.id)}
|
||||
>
|
||||
{a.avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: a.avatarUri }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarText}>{a.name[0]?.toUpperCase()}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.info}>
|
||||
<Text
|
||||
style={[styles.name, selected && styles.nameSelected]}
|
||||
>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.prompt}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{a.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.check}>{selected ? '✓' : ''}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{agents.length === 0 && (
|
||||
<Text style={styles.empty}>
|
||||
Aucun agent disponible
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Text style={styles.btnText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.btnPrimary}
|
||||
onPress={onConfirm}
|
||||
>
|
||||
<Text style={styles.btnPrimaryText}>Valider</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
list: { marginBottom: spacing.sm },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
borderRadius: 8,
|
||||
},
|
||||
rowSelected: { backgroundColor: colors.agentBg },
|
||||
avatar: { width: 36, height: 36, borderRadius: 18 },
|
||||
avatarPlaceholder: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: { color: colors.surface, fontWeight: typography.weights.bold },
|
||||
info: { flex: 1, marginLeft: spacing.sm },
|
||||
name: { fontSize: typography.sizes.sm, color: colors.textPrimary },
|
||||
nameSelected: { color: colors.agentAccent },
|
||||
prompt: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
check: {
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
empty: { textAlign: 'center', color: colors.textSecondary, padding: spacing.md },
|
||||
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
|
||||
btn: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md },
|
||||
btnText: { color: colors.textSecondary },
|
||||
btnPrimary: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
btnPrimaryText: { color: colors.surface, fontWeight: typography.weights.semibold },
|
||||
});
|
||||
885
app/src/screens/HardwareInfoScreen.tsx
Normal file
885
app/src/screens/HardwareInfoScreen.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { BuildInfo } from 'llama.rn';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../store';
|
||||
import { refreshHardwareInfo, refreshRamInfo } from '../store/hardwareSlice';
|
||||
import type { BackendDeviceInfo } from '../store/types';
|
||||
import { colors, spacing, typography, borderRadius } from '../theme/lightTheme';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
usage: string;
|
||||
}
|
||||
|
||||
// ─── Tooltip dictionary ───────────────────────────────────────────────────────
|
||||
|
||||
const TIPS: Record<string, TooltipInfo> = {
|
||||
os: {
|
||||
title: "Système d'exploitation",
|
||||
description:
|
||||
"Nom et version du système d'exploitation tournant sur l'appareil.",
|
||||
impact:
|
||||
"Certaines fonctionnalités de llama.rn (Metal, OpenCL) ne sont disponibles " +
|
||||
"que sur des OS spécifiques.",
|
||||
usage:
|
||||
"iOS → Metal disponible. Android → OpenCL/Hexagon NPU possibles selon SoC.",
|
||||
},
|
||||
osVersion: {
|
||||
title: "Version de l'OS",
|
||||
description:
|
||||
"Numéro de version (Android API level ou numéro iOS). " +
|
||||
"Pour Android, l'API level détermine les API natives disponibles.",
|
||||
impact:
|
||||
"Un API level élevé (≥ 28) améliore la compatibilité avec les backends GPU.",
|
||||
usage:
|
||||
"Si des crashs surviennent sur un ancien appareil, vérifiez l'API level. " +
|
||||
"llama.rn supporte arm64-v8a et x86_64.",
|
||||
},
|
||||
deviceModel: {
|
||||
title: "Modèle de l'appareil",
|
||||
description:
|
||||
"Nom commercial du téléphone ou de la tablette (fabricant + modèle).",
|
||||
impact:
|
||||
"Permet d'identifier le SoC (ex : Snapdragon 8 Gen 1 → Adreno 730 GPU, " +
|
||||
"Hexagon HTP disponible).",
|
||||
usage:
|
||||
"Recherchez le SoC de votre appareil pour savoir quels backends GPU " +
|
||||
"sont exploitables.",
|
||||
},
|
||||
uiMode: {
|
||||
title: "Mode UI",
|
||||
description:
|
||||
"Mode d'interface Android : normal, watch, tv, desk, car, vrheadset.",
|
||||
impact:
|
||||
"Peu d'impact sur l'inférence. Utile pour déboguer sur des appareils atypiques.",
|
||||
usage: "Attendez \"normal\" pour un téléphone classique.",
|
||||
},
|
||||
ramTotal: {
|
||||
title: "RAM totale",
|
||||
description:
|
||||
"Quantité totale de mémoire vive physique installée dans l'appareil.",
|
||||
impact:
|
||||
"Détermine la taille maximale du modèle chargeable. " +
|
||||
"Un modèle 7B Q4_K_M pèse ≈ 4 Go, un 13B ≈ 8 Go.",
|
||||
usage:
|
||||
"Règle générale : RAM totale − 2 Go (OS) = RAM utilisable pour le modèle. " +
|
||||
"Préférez des quantifications plus aggressives (Q4, Q3) si la RAM est limitée.",
|
||||
},
|
||||
ramFree: {
|
||||
title: "RAM disponible",
|
||||
description:
|
||||
"Mémoire vive actuellement libre (non utilisée par l'OS et les autres apps).",
|
||||
impact:
|
||||
"C'est la limite réelle pour charger un modèle sans swapper. " +
|
||||
"En dessous de 500 Mo de RAM libre, le risque de crash OOM est élevé.",
|
||||
usage:
|
||||
"Fermez les autres applications avant de charger un modèle lourd. " +
|
||||
"Utilisez use_mmap=true pour éviter de tout charger en RAM d'un coup.",
|
||||
},
|
||||
diskTotal: {
|
||||
title: "Stockage total",
|
||||
description:
|
||||
"Capacité totale du stockage interne de l'appareil.",
|
||||
impact:
|
||||
"Les modèles GGUF sont volumineux (1 – 30 Go). " +
|
||||
"Vérifiez l'espace avant de télécharger.",
|
||||
usage:
|
||||
"Téléchargez les modèles sur une carte SD ou un stockage externe " +
|
||||
"si le stockage interne est insuffisant.",
|
||||
},
|
||||
diskFree: {
|
||||
title: "Espace libre",
|
||||
description:
|
||||
"Espace de stockage actuellement disponible pour de nouveaux fichiers.",
|
||||
impact:
|
||||
"Un modèle 7B pèse entre 4 et 8 Go selon la quantification. " +
|
||||
"Prévoyez de la marge pour les fichiers temporaires.",
|
||||
usage:
|
||||
"Activez l'option use_mmap=true pour que le modèle soit lu " +
|
||||
"directement depuis le disque sans tout copier en RAM.",
|
||||
},
|
||||
backendDevice: {
|
||||
title: "Backend de calcul",
|
||||
description:
|
||||
"Périphérique matériel utilisé par llama.rn pour l'inférence : " +
|
||||
"CPU (BLAS), GPU (Metal/OpenCL), NPU (Hexagon).",
|
||||
impact:
|
||||
"GPU : jusqu'à 10× plus rapide que CPU pour la génération de tokens. " +
|
||||
"NPU (Hexagon) : très efficace en énergie sur Snapdragon 8 Gen 1+.",
|
||||
usage:
|
||||
"Configurez n_gpu_layers > 0 pour déporter des couches sur le GPU. " +
|
||||
"Plus n_gpu_layers est élevé, plus la vitesse augmente (jusqu'à la limite VRAM).",
|
||||
},
|
||||
deviceMemory: {
|
||||
title: "Mémoire du device GPU/NPU",
|
||||
description:
|
||||
"Quantité maximale de mémoire dédiée ou partagée disponible " +
|
||||
"pour le backend de calcul (GPU/NPU).",
|
||||
impact:
|
||||
"Limite le nombre de couches pouvant tenir en VRAM. " +
|
||||
"Si trop peu de couches tiennent, le reste est traité sur CPU.",
|
||||
usage:
|
||||
"Divisez la taille du modèle par le nombre de couches pour estimer " +
|
||||
"combien de couches tiennent en VRAM (n_gpu_layers).",
|
||||
},
|
||||
gpuUsed: {
|
||||
title: "GPU utilisé par le contexte",
|
||||
description:
|
||||
"Indique si le modèle actuellement chargé exploite le GPU pour l'inférence.",
|
||||
impact:
|
||||
"GPU = inférence rapide (tok/s élevé). CPU seul = plus lent mais universellement compatible.",
|
||||
usage:
|
||||
"Si GPU = Non alors que vous avez un GPU, vérifiez n_gpu_layers > 0 " +
|
||||
"dans la configuration du modèle.",
|
||||
},
|
||||
reasonNoGPU: {
|
||||
title: "Raison : GPU non utilisé",
|
||||
description:
|
||||
"Message de llama.cpp expliquant pourquoi le GPU n'est pas actif.",
|
||||
impact:
|
||||
"Peut indiquer un backend manquant, un appareil non supporté, " +
|
||||
"ou que n_gpu_layers = 0.",
|
||||
usage:
|
||||
"Lisez ce message pour diagnostiquer : backend non compilé, " +
|
||||
"mémoire VRAM insuffisante, ou paramètre n_gpu_layers à 0.",
|
||||
},
|
||||
devicesUsed: {
|
||||
title: "Appareils utilisés par le contexte",
|
||||
description:
|
||||
"Liste des identifiants de périphériques utilisés par llama.rn " +
|
||||
"pour le modèle chargé.",
|
||||
impact:
|
||||
"Confirme que le bon backend (GPU/NPU/CPU) est effectivement utilisé à l'exécution.",
|
||||
usage:
|
||||
"Sur Snapdragon, \"HTP0\" indique le NPU Hexagon. " +
|
||||
"Sur iOS, \"gpu\" indique Metal.",
|
||||
},
|
||||
androidLib: {
|
||||
title: "Bibliothèque native Android",
|
||||
description:
|
||||
"Nom du fichier .so chargé par llama.rn sur Android " +
|
||||
"(ex: librnllama_opencl.so, librnllama.so).",
|
||||
impact:
|
||||
"La variante \"_opencl\" active l'accélération GPU OpenCL. " +
|
||||
"La variante de base utilise uniquement le CPU.",
|
||||
usage:
|
||||
"Si vous n'obtenez pas la variante OpenCL, vérifiez que " +
|
||||
"libOpenCL.so est déclaré dans votre AndroidManifest.xml.",
|
||||
},
|
||||
simd: {
|
||||
title: "Capacités CPU / SIMD",
|
||||
description:
|
||||
"Instructions vectorielles disponibles sur le CPU : NEON, AVX, AVX2, ARM_FMA, etc. " +
|
||||
"Rapportées directement par llama.cpp au moment du chargement du modèle.",
|
||||
impact:
|
||||
"NEON (ARM) et AVX2 (x86) multiplient les performances CPU par 4 à 8×. " +
|
||||
"llama.cpp choisit automatiquement le meilleur chemin de code.",
|
||||
usage:
|
||||
"Cette information est fournie automatiquement. Chargez un modèle " +
|
||||
"pour afficher les capacités de votre processeur.",
|
||||
},
|
||||
buildNumber: {
|
||||
title: "Numéro de build llama.rn",
|
||||
description:
|
||||
"Identifiant interne de la version compilée de llama.rn intégrée dans l'app.",
|
||||
impact:
|
||||
"Les builds récentes incluent des corrections de bugs et de nouveaux backends.",
|
||||
usage:
|
||||
"Comparez avec la dernière release sur npm (llama.rn) pour savoir " +
|
||||
"si une mise à jour est disponible.",
|
||||
},
|
||||
buildCommit: {
|
||||
title: "Commit Git llama.rn",
|
||||
description:
|
||||
"Hash du commit llama.cpp / llama.rn utilisé pour compiler la bibliothèque native.",
|
||||
impact:
|
||||
"Permet de retrouver exactement quelle version du moteur d'inférence est utilisée.",
|
||||
usage:
|
||||
"Utile pour déboguer un problème précis en consultant le changelog de llama.cpp.",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) { return '—'; }
|
||||
if (bytes < 1024) { return `${bytes} B`; }
|
||||
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function pct(part: number, total: number): string {
|
||||
if (!total) { return '—'; }
|
||||
return `${Math.round((part / total) * 100)} %`;
|
||||
}
|
||||
|
||||
// ─── Tooltip Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipState {
|
||||
visible: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function TooltipModal({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: TooltipState;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const info = TIPS[state.key];
|
||||
if (!info) { return null; }
|
||||
return (
|
||||
<Modal
|
||||
visible={state.visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={ttSt.overlay} onPress={onClose}>
|
||||
<Pressable style={ttSt.card} onPress={() => {}}>
|
||||
<Text style={ttSt.title}>{info.title}</Text>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>📖 Description</Text>
|
||||
<Text style={ttSt.sectionText}>{info.description}</Text>
|
||||
</View>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>⚙️ Impact</Text>
|
||||
<Text style={ttSt.sectionText}>{info.impact}</Text>
|
||||
</View>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>💡 Utilisation</Text>
|
||||
<Text style={ttSt.sectionText}>{info.usage}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={ttSt.closeBtn} onPress={onClose}>
|
||||
<Text style={ttSt.closeBtnText}>Fermer</Text>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const ttSt = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'flex-end',
|
||||
padding: spacing.md,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
section: { marginBottom: spacing.sm },
|
||||
sectionLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.primary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
closeBtn: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeBtnText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── InfoRow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
tipKey,
|
||||
onInfo,
|
||||
mono,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
tipKey?: string;
|
||||
onInfo?: (key: string) => void;
|
||||
mono?: boolean;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
if (value === undefined || value === null || value === '') { return null; }
|
||||
return (
|
||||
<View style={rowSt.wrap}>
|
||||
<View style={rowSt.labelWrap}>
|
||||
{tipKey && onInfo ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(tipKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={rowSt.infoBtn}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<Text style={rowSt.label}>{label}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[rowSt.value, mono && rowSt.mono, accent && rowSt.accent]}
|
||||
numberOfLines={4}
|
||||
>
|
||||
{String(value)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const rowSt = StyleSheet.create({
|
||||
wrap: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingVertical: 7,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
labelWrap: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
},
|
||||
infoBtn: {
|
||||
fontSize: 15,
|
||||
color: colors.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flexShrink: 1,
|
||||
},
|
||||
value: {
|
||||
flex: 1.4,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
textAlign: 'right',
|
||||
},
|
||||
mono: {
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
accent: { color: colors.primary },
|
||||
});
|
||||
|
||||
// ─── Badge ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Badge({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<View style={[badgeSt.wrap, active ? badgeSt.on : badgeSt.off]}>
|
||||
<Text style={[badgeSt.text, active ? badgeSt.textOn : badgeSt.textOff]}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeSt = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginRight: spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
on: { backgroundColor: colors.successLight },
|
||||
off: { backgroundColor: colors.surfaceSecondary, opacity: 0.55 },
|
||||
text: { fontSize: 11, fontWeight: typography.weights.semibold },
|
||||
textOn: { color: colors.successDark },
|
||||
textOff: { color: colors.textTertiary },
|
||||
});
|
||||
|
||||
// ─── Card ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={cardSt.wrap}>
|
||||
<Text style={cardSt.title}>{title}</Text>
|
||||
<View style={cardSt.body}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const cardSt = StyleSheet.create({
|
||||
wrap: { marginBottom: spacing.md },
|
||||
title: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.surfaceSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── BackendDeviceCard ────────────────────────────────────────────────────────
|
||||
|
||||
function BackendDeviceCard({
|
||||
device,
|
||||
onInfo,
|
||||
}: {
|
||||
device: BackendDeviceInfo;
|
||||
onInfo: (key: string) => void;
|
||||
}) {
|
||||
const isGpu = device.type.toUpperCase() === 'GPU';
|
||||
const icon = isGpu ? '🎮' : '🧮';
|
||||
return (
|
||||
<View style={devSt.wrap}>
|
||||
<View style={devSt.header}>
|
||||
<Text style={devSt.icon}>{icon}</Text>
|
||||
<View style={devSt.nameCol}>
|
||||
<Text style={devSt.name}>{device.deviceName}</Text>
|
||||
<Text style={devSt.sub}>{device.backend} · {device.type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo('backendDevice')}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={devSt.infoBtn}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{device.maxMemorySize > 0 && (
|
||||
<InfoRow
|
||||
label="Mémoire disponible"
|
||||
value={fmtBytes(device.maxMemorySize)}
|
||||
tipKey="deviceMemory"
|
||||
onInfo={onInfo}
|
||||
accent
|
||||
/>
|
||||
)}
|
||||
{device.metadata
|
||||
? Object.entries(device.metadata).map(([k, v]) => (
|
||||
<InfoRow key={k} label={k} value={String(v)} />
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const devSt = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: 4,
|
||||
},
|
||||
nameCol: {
|
||||
flex: 1,
|
||||
},
|
||||
icon: { fontSize: 20 },
|
||||
name: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
sub: { fontSize: 11, color: colors.textTertiary },
|
||||
infoBtn: { fontSize: 15, color: colors.primary },
|
||||
});
|
||||
|
||||
// ─── Main Screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
const platformConsts = Platform.constants as Record<string, unknown>;
|
||||
|
||||
export default function HardwareInfoScreen() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const [tooltip, setTooltip] = useState<TooltipState>({ visible: false, key: '' });
|
||||
const showTip = useCallback((key: string) => setTooltip({ visible: true, key }), []);
|
||||
const closeTip = useCallback(() => setTooltip(t => ({ ...t, visible: false })), []);
|
||||
|
||||
// ── Hardware state from store ──────────────────────────────────────────────
|
||||
const ramTotal = useSelector((s: RootState) => s.hardware.ramTotal);
|
||||
const ramFree = useSelector((s: RootState) => s.hardware.ramFree);
|
||||
const diskTotal = useSelector((s: RootState) => s.hardware.diskTotal);
|
||||
const diskFree = useSelector((s: RootState) => s.hardware.diskFree);
|
||||
const backendDevices = useSelector((s: RootState) => s.hardware.backendDevices);
|
||||
const isLoading = useSelector((s: RootState) => s.hardware.isLoading);
|
||||
const hwError = useSelector((s: RootState) => s.hardware.error);
|
||||
|
||||
// ── LLaMA context from models store ───────────────────────────────────────
|
||||
const llamaSystemInfo = useSelector((s: RootState) => s.models.llamaSystemInfo);
|
||||
const llamaContextGpu = useSelector((s: RootState) => s.models.llamaContextGpu);
|
||||
const llamaContextDevices = useSelector((s: RootState) => s.models.llamaContextDevices);
|
||||
const llamaContextReasonNoGPU = useSelector((s: RootState) => s.models.llamaContextReasonNoGPU);
|
||||
const llamaAndroidLib = useSelector((s: RootState) => s.models.llamaAndroidLib);
|
||||
const currentLoadedModel = useSelector((s: RootState) => s.models.currentLoadedModel);
|
||||
|
||||
// Full refresh on focus (disk + backends + RAM); lightweight RAM polling every 2 s
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(refreshHardwareInfo());
|
||||
|
||||
if (Platform.OS !== 'android') { return; }
|
||||
|
||||
const interval = setInterval(() => { dispatch(refreshRamInfo()); }, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
const ramUsed = ramTotal > 0 ? ramTotal - ramFree : 0;
|
||||
const usedSpace = diskTotal > 0 ? diskTotal - diskFree : 0;
|
||||
|
||||
const hasGPU = backendDevices.some(d => d.type.toUpperCase() === 'GPU');
|
||||
const hasOpenCL = backendDevices.some(d => d.backend.toUpperCase().includes('OPENCL'));
|
||||
const hasMetal = backendDevices.some(d => d.backend.toUpperCase().includes('METAL'));
|
||||
const hasHexagon = backendDevices.some(
|
||||
d =>
|
||||
d.deviceName.toUpperCase().includes('HTP') ||
|
||||
d.deviceName.toUpperCase().includes('HEXAGON') ||
|
||||
d.backend.toUpperCase().includes('HEXAGON'),
|
||||
);
|
||||
const hasCUDA = backendDevices.some(d => d.backend.toUpperCase().includes('CUDA'));
|
||||
|
||||
const osLabel = Platform.OS === 'android' ? 'Android' : 'iOS';
|
||||
const osVersion =
|
||||
Platform.OS === 'android' ? `API ${Platform.Version}` : String(Platform.Version);
|
||||
const deviceModel =
|
||||
Platform.OS === 'android'
|
||||
? `${String(platformConsts.Manufacturer ?? '')} ${String(platformConsts.Model ?? '')}`.trim()
|
||||
: String(platformConsts.interfaceIdiom ?? '');
|
||||
const brand = Platform.OS === 'android' ? String(platformConsts.Brand ?? '') : undefined;
|
||||
const uiMode = Platform.OS === 'android' ? String(platformConsts.uiMode ?? '') : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipModal state={tooltip} onClose={closeTip} />
|
||||
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => dispatch(refreshHardwareInfo())}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text style={styles.pageTitle}>🖥 Matériel</Text>
|
||||
|
||||
{hwError ? (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>⚠ {hwError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* ── Système d'exploitation ─────────────────────────────────── */}
|
||||
<Card title="Système d'exploitation">
|
||||
<InfoRow label="OS" value={osLabel} tipKey="os" onInfo={showTip} />
|
||||
<InfoRow label="Version" value={osVersion} tipKey="osVersion" onInfo={showTip} />
|
||||
{deviceModel ? (
|
||||
<InfoRow label="Appareil" value={deviceModel} tipKey="deviceModel" onInfo={showTip} />
|
||||
) : null}
|
||||
{brand ? (
|
||||
<InfoRow label="Marque" value={brand} tipKey="deviceModel" onInfo={showTip} />
|
||||
) : null}
|
||||
{Platform.OS === 'android' && platformConsts.Release ? (
|
||||
<InfoRow
|
||||
label="Release"
|
||||
value={String(platformConsts.Release)}
|
||||
tipKey="osVersion"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{uiMode ? (
|
||||
<InfoRow label="Mode UI" value={uiMode} tipKey="uiMode" onInfo={showTip} />
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* ── Mémoire vive (RAM) ─────────────────────────────────────── */}
|
||||
<Card title="Mémoire vive (RAM)">
|
||||
{ramTotal > 0 ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="RAM totale"
|
||||
value={fmtBytes(ramTotal)}
|
||||
tipKey="ramTotal"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
<InfoRow
|
||||
label="RAM disponible"
|
||||
value={`${fmtBytes(ramFree)} (${pct(ramFree, ramTotal)})`}
|
||||
tipKey="ramFree"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
<InfoRow
|
||||
label="RAM utilisée"
|
||||
value={`${fmtBytes(ramUsed)} (${pct(ramUsed, ramTotal)})`}
|
||||
tipKey="ramFree"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="RAM"
|
||||
value={Platform.OS === 'ios' ? 'Non disponible sur iOS' : 'Chargement…'}
|
||||
tipKey="ramTotal"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Processeur (CPU) ───────────────────────────────────────── */}
|
||||
<Card title="Processeur (CPU)">
|
||||
{llamaSystemInfo ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Capacités SIMD"
|
||||
value="Voir détail ci-dessous"
|
||||
tipKey="simd"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<Text style={styles.monoBlock}>{llamaSystemInfo}</Text>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="Capacités SIMD"
|
||||
value="Chargez un modèle pour afficher"
|
||||
tipKey="simd"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Stockage ───────────────────────────────────────────────── */}
|
||||
<Card title="Stockage">
|
||||
{diskTotal > 0 ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Espace total"
|
||||
value={fmtBytes(diskTotal)}
|
||||
tipKey="diskTotal"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Espace utilisé"
|
||||
value={`${fmtBytes(usedSpace)} (${pct(usedSpace, diskTotal)})`}
|
||||
tipKey="diskFree"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Espace libre"
|
||||
value={`${fmtBytes(diskFree)} (${pct(diskFree, diskTotal)})`}
|
||||
tipKey="diskFree"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow label="Espace libre" value="Chargement…" tipKey="diskFree" onInfo={showTip} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Backends de calcul ─────────────────────────────────────── */}
|
||||
<Card title="Backends de calcul (llama.rn)">
|
||||
{backendDevices.length === 0 && !isLoading ? (
|
||||
<InfoRow
|
||||
label="Backends"
|
||||
value="Aucun détecté"
|
||||
tipKey="backendDevice"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{backendDevices.length === 0 && isLoading ? (
|
||||
<InfoRow label="Backends" value="Chargement…" />
|
||||
) : null}
|
||||
{backendDevices.length > 0 ? (
|
||||
<View style={styles.badgeRow}>
|
||||
<Badge label="GPU" active={hasGPU} />
|
||||
<Badge label="OpenCL" active={hasOpenCL} />
|
||||
<Badge label="Metal" active={hasMetal} />
|
||||
<Badge label="Hexagon NPU" active={hasHexagon} />
|
||||
<Badge label="CUDA" active={hasCUDA} />
|
||||
</View>
|
||||
) : null}
|
||||
{backendDevices.map((dev) => (
|
||||
<BackendDeviceCard key={`${dev.backend}-${dev.deviceName}`} device={dev} onInfo={showTip} />
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* ── Contexte actif ─────────────────────────────────────────── */}
|
||||
<Card title="Contexte actif (modèle chargé)">
|
||||
{currentLoadedModel ? (
|
||||
<>
|
||||
<InfoRow label="Modèle" value={currentLoadedModel.split('/').pop() ?? '—'} />
|
||||
<InfoRow
|
||||
label="GPU utilisé"
|
||||
value={
|
||||
llamaContextGpu === undefined
|
||||
? '—'
|
||||
: llamaContextGpu
|
||||
? 'Oui ✅'
|
||||
: 'Non ❌'
|
||||
}
|
||||
tipKey="gpuUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
{!llamaContextGpu && llamaContextReasonNoGPU ? (
|
||||
<InfoRow
|
||||
label="Raison (pas GPU)"
|
||||
value={llamaContextReasonNoGPU}
|
||||
tipKey="reasonNoGPU"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{llamaContextDevices && llamaContextDevices.length > 0 ? (
|
||||
<InfoRow
|
||||
label="Appareils utilisés"
|
||||
value={llamaContextDevices.join(', ')}
|
||||
tipKey="devicesUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{Platform.OS === 'android' && llamaAndroidLib ? (
|
||||
<InfoRow
|
||||
label="Lib Android"
|
||||
value={llamaAndroidLib}
|
||||
tipKey="androidLib"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="État"
|
||||
value="Aucun modèle chargé"
|
||||
tipKey="gpuUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── llama.rn ───────────────────────────────────────────────── */}
|
||||
<Card title="llama.rn">
|
||||
<InfoRow
|
||||
label="Build"
|
||||
value={String(BuildInfo.number)}
|
||||
tipKey="buildNumber"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Commit"
|
||||
value={String(BuildInfo.commit)}
|
||||
tipKey="buildCommit"
|
||||
onInfo={showTip}
|
||||
mono
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.refreshBtn}
|
||||
onPress={() => dispatch(refreshHardwareInfo())}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.refreshBtnText}>↻ Actualiser</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: spacing.xl * 2 }} />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
content: { padding: spacing.md },
|
||||
pageTitle: {
|
||||
fontSize: typography.sizes.xl,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
monoBlock: {
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
|
||||
fontSize: 11,
|
||||
lineHeight: 17,
|
||||
color: colors.textSecondary,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
errorBox: {
|
||||
backgroundColor: colors.errorLight,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
errorText: { color: colors.errorDark, fontSize: typography.sizes.sm },
|
||||
refreshBtn: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
refreshBtnText: {
|
||||
color: colors.primary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
85
app/src/screens/HuggingFaceModelsScreen/ApiKeyEditor.tsx
Normal file
85
app/src/screens/HuggingFaceModelsScreen/ApiKeyEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ApiKeyEditorProps {
|
||||
tempApiKey: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export default function ApiKeyEditor({
|
||||
tempApiKey,
|
||||
onChangeText,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: ApiKeyEditorProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
apiKeyInputContainer: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
apiKeyInput: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
apiKeyButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.apiKeyInputContainer}>
|
||||
<TextInput
|
||||
style={styles.apiKeyInput}
|
||||
value={tempApiKey}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="hf_..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<View style={styles.apiKeyButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={onSave}
|
||||
>
|
||||
<Text style={styles.buttonText}>Enregistrer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
209
app/src/screens/HuggingFaceModelsScreen/HFModelItem.tsx
Normal file
209
app/src/screens/HuggingFaceModelsScreen/HFModelItem.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import type { HuggingFaceModel } from '../../store/modelsSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface HFModelItemProps {
|
||||
model: HuggingFaceModel;
|
||||
onPress: (modelId: string) => void;
|
||||
onDownload: (modelId: string) => void;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
bytesWritten?: number;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export default function HFModelItem({
|
||||
model,
|
||||
onPress,
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
downloadProgress = 0,
|
||||
bytesWritten = 0,
|
||||
contentLength = 0,
|
||||
}: HFModelItemProps) {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modelCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
modelHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
modelAuthor: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.primary,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stat: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.6,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: colors.border,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
moreTagsText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
downloadButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
downloadButtonDisabled: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
downloadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
downloadButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
downloadPercentText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.modelCard}>
|
||||
<TouchableOpacity
|
||||
onPress={() => onPress(model.id)}
|
||||
activeOpacity={0.7}
|
||||
style={styles.cardContent}
|
||||
>
|
||||
<View style={styles.modelHeader}>
|
||||
<Text style={styles.modelAuthor}>{model.author}</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>⬇</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{formatNumber(model.downloads)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>♥</Text>
|
||||
<Text style={styles.statValue}>{formatNumber(model.likes)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.modelName}>{model.name}</Text>
|
||||
{model.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{model.tags.slice(0, 3).map(tag => (
|
||||
<View key={`tag-${model.id}-${tag}`} style={styles.tag}>
|
||||
<Text style={styles.tagText}>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
{model.tags.length > 3 && (
|
||||
<Text style={styles.moreTagsText}>
|
||||
+{model.tags.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.downloadButton,
|
||||
isDownloading && styles.downloadButtonDisabled,
|
||||
]}
|
||||
onPress={() => onDownload(model.id)}
|
||||
disabled={isDownloading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<View style={styles.downloadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.onPrimary} />
|
||||
<Text style={styles.downloadButtonText}>
|
||||
{(bytesWritten / 1024 / 1024).toFixed(1)} MB /
|
||||
{(contentLength / 1024 / 1024).toFixed(1)} MB
|
||||
</Text>
|
||||
<Text style={styles.downloadPercentText}>
|
||||
{Math.round(downloadProgress)}%
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.downloadButtonText}>⬇ Télécharger</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
87
app/src/screens/HuggingFaceModelsScreen/SearchSection.tsx
Normal file
87
app/src/screens/HuggingFaceModelsScreen/SearchSection.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface SearchSectionProps {
|
||||
searchQuery: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSearch: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function SearchSection({
|
||||
searchQuery,
|
||||
onChangeText,
|
||||
onSearch,
|
||||
isLoading,
|
||||
}: SearchSectionProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
searchSection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
searchButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchButtonText: {
|
||||
fontSize: typography.sizes.xl,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.searchSection}>
|
||||
<Text style={styles.sectionTitle}>Rechercher</Text>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="llama, mistral, phi..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSearch}
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.searchButton}
|
||||
onPress={onSearch}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
>
|
||||
<Text style={styles.searchButtonText}>
|
||||
{isLoading ? '...' : '🔍'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
69
app/src/screens/HuggingFaceModelsScreen/downloadHelper.ts
Normal file
69
app/src/screens/HuggingFaceModelsScreen/downloadHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Alert } from 'react-native';
|
||||
import { AppDispatch } from '../../store';
|
||||
import {
|
||||
downloadHuggingFaceModel,
|
||||
setDownloadProgress,
|
||||
removeDownloadProgress,
|
||||
} from '../../store/modelsSlice';
|
||||
|
||||
export async function handleModelDownload(
|
||||
modelId: string,
|
||||
modelsDirectory: string,
|
||||
dispatch: AppDispatch
|
||||
): Promise<void> {
|
||||
try {
|
||||
const apiUrl = `https://huggingface.co/api/models/${modelId}/tree/main`;
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Impossible de récupérer les fichiers du modèle');
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
const ggufFile = files.find((f: { path: string }) =>
|
||||
f.path.toLowerCase().endsWith('.gguf')
|
||||
);
|
||||
|
||||
if (!ggufFile) {
|
||||
Alert.alert('Erreur', 'Aucun fichier GGUF trouvé dans ce modèle');
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = `https://huggingface.co/${modelId}/resolve/main/${ggufFile.path}`;
|
||||
const sizeMB = (ggufFile.size / 1024 / 1024).toFixed(2);
|
||||
|
||||
Alert.alert(
|
||||
'Télécharger',
|
||||
`Fichier: ${ggufFile.path}\nTaille: ${sizeMB} MB\n\nTélécharger dans ${modelsDirectory}?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Télécharger',
|
||||
onPress: () => {
|
||||
dispatch(downloadHuggingFaceModel({
|
||||
modelId,
|
||||
fileName: ggufFile.path,
|
||||
downloadUrl,
|
||||
destinationDir: modelsDirectory,
|
||||
onProgress: (bytesWritten, contentLength) => {
|
||||
dispatch(setDownloadProgress({
|
||||
modelId,
|
||||
bytesWritten,
|
||||
contentLength,
|
||||
}));
|
||||
},
|
||||
})).then(() => {
|
||||
dispatch(removeDownloadProgress(modelId));
|
||||
Alert.alert('Succès', 'Modèle téléchargé avec succès');
|
||||
}).catch(() => {
|
||||
dispatch(removeDownloadProgress(modelId));
|
||||
Alert.alert('Erreur', 'Échec du téléchargement');
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert('Erreur', (error as Error).message);
|
||||
}
|
||||
}
|
||||
220
app/src/screens/HuggingFaceModelsScreen/index.tsx
Normal file
220
app/src/screens/HuggingFaceModelsScreen/index.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import {
|
||||
searchHuggingFaceModels,
|
||||
saveHuggingFaceApiKey,
|
||||
loadHuggingFaceApiKey,
|
||||
setSearchQuery,
|
||||
clearHFError,
|
||||
} from '../../store/modelsSlice';
|
||||
import HFModelItem from './HFModelItem';
|
||||
import ApiKeyEditor from './ApiKeyEditor';
|
||||
import SearchSection from './SearchSection';
|
||||
import { handleModelDownload } from './downloadHelper';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function HuggingFaceModelsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const {
|
||||
huggingFaceModels,
|
||||
huggingFaceApiKey,
|
||||
searchQuery,
|
||||
isLoadingHF,
|
||||
hfError,
|
||||
modelsDirectory,
|
||||
downloadProgress,
|
||||
} = useSelector((state: RootState) => state.models);
|
||||
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [tempApiKey, setTempApiKey] = useState('');
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved API key on mount
|
||||
dispatch(loadHuggingFaceApiKey());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(huggingFaceApiKey);
|
||||
}, [huggingFaceApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hfError) {
|
||||
Alert.alert('Erreur', hfError, [
|
||||
{ text: 'OK', onPress: () => dispatch(clearHFError()) },
|
||||
]);
|
||||
}
|
||||
}, [hfError, dispatch]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (localSearchQuery.trim()) {
|
||||
dispatch(setSearchQuery(localSearchQuery.trim()));
|
||||
dispatch(searchHuggingFaceModels({
|
||||
query: localSearchQuery.trim(),
|
||||
apiKey: huggingFaceApiKey || undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveApiKey = () => {
|
||||
dispatch(saveHuggingFaceApiKey(tempApiKey.trim()));
|
||||
setShowApiKeyInput(false);
|
||||
Alert.alert('Succès', 'Clé API enregistrée');
|
||||
};
|
||||
|
||||
const handleOpenHuggingFace = () => {
|
||||
Linking.openURL('https://huggingface.co/settings/tokens');
|
||||
};
|
||||
|
||||
const handleModelPress = (modelId: string) => {
|
||||
const url = `https://huggingface.co/${modelId}`;
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
const handleDownload = (modelId: string) => {
|
||||
handleModelDownload(modelId, modelsDirectory, dispatch);
|
||||
};
|
||||
|
||||
const renderModelItem = ({ item }: { item: typeof huggingFaceModels[0] }) => {
|
||||
const modelProgress = downloadProgress[item.id];
|
||||
const isDownloading = !!modelProgress;
|
||||
const progressPercent = modelProgress
|
||||
? (modelProgress.bytesWritten / modelProgress.contentLength) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<HFModelItem
|
||||
model={item}
|
||||
onPress={handleModelPress}
|
||||
onDownload={handleDownload}
|
||||
isDownloading={isDownloading}
|
||||
downloadProgress={progressPercent}
|
||||
bytesWritten={modelProgress?.bytesWritten || 0}
|
||||
contentLength={modelProgress?.contentLength || 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (isLoadingHF) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Text style={styles.emptyText}>Aucun modèle trouvé</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Essayez une autre recherche
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>
|
||||
Recherchez des modèles GGUF
|
||||
</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Entrez un terme de recherche ci-dessus
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
{/* API Key Section */}
|
||||
<View style={styles.apiKeySection}>
|
||||
<View style={styles.apiKeyHeader}>
|
||||
<Text style={styles.sectionTitle}>Clé API HuggingFace</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton}
|
||||
onPress={handleOpenHuggingFace}
|
||||
>
|
||||
<Text style={styles.infoButtonText}>ℹ️ Obtenir</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{showApiKeyInput ? (
|
||||
<ApiKeyEditor
|
||||
tempApiKey={tempApiKey}
|
||||
onChangeText={setTempApiKey}
|
||||
onCancel={() => {
|
||||
setTempApiKey(huggingFaceApiKey);
|
||||
setShowApiKeyInput(false);
|
||||
}}
|
||||
onSave={handleSaveApiKey}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.apiKeyDisplay}>
|
||||
<Text style={styles.apiKeyText}>
|
||||
{huggingFaceApiKey ? '•••••••••••••' : 'Non configurée'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.changeButton}
|
||||
onPress={() => setShowApiKeyInput(true)}
|
||||
>
|
||||
<Text style={styles.changeButtonText}>
|
||||
{huggingFaceApiKey ? 'Modifier' : 'Configurer'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.apiKeyHint}>
|
||||
Optionnel - Permet d'augmenter les limites de recherche
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Section */}
|
||||
<SearchSection
|
||||
searchQuery={localSearchQuery}
|
||||
onChangeText={setLocalSearchQuery}
|
||||
onSearch={handleSearch}
|
||||
isLoading={isLoadingHF}
|
||||
/>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.resultsSection}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Résultats ({huggingFaceModels.length})
|
||||
</Text>
|
||||
|
||||
{isLoadingHF && huggingFaceModels.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Recherche en cours...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={huggingFaceModels}
|
||||
renderItem={renderModelItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListEmptyComponent={renderEmptyList}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
139
app/src/screens/HuggingFaceModelsScreen/styles.ts
Normal file
139
app/src/screens/HuggingFaceModelsScreen/styles.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
export const createStyles = (colors: {
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
notification: string;
|
||||
onPrimary: string;
|
||||
}) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
apiKeySection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
apiKeyHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
infoButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
infoButtonText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.primary,
|
||||
},
|
||||
apiKeyDisplay: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
apiKeyText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
changeButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
changeButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
apiKeyHint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
searchSection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
searchButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchButtonText: {
|
||||
fontSize: typography.sizes.xl,
|
||||
},
|
||||
resultsSection: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
53
app/src/screens/LandingScreen.tsx
Normal file
53
app/src/screens/LandingScreen.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Landing'>;
|
||||
|
||||
export default function LandingScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'MainTabs',
|
||||
params: { screen: 'Models' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Image
|
||||
source={require('../../assets/logo.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel="My Mobile Agent"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
},
|
||||
});
|
||||
85
app/src/screens/LocalModelsScreen/DirectoryEditor.tsx
Normal file
85
app/src/screens/LocalModelsScreen/DirectoryEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface DirectoryEditorProps {
|
||||
tempDirectory: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onSelectCommon: (dir: string) => void;
|
||||
}
|
||||
|
||||
export default function DirectoryEditor({
|
||||
tempDirectory,
|
||||
onChangeText,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: DirectoryEditorProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editContainer: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
directoryInput: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
editButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.editContainer}>
|
||||
<TextInput
|
||||
style={styles.directoryInput}
|
||||
value={tempDirectory}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="Chemin du dossier"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<View style={styles.editButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={onSave}
|
||||
>
|
||||
<Text style={styles.buttonText}>Enregistrer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
71
app/src/screens/LocalModelsScreen/DirectoryPicker.tsx
Normal file
71
app/src/screens/LocalModelsScreen/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface DirectoryPickerProps {
|
||||
currentDirectory: string;
|
||||
onSelectDirectory: (directory: string) => void;
|
||||
}
|
||||
|
||||
const commonDirectories = [
|
||||
{ label: 'Téléchargements/models', path: `${RNFS.DownloadDirectoryPath}/models` },
|
||||
{ label: 'Documents/models', path: `${RNFS.DocumentDirectoryPath}/models` },
|
||||
{ label: 'Téléchargements', path: RNFS.DownloadDirectoryPath },
|
||||
];
|
||||
|
||||
export default function DirectoryPicker(
|
||||
{ onSelectDirectory }: DirectoryPickerProps
|
||||
) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
commonDirsContainer: {
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
commonDirsLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
commonDirButton: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
commonDirText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
marginBottom: 2,
|
||||
},
|
||||
commonDirPath: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.commonDirsContainer}>
|
||||
<Text style={styles.commonDirsLabel}>Dossiers courants :</Text>
|
||||
{commonDirectories.map((dir, _idx) => (
|
||||
<TouchableOpacity
|
||||
key={`dir-${dir.path}`}
|
||||
style={styles.commonDirButton}
|
||||
onPress={() => onSelectDirectory(dir.path)}
|
||||
>
|
||||
<Text style={styles.commonDirText}>{dir.label}</Text>
|
||||
<Text style={styles.commonDirPath} numberOfLines={1}>
|
||||
{dir.path}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
141
app/src/screens/LocalModelsScreen/ModelConfigScreen.tsx
Normal file
141
app/src/screens/LocalModelsScreen/ModelConfigScreen.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../../navigation';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import { saveModelConfig } from '../../store/modelsThunks';
|
||||
import { setCurrentLoadedModel, setModelConfig } from '../../store/modelsSlice';
|
||||
import { loadModel } from '../../store/chatThunks';
|
||||
import { colors } from '../../theme/lightTheme';
|
||||
import { PARAM_INFO } from './modelConfig/paramInfo';
|
||||
import { useModelConfigState } from './modelConfig/hooks/useModelConfigState';
|
||||
import { buildModelConfig } from './modelConfig/buildModelConfig';
|
||||
import SystemPromptSection from './modelConfig/sections/SystemPromptSection';
|
||||
import LoadingSection from './modelConfig/sections/LoadingSection';
|
||||
import SamplingSection from './modelConfig/sections/SamplingSection';
|
||||
import PenaltiesSection from './modelConfig/sections/PenaltiesSection';
|
||||
import OutputSection from './modelConfig/sections/OutputSection';
|
||||
import { styles } from './modelConfig/styles';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'ModelConfig'>;
|
||||
|
||||
export default function ModelConfigScreen({ route, navigation }: Props) {
|
||||
const { modelPath, modelName } = route.params;
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const loadModelProgress = useSelector(
|
||||
(s: RootState) => s.models.loadModelProgress ?? 0,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const state = useModelConfigState(modelPath, navigation, modelName);
|
||||
const tooltipInfo = PARAM_INFO[state.tooltip.key];
|
||||
const progressStyle = { width: `${loadModelProgress}%` as `${number}%` };
|
||||
|
||||
const handleSave = async () => {
|
||||
const config = buildModelConfig(state);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await dispatch(saveModelConfig({ modelPath, config }));
|
||||
dispatch(setModelConfig({ modelPath, config }));
|
||||
dispatch(setCurrentLoadedModel(modelPath));
|
||||
const result = await dispatch(loadModel({ modelPath, cfg: config }));
|
||||
if (loadModel.rejected.match(result)) {
|
||||
Alert.alert(
|
||||
'Erreur de chargement',
|
||||
String(result.payload ?? 'Impossible de charger le modèle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigation.navigate('MainTabs', { screen: 'Chat' } as never);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', (e as Error).message ?? 'Une erreur est survenue');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={state.tooltip.visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={state.closeTooltip}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={state.closeTooltip}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>{tooltipInfo?.title}</Text>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>📖 Description</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.description}</Text>
|
||||
</View>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>⚙️ Impact</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.impact}</Text>
|
||||
</View>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>💡 Utilisation</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.usage}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.modalClose}
|
||||
onPress={state.closeTooltip}
|
||||
>
|
||||
<Text style={styles.modalCloseText}>Fermer</Text>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
<Modal visible={isSubmitting} 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}>
|
||||
{modelName}
|
||||
</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>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<SystemPromptSection state={state} />
|
||||
<LoadingSection state={state} />
|
||||
<SamplingSection state={state} />
|
||||
<PenaltiesSection state={state} />
|
||||
<OutputSection state={state} />
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSave}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
💾 Sauvegarder et charger le modèle
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1374
app/src/screens/LocalModelsScreen/ModelConfigScreen_old.tsx
Normal file
1374
app/src/screens/LocalModelsScreen/ModelConfigScreen_old.tsx
Normal file
File diff suppressed because it is too large
Load Diff
285
app/src/screens/LocalModelsScreen/ModelItem.tsx
Normal file
285
app/src/screens/LocalModelsScreen/ModelItem.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import type { LocalModel } from '../../store/modelsSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ModelItemProps {
|
||||
model: LocalModel;
|
||||
isLoaded: boolean;
|
||||
onPress: (path: string, name: string) => void;
|
||||
/** Charge le modèle directement avec la config enregistrée */
|
||||
onLoad?: () => void;
|
||||
/** Décharge le modèle de la RAM */
|
||||
onUnload?: () => void;
|
||||
/** True uniquement pour la carte en cours de chargement */
|
||||
isLoadingThisModel?: boolean;
|
||||
/** Called when the card body is tapped (toggle highlight) */
|
||||
onHighlight?: () => void;
|
||||
isHighlighted?: boolean;
|
||||
/** Free RAM in bytes from hardware slice */
|
||||
ramFree?: number;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
bytesWritten?: number;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) { return `${bytes} B`; }
|
||||
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(2)} KB`; }
|
||||
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function ModelItem({
|
||||
model,
|
||||
isLoaded,
|
||||
onPress,
|
||||
onLoad,
|
||||
onUnload,
|
||||
isLoadingThisModel = false,
|
||||
onHighlight,
|
||||
isHighlighted = false,
|
||||
ramFree = 0,
|
||||
isDownloading = false,
|
||||
downloadProgress = 0,
|
||||
bytesWritten = 0,
|
||||
contentLength = 0,
|
||||
}: ModelItemProps) {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
|
||||
// How much of the available free RAM this model would consume
|
||||
const ramImpactPct = ramFree > 0 ? Math.round((model.size / ramFree) * 100) : null;
|
||||
const modelExceedsRam = ramImpactPct !== null && ramImpactPct > 100;
|
||||
const impactColor =
|
||||
ramImpactPct === null ? colors.text
|
||||
: modelExceedsRam ? colors.notification
|
||||
: ramImpactPct > 70 ? colors.warning
|
||||
: colors.success;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modelCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
borderWidth: 1.5,
|
||||
borderColor: isHighlighted ? colors.primary : colors.border,
|
||||
},
|
||||
modelCardHighlighted: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: isDark ? colors.card : colors.agentBg,
|
||||
},
|
||||
modelInfoTouchable: {
|
||||
flex: 1,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
modelDetails: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
impactRow: {
|
||||
marginTop: 2,
|
||||
},
|
||||
impactBarTrack: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 3,
|
||||
},
|
||||
impactBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
impactLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
loadedBadge: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
loadedText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.xs,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
actionCol: {
|
||||
flexDirection: 'column',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
loadBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
unloadBtn: {
|
||||
backgroundColor: colors.notification,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
unloadBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
configBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
configBtnActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: isDark ? colors.card : colors.agentBg,
|
||||
},
|
||||
configBtnText: {
|
||||
color: colors.text,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
loadingSpinner: {
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
downloadingText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
marginBottom: 2,
|
||||
},
|
||||
downloadPercentText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
// Outer card — tap to toggle highlight (shows footprint in header bar)
|
||||
<View style={[styles.modelCard, isHighlighted && styles.modelCardHighlighted]}>
|
||||
<TouchableOpacity
|
||||
style={styles.modelInfoTouchable}
|
||||
onPress={onHighlight}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.modelName} numberOfLines={1}>
|
||||
{model.name}
|
||||
</Text>
|
||||
|
||||
{isDownloading ? (
|
||||
<View>
|
||||
<Text style={styles.downloadingText}>
|
||||
Téléchargement : {(bytesWritten / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(contentLength / 1024 / 1024).toFixed(1)} MB
|
||||
</Text>
|
||||
<Text style={styles.downloadPercentText}>
|
||||
{Math.round(downloadProgress)} %
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.modelDetails}>
|
||||
Taille : {formatSize(model.size)}
|
||||
</Text>
|
||||
|
||||
{/* RAM impact bar — always visible when ramFree is known */}
|
||||
{ramImpactPct !== null && (
|
||||
<View style={styles.impactRow}>
|
||||
<View style={styles.impactBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.impactBarFill,
|
||||
{
|
||||
width: `${Math.min(ramImpactPct, 100)}%` as `${number}%`,
|
||||
backgroundColor: impactColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.impactLabel, { color: impactColor }]}>
|
||||
{modelExceedsRam
|
||||
? `⚠ Trop lourd — nécessite ${ramImpactPct} % de la RAM libre`
|
||||
: `Utilise ~${ramImpactPct} % de la RAM libre au chargement`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoaded && (
|
||||
<View style={styles.loadedBadge}>
|
||||
<Text style={styles.loadedText}>Chargé</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Action buttons column */}
|
||||
<View style={styles.actionCol}>
|
||||
{/* Charger / Décharger / spinner */}
|
||||
{isLoadingThisModel ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary}
|
||||
style={styles.loadingSpinner}
|
||||
/>
|
||||
) : isLoaded ? (
|
||||
<TouchableOpacity style={styles.unloadBtn} onPress={onUnload}>
|
||||
<Text style={styles.unloadBtnText}>Décharger</Text>
|
||||
</TouchableOpacity>
|
||||
) : !isDownloading ? (
|
||||
<TouchableOpacity style={styles.loadBtn} onPress={onLoad}>
|
||||
<Text style={styles.loadBtnText}>▶ Charger</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{/* Configurer — toujours visible sauf pendant le download */}
|
||||
{!isDownloading && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onPress(model.path, model.name)}
|
||||
style={[styles.configBtn, isHighlighted && styles.configBtnActive]}
|
||||
>
|
||||
<Text style={styles.configBtnText}>Configurer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
80
app/src/screens/LocalModelsScreen/ModelsList.tsx
Normal file
80
app/src/screens/LocalModelsScreen/ModelsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
import type { LocalModel } from '../../store/types';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface EmptyListProps {
|
||||
message: string;
|
||||
submessage: string;
|
||||
colors: {
|
||||
text: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyList({ message, submessage, colors }: EmptyListProps) {
|
||||
const styles = createStyles(colors);
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
<Text style={styles.emptySubtext}>{submessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelsListProps {
|
||||
models: LocalModel[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
renderItem: (item: { item: LocalModel }) => React.ReactElement;
|
||||
}
|
||||
|
||||
export default function ModelsList({
|
||||
models,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
renderItem,
|
||||
}: ModelsListProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.modelsSection}>
|
||||
<View style={styles.modelsHeader}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Modèles locaux ({models.length})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isLoading && models.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Scan en cours...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.path}
|
||||
ListEmptyComponent={
|
||||
<EmptyList
|
||||
message="Aucun fichier .gguf trouvé dans ce dossier"
|
||||
submessage="Placez vos modèles dans le dossier configuré"
|
||||
colors={colors}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary]}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
34
app/src/screens/LocalModelsScreen/PermissionMessage.tsx
Normal file
34
app/src/screens/LocalModelsScreen/PermissionMessage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface PermissionMessageProps {
|
||||
onRequestPermission: () => void;
|
||||
}
|
||||
|
||||
export default function PermissionMessage({
|
||||
onRequestPermission,
|
||||
}: PermissionMessageProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.permissionContainer}>
|
||||
<Text style={styles.permissionText}>
|
||||
📁 Permission requise
|
||||
</Text>
|
||||
<Text style={styles.permissionSubtext}>
|
||||
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={onRequestPermission}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>
|
||||
Autoriser l'accès aux fichiers
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ModelConfig } from '../../../store/types';
|
||||
import type { ModelConfigFormState } from './types';
|
||||
|
||||
function parseOptNum(v: string): number | undefined {
|
||||
return v ? Number(v) : undefined;
|
||||
}
|
||||
|
||||
export function buildModelConfig(state: ModelConfigFormState): ModelConfig {
|
||||
return {
|
||||
systemPrompt: state.systemPrompt.trim(),
|
||||
n_ctx: Number(state.nCtx) || 2048,
|
||||
n_batch: parseOptNum(state.nBatch),
|
||||
n_ubatch: parseOptNum(state.nUbatch),
|
||||
n_threads: parseOptNum(state.nThreads),
|
||||
n_gpu_layers: Number(state.nGpuLayers),
|
||||
flash_attn: state.flashAttn,
|
||||
cache_type_k: state.cacheTypeK,
|
||||
cache_type_v: state.cacheTypeV,
|
||||
use_mlock: state.useMlock,
|
||||
use_mmap: state.useMmap,
|
||||
rope_freq_base: parseOptNum(state.ropeFreqBase),
|
||||
rope_freq_scale: parseOptNum(state.ropeFreqScale),
|
||||
ctx_shift: state.ctxShift,
|
||||
kv_unified: state.kvUnified,
|
||||
n_cpu_moe: parseOptNum(state.nCpuMoe),
|
||||
cpu_mask: state.cpuMask || undefined,
|
||||
n_parallel: Number(state.nParallel) || 1,
|
||||
temperature: Number(state.temperature),
|
||||
top_k: Number(state.topK),
|
||||
top_p: Number(state.topP),
|
||||
min_p: Number(state.minP),
|
||||
n_predict: Number(state.nPredict),
|
||||
seed: Number(state.seed),
|
||||
typical_p: Number(state.typicalP),
|
||||
top_n_sigma: Number(state.topNSigma),
|
||||
mirostat: Number(state.mirostat),
|
||||
mirostat_tau: Number(state.mirostatTau),
|
||||
mirostat_eta: Number(state.mirostatEta),
|
||||
xtc_probability: Number(state.xtcProb),
|
||||
xtc_threshold: Number(state.xtcThresh),
|
||||
penalty_repeat: Number(state.penaltyRepeat),
|
||||
penalty_last_n: Number(state.penaltyLastN),
|
||||
penalty_freq: Number(state.penaltyFreq),
|
||||
penalty_present: Number(state.penaltyPresent),
|
||||
dry_multiplier: Number(state.dryMultiplier),
|
||||
dry_base: Number(state.dryBase),
|
||||
dry_allowed_length: Number(state.dryAllowed),
|
||||
dry_penalty_last_n: Number(state.dryLastN),
|
||||
ignore_eos: state.ignoreEos,
|
||||
n_probs: Number(state.nProbs),
|
||||
stop: state.stopStr
|
||||
? state.stopStr.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Switch, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface BoolRowProps {
|
||||
paramKey: string;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function BoolRow({ paramKey, value, onChange, onInfo }: BoolRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
trackColor={{ false: colors.border, true: colors.primary }}
|
||||
thumbColor={colors.surface}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
labelRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { TextInput, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
|
||||
interface NumInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function NumInput({ value, onChange, placeholder }: NumInputProps) {
|
||||
return (
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numeric"
|
||||
placeholder={placeholder ?? ''}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
input: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface ParamRowProps {
|
||||
paramKey: string;
|
||||
children: React.ReactNode;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function ParamRow({ paramKey, children, onInfo }: ParamRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
style={styles.infoBtn}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
infoBtn: { paddingLeft: spacing.sm },
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
expanded,
|
||||
onToggle,
|
||||
badge,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{badge ? <Text style={styles.badge}>{badge}</Text> : null}
|
||||
<Text style={styles.chevron}>{expanded ? '▲' : '▼'}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
badge: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
chevron: { fontSize: 12, color: colors.textTertiary },
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface SelectRowProps {
|
||||
paramKey: string;
|
||||
value: string;
|
||||
options: readonly string[];
|
||||
onChange: (v: string) => void;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SelectRow({
|
||||
paramKey,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onInfo,
|
||||
}: SelectRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.row}>
|
||||
{options.map(opt => (
|
||||
<TouchableOpacity
|
||||
key={opt}
|
||||
style={[styles.chip, value === opt && styles.chipActive]}
|
||||
onPress={() => onChange(opt)}
|
||||
>
|
||||
<Text style={[styles.chipText, value === opt && styles.chipTextActive]}>
|
||||
{opt}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
row: { flexDirection: 'row', gap: spacing.xs },
|
||||
chip: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 5,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
chipActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
chipTextActive: {
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ModelConfig } from '../../../../store/types';
|
||||
import type { CacheType } from '../types';
|
||||
|
||||
type E = Partial<ModelConfig>;
|
||||
|
||||
export function getLoadingCore1(e: E) {
|
||||
return {
|
||||
nCtx: String(e.n_ctx ?? 2048),
|
||||
nBatch: String(e.n_batch ?? ''),
|
||||
nUbatch: String(e.n_ubatch ?? ''),
|
||||
nThreads: String(e.n_threads ?? ''),
|
||||
nGpuLayers: String(e.n_gpu_layers ?? 0),
|
||||
flashAttn: e.flash_attn ?? false,
|
||||
cacheTypeK: (e.cache_type_k as CacheType) ?? 'f16',
|
||||
cacheTypeV: (e.cache_type_v as CacheType) ?? 'f16',
|
||||
useMlock: e.use_mlock ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLoadingCore2(e: E) {
|
||||
return {
|
||||
useMmap: e.use_mmap ?? true,
|
||||
ropeFreqBase: e.rope_freq_base !== undefined ? String(e.rope_freq_base) : '',
|
||||
ropeFreqScale: e.rope_freq_scale !== undefined ? String(e.rope_freq_scale) : '',
|
||||
ctxShift: e.ctx_shift ?? true,
|
||||
kvUnified: e.kv_unified ?? true,
|
||||
nCpuMoe: e.n_cpu_moe !== undefined ? String(e.n_cpu_moe) : '',
|
||||
cpuMask: e.cpu_mask ?? '',
|
||||
nParallel: String(e.n_parallel ?? 1),
|
||||
systemPrompt: e.systemPrompt ?? 'You are a helpful, concise assistant.',
|
||||
};
|
||||
}
|
||||
|
||||
export function getSampling1(e: E) {
|
||||
return {
|
||||
temperature: String(e.temperature ?? 0.8),
|
||||
topK: String(e.top_k ?? 40),
|
||||
topP: String(e.top_p ?? 0.95),
|
||||
minP: String(e.min_p ?? 0.05),
|
||||
nPredict: String(e.n_predict ?? e.max_new_tokens ?? -1),
|
||||
seed: String(e.seed ?? -1),
|
||||
typicalP: String(e.typical_p ?? 1.0),
|
||||
topNSigma: String(e.top_n_sigma ?? -1.0),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSampling2(e: E) {
|
||||
return {
|
||||
mirostat: String(e.mirostat ?? 0),
|
||||
mirostatTau: String(e.mirostat_tau ?? 5.0),
|
||||
mirostatEta: String(e.mirostat_eta ?? 0.1),
|
||||
xtcProb: String(e.xtc_probability ?? 0.0),
|
||||
xtcThresh: String(e.xtc_threshold ?? 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPenalties1(e: E) {
|
||||
return {
|
||||
penaltyRepeat: String(e.penalty_repeat ?? e.repetition_penalty ?? 1.0),
|
||||
penaltyLastN: String(e.penalty_last_n ?? 64),
|
||||
penaltyFreq: String(e.penalty_freq ?? 0.0),
|
||||
penaltyPresent: String(e.penalty_present ?? 0.0),
|
||||
dryMultiplier: String(e.dry_multiplier ?? 0.0),
|
||||
dryBase: String(e.dry_base ?? 1.75),
|
||||
dryAllowed: String(e.dry_allowed_length ?? 2),
|
||||
dryLastN: String(e.dry_penalty_last_n ?? -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOutputDefaults(e: E) {
|
||||
return {
|
||||
ignoreEos: e.ignore_eos ?? false,
|
||||
nProbs: String(e.n_probs ?? 0),
|
||||
stopStr: (e.stop ?? []).join(', '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../../../store';
|
||||
import type { ModelConfigFormState, CacheType } from '../types';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../../../../navigation';
|
||||
import {
|
||||
getLoadingCore1,
|
||||
getLoadingCore2,
|
||||
getSampling1,
|
||||
getSampling2,
|
||||
getPenalties1,
|
||||
getOutputDefaults,
|
||||
} from './defaults';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ModelConfig'>;
|
||||
|
||||
export function useModelConfigState(
|
||||
modelPath: string,
|
||||
navigation: Nav,
|
||||
modelName: string,
|
||||
): ModelConfigFormState {
|
||||
const configs = useSelector((s: RootState) => s.models.modelConfigs || {});
|
||||
const existing = configs[modelPath] || {};
|
||||
|
||||
const lc1 = getLoadingCore1(existing);
|
||||
const lc2 = getLoadingCore2(existing);
|
||||
const s1 = getSampling1(existing);
|
||||
const s2 = getSampling2(existing);
|
||||
const p1 = getPenalties1(existing);
|
||||
const out = getOutputDefaults(existing);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: modelName });
|
||||
}, [modelName, navigation]);
|
||||
|
||||
const [tooltip, setTooltip] = useState<{ visible: boolean; key: string }>({
|
||||
visible: false,
|
||||
key: '',
|
||||
});
|
||||
const showTooltip = useCallback(
|
||||
(key: string) => setTooltip({ visible: true, key }),
|
||||
[],
|
||||
);
|
||||
const closeTooltip = useCallback(
|
||||
() => setTooltip(t => ({ ...t, visible: false })),
|
||||
[],
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = useState({
|
||||
loading: true,
|
||||
sampling: true,
|
||||
penalties: false,
|
||||
output: false,
|
||||
});
|
||||
const toggle = useCallback(
|
||||
(k: keyof typeof expanded) => setExpanded(p => ({ ...p, [k]: !p[k] })),
|
||||
[],
|
||||
);
|
||||
|
||||
const [systemPrompt, setSystemPrompt] = useState(lc2.systemPrompt);
|
||||
const [nCtx, setNCtx] = useState(lc1.nCtx);
|
||||
const [nBatch, setNBatch] = useState(lc1.nBatch);
|
||||
const [nUbatch, setNUbatch] = useState(lc1.nUbatch);
|
||||
const [nThreads, setNThreads] = useState(lc1.nThreads);
|
||||
const [nGpuLayers, setNGpuLayers] = useState(lc1.nGpuLayers);
|
||||
const [flashAttn, setFlashAttn] = useState(lc1.flashAttn);
|
||||
const [cacheTypeK, setCacheTypeK] = useState<CacheType>(lc1.cacheTypeK);
|
||||
const [cacheTypeV, setCacheTypeV] = useState<CacheType>(lc1.cacheTypeV);
|
||||
const [useMlock, setUseMlock] = useState(lc1.useMlock);
|
||||
const [useMmap, setUseMmap] = useState(lc2.useMmap);
|
||||
const [ropeFreqBase, setRopeFreqBase] = useState(lc2.ropeFreqBase);
|
||||
const [ropeFreqScale, setRopeFreqScale] = useState(lc2.ropeFreqScale);
|
||||
const [ctxShift, setCtxShift] = useState(lc2.ctxShift);
|
||||
const [kvUnified, setKvUnified] = useState(lc2.kvUnified);
|
||||
const [nCpuMoe, setNCpuMoe] = useState(lc2.nCpuMoe);
|
||||
const [cpuMask, setCpuMask] = useState(lc2.cpuMask);
|
||||
const [nParallel, setNParallel] = useState(lc2.nParallel);
|
||||
const [temperature, setTemperature] = useState(s1.temperature);
|
||||
const [topK, setTopK] = useState(s1.topK);
|
||||
const [topP, setTopP] = useState(s1.topP);
|
||||
const [minP, setMinP] = useState(s1.minP);
|
||||
const [nPredict, setNPredict] = useState(s1.nPredict);
|
||||
const [seed, setSeed] = useState(s1.seed);
|
||||
const [typicalP, setTypicalP] = useState(s1.typicalP);
|
||||
const [topNSigma, setTopNSigma] = useState(s1.topNSigma);
|
||||
const [mirostat, setMirostat] = useState(s2.mirostat);
|
||||
const [mirostatTau, setMirostatTau] = useState(s2.mirostatTau);
|
||||
const [mirostatEta, setMirostatEta] = useState(s2.mirostatEta);
|
||||
const [xtcProb, setXtcProb] = useState(s2.xtcProb);
|
||||
const [xtcThresh, setXtcThresh] = useState(s2.xtcThresh);
|
||||
const [penaltyRepeat, setPenaltyRepeat] = useState(p1.penaltyRepeat);
|
||||
const [penaltyLastN, setPenaltyLastN] = useState(p1.penaltyLastN);
|
||||
const [penaltyFreq, setPenaltyFreq] = useState(p1.penaltyFreq);
|
||||
const [penaltyPresent, setPenaltyPresent] = useState(p1.penaltyPresent);
|
||||
const [dryMultiplier, setDryMultiplier] = useState(p1.dryMultiplier);
|
||||
const [dryBase, setDryBase] = useState(p1.dryBase);
|
||||
const [dryAllowed, setDryAllowed] = useState(p1.dryAllowed);
|
||||
const [dryLastN, setDryLastN] = useState(p1.dryLastN);
|
||||
const [ignoreEos, setIgnoreEos] = useState(out.ignoreEos);
|
||||
const [nProbs, setNProbs] = useState(out.nProbs);
|
||||
const [stopStr, setStopStr] = useState(out.stopStr);
|
||||
|
||||
return {
|
||||
systemPrompt, setSystemPrompt,
|
||||
expanded, toggle,
|
||||
tooltip, showTooltip, closeTooltip,
|
||||
nCtx, setNCtx, nBatch, setNBatch,
|
||||
nUbatch, setNUbatch, nThreads, setNThreads,
|
||||
nGpuLayers, setNGpuLayers, flashAttn, setFlashAttn,
|
||||
cacheTypeK, setCacheTypeK, cacheTypeV, setCacheTypeV,
|
||||
useMlock, setUseMlock, useMmap, setUseMmap,
|
||||
ropeFreqBase, setRopeFreqBase, ropeFreqScale, setRopeFreqScale,
|
||||
ctxShift, setCtxShift, kvUnified, setKvUnified,
|
||||
nCpuMoe, setNCpuMoe, cpuMask, setCpuMask,
|
||||
nParallel, setNParallel,
|
||||
temperature, setTemperature,
|
||||
topK, setTopK, topP, setTopP, minP, setMinP,
|
||||
nPredict, setNPredict, seed, setSeed,
|
||||
typicalP, setTypicalP, topNSigma, setTopNSigma,
|
||||
mirostat, setMirostat, mirostatTau, setMirostatTau,
|
||||
mirostatEta, setMirostatEta,
|
||||
xtcProb, setXtcProb, xtcThresh, setXtcThresh,
|
||||
penaltyRepeat, setPenaltyRepeat, penaltyLastN, setPenaltyLastN,
|
||||
penaltyFreq, setPenaltyFreq, penaltyPresent, setPenaltyPresent,
|
||||
dryMultiplier, setDryMultiplier, dryBase, setDryBase,
|
||||
dryAllowed, setDryAllowed, dryLastN, setDryLastN,
|
||||
ignoreEos, setIgnoreEos, nProbs, setNProbs,
|
||||
stopStr, setStopStr,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { ParamInfoEntry } from './types';
|
||||
import { loadingParamInfo } from './loading';
|
||||
import { samplingParamInfo } from './sampling';
|
||||
import { penaltiesParamInfo } from './penalties';
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const PARAM_INFO: Record<string, ParamInfoEntry> = {
|
||||
...loadingParamInfo,
|
||||
...samplingParamInfo,
|
||||
...penaltiesParamInfo,
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const loadingParamInfo: Record<string, ParamInfoEntry> = {
|
||||
systemPrompt: {
|
||||
title: 'Prompt Système',
|
||||
description:
|
||||
"Message de contexte envoyé au modèle avant toute conversation. Définit le rôle, le comportement et les contraintes de l'assistant.",
|
||||
impact:
|
||||
"Influence directement la personnalité et la pertinence des réponses. Un bon prompt système améliore considérablement la qualité globale.",
|
||||
usage:
|
||||
'Ex: "Tu es un expert Python, concis et précis." Sois explicite sur le ton, la langue, et les limites du modèle.',
|
||||
},
|
||||
n_ctx: {
|
||||
title: 'Fenêtre Contextuelle (n_ctx)',
|
||||
description:
|
||||
"Nombre maximum de tokens que le modèle peut « voir » simultanément — historique + prompt + réponse inclus.",
|
||||
impact:
|
||||
"🧠 RAM : chaque doublement de n_ctx ~×2 la mémoire du KV cache. Trop grand → crash OOM. ⚡ Vitesse : légèrement plus lent à grands contextes.",
|
||||
usage:
|
||||
'512–2 048 pour petits appareils. 4 096–8 192 si la RAM le permet. Ne jamais dépasser le contexte max du modèle (visible dans ses infos).',
|
||||
},
|
||||
n_batch: {
|
||||
title: 'Batch Size (n_batch)',
|
||||
description:
|
||||
`Nombre de tokens traités en parallèle lors de l'évaluation initiale du prompt (phase "prefill").`,
|
||||
impact:
|
||||
'⚡ Vitesse : plus grand = traitement du prompt plus rapide. 🧠 RAM : légèrement plus élevée. Impact nul sur la vitesse de génération token-by-token.',
|
||||
usage:
|
||||
'128–512 est idéal sur mobile. Dépasse rarement n_ctx. Valeur par défaut : 512.',
|
||||
},
|
||||
n_ubatch: {
|
||||
title: 'Micro-batch (n_ubatch)',
|
||||
description:
|
||||
'Sous-division interne de n_batch pour les opérations matricielles. Doit être ≤ n_batch.',
|
||||
impact:
|
||||
'🧠 Équilibre vitesse/mémoire des opérations GPU/CPU bas-niveau. Rarement nécessaire de modifier.',
|
||||
usage:
|
||||
'Laisser vide (= n_batch par défaut). Utile pour optimiser finement sur du matériel spécifique.',
|
||||
},
|
||||
n_threads: {
|
||||
title: 'Threads CPU (n_threads)',
|
||||
description: "Nombre de threads CPU alloués à l'inférence.",
|
||||
impact:
|
||||
`⚡ Plus de threads = plus rapide (jusqu'à un plateau). Trop de threads → contention et ralentissement. 🔋 Consommation CPU proportionnelle.`,
|
||||
usage:
|
||||
'Règle : moitié des cœurs physiques (ex : 8 cœurs → 4 threads). 0 = auto-détection. Tester 2, 4, 6 et mesurer.',
|
||||
},
|
||||
n_gpu_layers: {
|
||||
title: 'Couches GPU (n_gpu_layers)',
|
||||
description:
|
||||
'(iOS uniquement) Nombre de couches Transformer offloadées sur le GPU / Neural Engine.',
|
||||
impact:
|
||||
'⚡ Chaque couche sur GPU accélère significativement la génération. 🔋 Légèrement plus de consommation batterie. 🧠 Réduit la RAM CPU utilisée.',
|
||||
usage:
|
||||
'Commencer par 1, monter progressivement. Valeur max = nb total de couches du modèle. 0 = CPU uniquement (Android toujours CPU).',
|
||||
},
|
||||
flash_attn: {
|
||||
title: 'Flash Attention',
|
||||
description:
|
||||
"Algorithme d'attention optimisé qui calcule l'attention par blocs pour réduire drastiquement la mémoire du KV cache.",
|
||||
impact:
|
||||
'🧠 Réduit la VRAM KV de ~30–50%. ⚡ Gain de vitesse notable sur longs contextes. Recommandé avec n_gpu_layers > 0.',
|
||||
usage:
|
||||
"Activer avec GPU. Sur CPU pur, gain/perte variable selon l'appareil — tester les deux.",
|
||||
},
|
||||
cache_type_k: {
|
||||
title: 'Type KV Cache K',
|
||||
description:
|
||||
"Type de données des matrices K (Key) du cache d'attention. Contrôle la précision vs mémoire des clés.",
|
||||
impact:
|
||||
'🧠 f16 : qualité max, RAM max. q8_0 : −50% RAM, perte qualité négligeable. q4_0 : −75% RAM, légère dégradation possible.',
|
||||
usage:
|
||||
'f16 par défaut. Utiliser q8_0 si RAM insuffisante pour un grand contexte. q4_0 seulement pour RAM très contrainte.',
|
||||
},
|
||||
cache_type_v: {
|
||||
title: 'Type KV Cache V',
|
||||
description:
|
||||
"Type de données des matrices V (Value) du cache d'attention. Le cache V est plus sensible à la quantification que K.",
|
||||
impact:
|
||||
'🧠 Même économies mémoire que cache_type_k mais impact qualité légèrement plus fort. Quantifier K avant V.',
|
||||
usage:
|
||||
'Garder f16 si possible. Passer à q8_0 uniquement si cache_type_k est déjà q8_0 et que la RAM reste insuffisante.',
|
||||
},
|
||||
use_mlock: {
|
||||
title: 'Verrouillage RAM (use_mlock)',
|
||||
description:
|
||||
'Verrouille les pages mémoire du modèle en RAM physique pour empêcher le système de les swapper sur disque.',
|
||||
impact:
|
||||
'⚡ Élimine les pics de latence dus au swap. 🧠 Nécessite que le modèle tienne entièrement en RAM — crash si insuffisant.',
|
||||
usage:
|
||||
"Activer si le modèle tient en RAM. Désactiver sur appareils avec peu de RAM pour éviter les erreurs de chargement.",
|
||||
},
|
||||
use_mmap: {
|
||||
title: 'Memory-Map (use_mmap)',
|
||||
description:
|
||||
"Mappe le fichier modèle en mémoire virtuelle sans le copier entièrement en RAM. Le système charge les pages à la demande.",
|
||||
impact:
|
||||
`⚡ Démarrage très rapide. 🧠 Le système peut swapper les pages non utilisées — des accès disque peuvent survenir pendant l'inférence.`,
|
||||
usage:
|
||||
'Activer (défaut). Désactiver uniquement si vous avez assez de RAM et voulez éviter tout accès disque pendant la génération.',
|
||||
},
|
||||
rope_freq_base: {
|
||||
title: 'RoPE Base Frequency',
|
||||
description:
|
||||
"Fréquence de base des embeddings positionnels RoPE. Contrôle la plage de positions que le modèle peut distinguer.",
|
||||
impact:
|
||||
`📐 Augmenter étend la fenêtre contextuelle effective au-delà de la limite d'entraînement. Trop élevé → dégradation qualité.`,
|
||||
usage:
|
||||
'Laisser à 0 (auto). Pour étendre le contexte : new_base ≈ original_base × (new_ctx / train_ctx). Ex : 10 000 → 80 000 pour ×8 contexte.',
|
||||
},
|
||||
rope_freq_scale: {
|
||||
title: 'RoPE Frequency Scale',
|
||||
description:
|
||||
"Facteur de scaling appliqué aux fréquences RoPE. Alternative/complément à rope_freq_base pour l'extension de contexte.",
|
||||
impact:
|
||||
'📐 < 1 compresse les fréquences, permettant un contexte plus long. Ex : 0.5 = contexte ×2. Trop bas → perte de cohérence sur longues distances.',
|
||||
usage:
|
||||
'Laisser à 0 (auto). Utiliser conjointement à rope_freq_base pour un contrôle précis. Rarement nécessaire si le modèle gère déjà un long contexte.',
|
||||
},
|
||||
ctx_shift: {
|
||||
title: 'Context Shifting',
|
||||
description:
|
||||
"Lorsque le contexte est plein, décale automatiquement la fenêtre en supprimant les anciens tokens pour continuer à générer.",
|
||||
impact:
|
||||
'⚡ Conversations potentiellement infinies sans erreur. 📉 Les informations très anciennes sont progressivement perdues.',
|
||||
usage:
|
||||
'Activer pour des conversations longues (défaut recommandé). Désactiver si vous préférez une erreur explicite quand le contexte est saturé.',
|
||||
},
|
||||
kv_unified: {
|
||||
title: 'KV Cache Unifié',
|
||||
description:
|
||||
`Utilise un buffer KV partagé pour toutes les séquences parallèles lors du calcul de l'attention.`,
|
||||
impact:
|
||||
'⚡ Améliore les performances quand n_parallel=1 et que les séquences partagent un long préfixe. Contre-productif avec n_parallel > 1 et préfixes différents.',
|
||||
usage:
|
||||
'Activer (défaut) pour un usage standard à séquence unique. Désactiver si n_parallel > 1 et séquences indépendantes.',
|
||||
},
|
||||
n_cpu_moe: {
|
||||
title: 'Couches MoE sur CPU (n_cpu_moe)',
|
||||
description:
|
||||
'Nombre de couches MoE (Mixture of Experts) maintenues en RAM CPU plutôt que sur GPU. Pertinent pour Mixtral, Qwen-MoE, etc.',
|
||||
impact:
|
||||
`🧠 Réduit l'usage VRAM pour les modèles MoE. ⚡ Ralentit légèrement ces couches (transfert CPU↔GPU). 0 = tout sur GPU.`,
|
||||
usage:
|
||||
'Laisser à 0 sauf si vous utilisez un modèle MoE et que vous rencontrez des OOM GPU. Augmenter progressivement.',
|
||||
},
|
||||
cpu_mask: {
|
||||
title: 'Masque CPU (cpu_mask)',
|
||||
description:
|
||||
"Spécifie quels cœurs CPU utiliser pour l'inférence via un masque d'affinité de cœurs.",
|
||||
impact:
|
||||
'⚡ Permet de dédier les cœurs haute-performance (Big cores) au modèle sur architectures big.LITTLE/DynamIQ (Snapdragon, Apple).',
|
||||
usage:
|
||||
'Format : "0-3" (cœurs 0 à 3) ou "0,2,4,6" (cœurs spécifiques). Vide = tous les cœurs disponibles (défaut).',
|
||||
},
|
||||
n_parallel: {
|
||||
title: 'Séquences Parallèles (n_parallel)',
|
||||
description:
|
||||
'Nombre maximum de séquences traitées en parallèle dans le même contexte (slots parallèles).',
|
||||
impact:
|
||||
'🧠 RAM KV cache × n_parallel. Nécessaire pour le mode parallel.completion(). Inutile pour usage conversationnel standard.',
|
||||
usage:
|
||||
'Garder à 1 (défaut) pour usage standard. Augmenter uniquement si vous utilisez parallel.completion() pour des requêtes concurrentes.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const penaltiesParamInfo: Record<string, ParamInfoEntry> = {
|
||||
penalty_repeat: {
|
||||
title: 'Pénalité de Répétition',
|
||||
description:
|
||||
'Pénalise les tokens déjà apparus dans la fenêtre penalty_last_n en divisant leur probabilité par ce facteur.',
|
||||
impact:
|
||||
'📊 1.0 = aucune pénalité. 1.1–1.3 : réduit les répétitions. > 1.5 : texte incohérent. < 1 : encourage la répétition.',
|
||||
usage:
|
||||
'1.0 pour les conversations (défaut). 1.1–1.3 si le modèle boucle. Ne pas dépasser 1.5. Défaut : 1.0.',
|
||||
},
|
||||
penalty_last_n: {
|
||||
title: 'Fenêtre de Pénalité (penalty_last_n)',
|
||||
description:
|
||||
'Nombre de derniers tokens considérés pour toutes les pénalités de répétition (repeat, freq, present, DRY).',
|
||||
impact:
|
||||
'📊 Petit (32) = pénalise seulement les répétitions récentes. Grand (256) = pénalise sur plus de contexte. 0 = désactivé. -1 = taille contexte.',
|
||||
usage:
|
||||
'64 est un bon défaut. Augmenter si le modèle répète des phrases sur de longues distances. Défaut : 64.',
|
||||
},
|
||||
penalty_freq: {
|
||||
title: 'Pénalité de Fréquence',
|
||||
description:
|
||||
"Réduit la probabilité des tokens proportionnellement à leur fréquence d'apparition dans le contexte récent.",
|
||||
impact:
|
||||
'📊 > 0 = pénalise davantage les tokens très fréquents. Réduit la verbosité et les tics de langage répétitifs. 0 = désactivé.',
|
||||
usage:
|
||||
'0.0–0.2. Défaut : 0.0. Augmenter si le modèle utilise trop souvent les mêmes mots (ex : "Bien sûr !", "Absolument").',
|
||||
},
|
||||
penalty_present: {
|
||||
title: 'Pénalité de Présence',
|
||||
description:
|
||||
'Applique une pénalité fixe (indépendante de la fréquence) à tout token ayant déjà apparu dans le contexte récent.',
|
||||
impact:
|
||||
'📊 > 0 = encourage à utiliser du vocabulaire nouveau. Augmente la diversité lexicale globale du texte.',
|
||||
usage:
|
||||
'0.0–0.2. Défaut : 0.0. Combiner avec penalty_freq pour un contrôle fins des répétitions.',
|
||||
},
|
||||
dry_multiplier: {
|
||||
title: 'Multiplicateur DRY',
|
||||
description:
|
||||
"DRY (Don't Repeat Yourself) : multiplie une pénalité exponentielle pour les séquences de tokens répétées. Très efficace contre les boucles.",
|
||||
impact:
|
||||
'📊 0.0 = désactivé. 0.8–1.5 = pénalise exponentiellement les répétitions de séquences entières. Bien plus fort que penalty_repeat.',
|
||||
usage:
|
||||
'0.8–1.5 si le modèle génère des boucles de phrases. Défaut : 0.0. Activer en priorité si le modèle répète des paragraphes entiers.',
|
||||
},
|
||||
dry_base: {
|
||||
title: 'Base DRY',
|
||||
description:
|
||||
"Base de l'exponentiation DRY. Pénalité = dry_multiplier × dry_base^(longueur_répétition − dry_allowed_length).",
|
||||
impact:
|
||||
'📊 Plus grande = croissance exponentielle plus agressive pour les longues répétitions. Rarement nécessaire de toucher.',
|
||||
usage:
|
||||
'Défaut : 1.75. Modifier uniquement pour un contrôle très fin. Pertinent seulement si dry_multiplier > 0.',
|
||||
},
|
||||
dry_allowed_length: {
|
||||
title: 'Longueur Autorisée DRY',
|
||||
description:
|
||||
"Longueur minimale d'une séquence répétée avant que DRY commence à la pénaliser. Tolère les courtes répétitions.",
|
||||
impact:
|
||||
'📊 1 = pénalise même un seul token répété. 4 = tolère les expressions de 4 tokens ou moins.',
|
||||
usage:
|
||||
'Défaut : 2. Augmenter (ex : 3–5) pour permettre des expressions récurrentes courtes comme "Bien sûr" ou des mots de liaison.',
|
||||
},
|
||||
dry_penalty_last_n: {
|
||||
title: 'Fenêtre DRY (dry_penalty_last_n)',
|
||||
description:
|
||||
"Nombre de tokens scanné pour détecter les séquences répétées dans l'algorithme DRY.",
|
||||
impact:
|
||||
'📊 -1 = tout le contexte (défaut). 0 = désactivé. Plus grand = détecte les répétitions sur de plus longues distances.',
|
||||
usage:
|
||||
'-1 pour scanner tout le contexte. Réduire si les performances sont impactées sur de très longs contextes. Défaut : -1.',
|
||||
},
|
||||
ignore_eos: {
|
||||
title: 'Ignorer EOS',
|
||||
description:
|
||||
"Ignore le token de fin de séquence (End Of Sequence) et force le modèle à continuer de générer au-delà.",
|
||||
impact:
|
||||
'📊 Peut produire un texte plus long mais avec risque de répétitions ou de divagations après le point naturel de fin.',
|
||||
usage:
|
||||
'Désactivé par défaut. Activer uniquement pour des cas spécifiques : génération de code très long, benchmarking, tests de stress. Défaut : false.',
|
||||
},
|
||||
n_probs: {
|
||||
title: 'Probabilités de Tokens (n_probs)',
|
||||
description:
|
||||
'Retourne les N tokens alternatifs les plus probables et leur probabilité pour chaque token généré.',
|
||||
impact:
|
||||
`⚡ Légèrement plus lent, plus de données transmises. Utile pour déboguer le sampling ou visualiser l'incertitude du modèle.`,
|
||||
usage:
|
||||
'0 = désactivé (défaut). 5–10 pour analyse et debug. Ne pas activer en production. Défaut : 0.',
|
||||
},
|
||||
stop: {
|
||||
title: 'Stop Strings',
|
||||
description:
|
||||
"Séquences de texte qui arrêtent immédiatement la génération dès qu'elles apparaissent dans la sortie (non incluses).",
|
||||
impact:
|
||||
`📊 Essentiel pour les modèles instruction qui utilisent des balises de format. Évite que le modèle « joue » le rôle de l'utilisateur.`,
|
||||
usage:
|
||||
`Entrez les chaînes séparées par des virgules. Ex : "User:,<|im_end|>,###,Human:". Le modèle s'arrête au premier match.`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const samplingParamInfo: Record<string, ParamInfoEntry> = {
|
||||
temperature: {
|
||||
title: 'Température',
|
||||
description:
|
||||
"Divise les logits avant le softmax. Contrôle l'aléatoire du texte : faible = prévisible, élevé = créatif.",
|
||||
impact:
|
||||
'📊 0.0 = greedy (token le plus probable à chaque étape). 1.0 = distribution brute du modèle. > 1 = plus chaotique. < 0.5 = très cohérent.',
|
||||
usage:
|
||||
'0.1–0.3 : code, réponses factuelles. 0.5–0.8 : conversations équilibrées. 0.8–1.2 : créativité, fiction. Défaut : 0.8.',
|
||||
},
|
||||
top_k: {
|
||||
title: 'Top-K',
|
||||
description:
|
||||
'Limite le sampling aux K tokens les plus probables. Les tokens en dehors du top-K sont éliminés avant tirage.',
|
||||
impact:
|
||||
'📊 Petit K (ex : 10) = texte cohérent, peu varié. Grand K (ex : 100) = plus de diversité. K=0 = désactivé.',
|
||||
usage:
|
||||
'20–40 pour un équilibre cohérence/variété. 0 pour désactiver et laisser top_p seul travailler. Défaut : 40.',
|
||||
},
|
||||
top_p: {
|
||||
title: 'Top-P (Nucleus Sampling)',
|
||||
description:
|
||||
'Nucleus sampling : conserve le plus petit ensemble de tokens dont la probabilité cumulée atteint le seuil P.',
|
||||
impact:
|
||||
'📊 0.9 = garde les tokens couvrant 90% de la masse de probabilité. Plus bas = plus conservateur. 1.0 = désactivé.',
|
||||
usage:
|
||||
'0.85–0.95 pour un bon équilibre. Complémentaire à temperature. Peut être utilisé seul (top_k=0). Défaut : 0.95.',
|
||||
},
|
||||
min_p: {
|
||||
title: 'Min-P',
|
||||
description:
|
||||
'Filtre les tokens dont la probabilité est inférieure à min_p × (probabilité du token le plus probable).',
|
||||
impact:
|
||||
'📊 Élimine les tokens très improbables de façon adaptative. Complémentaire à top_p — souvent plus robuste. 0.0 = désactivé.',
|
||||
usage:
|
||||
'0.05–0.1 est une bonne plage. Fonctionne bien seul ou combiné à top_p. Peut remplacer top_k. Défaut : 0.05.',
|
||||
},
|
||||
n_predict: {
|
||||
title: 'Max Tokens (n_predict)',
|
||||
description:
|
||||
"Nombre maximum de tokens à générer en réponse. -1 = infini (jusqu'à EOS ou une stop string).",
|
||||
impact:
|
||||
'⚡ Limite la durée de génération. Trop petit = réponses tronquées. -1 peut générer très longtemps sur des modèles verbeux.',
|
||||
usage:
|
||||
'256 pour des réponses courtes. 1 024–2 048 pour des réponses détaillées. -1 pour laisser le modèle finir naturellement. Défaut : -1.',
|
||||
},
|
||||
seed: {
|
||||
title: 'Seed (Graine aléatoire)',
|
||||
description:
|
||||
"Graine du générateur de nombres aléatoires. Fixe la séquence de tokens générés pour un même prompt + paramètres.",
|
||||
impact:
|
||||
'🔁 -1 = aléatoire (résultats différents à chaque appel). Seed fixe = résultats 100% reproductibles. Utile pour le debug.',
|
||||
usage:
|
||||
'Laisser à -1 en production pour des réponses variées. Fixer un entier (ex : 42) pour reproduire une génération exacte. Défaut : -1.',
|
||||
},
|
||||
typical_p: {
|
||||
title: 'Typical-P (Locally Typical Sampling)',
|
||||
description:
|
||||
"Sélectionne les tokens dont l'information est «typique» pour le modèle, en filtrant ceux trop prévisibles ou trop surprenants.",
|
||||
impact:
|
||||
'📊 1.0 = désactivé. 0.9 = filtre les tokens atypiques. Souvent meilleur que top_p pour la génération créative longue.',
|
||||
usage:
|
||||
'0.9–0.95 si activé. Peut remplacer ou compléter top_p. Idéal pour des textes longs et cohérents. Défaut : 1.0 (désactivé).',
|
||||
},
|
||||
top_n_sigma: {
|
||||
title: 'Top-N Sigma',
|
||||
description:
|
||||
'Filtre les logits qui sont à plus de N écarts-types sous le logit maximum (papier académique "Top-nσ: Not All Logits Are You Need").',
|
||||
impact:
|
||||
'📊 Méthode basée sur la distribution statistique des logits. Expérimental. -1.0 = désactivé.',
|
||||
usage:
|
||||
'Valeurs typiques : 1.0–3.0. Tester avec temperature=1.0. Peut améliorer la qualité sur certains modèles. Défaut : -1.0 (désactivé).',
|
||||
},
|
||||
mirostat: {
|
||||
title: 'Mirostat',
|
||||
description:
|
||||
"Algorithme de sampling adaptatif qui contrôle la perplexité (complexité) du texte généré autour d'une valeur cible (tau).",
|
||||
impact:
|
||||
'📊 0 = désactivé. 1 = Mirostat v1. 2 = Mirostat v2 (recommandé). Maintient une perplexité stable sur toute la génération.',
|
||||
usage:
|
||||
'Utiliser 2 (v2) pour un texte à perplexité contrôlée. Désactiver top_k/top_p quand Mirostat est actif. Défaut : 0.',
|
||||
},
|
||||
mirostat_tau: {
|
||||
title: 'Mirostat Tau (τ)',
|
||||
description:
|
||||
'Entropie (perplexité) cible de Mirostat. Contrôle le niveau de « surprise » dans le texte généré.',
|
||||
impact:
|
||||
'📊 Petit τ (ex : 2.0) = texte cohérent, moins surprenant. Grand τ (ex : 8.0) = plus varié et imprévisible.',
|
||||
usage:
|
||||
'Plage utile : 2.0–8.0. Augmenter si le texte est trop monotone. Diminuer si trop chaotique. Pertinent seulement si mirostat ≥ 1. Défaut : 5.0.',
|
||||
},
|
||||
mirostat_eta: {
|
||||
title: 'Mirostat Eta (η)',
|
||||
description:
|
||||
`apprentissage de Mirostat. Vitesse d'adaptation de l`,
|
||||
impact:
|
||||
'📊 Petit η = adaptation lente et stable. Grand η = réaction rapide aux changements. Impact subtil en pratique.',
|
||||
usage:
|
||||
'Plage utile : 0.05–0.2. Défaut : 0.1. Rarement nécessaire de modifier si τ est bien réglé.',
|
||||
},
|
||||
xtc_probability: {
|
||||
title: 'Probabilité XTC',
|
||||
description:
|
||||
"XTC (eXclude Top Choices) : probabilité d'activer le sampler à chaque pas. Quand actif, supprime les tokens trop évidents.",
|
||||
impact:
|
||||
'📊 0.0 = désactivé. 0.1–0.5 = activation probabiliste. Force le modèle à explorer des tokens moins probables → plus de créativité.',
|
||||
usage:
|
||||
'Utiliser 0.1–0.5 pour plus de créativité et moins de formulations répétitives. Défaut : 0.0 (désactivé).',
|
||||
},
|
||||
xtc_threshold: {
|
||||
title: 'Seuil XTC',
|
||||
description:
|
||||
`Seuil de probabilité minimum pour qu'un token soit candidat à la suppression par XTC. Seuls les tokens > seuil sont supprimés.`,
|
||||
impact:
|
||||
'📊 Faible seuil = supprime beaucoup de tokens probables. Seuil > 0.5 désactive effectivement XTC.',
|
||||
usage:
|
||||
'Défaut : 0.1. Augmenter (ex : 0.3) pour être plus sélectif. Pertinent uniquement si xtc_probability > 0.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type ParamInfoEntry = {
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
usage: string;
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
import type { ModelConfigFormState, Agent } from '../types';
|
||||
|
||||
interface AgentChipProps {
|
||||
agent: Agent;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
function AgentChip({ agent, isSelected, onSelect }: AgentChipProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.agentChip, isSelected && styles.agentChipActive]}
|
||||
onPress={() => onSelect(agent.id)}
|
||||
>
|
||||
{agent.avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: agent.avatarUri }}
|
||||
style={styles.agentAvatar}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.agentInitial}>
|
||||
{agent.name[0]?.toUpperCase()}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.agentChipText, isSelected && styles.agentChipTextActive]}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
interface AgentSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function AgentSection({ state }: AgentSectionProps) {
|
||||
const { agents, agentId, setAgentId, usingAgent, activeAgent } = state;
|
||||
const hintText = usingAgent
|
||||
? `Le prompt de l'agent « ${activeAgent?.name} » sera utilisé`
|
||||
: 'Aucun agent — le prompt système ci-dessous sera utilisé';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.groupTitle}>🤖 Agent</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.agentHint}>{hintText}</Text>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.chipScroll}
|
||||
contentContainerStyle={styles.chipScrollContent}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={[styles.agentChip, !usingAgent && styles.agentChipActive]}
|
||||
onPress={() => setAgentId(null)}
|
||||
>
|
||||
<Text style={[styles.agentChipText, !usingAgent && styles.agentChipTextActive]}>
|
||||
Aucun
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{agents.map(agent => (
|
||||
<AgentChip
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={agentId === agent.id}
|
||||
onSelect={setAgentId}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
groupTitle: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
agentHint: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
chipScroll: { marginTop: spacing.sm },
|
||||
chipScrollContent: { gap: spacing.xs },
|
||||
agentChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
agentChipActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
agentChipText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
agentChipTextActive: {
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
agentAvatar: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
marginRight: 5,
|
||||
},
|
||||
agentInitial: {
|
||||
marginRight: 4,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { View, TextInput, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, borderRadius, typography } from '../../../../theme/tokens';
|
||||
import { SectionHeader } from '../components/SectionHeader';
|
||||
import { ParamRow } from '../components/ParamRow';
|
||||
import { NumInput } from '../components/NumInput';
|
||||
import { BoolRow } from '../components/BoolRow';
|
||||
import { SelectRow } from '../components/SelectRow';
|
||||
import { CACHE_TYPES, type ModelConfigFormState, type CacheType } from '../types';
|
||||
|
||||
interface LoadingSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function LoadingSection({ state }: LoadingSectionProps) {
|
||||
const {
|
||||
expanded, toggle, showTooltip,
|
||||
nCtx, setNCtx, nBatch, setNBatch,
|
||||
nUbatch, setNUbatch, nThreads, setNThreads,
|
||||
nGpuLayers, setNGpuLayers,
|
||||
flashAttn, setFlashAttn,
|
||||
cacheTypeK, setCacheTypeK,
|
||||
cacheTypeV, setCacheTypeV,
|
||||
useMlock, setUseMlock, useMmap, setUseMmap,
|
||||
ropeFreqBase, setRopeFreqBase,
|
||||
ropeFreqScale, setRopeFreqScale,
|
||||
ctxShift, setCtxShift, kvUnified, setKvUnified,
|
||||
nCpuMoe, setNCpuMoe, cpuMask, setCpuMask,
|
||||
nParallel, setNParallel,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="🔧 Chargement du Modèle"
|
||||
badge="17 params"
|
||||
expanded={expanded.loading}
|
||||
onToggle={() => toggle('loading')}
|
||||
/>
|
||||
{expanded.loading && (
|
||||
<View style={styles.card}>
|
||||
<ParamRow paramKey="n_ctx" onInfo={showTooltip}>
|
||||
<NumInput value={nCtx} onChange={setNCtx} placeholder="2048" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_batch" onInfo={showTooltip}>
|
||||
<NumInput value={nBatch} onChange={setNBatch} placeholder="512 (défaut)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_ubatch" onInfo={showTooltip}>
|
||||
<NumInput value={nUbatch} onChange={setNUbatch} placeholder="= n_batch (défaut)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_threads" onInfo={showTooltip}>
|
||||
<NumInput value={nThreads} onChange={setNThreads} placeholder="auto (0)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_gpu_layers" onInfo={showTooltip}>
|
||||
<NumInput value={nGpuLayers} onChange={setNGpuLayers} placeholder="0 (iOS seul.)" />
|
||||
</ParamRow>
|
||||
<BoolRow
|
||||
paramKey="flash_attn"
|
||||
value={flashAttn}
|
||||
onChange={setFlashAttn}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<SelectRow
|
||||
paramKey="cache_type_k"
|
||||
value={cacheTypeK}
|
||||
options={CACHE_TYPES}
|
||||
onChange={v => setCacheTypeK(v as CacheType)}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<SelectRow
|
||||
paramKey="cache_type_v"
|
||||
value={cacheTypeV}
|
||||
options={CACHE_TYPES}
|
||||
onChange={v => setCacheTypeV(v as CacheType)}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<BoolRow
|
||||
paramKey="use_mlock"
|
||||
value={useMlock}
|
||||
onChange={setUseMlock}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<BoolRow
|
||||
paramKey="use_mmap"
|
||||
value={useMmap}
|
||||
onChange={setUseMmap}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<ParamRow paramKey="rope_freq_base" onInfo={showTooltip}>
|
||||
<NumInput value={ropeFreqBase} onChange={setRopeFreqBase} placeholder="0 (auto)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="rope_freq_scale" onInfo={showTooltip}>
|
||||
<NumInput value={ropeFreqScale} onChange={setRopeFreqScale} placeholder="0 (auto)" />
|
||||
</ParamRow>
|
||||
<BoolRow
|
||||
paramKey="ctx_shift"
|
||||
value={ctxShift}
|
||||
onChange={setCtxShift}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<BoolRow
|
||||
paramKey="kv_unified"
|
||||
value={kvUnified}
|
||||
onChange={setKvUnified}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<ParamRow paramKey="n_cpu_moe" onInfo={showTooltip}>
|
||||
<NumInput value={nCpuMoe} onChange={setNCpuMoe} placeholder="0 (défaut)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="cpu_mask" onInfo={showTooltip}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={cpuMask}
|
||||
onChangeText={setCpuMask}
|
||||
placeholder='ex: "0-3" ou "0,2,4,6"'
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_parallel" onInfo={showTooltip}>
|
||||
<NumInput value={nParallel} onChange={setNParallel} placeholder="1 (défaut)" />
|
||||
</ParamRow>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { View, TextInput, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, borderRadius, typography } from '../../../../theme/tokens';
|
||||
import { SectionHeader } from '../components/SectionHeader';
|
||||
import { ParamRow } from '../components/ParamRow';
|
||||
import { NumInput } from '../components/NumInput';
|
||||
import { BoolRow } from '../components/BoolRow';
|
||||
import type { ModelConfigFormState } from '../types';
|
||||
|
||||
interface OutputSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function OutputSection({ state }: OutputSectionProps) {
|
||||
const {
|
||||
expanded, toggle, showTooltip,
|
||||
ignoreEos, setIgnoreEos,
|
||||
nProbs, setNProbs,
|
||||
stopStr, setStopStr,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="📤 Sortie"
|
||||
badge="3 params"
|
||||
expanded={expanded.output}
|
||||
onToggle={() => toggle('output')}
|
||||
/>
|
||||
{expanded.output && (
|
||||
<View style={styles.card}>
|
||||
<BoolRow
|
||||
paramKey="ignore_eos"
|
||||
value={ignoreEos}
|
||||
onChange={setIgnoreEos}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<ParamRow paramKey="n_probs" onInfo={showTooltip}>
|
||||
<NumInput value={nProbs} onChange={setNProbs} placeholder="0 (désactivé)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="stop" onInfo={showTooltip}>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={stopStr}
|
||||
onChangeText={setStopStr}
|
||||
placeholder='ex: "User:,<|im_end|>,###"'
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</ParamRow>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, borderRadius } from '../../../../theme/tokens';
|
||||
import { SectionHeader } from '../components/SectionHeader';
|
||||
import { ParamRow } from '../components/ParamRow';
|
||||
import { NumInput } from '../components/NumInput';
|
||||
import type { ModelConfigFormState } from '../types';
|
||||
|
||||
interface PenaltiesSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function PenaltiesSection({ state }: PenaltiesSectionProps) {
|
||||
const {
|
||||
expanded, toggle, showTooltip,
|
||||
penaltyRepeat, setPenaltyRepeat,
|
||||
penaltyLastN, setPenaltyLastN,
|
||||
penaltyFreq, setPenaltyFreq,
|
||||
penaltyPresent, setPenaltyPresent,
|
||||
dryMultiplier, setDryMultiplier,
|
||||
dryBase, setDryBase,
|
||||
dryAllowed, setDryAllowed,
|
||||
dryLastN, setDryLastN,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="🚫 Pénalités de Répétition"
|
||||
badge="8 params"
|
||||
expanded={expanded.penalties}
|
||||
onToggle={() => toggle('penalties')}
|
||||
/>
|
||||
{expanded.penalties && (
|
||||
<View style={styles.card}>
|
||||
<ParamRow paramKey="penalty_repeat" onInfo={showTooltip}>
|
||||
<NumInput
|
||||
value={penaltyRepeat}
|
||||
onChange={setPenaltyRepeat}
|
||||
placeholder="1.0 (désactivé)"
|
||||
/>
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="penalty_last_n" onInfo={showTooltip}>
|
||||
<NumInput value={penaltyLastN} onChange={setPenaltyLastN} placeholder="64" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="penalty_freq" onInfo={showTooltip}>
|
||||
<NumInput
|
||||
value={penaltyFreq}
|
||||
onChange={setPenaltyFreq}
|
||||
placeholder="0.0 (désactivé)"
|
||||
/>
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="penalty_present" onInfo={showTooltip}>
|
||||
<NumInput
|
||||
value={penaltyPresent}
|
||||
onChange={setPenaltyPresent}
|
||||
placeholder="0.0 (désactivé)"
|
||||
/>
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="dry_multiplier" onInfo={showTooltip}>
|
||||
<NumInput
|
||||
value={dryMultiplier}
|
||||
onChange={setDryMultiplier}
|
||||
placeholder="0.0 (désactivé)"
|
||||
/>
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="dry_base" onInfo={showTooltip}>
|
||||
<NumInput value={dryBase} onChange={setDryBase} placeholder="1.75" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="dry_allowed_length" onInfo={showTooltip}>
|
||||
<NumInput value={dryAllowed} onChange={setDryAllowed} placeholder="2" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="dry_penalty_last_n" onInfo={showTooltip}>
|
||||
<NumInput
|
||||
value={dryLastN}
|
||||
onChange={setDryLastN}
|
||||
placeholder="-1 (tout le contexte)"
|
||||
/>
|
||||
</ParamRow>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, borderRadius } from '../../../../theme/tokens';
|
||||
import { SectionHeader } from '../components/SectionHeader';
|
||||
import { ParamRow } from '../components/ParamRow';
|
||||
import { NumInput } from '../components/NumInput';
|
||||
import { SelectRow } from '../components/SelectRow';
|
||||
import type { ModelConfigFormState } from '../types';
|
||||
|
||||
interface SamplingSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function SamplingSection({ state }: SamplingSectionProps) {
|
||||
const {
|
||||
expanded, toggle, showTooltip,
|
||||
temperature, setTemperature,
|
||||
topK, setTopK, topP, setTopP, minP, setMinP,
|
||||
nPredict, setNPredict, seed, setSeed,
|
||||
typicalP, setTypicalP, topNSigma, setTopNSigma,
|
||||
mirostat, setMirostat,
|
||||
mirostatTau, setMirostatTau, mirostatEta, setMirostatEta,
|
||||
xtcProb, setXtcProb, xtcThresh, setXtcThresh,
|
||||
} = state;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="🎲 Sampling"
|
||||
badge="13 params"
|
||||
expanded={expanded.sampling}
|
||||
onToggle={() => toggle('sampling')}
|
||||
/>
|
||||
{expanded.sampling && (
|
||||
<View style={styles.card}>
|
||||
<ParamRow paramKey="temperature" onInfo={showTooltip}>
|
||||
<NumInput value={temperature} onChange={setTemperature} placeholder="0.8" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="top_k" onInfo={showTooltip}>
|
||||
<NumInput value={topK} onChange={setTopK} placeholder="40" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="top_p" onInfo={showTooltip}>
|
||||
<NumInput value={topP} onChange={setTopP} placeholder="0.95" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="min_p" onInfo={showTooltip}>
|
||||
<NumInput value={minP} onChange={setMinP} placeholder="0.05" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="n_predict" onInfo={showTooltip}>
|
||||
<NumInput value={nPredict} onChange={setNPredict} placeholder="-1 (infini)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="seed" onInfo={showTooltip}>
|
||||
<NumInput value={seed} onChange={setSeed} placeholder="-1 (aléatoire)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="typical_p" onInfo={showTooltip}>
|
||||
<NumInput value={typicalP} onChange={setTypicalP} placeholder="1.0 (désactivé)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="top_n_sigma" onInfo={showTooltip}>
|
||||
<NumInput value={topNSigma} onChange={setTopNSigma} placeholder="-1.0 (désactivé)" />
|
||||
</ParamRow>
|
||||
<SelectRow
|
||||
paramKey="mirostat"
|
||||
value={mirostat}
|
||||
options={['0', '1', '2']}
|
||||
onChange={setMirostat}
|
||||
onInfo={showTooltip}
|
||||
/>
|
||||
<ParamRow paramKey="mirostat_tau" onInfo={showTooltip}>
|
||||
<NumInput value={mirostatTau} onChange={setMirostatTau} placeholder="5.0" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="mirostat_eta" onInfo={showTooltip}>
|
||||
<NumInput value={mirostatEta} onChange={setMirostatEta} placeholder="0.1" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="xtc_probability" onInfo={showTooltip}>
|
||||
<NumInput value={xtcProb} onChange={setXtcProb} placeholder="0.0 (désactivé)" />
|
||||
</ParamRow>
|
||||
<ParamRow paramKey="xtc_threshold" onInfo={showTooltip}>
|
||||
<NumInput value={xtcThresh} onChange={setXtcThresh} placeholder="0.1" />
|
||||
</ParamRow>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TextInput, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
import { ParamRow } from '../components/ParamRow';
|
||||
import type { ModelConfigFormState } from '../types';
|
||||
|
||||
interface SystemPromptSectionProps {
|
||||
state: ModelConfigFormState;
|
||||
}
|
||||
|
||||
export default function SystemPromptSection({ state }: SystemPromptSectionProps) {
|
||||
const {
|
||||
usingAgent,
|
||||
activeAgent,
|
||||
systemPrompt,
|
||||
setSystemPrompt,
|
||||
showTooltip,
|
||||
} = state;
|
||||
|
||||
const value = usingAgent
|
||||
? (activeAgent?.systemPrompt ?? '')
|
||||
: systemPrompt;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.groupTitle}>🗒️ Prompt Système</Text>
|
||||
<View style={[styles.card, usingAgent && styles.cardDisabled]}>
|
||||
{usingAgent && (
|
||||
<Text style={styles.disabledHint}>
|
||||
⚠️ Désactivé — le prompt de l'agent est utilisé à la place
|
||||
</Text>
|
||||
)}
|
||||
<ParamRow paramKey="systemPrompt" onInfo={showTooltip}>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
styles.multiline,
|
||||
usingAgent && styles.inputDisabled,
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={usingAgent ? undefined : setSystemPrompt}
|
||||
editable={!usingAgent}
|
||||
multiline
|
||||
numberOfLines={5}
|
||||
textAlignVertical="top"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</ParamRow>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
groupTitle: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
cardDisabled: { opacity: 0.55 },
|
||||
disabledHint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.warning,
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
multiline: {
|
||||
minHeight: 110,
|
||||
textAlignVertical: 'top',
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
inputDisabled: {
|
||||
color: colors.textTertiary,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
});
|
||||
109
app/src/screens/LocalModelsScreen/modelConfig/styles.ts
Normal file
109
app/src/screens/LocalModelsScreen/modelConfig/styles.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../theme/tokens';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.surface,
|
||||
flexGrow: 1,
|
||||
},
|
||||
saveButton: {
|
||||
marginTop: spacing.lg,
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
bottomSpacer: { height: spacing.xxl },
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalCard: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: spacing.xl,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalSection: { marginBottom: spacing.md },
|
||||
modalSectionLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.primary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
modalSectionText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
modalClose: {
|
||||
marginTop: spacing.lg,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
modalCloseText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
loadingOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayStrong,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
loadingCard: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
loadingSubtitle: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
maxWidth: '100%',
|
||||
},
|
||||
loadingPercent: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
activityIndicator: { marginVertical: spacing.lg },
|
||||
progressBarBg: {
|
||||
width: '100%',
|
||||
height: 8,
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 999,
|
||||
},
|
||||
});
|
||||
70
app/src/screens/LocalModelsScreen/modelConfig/types.ts
Normal file
70
app/src/screens/LocalModelsScreen/modelConfig/types.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { Agent } from '../../../store/types';
|
||||
|
||||
export type { Agent };
|
||||
|
||||
export const CACHE_TYPES = [
|
||||
'f16', 'f32', 'q8_0', 'q4_0', 'q4_1', 'iq4_nl', 'q5_0', 'q5_1',
|
||||
] as const;
|
||||
export type CacheType = (typeof CACHE_TYPES)[number];
|
||||
|
||||
export interface ModelConfigFormState {
|
||||
systemPrompt: string;
|
||||
setSystemPrompt: (v: string) => void;
|
||||
|
||||
expanded: {
|
||||
loading: boolean;
|
||||
sampling: boolean;
|
||||
penalties: boolean;
|
||||
output: boolean;
|
||||
};
|
||||
toggle: (k: 'loading' | 'sampling' | 'penalties' | 'output') => void;
|
||||
|
||||
tooltip: { visible: boolean; key: string };
|
||||
showTooltip: (key: string) => void;
|
||||
closeTooltip: () => void;
|
||||
|
||||
nCtx: string; setNCtx: (v: string) => void;
|
||||
nBatch: string; setNBatch: (v: string) => void;
|
||||
nUbatch: string; setNUbatch: (v: string) => void;
|
||||
nThreads: string; setNThreads: (v: string) => void;
|
||||
nGpuLayers: string; setNGpuLayers: (v: string) => void;
|
||||
flashAttn: boolean; setFlashAttn: (v: boolean) => void;
|
||||
cacheTypeK: CacheType; setCacheTypeK: (v: CacheType) => void;
|
||||
cacheTypeV: CacheType; setCacheTypeV: (v: CacheType) => void;
|
||||
useMlock: boolean; setUseMlock: (v: boolean) => void;
|
||||
useMmap: boolean; setUseMmap: (v: boolean) => void;
|
||||
ropeFreqBase: string; setRopeFreqBase: (v: string) => void;
|
||||
ropeFreqScale: string; setRopeFreqScale: (v: string) => void;
|
||||
ctxShift: boolean; setCtxShift: (v: boolean) => void;
|
||||
kvUnified: boolean; setKvUnified: (v: boolean) => void;
|
||||
nCpuMoe: string; setNCpuMoe: (v: string) => void;
|
||||
cpuMask: string; setCpuMask: (v: string) => void;
|
||||
nParallel: string; setNParallel: (v: string) => void;
|
||||
|
||||
temperature: string; setTemperature: (v: string) => void;
|
||||
topK: string; setTopK: (v: string) => void;
|
||||
topP: string; setTopP: (v: string) => void;
|
||||
minP: string; setMinP: (v: string) => void;
|
||||
nPredict: string; setNPredict: (v: string) => void;
|
||||
seed: string; setSeed: (v: string) => void;
|
||||
typicalP: string; setTypicalP: (v: string) => void;
|
||||
topNSigma: string; setTopNSigma: (v: string) => void;
|
||||
mirostat: string; setMirostat: (v: string) => void;
|
||||
mirostatTau: string; setMirostatTau: (v: string) => void;
|
||||
mirostatEta: string; setMirostatEta: (v: string) => void;
|
||||
xtcProb: string; setXtcProb: (v: string) => void;
|
||||
xtcThresh: string; setXtcThresh: (v: string) => void;
|
||||
|
||||
penaltyRepeat: string; setPenaltyRepeat: (v: string) => void;
|
||||
penaltyLastN: string; setPenaltyLastN: (v: string) => void;
|
||||
penaltyFreq: string; setPenaltyFreq: (v: string) => void;
|
||||
penaltyPresent: string; setPenaltyPresent: (v: string) => void;
|
||||
dryMultiplier: string; setDryMultiplier: (v: string) => void;
|
||||
dryBase: string; setDryBase: (v: string) => void;
|
||||
dryAllowed: string; setDryAllowed: (v: string) => void;
|
||||
dryLastN: string; setDryLastN: (v: string) => void;
|
||||
|
||||
ignoreEos: boolean; setIgnoreEos: (v: boolean) => void;
|
||||
nProbs: string; setNProbs: (v: string) => void;
|
||||
stopStr: string; setStopStr: (v: string) => void;
|
||||
}
|
||||
243
app/src/screens/LocalModelsScreen/styles.ts
Normal file
243
app/src/screens/LocalModelsScreen/styles.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
export const createStyles = (colors: {
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
notification: string;
|
||||
onPrimary: string;
|
||||
}) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
directorySection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
directoryDisplay: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
directoryText: {
|
||||
flex: 1,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
changeButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
changeButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
modelsSection: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
modelsHeader: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
},
|
||||
permissionContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
permissionText: {
|
||||
fontSize: typography.sizes.xl,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
permissionSubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.6,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
permissionButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
permissionButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
|
||||
// ─── RAM bar ──────────────────────────────────────────────────────────────
|
||||
ramSection: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
ramSectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
},
|
||||
ramSectionLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '700' as const,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
ramHeaderRight: {
|
||||
fontSize: 11,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
ramBarTrack: {
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.border,
|
||||
flexDirection: 'row' as const,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
ramBarSeg: {
|
||||
height: '100%' as const,
|
||||
},
|
||||
ramLegend: {
|
||||
marginTop: 6,
|
||||
},
|
||||
ramLegendRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
},
|
||||
ramLegendDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
ramLegendText: {
|
||||
fontSize: 11,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
ramWarning: {
|
||||
marginTop: 4,
|
||||
fontSize: 11,
|
||||
color: colors.notification,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
ramHint: {
|
||||
marginTop: 5,
|
||||
fontSize: 11,
|
||||
color: colors.text,
|
||||
opacity: 0.4,
|
||||
fontStyle: 'italic' as const,
|
||||
},
|
||||
ramStrong: {
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
|
||||
// ─── Loading modal ────────────────────────────────────────────────────────
|
||||
loadingOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'center' as const,
|
||||
alignItems: 'center' as const,
|
||||
padding: spacing.xl,
|
||||
},
|
||||
loadingCard: {
|
||||
width: '100%' as const,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.xl,
|
||||
alignItems: 'center' as const,
|
||||
},
|
||||
loadingTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
loadingSubtitle: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
marginBottom: spacing.sm,
|
||||
maxWidth: '100%' as const,
|
||||
},
|
||||
loadingPercent: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
activityIndicator: { marginVertical: spacing.lg },
|
||||
progressBarBg: {
|
||||
width: '100%' as const,
|
||||
height: 8,
|
||||
backgroundColor: colors.border,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden' as const,
|
||||
},
|
||||
progressBarFill: {
|
||||
height: '100%' as const,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 999,
|
||||
},
|
||||
});
|
||||
54
app/src/screens/ModelsScreen.tsx
Normal file
54
app/src/screens/ModelsScreen.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import LocalModelsScreen from './LocalModelsScreen';
|
||||
import HuggingFaceModelsScreen from './HuggingFaceModelsScreen';
|
||||
|
||||
export type ModelsTabParamList = {
|
||||
LocalModels: undefined;
|
||||
HuggingFaceModels: undefined;
|
||||
};
|
||||
|
||||
const Tab = createMaterialTopTabNavigator<ModelsTabParamList>();
|
||||
|
||||
export default function ModelsScreen() {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: isDark ? '#8E8E93' : '#8E8E93',
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
textTransform: 'none',
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
elevation: 0,
|
||||
shadowOpacity: 0,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
tabBarIndicatorStyle: {
|
||||
backgroundColor: colors.primary,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="LocalModels"
|
||||
component={LocalModelsScreen}
|
||||
options={{ title: 'Modèles locaux' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="HuggingFaceModels"
|
||||
component={HuggingFaceModelsScreen}
|
||||
options={{ title: 'HuggingFace' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
119
app/src/screens/SettingsScreen.tsx
Normal file
119
app/src/screens/SettingsScreen.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList, TabParamList } from '../navigation';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
type TColors = {
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
notification: string;
|
||||
onPrimary?: string;
|
||||
};
|
||||
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
function makeStyles(colors: TColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
text: {
|
||||
fontSize: 20,
|
||||
},
|
||||
segmentWrap: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
segmentBar: {
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
segmentItem: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentItemActive: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
segmentText: {
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
},
|
||||
segmentTextActive: {
|
||||
color: colors.onPrimary ?? '#fff',
|
||||
},
|
||||
buttonWrap: {
|
||||
marginTop: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
/* eslint-enable react-native/no-unused-styles */
|
||||
|
||||
type Props = CompositeScreenProps<
|
||||
BottomTabScreenProps<TabParamList, 'Settings'>,
|
||||
NativeStackScreenProps<RootStackParamList>
|
||||
>;
|
||||
|
||||
export default function SettingsScreen({}: Props) {
|
||||
const { mode, setMode, colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
const items: { key: 'light' | 'system' | 'dark' | 'neon'; label: string }[] = [
|
||||
{ key: 'light', label: 'Light' },
|
||||
{ key: 'system', label: 'System' },
|
||||
{ key: 'dark', label: 'Dark' },
|
||||
{ key: 'neon', label: 'Neon' },
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Text style={[styles.text, { color: colors.text }]}>Settings</Text>
|
||||
|
||||
<View style={styles.segmentWrap}>
|
||||
<View style={styles.segmentBar}>
|
||||
{items.map((it) => {
|
||||
const selected = mode === it.key;
|
||||
const onPress = () => setMode(it.key);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={it.key}
|
||||
style={[
|
||||
styles.segmentItem,
|
||||
selected ? styles.segmentItemActive : null,
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.segmentText,
|
||||
selected ? styles.segmentTextActive : null,
|
||||
]}
|
||||
>
|
||||
{it.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
194
app/src/screens/TestScreen.tsx
Normal file
194
app/src/screens/TestScreen.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Button,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { pick, types } from '@react-native-documents/picker';
|
||||
import RNFS from 'react-native-fs';
|
||||
|
||||
import { loadLlamaModelInfo, initLlama } from 'llama.rn';
|
||||
|
||||
|
||||
|
||||
type LlamaContext = {
|
||||
completion: (
|
||||
params: {
|
||||
prompt?: string;
|
||||
n_predict?: number;
|
||||
stop?: string[];
|
||||
} & Record<string, unknown>,
|
||||
cb?: (data: { token?: string }) => void,
|
||||
) => Promise<{ text?: string } & Record<string, unknown>>;
|
||||
};
|
||||
|
||||
export default function TestScreen() {
|
||||
const [modelPath, setModelPath] = useState<string | null>(null);
|
||||
const [prompt, setPrompt] = useState<string>('Hello');
|
||||
const [output, setOutput] = useState<string>('');
|
||||
const [running, setRunning] = useState(false);
|
||||
const contextRef = useRef<LlamaContext | null>(null);
|
||||
|
||||
const pickModel = useCallback(async () => {
|
||||
try {
|
||||
const res = await pick({ type: [types.allFiles] });
|
||||
if (!res || res.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { uri, name } = res[0];
|
||||
let finalPath = uri;
|
||||
|
||||
// Si c'est un content URI Android, on copie vers un chemin accessible
|
||||
if (uri.startsWith('content://')) {
|
||||
const dest = `${RNFS.TemporaryDirectoryPath}/${name ?? 'model.gguf'}`;
|
||||
await RNFS.copyFile(uri, dest);
|
||||
finalPath = dest; // Pas de préfixe file://
|
||||
} else if (uri.startsWith('file://')) {
|
||||
// Enlever le préfixe file:// si présent
|
||||
finalPath = uri.replace('file://', '');
|
||||
}
|
||||
|
||||
setModelPath(finalPath);
|
||||
Alert.alert('Success', `Model path: ${finalPath}`);
|
||||
|
||||
try {
|
||||
const info = await loadLlamaModelInfo(finalPath);
|
||||
Alert.alert('Model info', JSON.stringify(info, null, 2));
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Failed to load model info:', errorMsg, e);
|
||||
Alert.alert('Model Info Error', errorMsg);
|
||||
}
|
||||
} catch (err: Error | unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error('Pick model error:', errorMsg, err);
|
||||
Alert.alert('Error', errorMsg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runInference = useCallback(async () => {
|
||||
if (!modelPath) {
|
||||
Alert.alert('Choose model first');
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setOutput('');
|
||||
try {
|
||||
if (!contextRef.current) {
|
||||
Alert.alert('Loading', `Initializing model: ${modelPath}`);
|
||||
const ctx = await initLlama({
|
||||
model: modelPath,
|
||||
n_ctx: 2048,
|
||||
use_mlock: true,
|
||||
|
||||
});
|
||||
contextRef.current = ctx as LlamaContext;
|
||||
Alert.alert('Success', 'Model loaded!');
|
||||
}
|
||||
|
||||
const ctx = contextRef.current as LlamaContext;
|
||||
const stopWords = ['</s>'];
|
||||
const result = await ctx.completion(
|
||||
{
|
||||
prompt,
|
||||
n_predict: 128,
|
||||
stop: stopWords,
|
||||
},
|
||||
(data) => {
|
||||
const d = data as { token?: string };
|
||||
if (d && d.token) {
|
||||
setOutput((o) => o + d.token);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result?.text) {
|
||||
setOutput((o) => o + result.text);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
console.error('Inference error:', errorMsg, e);
|
||||
Alert.alert('Inference Error', `${errorMsg}\n\nPath: ${modelPath}`);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [modelPath, prompt]);
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.container} keyboardShouldPersistTaps="handled">
|
||||
<Text style={styles.title}>llama.rn test</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Button title="Choose GGUF model" onPress={pickModel} />
|
||||
<Text style={styles.small}>{modelPath ?? 'No model selected'}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<TextInput
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="Enter prompt"
|
||||
multiline
|
||||
style={styles.input}
|
||||
/>
|
||||
<Button title="Run inference" onPress={runInference} disabled={running} />
|
||||
{running && <ActivityIndicator style={styles.activityIndicator} />}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.subtitle}>Output</Text>
|
||||
<Text style={styles.output}>{output}</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const COLORS = {
|
||||
textSecondary: '#666',
|
||||
border: '#ccc',
|
||||
background: '#fafafa',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
},
|
||||
title: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
small: {
|
||||
marginTop: 8,
|
||||
color: COLORS.textSecondary,
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
subtitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
output: {
|
||||
minHeight: 120,
|
||||
backgroundColor: COLORS.background,
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
},
|
||||
activityIndicator: {
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
69
app/src/store/agentsSlice.ts
Normal file
69
app/src/store/agentsSlice.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Agent, AgentsState } from './types';
|
||||
|
||||
const STORAGE_KEY = '@agents_v1';
|
||||
|
||||
// ─── Thunks ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const loadAgents = createAsyncThunk('agents/load', async () => {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {return [] as Agent[]};
|
||||
try {
|
||||
return JSON.parse(raw) as Agent[];
|
||||
} catch {
|
||||
return [] as Agent[];
|
||||
}
|
||||
});
|
||||
|
||||
export const saveAgent = createAsyncThunk(
|
||||
'agents/save',
|
||||
async (agent: Agent, { getState }) => {
|
||||
const state = (getState() as { agents: AgentsState }).agents;
|
||||
const idx = state.agents.findIndex(a => a.id === agent.id);
|
||||
const updated =
|
||||
idx !== -1
|
||||
? state.agents.map(a => (a.id === agent.id ? agent : a))
|
||||
: [...state.agents, agent];
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return agent;
|
||||
},
|
||||
);
|
||||
|
||||
export const deleteAgent = createAsyncThunk(
|
||||
'agents/delete',
|
||||
async (id: string, { getState }) => {
|
||||
const state = (getState() as { agents: AgentsState }).agents;
|
||||
const updated = state.agents.filter(a => a.id !== id);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Slice ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const initialState: AgentsState = { agents: [] };
|
||||
|
||||
const agentsSlice = createSlice({
|
||||
name: 'agents',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(loadAgents.fulfilled, (state, action) => {
|
||||
state.agents = action.payload;
|
||||
});
|
||||
builder.addCase(saveAgent.fulfilled, (state, action) => {
|
||||
const idx = state.agents.findIndex(a => a.id === action.payload.id);
|
||||
if (idx !== -1) {
|
||||
state.agents[idx] = action.payload;
|
||||
} else {
|
||||
state.agents.push(action.payload);
|
||||
}
|
||||
});
|
||||
builder.addCase(deleteAgent.fulfilled, (state, action) => {
|
||||
state.agents = state.agents.filter(a => a.id !== action.payload);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default agentsSlice.reducer;
|
||||
229
app/src/store/chatSlice.ts
Normal file
229
app/src/store/chatSlice.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { ChatState, Message, Conversation } from './chatTypes';
|
||||
import type { PersistedChatState } from './persistence';
|
||||
import { generateResponse, clearChat } from './chatThunks';
|
||||
|
||||
// ─── Factory ────────────────────────────────────────────────────────────────
|
||||
const makeConversation = (): Conversation => ({
|
||||
id: `conv-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
title: 'Nouveau chat',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
modelPath: null,
|
||||
activeAgentId: null,
|
||||
});
|
||||
|
||||
const defaultConv = makeConversation();
|
||||
|
||||
const initialState: ChatState = {
|
||||
conversations: [defaultConv],
|
||||
activeConversationId: defaultConv.id,
|
||||
selectedModel: null,
|
||||
isInferring: false,
|
||||
error: null,
|
||||
temperature: 0.7,
|
||||
maxTokens: 512,
|
||||
};
|
||||
|
||||
// ─── Helper ─────────────────────────────────────────────────────────────────
|
||||
function getActive(state: ChatState): Conversation | undefined {
|
||||
return state.conversations.find(c => c.id === state.activeConversationId);
|
||||
}
|
||||
|
||||
const chatSlice = createSlice({
|
||||
name: 'chat',
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedModel: (state, action: PayloadAction<string | null>) => {
|
||||
state.selectedModel = action.payload;
|
||||
},
|
||||
|
||||
// ── Conversation management ─────────────────────────────────────────────
|
||||
createConversation: (state) => {
|
||||
const conv = makeConversation();
|
||||
state.conversations.push(conv);
|
||||
state.activeConversationId = conv.id;
|
||||
},
|
||||
switchConversation: (state, action: PayloadAction<string>) => {
|
||||
if (state.conversations.some(c => c.id === action.payload)) {
|
||||
state.activeConversationId = action.payload;
|
||||
}
|
||||
},
|
||||
deleteConversation: (state, action: PayloadAction<string>) => {
|
||||
const idx = state.conversations.findIndex(c => c.id === action.payload);
|
||||
if (idx === -1) {return;}
|
||||
state.conversations.splice(idx, 1);
|
||||
// If we deleted the active one, switch to most recent remaining
|
||||
if (state.activeConversationId === action.payload) {
|
||||
if (state.conversations.length === 0) {
|
||||
const newConv = makeConversation();
|
||||
state.conversations.push(newConv);
|
||||
state.activeConversationId = newConv.id;
|
||||
} else {
|
||||
const sorted = [...state.conversations]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
state.activeConversationId = sorted[0].id;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Messages (target active conversation) ───────────────────────────────
|
||||
addMessage: (state, action: PayloadAction<Message>) => {
|
||||
const conv = getActive(state);
|
||||
if (!conv) {
|
||||
return;
|
||||
};
|
||||
conv.messages.push(action.payload);
|
||||
conv.updatedAt = Date.now();
|
||||
// Auto-title from first user message
|
||||
if (conv.title === 'Nouveau chat' && action.payload.role === 'user') {
|
||||
const raw = action.payload.content.trim();
|
||||
conv.title = raw.length > 45 ? `${raw.slice(0, 45)}…` : raw;
|
||||
}
|
||||
},
|
||||
setMessageMeta: (
|
||||
state,
|
||||
action: PayloadAction<{ id: string; meta: Record<string, unknown> }>
|
||||
) => {
|
||||
const { id, meta } = action.payload;
|
||||
const conv = getActive(state);
|
||||
if (!conv) { return; }
|
||||
const msg = conv.messages.find(m => m.id === id);
|
||||
if (msg) {
|
||||
msg.metadata = { ...(msg.metadata || {}), ...meta };
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Replace the content of a specific assistant message with the final
|
||||
* cleaned content (after stop-sequence stripping and think-tag removal).
|
||||
* Called by chatThunks once completion resolves to swap the dirty
|
||||
* streaming accumulation for the clean result.text from llama.cpp.
|
||||
*/
|
||||
finalizeAssistantMessage: (
|
||||
state,
|
||||
action: PayloadAction<{ id: string; content: string }>
|
||||
) => {
|
||||
const { id, content } = action.payload;
|
||||
const conv = getActive(state);
|
||||
if (!conv) { return; }
|
||||
const msg = conv.messages.find(m => m.id === id);
|
||||
if (msg && msg.role === 'assistant') {
|
||||
msg.content = content;
|
||||
conv.updatedAt = Date.now();
|
||||
}
|
||||
},
|
||||
updateLastMessage: (state, action: PayloadAction<string>) => {
|
||||
const conv = getActive(state);
|
||||
if (!conv || conv.messages.length === 0) {return;}
|
||||
const lastMsg = conv.messages[conv.messages.length - 1];
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content += action.payload;
|
||||
conv.updatedAt = Date.now();
|
||||
}
|
||||
},
|
||||
startAssistantMessage: (
|
||||
state,
|
||||
action: PayloadAction<string | undefined>,
|
||||
) => {
|
||||
const conv = getActive(state);
|
||||
if (!conv) {return;}
|
||||
conv.messages.push({
|
||||
id: action.payload ?? `msg-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
clearMessages: (state) => {
|
||||
const conv = getActive(state);
|
||||
if (!conv) {return;}
|
||||
conv.messages = [];
|
||||
conv.title = 'Nouveau chat';
|
||||
conv.updatedAt = Date.now();
|
||||
},
|
||||
setActiveAgent: (state, action: PayloadAction<string | null>) => {
|
||||
const conv = getActive(state);
|
||||
if (conv) { conv.activeAgentId = action.payload; }
|
||||
},
|
||||
setTemperature: (state, action: PayloadAction<number>) => {
|
||||
state.temperature = action.payload;
|
||||
},
|
||||
setMaxTokens: (state, action: PayloadAction<number>) => {
|
||||
state.maxTokens = action.payload;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
/**
|
||||
* Merge persisted chat state back into the store at app startup.
|
||||
* Cleans up any empty assistant messages from interrupted sessions.
|
||||
*/
|
||||
rehydrateChat: (_state, action: PayloadAction<PersistedChatState>) => {
|
||||
const persisted = action.payload;
|
||||
const cleanedConvs = persisted.conversations.map(conv => ({
|
||||
...conv,
|
||||
messages: conv.messages.filter(
|
||||
m => !(m.role === 'assistant' && !m.content),
|
||||
),
|
||||
}));
|
||||
return {
|
||||
...persisted,
|
||||
conversations: cleanedConvs,
|
||||
isInferring: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(generateResponse.pending, (state) => {
|
||||
state.isInferring = true;
|
||||
state.error = null;
|
||||
});
|
||||
builder.addCase(generateResponse.fulfilled, (state) => {
|
||||
state.isInferring = false;
|
||||
});
|
||||
builder.addCase(generateResponse.rejected, (state, action) => {
|
||||
state.isInferring = false;
|
||||
state.error = action.payload as string;
|
||||
const conv = getActive(state);
|
||||
if (conv && conv.messages.length > 0) {
|
||||
const lastMsg = conv.messages[conv.messages.length - 1];
|
||||
if (lastMsg.role === 'assistant' && !lastMsg.content) {
|
||||
conv.messages.pop();
|
||||
}
|
||||
}
|
||||
});
|
||||
builder.addCase(clearChat.fulfilled, (state) => {
|
||||
const conv = getActive(state);
|
||||
if (conv) {
|
||||
conv.messages = [];
|
||||
conv.title = 'Nouveau chat';
|
||||
conv.updatedAt = Date.now();
|
||||
}
|
||||
state.error = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setSelectedModel,
|
||||
createConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
addMessage,
|
||||
setMessageMeta,
|
||||
finalizeAssistantMessage,
|
||||
updateLastMessage,
|
||||
startAssistantMessage,
|
||||
clearMessages,
|
||||
setActiveAgent,
|
||||
setTemperature,
|
||||
setMaxTokens,
|
||||
clearError,
|
||||
rehydrateChat,
|
||||
} = chatSlice.actions;
|
||||
|
||||
export * from './chatTypes';
|
||||
export * from './chatThunks';
|
||||
export default chatSlice.reducer;
|
||||
200
app/src/store/chatThunks.ts
Normal file
200
app/src/store/chatThunks.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { initLlama } from 'llama.rn';
|
||||
import type { ModelConfig } from './types';
|
||||
import type { RootState } from '.';
|
||||
import {
|
||||
type LlamaContext,
|
||||
type InferenceParams,
|
||||
type CompletionResult,
|
||||
modelStopCache,
|
||||
buildModelStopTokens,
|
||||
cleanGeneratedText,
|
||||
buildOAIMessages,
|
||||
resolveActiveStops,
|
||||
resolveSystemPrompt,
|
||||
} from './chatThunksHelpers';
|
||||
import { buildInitParams, buildSamplingParams } from './chatThunksSampling';
|
||||
|
||||
let llamaContext: LlamaContext | null = null;
|
||||
let currentModelPath: string | null = null;
|
||||
let shouldStop = false;
|
||||
|
||||
export const loadModel = createAsyncThunk(
|
||||
'chat/loadModel',
|
||||
async (
|
||||
{ modelPath, cfg }: { modelPath: string; cfg: ModelConfig },
|
||||
{ rejectWithValue, dispatch },
|
||||
) => {
|
||||
try {
|
||||
dispatch({ type: 'models/setIsLoadingModel', payload: true });
|
||||
dispatch({ type: 'models/setLoadModelProgress', payload: 0 });
|
||||
if (llamaContext) {
|
||||
await llamaContext.release();
|
||||
llamaContext = null;
|
||||
currentModelPath = null;
|
||||
}
|
||||
llamaContext = (await initLlama(
|
||||
buildInitParams(modelPath, cfg),
|
||||
(progress: number) => {
|
||||
dispatch({ type: 'models/setLoadModelProgress', payload: Math.round(progress) });
|
||||
},
|
||||
)) as unknown as LlamaContext;
|
||||
currentModelPath = modelPath;
|
||||
modelStopCache[modelPath] = await buildModelStopTokens(llamaContext, cfg.stop ?? []);
|
||||
dispatch({
|
||||
type: 'models/setLlamaContextInfo',
|
||||
payload: {
|
||||
systemInfo: llamaContext.systemInfo ?? '',
|
||||
gpu: llamaContext.gpu ?? false,
|
||||
devices: llamaContext.devices,
|
||||
reasonNoGPU: llamaContext.reasonNoGPU ?? '',
|
||||
androidLib: llamaContext.androidLib,
|
||||
},
|
||||
});
|
||||
dispatch({ type: 'models/setIsLoadingModel', payload: false });
|
||||
dispatch({ type: 'models/setLoadModelProgress', payload: 100 });
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch({ type: 'models/setIsLoadingModel', payload: false });
|
||||
return rejectWithValue((error as Error).message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const generateResponse = createAsyncThunk(
|
||||
'chat/generateResponse',
|
||||
async (params: InferenceParams, { rejectWithValue, dispatch, getState }) => {
|
||||
try {
|
||||
shouldStop = false;
|
||||
const storeState = getState() as RootState;
|
||||
const cfgs = storeState.models.modelConfigs || {};
|
||||
const cfg = cfgs[params.modelPath] || {};
|
||||
const effectiveCfg: ModelConfig = { ...cfg, ...(params.overrideConfig || {}) };
|
||||
|
||||
if (!llamaContext || currentModelPath !== params.modelPath) {
|
||||
if (llamaContext) { await llamaContext.release(); }
|
||||
llamaContext = (await initLlama(
|
||||
buildInitParams(params.modelPath, cfg),
|
||||
)) as unknown as LlamaContext;
|
||||
currentModelPath = params.modelPath;
|
||||
modelStopCache[params.modelPath] = await buildModelStopTokens(
|
||||
llamaContext, cfg.stop ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
const agentsState = (getState() as RootState).agents;
|
||||
const systemPrompt = resolveSystemPrompt(
|
||||
effectiveCfg.systemPrompt,
|
||||
params.activeAgentId,
|
||||
agentsState?.agents ?? [],
|
||||
);
|
||||
|
||||
const oaiMessages = buildOAIMessages(params.messages, systemPrompt);
|
||||
const activeStops = resolveActiveStops(params.modelPath, effectiveCfg.stop);
|
||||
const samplingParams = buildSamplingParams(
|
||||
oaiMessages, activeStops, effectiveCfg, params.maxTokens, params.temperature,
|
||||
);
|
||||
|
||||
const promptForMeta = `${oaiMessages.map(m => `${m.role}: ${m.content}`).join('\n\n')}\n\nassistant:`;
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: {
|
||||
id: params.assistantMessageId,
|
||||
meta: { modelPath: params.modelPath, config: effectiveCfg, prompt: promptForMeta },
|
||||
},
|
||||
});
|
||||
|
||||
let rawStreamContent = '';
|
||||
let rawStreamReasoning = '';
|
||||
let liveTokenCount = 0;
|
||||
const generationStart = Date.now();
|
||||
let lastRateDispatch = 0;
|
||||
|
||||
const result: CompletionResult = await llamaContext.completion(
|
||||
samplingParams,
|
||||
(data) => {
|
||||
if (shouldStop) { return; }
|
||||
const token = data?.token ?? '';
|
||||
if (token) {
|
||||
rawStreamContent += token;
|
||||
liveTokenCount += 1;
|
||||
const now = Date.now();
|
||||
if (liveTokenCount % 5 === 0 && now - lastRateDispatch > 300) {
|
||||
lastRateDispatch = now;
|
||||
const elapsed = (now - generationStart) / 1000;
|
||||
if (elapsed > 0) {
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: {
|
||||
id: params.assistantMessageId,
|
||||
meta: { liveRate: Math.round((liveTokenCount / elapsed) * 10) / 10 },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// data.token is always the incremental raw piece — safe to append.
|
||||
// data.content is cumulative parsed text (think-tags stripped) so
|
||||
// we do NOT use it here; the final clean version is applied via
|
||||
// finalizeAssistantMessage after completion.
|
||||
if (params.onToken) { params.onToken(token); }
|
||||
}
|
||||
if (data?.reasoning_content) { rawStreamReasoning += data.reasoning_content; }
|
||||
},
|
||||
);
|
||||
|
||||
const elapsed = (Date.now() - generationStart) / 1000;
|
||||
dispatch({
|
||||
type: 'chat/setMessageMeta',
|
||||
payload: {
|
||||
id: params.assistantMessageId,
|
||||
meta: {
|
||||
rawContent: rawStreamContent,
|
||||
reasoningContent: result.reasoning_content || rawStreamReasoning || null,
|
||||
stoppedWord: result.stopped_word ?? null,
|
||||
stoppingWord: result.stopping_word ?? null,
|
||||
stoppedEos: result.stopped_eos ?? false,
|
||||
stoppedLimit: result.stopped_limit ?? false,
|
||||
interrupted: shouldStop || (result.interrupted ?? false),
|
||||
truncated: result.truncated ?? false,
|
||||
liveRate: (result.timings?.predicted_per_second as number | undefined)
|
||||
?? (liveTokenCount > 0 ? Math.round((liveTokenCount / elapsed) * 10) / 10 : null),
|
||||
...(result.timings ? { timings: result.timings } : {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rawFinal = shouldStop
|
||||
? rawStreamContent
|
||||
: (result.content ?? result.text ?? rawStreamContent);
|
||||
const cleanContent = cleanGeneratedText(rawFinal, activeStops);
|
||||
dispatch({
|
||||
type: 'chat/finalizeAssistantMessage',
|
||||
payload: { id: params.assistantMessageId, content: cleanContent },
|
||||
});
|
||||
return { role: 'assistant' as const, content: cleanContent, timestamp: Date.now() };
|
||||
} catch (error) {
|
||||
return rejectWithValue((error as Error).message);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const stopGeneration = createAsyncThunk(
|
||||
'chat/stopGeneration',
|
||||
async () => {
|
||||
shouldStop = true;
|
||||
if (llamaContext) { await llamaContext.stopCompletion(); }
|
||||
},
|
||||
);
|
||||
|
||||
export const releaseModel = createAsyncThunk(
|
||||
'chat/releaseModel',
|
||||
async () => {
|
||||
if (llamaContext) {
|
||||
await llamaContext.release();
|
||||
llamaContext = null;
|
||||
currentModelPath = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const clearChat = createAsyncThunk('chat/clearChat', async () => null);
|
||||
167
app/src/store/chatThunksHelpers.ts
Normal file
167
app/src/store/chatThunksHelpers.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { Message } from './chatTypes';
|
||||
import type { ModelConfig, Agent } from './types';
|
||||
|
||||
// ─── Completion / context types ───────────────────────────────────────────────
|
||||
|
||||
export type CompletionResult = {
|
||||
text: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
stopped_eos?: boolean;
|
||||
stopped_word?: string;
|
||||
stopping_word?: string;
|
||||
stopped_limit?: number;
|
||||
interrupted?: boolean;
|
||||
truncated?: boolean;
|
||||
timings?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type OAIMessage = { role: string; content: string };
|
||||
|
||||
export type LlamaContext = {
|
||||
id: number;
|
||||
systemInfo: string;
|
||||
gpu: boolean;
|
||||
devices?: string[];
|
||||
reasonNoGPU: string;
|
||||
androidLib?: string;
|
||||
model?: {
|
||||
metadata?: unknown;
|
||||
chatTemplates?: { jinja?: { default?: boolean } };
|
||||
};
|
||||
completion: (
|
||||
params: {
|
||||
prompt?: string;
|
||||
messages?: OAIMessage[];
|
||||
stop?: string[];
|
||||
n_predict?: number;
|
||||
reasoning_format?: string;
|
||||
} & Record<string, unknown>,
|
||||
cb?: (data: {
|
||||
token?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
}) => void,
|
||||
) => Promise<CompletionResult>;
|
||||
detokenize: (tokens: number[]) => Promise<string>;
|
||||
stopCompletion: () => Promise<void>;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
export interface InferenceParams {
|
||||
modelPath: string;
|
||||
messages: Message[];
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
assistantMessageId: string;
|
||||
activeAgentId?: string | null;
|
||||
onToken?: (token: string) => void;
|
||||
overrideConfig?: Partial<ModelConfig>;
|
||||
}
|
||||
|
||||
// ─── Stop token helpers ───────────────────────────────────────────────────────
|
||||
|
||||
// Known stop strings scanned against the model's Jinja chat template (PocketPal).
|
||||
const TEMPLATE_STOP_CANDIDATES = [
|
||||
'</s>', '<|eot_id|>', '<|end_of_text|>', '<|im_end|>', '<|EOT|>',
|
||||
'<|END_OF_TURN_TOKEN|>', '<|end_of_turn|>', '<end_of_turn>',
|
||||
'<|endoftext|>', '<|return|>', 'User:', '\n\nUser:',
|
||||
];
|
||||
|
||||
/** Per-model stop list (populated after loadModel). */
|
||||
export const modelStopCache: Record<string, string[]> = {};
|
||||
|
||||
/** Build stop list: EOS token from metadata + template scan + user stops. */
|
||||
export async function buildModelStopTokens(
|
||||
ctx: LlamaContext,
|
||||
userStops: string[],
|
||||
): Promise<string[]> {
|
||||
const stops = new Set<string>(userStops);
|
||||
try {
|
||||
const meta = (ctx.model?.metadata ?? {}) as Record<string, unknown>;
|
||||
const eosId = Number(meta['tokenizer.ggml.eos_token_id']);
|
||||
if (!isNaN(eosId)) {
|
||||
const eos = await ctx.detokenize([eosId]);
|
||||
if (eos) { stops.add(eos); }
|
||||
}
|
||||
const tpl = meta['tokenizer.chat_template'];
|
||||
const templates: string[] = typeof tpl === 'string' ? [tpl] : [];
|
||||
for (const t of templates) {
|
||||
for (const c of TEMPLATE_STOP_CANDIDATES) {
|
||||
if (t.includes(c)) { stops.add(c); }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[chatThunks] buildModelStopTokens error:', e);
|
||||
}
|
||||
stops.add('</s>');
|
||||
return Array.from(stops).filter(Boolean);
|
||||
}
|
||||
|
||||
// ─── Text cleanup ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Remove think blocks and any stop word that leaked onto the tail. */
|
||||
export function cleanGeneratedText(raw: string, stopSeqs: string[]): string {
|
||||
let out = raw
|
||||
.replace(/<think>[\s\S]*?<\/think>/gi, '')
|
||||
.replace(/<thought>[\s\S]*?<\/thought>/gi, '')
|
||||
.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '')
|
||||
.trim();
|
||||
for (const seq of stopSeqs) {
|
||||
while (out.endsWith(seq)) {
|
||||
out = out.slice(0, -seq.length).trimEnd();
|
||||
}
|
||||
for (let i = seq.length - 1; i > 0; i--) {
|
||||
const partial = seq.slice(0, i);
|
||||
if (out.endsWith(partial)) {
|
||||
out = out.slice(0, -partial.length).trimEnd();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// ─── OAI messages + stop resolvers ───────────────────────────────────────────
|
||||
|
||||
/** Build the OAI messages array that llama.rn's completion() expects. */
|
||||
export function buildOAIMessages(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
): OAIMessage[] {
|
||||
const result: OAIMessage[] = [];
|
||||
if (systemPrompt) { result.push({ role: 'system', content: systemPrompt }); }
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'system') { continue; }
|
||||
result.push({ role: msg.role, content: msg.content });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Return the merged stop list: user config > cache > hard fallback. */
|
||||
export function resolveActiveStops(
|
||||
modelPath: string,
|
||||
userStops?: string[],
|
||||
): string[] {
|
||||
const cached = modelStopCache[modelPath] ?? [];
|
||||
if (userStops?.length) {
|
||||
return Array.from(new Set([...userStops, ...cached]));
|
||||
}
|
||||
return cached.length ? cached : ['</s>', '<|im_end|>', '<|eot_id|>', '<|endoftext|>'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the effective system prompt: inject the selected agent's name and
|
||||
* system prompt in front of the model-level base prompt (if any).
|
||||
*/
|
||||
export function resolveSystemPrompt(
|
||||
basePrompt: string | undefined,
|
||||
activeAgentId: string | null | undefined,
|
||||
agents: Agent[],
|
||||
): string | undefined {
|
||||
if (!activeAgentId) { return basePrompt; }
|
||||
const agent = agents.find(a => a.id === activeAgentId);
|
||||
if (!agent) { return basePrompt; }
|
||||
const agentBlock = `[Agent: ${agent.name}]\n${agent.systemPrompt}`;
|
||||
return basePrompt ? `${agentBlock}\n\n${basePrompt}` : agentBlock;
|
||||
}
|
||||
65
app/src/store/chatThunksSampling.ts
Normal file
65
app/src/store/chatThunksSampling.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Sampling & initLlama parameter builders — extracted for max-lines compliance.
|
||||
import type { ModelConfig } from './types';
|
||||
import type { OAIMessage } from './chatThunksHelpers';
|
||||
|
||||
/** Map a ModelConfig + path into the param object expected by initLlama. */
|
||||
export function buildInitParams(modelPath: string, cfg: ModelConfig) {
|
||||
return {
|
||||
model: modelPath,
|
||||
n_ctx: cfg.n_ctx ?? 2048,
|
||||
n_batch: cfg.n_batch,
|
||||
n_ubatch: cfg.n_ubatch,
|
||||
n_threads: cfg.n_threads,
|
||||
n_gpu_layers: cfg.n_gpu_layers,
|
||||
flash_attn: cfg.flash_attn,
|
||||
cache_type_k: cfg.cache_type_k,
|
||||
cache_type_v: cfg.cache_type_v,
|
||||
use_mlock: cfg.use_mlock ?? false,
|
||||
use_mmap: cfg.use_mmap ?? true,
|
||||
rope_freq_base: cfg.rope_freq_base,
|
||||
rope_freq_scale: cfg.rope_freq_scale,
|
||||
ctx_shift: cfg.ctx_shift,
|
||||
kv_unified: cfg.kv_unified,
|
||||
n_cpu_moe: cfg.n_cpu_moe,
|
||||
cpu_mask: cfg.cpu_mask,
|
||||
n_parallel: cfg.n_parallel,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the full completion() params object (sampling + stop + messages). */
|
||||
export function buildSamplingParams(
|
||||
oaiMessages: OAIMessage[],
|
||||
activeStops: string[],
|
||||
cfg: ModelConfig,
|
||||
fallbackMaxTokens: number,
|
||||
fallbackTemp: number,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
messages: oaiMessages,
|
||||
n_predict: cfg.n_predict ?? cfg.max_new_tokens ?? fallbackMaxTokens,
|
||||
temperature: cfg.temperature ?? fallbackTemp,
|
||||
top_k: cfg.top_k,
|
||||
top_p: cfg.top_p,
|
||||
min_p: cfg.min_p,
|
||||
seed: cfg.seed,
|
||||
typical_p: cfg.typical_p,
|
||||
top_n_sigma: cfg.top_n_sigma,
|
||||
mirostat: cfg.mirostat,
|
||||
mirostat_tau: cfg.mirostat_tau,
|
||||
mirostat_eta: cfg.mirostat_eta,
|
||||
xtc_probability: cfg.xtc_probability,
|
||||
xtc_threshold: cfg.xtc_threshold,
|
||||
penalty_repeat: cfg.penalty_repeat ?? cfg.repetition_penalty,
|
||||
penalty_last_n: cfg.penalty_last_n,
|
||||
penalty_freq: cfg.penalty_freq,
|
||||
penalty_present: cfg.penalty_present,
|
||||
dry_multiplier: cfg.dry_multiplier,
|
||||
dry_base: cfg.dry_base,
|
||||
dry_allowed_length: cfg.dry_allowed_length,
|
||||
dry_penalty_last_n: cfg.dry_penalty_last_n,
|
||||
ignore_eos: cfg.ignore_eos,
|
||||
n_probs: cfg.n_probs,
|
||||
stop: activeStops,
|
||||
reasoning_format: 'auto',
|
||||
};
|
||||
}
|
||||
27
app/src/store/chatTypes.ts
Normal file
27
app/src/store/chatTypes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
modelPath?: string | null;
|
||||
activeAgentId?: string | null;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
selectedModel: string | null;
|
||||
isInferring: boolean;
|
||||
error: string | null;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
}
|
||||
116
app/src/store/hardwareSlice.ts
Normal file
116
app/src/store/hardwareSlice.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { Platform } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { getBackendDevicesInfo } from 'llama.rn';
|
||||
import type { HardwareState, BackendDeviceInfo } from './types';
|
||||
|
||||
// ─── Helper (exported for reuse) ──────────────────────────────────────────────
|
||||
|
||||
export function parseMeminfo(raw: string): { total: number; free: number } {
|
||||
let total = 0;
|
||||
let free = 0;
|
||||
for (const line of raw.split('\n')) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts[0] === 'MemTotal:') {
|
||||
total = parseInt(parts[1], 10) * 1024;
|
||||
} else if (parts[0] === 'MemAvailable:') {
|
||||
free = parseInt(parts[1], 10) * 1024;
|
||||
}
|
||||
}
|
||||
return { total, free };
|
||||
}
|
||||
|
||||
// ─── Thunks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Full refresh: RAM + disk + GPU backends */
|
||||
export const refreshHardwareInfo = createAsyncThunk(
|
||||
'hardware/refreshAll',
|
||||
async () => {
|
||||
let ramTotal = 0;
|
||||
let ramFree = 0;
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const raw = await RNFS.readFile('/proc/meminfo', 'utf8');
|
||||
const parsed = parseMeminfo(raw);
|
||||
ramTotal = parsed.total;
|
||||
ramFree = parsed.free;
|
||||
} catch {
|
||||
// /proc/meminfo unavailable (very rare)
|
||||
}
|
||||
}
|
||||
|
||||
const [fsInfo, nativeDevices] = await Promise.all([
|
||||
RNFS.getFSInfo(),
|
||||
getBackendDevicesInfo(),
|
||||
]);
|
||||
|
||||
const backendDevices: BackendDeviceInfo[] = nativeDevices.map(d => ({
|
||||
backend: d.backend,
|
||||
type: d.type,
|
||||
deviceName: d.deviceName,
|
||||
maxMemorySize: d.maxMemorySize,
|
||||
metadata: d.metadata as Record<string, unknown> | undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
ramTotal,
|
||||
ramFree,
|
||||
diskTotal: fsInfo.totalSpace,
|
||||
diskFree: fsInfo.freeSpace,
|
||||
backendDevices,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
/** Lightweight RAM-only poll (every 2 s when screen is focused) */
|
||||
export const refreshRamInfo = createAsyncThunk(
|
||||
'hardware/refreshRam',
|
||||
async () => {
|
||||
if (Platform.OS !== 'android') { return { total: 0, free: 0 }; }
|
||||
const raw = await RNFS.readFile('/proc/meminfo', 'utf8');
|
||||
return parseMeminfo(raw);
|
||||
},
|
||||
);
|
||||
|
||||
// ─── Slice ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const initialState: HardwareState = {
|
||||
ramTotal: 0,
|
||||
ramFree: 0,
|
||||
diskTotal: 0,
|
||||
diskFree: 0,
|
||||
backendDevices: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const hardwareSlice = createSlice({
|
||||
name: 'hardware',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(refreshHardwareInfo.pending, state => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(refreshHardwareInfo.fulfilled, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.ramTotal = action.payload.ramTotal;
|
||||
state.ramFree = action.payload.ramFree;
|
||||
state.diskTotal = action.payload.diskTotal;
|
||||
state.diskFree = action.payload.diskFree;
|
||||
state.backendDevices = action.payload.backendDevices;
|
||||
})
|
||||
.addCase(refreshHardwareInfo.rejected, (state, action) => {
|
||||
state.isLoading = false;
|
||||
state.error = action.error.message ?? 'Erreur inconnue';
|
||||
})
|
||||
.addCase(refreshRamInfo.fulfilled, (state, action) => {
|
||||
if (action.payload.total) { state.ramTotal = action.payload.total; }
|
||||
state.ramFree = action.payload.free;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default hardwareSlice.reducer;
|
||||
25
app/src/store/index.ts
Normal file
25
app/src/store/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import modelsReducer from './modelsSlice';
|
||||
import chatReducer from './chatSlice';
|
||||
import hardwareReducer from './hardwareSlice';
|
||||
import agentsReducer from './agentsSlice';
|
||||
import { debouncedSaveChatState } from './persistence';
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
models: modelsReducer,
|
||||
chat: chatReducer,
|
||||
hardware: hardwareReducer,
|
||||
agents: agentsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
// Auto-save the chat slice to AsyncStorage after every state change.
|
||||
// Uses a 1.5 s debounce so rapid token streaming doesn't thrash storage.
|
||||
store.subscribe(() => {
|
||||
debouncedSaveChatState(store.getState().chat);
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export default store;
|
||||
160
app/src/store/modelsSlice.ts
Normal file
160
app/src/store/modelsSlice.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import RNFS from 'react-native-fs';
|
||||
import type { ModelsState, DownloadProgress, ModelConfig } from './types';
|
||||
import {
|
||||
loadModelsDirectory,
|
||||
loadHuggingFaceApiKey,
|
||||
scanLocalModels,
|
||||
updateModelsDirectory,
|
||||
searchHuggingFaceModels,
|
||||
saveHuggingFaceApiKey,
|
||||
loadModelConfigs,
|
||||
saveModelConfig,
|
||||
} from './modelsThunks';
|
||||
|
||||
const DEFAULT_MODELS_DIR = `${RNFS.DownloadDirectoryPath}/models`;
|
||||
|
||||
const initialState: ModelsState = {
|
||||
localModels: [],
|
||||
modelsDirectory: DEFAULT_MODELS_DIR,
|
||||
isLoadingLocal: false,
|
||||
localError: null,
|
||||
currentLoadedModel: null,
|
||||
modelConfigs: {},
|
||||
huggingFaceModels: [],
|
||||
huggingFaceApiKey: '',
|
||||
searchQuery: '',
|
||||
isLoadingHF: false,
|
||||
hfError: null,
|
||||
downloadProgress: {},
|
||||
isLoadingModel: false,
|
||||
loadModelProgress: 0,
|
||||
llamaSystemInfo: undefined,
|
||||
llamaContextGpu: undefined,
|
||||
llamaContextDevices: undefined,
|
||||
llamaContextReasonNoGPU: undefined,
|
||||
llamaAndroidLib: undefined,
|
||||
};
|
||||
|
||||
const modelsSlice = createSlice({
|
||||
name: 'models',
|
||||
initialState,
|
||||
reducers: {
|
||||
setCurrentLoadedModel: (state, action: PayloadAction<string | null>) => {
|
||||
state.currentLoadedModel = action.payload;
|
||||
},
|
||||
setModelConfig: (
|
||||
state,
|
||||
action: PayloadAction<{ modelPath: string; config: ModelConfig }>,
|
||||
) => {
|
||||
state.modelConfigs = state.modelConfigs || {};
|
||||
state.modelConfigs[action.payload.modelPath] = action.payload.config;
|
||||
},
|
||||
setSearchQuery: (state, action: PayloadAction<string>) => {
|
||||
state.searchQuery = action.payload;
|
||||
},
|
||||
clearLocalError: (state) => {
|
||||
state.localError = null;
|
||||
},
|
||||
clearHFError: (state) => {
|
||||
state.hfError = null;
|
||||
},
|
||||
setDownloadProgress: (state, action: PayloadAction<DownloadProgress>) => {
|
||||
state.downloadProgress[action.payload.modelId] = action.payload;
|
||||
},
|
||||
removeDownloadProgress: (state, action: PayloadAction<string>) => {
|
||||
delete state.downloadProgress[action.payload];
|
||||
},
|
||||
setIsLoadingModel: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoadingModel = action.payload;
|
||||
},
|
||||
setLoadModelProgress: (state, action: PayloadAction<number>) => {
|
||||
state.loadModelProgress = action.payload;
|
||||
},
|
||||
setLlamaContextInfo: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
systemInfo: string;
|
||||
gpu: boolean;
|
||||
devices?: string[];
|
||||
reasonNoGPU: string;
|
||||
androidLib?: string;
|
||||
}>,
|
||||
) => {
|
||||
state.llamaSystemInfo = action.payload.systemInfo;
|
||||
state.llamaContextGpu = action.payload.gpu;
|
||||
state.llamaContextDevices = action.payload.devices;
|
||||
state.llamaContextReasonNoGPU = action.payload.reasonNoGPU;
|
||||
state.llamaAndroidLib = action.payload.androidLib;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(loadModelsDirectory.fulfilled, (state, action) => {
|
||||
state.modelsDirectory = action.payload;
|
||||
});
|
||||
|
||||
builder.addCase(loadModelConfigs.fulfilled, (state, action) => {
|
||||
state.modelConfigs = action.payload || {};
|
||||
});
|
||||
|
||||
builder.addCase(loadHuggingFaceApiKey.fulfilled, (state, action) => {
|
||||
state.huggingFaceApiKey = action.payload;
|
||||
});
|
||||
|
||||
builder.addCase(scanLocalModels.pending, (state) => {
|
||||
state.isLoadingLocal = true;
|
||||
state.localError = null;
|
||||
});
|
||||
builder.addCase(scanLocalModels.fulfilled, (state, action) => {
|
||||
state.isLoadingLocal = false;
|
||||
state.localModels = action.payload;
|
||||
});
|
||||
builder.addCase(scanLocalModels.rejected, (state, action) => {
|
||||
state.isLoadingLocal = false;
|
||||
state.localError = action.payload as string;
|
||||
});
|
||||
|
||||
builder.addCase(updateModelsDirectory.fulfilled, (state, action) => {
|
||||
state.modelsDirectory = action.payload;
|
||||
});
|
||||
|
||||
builder.addCase(searchHuggingFaceModels.pending, (state) => {
|
||||
state.isLoadingHF = true;
|
||||
state.hfError = null;
|
||||
});
|
||||
builder.addCase(searchHuggingFaceModels.fulfilled, (state, action) => {
|
||||
state.isLoadingHF = false;
|
||||
state.huggingFaceModels = action.payload;
|
||||
});
|
||||
builder.addCase(searchHuggingFaceModels.rejected, (state, action) => {
|
||||
state.isLoadingHF = false;
|
||||
state.hfError = action.payload as string;
|
||||
});
|
||||
|
||||
builder.addCase(saveHuggingFaceApiKey.fulfilled, (state, action) => {
|
||||
state.huggingFaceApiKey = action.payload;
|
||||
});
|
||||
|
||||
builder.addCase(saveModelConfig.fulfilled, (state, action) => {
|
||||
state.modelConfigs = state.modelConfigs || {};
|
||||
state.modelConfigs[action.payload.modelPath] = action.payload.config;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setCurrentLoadedModel,
|
||||
setModelConfig,
|
||||
setSearchQuery,
|
||||
clearLocalError,
|
||||
clearHFError,
|
||||
setDownloadProgress,
|
||||
removeDownloadProgress,
|
||||
setIsLoadingModel,
|
||||
setLoadModelProgress,
|
||||
setLlamaContextInfo,
|
||||
} = modelsSlice.actions;
|
||||
|
||||
export * from './types';
|
||||
export * from './modelsThunks';
|
||||
export default modelsSlice.reducer;
|
||||
214
app/src/store/modelsThunks.ts
Normal file
214
app/src/store/modelsThunks.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import RNFS from 'react-native-fs';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { LocalModel, ModelConfig } from './types';
|
||||
import type { RootState } from '.';
|
||||
|
||||
const DEFAULT_MODELS_DIR = `${RNFS.DownloadDirectoryPath}/models`;
|
||||
const STORAGE_KEYS = {
|
||||
MODELS_DIR: '@models_directory',
|
||||
HF_API_KEY: '@huggingface_api_key',
|
||||
MODEL_CONFIGS: '@model_configs',
|
||||
};
|
||||
|
||||
export const loadModelsDirectory = createAsyncThunk(
|
||||
'models/loadModelsDirectory',
|
||||
async () => {
|
||||
const savedDir = await AsyncStorage.getItem(STORAGE_KEYS.MODELS_DIR);
|
||||
return savedDir || DEFAULT_MODELS_DIR;
|
||||
}
|
||||
);
|
||||
|
||||
export const loadHuggingFaceApiKey = createAsyncThunk(
|
||||
'models/loadHuggingFaceApiKey',
|
||||
async () => {
|
||||
const savedKey = await AsyncStorage.getItem(STORAGE_KEYS.HF_API_KEY);
|
||||
return savedKey || '';
|
||||
}
|
||||
);
|
||||
|
||||
export const scanLocalModels = createAsyncThunk(
|
||||
'models/scanLocalModels',
|
||||
async (directory: string, { rejectWithValue }) => {
|
||||
try {
|
||||
const exists = await RNFS.exists(directory);
|
||||
if (!exists) {
|
||||
await RNFS.mkdir(directory);
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await RNFS.readDir(directory);
|
||||
|
||||
const ggufFiles: LocalModel[] = files
|
||||
.filter(file => {
|
||||
const isGguf = file.name.toLowerCase().endsWith('.gguf');
|
||||
const isFile = file.isFile();
|
||||
return isGguf && isFile;
|
||||
})
|
||||
.map(file => ({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
size: file.size,
|
||||
modifiedDate: file.mtime?.toISOString() || new Date().toISOString(),
|
||||
}));
|
||||
|
||||
return ggufFiles;
|
||||
} catch (error) {
|
||||
return rejectWithValue((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const updateModelsDirectory = createAsyncThunk(
|
||||
'models/updateModelsDirectory',
|
||||
async (newDirectory: string, { dispatch }) => {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.MODELS_DIR, newDirectory);
|
||||
dispatch(scanLocalModels(newDirectory));
|
||||
return newDirectory;
|
||||
}
|
||||
);
|
||||
|
||||
interface HFModelResponse {
|
||||
id?: string;
|
||||
modelId?: string;
|
||||
author?: string;
|
||||
downloads?: number;
|
||||
likes?: number;
|
||||
lastModified?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
export const searchHuggingFaceModels = createAsyncThunk(
|
||||
'models/searchHuggingFaceModels',
|
||||
async (params: SearchParams, { rejectWithValue }) => {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
search: params.query,
|
||||
filter: 'gguf',
|
||||
sort: 'downloads',
|
||||
direction: '-1',
|
||||
limit: '20',
|
||||
});
|
||||
|
||||
const url = `https://huggingface.co/api/models?${searchParams.toString()}`;
|
||||
const response = await fetch(url, { headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to search models');
|
||||
}
|
||||
|
||||
const data: HFModelResponse[] = await response.json();
|
||||
|
||||
return data.map((model) => {
|
||||
const modelId = model.id || model.modelId || 'Unknown';
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId.split('/').pop() || 'Unknown',
|
||||
author: modelId.split('/')[0] || 'Unknown',
|
||||
downloads: model.downloads || 0,
|
||||
likes: model.likes || 0,
|
||||
lastModified: model.lastModified || new Date().toISOString(),
|
||||
tags: model.tags || [],
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return rejectWithValue((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const saveHuggingFaceApiKey = createAsyncThunk(
|
||||
'models/saveHuggingFaceApiKey',
|
||||
async (apiKey: string) => {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.HF_API_KEY, apiKey);
|
||||
return apiKey;
|
||||
}
|
||||
);
|
||||
|
||||
interface DownloadModelParams {
|
||||
modelId: string;
|
||||
fileName: string;
|
||||
downloadUrl: string;
|
||||
destinationDir: string;
|
||||
onProgress: (bytesWritten: number, contentLength: number) => void;
|
||||
}
|
||||
|
||||
export const downloadHuggingFaceModel = createAsyncThunk(
|
||||
'models/downloadHuggingFaceModel',
|
||||
async (params: DownloadModelParams, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const destinationPath = `${params.destinationDir}/${params.fileName}`;
|
||||
|
||||
// Ensure directory exists
|
||||
const dirExists = await RNFS.exists(params.destinationDir);
|
||||
if (!dirExists) {
|
||||
await RNFS.mkdir(params.destinationDir);
|
||||
}
|
||||
|
||||
const downloadResult = await RNFS.downloadFile({
|
||||
fromUrl: params.downloadUrl,
|
||||
toFile: destinationPath,
|
||||
progress: (res) => {
|
||||
params.onProgress(res.bytesWritten, res.contentLength);
|
||||
},
|
||||
progressDivider: 1,
|
||||
}).promise;
|
||||
|
||||
if (downloadResult.statusCode === 200) {
|
||||
// Refresh local models after download
|
||||
dispatch(scanLocalModels(params.destinationDir));
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
throw new Error(`Download failed with status ${downloadResult.statusCode}`);
|
||||
} catch (error) {
|
||||
return rejectWithValue((error as Error).message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const loadModelConfigs = createAsyncThunk(
|
||||
'models/loadModelConfigs',
|
||||
async () => {
|
||||
const raw = await AsyncStorage.getItem(STORAGE_KEYS.MODEL_CONFIGS);
|
||||
if (!raw) {
|
||||
return {} as Record<string, ModelConfig>;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as Record<string, ModelConfig>;
|
||||
} catch {
|
||||
return {} as Record<string, ModelConfig>;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const saveModelConfig = createAsyncThunk(
|
||||
'models/saveModelConfig',
|
||||
async (
|
||||
params: { modelPath: string; config: ModelConfig },
|
||||
{ getState },
|
||||
) => {
|
||||
const state = getState() as unknown as RootState;
|
||||
const existing: Record<string, ModelConfig> =
|
||||
state.models.modelConfigs || {};
|
||||
const updated = { ...existing, [params.modelPath]: params.config };
|
||||
await AsyncStorage.setItem(
|
||||
STORAGE_KEYS.MODEL_CONFIGS,
|
||||
JSON.stringify(updated),
|
||||
);
|
||||
return { modelPath: params.modelPath, config: params.config };
|
||||
}
|
||||
);
|
||||
45
app/src/store/persistence.ts
Normal file
45
app/src/store/persistence.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { ChatState } from './chatTypes';
|
||||
|
||||
// Bump the version suffix when the ChatState schema changes in a breaking way.
|
||||
const CHAT_KEY = '@mymobileagents/chat_v1';
|
||||
|
||||
export type PersistedChatState = Omit<ChatState, 'isInferring' | 'error'>;
|
||||
|
||||
// ─── Load ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function loadChatState(): Promise<PersistedChatState | null> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(CHAT_KEY);
|
||||
if (!raw) { return null; }
|
||||
return JSON.parse(raw) as PersistedChatState;
|
||||
} catch {
|
||||
// Corrupted data — start fresh rather than crashing.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Save ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function saveChatState(state: ChatState): Promise<void> {
|
||||
try {
|
||||
// Strip transient runtime fields before serialising.
|
||||
const toSave: PersistedChatState = { ...state } as PersistedChatState;
|
||||
// Remove runtime-only fields which should not be persisted.
|
||||
delete (toSave as Partial<ChatState>).isInferring;
|
||||
delete (toSave as Partial<ChatState>).error;
|
||||
await AsyncStorage.setItem(CHAT_KEY, JSON.stringify(toSave));
|
||||
} catch {
|
||||
// Storage errors must never propagate — app must remain usable.
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Debounced wrapper ───────────────────────────────────────────────────────
|
||||
// Avoids hammering AsyncStorage on every streamed token.
|
||||
|
||||
let _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function debouncedSaveChatState(state: ChatState, delayMs = 1500): void {
|
||||
if (_saveTimer !== null) { clearTimeout(_saveTimer); }
|
||||
_saveTimer = setTimeout(() => { saveChatState(state); }, delayMs);
|
||||
}
|
||||
146
app/src/store/types.ts
Normal file
146
app/src/store/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export interface LocalModel {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedDate: string;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
systemPrompt?: string;
|
||||
|
||||
// ── Chargement du modèle ─────────────────────────────────────────────────
|
||||
n_ctx?: number;
|
||||
n_batch?: number;
|
||||
n_ubatch?: number;
|
||||
n_threads?: number;
|
||||
n_gpu_layers?: number;
|
||||
flash_attn?: boolean;
|
||||
cache_type_k?: 'f16' | 'f32' | 'q8_0' | 'q4_0' | 'q4_1' | 'iq4_nl' | 'q5_0' | 'q5_1';
|
||||
cache_type_v?: 'f16' | 'f32' | 'q8_0' | 'q4_0' | 'q4_1' | 'iq4_nl' | 'q5_0' | 'q5_1';
|
||||
use_mlock?: boolean;
|
||||
use_mmap?: boolean;
|
||||
rope_freq_base?: number;
|
||||
rope_freq_scale?: number;
|
||||
ctx_shift?: boolean;
|
||||
kv_unified?: boolean;
|
||||
n_cpu_moe?: number;
|
||||
cpu_mask?: string;
|
||||
n_parallel?: number;
|
||||
|
||||
// ── Sampling ─────────────────────────────────────────────────────────────
|
||||
temperature?: number;
|
||||
top_k?: number;
|
||||
top_p?: number;
|
||||
min_p?: number;
|
||||
n_predict?: number;
|
||||
seed?: number;
|
||||
typical_p?: number;
|
||||
top_n_sigma?: number;
|
||||
mirostat?: number;
|
||||
mirostat_tau?: number;
|
||||
mirostat_eta?: number;
|
||||
xtc_probability?: number;
|
||||
xtc_threshold?: number;
|
||||
|
||||
// ── Pénalités de répétition ───────────────────────────────────────────────
|
||||
penalty_repeat?: number;
|
||||
penalty_last_n?: number;
|
||||
penalty_freq?: number;
|
||||
penalty_present?: number;
|
||||
dry_multiplier?: number;
|
||||
dry_base?: number;
|
||||
dry_allowed_length?: number;
|
||||
dry_penalty_last_n?: number;
|
||||
|
||||
// ── Sortie ────────────────────────────────────────────────────────────────
|
||||
ignore_eos?: boolean;
|
||||
n_probs?: number;
|
||||
stop?: string[];
|
||||
|
||||
// Legacy fields (backward compat)
|
||||
max_new_tokens?: number;
|
||||
repetition_penalty?: number;
|
||||
}
|
||||
|
||||
export interface HuggingFaceModel {
|
||||
id: string;
|
||||
name: string;
|
||||
author: string;
|
||||
downloads: number;
|
||||
likes: number;
|
||||
lastModified: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
modelId: string;
|
||||
bytesWritten: number;
|
||||
contentLength: number;
|
||||
}
|
||||
|
||||
export interface ModelsState {
|
||||
localModels: LocalModel[];
|
||||
modelsDirectory: string;
|
||||
isLoadingLocal: boolean;
|
||||
localError: string | null;
|
||||
currentLoadedModel: string | null;
|
||||
modelConfigs?: Record<string, ModelConfig>;
|
||||
huggingFaceModels: HuggingFaceModel[];
|
||||
huggingFaceApiKey: string;
|
||||
searchQuery: string;
|
||||
isLoadingHF: boolean;
|
||||
hfError: string | null;
|
||||
downloadProgress: Record<string, DownloadProgress>;
|
||||
/** True while initLlama is running */
|
||||
isLoadingModel: boolean;
|
||||
/** 0–100 progress from initLlama's onProgress callback */
|
||||
loadModelProgress: number; /** systemInfo string from the loaded LlamaContext */
|
||||
llamaSystemInfo?: string;
|
||||
/** Whether GPU is being used by the active context */
|
||||
llamaContextGpu?: boolean;
|
||||
/** List of devices used by the active context */
|
||||
llamaContextDevices?: string[];
|
||||
/** Reason GPU is not used (if applicable) */
|
||||
llamaContextReasonNoGPU?: string;
|
||||
/** Name of the library loaded on Android */
|
||||
llamaAndroidLib?: string;
|
||||
}
|
||||
|
||||
// ─── Hardware ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BackendDeviceInfo {
|
||||
backend: string;
|
||||
type: string;
|
||||
deviceName: string;
|
||||
maxMemorySize: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HardwareState {
|
||||
/** Total physical RAM in bytes (Android: /proc/meminfo, iOS: 0) */
|
||||
ramTotal: number;
|
||||
/** Available RAM in bytes (MemAvailable from /proc/meminfo) */
|
||||
ramFree: number;
|
||||
/** Total internal storage in bytes */
|
||||
diskTotal: number;
|
||||
/** Free internal storage in bytes */
|
||||
diskFree: number;
|
||||
/** GPU/CPU backend devices reported by llama.rn */
|
||||
backendDevices: BackendDeviceInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ─── Agents ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
/** URI du fichier image local (dans DocumentDirectoryPath) ou null */
|
||||
avatarUri?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentsState {
|
||||
agents: Agent[];
|
||||
}
|
||||
91
app/src/theme/ThemeProvider.tsx
Normal file
91
app/src/theme/ThemeProvider.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useColorScheme as _useColorScheme } from 'react-native';
|
||||
import lightColors from './lightTheme';
|
||||
import darkColors from './darkTheme';
|
||||
import neonColors from './neonTheme';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system' | 'neon';
|
||||
|
||||
type ThemeContextValue = {
|
||||
mode: ThemeMode;
|
||||
setMode: (m: ThemeMode) => void;
|
||||
scheme: 'light' | 'dark' | 'neon';
|
||||
colors: Record<string, string>;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'themeMode';
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export const ThemeProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
}> = ({ children }) => {
|
||||
const device = _useColorScheme();
|
||||
const [mode, setModeState] = useState<ThemeMode>('system');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const v = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
if (v === 'light' || v === 'dark' || v === 'system' || v === 'neon') {
|
||||
setModeState(v);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const setMode = async (m: ThemeMode) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, m);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setModeState(m);
|
||||
};
|
||||
|
||||
const scheme = useMemo(() => {
|
||||
if (mode === 'system') {
|
||||
return device === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
if (mode === 'neon') return 'neon';
|
||||
|
||||
return mode === 'dark' ? 'dark' : 'light';
|
||||
}, [mode, device]);
|
||||
|
||||
const colors = useMemo(() => {
|
||||
if (scheme === 'dark') return darkColors;
|
||||
if (scheme === 'neon') return neonColors;
|
||||
|
||||
// light
|
||||
return {
|
||||
...(lightColors as Record<string, string>),
|
||||
...(lightOverrides as Record<string, string>),
|
||||
} as Record<string, string>;
|
||||
}, [scheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ mode, setMode, scheme, colors }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useTheme must be used within ThemeProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export default ThemeProvider;
|
||||
26
app/src/theme/darkTheme.ts
Normal file
26
app/src/theme/darkTheme.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { colors as tokenColors } from './lightTheme';
|
||||
|
||||
const darkColors: Record<string, string> = {
|
||||
...(tokenColors as Record<string, string>),
|
||||
background: '#0b0b0b',
|
||||
surface: '#111111',
|
||||
surfaceSecondary: '#0b0b0b',
|
||||
card: '#111111',
|
||||
textPrimary: '#FFFFFF',
|
||||
textSecondary: '#BDBDBD',
|
||||
textTertiary: '#8E8E93',
|
||||
text: '#FFFFFF',
|
||||
border: '#222222',
|
||||
overlay: 'rgba(255,255,255,0.06)',
|
||||
primary: '#4a90e2',
|
||||
onPrimary: '#FFFFFF',
|
||||
notification: '#FF453A',
|
||||
agentBg: '#151526',
|
||||
agentBorder: '#2a2a44',
|
||||
agentAccent: '#8fa8ff',
|
||||
surfaceLight: '#1a1a1a',
|
||||
transparent: 'transparent',
|
||||
};
|
||||
|
||||
export default darkColors;
|
||||
export { darkColors };
|
||||
80
app/src/theme/lightTheme.ts
Normal file
80
app/src/theme/lightTheme.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export const colors = {
|
||||
// Primary
|
||||
primary: '#007AFF',
|
||||
|
||||
// Backgrounds
|
||||
background: '#F2F2F7',
|
||||
surface: '#FFFFFF',
|
||||
card: '#FFFFFF',
|
||||
surfaceSecondary: '#F2F2F7',
|
||||
|
||||
// Text
|
||||
textPrimary: '#000000',
|
||||
textSecondary: '#3C3C43',
|
||||
textTertiary: '#8E8E93',
|
||||
|
||||
// Borders
|
||||
border: 'transparent',
|
||||
|
||||
// Status
|
||||
success: '#34C759',
|
||||
error: '#FF3B30',
|
||||
// Alias used across the app for notification / error states
|
||||
notification: '#FF3B30',
|
||||
warning: '#FF9500',
|
||||
|
||||
// States
|
||||
disabled: '#8E8E93',
|
||||
overlay: 'rgba(0, 0, 0, 0.5)',
|
||||
overlayStrong: 'rgba(0, 0, 0, 0.65)',
|
||||
userTimeText: 'rgba(255, 255, 255, 0.7)',
|
||||
|
||||
// Agent UI
|
||||
agentBg: '#F0F0FF',
|
||||
agentBorder: '#D0D0FF',
|
||||
agentAccent: '#4040CC',
|
||||
overlayLight: 'rgba(0, 0, 0, 0.45)',
|
||||
transparent: 'transparent' as const,
|
||||
// Color to use on top of `primary` buttons
|
||||
onPrimary: '#FFFFFF',
|
||||
// Additional helpers
|
||||
successLight: '#d1fae5',
|
||||
successDark: '#065f46',
|
||||
errorLight: '#fee2e2',
|
||||
errorDark: '#991b1b',
|
||||
errorBg: '#fff0f0',
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 24,
|
||||
xxl: 32,
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
sizes: {
|
||||
xs: 12,
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 17,
|
||||
xl: 20,
|
||||
},
|
||||
weights: {
|
||||
regular: '400' as const,
|
||||
medium: '500' as const,
|
||||
semibold: '600' as const,
|
||||
bold: '700' as const,
|
||||
},
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
sm: 4,
|
||||
md: 6,
|
||||
lg: 8,
|
||||
xl: 12,
|
||||
};
|
||||
|
||||
export default colors;
|
||||
25
app/src/theme/neonTheme.ts
Normal file
25
app/src/theme/neonTheme.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { colors as tokenColors } from './lightTheme';
|
||||
|
||||
export const neonColors: Record<string, string> = {
|
||||
...(tokenColors as Record<string, string>),
|
||||
background: '#0b0b10',
|
||||
surface: '#0f0f16',
|
||||
surfaceSecondary: '#0b0b10',
|
||||
card: '#0f0f16',
|
||||
textPrimary: '#E6FFFA',
|
||||
textSecondary: '#A7F3D0',
|
||||
textTertiary: '#7CFFC6',
|
||||
text: '#E6FFFA',
|
||||
border: '#00FFD6',
|
||||
overlay: 'rgba(0,255,214,0.06)',
|
||||
primary: '#00FFF6',
|
||||
onPrimary: '#001018',
|
||||
notification: '#FF4DFF',
|
||||
agentBg: '#081018',
|
||||
agentBorder: '#003344',
|
||||
agentAccent: '#39FF14',
|
||||
surfaceLight: '#12121a',
|
||||
transparent: 'transparent',
|
||||
};
|
||||
|
||||
export default neonColors;
|
||||
92
app/src/utils/permissions.ts
Normal file
92
app/src/utils/permissions.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { PermissionsAndroid, Platform, Linking, Alert, NativeModules } from 'react-native';
|
||||
|
||||
const { PermissionsModule } = NativeModules;
|
||||
|
||||
export async function checkStoragePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiLevel = Platform.Version;
|
||||
|
||||
if (apiLevel >= 30) {
|
||||
// Pour Android 11+, utiliser notre module natif
|
||||
const hasModule = PermissionsModule?.checkManageFilesPermission;
|
||||
if (hasModule) {
|
||||
const hasPermission = await hasModule();
|
||||
return hasPermission;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasPermission = await PermissionsAndroid.check(
|
||||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE
|
||||
);
|
||||
|
||||
return hasPermission;
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la vérification de permission:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestStoragePermission(): Promise<boolean> {
|
||||
if (Platform.OS !== 'android') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiLevel = Platform.Version;
|
||||
|
||||
// Android 11+ (API 30+) nécessite MANAGE_EXTERNAL_STORAGE
|
||||
if (apiLevel >= 30) {
|
||||
// Expliquer pourquoi on a besoin de cette permission
|
||||
Alert.alert(
|
||||
'Permission requise',
|
||||
'Pour lire vos modèles GGUF, l\'application a besoin d\'accéder à tous les fichiers.\n\n' +
|
||||
'Activez "Autoriser la gestion de tous les fichiers" dans l\'écran suivant.',
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel', onPress: () => {} },
|
||||
{
|
||||
text: 'Ouvrir paramètres',
|
||||
onPress: async () => {
|
||||
try {
|
||||
// Utiliser notre module natif
|
||||
const openSettings = PermissionsModule?.openManageFilesSettings;
|
||||
if (openSettings) {
|
||||
await openSettings();
|
||||
} else {
|
||||
await Linking.openSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur ouverture paramètres:', error);
|
||||
// Fallback vers paramètres généraux
|
||||
await Linking.openSettings();
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Android 10 et inférieur
|
||||
const granted = await PermissionsAndroid.request(
|
||||
PermissionsAndroid.PERMISSIONS.READ_EXTERNAL_STORAGE,
|
||||
{
|
||||
title: 'Permission de lecture',
|
||||
message: 'L\'application a besoin d\'accéder à vos fichiers pour charger les modèles.',
|
||||
buttonNeutral: 'Plus tard',
|
||||
buttonNegative: 'Refuser',
|
||||
buttonPositive: 'Autoriser',
|
||||
}
|
||||
);
|
||||
|
||||
return granted === PermissionsAndroid.RESULTS.GRANTED;
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la demande de permission:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user