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

0
.gitignore vendored Normal file
View File

2
app/.bundle/config Normal file
View File

@@ -0,0 +1,2 @@
BUNDLE_PATH: "vendor/bundle"
BUNDLE_FORCE_RUBY_PLATFORM: 1

46
app/.eslintrc.js Normal file
View File

@@ -0,0 +1,46 @@
module.exports = {
root: true,
extends: '@react-native',
rules: {
// React Native
'react-native/no-unused-styles': 'error',
'react-native/no-inline-styles': 'warn',
'react-native/no-color-literals': 'warn',
'react-native/no-raw-text': 'off',
// TypeScript
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'warn',
'@typescript-eslint/no-unused-expressions': 'error',
'@typescript-eslint/no-shadow': 'error',
'no-shadow': 'off', // Désactivé au profit de la version TypeScript
// React/Hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
'react/jsx-no-bind': ['warn', { allowArrowFunctions: true }],
'react/no-array-index-key': 'error',
// Code Quality
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
'eqeqeq': ['error', 'always'],
'no-duplicate-imports': 'error',
'no-return-await': 'error',
'no-param-reassign': ['error', { props: false }],
'no-throw-literal': 'error',
'max-lines': ['error', { max: 600, skipBlankLines: true }],
'max-len': ['error', { code: 100, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
// Style
'arrow-body-style': ['error', 'as-needed'],
'object-shorthand': ['error', 'always'],
'prefer-template': 'error',
'prefer-destructuring': ['error', { array: false, object: true }],
},
};

76
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,76 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
**/.xcode.env.local
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
.kotlin/
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# Bundle artifact
*.jsbundle
# Ruby / CocoaPods
**/Pods/
/vendor/bundle/
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# testing
/coverage
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
pocketpal-ai-main

5
app/.prettierrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
trailingComma: 'all',
};

19
app/.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,19 @@
{
"servers": {
"chrome-devtools": {
"type": "stdio",
"command": "npx",
"args": ["chrome-devtools-mcp@latest"]
},
"context7": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"]
},
"ui-expert": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@reallygood83/ui-expert-mcp"]
}
}
}

1
app/.watchmanconfig Normal file
View File

@@ -0,0 +1 @@
{}

103
app/App.tsx Normal file
View File

@@ -0,0 +1,103 @@
/**
* Sample React Native App
* https://github.com/facebook/react-native
*
* @format
*/
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, StatusBar, View, StyleSheet } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer, DefaultTheme, DarkTheme } from '@react-navigation/native';
import { Provider, useDispatch } from 'react-redux';
import store, { type AppDispatch } from './src/store';
import AppNavigator from './src/navigation';
import { ThemeProvider, useTheme } from './src/theme/ThemeProvider';
import { loadAgents } from './src/store/agentsSlice';
import { rehydrateChat } from './src/store/chatSlice';
import { loadChatState } from './src/store/persistence';
function InnerApp() {
const { scheme, colors } = useTheme();
const isDarkMode = scheme === 'dark';
const dispatch = useDispatch<AppDispatch>();
const [ready, setReady] = useState(false);
useEffect(() => {
const init = async () => {
// Run both in parallel — neither depends on the other.
const [persisted] = await Promise.all([
loadChatState(),
dispatch(loadAgents()),
]);
if (persisted) {
dispatch(rehydrateChat(persisted));
}
setReady(true);
};
init();
}, [dispatch]);
const navTheme = isDarkMode
? {
...DarkTheme,
colors: {
...DarkTheme.colors,
background: colors.background,
card: colors.card,
},
}
: {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
background: colors.background,
card: colors.card,
},
};
const containerStyle = { backgroundColor: colors.background };
if (!ready) {
return (
<View style={[styles.center, containerStyle]}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
hidden={true}
/>
<NavigationContainer theme={navTheme}>
<AppNavigator />
</NavigationContainer>
</>
);
}
function App() {
return (
<Provider store={store}>
<SafeAreaProvider>
<ThemeProvider>
<InnerApp />
</ThemeProvider>
</SafeAreaProvider>
</Provider>
);
}
const styles = StyleSheet.create({
center: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
});
export default App;

16
app/Gemfile Normal file
View File

@@ -0,0 +1,16 @@
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'

97
app/README.md Normal file
View File

