Compare commits
3 Commits
a213b1593a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7494107491 | ||
|
|
d77bdcf80e | ||
|
|
21b669f96c |
@@ -17,6 +17,7 @@ import { ThemeProvider, useTheme } from './src/theme/ThemeProvider';
|
|||||||
import { loadAgents } from './src/store/agentsSlice';
|
import { loadAgents } from './src/store/agentsSlice';
|
||||||
import { rehydrateChat } from './src/store/chatSlice';
|
import { rehydrateChat } from './src/store/chatSlice';
|
||||||
import { loadChatState } from './src/store/persistence';
|
import { loadChatState } from './src/store/persistence';
|
||||||
|
import { loadSettings } from './src/store/settingsSlice';
|
||||||
|
|
||||||
function InnerApp() {
|
function InnerApp() {
|
||||||
const { scheme, colors } = useTheme();
|
const { scheme, colors } = useTheme();
|
||||||
@@ -30,6 +31,7 @@ function InnerApp() {
|
|||||||
const [persisted] = await Promise.all([
|
const [persisted] = await Promise.all([
|
||||||
loadChatState(),
|
loadChatState(),
|
||||||
dispatch(loadAgents()),
|
dispatch(loadAgents()),
|
||||||
|
dispatch(loadSettings()),
|
||||||
]);
|
]);
|
||||||
if (persisted) {
|
if (persisted) {
|
||||||
dispatch(rehydrateChat(persisted));
|
dispatch(rehydrateChat(persisted));
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
const [avatarUri, setAvatarUri] = useState<string | null>(
|
const [avatarUri, setAvatarUri] = useState<string | null>(
|
||||||
agent?.avatarUri ?? null,
|
agent?.avatarUri ?? null,
|
||||||
);
|
);
|
||||||
|
const [toolNow, setToolNow] = useState(agent?.tools?.now ?? false);
|
||||||
|
const [toolUsername, setToolUsername] = useState(agent?.tools?.username ?? false);
|
||||||
const [pickingImage, setPickingImage] = useState(false);
|
const [pickingImage, setPickingImage] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
setName(agent?.name ?? '');
|
setName(agent?.name ?? '');
|
||||||
setPrompt(agent?.systemPrompt ?? 'Tu es un assistant utile et concis.');
|
setPrompt(agent?.systemPrompt ?? 'Tu es un assistant utile et concis.');
|
||||||
setAvatarUri(agent?.avatarUri ?? null);
|
setAvatarUri(agent?.avatarUri ?? null);
|
||||||
|
setToolNow(agent?.tools?.now ?? false);
|
||||||
|
setToolUsername(agent?.tools?.username ?? false);
|
||||||
}
|
}
|
||||||
}, [visible, agent]);
|
}, [visible, agent]);
|
||||||
|
|
||||||
@@ -91,6 +95,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
systemPrompt: prompt.trim(),
|
systemPrompt: prompt.trim(),
|
||||||
avatarUri: avatarUri ?? null,
|
avatarUri: avatarUri ?? null,
|
||||||
|
tools: { now: toolNow, username: toolUsername },
|
||||||
};
|
};
|
||||||
await dispatch(saveAgent(updated));
|
await dispatch(saveAgent(updated));
|
||||||
onClose();
|
onClose();
|
||||||
@@ -176,6 +181,32 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
numberOfLines={6}
|
numberOfLines={6}
|
||||||
textAlignVertical="top"
|
textAlignVertical="top"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Tools */}
|
||||||
|
<Text style={styles.label}>Tools context</Text>
|
||||||
|
<View style={styles.toolsRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.toolChip, toolNow && styles.toolChipActive]}
|
||||||
|
onPress={() => setToolNow(v => !v)}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
<Text style={[styles.toolChipText, toolNow && styles.toolChipTextActive]}>
|
||||||
|
🕒️ now
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.toolChip, toolUsername && styles.toolChipActive]}
|
||||||
|
onPress={() => setToolUsername(v => !v)}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
<Text style={[styles.toolChipText, toolUsername && styles.toolChipTextActive]}>
|
||||||
|
👤 username
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.toolsHint}>
|
||||||
|
Quand activés, ces données sont injectées automatiquement dans le contexte de l’agent au moment de l’inférence.
|
||||||
|
</Text>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -343,4 +374,39 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.surface,
|
color: colors.surface,
|
||||||
fontWeight: typography.weights.semibold,
|
fontWeight: typography.weights.semibold,
|
||||||
},
|
},
|
||||||
|
toolsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
toolChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: 7,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
toolChipActive: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.agentBg ?? colors.primary + '18',
|
||||||
|
},
|
||||||
|
toolChipText: {
|
||||||
|
fontSize: typography.sizes.sm,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: typography.weights.medium,
|
||||||
|
},
|
||||||
|
toolChipTextActive: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: typography.weights.semibold,
|
||||||
|
},
|
||||||
|
toolsHint: {
|
||||||
|
fontSize: typography.sizes.xs,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
lineHeight: 17,
|
||||||
|
marginTop: 2,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
|
ActivityIndicator,
|
||||||
|
ScrollView,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { Message } from '../../store/chatSlice';
|
import type { Message } from '../../store/chatSlice';
|
||||||
@@ -29,8 +31,13 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
? (message.metadata?.agentName as string | undefined) : undefined;
|
? (message.metadata?.agentName as string | undefined) : undefined;
|
||||||
const modelName = !isUser
|
const modelName = !isUser
|
||||||
? (message.metadata?.modelName as string | undefined) : undefined;
|
? (message.metadata?.modelName as string | undefined) : undefined;
|
||||||
|
const isThinking = !isUser
|
||||||
|
? (message.metadata?.isThinking as boolean | undefined) : undefined;
|
||||||
|
const reasoningContent = !isUser
|
||||||
|
? (message.metadata?.reasoningContent as string | undefined) : undefined;
|
||||||
|
|
||||||
const [showModel, setShowModel] = useState(false);
|
const [showModel, setShowModel] = useState(false);
|
||||||
|
const [showThinking, setShowThinking] = useState(false);
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const themeStyles = createStyles(colors);
|
const themeStyles = createStyles(colors);
|
||||||
|
|
||||||
@@ -39,24 +46,57 @@ export default function MessageBubble({ message }: MessageBubbleProps) {
|
|||||||
{agentName && (
|
{agentName && (
|
||||||
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
||||||
)}
|
)}
|
||||||
<Pressable
|
|
||||||
onLongPress={() =>
|
{/* ── Thinking indicator ────────────────────────────────────────── */}
|
||||||
navigation.navigate('MessageDetails', { messageId: message.id })
|
{!isUser && isThinking && (
|
||||||
}
|
<View style={themeStyles.thinkingSpinnerRow}>
|
||||||
delayLongPress={300}
|
<ActivityIndicator size="small" color={colors.textTertiary as string} />
|
||||||
style={({ pressed }) => [
|
<Text style={themeStyles.thinkingSpinnerText}>Thinking...</Text>
|
||||||
themeStyles.bubble,
|
</View>
|
||||||
isUser ? themeStyles.userBubble : themeStyles.assistantBubble,
|
)}
|
||||||
pressed && themeStyles.pressedBubble,
|
{!isUser && !isThinking && reasoningContent ? (
|
||||||
]}
|
<View style={themeStyles.thinkingWrapper}>
|
||||||
>
|
<TouchableOpacity
|
||||||
<Text style={[themeStyles.text, isUser && themeStyles.userText]}>
|
style={themeStyles.thinkingBadge}
|
||||||
{message.content || '...'}
|
onPress={() => setShowThinking(v => !v)}
|
||||||
</Text>
|
activeOpacity={0.7}
|
||||||
<Text style={[themeStyles.time, isUser && themeStyles.userTime]}>
|
>
|
||||||
{time}
|
<Text style={themeStyles.thinkingBadgeText}>
|
||||||
</Text>
|
🧠 Thinking {showThinking ? '▾' : '▸'}
|
||||||
</Pressable>
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{showThinking && (
|
||||||
|
<View style={themeStyles.thinkingCard}>
|
||||||
|
<ScrollView style={themeStyles.thinkingScroll} nestedScrollEnabled>
|
||||||
|
<Text style={themeStyles.thinkingCardText}>{reasoningContent}</Text>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* ── Main message bubble (hidden while thinking, always shown after) ── */}
|
||||||
|
{!isThinking && (
|
||||||
|
<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}>
|
<View style={themeStyles.metaRow}>
|
||||||
{liveRate !== undefined && (
|
{liveRate !== undefined && (
|
||||||
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
||||||
@@ -144,4 +184,54 @@ const createStyles = (colors: Record<string, unknown>) =>
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
|
// ── Thinking styles ──────────────────────────────────────────────────
|
||||||
|
thinkingSpinnerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
thinkingSpinnerText: {
|
||||||
|
fontSize: typography.sizes.sm,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
thinkingWrapper: {
|
||||||
|
maxWidth: '80%',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
thinkingBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 4,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
thinkingBadgeText: {
|
||||||
|
fontSize: typography.sizes.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: typography.weights.semibold,
|
||||||
|
},
|
||||||
|
thinkingCard: {
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
backgroundColor: colors.surfaceTertiary ?? colors.surfaceSecondary,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
borderLeftWidth: 2,
|
||||||
|
borderLeftColor: colors.textTertiary as string,
|
||||||
|
padding: spacing.sm,
|
||||||
|
maxHeight: 220,
|
||||||
|
},
|
||||||
|
thinkingScroll: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
thinkingCardText: {
|
||||||
|
fontSize: typography.sizes.xs,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 16,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, TouchableOpacity } from 'react-native';
|
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
import { createStyles } from './styles';
|
|
||||||
import { useTheme } from '../../theme/ThemeProvider';
|
import { useTheme } from '../../theme/ThemeProvider';
|
||||||
|
|
||||||
interface PermissionMessageProps {
|
interface PermissionMessageProps {
|
||||||
@@ -11,24 +10,62 @@ export default function PermissionMessage({
|
|||||||
onRequestPermission,
|
onRequestPermission,
|
||||||
}: PermissionMessageProps) {
|
}: PermissionMessageProps) {
|
||||||
const { colors } = useTheme();
|
const { colors } = useTheme();
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.permissionContainer}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<Text style={styles.permissionText}>
|
<Text style={styles.icon}>📁</Text>
|
||||||
📁 Permission requise
|
<Text style={[styles.title, { color: colors.text }]}>
|
||||||
|
Permission requise
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.permissionSubtext}>
|
<Text style={[styles.subtitle, { color: colors.text }]}>
|
||||||
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF
|
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF.
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.permissionButton}
|
style={[styles.button, { backgroundColor: colors.primary }]}
|
||||||
onPress={onRequestPermission}
|
onPress={onRequestPermission}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<Text style={styles.permissionButtonText}>
|
<Text style={[styles.buttonText, { color: colors.onPrimary }]}>
|
||||||
Autoriser l'accès aux fichiers
|
Autoriser l'accès aux fichiers
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
fontSize: 56,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '700',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 15,
|
||||||
|
textAlign: 'center',
|
||||||
|
opacity: 0.65,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingHorizontal: 28,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import type { ModelConfig } from '../../store/types';
|
|||||||
import ModelItem from './ModelItem';
|
import ModelItem from './ModelItem';
|
||||||
import DirectoryPicker from './DirectoryPicker';
|
import DirectoryPicker from './DirectoryPicker';
|
||||||
import DirectoryEditor from './DirectoryEditor';
|
import DirectoryEditor from './DirectoryEditor';
|
||||||
import PermissionMessage from './PermissionMessage';
|
|
||||||
import ModelsList from './ModelsList';
|
import ModelsList from './ModelsList';
|
||||||
import { createStyles } from './styles';
|
import { createStyles } from './styles';
|
||||||
import { useTheme } from '../../theme/ThemeProvider';
|
import { useTheme } from '../../theme/ThemeProvider';
|
||||||
@@ -381,9 +380,6 @@ export default function LocalModelsScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{!hasPermission && (
|
|
||||||
<PermissionMessage onRequestPermission={handleRequestPermission} />
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Models List */}
|
{/* Models List */}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { AppState } from 'react-native';
|
||||||
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs';
|
||||||
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
import LocalModelsScreen from './LocalModelsScreen';
|
import LocalModelsScreen from './LocalModelsScreen';
|
||||||
import HuggingFaceModelsScreen from './HuggingFaceModelsScreen';
|
import HuggingFaceModelsScreen from './HuggingFaceModelsScreen';
|
||||||
|
import PermissionMessage from './LocalModelsScreen/PermissionMessage';
|
||||||
|
import {
|
||||||
|
checkStoragePermission,
|
||||||
|
requestStoragePermission,
|
||||||
|
} from '../utils/permissions';
|
||||||
|
|
||||||
export type ModelsTabParamList = {
|
export type ModelsTabParamList = {
|
||||||
LocalModels: undefined;
|
LocalModels: undefined;
|
||||||
@@ -14,6 +21,31 @@ const Tab = createMaterialTopTabNavigator<ModelsTabParamList>();
|
|||||||
export default function ModelsScreen() {
|
export default function ModelsScreen() {
|
||||||
const { colors, scheme } = useTheme();
|
const { colors, scheme } = useTheme();
|
||||||
const isDark = scheme === 'dark';
|
const isDark = scheme === 'dark';
|
||||||
|
const [hasPermission, setHasPermission] = useState(false);
|
||||||
|
|
||||||
|
const checkPerm = useCallback(async () => {
|
||||||
|
const granted = await checkStoragePermission();
|
||||||
|
setHasPermission(granted);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkPerm();
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state === 'active') checkPerm();
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, [checkPerm]);
|
||||||
|
|
||||||
|
useFocusEffect(useCallback(() => { checkPerm(); }, [checkPerm]));
|
||||||
|
|
||||||
|
const handleRequestPermission = async () => {
|
||||||
|
await requestStoragePermission();
|
||||||
|
setTimeout(checkPerm, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return <PermissionMessage onRequestPermission={handleRequestPermission} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tab.Navigator
|
<Tab.Navigator
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, TextInput } from 'react-native';
|
||||||
import type { CompositeScreenProps } from '@react-navigation/native';
|
import type { CompositeScreenProps } from '@react-navigation/native';
|
||||||
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
import type { BottomTabScreenProps } from '@react-navigation/bottom-tabs';
|
||||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||||
import type { RootStackParamList, TabParamList } from '../navigation';
|
import type { RootStackParamList, TabParamList } from '../navigation';
|
||||||
import { useTheme } from '../theme/ThemeProvider';
|
import { useTheme } from '../theme/ThemeProvider';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import type { RootState, AppDispatch } from '../store';
|
||||||
|
import { loadSettings, saveUsername } from '../store/settingsSlice';
|
||||||
|
|
||||||
type TColors = {
|
type TColors = {
|
||||||
background: string;
|
background: string;
|
||||||
@@ -21,9 +24,8 @@ function makeStyles(colors: TColors) {
|
|||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 32,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -58,8 +60,47 @@ function makeStyles(colors: TColors) {
|
|||||||
segmentTextActive: {
|
segmentTextActive: {
|
||||||
color: colors.onPrimary ?? '#fff',
|
color: colors.onPrimary ?? '#fff',
|
||||||
},
|
},
|
||||||
buttonWrap: {
|
sectionLabel: {
|
||||||
marginTop: 20,
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
opacity: 0.5,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
marginTop: 28,
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
inputRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
saveBtn: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
borderRadius: 10,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
saveBtnText: {
|
||||||
|
color: colors.onPrimary ?? '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
savedHint: {
|
||||||
|
marginTop: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.primary,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -72,7 +113,26 @@ type Props = CompositeScreenProps<
|
|||||||
|
|
||||||
export default function SettingsScreen({}: Props) {
|
export default function SettingsScreen({}: Props) {
|
||||||
const { mode, setMode, colors } = useTheme();
|
const { mode, setMode, colors } = useTheme();
|
||||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
const styles = useMemo(() => makeStyles(colors as unknown as TColors), [colors]);
|
||||||
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
|
const storedUsername = useSelector((s: RootState) => s.settings.username);
|
||||||
|
|
||||||
|
const [localUsername, setLocalUsername] = useState(storedUsername);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(loadSettings());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalUsername(storedUsername);
|
||||||
|
}, [storedUsername]);
|
||||||
|
|
||||||
|
const handleSaveUsername = () => {
|
||||||
|
dispatch(saveUsername(localUsername.trim()));
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
const items: { key: 'light' | 'system' | 'dark' | 'neon'; label: string }[] = [
|
const items: { key: 'light' | 'system' | 'dark' | 'neon'; label: string }[] = [
|
||||||
{ key: 'light', label: 'Light' },
|
{ key: 'light', label: 'Light' },
|
||||||
@@ -83,8 +143,27 @@ export default function SettingsScreen({}: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||||
<Text style={[styles.text, { color: colors.text }]}>Settings</Text>
|
{/* Username */}
|
||||||
|
<Text style={styles.sectionLabel}>Nom d'utilisateur</Text>
|
||||||
|
<View style={styles.inputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={localUsername}
|
||||||
|
onChangeText={v => { setLocalUsername(v); setSaved(false); }}
|
||||||
|
placeholder="Ton prénom ou pseudo"
|
||||||
|
placeholderTextColor={`${colors.text}60`}
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleSaveUsername}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity style={styles.saveBtn} onPress={handleSaveUsername}>
|
||||||
|
<Text style={styles.saveBtnText}>Enregistrer</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{saved && <Text style={styles.savedHint}>✓ Enregistré</Text>}
|
||||||
|
|
||||||
|
{/* Theme */}
|
||||||
|
<Text style={styles.sectionLabel}>Thème</Text>
|
||||||
<View style={styles.segmentWrap}>
|
<View style={styles.segmentWrap}>
|
||||||
<View style={styles.segmentBar}>
|
<View style={styles.segmentBar}>
|
||||||
{items.map((it) => {
|
{items.map((it) => {
|
||||||
|
|||||||
@@ -83,10 +83,12 @@ export const generateResponse = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const agentsState = (getState() as RootState).agents;
|
const agentsState = (getState() as RootState).agents;
|
||||||
|
const username = (getState() as RootState).settings?.username ?? '';
|
||||||
const systemPrompt = resolveSystemPrompt(
|
const systemPrompt = resolveSystemPrompt(
|
||||||
effectiveCfg.systemPrompt,
|
effectiveCfg.systemPrompt,
|
||||||
params.activeAgentId,
|
params.activeAgentId,
|
||||||
agentsState?.agents ?? [],
|
agentsState?.agents ?? [],
|
||||||
|
username,
|
||||||
);
|
);
|
||||||
|
|
||||||
const oaiMessages = buildOAIMessages(params.messages, systemPrompt);
|
const oaiMessages = buildOAIMessages(params.messages, systemPrompt);
|
||||||
@@ -109,6 +111,9 @@ export const generateResponse = createAsyncThunk(
|
|||||||
let liveTokenCount = 0;
|
let liveTokenCount = 0;
|
||||||
const generationStart = Date.now();
|
const generationStart = Date.now();
|
||||||
let lastRateDispatch = 0;
|
let lastRateDispatch = 0;
|
||||||
|
// Track think-block state for live UI feedback
|
||||||
|
let lastCleanLength = 0;
|
||||||
|
let isThinkingActive = false;
|
||||||
|
|
||||||
const result: CompletionResult = await llamaContext.completion(
|
const result: CompletionResult = await llamaContext.completion(
|
||||||
samplingParams,
|
samplingParams,
|
||||||
@@ -132,16 +137,46 @@ export const generateResponse = createAsyncThunk(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// data.token is always the incremental raw piece — safe to append.
|
}
|
||||||
// data.content is cumulative parsed text (think-tags stripped) so
|
// Accumulate reasoning tokens and signal thinking state
|
||||||
// we do NOT use it here; the final clean version is applied via
|
if (data?.reasoning_content) {
|
||||||
// finalizeAssistantMessage after completion.
|
rawStreamReasoning += data.reasoning_content;
|
||||||
|
if (!isThinkingActive) {
|
||||||
|
isThinkingActive = true;
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: true } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Forward only the clean response via delta of cumulative data.content
|
||||||
|
// (think-tags already stripped by llama.rn with reasoning_format:'auto')
|
||||||
|
if (data?.content !== undefined && data.content.length > lastCleanLength) {
|
||||||
|
const delta = data.content.slice(lastCleanLength);
|
||||||
|
lastCleanLength = data.content.length;
|
||||||
|
if (isThinkingActive) {
|
||||||
|
isThinkingActive = false;
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (params.onToken) { params.onToken(delta); }
|
||||||
|
} else if (token && !data?.reasoning_content && data?.content === undefined) {
|
||||||
|
// Fallback: model without thinking support — forward raw token directly
|
||||||
if (params.onToken) { params.onToken(token); }
|
if (params.onToken) { params.onToken(token); }
|
||||||
}
|
}
|
||||||
if (data?.reasoning_content) { rawStreamReasoning += data.reasoning_content; }
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ensure isThinking is cleared if model ended inside a think block
|
||||||
|
if (isThinkingActive) {
|
||||||
|
dispatch({
|
||||||
|
type: 'chat/setMessageMeta',
|
||||||
|
payload: { id: params.assistantMessageId, meta: { isThinking: false } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const elapsed = (Date.now() - generationStart) / 1000;
|
const elapsed = (Date.now() - generationStart) / 1000;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'chat/setMessageMeta',
|
type: 'chat/setMessageMeta',
|
||||||
|
|||||||
@@ -153,15 +153,36 @@ export function resolveActiveStops(
|
|||||||
/**
|
/**
|
||||||
* Compose the effective system prompt: inject the selected agent's name and
|
* Compose the effective system prompt: inject the selected agent's name and
|
||||||
* system prompt in front of the model-level base prompt (if any).
|
* system prompt in front of the model-level base prompt (if any).
|
||||||
|
* Active tools are injected as context lines BEFORE the agent prompt.
|
||||||
*/
|
*/
|
||||||
export function resolveSystemPrompt(
|
export function resolveSystemPrompt(
|
||||||
basePrompt: string | undefined,
|
basePrompt: string | undefined,
|
||||||
activeAgentId: string | null | undefined,
|
activeAgentId: string | null | undefined,
|
||||||
agents: Agent[],
|
agents: Agent[],
|
||||||
|
username?: string,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
if (!activeAgentId) { return basePrompt; }
|
if (!activeAgentId) { return basePrompt; }
|
||||||
const agent = agents.find(a => a.id === activeAgentId);
|
const agent = agents.find(a => a.id === activeAgentId);
|
||||||
if (!agent) { return basePrompt; }
|
if (!agent) { return basePrompt; }
|
||||||
const agentBlock = `[Agent: ${agent.name}]\n${agent.systemPrompt}`;
|
|
||||||
|
// Build tools context block
|
||||||
|
const toolLines: string[] = [];
|
||||||
|
if (agent.tools?.now) {
|
||||||
|
const now = new Date();
|
||||||
|
const formatted = now.toLocaleString('fr-FR', {
|
||||||
|
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
toolLines.push(`Date et heure actuelles : ${formatted}`);
|
||||||
|
}
|
||||||
|
if (agent.tools?.username && username) {
|
||||||
|
toolLines.push(`Nom de l'utilisateur : ${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsBlock = toolLines.length > 0
|
||||||
|
? `[Contexte]\n${toolLines.join('\n')}\n`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const agentBlock = `${toolsBlock}[Agent: ${agent.name}]\n${agent.systemPrompt}`;
|
||||||
return basePrompt ? `${agentBlock}\n\n${basePrompt}` : agentBlock;
|
return basePrompt ? `${agentBlock}\n\n${basePrompt}` : agentBlock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import modelsReducer from './modelsSlice';
|
|||||||
import chatReducer from './chatSlice';
|
import chatReducer from './chatSlice';
|
||||||
import hardwareReducer from './hardwareSlice';
|
import hardwareReducer from './hardwareSlice';
|
||||||
import agentsReducer from './agentsSlice';
|
import agentsReducer from './agentsSlice';
|
||||||
|
import settingsReducer from './settingsSlice';
|
||||||
import { debouncedSaveChatState } from './persistence';
|
import { debouncedSaveChatState } from './persistence';
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
@@ -11,6 +12,7 @@ const store = configureStore({
|
|||||||
chat: chatReducer,
|
chat: chatReducer,
|
||||||
hardware: hardwareReducer,
|
hardware: hardwareReducer,
|
||||||
agents: agentsReducer,
|
agents: agentsReducer,
|
||||||
|
settings: settingsReducer,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
46
app/src/store/settingsSlice.ts
Normal file
46
app/src/store/settingsSlice.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const STORAGE_KEY = '@settings_v1';
|
||||||
|
|
||||||
|
export interface SettingsState {
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: SettingsState = { username: '' };
|
||||||
|
|
||||||
|
export const loadSettings = createAsyncThunk('settings/load', async () => {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) { return initialState; }
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as SettingsState;
|
||||||
|
} catch {
|
||||||
|
return initialState;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveUsername = createAsyncThunk(
|
||||||
|
'settings/saveUsername',
|
||||||
|
async (username: string) => {
|
||||||
|
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
||||||
|
const current = raw ? (JSON.parse(raw) as Partial<SettingsState>) : {};
|
||||||
|
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...current, username }));
|
||||||
|
return username;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsSlice = createSlice({
|
||||||
|
name: 'settings',
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: builder => {
|
||||||
|
builder.addCase(loadSettings.fulfilled, (state, action) => {
|
||||||
|
state.username = action.payload.username ?? '';
|
||||||
|
});
|
||||||
|
builder.addCase(saveUsername.fulfilled, (state, action) => {
|
||||||
|
state.username = action.payload;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default settingsSlice.reducer;
|
||||||
@@ -139,6 +139,11 @@ export interface Agent {
|
|||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
/** URI du fichier image local (dans DocumentDirectoryPath) ou null */
|
/** URI du fichier image local (dans DocumentDirectoryPath) ou null */
|
||||||
avatarUri?: string | null;
|
avatarUri?: string | null;
|
||||||
|
/** Tools actifs pour cet agent — injectés automatiquement dans le prompt système */
|
||||||
|
tools?: {
|
||||||
|
now?: boolean;
|
||||||
|
username?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentsState {
|
export interface AgentsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user