Initial commit

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

View File

@@ -0,0 +1,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>
);
}

View 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,
},
});

View 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,
},
});

View 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',
},
});

View 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,
},
});

View 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,
},
});

View 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

View 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,
},
});

View 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&apos;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,
},
});

View 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,
},
});

View 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 },
});

View 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,
},
});

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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);
}
}

View 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>
);
}

View 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,
},
});

View 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,
},
});

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,398 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
View,
Text,
TouchableOpacity,
Alert,
AppState,
Platform,
Modal,
ActivityIndicator,
} from 'react-native';
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import type { RootStackParamList } from '../../navigation';
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from '../../store';
import {
scanLocalModels,
updateModelsDirectory,
loadModelsDirectory,
clearLocalError,
setCurrentLoadedModel,
} from '../../store/modelsSlice';
import { loadModelConfigs } from '../../store/modelsThunks';
import { refreshHardwareInfo, refreshRamInfo } from '../../store/hardwareSlice';
import { loadModel, releaseModel } from '../../store/chatThunks';
import type { ModelConfig } from '../../store/types';
import ModelItem from './ModelItem';
import DirectoryPicker from './DirectoryPicker';
import DirectoryEditor from './DirectoryEditor';
import PermissionMessage from './PermissionMessage';
import ModelsList from './ModelsList';
import { createStyles } from './styles';
import { useTheme } from '../../theme/ThemeProvider';
import {
requestStoragePermission,
checkStoragePermission,
} from '../../utils/permissions';
export default function LocalModelsScreen() {
const { colors } = useTheme();
const styles = createStyles(colors);
const dispatch = useDispatch<AppDispatch>();
const navigation = useNavigation<
NativeStackNavigationProp<RootStackParamList>
>();
const {
localModels,
modelsDirectory,
isLoadingLocal,
localError,
currentLoadedModel,
downloadProgress,
} = useSelector((state: RootState) =>
state.models,
);
const ramFree = useSelector((s: RootState) => s.hardware.ramFree);
const ramTotal = useSelector((s: RootState) => s.hardware.ramTotal);
const isLoadingModel = useSelector((s: RootState) => s.models.isLoadingModel);
const loadModelProgress = useSelector((s: RootState) => s.models.loadModelProgress ?? 0);
const modelConfigs = useSelector((s: RootState) => s.models.modelConfigs ?? {});
// Highlighted model path — tapping a card shows its footprint in the RAM bar
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
// Path du modèle en cours de chargement (pour spinner sur la bonne carte)
const [loadingPath, setLoadingPath] = useState<string | null>(null);
// Nom du modèle affiché dans la modal de chargement
const [loadingModelName, setLoadingModelName] = useState<string | null>(null);
const [editingDirectory, setEditingDirectory] = useState(false);
const [tempDirectory, setTempDirectory] = useState(modelsDirectory);
const [hasPermission, setHasPermission] = useState(false);
useEffect(() => {
checkStoragePermission().then(setHasPermission);
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (nextAppState === 'active') {
const permitted = await checkStoragePermission();
setHasPermission(permitted);
if (permitted) {
dispatch(scanLocalModels(modelsDirectory));
}
}
});
return () => {
subscription.remove();
};
}, [dispatch, modelsDirectory]);
useEffect(() => {
// Load saved directory and scan on mount
const initializeScreen = async () => {
const result = await dispatch(loadModelsDirectory());
if (result.payload) {
const permitted = await checkStoragePermission();
setHasPermission(permitted);
if (permitted) {
dispatch(scanLocalModels(result.payload as string));
}
}
dispatch(loadModelConfigs());
};
initializeScreen();
// Refresh RAM info on mount so the indicator is up to date
dispatch(refreshHardwareInfo());
}, [dispatch]);
useEffect(() => {
setTempDirectory(modelsDirectory);
}, [modelsDirectory]);
// Polling RAM toutes les secondes quand l'écran est actif
useFocusEffect(
useCallback(() => {
dispatch(refreshRamInfo());
const interval = setInterval(() => {
dispatch(refreshRamInfo());
}, 1000);
return () => clearInterval(interval);
}, [dispatch]),
);
useEffect(() => {
if (localError) {
Alert.alert('Erreur', localError, [
{ text: 'OK', onPress: () => dispatch(clearLocalError()) },
]);
}
}, [localError, dispatch]);
const handleRefresh = () => {
dispatch(scanLocalModels(modelsDirectory));
};
const handleRequestPermission = async () => {
await requestStoragePermission();
// Attendre un peu puis revérifier
setTimeout(async () => {
const permitted = await checkStoragePermission();
setHasPermission(permitted);
if (permitted) {
dispatch(scanLocalModels(modelsDirectory));
}
}, 1000);
};
const handleUpdateDirectory = () => {
if (tempDirectory.trim()) {
dispatch(updateModelsDirectory(tempDirectory.trim()));
setEditingDirectory(false);
}
};
const handleSelectCommonDirectory = (dir: string) => {
setTempDirectory(dir);
dispatch(updateModelsDirectory(dir));
setEditingDirectory(false);
};
const handleLoadModel = async (modelPath: string, modelName: string) => {
const cfg: ModelConfig = modelConfigs[modelPath] ?? {
n_ctx: 2048,
n_threads: 4,
n_gpu_layers: 0,
use_mlock: false,
use_mmap: true,
};
setLoadingPath(modelPath);
setLoadingModelName(modelName);
try {
dispatch(setCurrentLoadedModel(modelPath));
const result = await dispatch(loadModel({ modelPath, cfg }));
if (loadModel.rejected.match(result)) {
dispatch(setCurrentLoadedModel(null));
Alert.alert(
'Erreur de chargement',
String(result.payload ?? `Impossible de charger ${modelName}`),
);
}
} finally {
setLoadingPath(null);
setLoadingModelName(null);
}
};
const handleUnloadModel = async () => {
await dispatch(releaseModel());
dispatch(setCurrentLoadedModel(null));
setHighlightedPath(null);
dispatch(refreshRamInfo());
};
const renderModelItem = ({ item }: { item: typeof localModels[0] }) => {
const isLoaded = currentLoadedModel === item.path;
// Vérifier si ce modèle est en cours de téléchargement
const downloadKey = Object.keys(downloadProgress).find(key => {
const fileName = downloadProgress[key].modelId.split('/').pop();
return item.name.includes(fileName || '');
});
const modelProgress = downloadKey ? downloadProgress[downloadKey] : null;
const isDownloading = !!modelProgress;
const progressPercent = modelProgress
? (modelProgress.bytesWritten / modelProgress.contentLength) * 100
: 0;
return (
<ModelItem
model={item}
isLoaded={isLoaded}
onPress={(path: string, name: string) => {
setHighlightedPath(null);
navigation.navigate('ModelConfig', { modelPath: path, modelName: name });
}}
onLoad={() => handleLoadModel(item.path, item.name)}
onUnload={handleUnloadModel}
isLoadingThisModel={isLoadingModel && loadingPath === item.path}
onHighlight={() =>
setHighlightedPath(prev => (prev === item.path ? null : item.path))
}
isHighlighted={highlightedPath === item.path}
ramFree={ramFree}
isDownloading={isDownloading}
downloadProgress={progressPercent}
bytesWritten={modelProgress?.bytesWritten || 0}
contentLength={modelProgress?.contentLength || 0}
/>
);
};
const fmtGB = (b: number) =>
b >= 1073741824
? `${(b / 1073741824).toFixed(2)} GB`
: b >= 1048576
? `${(b / 1048576).toFixed(0)} MB`
: '—';
// Highlighted model's size (null when nothing is selected)
const highlightedModel = localModels.find(m => m.path === highlightedPath);
const highlightedSize = highlightedModel?.size ?? null;
// RAM bar proportions (flex values normalized to 0100)
const ramPct = ramTotal > 0 ? (ramFree / ramTotal) * 100 : -1;
const ramColor = ramPct < 0
? colors.textTertiary
: ramPct < 20
? colors.error
: ramPct < 40
? colors.warning
: colors.success;
const usedFlex = ramTotal > 0 ? ((ramTotal - ramFree) / ramTotal) * 100 : 0;
const modelExceedsRam = (highlightedSize ?? 0) > ramFree;
const modelFlexRaw = highlightedSize && ramTotal > 0
? (Math.min(highlightedSize, ramFree) / ramTotal) * 100
: 0;
const freeFlex = Math.max(0, 100 - usedFlex - modelFlexRaw);
const modelBarColor = modelExceedsRam ? colors.error : colors.warning;
const progressStyle = { width: `${loadModelProgress}%` as `${number}%` };
return (
<View style={styles.container}>
{/* Loading modal — même comportement que ModelConfigScreen */}
<Modal visible={loadingModelName !== null} transparent animationType="fade">
<View style={styles.loadingOverlay}>
<View style={styles.loadingCard}>
<Text style={styles.loadingTitle}>Chargement du modèle</Text>
<Text style={styles.loadingSubtitle} numberOfLines={1}>
{loadingModelName}
</Text>
<ActivityIndicator
size="large"
color={colors.primary}
style={styles.activityIndicator}
/>
<View style={styles.progressBarBg}>
<View style={[styles.progressBarFill, progressStyle]} />
</View>
<Text style={styles.loadingPercent}>{loadModelProgress} %</Text>
</View>
</View>
</Modal>
{/* Directory Selection */}
<View style={styles.directorySection}>
<Text style={styles.sectionTitle}>Dossier des modèles</Text>
{editingDirectory ? (
<>
<DirectoryEditor
tempDirectory={tempDirectory}
onChangeText={setTempDirectory}
onCancel={() => {
setTempDirectory(modelsDirectory);
setEditingDirectory(false);
}}
onSave={handleUpdateDirectory}
onSelectCommon={handleSelectCommonDirectory}
/>
<DirectoryPicker
currentDirectory={modelsDirectory}
onSelectDirectory={handleSelectCommonDirectory}
/>
</>
) : (
<View style={styles.directoryDisplay}>
<Text style={styles.directoryText} numberOfLines={2}>
{modelsDirectory}
</Text>
<TouchableOpacity
style={styles.changeButton}
onPress={() => setEditingDirectory(true)}
>
<Text style={styles.changeButtonText}>Modifier</Text>
</TouchableOpacity>
</View>
)}
{/* RAM bar */}
{Platform.OS === 'android' && ramTotal > 0 && (
<View style={styles.ramSection}>
{/* Header row */}
<View style={styles.ramSectionHeader}>
<Text style={styles.ramSectionLabel}>RAM</Text>
<Text style={styles.ramHeaderRight}>
Libre :{' '}
<Text style={[{ color: ramColor }, styles.ramStrong]}>
{fmtGB(ramFree)} ({Math.round(ramPct)} %)
</Text>
{' / '}{fmtGB(ramTotal)}
</Text>
</View>
{/* Progress bar */}
<View style={styles.ramBarTrack}>
{/* Used */}
<View style={[styles.ramBarSeg,
{ flex: usedFlex, backgroundColor: colors.textSecondary }
]} />
{/* Model footprint */}
{modelFlexRaw > 0 && (
<View style={[styles.ramBarSeg, {
flex: modelFlexRaw,
backgroundColor: modelBarColor
}]} />
)}
{/* Remaining free */}
{freeFlex > 0 && (
<View style={[styles.ramBarSeg, { flex: freeFlex, backgroundColor: ramColor }]} />
)}
</View>
{/* Legend when a model is highlighted */}
{highlightedSize !== null && (
<View style={styles.ramLegend}>
<View style={styles.ramLegendRow}>
<View style={[styles.ramLegendDot, { backgroundColor: modelBarColor }]} />
<Text style={styles.ramLegendText}>
Modèle : {fmtGB(highlightedSize)}{' '}
<Text style={[{ color: modelBarColor }, styles.ramStrong]}>
({Math.round((highlightedSize / ramTotal) * 100)} % de la RAM)
</Text>
</Text>
</View>
{modelExceedsRam && (
<Text style={styles.ramWarning}>
Insuffisant fermez d'autres apps ou choisissez une quantification plus légère
</Text>
)}
</View>
)}
{/* Hint when nothing selected */}
{highlightedSize === null && (
<Text style={styles.ramHint}> Appuyez sur un modèle pour voir son impact</Text>
)}
</View>
)}
{!hasPermission && (
<PermissionMessage onRequestPermission={handleRequestPermission} />
)}
</View>
{/* Models List */}
<ModelsList
models={localModels}
isLoading={isLoadingLocal}
onRefresh={handleRefresh}
renderItem={renderModelItem}
/>
</View>
);
}

