Initial commit
2
app/.bundle/config
Normal file
@@ -0,0 +1,2 @@
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
BUNDLE_FORCE_RUBY_PLATFORM: 1
|
||||
46
app/.eslintrc.js
Normal 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
@@ -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
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
arrowParens: 'avoid',
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
};
|
||||
19
app/.vscode/mcp.json
vendored
Normal 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
@@ -0,0 +1 @@
|
||||
{}
|
||||
103
app/App.tsx
Normal 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
@@ -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
@@ -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.
|
||||
13
app/__tests__/App.test.tsx
Normal 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 />);
|
||||
});
|
||||
});
|
||||
119
app/android/app/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
app/android/app/debug.keystore
Normal file
10
app/android/app/proguard-rules.pro
vendored
Normal 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:
|
||||
34
app/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
BIN
app/android/app/src/main/assets/ggml-hexagon/libggml-htp-v69.so
Normal file
BIN
app/android/app/src/main/assets/ggml-hexagon/libggml-htp-v73.so
Normal file
BIN
app/android/app/src/main/assets/ggml-hexagon/libggml-htp-v75.so
Normal file
BIN
app/android/app/src/main/assets/ggml-hexagon/libggml-htp-v79.so
Normal file
BIN
app/android/app/src/main/assets/ggml-hexagon/libggml-htp-v81.so
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
BIN
app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
3
app/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">MyMobileagent</string>
|
||||
</resources>
|
||||
9
app/android/app/src/main/res/values/styles.xml
Normal 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
@@ -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"
|
||||
44
app/android/gradle.properties
Normal 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
|
||||
BIN
app/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
app/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@@ -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
@@ -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
|
||||
6
app/android/settings.gradle
Normal 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
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "MyMobileagent",
|
||||
"displayName": "MyMobileagent"
|
||||
}
|
||||
BIN
app/assets/logo.png
Normal file
|
After Width: | Height: | Size: 239 KiB |
3
app/babel.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
};
|
||||
9
app/index.js
Normal 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
@@ -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)
|
||||
475
app/ios/MyMobileagent.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
@@ -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>
|
||||
48
app/ios/MyMobileagent/AppDelegate.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
app/ios/MyMobileagent/Images.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
59
app/ios/MyMobileagent/Info.plist
Normal 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>
|
||||
47
app/ios/MyMobileagent/LaunchScreen.storyboard
Normal 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>
|
||||
37
app/ios/MyMobileagent/PrivacyInfo.xcprivacy
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
preset: 'react-native',
|
||||
};
|
||||
11
app/metro.config.js
Normal 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);
|
||||
BIN
app/models/tiny-random-llama.gguf
Normal file
12633
app/package-lock.json
generated
Normal file
59
app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
app/src/navigation/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
|
||||
import SettingsScreen from '../screens/SettingsScreen';
|
||||
import ModelsScreen from '../screens/ModelsScreen';
|
||||
import ChatScreen from '../screens/ChatScreen';
|
||||
import HardwareInfoScreen from '../screens/HardwareInfoScreen';
|
||||
import AgentsScreen from '../screens/AgentsScreen';
|
||||
import ModelConfigScreen from '../screens/LocalModelsScreen/ModelConfigScreen';
|
||||
import MessageDetails from '../screens/ChatScreen/MessageDetails';
|
||||
import LandingScreen from '../screens/LandingScreen';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Landing: undefined;
|
||||
MainTabs: { screen?: keyof TabParamList } | undefined;
|
||||
ModelConfig: { modelPath: string; modelName: string };
|
||||
MessageDetails: { messageId: string };
|
||||
};
|
||||
|
||||
export type TabParamList = {
|
||||
Chat: undefined;
|
||||
Models: undefined;
|
||||
Agents: undefined;
|
||||
Hardware: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
const RootStack = createNativeStackNavigator<RootStackParamList>();
|
||||
const Tab = createBottomTabNavigator<TabParamList>();
|
||||
|
||||
function TabNavigator() {
|
||||
const { colors, scheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: scheme === 'neon' ? colors.textSecondary ?? '#A7F3D0' : colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
borderTopColor: colors.surfaceSecondary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="Models"
|
||||
component={ModelsScreen}
|
||||
options={{ title: 'Modèles' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Chat"
|
||||
component={ChatScreen}
|
||||
options={{ title: 'Chat' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Agents"
|
||||
component={AgentsScreen}
|
||||
options={{ title: 'Agents' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Hardware"
|
||||
component={HardwareInfoScreen}
|
||||
options={{ title: 'Matériel' }}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{ title: 'Réglages' }}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppNavigator() {
|
||||
return (
|
||||
<RootStack.Navigator
|
||||
screenOptions={{ headerShown: false }}
|
||||
initialRouteName="Landing"
|
||||
>
|
||||
<RootStack.Screen
|
||||
name="Landing"
|
||||
component={LandingScreen}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="MainTabs"
|
||||
component={TabNavigator}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="ModelConfig"
|
||||
component={ModelConfigScreen}
|
||||
options={{ headerShown: true, title: 'Configuration du modèle' }}
|
||||
/>
|
||||
<RootStack.Screen
|
||||
name="MessageDetails"
|
||||
component={MessageDetails}
|
||||
options={{ headerShown: true, title: 'Détails du message' }}
|
||||
/>
|
||||
</RootStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
344
app/src/screens/AgentsScreen/AgentEditor.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { pick, types } from '@react-native-documents/picker';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import type { AppDispatch } from '../../store';
|
||||
import { saveAgent } from '../../store/agentsSlice';
|
||||
import type { Agent } from '../../store/types';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agent?: Agent | null; // null = new agent
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function makeId() {
|
||||
return `agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
}
|
||||
|
||||
export default function AgentEditor({ visible, agent, onClose }: Props) {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const [name, setName] = useState(agent?.name ?? '');
|
||||
const [prompt, setPrompt] = useState(
|
||||
agent?.systemPrompt ?? 'Tu es un assistant utile et concis.',
|
||||
);
|
||||
const [avatarUri, setAvatarUri] = useState<string | null>(
|
||||
agent?.avatarUri ?? null,
|
||||
);
|
||||
const [pickingImage, setPickingImage] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Reset fields when modal opens with a new agent
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
setName(agent?.name ?? '');
|
||||
setPrompt(agent?.systemPrompt ?? 'Tu es un assistant utile et concis.');
|
||||
setAvatarUri(agent?.avatarUri ?? null);
|
||||
}
|
||||
}, [visible, agent]);
|
||||
|
||||
const handlePickImage = async () => {
|
||||
try {
|
||||
setPickingImage(true);
|
||||
const [result] = await pick({ type: [types.images] });
|
||||
if (!result?.uri) return;
|
||||
|
||||
// Copy into app-local storage so the URI survives
|
||||
const ext = result.name?.split('.').pop() ?? 'jpg';
|
||||
const destName = `agent_avatar_${Date.now()}.${ext}`;
|
||||
const destPath = `${RNFS.DocumentDirectoryPath}/${destName}`;
|
||||
await RNFS.copyFile(result.uri, destPath);
|
||||
setAvatarUri(`file://${destPath}`);
|
||||
} catch (e: unknown) {
|
||||
// User cancelled or error
|
||||
if (!String(e).includes('cancelled') && !String(e).includes('DOCUMENT_PICKER_CANCELED')) {
|
||||
Alert.alert('Erreur', "Impossible de charger l'image");
|
||||
}
|
||||
} finally {
|
||||
setPickingImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
Alert.alert('Nom requis', "Donne un nom à l'agent");
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
Alert.alert('Prompt requis', 'Le prompt système ne peut pas être vide');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated: Agent = {
|
||||
id: agent?.id ?? makeId(),
|
||||
name: name.trim(),
|
||||
systemPrompt: prompt.trim(),
|
||||
avatarUri: avatarUri ?? null,
|
||||
};
|
||||
await dispatch(saveAgent(updated));
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitial = name.trim()[0]?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.backdrop} onPress={onClose}>
|
||||
<Pressable style={styles.sheet} onPress={() => {}}>
|
||||
{/* Handle */}
|
||||
<View style={styles.handle} />
|
||||
|
||||
<Text style={styles.title}>
|
||||
{agent ? "Modifier l'agent" : 'Nouvel agent'}
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Avatar picker */}
|
||||
<View style={styles.avatarRow}>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarBtn}
|
||||
onPress={handlePickImage}
|
||||
disabled={pickingImage}
|
||||
>
|
||||
{pickingImage ? (
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
) : avatarUri ? (
|
||||
<Image source={{ uri: avatarUri }} style={styles.avatarImg} />
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarInitial}>{avatarInitial}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.avatarEditBadge}>
|
||||
<Text style={styles.avatarEditBadgeText}>✎</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{avatarUri && (
|
||||
<TouchableOpacity
|
||||
style={styles.removeAvatarBtn}
|
||||
onPress={() => setAvatarUri(null)}
|
||||
>
|
||||
<Text style={styles.removeAvatarText}>Supprimer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Name */}
|
||||
<Text style={styles.label}>Nom de l'agent</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="Ex: Expert Python"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
maxLength={60}
|
||||
/>
|
||||
|
||||
{/* System prompt */}
|
||||
<Text style={styles.label}>Prompt système</Text>
|
||||
<TextInput
|
||||
style={[styles.input, styles.multiline]}
|
||||
value={prompt}
|
||||
onChangeText={setPrompt}
|
||||
placeholder="Tu es un expert en…"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
numberOfLines={6}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
</ScrollView>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity style={styles.cancelBtn} onPress={onClose}>
|
||||
<Text style={styles.cancelText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveBtn, saving && styles.saveBtnSaving]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color={colors.surface} size="small" />
|
||||
) : (
|
||||
<Text style={styles.saveText}>
|
||||
{agent ? 'Mettre à jour' : 'Créer'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = 72;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
sheet: {
|
||||
backgroundColor: colors.surface,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xxl,
|
||||
maxHeight: '90%',
|
||||
},
|
||||
handle: {
|
||||
width: 36,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
alignSelf: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarBtn: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImg: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarInitial: {
|
||||
fontSize: 28,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.surface,
|
||||
},
|
||||
avatarEditBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.textSecondary,
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarEditBadgeText: {
|
||||
fontSize: 11,
|
||||
color: colors.surface,
|
||||
},
|
||||
removeAvatarBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
removeAvatarText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.error,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
multiline: {
|
||||
height: 140,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
cancelBtn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
saveBtn: {
|
||||
flex: 2,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveBtnSaving: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
276
app/src/screens/AgentsScreen/index.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import { loadAgents, deleteAgent } from '../../store/agentsSlice';
|
||||
import type { Agent } from '../../store/types';
|
||||
import AgentEditor from './AgentEditor';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
export default function AgentsScreen() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const agents = useSelector((s: RootState) => s.agents.agents);
|
||||
|
||||
const [editorVisible, setEditorVisible] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(loadAgents());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleNew = () => {
|
||||
setEditingAgent(null);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (agent: Agent) => {
|
||||
setEditingAgent(agent);
|
||||
setEditorVisible(true);
|
||||
};
|
||||
|
||||
const handleDelete = (agent: Agent) => {
|
||||
Alert.alert(
|
||||
'Supprimer l\'agent',
|
||||
`Supprimer "${agent.name}" ?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Supprimer',
|
||||
style: 'destructive',
|
||||
onPress: () => dispatch(deleteAgent(agent.id)),
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: Agent }) => {
|
||||
const initial = item.name[0]?.toUpperCase() ?? '?';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.card}
|
||||
onPress={() => handleEdit(item)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{item.avatarUri ? (
|
||||
<Image source={{ uri: item.avatarUri }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarInitial}>{initial}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.agentName}>{item.name}</Text>
|
||||
<Text style={styles.agentPrompt} numberOfLines={2}>
|
||||
{item.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Actions */}
|
||||
<View style={styles.cardActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.editBtn}
|
||||
onPress={() => handleEdit(item)}
|
||||
>
|
||||
<Text style={styles.editBtnText}>✎</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => handleDelete(item)}
|
||||
>
|
||||
<Text style={styles.deleteBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>Agents</Text>
|
||||
<TouchableOpacity style={styles.addBtn} onPress={handleNew}>
|
||||
<Text style={styles.addBtnText}>+ Nouvel agent</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={agents}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.list}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyIcon}>🤖</Text>
|
||||
<Text style={styles.emptyTitle}>Aucun agent</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Créez un agent avec un nom, une image et un prompt système
|
||||
personnalisé.
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.emptyBtn} onPress={handleNew}>
|
||||
<Text style={styles.emptyBtnText}>Créer mon premier agent</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
<AgentEditor
|
||||
visible={editorVisible}
|
||||
agent={editingAgent}
|
||||
onClose={() => setEditorVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = 52;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: typography.sizes.xl,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
addBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
addBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
list: {
|
||||
padding: spacing.md,
|
||||
flexGrow: 1,
|
||||
},
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
shadowColor: colors.shadow,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 3,
|
||||
elevation: 2,
|
||||
},
|
||||
avatar: {
|
||||
width: AVATAR_SIZE,
|
||||
height: AVATAR_SIZE,
|
||||
borderRadius: AVATAR_SIZE / 2,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarInitial: {
|
||||
fontSize: 22,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.surface,
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
agentName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 3,
|
||||
},
|
||||
agentPrompt: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
cardActions: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
editBtn: {
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
editBtnText: {
|
||||
fontSize: 16,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.errorBg,
|
||||
},
|
||||
deleteBtnText: {
|
||||
fontSize: 14,
|
||||
color: colors.error,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
marginTop: 80,
|
||||
},
|
||||
emptyIcon: {
|
||||
fontSize: 56,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
emptyBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
emptyBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
481
app/src/screens/ChatScreen.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../store';
|
||||
import {
|
||||
addMessage,
|
||||
setSelectedModel,
|
||||
startAssistantMessage,
|
||||
updateLastMessage,
|
||||
clearError,
|
||||
createConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
setActiveAgent,
|
||||
setMessageMeta,
|
||||
} from '../store/chatSlice';
|
||||
import { generateResponse, stopGeneration } from '../store/chatThunks';
|
||||
import MessageBubble from './ChatScreen/MessageBubble';
|
||||
import ChatInput from './ChatScreen/ChatInput';
|
||||
import ChatDrawer from './ChatScreen/ChatDrawer';
|
||||
import AgentPickerModal from './ChatScreen/AgentPickerModal';
|
||||
import MultiAgentPickerModal from './ChatScreen/MultiAgentPickerModal';
|
||||
import { spacing, typography } from '../theme/lightTheme';
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const {
|
||||
conversations, activeConversationId, selectedModel,
|
||||
error, temperature, maxTokens, isInferring,
|
||||
} = useSelector((state: RootState) => state.chat);
|
||||
const { localModels, currentLoadedModel } = useSelector(
|
||||
(s: RootState) => s.models,
|
||||
);
|
||||
const agents = useSelector((s: RootState) => s.agents.agents);
|
||||
const activeConv = conversations.find(c => c.id === activeConversationId);
|
||||
const messages = activeConv?.messages ?? [];
|
||||
const activeTitle = activeConv?.title ?? 'Nouveau chat';
|
||||
const activeAgentId = activeConv?.activeAgentId ?? null;
|
||||
const activeAgent = activeAgentId
|
||||
? (agents.find(a => a.id === activeAgentId) ?? null) : null;
|
||||
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [agentPickerVisible, setAgentPickerVisible] = useState(false);
|
||||
const [roundtableMode, setRoundtableMode] = useState(false);
|
||||
const [multiPickerVisible, setMultiPickerVisible] = useState(false);
|
||||
const [roundtableSelectedIds, setRoundtableSelectedIds] = useState<string[]>([]);
|
||||
const [roundtableRunning, setRoundtableRunning] = useState(false);
|
||||
const roundtableStopRef = useRef(false);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentLoadedModel) { dispatch(setSelectedModel(currentLoadedModel)); }
|
||||
}, [currentLoadedModel, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
Alert.alert("Erreur d'inférence", error, [
|
||||
{ text: 'OK', onPress: () => dispatch(clearError()) },
|
||||
]);
|
||||
}
|
||||
}, [error, dispatch]);
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!selectedModel || isInferring || roundtableRunning) { return; }
|
||||
// If roundtable mode is active, start the roundtable flow instead of a single send
|
||||
if (roundtableMode) {
|
||||
await runRoundtable(inputText.trim());
|
||||
return;
|
||||
}
|
||||
if (!inputText.trim()) { return; }
|
||||
const userMessage = {
|
||||
id: `msg-${Date.now()}`, role: 'user' as const,
|
||||
content: inputText.trim(), timestamp: Date.now(),
|
||||
};
|
||||
const assistantMessageId = `msg-${Date.now() + 1}`;
|
||||
dispatch(addMessage(userMessage));
|
||||
dispatch(startAssistantMessage(assistantMessageId));
|
||||
dispatch(setMessageMeta({
|
||||
id: assistantMessageId,
|
||||
meta: { agentName: activeAgent?.name ?? null, modelName },
|
||||
}));
|
||||
setInputText('');
|
||||
dispatch(generateResponse({
|
||||
modelPath: selectedModel, messages: [...messages, userMessage],
|
||||
temperature, maxTokens, assistantMessageId, activeAgentId,
|
||||
onToken: (token: string) => { dispatch(updateLastMessage(token)); },
|
||||
}));
|
||||
};
|
||||
|
||||
// Run roundtable: sequentially generate assistant responses for selected agents.
|
||||
const runRoundtable = async (initialUserText: string) => {
|
||||
if (!roundtableSelectedIds.length || !selectedModel) { return; }
|
||||
// Capture ids + agents at start so closures remain stable
|
||||
const selectedIds = [...roundtableSelectedIds];
|
||||
const participantsNames = selectedIds
|
||||
.map(id => agents.find(a => a.id === id)?.name ?? id)
|
||||
.join(', ');
|
||||
|
||||
// Context message injected into every generation but never stored in UI.
|
||||
// Keeping participants OUT of the system prompt lets system stay focused on persona.
|
||||
const contextMsg = {
|
||||
id: 'rt-context',
|
||||
role: 'user' as const,
|
||||
content: `[Roundtable] Participants: ${participantsNames}. Respond in character.`,
|
||||
timestamp: 0,
|
||||
};
|
||||
|
||||
setRoundtableRunning(true);
|
||||
roundtableStopRef.current = false;
|
||||
|
||||
// Build the initial message list; optionally add user seed message
|
||||
let currentMessages = [...(activeConv?.messages ?? [])];
|
||||
if (initialUserText) {
|
||||
const userMessage = {
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'user' as const,
|
||||
content: initialUserText,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch(addMessage(userMessage));
|
||||
currentMessages = [...currentMessages, userMessage];
|
||||
}
|
||||
|
||||
// Rotate infinitely through selected agents until user stops
|
||||
let agentIndex = 0;
|
||||
let previousAssistantContent: string | null = null;
|
||||
let previousAgentName: string | null = null;
|
||||
|
||||
while (!roundtableStopRef.current) {
|
||||
const agentId = selectedIds[agentIndex];
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
if (!agent) {
|
||||
agentIndex = (agentIndex + 1) % selectedIds.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assistantMessageId = `msg-rt-${Date.now()}-${agentIndex}`;
|
||||
dispatch(startAssistantMessage(assistantMessageId));
|
||||
dispatch(setMessageMeta({
|
||||
id: assistantMessageId,
|
||||
meta: { agentName: agent.name, modelName },
|
||||
}));
|
||||
|
||||
// System = agent identity header + persona; previous turn appended with correct name.
|
||||
const { systemPrompt: agentPrompt, name: agentName } = agent;
|
||||
let systemPrompt = `You are ${agentName}.\n\n${agentPrompt}`;
|
||||
if (previousAssistantContent && previousAgentName) {
|
||||
systemPrompt += `\n\n${previousAgentName} just said:\n${previousAssistantContent}`;
|
||||
}
|
||||
|
||||
// Prepend context message so the model knows the roundtable setup.
|
||||
// It is not stored in the Redux state so it never appears in the UI.
|
||||
const result = await dispatch(generateResponse({
|
||||
modelPath: selectedModel,
|
||||
messages: [contextMsg, ...currentMessages],
|
||||
temperature,
|
||||
maxTokens,
|
||||
assistantMessageId,
|
||||
activeAgentId: null,
|
||||
overrideConfig: { systemPrompt },
|
||||
onToken: (token: string) => { dispatch(updateLastMessage(token)); },
|
||||
}));
|
||||
|
||||
if (generateResponse.rejected.match(result)) { break; }
|
||||
|
||||
const payload = result.payload as unknown as { content?: string } | undefined;
|
||||
previousAssistantContent = payload?.content ?? null;
|
||||
previousAgentName = agent.name;
|
||||
|
||||
// Append assistant reply to local message list so next agent sees it
|
||||
if (previousAssistantContent) {
|
||||
currentMessages = [...currentMessages, {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content: previousAssistantContent,
|
||||
timestamp: Date.now(),
|
||||
}];
|
||||
}
|
||||
|
||||
agentIndex = (agentIndex + 1) % selectedIds.length;
|
||||
|
||||
// 2-second pause before the next agent
|
||||
if (!roundtableStopRef.current) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
setRoundtableRunning(false);
|
||||
};
|
||||
|
||||
const handleAgentSelect = (id: string | null) => {
|
||||
dispatch(setActiveAgent(id));
|
||||
setAgentPickerVisible(false);
|
||||
};
|
||||
|
||||
const renderMessage = ({ item }: { item: typeof messages[0] }) => (
|
||||
<MessageBubble message={item} />
|
||||
);
|
||||
|
||||
const renderEmpty = () => (
|
||||
<View style={themeStyles.emptyContainer}>
|
||||
<Text style={themeStyles.emptyIcon}>💬</Text>
|
||||
<Text style={themeStyles.emptyText}>Aucun message</Text>
|
||||
<Text style={themeStyles.emptySubtext}>
|
||||
{selectedModel ? 'Commencez une conversation'
|
||||
: 'Sélectionnez un modèle pour commencer'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const modelName = localModels.find(m => m.path === selectedModel)?.name
|
||||
?? 'Aucun modèle';
|
||||
|
||||
return (
|
||||
<View style={themeStyles.container}>
|
||||
<KeyboardAvoidingView
|
||||
style={themeStyles.flex}
|
||||
behavior="padding"
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 60}
|
||||
>
|
||||
<View style={themeStyles.headerBar}>
|
||||
<TouchableOpacity style={themeStyles.iconBtn} onPress={() => setDrawerOpen(true)}>
|
||||
<Text style={themeStyles.iconBtnText}>☰</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={themeStyles.headerTitle} numberOfLines={1}>{activeTitle}</Text>
|
||||
<TouchableOpacity
|
||||
style={themeStyles.iconBtn}
|
||||
onPress={() => dispatch(createConversation())}
|
||||
>
|
||||
<Text style={themeStyles.newChatBtnText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={themeStyles.modelBand}>
|
||||
<View style={themeStyles.modelBandLeft}>
|
||||
<Text style={themeStyles.modelBandLabel}>Modèle</Text>
|
||||
<Text style={themeStyles.modelBandValue} numberOfLines={1}>{modelName}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.agentPill, activeAgent ? themeStyles.agentPillActive : null]}
|
||||
onPress={() => setAgentPickerVisible(true)}
|
||||
>
|
||||
{activeAgent?.avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: activeAgent.avatarUri }}
|
||||
style={themeStyles.agentPillAvatar}
|
||||
/>
|
||||
) : activeAgent
|
||||
? (
|
||||
<View style={themeStyles.agentPillAvatarPlaceholder}>
|
||||
<Text style={themeStyles.agentPillAvatarText}>
|
||||
{activeAgent.name[0]?.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
: <Text style={themeStyles.agentPillIcon}>🤖</Text>
|
||||
}
|
||||
<Text
|
||||
style={activeAgent ? themeStyles.agentPillName : themeStyles.agentPillNameInactive}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{activeAgent ? activeAgent.name : 'Agent'}
|
||||
</Text>
|
||||
<Text style={themeStyles.agentPillChevron}>▾</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.roundtableBtn, roundtableMode && themeStyles.roundtableBtnActive]}
|
||||
onPress={() => {
|
||||
// open multi-picker when enabling roundtable
|
||||
if (!roundtableMode) { setMultiPickerVisible(true); return; }
|
||||
// disabling roundtable
|
||||
setRoundtableMode(false);
|
||||
setRoundtableSelectedIds([]);
|
||||
}}
|
||||
>
|
||||
<Text style={themeStyles.roundtableBtnText}>{roundtableMode ? `RT (${roundtableSelectedIds.length})` : 'RT'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={themeStyles.messagesList}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
/>
|
||||
<ChatInput
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
onSend={handleSend}
|
||||
onStop={() => { roundtableStopRef.current = true; dispatch(stopGeneration()); }}
|
||||
disabled={isInferring || !selectedModel}
|
||||
isGenerating={isInferring}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
<ChatDrawer
|
||||
visible={drawerOpen}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversationId}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onNewChat={() => dispatch(createConversation())}
|
||||
onSelectConversation={(id) => dispatch(switchConversation(id))}
|
||||
onDeleteConversation={(id) => dispatch(deleteConversation(id))}
|
||||
/>
|
||||
<AgentPickerModal
|
||||
visible={agentPickerVisible}
|
||||
agents={agents}
|
||||
activeAgentId={activeAgentId}
|
||||
onSelect={handleAgentSelect}
|
||||
onClose={() => setAgentPickerVisible(false)}
|
||||
/>
|
||||
<MultiAgentPickerModal
|
||||
visible={multiPickerVisible}
|
||||
agents={agents}
|
||||
selectedIds={roundtableSelectedIds}
|
||||
onToggle={(id: string) => {
|
||||
setRoundtableSelectedIds(prev => (
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
));
|
||||
}}
|
||||
onConfirm={() => {
|
||||
setRoundtableMode(true);
|
||||
setMultiPickerVisible(false);
|
||||
}}
|
||||
onClose={() => setMultiPickerVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, string>) =>
|
||||
StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
headerBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
paddingHorizontal: spacing.xs,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
iconBtn: { padding: spacing.md },
|
||||
iconBtnText: { fontSize: 20, color: colors.textSecondary },
|
||||
newChatBtnText: {
|
||||
fontSize: 22,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
headerTitle: {
|
||||
flex: 1,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
modelBand: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
modelBandLeft: { flex: 1 },
|
||||
modelBandLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginBottom: 1,
|
||||
},
|
||||
modelBandValue: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
agentPill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 20,
|
||||
paddingVertical: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
gap: 5,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
maxWidth: 160,
|
||||
},
|
||||
agentPillActive: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
agentPillIcon: { fontSize: 14 },
|
||||
agentPillAvatar: { width: 20, height: 20, borderRadius: 10 },
|
||||
agentPillAvatarPlaceholder: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
agentPillAvatarText: {
|
||||
fontSize: 10,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
agentPillName: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
agentPillNameInactive: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: typography.weights.medium,
|
||||
flexShrink: 1,
|
||||
},
|
||||
agentPillChevron: { fontSize: 10, color: colors.textTertiary },
|
||||
roundtableBtn: {
|
||||
marginLeft: spacing.sm,
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
roundtableBtnActive: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
roundtableBtnText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
messagesList: { flexGrow: 1, paddingVertical: spacing.md },
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
emptyIcon: { fontSize: 48, marginBottom: spacing.md },
|
||||
emptyText: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
199
app/src/screens/ChatScreen/AgentPickerModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
avatarUri?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agents: Agent[];
|
||||
activeAgentId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function AgentRow({
|
||||
agent,
|
||||
isSelected,
|
||||
onPress,
|
||||
}: {
|
||||
agent: Agent;
|
||||
isSelected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.agentRow, isSelected && styles.agentRowSelected]}
|
||||
onPress={onPress}
|
||||
>
|
||||
{agent.avatarUri ? (
|
||||
<Image source={{ uri: agent.avatarUri }} style={styles.agentRowAvatar} />
|
||||
) : (
|
||||
<View style={styles.agentRowAvatarPlaceholder}>
|
||||
<Text style={styles.agentRowAvatarText}>
|
||||
{agent.name[0]?.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.agentRowInfo}>
|
||||
<Text style={[styles.agentRowName, isSelected && styles.agentRowNameSelected]}>
|
||||
{agent.name}
|
||||
</Text>
|
||||
<Text style={styles.agentRowPrompt} numberOfLines={1}>
|
||||
{agent.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && <Text style={styles.agentRowCheck}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentPickerModal({
|
||||
visible,
|
||||
agents,
|
||||
activeAgentId,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>Choisir un agent</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.agentRow, !activeAgentId && styles.agentRowSelected]}
|
||||
onPress={() => onSelect(null)}
|
||||
>
|
||||
<Text style={styles.noneIcon}>🚫</Text>
|
||||
<View style={styles.agentRowInfo}>
|
||||
<Text style={[styles.agentRowName, !activeAgentId && styles.agentRowNameSelected]}>
|
||||
Aucun agent
|
||||
</Text>
|
||||
<Text style={styles.agentRowPrompt} numberOfLines={1}>
|
||||
Utiliser uniquement le prompt système du modèle
|
||||
</Text>
|
||||
</View>
|
||||
{!activeAgentId && <Text style={styles.agentRowCheck}>✓</Text>}
|
||||
</TouchableOpacity>
|
||||
{agents.map(agent => (
|
||||
<AgentRow
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
isSelected={activeAgentId === agent.id}
|
||||
onPress={() => onSelect(agent.id)}
|
||||
/>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<Text style={styles.empty}>
|
||||
Aucun agent créé. Allez dans l'onglet Agents pour en créer un.
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
empty: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
agentRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.sm,
|
||||
borderRadius: 10,
|
||||
gap: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.transparent,
|
||||
},
|
||||
agentRowSelected: {
|
||||
backgroundColor: colors.agentBg,
|
||||
borderColor: colors.agentBorder,
|
||||
},
|
||||
agentRowAvatar: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
},
|
||||
agentRowAvatarPlaceholder: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
agentRowAvatarText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
noneIcon: {
|
||||
width: 36,
|
||||
textAlign: 'center',
|
||||
fontSize: 22,
|
||||
},
|
||||
agentRowInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
agentRowName: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
agentRowNameSelected: {
|
||||
color: colors.agentAccent,
|
||||
},
|
||||
agentRowPrompt: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
agentRowCheck: {
|
||||
fontSize: 16,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
});
|
||||
278
app/src/screens/ChatScreen/ChatDrawer.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import type { Conversation } from '../../store/chatTypes';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
const DRAWER_WIDTH = Math.min(Dimensions.get('window').width * 0.80, 310);
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onClose: () => void;
|
||||
onNewChat: () => void;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
const diffDays = Math.floor((Date.now() - ts) / 86400000);
|
||||
if (diffDays === 0) return "Aujourd'hui";
|
||||
if (diffDays === 1) return 'Hier';
|
||||
if (diffDays < 7) return `Il y a ${diffDays} j`;
|
||||
return new Date(ts).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
export default function ChatDrawer({
|
||||
visible,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onClose,
|
||||
onNewChat,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
}: Props) {
|
||||
const translateX = useRef(new Animated.Value(-DRAWER_WIDTH)).current;
|
||||
const backdropOpacity = useRef(new Animated.Value(0)).current;
|
||||
// Keep mounted during the close animation
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMounted(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 0.5,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(translateX, {
|
||||
toValue: -DRAWER_WIDTH,
|
||||
duration: 210,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(backdropOpacity, {
|
||||
toValue: 0,
|
||||
duration: 210,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => setMounted(false));
|
||||
}
|
||||
}, [visible, translateX, backdropOpacity]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sorted = [...conversations].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const renderItem = ({ item }: { item: Conversation }) => {
|
||||
const isActive = item.id === activeConversationId;
|
||||
const lastMsg = item.messages[item.messages.length - 1];
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.convItem, isActive && styles.convItemActive]}
|
||||
onPress={() => { onSelectConversation(item.id); onClose(); }}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<View style={styles.convItemContent}>
|
||||
<Text
|
||||
style={[styles.convTitle, isActive && styles.convTitleActive]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text style={styles.convMeta}>
|
||||
{item.messages.length} msg · {formatDate(item.updatedAt)}
|
||||
</Text>
|
||||
{lastMsg ? (
|
||||
<Text style={styles.convPreview} numberOfLines={1}>
|
||||
{lastMsg.role === 'user' ? '👤 ' : '🤖 '}
|
||||
{lastMsg.content}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={styles.convPreviewEmpty}>Vide</Text>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.deleteBtn}
|
||||
onPress={() => onDeleteConversation(item.id)}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.deleteBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFillObject} pointerEvents="box-none">
|
||||
{/* Backdrop */}
|
||||
<Animated.View
|
||||
style={[styles.backdrop, { opacity: backdropOpacity }]}
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFillObject} onPress={onClose} />
|
||||
</Animated.View>
|
||||
|
||||
{/* Drawer panel */}
|
||||
<Animated.View style={[styles.drawer, { transform: [{ translateX }] }]}>
|
||||
{/* Header */}
|
||||
<View style={styles.drawerHeader}>
|
||||
<Text style={styles.drawerTitle}>Conversations</Text>
|
||||
<TouchableOpacity
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.closeBtnText}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* New chat button */}
|
||||
<TouchableOpacity
|
||||
style={styles.newChatBtn}
|
||||
onPress={() => { onNewChat(); onClose(); }}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.newChatBtnText}>+ Nouveau chat</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Conversations list */}
|
||||
<FlatList
|
||||
data={sorted}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={
|
||||
<Text style={styles.emptyText}>Aucune conversation</Text>
|
||||
}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.textPrimary,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: DRAWER_WIDTH,
|
||||
backgroundColor: colors.surface,
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 4, height: 0 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 12,
|
||||
elevation: 16,
|
||||
},
|
||||
drawerHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.xxl,
|
||||
paddingBottom: spacing.md,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
drawerTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
closeBtnText: {
|
||||
fontSize: 18,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
newChatBtn: {
|
||||
margin: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
newChatBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.xxl,
|
||||
},
|
||||
convItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convItemActive: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary,
|
||||
},
|
||||
convItemContent: {
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
convTitle: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convTitleActive: {
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
convMeta: {
|
||||
fontSize: 11,
|
||||
color: colors.textTertiary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
convPreview: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
convPreviewEmpty: {
|
||||
fontSize: 12,
|
||||
color: colors.textTertiary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: 4,
|
||||
},
|
||||
deleteBtnText: {
|
||||
fontSize: 14,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
color: colors.textTertiary,
|
||||
marginTop: spacing.xl,
|
||||
fontSize: typography.sizes.sm,
|
||||
},
|
||||
});
|
||||
113
app/src/screens/ChatScreen/ChatInput.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React from 'react';
|
||||
import { View, TextInput, TouchableOpacity, Text, StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ChatInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSend: () => void;
|
||||
onStop: () => void;
|
||||
disabled: boolean;
|
||||
isGenerating: boolean;
|
||||
}
|
||||
|
||||
export default function ChatInput({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
onStop,
|
||||
disabled,
|
||||
isGenerating,
|
||||
}: ChatInputProps) {
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={themeStyles.container}>
|
||||
<TextInput
|
||||
style={themeStyles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="Écrivez votre message..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
multiline
|
||||
maxLength={2000}
|
||||
editable={!disabled}
|
||||
/>
|
||||
{isGenerating ? (
|
||||
<TouchableOpacity
|
||||
style={themeStyles.stopButton}
|
||||
onPress={onStop}
|
||||
>
|
||||
<View style={themeStyles.stopIcon} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[themeStyles.sendButton, disabled && themeStyles.sendButtonDisabled]}
|
||||
onPress={onSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
>
|
||||
<Text style={themeStyles.sendButtonText}>➤</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, unknown>) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minHeight: 40,
|
||||
maxHeight: 120,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
sendButtonText: {
|
||||
fontSize: 20,
|
||||
color: colors.surface,
|
||||
},
|
||||
stopButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.error,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stopIcon: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 2,
|
||||
},
|
||||
});
|
||||
|
||||
// module-level static styles intentionally omitted — runtime themeStyles used
|
||||
147
app/src/screens/ChatScreen/MessageBubble.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/* eslint-disable react-native/no-unused-styles */
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { Message } from '../../store/chatSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export default function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const navigation = useNavigation();
|
||||
const time = new Date(message.timestamp).toLocaleTimeString(
|
||||
'fr-FR',
|
||||
{ hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
const liveRate = !isUser
|
||||
? (message.metadata?.liveRate as number | undefined) : undefined;
|
||||
const agentName = !isUser
|
||||
? (message.metadata?.agentName as string | undefined) : undefined;
|
||||
const modelName = !isUser
|
||||
? (message.metadata?.modelName as string | undefined) : undefined;
|
||||
|
||||
const [showModel, setShowModel] = useState(false);
|
||||
const { colors } = useTheme();
|
||||
const themeStyles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={[themeStyles.container, isUser && themeStyles.userContainer]}>
|
||||
{agentName && (
|
||||
<Text style={themeStyles.agentLabel}>🤖 {agentName}</Text>
|
||||
)}
|
||||
<Pressable
|
||||
onLongPress={() =>
|
||||
navigation.navigate('MessageDetails', { messageId: message.id })
|
||||
}
|
||||
delayLongPress={300}
|
||||
style={({ pressed }) => [
|
||||
themeStyles.bubble,
|
||||
isUser ? themeStyles.userBubble : themeStyles.assistantBubble,
|
||||
pressed && themeStyles.pressedBubble,
|
||||
]}
|
||||
>
|
||||
<Text style={[themeStyles.text, isUser && themeStyles.userText]}>
|
||||
{message.content || '...'}
|
||||
</Text>
|
||||
<Text style={[themeStyles.time, isUser && themeStyles.userTime]}>
|
||||
{time}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={themeStyles.metaRow}>
|
||||
{liveRate !== undefined && (
|
||||
<Text style={themeStyles.rate}>{liveRate.toFixed(1)} tok/s</Text>
|
||||
)}
|
||||
{modelName && (
|
||||
<TouchableOpacity onPress={() => setShowModel(v => !v)}>
|
||||
<Text style={themeStyles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{showModel && modelName && (
|
||||
<Text style={themeStyles.modelTooltip}>{modelName}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const createStyles = (colors: Record<string, unknown>) =>
|
||||
StyleSheet.create({
|
||||
container: {
|
||||
marginVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
userContainer: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
agentLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.semibold,
|
||||
marginBottom: 3,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
bubble: {
|
||||
maxWidth: '80%',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
userBubble: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
assistantBubble: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
},
|
||||
pressedBubble: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
text: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
userText: {
|
||||
color: colors.surface,
|
||||
},
|
||||
time: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginTop: 4,
|
||||
},
|
||||
userTime: {
|
||||
color: colors.userTimeText,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: 2,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
rate: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
infoIcon: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
modelTooltip: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
marginTop: 2,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
});
|
||||
675
app/src/screens/ChatScreen/MessageDetails.tsx
Normal file
@@ -0,0 +1,675 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import { colors, spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import type { RootStackParamList } from '../../navigation';
|
||||
import {
|
||||
startAssistantMessage,
|
||||
updateLastMessage,
|
||||
} from '../../store/chatSlice';
|
||||
import { generateResponse } from '../../store/chatThunks';
|
||||
import type { ModelConfig } from '../../store/types';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'MessageDetails'>;
|
||||
|
||||
// ─── InfoRow ──────────────────────────────────────────────────────────────────
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | boolean | undefined | null;
|
||||
}) {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const display =
|
||||
typeof value === 'boolean' ? (value ? 'oui' : 'non') : String(value);
|
||||
return (
|
||||
<View style={rowStyles.row}>
|
||||
<Text style={rowStyles.label}>{label}</Text>
|
||||
<Text style={rowStyles.value}>{display}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const rowStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 5,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
value: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Collapsible Section ──────────────────────────────────────────────────────
|
||||
function Section({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<View style={secStyles.wrap}>
|
||||
<TouchableOpacity
|
||||
style={secStyles.header}
|
||||
onPress={() => setOpen(p => !p)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={secStyles.title}>{title}</Text>
|
||||
<Text style={secStyles.chevron}>{open ? '▲' : '▼'}</Text>
|
||||
</TouchableOpacity>
|
||||
{open && <View style={secStyles.body}>{children}</View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const secStyles = StyleSheet.create({
|
||||
wrap: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
chevron: { fontSize: 11, color: colors.textTertiary },
|
||||
body: {
|
||||
marginTop: 4,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Editable fork param row ──────────────────────────────────────────────────
|
||||
function ForkNum({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={forkStyles.row}>
|
||||
<Text style={forkStyles.label}>{label}</Text>
|
||||
<TextInput
|
||||
style={forkStyles.input}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numeric"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
selectTextOnFocus
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const forkStyles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 5,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
width: 90,
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
export default function MessageDetails({ route, navigation }: Props) {
|
||||
const { messageId } = route.params;
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const message = useSelector((s: RootState) => {
|
||||
const conv = s.chat.conversations.find(c => c.id === s.chat.activeConversationId);
|
||||
return conv?.messages.find(m => m.id === messageId);
|
||||
});
|
||||
const messages = useSelector((s: RootState) => {
|
||||
const conv = s.chat.conversations.find(c => c.id === s.chat.activeConversationId);
|
||||
return conv?.messages ?? [];
|
||||
});
|
||||
const currentLoadedModel = useSelector(
|
||||
(s: RootState) => s.models.currentLoadedModel,
|
||||
);
|
||||
|
||||
// ── Derive config early (safe even if message is undefined) ───────────────
|
||||
const meta = (message?.metadata || {}) as Record<string, unknown>;
|
||||
const rawMetaContent = meta.rawContent as string | undefined;
|
||||
const reasoningContent = meta.reasoningContent as string | null | undefined;
|
||||
const config = (meta.config as ModelConfig) || {};
|
||||
const timings = meta.timings as
|
||||
| {
|
||||
predicted_n?: number;
|
||||
predicted_ms?: number;
|
||||
predicted_per_second?: number;
|
||||
prompt_n?: number;
|
||||
prompt_ms?: number;
|
||||
prompt_per_second?: number;
|
||||
}
|
||||
| undefined;
|
||||
const modelPath =
|
||||
(meta.modelPath as string) || currentLoadedModel || '';
|
||||
const prompt = meta.prompt as string | undefined;
|
||||
|
||||
// ── Stop-reason metadata ──────────────────────────────────────────────────
|
||||
const stoppedWord = meta.stoppedWord as string | null | undefined;
|
||||
const stoppingWord = meta.stoppingWord as string | null | undefined;
|
||||
const stoppedEos = meta.stoppedEos as boolean | undefined;
|
||||
const stoppedLimit = meta.stoppedLimit as number | undefined;
|
||||
const interrupted = meta.interrupted as boolean | undefined;
|
||||
const truncated = meta.truncated as boolean | undefined;
|
||||
|
||||
// ── Fork state (always called, safe defaults) ─────────────────────────────
|
||||
const [isForkMode, setIsForkMode] = useState(false);
|
||||
const [forkSystemPrompt, setForkSystemPrompt] = useState(
|
||||
config.systemPrompt ?? '',
|
||||
);
|
||||
const [forkTemp, setForkTemp] = useState(
|
||||
String(config.temperature ?? 0.8),
|
||||
);
|
||||
const [forkTopK, setForkTopK] = useState(String(config.top_k ?? 40));
|
||||
const [forkTopP, setForkTopP] = useState(String(config.top_p ?? 0.95));
|
||||
const [forkMinP, setForkMinP] = useState(String(config.min_p ?? 0.05));
|
||||
const [forkNPredict, setForkNPredict] = useState(
|
||||
String(config.n_predict ?? -1),
|
||||
);
|
||||
const [forkSeed, setForkSeed] = useState(String(config.seed ?? -1));
|
||||
const [forkPenaltyRepeat, setForkPenaltyRepeat] = useState(
|
||||
String(config.penalty_repeat ?? 1.0),
|
||||
);
|
||||
const [forkPenaltyLastN, setForkPenaltyLastN] = useState(
|
||||
String(config.penalty_last_n ?? 64),
|
||||
);
|
||||
const [forkStop, setForkStop] = useState(
|
||||
(config.stop ?? []).join(', '),
|
||||
);
|
||||
|
||||
const handleFork = useCallback(() => {
|
||||
if (!modelPath) return;
|
||||
const msgIndex = messages.findIndex(m => m.id === messageId);
|
||||
if (msgIndex < 0) return;
|
||||
// Messages up to (but not including) this assistant reply
|
||||
const history = messages.slice(0, msgIndex);
|
||||
|
||||
const overrideConfig: Partial<ModelConfig> = {
|
||||
systemPrompt: forkSystemPrompt || undefined,
|
||||
temperature: Number(forkTemp),
|
||||
top_k: Number(forkTopK),
|
||||
top_p: Number(forkTopP),
|
||||
min_p: Number(forkMinP),
|
||||
n_predict: Number(forkNPredict),
|
||||
seed: Number(forkSeed),
|
||||
penalty_repeat: Number(forkPenaltyRepeat),
|
||||
penalty_last_n: Number(forkPenaltyLastN),
|
||||
stop: forkStop
|
||||
? forkStop
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const newId = `fork-${Date.now()}`;
|
||||
dispatch(startAssistantMessage(newId));
|
||||
dispatch(
|
||||
generateResponse({
|
||||
modelPath,
|
||||
messages: history,
|
||||
temperature: Number(forkTemp),
|
||||
maxTokens:
|
||||
Number(forkNPredict) > 0 ? Number(forkNPredict) : 512,
|
||||
assistantMessageId: newId,
|
||||
overrideConfig,
|
||||
onToken: (token: string) => dispatch(updateLastMessage(token)),
|
||||
}),
|
||||
);
|
||||
navigation.goBack();
|
||||
}, [
|
||||
modelPath,
|
||||
messages,
|
||||
messageId,
|
||||
forkSystemPrompt,
|
||||
forkTemp,
|
||||
forkTopK,
|
||||
forkTopP,
|
||||
forkMinP,
|
||||
forkNPredict,
|
||||
forkSeed,
|
||||
forkPenaltyRepeat,
|
||||
forkPenaltyLastN,
|
||||
forkStop,
|
||||
dispatch,
|
||||
navigation,
|
||||
]);
|
||||
|
||||
// ── Early return AFTER all hooks ──────────────────────────────────────────
|
||||
if (!message) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.empty}>Message introuvable</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isAssistant = message.role === 'assistant';
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* Header row */}
|
||||
<View style={styles.headerRow}>
|
||||
<Text style={styles.pageTitle}>Détails du message</Text>
|
||||
{isAssistant && (
|
||||
<TouchableOpacity
|
||||
style={[styles.forkBtn, isForkMode && styles.forkBtnActive]}
|
||||
onPress={() => setIsForkMode(p => !p)}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.forkBtnText,
|
||||
isForkMode && styles.forkBtnTextActive,
|
||||
]}
|
||||
>
|
||||
{isForkMode ? '✕ Annuler' : '🔀 Fork'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ── Message content ─────────────────────────────────────────────── */}
|
||||
<Section title="💬 Contenu propre (affiché dans le chat)">
|
||||
<Text style={styles.contentText}>
|
||||
{message.content || '(vide)'}
|
||||
</Text>
|
||||
<Text style={styles.metaSmall}>
|
||||
{message.role} ·{' '}
|
||||
{new Date(message.timestamp).toLocaleString('fr-FR')}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
{/* ── Thinking / Reasoning ──────────────────────────────────────── */}
|
||||
{reasoningContent ? (
|
||||
<Section title="🧠 Thinking (raisonnement)">
|
||||
<Text style={styles.rawText}>{reasoningContent}</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Raw streaming content (all tokens, no cleanup) ─────────────── */}
|
||||
{rawMetaContent !== undefined && (
|
||||
<Section title="🧹 Stream brut (avec marqueurs)" defaultOpen={true}>
|
||||
<Text style={styles.rawText}>
|
||||
{rawMetaContent || '(vide)'}
|
||||
</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Performance timings ─────────────────────────────────────────── */}
|
||||
{timings && (
|
||||
<Section title="⏱ Performance">
|
||||
{timings.predicted_n !== null && (
|
||||
<InfoRow label="Tokens générés" value={timings.predicted_n} />
|
||||
)}
|
||||
{timings.predicted_per_second !== null && (
|
||||
<InfoRow
|
||||
label="Vitesse génération"
|
||||
value={`${timings.predicted_per_second.toFixed(2)} tok/s`}
|
||||
/>
|
||||
)}
|
||||
{timings.prompt_n !== null && (
|
||||
<InfoRow label="Tokens prompt" value={timings.prompt_n} />
|
||||
)}
|
||||
{timings.prompt_per_second !== null && (
|
||||
<InfoRow
|
||||
label="Vitesse prompt eval"
|
||||
value={`${timings.prompt_per_second.toFixed(2)} tok/s`}
|
||||
/>
|
||||
)}
|
||||
{timings.predicted_ms !== null && timings.prompt_ms !== null && (
|
||||
<InfoRow
|
||||
label="Temps total"
|
||||
value={`${(
|
||||
(timings.predicted_ms + timings.prompt_ms) /
|
||||
1000
|
||||
).toFixed(2)} s`}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* ── Model & loading params ──────────────────────────────────────── */}
|
||||
<Section title="🔧 Modèle & Chargement">
|
||||
<InfoRow
|
||||
label="Modèle"
|
||||
value={modelPath ? modelPath.split('/').pop() : '—'}
|
||||
/>
|
||||
<InfoRow label="n_ctx" value={config.n_ctx} />
|
||||
<InfoRow label="n_threads" value={config.n_threads} />
|
||||
<InfoRow label="n_gpu_layers" value={config.n_gpu_layers} />
|
||||
<InfoRow label="flash_attn" value={config.flash_attn} />
|
||||
<InfoRow
|
||||
label="cache K / V"
|
||||
value={
|
||||
config.cache_type_k
|
||||
? `${config.cache_type_k} / ${config.cache_type_v ?? 'f16'}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<InfoRow label="use_mlock" value={config.use_mlock} />
|
||||
<InfoRow label="use_mmap" value={config.use_mmap} />
|
||||
<InfoRow label="rope_freq_base" value={config.rope_freq_base} />
|
||||
<InfoRow label="rope_freq_scale" value={config.rope_freq_scale} />
|
||||
</Section>
|
||||
|
||||
{/* ── Sampling params ─────────────────────────────────────────────── */}
|
||||
<Section title="🎲 Sampling">
|
||||
{isForkMode ? (
|
||||
<>
|
||||
<ForkNum
|
||||
label="Temperature"
|
||||
value={forkTemp}
|
||||
onChange={setForkTemp}
|
||||
/>
|
||||
<ForkNum label="Top-K" value={forkTopK} onChange={setForkTopK} />
|
||||
<ForkNum label="Top-P" value={forkTopP} onChange={setForkTopP} />
|
||||
<ForkNum label="Min-P" value={forkMinP} onChange={setForkMinP} />
|
||||
<ForkNum
|
||||
label="n_predict (max tokens)"
|
||||
value={forkNPredict}
|
||||
onChange={setForkNPredict}
|
||||
/>
|
||||
<ForkNum label="Seed" value={forkSeed} onChange={setForkSeed} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow label="temperature" value={config.temperature} />
|
||||
<InfoRow label="top_k" value={config.top_k} />
|
||||
<InfoRow label="top_p" value={config.top_p} />
|
||||
<InfoRow label="min_p" value={config.min_p} />
|
||||
<InfoRow label="n_predict" value={config.n_predict} />
|
||||
<InfoRow label="seed" value={config.seed} />
|
||||
<InfoRow label="typical_p" value={config.typical_p} />
|
||||
<InfoRow label="top_n_sigma" value={config.top_n_sigma} />
|
||||
<InfoRow label="mirostat" value={config.mirostat} />
|
||||
<InfoRow label="mirostat_tau" value={config.mirostat_tau} />
|
||||
<InfoRow label="mirostat_eta" value={config.mirostat_eta} />
|
||||
<InfoRow
|
||||
label="xtc_probability"
|
||||
value={config.xtc_probability}
|
||||
/>
|
||||
<InfoRow label="xtc_threshold" value={config.xtc_threshold} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Penalties ───────────────────────────────────────────────────── */}
|
||||
<Section title="🚫 Pénalités" defaultOpen={false}>
|
||||
{isForkMode ? (
|
||||
<>
|
||||
<ForkNum
|
||||
label="penalty_repeat"
|
||||
value={forkPenaltyRepeat}
|
||||
onChange={setForkPenaltyRepeat}
|
||||
/>
|
||||
<ForkNum
|
||||
label="penalty_last_n"
|
||||
value={forkPenaltyLastN}
|
||||
onChange={setForkPenaltyLastN}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow label="penalty_repeat" value={config.penalty_repeat} />
|
||||
<InfoRow label="penalty_last_n" value={config.penalty_last_n} />
|
||||
<InfoRow label="penalty_freq" value={config.penalty_freq} />
|
||||
<InfoRow
|
||||
label="penalty_present"
|
||||
value={config.penalty_present}
|
||||
/>
|
||||
<InfoRow
|
||||
label="dry_multiplier"
|
||||
value={config.dry_multiplier}
|
||||
/>
|
||||
<InfoRow label="dry_base" value={config.dry_base} />
|
||||
<InfoRow
|
||||
label="dry_allowed_length"
|
||||
value={config.dry_allowed_length}
|
||||
/>
|
||||
<InfoRow
|
||||
label="dry_penalty_last_n"
|
||||
value={config.dry_penalty_last_n}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Output / Stop ───────────────────────────────────────────────── */}
|
||||
<Section title="📤 Sortie" defaultOpen={false}>
|
||||
{isForkMode ? (
|
||||
<View>
|
||||
<Text style={styles.inputLabel}>
|
||||
Stop strings (séparés par virgule)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.textInput}
|
||||
value={forkStop}
|
||||
onChangeText={setForkStop}
|
||||
placeholder="</s>, User:, ..."
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<InfoRow
|
||||
label="stop"
|
||||
value={(config.stop ?? []).join(', ') || '(défaut)'}
|
||||
/>
|
||||
<InfoRow label="n_probs" value={config.n_probs} />
|
||||
<InfoRow label="ignore_eos" value={config.ignore_eos} />
|
||||
</>
|
||||
)}
|
||||
{/* Stop-reason info — filled after generation, always visible */}
|
||||
<View style={styles.stopReasonBox}>
|
||||
<Text style={styles.stopReasonTitle}>Raison d'arrêt</Text>
|
||||
<InfoRow label="stopped_eos" value={stoppedEos} />
|
||||
<InfoRow label="stopped_word" value={stoppedWord || undefined} />
|
||||
<InfoRow label="stopping_word" value={stoppingWord || undefined} />
|
||||
<InfoRow label="stopped_limit" value={stoppedLimit} />
|
||||
<InfoRow label="interrupted" value={interrupted} />
|
||||
<InfoRow label="truncated" value={truncated} />
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
{/* ── System prompt ───────────────────────────────────────────────── */}
|
||||
<Section title="🗒 Prompt système">
|
||||
{isForkMode ? (
|
||||
<TextInput
|
||||
style={[styles.textInput, styles.multiline]}
|
||||
value={forkSystemPrompt}
|
||||
onChangeText={setForkSystemPrompt}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
placeholder="(aucun)"
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.promptText}>
|
||||
{config.systemPrompt || '(aucun)'}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── Raw prompt sent ─────────────────────────────────────────────── */}
|
||||
{prompt ? (
|
||||
<Section title="📋 Prompt brut envoyé" defaultOpen={false}>
|
||||
<Text style={styles.promptText}>{prompt}</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{/* ── Fork / Regenerate button ─────────────────────────────────────── */}
|
||||
{isForkMode && isAssistant && (
|
||||
<TouchableOpacity
|
||||
style={styles.regenBtn}
|
||||
onPress={handleFork}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.regenBtnText}>
|
||||
🔀 Regénérer avec ces paramètres
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={{ height: spacing.xl * 2 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
content: { padding: spacing.md },
|
||||
empty: {
|
||||
padding: spacing.lg,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
forkBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
forkBtnActive: { backgroundColor: colors.primary },
|
||||
forkBtnText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.primary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
forkBtnTextActive: { color: colors.surface },
|
||||
contentText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
metaSmall: {
|
||||
marginTop: spacing.sm,
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
},
|
||||
promptText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
},
|
||||
rawText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 18,
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
stopReasonBox: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.surfaceSecondary,
|
||||
},
|
||||
stopReasonTitle: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
fontWeight: typography.weights.semibold,
|
||||
marginBottom: 4,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
textInput: {
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
multiline: { minHeight: 80, textAlignVertical: 'top' },
|
||||
regenBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
regenBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
59
app/src/screens/ChatScreen/ModelSelector.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
interface ModelSelectorProps {
|
||||
selectedModel: string | null;
|
||||
availableModels: Array<{ name: string; path: string }>;
|
||||
}
|
||||
|
||||
export default function ModelSelector({
|
||||
selectedModel,
|
||||
availableModels,
|
||||
}: ModelSelectorProps) {
|
||||
const selectedModelName = availableModels.find(
|
||||
m => m.path === selectedModel
|
||||
)?.name || 'Aucun modèle';
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.label}>Modèle actif</Text>
|
||||
<View style={styles.display}>
|
||||
<Text style={styles.selectedText} numberOfLines={1}>
|
||||
{selectedModelName}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
Changez le modèle depuis l'onglet Models
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.surface,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
display: {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
selectedText: {
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
hint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
165
app/src/screens/ChatScreen/MultiAgentPickerModal.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Pressable,
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography } from '../../theme/lightTheme';
|
||||
|
||||
export interface AgentItem {
|
||||
id: string;
|
||||
name: string;
|
||||
systemPrompt: string;
|
||||
avatarUri?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
agents: AgentItem[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function MultiAgentPickerModal({
|
||||
visible, agents, selectedIds, onToggle, onConfirm, onClose,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Pressable style={styles.overlay} onPress={onClose}>
|
||||
<Pressable style={styles.card} onPress={() => {}}>
|
||||
<Text style={styles.title}>Roundtable — sélectionner des agents</Text>
|
||||
<ScrollView style={styles.list}>
|
||||
{agents.map(a => {
|
||||
const selected = selectedIds.includes(a.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={a.id}
|
||||
style={[styles.row, selected && styles.rowSelected]}
|
||||
onPress={() => onToggle(a.id)}
|
||||
>
|
||||
{a.avatarUri ? (
|
||||
<Image
|
||||
source={{ uri: a.avatarUri }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.avatarPlaceholder}>
|
||||
<Text style={styles.avatarText}>{a.name[0]?.toUpperCase()}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.info}>
|
||||
<Text
|
||||
style={[styles.name, selected && styles.nameSelected]}
|
||||
>
|
||||
{a.name}
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.prompt}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{a.systemPrompt}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.check}>{selected ? '✓' : ''}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{agents.length === 0 && (
|
||||
<Text style={styles.empty}>
|
||||
Aucun agent disponible
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
<View style={styles.actions}>
|
||||
<TouchableOpacity
|
||||
style={styles.btn}
|
||||
onPress={onClose}
|
||||
>
|
||||
<Text style={styles.btnText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.btnPrimary}
|
||||
onPress={onConfirm}
|
||||
>
|
||||
<Text style={styles.btnPrimaryText}>Valider</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlayLight,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
card: {
|
||||
width: '100%',
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 12,
|
||||
padding: spacing.md,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
list: { marginBottom: spacing.sm },
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
borderRadius: 8,
|
||||
},
|
||||
rowSelected: { backgroundColor: colors.agentBg },
|
||||
avatar: { width: 36, height: 36, borderRadius: 18 },
|
||||
avatarPlaceholder: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarText: { color: colors.surface, fontWeight: typography.weights.bold },
|
||||
info: { flex: 1, marginLeft: spacing.sm },
|
||||
name: { fontSize: typography.sizes.sm, color: colors.textPrimary },
|
||||
nameSelected: { color: colors.agentAccent },
|
||||
prompt: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
check: {
|
||||
width: 24,
|
||||
textAlign: 'center',
|
||||
color: colors.agentAccent,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
empty: { textAlign: 'center', color: colors.textSecondary, padding: spacing.md },
|
||||
actions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.sm },
|
||||
btn: { paddingVertical: spacing.sm, paddingHorizontal: spacing.md },
|
||||
btnText: { color: colors.textSecondary },
|
||||
btnPrimary: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
btnPrimaryText: { color: colors.surface, fontWeight: typography.weights.semibold },
|
||||
});
|
||||
885
app/src/screens/HardwareInfoScreen.tsx
Normal file
@@ -0,0 +1,885 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
Platform,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { BuildInfo } from 'llama.rn';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../store';
|
||||
import { refreshHardwareInfo, refreshRamInfo } from '../store/hardwareSlice';
|
||||
import type { BackendDeviceInfo } from '../store/types';
|
||||
import { colors, spacing, typography, borderRadius } from '../theme/lightTheme';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
impact: string;
|
||||
usage: string;
|
||||
}
|
||||
|
||||
// ─── Tooltip dictionary ───────────────────────────────────────────────────────
|
||||
|
||||
const TIPS: Record<string, TooltipInfo> = {
|
||||
os: {
|
||||
title: "Système d'exploitation",
|
||||
description:
|
||||
"Nom et version du système d'exploitation tournant sur l'appareil.",
|
||||
impact:
|
||||
"Certaines fonctionnalités de llama.rn (Metal, OpenCL) ne sont disponibles " +
|
||||
"que sur des OS spécifiques.",
|
||||
usage:
|
||||
"iOS → Metal disponible. Android → OpenCL/Hexagon NPU possibles selon SoC.",
|
||||
},
|
||||
osVersion: {
|
||||
title: "Version de l'OS",
|
||||
description:
|
||||
"Numéro de version (Android API level ou numéro iOS). " +
|
||||
"Pour Android, l'API level détermine les API natives disponibles.",
|
||||
impact:
|
||||
"Un API level élevé (≥ 28) améliore la compatibilité avec les backends GPU.",
|
||||
usage:
|
||||
"Si des crashs surviennent sur un ancien appareil, vérifiez l'API level. " +
|
||||
"llama.rn supporte arm64-v8a et x86_64.",
|
||||
},
|
||||
deviceModel: {
|
||||
title: "Modèle de l'appareil",
|
||||
description:
|
||||
"Nom commercial du téléphone ou de la tablette (fabricant + modèle).",
|
||||
impact:
|
||||
"Permet d'identifier le SoC (ex : Snapdragon 8 Gen 1 → Adreno 730 GPU, " +
|
||||
"Hexagon HTP disponible).",
|
||||
usage:
|
||||
"Recherchez le SoC de votre appareil pour savoir quels backends GPU " +
|
||||
"sont exploitables.",
|
||||
},
|
||||
uiMode: {
|
||||
title: "Mode UI",
|
||||
description:
|
||||
"Mode d'interface Android : normal, watch, tv, desk, car, vrheadset.",
|
||||
impact:
|
||||
"Peu d'impact sur l'inférence. Utile pour déboguer sur des appareils atypiques.",
|
||||
usage: "Attendez \"normal\" pour un téléphone classique.",
|
||||
},
|
||||
ramTotal: {
|
||||
title: "RAM totale",
|
||||
description:
|
||||
"Quantité totale de mémoire vive physique installée dans l'appareil.",
|
||||
impact:
|
||||
"Détermine la taille maximale du modèle chargeable. " +
|
||||
"Un modèle 7B Q4_K_M pèse ≈ 4 Go, un 13B ≈ 8 Go.",
|
||||
usage:
|
||||
"Règle générale : RAM totale − 2 Go (OS) = RAM utilisable pour le modèle. " +
|
||||
"Préférez des quantifications plus aggressives (Q4, Q3) si la RAM est limitée.",
|
||||
},
|
||||
ramFree: {
|
||||
title: "RAM disponible",
|
||||
description:
|
||||
"Mémoire vive actuellement libre (non utilisée par l'OS et les autres apps).",
|
||||
impact:
|
||||
"C'est la limite réelle pour charger un modèle sans swapper. " +
|
||||
"En dessous de 500 Mo de RAM libre, le risque de crash OOM est élevé.",
|
||||
usage:
|
||||
"Fermez les autres applications avant de charger un modèle lourd. " +
|
||||
"Utilisez use_mmap=true pour éviter de tout charger en RAM d'un coup.",
|
||||
},
|
||||
diskTotal: {
|
||||
title: "Stockage total",
|
||||
description:
|
||||
"Capacité totale du stockage interne de l'appareil.",
|
||||
impact:
|
||||
"Les modèles GGUF sont volumineux (1 – 30 Go). " +
|
||||
"Vérifiez l'espace avant de télécharger.",
|
||||
usage:
|
||||
"Téléchargez les modèles sur une carte SD ou un stockage externe " +
|
||||
"si le stockage interne est insuffisant.",
|
||||
},
|
||||
diskFree: {
|
||||
title: "Espace libre",
|
||||
description:
|
||||
"Espace de stockage actuellement disponible pour de nouveaux fichiers.",
|
||||
impact:
|
||||
"Un modèle 7B pèse entre 4 et 8 Go selon la quantification. " +
|
||||
"Prévoyez de la marge pour les fichiers temporaires.",
|
||||
usage:
|
||||
"Activez l'option use_mmap=true pour que le modèle soit lu " +
|
||||
"directement depuis le disque sans tout copier en RAM.",
|
||||
},
|
||||
backendDevice: {
|
||||
title: "Backend de calcul",
|
||||
description:
|
||||
"Périphérique matériel utilisé par llama.rn pour l'inférence : " +
|
||||
"CPU (BLAS), GPU (Metal/OpenCL), NPU (Hexagon).",
|
||||
impact:
|
||||
"GPU : jusqu'à 10× plus rapide que CPU pour la génération de tokens. " +
|
||||
"NPU (Hexagon) : très efficace en énergie sur Snapdragon 8 Gen 1+.",
|
||||
usage:
|
||||
"Configurez n_gpu_layers > 0 pour déporter des couches sur le GPU. " +
|
||||
"Plus n_gpu_layers est élevé, plus la vitesse augmente (jusqu'à la limite VRAM).",
|
||||
},
|
||||
deviceMemory: {
|
||||
title: "Mémoire du device GPU/NPU",
|
||||
description:
|
||||
"Quantité maximale de mémoire dédiée ou partagée disponible " +
|
||||
"pour le backend de calcul (GPU/NPU).",
|
||||
impact:
|
||||
"Limite le nombre de couches pouvant tenir en VRAM. " +
|
||||
"Si trop peu de couches tiennent, le reste est traité sur CPU.",
|
||||
usage:
|
||||
"Divisez la taille du modèle par le nombre de couches pour estimer " +
|
||||
"combien de couches tiennent en VRAM (n_gpu_layers).",
|
||||
},
|
||||
gpuUsed: {
|
||||
title: "GPU utilisé par le contexte",
|
||||
description:
|
||||
"Indique si le modèle actuellement chargé exploite le GPU pour l'inférence.",
|
||||
impact:
|
||||
"GPU = inférence rapide (tok/s élevé). CPU seul = plus lent mais universellement compatible.",
|
||||
usage:
|
||||
"Si GPU = Non alors que vous avez un GPU, vérifiez n_gpu_layers > 0 " +
|
||||
"dans la configuration du modèle.",
|
||||
},
|
||||
reasonNoGPU: {
|
||||
title: "Raison : GPU non utilisé",
|
||||
description:
|
||||
"Message de llama.cpp expliquant pourquoi le GPU n'est pas actif.",
|
||||
impact:
|
||||
"Peut indiquer un backend manquant, un appareil non supporté, " +
|
||||
"ou que n_gpu_layers = 0.",
|
||||
usage:
|
||||
"Lisez ce message pour diagnostiquer : backend non compilé, " +
|
||||
"mémoire VRAM insuffisante, ou paramètre n_gpu_layers à 0.",
|
||||
},
|
||||
devicesUsed: {
|
||||
title: "Appareils utilisés par le contexte",
|
||||
description:
|
||||
"Liste des identifiants de périphériques utilisés par llama.rn " +
|
||||
"pour le modèle chargé.",
|
||||
impact:
|
||||
"Confirme que le bon backend (GPU/NPU/CPU) est effectivement utilisé à l'exécution.",
|
||||
usage:
|
||||
"Sur Snapdragon, \"HTP0\" indique le NPU Hexagon. " +
|
||||
"Sur iOS, \"gpu\" indique Metal.",
|
||||
},
|
||||
androidLib: {
|
||||
title: "Bibliothèque native Android",
|
||||
description:
|
||||
"Nom du fichier .so chargé par llama.rn sur Android " +
|
||||
"(ex: librnllama_opencl.so, librnllama.so).",
|
||||
impact:
|
||||
"La variante \"_opencl\" active l'accélération GPU OpenCL. " +
|
||||
"La variante de base utilise uniquement le CPU.",
|
||||
usage:
|
||||
"Si vous n'obtenez pas la variante OpenCL, vérifiez que " +
|
||||
"libOpenCL.so est déclaré dans votre AndroidManifest.xml.",
|
||||
},
|
||||
simd: {
|
||||
title: "Capacités CPU / SIMD",
|
||||
description:
|
||||
"Instructions vectorielles disponibles sur le CPU : NEON, AVX, AVX2, ARM_FMA, etc. " +
|
||||
"Rapportées directement par llama.cpp au moment du chargement du modèle.",
|
||||
impact:
|
||||
"NEON (ARM) et AVX2 (x86) multiplient les performances CPU par 4 à 8×. " +
|
||||
"llama.cpp choisit automatiquement le meilleur chemin de code.",
|
||||
usage:
|
||||
"Cette information est fournie automatiquement. Chargez un modèle " +
|
||||
"pour afficher les capacités de votre processeur.",
|
||||
},
|
||||
buildNumber: {
|
||||
title: "Numéro de build llama.rn",
|
||||
description:
|
||||
"Identifiant interne de la version compilée de llama.rn intégrée dans l'app.",
|
||||
impact:
|
||||
"Les builds récentes incluent des corrections de bugs et de nouveaux backends.",
|
||||
usage:
|
||||
"Comparez avec la dernière release sur npm (llama.rn) pour savoir " +
|
||||
"si une mise à jour est disponible.",
|
||||
},
|
||||
buildCommit: {
|
||||
title: "Commit Git llama.rn",
|
||||
description:
|
||||
"Hash du commit llama.cpp / llama.rn utilisé pour compiler la bibliothèque native.",
|
||||
impact:
|
||||
"Permet de retrouver exactement quelle version du moteur d'inférence est utilisée.",
|
||||
usage:
|
||||
"Utile pour déboguer un problème précis en consultant le changelog de llama.cpp.",
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) { return '—'; }
|
||||
if (bytes < 1024) { return `${bytes} B`; }
|
||||
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function pct(part: number, total: number): string {
|
||||
if (!total) { return '—'; }
|
||||
return `${Math.round((part / total) * 100)} %`;
|
||||
}
|
||||
|
||||
// ─── Tooltip Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
interface TooltipState {
|
||||
visible: boolean;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function TooltipModal({
|
||||
state,
|
||||
onClose,
|
||||
}: {
|
||||
state: TooltipState;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const info = TIPS[state.key];
|
||||
if (!info) { return null; }
|
||||
return (
|
||||
<Modal
|
||||
visible={state.visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<Pressable style={ttSt.overlay} onPress={onClose}>
|
||||
<Pressable style={ttSt.card} onPress={() => {}}>
|
||||
<Text style={ttSt.title}>{info.title}</Text>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>📖 Description</Text>
|
||||
<Text style={ttSt.sectionText}>{info.description}</Text>
|
||||
</View>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>⚙️ Impact</Text>
|
||||
<Text style={ttSt.sectionText}>{info.impact}</Text>
|
||||
</View>
|
||||
<View style={ttSt.section}>
|
||||
<Text style={ttSt.sectionLabel}>💡 Utilisation</Text>
|
||||
<Text style={ttSt.sectionText}>{info.usage}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={ttSt.closeBtn} onPress={onClose}>
|
||||
<Text style={ttSt.closeBtnText}>Fermer</Text>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const ttSt = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.overlay,
|
||||
justifyContent: 'flex-end',
|
||||
padding: spacing.md,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
section: { marginBottom: spacing.sm },
|
||||
sectionLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.primary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
closeBtn: {
|
||||
marginTop: spacing.md,
|
||||
backgroundColor: colors.surfaceSecondary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
alignItems: 'center',
|
||||
},
|
||||
closeBtnText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── InfoRow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
tipKey,
|
||||
onInfo,
|
||||
mono,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | undefined | null;
|
||||
tipKey?: string;
|
||||
onInfo?: (key: string) => void;
|
||||
mono?: boolean;
|
||||
accent?: boolean;
|
||||
}) {
|
||||
if (value === undefined || value === null || value === '') { return null; }
|
||||
return (
|
||||
<View style={rowSt.wrap}>
|
||||
<View style={rowSt.labelWrap}>
|
||||
{tipKey && onInfo ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(tipKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={rowSt.infoBtn}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<Text style={rowSt.label}>{label}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[rowSt.value, mono && rowSt.mono, accent && rowSt.accent]}
|
||||
numberOfLines={4}
|
||||
>
|
||||
{String(value)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const rowSt = StyleSheet.create({
|
||||
wrap: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
paddingVertical: 7,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
labelWrap: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
},
|
||||
infoBtn: {
|
||||
fontSize: 15,
|
||||
color: colors.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textSecondary,
|
||||
flexShrink: 1,
|
||||
},
|
||||
value: {
|
||||
flex: 1.4,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
fontWeight: typography.weights.medium,
|
||||
textAlign: 'right',
|
||||
},
|
||||
mono: {
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
|
||||
fontSize: 11,
|
||||
lineHeight: 16,
|
||||
},
|
||||
accent: { color: colors.primary },
|
||||
});
|
||||
|
||||
// ─── Badge ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Badge({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<View style={[badgeSt.wrap, active ? badgeSt.on : badgeSt.off]}>
|
||||
<Text style={[badgeSt.text, active ? badgeSt.textOn : badgeSt.textOff]}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeSt = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginRight: spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
on: { backgroundColor: colors.successLight },
|
||||
off: { backgroundColor: colors.surfaceSecondary, opacity: 0.55 },
|
||||
text: { fontSize: 11, fontWeight: typography.weights.semibold },
|
||||
textOn: { color: colors.successDark },
|
||||
textOff: { color: colors.textTertiary },
|
||||
});
|
||||
|
||||
// ─── Card ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function Card({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={cardSt.wrap}>
|
||||
<Text style={cardSt.title}>{title}</Text>
|
||||
<View style={cardSt.body}>{children}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const cardSt = StyleSheet.create({
|
||||
wrap: { marginBottom: spacing.md },
|
||||
title: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textTertiary,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing.xs,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
body: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.surfaceSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── BackendDeviceCard ────────────────────────────────────────────────────────
|
||||
|
||||
function BackendDeviceCard({
|
||||
device,
|
||||
onInfo,
|
||||
}: {
|
||||
device: BackendDeviceInfo;
|
||||
onInfo: (key: string) => void;
|
||||
}) {
|
||||
const isGpu = device.type.toUpperCase() === 'GPU';
|
||||
const icon = isGpu ? '🎮' : '🧮';
|
||||
return (
|
||||
<View style={devSt.wrap}>
|
||||
<View style={devSt.header}>
|
||||
<Text style={devSt.icon}>{icon}</Text>
|
||||
<View style={devSt.nameCol}>
|
||||
<Text style={devSt.name}>{device.deviceName}</Text>
|
||||
<Text style={devSt.sub}>{device.backend} · {device.type}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo('backendDevice')}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={devSt.infoBtn}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{device.maxMemorySize > 0 && (
|
||||
<InfoRow
|
||||
label="Mémoire disponible"
|
||||
value={fmtBytes(device.maxMemorySize)}
|
||||
tipKey="deviceMemory"
|
||||
onInfo={onInfo}
|
||||
accent
|
||||
/>
|
||||
)}
|
||||
{device.metadata
|
||||
? Object.entries(device.metadata).map(([k, v]) => (
|
||||
<InfoRow key={k} label={k} value={String(v)} />
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const devSt = StyleSheet.create({
|
||||
wrap: {
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.surfaceSecondary,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: 4,
|
||||
},
|
||||
nameCol: {
|
||||
flex: 1,
|
||||
},
|
||||
icon: { fontSize: 20 },
|
||||
name: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
sub: { fontSize: 11, color: colors.textTertiary },
|
||||
infoBtn: { fontSize: 15, color: colors.primary },
|
||||
});
|
||||
|
||||
// ─── Main Screen ──────────────────────────────────────────────────────────────
|
||||
|
||||
const platformConsts = Platform.constants as Record<string, unknown>;
|
||||
|
||||
export default function HardwareInfoScreen() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const [tooltip, setTooltip] = useState<TooltipState>({ visible: false, key: '' });
|
||||
const showTip = useCallback((key: string) => setTooltip({ visible: true, key }), []);
|
||||
const closeTip = useCallback(() => setTooltip(t => ({ ...t, visible: false })), []);
|
||||
|
||||
// ── Hardware state from store ──────────────────────────────────────────────
|
||||
const ramTotal = useSelector((s: RootState) => s.hardware.ramTotal);
|
||||
const ramFree = useSelector((s: RootState) => s.hardware.ramFree);
|
||||
const diskTotal = useSelector((s: RootState) => s.hardware.diskTotal);
|
||||
const diskFree = useSelector((s: RootState) => s.hardware.diskFree);
|
||||
const backendDevices = useSelector((s: RootState) => s.hardware.backendDevices);
|
||||
const isLoading = useSelector((s: RootState) => s.hardware.isLoading);
|
||||
const hwError = useSelector((s: RootState) => s.hardware.error);
|
||||
|
||||
// ── LLaMA context from models store ───────────────────────────────────────
|
||||
const llamaSystemInfo = useSelector((s: RootState) => s.models.llamaSystemInfo);
|
||||
const llamaContextGpu = useSelector((s: RootState) => s.models.llamaContextGpu);
|
||||
const llamaContextDevices = useSelector((s: RootState) => s.models.llamaContextDevices);
|
||||
const llamaContextReasonNoGPU = useSelector((s: RootState) => s.models.llamaContextReasonNoGPU);
|
||||
const llamaAndroidLib = useSelector((s: RootState) => s.models.llamaAndroidLib);
|
||||
const currentLoadedModel = useSelector((s: RootState) => s.models.currentLoadedModel);
|
||||
|
||||
// Full refresh on focus (disk + backends + RAM); lightweight RAM polling every 2 s
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(refreshHardwareInfo());
|
||||
|
||||
if (Platform.OS !== 'android') { return; }
|
||||
|
||||
const interval = setInterval(() => { dispatch(refreshRamInfo()); }, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
const ramUsed = ramTotal > 0 ? ramTotal - ramFree : 0;
|
||||
const usedSpace = diskTotal > 0 ? diskTotal - diskFree : 0;
|
||||
|
||||
const hasGPU = backendDevices.some(d => d.type.toUpperCase() === 'GPU');
|
||||
const hasOpenCL = backendDevices.some(d => d.backend.toUpperCase().includes('OPENCL'));
|
||||
const hasMetal = backendDevices.some(d => d.backend.toUpperCase().includes('METAL'));
|
||||
const hasHexagon = backendDevices.some(
|
||||
d =>
|
||||
d.deviceName.toUpperCase().includes('HTP') ||
|
||||
d.deviceName.toUpperCase().includes('HEXAGON') ||
|
||||
d.backend.toUpperCase().includes('HEXAGON'),
|
||||
);
|
||||
const hasCUDA = backendDevices.some(d => d.backend.toUpperCase().includes('CUDA'));
|
||||
|
||||
const osLabel = Platform.OS === 'android' ? 'Android' : 'iOS';
|
||||
const osVersion =
|
||||
Platform.OS === 'android' ? `API ${Platform.Version}` : String(Platform.Version);
|
||||
const deviceModel =
|
||||
Platform.OS === 'android'
|
||||
? `${String(platformConsts.Manufacturer ?? '')} ${String(platformConsts.Model ?? '')}`.trim()
|
||||
: String(platformConsts.interfaceIdiom ?? '');
|
||||
const brand = Platform.OS === 'android' ? String(platformConsts.Brand ?? '') : undefined;
|
||||
const uiMode = Platform.OS === 'android' ? String(platformConsts.uiMode ?? '') : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipModal state={tooltip} onClose={closeTip} />
|
||||
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={() => dispatch(refreshHardwareInfo())}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Text style={styles.pageTitle}>🖥 Matériel</Text>
|
||||
|
||||
{hwError ? (
|
||||
<View style={styles.errorBox}>
|
||||
<Text style={styles.errorText}>⚠ {hwError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{/* ── Système d'exploitation ─────────────────────────────────── */}
|
||||
<Card title="Système d'exploitation">
|
||||
<InfoRow label="OS" value={osLabel} tipKey="os" onInfo={showTip} />
|
||||
<InfoRow label="Version" value={osVersion} tipKey="osVersion" onInfo={showTip} />
|
||||
{deviceModel ? (
|
||||
<InfoRow label="Appareil" value={deviceModel} tipKey="deviceModel" onInfo={showTip} />
|
||||
) : null}
|
||||
{brand ? (
|
||||
<InfoRow label="Marque" value={brand} tipKey="deviceModel" onInfo={showTip} />
|
||||
) : null}
|
||||
{Platform.OS === 'android' && platformConsts.Release ? (
|
||||
<InfoRow
|
||||
label="Release"
|
||||
value={String(platformConsts.Release)}
|
||||
tipKey="osVersion"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{uiMode ? (
|
||||
<InfoRow label="Mode UI" value={uiMode} tipKey="uiMode" onInfo={showTip} />
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
{/* ── Mémoire vive (RAM) ─────────────────────────────────────── */}
|
||||
<Card title="Mémoire vive (RAM)">
|
||||
{ramTotal > 0 ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="RAM totale"
|
||||
value={fmtBytes(ramTotal)}
|
||||
tipKey="ramTotal"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
<InfoRow
|
||||
label="RAM disponible"
|
||||
value={`${fmtBytes(ramFree)} (${pct(ramFree, ramTotal)})`}
|
||||
tipKey="ramFree"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
<InfoRow
|
||||
label="RAM utilisée"
|
||||
value={`${fmtBytes(ramUsed)} (${pct(ramUsed, ramTotal)})`}
|
||||
tipKey="ramFree"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="RAM"
|
||||
value={Platform.OS === 'ios' ? 'Non disponible sur iOS' : 'Chargement…'}
|
||||
tipKey="ramTotal"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Processeur (CPU) ───────────────────────────────────────── */}
|
||||
<Card title="Processeur (CPU)">
|
||||
{llamaSystemInfo ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Capacités SIMD"
|
||||
value="Voir détail ci-dessous"
|
||||
tipKey="simd"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<Text style={styles.monoBlock}>{llamaSystemInfo}</Text>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="Capacités SIMD"
|
||||
value="Chargez un modèle pour afficher"
|
||||
tipKey="simd"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Stockage ───────────────────────────────────────────────── */}
|
||||
<Card title="Stockage">
|
||||
{diskTotal > 0 ? (
|
||||
<>
|
||||
<InfoRow
|
||||
label="Espace total"
|
||||
value={fmtBytes(diskTotal)}
|
||||
tipKey="diskTotal"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Espace utilisé"
|
||||
value={`${fmtBytes(usedSpace)} (${pct(usedSpace, diskTotal)})`}
|
||||
tipKey="diskFree"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Espace libre"
|
||||
value={`${fmtBytes(diskFree)} (${pct(diskFree, diskTotal)})`}
|
||||
tipKey="diskFree"
|
||||
onInfo={showTip}
|
||||
accent
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<InfoRow label="Espace libre" value="Chargement…" tipKey="diskFree" onInfo={showTip} />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Backends de calcul ─────────────────────────────────────── */}
|
||||
<Card title="Backends de calcul (llama.rn)">
|
||||
{backendDevices.length === 0 && !isLoading ? (
|
||||
<InfoRow
|
||||
label="Backends"
|
||||
value="Aucun détecté"
|
||||
tipKey="backendDevice"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{backendDevices.length === 0 && isLoading ? (
|
||||
<InfoRow label="Backends" value="Chargement…" />
|
||||
) : null}
|
||||
{backendDevices.length > 0 ? (
|
||||
<View style={styles.badgeRow}>
|
||||
<Badge label="GPU" active={hasGPU} />
|
||||
<Badge label="OpenCL" active={hasOpenCL} />
|
||||
<Badge label="Metal" active={hasMetal} />
|
||||
<Badge label="Hexagon NPU" active={hasHexagon} />
|
||||
<Badge label="CUDA" active={hasCUDA} />
|
||||
</View>
|
||||
) : null}
|
||||
{backendDevices.map((dev) => (
|
||||
<BackendDeviceCard key={`${dev.backend}-${dev.deviceName}`} device={dev} onInfo={showTip} />
|
||||
))}
|
||||
</Card>
|
||||
|
||||
{/* ── Contexte actif ─────────────────────────────────────────── */}
|
||||
<Card title="Contexte actif (modèle chargé)">
|
||||
{currentLoadedModel ? (
|
||||
<>
|
||||
<InfoRow label="Modèle" value={currentLoadedModel.split('/').pop() ?? '—'} />
|
||||
<InfoRow
|
||||
label="GPU utilisé"
|
||||
value={
|
||||
llamaContextGpu === undefined
|
||||
? '—'
|
||||
: llamaContextGpu
|
||||
? 'Oui ✅'
|
||||
: 'Non ❌'
|
||||
}
|
||||
tipKey="gpuUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
{!llamaContextGpu && llamaContextReasonNoGPU ? (
|
||||
<InfoRow
|
||||
label="Raison (pas GPU)"
|
||||
value={llamaContextReasonNoGPU}
|
||||
tipKey="reasonNoGPU"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{llamaContextDevices && llamaContextDevices.length > 0 ? (
|
||||
<InfoRow
|
||||
label="Appareils utilisés"
|
||||
value={llamaContextDevices.join(', ')}
|
||||
tipKey="devicesUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
{Platform.OS === 'android' && llamaAndroidLib ? (
|
||||
<InfoRow
|
||||
label="Lib Android"
|
||||
value={llamaAndroidLib}
|
||||
tipKey="androidLib"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<InfoRow
|
||||
label="État"
|
||||
value="Aucun modèle chargé"
|
||||
tipKey="gpuUsed"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── llama.rn ───────────────────────────────────────────────── */}
|
||||
<Card title="llama.rn">
|
||||
<InfoRow
|
||||
label="Build"
|
||||
value={String(BuildInfo.number)}
|
||||
tipKey="buildNumber"
|
||||
onInfo={showTip}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Commit"
|
||||
value={String(BuildInfo.commit)}
|
||||
tipKey="buildCommit"
|
||||
onInfo={showTip}
|
||||
mono
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.refreshBtn}
|
||||
onPress={() => dispatch(refreshHardwareInfo())}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.refreshBtnText}>↻ Actualiser</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ height: spacing.xl * 2 }} />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.background },
|
||||
content: { padding: spacing.md },
|
||||
pageTitle: {
|
||||
fontSize: typography.sizes.xl,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
badgeRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
monoBlock: {
|
||||
fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace' }),
|
||||
fontSize: 11,
|
||||
lineHeight: 17,
|
||||
color: colors.textSecondary,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
errorBox: {
|
||||
backgroundColor: colors.errorLight,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
errorText: { color: colors.errorDark, fontSize: typography.sizes.sm },
|
||||
refreshBtn: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
refreshBtnText: {
|
||||
color: colors.primary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
85
app/src/screens/HuggingFaceModelsScreen/ApiKeyEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ApiKeyEditorProps {
|
||||
tempApiKey: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export default function ApiKeyEditor({
|
||||
tempApiKey,
|
||||
onChangeText,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: ApiKeyEditorProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
apiKeyInputContainer: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
apiKeyInput: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
apiKeyButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.apiKeyInputContainer}>
|
||||
<TextInput
|
||||
style={styles.apiKeyInput}
|
||||
value={tempApiKey}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="hf_..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
secureTextEntry
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<View style={styles.apiKeyButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={onSave}
|
||||
>
|
||||
<Text style={styles.buttonText}>Enregistrer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
209
app/src/screens/HuggingFaceModelsScreen/HFModelItem.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import type { HuggingFaceModel } from '../../store/modelsSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface HFModelItemProps {
|
||||
model: HuggingFaceModel;
|
||||
onPress: (modelId: string) => void;
|
||||
onDownload: (modelId: string) => void;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
bytesWritten?: number;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export default function HFModelItem({
|
||||
model,
|
||||
onPress,
|
||||
onDownload,
|
||||
isDownloading = false,
|
||||
downloadProgress = 0,
|
||||
bytesWritten = 0,
|
||||
contentLength = 0,
|
||||
}: HFModelItemProps) {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modelCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
cardContent: {
|
||||
flex: 1,
|
||||
},
|
||||
modelHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
modelAuthor: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.primary,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
},
|
||||
stat: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.6,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
tag: {
|
||||
backgroundColor: colors.border,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
tagText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
moreTagsText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
downloadButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
downloadButtonDisabled: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
downloadingContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
downloadButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
downloadPercentText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.bold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.modelCard}>
|
||||
<TouchableOpacity
|
||||
onPress={() => onPress(model.id)}
|
||||
activeOpacity={0.7}
|
||||
style={styles.cardContent}
|
||||
>
|
||||
<View style={styles.modelHeader}>
|
||||
<Text style={styles.modelAuthor}>{model.author}</Text>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>⬇</Text>
|
||||
<Text style={styles.statValue}>
|
||||
{formatNumber(model.downloads)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.stat}>
|
||||
<Text style={styles.statLabel}>♥</Text>
|
||||
<Text style={styles.statValue}>{formatNumber(model.likes)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.modelName}>{model.name}</Text>
|
||||
{model.tags.length > 0 && (
|
||||
<View style={styles.tagsContainer}>
|
||||
{model.tags.slice(0, 3).map(tag => (
|
||||
<View key={`tag-${model.id}-${tag}`} style={styles.tag}>
|
||||
<Text style={styles.tagText}>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
{model.tags.length > 3 && (
|
||||
<Text style={styles.moreTagsText}>
|
||||
+{model.tags.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.downloadButton,
|
||||
isDownloading && styles.downloadButtonDisabled,
|
||||
]}
|
||||
onPress={() => onDownload(model.id)}
|
||||
disabled={isDownloading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<View style={styles.downloadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.onPrimary} />
|
||||
<Text style={styles.downloadButtonText}>
|
||||
{(bytesWritten / 1024 / 1024).toFixed(1)} MB /
|
||||
{(contentLength / 1024 / 1024).toFixed(1)} MB
|
||||
</Text>
|
||||
<Text style={styles.downloadPercentText}>
|
||||
{Math.round(downloadProgress)}%
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.downloadButtonText}>⬇ Télécharger</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
87
app/src/screens/HuggingFaceModelsScreen/SearchSection.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, TextInput, StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface SearchSectionProps {
|
||||
searchQuery: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSearch: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function SearchSection({
|
||||
searchQuery,
|
||||
onChangeText,
|
||||
onSearch,
|
||||
isLoading,
|
||||
}: SearchSectionProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
searchSection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
searchButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchButtonText: {
|
||||
fontSize: typography.sizes.xl,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.searchSection}>
|
||||
<Text style={styles.sectionTitle}>Rechercher</Text>
|
||||
<View style={styles.searchContainer}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={searchQuery}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="llama, mistral, phi..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSearch}
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={styles.searchButton}
|
||||
onPress={onSearch}
|
||||
disabled={isLoading || !searchQuery.trim()}
|
||||
>
|
||||
<Text style={styles.searchButtonText}>
|
||||
{isLoading ? '...' : '🔍'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
69
app/src/screens/HuggingFaceModelsScreen/downloadHelper.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Alert } from 'react-native';
|
||||
import { AppDispatch } from '../../store';
|
||||
import {
|
||||
downloadHuggingFaceModel,
|
||||
setDownloadProgress,
|
||||
removeDownloadProgress,
|
||||
} from '../../store/modelsSlice';
|
||||
|
||||
export async function handleModelDownload(
|
||||
modelId: string,
|
||||
modelsDirectory: string,
|
||||
dispatch: AppDispatch
|
||||
): Promise<void> {
|
||||
try {
|
||||
const apiUrl = `https://huggingface.co/api/models/${modelId}/tree/main`;
|
||||
const response = await fetch(apiUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Impossible de récupérer les fichiers du modèle');
|
||||
}
|
||||
|
||||
const files = await response.json();
|
||||
const ggufFile = files.find((f: { path: string }) =>
|
||||
f.path.toLowerCase().endsWith('.gguf')
|
||||
);
|
||||
|
||||
if (!ggufFile) {
|
||||
Alert.alert('Erreur', 'Aucun fichier GGUF trouvé dans ce modèle');
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = `https://huggingface.co/${modelId}/resolve/main/${ggufFile.path}`;
|
||||
const sizeMB = (ggufFile.size / 1024 / 1024).toFixed(2);
|
||||
|
||||
Alert.alert(
|
||||
'Télécharger',
|
||||
`Fichier: ${ggufFile.path}\nTaille: ${sizeMB} MB\n\nTélécharger dans ${modelsDirectory}?`,
|
||||
[
|
||||
{ text: 'Annuler', style: 'cancel' },
|
||||
{
|
||||
text: 'Télécharger',
|
||||
onPress: () => {
|
||||
dispatch(downloadHuggingFaceModel({
|
||||
modelId,
|
||||
fileName: ggufFile.path,
|
||||
downloadUrl,
|
||||
destinationDir: modelsDirectory,
|
||||
onProgress: (bytesWritten, contentLength) => {
|
||||
dispatch(setDownloadProgress({
|
||||
modelId,
|
||||
bytesWritten,
|
||||
contentLength,
|
||||
}));
|
||||
},
|
||||
})).then(() => {
|
||||
dispatch(removeDownloadProgress(modelId));
|
||||
Alert.alert('Succès', 'Modèle téléchargé avec succès');
|
||||
}).catch(() => {
|
||||
dispatch(removeDownloadProgress(modelId));
|
||||
Alert.alert('Erreur', 'Échec du téléchargement');
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
Alert.alert('Erreur', (error as Error).message);
|
||||
}
|
||||
}
|
||||
220
app/src/screens/HuggingFaceModelsScreen/index.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import {
|
||||
searchHuggingFaceModels,
|
||||
saveHuggingFaceApiKey,
|
||||
loadHuggingFaceApiKey,
|
||||
setSearchQuery,
|
||||
clearHFError,
|
||||
} from '../../store/modelsSlice';
|
||||
import HFModelItem from './HFModelItem';
|
||||
import ApiKeyEditor from './ApiKeyEditor';
|
||||
import SearchSection from './SearchSection';
|
||||
import { handleModelDownload } from './downloadHelper';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
export default function HuggingFaceModelsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const {
|
||||
huggingFaceModels,
|
||||
huggingFaceApiKey,
|
||||
searchQuery,
|
||||
isLoadingHF,
|
||||
hfError,
|
||||
modelsDirectory,
|
||||
downloadProgress,
|
||||
} = useSelector((state: RootState) => state.models);
|
||||
|
||||
const [showApiKeyInput, setShowApiKeyInput] = useState(false);
|
||||
const [tempApiKey, setTempApiKey] = useState('');
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved API key on mount
|
||||
dispatch(loadHuggingFaceApiKey());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempApiKey(huggingFaceApiKey);
|
||||
}, [huggingFaceApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hfError) {
|
||||
Alert.alert('Erreur', hfError, [
|
||||
{ text: 'OK', onPress: () => dispatch(clearHFError()) },
|
||||
]);
|
||||
}
|
||||
}, [hfError, dispatch]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (localSearchQuery.trim()) {
|
||||
dispatch(setSearchQuery(localSearchQuery.trim()));
|
||||
dispatch(searchHuggingFaceModels({
|
||||
query: localSearchQuery.trim(),
|
||||
apiKey: huggingFaceApiKey || undefined,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveApiKey = () => {
|
||||
dispatch(saveHuggingFaceApiKey(tempApiKey.trim()));
|
||||
setShowApiKeyInput(false);
|
||||
Alert.alert('Succès', 'Clé API enregistrée');
|
||||
};
|
||||
|
||||
const handleOpenHuggingFace = () => {
|
||||
Linking.openURL('https://huggingface.co/settings/tokens');
|
||||
};
|
||||
|
||||
const handleModelPress = (modelId: string) => {
|
||||
const url = `https://huggingface.co/${modelId}`;
|
||||
Linking.openURL(url);
|
||||
};
|
||||
|
||||
const handleDownload = (modelId: string) => {
|
||||
handleModelDownload(modelId, modelsDirectory, dispatch);
|
||||
};
|
||||
|
||||
const renderModelItem = ({ item }: { item: typeof huggingFaceModels[0] }) => {
|
||||
const modelProgress = downloadProgress[item.id];
|
||||
const isDownloading = !!modelProgress;
|
||||
const progressPercent = modelProgress
|
||||
? (modelProgress.bytesWritten / modelProgress.contentLength) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<HFModelItem
|
||||
model={item}
|
||||
onPress={handleModelPress}
|
||||
onDownload={handleDownload}
|
||||
isDownloading={isDownloading}
|
||||
downloadProgress={progressPercent}
|
||||
bytesWritten={modelProgress?.bytesWritten || 0}
|
||||
contentLength={modelProgress?.contentLength || 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (isLoadingHF) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
{searchQuery ? (
|
||||
<>
|
||||
<Text style={styles.emptyText}>Aucun modèle trouvé</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Essayez une autre recherche
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.emptyText}>
|
||||
Recherchez des modèles GGUF
|
||||
</Text>
|
||||
<Text style={styles.emptySubtext}>
|
||||
Entrez un terme de recherche ci-dessus
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.container}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
{/* API Key Section */}
|
||||
<View style={styles.apiKeySection}>
|
||||
<View style={styles.apiKeyHeader}>
|
||||
<Text style={styles.sectionTitle}>Clé API HuggingFace</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton}
|
||||
onPress={handleOpenHuggingFace}
|
||||
>
|
||||
<Text style={styles.infoButtonText}>ℹ️ Obtenir</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{showApiKeyInput ? (
|
||||
<ApiKeyEditor
|
||||
tempApiKey={tempApiKey}
|
||||
onChangeText={setTempApiKey}
|
||||
onCancel={() => {
|
||||
setTempApiKey(huggingFaceApiKey);
|
||||
setShowApiKeyInput(false);
|
||||
}}
|
||||
onSave={handleSaveApiKey}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.apiKeyDisplay}>
|
||||
<Text style={styles.apiKeyText}>
|
||||
{huggingFaceApiKey ? '•••••••••••••' : 'Non configurée'}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.changeButton}
|
||||
onPress={() => setShowApiKeyInput(true)}
|
||||
>
|
||||
<Text style={styles.changeButtonText}>
|
||||
{huggingFaceApiKey ? 'Modifier' : 'Configurer'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.apiKeyHint}>
|
||||
Optionnel - Permet d'augmenter les limites de recherche
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Search Section */}
|
||||
<SearchSection
|
||||
searchQuery={localSearchQuery}
|
||||
onChangeText={setLocalSearchQuery}
|
||||
onSearch={handleSearch}
|
||||
isLoading={isLoadingHF}
|
||||
/>
|
||||
|
||||
{/* Results Section */}
|
||||
<View style={styles.resultsSection}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Résultats ({huggingFaceModels.length})
|
||||
</Text>
|
||||
|
||||
{isLoadingHF && huggingFaceModels.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#007AFF" />
|
||||
<Text style={styles.loadingText}>Recherche en cours...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={huggingFaceModels}
|
||||
renderItem={renderModelItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
ListEmptyComponent={renderEmptyList}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
139
app/src/screens/HuggingFaceModelsScreen/styles.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
|
||||
export const createStyles = (colors: {
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
border: string;
|
||||
primary: string;
|
||||
notification: string;
|
||||
onPrimary: string;
|
||||
}) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
apiKeySection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
apiKeyHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: typography.sizes.lg,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
},
|
||||
infoButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
infoButtonText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.primary,
|
||||
},
|
||||
apiKeyDisplay: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
apiKeyText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
changeButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 6,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
changeButtonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
apiKeyHint: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
searchSection: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
searchButton: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
borderRadius: borderRadius.lg,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchButtonText: {
|
||||
fontSize: typography.sizes.xl,
|
||||
},
|
||||
resultsSection: {
|
||||
flex: 1,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xxl,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: typography.sizes.md,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
53
app/src/screens/LandingScreen.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Image, StyleSheet, View } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
import type { RootStackParamList } from '../navigation';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList, 'Landing'>;
|
||||
|
||||
export default function LandingScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'MainTabs',
|
||||
params: { screen: 'Models' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [navigation]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Image
|
||||
source={require('../../assets/logo.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel="My Mobile Agent"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 220,
|
||||
height: 220,
|
||||
},
|
||||
});
|
||||
85
app/src/screens/LocalModelsScreen/DirectoryEditor.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface DirectoryEditorProps {
|
||||
tempDirectory: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
onSelectCommon: (dir: string) => void;
|
||||
}
|
||||
|
||||
export default function DirectoryEditor({
|
||||
tempDirectory,
|
||||
onChangeText,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: DirectoryEditorProps) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
editContainer: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
directoryInput: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
editButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: 10,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.border,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
buttonText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.editContainer}>
|
||||
<TextInput
|
||||
style={styles.directoryInput}
|
||||
value={tempDirectory}
|
||||
onChangeText={onChangeText}
|
||||
placeholder="Chemin du dossier"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholderTextColor={`${colors.text }80`}
|
||||
/>
|
||||
<View style={styles.editButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.cancelButton]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.buttonText}>Annuler</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, styles.saveButton]}
|
||||
onPress={onSave}
|
||||
>
|
||||
<Text style={styles.buttonText}>Enregistrer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
71
app/src/screens/LocalModelsScreen/DirectoryPicker.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import RNFS from 'react-native-fs';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface DirectoryPickerProps {
|
||||
currentDirectory: string;
|
||||
onSelectDirectory: (directory: string) => void;
|
||||
}
|
||||
|
||||
const commonDirectories = [
|
||||
{ label: 'Téléchargements/models', path: `${RNFS.DownloadDirectoryPath}/models` },
|
||||
{ label: 'Documents/models', path: `${RNFS.DocumentDirectoryPath}/models` },
|
||||
{ label: 'Téléchargements', path: RNFS.DownloadDirectoryPath },
|
||||
];
|
||||
|
||||
export default function DirectoryPicker(
|
||||
{ onSelectDirectory }: DirectoryPickerProps
|
||||
) {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
commonDirsContainer: {
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
commonDirsLabel: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
},
|
||||
commonDirButton: {
|
||||
backgroundColor: colors.card,
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
commonDirText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.text,
|
||||
marginBottom: 2,
|
||||
},
|
||||
commonDirPath: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.text,
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.commonDirsContainer}>
|
||||
<Text style={styles.commonDirsLabel}>Dossiers courants :</Text>
|
||||
{commonDirectories.map((dir, _idx) => (
|
||||
<TouchableOpacity
|
||||
key={`dir-${dir.path}`}
|
||||
style={styles.commonDirButton}
|
||||
onPress={() => onSelectDirectory(dir.path)}
|
||||
>
|
||||
<Text style={styles.commonDirText}>{dir.label}</Text>
|
||||
<Text style={styles.commonDirPath} numberOfLines={1}>
|
||||
{dir.path}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
141
app/src/screens/LocalModelsScreen/ModelConfigScreen.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../../navigation';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import { saveModelConfig } from '../../store/modelsThunks';
|
||||
import { setCurrentLoadedModel, setModelConfig } from '../../store/modelsSlice';
|
||||
import { loadModel } from '../../store/chatThunks';
|
||||
import { colors } from '../../theme/lightTheme';
|
||||
import { PARAM_INFO } from './modelConfig/paramInfo';
|
||||
import { useModelConfigState } from './modelConfig/hooks/useModelConfigState';
|
||||
import { buildModelConfig } from './modelConfig/buildModelConfig';
|
||||
import SystemPromptSection from './modelConfig/sections/SystemPromptSection';
|
||||
import LoadingSection from './modelConfig/sections/LoadingSection';
|
||||
import SamplingSection from './modelConfig/sections/SamplingSection';
|
||||
import PenaltiesSection from './modelConfig/sections/PenaltiesSection';
|
||||
import OutputSection from './modelConfig/sections/OutputSection';
|
||||
import { styles } from './modelConfig/styles';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'ModelConfig'>;
|
||||
|
||||
export default function ModelConfigScreen({ route, navigation }: Props) {
|
||||
const { modelPath, modelName } = route.params;
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const loadModelProgress = useSelector(
|
||||
(s: RootState) => s.models.loadModelProgress ?? 0,
|
||||
);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const state = useModelConfigState(modelPath, navigation, modelName);
|
||||
const tooltipInfo = PARAM_INFO[state.tooltip.key];
|
||||
const progressStyle = { width: `${loadModelProgress}%` as `${number}%` };
|
||||
|
||||
const handleSave = async () => {
|
||||
const config = buildModelConfig(state);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await dispatch(saveModelConfig({ modelPath, config }));
|
||||
dispatch(setModelConfig({ modelPath, config }));
|
||||
dispatch(setCurrentLoadedModel(modelPath));
|
||||
const result = await dispatch(loadModel({ modelPath, cfg: config }));
|
||||
if (loadModel.rejected.match(result)) {
|
||||
Alert.alert(
|
||||
'Erreur de chargement',
|
||||
String(result.payload ?? 'Impossible de charger le modèle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
navigation.navigate('MainTabs', { screen: 'Chat' } as never);
|
||||
} catch (e) {
|
||||
Alert.alert('Erreur', (e as Error).message ?? 'Une erreur est survenue');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
visible={state.tooltip.visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={state.closeTooltip}
|
||||
>
|
||||
<Pressable style={styles.modalOverlay} onPress={state.closeTooltip}>
|
||||
<Pressable style={styles.modalCard} onPress={() => {}}>
|
||||
<Text style={styles.modalTitle}>{tooltipInfo?.title}</Text>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>📖 Description</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.description}</Text>
|
||||
</View>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>⚙️ Impact</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.impact}</Text>
|
||||
</View>
|
||||
<View style={styles.modalSection}>
|
||||
<Text style={styles.modalSectionLabel}>💡 Utilisation</Text>
|
||||
<Text style={styles.modalSectionText}>{tooltipInfo?.usage}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.modalClose}
|
||||
onPress={state.closeTooltip}
|
||||
>
|
||||
<Text style={styles.modalCloseText}>Fermer</Text>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
|
||||
<Modal visible={isSubmitting} transparent animationType="fade">
|
||||
<View style={styles.loadingOverlay}>
|
||||
<View style={styles.loadingCard}>
|
||||
<Text style={styles.loadingTitle}>Chargement du modèle</Text>
|
||||
<Text style={styles.loadingSubtitle} numberOfLines={1}>
|
||||
{modelName}
|
||||
</Text>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={colors.primary}
|
||||
style={styles.activityIndicator}
|
||||
/>
|
||||
<View style={styles.progressBarBg}>
|
||||
<View style={[styles.progressBarFill, progressStyle]} />
|
||||
</View>
|
||||
<Text style={styles.loadingPercent}>{loadModelProgress}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<SystemPromptSection state={state} />
|
||||
<LoadingSection state={state} />
|
||||
<SamplingSection state={state} />
|
||||
<PenaltiesSection state={state} />
|
||||
<OutputSection state={state} />
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSave}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>
|
||||
💾 Sauvegarder et charger le modèle
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.bottomSpacer} />
|
||||
</ScrollView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1374
app/src/screens/LocalModelsScreen/ModelConfigScreen_old.tsx
Normal file
285
app/src/screens/LocalModelsScreen/ModelItem.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import type { LocalModel } from '../../store/modelsSlice';
|
||||
import { spacing, typography, borderRadius } from '../../theme/lightTheme';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface ModelItemProps {
|
||||
model: LocalModel;
|
||||
isLoaded: boolean;
|
||||
onPress: (path: string, name: string) => void;
|
||||
/** Charge le modèle directement avec la config enregistrée */
|
||||
onLoad?: () => void;
|
||||
/** Décharge le modèle de la RAM */
|
||||
onUnload?: () => void;
|
||||
/** True uniquement pour la carte en cours de chargement */
|
||||
isLoadingThisModel?: boolean;
|
||||
/** Called when the card body is tapped (toggle highlight) */
|
||||
onHighlight?: () => void;
|
||||
isHighlighted?: boolean;
|
||||
/** Free RAM in bytes from hardware slice */
|
||||
ramFree?: number;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
bytesWritten?: number;
|
||||
contentLength?: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) { return `${bytes} B`; }
|
||||
if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(2)} KB`; }
|
||||
if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; }
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function ModelItem({
|
||||
model,
|
||||
isLoaded,
|
||||
onPress,
|
||||
onLoad,
|
||||
onUnload,
|
||||
isLoadingThisModel = false,
|
||||
onHighlight,
|
||||
isHighlighted = false,
|
||||
ramFree = 0,
|
||||
isDownloading = false,
|
||||
downloadProgress = 0,
|
||||
bytesWritten = 0,
|
||||
contentLength = 0,
|
||||
}: ModelItemProps) {
|
||||
const { colors, scheme } = useTheme();
|
||||
const isDark = scheme === 'dark';
|
||||
|
||||
// How much of the available free RAM this model would consume
|
||||
const ramImpactPct = ramFree > 0 ? Math.round((model.size / ramFree) * 100) : null;
|
||||
const modelExceedsRam = ramImpactPct !== null && ramImpactPct > 100;
|
||||
const impactColor =
|
||||
ramImpactPct === null ? colors.text
|
||||
: modelExceedsRam ? colors.notification
|
||||
: ramImpactPct > 70 ? colors.warning
|
||||
: colors.success;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modelCard: {
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: colors.textPrimary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: isDark ? 0.3 : 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
borderWidth: 1.5,
|
||||
borderColor: isHighlighted ? colors.primary : colors.border,
|
||||
},
|
||||
modelCardHighlighted: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: isDark ? colors.card : colors.agentBg,
|
||||
},
|
||||
modelInfoTouchable: {
|
||||
flex: 1,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
modelName: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.text,
|
||||
marginBottom: 4,
|
||||
},
|
||||
modelDetails: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.6,
|
||||
marginBottom: 4,
|
||||
},
|
||||
impactRow: {
|
||||
marginTop: 2,
|
||||
},
|
||||
impactBarTrack: {
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: colors.border,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 3,
|
||||
},
|
||||
impactBarFill: {
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
},
|
||||
impactLabel: {
|
||||
fontSize: typography.sizes.xs,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
loadedBadge: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
loadedText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.xs,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
actionCol: {
|
||||
flexDirection: 'column',
|
||||
gap: spacing.sm,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
loadBtn: {
|
||||
backgroundColor: colors.primary,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadBtnText: {
|
||||
color: colors.onPrimary,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
unloadBtn: {
|
||||
backgroundColor: colors.notification,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
unloadBtnText: {
|
||||
color: colors.surface,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
configBtn: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
minWidth: 90,
|
||||
alignItems: 'center',
|
||||
},
|
||||
configBtnActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: isDark ? colors.card : colors.agentBg,
|
||||
},
|
||||
configBtnText: {
|
||||
color: colors.text,
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
},
|
||||
loadingSpinner: {
|
||||
marginVertical: spacing.sm,
|
||||
},
|
||||
downloadingText: {
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.text,
|
||||
opacity: 0.7,
|
||||
marginBottom: 2,
|
||||
},
|
||||
downloadPercentText: {
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.bold,
|
||||
color: colors.primary,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
// Outer card — tap to toggle highlight (shows footprint in header bar)
|
||||
<View style={[styles.modelCard, isHighlighted && styles.modelCardHighlighted]}>
|
||||
<TouchableOpacity
|
||||
style={styles.modelInfoTouchable}
|
||||
onPress={onHighlight}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text style={styles.modelName} numberOfLines={1}>
|
||||
{model.name}
|
||||
</Text>
|
||||
|
||||
{isDownloading ? (
|
||||
<View>
|
||||
<Text style={styles.downloadingText}>
|
||||
Téléchargement : {(bytesWritten / 1024 / 1024).toFixed(1)} MB /{' '}
|
||||
{(contentLength / 1024 / 1024).toFixed(1)} MB
|
||||
</Text>
|
||||
<Text style={styles.downloadPercentText}>
|
||||
{Math.round(downloadProgress)} %
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={styles.modelDetails}>
|
||||
Taille : {formatSize(model.size)}
|
||||
</Text>
|
||||
|
||||
{/* RAM impact bar — always visible when ramFree is known */}
|
||||
{ramImpactPct !== null && (
|
||||
<View style={styles.impactRow}>
|
||||
<View style={styles.impactBarTrack}>
|
||||
<View
|
||||
style={[
|
||||
styles.impactBarFill,
|
||||
{
|
||||
width: `${Math.min(ramImpactPct, 100)}%` as `${number}%`,
|
||||
backgroundColor: impactColor,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.impactLabel, { color: impactColor }]}>
|
||||
{modelExceedsRam
|
||||
? `⚠ Trop lourd — nécessite ${ramImpactPct} % de la RAM libre`
|
||||
: `Utilise ~${ramImpactPct} % de la RAM libre au chargement`}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isLoaded && (
|
||||
<View style={styles.loadedBadge}>
|
||||
<Text style={styles.loadedText}>Chargé</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Action buttons column */}
|
||||
<View style={styles.actionCol}>
|
||||
{/* Charger / Décharger / spinner */}
|
||||
{isLoadingThisModel ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary}
|
||||
style={styles.loadingSpinner}
|
||||
/>
|
||||
) : isLoaded ? (
|
||||
<TouchableOpacity style={styles.unloadBtn} onPress={onUnload}>
|
||||
<Text style={styles.unloadBtnText}>Décharger</Text>
|
||||
</TouchableOpacity>
|
||||
) : !isDownloading ? (
|
||||
<TouchableOpacity style={styles.loadBtn} onPress={onLoad}>
|
||||
<Text style={styles.loadBtnText}>▶ Charger</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{/* Configurer — toujours visible sauf pendant le download */}
|
||||
{!isDownloading && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onPress(model.path, model.name)}
|
||||
style={[styles.configBtn, isHighlighted && styles.configBtnActive]}
|
||||
>
|
||||
<Text style={styles.configBtnText}>Configurer</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
80
app/src/screens/LocalModelsScreen/ModelsList.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { View, Text, FlatList, ActivityIndicator, RefreshControl } from 'react-native';
|
||||
import type { LocalModel } from '../../store/types';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface EmptyListProps {
|
||||
message: string;
|
||||
submessage: string;
|
||||
colors: {
|
||||
text: string;
|
||||
border: string;
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyList({ message, submessage, colors }: EmptyListProps) {
|
||||
const styles = createStyles(colors);
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{message}</Text>
|
||||
<Text style={styles.emptySubtext}>{submessage}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelsListProps {
|
||||
models: LocalModel[];
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
renderItem: (item: { item: LocalModel }) => React.ReactElement;
|
||||
}
|
||||
|
||||
export default function ModelsList({
|
||||
models,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
renderItem,
|
||||
}: ModelsListProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.modelsSection}>
|
||||
<View style={styles.modelsHeader}>
|
||||
<Text style={styles.sectionTitle}>
|
||||
Modèles locaux ({models.length})
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isLoading && models.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>Scan en cours...</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={models}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.path}
|
||||
ListEmptyComponent={
|
||||
<EmptyList
|
||||
message="Aucun fichier .gguf trouvé dans ce dossier"
|
||||
submessage="Placez vos modèles dans le dossier configuré"
|
||||
colors={colors}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary]}
|
||||
tintColor={colors.primary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
34
app/src/screens/LocalModelsScreen/PermissionMessage.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
|
||||
interface PermissionMessageProps {
|
||||
onRequestPermission: () => void;
|
||||
}
|
||||
|
||||
export default function PermissionMessage({
|
||||
onRequestPermission,
|
||||
}: PermissionMessageProps) {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
|
||||
return (
|
||||
<View style={styles.permissionContainer}>
|
||||
<Text style={styles.permissionText}>
|
||||
📁 Permission requise
|
||||
</Text>
|
||||
<Text style={styles.permissionSubtext}>
|
||||
L'application a besoin d'accéder aux fichiers pour lire vos modèles GGUF
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={onRequestPermission}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>
|
||||
Autoriser l'accès aux fichiers
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
398
app/src/screens/LocalModelsScreen/index.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
AppState,
|
||||
Platform,
|
||||
Modal,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../../navigation';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import type { RootState, AppDispatch } from '../../store';
|
||||
import {
|
||||
scanLocalModels,
|
||||
updateModelsDirectory,
|
||||
loadModelsDirectory,
|
||||
clearLocalError,
|
||||
setCurrentLoadedModel,
|
||||
} from '../../store/modelsSlice';
|
||||
import { loadModelConfigs } from '../../store/modelsThunks';
|
||||
import { refreshHardwareInfo, refreshRamInfo } from '../../store/hardwareSlice';
|
||||
import { loadModel, releaseModel } from '../../store/chatThunks';
|
||||
import type { ModelConfig } from '../../store/types';
|
||||
import ModelItem from './ModelItem';
|
||||
import DirectoryPicker from './DirectoryPicker';
|
||||
import DirectoryEditor from './DirectoryEditor';
|
||||
import PermissionMessage from './PermissionMessage';
|
||||
import ModelsList from './ModelsList';
|
||||
import { createStyles } from './styles';
|
||||
import { useTheme } from '../../theme/ThemeProvider';
|
||||
import {
|
||||
requestStoragePermission,
|
||||
checkStoragePermission,
|
||||
} from '../../utils/permissions';
|
||||
|
||||
export default function LocalModelsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = createStyles(colors);
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const navigation = useNavigation<
|
||||
NativeStackNavigationProp<RootStackParamList>
|
||||
>();
|
||||
const {
|
||||
localModels,
|
||||
modelsDirectory,
|
||||
isLoadingLocal,
|
||||
localError,
|
||||
currentLoadedModel,
|
||||
downloadProgress,
|
||||
} = useSelector((state: RootState) =>
|
||||
state.models,
|
||||
);
|
||||
|
||||
const ramFree = useSelector((s: RootState) => s.hardware.ramFree);
|
||||
const ramTotal = useSelector((s: RootState) => s.hardware.ramTotal);
|
||||
const isLoadingModel = useSelector((s: RootState) => s.models.isLoadingModel);
|
||||
const loadModelProgress = useSelector((s: RootState) => s.models.loadModelProgress ?? 0);
|
||||
const modelConfigs = useSelector((s: RootState) => s.models.modelConfigs ?? {});
|
||||
|
||||
// Highlighted model path — tapping a card shows its footprint in the RAM bar
|
||||
const [highlightedPath, setHighlightedPath] = useState<string | null>(null);
|
||||
// Path du modèle en cours de chargement (pour spinner sur la bonne carte)
|
||||
const [loadingPath, setLoadingPath] = useState<string | null>(null);
|
||||
// Nom du modèle affiché dans la modal de chargement
|
||||
const [loadingModelName, setLoadingModelName] = useState<string | null>(null);
|
||||
|
||||
const [editingDirectory, setEditingDirectory] = useState(false);
|
||||
const [tempDirectory, setTempDirectory] = useState(modelsDirectory);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkStoragePermission().then(setHasPermission);
|
||||
|
||||
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
const permitted = await checkStoragePermission();
|
||||
setHasPermission(permitted);
|
||||
if (permitted) {
|
||||
dispatch(scanLocalModels(modelsDirectory));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [dispatch, modelsDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load saved directory and scan on mount
|
||||
const initializeScreen = async () => {
|
||||
const result = await dispatch(loadModelsDirectory());
|
||||
if (result.payload) {
|
||||
const permitted = await checkStoragePermission();
|
||||
setHasPermission(permitted);
|
||||
if (permitted) {
|
||||
dispatch(scanLocalModels(result.payload as string));
|
||||
}
|
||||
}
|
||||
dispatch(loadModelConfigs());
|
||||
};
|
||||
|
||||
initializeScreen();
|
||||
// Refresh RAM info on mount so the indicator is up to date
|
||||
dispatch(refreshHardwareInfo());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
setTempDirectory(modelsDirectory);
|
||||
}, [modelsDirectory]);
|
||||
|
||||
// Polling RAM toutes les secondes quand l'écran est actif
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
dispatch(refreshRamInfo());
|
||||
const interval = setInterval(() => {
|
||||
dispatch(refreshRamInfo());
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (localError) {
|
||||
Alert.alert('Erreur', localError, [
|
||||
{ text: 'OK', onPress: () => dispatch(clearLocalError()) },
|
||||
]);
|
||||
}
|
||||
}, [localError, dispatch]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
dispatch(scanLocalModels(modelsDirectory));
|
||||
};
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestStoragePermission();
|
||||
// Attendre un peu puis revérifier
|
||||
setTimeout(async () => {
|
||||
const permitted = await checkStoragePermission();
|
||||
setHasPermission(permitted);
|
||||
if (permitted) {
|
||||
dispatch(scanLocalModels(modelsDirectory));
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleUpdateDirectory = () => {
|
||||
if (tempDirectory.trim()) {
|
||||
dispatch(updateModelsDirectory(tempDirectory.trim()));
|
||||
setEditingDirectory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectCommonDirectory = (dir: string) => {
|
||||
setTempDirectory(dir);
|
||||
dispatch(updateModelsDirectory(dir));
|
||||
setEditingDirectory(false);
|
||||
};
|
||||
|
||||
const handleLoadModel = async (modelPath: string, modelName: string) => {
|
||||
const cfg: ModelConfig = modelConfigs[modelPath] ?? {
|
||||
n_ctx: 2048,
|
||||
n_threads: 4,
|
||||
n_gpu_layers: 0,
|
||||
use_mlock: false,
|
||||
use_mmap: true,
|
||||
};
|
||||
setLoadingPath(modelPath);
|
||||
setLoadingModelName(modelName);
|
||||
try {
|
||||
dispatch(setCurrentLoadedModel(modelPath));
|
||||
const result = await dispatch(loadModel({ modelPath, cfg }));
|
||||
if (loadModel.rejected.match(result)) {
|
||||
dispatch(setCurrentLoadedModel(null));
|
||||
Alert.alert(
|
||||
'Erreur de chargement',
|
||||
String(result.payload ?? `Impossible de charger ${modelName}`),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoadingPath(null);
|
||||
setLoadingModelName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnloadModel = async () => {
|
||||
await dispatch(releaseModel());
|
||||
dispatch(setCurrentLoadedModel(null));
|
||||
setHighlightedPath(null);
|
||||
dispatch(refreshRamInfo());
|
||||
};
|
||||
|
||||
const renderModelItem = ({ item }: { item: typeof localModels[0] }) => {
|
||||
const isLoaded = currentLoadedModel === item.path;
|
||||
|
||||
// Vérifier si ce modèle est en cours de téléchargement
|
||||
const downloadKey = Object.keys(downloadProgress).find(key => {
|
||||
const fileName = downloadProgress[key].modelId.split('/').pop();
|
||||
return item.name.includes(fileName || '');
|
||||
});
|
||||
|
||||
const modelProgress = downloadKey ? downloadProgress[downloadKey] : null;
|
||||
const isDownloading = !!modelProgress;
|
||||
const progressPercent = modelProgress
|
||||
? (modelProgress.bytesWritten / modelProgress.contentLength) * 100
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<ModelItem
|
||||
model={item}
|
||||
isLoaded={isLoaded}
|
||||
onPress={(path: string, name: string) => {
|
||||
setHighlightedPath(null);
|
||||
navigation.navigate('ModelConfig', { modelPath: path, modelName: name });
|
||||
}}
|
||||
onLoad={() => handleLoadModel(item.path, item.name)}
|
||||
onUnload={handleUnloadModel}
|
||||
isLoadingThisModel={isLoadingModel && loadingPath === item.path}
|
||||
onHighlight={() =>
|
||||
setHighlightedPath(prev => (prev === item.path ? null : item.path))
|
||||
}
|
||||
isHighlighted={highlightedPath === item.path}
|
||||
ramFree={ramFree}
|
||||
isDownloading={isDownloading}
|
||||
downloadProgress={progressPercent}
|
||||
bytesWritten={modelProgress?.bytesWritten || 0}
|
||||
contentLength={modelProgress?.contentLength || 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const fmtGB = (b: number) =>
|
||||
b >= 1073741824
|
||||
? `${(b / 1073741824).toFixed(2)} GB`
|
||||
: b >= 1048576
|
||||
? `${(b / 1048576).toFixed(0)} MB`
|
||||
: '—';
|
||||
|
||||
// Highlighted model's size (null when nothing is selected)
|
||||
const highlightedModel = localModels.find(m => m.path === highlightedPath);
|
||||
const highlightedSize = highlightedModel?.size ?? null;
|
||||
|
||||
// RAM bar proportions (flex values normalized to 0–100)
|
||||
const ramPct = ramTotal > 0 ? (ramFree / ramTotal) * 100 : -1;
|
||||
const ramColor = ramPct < 0
|
||||
? colors.textTertiary
|
||||
: ramPct < 20
|
||||
? colors.error
|
||||
: ramPct < 40
|
||||
? colors.warning
|
||||
: colors.success;
|
||||
const usedFlex = ramTotal > 0 ? ((ramTotal - ramFree) / ramTotal) * 100 : 0;
|
||||
const modelExceedsRam = (highlightedSize ?? 0) > ramFree;
|
||||
const modelFlexRaw = highlightedSize && ramTotal > 0
|
||||
? (Math.min(highlightedSize, ramFree) / ramTotal) * 100
|
||||
: 0;
|
||||
const freeFlex = Math.max(0, 100 - usedFlex - modelFlexRaw);
|
||||
const modelBarColor = modelExceedsRam ? colors.error : colors.warning;
|
||||
|
||||
const progressStyle = { width: `${loadModelProgress}%` as `${number}%` };
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Loading modal — même comportement que ModelConfigScreen */}
|
||||
<Modal visible={loadingModelName !== null} transparent animationType="fade">
|
||||
<View style={styles.loadingOverlay}>
|
||||
<View style={styles.loadingCard}>
|
||||
<Text style={styles.loadingTitle}>Chargement du modèle</Text>
|
||||
<Text style={styles.loadingSubtitle} numberOfLines={1}>
|
||||
{loadingModelName}
|
||||
</Text>
|
||||
<ActivityIndicator
|
||||
size="large"
|
||||
color={colors.primary}
|
||||
style={styles.activityIndicator}
|
||||
/>
|
||||
<View style={styles.progressBarBg}>
|
||||
<View style={[styles.progressBarFill, progressStyle]} />
|
||||
</View>
|
||||
<Text style={styles.loadingPercent}>{loadModelProgress} %</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
{/* Directory Selection */}
|
||||
<View style={styles.directorySection}>
|
||||
<Text style={styles.sectionTitle}>Dossier des modèles</Text>
|
||||
{editingDirectory ? (
|
||||
<>
|
||||
<DirectoryEditor
|
||||
tempDirectory={tempDirectory}
|
||||
onChangeText={setTempDirectory}
|
||||
onCancel={() => {
|
||||
setTempDirectory(modelsDirectory);
|
||||
setEditingDirectory(false);
|
||||
}}
|
||||
onSave={handleUpdateDirectory}
|
||||
onSelectCommon={handleSelectCommonDirectory}
|
||||
/>
|
||||
<DirectoryPicker
|
||||
currentDirectory={modelsDirectory}
|
||||
onSelectDirectory={handleSelectCommonDirectory}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.directoryDisplay}>
|
||||
<Text style={styles.directoryText} numberOfLines={2}>
|
||||
{modelsDirectory}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.changeButton}
|
||||
onPress={() => setEditingDirectory(true)}
|
||||
>
|
||||
<Text style={styles.changeButtonText}>Modifier</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{/* RAM bar */}
|
||||
{Platform.OS === 'android' && ramTotal > 0 && (
|
||||
<View style={styles.ramSection}>
|
||||
{/* Header row */}
|
||||
<View style={styles.ramSectionHeader}>
|
||||
<Text style={styles.ramSectionLabel}>RAM</Text>
|
||||
<Text style={styles.ramHeaderRight}>
|
||||
Libre :{' '}
|
||||
<Text style={[{ color: ramColor }, styles.ramStrong]}>
|
||||
{fmtGB(ramFree)} ({Math.round(ramPct)} %)
|
||||
</Text>
|
||||
{' / '}{fmtGB(ramTotal)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Progress bar */}
|
||||
<View style={styles.ramBarTrack}>
|
||||
{/* Used */}
|
||||
<View style={[styles.ramBarSeg,
|
||||
{ flex: usedFlex, backgroundColor: colors.textSecondary }
|
||||
]} />
|
||||
{/* Model footprint */}
|
||||
{modelFlexRaw > 0 && (
|
||||
<View style={[styles.ramBarSeg, {
|
||||
flex: modelFlexRaw,
|
||||
backgroundColor: modelBarColor
|
||||
}]} />
|
||||
)}
|
||||
{/* Remaining free */}
|
||||
{freeFlex > 0 && (
|
||||
<View style={[styles.ramBarSeg, { flex: freeFlex, backgroundColor: ramColor }]} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Legend when a model is highlighted */}
|
||||
{highlightedSize !== null && (
|
||||
<View style={styles.ramLegend}>
|
||||
<View style={styles.ramLegendRow}>
|
||||
<View style={[styles.ramLegendDot, { backgroundColor: modelBarColor }]} />
|
||||
<Text style={styles.ramLegendText}>
|
||||
Modèle : {fmtGB(highlightedSize)}{' '}
|
||||
<Text style={[{ color: modelBarColor }, styles.ramStrong]}>
|
||||
({Math.round((highlightedSize / ramTotal) * 100)} % de la RAM)
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
{modelExceedsRam && (
|
||||
<Text style={styles.ramWarning}>
|
||||
⚠ Insuffisant — fermez d'autres apps ou choisissez une quantification plus légère
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Hint when nothing selected */}
|
||||
{highlightedSize === null && (
|
||||
<Text style={styles.ramHint}>↑ Appuyez sur un modèle pour voir son impact</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
{!hasPermission && (
|
||||
<PermissionMessage onRequestPermission={handleRequestPermission} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Models List */}
|
||||
<ModelsList
|
||||
models={localModels}
|
||||
isLoading={isLoadingLocal}
|
||||
onRefresh={handleRefresh}
|
||||
renderItem={renderModelItem}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ModelConfig } from '../../../store/types';
|
||||
import type { ModelConfigFormState } from './types';
|
||||
|
||||
function parseOptNum(v: string): number | undefined {
|
||||
return v ? Number(v) : undefined;
|
||||
}
|
||||
|
||||
export function buildModelConfig(state: ModelConfigFormState): ModelConfig {
|
||||
return {
|
||||
systemPrompt: state.systemPrompt.trim(),
|
||||
n_ctx: Number(state.nCtx) || 2048,
|
||||
n_batch: parseOptNum(state.nBatch),
|
||||
n_ubatch: parseOptNum(state.nUbatch),
|
||||
n_threads: parseOptNum(state.nThreads),
|
||||
n_gpu_layers: Number(state.nGpuLayers),
|
||||
flash_attn: state.flashAttn,
|
||||
cache_type_k: state.cacheTypeK,
|
||||
cache_type_v: state.cacheTypeV,
|
||||
use_mlock: state.useMlock,
|
||||
use_mmap: state.useMmap,
|
||||
rope_freq_base: parseOptNum(state.ropeFreqBase),
|
||||
rope_freq_scale: parseOptNum(state.ropeFreqScale),
|
||||
ctx_shift: state.ctxShift,
|
||||
kv_unified: state.kvUnified,
|
||||
n_cpu_moe: parseOptNum(state.nCpuMoe),
|
||||
cpu_mask: state.cpuMask || undefined,
|
||||
n_parallel: Number(state.nParallel) || 1,
|
||||
temperature: Number(state.temperature),
|
||||
top_k: Number(state.topK),
|
||||
top_p: Number(state.topP),
|
||||
min_p: Number(state.minP),
|
||||
n_predict: Number(state.nPredict),
|
||||
seed: Number(state.seed),
|
||||
typical_p: Number(state.typicalP),
|
||||
top_n_sigma: Number(state.topNSigma),
|
||||
mirostat: Number(state.mirostat),
|
||||
mirostat_tau: Number(state.mirostatTau),
|
||||
mirostat_eta: Number(state.mirostatEta),
|
||||
xtc_probability: Number(state.xtcProb),
|
||||
xtc_threshold: Number(state.xtcThresh),
|
||||
penalty_repeat: Number(state.penaltyRepeat),
|
||||
penalty_last_n: Number(state.penaltyLastN),
|
||||
penalty_freq: Number(state.penaltyFreq),
|
||||
penalty_present: Number(state.penaltyPresent),
|
||||
dry_multiplier: Number(state.dryMultiplier),
|
||||
dry_base: Number(state.dryBase),
|
||||
dry_allowed_length: Number(state.dryAllowed),
|
||||
dry_penalty_last_n: Number(state.dryLastN),
|
||||
ignore_eos: state.ignoreEos,
|
||||
n_probs: Number(state.nProbs),
|
||||
stop: state.stopStr
|
||||
? state.stopStr.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { View, Text, Switch, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface BoolRowProps {
|
||||
paramKey: string;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function BoolRow({ paramKey, value, onChange, onInfo }: BoolRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.labelRow}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onChange}
|
||||
trackColor={{ false: colors.border, true: colors.primary }}
|
||||
thumbColor={colors.surface}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
labelRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { TextInput, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
|
||||
interface NumInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function NumInput({ value, onChange, placeholder }: NumInputProps) {
|
||||
return (
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChange}
|
||||
keyboardType="numeric"
|
||||
placeholder={placeholder ?? ''}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
input: {
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
fontSize: typography.sizes.sm,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface ParamRowProps {
|
||||
paramKey: string;
|
||||
children: React.ReactNode;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function ParamRow({ paramKey, children, onInfo }: ParamRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
style={styles.infoBtn}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
infoBtn: { paddingLeft: spacing.sm },
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Text, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
expanded,
|
||||
onToggle,
|
||||
badge,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onToggle}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{badge ? <Text style={styles.badge}>{badge}</Text> : null}
|
||||
<Text style={styles.chevron}>{expanded ? '▲' : '▼'}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
fontSize: typography.sizes.md,
|
||||
fontWeight: typography.weights.semibold,
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
badge: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textTertiary,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
chevron: { fontSize: 12, color: colors.textTertiary },
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { colors, spacing, typography, borderRadius } from '../../../../theme/tokens';
|
||||
import { PARAM_INFO } from '../paramInfo';
|
||||
|
||||
interface SelectRowProps {
|
||||
paramKey: string;
|
||||
value: string;
|
||||
options: readonly string[];
|
||||
onChange: (v: string) => void;
|
||||
onInfo: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SelectRow({
|
||||
paramKey,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
onInfo,
|
||||
}: SelectRowProps) {
|
||||
const info = PARAM_INFO[paramKey];
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.label}>{info?.title ?? paramKey}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => onInfo(paramKey)}
|
||||
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
|
||||
>
|
||||
<Text style={styles.infoIcon}>ⓘ</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.row}>
|
||||
{options.map(opt => (
|
||||
<TouchableOpacity
|
||||
key={opt}
|
||||
style={[styles.chip, value === opt && styles.chipActive]}
|
||||
onPress={() => onChange(opt)}
|
||||
>
|
||||
<Text style={[styles.chipText, value === opt && styles.chipTextActive]}>
|
||||
{opt}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { marginBottom: spacing.md },
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
label: {
|
||||
fontSize: typography.sizes.sm,
|
||||
fontWeight: typography.weights.medium,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
infoIcon: { fontSize: 16, color: colors.primary },
|
||||
row: { flexDirection: 'row', gap: spacing.xs },
|
||||
chip: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 5,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
chipActive: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
chipText: {
|
||||
fontSize: typography.sizes.xs,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
chipTextActive: {
|
||||
color: colors.surface,
|
||||
fontWeight: typography.weights.semibold,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { ModelConfig } from '../../../../store/types';
|
||||
import type { CacheType } from '../types';
|
||||
|
||||
type E = Partial<ModelConfig>;
|
||||
|
||||
export function getLoadingCore1(e: E) {
|
||||
return {
|
||||
nCtx: String(e.n_ctx ?? 2048),
|
||||
nBatch: String(e.n_batch ?? ''),
|
||||
nUbatch: String(e.n_ubatch ?? ''),
|
||||
nThreads: String(e.n_threads ?? ''),
|
||||
nGpuLayers: String(e.n_gpu_layers ?? 0),
|
||||
flashAttn: e.flash_attn ?? false,
|
||||
cacheTypeK: (e.cache_type_k as CacheType) ?? 'f16',
|
||||
cacheTypeV: (e.cache_type_v as CacheType) ?? 'f16',
|
||||
useMlock: e.use_mlock ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getLoadingCore2(e: E) {
|
||||
return {
|
||||
useMmap: e.use_mmap ?? true,
|
||||
ropeFreqBase: e.rope_freq_base !== undefined ? String(e.rope_freq_base) : '',
|
||||
ropeFreqScale: e.rope_freq_scale !== undefined ? String(e.rope_freq_scale) : '',
|
||||
ctxShift: e.ctx_shift ?? true,
|
||||
kvUnified: e.kv_unified ?? true,
|
||||
nCpuMoe: e.n_cpu_moe !== undefined ? String(e.n_cpu_moe) : '',
|
||||
cpuMask: e.cpu_mask ?? '',
|
||||
nParallel: String(e.n_parallel ?? 1),
|
||||
systemPrompt: e.systemPrompt ?? 'You are a helpful, concise assistant.',
|
||||
};
|
||||
}
|
||||
|
||||
export function getSampling1(e: E) {
|
||||
return {
|
||||
temperature: String(e.temperature ?? 0.8),
|
||||
topK: String(e.top_k ?? 40),
|
||||
topP: String(e.top_p ?? 0.95),
|
||||
minP: String(e.min_p ?? 0.05),
|
||||
nPredict: String(e.n_predict ?? e.max_new_tokens ?? -1),
|
||||
seed: String(e.seed ?? -1),
|
||||
typicalP: String(e.typical_p ?? 1.0),
|
||||
topNSigma: String(e.top_n_sigma ?? -1.0),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSampling2(e: E) {
|
||||
return {
|
||||
mirostat: String(e.mirostat ?? 0),
|
||||
mirostatTau: String(e.mirostat_tau ?? 5.0),
|
||||
mirostatEta: String(e.mirostat_eta ?? 0.1),
|
||||
xtcProb: String(e.xtc_probability ?? 0.0),
|
||||
xtcThresh: String(e.xtc_threshold ?? 0.1),
|
||||
};
|
||||
}
|
||||
|
||||
export function getPenalties1(e: E) {
|
||||
return {
|
||||
penaltyRepeat: String(e.penalty_repeat ?? e.repetition_penalty ?? 1.0),
|
||||
penaltyLastN: String(e.penalty_last_n ?? 64),
|
||||
penaltyFreq: String(e.penalty_freq ?? 0.0),
|
||||
penaltyPresent: String(e.penalty_present ?? 0.0),
|
||||
dryMultiplier: String(e.dry_multiplier ?? 0.0),
|
||||
dryBase: String(e.dry_base ?? 1.75),
|
||||
dryAllowed: String(e.dry_allowed_length ?? 2),
|
||||
dryLastN: String(e.dry_penalty_last_n ?? -1),
|
||||
};
|
||||
}
|
||||
|
||||
export function getOutputDefaults(e: E) {
|
||||
return {
|
||||
ignoreEos: e.ignore_eos ?? false,
|
||||
nProbs: String(e.n_probs ?? 0),
|
||||
stopStr: (e.stop ?? []).join(', '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import type { RootState } from '../../../../store';
|
||||
import type { ModelConfigFormState, CacheType } from '../types';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '../../../../navigation';
|
||||
import {
|
||||
getLoadingCore1,
|
||||
getLoadingCore2,
|
||||
getSampling1,
|
||||
getSampling2,
|
||||
getPenalties1,
|
||||
getOutputDefaults,
|
||||
} from './defaults';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList, 'ModelConfig'>;
|
||||
|
||||
export function useModelConfigState(
|
||||
modelPath: string,
|
||||
navigation: Nav,
|
||||
modelName: string,
|
||||
): ModelConfigFormState {
|
||||
const configs = useSelector((s: RootState) => s.models.modelConfigs || {});
|
||||
const existing = configs[modelPath] || {};
|
||||
|
||||
const lc1 = getLoadingCore1(existing);
|
||||
const lc2 = getLoadingCore2(existing);
|
||||
const s1 = getSampling1(existing);
|
||||
const s2 = getSampling2(existing);
|
||||
const p1 = getPenalties1(existing);
|
||||
const out = getOutputDefaults(existing);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: modelName });
|
||||
}, [modelName, navigation]);
|
||||
|
||||
const [tooltip, setTooltip] = useState<{ visible: boolean; key: string }>({
|
||||
visible: false,
|
||||
key: '',
|
||||
});
|
||||
const showTooltip = useCallback(
|
||||
(key: string) => setTooltip({ visible: true, key }),
|
||||
[],
|
||||
);
|
||||
const closeTooltip = useCallback(
|
||||
() => setTooltip(t => ({ ...t, visible: false })),
|
||||
[],
|
||||
);
|
||||
|
||||
const [expanded, setExpanded] = useState({
|
||||
loading: true,
|
||||
sampling: true,
|
||||
penalties: false,
|
||||
output: false,
|
||||
});
|
||||
const toggle = useCallback(
|
||||
(k: keyof typeof expanded) => setExpanded(p => ({ ...p, [k]: !p[k] })),
|
||||
[],
|
||||
);
|
||||
|
||||
const [systemPrompt, setSystemPrompt] = useState(lc2.systemPrompt);
|
||||
const [nCtx, setNCtx] = useState(lc1.nCtx);
|
||||
const [nBatch, setNBatch] = useState(lc1.nBatch);
|
||||
const [nUbatch, setNUbatch] = useState(lc1.nUbatch);
|
||||
const [nThreads, setNThreads] = useState(lc1.nThreads);
|
||||
const [nGpuLayers, setNGpuLayers] = useState(lc1.nGpuLayers);
|
||||
const [flashAttn, setFlashAttn] = useState(lc1.flashAttn);
|
||||
const [cacheTypeK, setCacheTypeK] = useState<CacheType>(lc1.cacheTypeK);
|
||||
const [cacheTypeV, setCacheTypeV] = useState<CacheType>(lc1.cacheTypeV);
|
||||
const [useMlock, setUseMlock] = useState(lc1.useMlock);
|
||||
const [useMmap, setUseMmap] = useState(lc2.useMmap);
|
||||
const [ropeFreqBase, setRopeFreqBase] = useState(lc2.ropeFreqBase);
|
||||
const [ropeFreqScale, setRopeFreqScale] = useState(lc2.ropeFreqScale);
|
||||
const [ctxShift, setCtxShift] = useState(lc2.ctxShift);
|
||||
const [kvUnified, setKvUnified] = useState(lc2.kvUnified);
|
||||
const [nCpuMoe, setNCpuMoe] = useState(lc2.nCpuMoe);
|
||||
const [cpuMask, setCpuMask] = useState(lc2.cpuMask);
|
||||
const [nParallel, setNParallel] = useState(lc2.nParallel);
|
||||
const [temperature, setTemperature] = useState(s1.temperature);
|
||||
const [topK, setTopK] = useState(s1.topK);
|
||||
const [topP, setTopP] = useState(s1.topP);
|
||||
const [minP, setMinP] = useState(s1.minP);
|
||||
const [nPredict, setNPredict] = useState(s1.nPredict);
|
||||
const [seed, setSeed] = useState(s1.seed);
|
||||
const [typicalP, setTypicalP] = useState(s1.typicalP);
|
||||
const [topNSigma, setTopNSigma] = useState(s1.topNSigma);
|
||||
const [mirostat, setMirostat] = useState(s2.mirostat);
|
||||
const [mirostatTau, setMirostatTau] = useState(s2.mirostatTau);
|
||||
const [mirostatEta, setMirostatEta] = useState(s2.mirostatEta);
|
||||
const [xtcProb, setXtcProb] = useState(s2.xtcProb);
|
||||
const [xtcThresh, setXtcThresh] = useState(s2.xtcThresh);
|
||||
const [penaltyRepeat, setPenaltyRepeat] = useState(p1.penaltyRepeat);
|
||||
const [penaltyLastN, setPenaltyLastN] = useState(p1.penaltyLastN);
|
||||
const [penaltyFreq, setPenaltyFreq] = useState(p1.penaltyFreq);
|
||||
const [penaltyPresent, setPenaltyPresent] = useState(p1.penaltyPresent);
|
||||
const [dryMultiplier, setDryMultiplier] = useState(p1.dryMultiplier);
|
||||
const [dryBase, setDryBase] = useState(p1.dryBase);
|
||||
const [dryAllowed, setDryAllowed] = useState(p1.dryAllowed);
|
||||
const [dryLastN, setDryLastN] = useState(p1.dryLastN);
|
||||
const [ignoreEos, setIgnoreEos] = useState(out.ignoreEos);
|
||||
const [nProbs, setNProbs] = useState(out.nProbs);
|
||||
const [stopStr, setStopStr] = useState(out.stopStr);
|
||||
|
||||
return {
|
||||
systemPrompt, setSystemPrompt,
|
||||
expanded, toggle,
|
||||
tooltip, showTooltip, closeTooltip,
|
||||
nCtx, setNCtx, nBatch, setNBatch,
|
||||
nUbatch, setNUbatch, nThreads, setNThreads,
|
||||
nGpuLayers, setNGpuLayers, flashAttn, setFlashAttn,
|
||||
cacheTypeK, setCacheTypeK, cacheTypeV, setCacheTypeV,
|
||||
useMlock, setUseMlock, useMmap, setUseMmap,
|
||||
ropeFreqBase, setRopeFreqBase, ropeFreqScale, setRopeFreqScale,
|
||||
ctxShift, setCtxShift, kvUnified, setKvUnified,
|
||||
nCpuMoe, setNCpuMoe, cpuMask, setCpuMask,
|
||||
nParallel, setNParallel,
|
||||
temperature, setTemperature,
|
||||
topK, setTopK, topP, setTopP, minP, setMinP,
|
||||
nPredict, setNPredict, seed, setSeed,
|
||||
typicalP, setTypicalP, topNSigma, setTopNSigma,
|
||||
mirostat, setMirostat, mirostatTau, setMirostatTau,
|
||||
mirostatEta, setMirostatEta,
|
||||
xtcProb, setXtcProb, xtcThresh, setXtcThresh,
|
||||
penaltyRepeat, setPenaltyRepeat, penaltyLastN, setPenaltyLastN,
|
||||
penaltyFreq, setPenaltyFreq, penaltyPresent, setPenaltyPresent,
|
||||
dryMultiplier, setDryMultiplier, dryBase, setDryBase,
|
||||
dryAllowed, setDryAllowed, dryLastN, setDryLastN,
|
||||
ignoreEos, setIgnoreEos, nProbs, setNProbs,
|
||||
stopStr, setStopStr,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type { ParamInfoEntry } from './types';
|
||||
import { loadingParamInfo } from './loading';
|
||||
import { samplingParamInfo } from './sampling';
|
||||
import { penaltiesParamInfo } from './penalties';
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const PARAM_INFO: Record<string, ParamInfoEntry> = {
|
||||
...loadingParamInfo,
|
||||
...samplingParamInfo,
|
||||
...penaltiesParamInfo,
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const loadingParamInfo: Record<string, ParamInfoEntry> = {
|
||||
systemPrompt: {
|
||||
title: 'Prompt Système',
|
||||
description:
|
||||
"Message de contexte envoyé au modèle avant toute conversation. Définit le rôle, le comportement et les contraintes de l'assistant.",
|
||||
impact:
|
||||
"Influence directement la personnalité et la pertinence des réponses. Un bon prompt système améliore considérablement la qualité globale.",
|
||||
usage:
|
||||
'Ex: "Tu es un expert Python, concis et précis." Sois explicite sur le ton, la langue, et les limites du modèle.',
|
||||
},
|
||||
n_ctx: {
|
||||
title: 'Fenêtre Contextuelle (n_ctx)',
|
||||
description:
|
||||
"Nombre maximum de tokens que le modèle peut « voir » simultanément — historique + prompt + réponse inclus.",
|
||||
impact:
|
||||
"🧠 RAM : chaque doublement de n_ctx ~×2 la mémoire du KV cache. Trop grand → crash OOM. ⚡ Vitesse : légèrement plus lent à grands contextes.",
|
||||
usage:
|
||||
'512–2 048 pour petits appareils. 4 096–8 192 si la RAM le permet. Ne jamais dépasser le contexte max du modèle (visible dans ses infos).',
|
||||
},
|
||||
n_batch: {
|
||||
title: 'Batch Size (n_batch)',
|
||||
description:
|
||||
`Nombre de tokens traités en parallèle lors de l'évaluation initiale du prompt (phase "prefill").`,
|
||||
impact:
|
||||
'⚡ Vitesse : plus grand = traitement du prompt plus rapide. 🧠 RAM : légèrement plus élevée. Impact nul sur la vitesse de génération token-by-token.',
|
||||
usage:
|
||||
'128–512 est idéal sur mobile. Dépasse rarement n_ctx. Valeur par défaut : 512.',
|
||||
},
|
||||
n_ubatch: {
|
||||
title: 'Micro-batch (n_ubatch)',
|
||||
description:
|
||||
'Sous-division interne de n_batch pour les opérations matricielles. Doit être ≤ n_batch.',
|
||||
impact:
|
||||
'🧠 Équilibre vitesse/mémoire des opérations GPU/CPU bas-niveau. Rarement nécessaire de modifier.',
|
||||
usage:
|
||||
'Laisser vide (= n_batch par défaut). Utile pour optimiser finement sur du matériel spécifique.',
|
||||
},
|
||||
n_threads: {
|
||||
title: 'Threads CPU (n_threads)',
|
||||
description: "Nombre de threads CPU alloués à l'inférence.",
|
||||
impact:
|
||||
`⚡ Plus de threads = plus rapide (jusqu'à un plateau). Trop de threads → contention et ralentissement. 🔋 Consommation CPU proportionnelle.`,
|
||||
usage:
|
||||
'Règle : moitié des cœurs physiques (ex : 8 cœurs → 4 threads). 0 = auto-détection. Tester 2, 4, 6 et mesurer.',
|
||||
},
|
||||
n_gpu_layers: {
|
||||
title: 'Couches GPU (n_gpu_layers)',
|
||||
description:
|
||||
'(iOS uniquement) Nombre de couches Transformer offloadées sur le GPU / Neural Engine.',
|
||||
impact:
|
||||
'⚡ Chaque couche sur GPU accélère significativement la génération. 🔋 Légèrement plus de consommation batterie. 🧠 Réduit la RAM CPU utilisée.',
|
||||
usage:
|
||||
'Commencer par 1, monter progressivement. Valeur max = nb total de couches du modèle. 0 = CPU uniquement (Android toujours CPU).',
|
||||
},
|
||||
flash_attn: {
|
||||
title: 'Flash Attention',
|
||||
description:
|
||||
"Algorithme d'attention optimisé qui calcule l'attention par blocs pour réduire drastiquement la mémoire du KV cache.",
|
||||
impact:
|
||||
'🧠 Réduit la VRAM KV de ~30–50%. ⚡ Gain de vitesse notable sur longs contextes. Recommandé avec n_gpu_layers > 0.',
|
||||
usage:
|
||||
"Activer avec GPU. Sur CPU pur, gain/perte variable selon l'appareil — tester les deux.",
|
||||
},
|
||||
cache_type_k: {
|
||||
title: 'Type KV Cache K',
|
||||
description:
|
||||
"Type de données des matrices K (Key) du cache d'attention. Contrôle la précision vs mémoire des clés.",
|
||||
impact:
|
||||
'🧠 f16 : qualité max, RAM max. q8_0 : −50% RAM, perte qualité négligeable. q4_0 : −75% RAM, légère dégradation possible.',
|
||||
usage:
|
||||
'f16 par défaut. Utiliser q8_0 si RAM insuffisante pour un grand contexte. q4_0 seulement pour RAM très contrainte.',
|
||||
},
|
||||
cache_type_v: {
|
||||
title: 'Type KV Cache V',
|
||||
description:
|
||||
"Type de données des matrices V (Value) du cache d'attention. Le cache V est plus sensible à la quantification que K.",
|
||||
impact:
|
||||
'🧠 Même économies mémoire que cache_type_k mais impact qualité légèrement plus fort. Quantifier K avant V.',
|
||||
usage:
|
||||
'Garder f16 si possible. Passer à q8_0 uniquement si cache_type_k est déjà q8_0 et que la RAM reste insuffisante.',
|
||||
},
|
||||
use_mlock: {
|
||||
title: 'Verrouillage RAM (use_mlock)',
|
||||
description:
|
||||
'Verrouille les pages mémoire du modèle en RAM physique pour empêcher le système de les swapper sur disque.',
|
||||
impact:
|
||||
'⚡ Élimine les pics de latence dus au swap. 🧠 Nécessite que le modèle tienne entièrement en RAM — crash si insuffisant.',
|
||||
usage:
|
||||
"Activer si le modèle tient en RAM. Désactiver sur appareils avec peu de RAM pour éviter les erreurs de chargement.",
|
||||
},
|
||||
use_mmap: {
|
||||
title: 'Memory-Map (use_mmap)',
|
||||
description:
|
||||
"Mappe le fichier modèle en mémoire virtuelle sans le copier entièrement en RAM. Le système charge les pages à la demande.",
|
||||
impact:
|
||||
`⚡ Démarrage très rapide. 🧠 Le système peut swapper les pages non utilisées — des accès disque peuvent survenir pendant l'inférence.`,
|
||||
usage:
|
||||
'Activer (défaut). Désactiver uniquement si vous avez assez de RAM et voulez éviter tout accès disque pendant la génération.',
|
||||
},
|
||||
rope_freq_base: {
|
||||
title: 'RoPE Base Frequency',
|
||||
description:
|
||||
"Fréquence de base des embeddings positionnels RoPE. Contrôle la plage de positions que le modèle peut distinguer.",
|
||||
impact:
|
||||
`📐 Augmenter étend la fenêtre contextuelle effective au-delà de la limite d'entraînement. Trop élevé → dégradation qualité.`,
|
||||
usage:
|
||||
'Laisser à 0 (auto). Pour étendre le contexte : new_base ≈ original_base × (new_ctx / train_ctx). Ex : 10 000 → 80 000 pour ×8 contexte.',
|
||||
},
|
||||
rope_freq_scale: {
|
||||
title: 'RoPE Frequency Scale',
|
||||
description:
|
||||
"Facteur de scaling appliqué aux fréquences RoPE. Alternative/complément à rope_freq_base pour l'extension de contexte.",
|
||||
impact:
|
||||
'📐 < 1 compresse les fréquences, permettant un contexte plus long. Ex : 0.5 = contexte ×2. Trop bas → perte de cohérence sur longues distances.',
|
||||
usage:
|
||||
'Laisser à 0 (auto). Utiliser conjointement à rope_freq_base pour un contrôle précis. Rarement nécessaire si le modèle gère déjà un long contexte.',
|
||||
},
|
||||
ctx_shift: {
|
||||
title: 'Context Shifting',
|
||||
description:
|
||||
"Lorsque le contexte est plein, décale automatiquement la fenêtre en supprimant les anciens tokens pour continuer à générer.",
|
||||
impact:
|
||||
'⚡ Conversations potentiellement infinies sans erreur. 📉 Les informations très anciennes sont progressivement perdues.',
|
||||
usage:
|
||||
'Activer pour des conversations longues (défaut recommandé). Désactiver si vous préférez une erreur explicite quand le contexte est saturé.',
|
||||
},
|
||||
kv_unified: {
|
||||
title: 'KV Cache Unifié',
|
||||
description:
|
||||
`Utilise un buffer KV partagé pour toutes les séquences parallèles lors du calcul de l'attention.`,
|
||||
impact:
|
||||
'⚡ Améliore les performances quand n_parallel=1 et que les séquences partagent un long préfixe. Contre-productif avec n_parallel > 1 et préfixes différents.',
|
||||
usage:
|
||||
'Activer (défaut) pour un usage standard à séquence unique. Désactiver si n_parallel > 1 et séquences indépendantes.',
|
||||
},
|
||||
n_cpu_moe: {
|
||||
title: 'Couches MoE sur CPU (n_cpu_moe)',
|
||||
description:
|
||||
'Nombre de couches MoE (Mixture of Experts) maintenues en RAM CPU plutôt que sur GPU. Pertinent pour Mixtral, Qwen-MoE, etc.',
|
||||
impact:
|
||||
`🧠 Réduit l'usage VRAM pour les modèles MoE. ⚡ Ralentit légèrement ces couches (transfert CPU↔GPU). 0 = tout sur GPU.`,
|
||||
usage:
|
||||
'Laisser à 0 sauf si vous utilisez un modèle MoE et que vous rencontrez des OOM GPU. Augmenter progressivement.',
|
||||
},
|
||||
cpu_mask: {
|
||||
title: 'Masque CPU (cpu_mask)',
|
||||
description:
|
||||
"Spécifie quels cœurs CPU utiliser pour l'inférence via un masque d'affinité de cœurs.",
|
||||
impact:
|
||||
'⚡ Permet de dédier les cœurs haute-performance (Big cores) au modèle sur architectures big.LITTLE/DynamIQ (Snapdragon, Apple).',
|
||||
usage:
|
||||
'Format : "0-3" (cœurs 0 à 3) ou "0,2,4,6" (cœurs spécifiques). Vide = tous les cœurs disponibles (défaut).',
|
||||
},
|
||||
n_parallel: {
|
||||
title: 'Séquences Parallèles (n_parallel)',
|
||||
description:
|
||||
'Nombre maximum de séquences traitées en parallèle dans le même contexte (slots parallèles).',
|
||||
impact:
|
||||
'🧠 RAM KV cache × n_parallel. Nécessaire pour le mode parallel.completion(). Inutile pour usage conversationnel standard.',
|
||||
usage:
|
||||
'Garder à 1 (défaut) pour usage standard. Augmenter uniquement si vous utilisez parallel.completion() pour des requêtes concurrentes.',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { ParamInfoEntry } from './types';
|
||||
|
||||
export const penaltiesParamInfo: Record<string, ParamInfoEntry> = {
|
||||
penalty_repeat: {
|
||||
title: 'Pénalité de Répétition',
|
||||
description:
|
||||
'Pénalise les tokens déjà apparus dans la fenêtre penalty_last_n en divisant leur probabilité par ce facteur.',
|
||||
impact:
|
||||
'📊 1.0 = aucune pénalité. 1.1–1.3 : réduit les répétitions. > 1.5 : texte incohérent. < 1 : encourage la répétition.',
|
||||
usage:
|
||||
'1.0 pour les conversations (défaut). 1.1–1.3 si le modèle boucle. Ne pas dépasser 1.5. Défaut : 1.0.',
|
||||
},
|
||||
penalty_last_n: {
|
||||
title: 'Fenêtre de Pénalité (penalty_last_n)',
|
||||
description:
|
||||
'Nombre de derniers tokens considérés pour toutes les pénalités de répétition (repeat, freq, present, DRY).',
|
||||
impact:
|
||||
'📊 Petit (32) = pénalise seulement les répétitions récentes. Grand (256) = pénalise sur plus de contexte. 0 = désactivé. -1 = taille contexte.',
|
||||
usage:
|
||||
'64 est un bon défaut. Augmenter si le modèle répète des phrases sur de longues distances. Défaut : 64.',
|
||||
},
|
||||
penalty_freq: {
|
||||
title: 'Pénalité de Fréquence',
|
||||
description:
|
||||
"Réduit la probabilité des tokens proportionnellement à leur fréquence d'apparition dans le contexte récent.",
|
||||
impact:
|
||||
'📊 > 0 = pénalise davantage les tokens très fréquents. Réduit la verbosité et les tics de langage répétitifs. 0 = désactivé.',
|
||||
usage:
|
||||
'0.0–0.2. Défaut : 0.0. Augmenter si le modèle utilise trop souvent les mêmes mots (ex : "Bien sûr !", "Absolument").',
|
||||
},
|
||||
penalty_present: {
|
||||
title: 'Pénalité de Présence',
|
||||
description:
|
||||
'Applique une pénalité fixe (indépendante de la fréquence) à tout token ayant déjà apparu dans le contexte récent.',
|
||||
impact:
|
||||
'📊 > 0 = encourage à utiliser du vocabulaire nouveau. Augmente la diversité lexicale globale du texte.',
|
||||
usage:
|
||||
'0.0–0.2. Défaut : 0.0. Combiner avec penalty_freq pour un contrôle fins des répétitions.',
|
||||
},
|
||||
dry_multiplier: {
|
||||
title: 'Multiplicateur DRY',
|
||||
description:
|
||||
"DRY (Don't Repeat Yourself) : multiplie une pénalité exponentielle pour les séquences de tokens répétées. Très efficace contre les boucles.",
|
||||
impact:
|
||||
'📊 0.0 = désactivé. 0.8–1.5 = pénalise exponentiellement les répétitions de séquences entières. Bien plus fort que penalty_repeat.',
|
||||
usage:
|
||||
'0.8–1.5 si le modèle génère des boucles de phrases. Défaut : 0.0. Activer en priorité si le modèle répète des paragraphes entiers.',
|
||||
},
|
||||
dry_base: {
|
||||
title: 'Base DRY',
|
||||
description:
|
||||
"Base de l'exponentiation DRY. Pénalité = dry_multiplier × dry_base^(longueur_répétition − dry_allowed_length).",
|
||||
impact:
|
||||
'📊 Plus grande = croissance exponentielle plus agressive pour les longues répétitions. Rarement nécessaire de toucher.',
|
||||
usage:
|
||||
'Défaut : 1.75. Modifier uniquement pour un contrôle très fin. Pertinent seulement si dry_multiplier > 0.',
|
||||
},
|
||||
dry_allowed_length: {
|
||||
title: 'Longueur Autorisée DRY',
|
||||
description:
|
||||
"Longueur minimale d'une séquence répétée avant que DRY commence à la pénaliser. Tolère les courtes répétitions.",
|
||||
impact:
|
||||
'📊 1 = pénalise même un seul token répété. 4 = tolère les expressions de 4 tokens ou moins.',
|
||||
usage:
|
||||
'Défaut : 2. Augmenter (ex : 3–5) pour permettre des expressions récurrentes courtes comme "Bien sûr" ou des mots de liaison.',
|
||||
},
|
||||
dry_penalty_last_n: {
|
||||
title: 'Fenêtre DRY (dry_penalty_last_n)',
|
||||
description:
|
||||
"Nombre de tokens scanné pour détecter les séquences répétées dans l'algorithme DRY.",
|
||||
impact:
|
||||
'📊 -1 = tout le contexte (défaut). 0 = désactivé. Plus grand = détecte les répétitions sur de plus longues distances.',
|
||||
usage:
|
||||
'-1 pour scanner tout le contexte. Réduire si les performances sont impactées sur de très longs contextes. Défaut : -1.',
|
||||
},
|
||||
ignore_eos: {
|
||||
title: 'Ignorer EOS',
|
||||
description:
|
||||
"Ignore le token de fin de séquence (End Of Sequence) et force le modèle à continuer de générer au-delà.",
|
||||
impact:
|
||||
'📊 Peut produire un texte plus long mais avec risque de répétitions ou de divagations après le point naturel de fin.',
|
||||
usage:
|
||||
'Désactivé par défaut. Activer uniquement pour des cas spécifiques : génération de code très long, benchmarking, tests de stress. Défaut : false.',
|
||||
},
|
||||
n_probs: {
|
||||
title: 'Probabilités de Tokens (n_probs)',
|
||||
description:
|
||||
'Retourne les N tokens alternatifs les plus probables et leur probabilité pour chaque token généré.',
|
||||
impact:
|
||||
`⚡ Légèrement plus lent, plus de données transmises. Utile pour déboguer le sampling ou visualiser l'incertitude du modèle.`,
|
||||
usage:
|
||||
'0 = désactivé (défaut). 5–10 pour analyse et debug. Ne pas activer en production. Défaut : 0.',
|
||||
},
|
||||
stop: {
|
||||
title: 'Stop Strings',
|
||||
description:
|
||||
"Séquences de texte qui arrêtent immédiatement la génération dès qu'elles apparaissent dans la sortie (non incluses).",
|
||||
impact:
|
||||
`📊 Essentiel pour les modèles instruction qui utilisent des balises de format. Évite que le modèle « joue » le rôle de l'utilisateur.`,
|
||||
usage:
|
||||
`Entrez les chaînes séparées par des virgules. Ex : "User:,<|im_end|>,###,Human:". Le modèle s'arrête au premier match.`,
|
||||
},
|
||||
};
|
||||