@@ -0,0 +1,97 @@
This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli).
# Getting Started
> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding.
## Step 1: Start Metro
First, you will need to run **Metro**, the JavaScript build tool for React Native.
To start the Metro dev server, run the following command from the root of your React Native project:
```sh
# Using npm
npm start
# OR using Yarn
yarn start
```
## Step 2: Build and run your app
With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app:
### Android
```sh
# Using npm
npm run android
# OR using Yarn
yarn android
```
### iOS
For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps).
The first time you create a new project, run the Ruby bundler to install CocoaPods itself:
```sh
bundle install
```
Then, and every time you update your native dependencies, run:
```sh
bundle exec pod install
```
For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html).
```sh
# Using npm
npm run ios
# OR using Yarn
yarn ios
```
If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device.
This is one way to run your app — you can also build it directly from Android Studio or Xcode.
## Step 3: Modify your app
Now that you have successfully run the app, let's make changes!
Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh).
When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload:
- **Android**: Press the <kbd>R</kbd> key twice or select **"Reload"** from the **Dev Menu**, accessed via <kbd>Ctrl</kbd> + <kbd>M</kbd> (Windows/Linux) or <kbd>Cmd ⌘</kbd> + <kbd>M</kbd> (macOS).
- **iOS**: Press <kbd>R</kbd> in iOS Simulator.
## Congratulations! :tada:
You've successfully run and modified your React Native App. :partying_face:
### Now what?
- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps).
- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started).
# Troubleshooting
If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page.
# Learn More
To learn more about React Native, take a look at the following resources:
- [React Native Website](https://reactnative.dev) - learn more about React Native.
- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment.
- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**.
- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts.
- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native.

View File

@@ -0,0 +1,13 @@
/**
* @format
*/
import React from 'react';
import ReactTestRenderer from 'react-test-renderer';
import App from '../App';
test('renders correctly', async () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
});
});

View File

@@ -0,0 +1,119 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
// cliFile = file("../../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.mymobileagent"
defaultConfig {
applicationId "com.mymobileagent"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

10
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,10 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:

View File

@@ -0,0 +1,34 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:requestLegacyExternalStorage="true"
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustPan"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,61 @@
package com.mymobileagent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.WindowInsets
import android.view.WindowInsetsController
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "MyMobileagent"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
hideSystemUI()
}
override fun onWindowFocusChanged(hasFocus: Boolean) {
super.onWindowFocusChanged(hasFocus)
if (hasFocus) {
hideSystemUI()
}
}
private fun hideSystemUI() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Use new API for Android 11+
window.setDecorFitsSystemWindows(false)
window.insetsController?.let {
it.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
it.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} else {
// Use older API for Android 10 and below
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
}
}
}

View File

@@ -0,0 +1,28 @@
package com.mymobileagent
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
add(PermissionsPackage())
},
)
}
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
}

View File

@@ -0,0 +1,55 @@
package com.mymobileagent
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class PermissionsModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String {
return "PermissionsModule"
}
@ReactMethod
fun checkManageFilesPermission(promise: Promise) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val hasPermission = Environment.isExternalStorageManager()
promise.resolve(hasPermission)
} else {
// Pour les versions < Android 11, toujours true
promise.resolve(true)
}
} catch (e: Exception) {
promise.reject("ERROR", "Impossible de vérifier la permission: ${e.message}")
}
}
@ReactMethod
fun openManageFilesSettings(promise: Promise) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${reactApplicationContext.packageName}")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
reactApplicationContext.startActivity(intent)
promise.resolve(true)
} else {
// Pour les versions < Android 11, ouvrir les paramètres normaux
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:${reactApplicationContext.packageName}")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
reactApplicationContext.startActivity(intent)
promise.resolve(true)
}
} catch (e: Exception) {
promise.reject("ERROR", "Impossible d'ouvrir les paramètres: ${e.message}")
}
}
}

View File

@@ -0,0 +1,18 @@
package com.mymobileagent
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ReactShadowNode
import com.facebook.react.uimanager.ViewManager
class PermissionsPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(PermissionsModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
return emptyList()
}
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">MyMobileagent</string>
</resources>

View File

@@ -0,0 +1,9 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
</resources>

21
app/android/build.gradle Normal file
View File

@@ -0,0 +1,21 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.1.20"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
}
}
apply plugin: "com.facebook.react.rootproject"

View File

@@ -0,0 +1,44 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
app/android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

99
app/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,99 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,6 @@
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'MyMobileagent'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')

4
app/app.json Normal file
View File

@@ -0,0 +1,4 @@
{
"name": "MyMobileagent",
"displayName": "MyMobileagent"
}

BIN
app/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

3
app/babel.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
};

9
app/index.js Normal file
View File

@@ -0,0 +1,9 @@
/**
* @format
*/
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);

11
app/ios/.xcode.env Normal file
View File

@@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)

View File

@@ -0,0 +1,475 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
0C80B921A6F3F58F76C31292 /* libPods-MyMobileagent.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-MyMobileagent.a */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* MyMobileagent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyMobileagent.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = MyMobileagent/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = MyMobileagent/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = MyMobileagent/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
3B4392A12AC88292D35C810B /* Pods-MyMobileagent.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MyMobileagent.debug.xcconfig"; path = "Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent.debug.xcconfig"; sourceTree = "<group>"; };
5709B34CF0A7D63546082F79 /* Pods-MyMobileagent.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MyMobileagent.release.xcconfig"; path = "Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent.release.xcconfig"; sourceTree = "<group>"; };
5DCACB8F33CDC322A6C60F78 /* libPods-MyMobileagent.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MyMobileagent.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = MyMobileagent/AppDelegate.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = MyMobileagent/LaunchScreen.storyboard; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
0C80B921A6F3F58F76C31292 /* libPods-MyMobileagent.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
13B07FAE1A68108700A75B9A /* MyMobileagent */ = {
isa = PBXGroup;
children = (
13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
);
name = MyMobileagent;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-MyMobileagent.a */,
);
name = Frameworks;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* MyMobileagent */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* MyMobileagent.app */,
);
name = Products;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
3B4392A12AC88292D35C810B /* Pods-MyMobileagent.debug.xcconfig */,
5709B34CF0A7D63546082F79 /* Pods-MyMobileagent.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* MyMobileagent */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "MyMobileagent" */;
buildPhases = (
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */,
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = MyMobileagent;
productName = MyMobileagent;
productReference = 13B07F961A680F5B00A75B9A /* MyMobileagent.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1210;
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1120;
};
};
};
buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "MyMobileagent" */;
compatibilityVersion = "Xcode 12.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* MyMobileagent */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/.xcode.env",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n";
};
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-MyMobileagent-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-MyMobileagent/Pods-MyMobileagent-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-MyMobileagent.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = MyMobileagent/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = MyMobileagent;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-MyMobileagent.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 1;
INFOPLIST_FILE = MyMobileagent/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = MyMobileagent;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
83CBBA201A601CBA00E9B192 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
SDKROOT = iphoneos;
};
name = Debug;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-DFOLLY_USE_LIBCPP=1",
"-DFOLLY_CFG_NO_COROUTINES=1",
"-DFOLLY_HAVE_CLOCK_GETTIME=1",
);
SDKROOT = iphoneos;
VALIDATE_PRODUCT = YES;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "MyMobileagent" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "MyMobileagent" */ = {
isa = XCConfigurationList;
buildConfigurations = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
}

View File

@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1210"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MyMobileagent.app"
BlueprintName = "MyMobileagent"
ReferencedContainer = "container:MyMobileagent.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "MyMobileagentTests.xctest"
BlueprintName = "MyMobileagentTests"
ReferencedContainer = "container:MyMobileagent.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MyMobileagent.app"
BlueprintName = "MyMobileagent"
ReferencedContainer = "container:MyMobileagent.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "MyMobileagent.app"
BlueprintName = "MyMobileagent"
ReferencedContainer = "container:MyMobileagent.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,48 @@
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "MyMobileagent",
in: window,
launchOptions: launchOptions
)
return true
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}

View File

@@ -0,0 +1,53 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>MyMobileagent</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<!-- Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection! -->
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>

View File

@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="MyMobileagent" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.173913043478265" y="375"/>
</scene>
</scenes>
</document>

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>

34
app/ios/Podfile Normal file
View File

@@ -0,0 +1,34 @@
# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
'require.resolve(
"react-native/scripts/react_native_pods.rb",
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported
prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
use_frameworks! :linkage => linkage.to_sym
end
target 'MyMobileagent' do
config = use_native_modules!
use_react_native!(
:path => config[:reactNativePath],
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
# :ccache_enabled => true
)
end
end

3
app/jest.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
preset: 'react-native',
};

11
app/metro.config.js Normal file
View File

@@ -0,0 +1,11 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
/**
* Metro configuration
* https://reactnative.dev/docs/metro
*
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);

Binary file not shown.

12633
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
app/package.json Normal file
View File

@@ -0,0 +1,59 @@
{
"name": "MyMobileagent",
"version": "0.0.1",
"private": true,
"scripts": {
"android": "react-native run-android",
"ios": "react-native run-ios",
"lint": "eslint .",
"start": "react-native start",
"test": "jest"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/geolocation": "^3.4.0",
"@react-native-documents/picker": "^12.0.1",
"@react-native/new-app-screen": "0.84.0",
"@react-navigation/bottom-tabs": "^7.14.0",
"@react-navigation/material-top-tabs": "^7.4.13",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.13.0",
"@reduxjs/toolkit": "^2.11.2",
"llama.rn": "^0.11.1",
"react": "19.2.3",
"react-native": "0.84.0",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "^2.30.0",
"react-native-pager-view": "^8.0.0",
"react-native-safe-area-context": "^5.6.2",
"react-native-screens": "^4.23.0",
"react-native-tab-view": "^4.2.2",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@babel/runtime": "^7.25.0",
"@react-native-community/cli": "20.1.0",
"@react-native-community/cli-platform-android": "20.1.0",
"@react-native-community/cli-platform-ios": "20.1.0",
"@react-native/babel-preset": "0.84.0",
"@react-native/eslint-config": "0.84.0",
"@react-native/metro-config": "0.84.0",
"@react-native/typescript-config": "0.84.0",
"@types/jest": "^29.5.13",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19.1.0",
"eslint": "^8.19.0",
"eslint-plugin-react-native": "^5.0.0",
"jest": "^29.6.3",
"patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"prettier": "2.8.8",
"react-test-renderer": "19.2.3",
"typescript": "^5.8.3"
},
"engines": {
"node": ">= 22.11.0"
}
}

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

Some files were not shown because too many files have changed in this diff Show More