View File

@@ -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)
: [],
};
}

View File

@@ -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 },
});

View File

@@ -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,
},
});

View File

@@ -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 },
});

View File

@@ -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 },
});

View File

@@ -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,
},
});

View File

@@ -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(', '),
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -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:
'5122 048 pour petits appareils. 4 0968 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:
'128512 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 ~3050%. ⚡ 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.',
},
};

View File

@@ -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.11.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.11.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.00.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.00.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.81.5 = pénalise exponentiellement les répétitions de séquences entières. Bien plus fort que penalty_repeat.',
usage:
'0.81.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 : 35) 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). 510 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.`,
},
};

View File

@@ -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.10.3 : code, réponses factuelles. 0.50.8 : conversations équilibrées. 0.81.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:
'2040 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.850.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.050.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 0242 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.90.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.03.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.08.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.050.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.10.5 = activation probabiliste. Force le modèle à explorer des tokens moins probables → plus de créativité.',
usage:
'Utiliser 0.10.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.',
},
};

View File

@@ -0,0 +1,6 @@
export type ParamInfoEntry = {
title: string;
description: string;
impact: string;
usage: string;
};

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View File

@@ -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,
},
});

View 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,
},
});

View 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;
}

View 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,
},
});

View 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>
);
}

View 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>
);
}

View 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,
},
});

View 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
View 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
View 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);

View 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;
}

View 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',
};
}

View 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;
}

View 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
View 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;

View 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;

View 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 };
}
);

View 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
View 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;
/** 0100 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[];
}

View 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;

View 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 };

View 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;

View 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;

View 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;
}
}