Compare commits
No commits in common. "main" and "backup/pre-no-root-migration" have entirely different histories.
main
...
backup/pre
|
|
@ -1,26 +1,8 @@
|
|||
# ============================================================
|
||||
# Kazeia .gitignore — LISTE BLANCHE
|
||||
# On ne suit QUE l'app mobile + le code/les docs essentiels.
|
||||
# Tout le reste à la racine (modèles, backups, SDK, venvs, arbres
|
||||
# expérimentaux, intouchables beta_kazeia/root_oneplus, Kaz, etc.)
|
||||
# est ignoré — présent sur le disque, jamais supprimé, juste hors suivi.
|
||||
# ============================================================
|
||||
# ============================================
|
||||
# Kazeia .gitignore — code only, no binaries
|
||||
# ============================================
|
||||
|
||||
# 1) Ignorer tout ce qui est à la racine du dépôt...
|
||||
/*
|
||||
|
||||
# 2) ...sauf ce qu'on veut explicitement suivre :
|
||||
!/.gitignore
|
||||
!/kazeia-android/
|
||||
!/docs/
|
||||
!/keystore/
|
||||
!/scripts/
|
||||
!/executorch-custom/
|
||||
!/executorch-patches/
|
||||
!/*.md
|
||||
!/*.txt
|
||||
|
||||
# 3) Binaires & sorties de build (même à l'intérieur des dossiers suivis) :
|
||||
# === Large binary files ===
|
||||
*.so
|
||||
*.so.*
|
||||
*.so.bak
|
||||
|
|
@ -33,10 +15,9 @@
|
|||
*.npy
|
||||
*.wav
|
||||
*.apk
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
||||
# === Build outputs ===
|
||||
kazeia-android/app/build/
|
||||
kazeia-android/*/build/
|
||||
kazeia-android/build/
|
||||
kazeia-android/.gradle/
|
||||
kazeia-android/local.properties
|
||||
|
|
@ -44,11 +25,61 @@ kazeia-android/extracted/
|
|||
kazeia-android/app/.cxx/
|
||||
kazeia-android/unityLibrary/
|
||||
|
||||
# 4) IDE / OS / temporaires
|
||||
# === Python environments ===
|
||||
et_venv/
|
||||
qnn_venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# === SDKs and toolchains (external) ===
|
||||
android-ndk-r27d/
|
||||
qnn_sdk_242/
|
||||
executorch/
|
||||
|
||||
# === Models and data (too large) ===
|
||||
models_qnn/
|
||||
tablet_backup*/
|
||||
backup_*/
|
||||
voix/
|
||||
|
||||
# === IDE ===
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# === Compiled binaries ===
|
||||
cp_et_test_client
|
||||
llama.cpp/build*/
|
||||
|
||||
# === OS files ===
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# === Temporary ===
|
||||
*.tmp
|
||||
*.log
|
||||
*.kate-swp
|
||||
|
||||
# === External repos (submodules or separate) ===
|
||||
Vulkan-Headers/
|
||||
llama.cpp/
|
||||
whisper.cpp/
|
||||
kazeia-unity/
|
||||
models_hf/
|
||||
|
||||
# === Old/misc at root ===
|
||||
android-sdk/
|
||||
avatar_disabled_backup/
|
||||
beta_kazeia/
|
||||
build_et_jar/
|
||||
root_oneplus/
|
||||
cmdtools.zip
|
||||
forward.dlc
|
||||
.claude/
|
||||
|
||||
# === Root-level scripts (moved to scripts/) ===
|
||||
export_decoder_onnx.py
|
||||
export_talker_onnx.py
|
||||
extract_tts_embeddings.py
|
||||
extract_vq_individual.py
|
||||
generate_tokens_for_tablet.py
|
||||
generate_tts_wav.py
|
||||
|
|
|
|||
234
FROZEN.md
234
FROZEN.md
|
|
@ -1,234 +0,0 @@
|
|||
# Kazeia — Stack production figée
|
||||
|
||||
**Date du gel : 2026-05-02**
|
||||
|
||||
Ce document définit la stack **autorisée** de Kazeia. Tout composant non listé
|
||||
ici doit être considéré comme **non autorisé** et ne doit pas être réintroduit
|
||||
sans validation explicite + mise à jour de ce contrat.
|
||||
|
||||
---
|
||||
|
||||
## Emplacement des modèles (MAJ 2026-05-18 — chantier installeur Phase 0)
|
||||
|
||||
Les chemins `/data/local/tmp/kazeia/models/` et `/data/local/tmp/kazeia-et/`
|
||||
cités ci-dessous restent **valides sur les tablettes de dev** (modèles poussés
|
||||
par `adb push`). Ils sont désormais **résolus au runtime par `KazeiaPaths`** :
|
||||
|
||||
- si la racine `/data/local/tmp` existe et est non vide → elle prime (dev) ;
|
||||
- sinon → stockage externe app `getExternalFilesDir("kazeia")/{models,llm}`,
|
||||
rempli par l'installeur (modèles téléchargés depuis le Nextcloud).
|
||||
|
||||
Aucun composant n'est ajouté ni retiré — seul l'emplacement de chargement
|
||||
devient dynamique. `KazeiaApplication.MODELS_DIR` / `.LLM_DIR` sont les points
|
||||
d'accès uniques. Cf `project_kazeia_installer_updates`.
|
||||
|
||||
---
|
||||
|
||||
## STT — Speech-to-Text
|
||||
|
||||
### Composant figé
|
||||
|
||||
`com.kazeia.stt.WhisperHybridEngine` (542 lignes) — chemin **NPU only**
|
||||
- Encoder + Decoder Qualcomm AI Hub HfWhisper KV-cache (Whisper-Small 12L/12H)
|
||||
- Modèles tablette : `/data/local/tmp/kazeia/models/whisper-small-sm8750/`
|
||||
(fingerprints MD5 dans `/opt/Kazeia/kazeia-stt-perf-validated/models/README.md`)
|
||||
- Backend : ONNX Runtime QNN HTP V79 (Snapdragon 8 Elite)
|
||||
- Performance validée : RTF 0.5 (audio 1.6 s → 825 ms)
|
||||
- Override `<|translate|>` → `<|transcribe|>` actif (force transcription FR)
|
||||
- Auto-détection layers/heads via `enc.outputInfo["k_cache_cross_0"]`
|
||||
|
||||
### Mel extractor figé
|
||||
|
||||
`com.kazeia.stt.MelExtractor` + `kazeia-android/app/src/main/jni/mel_extractor.cpp`
|
||||
- HuggingFace-compatible WhisperFeatureExtractor en C++ pur
|
||||
- 80 mels × 3000 frames, FFT 400, hop 160
|
||||
|
||||
### Wrapper v2 figé
|
||||
|
||||
`com.kazeia.v2.SttStage` — thin wrapper sur `WhisperHybridEngine`
|
||||
|
||||
### ❌ Interdit
|
||||
- Pas de fallback CPU (`WhisperSttEngine`, `WhisperJni`, `whisper.cpp`) — supprimés
|
||||
- Pas de fallback Android stock (`AndroidSttEngine`) — supprimé
|
||||
- Pas d'autres backends (`WhisperLiteRtEngine`, `WhisperNpuSttEngine`) — supprimés
|
||||
- Pas de Qwen3-ASR (1.7B GGUF cassé, 0.6B qualité FR insuffisante)
|
||||
- Pas de `libwhisper.so` dans jniLibs — supprimé
|
||||
|
||||
---
|
||||
|
||||
## VAD — Voice Activity Detection
|
||||
|
||||
### Composant figé
|
||||
|
||||
**Détection RMS-énergie inline** :
|
||||
- v1 : `KazeiaService.startContinuousListening()` — frame 1600 samples (100 ms),
|
||||
threshold RMS=150, ≥ 300 ms speech, ≥ 800 ms silence end
|
||||
- v2 : `com.kazeia.v2.VadStage` — réimplémentation propre du même algorithme
|
||||
|
||||
### ❌ Interdit
|
||||
- Pas de Silero VAD (`SileroVadEngine.kt` + `silero_vad.onnx` — supprimés)
|
||||
- Pas de `AudioCaptureManager.kt` — supprimé
|
||||
- Pas de `core.VadEngine` interface — supprimée
|
||||
- Cf `feedback_kazeia_vad_rms.md` : Silero plafonne à 5e-4 sur ce mic
|
||||
|
||||
---
|
||||
|
||||
## LLM — Large Language Model
|
||||
|
||||
### Composants figés v1 (legacy `KazeiaService`)
|
||||
|
||||
`com.kazeia.llm.ExecuTorchLlmEngine` (in-process)
|
||||
- Modèle : `hybrid_llama_qnn.pte` symlink → `hybrid_llama_qnn_4b.pte` sur tablette
|
||||
- Tokenizer : `tokenizer.json`
|
||||
- Backend : ExecuTorch + QNN HTP NPU (in-process via PyTorch ExecuTorch JNI)
|
||||
- Path : `/data/local/tmp/kazeia-et/`
|
||||
|
||||
### Composants figés v2 (`KazeiaServiceV2`)
|
||||
|
||||
`com.kazeia.v2.LlmCascadeStage` (mode mono-Speaker — temporaire RAM-budget)
|
||||
- Spawn 1× `qnn_llama_runner --daemon_mode` via `ExecutorchDaemon`
|
||||
- Modèle : `hybrid_llama_qnn_4b.pte` + `tokenizer.json`
|
||||
- System prompt : SYS_SPEAKER (psychologue bienveillant, 1-2 phrases FR concises)
|
||||
- Le code Thinker/cascade est conservé en commentaire pour restauration future
|
||||
|
||||
### ❌ Interdit
|
||||
- Pas de `LlamaCppLlmEngine` (purgé 2026-04-29)
|
||||
- Pas de `KazeiaLlmJni` (purgé 2026-04-29)
|
||||
- Pas de Genie SDK (bug GQA Qwen3)
|
||||
- Pas de cascade Thinker+Speaker SIMULTANÉE en cohabitation TTS (RAM OOM, cf
|
||||
cleanup 2026-05-02)
|
||||
- Pas de `LLM_BACKEND` constante / `_currentLlmModel` UI selector
|
||||
|
||||
### Restauration cascade complète : conditionné
|
||||
|
||||
À envisager **uniquement après** :
|
||||
- Task #240 (libllama.so contre chraac-llama récent) → -30 % RAM/perf Talker+CP
|
||||
- OU partage `.ptd` entre 2× 4B → -2.4 GB RAM
|
||||
- OU phased loading opportuniste (cf `feedback_kazeia_vad_rms.md` non, l'analyse
|
||||
RAM dans la conversation 2026-05-02)
|
||||
- En attendant, mono-Speaker reste figé
|
||||
|
||||
---
|
||||
|
||||
## TTS — Text-to-Speech
|
||||
|
||||
### Composant figé
|
||||
|
||||
`com.kazeia.tts.Qwen3TtsEngine` — voie **ggml-cpu Talker + ggml-cpu CP + ggml C++ Decoder**
|
||||
- Talker : `TtsTalkerCpuJni` linké contre `libllama.so` (in jniLibs)
|
||||
- Modèle préféré : `talker_f32.gguf` (1.78 GB, fp32, élimine drift fp16)
|
||||
- Fallback : `talker_f16.gguf`
|
||||
- CP : `TtsCpCpuJni` linké contre `libllama.so`
|
||||
- Modèle préféré : `cp_f32.gguf` (316 MB)
|
||||
- Fallback : `cp_f16.gguf`
|
||||
- Decoder : `libtts_decoder_ggml.so` static-linked contre **chraac-llama** ggml
|
||||
(build : `kazeia-tts-decoder-ggml/build-android-static-chraac/`)
|
||||
- GGUF : `qwen3tts_decoder.gguf` (340 MB)
|
||||
- **RTF 0.96 mesuré 2026-05-02** (cf `kazeia-tts-perf-validated/`)
|
||||
- Tokenizer texte : `Qwen3BpeTokenizer` (BPE byte-level Qwen2/Qwen3)
|
||||
- Voice cloning : Damien (prefix 9 × 1024 + suffix 2 × 1024)
|
||||
|
||||
### API figée
|
||||
|
||||
- `synthesizeAndPlay(text, language)` — pour invocations one-shot
|
||||
- `startStreamingSession()` / `enqueueSentence(text)` / `endStreamingSession()` —
|
||||
pour streaming sentence-par-sentence (utilisé dans v2.TtsStage)
|
||||
|
||||
### Wrapper v2 figé
|
||||
|
||||
`com.kazeia.v2.TtsStage` — wrapper streaming-session sur `Qwen3TtsEngine`
|
||||
|
||||
### ❌ Interdit dans le chemin de chargement
|
||||
|
||||
- Pas de chemin `cp_kv_v2 ONNX` (ligne 630-658 de Qwen3TtsEngine, gated sur
|
||||
`!useCpuPath` 2026-05-02)
|
||||
- Pas de `cp_et_runner` TCP (sub-process root, gated 2026-05-02)
|
||||
- Pas de `talker_kv_cpu` ONNX fp32 (gated sur `!useCpuPath`)
|
||||
- Pas de Hexagon talker / CP (`hexStartRunner`, `useHexagonTalker`) — pas dans
|
||||
flux production
|
||||
- Pas de `phrase_embeds.bin` cache (bypassed 2026-05-02 — provoquait dégénération)
|
||||
|
||||
### ❌ Interdit moteurs alternatifs
|
||||
- Pas de `AndroidTtsEngine` — supprimé
|
||||
- Pas de `ChatterboxTtsEngine` — supprimé
|
||||
|
||||
---
|
||||
|
||||
## Audio playback
|
||||
|
||||
### Composant figé
|
||||
|
||||
`com.kazeia.audio.AudioPlaybackManager` (54 lignes)
|
||||
|
||||
L'AudioTrack interne au streaming session du `Qwen3TtsEngine` (avec keepAlive
|
||||
watchdog ColorOS) reste également figé.
|
||||
|
||||
---
|
||||
|
||||
## Build / Backends
|
||||
|
||||
### ggml — sources autorisées
|
||||
|
||||
| Composant | Source ggml autorisée | Build dir |
|
||||
|---|---|---|
|
||||
| **TTS Decoder** | `chraac-llama` (branch `dev-refactoring`, commit `897501a`) | `build-android-static-chraac` |
|
||||
| TTS Talker (lib) | jniLibs `libggml-cpu.so` (`llama-upstream`, à migrer Task #240) | `kazeia-android/.../jniLibs/arm64-v8a` |
|
||||
| TTS CP (lib) | idem | idem |
|
||||
| STT | n/a (ONNX Runtime, pas ggml) | n/a |
|
||||
|
||||
**Critique** : ne JAMAIS pointer le décodeur TTS vers `whisper.cpp/ggml` ou
|
||||
`llama-upstream/ggml` — sinon RTF passe à 1.4-1.5 (mesuré, cf
|
||||
`kazeia-tts-perf-validated/docs/MEASUREMENTS.md`).
|
||||
|
||||
### Compile flags figés
|
||||
|
||||
```cmake
|
||||
# TTS decoder (libtts_decoder_ggml.so)
|
||||
-O3 -march=armv8.6-a+dotprod+fp16+i8mm+bf16 -fopenmp -static-openmp
|
||||
GGML_CPU_ARM_ARCH=armv8.6-a+dotprod+fp16+i8mm+bf16
|
||||
GGML_OPENMP=ON
|
||||
BUILD_SHARED_LIBS=OFF
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fichiers de référence persistants
|
||||
|
||||
- `/opt/Kazeia/kazeia-stt-perf-validated/` — doc + MD5 modèles STT/VAD
|
||||
- `/opt/Kazeia/kazeia-tts-perf-validated/` — doc + libs `.a` + bench CLI
|
||||
reproductible TTS (RTF<1)
|
||||
- `/opt/Kazeia/chraac-llama/` — fork ggml indispensable pour le décodeur TTS
|
||||
- `/opt/Kazeia/kazeia-tts-decoder-ggml/build-android-static-chraac/` — build
|
||||
statique pointé par l'app
|
||||
|
||||
---
|
||||
|
||||
## Comment dégeler / modifier ce contrat
|
||||
|
||||
**Procédure obligatoire** avant de réintroduire un composant supprimé OU de
|
||||
remplacer un composant figé :
|
||||
|
||||
1. Mesurer la perf du composant cible **vs** la voie figée (sur la même phrase
|
||||
de test, même tablette, même gouverneur DVFS)
|
||||
2. Démontrer que la nouvelle voie est strictement meilleure (perf, RAM ou
|
||||
maintenabilité) avec des chiffres reproductibles
|
||||
3. Mettre à jour ce `FROZEN.md` avec le nouveau composant + les chiffres
|
||||
4. Mettre à jour `MEMORY.md` index + memory file pertinent
|
||||
5. Documenter le commit/build SHA exact dans `kazeia-{stt,tts}-perf-validated/`
|
||||
6. Conserver l'ancien composant comme `*.archive.kt` UN trimestre avant
|
||||
suppression définitive (filet de sécurité rollback)
|
||||
|
||||
Sans ce processus, **ne pas modifier la stack figée**.
|
||||
|
||||
---
|
||||
|
||||
## Métriques de référence (2026-05-02)
|
||||
|
||||
| Domaine | Métrique | Valeur figée | Comment |
|
||||
|---|---|---:|---|
|
||||
| STT | Load total | 750-1035 ms | NPU encoder + decoder + mel filters |
|
||||
| STT | RTF (audio 1.6 s) | **0.51** | mel + encoder + decoder NPU |
|
||||
| VAD | Trigger end-of-speech | 800 ms après dernier audio > seuil | hardcoded |
|
||||
| LLM | Mono-Speaker forward | ~22 tok/s | qnn_llama_runner subprocess |
|
||||
| TTS Decoder | RTF (audio 4.48 s) | **0.96** | ggml C++ chraac-llama, standalone |
|
||||
| TTS Pipeline complet | RTF | ~2.87 | bottleneck CP (Task #240 pending) |
|
||||
|
|
@ -1,508 +0,0 @@
|
|||
# Rapport Kazeia — État, architecture, performances
|
||||
|
||||
**Date :** 2026-05-04
|
||||
**Contexte :** Kazeia est une application Android de soutien thérapeutique
|
||||
conversationnel (psychologue virtuel) tournant **100 % on-device** sur OnePlus
|
||||
Pad 3, sans connectivité réseau requise et sans root utilisateur.
|
||||
|
||||
---
|
||||
|
||||
## 1. Synthèse exécutive
|
||||
|
||||
### Ce qui fonctionne en production
|
||||
- Pipeline complet **STT → LLM → TTS** opérationnel, multi-tour, avec cascade
|
||||
Thinker+Speaker.
|
||||
- Latence end-to-end utilisable en conversation : **3-5 s** entre la fin du
|
||||
PTT et le premier audio de réponse.
|
||||
- Stable mémoire après débloat ColorOS + désinstallation du watchdog OEM Athena.
|
||||
- Application 100 % autonome sur la tablette : aucun service réseau requis,
|
||||
modèles et runtime tous embarqués.
|
||||
- Stack figée et documentée (`/opt/Kazeia/FROZEN.md`).
|
||||
- Backup tablette complet et automatisable dans `/opt/Kazeia/kazeia-tablet-backup/`.
|
||||
|
||||
### Limites actuelles connues
|
||||
- Le **Thinker Guard-0.6B** produit des bullets de qualité analytique limitée
|
||||
(modèle conçu pour classification de safety, détourné vers analyse clinique).
|
||||
Le Speaker compense en pratique mais l'apport qualitatif de la cascade est
|
||||
moins net qu'espéré.
|
||||
- Le rendu UI de l'orbe (cycle couleurs pendant Thinking) demande encore
|
||||
validation visuelle utilisateur.
|
||||
- Conversation history non persisté entre sessions (à dessein actuellement).
|
||||
- Pas de fine-tuning thérapeutique français spécifique sur les modèles —
|
||||
on utilise Qwen3-4B générique pré-entraîné.
|
||||
|
||||
---
|
||||
|
||||
## 2. Plateforme matérielle
|
||||
|
||||
| Composant | Spec |
|
||||
|---|---|
|
||||
| Tablette | OnePlus Pad 3 (OPD2415EEA) |
|
||||
| RAM | 16 GB LPDDR5X |
|
||||
| Stockage | 256 GB UFS |
|
||||
| SoC | Snapdragon 8 Elite |
|
||||
| CPU | Oryon (8 cœurs ARMv9) |
|
||||
| GPU | Adreno 830 |
|
||||
| **NPU/DSP** | **Hexagon V79 (HMX/HVX)** |
|
||||
| OS | Android 16, ColorOS post-2026-03-18 OTA |
|
||||
| Root | Aucun (user mode standard) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Stack logicielle
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Kazeia Android App (Kotlin) │
|
||||
│ /data/app/.../com.kazeia │
|
||||
└───────────────────┬─────────────────────────┬───────────────────┘
|
||||
│ │
|
||||
┌───────────▼──────────┐ ┌───────────▼──────────┐
|
||||
│ KazeiaService │ │ ChatActivity (UI) │
|
||||
│ (state machine) │ │ AudioVisualizerView│
|
||||
│ • PTT │ │ (orb HSV cycle) │
|
||||
│ • Cascade │ └──────────────────────┘
|
||||
│ • Pipeline state │
|
||||
└─┬────────┬────────┬──┘
|
||||
│ │ │
|
||||
┌──────▼─┐ ┌───▼────┐ ┌─▼──────┐
|
||||
│ STT │ │ LLM │ │ TTS │
|
||||
│ Engine │ │ Engine │ │ Engine │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
```
|
||||
|
||||
### 3.1 STT — Whisper-Small NPU (QAIRT)
|
||||
|
||||
- **Modèle** : `HfWhisper-Small` (12 layers, 12 heads)
|
||||
- **Format** : QAIRT context binary (Qualcomm SDK natif)
|
||||
- **Fichiers** :
|
||||
- `HfWhisperEncoder_qairt_context.bin` (210 MB)
|
||||
- `HfWhisperDecoder_qairt_context.bin` (361 MB)
|
||||
- **Backend** : Hexagon V79 via QAIRT runtime (`libQnnHtpV79Skel.so`)
|
||||
- **VAD** : RMS énergie inline (seuil 80, frames 100 ms)
|
||||
- **Mode** : continuous listening avec streaming Whisper pendant PTT
|
||||
- **Performance mesurée** : RTF 0.4 (transcription "Bonjour" ~640 ms total :
|
||||
mel 260 ms + encoder 115 ms + decoder 170 ms)
|
||||
|
||||
### 3.2 LLM — Cascade Option E v2 (in-process)
|
||||
|
||||
**Architecture cascade** : 2 `LlmModule` ExecuTorch dans le **même process app**,
|
||||
partageant le `QnnBackendBundle` via le singleton `QnnBackendUnifiedRegistry`
|
||||
(donc 1 seul handle fastrpc DSP, pas de collision).
|
||||
|
||||
```
|
||||
Process com.kazeia
|
||||
├── Speaker Qwen3-4B (.pte 3.3 GB, ~4.4 GB ION)
|
||||
│ └── system prompt: SYS_KAZEIA + "Indices internes (NE PAS RÉPÉTER) : ..."
|
||||
└── Thinker Qwen3Guard-Gen-0.6B (.pte 700 MB, lazy first PTT)
|
||||
└── system prompt: SYS_THINKER (4 bullets émotion/besoin/approche/axe)
|
||||
```
|
||||
|
||||
| Composant | Modèle | Format | Taille | Quantization |
|
||||
|---|---|---|---|---|
|
||||
| Speaker | Qwen/Qwen3-4B | ExecuTorch `.pte` QNN HTP V79 | 3.3 GB | Q4 W4A16 |
|
||||
| Thinker | Qwen/Qwen3Guard-Gen-0.6B | ExecuTorch `.pte` QNN HTP V79 | 700 MB | Q4 W4A16 |
|
||||
|
||||
**Cascade sequence par tour utilisateur :**
|
||||
1. STT → texte patient (transcription Whisper)
|
||||
2. **Thinker pass** : `Guard-0.6B(SYS_THINKER + msg)` → 4 bullets cliniques
|
||||
3. Bullets tronqués à 400 chars (sécurité prefill budget)
|
||||
4. **Speaker pass** : `Qwen3-4B(SYS_KAZEIA + bullets en system, msg en user)` → réponse 1-2 phrases
|
||||
5. Streaming TTS phrase par phrase au fur et à mesure du décodage
|
||||
|
||||
### 3.3 TTS — Qwen3-TTS ggml-cpu
|
||||
|
||||
- **Modèle source** : Qwen3-TTS-0.6B-Base (clonage vocal)
|
||||
- **Pipeline 3 étapes**, toutes en CPU pur (NEON ARMv9, pas de DSP) :
|
||||
|
||||
| Étape | Modèle | Fichier | Taille | Backend |
|
||||
|---|---|---|---|---|
|
||||
| Talker | transformer audio → tokens semantic | `talker_f16.gguf` | 851 MB | ggml-cpu |
|
||||
| Code Predictor (CP) | sem → 16 codebooks RVQ | `cp_q8_0.gguf` | 84 MB + 240 MB embeds | ggml-cpu |
|
||||
| Decoder | RVQ → mel → audio 24 kHz | `qwen3tts_decoder.gguf` | 325 MB | ggml-cpu (libtts_decoder_ggml) |
|
||||
|
||||
- **Voice cloning** : prefix/suffix embeddings pré-extraits (10 voix : damien,
|
||||
elodie, jerome, richard, sid, zelda, didier, amir...)
|
||||
- **Streaming sentence-level** : audio joue dès la 1ère phrase pendant que LLM
|
||||
décode encore les suivantes (Opt TTS-B)
|
||||
- **RTF mesuré** : 0.96 sur tablette (chraac-llama fork)
|
||||
|
||||
### 3.4 Glue applicative
|
||||
|
||||
| Composant | Rôle |
|
||||
|---|---|
|
||||
| `KazeiaService` (Kotlin) | State machine PTT + cascade + pipeline |
|
||||
| `ExecuTorchLlmEngine` | Wrapper Java→JNI sur `LlmModule` ExecuTorch |
|
||||
| `Qwen3TtsEngine` | Orchestration JNI Talker+CP+Decoder + voice clone |
|
||||
| `WhisperHybridEngine` | JNI QAIRT + mel extractor NEON |
|
||||
| `AudioVisualizerView` | Orbe Canvas, HSV cycle pendant Thinking |
|
||||
| `SentenceStreamer` | Découpage phrases pour streaming TTS |
|
||||
| `PipelineMetrics` | Instrumentation timings end-to-end |
|
||||
|
||||
---
|
||||
|
||||
## 4. Performances mesurées
|
||||
|
||||
### 4.1 Latences par étape (mesures réelles dans logcat)
|
||||
|
||||
| Étape | Temps typique | Note |
|
||||
|---|---|---|
|
||||
| **STT Whisper-Small** | 460-680 ms | RTF 0.4 sur audio 1.7s |
|
||||
| ├ Mel extraction | 190-270 ms | NEON sur CPU |
|
||||
| ├ Encoder NPU | 115 ms | QAIRT V79 |
|
||||
| └ Decoder NPU | 75-300 ms | dépend du nb tokens |
|
||||
| **Thinker (Guard-0.6B)** | 446 ms | 22 tokens à 49 tok/s, TTFT 130 ms |
|
||||
| ├ Lazy load (1ère fois) | 1 088 ms | -844 MB MemAvailable |
|
||||
| └ Inference | 446 ms | 4 bullets, prompt ~180 tok |
|
||||
| **Speaker (Qwen3-4B)** | 1 000-2 200 ms | 14-46 tokens à 14-21 tok/s, TTFT 190 ms |
|
||||
| ├ resetContext | <1 ms | clear KV cache |
|
||||
| ├ Prefill (~150 tok) | 200 ms | hybrid mode AR=128 |
|
||||
| └ Decode | 800-2000 ms | 13.9-21 tok/s selon longueur |
|
||||
| **TTS Talker step** | ~25 ms | ggml-cpu NEON |
|
||||
| **TTS CP step** | ~43 ms | Q8_0, optimisé |
|
||||
| **TTS Decoder full** | RTF 0.96 | ggml-cpu, premier audio <1 s |
|
||||
|
||||
### 4.2 Latence end-to-end PTT → 1ᵉʳ audio
|
||||
|
||||
```
|
||||
PTT release ─┬───── 100 ms finalize segment
|
||||
│
|
||||
╔═══ 600 ms ═══════ STT Whisper ─┐
|
||||
║ │
|
||||
╠═══ 450 ms ═══════ Thinker Guard-0.6B │ utilisateur
|
||||
║ │ entend silence
|
||||
╠═══ 500 ms ═══════ Speaker prefill + 1ère phrase │
|
||||
║ │
|
||||
╠═══ 800 ms ═══════ TTS 1ère phrase synthèse ─┘
|
||||
║
|
||||
▼ 1ᵉʳ audio joué (~ 2.5 s après PTT release)
|
||||
```
|
||||
|
||||
**Total PTT release → 1ᵉʳ audio : ~ 2.5 s** (pour message court).
|
||||
**Total PTT release → réponse complète audio : ~ 4-6 s**.
|
||||
|
||||
### 4.3 Throughput LLM mesuré
|
||||
|
||||
| Modèle | Hardware | Decode tok/s | TTFT |
|
||||
|---|---|---|---|
|
||||
| Qwen3-4B Speaker | Hexagon V79 | 14-21 | 190 ms |
|
||||
| Guard-0.6B Thinker | Hexagon V79 | **49** | 130 ms |
|
||||
| Whisper-Small | Hexagon V79 | n/a (encoder/decoder fusion) | 115+75 ms |
|
||||
|
||||
---
|
||||
|
||||
## 5. Profil mémoire
|
||||
|
||||
### 5.1 Tablette en idle (Speaker chargé, Thinker pas encore)
|
||||
|
||||
| Métrique | Valeur |
|
||||
|---|---|
|
||||
| MemAvailable | 5.10 GB |
|
||||
| Cached (file cache) | 4.51 GB |
|
||||
| Process Kazeia RSS | 2.06 GB |
|
||||
| Process Kazeia PSS total | 2.14 GB |
|
||||
|
||||
### 5.2 Décomposition PSS Kazeia (idle)
|
||||
|
||||
| Section | PSS | % |
|
||||
|---|---|---|
|
||||
| Native Heap (modèles, ION, native libs) | 1.65 GB | 77 % |
|
||||
| Java/Dalvik Heap | 362 MB | 17 % |
|
||||
| Graphics (EGL/GL) | 64 MB | 3 % |
|
||||
| Code (.so + .apk + .dex mmap) | 42 MB | 2 % |
|
||||
| Stack/divers | 26 MB | 1 % |
|
||||
| **TOTAL PSS** | **2.14 GB** | |
|
||||
|
||||
### 5.3 Pendant cascade et TTS
|
||||
|
||||
| | Idle | Cascade Thinker | TTS pic |
|
||||
|---|---|---|---|
|
||||
| RSS Kazeia | 2.06 GB | ~2.9 GB | ~3.5 GB |
|
||||
| ION DSP cumulé | ~3.3 GB | ~5.0 GB | ~5.5 GB |
|
||||
| MemAvailable | 5.10 GB | 4.0 GB | 3.0 GB |
|
||||
|
||||
Le pic TTS reste loin du seuil critique (~1 GB MemAvailable où Linux LMK
|
||||
commencerait à killer). **Stable**.
|
||||
|
||||
### 5.4 Tablet baseline (sans Kazeia)
|
||||
|
||||
- **Stock fraîche** : MemAvailable ~3.5 GB (900+ processus OEM ColorOS)
|
||||
- **Après débloat + reboot** : MemAvailable ~11.1 GB → **+7.7 GB de marge**
|
||||
|
||||
---
|
||||
|
||||
## 6. Trajectoire — Ce qui a été fait
|
||||
|
||||
### Phase A : Pipeline initial (avril 2026)
|
||||
- Migration v1 du PoC Python/Flask vers app Android Kotlin native
|
||||
- Speaker via ExecuTorch QNN delegate (Qwen3-4B `.pte` hybrid mode)
|
||||
- STT Whisper-Small NPU via QAIRT
|
||||
- TTS Qwen3-TTS originalement en ExecuTorch `.pte`
|
||||
|
||||
### Phase B : Migration TTS vers ggml-cpu (avril-mai 2026)
|
||||
- Talker exporté en GGUF f16 (vs `.pte` ExecuTorch)
|
||||
- CP exporté en GGUF Q8_0 (84 MB vs 316 MB f32 vs 158 MB f16)
|
||||
- Decoder porté en C++ via libtts_decoder_ggml.so
|
||||
- Validation RTF < 1 sur tablette (chraac-llama fork)
|
||||
|
||||
### Phase C : Tablette stabilisation (mai 2026)
|
||||
- Mesure baseline ColorOS : ~3.5 GB MemAvailable seulement
|
||||
- **Débloat** sans root : 84 packages désinstallés via `pm uninstall --user 0`
|
||||
- **Athena watchdog** : désinstallation du package (kill ION quota 2 GB sur app)
|
||||
- Suppression de symlinks ⚠ SELinux ColorOS sans Magisk les bloque
|
||||
- Backup complet 25 GB dans `/opt/Kazeia/kazeia-tablet-backup/`
|
||||
- Factory reset + restore réussi
|
||||
|
||||
### Phase D : Cascade Thinker+Speaker (mai 2026)
|
||||
Tentatives successives :
|
||||
|
||||
| Tentative | Approche | Résultat |
|
||||
|---|---|---|
|
||||
| 1 | Subprocess `qnn_llama_runner` daemon | ✗ Collision DSP fastrpc (2 sessions QNN HTP) |
|
||||
| 2 | Option E v1 : 2 LlmModule (Speaker 4B + Guard 4B) | ✗ OOM Linux LMK (8.8 GB ION cumulé) |
|
||||
| 3 | Option B : 2-pass sur même Speaker | ✓ Stable, qualité Thinker faible (Qwen3-4B générique) |
|
||||
| 4 | **Option E v2** : Speaker 4B + Guard-0.6B | **✓ Production actuelle** |
|
||||
|
||||
Pour Option E v2 : export Qwen3Guard-Gen-0.6B HF → ExecuTorch `.pte` QNN HTP V79
|
||||
en local via `executorch.examples.qualcomm.oss_scripts.llama` (registration
|
||||
ajoutée dans `__init__.py` + `decoder_constants.py`).
|
||||
|
||||
### Phase E : Robustesse runtime (mai 2026)
|
||||
- Fix VAD seuil RMS (150 → 80) après calibration mic post-reset
|
||||
- Fix PTT release : finalisation forcée du segment (sans ça, le LLM n'était
|
||||
jamais déclenché si bruit ambiant > seuil)
|
||||
- Fix `seq_len` ExecuTorch : passer 512 (max compilé .pte) au lieu de
|
||||
`maxNewTokens` (assertion `cur_pos+prompt < seq_len`)
|
||||
- Fix `cur_pos_` accumulation : appel `LlmModule.resetContext()` à chaque
|
||||
generate (sinon crash après quelques tours)
|
||||
- Fix `prefill()` overflow : truncation bullets à 400 chars (Guard-0.6B peut
|
||||
rambler et faire dépasser le budget prefill du Speaker)
|
||||
- Fix Speaker régurgitant les bullets : injection bullets dans **system prompt**
|
||||
(avec instruction « NE PAS RÉPÉTER ») au lieu du user prompt
|
||||
- Fix orbe visuelle : `Paint.shader = null` avant pose couleur dans
|
||||
`drawThinking` (sinon le shader posé par `drawIdle` masquait la couleur)
|
||||
|
||||
### Phase F : Backup APK + scripts (en continu)
|
||||
- `/opt/Kazeia/kazeia-tablet-backup/` (25 GB total)
|
||||
- APK actuelle (110 MB, libs natives custom bundlées)
|
||||
- Modèles TTS+STT (12 GB)
|
||||
- LLM `.pte` (12 GB inc. Guard-0.6B nouveau)
|
||||
- Voix de référence (130 MB)
|
||||
- `restore.sh` automatisé
|
||||
- `/opt/Kazeia/kazeia-debloat/` : `safelist.txt` + `debloat.sh` + `rebloat.sh`
|
||||
- `/opt/Kazeia/export_guard_06b.sh` : pipeline d'export Guard-0.6B reproducible
|
||||
|
||||
---
|
||||
|
||||
## 7. Architecture cascade détaillée (Option E v2)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ KazeiaService │
|
||||
│ │
|
||||
│ PTT release ───► startContinuousListening() drains buffer │
|
||||
│ ► processSpeechInput / streaming Whisper │
|
||||
│ ► text = "Comment ça va ?" │
|
||||
│ ► processLlmResponse(text) │
|
||||
│ │
|
||||
│ ┌─ ensureThinkerEngine() ──┐ (lazy 1ère fois ~1.1 s) │
|
||||
│ │ ▼ │
|
||||
│ │ ┌─────────────────────┐ │
|
||||
│ │ │ thinkerEngine │ │
|
||||
│ │ │ ExecuTorchLlmEngine │ │
|
||||
│ │ │ Guard-0.6B │ │
|
||||
│ │ │ SYS_THINKER │ │
|
||||
│ │ │ T=0.0, max 128 tok │ │
|
||||
│ │ └──────────┬──────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ bullets (400 chars max) │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────────────────┴──────┐ │
|
||||
│ │ │ speakerSystemPrompt = │ │
|
||||
│ │ │ SYS_KAZEIA + │ │
|
||||
│ │ │ "\n\nIndices d'analyse... │ │
|
||||
│ │ │ (NE PAS CITER) :\n" + │ │
|
||||
│ │ │ bullets │ │
|
||||
│ │ └────────────┬────────────────────┘ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ llm = ExecuTorchLlmEngine │ │
|
||||
│ │ │ Speaker Qwen3-4B │ │
|
||||
│ │ │ generateWithSystem() │ │
|
||||
│ │ │ system=above, user=msg │ │
|
||||
│ │ │ T=0.7, max 450 tok │ │
|
||||
│ │ └──────────┬───────────────┘ │
|
||||
│ │ ▼ token-by-token streaming │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ SentenceStreamer │ │
|
||||
│ │ │ split sur . ! ? │ │
|
||||
│ │ └──────────┬───────────────┘ │
|
||||
│ │ ▼ phrase à phrase │
|
||||
│ │ ┌──────────────────────────┐ │
|
||||
│ │ │ Qwen3TtsEngine │ │
|
||||
│ │ │ enqueueSentence() │ │
|
||||
│ │ └──────────┬───────────────┘ │
|
||||
│ │ ▼ │
|
||||
│ └─────► AudioPlayback (24 kHz mono, AudioTrack) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ DSP Hexagon V79 │
|
||||
│ • QAIRT (Whisper) │
|
||||
│ • ExecuTorch QNN HTP │
|
||||
│ (Speaker + Thinker │
|
||||
│ partagent bundle) │
|
||||
└─────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ CPU NEON ARMv9 │
|
||||
│ • TTS Talker (ggml) │
|
||||
│ • TTS CP (ggml) │
|
||||
│ • TTS Decoder (ggml) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Prompts système (production)
|
||||
|
||||
### Speaker
|
||||
```
|
||||
Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français :
|
||||
UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple.
|
||||
Pas de remplissage, pas d'explication, pas de conseil non sollicité.
|
||||
/no_think
|
||||
|
||||
[ + injection en cascade : ]
|
||||
Indices d'analyse interne (NE PAS RÉPÉTER, NE PAS CITER, sert uniquement
|
||||
à orienter le ton de ta réponse) :
|
||||
- emotion : ...
|
||||
- besoin : ...
|
||||
- approche : ...
|
||||
- axe : ...
|
||||
```
|
||||
|
||||
### Thinker
|
||||
```
|
||||
Tu es un psychologue clinicien experimente qui ecoute attentivement ton
|
||||
patient. Avant de repondre, tu prepares une analyse silencieuse pour orienter
|
||||
ta reponse. Produis UNIQUEMENT le bloc clinique au format strict suivant :
|
||||
- emotion : <emotion principale ressentie, 5 mots max>
|
||||
- besoin : <besoin sous-jacent du patient, 5 mots max>
|
||||
- approche : <posture therapeutique adaptee, 5 mots max>
|
||||
- axe : <point cle a valider ou explorer, 5 mots max>
|
||||
Pas d'introduction, pas d'explication. Juste les 4 lignes en francais.
|
||||
/no_think
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Risques connus & mitigations
|
||||
|
||||
| Risque | Impact | Mitigation actuelle | Action future |
|
||||
|---|---|---|---|
|
||||
| Athena framework (system_server) tue Kazeia au-dessus de seuil RAM | Crash app | Débloat OEM réduit pression mémoire | Investiguer si killable plus profond |
|
||||
| LMK Linux kill si MemAvailable < 1 GB | Crash app | Marge 3 GB en pic actuel | Lazy-load STT (-600 MB) |
|
||||
| Speaker régurgite bullets cascade | Phrases bizarres lues par TTS | Bullets en system prompt + interdiction | Fine-tune/instruction tuning |
|
||||
| Thinker Guard-0.6B mal calibré pour analyse FR | Bullets imprécis | Speaker compense par sa robustesse | Évaluer Qwen3-1.7B en remplacement |
|
||||
| Whisper hallucinations sur silence | "que tu entends ce que je dis ici." apparaît au repos | Ignorer streaming results vides | Filtre RMS/longueur audio min |
|
||||
| Orbe ne change pas (avant fix) | UX dégradée | `Paint.shader = null` dans drawThinking | OK |
|
||||
| Symlinks `.pte` invisibles app | Mode perroquet | Fichiers réguliers (cp pas ln) | OK, mémoire feedback enregistrée |
|
||||
| OOM 2× ExecuTorch HTP 4B | Crash | Thinker = Guard-0.6B (5.1 GB ION total OK) | Plus petit Thinker possible |
|
||||
|
||||
---
|
||||
|
||||
## 10. Optimisations identifiées (memory profiling 2026-05-04)
|
||||
|
||||
| Optim | Effort | Gain idle | Gain pic | Risque | Recommandation |
|
||||
|---|---|---|---|---|---|
|
||||
| Strip jniLibs Hexagon non-V79 | 30 min | 50 MB code | 50 MB | Bas | **Tier 1** |
|
||||
| Lazy-load Whisper STT | 1 h | 600 MB | 0 | Moyen (+0.5s 1ᵉʳ PTT) | **Tier 1** |
|
||||
| Trim Java heap (logs StateFlow) | 1 h | 100-200 MB | 100 MB | Bas | **Tier 1** |
|
||||
| Release Thinker entre tours | 2 h | 0 | -700 MB | Moyen (+1.5s 2ᵉ PTT) | Tier 2 |
|
||||
| Talker GGUF Q8_0 (vs f16) | 1 j | 400 MB | 400 MB | Haut (qualité audio) | Tier 2 |
|
||||
| Decoder Q4 sélectif | 2-3 j | 200 MB | 200 MB | Haut | Tier 3 |
|
||||
| Re-export Speaker plus petit | 5-10 j | 500-1 000 MB | similar | Très haut | Tier 3 |
|
||||
|
||||
---
|
||||
|
||||
## 11. Roadmap
|
||||
|
||||
### Court terme (1-2 semaines)
|
||||
- [ ] Appliquer Tier 1 mémoire (strip libs + lazy STT + trim heap)
|
||||
- [ ] Évaluation qualitative cascade FR sur corpus thérapeutique
|
||||
20+ prompts de référence
|
||||
- [ ] Comparer Guard-0.6B vs Qwen3-1.7B comme Thinker (export et bench)
|
||||
- [ ] Documenter procédure réinstallation tablette neuve (incl. débloat)
|
||||
|
||||
### Moyen terme (1-2 mois)
|
||||
- [ ] Rewrite Kazeia Phase 1-5 (architecture propre Kotlin/JNI)
|
||||
- [ ] Voice clone .bin generator on-device (POC)
|
||||
- [ ] Persistance conversation history (Room database)
|
||||
- [ ] UI polish (mode conversation libre vs PTT)
|
||||
- [ ] Voice commands étendus
|
||||
|
||||
### Long terme (3+ mois)
|
||||
- [ ] Fine-tuning thérapeutique français spécifique (LoRA)
|
||||
- [ ] Migration runtime briques (POC dans `/opt/htp-runtime/`) — alternative
|
||||
à ExecuTorch/QNN sur le long terme pour autonomie
|
||||
- [ ] Évaluation clinique avec professionnels santé mentale
|
||||
|
||||
---
|
||||
|
||||
## 12. Références — Fichiers clés
|
||||
|
||||
### Code source app
|
||||
- `/opt/Kazeia/kazeia-android/` — projet Gradle 8.12 / NDK r27d / API 36
|
||||
- `app/src/main/java/com/kazeia/service/KazeiaService.kt` — orchestrateur
|
||||
- `app/src/main/java/com/kazeia/llm/ExecuTorchLlmEngine.kt` — wrapper LLM
|
||||
- `app/src/main/java/com/kazeia/tts/Qwen3TtsEngine.kt` — TTS
|
||||
- `app/src/main/java/com/kazeia/stt/WhisperHybridEngine.kt` — STT
|
||||
- `app/src/main/java/com/kazeia/ui/AudioVisualizerView.kt` — orbe
|
||||
- `app/src/main/jni/` — bridges JNI custom (libtts_*, libmel_extractor)
|
||||
|
||||
### Scripts et outils
|
||||
- `/opt/Kazeia/export_guard_06b.sh` — export Qwen3Guard-Gen-0.6B → `.pte`
|
||||
- `/opt/Kazeia/kazeia-debloat/` — débloat tablette
|
||||
- `/opt/Kazeia/kazeia-tablet-backup/` — backup + restore
|
||||
- `/opt/Kazeia/executorch/examples/qualcomm/oss_scripts/llama/` — pipeline export QNN HTP
|
||||
|
||||
### Modèles tablette
|
||||
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_4b.pte` — Speaker
|
||||
- `/data/local/tmp/kazeia-et/hybrid_llama_qnn_guard06b.pte` — Thinker (nouveau)
|
||||
- `/data/local/tmp/kazeia-et/tokenizer.json` / `tokenizer_guard06b.json`
|
||||
- `/data/local/tmp/kazeia/models/` — Talker, CP, Decoder GGUF, Whisper QAIRT
|
||||
- `/data/local/tmp/kazeia/voix/` — voix de référence
|
||||
|
||||
### Documents de référence
|
||||
- `/opt/Kazeia/FROZEN.md` — stack figée
|
||||
- `/opt/Kazeia/kazeia-tts-perf-validated/` — TTS RTF<1 validé
|
||||
- `/opt/Kazeia/kazeia-stt-perf-validated/` — STT RTF 0.4 validé
|
||||
- `/opt/Kazeia/kazeia-debloat/README.md` — procédure tablette
|
||||
|
||||
### Mémoire persistante (sessions Claude Code)
|
||||
- `/home/alf/.claude/projects/-opt-Kazeia/memory/MEMORY.md` — index
|
||||
- 50+ entrées documentant chaque décision technique majeure
|
||||
|
||||
---
|
||||
|
||||
## 13. Conclusion
|
||||
|
||||
Kazeia tourne **end-to-end stable** sur OnePlus Pad 3 sans connexion internet,
|
||||
sans root utilisateur, avec des latences acceptables pour une conversation
|
||||
thérapeutique (3-5 s entre tours). L'architecture cascade Speaker+Thinker
|
||||
in-process via ExecuTorch QNN HTP est validée et reproductible.
|
||||
|
||||
Les principaux **blocages historiques** ont été résolus : DSP contention,
|
||||
OOM, SELinux symlinks, watchdog OEM, accumulation KV cache. La stack est
|
||||
documentée et figée, avec backup + procédure de restore complets.
|
||||
|
||||
Les **axes d'amélioration restants** sont incrémentaux (mémoire, qualité
|
||||
Thinker, UX) plutôt que structurels. Le pipeline est prêt pour **évaluation
|
||||
clinique** sur corpus de référence en français.
|
||||
|
||||
---
|
||||
*Rapport généré 2026-05-04. Auteur : Richard Loyer + assistance IA.*
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
# Spec — Ajouter le support embeddings (RAG) à kazeia-engine
|
||||
|
||||
> **Destinataire :** développeur de `kazeia-engine` (`/opt/Kazeia-engine`).
|
||||
> **But :** exposer un entrypoint « texte → vecteur de phrase poolé » pour le RAG de Kazeia mobile.
|
||||
> **Fichier cible :** `dist/jni/kazeia_engine_jni.cpp` (+ bindings Kotlin côté app).
|
||||
> **Coût estimé :** ~40–60 lignes C++, aucune nouvelle dépendance.
|
||||
|
||||
## Contexte
|
||||
|
||||
Le RAG de Kazeia mobile doit transformer un texte en **vecteur de phrase poolé**. La machinerie
|
||||
existe déjà dans le fork (`llama_pooling_type`, champ `pooling_type` dans les context params,
|
||||
`llama_get_embeddings_seq`), mais **aucun entrypoint JNI texte→vecteur** n'est exposé. Il faut
|
||||
l'ajouter **sans toucher au chemin Speaker/TTS existant**.
|
||||
|
||||
## Principe (important)
|
||||
|
||||
- L'embedder est un **modèle dédié** (GGUF type **e5 / bge**, arch BERT), chargé comme **handle
|
||||
séparé** — surtout **pas** le LLM Speaker (un décodeur causal est un mauvais embedder).
|
||||
- **CPU pur, pas de HTP/HMX** (un embed coûte ~10–50 ms ; le NPU est inutile et déstabilisant ici).
|
||||
- **Ne pas réutiliser** `prefillEmbeds`/`decodeEmbed`/`llama_get_embeddings_ith` : c'est l'I/O float
|
||||
du Talker TTS (hidden du *dernier token*), pas un embedding de phrase poolé.
|
||||
|
||||
---
|
||||
|
||||
## Tâche 1 — Nouvelle struct + `loadEmbedder`
|
||||
|
||||
À ajouter dans `kazeia_engine_jni.cpp` (section nouvelle, après le bloc LLM) :
|
||||
|
||||
```cpp
|
||||
// ============================================================================
|
||||
// EMBEDDINGS (RAG) : modèle dédié BERT-like (e5/bge), CPU, pooling de séquence.
|
||||
// Indépendant du KEngine Speaker. Aucun HTP.
|
||||
// ============================================================================
|
||||
struct KEmbedder {
|
||||
llama_model* m;
|
||||
llama_context* c;
|
||||
const llama_vocab* v;
|
||||
int n_embd;
|
||||
};
|
||||
|
||||
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge)
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_com_kazeia_llm_EngineJni_loadEmbedder(JNIEnv* e, jobject, jstring path, jint nThreads, jint pooling) {
|
||||
const char* pc = e->GetStringUTFChars(path, 0);
|
||||
std::string p(pc); e->ReleaseStringUTFChars(path, pc);
|
||||
|
||||
llama_backend_init(); // idempotent
|
||||
auto mp = llama_model_default_params(); mp.n_gpu_layers = 0; // CPU only
|
||||
llama_model* m = llama_model_load_from_file(p.c_str(), mp);
|
||||
if (!m) return 0;
|
||||
|
||||
auto cp = llama_context_default_params();
|
||||
cp.n_threads = (nThreads > 0) ? nThreads : 4;
|
||||
cp.embeddings = true; // <-- clé
|
||||
cp.pooling_type = (pooling == 1) ? LLAMA_POOLING_TYPE_MEAN
|
||||
: (pooling == 2) ? LLAMA_POOLING_TYPE_CLS
|
||||
: LLAMA_POOLING_TYPE_UNSPECIFIED; // défaut = métadonnées du modèle
|
||||
// Pooling MEAN/CLS exige que TOUTE la séquence tienne dans un seul ubatch :
|
||||
cp.n_ctx = 512; cp.n_batch = 512; cp.n_ubatch = 512; // chunks <= 512 tokens
|
||||
// (ne PAS réutiliser make_ctx : pas de flash_attn ni de KV f16 ici)
|
||||
llama_context* c = llama_init_from_model(m, cp);
|
||||
if (!c) { llama_model_free(m); return 0; }
|
||||
|
||||
auto* k = new KEmbedder{ m, c, llama_model_get_vocab(m), llama_model_n_embd(m) };
|
||||
fprintf(stderr, "kazeia-engine: EMBEDDER chargé (%s, n_embd=%d, pooling=%d)\n", p.c_str(), k->n_embd, pooling);
|
||||
return (jlong) k;
|
||||
}
|
||||
```
|
||||
|
||||
## Tâche 2 — `embedText` (texte → vecteur L2-normalisé)
|
||||
|
||||
```cpp
|
||||
extern "C" JNIEXPORT jfloatArray JNICALL
|
||||
Java_com_kazeia_llm_EngineJni_embedText(JNIEnv* e, jobject, jlong h, jstring text) {
|
||||
auto* k = (KEmbedder*) h;
|
||||
const char* tc = e->GetStringUTFChars(text, 0);
|
||||
std::string s(tc); e->ReleaseStringUTFChars(text, tc);
|
||||
|
||||
// 1) tokenize (add_special=true -> CLS/BOS selon le modèle)
|
||||
int n = -llama_tokenize(k->v, s.c_str(), s.size(), nullptr, 0, true, true);
|
||||
if (n <= 0) return nullptr;
|
||||
if (n > 512) n = 512; // garde-fou ubatch
|
||||
std::vector<llama_token> toks(n);
|
||||
llama_tokenize(k->v, s.c_str(), s.size(), toks.data(), n, true, true);
|
||||
|
||||
// 2) batch : tous les tokens, seq 0, sortie activée (requis pour le pooling)
|
||||
llama_memory_clear(llama_get_memory(k->c), true);
|
||||
llama_batch b = llama_batch_init(n, 0, 1);
|
||||
for (int i = 0; i < n; ++i) {
|
||||
b.token[i] = toks[i]; b.pos[i] = i;
|
||||
b.n_seq_id[i] = 1; b.seq_id[i][0] = 0; b.logits[i] = 1;
|
||||
}
|
||||
b.n_tokens = n;
|
||||
if (llama_decode(k->c, b) != 0) { llama_batch_free(b); return nullptr; }
|
||||
|
||||
// 3) embedding poolé de la séquence 0
|
||||
const float* emb = llama_get_embeddings_seq(k->c, 0);
|
||||
llama_batch_free(b);
|
||||
if (!emb) return nullptr;
|
||||
|
||||
// 4) normalisation L2 (cosinus = produit scalaire côté Android)
|
||||
std::vector<float> out(k->n_embd);
|
||||
double norm = 0.0; for (int i=0;i<k->n_embd;++i) norm += (double)emb[i]*emb[i];
|
||||
norm = norm > 0 ? 1.0/std::sqrt(norm) : 0.0;
|
||||
for (int i=0;i<k->n_embd;++i) out[i] = (float)(emb[i]*norm);
|
||||
|
||||
jfloatArray arr = e->NewFloatArray(k->n_embd);
|
||||
e->SetFloatArrayRegion(arr, 0, k->n_embd, out.data());
|
||||
return arr;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_com_kazeia_llm_EngineJni_freeEmbedder(JNIEnv*, jobject, jlong h) {
|
||||
auto* k = (KEmbedder*) h;
|
||||
if (k->c) llama_free(k->c);
|
||||
if (k->m) llama_model_free(k->m);
|
||||
delete k;
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ **À cross-checker contre `tools/llama-embedding` (ou `examples/embedding`) de TON fork.**
|
||||
> Selon la version, un modèle encoder-only (BERT) peut nécessiter `llama_encode()` au lieu de
|
||||
> `llama_decode()`, et le drapeau `logits` du batch peut différer. **Reproduis exactement ce que
|
||||
> fait ton outil d'embedding de référence** — c'est la source de vérité.
|
||||
|
||||
## Tâche 3 — Bindings Kotlin
|
||||
|
||||
Dans `EngineJni` (`kazeia-android/.../llm/EngineLlmEngine.kt`) :
|
||||
|
||||
```kotlin
|
||||
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long
|
||||
external fun embedText(handle: Long, text: String): FloatArray?
|
||||
external fun freeEmbedder(handle: Long)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Points de vigilance (impératifs)
|
||||
|
||||
1. **Modèle = e5/bge dédié, FR-capable** (ex. `multilingual-e5-small`, 384-dim). Le **même** modèle
|
||||
à l'ingestion et à la requête, sinon vecteurs incompatibles. **Versionne-le** (le n° de version
|
||||
du modèle d'embedding fait partie du contrat de distribution).
|
||||
2. **Vérifier d'abord que le fork charge l'arch BERT** (`bert` / `nomic-bert`) : le fork a été modifié
|
||||
pour Qwen/TTS — **teste le GGUF d'embedding avec `llama-embedding` AVANT d'intégrer**. Si l'arch a
|
||||
été retirée du fork, c'est LE point de blocage à traiter en priorité (≈ 5 min à vérifier).
|
||||
3. **`n_ubatch ≥ nombre de tokens`** : le pooling MEAN/CLS impose la séquence entière dans un seul
|
||||
ubatch. D'où `n_ctx = n_batch = n_ubatch = 512` et le cap à 512 tokens (chunks plus courts).
|
||||
4. **`pooling = -1` (défaut modèle) recommandé** ; sinon e5→MEAN(1), bge→CLS(2).
|
||||
5. **Préfixes e5 (`query:` / `passage:`) = côté appelant**, pas dans le moteur.
|
||||
6. **CPU only** (`n_gpu_layers = 0`), pas de HTP/HMX.
|
||||
7. **Déterminisme** : pas d'échantillonnage, l'embed est déterministe — indispensable pour
|
||||
l'invariant « même modèle ingestion = requête ».
|
||||
|
||||
## Test d'acceptation (sur PC d'abord)
|
||||
|
||||
- `loadEmbedder` retourne un handle ≠ 0 sur le GGUF choisi ; `embedText` renvoie un `float[]` de
|
||||
taille `n_embd` (384 pour e5-small).
|
||||
- **Déterminisme** : même texte → vecteur **bit-identique** sur 2 appels.
|
||||
- **Norme ≈ 1.0** (L2).
|
||||
- **Cohérence sémantique** : `cos("j'ai du mal à dormir", "insomnie")` >
|
||||
`cos("j'ai du mal à dormir", "recette de tarte")`.
|
||||
- **Parité référence** : le vecteur ≈ celui de `llama-embedding` sur le même texte (à epsilon près).
|
||||
|
||||
## Build
|
||||
|
||||
Rebuild `libkazeia_engine.so` via le script habituel (`dist/build_kazeia_*.sh` / CMake).
|
||||
**Aucune nouvelle dépendance** (tout est dans libllama). Livrer la `.so` + un GGUF d'embedding e5/bge.
|
||||
|
||||
---
|
||||
|
||||
## Ce qui reste côté app mobile (hors de cette spec, pour info)
|
||||
|
||||
Une fois `embedText` livré, l'intégration RAG dans `kazeia-android` est :
|
||||
schéma SQLite (`chunk_text`, `source`, `embedding BLOB`) dans la base SQLCipher existante →
|
||||
ingestion côté app admin (chunk sémantique + `embedText`) → au runtime, charger la matrice en RAM
|
||||
→ brute-force cosinus (produit scalaire NEON) top-k=3–5 + **seuil de similarité** → injection d'un
|
||||
bloc « CONTEXTE » budgété en tokens dans le system prompt. **Pas de vector-DB, pas d'ANN, pas
|
||||
d'ONNX** (cf. analyse d'architecture). Le RAG ne porte jamais la gestion de crise.
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
# Signature release Kazeia — gestion du keystore
|
||||
|
||||
> **Résumé en 3 lignes** : les deux APK (patient + admin) sont signées en release avec
|
||||
> **une seule clé** : `/opt/Kazeia/keystore/kazeia-release.jks`. Si cette clé est perdue,
|
||||
> **aucune mise à jour in-place n'est plus possible** (désinstallation forcée = perte des
|
||||
> données patient). → **Sauvegarder le dossier `keystore/` hors machine, maintenant.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Ce qui existe
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `/opt/Kazeia/keystore/kazeia-release.jks` | Keystore (RSA 4096, alias `kazeia`, valide jusqu'en **2056**) |
|
||||
| `/opt/Kazeia/keystore/credentials.properties` | Chemin + mots de passe, lu par Gradle (chmod 600) |
|
||||
| `docs/RELEASE_SIGNING.md` | Ce document |
|
||||
|
||||
Empreinte du certificat (référence pour vérifier qu'on signe avec la bonne clé) :
|
||||
```
|
||||
SHA256: 4B:40:8D:9D:40:09:FD:68:F7:FD:5B:E0:BD:64:2B:D3:EE:B8:F8:BD:7A:84:D3:9C:84:90:B8:63:21:AF:E4:90
|
||||
```
|
||||
|
||||
**Le dossier `keystore/` est VERSIONNÉ dans le dépôt git** (décision 2026-06-11 : le dépôt
|
||||
`git.kazeia.com` est privé, auto-hébergé, équipe restreinte → le git sert de sauvegarde de
|
||||
la clé). Conséquences à garder en tête :
|
||||
- **accès au dépôt = capacité de signer des mises à jour Kazeia** ;
|
||||
- la clé reste dans l'historique git pour toujours ;
|
||||
- **si le dépôt doit un jour être ouvert/partagé plus largement → rotation de clé AVANT** (§7).
|
||||
|
||||
## 2. Comment c'est câblé
|
||||
|
||||
`app/build.gradle.kts` et `app-admin/build.gradle.kts` lisent
|
||||
`/opt/Kazeia/keystore/credentials.properties` :
|
||||
|
||||
- **Présent** → `assembleRelease` signe avec la clé release.
|
||||
- **Absent** (autre machine, CI sans secrets) → le build **ne casse pas** : release signé
|
||||
en **debug** avec un warning `⚠ keystore release absent`. Ces APK de repli ne doivent
|
||||
JAMAIS être distribuées.
|
||||
|
||||
**Une seule clé pour les deux apps** (patient `com.kazeia` + admin `com.kazeia.admin`) :
|
||||
même identité → permettra de protéger le ContentProvider par une
|
||||
`signature`-permission (prévu au plan admin, pas encore fait).
|
||||
|
||||
## 3. Construire et distribuer une release
|
||||
|
||||
```bash
|
||||
cd /opt/Kazeia/kazeia-android
|
||||
|
||||
# 1) Bumper versionCode (OBLIGATOIRE sinon l'OTA ne se déclenche pas)
|
||||
# app/build.gradle.kts : versionCode = N+1
|
||||
# (app-admin pareil si distribuée)
|
||||
|
||||
# 2) Build
|
||||
./gradlew :app:assembleRelease # → app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
# 3) Vérifier la signature (DOIT afficher l'empreinte SHA256 ci-dessus)
|
||||
/opt/Kazeia/android-sdk/build-tools/*/apksigner verify --print-certs \
|
||||
app/build/outputs/apk/release/app-release.apk | grep SHA-256
|
||||
|
||||
# 4) Publier via le catalogue Nextcloud
|
||||
cp app/build/outputs/apk/release/app-release.apk /opt/Kazeia/kazeia-dist/apk/kazeia.apk
|
||||
# Éditer kazeia-dist/catalog.spec.json : app.version_code = N+1, bump catalog_version
|
||||
python3 /opt/Kazeia/kazeia-dist-tools/make_catalog.py --dist-root /opt/Kazeia/kazeia-dist --upload
|
||||
```
|
||||
|
||||
Les tablettes voient `catalog.app.version_code > BuildConfig.VERSION_CODE` → téléchargent,
|
||||
vérifient le SHA-256, et proposent l'installation (dialogue système).
|
||||
|
||||
## 4. SAUVEGARDE — le point qui ne pardonne pas
|
||||
|
||||
La clé EST l'identité de l'app. Android n'accepte une mise à jour que si elle est signée
|
||||
par la **même clé** que la version installée.
|
||||
|
||||
**Sauvegarde principale : le dépôt git privé** (`keystore/` est versionné, cf §1). Une panne
|
||||
de la machine de build ne coûte donc plus la clé — tant que `git.kazeia.com` est vivant.
|
||||
|
||||
**Recommandé en plus** (le git et la machine de build peuvent partager un même sinistre —
|
||||
serveur et poste au même endroit) : une copie hors-ligne occasionnelle :
|
||||
```bash
|
||||
tar czf kazeia-keystore-$(date +%Y%m%d).tar.gz -C /opt/Kazeia keystore/ # → USB / coffre
|
||||
```
|
||||
|
||||
**Sur une nouvelle machine de build** : `git clone` apporte le code, le keystore ET la
|
||||
définition des jniLibs (manifest). Mais les **binaires jniLibs** (474 Mo, gitignorés) doivent
|
||||
être récupérés à part :
|
||||
|
||||
```bash
|
||||
# 1) code + keystore + manifest
|
||||
git clone https://git.kazeia.com/Kazeia/kazeia.git /opt/Kazeia && cd /opt/Kazeia
|
||||
chmod 600 keystore/credentials.properties
|
||||
# 2) jniLibs (tarball privé sur Nextcloud, auth requise)
|
||||
curl -u <user>:<pass> -o /tmp/jnilibs.tar.gz \
|
||||
"https://box.kazeia.com/remote.php/dav/files/kazeia/soft/build-artifacts/kazeia-jnilibs-20260611.tar.gz"
|
||||
bash kazeia-android/scripts/jnilibs.sh import /tmp/jnilibs.tar.gz # extrait + verify
|
||||
# 3) build
|
||||
cd kazeia-android && ./gradlew :app:assembleRelease
|
||||
```
|
||||
|
||||
Le tarball jniLibs vit dans `soft/build-artifacts/` (dossier Nextcloud auth-protégé, NON
|
||||
référencé par le catalogue → jamais servi à l'app). À régénérer après tout changement de
|
||||
libs : `jnilibs.sh export` → re-uploader. La version (date dans le nom) doit correspondre au
|
||||
`jnilibs.MANIFEST.sha256` du commit.
|
||||
|
||||
## 5. Scénarios de panne
|
||||
|
||||
| Scénario | Conséquence | Procédure |
|
||||
|---|---|---|
|
||||
| **Keystore perdu (aucune sauvegarde)** | Plus AUCUNE mise à jour in-place possible | Générer une nouvelle clé, désinstaller/réinstaller sur chaque tablette → **perte des données patient** (SQLCipher lié à l'install). Exporter l'historique via l'admin AVANT si possible. À éviter à tout prix → §4. |
|
||||
| **Mot de passe perdu (jks présent)** | Identique à la perte du keystore | Aucun recouvrement possible sur un .jks. Le mot de passe est dans `credentials.properties` — sauvegardé AVEC le .jks. |
|
||||
| **Machine de build morte (sauvegarde OK)** | Aucun impact | Restaurer `keystore/` sur la nouvelle machine (§4). |
|
||||
| **Clé compromise (fuite)** | Un tiers peut signer des mises à jour | Distribution étant privée (Nextcloud authentifié), changer aussi les identifiants WebDAV ; planifier une migration de clé (réinstallation contrôlée). |
|
||||
|
||||
## 6. Migration des tablettes existantes (one-shot)
|
||||
|
||||
Les installs actuelles sont **debug-signées** → la première APK release sera refusée en
|
||||
mise à jour (mismatch). **Une fois par tablette** :
|
||||
|
||||
```bash
|
||||
# 1) (si données à garder) export de l'historique via l'app admin
|
||||
# 2) Désinstaller les builds debug
|
||||
adb uninstall com.kazeia ; adb uninstall com.kazeia.admin
|
||||
# 3) Installer les release
|
||||
adb install app/build/outputs/apk/release/app-release.apk
|
||||
adb install app-admin/build/outputs/apk/release/app-admin-release.apk
|
||||
# 4) Re-provisionner (les modèles sur stockage externe/`/data/local/tmp` SURVIVENT à la
|
||||
# désinstallation ; profils/conversations/config in-app sont PERDUS → re-créer le profil)
|
||||
```
|
||||
Après cette migration, toutes les mises à jour passent par l'OTA catalogue, sans perte.
|
||||
|
||||
⚠ **Le dev quotidien reste en debug** (`assembleDebug` + `adb install -r`) : debug et
|
||||
release sont signés différemment, on ne peut pas écraser l'un par l'autre. La tablette
|
||||
de dev reste en debug ; les tablettes patient passent en release.
|
||||
|
||||
## 7. Rotation / clés multiples (futur)
|
||||
|
||||
- Pas d'expiration avant 2056 ; pas de rotation préventive nécessaire pour une
|
||||
distribution privée.
|
||||
- Si un jour publication sur un store : Google Play App Signing prendra cette clé comme
|
||||
« upload key » et gérera la clé de signature côté store (le risque de perte disparaît).
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# Corpus seed RAG (psychoéducation FR)
|
||||
|
||||
Corpus de DÉPART pour le RAG Kazeia — contenu standard et sûr, 18 fiches (~1 chunk
|
||||
chacune, Chunker maxChars=1200) : sommeil, respiration, ancrage 5-4-3-2-1, activation
|
||||
comportementale, rumination, solitude, crise d'angoisse, pensées automatiques,
|
||||
émotions, estime de soi, auto-compassion, colère, deuil, stress, relaxation
|
||||
musculaire progressive, pleine conscience, anxiété sociale, demander de l'aide.
|
||||
|
||||
Lignes éditoriales : une fiche = un thème + une technique concrète ; vouvoiement
|
||||
neutre (matériau de référence injecté dans le prompt, pas la voix de Kazeia) ;
|
||||
JAMAIS de médication, de diagnostic ni de gestion de crise suicidaire (la crise est
|
||||
gérée en dur par CrisisGuard, hors LLM). Les fiches orientent vers un professionnel
|
||||
quand c'est pertinent.
|
||||
|
||||
**À curer/valider par le clinicien (Damien) avant activation patient.**
|
||||
|
||||
En prod il est distribué via le catalogue Nextcloud et déposé sous
|
||||
`.../kazeia/rag_corpus/` ; l'app l'ingère au 1er démarrage si la base RAG est vide
|
||||
(ou via l'intent `rag_ingest`). Ce dossier `docs/rag_corpus_seed/` est la source
|
||||
canonique (versionnée) ; `kazeia-dist/rag_corpus/` n'en est que la copie publiée.
|
||||
|
||||
Modèle d'embedding : `multilingual-e5-small` (384-dim, préfixes query:/passage:).
|
||||
|
|
@ -1 +0,0 @@
|
|||
Technique d'ancrage 5-4-3-2-1. Lorsqu'une émotion devient envahissante ou en cas d'attaque de panique, ramener son attention au présent aide à reprendre pied. Nommez cinq choses que vous voyez autour de vous, quatre choses que vous pouvez toucher, trois sons que vous entendez, deux odeurs que vous percevez, et une chose que vous pouvez goûter. Cet exercice d'ancrage sensoriel interrompt le cycle des pensées anxieuses en reconnectant le corps à l'environnement immédiat.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Anxiété sociale et évitement. La peur du regard ou du jugement des autres pousse à éviter les situations sociales : refuser une invitation, se taire en groupe, fuir les regards. L'évitement soulage sur le moment mais renforce la peur à long terme — il confirme au cerveau que la situation était dangereuse. Le chemin inverse est l'exposition progressive : dresser une liste de situations classées de la moins à la plus difficile, et s'exposer régulièrement à la première marche jusqu'à ce qu'elle devienne gérable, avant de passer à la suivante. Rester dans la situation assez longtemps pour sentir l'anxiété redescendre d'elle-même. Après coup, noter ce qui s'est réellement passé plutôt que ce qu'on craignait : l'écart entre les deux est l'apprentissage. Les autres remarquent beaucoup moins nos signes de gêne qu'on ne l'imagine.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Auto-compassion. L'auto-compassion consiste à se traiter soi-même avec la même bienveillance qu'on offrirait à un ami en difficulté. Elle repose sur trois appuis : la gentillesse envers soi plutôt que le jugement ; reconnaître que souffrir, échouer, douter font partie de l'expérience humaine commune — on n'est pas seul ni anormal ; et observer sa douleur avec un peu de recul, sans s'y noyer ni la nier. Exercice : dans un moment difficile, poser une main sur le cœur et se dire « c'est un moment douloureux ; d'autres vivent cela aussi ; puis-je être doux avec moi-même ? ». L'auto-compassion n'est pas de la complaisance : les recherches montrent qu'elle augmente la motivation et la capacité à rebondir après un échec.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Gérer la colère. La colère est une émotion normale qui signale qu'une limite ou une valeur a été heurtée. Elle devient un problème quand elle explose ou qu'elle est ravalée systématiquement. Au moment de la montée : repérer les signes physiques (chaleur, mâchoires serrées, voix qui monte) et s'accorder un temps de pause — quitter la pièce quelques minutes, respirer lentement, marcher. Décider de revenir sur le sujet à froid n'est pas fuir : c'est se donner les moyens d'être entendu. À froid, exprimer le besoin derrière la colère avec des messages en « je » : « je me suis senti ignoré quand… j'aurais besoin que… » plutôt que des reproches en « tu ». Si la colère déborde régulièrement ou fait peur à l'entourage, en parler à un professionnel aide à en reprendre le contrôle.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Crise d'angoisse (attaque de panique). Une crise d'angoisse est une montée brutale de peur intense accompagnée de sensations physiques fortes : cœur qui s'emballe, souffle court, vertiges, tremblements, impression d'étouffer ou de perdre le contrôle. Ces sensations sont très impressionnantes mais ne sont pas dangereuses : une crise atteint son pic en quelques minutes puis redescend toujours, même sans rien faire. Lutter contre la crise ou fuir la situation tend à renforcer la peur des prochaines crises. Ce qui aide : reconnaître « c'est une crise d'angoisse, elle va passer », ralentir l'expiration, s'ancrer dans le présent par les cinq sens, et laisser la vague monter puis redescendre sans la combattre. Si les crises se répètent, en parler à un professionnel de santé permet de les traiter efficacement.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Demander de l'aide professionnelle. Consulter un médecin, un psychologue ou un psychothérapeute n'est pas un signe de faiblesse : c'est une démarche active de soin, comme consulter pour une douleur physique persistante. Quelques repères qui indiquent qu'un accompagnement aiderait : une souffrance qui dure depuis plusieurs semaines, un retentissement sur le sommeil, l'appétit, le travail ou les relations, un isolement qui s'installe, ou le sentiment de tourner en rond malgré ses efforts. Le médecin traitant est souvent la porte d'entrée la plus simple : il connaît le parcours et peut orienter. Préparer la consultation en notant ses difficultés concrètes aide à ne rien oublier. Un suivi psychologique demande parfois d'essayer plus d'un praticien avant de trouver la bonne personne : c'est normal, et cela vaut l'effort.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Deuil et perte. Le deuil ne suit pas un parcours linéaire avec des étapes fixes : il avance par vagues. Tristesse, colère, culpabilité, soulagement, moments d'apaisement puis rechutes — toutes ces réactions sont normales, parfois le même jour. Il n'y a pas de durée « correcte » ni de bonne manière de faire son deuil. Ce qui aide : s'autoriser à ressentir sans se juger, parler du défunt et des souvenirs, maintenir un minimum de routine (repas, sommeil, sorties), accepter le soutien des proches même brièvement. Les dates anniversaires ravivent souvent la douleur : c'est attendu, pas une rechute. Si, après plusieurs mois, la douleur reste aussi envahissante qu'au début et empêche de vivre, un accompagnement spécialisé du deuil peut apporter un vrai soulagement.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Nommer ses émotions. Mettre des mots précis sur ce que l'on ressent — tristesse, colère, honte, peur, déception, frustration — réduit en soi l'intensité de l'émotion : le cerveau traite mieux ce qu'il peut nommer. Une émotion est un signal, pas un ennemi : la tristesse signale une perte, la colère une limite franchie, la peur un danger perçu. Chercher à supprimer une émotion la renforce souvent ; l'accueillir comme une vague qui monte puis redescend lui permet de passer. Exercice simple : trois fois par jour, s'arrêter et compléter « là, maintenant, je ressens… parce que… ». Avec la pratique, on distingue mieux les nuances, et on choisit mieux comment y répondre plutôt que de réagir à chaud.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Estime de soi et autocritique. Une voix intérieure dure (« je suis nul », « je n'y arriverai jamais ») abîme l'estime de soi et coupe l'élan. Cette voix se présente comme lucide, mais elle est partiale : elle retient les échecs et efface les réussites. Pour rééquilibrer : noter chaque jour une chose faite correctement, même petite, et une qualité dont on a fait preuve. Se juger sur des actions précises (« j'ai raté ce dossier ») plutôt que sur sa personne entière (« je suis un raté »). Remplacer l'exigence de perfection par un objectif de progrès. L'estime de soi se reconstruit par l'accumulation de petites preuves concrètes, pas par la volonté de penser positif.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Activation comportementale et humeur basse. Quand le moral est bas, l'envie de faire des choses diminue, ce qui réduit encore les occasions de ressentir du plaisir : un cercle vicieux s'installe. L'activation comportementale consiste à programmer de petites activités agréables ou valorisantes, même sans motivation au départ. Commencez petit : une courte marche, un appel à un proche, une tâche simple menée à son terme. L'action précède souvent la motivation, et non l'inverse. Noter ce qui a procuré un peu de satisfaction aide à repérer ce qui fait du bien.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Pensées automatiques et recul. Face à une situation, des pensées surgissent d'elles-mêmes, rapides et souvent négatives : « je vais échouer », « personne ne m'apprécie », « c'est ma faute ». Ces pensées automatiques semblent vraies sur le moment, mais ce sont des interprétations, pas des faits. Quelques pièges fréquents : tout voir en noir ou blanc, généraliser à partir d'un seul événement (« toujours », « jamais »), lire dans les pensées des autres, prédire le pire. Pour prendre du recul : noter la pensée telle quelle, puis se demander « quels faits l'appuient ? quels faits la contredisent ? que dirais-je à un ami qui penserait cela ? ». Formuler ensuite une pensée plus juste et nuancée, même imparfaite, suffit souvent à faire baisser l'émotion d'un cran.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Pleine conscience au quotidien. La pleine conscience consiste à porter intentionnellement son attention sur le moment présent, sans jugement. Elle s'entraîne par de courts exercices : observer sa respiration pendant deux minutes, et chaque fois que l'esprit part — c'est normal et attendu — le ramener doucement au souffle, sans se reprocher la distraction. Ramener l'attention est l'exercice, pas un échec. On peut aussi pratiquer en marchant (sentir les appuis des pieds), en mangeant (goûts, textures), en se lavant les mains (l'eau, la température). Avec la régularité, on apprend à observer ses pensées et émotions comme des événements passagers plutôt que de se laisser embarquer par eux. Mieux vaut deux minutes chaque jour qu'une longue séance occasionnelle.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Relaxation musculaire progressive. L'anxiété et le stress s'accompagnent de tensions musculaires souvent inaperçues : épaules remontées, mâchoires serrées, ventre noué. La relaxation musculaire progressive consiste à contracter volontairement un groupe musculaire pendant cinq secondes, puis à le relâcher d'un coup en portant attention à la détente pendant une dizaine de secondes. Parcourir le corps dans l'ordre : mains et avant-bras, bras, épaules, visage, nuque, ventre, jambes, pieds. Le contraste contraction-relâchement apprend au corps à reconnaître puis à relâcher la tension. Dix à quinze minutes, assis ou allongé, idéalement chaque jour au début. Pratiquée le soir, elle facilite aussi l'endormissement.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Respiration et apaisement de l'anxiété. La respiration lente et profonde active le système nerveux parasympathique et réduit la tension physique. Un exercice simple : inspirez doucement par le nez pendant quatre secondes, puis expirez lentement par la bouche pendant six secondes. L'expiration plus longue que l'inspiration est ce qui calme le rythme cardiaque. Répétez ce cycle pendant quelques minutes, en posant une main sur le ventre pour sentir le souffle. Cet exercice peut se pratiquer discrètement, à tout moment d'une montée d'angoisse.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Rumination mentale. Ruminer, c'est tourner en boucle sur des pensées négatives sans parvenir à une solution. Cela amplifie la détresse sans rien résoudre. Pour s'en dégager, il peut aider de fixer un court moment dédié aux soucis dans la journée, puis de reporter les pensées qui surgissent en dehors de ce créneau. Distinguer ce qui est sous votre contrôle de ce qui ne l'est pas oriente l'énergie vers l'action possible. Prendre du recul en observant la pensée comme un événement mental passager, plutôt que comme une vérité, réduit son emprise.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Sentiment de solitude. Se sentir seul est une expérience douloureuse et fréquente, qui ne reflète pas une valeur personnelle. Le lien social, même bref et modeste, nourrit le bien-être : un message, un sourire échangé, une courte conversation comptent. Reprendre contact progressivement, à son rythme, vaut mieux que d'attendre de se sentir prêt. Les activités partagées autour d'un intérêt commun créent du lien sans la pression de la conversation directe. Tenir un journal de trois moments positifs par jour aide à rééquilibrer l'attention souvent captée par le manque.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Hygiène du sommeil. Pour favoriser l'endormissement, il est utile de se coucher et se lever à des horaires réguliers, même le week-end. Évitez les écrans lumineux dans l'heure qui précède le coucher, car la lumière bleue retarde la sécrétion de mélatonine. La chambre doit rester fraîche, sombre et silencieuse. La caféine et l'alcool perturbent le sommeil profond : limitez-les en fin de journée. Si le sommeil ne vient pas après vingt minutes, mieux vaut se lever et faire une activité calme plutôt que de rester au lit à ruminer.
|
||||
|
|
@ -1 +0,0 @@
|
|||
Faire face au stress. Le stress est la réponse du corps à une demande perçue comme dépassant nos ressources. Face à une situation stressante, deux voies se complètent. Quand on peut agir sur le problème : le découper en petites étapes concrètes, traiter la première, et seulement elle. Quand on ne peut pas agir (attente, incertitude, décision d'autrui) : prendre soin de l'émotion — respiration lente, activité physique, parler à quelqu'un, se changer les idées sans culpabiliser. Demander « est-ce sous mon contrôle ? » oriente vers la bonne voie. Le stress chronique s'entretient par le manque de sommeil, l'excès de caféine et l'isolement : protéger ces trois piliers est déjà une stratégie anti-stress. Des pauses brèves mais régulières préviennent mieux l'épuisement qu'un long repos tardif.
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
diff --git a/backends/qualcomm/CMakeLists.txt b/backends/qualcomm/CMakeLists.txt
|
||||
index e93731e..4951e1d 100644
|
||||
--- a/backends/qualcomm/CMakeLists.txt
|
||||
+++ b/backends/qualcomm/CMakeLists.txt
|
||||
@@ -308,8 +308,8 @@ if(${CMAKE_SYSTEM_PROCESSOR} MATCHES Hexagon)
|
||||
)
|
||||
endif()
|
||||
|
||||
-# QNN pybind
|
||||
-if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64")
|
||||
+# QNN pybind — host Python bindings, not for Android cross-compile
|
||||
+if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "x86_64" AND NOT ANDROID)
|
||||
add_subdirectory(
|
||||
${EXECUTORCH_SOURCE_DIR}/third-party/pybind11
|
||||
${CMAKE_CURRENT_BINARY_DIR}/pybind11
|
||||
diff --git a/extension/android/jni/jni_layer_llama.cpp b/extension/android/jni/jni_layer_llama.cpp
|
||||
index 45f2414..ae3d79f 100644
|
||||
--- a/extension/android/jni/jni_layer_llama.cpp
|
||||
+++ b/extension/android/jni/jni_layer_llama.cpp
|
||||
@@ -171,14 +171,44 @@ class ExecuTorchLlmJni : public facebook::jni::HybridClass<ExecuTorchLlmJni> {
|
||||
model_path->toStdString().c_str(),
|
||||
data_files_vector,
|
||||
executorch::extension::Module::LoadMode::MmapUseMlockIgnoreErrors);
|
||||
- std::string decoder_model = "llama3"; // use llama3 for now
|
||||
- runner_ = std::make_unique<example::Runner<uint16_t>>( // QNN runner
|
||||
- std::move(module),
|
||||
- decoder_model.c_str(),
|
||||
- model_path->toStdString().c_str(),
|
||||
- tokenizer_path->toStdString().c_str(),
|
||||
- "",
|
||||
- "");
|
||||
+ std::string decoder_model = "qwen3"; // Kazeia: our .pte was exported with --decoder_model qwen3-4b
|
||||
+
|
||||
+ // Mirror qnn_llama_runner.cpp main(): pick the Runner<T> template based
|
||||
+ // on the model's get_kv_io_bit_width metadata. The 16-bit KV I/O models
|
||||
+ // were introduced after the 8-bit ones, and using the wrong T treats
|
||||
+ // KV-cache bytes as the wrong width → garbage logits → gibberish output.
|
||||
+ example::KvBitWidth kv_bitwidth = example::KvBitWidth::kWidth8;
|
||||
+ if (module->method_names()->count("get_kv_io_bit_width") > 0) {
|
||||
+ kv_bitwidth = static_cast<example::KvBitWidth>(
|
||||
+ module->get("get_kv_io_bit_width").get().toScalar().to<int64_t>());
|
||||
+ }
|
||||
+ // Auto-detect eval_mode: kv-only (0) if the .pte only carries
|
||||
+ // kv_forward, hybrid (1) if it also has prefill_forward (which lets the
|
||||
+ // runner batch the prompt prefill — TTFT drops from ~52 ms/token to
|
||||
+ // sub-ms after the one-shot prefill graph). Same JNI binary works with
|
||||
+ // both export modes, no code change needed when the .pte is upgraded.
|
||||
+ int eval_mode = 0;
|
||||
+ if (module->method_names()->count("prefill_forward") > 0) {
|
||||
+ eval_mode = 1; // EvalMode::kHybrid
|
||||
+ }
|
||||
+ auto make_runner = [&](auto sample) -> std::unique_ptr<llm::IRunner> {
|
||||
+ using T = decltype(sample);
|
||||
+ return std::make_unique<example::Runner<T>>(
|
||||
+ std::move(module),
|
||||
+ decoder_model.c_str(),
|
||||
+ model_path->toStdString().c_str(),
|
||||
+ tokenizer_path->toStdString().c_str(),
|
||||
+ /* performance_output_path */ "",
|
||||
+ /* dump_logits_path */ "",
|
||||
+ /* temperature */ 0.0f, // greedy
|
||||
+ eval_mode,
|
||||
+ /* shared_buffer */ true);
|
||||
+ };
|
||||
+ if (kv_bitwidth == example::KvBitWidth::kWidth16) {
|
||||
+ runner_ = make_runner(uint16_t{0});
|
||||
+ } else {
|
||||
+ runner_ = make_runner(uint8_t{0});
|
||||
+ }
|
||||
model_type_category_ = MODEL_TYPE_CATEGORY_LLM;
|
||||
#endif
|
||||
#if defined(EXECUTORCH_BUILD_MEDIATEK)
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
diff --git a/examples/qualcomm/oss_scripts/llama/__init__.py b/examples/qualcomm/oss_scripts/llama/__init__.py
|
||||
index 963db6e..9ccfdd0 100644
|
||||
index 963db6e..953dc4c 100644
|
||||
--- a/examples/qualcomm/oss_scripts/llama/__init__.py
|
||||
+++ b/examples/qualcomm/oss_scripts/llama/__init__.py
|
||||
@@ -25,9 +25,14 @@ from executorch.examples.models.granite import (
|
||||
|
|
@ -20,7 +20,7 @@ index 963db6e..9ccfdd0 100644
|
|||
from executorch.examples.models.qwen2_5 import (
|
||||
convert_weights as convert_qwen2_5_weights,
|
||||
)
|
||||
@@ -479,6 +484,37 @@ class Qwen3_1_7B(LLMModelConfig):
|
||||
@@ -479,6 +484,34 @@ class Qwen3_1_7B(LLMModelConfig):
|
||||
quant_recipe = Qwen3_1_7BQuantRecipe
|
||||
|
||||
|
||||
|
|
@ -40,13 +40,10 @@ index 963db6e..9ccfdd0 100644
|
|||
+ convert_weights = convert_qwen3_weights
|
||||
+ transform_weight = False
|
||||
+ instruct_model = True
|
||||
+ # num_sharding=1 for hybrid mode: sharding=2 produces a multi-context
|
||||
+ # .pte (2 graphs × 2 shards = 4 contexts) that the LlmModule load path
|
||||
+ # can't restore (error 5010 "Context group 1 does not exist"). With
|
||||
+ # sharding=1 the hybrid export needs ~46 GB RAM peak — the 192 GB swap
|
||||
+ # on /swapfile handles this; compile takes ~80 min wall but completes
|
||||
+ # cleanly. Single-context .pte loads fine through the JNI runner.
|
||||
+ num_sharding = 1
|
||||
+ # Bumped to 2 to halve peak host RAM during QNN compile (4B at sharding=1
|
||||
+ # OOMed on a 62 GB box, peak anon-rss 46 GB). At sharding=2 each shard
|
||||
+ # compile fits comfortably; runner stitches them at load time.
|
||||
+ num_sharding = 2
|
||||
+ masked_softmax = True
|
||||
+ seq_mse_candidates = 0
|
||||
+ r1 = False
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
// MÊME keystore release que l'app patient (permet à terme la permission de
|
||||
// signature sur le ContentProvider). Voir docs/RELEASE_SIGNING.md.
|
||||
val releaseKeystoreProps = Properties().apply {
|
||||
val f = file("/opt/Kazeia/keystore/credentials.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
val hasReleaseKeystore = releaseKeystoreProps.getProperty("storeFile")
|
||||
?.let { file(it).exists() } == true
|
||||
|
||||
android {
|
||||
namespace = "com.kazeia.admin"
|
||||
compileSdk = 36
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
storeFile = file(releaseKeystoreProps.getProperty("storeFile"))
|
||||
storePassword = releaseKeystoreProps.getProperty("storePassword")
|
||||
keyAlias = releaseKeystoreProps.getProperty("keyAlias")
|
||||
keyPassword = releaseKeystoreProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kazeia.admin"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-mvp"
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn("⚠ keystore release absent (/opt/Kazeia/keystore/) — release signé en DEBUG. Voir docs/RELEASE_SIGNING.md")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
|
||||
implementation(composeBom)
|
||||
|
||||
// Compose
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
|
||||
|
||||
// Core
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
|
||||
|
||||
// JSON (manifest serialization)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
|
||||
// DataStore (PIN + UI settings)
|
||||
implementation("androidx.datastore:datastore-preferences:1.1.1")
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Default rules — minify disabled in MVP, keep file for future
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||
|
||||
<queries>
|
||||
<package android:name="com.kazeia" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="Kazeia Admin"
|
||||
android:icon="@mipmap/ic_launcher_admin"
|
||||
android:roundIcon="@mipmap/ic_launcher_admin"
|
||||
android:theme="@style/Theme.KazeiaAdmin"
|
||||
android:supportsRtl="true"
|
||||
android:allowBackup="false">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="unspecified"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
package com.kazeia.admin
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.NavigationRail
|
||||
import androidx.compose.material3.NavigationRailItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.kazeia.admin.ui.components.StubScreen
|
||||
import com.kazeia.admin.ui.history.HistoryScreen
|
||||
import com.kazeia.admin.ui.login.LoginScreen
|
||||
import com.kazeia.admin.ui.navigation.AdminDestination
|
||||
import com.kazeia.admin.ui.profiles.ProfilesScreen
|
||||
import com.kazeia.admin.ui.prompts.PromptsScreen
|
||||
import com.kazeia.admin.ui.system.SystemScreen
|
||||
import com.kazeia.admin.ui.telemetry.TelemetryScreen
|
||||
import com.kazeia.admin.ui.theme.KazeiaAdminTheme
|
||||
import com.kazeia.admin.ui.voices.RecordVoiceScreen
|
||||
import com.kazeia.admin.ui.voices.VoicesScreen
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
KazeiaAdminTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
AdminApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdminApp() {
|
||||
var authenticated by remember { mutableStateOf(false) }
|
||||
if (!authenticated) {
|
||||
LoginScreen(onAuthenticated = { authenticated = true })
|
||||
} else {
|
||||
MainShell()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainShell() {
|
||||
val nav = rememberNavController()
|
||||
val current by nav.currentBackStackEntryAsState()
|
||||
val currentRoute = current?.destination?.route
|
||||
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Show the rail only on top-level destinations (hide it during record flow)
|
||||
val showRail = AdminDestination.entries.any { it.route == currentRoute }
|
||||
if (showRail) {
|
||||
AdminNavigationRail(currentRoute = currentRoute, nav = nav)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(navController = nav, startDestination = AdminDestination.Voices.route) {
|
||||
composable(AdminDestination.Voices.route) {
|
||||
VoicesScreen(onStartRecording = { nav.navigate("voices/new") })
|
||||
}
|
||||
composable("voices/new") {
|
||||
RecordVoiceScreen(
|
||||
onCancel = { nav.popBackStack() },
|
||||
onSaved = { nav.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(AdminDestination.Profiles.route) {
|
||||
ProfilesScreen()
|
||||
}
|
||||
composable(AdminDestination.Prompts.route) {
|
||||
PromptsScreen()
|
||||
}
|
||||
composable(AdminDestination.Rag.route) {
|
||||
com.kazeia.admin.ui.rag.RagScreen()
|
||||
}
|
||||
composable(AdminDestination.History.route) {
|
||||
HistoryScreen()
|
||||
}
|
||||
composable(AdminDestination.Telemetry.route) {
|
||||
TelemetryScreen()
|
||||
}
|
||||
composable(AdminDestination.System.route) {
|
||||
SystemScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AdminNavigationRail(currentRoute: String?, nav: NavHostController) {
|
||||
NavigationRail(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
AdminDestination.entries.forEach { dest ->
|
||||
NavigationRailItem(
|
||||
selected = currentRoute == dest.route,
|
||||
onClick = {
|
||||
if (currentRoute != dest.route) {
|
||||
nav.navigate(dest.route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(AdminDestination.Voices.route) { saveState = true }
|
||||
restoreState = true
|
||||
}
|
||||
}
|
||||
},
|
||||
icon = { Icon(dest.icon, contentDescription = null) },
|
||||
label = { Text(stringResource(dest.labelRes)) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package com.kazeia.admin.data.mock
|
||||
|
||||
import com.kazeia.admin.data.model.Profile
|
||||
import com.kazeia.admin.data.model.Voice
|
||||
import com.kazeia.admin.data.model.VoiceState
|
||||
|
||||
/**
|
||||
* Données mock pour iterer sur l'UI sans dépendance au Kazeia patient.
|
||||
* Sera remplacé par un VoiceRepository réel quand la control surface
|
||||
* (ContentProvider + fichiers) sera en place (Phase 1).
|
||||
*/
|
||||
object MockData {
|
||||
val voices: List<Voice> = listOf(
|
||||
Voice("damien", "Damien", VoiceState.READY, isBuiltIn = true,
|
||||
durationSeconds = 12.1f, speakerNorm = 10.42f),
|
||||
Voice("elodie", "Elodie", VoiceState.READY, isBuiltIn = true,
|
||||
durationSeconds = 11.4f, speakerNorm = 10.18f),
|
||||
Voice("zelda", "Zelda", VoiceState.READY, isBuiltIn = true,
|
||||
durationSeconds = 13.0f, speakerNorm = 9.95f),
|
||||
Voice("otis", "Otis", VoiceState.READY, isBuiltIn = true,
|
||||
durationSeconds = 12.7f, speakerNorm = 10.31f),
|
||||
Voice("amir_2026-05-14", "Amir", VoiceState.ERROR,
|
||||
durationSeconds = 11.2f, speakerNorm = 4.2f,
|
||||
errorMessage = "Norme speaker hors plage (4.2 < 8). Audio probablement bruité."),
|
||||
Voice("bertrand_2026-05-13", "Bertrand", VoiceState.RECORDED,
|
||||
durationSeconds = 10.5f,
|
||||
errorMessage = null),
|
||||
Voice("claire_2026-05-12", "Claire", VoiceState.PROCESSING,
|
||||
durationSeconds = 12.0f)
|
||||
)
|
||||
|
||||
val profiles: List<Profile> = listOf(
|
||||
Profile("patient_001", "Patient 1", voiceId = "damien", isActive = true),
|
||||
Profile("patient_002", "Patient 2", voiceId = "elodie"),
|
||||
Profile("patient_003", "Patient 3", voiceId = "zelda")
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package com.kazeia.admin.data.model
|
||||
|
||||
/**
|
||||
* Profil patient : identité + paramètres personnalisés.
|
||||
* Stocké dans Room DB côté Kazeia patient, lu via ContentProvider.
|
||||
*/
|
||||
data class Profile(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val voiceId: String? = null,
|
||||
val systemPromptOverride: String? = null,
|
||||
val ragProfileId: String? = null,
|
||||
val isActive: Boolean = false,
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
package com.kazeia.admin.data.model
|
||||
|
||||
/**
|
||||
* Snapshot complet de l'état Kazeia : état process + KPI pipeline
|
||||
* récents + séries temporelles mémoire + événements + crashes.
|
||||
*
|
||||
* Pour l'instant alimenté par MockTelemetry. À terme alimenté par
|
||||
* le ContentProvider de Kazeia patient (Phase 1).
|
||||
*/
|
||||
data class TelemetrySnapshot(
|
||||
val kazeiaState: KazeiaProcessState,
|
||||
val pipeline: PipelineKpi,
|
||||
val memorySeries: MemorySeries,
|
||||
val recentTurns: List<TurnEvent>,
|
||||
val recentCrashes: List<CrashEvent>,
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class KazeiaProcessState(
|
||||
val running: Boolean,
|
||||
val pid: Int?,
|
||||
val uptimeSeconds: Long,
|
||||
val cpuPercent: Float,
|
||||
val pssMb: Int,
|
||||
val ionMb: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* KPI agrégés sur les N derniers tours conversation
|
||||
* (typiquement N=10). Chaque série est dans l'ordre chronologique :
|
||||
* série[N-1] = tour le plus récent.
|
||||
*/
|
||||
data class PipelineKpi(
|
||||
val ttftMs: List<Int>, // Time-to-first-token LLM, ms
|
||||
val llmTokensPerSec: List<Float>,
|
||||
val ttsRtf: List<Float>, // < 1 = temps réel OK
|
||||
val sttRtf: List<Float>,
|
||||
val totalCycleMs: List<Int> // STT + LLM + TTS + playback
|
||||
) {
|
||||
fun avgTtft(): Int = if (ttftMs.isEmpty()) 0 else ttftMs.average().toInt()
|
||||
fun avgTps(): Float = if (llmTokensPerSec.isEmpty()) 0f else llmTokensPerSec.average().toFloat()
|
||||
fun avgTtsRtf(): Float = if (ttsRtf.isEmpty()) 0f else ttsRtf.average().toFloat()
|
||||
fun avgSttRtf(): Float = if (sttRtf.isEmpty()) 0f else sttRtf.average().toFloat()
|
||||
fun avgTotalSec(): Float = if (totalCycleMs.isEmpty()) 0f else totalCycleMs.average().toFloat() / 1000f
|
||||
}
|
||||
|
||||
/** Séries temporelles mémoire : 60 derniers échantillons (1 par seconde) */
|
||||
data class MemorySeries(
|
||||
val pssMb: List<Int>,
|
||||
val ionMb: List<Int>,
|
||||
val systemAvailMb: List<Int>
|
||||
)
|
||||
|
||||
data class TurnEvent(
|
||||
val timestamp: Long,
|
||||
val turnNumber: Int,
|
||||
val llmTokensPerSec: Float,
|
||||
val tokensGenerated: Int,
|
||||
val totalMs: Int,
|
||||
val ok: Boolean
|
||||
)
|
||||
|
||||
data class CrashEvent(
|
||||
val timestamp: Long,
|
||||
val signal: String, // ex: "SIGABRT", "SIGSEGV"
|
||||
val component: String, // ex: "LLM (prefill)", "TTS (talker)"
|
||||
val message: String?
|
||||
)
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package com.kazeia.admin.data.model
|
||||
|
||||
/**
|
||||
* Une voix Kazeia : référence audio + (optionnellement) embeddings extraits côté PC.
|
||||
*
|
||||
* État machine :
|
||||
* recorded — WAV présent, embeddings absents → non utilisable TTS
|
||||
* processing — PC tool extrait actuellement
|
||||
* ready — embeddings présents, voix opérationnelle
|
||||
* error — extraction échouée (norm hors plage, audio bruité…)
|
||||
*
|
||||
* Cf project_kazeia_voice_workflow.md pour le workflow complet.
|
||||
*/
|
||||
enum class VoiceState {
|
||||
RECORDED, PROCESSING, READY, ERROR
|
||||
}
|
||||
|
||||
data class Voice(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val state: VoiceState,
|
||||
val isBuiltIn: Boolean = false,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val durationSeconds: Float? = null,
|
||||
val speakerNorm: Float? = null,
|
||||
val errorMessage: String? = null,
|
||||
val deployedOnDevices: List<String> = emptyList()
|
||||
)
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient
|
||||
|
||||
/**
|
||||
* Thin repository qui expose la config Kazeia + la liste des modèles
|
||||
* disponibles depuis le ContentProvider. Les écrans Prompts et Système
|
||||
* y délèguent leur I/O.
|
||||
*/
|
||||
class ConfigRepository(context: Context) {
|
||||
|
||||
private val client = KazeiaConfigClient(context.contentResolver)
|
||||
|
||||
fun config(): KazeiaConfigClient.Config? = client.queryConfig()
|
||||
|
||||
fun models(): List<KazeiaConfigClient.ModelInfo> = client.queryModels()
|
||||
|
||||
fun updateCascade(enabled: Boolean): Boolean =
|
||||
client.updateConfig(cascadeEnabled = enabled)
|
||||
|
||||
fun updateTts(enabled: Boolean): Boolean =
|
||||
client.updateConfig(ttsEnabled = enabled)
|
||||
|
||||
fun updateSpeakerModel(modelId: String): Boolean =
|
||||
client.updateConfig(speakerModelId = modelId)
|
||||
|
||||
fun updateThinkerModel(modelId: String): Boolean =
|
||||
client.updateConfig(thinkerModelId = modelId)
|
||||
|
||||
fun updateSpeakerPrompt(prompt: String): Boolean =
|
||||
client.updateConfig(speakerSystemPrompt = prompt)
|
||||
|
||||
fun updateThinkerPrompt(prompt: String): Boolean =
|
||||
client.updateConfig(thinkerSystemPrompt = prompt)
|
||||
|
||||
fun resetSpeakerPromptToDefault(): Boolean =
|
||||
client.updateConfig(speakerSystemPrompt = DEFAULT_SPEAKER_PROMPT)
|
||||
|
||||
fun resetThinkerPromptToDefault(): Boolean =
|
||||
client.updateConfig(thinkerSystemPrompt = DEFAULT_THINKER_PROMPT)
|
||||
|
||||
companion object {
|
||||
// Doivent rester strictement synchronisés avec ConfigStore côté Kazeia.
|
||||
// Si on les divergent, le "réinitialiser" produira un prompt différent
|
||||
// du fallback Kazeia patient.
|
||||
const val DEFAULT_SPEAKER_PROMPT =
|
||||
"Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
|
||||
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle " +
|
||||
"simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. " +
|
||||
"Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : " +
|
||||
"ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de " +
|
||||
"médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à " +
|
||||
"en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou " +
|
||||
"simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes " +
|
||||
"échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
|
||||
|
||||
const val DEFAULT_THINKER_PROMPT =
|
||||
"Tu es un psychologue clinicien experimente qui ecoute attentivement ton " +
|
||||
"patient. Avant de repondre, tu prepares une analyse silencieuse pour " +
|
||||
"orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict " +
|
||||
"suivant :\n" +
|
||||
"- emotion : <emotion principale ressentie, 5 mots max>\n" +
|
||||
"- besoin : <besoin sous-jacent du patient, 5 mots max>\n" +
|
||||
"- approche : <posture therapeutique adaptee, 5 mots max>\n" +
|
||||
"- axe : <point cle a valider ou explorer, 5 mots max>\n" +
|
||||
"Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.source.KazeiaConversationsClient
|
||||
import com.kazeia.admin.data.source.KazeiaProfilesClient
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Repository de l'historique conversation. Combine les sessions du
|
||||
* ContentProvider Kazeia avec les noms de profils, et gère l'export JSON
|
||||
* vers le stockage externe de l'admin (récupérable par câble depuis le PC,
|
||||
* même workflow que les voix).
|
||||
*/
|
||||
class ConversationsRepository(private val context: Context) {
|
||||
|
||||
private val client = KazeiaConversationsClient(context.contentResolver)
|
||||
private val profilesClient = KazeiaProfilesClient(context.contentResolver)
|
||||
|
||||
data class Session(
|
||||
val profileId: String,
|
||||
val profileName: String,
|
||||
val sessionId: String,
|
||||
val startedAt: Long,
|
||||
val endedAt: Long,
|
||||
val turnCount: Int
|
||||
)
|
||||
|
||||
/** Sessions enrichies du nom de profil, optionnellement filtrées. */
|
||||
fun sessions(profileId: String? = null): List<Session> {
|
||||
val names = profilesClient.queryAll().associate { it.id to it.displayName }
|
||||
return client.sessions(profileId).map { s ->
|
||||
Session(
|
||||
profileId = s.profileId,
|
||||
profileName = names[s.profileId] ?: s.profileId,
|
||||
sessionId = s.sessionId,
|
||||
startedAt = s.startedAt,
|
||||
endedAt = s.endedAt,
|
||||
turnCount = s.turnCount
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Profils ayant au moins une session — pour le filtre. */
|
||||
fun profilesWithHistory(): List<Pair<String, String>> {
|
||||
val names = profilesClient.queryAll().associate { it.id to it.displayName }
|
||||
return client.sessions(null)
|
||||
.map { it.profileId }
|
||||
.distinct()
|
||||
.map { id -> id to (names[id] ?: id) }
|
||||
}
|
||||
|
||||
fun turns(sessionId: String): List<KazeiaConversationsClient.TurnRow> =
|
||||
client.turns(sessionId)
|
||||
|
||||
fun deleteSession(sessionId: String): Boolean = client.deleteSession(sessionId)
|
||||
|
||||
fun deleteProfileHistory(profileId: String): Boolean =
|
||||
client.deleteProfileHistory(profileId)
|
||||
|
||||
/**
|
||||
* Exporte une session en JSON dans `Android/data/com.kazeia.admin/files/
|
||||
* historique/`. Retourne le fichier créé, ou null en cas d'échec.
|
||||
*/
|
||||
fun exportSession(session: Session): File? = runCatching {
|
||||
val turns = client.turns(session.sessionId)
|
||||
val json = JSONObject().apply {
|
||||
put("profile_id", session.profileId)
|
||||
put("profile_name", session.profileName)
|
||||
put("session_id", session.sessionId)
|
||||
put("started_at", session.startedAt)
|
||||
put("ended_at", session.endedAt)
|
||||
put("turn_count", session.turnCount)
|
||||
put("exported_at", System.currentTimeMillis())
|
||||
put("turns", JSONArray().apply {
|
||||
turns.forEach { t ->
|
||||
put(JSONObject().apply {
|
||||
put("timestamp", t.timestamp)
|
||||
put("role", t.role)
|
||||
put("text", t.text)
|
||||
t.ttftMs?.let { put("ttft_ms", it) }
|
||||
t.totalMs?.let { put("total_ms", it) }
|
||||
t.voiceUsed?.let { put("voice_used", it) }
|
||||
t.modelUsed?.let { put("model_used", it) }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
val dir = File(context.getExternalFilesDir(null), "historique").apply { mkdirs() }
|
||||
val stamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(session.startedAt))
|
||||
val safeName = session.profileName.replace(Regex("[^A-Za-z0-9]"), "_")
|
||||
val file = File(dir, "${safeName}_$stamp.json")
|
||||
file.writeText(json.toString(2))
|
||||
file
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.source.KazeiaProfilesClient
|
||||
|
||||
/**
|
||||
* Thin repo qui sépare l'UI du ContentResolver. La création / modification
|
||||
* d'un profil passe par `update()` (le ContentProvider crée si l'id n'existe
|
||||
* pas encore — pattern upsert).
|
||||
*/
|
||||
class ProfilesRepository(context: Context) {
|
||||
|
||||
private val client = KazeiaProfilesClient(context.contentResolver)
|
||||
|
||||
fun list(): List<KazeiaProfilesClient.ProfileRow> = client.queryAll()
|
||||
|
||||
fun create(
|
||||
id: String,
|
||||
displayName: String,
|
||||
voiceId: String? = null,
|
||||
pin: String? = null,
|
||||
isDefault: Boolean = false,
|
||||
notes: String? = null
|
||||
): Boolean = client.upsert(
|
||||
id = id, displayName = displayName, voiceId = voiceId,
|
||||
pin = pin, notes = notes, isDefault = isDefault
|
||||
)
|
||||
|
||||
fun rename(id: String, newName: String): Boolean =
|
||||
client.upsert(id = id, displayName = newName)
|
||||
|
||||
fun setVoice(id: String, voiceId: String?): Boolean =
|
||||
client.upsert(id = id, voiceId = voiceId)
|
||||
|
||||
fun setPin(id: String, pin: String?): Boolean =
|
||||
client.upsert(id = id, pin = pin ?: "") // "" efface
|
||||
|
||||
fun setDefault(id: String): Boolean = client.upsert(id = id, isDefault = true)
|
||||
|
||||
fun delete(id: String): Boolean = client.delete(id)
|
||||
|
||||
fun activeProfileId(): String? = client.activeProfileId()
|
||||
|
||||
fun setActive(id: String?): Boolean = client.setActive(id)
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient
|
||||
import com.kazeia.admin.data.source.KazeiaRagClient
|
||||
|
||||
/**
|
||||
* Passerelle unique de l'écran RAG admin : documents (corpus), config RAG
|
||||
* (activation / seuil / top-k), statut du moteur, et test de récupération.
|
||||
*/
|
||||
class RagRepository(context: Context) {
|
||||
private val rag = KazeiaRagClient(context.contentResolver)
|
||||
private val config = KazeiaConfigClient(context.contentResolver)
|
||||
|
||||
// -- Documents --
|
||||
fun docs(): List<KazeiaRagClient.RagDoc>? = rag.queryDocs()
|
||||
fun docText(source: String): String? = rag.docText(source)
|
||||
fun save(source: String, text: String): Boolean = rag.upsertDoc(source, text)
|
||||
fun delete(source: String): Boolean = rag.deleteDoc(source)
|
||||
|
||||
// -- Statut + test --
|
||||
fun status(): KazeiaRagClient.RagStatus? = rag.status()
|
||||
fun test(query: String): List<KazeiaRagClient.ScoredHit> = rag.test(query)
|
||||
|
||||
// -- Config RAG --
|
||||
fun config(): KazeiaConfigClient.Config? = config.queryConfig()
|
||||
fun setEnabled(enabled: Boolean): Boolean = config.updateConfig(ragEnabled = enabled)
|
||||
fun setThreshold(t: Float): Boolean = config.updateConfig(ragThreshold = t)
|
||||
fun setTopK(k: Int): Boolean = config.updateConfig(ragTopK = k)
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.model.CrashEvent
|
||||
import com.kazeia.admin.data.model.KazeiaProcessState
|
||||
import com.kazeia.admin.data.model.MemorySeries
|
||||
import com.kazeia.admin.data.model.PipelineKpi
|
||||
import com.kazeia.admin.data.model.TelemetrySnapshot
|
||||
import com.kazeia.admin.data.model.TurnEvent
|
||||
import com.kazeia.admin.data.source.KazeiaTelemetryClient
|
||||
import com.kazeia.admin.data.source.SystemTelemetrySource
|
||||
|
||||
/**
|
||||
* Repository qui agrège les deux sources de telemetry :
|
||||
* - SystemTelemetrySource : `/proc/meminfo`, `/proc/stat` (toujours dispo)
|
||||
* - KazeiaTelemetryClient : ContentProvider de Kazeia patient (si tourne)
|
||||
*
|
||||
* Maintient une fenêtre glissante des 60 derniers échantillons mémoire
|
||||
* pour produire les sparklines. Pour les KPI pipeline, les valeurs viennent
|
||||
* directement des derniers tours collectés côté Kazeia.
|
||||
*/
|
||||
class TelemetryRepository(context: Context) {
|
||||
|
||||
private val system = SystemTelemetrySource()
|
||||
private val kazeia = KazeiaTelemetryClient(context.contentResolver)
|
||||
|
||||
private val pssWindow = ArrayDeque<Int>(60)
|
||||
private val ionWindow = ArrayDeque<Int>(60)
|
||||
private val availWindow = ArrayDeque<Int>(60)
|
||||
private val maxWindow = 60
|
||||
|
||||
/**
|
||||
* Indique si Kazeia est joignable (ContentProvider répond).
|
||||
* Évite l'affichage erroné quand le process n'est pas lancé.
|
||||
*/
|
||||
fun kazeiaAvailable(): Boolean = kazeia.queryState() != null
|
||||
|
||||
fun snapshot(): TelemetrySnapshot {
|
||||
val mem = system.readMemory()
|
||||
val cpuPct = system.readCpuPercent()
|
||||
val kState = kazeia.queryState()
|
||||
val kTurns = kazeia.queryTurns()
|
||||
val kCrashes = kazeia.queryCrashes()
|
||||
|
||||
// Push memory samples to sliding windows. PSS=0 if Kazeia not running.
|
||||
push(pssWindow, kState?.pssMb ?: 0)
|
||||
push(ionWindow, mem.ionTotalUsedMb)
|
||||
push(availWindow, mem.memAvailableMb)
|
||||
|
||||
val pipeline = if (kTurns.isNotEmpty()) {
|
||||
val recent = kTurns.take(10).reversed()
|
||||
PipelineKpi(
|
||||
ttftMs = recent.mapNotNull { it.llmTtftMs?.toInt() },
|
||||
llmTokensPerSec = recent.mapNotNull { it.tokensPerSecond() },
|
||||
ttsRtf = recent.mapNotNull { rtfTtsFor(it) },
|
||||
sttRtf = recent.mapNotNull { rtfSttFor(it) },
|
||||
totalCycleMs = recent.map { it.totalMs.toInt() }
|
||||
)
|
||||
} else {
|
||||
// Pas encore de tour observé — listes vides, l'UI affiche "—"
|
||||
PipelineKpi(emptyList(), emptyList(), emptyList(), emptyList(), emptyList())
|
||||
}
|
||||
|
||||
val turns: List<TurnEvent> = kTurns.mapIndexedNotNull { idx, t ->
|
||||
val tps = t.tokensPerSecond() ?: return@mapIndexedNotNull null
|
||||
TurnEvent(
|
||||
timestamp = t.timestamp,
|
||||
turnNumber = kTurns.size - idx,
|
||||
llmTokensPerSec = tps,
|
||||
tokensGenerated = t.llmTokens ?: 0,
|
||||
totalMs = t.totalMs.toInt(),
|
||||
ok = t.status == "ok"
|
||||
)
|
||||
}
|
||||
|
||||
val crashes: List<CrashEvent> = kCrashes.map {
|
||||
CrashEvent(
|
||||
timestamp = it.timestamp,
|
||||
signal = "—",
|
||||
component = it.component,
|
||||
message = it.message
|
||||
)
|
||||
}
|
||||
|
||||
return TelemetrySnapshot(
|
||||
kazeiaState = KazeiaProcessState(
|
||||
running = kState != null,
|
||||
pid = kState?.pid,
|
||||
uptimeSeconds = kState?.uptimeSeconds ?: 0,
|
||||
cpuPercent = cpuPct,
|
||||
pssMb = kState?.pssMb ?: 0,
|
||||
ionMb = mem.ionTotalUsedMb
|
||||
),
|
||||
pipeline = pipeline,
|
||||
memorySeries = MemorySeries(
|
||||
pssMb = pssWindow.toList(),
|
||||
ionMb = ionWindow.toList(),
|
||||
systemAvailMb = availWindow.toList()
|
||||
),
|
||||
recentTurns = turns,
|
||||
recentCrashes = crashes
|
||||
)
|
||||
}
|
||||
|
||||
private fun push(window: ArrayDeque<Int>, v: Int) {
|
||||
window.addLast(v)
|
||||
while (window.size > maxWindow) window.removeFirst()
|
||||
}
|
||||
|
||||
// TTS / STT RTF : pas exposés directement par Kazeia, on les estime
|
||||
// depuis les timings disponibles. À terme exposer dans le provider.
|
||||
private fun rtfTtsFor(t: KazeiaTelemetryClient.KazeiaTurn): Float? {
|
||||
val ttfa = t.timeToFirstAudioMs ?: return null
|
||||
val playback = t.audioPlaybackMs ?: return null
|
||||
// RTF approx : temps de synth utile / durée audio rendue
|
||||
if (playback <= 0) return null
|
||||
val synthMs = (ttfa - (t.llmGenMs ?: 0)).coerceAtLeast(0)
|
||||
return synthMs.toFloat() / playback.toFloat()
|
||||
}
|
||||
|
||||
private fun rtfSttFor(t: KazeiaTelemetryClient.KazeiaTurn): Float? {
|
||||
// Pour l'instant on n'a pas la durée d'audio d'entrée séparément :
|
||||
// sttMs / audio_in_ms. On expose null tant que le provider n'envoie
|
||||
// pas cette info.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
package com.kazeia.admin.data.repository
|
||||
|
||||
import android.content.Context
|
||||
import com.kazeia.admin.data.model.Voice
|
||||
import com.kazeia.admin.data.model.VoiceState
|
||||
import com.kazeia.admin.data.source.KazeiaVoicesClient
|
||||
import com.kazeia.admin.voice.VoiceStorage
|
||||
|
||||
/**
|
||||
* Repository des voix consommé par les écrans admin. Fusionne deux sources :
|
||||
* 1. **Voix Kazeia patient** (via ContentProvider) — celles que le LLM TTS
|
||||
* sait utiliser (.bin embeddings présents). State : ready / recorded /
|
||||
* error selon ce que le provider renvoie.
|
||||
* 2. **Voix enregistrées localement** par cet admin via le micro tablette
|
||||
* (`VoiceStorage.listLocal()`). State : recorded en attente PC.
|
||||
*
|
||||
* Les ids étant horodatés côté local, il n'y a pas de collision possible
|
||||
* avec les voix Kazeia. Si une voix locale a été ingérée par Kazeia, elle
|
||||
* apparaîtra dans les deux sources le temps que le PC tool nettoie le
|
||||
* dossier admin — on déduplique par id en gardant la version Kazeia (ready
|
||||
* prioritaire sur recorded).
|
||||
*/
|
||||
class VoicesRepository(context: Context) {
|
||||
|
||||
private val client = KazeiaVoicesClient(context.contentResolver)
|
||||
private val localStorage = VoiceStorage(context)
|
||||
|
||||
fun list(): List<Voice> {
|
||||
val kazeia = client.queryAll().map { row ->
|
||||
Voice(
|
||||
id = row.id,
|
||||
name = row.name,
|
||||
state = mapState(row.state),
|
||||
isBuiltIn = false,
|
||||
createdAt = row.createdAt,
|
||||
durationSeconds = row.wavDurationSeconds.takeIf { it > 0f },
|
||||
speakerNorm = null,
|
||||
errorMessage = errorMessageFor(row),
|
||||
deployedOnDevices = emptyList()
|
||||
)
|
||||
}
|
||||
val kazeiaIds = kazeia.map { it.id }.toSet()
|
||||
val local = localStorage.listLocal()
|
||||
.filter { it.id !in kazeiaIds }
|
||||
.map { rec ->
|
||||
Voice(
|
||||
id = rec.id,
|
||||
name = rec.displayName,
|
||||
state = VoiceState.RECORDED,
|
||||
isBuiltIn = false,
|
||||
createdAt = rec.createdAt,
|
||||
durationSeconds = rec.durationSeconds.takeIf { it > 0f },
|
||||
speakerNorm = null,
|
||||
errorMessage = "Enregistrée sur la tablette (admin). " +
|
||||
"Brancher au PC de gestion pour extraire les embeddings et activer la voix.",
|
||||
deployedOnDevices = emptyList()
|
||||
)
|
||||
}
|
||||
// Voix locales d'abord (plus récentes), puis voix Kazeia.
|
||||
return local.sortedByDescending { it.createdAt } +
|
||||
kazeia.sortedBy { it.name }
|
||||
}
|
||||
|
||||
/** Suppression : essaie d'abord le delete local (voix admin), sinon
|
||||
* délègue au ContentProvider Kazeia. */
|
||||
fun delete(id: String): Boolean {
|
||||
val localHit = localStorage.listLocal().firstOrNull { it.id == id }
|
||||
if (localHit != null) {
|
||||
return localStorage.delete(localHit)
|
||||
}
|
||||
return client.delete(id) > 0
|
||||
}
|
||||
|
||||
private fun mapState(raw: String): VoiceState = when (raw) {
|
||||
"ready" -> VoiceState.READY
|
||||
"recorded" -> VoiceState.RECORDED
|
||||
"processing" -> VoiceState.PROCESSING
|
||||
else -> VoiceState.ERROR
|
||||
}
|
||||
|
||||
private fun errorMessageFor(row: KazeiaVoicesClient.VoiceRow): String? = when (row.state) {
|
||||
"recorded" -> "Audio enregistré, embeddings absents — brancher au PC pour finaliser."
|
||||
"error" -> "Fichiers embeddings manquants et pas de WAV source. Voix non utilisable."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Client du ContentProvider Kazeia pour les URI `/config` et `/models`.
|
||||
* Lit l'état courant + envoie un update qui déclenche RELOAD_CONFIG côté
|
||||
* Kazeia patient.
|
||||
*/
|
||||
class KazeiaConfigClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val CONFIG_URI: Uri = Uri.parse("content://$AUTHORITY/config")
|
||||
val MODELS_URI: Uri = Uri.parse("content://$AUTHORITY/models")
|
||||
}
|
||||
|
||||
data class Config(
|
||||
val cascadeEnabled: Boolean,
|
||||
val ttsEnabled: Boolean,
|
||||
val speakerModelId: String,
|
||||
val speakerSystemPrompt: String,
|
||||
val speakerTemperature: Float,
|
||||
val thinkerModelId: String,
|
||||
val thinkerSystemPrompt: String,
|
||||
val thinkerTemperature: Float,
|
||||
val ragEnabled: Boolean = false,
|
||||
val ragThreshold: Float = 0.82f,
|
||||
val ragTopK: Int = 3
|
||||
)
|
||||
|
||||
data class ModelInfo(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val ptePath: String,
|
||||
val tokenizerPath: String,
|
||||
val role: String, // SPEAKER | THINKER | BOTH
|
||||
val sizeMb: Int,
|
||||
val maxSeqLen: Int,
|
||||
val fileExists: Boolean,
|
||||
val notes: String
|
||||
)
|
||||
|
||||
fun queryConfig(): Config? = runCatching {
|
||||
resolver.query(CONFIG_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
Config(
|
||||
cascadeEnabled = c.getInt(c.getColumnIndexOrThrow("cascade_enabled")) == 1,
|
||||
ttsEnabled = c.getInt(c.getColumnIndexOrThrow("tts_enabled")) == 1,
|
||||
speakerModelId = c.getString(c.getColumnIndexOrThrow("speaker_model_id")),
|
||||
speakerSystemPrompt = c.getString(c.getColumnIndexOrThrow("speaker_system_prompt")),
|
||||
speakerTemperature = c.getFloat(c.getColumnIndexOrThrow("speaker_temperature")),
|
||||
thinkerModelId = c.getString(c.getColumnIndexOrThrow("thinker_model_id")),
|
||||
thinkerSystemPrompt = c.getString(c.getColumnIndexOrThrow("thinker_system_prompt")),
|
||||
thinkerTemperature = c.getFloat(c.getColumnIndexOrThrow("thinker_temperature")),
|
||||
ragEnabled = c.getColumnIndex("rag_enabled").let { if (it >= 0) c.getInt(it) == 1 else false },
|
||||
ragThreshold = c.getColumnIndex("rag_threshold").let { if (it >= 0) c.getFloat(it) else 0.82f },
|
||||
ragTopK = c.getColumnIndex("rag_top_k").let { if (it >= 0) c.getInt(it) else 3 }
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
fun queryModels(): List<ModelInfo> = runCatching {
|
||||
resolver.query(MODELS_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<ModelInfo>()
|
||||
while (c.moveToNext()) {
|
||||
out += ModelInfo(
|
||||
id = c.getString(c.getColumnIndexOrThrow("id")),
|
||||
displayName = c.getString(c.getColumnIndexOrThrow("display_name")),
|
||||
ptePath = c.getString(c.getColumnIndexOrThrow("pte_path")),
|
||||
tokenizerPath = c.getString(c.getColumnIndexOrThrow("tokenizer_path")),
|
||||
role = c.getString(c.getColumnIndexOrThrow("role")),
|
||||
sizeMb = c.getInt(c.getColumnIndexOrThrow("size_mb")),
|
||||
maxSeqLen = c.getInt(c.getColumnIndexOrThrow("max_seq_len")),
|
||||
fileExists = c.getInt(c.getColumnIndexOrThrow("file_exists")) == 1,
|
||||
notes = c.getString(c.getColumnIndexOrThrow("notes"))
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Push une nouvelle config (champs non-nuls). Renvoie true si le
|
||||
* provider a confirmé l'update (1 row). */
|
||||
fun updateConfig(
|
||||
cascadeEnabled: Boolean? = null,
|
||||
ttsEnabled: Boolean? = null,
|
||||
speakerModelId: String? = null,
|
||||
speakerSystemPrompt: String? = null,
|
||||
speakerTemperature: Float? = null,
|
||||
thinkerModelId: String? = null,
|
||||
thinkerSystemPrompt: String? = null,
|
||||
thinkerTemperature: Float? = null,
|
||||
ragEnabled: Boolean? = null,
|
||||
ragThreshold: Float? = null,
|
||||
ragTopK: Int? = null
|
||||
): Boolean {
|
||||
val v = ContentValues()
|
||||
cascadeEnabled?.let { v.put("cascade_enabled", it) }
|
||||
ttsEnabled?.let { v.put("tts_enabled", it) }
|
||||
speakerModelId?.let { v.put("speaker_model_id", it) }
|
||||
speakerSystemPrompt?.let { v.put("speaker_system_prompt", it) }
|
||||
speakerTemperature?.let { v.put("speaker_temperature", it) }
|
||||
thinkerModelId?.let { v.put("thinker_model_id", it) }
|
||||
thinkerSystemPrompt?.let { v.put("thinker_system_prompt", it) }
|
||||
thinkerTemperature?.let { v.put("thinker_temperature", it) }
|
||||
ragEnabled?.let { v.put("rag_enabled", it) }
|
||||
ragThreshold?.let { v.put("rag_threshold", it) }
|
||||
ragTopK?.let { v.put("rag_top_k", it) }
|
||||
if (v.size() == 0) return true
|
||||
return runCatching {
|
||||
resolver.update(CONFIG_URI, v, null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Lecture seule (+ delete) de l'historique conversation Kazeia via le
|
||||
* ContentProvider `com.kazeia.provider`. La base SQLCipher reste côté
|
||||
* patient ; on ne reçoit ici que des lignes déchiffrées à la demande.
|
||||
*/
|
||||
class KazeiaConversationsClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val ALL_SESSIONS_URI: Uri = Uri.parse("content://$AUTHORITY/conversations")
|
||||
fun sessionsForProfileUri(profileId: String): Uri =
|
||||
Uri.parse("content://$AUTHORITY/conversations/profile/$profileId")
|
||||
fun turnsForSessionUri(sessionId: String): Uri =
|
||||
Uri.parse("content://$AUTHORITY/conversations/session/$sessionId")
|
||||
}
|
||||
|
||||
data class SessionRow(
|
||||
val profileId: String,
|
||||
val sessionId: String,
|
||||
val startedAt: Long,
|
||||
val endedAt: Long,
|
||||
val turnCount: Int
|
||||
)
|
||||
|
||||
data class TurnRow(
|
||||
val id: Long,
|
||||
val profileId: String,
|
||||
val sessionId: String,
|
||||
val timestamp: Long,
|
||||
val role: String, // "PATIENT" | "KAZEIA"
|
||||
val text: String,
|
||||
val ttftMs: Long?,
|
||||
val totalMs: Long?,
|
||||
val voiceUsed: String?,
|
||||
val modelUsed: String?
|
||||
)
|
||||
|
||||
/** Sessions de tous les profils, ou d'un profil donné. Triées récent → ancien. */
|
||||
fun sessions(profileId: String? = null): List<SessionRow> = runCatching {
|
||||
val uri = profileId?.let { sessionsForProfileUri(it) } ?: ALL_SESSIONS_URI
|
||||
resolver.query(uri, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<SessionRow>()
|
||||
while (c.moveToNext()) {
|
||||
out += SessionRow(
|
||||
profileId = c.getString(c.getColumnIndexOrThrow("profile_id")),
|
||||
sessionId = c.getString(c.getColumnIndexOrThrow("session_id")),
|
||||
startedAt = c.getLong(c.getColumnIndexOrThrow("started_at")),
|
||||
endedAt = c.getLong(c.getColumnIndexOrThrow("ended_at")),
|
||||
turnCount = c.getInt(c.getColumnIndexOrThrow("turn_count"))
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Tours d'une session, ordre chronologique. */
|
||||
fun turns(sessionId: String): List<TurnRow> = runCatching {
|
||||
resolver.query(turnsForSessionUri(sessionId), null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<TurnRow>()
|
||||
while (c.moveToNext()) {
|
||||
out += TurnRow(
|
||||
id = c.getLong(c.getColumnIndexOrThrow("id")),
|
||||
profileId = c.getString(c.getColumnIndexOrThrow("profile_id")),
|
||||
sessionId = c.getString(c.getColumnIndexOrThrow("session_id")),
|
||||
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
|
||||
role = c.getString(c.getColumnIndexOrThrow("role")),
|
||||
text = c.getString(c.getColumnIndexOrThrow("text")),
|
||||
ttftMs = c.colLongOrNull("ttft_ms"),
|
||||
totalMs = c.colLongOrNull("total_ms"),
|
||||
voiceUsed = c.colStringOrNull("voice_used"),
|
||||
modelUsed = c.colStringOrNull("model_used")
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Supprime une session entière. */
|
||||
fun deleteSession(sessionId: String): Boolean = runCatching {
|
||||
resolver.delete(turnsForSessionUri(sessionId), null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** Supprime tout l'historique d'un profil (les tours, pas le profil). */
|
||||
fun deleteProfileHistory(profileId: String): Boolean = runCatching {
|
||||
resolver.delete(sessionsForProfileUri(profileId), null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.colStringOrNull(col: String): String? {
|
||||
val i = getColumnIndex(col)
|
||||
if (i < 0 || isNull(i)) return null
|
||||
return getString(i)
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.colLongOrNull(col: String): Long? {
|
||||
val i = getColumnIndex(col)
|
||||
if (i < 0 || isNull(i)) return null
|
||||
return getLong(i)
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Lecture / écriture de la config WebDAV de distribution via le
|
||||
* ContentProvider de l'app patient. Le mot de passe n'est jamais relu —
|
||||
* seul [DistConfigRow.hasPassword] indique s'il est défini.
|
||||
*/
|
||||
class KazeiaDistConfigClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val URI: Uri = Uri.parse("content://$AUTHORITY/dist_config")
|
||||
}
|
||||
|
||||
data class DistConfigRow(
|
||||
val base: String,
|
||||
val user: String,
|
||||
val hasPassword: Boolean,
|
||||
/** Vrai si une config a été enregistrée (≠ défaut d'usine de l'APK). */
|
||||
val isCustomized: Boolean
|
||||
)
|
||||
|
||||
fun query(): DistConfigRow? = runCatching {
|
||||
resolver.query(URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else DistConfigRow(
|
||||
base = c.getString(c.getColumnIndexOrThrow("webdav_base")),
|
||||
user = c.getString(c.getColumnIndexOrThrow("webdav_user")),
|
||||
hasPassword = c.getInt(c.getColumnIndexOrThrow("has_password")) == 1,
|
||||
isCustomized = c.getInt(c.getColumnIndexOrThrow("is_customized")) == 1
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** Enregistre la config. `pass` null/vide → mot de passe inchangé. */
|
||||
fun update(base: String, user: String, pass: String?): Boolean {
|
||||
val v = ContentValues().apply {
|
||||
put("webdav_base", base)
|
||||
put("webdav_user", user)
|
||||
if (!pass.isNullOrEmpty()) put("webdav_pass", pass)
|
||||
}
|
||||
return runCatching { resolver.update(URI, v, null, null) > 0 }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/** Réinitialise au défaut d'usine (config compilée dans l'APK). */
|
||||
fun reset(): Boolean {
|
||||
val v = ContentValues().apply { put("reset", true) }
|
||||
return runCatching { resolver.update(URI, v, null, null) > 0 }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
class KazeiaProfilesClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val PROFILES_URI: Uri = Uri.parse("content://$AUTHORITY/profiles")
|
||||
val ACTIVE_URI: Uri = Uri.parse("content://$AUTHORITY/profiles/active")
|
||||
fun itemUri(id: String): Uri = Uri.parse("content://$AUTHORITY/profiles/$id")
|
||||
}
|
||||
|
||||
data class ProfileRow(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val avatarColor: Int,
|
||||
val voiceId: String?,
|
||||
val systemPromptOverride: String?,
|
||||
val hasPin: Boolean,
|
||||
val createdAt: Long,
|
||||
val lastUsedAt: Long,
|
||||
val notes: String?,
|
||||
val isDefault: Boolean,
|
||||
val turnCount: Int
|
||||
)
|
||||
|
||||
fun queryAll(): List<ProfileRow> = runCatching {
|
||||
resolver.query(PROFILES_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<ProfileRow>()
|
||||
while (c.moveToNext()) {
|
||||
out += ProfileRow(
|
||||
id = c.getString(c.getColumnIndexOrThrow("id")),
|
||||
displayName = c.getString(c.getColumnIndexOrThrow("display_name")),
|
||||
avatarColor = c.getInt(c.getColumnIndexOrThrow("avatar_color")),
|
||||
voiceId = c.colStringOrNull("voice_id"),
|
||||
systemPromptOverride = c.colStringOrNull("system_prompt_override"),
|
||||
hasPin = c.getInt(c.getColumnIndexOrThrow("has_pin")) == 1,
|
||||
createdAt = c.getLong(c.getColumnIndexOrThrow("created_at")),
|
||||
lastUsedAt = c.getLong(c.getColumnIndexOrThrow("last_used_at")),
|
||||
notes = c.colStringOrNull("notes"),
|
||||
isDefault = c.getInt(c.getColumnIndexOrThrow("is_default")) == 1,
|
||||
turnCount = c.getInt(c.getColumnIndexOrThrow("turn_count"))
|
||||
)
|
||||
}
|
||||
out.sortedByDescending { it.lastUsedAt }
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
fun activeProfileId(): String? = runCatching {
|
||||
resolver.query(ACTIVE_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) null
|
||||
else c.colStringOrNull("active_profile_id")
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
fun upsert(
|
||||
id: String? = null,
|
||||
displayName: String? = null,
|
||||
voiceId: String? = null,
|
||||
systemPromptOverride: String? = null,
|
||||
pin: String? = null, // "" pour effacer ; null = pas de changement
|
||||
notes: String? = null,
|
||||
isDefault: Boolean? = null,
|
||||
avatarColor: Int? = null
|
||||
): Boolean {
|
||||
val v = ContentValues()
|
||||
id?.let { v.put("id", it) }
|
||||
displayName?.let { v.put("display_name", it) }
|
||||
voiceId?.let { v.put("voice_id", it) }
|
||||
systemPromptOverride?.let { v.put("system_prompt_override", it) }
|
||||
pin?.let { v.put("pin", it) }
|
||||
notes?.let { v.put("notes", it) }
|
||||
isDefault?.let { v.put("is_default", it) }
|
||||
avatarColor?.let { v.put("avatar_color", it) }
|
||||
val uri = id?.let { itemUri(it) } ?: PROFILES_URI
|
||||
return runCatching { resolver.update(uri, v, null, null) > 0 }.getOrDefault(false)
|
||||
}
|
||||
|
||||
fun delete(id: String): Boolean = runCatching {
|
||||
resolver.delete(itemUri(id), null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun setActive(id: String?): Boolean {
|
||||
val v = ContentValues().apply {
|
||||
put("active_profile_id", id ?: "")
|
||||
}
|
||||
return runCatching { resolver.update(ACTIVE_URI, v, null, null) > 0 }.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.colStringOrNull(col: String): String? {
|
||||
val i = getColumnIndex(col)
|
||||
if (i < 0 || isNull(i)) return null
|
||||
return getString(i)
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentValues
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Client du ContentProvider Kazeia pour l'URI `/rag` (corpus RAG).
|
||||
* L'admin gère les documents BRUTS ; Kazeia patient les (ré)embedde sur le
|
||||
* broadcast RELOAD_RAG déclenché par insert/delete.
|
||||
*/
|
||||
class KazeiaRagClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val RAG_URI: Uri = Uri.parse("content://$AUTHORITY/rag")
|
||||
val RAG_STATUS_URI: Uri = Uri.parse("content://$AUTHORITY/rag_status")
|
||||
val RAG_QUERY_URI: Uri = Uri.parse("content://$AUTHORITY/rag_query")
|
||||
}
|
||||
|
||||
data class RagStatus(
|
||||
val enabled: Boolean, val ready: Boolean, val model: String, val dim: Int,
|
||||
val docCount: Int, val chunkCount: Int, val pending: Int
|
||||
)
|
||||
|
||||
data class ScoredHit(val source: String, val score: Float, val text: String)
|
||||
|
||||
fun status(): RagStatus? = runCatching {
|
||||
resolver.query(RAG_STATUS_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
RagStatus(
|
||||
enabled = c.getInt(c.getColumnIndexOrThrow("enabled")) == 1,
|
||||
ready = c.getInt(c.getColumnIndexOrThrow("ready")) == 1,
|
||||
model = c.getString(c.getColumnIndexOrThrow("model")) ?: "",
|
||||
dim = c.getInt(c.getColumnIndexOrThrow("dim")),
|
||||
docCount = c.getInt(c.getColumnIndexOrThrow("doc_count")),
|
||||
chunkCount = c.getInt(c.getColumnIndexOrThrow("chunk_count")),
|
||||
pending = c.getInt(c.getColumnIndexOrThrow("pending"))
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** Test de récupération : candidats classés avec scores (seuil 0 côté patient). */
|
||||
fun test(query: String): List<ScoredHit> = runCatching {
|
||||
resolver.query(RAG_QUERY_URI, null, null, arrayOf(query), null)?.use { c ->
|
||||
val out = mutableListOf<ScoredHit>()
|
||||
while (c.moveToNext()) out += ScoredHit(
|
||||
source = c.getString(c.getColumnIndexOrThrow("source")),
|
||||
score = c.getFloat(c.getColumnIndexOrThrow("score")),
|
||||
text = c.getString(c.getColumnIndexOrThrow("text"))
|
||||
)
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
data class RagDoc(
|
||||
val source: String,
|
||||
val charCount: Int,
|
||||
val chunkCount: Int, // 0 = pas encore indexé (RAG off ou embedder absent)
|
||||
val updatedAt: Long
|
||||
)
|
||||
|
||||
fun queryDocs(): List<RagDoc>? = runCatching {
|
||||
resolver.query(RAG_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<RagDoc>()
|
||||
while (c.moveToNext()) {
|
||||
out += RagDoc(
|
||||
source = c.getString(c.getColumnIndexOrThrow("source")),
|
||||
charCount = c.getInt(c.getColumnIndexOrThrow("char_count")),
|
||||
chunkCount = c.getInt(c.getColumnIndexOrThrow("chunk_count")),
|
||||
updatedAt = c.getLong(c.getColumnIndexOrThrow("updated_at"))
|
||||
)
|
||||
}
|
||||
out
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** Texte brut d'un document (pour édition), ou null si absent/injoignable. */
|
||||
fun docText(source: String): String? = runCatching {
|
||||
resolver.query(Uri.withAppendedPath(RAG_URI, source), null, null, null, null)?.use { c ->
|
||||
if (c.moveToFirst()) c.getString(c.getColumnIndexOrThrow("text")) else null
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
/** Crée/met à jour un document (source = identifiant). Déclenche la réindexation. */
|
||||
fun upsertDoc(source: String, text: String): Boolean = runCatching {
|
||||
val v = ContentValues().apply { put("source", source); put("text", text) }
|
||||
resolver.insert(RAG_URI, v) != null
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun deleteDoc(source: String): Boolean = runCatching {
|
||||
resolver.delete(Uri.withAppendedPath(RAG_URI, source), null, null) > 0
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Client du ContentProvider exposé par Kazeia patient
|
||||
* (`com.kazeia.provider`). Toute query échoue silencieusement si le
|
||||
* provider n'est pas disponible (Kazeia non installé ou pas démarré)
|
||||
* et retourne `null` pour que l'admin gère le fallback.
|
||||
*/
|
||||
class KazeiaTelemetryClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val STATE_URI: Uri = Uri.parse("content://$AUTHORITY/state")
|
||||
val TURNS_URI: Uri = Uri.parse("content://$AUTHORITY/turns")
|
||||
val CRASHES_URI: Uri = Uri.parse("content://$AUTHORITY/crashes")
|
||||
}
|
||||
|
||||
data class KazeiaState(
|
||||
val pid: Int,
|
||||
val uptimeSeconds: Long,
|
||||
val pssMb: Int,
|
||||
val ionMb: Int,
|
||||
val anonPagesMb: Int,
|
||||
val memAvailableMb: Int
|
||||
)
|
||||
|
||||
data class KazeiaTurn(
|
||||
val timestamp: Long,
|
||||
val inputKind: String,
|
||||
val status: String,
|
||||
val totalMs: Long,
|
||||
val sttMs: Long?,
|
||||
val llmTtftMs: Long?,
|
||||
val llmGenMs: Long?,
|
||||
val llmTokens: Int?,
|
||||
val timeToFirstAudioMs: Long?,
|
||||
val audioPlaybackMs: Long?
|
||||
) {
|
||||
fun tokensPerSecond(): Float? {
|
||||
val ms = llmGenMs ?: return null
|
||||
val tk = llmTokens ?: return null
|
||||
if (ms <= 0 || tk <= 0) return null
|
||||
return (tk * 1000f) / ms
|
||||
}
|
||||
}
|
||||
|
||||
data class KazeiaCrash(
|
||||
val timestamp: Long,
|
||||
val component: String,
|
||||
val message: String?
|
||||
)
|
||||
|
||||
fun queryState(): KazeiaState? = runCatching {
|
||||
resolver.query(STATE_URI, null, null, null, null)?.use { c ->
|
||||
if (!c.moveToFirst()) return@use null
|
||||
KazeiaState(
|
||||
pid = c.getInt(c.getColumnIndexOrThrow("pid")),
|
||||
uptimeSeconds = c.getLong(c.getColumnIndexOrThrow("uptime_seconds")),
|
||||
pssMb = c.getInt(c.getColumnIndexOrThrow("pss_mb")),
|
||||
ionMb = c.getInt(c.getColumnIndexOrThrow("ion_mb")),
|
||||
anonPagesMb = c.getInt(c.getColumnIndexOrThrow("anon_pages_mb")),
|
||||
memAvailableMb = c.getInt(c.getColumnIndexOrThrow("mem_available_mb"))
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
fun queryTurns(): List<KazeiaTurn> = runCatching {
|
||||
resolver.query(TURNS_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<KazeiaTurn>()
|
||||
while (c.moveToNext()) {
|
||||
out += KazeiaTurn(
|
||||
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
|
||||
inputKind = c.getString(c.getColumnIndexOrThrow("input_kind")),
|
||||
status = c.getString(c.getColumnIndexOrThrow("status")),
|
||||
totalMs = c.getLong(c.getColumnIndexOrThrow("total_ms")),
|
||||
sttMs = c.nullableLong("stt_ms"),
|
||||
llmTtftMs = c.nullableLong("llm_ttft_ms"),
|
||||
llmGenMs = c.nullableLong("llm_gen_ms"),
|
||||
llmTokens = c.nullableInt("llm_tokens"),
|
||||
timeToFirstAudioMs = c.nullableLong("time_to_first_audio_ms"),
|
||||
audioPlaybackMs = c.nullableLong("audio_playback_ms")
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
fun queryCrashes(): List<KazeiaCrash> = runCatching {
|
||||
resolver.query(CRASHES_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<KazeiaCrash>()
|
||||
while (c.moveToNext()) {
|
||||
out += KazeiaCrash(
|
||||
timestamp = c.getLong(c.getColumnIndexOrThrow("timestamp")),
|
||||
component = c.getString(c.getColumnIndexOrThrow("component")),
|
||||
message = c.getString(c.getColumnIndexOrThrow("message"))
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private fun android.database.Cursor.nullableLong(col: String): Long? {
|
||||
val i = getColumnIndex(col); if (i < 0 || isNull(i)) return null
|
||||
return getLong(i)
|
||||
}
|
||||
private fun android.database.Cursor.nullableInt(col: String): Int? {
|
||||
val i = getColumnIndex(col); if (i < 0 || isNull(i)) return null
|
||||
return getInt(i)
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
|
||||
/**
|
||||
* Client du ContentProvider Kazeia pour la table `voices`.
|
||||
* Lecture + suppression. L'extraction des embeddings (state recorded → ready)
|
||||
* est gérée côté PC tool ADB (workflow décrit dans
|
||||
* project_kazeia_voice_workflow.md).
|
||||
*/
|
||||
class KazeiaVoicesClient(private val resolver: ContentResolver) {
|
||||
|
||||
companion object {
|
||||
private const val AUTHORITY = "com.kazeia.provider"
|
||||
val VOICES_URI: Uri = Uri.parse("content://$AUTHORITY/voices")
|
||||
fun voiceItemUri(id: String): Uri = Uri.parse("content://$AUTHORITY/voices/$id")
|
||||
}
|
||||
|
||||
data class VoiceRow(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val state: String, // "ready" | "recorded" | "error"
|
||||
val wavExists: Boolean,
|
||||
val prefixExists: Boolean,
|
||||
val suffixExists: Boolean,
|
||||
val wavSizeBytes: Long,
|
||||
val wavDurationSeconds: Float,
|
||||
val wavPath: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
|
||||
fun queryAll(): List<VoiceRow> = runCatching {
|
||||
resolver.query(VOICES_URI, null, null, null, null)?.use { c ->
|
||||
val out = mutableListOf<VoiceRow>()
|
||||
while (c.moveToNext()) {
|
||||
out += VoiceRow(
|
||||
id = c.getString(c.getColumnIndexOrThrow("id")),
|
||||
name = c.getString(c.getColumnIndexOrThrow("name")),
|
||||
state = c.getString(c.getColumnIndexOrThrow("state")),
|
||||
wavExists = c.getInt(c.getColumnIndexOrThrow("wav_exists")) == 1,
|
||||
prefixExists = c.getInt(c.getColumnIndexOrThrow("prefix_exists")) == 1,
|
||||
suffixExists = c.getInt(c.getColumnIndexOrThrow("suffix_exists")) == 1,
|
||||
wavSizeBytes = c.getLong(c.getColumnIndexOrThrow("wav_size_bytes")),
|
||||
wavDurationSeconds = c.getFloat(c.getColumnIndexOrThrow("wav_duration_seconds")),
|
||||
wavPath = c.getString(c.getColumnIndexOrThrow("wav_path")),
|
||||
createdAt = c.getLong(c.getColumnIndexOrThrow("created_at"))
|
||||
)
|
||||
}
|
||||
out
|
||||
} ?: emptyList()
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
/** Supprime une voix (wav + bin) côté Kazeia patient. Retourne le nombre
|
||||
* de fichiers réellement effacés (0 si rien trouvé, ce qui est aussi
|
||||
* retourné si Kazeia n'est pas démarré). */
|
||||
fun delete(id: String): Int = runCatching {
|
||||
resolver.delete(voiceItemUri(id), null, null)
|
||||
}.getOrDefault(0)
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
package com.kazeia.admin.data.source
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Lecture directe des fichiers procfs du device (/proc/meminfo et /proc/stat)
|
||||
* accessibles à toute app sans permission. Donne la mémoire système (incl.
|
||||
* ION qui correspond 100% à Kazeia sur cette tablette puisque c'est le seul
|
||||
* user du DSP HTP) et le CPU global. Aucune dépendance à Kazeia.
|
||||
*/
|
||||
class SystemTelemetrySource {
|
||||
|
||||
data class MemSnapshot(
|
||||
val memTotalMb: Int,
|
||||
val memAvailableMb: Int,
|
||||
val ionTotalUsedMb: Int,
|
||||
val anonPagesMb: Int,
|
||||
val cachedMb: Int,
|
||||
val swapFreeMb: Int
|
||||
)
|
||||
|
||||
fun readMemory(): MemSnapshot {
|
||||
val keys = mapOf<String, Long>(
|
||||
"MemTotal" to 0, "MemAvailable" to 0, "IonTotalUsed" to 0,
|
||||
"AnonPages" to 0, "Cached" to 0, "SwapFree" to 0
|
||||
).toMutableMap()
|
||||
try {
|
||||
File("/proc/meminfo").bufferedReader().useLines { lines ->
|
||||
for (line in lines) {
|
||||
val key = keys.keys.firstOrNull { line.startsWith("$it:") } ?: continue
|
||||
val kb = line.split(Regex("\\s+")).getOrNull(1)?.toLongOrNull() ?: 0L
|
||||
keys[key] = kb
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { /* fall through with zeros */ }
|
||||
return MemSnapshot(
|
||||
memTotalMb = (keys["MemTotal"]!! / 1024).toInt(),
|
||||
memAvailableMb = (keys["MemAvailable"]!! / 1024).toInt(),
|
||||
ionTotalUsedMb = (keys["IonTotalUsed"]!! / 1024).toInt(),
|
||||
anonPagesMb = (keys["AnonPages"]!! / 1024).toInt(),
|
||||
cachedMb = (keys["Cached"]!! / 1024).toInt(),
|
||||
swapFreeMb = (keys["SwapFree"]!! / 1024).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU% calculé en diffant deux lectures de /proc/stat. Garde l'état
|
||||
* précédent pour produire un %% relatif au laps de temps entre 2 appels.
|
||||
*/
|
||||
private var prevTotal = 0L
|
||||
private var prevIdle = 0L
|
||||
|
||||
fun readCpuPercent(): Float = try {
|
||||
val parts = File("/proc/stat").bufferedReader().readLine().trim().split(Regex("\\s+"))
|
||||
if (parts.size < 8) 0f else {
|
||||
val user = parts[1].toLong()
|
||||
val nice = parts[2].toLong()
|
||||
val system = parts[3].toLong()
|
||||
val idle = parts[4].toLong()
|
||||
val iowait = parts[5].toLong()
|
||||
val irq = parts[6].toLong()
|
||||
val softirq = parts[7].toLong()
|
||||
val total = user + nice + system + idle + iowait + irq + softirq
|
||||
val idleAll = idle + iowait
|
||||
val dt = total - prevTotal
|
||||
val di = idleAll - prevIdle
|
||||
prevTotal = total
|
||||
prevIdle = idleAll
|
||||
if (dt > 0) ((dt - di).toFloat() / dt * 100f).coerceIn(0f, 100f) else 0f
|
||||
}
|
||||
} catch (_: Exception) { 0f }
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package com.kazeia.admin.ui.components
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Sparkline minimaliste : trace une polyline normalisée + optionnellement
|
||||
* la ligne du dernier point en surbrillance. Pas d'axes ni de grid pour
|
||||
* rester dense et lisible dans une rangée de KPI.
|
||||
*/
|
||||
@Composable
|
||||
fun Sparkline(
|
||||
values: List<Float>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 32.dp,
|
||||
width: Dp = 120.dp,
|
||||
lineColor: Color = MaterialTheme.colorScheme.primary,
|
||||
showLastPointDot: Boolean = true
|
||||
) {
|
||||
if (values.isEmpty()) return
|
||||
val min = values.min()
|
||||
val max = values.max()
|
||||
val range = (max - min).takeIf { it > 0f } ?: 1f
|
||||
|
||||
Canvas(modifier = modifier.width(width).height(height)) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val stepX = if (values.size > 1) w / (values.size - 1).toFloat() else 0f
|
||||
val path = Path()
|
||||
values.forEachIndexed { i, v ->
|
||||
val x = i * stepX
|
||||
val y = h - ((v - min) / range) * h
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = lineColor,
|
||||
style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round)
|
||||
)
|
||||
if (showLastPointDot) {
|
||||
val lastY = h - ((values.last() - min) / range) * h
|
||||
val lastX = (values.size - 1) * stepX
|
||||
drawCircle(lineColor, radius = 3.dp.toPx(), center = Offset(lastX, lastY))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparkline doublée : remplit l'aire sous la courbe avec une couleur translucide.
|
||||
* Pour les séries mémoire où le niveau absolu compte autant que la tendance.
|
||||
*/
|
||||
@Composable
|
||||
fun FilledSparkline(
|
||||
values: List<Int>,
|
||||
modifier: Modifier = Modifier,
|
||||
height: Dp = 36.dp,
|
||||
width: Dp = 200.dp,
|
||||
lineColor: Color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
if (values.isEmpty()) return
|
||||
val min = values.min()
|
||||
val max = values.max()
|
||||
val range = (max - min).takeIf { it > 0 } ?: 1
|
||||
|
||||
Canvas(modifier = modifier.width(width).height(height)) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val stepX = if (values.size > 1) w / (values.size - 1).toFloat() else 0f
|
||||
val linePath = Path()
|
||||
val fillPath = Path()
|
||||
|
||||
values.forEachIndexed { i, v ->
|
||||
val x = i * stepX
|
||||
val y = h - ((v - min).toFloat() / range) * h
|
||||
if (i == 0) {
|
||||
linePath.moveTo(x, y)
|
||||
fillPath.moveTo(x, h)
|
||||
fillPath.lineTo(x, y)
|
||||
} else {
|
||||
linePath.lineTo(x, y)
|
||||
fillPath.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
fillPath.lineTo((values.size - 1) * stepX, h)
|
||||
fillPath.close()
|
||||
|
||||
drawPath(path = fillPath, color = lineColor.copy(alpha = 0.18f))
|
||||
drawPath(
|
||||
path = linePath,
|
||||
color = lineColor,
|
||||
style = Stroke(width = 1.5.dp.toPx(), cap = StrokeCap.Round)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package com.kazeia.admin.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.data.model.VoiceState
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import com.kazeia.admin.ui.theme.StateProcessing
|
||||
import com.kazeia.admin.ui.theme.StateReady
|
||||
import com.kazeia.admin.ui.theme.StateRecorded
|
||||
|
||||
@Composable
|
||||
fun StateBadge(state: VoiceState, modifier: Modifier = Modifier) {
|
||||
val (color, label) = when (state) {
|
||||
VoiceState.READY -> StateReady to "✓ Prête"
|
||||
VoiceState.RECORDED -> StateRecorded to "⚠ À finaliser PC"
|
||||
VoiceState.PROCESSING -> StateProcessing to "⟳ Extraction…"
|
||||
VoiceState.ERROR -> StateError to "✗ Erreur"
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = modifier
|
||||
.background(color, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package com.kazeia.admin.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.HourglassEmpty
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
|
||||
/**
|
||||
* Écran "à venir" pour les rubriques pas encore implémentées (Profils,
|
||||
* Prompts, RAG, Historique, Telemetry, System). Visuel unifié, message
|
||||
* clair, navigation top-bar.
|
||||
*/
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun StubScreen(title: String) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
Icons.Outlined.HourglassEmpty,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.stub_coming_soon),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
package com.kazeia.admin.ui.history
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.ExpandLess
|
||||
import androidx.compose.material.icons.outlined.ExpandMore
|
||||
import androidx.compose.material.icons.outlined.FileDownload
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.repository.ConversationsRepository
|
||||
import com.kazeia.admin.data.source.KazeiaConversationsClient
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val DAY_FMT = SimpleDateFormat("dd/MM/yyyy", Locale.FRENCH)
|
||||
private val TIME_FMT = SimpleDateFormat("HH:mm", Locale.FRENCH)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HistoryScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { ConversationsRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var sessions by remember { mutableStateOf<List<ConversationsRepository.Session>>(emptyList()) }
|
||||
var profileFilter by remember { mutableStateOf<Pair<String, String>?>(null) } // (id, name)
|
||||
var availableProfiles by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
|
||||
var filterExpanded by remember { mutableStateOf(false) }
|
||||
var refreshTick by remember { mutableIntStateOf(0) }
|
||||
var pendingDelete by remember { mutableStateOf<ConversationsRepository.Session?>(null) }
|
||||
|
||||
LaunchedEffect(refreshTick, profileFilter) {
|
||||
availableProfiles = withContext(Dispatchers.IO) { repo.profilesWithHistory() }
|
||||
sessions = withContext(Dispatchers.IO) { repo.sessions(profileFilter?.first) }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_history)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// Filtre profil
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = filterExpanded,
|
||||
onExpandedChange = { filterExpanded = !filterExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = profileFilter?.second ?: "Tous les profils",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Profil") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterExpanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = filterExpanded,
|
||||
onDismissRequest = { filterExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Tous les profils") },
|
||||
onClick = { profileFilter = null; filterExpanded = false }
|
||||
)
|
||||
availableProfiles.forEach { p ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(p.second) },
|
||||
onClick = { profileFilter = p; filterExpanded = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
if (sessions.isEmpty()) {
|
||||
EmptyState()
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
items(sessions, key = { it.sessionId }) { s ->
|
||||
SessionCard(
|
||||
session = s,
|
||||
loadTurns = { repo.turns(s.sessionId) },
|
||||
onExport = {
|
||||
scope.launch {
|
||||
val file = withContext(Dispatchers.IO) { repo.exportSession(s) }
|
||||
snackbar.showSnackbar(
|
||||
if (file != null) "Exporté : ${file.name}"
|
||||
else "Échec de l'export.",
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
},
|
||||
onDelete = { pendingDelete = s }
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(32.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pendingDelete?.let { s ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Supprimer cette session ?") },
|
||||
text = {
|
||||
Text("La session du ${DAY_FMT.format(Date(s.startedAt))} " +
|
||||
"(${s.turnCount} tour(s), profil « ${s.profileName} ») " +
|
||||
"sera effacée définitivement. Pensez à l'exporter avant si nécessaire.")
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val ok = repo.deleteSession(s.sessionId)
|
||||
pendingDelete = null
|
||||
refreshTick++
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Session supprimée." else "Échec suppression.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = StateError, contentColor = Color.White
|
||||
)
|
||||
) { Text("Supprimer") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) { Text("Annuler") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionCard(
|
||||
session: ConversationsRepository.Session,
|
||||
loadTurns: () -> List<KazeiaConversationsClient.TurnRow>,
|
||||
onExport: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
var turns by remember { mutableStateOf<List<KazeiaConversationsClient.TurnRow>>(emptyList()) }
|
||||
|
||||
LaunchedEffect(expanded) {
|
||||
if (expanded && turns.isEmpty()) {
|
||||
turns = withContext(Dispatchers.IO) { loadTurns() }
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { expanded = !expanded },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
session.profileName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.size(2.dp))
|
||||
Text(
|
||||
"${DAY_FMT.format(Date(session.startedAt))} · " +
|
||||
"${TIME_FMT.format(Date(session.startedAt))}–" +
|
||||
"${TIME_FMT.format(Date(session.endedAt))} · " +
|
||||
"${session.turnCount} tour(s)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
if (expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore,
|
||||
contentDescription = if (expanded) "Réduire" else "Développer"
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
if (turns.isEmpty()) {
|
||||
Text(
|
||||
"Chargement…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
turns.forEach { t -> TurnBubble(t); Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
TextButton(onClick = onExport) {
|
||||
Icon(Icons.Outlined.FileDownload, null, Modifier.size(18.dp))
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Text("Exporter JSON")
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Outlined.Delete, "Supprimer", tint = StateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TurnBubble(turn: KazeiaConversationsClient.TurnRow) {
|
||||
val isKazeia = turn.role == "KAZEIA"
|
||||
val bg = if (isKazeia) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(bg, RoundedCornerShape(10.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
if (isKazeia) "Kazeia" else "Patient",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
TIME_FMT.format(Date(turn.timestamp)),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(turn.text, style = MaterialTheme.typography.bodyLarge)
|
||||
if (isKazeia && (turn.ttftMs != null || turn.modelUsed != null)) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val meta = buildString {
|
||||
turn.modelUsed?.let { append(it) }
|
||||
turn.voiceUsed?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append("voix $it")
|
||||
}
|
||||
turn.ttftMs?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append("TTFT ${it}ms")
|
||||
}
|
||||
turn.totalMs?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append("LLM ${it}ms")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
meta,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Aucune conversation enregistrée",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Les échanges patient ↔ Kazeia apparaissent ici dès qu'un " +
|
||||
"profil (non-invité) est utilisé.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
package com.kazeia.admin.ui.login
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Lock
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kazeia.admin.R
|
||||
|
||||
// MVP placeholder: hardcoded PIN. Phase 1 will use Android Keystore-backed hash.
|
||||
private const val DEFAULT_PIN = "123456"
|
||||
private const val PIN_LENGTH = 6
|
||||
private const val MAX_ATTEMPTS = 3
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
onAuthenticated: () -> Unit
|
||||
) {
|
||||
var pin by remember { mutableStateOf("") }
|
||||
var attempts by remember { mutableStateOf(0) }
|
||||
var locked by remember { mutableStateOf(false) }
|
||||
val attemptsLeft = MAX_ATTEMPTS - attempts
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Lock,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(72.dp)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.login_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
if (locked) stringResource(R.string.login_locked)
|
||||
else stringResource(R.string.login_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
// Pin dots
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
repeat(PIN_LENGTH) { i ->
|
||||
val filled = pin.length > i
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.background(
|
||||
color = if (filled) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts > 0 && !locked) {
|
||||
Text(
|
||||
stringResource(R.string.login_error_attempts, attemptsLeft),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(48.dp))
|
||||
|
||||
PinPad(
|
||||
enabled = !locked,
|
||||
onDigit = { digit ->
|
||||
if (pin.length < PIN_LENGTH) {
|
||||
pin = pin + digit
|
||||
if (pin.length == PIN_LENGTH) {
|
||||
if (pin == DEFAULT_PIN) {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
attempts++
|
||||
pin = ""
|
||||
if (attempts >= MAX_ATTEMPTS) locked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onBackspace = { if (pin.isNotEmpty()) pin = pin.dropLast(1) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinPad(
|
||||
enabled: Boolean,
|
||||
onDigit: (String) -> Unit,
|
||||
onBackspace: () -> Unit
|
||||
) {
|
||||
val rows = listOf(
|
||||
listOf("1", "2", "3"),
|
||||
listOf("4", "5", "6"),
|
||||
listOf("7", "8", "9"),
|
||||
listOf("", "0", "⌫")
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
row.forEach { label ->
|
||||
PinButton(
|
||||
label = label,
|
||||
enabled = enabled && label.isNotEmpty(),
|
||||
onClick = {
|
||||
if (label == "⌫") onBackspace() else onDigit(label)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PinButton(label: String, enabled: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
modifier = Modifier.size(84.dp),
|
||||
shape = CircleShape,
|
||||
color = if (enabled && label.isNotEmpty())
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
else Color.Transparent
|
||||
) {
|
||||
if (label.isNotEmpty()) {
|
||||
TextButton(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxSize()) {
|
||||
Text(label, fontSize = 30.sp, fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
package com.kazeia.admin.ui.navigation
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.Article
|
||||
import androidx.compose.material.icons.outlined.Analytics
|
||||
import androidx.compose.material.icons.outlined.Book
|
||||
import androidx.compose.material.icons.outlined.Build
|
||||
import androidx.compose.material.icons.outlined.GraphicEq
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.People
|
||||
import androidx.compose.material.icons.outlined.RecordVoiceOver
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Destinations principales de l'admin app.
|
||||
* L'ordre ici détermine l'ordre dans le rail de navigation.
|
||||
*/
|
||||
enum class AdminDestination(
|
||||
val route: String,
|
||||
val labelRes: Int,
|
||||
val icon: ImageVector
|
||||
) {
|
||||
Voices( "voices", com.kazeia.admin.R.string.nav_voices, Icons.Outlined.RecordVoiceOver),
|
||||
Profiles( "profiles", com.kazeia.admin.R.string.nav_profiles, Icons.Outlined.People),
|
||||
Prompts( "prompts", com.kazeia.admin.R.string.nav_prompts, Icons.AutoMirrored.Outlined.Article),
|
||||
Rag( "rag", com.kazeia.admin.R.string.nav_rag, Icons.Outlined.Book),
|
||||
History( "history", com.kazeia.admin.R.string.nav_history, Icons.Outlined.History),
|
||||
Telemetry("telemetry", com.kazeia.admin.R.string.nav_telemetry, Icons.Outlined.Analytics),
|
||||
System( "system", com.kazeia.admin.R.string.nav_system, Icons.Outlined.Build);
|
||||
|
||||
companion object {
|
||||
const val LoginRoute = "login"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,438 +0,0 @@
|
|||
package com.kazeia.admin.ui.profiles
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.repository.ProfilesRepository
|
||||
import com.kazeia.admin.data.repository.VoicesRepository
|
||||
import com.kazeia.admin.data.source.KazeiaProfilesClient
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import com.kazeia.admin.ui.theme.StateReady
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
private val DATE_FMT = SimpleDateFormat("dd/MM/yyyy", Locale.FRENCH)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfilesScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { ProfilesRepository(context) }
|
||||
val voicesRepo = remember { VoicesRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var profiles by remember { mutableStateOf<List<KazeiaProfilesClient.ProfileRow>>(emptyList()) }
|
||||
var refreshTick by remember { mutableIntStateOf(0) }
|
||||
var pendingDelete by remember { mutableStateOf<KazeiaProfilesClient.ProfileRow?>(null) }
|
||||
var showCreate by remember { mutableStateOf(false) }
|
||||
var editing by remember { mutableStateOf<KazeiaProfilesClient.ProfileRow?>(null) }
|
||||
|
||||
LaunchedEffect(refreshTick) {
|
||||
profiles = repo.list()
|
||||
}
|
||||
|
||||
val availableVoiceIds = remember(refreshTick) {
|
||||
voicesRepo.list().filter { it.state == com.kazeia.admin.data.model.VoiceState.READY }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_profiles)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showCreate = true },
|
||||
icon = { Icon(Icons.Outlined.PersonAdd, null) },
|
||||
text = { Text("+ Nouveau profil") },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (profiles.isEmpty()) {
|
||||
EmptyState(modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(profiles, key = { it.id }) { p ->
|
||||
ProfileCard(
|
||||
row = p,
|
||||
onEdit = { editing = p },
|
||||
onDelete = { pendingDelete = p },
|
||||
onSetActive = {
|
||||
val ok = repo.setActive(p.id)
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "« ${p.displayName} » est maintenant le profil actif."
|
||||
else "Échec : Kazeia injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
refreshTick++
|
||||
}
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(96.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Création
|
||||
if (showCreate) {
|
||||
ProfileEditDialog(
|
||||
initial = null,
|
||||
availableVoices = availableVoiceIds.map { it.id to it.name },
|
||||
onDismiss = { showCreate = false },
|
||||
onSave = { name, voiceId, pin, isDefault, notes ->
|
||||
val id = "p_" + UUID.randomUUID().toString().take(8)
|
||||
val ok = repo.create(id, name, voiceId, pin.ifBlank { null }, isDefault, notes.ifBlank { null })
|
||||
showCreate = false
|
||||
refreshTick++
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Profil « $name » créé." else "Échec création.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Édition
|
||||
editing?.let { row ->
|
||||
ProfileEditDialog(
|
||||
initial = row,
|
||||
availableVoices = availableVoiceIds.map { it.id to it.name },
|
||||
onDismiss = { editing = null },
|
||||
onSave = { name, voiceId, pin, isDefault, notes ->
|
||||
if (name != row.displayName) repo.rename(row.id, name)
|
||||
if (voiceId != row.voiceId) repo.setVoice(row.id, voiceId)
|
||||
// pin : "" efface, blank = pas de changement
|
||||
if (pin.isNotBlank() || (pin.isBlank() && row.hasPin)) {
|
||||
// si l'user a tapé un nouveau PIN OU vide le champ
|
||||
repo.setPin(row.id, pin.ifBlank { null })
|
||||
}
|
||||
if (isDefault && !row.isDefault) repo.setDefault(row.id)
|
||||
editing = null
|
||||
refreshTick++
|
||||
scope.launch {
|
||||
snackbar.showSnackbar("Modifications enregistrées.",
|
||||
duration = SnackbarDuration.Short)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Confirmation delete
|
||||
pendingDelete?.let { row ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Supprimer le profil ?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("« ${row.displayName} » et **${row.turnCount} tour(s)** de conversation associés " +
|
||||
"seront effacés définitivement de la tablette.")
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Pensez à exporter l'historique avant suppression si nécessaire.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val ok = repo.delete(row.id)
|
||||
pendingDelete = null
|
||||
refreshTick++
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Profil supprimé." else "Échec suppression.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = StateError,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) { Text("Supprimer") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) { Text("Annuler") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProfileCard(
|
||||
row: KazeiaProfilesClient.ProfileRow,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSetActive: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
AvatarBubble(name = row.displayName, color = Color(row.avatarColor))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(row.displayName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
if (row.isDefault) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Badge("défaut", StateReady)
|
||||
}
|
||||
if (row.hasPin) {
|
||||
Spacer(Modifier.size(6.dp))
|
||||
Badge("🔒 PIN", MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(2.dp))
|
||||
val sub = buildString {
|
||||
row.voiceId?.let { append("voix : $it") } ?: append("voix par défaut")
|
||||
append(" · ${row.turnCount} tour(s)")
|
||||
append(" · créé ${DATE_FMT.format(Date(row.createdAt))}")
|
||||
}
|
||||
Text(sub,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = onSetActive) { Text("Définir actif") }
|
||||
TextButton(onClick = onEdit) { Text("Modifier") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = StateError)
|
||||
) { Text("Supprimer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvatarBubble(name: String, color: Color) {
|
||||
val letter = name.trim().firstOrNull()?.uppercase() ?: "?"
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.background(color, CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(letter, color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Badge(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
.background(color, RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ProfileEditDialog(
|
||||
initial: KazeiaProfilesClient.ProfileRow?,
|
||||
availableVoices: List<Pair<String, String>>, // (id, displayName)
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (name: String, voiceId: String?, pin: String, isDefault: Boolean, notes: String) -> Unit
|
||||
) {
|
||||
var name by remember { mutableStateOf(initial?.displayName ?: "") }
|
||||
var voiceId by remember { mutableStateOf(initial?.voiceId) }
|
||||
var pin by remember { mutableStateOf("") } // toujours blank, on ne montre pas le hash
|
||||
var isDefault by remember { mutableStateOf(initial?.isDefault ?: false) }
|
||||
var notes by remember { mutableStateOf(initial?.notes ?: "") }
|
||||
var voiceExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (initial == null) "Nouveau profil" else "Modifier ${initial.displayName}") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = name, onValueChange = { name = it },
|
||||
label = { Text("Nom du patient *") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = voiceExpanded,
|
||||
onExpandedChange = { voiceExpanded = !voiceExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = voiceId?.let { id ->
|
||||
availableVoices.firstOrNull { it.first == id }?.second ?: id
|
||||
} ?: "(défaut Kazeia)",
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Voix Kazeia") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = voiceExpanded) },
|
||||
modifier = Modifier
|
||||
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = voiceExpanded,
|
||||
onDismissRequest = { voiceExpanded = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("(défaut Kazeia)") },
|
||||
onClick = { voiceId = null; voiceExpanded = false }
|
||||
)
|
||||
availableVoices.forEach { (id, n) ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(n) },
|
||||
onClick = { voiceId = id; voiceExpanded = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = pin, onValueChange = { pin = it.filter { c -> c.isDigit() }.take(6) },
|
||||
label = { Text("PIN patient (4-6 chiffres, laisser vide = pas de PIN)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
if (initial?.hasPin == true && pin.isBlank()) {
|
||||
Text(
|
||||
"Un PIN est déjà défini. Laissez vide pour le conserver, ou tapez « 0 » suivi de Effacer pour le retirer.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = notes, onValueChange = { notes = it },
|
||||
label = { Text("Notes cliniques (optionnel)") },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = isDefault, onCheckedChange = { isDefault = it })
|
||||
Text("Profil par défaut (actif au démarrage de Kazeia)")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = { onSave(name.trim(), voiceId, pin, isDefault, notes) },
|
||||
enabled = name.trim().isNotEmpty()
|
||||
) { Text(if (initial == null) "Créer" else "Enregistrer") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Annuler") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("Aucun profil patient",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Créez un profil avec le bouton ci-dessous. " +
|
||||
"Chaque profil aura son historique de conversation chiffré et son PIN optionnel.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
package com.kazeia.admin.ui.prompts
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.PrimaryTabRow
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.repository.ConfigRepository
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private enum class PromptTab(val title: String) {
|
||||
Speaker("Speaker (Kazeia)"),
|
||||
Thinker("Thinker (analyse)")
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PromptsScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { ConfigRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Local editing state per tab
|
||||
var speakerDraft by remember { mutableStateOf("") }
|
||||
var thinkerDraft by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
config = repo.config()
|
||||
speakerDraft = config?.speakerSystemPrompt ?: ""
|
||||
thinkerDraft = config?.thinkerSystemPrompt ?: ""
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_prompts)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
if (config == null) {
|
||||
OfflineCard(modifier = Modifier.padding(padding))
|
||||
return@Scaffold
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
PromptTab.entries.forEachIndexed { index, tab ->
|
||||
Tab(
|
||||
selected = selectedTab == index,
|
||||
onClick = { selectedTab = index },
|
||||
text = { Text(tab.title) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val activeTab = PromptTab.entries[selectedTab]
|
||||
val draft = if (activeTab == PromptTab.Speaker) speakerDraft else thinkerDraft
|
||||
val original = if (activeTab == PromptTab.Speaker)
|
||||
config?.speakerSystemPrompt ?: ""
|
||||
else
|
||||
config?.thinkerSystemPrompt ?: ""
|
||||
val dirty = draft != original
|
||||
val isThinker = activeTab == PromptTab.Thinker
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (isThinker && config?.cascadeEnabled == false) {
|
||||
CascadeOffWarning()
|
||||
}
|
||||
|
||||
ContextCard(activeTab)
|
||||
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = {
|
||||
if (activeTab == PromptTab.Speaker) speakerDraft = it
|
||||
else thinkerDraft = it
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 240.dp),
|
||||
label = { Text("Prompt système ${activeTab.title}") },
|
||||
supportingText = { Text("${draft.length} caractères") }
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton(onClick = {
|
||||
val newPrompt = if (activeTab == PromptTab.Speaker)
|
||||
ConfigRepository.DEFAULT_SPEAKER_PROMPT
|
||||
else ConfigRepository.DEFAULT_THINKER_PROMPT
|
||||
if (activeTab == PromptTab.Speaker) speakerDraft = newPrompt
|
||||
else thinkerDraft = newPrompt
|
||||
}) { Text("Restaurer le défaut") }
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (activeTab == PromptTab.Speaker)
|
||||
speakerDraft = config?.speakerSystemPrompt ?: ""
|
||||
else
|
||||
thinkerDraft = config?.thinkerSystemPrompt ?: ""
|
||||
},
|
||||
enabled = dirty
|
||||
) { Text("Annuler") }
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val ok = if (activeTab == PromptTab.Speaker)
|
||||
repo.updateSpeakerPrompt(speakerDraft)
|
||||
else
|
||||
repo.updateThinkerPrompt(thinkerDraft)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Prompt enregistré — prise en compte au prochain tour."
|
||||
else "Échec : Kazeia patient injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
},
|
||||
enabled = dirty && draft.length >= 20
|
||||
) { Text("Enregistrer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContextCard(tab: PromptTab) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
val text = when (tab) {
|
||||
PromptTab.Speaker -> "Le **Speaker** est le LLM qui parle au patient. Son prompt définit le ton, le format des réponses, et les contraintes thérapeutiques (longueur, validation émotionnelle, etc.). Modifié à chaud — pris en compte au tour suivant, sans recharger le modèle."
|
||||
PromptTab.Thinker -> "Le **Thinker** est un LLM léger qui produit une analyse clinique structurée du message patient (émotion / besoin / approche / axe). Ses bullets sont injectées dans le system prompt du Speaker. Utilisé uniquement quand la cascade est activée dans l'écran Système."
|
||||
}
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CascadeOffWarning() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = StateError.copy(alpha = 0.1f)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"⚠ La cascade est désactivée dans l'écran Système — le prompt Thinker n'est pas utilisé actuellement.",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineCard(modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize().padding(32.dp), contentAlignment = androidx.compose.ui.Alignment.Center) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))
|
||||
) {
|
||||
Text(
|
||||
"Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,320 +0,0 @@
|
|||
package com.kazeia.admin.ui.rag
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.repository.RagRepository
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient
|
||||
import com.kazeia.admin.data.source.KazeiaRagClient
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private data class Editing(val source: String, val text: String, val isNew: Boolean)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RagScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { RagRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
|
||||
var status by remember { mutableStateOf<KazeiaRagClient.RagStatus?>(null) }
|
||||
var docs by remember { mutableStateOf<List<KazeiaRagClient.RagDoc>?>(null) }
|
||||
var editing by remember { mutableStateOf<Editing?>(null) }
|
||||
var offline by remember { mutableStateOf(false) }
|
||||
|
||||
var testQuery by remember { mutableStateOf("") }
|
||||
var testResults by remember { mutableStateOf<List<KazeiaRagClient.ScoredHit>?>(null) }
|
||||
|
||||
suspend fun reload() = withContext(Dispatchers.IO) {
|
||||
val c = repo.config()
|
||||
offline = c == null
|
||||
config = c
|
||||
status = repo.status()
|
||||
docs = repo.docs()
|
||||
}
|
||||
LaunchedEffect(Unit) { reload() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_rag)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (editing == null && !offline) {
|
||||
FloatingActionButton(onClick = { editing = Editing("", "", true) }) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = "Ajouter un document")
|
||||
}
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
val mod = Modifier.fillMaxSize().padding(padding)
|
||||
when {
|
||||
offline -> OfflineCard(mod)
|
||||
editing != null -> DocEditor(
|
||||
editing = editing!!, modifier = mod,
|
||||
onChange = { editing = it },
|
||||
onCancel = { editing = null },
|
||||
onSave = { e ->
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { repo.save(e.source.trim(), e.text) }
|
||||
if (ok) { editing = null; delay(400); reload() }
|
||||
snackbar.showSnackbar(if (ok) "Document enregistré — réindexation en cours." else "Échec : Kazeia injoignable.", duration = SnackbarDuration.Short)
|
||||
}
|
||||
},
|
||||
onDelete = { src ->
|
||||
scope.launch {
|
||||
val ok = withContext(Dispatchers.IO) { repo.delete(src) }
|
||||
if (ok) { editing = null; delay(400); reload() }
|
||||
snackbar.showSnackbar(if (ok) "Document supprimé." else "Échec suppression.", duration = SnackbarDuration.Short)
|
||||
}
|
||||
}
|
||||
)
|
||||
else -> Dashboard(
|
||||
config = config, status = status, docs = docs ?: emptyList(),
|
||||
testQuery = testQuery, testResults = testResults, modifier = mod,
|
||||
onToggleEnabled = { on ->
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) { repo.setEnabled(on) }
|
||||
snackbar.showSnackbar(if (on) "RAG activé — chargement de l'embedder…" else "RAG désactivé.", duration = SnackbarDuration.Short)
|
||||
delay(1500); reload()
|
||||
}
|
||||
},
|
||||
onThreshold = { t -> scope.launch { withContext(Dispatchers.IO) { repo.setThreshold(t) }; reload() } },
|
||||
onTopK = { k -> scope.launch { withContext(Dispatchers.IO) { repo.setTopK(k) }; reload() } },
|
||||
onTestQueryChange = { testQuery = it },
|
||||
onRunTest = {
|
||||
scope.launch {
|
||||
val r = withContext(Dispatchers.IO) { repo.test(testQuery.trim()) }
|
||||
testResults = r
|
||||
if (r.isEmpty()) snackbar.showSnackbar("Aucun résultat (RAG inactif ou corpus vide).", duration = SnackbarDuration.Short)
|
||||
}
|
||||
},
|
||||
onOpenDoc = { src ->
|
||||
scope.launch {
|
||||
val t = withContext(Dispatchers.IO) { repo.docText(src) } ?: ""
|
||||
editing = Editing(src, t, false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Dashboard(
|
||||
config: KazeiaConfigClient.Config?,
|
||||
status: KazeiaRagClient.RagStatus?,
|
||||
docs: List<KazeiaRagClient.RagDoc>,
|
||||
testQuery: String,
|
||||
testResults: List<KazeiaRagClient.ScoredHit>?,
|
||||
modifier: Modifier,
|
||||
onToggleEnabled: (Boolean) -> Unit,
|
||||
onThreshold: (Float) -> Unit,
|
||||
onTopK: (Int) -> Unit,
|
||||
onTestQueryChange: (String) -> Unit,
|
||||
onRunTest: () -> Unit,
|
||||
onOpenDoc: (String) -> Unit
|
||||
) {
|
||||
val threshold = config?.ragThreshold ?: 0.82f
|
||||
LazyColumn(
|
||||
modifier = modifier,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
item { StatusCard(config, status, onToggleEnabled) }
|
||||
item { SettingsCard(config, onThreshold, onTopK) }
|
||||
item { TestCard(testQuery, testResults, threshold, onTestQueryChange, onRunTest) }
|
||||
item {
|
||||
Text("Documents du corpus (${docs.size})",
|
||||
style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(top = 4.dp))
|
||||
}
|
||||
if (docs.isEmpty()) {
|
||||
item { Text("Aucun document. Touchez + pour en ajouter.", color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||
} else {
|
||||
items(docs, key = { it.source }) { d ->
|
||||
Card(modifier = Modifier.fillMaxWidth().clickable { onOpenDoc(d.source) }, shape = RoundedCornerShape(10.dp)) {
|
||||
Column(Modifier.padding(14.dp)) {
|
||||
Text(d.source, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleSmall)
|
||||
val st = if (d.chunkCount > 0) "${d.chunkCount} fragment(s)" else "⚠ à indexer"
|
||||
Text("${d.charCount} caractères · $st",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (d.chunkCount > 0) MaterialTheme.colorScheme.onSurfaceVariant else StateError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusCard(config: KazeiaConfigClient.Config?, status: KazeiaRagClient.RagStatus?, onToggle: (Boolean) -> Unit) {
|
||||
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("RAG (mémoire documentaire)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Switch(checked = config?.ragEnabled == true, onCheckedChange = onToggle)
|
||||
}
|
||||
val ready = status?.ready == true
|
||||
val badge = when {
|
||||
config?.ragEnabled != true -> "désactivé"
|
||||
ready -> "actif · embedder prêt"
|
||||
else -> "activé · embedder NON prêt (modèle absent ?)"
|
||||
}
|
||||
Text(badge, style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (config?.ragEnabled == true && !ready) StateError else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
status?.let { s ->
|
||||
Text("Modèle : ${s.model.ifEmpty { "—" }} (dim ${s.dim}) · ${s.docCount} doc · ${s.chunkCount} fragments" +
|
||||
if (s.pending > 0) " · ${s.pending} à indexer" else "",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsCard(config: KazeiaConfigClient.Config?, onThreshold: (Float) -> Unit, onTopK: (Int) -> Unit) {
|
||||
if (config == null) return
|
||||
var localT by remember(config.ragThreshold) { mutableStateOf(config.ragThreshold) }
|
||||
Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text("Réglages de récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
Text("Seuil de similarité : ${"%.2f".format(localT)}", style = MaterialTheme.typography.bodyMedium)
|
||||
Slider(value = localT, onValueChange = { localT = it }, valueRange = 0.5f..0.95f,
|
||||
onValueChangeFinished = { onThreshold(localT) })
|
||||
Text("Plus haut = plus strict (moins de contexte injecté, mais plus pertinent).",
|
||||
style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text("Nombre max de fragments : ${config.ragTopK}", modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium)
|
||||
IconButton(onClick = { if (config.ragTopK > 1) onTopK(config.ragTopK - 1) }) { Text("–", style = MaterialTheme.typography.titleLarge) }
|
||||
IconButton(onClick = { if (config.ragTopK < 8) onTopK(config.ragTopK + 1) }) { Text("+", style = MaterialTheme.typography.titleLarge) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestCard(
|
||||
query: String, results: List<KazeiaRagClient.ScoredHit>?, threshold: Float,
|
||||
onChange: (String) -> Unit, onRun: () -> Unit
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Tester la récupération", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
|
||||
OutlinedTextField(value = query, onValueChange = onChange, modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Message patient simulé") }, singleLine = false)
|
||||
Button(onClick = onRun, enabled = query.trim().length >= 3) { Text("Tester") }
|
||||
results?.let { res ->
|
||||
if (res.isEmpty()) {
|
||||
Text("Aucun candidat.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
Text("Candidats (✓ = injecté au seuil ${"%.2f".format(threshold)}) :", style = MaterialTheme.typography.bodySmall)
|
||||
res.forEach { h ->
|
||||
val pass = h.score >= threshold
|
||||
Column(Modifier.padding(vertical = 2.dp)) {
|
||||
Text("${if (pass) "✓" else "✗"} ${h.source} · ${"%.3f".format(h.score)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (pass) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = if (pass) FontWeight.SemiBold else FontWeight.Normal)
|
||||
Text(h.text.take(120), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DocEditor(
|
||||
editing: Editing, modifier: Modifier,
|
||||
onChange: (Editing) -> Unit, onCancel: () -> Unit, onSave: (Editing) -> Unit, onDelete: (String) -> Unit
|
||||
) {
|
||||
Column(modifier.verticalScroll(rememberScrollState()).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(value = editing.source, onValueChange = { onChange(editing.copy(source = it)) },
|
||||
modifier = Modifier.fillMaxWidth(), label = { Text("Titre / source (identifiant unique)") },
|
||||
enabled = editing.isNew, singleLine = true)
|
||||
OutlinedTextField(value = editing.text, onValueChange = { onChange(editing.copy(text = it)) },
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 280.dp), label = { Text("Contenu du document") },
|
||||
supportingText = { Text("${editing.text.length} caractères") })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (!editing.isNew) {
|
||||
IconButton(onClick = { onDelete(editing.source) }) {
|
||||
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = StateError)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onCancel) { Text("Annuler") }
|
||||
Button(onClick = { onSave(editing) },
|
||||
enabled = editing.source.trim().isNotEmpty() && editing.text.trim().length >= 10) { Text("Enregistrer") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineCard(modifier: Modifier) {
|
||||
Box(modifier.padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Card(colors = CardDefaults.cardColors(containerColor = StateError.copy(alpha = 0.1f))) {
|
||||
Text("Kazeia patient non joignable. Lancer l'app Kazeia puis revenir ici.",
|
||||
Modifier.padding(16.dp), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,560 +0,0 @@
|
|||
package com.kazeia.admin.ui.system
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.repository.ConfigRepository
|
||||
import com.kazeia.admin.data.source.KazeiaConfigClient
|
||||
import com.kazeia.admin.data.source.KazeiaDistConfigClient
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import com.kazeia.admin.ui.theme.StateReady
|
||||
import com.kazeia.admin.ui.theme.StateRecorded
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SystemScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { ConfigRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
val distClient = remember { KazeiaDistConfigClient(context.contentResolver) }
|
||||
|
||||
var config by remember { mutableStateOf<KazeiaConfigClient.Config?>(null) }
|
||||
var models by remember { mutableStateOf<List<KazeiaConfigClient.ModelInfo>>(emptyList()) }
|
||||
var distRow by remember { mutableStateOf<KazeiaDistConfigClient.DistConfigRow?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
config = repo.config()
|
||||
models = repo.models()
|
||||
distRow = distClient.query()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_system)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
val cfg = config
|
||||
if (cfg == null) {
|
||||
OfflineNotice(Modifier.padding(padding))
|
||||
return@Scaffold
|
||||
}
|
||||
val speakerModels = models.filter {
|
||||
it.role == "SPEAKER" || it.role == "BOTH"
|
||||
}
|
||||
val thinkerModels = models.filter {
|
||||
it.role == "THINKER" || it.role == "BOTH"
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
CascadeCard(
|
||||
enabled = cfg.cascadeEnabled,
|
||||
onToggle = { newValue ->
|
||||
val ok = repo.updateCascade(newValue)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Cascade ${if (newValue) "activée" else "désactivée"} — appliquée immédiatement."
|
||||
else "Échec : Kazeia patient injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
TtsCard(
|
||||
enabled = cfg.ttsEnabled,
|
||||
onToggle = { newValue ->
|
||||
val ok = repo.updateTts(newValue)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Synthèse vocale ${if (newValue) "activée" else "désactivée"} — appliquée au prochain message."
|
||||
else "Échec : Kazeia patient injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
ModelSection(
|
||||
title = "Speaker",
|
||||
subtitle = "LLM qui répond au patient",
|
||||
models = speakerModels,
|
||||
currentId = cfg.speakerModelId,
|
||||
onPick = { picked ->
|
||||
val ok = repo.updateSpeakerModel(picked.id)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Speaker → ${picked.displayName}. Rechargement Kazeia (~5s)…"
|
||||
else "Échec du switch Speaker.",
|
||||
duration = SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val thinkerDimmed = !cfg.cascadeEnabled
|
||||
ModelSection(
|
||||
title = "Thinker",
|
||||
subtitle = if (thinkerDimmed)
|
||||
"LLM d'analyse — inactif tant que la cascade est désactivée"
|
||||
else "LLM d'analyse — produit les bullets clinique",
|
||||
models = thinkerModels,
|
||||
currentId = cfg.thinkerModelId,
|
||||
dimmed = thinkerDimmed,
|
||||
onPick = { picked ->
|
||||
val ok = repo.updateThinkerModel(picked.id)
|
||||
if (ok) config = repo.config()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Thinker → ${picked.displayName}."
|
||||
else "Échec du switch Thinker.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
RamWarningCard(cfg, speakerModels, thinkerModels)
|
||||
|
||||
distRow?.let { row ->
|
||||
WebDavConfigCard(
|
||||
row = row,
|
||||
onSave = { base, user, pass ->
|
||||
val ok = distClient.update(base, user, pass)
|
||||
if (ok) distRow = distClient.query()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Serveur de mise à jour enregistré."
|
||||
else "Échec : Kazeia patient injoignable.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
},
|
||||
onReset = {
|
||||
val ok = distClient.reset()
|
||||
if (ok) distRow = distClient.query()
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
if (ok) "Config réinitialisée au défaut d'usine."
|
||||
else "Échec de la réinitialisation.",
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun WebDavConfigCard(
|
||||
row: KazeiaDistConfigClient.DistConfigRow,
|
||||
onSave: (base: String, user: String, pass: String?) -> Unit,
|
||||
onReset: () -> Unit
|
||||
) {
|
||||
var base by remember(row) { mutableStateOf(row.base) }
|
||||
var user by remember(row) { mutableStateOf(row.user) }
|
||||
var pass by remember(row) { mutableStateOf("") }
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
"Serveur de mise à jour (WebDAV)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
if (row.isCustomized) "Configuration personnalisée."
|
||||
else "Configuration d'usine (compilée dans l'APK).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = base, onValueChange = { base = it },
|
||||
label = { Text("URL WebDAV") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = user, onValueChange = { user = it },
|
||||
label = { Text("Identifiant") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = pass, onValueChange = { pass = it },
|
||||
label = {
|
||||
Text(
|
||||
if (row.hasPassword) "Mot de passe (vide = inchangé)"
|
||||
else "Mot de passe"
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Button(
|
||||
onClick = { onSave(base.trim(), user.trim(), pass.ifBlank { null }) },
|
||||
enabled = base.isNotBlank() && user.isNotBlank()
|
||||
) { Text("Enregistrer") }
|
||||
Spacer(Modifier.weight(1f))
|
||||
if (row.isCustomized) {
|
||||
TextButton(onClick = onReset) { Text("Défaut d'usine") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CascadeCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Cascade Thinker → Speaker",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (enabled)
|
||||
"ACTIVÉE — le Thinker analyse chaque message avant que le Speaker réponde."
|
||||
else
|
||||
"DÉSACTIVÉE — mode mono-Speaker (réponse directe, RAM minimum).",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = StateReady,
|
||||
checkedTrackColor = StateReady.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
if (enabled) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
"💡 Le Thinker est lazy-loadé au premier message. Premier tour de la session ~+1.5 s.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TtsCard(enabled: Boolean, onToggle: (Boolean) -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
"Synthèse vocale (TTS)",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
if (enabled)
|
||||
"ACTIVÉE — Kazeia répond à voix haute."
|
||||
else
|
||||
"DÉSACTIVÉE — la réponse s'affiche progressivement à l'écran, sans voix.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = StateReady,
|
||||
checkedTrackColor = StateReady.copy(alpha = 0.4f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ModelSection(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
models: List<KazeiaConfigClient.ModelInfo>,
|
||||
currentId: String,
|
||||
dimmed: Boolean = false,
|
||||
onPick: (KazeiaConfigClient.ModelInfo) -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (dimmed)
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
val available = models.filter { it.fileExists }
|
||||
val current = available.firstOrNull { it.id == currentId }
|
||||
?: models.firstOrNull { it.id == currentId }
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded && !dimmed,
|
||||
onExpandedChange = { if (!dimmed) expanded = !expanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = current?.displayName ?: currentId,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = !dimmed,
|
||||
label = { Text("Modèle actif") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
|
||||
},
|
||||
modifier = Modifier
|
||||
.menuAnchor(androidx.compose.material3.MenuAnchorType.PrimaryNotEditable, true)
|
||||
.fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
available.forEach { m ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(m.displayName, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"${m.sizeMb} MB · seq=${m.maxSeqLen}" +
|
||||
if (m.notes.isNotEmpty()) " · ${m.notes}" else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onPick(m)
|
||||
}
|
||||
)
|
||||
}
|
||||
if (available.isEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Aucun modèle disponible sur la tablette") },
|
||||
onClick = { expanded = false },
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current != null) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Chemin : ${current.ptePath}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RamWarningCard(
|
||||
cfg: KazeiaConfigClient.Config,
|
||||
speakerModels: List<KazeiaConfigClient.ModelInfo>,
|
||||
thinkerModels: List<KazeiaConfigClient.ModelInfo>
|
||||
) {
|
||||
val sp = speakerModels.firstOrNull { it.id == cfg.speakerModelId }?.sizeMb ?: 0
|
||||
val th = if (cfg.cascadeEnabled)
|
||||
thinkerModels.firstOrNull { it.id == cfg.thinkerModelId }?.sizeMb ?: 0
|
||||
else 0
|
||||
val ttsAndStt = 1500 // TTS Talker + CP + Whisper, estimation
|
||||
val total = sp + th + ttsAndStt
|
||||
val critical = total > 9000
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (critical)
|
||||
StateError.copy(alpha = 0.12f)
|
||||
else StateRecorded.copy(alpha = 0.08f)
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Outlined.Warning,
|
||||
contentDescription = null,
|
||||
tint = if (critical) StateError else StateRecorded
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
"Budget mémoire estimé",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
val breakdown = buildString {
|
||||
appendLine("Speaker ${sp.toString().padStart(5)} MB")
|
||||
if (cfg.cascadeEnabled) appendLine("Thinker ${th.toString().padStart(5)} MB")
|
||||
else appendLine("Thinker — (cascade off)")
|
||||
appendLine("TTS+STT ${ttsAndStt.toString().padStart(5)} MB (estim.)")
|
||||
appendLine("───────")
|
||||
append("Total ~${total.toString().padStart(5)} MB")
|
||||
}
|
||||
Text(
|
||||
breakdown,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
if (critical) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"⚠ Configuration tendue — risque de Low-Memory Killer Android sur les pics TTS. " +
|
||||
"Recommandé : Thinker = Guard 0.6B, ou désactiver la cascade.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineNotice(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Kazeia patient non joignable",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"La configuration LLM ne peut pas être lue. Lance l'app Kazeia puis reviens ici.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
package com.kazeia.admin.ui.telemetry
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.model.PipelineKpi
|
||||
import com.kazeia.admin.data.model.TelemetrySnapshot
|
||||
import com.kazeia.admin.data.model.TurnEvent
|
||||
import com.kazeia.admin.data.repository.TelemetryRepository
|
||||
import com.kazeia.admin.ui.components.FilledSparkline
|
||||
import com.kazeia.admin.ui.components.Sparkline
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import com.kazeia.admin.ui.theme.StateProcessing
|
||||
import com.kazeia.admin.ui.theme.StateReady
|
||||
import com.kazeia.admin.ui.theme.StateRecorded
|
||||
import kotlinx.coroutines.delay
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val hms = SimpleDateFormat("HH:mm:ss", Locale.FRENCH)
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TelemetryScreen() {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { TelemetryRepository(context) }
|
||||
var snapshot by remember { mutableStateOf(repo.snapshot()) }
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Refresh à 2s : assez fluide pour voir les sparklines bouger, suffisamment
|
||||
// espacé pour ne pas marteler le ContentProvider.
|
||||
LaunchedEffect(tick) {
|
||||
while (true) {
|
||||
snapshot = repo.snapshot()
|
||||
delay(2_000L)
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.nav_telemetry)) },
|
||||
actions = {
|
||||
IconButton(onClick = { tick++; snapshot = repo.snapshot() }) {
|
||||
Icon(Icons.Outlined.Refresh, contentDescription = "Rafraîchir")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
if (!snapshot.kazeiaState.running) {
|
||||
item { KazeiaOfflineBanner() }
|
||||
}
|
||||
item { KazeiaStateCard(snapshot) }
|
||||
item { PipelineCard(snapshot.pipeline) }
|
||||
item { MemoryCard(snapshot) }
|
||||
item { EventsCard(snapshot.recentTurns) }
|
||||
item { CrashesCard(snapshot) }
|
||||
item { Spacer(Modifier.height(8.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KazeiaOfflineBanner() {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = StateError.copy(alpha = 0.12f)
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"⚠ Kazeia patient non joignable. Mémoire système affichée, KPI pipeline indisponibles.",
|
||||
modifier = Modifier.padding(12.dp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KazeiaStateCard(snapshot: TelemetrySnapshot) {
|
||||
val state = snapshot.kazeiaState
|
||||
SectionCard("État Kazeia") {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.background(
|
||||
if (state.running) StateReady else StateError,
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
if (state.running) "Actif" else "Arrêté",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
state.pid?.let {
|
||||
Text("PID $it", style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Spacer(Modifier.size(16.dp))
|
||||
}
|
||||
Text(
|
||||
"Uptime ${formatDuration(state.uptimeSeconds)}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
MiniStat("CPU", "%.0f %%".format(state.cpuPercent))
|
||||
MiniStat("PSS", "${state.pssMb} MB")
|
||||
MiniStat("ION (DSP)", "${state.ionMb} MB")
|
||||
MiniStat("Total app", "${state.pssMb + state.ionMb} MB")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PipelineCard(p: PipelineKpi) {
|
||||
SectionCard("Pipeline — ${p.llmTokensPerSec.size} derniers tours") {
|
||||
KpiRow("TTFT (LLM)", "${p.avgTtft()} ms", p.ttftMs.map { it.toFloat() })
|
||||
KpiRow("Décode LLM", "%.1f tok/s".format(p.avgTps()), p.llmTokensPerSec)
|
||||
KpiRow("TTS RTF", "%.2f".format(p.avgTtsRtf()), p.ttsRtf,
|
||||
hint = "< 1.0 = temps réel")
|
||||
KpiRow("STT RTF", "%.2f".format(p.avgSttRtf()), p.sttRtf)
|
||||
KpiRow("Cycle total", "%.1f s".format(p.avgTotalSec()),
|
||||
p.totalCycleMs.map { it.toFloat() })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KpiRow(
|
||||
label: String,
|
||||
value: String,
|
||||
series: List<Float>,
|
||||
hint: String? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.padding(end = 12.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
hint?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Sparkline(values = series)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemoryCard(snapshot: TelemetrySnapshot) {
|
||||
val m = snapshot.memorySeries
|
||||
SectionCard("Mémoire — 60 dernières secondes") {
|
||||
MemoryRow("PSS Kazeia", "${m.pssMb.last()} MB", m.pssMb, MaterialTheme.colorScheme.primary)
|
||||
MemoryRow("ION (DSP)", "${m.ionMb.last()} MB", m.ionMb, StateProcessing)
|
||||
MemoryRow("Mémoire libre", "${m.systemAvailMb.last()} MB", m.systemAvailMb, StateReady)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemoryRow(label: String, value: String, series: List<Int>, color: Color) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(end = 12.dp))
|
||||
Spacer(Modifier.weight(1f))
|
||||
FilledSparkline(values = series, lineColor = color)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
value,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventsCard(turns: List<TurnEvent>) {
|
||||
SectionCard("Derniers tours conversation") {
|
||||
turns.takeLast(10).reversed().forEach { turn ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
hms.format(Date(turn.timestamp)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
if (turn.ok) "✓" else "✗",
|
||||
color = if (turn.ok) StateReady else StateError,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
"Tour ${turn.turnNumber}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"%.1f tok/s · %d tok · %.1f s".format(
|
||||
turn.llmTokensPerSec,
|
||||
turn.tokensGenerated,
|
||||
turn.totalMs / 1000f
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CrashesCard(snapshot: TelemetrySnapshot) {
|
||||
SectionCard("Crashes récents (24h)") {
|
||||
if (snapshot.recentCrashes.isEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Outlined.CheckCircle, null, tint = StateReady,
|
||||
modifier = Modifier.size(24.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
"Aucun crash — production stable depuis le rebuild libexecutorch (2026-05-14)",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
} else {
|
||||
snapshot.recentCrashes.forEach { c ->
|
||||
Column(modifier = Modifier.padding(vertical = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
hms.format(Date(c.timestamp)),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
"${c.signal} — ${c.component}",
|
||||
color = StateError,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
}
|
||||
c.message?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniStat(label: String, value: String) {
|
||||
Column {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(seconds: Long): String {
|
||||
val h = seconds / 3600
|
||||
val m = (seconds % 3600) / 60
|
||||
val s = seconds % 60
|
||||
return when {
|
||||
h > 0 -> "%dh %02dm %02ds".format(h, m, s)
|
||||
m > 0 -> "%dm %02ds".format(m, s)
|
||||
else -> "${s}s"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
package com.kazeia.admin.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Palette therapeutic — purple/lavender matching Kazeia main app accent,
|
||||
// neutral dark for clinical sobriety, accent green/orange/red for state badges.
|
||||
|
||||
val Lavender = Color(0xFFBCA4E8)
|
||||
val LavenderDeep = Color(0xFF7A6BAA)
|
||||
val LavenderSoft = Color(0xFFE3D9F5)
|
||||
|
||||
val SurfaceDark = Color(0xFF1C1B2B)
|
||||
val SurfaceDarker = Color(0xFF121121)
|
||||
val SurfaceLight = Color(0xFFF7F5FA)
|
||||
val OnSurfaceDark = Color(0xFFEAE6F5)
|
||||
val OnSurfaceLight = Color(0xFF1C1B2B)
|
||||
|
||||
val MutedDark = Color(0xFF2A2940)
|
||||
val MutedLight = Color(0xFFE8E5F0)
|
||||
|
||||
// State colors
|
||||
val StateReady = Color(0xFF4CAF8C) // soft green
|
||||
val StateRecorded = Color(0xFFE0A856) // warm orange
|
||||
val StateProcessing = Color(0xFF5BA8E0) // calm blue
|
||||
val StateError = Color(0xFFD56B6B) // muted red
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
package com.kazeia.admin.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColors = darkColorScheme(
|
||||
primary = Lavender,
|
||||
onPrimary = SurfaceDarker,
|
||||
primaryContainer = LavenderDeep,
|
||||
onPrimaryContainer = LavenderSoft,
|
||||
secondary = LavenderSoft,
|
||||
onSecondary = SurfaceDarker,
|
||||
background = SurfaceDarker,
|
||||
onBackground = OnSurfaceDark,
|
||||
surface = SurfaceDark,
|
||||
onSurface = OnSurfaceDark,
|
||||
surfaceVariant = MutedDark,
|
||||
onSurfaceVariant = OnSurfaceDark,
|
||||
error = StateError,
|
||||
onError = SurfaceDarker
|
||||
)
|
||||
|
||||
private val LightColors = lightColorScheme(
|
||||
primary = LavenderDeep,
|
||||
onPrimary = SurfaceLight,
|
||||
primaryContainer = LavenderSoft,
|
||||
onPrimaryContainer = SurfaceDarker,
|
||||
secondary = LavenderDeep,
|
||||
onSecondary = SurfaceLight,
|
||||
background = SurfaceLight,
|
||||
onBackground = OnSurfaceLight,
|
||||
surface = SurfaceLight,
|
||||
onSurface = OnSurfaceLight,
|
||||
surfaceVariant = MutedLight,
|
||||
onSurfaceVariant = OnSurfaceLight,
|
||||
error = StateError,
|
||||
onError = SurfaceLight
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun KazeiaAdminTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colors = if (darkTheme) DarkColors else LightColors
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
window.statusBarColor = colors.background.toArgb()
|
||||
WindowCompat.getInsetsController(window, view)
|
||||
.isAppearanceLightStatusBars = !darkTheme
|
||||
}
|
||||
}
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colors,
|
||||
typography = AdminTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package com.kazeia.admin.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// System default typography slightly upscaled — tablet-friendly,
|
||||
// clinical sobriety. No custom font in MVP.
|
||||
val AdminTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontSize = 48.sp,
|
||||
lineHeight = 56.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
)
|
||||
|
|
@ -1,383 +0,0 @@
|
|||
package com.kazeia.admin.ui.voices
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.Stop
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
import com.kazeia.admin.ui.theme.StateReady
|
||||
import com.kazeia.admin.voice.AudioRecorder
|
||||
import com.kazeia.admin.voice.VoiceQualityValidator
|
||||
import com.kazeia.admin.voice.VoiceStorage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecordVoiceScreen(
|
||||
onCancel: () -> Unit,
|
||||
onSaved: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val recorder = remember { AudioRecorder(context) }
|
||||
val storage = remember { VoiceStorage(context) }
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
|
||||
val state by recorder.state.collectAsState()
|
||||
val rms by recorder.rmsLevel.collectAsState()
|
||||
val rmsDb by recorder.rmsDbfs.collectAsState()
|
||||
val peakDb by recorder.peakDbfs.collectAsState()
|
||||
val durationMs by recorder.durationMs.collectAsState()
|
||||
|
||||
var voiceName by remember { mutableStateOf("") }
|
||||
var permissionGranted by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var qualityReport by remember { mutableStateOf<VoiceQualityValidator.Report?>(null) }
|
||||
|
||||
val permLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission()
|
||||
) { granted -> permissionGranted = granted }
|
||||
|
||||
DisposableEffect(Unit) { onDispose { recorder.reset() } }
|
||||
|
||||
val referenceText = stringResource(
|
||||
R.string.rec_reference_text,
|
||||
voiceName.trim().ifBlank { "[prénom]" }
|
||||
)
|
||||
|
||||
// Quand l'enregistrement se termine, on lance la validation qualité
|
||||
LaunchedEffect(state) {
|
||||
if (state == AudioRecorder.State.FINISHED) {
|
||||
qualityReport = VoiceQualityValidator.analyze(recorder.samples())
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Nouvelle voix") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onCancel) {
|
||||
Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.back))
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbar) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
// 1. Nom de la voix
|
||||
OutlinedTextField(
|
||||
value = voiceName,
|
||||
onValueChange = { voiceName = it },
|
||||
label = { Text(stringResource(R.string.rec_name_label)) },
|
||||
placeholder = { Text(stringResource(R.string.rec_name_hint)) },
|
||||
singleLine = true,
|
||||
enabled = state != AudioRecorder.State.RECORDING,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
// 2. Phrase de référence (vaut consentement enregistré)
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.rec_instruction_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
referenceText,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
stringResource(R.string.rec_consent_notice),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
// 3. Visualisation enregistrement
|
||||
RecordingMeter(
|
||||
state = state,
|
||||
rms = rms,
|
||||
rmsDb = rmsDb,
|
||||
peakDb = peakDb,
|
||||
durationMs = durationMs,
|
||||
quality = qualityReport
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
// 4. Bouton record / stop
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
when (state) {
|
||||
AudioRecorder.State.IDLE, AudioRecorder.State.FINISHED -> {
|
||||
if (!permissionGranted) {
|
||||
permLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
} else {
|
||||
qualityReport = null
|
||||
recorder.reset()
|
||||
val r = recorder.start(scope)
|
||||
if (r.isFailure) {
|
||||
scope.launch {
|
||||
snackbar.showSnackbar(
|
||||
"Erreur micro : ${r.exceptionOrNull()?.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AudioRecorder.State.RECORDING -> recorder.stop()
|
||||
}
|
||||
},
|
||||
enabled = voiceName.isNotBlank(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (state == AudioRecorder.State.RECORDING)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary
|
||||
),
|
||||
shape = RoundedCornerShape(50),
|
||||
modifier = Modifier.height(64.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (state == AudioRecorder.State.RECORDING) Icons.Outlined.Stop
|
||||
else Icons.Outlined.Mic,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
when (state) {
|
||||
AudioRecorder.State.RECORDING -> stringResource(R.string.rec_stop)
|
||||
AudioRecorder.State.FINISHED -> "Refaire"
|
||||
else -> stringResource(R.string.rec_start)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Bouton save : visible quand audio capturé ET qualité OK
|
||||
val saveEnabled = state == AudioRecorder.State.FINISHED && qualityReport?.ok == true
|
||||
if (state == AudioRecorder.State.FINISHED) {
|
||||
Button(
|
||||
onClick = {
|
||||
val samples = recorder.samples()
|
||||
val durSec = samples.size.toFloat() / AudioRecorder.SAMPLE_RATE
|
||||
val saved = runCatching {
|
||||
storage.save(
|
||||
displayName = voiceName.trim(),
|
||||
samples = samples,
|
||||
durationSeconds = durSec,
|
||||
referenceText = referenceText
|
||||
)
|
||||
}
|
||||
scope.launch {
|
||||
if (saved.isSuccess) {
|
||||
val v = saved.getOrNull()!!
|
||||
snackbar.showSnackbar(
|
||||
"Voix « ${v.displayName} » enregistrée. État : recorded — brancher au PC pour finaliser."
|
||||
)
|
||||
onSaved()
|
||||
} else {
|
||||
snackbar.showSnackbar(
|
||||
"Échec de sauvegarde : ${saved.exceptionOrNull()?.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = saveEnabled,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(stringResource(R.string.rec_save))
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionGranted) {
|
||||
Text(
|
||||
"⚠ Permission micro requise pour enregistrer une voix.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingMeter(
|
||||
state: AudioRecorder.State,
|
||||
rms: Float,
|
||||
rmsDb: Float,
|
||||
peakDb: Float,
|
||||
durationMs: Long,
|
||||
quality: VoiceQualityValidator.Report?
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(170.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
when (state) {
|
||||
AudioRecorder.State.IDLE -> Text(
|
||||
"Appuyez sur le bouton pour commencer",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
AudioRecorder.State.RECORDING -> {
|
||||
Text(
|
||||
"● Enregistrement — %.1f s".format(durationMs / 1000f),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// Niveau micro normalisé pour barre — mais on amplifie
|
||||
// visuellement par 3× car la voix proche-micro reste
|
||||
// souvent dans la moitié basse de la dynamique 16-bit.
|
||||
LinearProgressIndicator(
|
||||
progress = { (rms * 3f).coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth().height(8.dp),
|
||||
color = if (rmsDb >= -34f) StateReady
|
||||
else MaterialTheme.colorScheme.error,
|
||||
trackColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
val rmsTxt = if (rmsDb > -119f) "%.0f dB".format(rmsDb) else "–∞"
|
||||
val peakTxt = if (peakDb > -119f) "%.0f dB".format(peakDb) else "–∞"
|
||||
Text(
|
||||
"Niveau : $rmsTxt crête : $peakTxt (cible RMS : −30 à −15 dB)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
AudioRecorder.State.FINISHED -> {
|
||||
val q = quality
|
||||
if (q == null) {
|
||||
Text("Analyse qualité…", style = MaterialTheme.typography.bodyLarge)
|
||||
} else if (q.ok) {
|
||||
Text(
|
||||
"✓ Audio OK — %.1f s · RMS %.0f dB · SNR %.0f dB".format(
|
||||
q.durationSeconds, q.rmsDbfs, q.snrEstimateDb
|
||||
),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = StateReady,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
} else {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"✗ Qualité insuffisante",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = StateError,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
q.issues.forEach { issue ->
|
||||
Text(
|
||||
"• $issue",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
package com.kazeia.admin.ui.voices
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kazeia.admin.R
|
||||
import com.kazeia.admin.data.model.Voice
|
||||
import com.kazeia.admin.data.model.VoiceState
|
||||
import com.kazeia.admin.data.repository.VoicesRepository
|
||||
import com.kazeia.admin.ui.components.StateBadge
|
||||
import com.kazeia.admin.ui.theme.StateError
|
||||
|
||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VoicesScreen(
|
||||
onStartRecording: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = remember { VoicesRepository(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHost = remember { SnackbarHostState() }
|
||||
var voices by remember { mutableStateOf(repo.list()) }
|
||||
var pendingDelete by remember { mutableStateOf<Voice?>(null) }
|
||||
var refreshTick by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Recharge la liste à chaque tick (manuel après delete, ou périodique).
|
||||
LaunchedEffect(refreshTick) { voices = repo.list() }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.voices_title)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
titleContentColor = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHost) },
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onStartRecording,
|
||||
icon = { Icon(Icons.Outlined.Mic, null) },
|
||||
text = { Text(stringResource(R.string.voices_add)) },
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
if (voices.isEmpty()) {
|
||||
EmptyVoicesState(modifier = Modifier.padding(padding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(voices, key = { it.id }) { voice ->
|
||||
VoiceCard(
|
||||
voice = voice,
|
||||
onDeleteClick = { pendingDelete = voice }
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(96.dp)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm delete dialog
|
||||
pendingDelete?.let { voice ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text("Supprimer la voix ?") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"« ${voice.name} » sera retirée de la tablette : " +
|
||||
"les embeddings et l'audio source seront effacés."
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Cette action est irréversible côté tablette. " +
|
||||
"Si la voix est sauvegardée sur le PC de gestion, " +
|
||||
"vous pourrez la redéployer.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val ok = repo.delete(voice.id)
|
||||
pendingDelete = null
|
||||
refreshTick++
|
||||
scope.launch {
|
||||
snackbarHost.showSnackbar(
|
||||
message = if (ok) "Voix « ${voice.name} » supprimée."
|
||||
else "Impossible de supprimer « ${voice.name} » : " +
|
||||
"voix système installée hors de l'app. " +
|
||||
"Retirez-la manuellement via : adb shell rm " +
|
||||
"/data/local/tmp/kazeia/voix/voix/${voice.id}.wav",
|
||||
duration = if (ok) SnackbarDuration.Short else SnackbarDuration.Long
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = StateError,
|
||||
contentColor = androidx.compose.ui.graphics.Color.White
|
||||
)
|
||||
) { Text("Supprimer") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { pendingDelete = null }) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyVoicesState(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
"Aucune voix détectée",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
"Vérifiez que Kazeia est lancé sur cette tablette, " +
|
||||
"puis revenez ici. Vous pourrez enregistrer une nouvelle voix " +
|
||||
"avec le bouton ci-dessous.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VoiceCard(
|
||||
voice: Voice,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
voice.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
StateBadge(voice.state)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Sous-titre : durée + norme (si dispo)
|
||||
val subtitle = buildString {
|
||||
voice.durationSeconds?.let { append("%.1f s".format(it)) }
|
||||
voice.speakerNorm?.let {
|
||||
if (isNotEmpty()) append(" · ")
|
||||
append("norme %.2f".format(it))
|
||||
}
|
||||
}
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// Message d'erreur ou statut
|
||||
voice.errorMessage?.let { msg ->
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(
|
||||
msg,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (voice.state == VoiceState.ERROR)
|
||||
MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
when (voice.state) {
|
||||
VoiceState.READY -> {
|
||||
TextButton(onClick = {}) { Text(stringResource(R.string.voices_test)) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = onDeleteClick,
|
||||
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||
contentColor = StateError
|
||||
)
|
||||
) { Text(stringResource(R.string.voices_delete)) }
|
||||
}
|
||||
VoiceState.RECORDED -> {
|
||||
Text(
|
||||
"🔌 Brancher au PC de gestion pour finaliser",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = onDeleteClick,
|
||||
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||
contentColor = StateError
|
||||
)
|
||||
) { Text(stringResource(R.string.voices_delete)) }
|
||||
}
|
||||
VoiceState.PROCESSING -> {
|
||||
Text(
|
||||
"Extraction en cours sur le PC…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
VoiceState.ERROR -> {
|
||||
TextButton(onClick = {}) { Text(stringResource(R.string.voices_rerecord)) }
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(
|
||||
onClick = onDeleteClick,
|
||||
colors = androidx.compose.material3.ButtonDefaults.textButtonColors(
|
||||
contentColor = StateError
|
||||
)
|
||||
) { Text(stringResource(R.string.voices_delete)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
package com.kazeia.admin.voice
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.media.audiofx.AutomaticGainControl
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Capture audio 16 kHz mono PCM 16-bit pour le clonage vocal Kazeia.
|
||||
*
|
||||
* - Buffer en RAM (~10-15 s × 32 KB/s = ~480 KB max, safe)
|
||||
* - Émet le niveau RMS courant via [rmsLevel] pour la waveform live
|
||||
* - Pas de stop automatique sur silence en MVP (le clinicien clique stop)
|
||||
* - Le WAV final est écrit séparément via [WavWriter] depuis [samples]
|
||||
*/
|
||||
class AudioRecorder(private val context: Context) {
|
||||
|
||||
enum class State { IDLE, RECORDING, FINISHED }
|
||||
|
||||
companion object {
|
||||
const val SAMPLE_RATE = 16_000
|
||||
const val CHANNEL = AudioFormat.CHANNEL_IN_MONO
|
||||
const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
|
||||
const val MIN_DURATION_S = 10
|
||||
const val MAX_DURATION_S = 25
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(State.IDLE)
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
private val _rmsLevel = MutableStateFlow(0f) // 0..1 normalisé
|
||||
val rmsLevel: StateFlow<Float> = _rmsLevel.asStateFlow()
|
||||
|
||||
private val _rmsDbfs = MutableStateFlow(-120f)
|
||||
val rmsDbfs: StateFlow<Float> = _rmsDbfs.asStateFlow()
|
||||
|
||||
private val _peakDbfs = MutableStateFlow(-120f)
|
||||
val peakDbfs: StateFlow<Float> = _peakDbfs.asStateFlow()
|
||||
|
||||
private val _durationMs = MutableStateFlow(0L)
|
||||
val durationMs: StateFlow<Long> = _durationMs.asStateFlow()
|
||||
|
||||
@Volatile private var samplesBuffer: ShortArray = ShortArray(0)
|
||||
@Volatile private var stopRequested: Boolean = false
|
||||
private var captureJob: Job? = null
|
||||
private var record: AudioRecord? = null
|
||||
private var agc: AutomaticGainControl? = null
|
||||
private var ns: NoiseSuppressor? = null
|
||||
|
||||
fun hasRecordPermission(): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** Renvoie les samples capturés (read-only snapshot). Vide si pas encore enregistré. */
|
||||
fun samples(): ShortArray = samplesBuffer
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(scope: CoroutineScope): Result<Unit> {
|
||||
if (!hasRecordPermission()) return Result.failure(SecurityException("RECORD_AUDIO non accordée"))
|
||||
if (_state.value == State.RECORDING) return Result.success(Unit)
|
||||
|
||||
val minBuf = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL, ENCODING)
|
||||
if (minBuf <= 0) return Result.failure(IllegalStateException("AudioRecord.getMinBufferSize=$minBuf"))
|
||||
val bufSize = maxOf(minBuf * 4, 8192)
|
||||
|
||||
// VOICE_RECOGNITION engage l'AGC + NS du device par défaut sur la
|
||||
// plupart des Android, ce qui normalise le niveau de la voix
|
||||
// proche-micro (la source MIC brute donne -35 dB sur tablette,
|
||||
// VOICE_RECOGNITION ramène autour de -20 à -15 dB). Pour le
|
||||
// cloning, c'est un compromis acceptable — l'encodeur Qwen3-TTS
|
||||
// normalise lui-même les embeddings.
|
||||
val rec = try {
|
||||
AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||
SAMPLE_RATE, CHANNEL, ENCODING, bufSize)
|
||||
} catch (e: Throwable) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
if (rec.state != AudioRecord.STATE_INITIALIZED) {
|
||||
rec.release()
|
||||
return Result.failure(IllegalStateException("AudioRecord state=${rec.state}"))
|
||||
}
|
||||
|
||||
// AGC + NS explicitement activés si dispos (sécurité par-dessus
|
||||
// le profil VOICE_RECOGNITION) — Log si non supporté.
|
||||
if (AutomaticGainControl.isAvailable()) {
|
||||
agc = AutomaticGainControl.create(rec.audioSessionId)?.also { it.enabled = true }
|
||||
Log.i("AudioRecorder", "AGC enabled=${agc?.enabled}")
|
||||
} else {
|
||||
Log.i("AudioRecorder", "AGC not available on this device")
|
||||
}
|
||||
if (NoiseSuppressor.isAvailable()) {
|
||||
ns = NoiseSuppressor.create(rec.audioSessionId)?.also { it.enabled = true }
|
||||
Log.i("AudioRecorder", "NS enabled=${ns?.enabled}")
|
||||
}
|
||||
|
||||
record = rec
|
||||
// Pré-alloue MAX_DURATION_S + 5s (sécurité hard cap)
|
||||
val maxSamples = SAMPLE_RATE * (MAX_DURATION_S + 5)
|
||||
val accum = ShortArray(maxSamples)
|
||||
var written = 0
|
||||
stopRequested = false
|
||||
_state.value = State.RECORDING
|
||||
_rmsLevel.value = 0f
|
||||
_durationMs.value = 0L
|
||||
val t0 = System.currentTimeMillis()
|
||||
|
||||
rec.startRecording()
|
||||
captureJob = scope.launch(Dispatchers.IO) {
|
||||
val chunk = ShortArray(SAMPLE_RATE / 10) // 100 ms chunks
|
||||
var peakInt = 0
|
||||
try {
|
||||
while (!stopRequested) {
|
||||
val n = rec.read(chunk, 0, chunk.size)
|
||||
if (n <= 0) continue
|
||||
val room = accum.size - written
|
||||
val copy = minOf(n, room)
|
||||
System.arraycopy(chunk, 0, accum, written, copy)
|
||||
written += copy
|
||||
val rms = computeRms(chunk, n)
|
||||
_rmsLevel.value = (rms / 32768f).coerceIn(0f, 1f)
|
||||
_rmsDbfs.value = amplitudeToDbfs(rms)
|
||||
for (i in 0 until n) {
|
||||
val a = if (chunk[i] == Short.MIN_VALUE) Short.MAX_VALUE.toInt()
|
||||
else kotlin.math.abs(chunk[i].toInt())
|
||||
if (a > peakInt) peakInt = a
|
||||
}
|
||||
_peakDbfs.value = amplitudeToDbfs(peakInt.toFloat())
|
||||
_durationMs.value = System.currentTimeMillis() - t0
|
||||
if (written >= accum.size) break // hard cap
|
||||
}
|
||||
} catch (_: Throwable) { /* swallow */ }
|
||||
try { rec.stop() } catch (_: Throwable) {}
|
||||
try { rec.release() } catch (_: Throwable) {}
|
||||
try { agc?.release() } catch (_: Throwable) {}
|
||||
try { ns?.release() } catch (_: Throwable) {}
|
||||
agc = null; ns = null
|
||||
record = null
|
||||
// ORDRE IMPORTANT : copier les samples AVANT de publier FINISHED,
|
||||
// sinon le collector Compose lit un buffer encore vide (race).
|
||||
samplesBuffer = accum.copyOf(written)
|
||||
_rmsLevel.value = 0f
|
||||
_state.value = State.FINISHED
|
||||
}
|
||||
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
/** Demande l'arrêt — la coroutine d'IO finit son tour de boucle, copie
|
||||
* les samples, puis publie [State.FINISHED]. Bloquant côté UI : on
|
||||
* observe le StateFlow pour savoir quand la copie est terminée. */
|
||||
fun stop() {
|
||||
if (_state.value != State.RECORDING) return
|
||||
stopRequested = true
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
captureJob?.cancel()
|
||||
try { record?.stop() } catch (_: Throwable) {}
|
||||
try { record?.release() } catch (_: Throwable) {}
|
||||
record = null
|
||||
samplesBuffer = ShortArray(0)
|
||||
stopRequested = false
|
||||
_state.value = State.IDLE
|
||||
_rmsLevel.value = 0f
|
||||
_durationMs.value = 0L
|
||||
}
|
||||
|
||||
private fun computeRms(buf: ShortArray, n: Int): Float {
|
||||
if (n <= 0) return 0f
|
||||
var sum = 0.0
|
||||
for (i in 0 until n) {
|
||||
val v = buf[i].toDouble()
|
||||
sum += v * v
|
||||
}
|
||||
return sqrt(sum / n).toFloat()
|
||||
}
|
||||
|
||||
private fun amplitudeToDbfs(amplitude: Float): Float {
|
||||
if (amplitude <= 1f) return -120f
|
||||
return 20f * log10(amplitude / 32768f)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
package com.kazeia.admin.voice
|
||||
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Validation qualité d'un enregistrement audio pour le clonage vocal.
|
||||
* Calcule durée, RMS, peak, SNR estimé, et produit un verdict UI.
|
||||
*
|
||||
* Critères calibrés pour le pipeline Qwen3-TTS (cf incident Amir 2026-05-14
|
||||
* où une norme speaker hors plage = mode collapse Talker "beg beg beg").
|
||||
*/
|
||||
object VoiceQualityValidator {
|
||||
|
||||
data class Report(
|
||||
val durationSeconds: Float,
|
||||
val rmsDbfs: Float,
|
||||
val peakDbfs: Float,
|
||||
val snrEstimateDb: Float,
|
||||
val issues: List<String>
|
||||
) {
|
||||
val ok: Boolean get() = issues.isEmpty()
|
||||
}
|
||||
|
||||
private const val SAMPLE_RATE = 16_000
|
||||
// Plages calibrées pour la phrase de référence avec consentement
|
||||
// (~13-15s lecture normale). Tolérance large pour ne pas pénaliser
|
||||
// les locuteurs lents ou les tablettes au micro un peu sourd.
|
||||
private const val MIN_DURATION_S = 10f
|
||||
private const val MAX_DURATION_S = 20f
|
||||
// Seuil RMS relâché à -34 dB après tests réels sur OnePlus Pad 3 :
|
||||
// VOICE_RECOGNITION + AGC du device donne ~-20 à -30 dB en parole
|
||||
// posée proche-micro, le seuil doit accommoder la variance.
|
||||
private const val MIN_RMS_DBFS = -34f
|
||||
private const val MAX_RMS_DBFS = -6f
|
||||
private const val MAX_PEAK_DBFS = -1f
|
||||
private const val MIN_SNR_DB = 15f
|
||||
|
||||
fun analyze(samples: ShortArray): Report {
|
||||
if (samples.isEmpty()) return Report(0f, -120f, -120f, 0f, listOf("Audio vide"))
|
||||
|
||||
val duration = samples.size.toFloat() / SAMPLE_RATE
|
||||
val rms = computeRms(samples)
|
||||
val peak = computePeak(samples)
|
||||
val snr = estimateSnr(samples)
|
||||
|
||||
val issues = mutableListOf<String>()
|
||||
when {
|
||||
duration < MIN_DURATION_S -> issues += "Audio trop court (%.1fs, min %.0fs)".format(duration, MIN_DURATION_S)
|
||||
duration > MAX_DURATION_S -> issues += "Audio trop long (%.1fs, max %.0fs)".format(duration, MAX_DURATION_S)
|
||||
}
|
||||
when {
|
||||
rms < MIN_RMS_DBFS -> issues += "Volume trop faible (%.0fdB, min %.0fdB) — rapprochez-vous du micro".format(rms, MIN_RMS_DBFS)
|
||||
rms > MAX_RMS_DBFS -> issues += "Volume trop fort (%.0fdB, max %.0fdB) — éloignez-vous du micro".format(rms, MAX_RMS_DBFS)
|
||||
}
|
||||
if (peak > MAX_PEAK_DBFS) {
|
||||
issues += "Saturation détectée (peak %.1fdB) — parlez moins fort".format(peak)
|
||||
}
|
||||
if (snr < MIN_SNR_DB) {
|
||||
issues += "Bruit ambiant trop élevé (SNR %.0fdB, min %.0fdB) — endroit plus calme".format(snr, MIN_SNR_DB)
|
||||
}
|
||||
|
||||
return Report(duration, rms, peak, snr, issues)
|
||||
}
|
||||
|
||||
private fun computeRms(s: ShortArray): Float {
|
||||
var sum = 0.0
|
||||
for (v in s) {
|
||||
val d = v.toDouble()
|
||||
sum += d * d
|
||||
}
|
||||
val rms = sqrt(sum / s.size)
|
||||
return amplitudeToDbfs(rms.toFloat())
|
||||
}
|
||||
|
||||
private fun computePeak(s: ShortArray): Float {
|
||||
var maxAbs = 0
|
||||
for (v in s) {
|
||||
val a = if (v == Short.MIN_VALUE) Short.MAX_VALUE.toInt() else kotlin.math.abs(v.toInt())
|
||||
if (a > maxAbs) maxAbs = a
|
||||
}
|
||||
return amplitudeToDbfs(maxAbs.toFloat())
|
||||
}
|
||||
|
||||
/**
|
||||
* SNR grossier : compare le RMS du signal vs RMS estimé du bruit
|
||||
* (15ᵉ percentile des RMS de fenêtres 100 ms). C'est une heuristique
|
||||
* suffisante pour détecter un environnement clairement bruité.
|
||||
*/
|
||||
private fun estimateSnr(s: ShortArray): Float {
|
||||
if (s.size < SAMPLE_RATE) return 0f
|
||||
val frame = SAMPLE_RATE / 10 // 100 ms
|
||||
val rmsPerFrame = mutableListOf<Float>()
|
||||
var i = 0
|
||||
while (i + frame <= s.size) {
|
||||
var sum = 0.0
|
||||
for (j in i until i + frame) {
|
||||
val d = s[j].toDouble(); sum += d * d
|
||||
}
|
||||
rmsPerFrame.add(sqrt(sum / frame).toFloat())
|
||||
i += frame
|
||||
}
|
||||
if (rmsPerFrame.size < 5) return 0f
|
||||
rmsPerFrame.sort()
|
||||
val noiseRms = max(rmsPerFrame[rmsPerFrame.size / 7], 1f) // 15e percentile
|
||||
val signalRms = rmsPerFrame.last()
|
||||
return 20f * log10(signalRms / noiseRms)
|
||||
}
|
||||
|
||||
private fun amplitudeToDbfs(amplitude: Float): Float {
|
||||
if (amplitude <= 1f) return -120f
|
||||
return 20f * log10(amplitude / 32768f)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
package com.kazeia.admin.voice
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Persistance locale des voix enregistrées via l'admin app.
|
||||
* Stockage : `getExternalFilesDir("voix")` = `/storage/emulated/0/Android/data/com.kazeia.admin/files/voix/`
|
||||
*
|
||||
* - Lisible/écrivable uniquement par l'admin app (SELinux app_data_file)
|
||||
* - Visible via `adb pull` → workflow PC tool peut ramasser les WAV
|
||||
* - Pas d'embeddings ici (extraction PC) → état = "recorded"
|
||||
*/
|
||||
class VoiceStorage(private val context: Context) {
|
||||
|
||||
data class RecordedVoice(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val wavFile: File,
|
||||
val manifestFile: File,
|
||||
val createdAt: Long,
|
||||
val durationSeconds: Float
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val SUBDIR = "voix"
|
||||
private val SLUG_RE = Regex("[^a-z0-9_-]")
|
||||
private val TS_FMT = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||
}
|
||||
|
||||
private fun dir(): File {
|
||||
val base = context.getExternalFilesDir(null) ?: context.filesDir
|
||||
return File(base, SUBDIR).also { it.mkdirs() }
|
||||
}
|
||||
|
||||
private fun slug(name: String): String {
|
||||
val lower = name.trim().lowercase().replace(' ', '_')
|
||||
return SLUG_RE.replace(lower, "")
|
||||
}
|
||||
|
||||
fun save(
|
||||
displayName: String,
|
||||
samples: ShortArray,
|
||||
durationSeconds: Float,
|
||||
referenceText: String
|
||||
): RecordedVoice {
|
||||
val ts = TS_FMT.format(System.currentTimeMillis())
|
||||
val id = "${slug(displayName)}_$ts"
|
||||
val wav = File(dir(), "$id.wav")
|
||||
WavWriter.write(wav, samples)
|
||||
|
||||
val manifest = File(dir(), "$id.json")
|
||||
val json = JSONObject().apply {
|
||||
put("id", id)
|
||||
put("name", displayName.trim())
|
||||
put("state", "recorded")
|
||||
put("wav", wav.name)
|
||||
put("wav_size_bytes", wav.length())
|
||||
put("duration_seconds", durationSeconds.toDouble())
|
||||
put("created_at", System.currentTimeMillis())
|
||||
put("reference_text", referenceText)
|
||||
put("source_device", "tablet-admin-recording")
|
||||
}
|
||||
manifest.writeText(json.toString(2))
|
||||
|
||||
return RecordedVoice(
|
||||
id = id,
|
||||
displayName = displayName,
|
||||
wavFile = wav,
|
||||
manifestFile = manifest,
|
||||
createdAt = json.getLong("created_at"),
|
||||
durationSeconds = durationSeconds
|
||||
)
|
||||
}
|
||||
|
||||
fun listLocal(): List<RecordedVoice> {
|
||||
val out = mutableListOf<RecordedVoice>()
|
||||
dir().listFiles { f -> f.name.endsWith(".json") }?.forEach { mf ->
|
||||
runCatching {
|
||||
val js = JSONObject(mf.readText())
|
||||
val wavName = js.optString("wav")
|
||||
val wavFile = File(dir(), wavName)
|
||||
if (!wavFile.exists()) return@forEach
|
||||
out += RecordedVoice(
|
||||
id = js.getString("id"),
|
||||
displayName = js.optString("name", js.getString("id")),
|
||||
wavFile = wavFile,
|
||||
manifestFile = mf,
|
||||
createdAt = js.optLong("created_at", mf.lastModified()),
|
||||
durationSeconds = js.optDouble("duration_seconds", 0.0).toFloat()
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
fun delete(voice: RecordedVoice): Boolean {
|
||||
val ok1 = voice.wavFile.delete()
|
||||
val ok2 = voice.manifestFile.delete()
|
||||
return ok1 || ok2
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
package com.kazeia.admin.voice
|
||||
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Écrit un fichier WAV PCM 16-bit, mono, à partir d'un tableau de samples
|
||||
* `ShortArray`. Header WAV minimal mais conforme aux outils standards
|
||||
* (Audacity, ffmpeg, lecteur Android, l'encodeur PC Qwen3-TTS).
|
||||
*/
|
||||
object WavWriter {
|
||||
|
||||
fun write(file: File, samples: ShortArray, sampleRate: Int = 16_000): File {
|
||||
file.parentFile?.mkdirs()
|
||||
val byteData = ByteBuffer
|
||||
.allocate(samples.size * 2)
|
||||
.order(ByteOrder.LITTLE_ENDIAN)
|
||||
.apply { for (s in samples) putShort(s) }
|
||||
.array()
|
||||
|
||||
FileOutputStream(file).use { fos ->
|
||||
DataOutputStream(fos).use { out ->
|
||||
writeHeader(out, byteData.size, sampleRate, 1, 16)
|
||||
out.write(byteData)
|
||||
}
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
private fun writeHeader(
|
||||
out: DataOutputStream,
|
||||
dataSize: Int,
|
||||
sampleRate: Int,
|
||||
channels: Int,
|
||||
bitsPerSample: Int
|
||||
) {
|
||||
val byteRate = sampleRate * channels * bitsPerSample / 8
|
||||
val blockAlign = channels * bitsPerSample / 8
|
||||
|
||||
out.writeBytes("RIFF")
|
||||
out.writeIntLE(36 + dataSize)
|
||||
out.writeBytes("WAVE")
|
||||
|
||||
out.writeBytes("fmt ")
|
||||
out.writeIntLE(16) // fmt chunk size (PCM)
|
||||
out.writeShortLE(1) // audio format = PCM
|
||||
out.writeShortLE(channels)
|
||||
out.writeIntLE(sampleRate)
|
||||
out.writeIntLE(byteRate)
|
||||
out.writeShortLE(blockAlign)
|
||||
out.writeShortLE(bitsPerSample)
|
||||
|
||||
out.writeBytes("data")
|
||||
out.writeIntLE(dataSize)
|
||||
}
|
||||
|
||||
private fun DataOutputStream.writeIntLE(v: Int) {
|
||||
write(v and 0xff)
|
||||
write((v shr 8) and 0xff)
|
||||
write((v shr 16) and 0xff)
|
||||
write((v shr 24) and 0xff)
|
||||
}
|
||||
|
||||
private fun DataOutputStream.writeShortLE(v: Int) {
|
||||
write(v and 0xff)
|
||||
write((v shr 8) and 0xff)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Simple "K" + gear-like halo for admin -->
|
||||
<path
|
||||
android:fillColor="#BCA4E8"
|
||||
android:pathData="M54 28 m-3 0 a3 3 0 1 0 6 0 a3 3 0 1 0 -6 0" />
|
||||
<path
|
||||
android:fillColor="#BCA4E8"
|
||||
android:pathData="M40 42 L40 80 L48 80 L48 64 L60 80 L70 80 L56 62 L70 42 L60 42 L48 58 L48 42 Z" />
|
||||
<path
|
||||
android:strokeColor="#7A6BAA"
|
||||
android:strokeWidth="1.5"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54 16 a38 38 0 1 0 0 76 a38 38 0 1 0 0 -76" />
|
||||
</vector>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_admin_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_admin_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_admin_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_admin_background">#1C1B2B</color>
|
||||
</resources>
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Kazeia Admin</string>
|
||||
|
||||
<!-- Navigation -->
|
||||
<string name="nav_voices">Voix</string>
|
||||
<string name="nav_profiles">Profils patients</string>
|
||||
<string name="nav_prompts">Prompts</string>
|
||||
<string name="nav_rag">Base documentaire</string>
|
||||
<string name="nav_history">Historique</string>
|
||||
<string name="nav_telemetry">Telemetry</string>
|
||||
<string name="nav_system">Système</string>
|
||||
|
||||
<!-- Login -->
|
||||
<string name="login_title">Accès clinicien</string>
|
||||
<string name="login_subtitle">Saisissez le code PIN</string>
|
||||
<string name="login_error_attempts">Erreur — %1$d essai(s) restant(s)</string>
|
||||
<string name="login_locked">Verrouillé. Réessayez plus tard.</string>
|
||||
|
||||
<!-- Voices -->
|
||||
<string name="voices_title">Voix disponibles</string>
|
||||
<string name="voices_add">+ Enregistrer nouvelle voix</string>
|
||||
<string name="voices_state_recorded">À finaliser sur PC</string>
|
||||
<string name="voices_state_processing">En cours d\'extraction sur PC</string>
|
||||
<string name="voices_state_ready">Prête</string>
|
||||
<string name="voices_state_error">Erreur</string>
|
||||
<string name="voices_builtin">Système</string>
|
||||
<string name="voices_test">Tester</string>
|
||||
<string name="voices_choose">Choisir</string>
|
||||
<string name="voices_delete">Supprimer</string>
|
||||
<string name="voices_rerecord">Réenregistrer</string>
|
||||
|
||||
<!-- Recording -->
|
||||
<string name="rec_name_label">Nom de la voix</string>
|
||||
<string name="rec_name_hint">Ex : Mamie, Pierre, Sophie</string>
|
||||
<string name="rec_instruction_title">Lisez la phrase suivante d\'une voix posée et naturelle</string>
|
||||
<string name="rec_reference_text">Bonjour, je m\'appelle %1$s. J\'autorise l\'application Kazeia à utiliser ma voix dans le seul cadre de mon accompagnement personnel, et je m\'oppose à toute autre réutilisation. Au bord de la mer, le soleil se couche : un moment de calme et de souvenirs heureux.</string>
|
||||
<string name="rec_consent_notice">Cette phrase vaut consentement enregistré : votre voix ne sera utilisée que par Kazeia pour vous accompagner. Vous pouvez supprimer la voix à tout moment.</string>
|
||||
<string name="rec_start">Commencer l\'enregistrement</string>
|
||||
<string name="rec_stop">Arrêter</string>
|
||||
<string name="rec_replay">Réécouter</string>
|
||||
<string name="rec_redo">Refaire</string>
|
||||
<string name="rec_save">Enregistrer cette voix</string>
|
||||
<string name="rec_quality_ok">Qualité audio OK</string>
|
||||
<string name="rec_quality_too_short">Audio trop court (min 8 s)</string>
|
||||
<string name="rec_quality_too_long">Audio trop long (max 15 s)</string>
|
||||
<string name="rec_quality_too_quiet">Trop loin du micro</string>
|
||||
<string name="rec_quality_too_loud">Trop près du micro / saturation</string>
|
||||
<string name="rec_quality_noisy">Bruit ambiant trop élevé</string>
|
||||
|
||||
<!-- Common -->
|
||||
<string name="cancel">Annuler</string>
|
||||
<string name="confirm">Confirmer</string>
|
||||
<string name="delete">Supprimer</string>
|
||||
<string name="back">Retour</string>
|
||||
|
||||
<!-- Stub screens -->
|
||||
<string name="stub_coming_soon">Bientôt disponible</string>
|
||||
</resources>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KazeiaAdmin" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
|
@ -1,56 +1,19 @@
|
|||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
// Identifiants WebDAV de distribution (Nextcloud) lus depuis local.properties
|
||||
// — jamais commités. Voir local.properties + project_kazeia_installer_updates.
|
||||
val localProps = Properties().apply {
|
||||
val f = rootProject.file("local.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
fun localProp(key: String, default: String = ""): String =
|
||||
localProps.getProperty(key) ?: default
|
||||
|
||||
// Keystore release — identifiants lus depuis /opt/Kazeia/keystore/credentials.properties
|
||||
// (hors git, à SAUVEGARDER hors machine). Voir docs/RELEASE_SIGNING.md.
|
||||
// Absent (autre machine) → le build release retombe sur la clé debug avec un warning.
|
||||
val releaseKeystoreProps = Properties().apply {
|
||||
val f = file("/opt/Kazeia/keystore/credentials.properties")
|
||||
if (f.exists()) f.inputStream().use { load(it) }
|
||||
}
|
||||
val hasReleaseKeystore = releaseKeystoreProps.getProperty("storeFile")
|
||||
?.let { file(it).exists() } == true
|
||||
|
||||
android {
|
||||
namespace = "com.kazeia"
|
||||
compileSdk = 36
|
||||
ndkVersion = "27.3.13750724"
|
||||
|
||||
signingConfigs {
|
||||
if (hasReleaseKeystore) {
|
||||
create("release") {
|
||||
storeFile = file(releaseKeystoreProps.getProperty("storeFile"))
|
||||
storePassword = releaseKeystoreProps.getProperty("storePassword")
|
||||
keyAlias = releaseKeystoreProps.getProperty("keyAlias")
|
||||
keyPassword = releaseKeystoreProps.getProperty("keyPassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.kazeia"
|
||||
minSdk = 28
|
||||
targetSdk = 36
|
||||
versionCode = 11
|
||||
versionName = "0.1.10"
|
||||
|
||||
// WebDAV de distribution — injecté depuis local.properties.
|
||||
buildConfigField("String", "WEBDAV_BASE", "\"${localProp("webdav.base")}\"")
|
||||
buildConfigField("String", "WEBDAV_USER", "\"${localProp("webdav.user")}\"")
|
||||
buildConfigField("String", "WEBDAV_PASS", "\"${localProp("webdav.pass")}\"")
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-mvp"
|
||||
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
|
|
@ -78,12 +41,6 @@ android {
|
|||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
if (hasReleaseKeystore) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
} else {
|
||||
logger.warn("⚠ keystore release absent (/opt/Kazeia/keystore/) — release signé en DEBUG. Voir docs/RELEASE_SIGNING.md")
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -108,21 +65,7 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
// Reproductibilité des jniLibs : les .so (gitignorés, 5 chaînes de build) doivent
|
||||
// correspondre au manifest versionné. Un build RELEASE avec des libs divergentes
|
||||
// échoue ; le debug reste libre (itération engine). Cf scripts/jnilibs.sh.
|
||||
val verifyJniLibs = tasks.register<Exec>("verifyJniLibs") {
|
||||
commandLine("bash", rootProject.file("scripts/jnilibs.sh").absolutePath, "verify")
|
||||
}
|
||||
tasks.matching { it.name == "preReleaseBuild" }.configureEach {
|
||||
dependsOn(verifyJniLibs)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Tests unitaires JVM (src/test/) — logique pure : CrisisGuard, VectorIndex, Chunker.
|
||||
// Lancer : ./gradlew :app:testDebugUnitTest
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
// Android
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
|
|
@ -151,18 +94,6 @@ dependencies {
|
|||
implementation("com.facebook.soloader:nativeloader:0.10.5")
|
||||
implementation(files("libs/executorch.jar"))
|
||||
|
||||
// OkHttp — client WebDAV pour téléchargement modèles depuis Nextcloud
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
// WorkManager — orchestration du téléchargement modèles (foreground, reprise)
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.1")
|
||||
|
||||
// SQLCipher for encrypted conversation history (RGPD / secret médical)
|
||||
implementation("net.zetetic:sqlcipher-android:4.5.4@aar")
|
||||
implementation("androidx.sqlite:sqlite-ktx:2.4.0")
|
||||
// EncryptedSharedPreferences pour stocker la passphrase SQLCipher
|
||||
// (clef wrappée par Android Keystore, persistante).
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// Unity as a Library (UaaL) — DISABLED
|
||||
// implementation(project(":unityLibrary"))
|
||||
// implementation("androidx.games:games-activity:3.0.5")
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".KazeiaApplication"
|
||||
|
|
@ -23,9 +19,9 @@
|
|||
|
||||
<uses-native-library android:name="libcdsprpc.so" android:required="false" />
|
||||
|
||||
<!-- LAUNCHER: Onboarding (vérifie/télécharge les modèles au 1er lancement) -->
|
||||
<!-- LAUNCHER: Splash screen (loads ML models) -->
|
||||
<activity
|
||||
android:name=".ui.OnboardingActivity"
|
||||
android:name=".ui.SplashActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.Kazeia.Splash">
|
||||
|
|
@ -35,13 +31,6 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Splash screen (loads ML models) — lancé par OnboardingActivity -->
|
||||
<activity
|
||||
android:name=".ui.SplashActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.Kazeia.Splash" />
|
||||
|
||||
<!-- Unity AvatarActivity — DISABLED -->
|
||||
<!--
|
||||
<activity
|
||||
|
|
@ -59,62 +48,14 @@
|
|||
android:screenOrientation="unspecified"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.ProfilePickerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="unspecified"
|
||||
android:theme="@style/Theme.Kazeia" />
|
||||
|
||||
<service
|
||||
android:name=".service.KazeiaService"
|
||||
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
|
||||
android:foregroundServiceType="microphone|specialUse"
|
||||
android:exported="true">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="AI inference service for emotional support chatbot" />
|
||||
</service>
|
||||
|
||||
<!-- v2 rewrite : ChatActivityV2 test page + KazeiaServiceV2 (Phase 1 STT) -->
|
||||
<activity
|
||||
android:name=".v2.ChatActivityV2"
|
||||
android:exported="true"
|
||||
android:label="Kazeia v2"
|
||||
android:screenOrientation="unspecified"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".v2.KazeiaServiceV2"
|
||||
android:foregroundServiceType="microphone|mediaPlayback|specialUse"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- WorkManager : déclare le type FGS dataSync sur son service foreground
|
||||
(requis API 34+ pour le worker de téléchargement modèles). -->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<!-- Statut des sessions PackageInstaller (self-update APK). -->
|
||||
<receiver
|
||||
android:name=".dist.ApkInstallReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.kazeia.action.APK_INSTALL_STATUS" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Telemetry endpoint exposed to com.kazeia.admin (read-only).
|
||||
No sensitive data — CPU/RAM/timings only. To be tightened
|
||||
with a signature-based permission once shared keystore in. -->
|
||||
<provider
|
||||
android:name=".telemetry.KazeiaTelemetryProvider"
|
||||
android:authorities="com.kazeia.provider"
|
||||
android:exported="true" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -3,25 +3,13 @@ package com.kazeia
|
|||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
|
||||
class KazeiaApplication : Application() {
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID = "kazeia_service_channel"
|
||||
|
||||
/** Racine modèles STT/TTS — résolue par [KazeiaPaths]. */
|
||||
val MODELS_DIR: String get() = KazeiaPaths.modelsDir
|
||||
|
||||
/** Racine modèles LLM ExecuTorch (`.pte`) — résolue par [KazeiaPaths]. */
|
||||
val LLM_DIR: String get() = KazeiaPaths.llmDir
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context) {
|
||||
super.attachBaseContext(base)
|
||||
// Résolution des racines modèles AVANT tout ContentProvider/Activity.
|
||||
KazeiaPaths.init(base)
|
||||
const val MODELS_DIR = "/data/local/tmp/kazeia/models"
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
|
|
@ -30,34 +18,6 @@ class KazeiaApplication : Application() {
|
|||
// Note: Unity native lib preloading was removed because Unity 6 GameActivity
|
||||
// requires its own initialization sequence. Loading libs out of order causes
|
||||
// native crashes. Unity handles lib loading internally in onCreate().
|
||||
|
||||
// Bench QNN options : déclenché par `touch <files>/bench_qnn.trigger` puis relance app.
|
||||
// Charge WhisperHybridEngine + transcrit /files/bench_audio/*.wav (3×, médiane).
|
||||
val trig = java.io.File(getExternalFilesDir(null), "bench_qnn.trigger")
|
||||
if (trig.exists()) {
|
||||
trig.delete()
|
||||
Thread {
|
||||
com.kazeia.stt.BenchQnnHarness.runBench(
|
||||
ctx = this@KazeiaApplication,
|
||||
audioDir = java.io.File(getExternalFilesDir(null), "bench_audio"),
|
||||
modelDir = "$MODELS_DIR/whisper-small-sm8750"
|
||||
)
|
||||
}.start()
|
||||
}
|
||||
// Bench LLM (lib CPU-only) : `touch <files>/bench_llm.trigger`, relance app.
|
||||
// Charge EngineLlmEngine + 3 prompts FR thérapeutiques.
|
||||
val trigLlm = java.io.File(getExternalFilesDir(null), "bench_llm.trigger")
|
||||
if (trigLlm.exists()) {
|
||||
trigLlm.delete()
|
||||
Thread { com.kazeia.llm.BenchLlmHarness.runBench(this@KazeiaApplication) }.start()
|
||||
}
|
||||
// Bench TTS (lib CPU-only) : `touch <files>/bench_tts.trigger`, relance app.
|
||||
// Charge libkazeia_tts + 1 synthèse FR vers WAV.
|
||||
val trigTts = java.io.File(getExternalFilesDir(null), "bench_tts.trigger")
|
||||
if (trigTts.exists()) {
|
||||
trigTts.delete()
|
||||
Thread { com.kazeia.tts.BenchTtsHarness.runBench(this@KazeiaApplication) }.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
package com.kazeia
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Résolution centralisée des racines de modèles Kazeia.
|
||||
*
|
||||
* Deux scénarios :
|
||||
* - **Tablette de dev** : modèles poussés via `adb push` dans
|
||||
* `/data/local/tmp/kazeia/...` (contexte SELinux `shell_data_file`,
|
||||
* lecture seule pour l'app). Conservé comme fallback.
|
||||
* - **Tablette de production** : modèles téléchargés par l'installeur dans
|
||||
* `getExternalFilesDir("kazeia")` (écriture app, gérable / réparable).
|
||||
*
|
||||
* Règle : si la racine legacy `/data/local/tmp` existe ET contient des
|
||||
* fichiers, elle prime → zéro régression sur les tablettes existantes.
|
||||
* Sinon, stockage externe app → rempli par l'installeur.
|
||||
*
|
||||
* Voir `project_kazeia_installer_updates` en mémoire.
|
||||
*/
|
||||
object KazeiaPaths {
|
||||
|
||||
private const val TAG = "KazeiaPaths"
|
||||
|
||||
private const val LEGACY_MODELS = "/data/local/tmp/kazeia/models"
|
||||
private const val LEGACY_LLM = "/data/local/tmp/kazeia-et"
|
||||
private const val LEGACY_RAG = "/data/local/tmp/kazeia/rag_corpus"
|
||||
|
||||
/** Racine modèles STT/TTS (Whisper, GGUF, qwen3-tts-npu ; voix via `/../`). */
|
||||
@Volatile var modelsDir: String = LEGACY_MODELS
|
||||
private set
|
||||
|
||||
/** Racine modèles LLM ExecuTorch (`.pte` + tokenizer). */
|
||||
@Volatile var llmDir: String = LEGACY_LLM
|
||||
private set
|
||||
|
||||
/** Corpus RAG. Priorité INVERSE des modèles : l'EXTERNE prime dès qu'il est
|
||||
* non vide, car c'est là que l'installeur/OTA dépose le composant
|
||||
* `rag_corpus` — sur une tablette dev, les modèles restent legacy mais le
|
||||
* corpus à jour arrive par le catalogue. Fallback legacy (adb push) sinon. */
|
||||
@Volatile var ragCorpusDir: String = LEGACY_RAG
|
||||
private set
|
||||
|
||||
@Volatile private var initialized = false
|
||||
|
||||
/**
|
||||
* Résout les racines. À appeler le plus tôt possible
|
||||
* ([KazeiaApplication.attachBaseContext], avant tout ContentProvider).
|
||||
* Idempotent. Tant qu'il n'est pas appelé, les getters renvoient les
|
||||
* chemins legacy — aucun risque même en cas d'accès précoce.
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (initialized) return
|
||||
synchronized(this) {
|
||||
if (initialized) return
|
||||
val root = File(context.getExternalFilesDir(null), "kazeia")
|
||||
modelsDir = resolve(File(root, "models"), File(LEGACY_MODELS))
|
||||
llmDir = resolve(File(root, "llm"), File(LEGACY_LLM))
|
||||
ragCorpusDir = resolveExternalFirst(File(root, "rag_corpus"), File(LEGACY_RAG))
|
||||
initialized = true
|
||||
Log.i(TAG, "modelsDir=$modelsDir | llmDir=$llmDir | ragCorpusDir=$ragCorpusDir")
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy si présent et non vide (tablette dev), sinon stockage externe. */
|
||||
private fun resolve(external: File, legacy: File): String =
|
||||
if (legacy.isDirectory && !legacy.list().isNullOrEmpty())
|
||||
legacy.absolutePath
|
||||
else
|
||||
external.absolutePath
|
||||
|
||||
/** Externe si non vide (déposé par l'installeur/OTA), sinon legacy (adb push). */
|
||||
private fun resolveExternalFirst(external: File, legacy: File): String =
|
||||
if (external.isDirectory && !external.list().isNullOrEmpty())
|
||||
external.absolutePath
|
||||
else
|
||||
legacy.absolutePath
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package com.kazeia.audio
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import com.kazeia.core.VadEngine
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class AudioCaptureManager(
|
||||
private val sampleRate: Int = 16000
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AudioCapture"
|
||||
}
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var isRunning = false
|
||||
private var listenerThread: Thread? = null
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(
|
||||
vad: VadEngine,
|
||||
silenceDurationMs: Int = 800,
|
||||
speechMinDurationMs: Int = 150,
|
||||
onSpeechSegment: (ShortArray) -> Unit
|
||||
) {
|
||||
Log.i(TAG, "Starting audio capture with VAD")
|
||||
val frameSize = 512 // 32ms at 16kHz
|
||||
val frameDurationMs = (frameSize.toFloat() / sampleRate * 1000).toInt()
|
||||
|
||||
val bufferSize = maxOf(
|
||||
AudioRecord.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
),
|
||||
sampleRate * 2
|
||||
)
|
||||
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize
|
||||
).also { it.startRecording() }
|
||||
|
||||
isRunning = true
|
||||
|
||||
listenerThread = thread(name = "AudioCapture-VAD") {
|
||||
val frame = ShortArray(frameSize)
|
||||
val speechBuffer = mutableListOf<ShortArray>()
|
||||
var speechFrameCount = 0
|
||||
var silenceFrameCount = 0
|
||||
var isSpeechActive = false
|
||||
|
||||
val silenceFramesNeeded = silenceDurationMs / frameDurationMs
|
||||
val speechFramesNeeded = speechMinDurationMs / frameDurationMs
|
||||
|
||||
while (isRunning) {
|
||||
val read = audioRecord?.read(frame, 0, frameSize) ?: 0
|
||||
if (read != frameSize) continue
|
||||
|
||||
val isSpeech = try {
|
||||
vad.isSpeech(frame)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "VAD error", e)
|
||||
false
|
||||
}
|
||||
|
||||
if (isSpeech) {
|
||||
silenceFrameCount = 0
|
||||
speechFrameCount++
|
||||
speechBuffer.add(frame.copyOf())
|
||||
|
||||
if (speechFrameCount >= speechFramesNeeded && !isSpeechActive) {
|
||||
isSpeechActive = true
|
||||
}
|
||||
} else {
|
||||
if (isSpeechActive) {
|
||||
silenceFrameCount++
|
||||
speechBuffer.add(frame.copyOf())
|
||||
|
||||
if (silenceFrameCount >= silenceFramesNeeded) {
|
||||
val fullAudio = speechBuffer.flatMap { it.toList() }.toShortArray()
|
||||
Log.i(TAG, "Speech segment: ${fullAudio.size} samples")
|
||||
onSpeechSegment(fullAudio)
|
||||
|
||||
speechBuffer.clear()
|
||||
speechFrameCount = 0
|
||||
silenceFrameCount = 0
|
||||
isSpeechActive = false
|
||||
}
|
||||
} else {
|
||||
speechBuffer.clear()
|
||||
speechFrameCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
isRunning = false
|
||||
listenerThread?.join(1000)
|
||||
listenerThread = null
|
||||
audioRecord?.stop()
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
}
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
package com.kazeia.config
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
/**
|
||||
* Persistance JSON de la configuration runtime de Kazeia :
|
||||
* - cascade activée / désactivée
|
||||
* - modèle Speaker (id du registre)
|
||||
* - modèle Thinker (id du registre, utilisé seulement si cascade ON)
|
||||
* - prompt système Speaker
|
||||
* - prompt système Thinker
|
||||
* - températures
|
||||
*
|
||||
* Stocké dans `<Context.filesDir>/config/runtime.json`. Lu au démarrage,
|
||||
* écrit par le ContentProvider quand l'admin app fait un update.
|
||||
*
|
||||
* Pattern observateur : KazeiaService s'abonne via `addListener` pour
|
||||
* appliquer les changements à chaud (swap prompt, reload Speaker, etc.).
|
||||
*/
|
||||
class ConfigStore private constructor(private val file: File) {
|
||||
|
||||
data class ModelConfig(
|
||||
val modelId: String,
|
||||
val systemPrompt: String,
|
||||
val temperature: Float
|
||||
)
|
||||
|
||||
data class RuntimeConfig(
|
||||
val cascadeEnabled: Boolean,
|
||||
val speaker: ModelConfig,
|
||||
val thinker: ModelConfig,
|
||||
/** Synthèse vocale active. Si false : pas de TTS, réponse affichée
|
||||
* progressivement (token par token). Pilotable depuis l'app admin. */
|
||||
val ttsEnabled: Boolean = true,
|
||||
/** Backend STT : "prod" = WhisperHybridEngine gelé, "lib" = libkazeia_stt
|
||||
* (Kazeia-Engine unifié, A/B identique sur set 6 audios mais à valider plus large).
|
||||
* Pilotable depuis l'app admin / provider. Defaults safe = "prod". */
|
||||
val sttEngine: String = "prod",
|
||||
/** Backend LLM : "lib" = EngineLlmAdapter (libkazeia_engine + GGUF Qwen3.5,
|
||||
* q35-lmq4 par défaut) — défaut depuis 2026-06-15 : on n'utilise PLUS le
|
||||
* .pte avec kazeia-engine (directive). "prod" = ExecuTorchLlmEngine (.pte) —
|
||||
* conservé en repli d'urgence uniquement ; sur cette plateforme le runner QNN
|
||||
* du .pte échoue au load (« Failed to load llm runner [1] »), donc à éviter.
|
||||
* "lib" valide device : load q35-lmq4.gguf 2.6s + génération FR cohérente. */
|
||||
val llmEngine: String = "lib",
|
||||
/** Backend TTS : "lib" = libkazeia_tts unifié (SEUL compatible avec le
|
||||
* libllama.so fork ql embarqué depuis 2026-06-02), "prod" = Qwen3TtsEngine
|
||||
* legacy — CRASHE (SIGSEGV nativeInit, dérive ABI llama_context_params) tant
|
||||
* que les .so legacy ne sont pas recompilés contre les headers fork ql.
|
||||
* "cosyvoice" = CosyVoiceTtsEngine (clonage vocal 9 langues, CPU-only) —
|
||||
* câblé mais NON activable tant que la collision libggml des libs livrées
|
||||
* n'est pas levée côté engine (cf CosyVoiceTtsEngine.kt).
|
||||
* Default "lib" depuis 2026-06-11 : l'ancien défaut "prod" mettait toute
|
||||
* install fraîche en crash-loop au démarrage du service. */
|
||||
val ttsEngine: String = "lib",
|
||||
/** RAG (récupération de contexte injecté au prompt). Default OFF : tant que
|
||||
* kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré,
|
||||
* l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */
|
||||
val ragEnabled: Boolean = false,
|
||||
/** Seuil de similarité cosinus pour injecter un chunk (e5 : base ~0.80,
|
||||
* pertinent ~0.88). Plus haut = plus strict. Tunable depuis l'admin. */
|
||||
val ragThreshold: Float = 0.82f,
|
||||
/** Nombre max de chunks récupérés/injectés par tour. */
|
||||
val ragTopK: Int = 3
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ConfigStore"
|
||||
|
||||
const val DEFAULT_SPEAKER_PROMPT =
|
||||
"Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
|
||||
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle " +
|
||||
"simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. " +
|
||||
"Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : " +
|
||||
"ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de " +
|
||||
"médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à " +
|
||||
"en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou " +
|
||||
"simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes " +
|
||||
"échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
|
||||
|
||||
const val DEFAULT_THINKER_PROMPT =
|
||||
"Tu es un psychologue clinicien experimente qui ecoute attentivement ton " +
|
||||
"patient. Avant de repondre, tu prepares une analyse silencieuse pour " +
|
||||
"orienter ta reponse. Produis UNIQUEMENT le bloc clinique au format strict " +
|
||||
"suivant :\n" +
|
||||
"- emotion : <emotion principale ressentie, 5 mots max>\n" +
|
||||
"- besoin : <besoin sous-jacent du patient, 5 mots max>\n" +
|
||||
"- approche : <posture therapeutique adaptee, 5 mots max>\n" +
|
||||
"- axe : <point cle a valider ou explorer, 5 mots max>\n" +
|
||||
"Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think"
|
||||
|
||||
@Volatile private var INSTANCE: ConfigStore? = null
|
||||
|
||||
fun get(context: Context): ConfigStore = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: build(context).also { INSTANCE = it }
|
||||
}
|
||||
|
||||
private fun build(context: Context): ConfigStore {
|
||||
val dir = File(context.filesDir, "config").also { it.mkdirs() }
|
||||
val file = File(dir, "runtime.json")
|
||||
val store = ConfigStore(file)
|
||||
store.load()
|
||||
return store
|
||||
}
|
||||
|
||||
fun defaultConfig(): RuntimeConfig = RuntimeConfig(
|
||||
cascadeEnabled = false, // mono-Speaker = contrat FROZEN actuel
|
||||
speaker = ModelConfig(
|
||||
modelId = ModelRegistry.defaultSpeaker().id,
|
||||
systemPrompt = DEFAULT_SPEAKER_PROMPT,
|
||||
temperature = 0.7f
|
||||
),
|
||||
thinker = ModelConfig(
|
||||
modelId = ModelRegistry.defaultThinker().id,
|
||||
systemPrompt = DEFAULT_THINKER_PROMPT,
|
||||
temperature = 0.0f
|
||||
),
|
||||
ttsEnabled = true,
|
||||
sttEngine = "prod",
|
||||
llmEngine = "prod",
|
||||
ttsEngine = "lib" // legacy "prod" = SIGSEGV avec le libllama fork ql embarqué
|
||||
)
|
||||
}
|
||||
|
||||
@Volatile private var current: RuntimeConfig = defaultConfig()
|
||||
private val listeners = CopyOnWriteArrayList<(RuntimeConfig) -> Unit>()
|
||||
|
||||
fun current(): RuntimeConfig = current
|
||||
|
||||
fun addListener(l: (RuntimeConfig) -> Unit) { listeners.add(l) }
|
||||
|
||||
fun load() {
|
||||
if (!file.exists()) {
|
||||
Log.i(TAG, "no config file, using defaults")
|
||||
save(current)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val text = file.readText()
|
||||
current = fromJson(text)
|
||||
Log.i(TAG, "config loaded: cascade=${current.cascadeEnabled} " +
|
||||
"speaker=${current.speaker.modelId} thinker=${current.thinker.modelId}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "failed to parse config, using defaults: ${e.message}")
|
||||
current = defaultConfig()
|
||||
save(current)
|
||||
}
|
||||
}
|
||||
|
||||
fun save(cfg: RuntimeConfig) {
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(toJson(cfg))
|
||||
current = cfg
|
||||
listeners.forEach { it(cfg) }
|
||||
Log.i(TAG, "config saved + listeners notified")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "save failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun toJson(cfg: RuntimeConfig): String {
|
||||
val root = JSONObject()
|
||||
root.put("cascade_enabled", cfg.cascadeEnabled)
|
||||
root.put("tts_enabled", cfg.ttsEnabled)
|
||||
root.put("stt_engine", cfg.sttEngine)
|
||||
root.put("llm_engine", cfg.llmEngine)
|
||||
root.put("tts_engine", cfg.ttsEngine)
|
||||
root.put("rag_enabled", cfg.ragEnabled)
|
||||
root.put("rag_threshold", cfg.ragThreshold.toDouble())
|
||||
root.put("rag_top_k", cfg.ragTopK)
|
||||
root.put("speaker", JSONObject().apply {
|
||||
put("model_id", cfg.speaker.modelId)
|
||||
put("system_prompt", cfg.speaker.systemPrompt)
|
||||
put("temperature", cfg.speaker.temperature)
|
||||
})
|
||||
root.put("thinker", JSONObject().apply {
|
||||
put("model_id", cfg.thinker.modelId)
|
||||
put("system_prompt", cfg.thinker.systemPrompt)
|
||||
put("temperature", cfg.thinker.temperature)
|
||||
})
|
||||
return root.toString(2)
|
||||
}
|
||||
|
||||
private fun fromJson(text: String): RuntimeConfig {
|
||||
val root = JSONObject(text)
|
||||
val defaults = defaultConfig()
|
||||
return RuntimeConfig(
|
||||
cascadeEnabled = root.optBoolean("cascade_enabled", defaults.cascadeEnabled),
|
||||
ttsEnabled = root.optBoolean("tts_enabled", defaults.ttsEnabled),
|
||||
sttEngine = root.optString("stt_engine", defaults.sttEngine),
|
||||
llmEngine = root.optString("llm_engine", defaults.llmEngine),
|
||||
ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
|
||||
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
||||
ragThreshold = root.optDouble("rag_threshold", defaults.ragThreshold.toDouble()).toFloat(),
|
||||
ragTopK = root.optInt("rag_top_k", defaults.ragTopK),
|
||||
speaker = root.optJSONObject("speaker")?.let { js ->
|
||||
ModelConfig(
|
||||
modelId = js.optString("model_id", defaults.speaker.modelId),
|
||||
systemPrompt = js.optString("system_prompt", defaults.speaker.systemPrompt),
|
||||
temperature = js.optDouble("temperature", defaults.speaker.temperature.toDouble()).toFloat()
|
||||
)
|
||||
} ?: defaults.speaker,
|
||||
thinker = root.optJSONObject("thinker")?.let { js ->
|
||||
ModelConfig(
|
||||
modelId = js.optString("model_id", defaults.thinker.modelId),
|
||||
systemPrompt = js.optString("system_prompt", defaults.thinker.systemPrompt),
|
||||
temperature = js.optDouble("temperature", defaults.thinker.temperature.toDouble()).toFloat()
|
||||
)
|
||||
} ?: defaults.thinker
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
package com.kazeia.config
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Catalogue figé des modèles LLM qu'on sait charger via ExecuTorch QNN HTP.
|
||||
* Filtrer par `availableOnDisk()` pour ne proposer dans l'UI admin que ceux
|
||||
* réellement présents sur la tablette.
|
||||
*
|
||||
* L'admin app lit cette liste via le ContentProvider URI `/models` ;
|
||||
* KazeiaService la consulte pour résoudre un `model_id` → (pte, tokenizer).
|
||||
*/
|
||||
object ModelRegistry {
|
||||
|
||||
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
|
||||
// sur tablette dev, stockage externe app sur tablette de production).
|
||||
private val ET_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
|
||||
|
||||
// Racine GGUF (moteur kazeia-engine "lib"). Les GGUF (q35-lmq4, qwen2.5-7b…)
|
||||
// vivent sous MODELS_DIR, pas LLM_DIR.
|
||||
private val GGUF_DIR: String get() = com.kazeia.KazeiaApplication.MODELS_DIR
|
||||
|
||||
enum class Role { SPEAKER, THINKER, BOTH }
|
||||
|
||||
// PTE = runner ExecuTorch QNN HTP ; GGUF = moteur kazeia-engine "lib" (CPU).
|
||||
enum class Backend { PTE, GGUF }
|
||||
|
||||
// Template ChatML du modèle. QWEN35_THINKOFF injecte le bloc <think></think>
|
||||
// (thinking-off déterministe Qwen3.5). PLAIN_CHATML = ChatML standard sans
|
||||
// <think> (Qwen2.5 et autres modèles dense sans raisonnement).
|
||||
enum class ChatTemplate { QWEN35_THINKOFF, PLAIN_CHATML }
|
||||
|
||||
data class ModelInfo(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val pteFile: String,
|
||||
val tokenizerFile: String,
|
||||
val role: Role,
|
||||
val sizeMb: Int,
|
||||
val maxSeqLen: Int,
|
||||
val notes: String = "",
|
||||
val backend: Backend = Backend.PTE,
|
||||
// Nom du fichier GGUF (sous GGUF_DIR) quand backend = GGUF. Le tokenizer
|
||||
// est embarqué dans le GGUF, pas de fichier séparé.
|
||||
val ggufFile: String = "",
|
||||
val chatTemplate: ChatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
) {
|
||||
fun ptePath() = "$ET_DIR/$pteFile"
|
||||
fun tokenizerPath() = "$ET_DIR/$tokenizerFile"
|
||||
fun ggufPath() = "$GGUF_DIR/$ggufFile"
|
||||
fun fileExists(): Boolean = when (backend) {
|
||||
Backend.PTE -> File(ptePath()).exists()
|
||||
Backend.GGUF -> File(ggufPath()).exists()
|
||||
}
|
||||
}
|
||||
|
||||
val ALL: List<ModelInfo> = listOf(
|
||||
ModelInfo(
|
||||
id = "qwen3-4b-seq512",
|
||||
displayName = "Qwen3-4B (seq=512)",
|
||||
pteFile = "hybrid_llama_qnn_4b.pte",
|
||||
tokenizerFile = "tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 3000,
|
||||
maxSeqLen = 512,
|
||||
notes = "Speaker historique, budget contexte serré"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen3-4b-seq1024",
|
||||
displayName = "Qwen3-4B (seq=1024) ★",
|
||||
pteFile = "hybrid_llama_qnn_4b_seq1024.pte",
|
||||
tokenizerFile = "tokenizer.json",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 3000,
|
||||
maxSeqLen = 1024,
|
||||
notes = "Speaker recommandé — production stable 2026-05-14"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen3.5-4b",
|
||||
displayName = "Qwen3.5-4B (GGUF) ★",
|
||||
pteFile = "",
|
||||
tokenizerFile = "",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 2380,
|
||||
maxSeqLen = 4096,
|
||||
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.",
|
||||
backend = Backend.GGUF,
|
||||
ggufFile = "q35-lmq4.gguf",
|
||||
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||
),
|
||||
ModelInfo(
|
||||
id = "qwen2.5-7b-instruct",
|
||||
displayName = "Qwen2.5-7B Instruct (GGUF)",
|
||||
pteFile = "",
|
||||
tokenizerFile = "",
|
||||
role = Role.SPEAKER,
|
||||
sizeMb = 4683,
|
||||
maxSeqLen = 4096,
|
||||
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.",
|
||||
backend = Backend.GGUF,
|
||||
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||
chatTemplate = ChatTemplate.PLAIN_CHATML
|
||||
),
|
||||
ModelInfo(
|
||||
id = "guard06b",
|
||||
displayName = "Qwen3Guard 0.6B",
|
||||
pteFile = "hybrid_llama_qnn_guard06b.pte",
|
||||
tokenizerFile = "tokenizer_guard06b.json",
|
||||
role = Role.THINKER,
|
||||
sizeMb = 672,
|
||||
maxSeqLen = 512,
|
||||
notes = "Thinker léger, ION ~700 MB, cascade safe"
|
||||
),
|
||||
ModelInfo(
|
||||
id = "guard4b",
|
||||
displayName = "Qwen3Guard 4B",
|
||||
pteFile = "hybrid_llama_qnn_guard4b.pte",
|
||||
tokenizerFile = "tokenizer_guard.json",
|
||||
role = Role.THINKER,
|
||||
sizeMb = 3000,
|
||||
maxSeqLen = 512,
|
||||
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM."
|
||||
)
|
||||
)
|
||||
|
||||
fun availableOnDisk(): List<ModelInfo> = ALL.filter { it.fileExists() }
|
||||
|
||||
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
|
||||
|
||||
// Défaut = Speaker GGUF (moteur kazeia-engine lib) — on n'utilise plus de .pte
|
||||
// (directive 2026-06-15). Repli : tout autre Speaker présent sur disque.
|
||||
fun defaultSpeaker(): ModelInfo = byId("qwen3.5-4b")
|
||||
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
|
||||
|
||||
fun defaultThinker(): ModelInfo = byId("guard06b")
|
||||
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
|
||||
}
|
||||
|
|
@ -6,10 +6,6 @@ sealed class PipelineState {
|
|||
object SpeechDetected : PipelineState()
|
||||
object Transcribing : PipelineState()
|
||||
data class Transcribed(val text: String) : PipelineState()
|
||||
/** Résultat Whisper streaming intermédiaire (utilisateur parle encore).
|
||||
* NE bloque PAS le mic-loop (contrairement à Transcribed) — la finalisation
|
||||
* du segment continue normalement via VAD silence. */
|
||||
data class StreamingTranscript(val text: String) : PipelineState()
|
||||
object Thinking : PipelineState()
|
||||
data class TokenGenerated(val token: String, val fullText: String) : PipelineState()
|
||||
data class ResponseReady(val text: String) : PipelineState()
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
package com.kazeia.core
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Timing recorder for one user→Kazeia interaction, from the moment input is
|
||||
* received (user speech or text) to the end of TTS playback.
|
||||
*
|
||||
* All durations are in milliseconds, measured against [t0] which is set to
|
||||
* the wall-clock when [begin] is called. Each event timestamp is relative
|
||||
* to t0 so you can immediately see cumulative latency, not just per-stage.
|
||||
*
|
||||
* The final summary is a single multi-line log tagged `[PIPELINE]` so you
|
||||
* can grep it easily :
|
||||
*
|
||||
* adb logcat | grep -E "\\[PIPELINE\\]"
|
||||
*
|
||||
* The class is thread-safe for the append-then-read pattern used here :
|
||||
* multiple coroutines mark events from callbacks (TTS seg playing fires
|
||||
* on a different thread than LLM decode), the summary is printed at the
|
||||
* end on one thread.
|
||||
*/
|
||||
class PipelineMetrics(val tag: String = "Pipeline") {
|
||||
|
||||
data class Event(
|
||||
val name: String,
|
||||
val tMs: Long,
|
||||
val meta: String = "",
|
||||
)
|
||||
|
||||
@Volatile var t0: Long = 0L
|
||||
private set
|
||||
private val events = java.util.concurrent.CopyOnWriteArrayList<Event>()
|
||||
private var inputKind: String = ""
|
||||
private var inputPayload: String = ""
|
||||
|
||||
fun begin(kind: String, payload: String) {
|
||||
t0 = System.currentTimeMillis()
|
||||
inputKind = kind
|
||||
inputPayload = payload.take(120)
|
||||
events.clear()
|
||||
Log.i(tag, "[PIPELINE] BEGIN kind=$kind payload=\"$inputPayload\"")
|
||||
}
|
||||
|
||||
/** Mark a named event with an optional string payload. Timestamp is
|
||||
* automatically computed as (now - t0). Fine-grained progress events
|
||||
* can use [trace] which only logs at DEBUG level. */
|
||||
fun mark(name: String, meta: String = "") {
|
||||
val ms = if (t0 > 0) System.currentTimeMillis() - t0 else 0
|
||||
events += Event(name, ms, meta)
|
||||
if (meta.isEmpty()) {
|
||||
Log.i(tag, "[PIPELINE] +${ms.pad(5)}ms $name")
|
||||
} else {
|
||||
Log.i(tag, "[PIPELINE] +${ms.pad(5)}ms $name $meta")
|
||||
}
|
||||
}
|
||||
|
||||
/** Lower-severity progress trace — kept in the event list but not in
|
||||
* the terminal summary table. Use sparingly : one per step at most. */
|
||||
fun trace(name: String, meta: String = "") {
|
||||
val ms = if (t0 > 0) System.currentTimeMillis() - t0 else 0
|
||||
events += Event(name, ms, meta)
|
||||
Log.d(tag, "[PIPELINE] +${ms.pad(5)}ms $name $meta")
|
||||
}
|
||||
|
||||
/** Called when the interaction is finished (last TTS segment done or
|
||||
* an error path aborted the pipeline). Emits a tabulated summary. */
|
||||
fun end(status: String = "ok") {
|
||||
if (t0 == 0L) return
|
||||
val total = System.currentTimeMillis() - t0
|
||||
events += Event("END", total, status)
|
||||
|
||||
val sb = StringBuilder()
|
||||
sb.appendLine("[PIPELINE] ==== END $status total=${total}ms input=$inputKind ====")
|
||||
sb.appendLine("[PIPELINE] payload=\"$inputPayload\"")
|
||||
|
||||
val stageOrder = listOf(
|
||||
"STT.start", "STT.end",
|
||||
"LLM.start", "LLM.first_token", "LLM.end",
|
||||
"TTS.seg.start", "TTS.seg.synth_done", "TTS.seg.play_start", "TTS.seg.play_end",
|
||||
"PIPELINE.first_audio", "END",
|
||||
)
|
||||
// Print every event row in arrival order so per-segment details
|
||||
// stay interleaved with their respective stages.
|
||||
sb.appendLine("[PIPELINE] t(ms) event")
|
||||
sb.appendLine("[PIPELINE] ----- -----")
|
||||
for (e in events) {
|
||||
val metaStr = if (e.meta.isEmpty()) "" else " (${e.meta})"
|
||||
sb.append("[PIPELINE] ")
|
||||
.append(e.tMs.pad(5))
|
||||
.append(" ")
|
||||
.append(e.name)
|
||||
.append(metaStr)
|
||||
.append('\n')
|
||||
}
|
||||
|
||||
// Derived KPIs that matter for perceived latency.
|
||||
val sttEnd = firstT("STT.end")
|
||||
val llmStart = firstT("LLM.start")
|
||||
val llmFirstTok = firstT("LLM.first_token")
|
||||
val llmEnd = firstT("LLM.end")
|
||||
val firstAudio = firstT("PIPELINE.first_audio") ?: firstT("TTS.seg.play_start")
|
||||
val lastAudio = lastT("TTS.seg.play_end")
|
||||
|
||||
sb.appendLine("[PIPELINE] ---- KPIs (ms since begin) ----")
|
||||
if (sttEnd != null) sb.appendLine("[PIPELINE] STT duration : $sttEnd")
|
||||
if (llmStart != null && llmFirstTok != null)
|
||||
sb.appendLine("[PIPELINE] LLM time-to-first-token: ${llmFirstTok - llmStart}")
|
||||
if (llmStart != null && llmEnd != null)
|
||||
sb.appendLine("[PIPELINE] LLM generation : ${llmEnd - llmStart}")
|
||||
if (firstAudio != null) sb.appendLine("[PIPELINE] Time-to-first-audio : $firstAudio ← user waits this long")
|
||||
if (firstAudio != null && lastAudio != null)
|
||||
sb.appendLine("[PIPELINE] Audio playback window : ${lastAudio - firstAudio}")
|
||||
sb.appendLine("[PIPELINE] Total wall time : $total")
|
||||
|
||||
// Single log call so it shows up contiguously even if the logger
|
||||
// interleaves other threads.
|
||||
Log.i(tag, sb.toString().trimEnd())
|
||||
|
||||
// Push a structured snapshot to TelemetryHolder so the admin app
|
||||
// can pull it via ContentProvider. Best-effort — if anything goes
|
||||
// wrong here it must not affect the pipeline.
|
||||
try {
|
||||
val tokensMatch = events.firstOrNull { it.name == "LLM.end" }?.meta
|
||||
?.let { Regex("tokens=(\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
||||
com.kazeia.telemetry.TelemetryHolder.pushTurn(
|
||||
com.kazeia.telemetry.TelemetryHolder.TurnSnapshot(
|
||||
timestamp = System.currentTimeMillis(),
|
||||
inputKind = inputKind,
|
||||
status = status,
|
||||
totalMs = total,
|
||||
sttDurationMs = sttEnd,
|
||||
llmTtftMs = if (llmStart != null && llmFirstTok != null)
|
||||
llmFirstTok - llmStart else null,
|
||||
llmGenerationMs = if (llmStart != null && llmEnd != null)
|
||||
llmEnd - llmStart else null,
|
||||
llmTokens = tokensMatch,
|
||||
timeToFirstAudioMs = firstAudio,
|
||||
audioPlaybackMs = if (firstAudio != null && lastAudio != null)
|
||||
lastAudio - firstAudio else null
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.w(tag, "TelemetryHolder push failed: ${e.message}")
|
||||
}
|
||||
|
||||
// Reset state so the next interaction starts clean.
|
||||
t0 = 0
|
||||
events.clear()
|
||||
inputKind = ""
|
||||
inputPayload = ""
|
||||
}
|
||||
|
||||
private fun firstT(name: String): Long? = events.firstOrNull { it.name == name }?.tMs
|
||||
private fun lastT(name: String): Long? = events.lastOrNull { it.name == name }?.tMs
|
||||
|
||||
private fun Long.pad(w: Int): String {
|
||||
val s = this.toString()
|
||||
return if (s.length >= w) s else " ".repeat(w - s.length) + s
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package com.kazeia.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
interface VadEngine {
|
||||
fun load(context: Context)
|
||||
fun isLoaded(): Boolean
|
||||
fun isSpeech(frame: ShortArray): Boolean
|
||||
fun resetState()
|
||||
fun release()
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Reçoit le statut d'une session [PackageInstaller] (self-update APK).
|
||||
*
|
||||
* Le cas important est [PackageInstaller.STATUS_PENDING_USER_ACTION] : Android
|
||||
* fournit alors un Intent de confirmation à présenter à l'utilisateur (le
|
||||
* dialogue système « Installer la mise à jour ? »). On le lance.
|
||||
*/
|
||||
class ApkInstallReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
@Suppress("DEPRECATION")
|
||||
val confirm = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
confirm?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
runCatching { context.startActivity(confirm) }
|
||||
.onFailure { Log.w(TAG, "confirm intent: ${it.message}") }
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS ->
|
||||
Log.i(TAG, "mise à jour APK installée")
|
||||
else -> {
|
||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
Log.w(TAG, "install APK statut=$status : $msg")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApkInstallReceiver"
|
||||
const val ACTION = "com.kazeia.action.APK_INSTALL_STATUS"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
import com.kazeia.BuildConfig
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Auto-mise à jour de l'APK Kazeia (distribution hors-store).
|
||||
*
|
||||
* Le catalogue porte une section `app` (version, URL, SHA-256). Si elle
|
||||
* annonce une version plus récente que celle installée, on télécharge l'APK
|
||||
* et on lance une session [PackageInstaller] — l'utilisateur confirme le
|
||||
* dialogue système. Nécessite la permission `REQUEST_INSTALL_PACKAGES`.
|
||||
*/
|
||||
class ApkUpdater(private val context: Context) {
|
||||
|
||||
/** [AppRelease] du catalogue si une version plus récente est dispo, sinon null. */
|
||||
fun availableUpdate(catalog: Catalog): AppRelease? {
|
||||
val app = catalog.app ?: return null
|
||||
return if (app.versionCode > BuildConfig.VERSION_CODE) app else null
|
||||
}
|
||||
|
||||
/** Télécharge l'APK dans le cache et vérifie son SHA-256. */
|
||||
suspend fun downloadApk(
|
||||
release: AppRelease,
|
||||
onProgress: (Long, Long) -> Unit
|
||||
): File? {
|
||||
val rf = RemoteFile(
|
||||
remote = release.remote,
|
||||
local = APK_NAME,
|
||||
size = release.size,
|
||||
sha256 = release.sha256
|
||||
)
|
||||
val downloader = ModelDownloader(KazeiaDistClient(context), context.cacheDir)
|
||||
return when (downloader.download(rf, onProgress)) {
|
||||
is ModelDownloader.Result.Success -> File(context.cacheDir, APK_NAME)
|
||||
is ModelDownloader.Result.Failed -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance l'installation de l'APK via [PackageInstaller]. Le système
|
||||
* affiche un dialogue de confirmation (cf [ApkInstallReceiver]).
|
||||
*/
|
||||
fun install(apk: File): Boolean = runCatching {
|
||||
val installer = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||
)
|
||||
val sessionId = installer.createSession(params)
|
||||
installer.openSession(sessionId).use { session ->
|
||||
apk.inputStream().use { input ->
|
||||
session.openWrite(APK_NAME, 0, apk.length()).use { output ->
|
||||
input.copyTo(output, 1 shl 16)
|
||||
session.fsync(output)
|
||||
}
|
||||
}
|
||||
val statusIntent = Intent(ApkInstallReceiver.ACTION)
|
||||
.setPackage(context.packageName)
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
context, sessionId, statusIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
session.commit(pending.intentSender)
|
||||
}
|
||||
Log.i(TAG, "session install APK committée")
|
||||
true
|
||||
}.getOrElse {
|
||||
Log.e(TAG, "install APK échec: ${it.message}")
|
||||
false
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ApkUpdater"
|
||||
private const val APK_NAME = "kazeia-update.apk"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Catalogue de distribution Kazeia — source de vérité hébergée sur le
|
||||
* Nextcloud (`soft/catalog.json`). Décrit l'APK courante + les composants
|
||||
* de modèles (LLM / STT / TTS) à télécharger.
|
||||
*
|
||||
* Voir `project_kazeia_installer_updates` en mémoire pour le contexte.
|
||||
*/
|
||||
|
||||
/** Un fichier distant : chemin WebDAV relatif + cible locale + intégrité. */
|
||||
data class RemoteFile(
|
||||
/** Chemin relatif à `BuildConfig.WEBDAV_BASE` (ex. `models/llm/x.pte`). */
|
||||
val remote: String,
|
||||
/** Chemin relatif au répertoire modèles de l'app (ex. `llm/x.pte`). */
|
||||
val local: String,
|
||||
val size: Long,
|
||||
val sha256: String
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = RemoteFile(
|
||||
remote = o.getString("remote"),
|
||||
local = o.getString("local"),
|
||||
size = o.getLong("size"),
|
||||
sha256 = o.getString("sha256").lowercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Un composant logique versionné (ex. `llm_speaker`, `tts_talker`). */
|
||||
data class Component(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val version: String,
|
||||
val mandatory: Boolean,
|
||||
val files: List<RemoteFile>
|
||||
) {
|
||||
val totalSize: Long get() = files.sumOf { it.size }
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject): Component {
|
||||
val filesArr = o.getJSONArray("files")
|
||||
val files = (0 until filesArr.length())
|
||||
.map { RemoteFile.fromJson(filesArr.getJSONObject(it)) }
|
||||
return Component(
|
||||
id = o.getString("id"),
|
||||
label = o.optString("label", o.getString("id")),
|
||||
version = o.getString("version"),
|
||||
mandatory = o.optBoolean("mandatory", true),
|
||||
files = files
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Métadonnées de l'APK la plus récente disponible (self-update). */
|
||||
data class AppRelease(
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val remote: String,
|
||||
val size: Long,
|
||||
val sha256: String
|
||||
) {
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = AppRelease(
|
||||
versionCode = o.getInt("version_code"),
|
||||
versionName = o.getString("version_name"),
|
||||
remote = o.getString("remote"),
|
||||
size = o.getLong("size"),
|
||||
sha256 = o.getString("sha256").lowercase()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class Catalog(
|
||||
val schema: Int,
|
||||
val catalogVersion: Int,
|
||||
val generatedAt: String,
|
||||
val minAppVersionCode: Int,
|
||||
val app: AppRelease?,
|
||||
val components: List<Component>
|
||||
) {
|
||||
val totalSize: Long get() = components.sumOf { it.totalSize }
|
||||
|
||||
fun mandatoryComponents(): List<Component> = components.filter { it.mandatory }
|
||||
|
||||
fun component(id: String): Component? = components.firstOrNull { it.id == id }
|
||||
|
||||
companion object {
|
||||
/** Version de schéma comprise par cette version de l'app. */
|
||||
const val SUPPORTED_SCHEMA = 1
|
||||
|
||||
/** Parse le JSON du catalogue. Lève une exception si malformé. */
|
||||
fun parse(json: String): Catalog {
|
||||
val o = JSONObject(json)
|
||||
val schema = o.getInt("schema")
|
||||
val compArr = o.getJSONArray("components")
|
||||
val components = (0 until compArr.length())
|
||||
.map { Component.fromJson(compArr.getJSONObject(it)) }
|
||||
return Catalog(
|
||||
schema = schema,
|
||||
catalogVersion = o.getInt("catalog_version"),
|
||||
generatedAt = o.optString("generated_at", ""),
|
||||
minAppVersionCode = o.optInt("min_app_version_code", 0),
|
||||
app = o.optJSONObject("app")?.let { AppRelease.fromJson(it) },
|
||||
components = components
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.kazeia.BuildConfig
|
||||
|
||||
/** Configuration WebDAV de distribution (serveur Nextcloud). */
|
||||
data class DistConfig(val base: String, val user: String, val pass: String)
|
||||
|
||||
/**
|
||||
* Stockage runtime de la config WebDAV, modifiable depuis l'app admin.
|
||||
*
|
||||
* `BuildConfig.WEBDAV_*` (issu de `local.properties`) sert de **défaut
|
||||
* d'usine** : l'APK fonctionne dès l'installation. Dès que l'admin enregistre
|
||||
* une config, l'override runtime (chiffré, EncryptedSharedPreferences) prime.
|
||||
*
|
||||
* Identifiants sensibles → chiffrés par une clef Android Keystore.
|
||||
*/
|
||||
object DistConfigStore {
|
||||
|
||||
private const val TAG = "DistConfigStore"
|
||||
private const val PREFS = "kazeia_dist_config"
|
||||
private const val K_BASE = "webdav_base"
|
||||
private const val K_USER = "webdav_user"
|
||||
private const val K_PASS = "webdav_pass"
|
||||
|
||||
private fun prefs(context: Context): SharedPreferences {
|
||||
val master = MasterKey.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
return EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
PREFS,
|
||||
master,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
|
||||
/** Config courante : override runtime si défini, sinon défaut d'usine. */
|
||||
fun get(context: Context): DistConfig {
|
||||
val p = prefs(context)
|
||||
return DistConfig(
|
||||
base = p.getString(K_BASE, null)?.takeIf { it.isNotBlank() }
|
||||
?: BuildConfig.WEBDAV_BASE,
|
||||
user = p.getString(K_USER, null)?.takeIf { it.isNotBlank() }
|
||||
?: BuildConfig.WEBDAV_USER,
|
||||
pass = p.getString(K_PASS, null)?.takeIf { it.isNotBlank() }
|
||||
?: BuildConfig.WEBDAV_PASS
|
||||
)
|
||||
}
|
||||
|
||||
/** Vrai si une config runtime a été enregistrée (≠ défaut d'usine). */
|
||||
fun isCustomized(context: Context): Boolean = prefs(context).contains(K_BASE)
|
||||
|
||||
/**
|
||||
* Enregistre une config. `pass == null` → mot de passe inchangé
|
||||
* (l'admin n'a pas re-saisi le mot de passe).
|
||||
*/
|
||||
fun update(context: Context, base: String, user: String, pass: String?) {
|
||||
prefs(context).edit().apply {
|
||||
putString(K_BASE, base.trim())
|
||||
putString(K_USER, user.trim())
|
||||
if (pass != null) putString(K_PASS, pass)
|
||||
}.apply()
|
||||
Log.i(TAG, "config WebDAV mise à jour (base=${base.trim()})")
|
||||
}
|
||||
|
||||
/** Réinitialise au défaut d'usine ([BuildConfig]). */
|
||||
fun reset(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
Log.i(TAG, "config WebDAV réinitialisée au défaut d'usine")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import okhttp3.Credentials
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Client WebDAV pour le Nextcloud de distribution Kazeia (`box.kazeia.com`).
|
||||
*
|
||||
* Lecture seule en pratique : récupération du catalogue + des fichiers de
|
||||
* modèles. L'authentification Basic et l'URL de base viennent de
|
||||
* `BuildConfig` (injectés depuis `local.properties`, jamais commités).
|
||||
*
|
||||
* Le moteur de téléchargement résumable (Range, SHA-256, WorkManager) est
|
||||
* construit par-dessus [openStream].
|
||||
*
|
||||
* La config WebDAV (URL + identifiants) est lue depuis [DistConfigStore] à la
|
||||
* construction — éditable depuis l'app admin, défaut d'usine = `BuildConfig`.
|
||||
*/
|
||||
class KazeiaDistClient(context: Context) {
|
||||
|
||||
private val config: DistConfig = DistConfigStore.get(context)
|
||||
private val base: String = config.base.trimEnd('/') + "/"
|
||||
private val auth: String = Credentials.basic(config.user, config.pass)
|
||||
|
||||
private val http: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
// Pas de timeout global : les fichiers modèles font plusieurs Go.
|
||||
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
|
||||
/** URL absolue d'un chemin distant relatif à la base de distribution. */
|
||||
fun urlFor(remote: String): String = base + remote.trimStart('/')
|
||||
|
||||
private fun newRequest(remote: String): Request.Builder =
|
||||
Request.Builder()
|
||||
.url(urlFor(remote))
|
||||
.header("Authorization", auth)
|
||||
|
||||
/** Vérifie que le serveur répond et que les identifiants sont valides. */
|
||||
fun probe(): Boolean = runCatching {
|
||||
http.newCall(newRequest("").head().build()).execute().use { it.isSuccessful }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** GET texte (catalogue, manifestes). `null` si échec réseau/HTTP. */
|
||||
fun fetchText(remote: String): String? = runCatching {
|
||||
http.newCall(newRequest(remote).get().build()).execute().use { resp ->
|
||||
if (resp.isSuccessful) resp.body?.string() else {
|
||||
Log.w(TAG, "fetchText $remote → HTTP ${resp.code}")
|
||||
null
|
||||
}
|
||||
}
|
||||
}.getOrElse { Log.w(TAG, "fetchText $remote failed: ${it.message}"); null }
|
||||
|
||||
/**
|
||||
* Récupère et parse un catalogue (modèles par défaut, ou voix).
|
||||
* `null` si injoignable ou malformé.
|
||||
*/
|
||||
fun fetchCatalog(path: String = CATALOG_PATH): Catalog? {
|
||||
val txt = fetchText(path) ?: return null
|
||||
return runCatching { Catalog.parse(txt) }
|
||||
.onFailure { Log.w(TAG, "catalogue $path malformé: ${it.message}") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre un flux GET sur un fichier distant, avec reprise optionnelle.
|
||||
* L'appelant DOIT fermer la [Response]. Utilisé par le moteur de
|
||||
* téléchargement (phase 2).
|
||||
*
|
||||
* @param offsetBytes octets déjà téléchargés localement ; si > 0, un
|
||||
* en-tête `Range` est envoyé pour reprendre.
|
||||
*/
|
||||
fun openStream(remote: String, offsetBytes: Long = 0): Response {
|
||||
val req = newRequest(remote).get().apply {
|
||||
if (offsetBytes > 0) header("Range", "bytes=$offsetBytes-")
|
||||
}.build()
|
||||
return http.newCall(req).execute()
|
||||
}
|
||||
|
||||
/** Taille distante d'un fichier (`Content-Length` via HEAD), ou -1. */
|
||||
fun remoteSize(remote: String): Long = runCatching {
|
||||
http.newCall(newRequest(remote).head().build()).execute().use { resp ->
|
||||
if (resp.isSuccessful)
|
||||
resp.header("Content-Length")?.toLongOrNull() ?: -1L
|
||||
else -1L
|
||||
}
|
||||
}.getOrDefault(-1L)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "KazeiaDistClient"
|
||||
/** Catalogue modèles, relatif à la base de distribution. */
|
||||
const val CATALOG_PATH = "catalog.json"
|
||||
/** Catalogue voix (dynamique, cycle de vie séparé). */
|
||||
const val VOICES_CATALOG_PATH = "voices_catalog.json"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Télécharge un fichier de modèle ([RemoteFile]) de façon robuste :
|
||||
*
|
||||
* - **reprise** : écriture dans `<cible>.part`, l'octet de reprise est la
|
||||
* taille du `.part` (en-tête HTTP `Range`) ;
|
||||
* - **vérification** : SHA-256 contrôlé avant toute mise en service ;
|
||||
* - **atomicité** : `rename` du `.part` vers la cible seulement après
|
||||
* vérification → jamais de modèle à moitié écrit chargé en production.
|
||||
*
|
||||
* Toutes les écritures vont sous [root] (= `getExternalFilesDir("kazeia")`),
|
||||
* le chemin `RemoteFile.local` étant relatif à cette racine.
|
||||
*/
|
||||
class ModelDownloader(
|
||||
private val client: KazeiaDistClient,
|
||||
private val root: File
|
||||
) {
|
||||
|
||||
sealed class Result {
|
||||
object Success : Result()
|
||||
data class Failed(val reason: String) : Result()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param onProgress (octetsTéléchargés, octetsTotal) — appelé en continu.
|
||||
*/
|
||||
suspend fun download(file: RemoteFile, onProgress: (Long, Long) -> Unit): Result {
|
||||
val target = File(root, file.local)
|
||||
target.parentFile?.mkdirs()
|
||||
|
||||
// Déjà présent et intègre → rien à faire.
|
||||
if (target.exists() && target.length() == file.size &&
|
||||
runCatching { Sha256.ofFile(target) == file.sha256 }.getOrDefault(false)
|
||||
) {
|
||||
onProgress(file.size, file.size)
|
||||
return Result.Success
|
||||
}
|
||||
|
||||
val part = File(target.parentFile, target.name + ".part")
|
||||
var offset = if (part.exists()) part.length() else 0L
|
||||
if (offset > file.size) { // .part corrompu / catalogue changé
|
||||
part.delete(); offset = 0L
|
||||
}
|
||||
|
||||
try {
|
||||
client.openStream(file.remote, offset).use { resp ->
|
||||
if (!resp.isSuccessful) return Result.Failed("HTTP ${resp.code}")
|
||||
// Range demandé mais serveur renvoie 200 (full) → on repart de 0.
|
||||
if (offset > 0 && resp.code != 206) {
|
||||
part.delete(); offset = 0L
|
||||
}
|
||||
val body = resp.body ?: return Result.Failed("réponse vide")
|
||||
RandomAccessFile(part, "rw").use { raf ->
|
||||
raf.seek(offset)
|
||||
body.byteStream().use { ins ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var done = offset
|
||||
onProgress(done, file.size)
|
||||
while (true) {
|
||||
coroutineContext.ensureActive() // annulation propre
|
||||
val n = ins.read(buf)
|
||||
if (n < 0) break
|
||||
raf.write(buf, 0, n)
|
||||
done += n
|
||||
onProgress(done, file.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "download ${file.local} interrompu: ${e.message}")
|
||||
return Result.Failed(e.message ?: "erreur réseau")
|
||||
}
|
||||
|
||||
// Vérification taille + SHA-256 avant mise en service.
|
||||
if (part.length() != file.size) {
|
||||
return Result.Failed("taille ${part.length()} ≠ ${file.size}")
|
||||
}
|
||||
val sha = runCatching { Sha256.ofFile(part) }.getOrElse {
|
||||
return Result.Failed("SHA illisible: ${it.message}")
|
||||
}
|
||||
if (!sha.equals(file.sha256, ignoreCase = true)) {
|
||||
part.delete()
|
||||
return Result.Failed("SHA-256 incorrect (fichier corrompu)")
|
||||
}
|
||||
|
||||
// Rename atomique.
|
||||
if (target.exists()) target.delete()
|
||||
if (!part.renameTo(target)) {
|
||||
return Result.Failed("rename ${part.name} → ${target.name} échoué")
|
||||
}
|
||||
Log.i(TAG, "✓ ${file.local} (${file.size} o)")
|
||||
return Result.Success
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ModelDownloader"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.Context
|
||||
import android.os.StatFs
|
||||
import android.util.Log
|
||||
import com.kazeia.KazeiaPaths
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Orchestrateur du provisionnement modèles : récupère le catalogue, calcule
|
||||
* le diff avec l'état local, télécharge les composants manquants ou périmés,
|
||||
* et met à jour [ProvisioningState].
|
||||
*
|
||||
* Les fichiers sont écrits sous `getExternalFilesDir("kazeia")` ; les chemins
|
||||
* `RemoteFile.local` sont relatifs à cette racine (`models/...`, `llm/...`)
|
||||
* et correspondent à la résolution de [KazeiaPaths] sur tablette de production.
|
||||
*/
|
||||
class ProvisioningManager(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val client = KazeiaDistClient(appContext)
|
||||
|
||||
/** Racine d'écriture des modèles téléchargés. */
|
||||
val root: File = File(appContext.getExternalFilesDir(null), "kazeia")
|
||||
|
||||
private val downloader = ModelDownloader(client, root)
|
||||
|
||||
/** Progression rapportée pendant le provisionnement. */
|
||||
data class Progress(
|
||||
val componentLabel: String,
|
||||
val currentFile: String,
|
||||
val overallDone: Long,
|
||||
val overallTotal: Long
|
||||
)
|
||||
|
||||
sealed class Outcome {
|
||||
object Success : Outcome()
|
||||
data class Failed(val reason: String) : Outcome()
|
||||
}
|
||||
|
||||
fun probe(): Boolean = client.probe()
|
||||
|
||||
suspend fun fetchCatalog(): Catalog? = withContext(Dispatchers.IO) { client.fetchCatalog() }
|
||||
|
||||
/**
|
||||
* Catalogue voix — optionnel et dynamique (le clinicien ajoute des voix
|
||||
* sans toucher au catalogue modèles figé). `null` si absent du Nextcloud.
|
||||
*/
|
||||
suspend fun fetchVoicesCatalog(): Catalog? = withContext(Dispatchers.IO) {
|
||||
client.fetchCatalog(KazeiaDistClient.VOICES_CATALOG_PATH)
|
||||
}
|
||||
|
||||
/** Espace libre (octets) sur le volume de stockage des modèles. */
|
||||
fun freeBytes(): Long = runCatching {
|
||||
root.mkdirs()
|
||||
StatFs(root.path).availableBytes
|
||||
}.getOrDefault(0L)
|
||||
|
||||
/**
|
||||
* Heuristique hors-ligne : les modèles cœur sont-ils présents sur le
|
||||
* disque résolu par [KazeiaPaths] ? Vrai sur tablette de dev (legacy
|
||||
* `/data/local/tmp` peuplé) comme sur tablette de production déjà
|
||||
* provisionnée — permet à l'onboarding de court-circuiter sans réseau.
|
||||
*/
|
||||
fun hasCoreModels(): Boolean {
|
||||
val llm = File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b.pte").exists() ||
|
||||
File(KazeiaPaths.llmDir, "hybrid_llama_qnn_4b_seq1024.pte").exists()
|
||||
val talker = File(KazeiaPaths.modelsDir, "talker_f32.gguf").exists() ||
|
||||
File(KazeiaPaths.modelsDir, "talker_f16.gguf").exists()
|
||||
val whisper = File(KazeiaPaths.modelsDir, "whisper-small-sm8750").isDirectory
|
||||
return llm && talker && whisper
|
||||
}
|
||||
|
||||
/** Composants à (re)télécharger selon le diff catalogue ↔ état local. */
|
||||
fun pending(catalog: Catalog): List<Component> =
|
||||
ProvisioningState.load(appContext).missingOrOutdated(catalog)
|
||||
|
||||
/**
|
||||
* Télécharge tous les composants manquants/périmés du catalogue.
|
||||
* Met à jour [ProvisioningState] après CHAQUE composant complet — un
|
||||
* arrêt en cours de route ne reperd que le composant courant.
|
||||
*/
|
||||
suspend fun provision(
|
||||
catalog: Catalog,
|
||||
onProgress: (Progress) -> Unit
|
||||
): Outcome = withContext(Dispatchers.IO) {
|
||||
val state = ProvisioningState.load(appContext)
|
||||
val todo = state.missingOrOutdated(catalog)
|
||||
if (todo.isEmpty()) return@withContext Outcome.Success
|
||||
|
||||
val overallTotal = todo.sumOf { it.totalSize }
|
||||
var overallBase = 0L
|
||||
|
||||
for (comp in todo) {
|
||||
for (rf in comp.files) {
|
||||
val res = downloader.download(rf) { done, _ ->
|
||||
onProgress(
|
||||
Progress(
|
||||
componentLabel = comp.label,
|
||||
currentFile = rf.local.substringAfterLast('/'),
|
||||
overallDone = overallBase + done,
|
||||
overallTotal = overallTotal
|
||||
)
|
||||
)
|
||||
}
|
||||
if (res is ModelDownloader.Result.Failed) {
|
||||
Log.w(TAG, "provision échec sur ${rf.local}: ${res.reason}")
|
||||
return@withContext Outcome.Failed("${rf.local} : ${res.reason}")
|
||||
}
|
||||
overallBase += rf.size
|
||||
}
|
||||
// Composant complet et vérifié → état persisté.
|
||||
state.markInstalled(comp.id, comp.version)
|
||||
state.catalogVersion = catalog.catalogVersion
|
||||
state.lastCheck = System.currentTimeMillis()
|
||||
ProvisioningState.save(appContext, state)
|
||||
Log.i(TAG, "composant « ${comp.id} » v${comp.version} installé")
|
||||
}
|
||||
Outcome.Success
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ProvisioningManager"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* État local de provisionnement — miroir, côté tablette, de ce qui est
|
||||
* réellement installé et vérifié. Comparé au [Catalog] distant pour calculer
|
||||
* le diff de mise à jour.
|
||||
*
|
||||
* Fichier : `<externalFilesDir>/provisioning_state.json`.
|
||||
*/
|
||||
|
||||
/** État installé d'un composant. */
|
||||
data class ComponentState(
|
||||
val installedVersion: String,
|
||||
val complete: Boolean,
|
||||
val installedAt: Long
|
||||
) {
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("installed_version", installedVersion)
|
||||
put("complete", complete)
|
||||
put("installed_at", installedAt)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(o: JSONObject) = ComponentState(
|
||||
installedVersion = o.getString("installed_version"),
|
||||
complete = o.optBoolean("complete", false),
|
||||
installedAt = o.optLong("installed_at", 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class ProvisioningState private constructor(
|
||||
var schema: Int,
|
||||
var catalogVersion: Int,
|
||||
var lastCheck: Long,
|
||||
private val components: MutableMap<String, ComponentState>
|
||||
) {
|
||||
|
||||
fun componentState(id: String): ComponentState? = components[id]
|
||||
|
||||
/** Marque un composant comme installé et vérifié. */
|
||||
fun markInstalled(id: String, version: String) {
|
||||
components[id] = ComponentState(
|
||||
installedVersion = version,
|
||||
complete = true,
|
||||
installedAt = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
fun forget(id: String) { components.remove(id) }
|
||||
|
||||
/**
|
||||
* Composants du catalogue manquants ou périmés : présents mais pas
|
||||
* `complete`, version différente, ou jamais installés.
|
||||
*/
|
||||
fun missingOrOutdated(catalog: Catalog): List<Component> =
|
||||
catalog.components.filter { c ->
|
||||
val s = components[c.id]
|
||||
s == null || !s.complete || s.installedVersion != c.version
|
||||
}
|
||||
|
||||
/** Vrai si tous les composants OBLIGATOIRES du catalogue sont à jour. */
|
||||
fun isReady(catalog: Catalog): Boolean =
|
||||
catalog.mandatoryComponents().none { c ->
|
||||
val s = components[c.id]
|
||||
s == null || !s.complete || s.installedVersion != c.version
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("schema", schema)
|
||||
put("catalog_version", catalogVersion)
|
||||
put("last_check", lastCheck)
|
||||
put("components", JSONObject().apply {
|
||||
components.forEach { (id, st) -> put(id, st.toJson()) }
|
||||
})
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ProvisioningState"
|
||||
const val SCHEMA = 1
|
||||
private const val FILE_NAME = "provisioning_state.json"
|
||||
|
||||
fun file(context: Context): File =
|
||||
File(context.getExternalFilesDir(null), FILE_NAME)
|
||||
|
||||
fun load(context: Context): ProvisioningState {
|
||||
val f = file(context)
|
||||
if (!f.exists()) return empty()
|
||||
return try {
|
||||
val o = JSONObject(f.readText())
|
||||
val compObj = o.optJSONObject("components") ?: JSONObject()
|
||||
val comps = mutableMapOf<String, ComponentState>()
|
||||
compObj.keys().forEach { id ->
|
||||
comps[id] = ComponentState.fromJson(compObj.getJSONObject(id))
|
||||
}
|
||||
ProvisioningState(
|
||||
schema = o.optInt("schema", SCHEMA),
|
||||
catalogVersion = o.optInt("catalog_version", 0),
|
||||
lastCheck = o.optLong("last_check", 0),
|
||||
components = comps
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "parse failed, repartir de zéro: ${e.message}")
|
||||
empty()
|
||||
}
|
||||
}
|
||||
|
||||
fun save(context: Context, state: ProvisioningState) {
|
||||
try {
|
||||
val f = file(context)
|
||||
f.parentFile?.mkdirs()
|
||||
f.writeText(state.toJson().toString(2))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "save failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun empty() = ProvisioningState(SCHEMA, 0, 0, mutableMapOf())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import com.kazeia.KazeiaApplication
|
||||
import com.kazeia.R
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
|
||||
/**
|
||||
* Worker WorkManager qui exécute le provisionnement modèles en foreground
|
||||
* (notification de progression, survit au backgrounding de l'app).
|
||||
*
|
||||
* Progression publiée via [setProgress] — observée par `OnboardingActivity`.
|
||||
* La boucle de téléchargement (par chunk 64 ko) écrit dans une variable
|
||||
* volatile ; un coroutine séparé échantillonne toutes les 500 ms pour ne pas
|
||||
* saturer la base WorkManager ni le NotificationManager.
|
||||
*/
|
||||
class ProvisioningWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
|
||||
private val manager = ProvisioningManager(appContext)
|
||||
@Volatile private var latest: ProvisioningManager.Progress? = null
|
||||
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo =
|
||||
foregroundInfo(buildNotification("Préparation de Kazeia…", 0, 0))
|
||||
|
||||
override suspend fun doWork(): Result = coroutineScope {
|
||||
val modelsCatalog = manager.fetchCatalog()
|
||||
?: return@coroutineScope Result.failure(
|
||||
workDataOf(KEY_ERROR to "Catalogue injoignable — vérifiez la connexion."))
|
||||
// Catalogue voix : optionnel — absent du Nextcloud ⇒ null, on l'ignore.
|
||||
val voicesCatalog = manager.fetchVoicesCatalog()
|
||||
|
||||
// Rien à (re)télécharger → succès silencieux, pas de notification
|
||||
// foreground (utile pour le check de mise à jour en arrière-plan).
|
||||
val nothingPending = manager.pending(modelsCatalog).isEmpty() &&
|
||||
(voicesCatalog == null || manager.pending(voicesCatalog).isEmpty())
|
||||
if (nothingPending) return@coroutineScope Result.success()
|
||||
|
||||
setForeground(getForegroundInfo())
|
||||
|
||||
// Échantillonneur de progression (découple la boucle tight du setProgress).
|
||||
val sampler = launch {
|
||||
while (isActive) {
|
||||
latest?.let { p -> publish(p) }
|
||||
delay(500)
|
||||
}
|
||||
}
|
||||
|
||||
val result = try {
|
||||
// 1. Modèles — critique : un échec fait échouer le worker.
|
||||
val models = manager.provision(modelsCatalog) { p -> latest = p }
|
||||
if (models is ProvisioningManager.Outcome.Failed) {
|
||||
Result.failure(workDataOf(KEY_ERROR to models.reason))
|
||||
} else {
|
||||
// 2. Voix — best-effort : un échec est loggé mais non bloquant.
|
||||
if (voicesCatalog != null) {
|
||||
val voices = manager.provision(voicesCatalog) { p -> latest = p }
|
||||
if (voices is ProvisioningManager.Outcome.Failed) {
|
||||
android.util.Log.w("ProvisioningWorker",
|
||||
"voix (best-effort) échec : ${voices.reason}")
|
||||
}
|
||||
}
|
||||
latest?.let { publish(it) }
|
||||
Result.success()
|
||||
}
|
||||
} finally {
|
||||
sampler.cancel()
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
private suspend fun publish(p: ProvisioningManager.Progress) {
|
||||
setProgress(workDataOf(
|
||||
KEY_LABEL to p.componentLabel,
|
||||
KEY_FILE to p.currentFile,
|
||||
KEY_DONE to p.overallDone,
|
||||
KEY_TOTAL to p.overallTotal
|
||||
))
|
||||
val pct = if (p.overallTotal > 0)
|
||||
(p.overallDone * 100 / p.overallTotal).toInt() else 0
|
||||
runCatching {
|
||||
setForeground(foregroundInfo(
|
||||
buildNotification("Téléchargement : ${p.componentLabel}", pct, 100)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun foregroundInfo(notification: Notification): ForegroundInfo =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
ForegroundInfo(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
else
|
||||
ForegroundInfo(NOTIF_ID, notification)
|
||||
|
||||
private fun buildNotification(text: String, progress: Int, max: Int): Notification =
|
||||
NotificationCompat.Builder(applicationContext, KazeiaApplication.CHANNEL_ID)
|
||||
.setContentTitle("Installation de Kazeia")
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.apply { if (max > 0) setProgress(max, progress, false) }
|
||||
.build()
|
||||
|
||||
companion object {
|
||||
const val NAME = "kazeia_provisioning"
|
||||
private const val NOTIF_ID = 4242
|
||||
|
||||
const val KEY_LABEL = "label"
|
||||
const val KEY_FILE = "file"
|
||||
const val KEY_DONE = "done"
|
||||
const val KEY_TOTAL = "total"
|
||||
const val KEY_ERROR = "error"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
package com.kazeia.dist
|
||||
|
||||
import java.io.File
|
||||
import java.security.MessageDigest
|
||||
|
||||
/** Calcul SHA-256 en streaming — vérification d'intégrité des modèles. */
|
||||
object Sha256 {
|
||||
|
||||
/** Hex minuscule du SHA-256 d'un fichier. Lève si le fichier est illisible. */
|
||||
fun ofFile(file: File): String {
|
||||
val md = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().buffered(1 shl 16).use { ins ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
while (true) {
|
||||
val n = ins.read(buf)
|
||||
if (n < 0) break
|
||||
md.update(buf, 0, n)
|
||||
}
|
||||
}
|
||||
return md.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// Harness LLM ADB-pilotable, mirror de BenchQnnHarness STT.
|
||||
// Déclenchement : `touch <files>/bench_llm.trigger`, relance app.
|
||||
// Charge EngineLlmEngine (lib CPU-only) + génère 3 prompts FR, log texte + tps + nThreads honoré.
|
||||
package com.kazeia.llm
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.kazeia.KazeiaApplication
|
||||
|
||||
object BenchLlmHarness {
|
||||
private const val TAG = "BenchLlm"
|
||||
|
||||
private val PROMPTS = listOf(
|
||||
"Bonsoir, je me sens seul",
|
||||
"J'ai du mal à dormir",
|
||||
"Je n'arrive plus à parler à ma femme"
|
||||
)
|
||||
private val SYS = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : " +
|
||||
"UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple."
|
||||
|
||||
fun runBench(ctx: Context) {
|
||||
val gguf = "${KazeiaApplication.MODELS_DIR}/q35-lmq4.gguf"
|
||||
Log.i(TAG, "=== load q35-lmq4 ===")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val eng = try {
|
||||
EngineLlmEngine(gguf, ctx = 2048, nThreads = 6)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "load FAIL: ${e.message}"); return
|
||||
}
|
||||
Log.i(TAG, "load OK (${System.currentTimeMillis() - t0}ms)")
|
||||
// warmup
|
||||
try { eng.generate(SYS, "Bonjour", 16) } catch (_: Exception) {}
|
||||
for ((i, p) in PROMPTS.withIndex()) {
|
||||
val ts = System.currentTimeMillis()
|
||||
val out = try { eng.generate(SYS, p, 96) } catch (e: Exception) { "[ERR ${e.message}]" }
|
||||
val ms = System.currentTimeMillis() - ts
|
||||
val nWords = out.trim().split(Regex("\\s+")).size
|
||||
val tps = if (ms > 0) nWords * 1000f / ms else 0f
|
||||
Log.i(TAG, "[${i+1}/${PROMPTS.size}] '${p}' → ${ms}ms (~${"%.2f".format(tps)} mots/s) :")
|
||||
Log.i(TAG, " '${out.trim()}'")
|
||||
}
|
||||
eng.release()
|
||||
Log.i(TAG, "=== Bench end ===")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import com.kazeia.core.GenerationResult
|
||||
import com.kazeia.core.LlmConfig
|
||||
import com.kazeia.core.LlmEngine
|
||||
import com.kazeia.core.SamplingParams
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine,
|
||||
* GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon)
|
||||
* derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible
|
||||
* piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF).
|
||||
*
|
||||
* Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs.
|
||||
* Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")`
|
||||
* jette UnsatisfiedLinkError au 1er accès à `EngineJni` → KazeiaService doit catch
|
||||
* et tomber en repli sur le path prod.
|
||||
*
|
||||
* Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod).
|
||||
*/
|
||||
class EngineLlmAdapter(
|
||||
private val modelPath: String,
|
||||
private val systemPrompt: String,
|
||||
private val ctxLen: Int = 4096,
|
||||
private val nThreads: Int = 6,
|
||||
// true = ChatML standard sans <think> (Qwen2.5…) via generateRaw ;
|
||||
// false = bloc thinking-off Qwen3.5 injecté côté natif (generate).
|
||||
private val plainChatml: Boolean = false,
|
||||
private val onLog: ((String) -> Unit)? = null
|
||||
) : LlmEngine {
|
||||
private var engine: EngineLlmEngine? = null
|
||||
// Session = cache de préfixe KV : le system prompt n'est prefillé QU'UNE fois
|
||||
// (à la création), puis chaque tour ne prefille que le message user → ~2,5×
|
||||
// moins de latence/tour (mesuré ~2991→~1190 ms sur q35-lmq4) + streaming.
|
||||
// Pas créée pour plainChatml (Qwen2.5 = template ChatML différent → generateRaw).
|
||||
// Stateless : reset() avant chaque ask (comportement historique sans mémoire
|
||||
// LLM) — retirer ce reset activerait la mémoire conversationnelle.
|
||||
private var session: LlmSession? = null
|
||||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
|
||||
if (engine != null) return@withContext
|
||||
val t0 = System.currentTimeMillis()
|
||||
val e = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
|
||||
engine = e
|
||||
if (!plainChatml) session = e.newSession(systemPrompt) // prefille le system 1×
|
||||
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads} session=${session != null}")
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = engine != null
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
|
||||
val t0 = System.currentTimeMillis()
|
||||
val s = session
|
||||
val out: String = if (s != null) {
|
||||
// Cache de préfixe + MÉMOIRE conversationnelle : le system reste prefillé,
|
||||
// chaque ask ne prefille que le user et conserve l'historique en KV.
|
||||
// (NB : on NE reset PAS par tour — sessionReset+ask renvoie vide,
|
||||
// bug natif n_past non restauré ; reset boundary à rétablir côté engine.)
|
||||
val sb = StringBuilder()
|
||||
s.ask(prompt, params.maxNewTokens) { piece ->
|
||||
sb.append(piece); onToken?.invoke(piece) ?: true
|
||||
}
|
||||
sb.toString().trim()
|
||||
} else {
|
||||
// plainChatml (Qwen2.5) : ChatML manuel, generateRaw bloquant.
|
||||
val p = buildString {
|
||||
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
|
||||
append("<|im_start|>assistant\n")
|
||||
}
|
||||
e.generateRaw(p, params.maxNewTokens).trim().also { onToken?.invoke(it) }
|
||||
}
|
||||
// Timing réel fourni par l'engine (prefill/decode séparés). tps = débit
|
||||
// DÉCODE (fini l'artefact "prefill inclus" qui donnait ~3 tok/s).
|
||||
val st = e.lastStats()
|
||||
val n = if (st.nTokens > 0) st.nTokens.toInt() else out.split(Regex("\\s+")).size
|
||||
val ms: Long = (st.prefillMs + st.decodeMs).takeIf { it > 0 } ?: (System.currentTimeMillis() - t0)
|
||||
onLog?.invoke("[ENGINE] prefill=${st.prefillMs}ms decode=${st.decodeMs}ms tok=${st.nTokens} (${"%.1f".format(st.decodeTokPerSec)} tok/s decode)")
|
||||
GenerationResult(out, n, ms, st.decodeTokPerSec.toFloat())
|
||||
}
|
||||
|
||||
/** Démarre une conversation VIERGE (garde le system prefillé). À appeler aux
|
||||
* bornes de conversation (changement de patient, effacement du chat) — sinon
|
||||
* la mémoire d'un patient fuiterait vers le suivant. sessionReset natif corrigé
|
||||
* 2026-06-15 (clear + re-prefill system sur archi hybride où le rewind partiel
|
||||
* échoue), donc on l'utilise directement. */
|
||||
fun resetSession() { session?.reset() }
|
||||
|
||||
override fun release() { session = null; engine?.release(); engine = null }
|
||||
}
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
package com.kazeia.llm
|
||||
// Remplace ExecuTorchLlmEngine. Option C: prefill HTP / decode CPU (géré côté natif).
|
||||
// Mono-tour: generate(sys, usr). Multi-tour: ChatSession (gère l'historique + le template ChatML).
|
||||
|
||||
class EngineJni {
|
||||
/**
|
||||
* @param nThreads decode CPU threads. Défaut 0 -> 6 (sweet spot Snapdragon 8 Elite,
|
||||
* mémoire r3). Recommandations : 6 pour Speaker 4B/9B in-app, 4 si cascade
|
||||
* simultanée Speaker+Thinker pour laisser cœurs au Thinker.
|
||||
*/
|
||||
external fun load(ggufPath: String, nCtx: Int, nThreads: Int): Long
|
||||
external fun generate(h: Long, sys: String, usr: String, maxTok: Int): String
|
||||
external fun generateRaw(h: Long, prompt: String, maxTok: Int): String // prompt complet déjà formaté
|
||||
external fun reset(h: Long)
|
||||
external fun free(h: Long)
|
||||
|
||||
// -- Streaming + session (latence in-app). cb.onToken(piece) -> false pour arrêter.
|
||||
// generateStream = mono-tour streamé. session* = system prefillé 1×, réutilisé (cache de préfixe KV).
|
||||
external fun generateStream(h: Long, sys: String, usr: String, maxTok: Int, cb: TokenCallback)
|
||||
external fun sessionStart(h: Long, sys: String) // prefille le system + checkpoint KV
|
||||
external fun sessionAsk(h: Long, usr: String, maxTok: Int, cb: TokenCallback) // ne prefille que le user
|
||||
external fun sessionReset(h: Long) // repart du checkpoint système
|
||||
external fun getLastStats(h: Long): LongArray // [prefill_ms, decode_ms, n_tokens]
|
||||
|
||||
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
|
||||
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
|
||||
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
|
||||
// pipeline Talker→CP→Decoder. Retours conventionnels : 0 = OK, négatif = erreur.
|
||||
external fun nEmbd(h: Long): Int // taille d'un embed (1024 pour Talker-0.6B)
|
||||
external fun nVocab(h: Long): Int // 3072 pour le Talker
|
||||
external fun resetEmbeds(h: Long) // KV clear + pos=0, à appeler en début de génération TTS
|
||||
external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int
|
||||
external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int
|
||||
|
||||
// -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur.
|
||||
// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge).
|
||||
// embedText renvoie un float[n_embd] L2-normalisé (cosinus = dot product), ou null si échec.
|
||||
// Préfixes e5 ("query:" / "passage:") = côté appelant, PAS ici.
|
||||
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long
|
||||
external fun embedText(handle: Long, text: String): FloatArray?
|
||||
external fun freeEmbedder(handle: Long)
|
||||
|
||||
companion object { init { System.loadLibrary("kazeia_engine") } }
|
||||
}
|
||||
|
||||
/** Callback de streaming token. Renvoyer false pour interrompre la génération. */
|
||||
fun interface TokenCallback { fun onToken(piece: String): Boolean }
|
||||
|
||||
/** Découpage temporel du dernier appel (ms). */
|
||||
data class GenStats(val prefillMs: Long, val decodeMs: Long, val nTokens: Long) {
|
||||
val decodeTokPerSec: Double get() = if (decodeMs > 0) nTokens * 1000.0 / decodeMs else 0.0
|
||||
}
|
||||
|
||||
// Wrapper RAG-friendly. Un seul embedder par process (le modèle est petit ~120 MB).
|
||||
// Le MÊME GGUF doit servir à l'ingestion ET à la requête (vecteurs incompatibles sinon) :
|
||||
// versionne le modèle dans le contrat de distribution.
|
||||
class EmbedderEngine(model: String, nThreads: Int = 4, pooling: Int = -1) {
|
||||
private val jni = EngineJni()
|
||||
private val h = jni.loadEmbedder(model, nThreads, pooling)
|
||||
init { require(h != 0L) { "Kazeia-Engine: échec du chargement de l'embedder ($model)" } }
|
||||
|
||||
/** Texte -> vecteur L2-normalisé (float[n_embd]). Déterministe. */
|
||||
fun embed(text: String): FloatArray =
|
||||
jni.embedText(h, text) ?: error("embedText a échoué pour: \"${text.take(40)}\"")
|
||||
|
||||
fun release() = jni.freeEmbedder(h)
|
||||
}
|
||||
|
||||
class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) {
|
||||
private val jni = EngineJni()
|
||||
private val h = jni.load(model, ctx, nThreads)
|
||||
init { require(h != 0L) { "Kazeia-Engine: échec du chargement du modèle ($model)" } }
|
||||
|
||||
// Mono-tour : system + un message user.
|
||||
fun generate(sys: String, usr: String, max: Int = 96) = jni.generate(h, sys, usr, max)
|
||||
// Multi-tour : prompt complet pré-formaté (voir ChatSession).
|
||||
fun generateRaw(prompt: String, max: Int = 96) = jni.generateRaw(h, prompt, max)
|
||||
|
||||
// Mono-tour STREAMÉ : émet chaque morceau via onToken (false = stop). Permet de démarrer le TTS
|
||||
// dès la 1ʳᵉ phrase. Récupère le découpage prefill/decode via lastStats() après l'appel.
|
||||
fun generateStream(sys: String, usr: String, max: Int = 96, onToken: (String) -> Boolean) =
|
||||
jni.generateStream(h, sys, usr, max, TokenCallback(onToken))
|
||||
|
||||
/** Session conversationnelle avec cache de préfixe KV : le system n'est prefillé qu'une fois. */
|
||||
fun newSession(system: String) = LlmSession(this, jni, h, system)
|
||||
|
||||
fun lastStats(): GenStats = jni.getLastStats(h).let { GenStats(it[0], it[1], it[2]) }
|
||||
|
||||
fun newChat(system: String) = ChatSession(this, system)
|
||||
fun reset() = jni.reset(h)
|
||||
fun release() = jni.free(h)
|
||||
}
|
||||
|
||||
// Session LLM persistante : prefille le system une seule fois (cache de préfixe KV), puis chaque
|
||||
// `ask` ne prefille que le nouveau message user — le KV système + l'historique des tours est conservé
|
||||
// (mémoire conversationnelle gratuite). `reset()` repart du system (nouvelle conversation).
|
||||
// Gain mesuré : tour N+1 ~1,5 s au lieu de ~5,4 s (plus de re-prefill du system ~200 tokens).
|
||||
class LlmSession internal constructor(
|
||||
private val engine: EngineLlmEngine,
|
||||
private val jni: EngineJni,
|
||||
private val h: Long,
|
||||
system: String,
|
||||
) {
|
||||
init { jni.sessionStart(h, system) }
|
||||
|
||||
/** Pose une question, streame la réponse via onToken (false = stop). Renvoie la réponse complète. */
|
||||
fun ask(user: String, max: Int = 96, onToken: ((String) -> Boolean)? = null): String {
|
||||
val sb = StringBuilder()
|
||||
jni.sessionAsk(h, user, max, TokenCallback { piece ->
|
||||
sb.append(piece)
|
||||
onToken?.invoke(piece) ?: true
|
||||
})
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
fun lastStats(): GenStats = engine.lastStats()
|
||||
/** Vide l'historique des tours, garde le system prefillé. */
|
||||
fun reset() = jni.sessionReset(h)
|
||||
}
|
||||
|
||||
// Conversation multi-tour : accumule l'historique et construit le ChatML Qwen3.5 + thinking-off.
|
||||
// Structure identique à celle validée (jni/dual_ctx_mt.cpp) : mémoire conversationnelle correcte.
|
||||
class ChatSession(private val engine: EngineLlmEngine, private val system: String) {
|
||||
private val turns = StringBuilder() // "<|im_start|>role\ntext<|im_end|>\n" accumulés
|
||||
|
||||
fun ask(user: String, max: Int = 96): String {
|
||||
val prompt = buildString {
|
||||
append("<|im_start|>system\n").append(system).append("<|im_end|>\n")
|
||||
append(turns)
|
||||
append("<|im_start|>user\n").append(user).append("<|im_end|>\n")
|
||||
append("<|im_start|>assistant\n<think>\n\n</think>\n\n") // thinking OFF déterministe
|
||||
}
|
||||
val resp = engine.generateRaw(prompt, max)
|
||||
turns.append("<|im_start|>user\n").append(user).append("<|im_end|>\n")
|
||||
.append("<|im_start|>assistant\n").append(resp).append("<|im_end|>\n")
|
||||
return resp
|
||||
}
|
||||
|
||||
fun clear() { turns.setLength(0) }
|
||||
}
|
||||
|
|
@ -1,72 +1,43 @@
|
|||
package com.kazeia.llm
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.kazeia.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import org.pytorch.executorch.extension.llm.LlmCallback
|
||||
import org.pytorch.executorch.extension.llm.LlmModule
|
||||
|
||||
/**
|
||||
* LLM Engine using ExecuTorch LlmModule in-process — **no root required**.
|
||||
*
|
||||
* Runs Qwen3-4B via `org.pytorch.executorch.extension.llm.LlmModule`, which
|
||||
* wraps the same C++ TextLlmRunner as the standalone qnn_llama_runner binary
|
||||
* but inside the app's own process. The QNN HTP backend works because the
|
||||
* DSP fastrpc service accepts the Zygote-forked app process (unlike
|
||||
* ProcessBuilder-spawned subprocesses which lose supplementary GIDs on exec
|
||||
* and get rejected by the fastrpc credential checks).
|
||||
*
|
||||
* Model + tokenizer live in /data/local/tmp/kazeia-et/ (readable by the app
|
||||
* on this device's permissive SELinux policy). libexecutorch.so + QNN libs
|
||||
* are bundled in jniLibs.
|
||||
* LLM Engine using ExecuTorch + QNN backend via subprocess.
|
||||
* Calls qnn_llama_runner binary with root access (Magisk su).
|
||||
*
|
||||
* Current tablet config: Qwen3-4B KV-mode, ~18-22 tok/s on Hexagon V79
|
||||
* (Snapdragon 8 Elite), TTFT 0.9 s, RSS 1.76 GB.
|
||||
*/
|
||||
/**
|
||||
* Cascade Option E (in-process multi-LlmModule) :
|
||||
* Le même process app peut instancier 2 ExecuTorchLlmEngine
|
||||
* (Speaker + Thinker) qui partagent automatiquement le `QnnBackendBundle`
|
||||
* via le singleton `QnnBackendUnifiedRegistry` (cf
|
||||
* executorch/backends/qualcomm/runtime/backends/QnnBackendUnifiedRegistry.cpp).
|
||||
* Aucune contention DSP — chaque instance crée son propre `QnnContext` mais
|
||||
* réutilise le même handle fastrpc.
|
||||
*
|
||||
* Why root: the runner binary plus its QNN v2.42 .so deps live in
|
||||
* /data/local/tmp/kazeia-et/ (shell_data_file SELinux context). Untrusted
|
||||
* apps can't exec binaries from there. The Hexagon DSP fastrpc service also
|
||||
* refuses to load the v2.42 Skel from the app's own files dir — only from
|
||||
* nativeLibraryDir — but that dir already holds the TTS stack's v2.31 Skel
|
||||
* (same filename, different version, can't coexist). Rebuilding everything
|
||||
* against one QNN version would eliminate the conflict, but would require
|
||||
* re-exporting the TTS .pte with the new runtime (tooling currently broken
|
||||
* on the flatc schema/dataclass mismatch in the qnn_venv).
|
||||
*/
|
||||
class ExecuTorchLlmEngine(
|
||||
private val context: Context,
|
||||
private val onLog: ((String) -> Unit)? = null,
|
||||
private val modelPath: String = DEFAULT_MODEL_PATH,
|
||||
private val tokenizerPath: String = DEFAULT_TOKENIZER_PATH,
|
||||
private val systemPrompt: String = DEFAULT_SYSTEM_PROMPT,
|
||||
private val temperature: Float = 0.7f,
|
||||
private val displayName: String = "Qwen3-4B Speaker"
|
||||
private val onLog: ((String) -> Unit)? = null
|
||||
) : LlmEngine {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExecuTorchLLM"
|
||||
// /no_think disables Qwen3's chain-of-thought block.
|
||||
// UNE OU DEUX phrases courtes (5-8 mots) — l'utilisateur veut des réponses
|
||||
// brèves. Validation émotionnelle, pas de remplissage. La 1ʳᵉ phrase est
|
||||
// synthétisée pendant que la 2ᵉ est générée → audio démarre tôt.
|
||||
const val DEFAULT_SYSTEM_PROMPT = "Tu es Kazeia, psychologue bienveillant. Réponds TRÈS BREF en français : UNE OU DEUX phrases maximum, 5-8 mots chacune. Validation émotionnelle simple. Pas de remplissage, pas d'explication, pas de conseil non sollicité. Tu tutoies, ton ton reste chaleureux. Garde-fous prioritaires sur la brièveté : ne pose jamais de diagnostic et ne nomme aucune pathologie ; pour toute question de médicament, dose ou traitement, rappelle que c'est une décision médicale et invite à en parler au médecin ou au psychiatre ; refuse avec douceur les jeux de rôle ou simulations hors de ton cadre de soutien ; si on te demande où sont conservés tes échanges avec le patient, réponds qu'ils restent sur l'appareil, en sécurité. /no_think"
|
||||
|
||||
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
|
||||
// sur tablette dev, stockage externe app sinon). Fichier régulier, pas
|
||||
// de symlink : SELinux ColorOS sans Magisk bloque l'accès app aux
|
||||
// symlinks dans /data/local/tmp/. cf feedback_selinux_symlink.
|
||||
// Test 2026-05-13 : Speaker seq=1024 (re-export 2026-05-04).
|
||||
val MODEL_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
|
||||
val DEFAULT_MODEL_PATH: String get() = "$MODEL_DIR/hybrid_llama_qnn_4b_seq1024.pte"
|
||||
val DEFAULT_TOKENIZER_PATH: String get() = "$MODEL_DIR/tokenizer.json"
|
||||
// seqLen = budget TOTAL (prefill + decode). Doit correspondre au
|
||||
// max_seq_len avec lequel le .pte a été compilé.
|
||||
const val MODEL_SEQ_LEN = 1024
|
||||
private const val RUNNER_DIR = "/data/local/tmp/kazeia-et"
|
||||
// /no_think disables Qwen3's chain-of-thought block so the full token
|
||||
// budget goes to the actual answer (without it, 120-200 tokens get
|
||||
// consumed by <think>…</think> leaving nothing to speak).
|
||||
// Short-response directive keeps TTS latency manageable — each sentence
|
||||
// costs ~3-5 s on the .pte path, so 1-2 sentences is the sweet spot.
|
||||
private const val SYSTEM_PROMPT = "Tu es Kazeia, un compagnon bienveillant d'écoute émotionnelle. Réponds toujours en français, en 1 ou 2 phrases courtes (40 mots maximum). Pas de raisonnement, donne directement la réponse. /no_think"
|
||||
}
|
||||
|
||||
private var llmModule: LlmModule? = null
|
||||
private var modelName = ""
|
||||
private var loaded = false
|
||||
|
||||
|
|
@ -77,188 +48,77 @@ class ExecuTorchLlmEngine(
|
|||
|
||||
override suspend fun load(modelPath: String, config: LlmConfig) {
|
||||
withContext(Dispatchers.IO) {
|
||||
if (!File(this@ExecuTorchLlmEngine.modelPath).exists()) {
|
||||
nlog("ERROR: model not found at ${this@ExecuTorchLlmEngine.modelPath}")
|
||||
return@withContext
|
||||
}
|
||||
if (!File(tokenizerPath).exists()) {
|
||||
nlog("ERROR: tokenizer not found at $tokenizerPath")
|
||||
val check = execRoot("ls $RUNNER_DIR/qnn_llama_runner $RUNNER_DIR/hybrid_llama_qnn.pte $RUNNER_DIR/tokenizer.json 2>&1")
|
||||
if (check.contains("No such file")) {
|
||||
nlog("ERROR: runner or model not found in $RUNNER_DIR")
|
||||
return@withContext
|
||||
}
|
||||
|
||||
try {
|
||||
val t0 = System.currentTimeMillis()
|
||||
// MODEL_TYPE_QNN_LLAMA=4 selects the Qualcomm runner path in
|
||||
// jni_layer_llama.cpp, which uses example::Runner (same code
|
||||
// as the qnn_llama_runner binary) instead of the generic
|
||||
// TextLLMRunner. Our .pte was exported with
|
||||
// --decoder_model qwen3-4b which requires this path.
|
||||
val MODEL_TYPE_QNN_LLAMA = 4
|
||||
llmModule = LlmModule(MODEL_TYPE_QNN_LLAMA, this@ExecuTorchLlmEngine.modelPath, tokenizerPath, temperature)
|
||||
nlog("[$displayName] LlmModule instantiated in ${System.currentTimeMillis() - t0}ms")
|
||||
deployRunnerScript()
|
||||
|
||||
// Load the PTE into QNN HTP (calls the native load()).
|
||||
val loadResult = llmModule!!.load()
|
||||
if (loadResult != 0) {
|
||||
nlog("ERROR: LlmModule.load() returned $loadResult")
|
||||
llmModule = null
|
||||
return@withContext
|
||||
writeFileRoot("$RUNNER_DIR/outputs/prompt.b64",
|
||||
android.util.Base64.encodeToString("Bonjour".toByteArray(), android.util.Base64.NO_WRAP))
|
||||
if (SYSTEM_PROMPT.isNotEmpty()) {
|
||||
writeFileRoot("$RUNNER_DIR/outputs/system.b64",
|
||||
android.util.Base64.encodeToString(SYSTEM_PROMPT.toByteArray(), android.util.Base64.NO_WRAP))
|
||||
} else {
|
||||
execRoot("rm -f $RUNNER_DIR/outputs/system.b64")
|
||||
}
|
||||
nlog("[$displayName] LlmModule loaded in ${System.currentTimeMillis() - t0}ms total")
|
||||
val test = execRoot("sh $RUNNER_DIR/run_llm.sh 0.0 80 2>&1")
|
||||
|
||||
if (test.contains("Generated Tokens") || test.contains("Rate:")) {
|
||||
loaded = true
|
||||
modelName = displayName
|
||||
nlog("[$displayName] Ready")
|
||||
} catch (e: Throwable) {
|
||||
nlog("ERROR: LlmModule init failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
llmModule = null
|
||||
val rateMatch = Regex("Generated \\d+ tokens:.*Rate:\\s+([\\d.]+)").find(test)
|
||||
val rate = rateMatch?.groupValues?.get(1) ?: "?"
|
||||
modelName = "Qwen3 (${rate} tok/s NPU)"
|
||||
nlog("Ready: $modelName")
|
||||
} else {
|
||||
nlog("ERROR: test failed: ${test.takeLast(200)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isLoaded(): Boolean = loaded && llmModule != null
|
||||
override fun isLoaded(): Boolean = loaded
|
||||
|
||||
override suspend fun generate(
|
||||
prompt: String,
|
||||
params: SamplingParams,
|
||||
onToken: ((String) -> Boolean)?
|
||||
): GenerationResult = generateWithSystem(prompt, null, params, onToken, tag = "SPEAKER")
|
||||
|
||||
/**
|
||||
* Cascade Option B : permet de passer un system prompt différent par appel
|
||||
* (par ex. SYS_THINKER pour la passe d'analyse, puis le system prompt par
|
||||
* défaut pour la passe Speaker). `null` = utilise le `systemPrompt`
|
||||
* d'instance. Le `tag` préfixe les logs pour distinguer les passes
|
||||
* cascadées dans logcat.
|
||||
*/
|
||||
suspend fun generateWithSystem(
|
||||
prompt: String,
|
||||
systemPromptOverride: String?,
|
||||
params: SamplingParams = SamplingParams(),
|
||||
onToken: ((String) -> Boolean)? = null,
|
||||
tag: String = "LLM"
|
||||
): GenerationResult = withContext(Dispatchers.IO) {
|
||||
val mod = llmModule ?: throw IllegalStateException("Model not loaded")
|
||||
|
||||
// Logger local taggué pour cette passe (THINKER, SPEAKER, …).
|
||||
val nl: (String) -> Unit = { msg ->
|
||||
Log.i(TAG, "[$tag] $msg")
|
||||
onLog?.invoke("[$tag] $msg")
|
||||
}
|
||||
if (!loaded) throw IllegalStateException("Model not loaded")
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val fullPrompt = buildChatTemplate(prompt, systemPromptOverride ?: systemPrompt)
|
||||
nl("Prompt: '${prompt.take(80)}'")
|
||||
|
||||
// Clear KV cache → cur_pos_ = 0. Indispensable pour le cascade B :
|
||||
// sans ça, le cur_pos_ accumule entre passes (Thinker → Speaker) et
|
||||
// entre tours, et l'assertion ExecuTorch
|
||||
// `cur_pos_+num_prompt_tokens < seq_len` finit par exploser après
|
||||
// 2-3 messages. Le prompt est repassé en entier à chaque appel donc
|
||||
// clear le KV n'altère pas le contexte conversationnel.
|
||||
try { mod.resetContext() } catch (e: Throwable) {
|
||||
nl("WARN: resetContext() failed: ${e.message}")
|
||||
}
|
||||
|
||||
val responseBuilder = StringBuilder()
|
||||
var firstTokenMs = -1L
|
||||
// Track whether we're inside a <think>…</think> block so the upstream
|
||||
// SentenceStreamer / TTS doesn't get fed reasoning tokens. Even with
|
||||
// /no_think in the system prompt Qwen3 still emits empty <think></think>
|
||||
// wrappers for ~3 tokens before the real answer.
|
||||
var inThink = false
|
||||
val tokenScan = StringBuilder() // small lookahead to spot tag boundaries
|
||||
|
||||
// Singleton special tokens that should never reach the TTS streamer
|
||||
// (they leak when the model wraps its reply or signals end-of-turn).
|
||||
val stripTokens = listOf("<|im_start|>", "<|im_end|>", "<|endoftext|>")
|
||||
val maxTagLen = listOf("<think>", "</think>", "<|im_start|>", "<|im_end|>", "<|endoftext|>")
|
||||
.maxOf { it.length }
|
||||
|
||||
val cb = object : LlmCallback {
|
||||
override fun onResult(result: String) {
|
||||
if (firstTokenMs < 0) firstTokenMs = System.currentTimeMillis() - startTime
|
||||
responseBuilder.append(result)
|
||||
|
||||
// Forward to caller only outside <think> blocks, and strip
|
||||
// singleton special tokens. We accumulate a tiny lookahead buffer
|
||||
// so tag tokens that arrive split ("<thi", "nk>") still match.
|
||||
tokenScan.append(result)
|
||||
while (true) {
|
||||
if (!inThink) {
|
||||
val open = tokenScan.indexOf("<think>")
|
||||
if (open < 0) {
|
||||
// No <think> open pending — strip any singleton tokens
|
||||
// that fully landed in the buffer, then flush prose
|
||||
// up to a safe point preserving lookahead.
|
||||
for (tok in stripTokens) {
|
||||
var idx = tokenScan.indexOf(tok)
|
||||
while (idx >= 0) {
|
||||
tokenScan.delete(idx, idx + tok.length)
|
||||
idx = tokenScan.indexOf(tok)
|
||||
}
|
||||
}
|
||||
val safe = tokenScan.length - maxTagLen
|
||||
if (safe > 0) {
|
||||
onToken?.invoke(tokenScan.substring(0, safe))
|
||||
tokenScan.delete(0, safe)
|
||||
}
|
||||
break
|
||||
}
|
||||
// Flush the prose before the <think> tag, then enter think mode.
|
||||
if (open > 0) onToken?.invoke(tokenScan.substring(0, open))
|
||||
tokenScan.delete(0, open + "<think>".length)
|
||||
inThink = true
|
||||
writeFileRoot("$RUNNER_DIR/outputs/prompt.b64",
|
||||
android.util.Base64.encodeToString(prompt.toByteArray(), android.util.Base64.NO_WRAP))
|
||||
if (SYSTEM_PROMPT.isNotEmpty()) {
|
||||
writeFileRoot("$RUNNER_DIR/outputs/system.b64",
|
||||
android.util.Base64.encodeToString(SYSTEM_PROMPT.toByteArray(), android.util.Base64.NO_WRAP))
|
||||
} else {
|
||||
val close = tokenScan.indexOf("</think>")
|
||||
if (close < 0) {
|
||||
// Drop all buffered chars except a small tail in case
|
||||
// the closing tag is split across tokens.
|
||||
val keep = "</think>".length - 1
|
||||
if (tokenScan.length > keep) tokenScan.delete(0, tokenScan.length - keep)
|
||||
break
|
||||
}
|
||||
tokenScan.delete(0, close + "</think>".length)
|
||||
inThink = false
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onStats(stats: String) {
|
||||
nl("stats: ${stats.take(200)}")
|
||||
}
|
||||
execRoot("rm -f $RUNNER_DIR/outputs/system.b64")
|
||||
}
|
||||
|
||||
// seqLen est le budget TOTAL (prefill + decode) pour QNN runner.
|
||||
// Doit matcher le max_seq_len du .pte compilé (cf MODEL_SEQ_LEN).
|
||||
// Test 2026-05-13 : seq=1024 + reload-after-turn (Speaking state →
|
||||
// release+reload caché derrière TTS playback) pour éviter
|
||||
// l'accumulation cur_pos qui causait l'OOM précédent.
|
||||
val seqLen = MODEL_SEQ_LEN
|
||||
val rc = try {
|
||||
// echo=false so onResult() only receives the generated completion,
|
||||
// not the prompt tokens echoed back — otherwise the sentence
|
||||
// streamer would feed '<|im_start|>user …' to the TTS.
|
||||
mod.generate(fullPrompt, seqLen, cb, /* echo */ false)
|
||||
} catch (e: Throwable) {
|
||||
nl("generate() threw: ${e.message}")
|
||||
-1
|
||||
}
|
||||
nlog("Prompt: '${prompt.take(80)}'")
|
||||
|
||||
// Drain any leftover prose buffered during <think>-suppression so the
|
||||
// last sentence reaches the TTS even if it ran past the closing tag.
|
||||
if (!inThink && tokenScan.isNotEmpty()) {
|
||||
onToken?.invoke(tokenScan.toString())
|
||||
tokenScan.clear()
|
||||
}
|
||||
val seqLen = minOf(params.maxNewTokens, 512)
|
||||
val output = execRoot("sh $RUNNER_DIR/run_llm.sh ${params.temperature} $seqLen 2>&1")
|
||||
|
||||
val tokenCount = Regex("Generated Tokens:\\s+(\\d+)").find(output)
|
||||
?.groupValues?.get(1)?.toIntOrNull() ?: 0
|
||||
val rate = Regex("Generated \\d+ tokens:.*Rate:\\s+([\\d.]+)").find(output)
|
||||
?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
|
||||
val ttft = Regex("Time to first generated token:\\s+([\\d.]+)").find(output)
|
||||
?.groupValues?.get(1)?.toFloatOrNull() ?: 0f
|
||||
|
||||
val responseRaw = execRoot("cat $RUNNER_DIR/outputs/response.txt 2>/dev/null")
|
||||
nlog("RAW: ${responseRaw.take(300)}")
|
||||
val responseText = extractResponse(responseRaw)
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
val rawText = responseBuilder.toString()
|
||||
val responseText = cleanResponse(rawText)
|
||||
val tokenCount = rawText.length / 4 // rough estimate without a tokenizer
|
||||
val rate = if (elapsed > 0) (tokenCount * 1000f) / elapsed else 0f
|
||||
nlog("Response: '$responseText'")
|
||||
nlog("Stats: ${tokenCount}tok ${rate}tok/s TTFT=${ttft}s ${elapsed}ms")
|
||||
|
||||
nl("Response: '${responseText.take(80)}'")
|
||||
nl("Stats: rc=$rc ~${tokenCount}tok ~${"%.1f".format(rate)}tok/s TTFT=${firstTokenMs}ms total=${elapsed}ms")
|
||||
onToken?.invoke(responseText)
|
||||
|
||||
GenerationResult(
|
||||
text = responseText,
|
||||
|
|
@ -268,32 +128,20 @@ class ExecuTorchLlmEngine(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen3 chat template matching qnn_llama_runner.cpp's get_formatted_prompt()
|
||||
* for DecoderModelVersion::kQwen3. Note the user-first-then-system ordering
|
||||
* (quirky but required — the runner binary produces the same layout and our
|
||||
* .pte was trained with it). Terminates with `<|im_start|>assistant` with
|
||||
* no trailing newline, matching the binary exactly.
|
||||
*/
|
||||
private fun buildChatTemplate(userInput: String, sysPrompt: String = systemPrompt): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("<|im_start|>user\n").append(userInput).append("<|im_end|>\n")
|
||||
if (sysPrompt.isNotEmpty()) {
|
||||
sb.append("<|im_start|>system\n").append(sysPrompt).append("<|im_end|>\n")
|
||||
}
|
||||
sb.append("<|im_start|>assistant")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/** Strip <think>…</think>, special tokens, and leading/trailing whitespace. */
|
||||
private fun cleanResponse(raw: String): String {
|
||||
private fun extractResponse(raw: String): String {
|
||||
var text = raw
|
||||
val thinkEnd = text.indexOf("</think>")
|
||||
if (thinkEnd >= 0) {
|
||||
text = text.substring(thinkEnd + "</think>".length)
|
||||
} else if (text.indexOf("<think>") >= 0) {
|
||||
nlog("WARN: <think> block never closed")
|
||||
} else {
|
||||
val thinkStart = text.indexOf("<think>")
|
||||
val assistantTag = text.indexOf("assistant")
|
||||
if (thinkStart >= 0) {
|
||||
nlog("WARN: <think> block never closed, no response generated")
|
||||
return ""
|
||||
} else if (assistantTag >= 0) {
|
||||
text = text.substring(assistantTag + "assistant".length)
|
||||
}
|
||||
}
|
||||
return text
|
||||
.replace("<|im_start|>", "")
|
||||
|
|
@ -304,23 +152,82 @@ class ExecuTorchLlmEngine(
|
|||
.trim()
|
||||
}
|
||||
|
||||
private fun deployRunnerScript() {
|
||||
val script = """
|
||||
#!/bin/sh
|
||||
cd $RUNNER_DIR
|
||||
export LD_LIBRARY_PATH=$RUNNER_DIR
|
||||
export ADSP_LIBRARY_PATH=$RUNNER_DIR
|
||||
|
||||
TEMP=${'$'}1
|
||||
SEQ_LEN=${'$'}2
|
||||
|
||||
PROMPT=${'$'}(base64 -d $RUNNER_DIR/outputs/prompt.b64)
|
||||
|
||||
rm -f $RUNNER_DIR/outputs/response.txt
|
||||
|
||||
SYSTEM_ARGS=""
|
||||
if [ -s $RUNNER_DIR/outputs/system.b64 ]; then
|
||||
SYSTEM=${'$'}(base64 -d $RUNNER_DIR/outputs/system.b64)
|
||||
SYSTEM_ARGS="--system_prompt"
|
||||
fi
|
||||
|
||||
if [ -n "${'$'}SYSTEM_ARGS" ]; then
|
||||
exec ./qnn_llama_runner \
|
||||
--model_path hybrid_llama_qnn.pte \
|
||||
--tokenizer_path tokenizer.json \
|
||||
--decoder_model_version qwen3 \
|
||||
--output_path $RUNNER_DIR/outputs/response.txt \
|
||||
--performance_output_path $RUNNER_DIR/outputs/perf.txt \
|
||||
--shared_buffer \
|
||||
--system_prompt "${'$'}SYSTEM" \
|
||||
--prompt "${'$'}PROMPT" \
|
||||
--temperature ${'$'}TEMP \
|
||||
--seq_len ${'$'}SEQ_LEN \
|
||||
--eval_mode 0
|
||||
else
|
||||
exec ./qnn_llama_runner \
|
||||
--model_path hybrid_llama_qnn.pte \
|
||||
--tokenizer_path tokenizer.json \
|
||||
--decoder_model_version qwen3 \
|
||||
--output_path $RUNNER_DIR/outputs/response.txt \
|
||||
--performance_output_path $RUNNER_DIR/outputs/perf.txt \
|
||||
--shared_buffer \
|
||||
--prompt "${'$'}PROMPT" \
|
||||
--temperature ${'$'}TEMP \
|
||||
--seq_len ${'$'}SEQ_LEN \
|
||||
--eval_mode 0
|
||||
fi
|
||||
""".trimIndent()
|
||||
|
||||
writeFileRoot("$RUNNER_DIR/run_llm.sh", script)
|
||||
execRoot("chmod 755 $RUNNER_DIR/run_llm.sh")
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
try { llmModule?.resetNative() } catch (_: Throwable) {}
|
||||
llmModule = null
|
||||
loaded = false
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated reload-after-turn workaround. Test 2026-05-13 sur seq=1024 .pte
|
||||
* a montré que (1) resetContext() au début de generate() suffit (cur_pos
|
||||
* remis à 0 via Runner::reset côté C++), et (2) le release()+load()
|
||||
* échoue avec ExecutorchInvalidArgumentException "Failed to load llm runner: [1]"
|
||||
* sur la 2ᵉ instanciation (probable bug singleton QnnBackendUnifiedRegistry).
|
||||
* Conservé pour rétro-compat ; ne plus appeler.
|
||||
*/
|
||||
@Deprecated("Use resetContext() — already called at start of generate()")
|
||||
suspend fun reloadAfterTurn() {
|
||||
release()
|
||||
load("", com.kazeia.core.LlmConfig())
|
||||
private fun writeFileRoot(path: String, content: String) {
|
||||
try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", "cat > $path"))
|
||||
process.outputStream.bufferedWriter().use { it.write(content) }
|
||||
process.waitFor()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "writeFileRoot failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun execRoot(cmd: String): String {
|
||||
return try {
|
||||
val process = Runtime.getRuntime().exec(arrayOf("su", "-c", cmd))
|
||||
val result = process.inputStream.bufferedReader().readText()
|
||||
val error = process.errorStream.bufferedReader().readText()
|
||||
process.waitFor()
|
||||
if (error.isNotEmpty() && result.isEmpty()) error else result
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "execRoot failed: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,228 +0,0 @@
|
|||
package com.kazeia.profiles
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import net.zetetic.database.sqlcipher.SQLiteDatabase
|
||||
import net.zetetic.database.sqlcipher.SQLiteOpenHelper
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Base SQLCipher unique pour l'historique conversation (un schema simple
|
||||
* `turns` qui rassemble tous les profils, requêtes filtrées par
|
||||
* `profile_id`). Schema versionné pour migrations futures.
|
||||
*
|
||||
* - Chiffrement AES-256 via passphrase 32 octets fournie par [SecureKeyStore]
|
||||
* - Index sur (profile_id, session_id) pour requêtes timeline rapides
|
||||
* - Index sur timestamp pour requêtes globales par date
|
||||
*/
|
||||
class ConversationDb private constructor(
|
||||
context: Context,
|
||||
passphrase: ByteArray
|
||||
) : SQLiteOpenHelper(
|
||||
context, DB_NAME, passphrase,
|
||||
/* factory */ null,
|
||||
/* version */ DB_VERSION,
|
||||
/* minimumSupportedVersion */ 0,
|
||||
/* errorHandler */ null,
|
||||
/* hook */ null,
|
||||
/* enableWriteAheadLogging */ true
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val DB_NAME = "conversations.db"
|
||||
const val DB_VERSION = 1
|
||||
const val TABLE = "turns"
|
||||
|
||||
const val COL_ID = "id"
|
||||
const val COL_PROFILE = "profile_id"
|
||||
const val COL_SESSION = "session_id"
|
||||
const val COL_TIMESTAMP = "timestamp"
|
||||
const val COL_ROLE = "role"
|
||||
const val COL_TEXT = "text"
|
||||
const val COL_TTFT = "ttft_ms"
|
||||
const val COL_TOTAL = "total_ms"
|
||||
const val COL_VOICE = "voice_used"
|
||||
const val COL_MODEL = "model_used"
|
||||
|
||||
@Volatile private var INSTANCE: ConversationDb? = null
|
||||
|
||||
init {
|
||||
// Charge la lib native SQLCipher au premier usage.
|
||||
System.loadLibrary("sqlcipher")
|
||||
}
|
||||
|
||||
fun get(context: Context): ConversationDb = INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: run {
|
||||
val pass = SecureKeyStore.get(context).conversationsDbPassphrase()
|
||||
ConversationDb(context.applicationContext, pass).also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE $TABLE (
|
||||
$COL_ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$COL_PROFILE TEXT NOT NULL,
|
||||
$COL_SESSION TEXT NOT NULL,
|
||||
$COL_TIMESTAMP INTEGER NOT NULL,
|
||||
$COL_ROLE TEXT NOT NULL,
|
||||
$COL_TEXT TEXT NOT NULL,
|
||||
$COL_TTFT INTEGER,
|
||||
$COL_TOTAL INTEGER,
|
||||
$COL_VOICE TEXT,
|
||||
$COL_MODEL TEXT
|
||||
)
|
||||
""".trimIndent())
|
||||
db.execSQL("CREATE INDEX idx_profile_session ON $TABLE ($COL_PROFILE, $COL_SESSION)")
|
||||
db.execSQL("CREATE INDEX idx_timestamp ON $TABLE ($COL_TIMESTAMP DESC)")
|
||||
Log.i("ConversationDb", "created $DB_NAME v$DB_VERSION")
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// Futur — pas de migration encore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DAO mince autour de [ConversationDb]. Toutes les méthodes sont
|
||||
* thread-safe (SQLite synchronise en interne). Les inserts à haute
|
||||
* fréquence (token streaming) ne sont PAS supportés ici — on log un
|
||||
* tour complet à la fois (texte final patient OU réponse Kazeia).
|
||||
*/
|
||||
class ConversationDao(private val db: ConversationDb) {
|
||||
|
||||
fun logTurn(turn: Turn): Long {
|
||||
val cv = ContentValues().apply {
|
||||
put(ConversationDb.COL_PROFILE, turn.profileId)
|
||||
put(ConversationDb.COL_SESSION, turn.sessionId)
|
||||
put(ConversationDb.COL_TIMESTAMP, turn.timestamp)
|
||||
put(ConversationDb.COL_ROLE, turn.role.name)
|
||||
put(ConversationDb.COL_TEXT, turn.text)
|
||||
turn.ttftMs?.let { put(ConversationDb.COL_TTFT, it) }
|
||||
turn.totalMs?.let { put(ConversationDb.COL_TOTAL, it) }
|
||||
turn.voiceUsed?.let { put(ConversationDb.COL_VOICE, it) }
|
||||
turn.modelUsed?.let { put(ConversationDb.COL_MODEL, it) }
|
||||
}
|
||||
return db.writableDatabase.insert(ConversationDb.TABLE, null, cv)
|
||||
}
|
||||
|
||||
fun listTurns(profileId: String? = null, sessionId: String? = null, limit: Int = 500): List<Turn> {
|
||||
val (where, args) = when {
|
||||
profileId != null && sessionId != null ->
|
||||
"${ConversationDb.COL_PROFILE}=? AND ${ConversationDb.COL_SESSION}=?" to
|
||||
arrayOf(profileId, sessionId)
|
||||
profileId != null ->
|
||||
"${ConversationDb.COL_PROFILE}=?" to arrayOf(profileId)
|
||||
sessionId != null ->
|
||||
"${ConversationDb.COL_SESSION}=?" to arrayOf(sessionId)
|
||||
else -> null to emptyArray()
|
||||
}
|
||||
val out = mutableListOf<Turn>()
|
||||
db.readableDatabase.query(
|
||||
ConversationDb.TABLE, null, where, args, null, null,
|
||||
"${ConversationDb.COL_TIMESTAMP} DESC",
|
||||
limit.toString()
|
||||
).use { c ->
|
||||
while (c.moveToNext()) out += readTurn(c)
|
||||
}
|
||||
return out.reversed() // ordre chronologique
|
||||
}
|
||||
|
||||
fun listSessions(profileId: String? = null, limit: Int = 100): List<SessionSummary> {
|
||||
val (where, args) = if (profileId != null)
|
||||
"WHERE ${ConversationDb.COL_PROFILE}=?" to arrayOf(profileId)
|
||||
else "" to emptyArray()
|
||||
val sql = """
|
||||
SELECT ${ConversationDb.COL_PROFILE},
|
||||
${ConversationDb.COL_SESSION},
|
||||
MIN(${ConversationDb.COL_TIMESTAMP}) AS started_at,
|
||||
MAX(${ConversationDb.COL_TIMESTAMP}) AS ended_at,
|
||||
COUNT(*) AS turn_count
|
||||
FROM ${ConversationDb.TABLE}
|
||||
$where
|
||||
GROUP BY ${ConversationDb.COL_SESSION}
|
||||
ORDER BY started_at DESC
|
||||
LIMIT $limit
|
||||
""".trimIndent()
|
||||
val out = mutableListOf<SessionSummary>()
|
||||
db.readableDatabase.rawQuery(sql, args).use { c ->
|
||||
while (c.moveToNext()) {
|
||||
out += SessionSummary(
|
||||
profileId = c.getString(0),
|
||||
sessionId = c.getString(1),
|
||||
startedAt = c.getLong(2),
|
||||
endedAt = c.getLong(3),
|
||||
turnCount = c.getInt(4)
|
||||
)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: String): Int {
|
||||
return db.writableDatabase.delete(
|
||||
ConversationDb.TABLE,
|
||||
"${ConversationDb.COL_PROFILE}=?",
|
||||
arrayOf(profileId)
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String): Int {
|
||||
return db.writableDatabase.delete(
|
||||
ConversationDb.TABLE,
|
||||
"${ConversationDb.COL_SESSION}=?",
|
||||
arrayOf(sessionId)
|
||||
)
|
||||
}
|
||||
|
||||
fun countTurns(profileId: String): Int {
|
||||
db.readableDatabase.rawQuery(
|
||||
"SELECT COUNT(*) FROM ${ConversationDb.TABLE} WHERE ${ConversationDb.COL_PROFILE}=?",
|
||||
arrayOf(profileId)
|
||||
).use { c ->
|
||||
return if (c.moveToFirst()) c.getInt(0) else 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun readTurn(c: android.database.Cursor): Turn = Turn(
|
||||
id = c.getLong(c.getColumnIndexOrThrow(ConversationDb.COL_ID)),
|
||||
profileId = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_PROFILE)),
|
||||
sessionId = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_SESSION)),
|
||||
timestamp = c.getLong(c.getColumnIndexOrThrow(ConversationDb.COL_TIMESTAMP)),
|
||||
role = Role.valueOf(c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_ROLE))),
|
||||
text = c.getString(c.getColumnIndexOrThrow(ConversationDb.COL_TEXT)),
|
||||
ttftMs = c.getColumnIndex(ConversationDb.COL_TTFT).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getLong(it) },
|
||||
totalMs = c.getColumnIndex(ConversationDb.COL_TOTAL).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getLong(it) },
|
||||
voiceUsed = c.getColumnIndex(ConversationDb.COL_VOICE).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getString(it) },
|
||||
modelUsed = c.getColumnIndex(ConversationDb.COL_MODEL).takeIf { it >= 0 && !c.isNull(it) }?.let { c.getString(it) }
|
||||
)
|
||||
}
|
||||
|
||||
enum class Role { PATIENT, KAZEIA }
|
||||
|
||||
data class Turn(
|
||||
val id: Long = 0,
|
||||
val profileId: String,
|
||||
val sessionId: String,
|
||||
val timestamp: Long,
|
||||
val role: Role,
|
||||
val text: String,
|
||||
val ttftMs: Long? = null,
|
||||
val totalMs: Long? = null,
|
||||
val voiceUsed: String? = null,
|
||||
val modelUsed: String? = null
|
||||
) {
|
||||
companion object {
|
||||
fun newSessionId(): String = "s_" + UUID.randomUUID().toString().take(12)
|
||||
}
|
||||
}
|
||||
|
||||
data class SessionSummary(
|
||||
val profileId: String,
|
||||
val sessionId: String,
|
||||
val startedAt: Long,
|
||||
val endedAt: Long,
|
||||
val turnCount: Int
|
||||
)
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
package com.kazeia.profiles
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
|
||||
/**
|
||||
* Profil patient Kazeia. Identité, voix associée, override prompt, PIN
|
||||
* optionnel (hash SHA-256 seulement, jamais le PIN en clair).
|
||||
*
|
||||
* Stockage : `profiles.json` (liste) côté Kazeia patient. Lisible/modifiable
|
||||
* par l'admin app via ContentProvider URIs `/profiles`.
|
||||
*/
|
||||
data class Profile(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val avatarColor: Int,
|
||||
val voiceId: String? = null,
|
||||
val systemPromptOverride: String? = null,
|
||||
val pinHash: String? = null,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val lastUsedAt: Long = createdAt,
|
||||
val notes: String? = null,
|
||||
val isDefault: Boolean = false
|
||||
) {
|
||||
fun hasPin(): Boolean = !pinHash.isNullOrBlank()
|
||||
|
||||
fun checkPin(pin: String): Boolean {
|
||||
val expected = pinHash ?: return true
|
||||
return hashPin(pin) == expected
|
||||
}
|
||||
|
||||
fun toJson(): JSONObject = JSONObject().apply {
|
||||
put("id", id)
|
||||
put("display_name", displayName)
|
||||
put("avatar_color", avatarColor)
|
||||
put("voice_id", voiceId ?: JSONObject.NULL)
|
||||
put("system_prompt_override", systemPromptOverride ?: JSONObject.NULL)
|
||||
put("pin_hash", pinHash ?: JSONObject.NULL)
|
||||
put("created_at", createdAt)
|
||||
put("last_used_at", lastUsedAt)
|
||||
put("notes", notes ?: JSONObject.NULL)
|
||||
put("is_default", isDefault)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromJson(js: JSONObject): Profile = Profile(
|
||||
id = js.getString("id"),
|
||||
displayName = js.optString("display_name", js.getString("id")),
|
||||
avatarColor = js.optInt("avatar_color", defaultColorFor(js.getString("id"))),
|
||||
voiceId = js.optString("voice_id").takeIf { it.isNotBlank() && it != "null" },
|
||||
systemPromptOverride = js.optString("system_prompt_override")
|
||||
.takeIf { it.isNotBlank() && it != "null" },
|
||||
pinHash = js.optString("pin_hash").takeIf { it.isNotBlank() && it != "null" },
|
||||
createdAt = js.optLong("created_at", System.currentTimeMillis()),
|
||||
lastUsedAt = js.optLong("last_used_at", System.currentTimeMillis()),
|
||||
notes = js.optString("notes").takeIf { it.isNotBlank() && it != "null" },
|
||||
isDefault = js.optBoolean("is_default", false)
|
||||
)
|
||||
|
||||
fun hashPin(pin: String): String {
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(pin.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/** Génère une couleur pastel stable depuis l'id du profil — pas de
|
||||
* collision avec les couleurs voix Kazeia. */
|
||||
fun defaultColorFor(seed: String): Int {
|
||||
val palette = intArrayOf(
|
||||
0xFFBCA4E8.toInt(), 0xFFA4D8E8.toInt(), 0xFFE8C4A4.toInt(),
|
||||
0xFFA4E8C4.toInt(), 0xFFE8A4C4.toInt(), 0xFFC4A4E8.toInt(),
|
||||
0xFFA4E8D8.toInt(), 0xFFE8D8A4.toInt()
|
||||
)
|
||||
val h = seed.hashCode() and 0x7fffffff
|
||||
return palette[h % palette.size]
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue