Sauvegarde JARVIS Local + guide d'installation
Browse files- .env.example +49 -0
- .gitignore +7 -0
- INSTALL.md +116 -0
- LICENSE +21 -0
- README.md +135 -0
- corrections.json +10 -0
- designs/a-reacteur.html +247 -0
- designs/ancien-index.html +652 -0
- designs/b-halo.html +171 -0
- designs/c-commandement.html +240 -0
- index.html +681 -0
- install.ps1 +19 -0
- requirements.txt +11 -0
- server.py +1320 -0
- start.bat +5 -0
.env.example
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# JARVIS Local — configuration (100 % local, aucune clé API)
|
| 3 |
+
# Copie ce fichier en ".env" et décommente ce que tu veux changer.
|
| 4 |
+
# ============================================================
|
| 5 |
+
|
| 6 |
+
# ---------------- Cerveau (Ollama)
|
| 7 |
+
# OLLAMA_URL=http://127.0.0.1:11434
|
| 8 |
+
# JARVIS_LLM=qwen3:30b-a3b-instruct-2507-q4_K_M # MoE rapide ; qwen3:32b = dense, ~4x plus lent
|
| 9 |
+
# JARVIS_NUM_CTX=32768 # doit égaler OLLAMA_CONTEXT_LENGTH (sinon Ollama recharge le modèle)
|
| 10 |
+
# JARVIS_TEMPERATURE=0.3 # plus bas = décisions plus stables
|
| 11 |
+
# JARVIS_KEEP_ALIVE=2h # durée pendant laquelle le modèle reste en VRAM
|
| 12 |
+
|
| 13 |
+
# ---------------- Oreille (faster-whisper)
|
| 14 |
+
# WHISPER_MODEL=large-v3-turbo
|
| 15 |
+
# WHISPER_DEVICE=cuda # cpu si pas de GPU NVIDIA
|
| 16 |
+
# WHISPER_COMPUTE=int8_float16 # float16 = un peu plus de VRAM ; int8 pour le CPU
|
| 17 |
+
# WHISPER_LANG=fr
|
| 18 |
+
|
| 19 |
+
# ---------------- Voix (Kokoro)
|
| 20 |
+
# JARVIS_VOICE=ff_siwis # seule voix française native (féminine)
|
| 21 |
+
# # bm_george / bm_lewis / bm_daniel / bm_fable = hommes britanniques
|
| 22 |
+
# # qui parlent français avec l'accent... très majordome
|
| 23 |
+
# TTS_SPEED=1.0
|
| 24 |
+
# TTS_DEVICE=cuda # cpu possible (~3x plus lent)
|
| 25 |
+
# JARVIS_LANGUAGE=français
|
| 26 |
+
# Comment JARVIS t'appelle (vide = « monsieur »)
|
| 27 |
+
JARVIS_USER_NAME=
|
| 28 |
+
# Mots que Whisper doit bien reconnaître (noms propres), séparés par des virgules
|
| 29 |
+
# JARVIS_HOTWORDS=Mediawan, Kubernetes
|
| 30 |
+
|
| 31 |
+
# ---------------- Détection de parole (Silero VAD)
|
| 32 |
+
# VAD_THRESHOLD=0.5 # monte à 0.6-0.7 dans une pièce bruyante
|
| 33 |
+
# JARVIS_SILENCE_MS=550 # silence qui marque la fin de ta phrase (monte à 700 si il te coupe)
|
| 34 |
+
# JARVIS_CONTINUE_MS=1000 # si tu reprends la parole avant ce délai, c'est la suite de ta phrase
|
| 35 |
+
# JARVIS_BARGE_IN=1 # 1 = tu peux couper JARVIS en parlant ; 0 = micro ignoré pendant qu'il parle
|
| 36 |
+
# # (mets 0 si tu es sur haut-parleurs et qu'il s'interrompt tout seul)
|
| 37 |
+
|
| 38 |
+
# ---------------- Agent de tâches (Claude Code)
|
| 39 |
+
# JARVIS_AGENT_BACKEND=ollama # ollama = Claude Code tourne sur le modèle local
|
| 40 |
+
# # anthropic = ton abonnement Claude (plus capable, mais en ligne)
|
| 41 |
+
# JARVIS_AGENT_MODEL=qwen3:30b-a3b-instruct-2507-q4_K_M
|
| 42 |
+
# JARVIS_COMPLEX_BACKEND=anthropic # tâches complexes -> ton abonnement Claude (ollama = tout local)
|
| 43 |
+
# JARVIS_NOTIFY=1 # notification Windows à la fin de chaque tâche
|
| 44 |
+
# Dossier où l'agent a le droit de travailler (conseil : un dossier dédié)
|
| 45 |
+
JARVIS_WORKDIR=D:/jarvis-workspace
|
| 46 |
+
# JARVIS_TASK_TIMEOUT=600
|
| 47 |
+
# JARVIS_PERMISSION_MODE=bypassPermissions # acceptEdits = fichiers OK, shell refusé
|
| 48 |
+
|
| 49 |
+
# JARVIS_PORT=8788
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
.venv/
|
| 5 |
+
models/
|
| 6 |
+
memory.json
|
| 7 |
+
logs/
|
INSTALL.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 📘 JARVIS Local — Guide d'installation pas à pas
|
| 2 |
+
|
| 3 |
+
Ce guide permet de réinstaller JARVIS de zéro sur un PC Windows, à partir de cette sauvegarde.
|
| 4 |
+
|
| 5 |
+
## 1. Configuration testée (fonctionnelle au 03/10/2026)
|
| 6 |
+
|
| 7 |
+
| Élément | Version |
|
| 8 |
+
|---|---|
|
| 9 |
+
| OS | Windows 11 Pro |
|
| 10 |
+
| GPU | NVIDIA GeForce RTX 5090 (32 Go), pilote 616.64 |
|
| 11 |
+
| Python (venv) | 3.12.15 (créé par `uv`) |
|
| 12 |
+
| Ollama | 0.35.0 |
|
| 13 |
+
| Modèle LLM | `qwen3:30b-a3b-instruct-2507-q4_K_M` |
|
| 14 |
+
| Node.js | v24.19.0 |
|
| 15 |
+
| Claude Code | 2.1.288 |
|
| 16 |
+
|
| 17 |
+
Avec 24 Go de VRAM : baisser le contexte (`OLLAMA_CONTEXT_LENGTH` / `JARVIS_NUM_CTX` = 24576) ou prendre un modèle plus petit.
|
| 18 |
+
|
| 19 |
+
## 2. Prérequis à installer
|
| 20 |
+
|
| 21 |
+
1. **Pilote NVIDIA** récent (CUDA 12/13 pris en charge).
|
| 22 |
+
2. **Python** (n'importe quelle version récente) : <https://www.python.org/downloads/> — cocher « Add to PATH ».
|
| 23 |
+
3. **Ollama** : <https://ollama.com/download>
|
| 24 |
+
4. *(Optionnel, pour les tâches)* **Node.js** : <https://nodejs.org>, puis :
|
| 25 |
+
```powershell
|
| 26 |
+
npm install -g @anthropic-ai/claude-code
|
| 27 |
+
```
|
| 28 |
+
Pas besoin de compte en mode `ollama`. Pour les tâches complexes envoyées à Claude (`JARVIS_COMPLEX_BACKEND=anthropic`), lancer `claude` une fois et se connecter avec son abonnement.
|
| 29 |
+
|
| 30 |
+
## 3. Récupérer le code
|
| 31 |
+
|
| 32 |
+
```powershell
|
| 33 |
+
# Option A : avec git (git lfs non nécessaire, aucun gros fichier dans le dépôt)
|
| 34 |
+
git clone https://huggingface.co/PascalF53/jarvis-local D:\jarvis
|
| 35 |
+
|
| 36 |
+
# Option B : avec la CLI Hugging Face
|
| 37 |
+
pip install -U huggingface_hub
|
| 38 |
+
hf download PascalF53/jarvis-local --local-dir D:\jarvis
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Le dossier `models/`, l'environnement `.venv`, le fichier `.env`, `memory.json` et `logs/` **ne sont pas sauvegardés** : ils sont recréés par l'installation (ou contiennent des données personnelles).
|
| 42 |
+
|
| 43 |
+
## 4. Lancer l'installeur
|
| 44 |
+
|
| 45 |
+
```powershell
|
| 46 |
+
cd D:\jarvis
|
| 47 |
+
powershell -ExecutionPolicy Bypass -File install.ps1
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
Ce que fait `install.ps1` :
|
| 51 |
+
|
| 52 |
+
1. installe `uv`, crée `.venv` en Python 3.12 et y installe `requirements.txt` (FastAPI, faster-whisper, kokoro-onnx, onnxruntime-gpu, bibliothèques CUDA…) ;
|
| 53 |
+
2. télécharge dans `models/` :
|
| 54 |
+
- `kokoro-v1.0.onnx` et `voices-v1.0.bin` (voix, ~350 Mo) ;
|
| 55 |
+
- `silero_vad.onnx` (détection de parole) ;
|
| 56 |
+
3. `ollama pull qwen3:30b-a3b-instruct-2507-q4_K_M` (~18 Go) ;
|
| 57 |
+
4. copie `.env.example` en `.env` s'il n'existe pas.
|
| 58 |
+
|
| 59 |
+
Whisper `large-v3-turbo` se télécharge tout seul au premier démarrage.
|
| 60 |
+
|
| 61 |
+
## 5. Régler Ollama (fortement recommandé)
|
| 62 |
+
|
| 63 |
+
Sans ça, JARVIS ne peut plus répondre pendant qu'une tâche tourne :
|
| 64 |
+
|
| 65 |
+
```powershell
|
| 66 |
+
setx OLLAMA_NUM_PARALLEL 2
|
| 67 |
+
setx OLLAMA_FLASH_ATTENTION 1
|
| 68 |
+
setx OLLAMA_KV_CACHE_TYPE q8_0
|
| 69 |
+
setx OLLAMA_CONTEXT_LENGTH 32768
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Puis **quitter et relancer Ollama** (icône dans la barre des tâches).
|
| 73 |
+
|
| 74 |
+
## 6. Configurer `.env`
|
| 75 |
+
|
| 76 |
+
Ouvrir `.env` et adapter au minimum :
|
| 77 |
+
|
| 78 |
+
```ini
|
| 79 |
+
JARVIS_USER_NAME=Tony # comment JARVIS t'appelle
|
| 80 |
+
JARVIS_WORKDIR=D:/jarvis-workspace # dossier dédié où l'agent a le droit de travailler
|
| 81 |
+
JARVIS_HOTWORDS=Mediawan # noms propres à bien reconnaître
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Créer le dossier de travail : `mkdir D:\jarvis-workspace`.
|
| 85 |
+
Toutes les autres options sont décrites dans `.env.example` et dans le README.
|
| 86 |
+
|
| 87 |
+
Fichiers personnels optionnels :
|
| 88 |
+
- `corrections.json` (fourni) : mots mal transcrits → mot correct ;
|
| 89 |
+
- `memory.json` : créé automatiquement quand on dit « Souviens-toi que… ».
|
| 90 |
+
|
| 91 |
+
## 7. Premier lancement et vérifications
|
| 92 |
+
|
| 93 |
+
```powershell
|
| 94 |
+
ollama ps # Ollama doit répondre
|
| 95 |
+
.\start.bat # ou .venv\Scripts\python.exe server.py
|
| 96 |
+
```
|
| 97 |
+
|
| 98 |
+
1. Le navigateur s'ouvre sur <http://127.0.0.1:8788> (le premier démarrage prend ~30 s : compilation CUDA de Whisper).
|
| 99 |
+
2. Cliquer sur l'orbe, autoriser le micro, choisir le bon micro dans la liste si besoin.
|
| 100 |
+
3. Dire « Bonjour JARVIS, présente-toi. » → la console affiche la transcription et « 1er son … » (~0,8 s attendu).
|
| 101 |
+
4. Dire « Lance une tâche : liste les fichiers de mon dossier de travail. » pour vérifier l'agent Claude Code.
|
| 102 |
+
5. `ollama ps` doit indiquer **100% GPU** ; sinon la VRAM est saturée (voir README › Problèmes fréquents).
|
| 103 |
+
|
| 104 |
+
## 8. Contenu de la sauvegarde
|
| 105 |
+
|
| 106 |
+
| Fichier | Rôle |
|
| 107 |
+
|---|---|
|
| 108 |
+
| `server.py` | Serveur FastAPI : VAD, Whisper, LLM + outils, Kokoro, agent de tâches |
|
| 109 |
+
| `index.html` | Interface (orbe) servie sur le port 8788 |
|
| 110 |
+
| `designs/` | Variantes d'interface |
|
| 111 |
+
| `install.ps1` | Installeur Windows |
|
| 112 |
+
| `start.bat` | Lanceur |
|
| 113 |
+
| `requirements.txt` | Dépendances Python |
|
| 114 |
+
| `.env.example` | Modèle de configuration commenté |
|
| 115 |
+
| `corrections.json` | Corrections de transcription |
|
| 116 |
+
| `README.md` / `INSTALL.md` | Documentation |
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Thomas Berton
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: mit
|
| 3 |
+
language:
|
| 4 |
+
- fr
|
| 5 |
+
tags:
|
| 6 |
+
- voice-assistant
|
| 7 |
+
- speech-to-text
|
| 8 |
+
- text-to-speech
|
| 9 |
+
- ollama
|
| 10 |
+
- faster-whisper
|
| 11 |
+
- kokoro
|
| 12 |
+
- silero-vad
|
| 13 |
+
- claude-code
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
# 🤖 JARVIS Local
|
| 17 |
+
|
| 18 |
+
**Ton assistant vocal personnel, façon Iron Man, 100 % sur ton PC. Aucune clé API, rien ne sort de la machine.**
|
| 19 |
+
|
| 20 |
+
Tu parles à une orbe futuriste. JARVIS te répond à la voix, et il sait vraiment faire des choses :
|
| 21 |
+
|
| 22 |
+
- 🗣️ **Conversation vocale mains libres** : il détecte tout seul quand tu as fini de parler, et tu peux lui couper la parole
|
| 23 |
+
- 🛠️ **Vraies tâches sur ta machine** : il délègue à un agent **Claude Code branché sur le modèle local** (fichiers, code, analyses…) et te résume le résultat
|
| 24 |
+
- 🚀 **Lancer tes applications** sur l'écran de ton choix (« Mets Spotify sur l'écran de gauche »)
|
| 25 |
+
- 🌐 **Ouvrir des sites** (« Ouvre mes emails »)
|
| 26 |
+
- 📊 **Cartes, rapports et graphiques** directement sur l'interface
|
| 27 |
+
- ❌ **Annuler une tâche** à la voix ou d'un clic
|
| 28 |
+
|
| 29 |
+
## 🧱 La stack
|
| 30 |
+
|
| 31 |
+
| Brique | Outil | Où ça tourne |
|
| 32 |
+
|---|---|---|
|
| 33 |
+
| Détection de fin de parole | **Silero VAD** (ONNX) | CPU, ~0,1 ms par trame |
|
| 34 |
+
| Oreille | **faster-whisper** large-v3-turbo (int8) | GPU, ~0,2 s par phrase |
|
| 35 |
+
| Cerveau | **Qwen3-30B-A3B Instruct** (MoE) via **Ollama** | GPU, ~250 tokens/s |
|
| 36 |
+
| Voix | **Kokoro** 82M (ONNX) | GPU, ~0,1-0,25 s par phrase |
|
| 37 |
+
| Agent de tâches | **Claude Code** : tâches simples sur le modèle local (via Ollama), tâches complexes sur ton abonnement Claude | GPU / cloud |
|
| 38 |
+
|
| 39 |
+
```
|
| 40 |
+
micro ─WebSocket PCM 16 kHz─► Silero VAD ─► Whisper ─► Qwen3 (+ outils) ─► Kokoro ─► haut-parleurs
|
| 41 |
+
│
|
| 42 |
+
└─► delegate_task ─► claude -p (sur Ollama)
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
La réponse est lue **par morceaux** pendant que le modèle écrit encore : le premier son arrive **~0,8 s** après la fin de ta phrase (affiché dans la console : « 1er son … »).
|
| 46 |
+
|
| 47 |
+
### Ce que JARVIS sait faire en plus
|
| 48 |
+
- **Mémoire** : « Souviens-toi que… » / « Oublie… ». Les faits sont gardés dans `memory.json` (modifiable à la main) et rechargés à chaque session.
|
| 49 |
+
- **Notifications Windows** à la fin de chaque tâche, et sur demande (« préviens-moi… »).
|
| 50 |
+
- **Routage des tâches** : il choisit lui-même local ou Claude selon la difficulté ; dis « avec Claude » ou « en mode cloud » pour forcer Claude.
|
| 51 |
+
- **Heure et date** instantanées (sans passer par l'agent).
|
| 52 |
+
- **Phrases avec hésitation** : si tu reprends la parole moins d'une seconde après une pause, JARVIS abandonne sa réponse et recolle les deux morceaux.
|
| 53 |
+
- **Corrections de transcription** : `corrections.json` (« MediaOne » → « Mediawan »…), pris en compte sans redémarrer.
|
| 54 |
+
- **Journal** : chaque session est enregistrée dans `logs/AAAA-MM-JJ.log` (ce qu'il a entendu, ses réponses, ses outils, la latence).
|
| 55 |
+
|
| 56 |
+
## 📋 Prérequis
|
| 57 |
+
|
| 58 |
+
- Windows, GPU NVIDIA (testé sur RTX 5090 32 Go ; 24 Go = prends un modèle plus petit, voir plus bas)
|
| 59 |
+
- [Python](https://www.python.org/downloads/) (n'importe quelle version récente : l'installeur crée un environnement 3.12)
|
| 60 |
+
- [Ollama](https://ollama.com/download)
|
| 61 |
+
- Optionnel, pour les tâches : [Node.js](https://nodejs.org) + Claude Code (`npm install -g @anthropic-ai/claude-code`). Pas besoin de compte en mode `ollama`.
|
| 62 |
+
|
| 63 |
+
## ⚙️ Installation
|
| 64 |
+
|
| 65 |
+
> 📘 Guide pas à pas complet (versions testées, vérifications, réinstallation sur un nouveau PC) : **[INSTALL.md](INSTALL.md)**
|
| 66 |
+
|
| 67 |
+
```powershell
|
| 68 |
+
powershell -ExecutionPolicy Bypass -File install.ps1
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Le script crée `.venv`, installe les dépendances, télécharge Kokoro et Silero dans `models/`, puis fait `ollama pull qwen3:30b-a3b-instruct-2507-q4_K_M`. Whisper se télécharge tout seul au premier démarrage.
|
| 72 |
+
|
| 73 |
+
### ⚡ Réglage Ollama recommandé
|
| 74 |
+
|
| 75 |
+
Par défaut, Ollama ne traite **qu'une requête à la fois** : pendant qu'une tâche de l'agent tourne, JARVIS ne peut plus te répondre. Pour éviter ça, mets ces variables d'environnement utilisateur, puis **redémarre Ollama** :
|
| 76 |
+
|
| 77 |
+
```powershell
|
| 78 |
+
setx OLLAMA_NUM_PARALLEL 2
|
| 79 |
+
setx OLLAMA_FLASH_ATTENTION 1
|
| 80 |
+
setx OLLAMA_KV_CACHE_TYPE q8_0
|
| 81 |
+
setx OLLAMA_CONTEXT_LENGTH 32768
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Avec ça, le modèle MoE occupe environ 22 Go (2 conversations × 32k de contexte) et laisse de la marge à Whisper et Kokoro sur une carte de 32 Go.
|
| 85 |
+
|
| 86 |
+
## 🚀 Lancer
|
| 87 |
+
|
| 88 |
+
Double-clique **`start.bat`** (ou `.venv\Scripts\python.exe server.py`), puis va sur **http://127.0.0.1:8788**, clique l'orbe, autorise le micro et parle.
|
| 89 |
+
|
| 90 |
+
- « Bonjour JARVIS, présente-toi. »
|
| 91 |
+
- « Ouvre Discord sur l'écran de gauche. »
|
| 92 |
+
- « Lance une tâche : liste les fichiers de mon dossier de travail. »
|
| 93 |
+
- « Affiche-moi un comparatif des trois plus grandes villes de France. »
|
| 94 |
+
- « Stop » (en lui coupant la parole) / « Annule la tâche. »
|
| 95 |
+
|
| 96 |
+
## 🔧 Personnalisation (`.env`)
|
| 97 |
+
|
| 98 |
+
Toutes les options sont commentées dans `.env.example`. Les principales :
|
| 99 |
+
|
| 100 |
+
| Variable | Défaut | Rôle |
|
| 101 |
+
|---|---|---|
|
| 102 |
+
| `JARVIS_LLM` | `qwen3:30b-a3b-instruct-2507-q4_K_M` | Modèle Ollama (`qwen3:32b` = dense, un peu plus fin mais ~4x plus lent). Évite `qwen3:30b-a3b` tout court : il raisonne à voix haute |
|
| 103 |
+
| `JARVIS_USER_NAME` | *(vide)* | Ton prénom : JARVIS t'appelle ainsi au lieu de « monsieur » |
|
| 104 |
+
| `JARVIS_VOICE` | `ff_siwis` | Voix Kokoro. `ff_siwis` = seule voix française native (féminine). `bm_george`, `bm_lewis`… = hommes britanniques qui parlent français **avec l'accent** : l'effet majordome garanti |
|
| 105 |
+
| `JARVIS_BARGE_IN` | `1` | Couper JARVIS en parlant. Mets `0` sur haut-parleurs si sa propre voix l'interrompt |
|
| 106 |
+
| `VAD_THRESHOLD` / `JARVIS_SILENCE_MS` | `0.5` / `550` | Sensibilité du micro / silence qui marque la fin de ta phrase |
|
| 107 |
+
| `JARVIS_AGENT_BACKEND` | `ollama` | Moteur des tâches simples (`anthropic` = tout sur Claude) |
|
| 108 |
+
| `JARVIS_COMPLEX_BACKEND` | `anthropic` | Moteur des tâches complexes (`ollama` = 100 % local) |
|
| 109 |
+
| `JARVIS_CONTINUE_MS` | `1000` | Délai pendant lequel une reprise de parole est la suite de ta phrase |
|
| 110 |
+
| `JARVIS_TEMPERATURE` | `0.3` | Créativité du modèle (bas = plus stable) |
|
| 111 |
+
| `JARVIS_HOTWORDS` | *(vide)* | Noms propres à bien reconnaître, séparés par des virgules |
|
| 112 |
+
| `JARVIS_NOTIFY` | `1` | Notification Windows à la fin des tâches |
|
| 113 |
+
| `JARVIS_WORKDIR` | `D:/jarvis-workspace` | Seul dossier où l'agent travaille |
|
| 114 |
+
| `WHISPER_DEVICE` | `cuda` | `cpu` sans GPU NVIDIA (avec `WHISPER_COMPUTE=int8`) |
|
| 115 |
+
|
| 116 |
+
## ❓ Problèmes fréquents
|
| 117 |
+
|
| 118 |
+
- **« Ollama injoignable » au démarrage** : lance Ollama (icône dans la barre des tâches) puis relance.
|
| 119 |
+
- **JARVIS ne répond plus pendant une tâche** : applique le « Réglage Ollama recommandé » ci-dessus.
|
| 120 |
+
- **Réponses lentes, `ollama ps` affiche du CPU** : la VRAM est pleine. Ferme les applis gourmandes en GPU, baisse `OLLAMA_CONTEXT_LENGTH`/`JARVIS_NUM_CTX` (24576), ou passe à un modèle plus petit.
|
| 121 |
+
- **Il s'interrompt tout seul** : sa voix revient dans le micro. Prends un casque, ou `JARVIS_BARGE_IN=0`, ou monte `VAD_THRESHOLD`.
|
| 122 |
+
- **Chrome ne t'entend pas (Edge oui)** : Chrome capte un autre micro. Choisis le bon dans la liste sous l'orbe (le choix est mémorisé).
|
| 123 |
+
- **Il transcrit mal un nom propre** (ex. « MediaOne » au lieu de « Mediawan ») : ajoute-le dans `JARVIS_HOTWORDS`.
|
| 124 |
+
- **Le tout premier démarrage est long** (~30 s pour Whisper) : la carte compile ses noyaux CUDA une fois pour toutes, c'est normal.
|
| 125 |
+
- **Les tâches sont moins brillantes qu'avec Claude** : c'est un modèle local de 32B dans l'agent. Pour les grosses tâches : `JARVIS_AGENT_BACKEND=anthropic`.
|
| 126 |
+
- **Les rapports/graphiques ne s'affichent pas** : les composants graphiques de l'interface se chargent depuis un CDN, il faut internet (seulement pour ça).
|
| 127 |
+
|
| 128 |
+
## 🔒 Sécurité
|
| 129 |
+
|
| 130 |
+
- Serveur en **127.0.0.1 uniquement** : ne l'expose jamais sur le réseau.
|
| 131 |
+
- L'agent tourne **sans demande d'autorisation** (`bypassPermissions`), avec tes droits, dans `JARVIS_WORKDIR`. Garde un dossier dédié. Pour un garde-fou plus strict : `JARVIS_PERMISSION_MODE=acceptEdits` (les commandes shell sont alors refusées).
|
| 132 |
+
|
| 133 |
+
## 📄 Licence
|
| 134 |
+
|
| 135 |
+
MIT — fais-en ce que tu veux.
|
corrections.json
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_aide": "Mot mal transcrit -> mot correct. Insensible à la casse, appliqué après chaque transcription, pris en compte sans redémarrer.",
|
| 3 |
+
"MediaOne": "Mediawan",
|
| 4 |
+
"Media One": "Mediawan",
|
| 5 |
+
"Média One": "Mediawan",
|
| 6 |
+
"Javi": "JARVIS",
|
| 7 |
+
"Jarvi": "JARVIS",
|
| 8 |
+
"Jarviss": "JARVIS",
|
| 9 |
+
"Djarvis": "JARVIS"
|
| 10 |
+
}
|
designs/a-reacteur.html
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>JARVIS · Proposition A — Réacteur</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
/* Direction A — "Réacteur" : l'esprit Iron Man, épuré.
|
| 11 |
+
Une seule couleur vive (cyan) + un accent ambre réservé aux alertes,
|
| 12 |
+
l'orbe au centre, tout le reste orbite autour en anneaux discrets. */
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #03070d; --bg2: #071321;
|
| 15 |
+
--cyan: #5ee6ff; --cyan-2: #2aa8cc; --cyan-dim: rgba(94,230,255,.14);
|
| 16 |
+
--amber: #ffb547; --ok: #5dffb0; --err: #ff5f7a;
|
| 17 |
+
--text: #d8f4ff; --muted: #5f8aa3; --line: rgba(94,230,255,.16);
|
| 18 |
+
--hud: "Rajdhani", sans-serif; --mono: "JetBrains Mono", monospace;
|
| 19 |
+
}
|
| 20 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 21 |
+
html, body { height: 100%; }
|
| 22 |
+
body {
|
| 23 |
+
background: radial-gradient(900px 700px at 50% 45%, var(--bg2), var(--bg) 70%);
|
| 24 |
+
color: var(--text); font-family: var(--hud); overflow: hidden;
|
| 25 |
+
}
|
| 26 |
+
/* fine concentric guides behind everything */
|
| 27 |
+
body::before {
|
| 28 |
+
content: ""; position: fixed; inset: 0; pointer-events: none;
|
| 29 |
+
background: repeating-radial-gradient(circle at 50% 45%, transparent 0 79px, rgba(94,230,255,.035) 80px);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/* ---------- top bar */
|
| 33 |
+
header {
|
| 34 |
+
position: fixed; top: 0; left: 0; right: 0; height: 56px; z-index: 5;
|
| 35 |
+
display: flex; align-items: center; gap: 22px; padding: 0 28px;
|
| 36 |
+
font-family: var(--mono); font-size: 11px; letter-spacing: .22em; color: var(--muted);
|
| 37 |
+
text-transform: uppercase; border-bottom: 1px solid var(--line);
|
| 38 |
+
background: linear-gradient(180deg, rgba(3,7,13,.9), transparent);
|
| 39 |
+
}
|
| 40 |
+
header .brand { color: var(--cyan); font-weight: 500; letter-spacing: .4em; }
|
| 41 |
+
header .brand b { color: var(--text); font-weight: 500; }
|
| 42 |
+
header .sep { flex: 1; }
|
| 43 |
+
header .pill { display: flex; align-items: center; gap: 7px; }
|
| 44 |
+
header .pill i { width: 6px; height: 6px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
| 45 |
+
#clock { color: var(--text); }
|
| 46 |
+
|
| 47 |
+
/* ---------- stage */
|
| 48 |
+
main { position: fixed; inset: 56px 0 0 0; display: grid; place-items: center; }
|
| 49 |
+
#orbWrap { position: relative; width: min(62vh, 560px); aspect-ratio: 1; }
|
| 50 |
+
#orb { width: 100%; height: 100%; display: block; cursor: pointer; }
|
| 51 |
+
#state {
|
| 52 |
+
position: absolute; left: 50%; bottom: -6px; transform: translateX(-50%);
|
| 53 |
+
font-family: var(--mono); font-size: 11px; letter-spacing: .5em; text-transform: uppercase;
|
| 54 |
+
color: var(--cyan); text-shadow: 0 0 14px rgba(94,230,255,.7); white-space: nowrap;
|
| 55 |
+
}
|
| 56 |
+
#state small { color: var(--muted); letter-spacing: .2em; margin-left: 10px; }
|
| 57 |
+
|
| 58 |
+
/* subtitles: user line (dim) above, JARVIS line (bright) below */
|
| 59 |
+
#subs { position: absolute; left: 50%; bottom: 7vh; transform: translateX(-50%); width: min(820px, 80vw); text-align: center; }
|
| 60 |
+
#you { font-size: 15px; color: var(--muted); min-height: 22px; font-style: italic; }
|
| 61 |
+
#you:not(:empty)::before { content: "VOUS › "; font-family: var(--mono); font-style: normal; font-size: 10px; letter-spacing: .25em; color: var(--cyan-2); }
|
| 62 |
+
#say { margin-top: 8px; font-size: 22px; font-weight: 500; line-height: 1.35; min-height: 30px; text-shadow: 0 0 22px rgba(94,230,255,.25); }
|
| 63 |
+
|
| 64 |
+
/* ---------- left: holo cards */
|
| 65 |
+
#cards { position: fixed; left: 28px; top: 84px; width: 320px; display: flex; flex-direction: column; gap: 12px; z-index: 4; }
|
| 66 |
+
.card {
|
| 67 |
+
position: relative; padding: 14px 16px 14px 18px;
|
| 68 |
+
background: linear-gradient(120deg, rgba(94,230,255,.08), rgba(7,19,33,.85) 50%);
|
| 69 |
+
border: 1px solid var(--line); backdrop-filter: blur(8px);
|
| 70 |
+
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 14px 100%, 0 calc(100% - 14px));
|
| 71 |
+
animation: in .5s cubic-bezier(.2,.9,.25,1);
|
| 72 |
+
}
|
| 73 |
+
.card::before { content: ""; position: absolute; left: 0; top: 14px; bottom: 14px; width: 2px; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); }
|
| 74 |
+
.card.result::before { background: var(--ok); box-shadow: 0 0 10px var(--ok); }
|
| 75 |
+
.card h3 { font-family: var(--mono); font-size: 10px; letter-spacing: .3em; text-transform: uppercase; color: var(--cyan); margin-bottom: 8px; font-weight: 500; }
|
| 76 |
+
.card.result h3 { color: var(--ok); }
|
| 77 |
+
.card p, .card li { font-size: 14.5px; line-height: 1.5; }
|
| 78 |
+
.card ul { list-style: none; }
|
| 79 |
+
.card li { display: flex; justify-content: space-between; border-bottom: 1px dashed var(--line); padding: 3px 0; }
|
| 80 |
+
.card li b { font-family: var(--mono); font-weight: 500; color: var(--amber); font-size: 13px; }
|
| 81 |
+
@keyframes in { from { opacity: 0; transform: translateX(-30px); filter: brightness(2.5); } }
|
| 82 |
+
|
| 83 |
+
/* ---------- right: tasks as a vertical rail */
|
| 84 |
+
#rail { position: fixed; right: 28px; top: 84px; width: 300px; z-index: 4; }
|
| 85 |
+
#rail h2 { font-family: var(--mono); font-size: 10px; letter-spacing: .32em; color: var(--muted); text-transform: uppercase; margin-bottom: 14px; display: flex; justify-content: space-between; }
|
| 86 |
+
.task { position: relative; padding: 0 0 18px 22px; border-left: 1px solid var(--line); margin-left: 5px; animation: in .4s ease; }
|
| 87 |
+
.task::before { content: ""; position: absolute; left: -6px; top: 2px; width: 11px; height: 11px; border-radius: 50%; border: 2px solid var(--cyan); background: var(--bg); box-shadow: 0 0 10px var(--cyan); animation: pulse 1.2s infinite alternate; }
|
| 88 |
+
.task.done::before { border-color: var(--ok); background: var(--ok); box-shadow: 0 0 10px var(--ok); animation: none; }
|
| 89 |
+
.task .t { font-size: 15px; font-weight: 600; }
|
| 90 |
+
.task .m { font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 3px; letter-spacing: .08em; }
|
| 91 |
+
.task .m .eng { color: var(--amber); }
|
| 92 |
+
.task .bar { height: 2px; background: var(--line); margin-top: 8px; overflow: hidden; }
|
| 93 |
+
.task .bar i { display: block; height: 100%; width: 35%; background: var(--cyan); animation: scan 1.4s linear infinite; }
|
| 94 |
+
.task.done .bar i { width: 100%; background: var(--ok); animation: none; }
|
| 95 |
+
@keyframes pulse { to { box-shadow: 0 0 2px var(--cyan); } }
|
| 96 |
+
@keyframes scan { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
|
| 97 |
+
|
| 98 |
+
/* ---------- bottom-left: mic + telemetry */
|
| 99 |
+
footer {
|
| 100 |
+
position: fixed; left: 28px; bottom: 22px; z-index: 5; display: flex; gap: 18px; align-items: center;
|
| 101 |
+
font-family: var(--mono); font-size: 10.5px; color: var(--muted); letter-spacing: .12em;
|
| 102 |
+
}
|
| 103 |
+
select { background: transparent; color: var(--muted); border: 1px solid var(--line); padding: 5px 8px; font: inherit; }
|
| 104 |
+
footer b { color: var(--text); font-weight: 400; }
|
| 105 |
+
.badge { position: fixed; right: 28px; bottom: 22px; font-family: var(--mono); font-size: 10px; letter-spacing: .25em; color: var(--muted); }
|
| 106 |
+
</style>
|
| 107 |
+
</head>
|
| 108 |
+
<body>
|
| 109 |
+
<header>
|
| 110 |
+
<span class="brand">J.A.R.V.I.S. <b>· MK VIII</b></span>
|
| 111 |
+
<span class="pill"><i></i> 100 % local</span>
|
| 112 |
+
<span class="pill">qwen3 · whisper · kokoro</span>
|
| 113 |
+
<span class="sep"></span>
|
| 114 |
+
<span id="clock">--:--:--</span>
|
| 115 |
+
</header>
|
| 116 |
+
|
| 117 |
+
<main>
|
| 118 |
+
<div id="orbWrap">
|
| 119 |
+
<canvas id="orb"></canvas>
|
| 120 |
+
<div id="state">en veille</div>
|
| 121 |
+
</div>
|
| 122 |
+
<div id="subs"><div id="you"></div><div id="say"></div></div>
|
| 123 |
+
</main>
|
| 124 |
+
|
| 125 |
+
<section id="cards"></section>
|
| 126 |
+
<aside id="rail"><h2><span>Tâches</span><span id="count">0</span></h2><div id="tasks"></div></aside>
|
| 127 |
+
<footer>
|
| 128 |
+
<select><option>🎙 Micro — Shure MV7</option></select>
|
| 129 |
+
<span>latence <b id="lat">0,74 s</b></span>
|
| 130 |
+
<span>vram <b>24,1 / 32 Go</b></span>
|
| 131 |
+
</footer>
|
| 132 |
+
<div class="badge">PROPOSITION A · RÉACTEUR · démo</div>
|
| 133 |
+
|
| 134 |
+
<script>
|
| 135 |
+
"use strict";
|
| 136 |
+
/* ------------------------------------------------ orb: layered arc reactor */
|
| 137 |
+
const cv = document.getElementById("orb"), cx2 = cv.getContext("2d");
|
| 138 |
+
const fit = () => { const r = cv.getBoundingClientRect(); cv.width = r.width * devicePixelRatio; cv.height = r.height * devicePixelRatio; };
|
| 139 |
+
addEventListener("resize", fit); fit();
|
| 140 |
+
let level = 0, target = 0, mode = "idle", t = 0;
|
| 141 |
+
const TAU = Math.PI * 2;
|
| 142 |
+
const COL = { idle: [94,230,255], listening: [94,230,255], thinking: [255,181,71], speaking: [93,255,176] };
|
| 143 |
+
let col = [...COL.idle];
|
| 144 |
+
|
| 145 |
+
function draw() {
|
| 146 |
+
requestAnimationFrame(draw); t += 1 / 60;
|
| 147 |
+
level += (target - level) * 0.18;
|
| 148 |
+
col = col.map((c, i) => c + (COL[mode][i] - c) * 0.06);
|
| 149 |
+
const C = col.map(Math.round).join(",");
|
| 150 |
+
const w = cv.width, h = cv.height, cx = w / 2, cy = h / 2, d = devicePixelRatio;
|
| 151 |
+
const R = Math.min(w, h) * 0.17 * (1 + level * 0.12);
|
| 152 |
+
cx2.clearRect(0, 0, w, h);
|
| 153 |
+
|
| 154 |
+
// halo
|
| 155 |
+
let g = cx2.createRadialGradient(cx, cy, R * .3, cx, cy, R * 2.9);
|
| 156 |
+
g.addColorStop(0, `rgba(${C},${.22 + level * .25})`); g.addColorStop(1, "transparent");
|
| 157 |
+
cx2.fillStyle = g; cx2.fillRect(0, 0, w, h);
|
| 158 |
+
|
| 159 |
+
// radial spectrum (the voice made visible)
|
| 160 |
+
const bars = 96;
|
| 161 |
+
for (let i = 0; i < bars; i++) {
|
| 162 |
+
const a = i / bars * TAU - Math.PI / 2;
|
| 163 |
+
const n = Math.abs(Math.sin(i * 1.7 + t * 5) * Math.sin(i * .31 - t * 3));
|
| 164 |
+
const len = R * (.06 + level * .55 * n + (mode === "thinking" ? .05 * Math.sin(t * 6 + i / 4) ** 2 : 0));
|
| 165 |
+
const r0 = R * 1.32;
|
| 166 |
+
cx2.strokeStyle = `rgba(${C},${.35 + n * .5})`; cx2.lineWidth = 2.2 * d;
|
| 167 |
+
cx2.beginPath(); cx2.moveTo(cx + r0 * Math.cos(a), cy + r0 * Math.sin(a));
|
| 168 |
+
cx2.lineTo(cx + (r0 + len) * Math.cos(a), cy + (r0 + len) * Math.sin(a)); cx2.stroke();
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// segmented coil ring
|
| 172 |
+
cx2.save(); cx2.translate(cx, cy); cx2.rotate(t * (mode === "thinking" ? .9 : .18));
|
| 173 |
+
for (let i = 0; i < 10; i++) {
|
| 174 |
+
const a0 = i / 10 * TAU;
|
| 175 |
+
cx2.strokeStyle = `rgba(${C},.85)`; cx2.lineWidth = 6 * d;
|
| 176 |
+
cx2.beginPath(); cx2.arc(0, 0, R * 1.16, a0, a0 + TAU / 10 * .7); cx2.stroke();
|
| 177 |
+
}
|
| 178 |
+
cx2.restore();
|
| 179 |
+
|
| 180 |
+
// outer graduated ring + rotating reticle
|
| 181 |
+
cx2.save(); cx2.translate(cx, cy); cx2.rotate(-t * .07);
|
| 182 |
+
for (let i = 0; i < 120; i++) {
|
| 183 |
+
const a = i / 120 * TAU, big = i % 10 === 0;
|
| 184 |
+
cx2.strokeStyle = big ? "rgba(255,181,71,.8)" : `rgba(${C},.22)`; cx2.lineWidth = (big ? 2 : 1) * d;
|
| 185 |
+
const r0 = R * 2.05, r1 = R * (big ? 2.18 : 2.11);
|
| 186 |
+
cx2.beginPath(); cx2.moveTo(r0 * Math.cos(a), r0 * Math.sin(a)); cx2.lineTo(r1 * Math.cos(a), r1 * Math.sin(a)); cx2.stroke();
|
| 187 |
+
}
|
| 188 |
+
cx2.restore();
|
| 189 |
+
[[1.9, .35, 1], [2.32, .12, -1.6]].forEach(([k, span, sp]) => {
|
| 190 |
+
cx2.save(); cx2.translate(cx, cy); cx2.rotate(t * sp * .4);
|
| 191 |
+
cx2.strokeStyle = `rgba(${C},.6)`; cx2.lineWidth = 1.6 * d;
|
| 192 |
+
cx2.beginPath(); cx2.arc(0, 0, R * k, 0, TAU * span); cx2.stroke(); cx2.restore();
|
| 193 |
+
});
|
| 194 |
+
|
| 195 |
+
// core
|
| 196 |
+
g = cx2.createRadialGradient(cx, cy, 0, cx, cy, R);
|
| 197 |
+
g.addColorStop(0, "rgba(245,255,255,1)"); g.addColorStop(.35, `rgba(${C},.95)`); g.addColorStop(1, "rgba(5,25,45,.95)");
|
| 198 |
+
cx2.fillStyle = g; cx2.beginPath(); cx2.arc(cx, cy, R * .95, 0, TAU); cx2.fill();
|
| 199 |
+
// triangle (MK signature)
|
| 200 |
+
cx2.save(); cx2.translate(cx, cy); cx2.rotate(-t * .05);
|
| 201 |
+
cx2.strokeStyle = "rgba(255,255,255,.55)"; cx2.lineWidth = 1.5 * d; cx2.beginPath();
|
| 202 |
+
for (let i = 0; i <= 3; i++) { const a = i / 3 * TAU - Math.PI / 2; const p = [R * .62 * Math.cos(a), R * .62 * Math.sin(a)]; i ? cx2.lineTo(...p) : cx2.moveTo(...p); }
|
| 203 |
+
cx2.stroke(); cx2.restore();
|
| 204 |
+
}
|
| 205 |
+
draw();
|
| 206 |
+
|
| 207 |
+
/* ------------------------------------------------ demo conversation */
|
| 208 |
+
const $ = id => document.getElementById(id);
|
| 209 |
+
setInterval(() => $("clock").textContent = new Date().toLocaleTimeString("fr-FR"), 1000);
|
| 210 |
+
const LABEL = { idle: "en veille", listening: "écoute", thinking: "réflexion", speaking: "jarvis parle" };
|
| 211 |
+
function setMode(m, sub = "") { mode = m; $("state").innerHTML = LABEL[m] + (sub ? `<small>${sub}</small>` : ""); }
|
| 212 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
| 213 |
+
async function type(el, text, speed = 28) { el.textContent = ""; for (const ch of text) { el.textContent += ch; await sleep(speed); } }
|
| 214 |
+
setInterval(() => { target = mode === "listening" || mode === "speaking" ? .35 + Math.random() * .65 : mode === "thinking" ? .12 : 0; }, 90);
|
| 215 |
+
|
| 216 |
+
function card(title, html, kind = "") {
|
| 217 |
+
const c = document.createElement("div"); c.className = "card " + kind;
|
| 218 |
+
c.innerHTML = `<h3>${title}</h3>${html}`; $("cards").prepend(c);
|
| 219 |
+
while ($("cards").children.length > 3) $("cards").lastChild.remove();
|
| 220 |
+
}
|
| 221 |
+
function task(title, engine) {
|
| 222 |
+
const el = document.createElement("div"); el.className = "task";
|
| 223 |
+
el.innerHTML = `<div class="t">${title}</div><div class="m"><span class="eng">${engine}</span> · <span class="s">en cours</span> · <span class="c">0 s</span></div><div class="bar"><i></i></div>`;
|
| 224 |
+
$("tasks").prepend(el); $("count").textContent = $("tasks").children.length;
|
| 225 |
+
const t0 = Date.now(), iv = setInterval(() => el.querySelector(".c").textContent = Math.round((Date.now() - t0) / 1000) + " s", 1000);
|
| 226 |
+
return () => { clearInterval(iv); el.classList.add("done"); el.querySelector(".s").textContent = "terminé"; };
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
async function demo() {
|
| 230 |
+
for (;;) {
|
| 231 |
+
setMode("idle"); $("you").textContent = ""; $("say").textContent = ""; await sleep(1800);
|
| 232 |
+
setMode("listening"); await type($("you"), "Lance une veille sur les CVE critiques de la semaine.", 45);
|
| 233 |
+
setMode("thinking", "0,2 s"); await sleep(700);
|
| 234 |
+
setMode("speaking"); await type($("say"), "Très bien, Pascal. Je lance la veille, je vous préviens dès qu'elle est prête.");
|
| 235 |
+
const done = task("Veille CVE critiques", "local");
|
| 236 |
+
await sleep(1400); setMode("idle"); await sleep(3200);
|
| 237 |
+
done();
|
| 238 |
+
setMode("speaking"); $("you").textContent = "";
|
| 239 |
+
await type($("say"), "La veille est prête : trois vulnérabilités critiques, dont une activement exploitée.");
|
| 240 |
+
card("Veille CVE · semaine 40", `<ul><li>Exemple · passerelle VPN <b>9.8</b></li><li>Exemple · messagerie <b>9.1</b></li><li>Exemple · Kubernetes <b>8.8</b></li></ul>`, "result");
|
| 241 |
+
await sleep(2600);
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
demo();
|
| 245 |
+
</script>
|
| 246 |
+
</body>
|
| 247 |
+
</html>
|
designs/ancien-index.html
ADDED
|
@@ -0,0 +1,652 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>J.A.R.V.I.S.</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css">
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
| 13 |
+
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
|
| 14 |
+
<style>
|
| 15 |
+
:root {
|
| 16 |
+
--bg: #010409; --panel: rgba(6,14,24,.78); --line: rgba(64,220,255,.18);
|
| 17 |
+
--cyan: #40dcff; --cyan-dim: #1e7ea3; --amber: #ffb347; --text: #cfeaff;
|
| 18 |
+
--dim: #4d7c99; --ok: #43ffa4; --err: #ff5470;
|
| 19 |
+
--font-hud: "Rajdhani", sans-serif; --font-mono: "Share Tech Mono", monospace;
|
| 20 |
+
}
|
| 21 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 22 |
+
body {
|
| 23 |
+
background:
|
| 24 |
+
radial-gradient(1100px 650px at 50% 38%, rgba(20,60,100,.35) 0%, transparent 60%),
|
| 25 |
+
var(--bg);
|
| 26 |
+
color: var(--text); font-family: var(--font-hud);
|
| 27 |
+
height: 100vh; overflow: hidden; display: flex;
|
| 28 |
+
}
|
| 29 |
+
/* hex grid + scanlines overlay */
|
| 30 |
+
body::before {
|
| 31 |
+
content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .5;
|
| 32 |
+
background-image:
|
| 33 |
+
repeating-linear-gradient(0deg, rgba(64,220,255,.028) 0 1px, transparent 1px 3px),
|
| 34 |
+
linear-gradient(rgba(64,220,255,.045) 1px, transparent 1px),
|
| 35 |
+
linear-gradient(90deg, rgba(64,220,255,.045) 1px, transparent 1px);
|
| 36 |
+
background-size: 100% 3px, 52px 52px, 52px 52px;
|
| 37 |
+
}
|
| 38 |
+
/* subtle vignette */
|
| 39 |
+
body::after {
|
| 40 |
+
content: ""; position: fixed; inset: 0; pointer-events: none;
|
| 41 |
+
box-shadow: inset 0 0 220px rgba(0,0,0,.85);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/* ------------------------------------------------ HUD chrome */
|
| 45 |
+
.corner { position: fixed; width: 46px; height: 46px; pointer-events: none; z-index: 5; }
|
| 46 |
+
.corner::before { content: ""; position: absolute; inset: 0; border: 2px solid var(--cyan); opacity: .55; }
|
| 47 |
+
.corner.tl { top: 14px; left: 14px; } .corner.tl::before { border-right: 0; border-bottom: 0; }
|
| 48 |
+
.corner.tr { top: 14px; right: 358px; } .corner.tr::before { border-left: 0; border-bottom: 0; }
|
| 49 |
+
.corner.bl { bottom: 14px; left: 14px; } .corner.bl::before { border-right: 0; border-top: 0; }
|
| 50 |
+
.corner.br { bottom: 14px; right: 358px; } .corner.br::before { border-left: 0; border-top: 0; }
|
| 51 |
+
|
| 52 |
+
#topbar {
|
| 53 |
+
position: fixed; top: 20px; left: 80px; right: 420px; z-index: 5;
|
| 54 |
+
display: flex; justify-content: space-between; align-items: center;
|
| 55 |
+
font-family: var(--font-mono); font-size: 11px; color: var(--dim);
|
| 56 |
+
letter-spacing: .28em; text-transform: uppercase;
|
| 57 |
+
}
|
| 58 |
+
#topbar .sys { color: var(--cyan); text-shadow: 0 0 12px rgba(64,220,255,.8); }
|
| 59 |
+
#clock { color: var(--amber); }
|
| 60 |
+
|
| 61 |
+
#stage { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; }
|
| 62 |
+
#orb { width: min(48vh, 440px); height: min(48vh, 440px); cursor: pointer; filter: drop-shadow(0 0 35px rgba(64,220,255,.25)); }
|
| 63 |
+
#status {
|
| 64 |
+
margin-top: 6px; letter-spacing: .45em; text-transform: uppercase;
|
| 65 |
+
font-size: 12px; font-weight: 600; color: var(--dim); font-family: var(--font-mono);
|
| 66 |
+
}
|
| 67 |
+
#status b { color: var(--cyan); font-weight: 600; text-shadow: 0 0 14px rgba(64,220,255,.9); }
|
| 68 |
+
#mic {
|
| 69 |
+
position: absolute; bottom: 48px; max-width: 320px; padding: 4px 8px;
|
| 70 |
+
background: rgba(6,14,24,.85); color: var(--dim); border: 1px solid var(--line);
|
| 71 |
+
font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; cursor: pointer;
|
| 72 |
+
}
|
| 73 |
+
#mic:hover, #mic:focus { color: var(--cyan); border-color: var(--cyan-dim); outline: none; }
|
| 74 |
+
#hint { position: absolute; bottom: 22px; font-size: 11px; color: var(--dim); font-family: var(--font-mono); letter-spacing: .2em; }
|
| 75 |
+
#transcript {
|
| 76 |
+
position: absolute; top: 54px; left: 50%; transform: translateX(-50%);
|
| 77 |
+
max-width: 68%; text-align: center; font-size: 15px; font-weight: 500;
|
| 78 |
+
color: #9fd4f5; min-height: 22px; text-shadow: 0 0 18px rgba(64,220,255,.35);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* ------------------------------------------------ display cards (HUD holograms) */
|
| 82 |
+
#cards {
|
| 83 |
+
position: absolute; top: 96px; left: 34px; bottom: 30px; width: 330px;
|
| 84 |
+
display: flex; flex-direction: column; gap: 14px; overflow-y: auto;
|
| 85 |
+
pointer-events: none; scrollbar-width: none; z-index: 4;
|
| 86 |
+
}
|
| 87 |
+
.card {
|
| 88 |
+
pointer-events: auto; position: relative;
|
| 89 |
+
background: linear-gradient(135deg, rgba(64,220,255,.10), rgba(6,14,24,.85) 45%);
|
| 90 |
+
border: 1px solid var(--line); border-left: 3px solid var(--cyan);
|
| 91 |
+
padding: 12px 14px 12px 16px; backdrop-filter: blur(6px);
|
| 92 |
+
clip-path: polygon(0 0, calc(100% - 16px) 0, 100% 16px, 100% 100%, 0 100%);
|
| 93 |
+
animation: materialize .45s cubic-bezier(.2,.9,.3,1);
|
| 94 |
+
box-shadow: 0 0 24px rgba(64,220,255,.10), inset 0 0 30px rgba(64,220,255,.04);
|
| 95 |
+
}
|
| 96 |
+
@keyframes materialize {
|
| 97 |
+
0% { transform: translateX(-24px) skewX(-4deg); opacity: 0; filter: brightness(3); }
|
| 98 |
+
60% { filter: brightness(1.6); }
|
| 99 |
+
100% { transform: none; opacity: 1; filter: none; }
|
| 100 |
+
}
|
| 101 |
+
.card.warning { border-left-color: var(--amber); }
|
| 102 |
+
.card.result { border-left-color: var(--ok); }
|
| 103 |
+
.card h3 {
|
| 104 |
+
font-size: 12px; letter-spacing: .3em; text-transform: uppercase; font-weight: 700;
|
| 105 |
+
color: var(--cyan); margin-bottom: 8px; font-family: var(--font-mono);
|
| 106 |
+
display: flex; justify-content: space-between; align-items: baseline;
|
| 107 |
+
}
|
| 108 |
+
.card.warning h3 { color: var(--amber); } .card.result h3 { color: var(--ok); }
|
| 109 |
+
.card h3 .x { cursor: pointer; color: var(--dim); font-size: 13px; letter-spacing: 0; }
|
| 110 |
+
.card h3 .x:hover { color: var(--err); }
|
| 111 |
+
.card .body { font-size: 14px; line-height: 1.5; color: var(--text); white-space: pre-wrap; word-break: break-word; }
|
| 112 |
+
.card .body code {
|
| 113 |
+
font-family: var(--font-mono); font-size: 12.5px; color: var(--amber);
|
| 114 |
+
background: rgba(255,179,71,.08); padding: 1px 5px; border-radius: 2px;
|
| 115 |
+
}
|
| 116 |
+
.card .body ul { margin: 4px 0 0 16px; }
|
| 117 |
+
.card.code .body { font-family: var(--font-mono); font-size: 12px; color: #9fd4f5; }
|
| 118 |
+
|
| 119 |
+
/* ------------------------------------------------ right panel */
|
| 120 |
+
#side {
|
| 121 |
+
width: 340px; border-left: 1px solid var(--line); background: var(--panel);
|
| 122 |
+
backdrop-filter: blur(10px); padding: 18px; overflow-y: auto; z-index: 6;
|
| 123 |
+
}
|
| 124 |
+
#side h2 {
|
| 125 |
+
font-size: 11px; letter-spacing: .34em; text-transform: uppercase; color: var(--cyan);
|
| 126 |
+
margin-bottom: 14px; font-family: var(--font-mono);
|
| 127 |
+
border-bottom: 1px solid var(--line); padding-bottom: 10px;
|
| 128 |
+
text-shadow: 0 0 10px rgba(64,220,255,.6);
|
| 129 |
+
}
|
| 130 |
+
.task {
|
| 131 |
+
border: 1px solid var(--line); border-left: 3px solid var(--cyan);
|
| 132 |
+
padding: 12px 14px; margin-bottom: 12px;
|
| 133 |
+
background: linear-gradient(180deg, rgba(64,220,255,.06), transparent);
|
| 134 |
+
clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 0 100%);
|
| 135 |
+
animation: materialize .35s ease;
|
| 136 |
+
}
|
| 137 |
+
.task .t { font-size: 14px; font-weight: 600; margin-bottom: 6px; letter-spacing: .04em; }
|
| 138 |
+
.task .s { font-size: 11px; color: var(--dim); display: flex; align-items: center; gap: 8px; font-family: var(--font-mono); }
|
| 139 |
+
.task .out { font-size: 11px; color: #a9cbe4; margin-top: 8px; max-height: 120px; overflow: auto; white-space: pre-wrap; font-family: var(--font-mono); }
|
| 140 |
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 8px var(--cyan); animation: blink 1s infinite alternate; }
|
| 141 |
+
.task.done { border-left-color: var(--ok); } .task.done .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); animation: none; }
|
| 142 |
+
.task.error { border-left-color: var(--err); } .task.error .dot { background: var(--err); box-shadow: 0 0 8px var(--err); animation: none; }
|
| 143 |
+
.task.cancelled { border-left-color: var(--dim); opacity: .65; } .task.cancelled .dot { background: var(--dim); box-shadow: none; animation: none; }
|
| 144 |
+
.task .cancel { margin-left: auto; cursor: pointer; color: var(--dim); }
|
| 145 |
+
.task .cancel:hover { color: var(--err); }
|
| 146 |
+
@keyframes blink { to { opacity: .2; } }
|
| 147 |
+
#empty { font-size: 12px; color: var(--dim); font-style: italic; }
|
| 148 |
+
|
| 149 |
+
/* ------------------------------------------------ report dashboard overlay */
|
| 150 |
+
#report {
|
| 151 |
+
position: fixed; inset: 60px 380px 40px 60px; z-index: 20; display: none;
|
| 152 |
+
background: rgba(5,12,22,.96); border: 1px solid var(--line);
|
| 153 |
+
border-top: 2px solid var(--cyan); backdrop-filter: blur(14px);
|
| 154 |
+
box-shadow: 0 0 60px rgba(0,0,0,.7), 0 0 30px rgba(64,220,255,.08);
|
| 155 |
+
padding: 22px 26px; overflow-y: auto; animation: materialize .35s ease;
|
| 156 |
+
}
|
| 157 |
+
#report.open { display: block; }
|
| 158 |
+
#report .rhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; }
|
| 159 |
+
#report .rhead h2 { font-size: 16px; letter-spacing: .22em; text-transform: uppercase; color: var(--cyan); font-family: var(--font-mono); }
|
| 160 |
+
#report .rhead .x { cursor: pointer; color: var(--dim); font-size: 18px; }
|
| 161 |
+
#report .rhead .x:hover { color: var(--err); }
|
| 162 |
+
#kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
| 163 |
+
.kpi { border: 1px solid var(--line); border-left: 3px solid var(--cyan); padding: 12px 14px; background: rgba(64,220,255,.04); }
|
| 164 |
+
.kpi .l { font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--dim); font-family: var(--font-mono); }
|
| 165 |
+
.kpi .v { font-size: 26px; font-weight: 700; color: var(--text); margin-top: 4px; }
|
| 166 |
+
.kpi .d { font-size: 12px; font-family: var(--font-mono); margin-top: 2px; color: var(--ok); }
|
| 167 |
+
.kpi .d.neg { color: var(--err); }
|
| 168 |
+
#rchart { margin-bottom: 18px; }
|
| 169 |
+
#rtable { margin-bottom: 18px; }
|
| 170 |
+
/* Grid.js dark re-skin */
|
| 171 |
+
#rtable .gridjs-wrapper, #rtable .gridjs-table { background: transparent; box-shadow: none; }
|
| 172 |
+
#rtable th.gridjs-th { background: rgba(64,220,255,.06); color: var(--cyan); border-color: var(--line); font-family: var(--font-mono); font-size: 11px; letter-spacing: .1em; }
|
| 173 |
+
#rtable td.gridjs-td, #rtable .gridjs-tr, #rtable .gridjs-tbody { background: transparent !important; color: var(--text); border-color: var(--line); font-size: 13px; }
|
| 174 |
+
#rtable .gridjs-tr:hover td { background: rgba(64,220,255,.06) !important; }
|
| 175 |
+
#rtable .gridjs-search input, #rtable .gridjs-pagination { background: transparent; color: var(--dim); border-color: var(--line); }
|
| 176 |
+
#rtable .gridjs-pagination button { background: transparent; color: var(--dim); border-color: var(--line); }
|
| 177 |
+
#rtable .gridjs-pagination button.gridjs-currentPage { background: rgba(64,220,255,.12); color: var(--cyan); }
|
| 178 |
+
#rmd { font-size: 14px; line-height: 1.6; color: var(--text); }
|
| 179 |
+
#rmd h1, #rmd h2, #rmd h3 { color: var(--cyan); margin: 12px 0 6px; font-family: var(--font-hud); }
|
| 180 |
+
#rmd table { border-collapse: collapse; margin: 8px 0; }
|
| 181 |
+
#rmd th, #rmd td { border: 1px solid var(--line); padding: 5px 10px; font-size: 13px; }
|
| 182 |
+
#rmd code { font-family: var(--font-mono); color: var(--amber); background: rgba(255,179,71,.08); padding: 1px 5px; }
|
| 183 |
+
#rmd ul, #rmd ol { margin: 6px 0 6px 20px; }
|
| 184 |
+
</style>
|
| 185 |
+
</head>
|
| 186 |
+
<body>
|
| 187 |
+
<div class="corner tl"></div><div class="corner tr"></div>
|
| 188 |
+
<div class="corner bl"></div><div class="corner br"></div>
|
| 189 |
+
<div id="topbar">
|
| 190 |
+
<span class="sys">◈ J.A.R.V.I.S. — MARK VII</span>
|
| 191 |
+
<span id="clock">--:--:--</span>
|
| 192 |
+
</div>
|
| 193 |
+
|
| 194 |
+
<div id="stage">
|
| 195 |
+
<div id="transcript"></div>
|
| 196 |
+
<div id="cards"></div>
|
| 197 |
+
<canvas id="orb"></canvas>
|
| 198 |
+
<div id="status">clique l'orbe pour <b>initialiser</b></div>
|
| 199 |
+
<select id="mic" title="Micro utilisé par JARVIS"><option value="">🎙 Micro par défaut</option></select>
|
| 200 |
+
<div id="hint">100 % LOCAL · QWEN3 · WHISPER · KOKORO · SILERO</div>
|
| 201 |
+
</div>
|
| 202 |
+
<div id="report">
|
| 203 |
+
<div class="rhead"><h2 id="rtitle"></h2><span class="x" onclick="document.getElementById('report').classList.remove('open')">✕</span></div>
|
| 204 |
+
<div id="kpis"></div>
|
| 205 |
+
<div id="rchart"></div>
|
| 206 |
+
<div id="rtable"></div>
|
| 207 |
+
<div id="rmd"></div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<aside id="side">
|
| 211 |
+
<h2>⟢ Tâches de l'agent</h2>
|
| 212 |
+
<div id="tasks"><div id="empty">Aucune session. Demande une tâche à la voix.</div></div>
|
| 213 |
+
</aside>
|
| 214 |
+
|
| 215 |
+
<script>
|
| 216 |
+
"use strict";
|
| 217 |
+
const orb = document.getElementById("orb");
|
| 218 |
+
const ctx = orb.getContext("2d");
|
| 219 |
+
const statusEl = document.getElementById("status");
|
| 220 |
+
const transcriptEl = document.getElementById("transcript");
|
| 221 |
+
const tasksEl = document.getElementById("tasks");
|
| 222 |
+
const emptyEl = document.getElementById("empty");
|
| 223 |
+
const cardsEl = document.getElementById("cards");
|
| 224 |
+
|
| 225 |
+
let micStream = null;
|
| 226 |
+
let analyserIn = null, analyserOut = null;
|
| 227 |
+
let running = false;
|
| 228 |
+
|
| 229 |
+
/* clock */
|
| 230 |
+
setInterval(() => {
|
| 231 |
+
document.getElementById("clock").textContent = new Date().toLocaleTimeString("fr-FR");
|
| 232 |
+
}, 1000);
|
| 233 |
+
|
| 234 |
+
/* ---------------------------------------------------------- orb rendering (arc reactor HUD) */
|
| 235 |
+
function sizeOrb() {
|
| 236 |
+
const r = orb.getBoundingClientRect();
|
| 237 |
+
orb.width = r.width * devicePixelRatio; orb.height = r.height * devicePixelRatio;
|
| 238 |
+
}
|
| 239 |
+
window.addEventListener("resize", sizeOrb); sizeOrb();
|
| 240 |
+
|
| 241 |
+
function level(analyser) {
|
| 242 |
+
if (!analyser) return 0;
|
| 243 |
+
const buf = new Uint8Array(analyser.frequencyBinCount);
|
| 244 |
+
analyser.getByteFrequencyData(buf);
|
| 245 |
+
let s = 0; for (const v of buf) s += v;
|
| 246 |
+
return s / buf.length / 255;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const TAU = Math.PI * 2;
|
| 250 |
+
let t = 0;
|
| 251 |
+
function draw() {
|
| 252 |
+
requestAnimationFrame(draw);
|
| 253 |
+
t += 0.016;
|
| 254 |
+
const w = orb.width, h = orb.height, cx = w / 2, cy = h / 2;
|
| 255 |
+
const dpr = devicePixelRatio;
|
| 256 |
+
ctx.clearRect(0, 0, w, h);
|
| 257 |
+
const inL = level(analyserIn), outL = level(analyserOut);
|
| 258 |
+
const energy = Math.max(inL, outL * 1.3);
|
| 259 |
+
const base = Math.min(w, h) * 0.20;
|
| 260 |
+
const R = base * (1 + 0.14 * energy + 0.012 * Math.sin(t * 2.1));
|
| 261 |
+
const CYAN = "64,220,255", AMBER = "255,179,71";
|
| 262 |
+
|
| 263 |
+
// outer glow
|
| 264 |
+
let g = ctx.createRadialGradient(cx, cy, R * 0.2, cx, cy, R * 2.6);
|
| 265 |
+
g.addColorStop(0, `rgba(${CYAN},${0.16 + energy * 0.3})`);
|
| 266 |
+
g.addColorStop(1, "transparent");
|
| 267 |
+
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, R * 2.6, 0, TAU); ctx.fill();
|
| 268 |
+
|
| 269 |
+
// segmented outer ring (arc reactor coils)
|
| 270 |
+
const segs = 12;
|
| 271 |
+
ctx.save(); ctx.translate(cx, cy); ctx.rotate(t * 0.25);
|
| 272 |
+
for (let i = 0; i < segs; i++) {
|
| 273 |
+
const a0 = (i / segs) * TAU, a1 = a0 + TAU / segs * 0.62;
|
| 274 |
+
ctx.strokeStyle = `rgba(${CYAN},${0.55 + 0.4 * Math.sin(t * 3 + i)})`;
|
| 275 |
+
ctx.lineWidth = 7 * dpr;
|
| 276 |
+
ctx.beginPath(); ctx.arc(0, 0, R * 1.55, a0, a1); ctx.stroke();
|
| 277 |
+
}
|
| 278 |
+
ctx.restore();
|
| 279 |
+
|
| 280 |
+
// tick ring (graduations)
|
| 281 |
+
ctx.save(); ctx.translate(cx, cy); ctx.rotate(-t * 0.1);
|
| 282 |
+
for (let i = 0; i < 60; i++) {
|
| 283 |
+
const a = (i / 60) * TAU, big = i % 5 === 0;
|
| 284 |
+
ctx.strokeStyle = `rgba(${big ? AMBER : CYAN},${big ? .8 : .3})`;
|
| 285 |
+
ctx.lineWidth = (big ? 2 : 1) * dpr;
|
| 286 |
+
const r0 = R * 1.78, r1 = R * (big ? 1.88 : 1.83);
|
| 287 |
+
ctx.beginPath();
|
| 288 |
+
ctx.moveTo(r0 * Math.cos(a), r0 * Math.sin(a));
|
| 289 |
+
ctx.lineTo(r1 * Math.cos(a), r1 * Math.sin(a));
|
| 290 |
+
ctx.stroke();
|
| 291 |
+
}
|
| 292 |
+
ctx.restore();
|
| 293 |
+
|
| 294 |
+
// orbiting reticle arcs
|
| 295 |
+
for (let i = 0; i < 3; i++) {
|
| 296 |
+
ctx.save(); ctx.translate(cx, cy);
|
| 297 |
+
ctx.rotate(t * (0.5 + i * 0.3) * (i % 2 ? -1 : 1));
|
| 298 |
+
ctx.strokeStyle = `rgba(${i === 1 ? AMBER : CYAN},${0.5 + energy * 0.4})`;
|
| 299 |
+
ctx.lineWidth = 2.2 * dpr;
|
| 300 |
+
ctx.beginPath(); ctx.arc(0, 0, R * (1.18 + i * 0.13), 0, TAU * (0.16 + 0.1 * i)); ctx.stroke();
|
| 301 |
+
ctx.restore();
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
// triangular core frame (arc reactor MK)
|
| 305 |
+
ctx.save(); ctx.translate(cx, cy); ctx.rotate(-t * 0.06);
|
| 306 |
+
ctx.strokeStyle = `rgba(${CYAN},.45)`; ctx.lineWidth = 1.5 * dpr;
|
| 307 |
+
ctx.beginPath();
|
| 308 |
+
for (let i = 0; i <= 3; i++) {
|
| 309 |
+
const a = (i / 3) * TAU - Math.PI / 2;
|
| 310 |
+
const x = R * 1.02 * Math.cos(a), y = R * 1.02 * Math.sin(a);
|
| 311 |
+
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 312 |
+
}
|
| 313 |
+
ctx.stroke(); ctx.restore();
|
| 314 |
+
|
| 315 |
+
// core
|
| 316 |
+
g = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R);
|
| 317 |
+
g.addColorStop(0, "rgba(235,252,255,.98)");
|
| 318 |
+
g.addColorStop(0.4, `rgba(${CYAN},${0.9 + energy * 0.1})`);
|
| 319 |
+
g.addColorStop(1, "rgba(8,30,60,.95)");
|
| 320 |
+
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(cx, cy, R * 0.92, 0, TAU); ctx.fill();
|
| 321 |
+
|
| 322 |
+
// waveform ring when speaking
|
| 323 |
+
if (energy > 0.02) {
|
| 324 |
+
ctx.strokeStyle = `rgba(255,255,255,${0.3 + energy * 0.5})`;
|
| 325 |
+
ctx.lineWidth = 1.5 * dpr;
|
| 326 |
+
ctx.beginPath();
|
| 327 |
+
for (let a = 0; a <= TAU; a += 0.05) {
|
| 328 |
+
const rr = R * (1.0 + 0.07 * energy * Math.sin(a * 9 + t * 6));
|
| 329 |
+
const x = cx + rr * Math.cos(a), y = cy + rr * Math.sin(a);
|
| 330 |
+
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
| 331 |
+
}
|
| 332 |
+
ctx.closePath(); ctx.stroke();
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
draw();
|
| 336 |
+
|
| 337 |
+
/* ---------------------------------------------------------- local voice session */
|
| 338 |
+
// Mic -> 16 kHz PCM16 frames over the WebSocket. The server runs the VAD,
|
| 339 |
+
// Whisper, the LLM and Kokoro, and streams back 24 kHz PCM16 speech.
|
| 340 |
+
const WORKLET = `
|
| 341 |
+
class MicDown extends AudioWorkletProcessor {
|
| 342 |
+
constructor() { super(); this.step = sampleRate / 16000; this.phase = 0;
|
| 343 |
+
this.acc = 0; this.cnt = 0; this.out = new Int16Array(1024); this.n = 0; }
|
| 344 |
+
process(inputs) {
|
| 345 |
+
const ch = inputs[0][0];
|
| 346 |
+
if (!ch) return true;
|
| 347 |
+
for (let i = 0; i < ch.length; i++) { // box-filter decimation
|
| 348 |
+
this.acc += ch[i]; this.cnt++; this.phase += 1;
|
| 349 |
+
if (this.phase >= this.step) {
|
| 350 |
+
this.phase -= this.step;
|
| 351 |
+
const v = Math.max(-1, Math.min(1, this.acc / this.cnt));
|
| 352 |
+
this.out[this.n++] = v * 32767; this.acc = 0; this.cnt = 0;
|
| 353 |
+
if (this.n === this.out.length) { this.port.postMessage(this.out.slice().buffer); this.n = 0; }
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
return true;
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
registerProcessor("mic-down", MicDown);`;
|
| 360 |
+
|
| 361 |
+
let ws = null, ac = null, micNode = null;
|
| 362 |
+
let playing = new Set(), playHead = 0;
|
| 363 |
+
|
| 364 |
+
const STATES = {
|
| 365 |
+
idle: "<b>en ligne</b> · à votre service",
|
| 366 |
+
listening: "<b>écoute…</b>",
|
| 367 |
+
transcribing: "<b>transcription…</b>",
|
| 368 |
+
thinking: "<b>réflexion…</b>",
|
| 369 |
+
speaking: "<b>jarvis parle</b>",
|
| 370 |
+
};
|
| 371 |
+
|
| 372 |
+
async function start() {
|
| 373 |
+
if (running) return stop();
|
| 374 |
+
statusEl.innerHTML = "<b>connexion…</b>";
|
| 375 |
+
try {
|
| 376 |
+
const deviceId = micSel.value;
|
| 377 |
+
micStream = await navigator.mediaDevices.getUserMedia({ audio: {
|
| 378 |
+
channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true,
|
| 379 |
+
...(deviceId ? { deviceId: { exact: deviceId } } : {}) } });
|
| 380 |
+
listMics(); // labels only become readable once the permission is granted
|
| 381 |
+
ac = new AudioContext();
|
| 382 |
+
analyserIn = ac.createAnalyser(); analyserIn.fftSize = 256;
|
| 383 |
+
analyserOut = ac.createAnalyser(); analyserOut.fftSize = 256;
|
| 384 |
+
analyserOut.connect(ac.destination);
|
| 385 |
+
const src = ac.createMediaStreamSource(micStream);
|
| 386 |
+
src.connect(analyserIn);
|
| 387 |
+
await ac.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: "text/javascript" })));
|
| 388 |
+
micNode = new AudioWorkletNode(ac, "mic-down");
|
| 389 |
+
src.connect(micNode);
|
| 390 |
+
// Chrome only runs nodes that reach the destination: route through a muted gain.
|
| 391 |
+
const sink = ac.createGain(); sink.gain.value = 0;
|
| 392 |
+
micNode.connect(sink).connect(ac.destination);
|
| 393 |
+
if (ac.state === "suspended") await ac.resume();
|
| 394 |
+
|
| 395 |
+
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
| 396 |
+
ws.binaryType = "arraybuffer";
|
| 397 |
+
ws.onmessage = (e) => typeof e.data === "string" ? handleEvent(JSON.parse(e.data)) : playPcm(e.data);
|
| 398 |
+
ws.onclose = () => { if (running) { stop(); statusEl.innerHTML = `<b style="color:var(--err)">déconnecté</b> · le serveur est-il lancé ?`; } };
|
| 399 |
+
await new Promise((ok, ko) => { ws.onopen = ok; ws.onerror = () => ko(new Error("serveur injoignable")); });
|
| 400 |
+
micNode.port.onmessage = (e) => { if (ws && ws.readyState === 1) ws.send(e.data); };
|
| 401 |
+
|
| 402 |
+
running = true;
|
| 403 |
+
statusEl.innerHTML = STATES.idle;
|
| 404 |
+
} catch (err) {
|
| 405 |
+
stop();
|
| 406 |
+
statusEl.innerHTML = `<b style="color:var(--err)">erreur</b> · ${esc(err.message || String(err))}`;
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
/* microphone picker (choice remembered in this browser) */
|
| 411 |
+
const micSel = document.getElementById("mic");
|
| 412 |
+
function savedMic() { try { return localStorage.getItem("jarvis.mic") || ""; } catch (_) { return ""; } }
|
| 413 |
+
|
| 414 |
+
async function listMics() {
|
| 415 |
+
const devices = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === "audioinput");
|
| 416 |
+
const want = micSel.value || savedMic();
|
| 417 |
+
micSel.innerHTML = '<option value="">🎙 Micro par défaut</option>' + devices
|
| 418 |
+
.filter(d => d.deviceId && d.deviceId !== "default" && d.deviceId !== "communications")
|
| 419 |
+
.map((d, i) => `<option value="${esc(d.deviceId)}">🎙 ${esc(d.label || `Micro ${i + 1}`)}</option>`).join("");
|
| 420 |
+
micSel.value = [...micSel.options].some(o => o.value === want) ? want : "";
|
| 421 |
+
}
|
| 422 |
+
micSel.addEventListener("change", async () => {
|
| 423 |
+
try { localStorage.setItem("jarvis.mic", micSel.value); } catch (_) {}
|
| 424 |
+
if (running) { stop(); await start(); } // reconnect on the new mic
|
| 425 |
+
});
|
| 426 |
+
navigator.mediaDevices.addEventListener("devicechange", listMics);
|
| 427 |
+
listMics();
|
| 428 |
+
|
| 429 |
+
function stop() {
|
| 430 |
+
running = false;
|
| 431 |
+
if (ws) { ws.onclose = null; ws.close(); } ws = null;
|
| 432 |
+
if (micStream) micStream.getTracks().forEach(t => t.stop());
|
| 433 |
+
stopAudio(false);
|
| 434 |
+
if (ac) ac.close(); ac = null; micNode = null;
|
| 435 |
+
analyserIn = analyserOut = null;
|
| 436 |
+
statusEl.innerHTML = "clique l'orbe pour <b>initialiser</b>";
|
| 437 |
+
}
|
| 438 |
+
orb.addEventListener("click", start);
|
| 439 |
+
|
| 440 |
+
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
| 441 |
+
|
| 442 |
+
/* speech playback: gapless queue of 24 kHz chunks */
|
| 443 |
+
function playPcm(buf) {
|
| 444 |
+
if (!ac) return;
|
| 445 |
+
const pcm = new Int16Array(buf);
|
| 446 |
+
const audio = ac.createBuffer(1, pcm.length, 24000);
|
| 447 |
+
const ch = audio.getChannelData(0);
|
| 448 |
+
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
|
| 449 |
+
const node = ac.createBufferSource();
|
| 450 |
+
node.buffer = audio; node.connect(analyserOut);
|
| 451 |
+
if (!playing.size) send({ type: "playback", playing: true });
|
| 452 |
+
playHead = Math.max(playHead, ac.currentTime + 0.04);
|
| 453 |
+
node.start(playHead);
|
| 454 |
+
playHead += audio.duration;
|
| 455 |
+
playing.add(node);
|
| 456 |
+
node.onended = () => {
|
| 457 |
+
playing.delete(node);
|
| 458 |
+
if (!playing.size) send({ type: "playback", playing: false });
|
| 459 |
+
};
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
function stopAudio(notify = true) {
|
| 463 |
+
for (const n of playing) { n.onended = null; try { n.stop(); } catch (_) {} }
|
| 464 |
+
playing.clear(); playHead = 0;
|
| 465 |
+
if (notify) send({ type: "playback", playing: false });
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
/* ---------------------------------------------------------- events + tools */
|
| 469 |
+
function handleEvent(ev) {
|
| 470 |
+
if (ev.type === "ready") {
|
| 471 |
+
STATES.idle = `<b>en ligne</b> · à votre service, ${esc(ev.address || "monsieur")}`;
|
| 472 |
+
if (running) statusEl.innerHTML = STATES.idle;
|
| 473 |
+
} else if (ev.type === "state") {
|
| 474 |
+
if (running && STATES[ev.state]) statusEl.innerHTML = STATES[ev.state];
|
| 475 |
+
} else if (ev.type === "user_text") {
|
| 476 |
+
transcriptEl.dataset.cur = "";
|
| 477 |
+
transcriptEl.innerHTML = `<span style="color:var(--dim)">« ${esc(ev.text)} »</span>`;
|
| 478 |
+
} else if (ev.type === "reply_start") {
|
| 479 |
+
transcriptEl.dataset.cur = "";
|
| 480 |
+
} else if (ev.type === "assistant_delta") {
|
| 481 |
+
const cur = (transcriptEl.dataset.cur || "") + ev.text;
|
| 482 |
+
transcriptEl.dataset.cur = cur;
|
| 483 |
+
transcriptEl.textContent = cur;
|
| 484 |
+
} else if (ev.type === "stop_audio") {
|
| 485 |
+
stopAudio(false);
|
| 486 |
+
} else if (ev.type === "task") {
|
| 487 |
+
if (!document.getElementById(`task-${ev.task.id}`)) addTaskCard(ev.task.id, ev.task.title);
|
| 488 |
+
updateTaskCard(ev.task);
|
| 489 |
+
} else if (ev.type === "ui") {
|
| 490 |
+
const a = ev.args || {};
|
| 491 |
+
if (ev.name === "display_card") addCard(a.title || "Info", a.content || "", a.kind || "info");
|
| 492 |
+
else if (ev.name === "display_report") {
|
| 493 |
+
try { showReport(a); } catch (err) { addCard("Rapport", String(err), "warning"); }
|
| 494 |
+
}
|
| 495 |
+
} else if (ev.type === "error") {
|
| 496 |
+
addCard("Erreur", ev.text || "", "warning");
|
| 497 |
+
}
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
async function cancelTask(taskId) {
|
| 501 |
+
try {
|
| 502 |
+
const res = await fetch(`/api/task/${taskId}/cancel`, { method: "POST" }).then(r => r.json());
|
| 503 |
+
if (res.cancelled) updateTaskCard(await fetch(`/api/task/${res.cancelled}`).then(r => r.json()));
|
| 504 |
+
} catch (err) {
|
| 505 |
+
addCard("Annulation", String(err), "warning");
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
/* ---------------------------------------------------------- display cards */
|
| 510 |
+
function esc(s) {
|
| 511 |
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
| 512 |
+
}
|
| 513 |
+
function mdLite(s) {
|
| 514 |
+
// minimal markdown: **bold**, `code`, "- " bullets
|
| 515 |
+
let html = esc(s)
|
| 516 |
+
.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
|
| 517 |
+
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
| 518 |
+
const lines = html.split("\n");
|
| 519 |
+
let out = "", inList = false;
|
| 520 |
+
for (const l of lines) {
|
| 521 |
+
if (l.startsWith("- ")) {
|
| 522 |
+
if (!inList) { out += "<ul>"; inList = true; }
|
| 523 |
+
out += `<li>${l.slice(2)}</li>`;
|
| 524 |
+
} else {
|
| 525 |
+
if (inList) { out += "</ul>"; inList = false; }
|
| 526 |
+
out += l + "\n";
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
if (inList) out += "</ul>";
|
| 530 |
+
return out.trimEnd();
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
function addCard(title, content, kind) {
|
| 534 |
+
const el = document.createElement("div");
|
| 535 |
+
el.className = `card ${kind}`;
|
| 536 |
+
el.innerHTML = `<h3><span>${esc(title)}</span><span class="x" title="fermer">✕</span></h3>
|
| 537 |
+
<div class="body">${mdRender(content)}</div>`;
|
| 538 |
+
el.querySelector(".x").addEventListener("click", () => el.remove());
|
| 539 |
+
cardsEl.prepend(el);
|
| 540 |
+
// keep at most 6 cards
|
| 541 |
+
while (cardsEl.children.length > 6) cardsEl.lastChild.remove();
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
/* ---------------------------------------------------------- report dashboard */
|
| 545 |
+
// Series palette validated for dark surfaces (CVD-safe, dataviz checks pass).
|
| 546 |
+
const SERIES_COLORS = ["#1e93c4", "#c47b1e", "#1ea86a"];
|
| 547 |
+
let _apex = null, _grid = null;
|
| 548 |
+
|
| 549 |
+
function mdRender(text) {
|
| 550 |
+
if (window.marked && window.DOMPurify) {
|
| 551 |
+
return DOMPurify.sanitize(marked.parse(text));
|
| 552 |
+
}
|
| 553 |
+
return mdLite(text); // offline fallback
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
function showReport(r) {
|
| 557 |
+
const rep = document.getElementById("report");
|
| 558 |
+
document.getElementById("rtitle").textContent = r.title || "Rapport";
|
| 559 |
+
|
| 560 |
+
// KPI tiles
|
| 561 |
+
const kpisEl = document.getElementById("kpis");
|
| 562 |
+
kpisEl.innerHTML = "";
|
| 563 |
+
for (const k of (r.kpis || []).slice(0, 4)) {
|
| 564 |
+
const d = k.delta ? `<div class="d ${String(k.delta).trim().startsWith("-") ? "neg" : ""}">${esc(k.delta)}</div>` : "";
|
| 565 |
+
kpisEl.insertAdjacentHTML("beforeend",
|
| 566 |
+
`<div class="kpi"><div class="l">${esc(k.label)}</div><div class="v">${esc(k.value)}</div>${d}</div>`);
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
// Chart (ApexCharts)
|
| 570 |
+
const chartEl = document.getElementById("rchart");
|
| 571 |
+
if (_apex) { _apex.destroy(); _apex = null; }
|
| 572 |
+
chartEl.innerHTML = "";
|
| 573 |
+
const c = r.chart;
|
| 574 |
+
if (c && window.ApexCharts && (c.series || []).length) {
|
| 575 |
+
const donut = c.type === "donut";
|
| 576 |
+
const nSeries = donut ? (c.categories || []).length : c.series.length;
|
| 577 |
+
const opts = {
|
| 578 |
+
chart: { type: c.type || "line", height: 280, background: "transparent",
|
| 579 |
+
toolbar: { show: false }, foreColor: "#4d7c99",
|
| 580 |
+
fontFamily: "Rajdhani, sans-serif" },
|
| 581 |
+
theme: { mode: "dark" },
|
| 582 |
+
colors: SERIES_COLORS,
|
| 583 |
+
stroke: { width: 2, curve: "straight" },
|
| 584 |
+
grid: { borderColor: "rgba(64,220,255,.12)", strokeDashArray: 3 },
|
| 585 |
+
dataLabels: { enabled: false },
|
| 586 |
+
legend: { show: nSeries > 1, labels: { colors: "#cfeaff" } },
|
| 587 |
+
tooltip: { theme: "dark" },
|
| 588 |
+
series: donut ? c.series[0].data : c.series,
|
| 589 |
+
};
|
| 590 |
+
if (donut) opts.labels = c.categories || [];
|
| 591 |
+
else opts.xaxis = { categories: c.categories || [] };
|
| 592 |
+
if (c.type === "area") opts.fill = { type: "gradient", gradient: { opacityFrom: .3, opacityTo: .02 } };
|
| 593 |
+
_apex = new ApexCharts(chartEl, opts);
|
| 594 |
+
_apex.render();
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
// Table (Grid.js)
|
| 598 |
+
const tableEl = document.getElementById("rtable");
|
| 599 |
+
if (_grid) { _grid.destroy(); _grid = null; }
|
| 600 |
+
tableEl.innerHTML = "";
|
| 601 |
+
const t = r.table;
|
| 602 |
+
if (t && window.gridjs && (t.rows || []).length) {
|
| 603 |
+
_grid = new gridjs.Grid({
|
| 604 |
+
columns: t.columns || [],
|
| 605 |
+
data: t.rows,
|
| 606 |
+
sort: true,
|
| 607 |
+
search: t.rows.length > 8,
|
| 608 |
+
pagination: t.rows.length > 12 ? { limit: 10 } : false,
|
| 609 |
+
style: { table: { "font-family": "Rajdhani, sans-serif" } },
|
| 610 |
+
});
|
| 611 |
+
_grid.render(tableEl);
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
// Markdown notes
|
| 615 |
+
document.getElementById("rmd").innerHTML = r.markdown ? mdRender(r.markdown) : "";
|
| 616 |
+
|
| 617 |
+
rep.classList.add("open");
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
/* ---------------------------------------------------------- task cards */
|
| 621 |
+
function addTaskCard(id, title) {
|
| 622 |
+
emptyEl && emptyEl.remove();
|
| 623 |
+
const el = document.createElement("div");
|
| 624 |
+
el.className = "task"; el.id = `task-${id}`;
|
| 625 |
+
el.innerHTML = `<div class="t">${esc(title)}</div>
|
| 626 |
+
<div class="s"><span class="dot"></span><span class="lbl">L'agent travaille…</span> <span class="chrono">0s</span>
|
| 627 |
+
<span class="cancel" title="annuler">✕</span></div>
|
| 628 |
+
<div class="out"></div>`;
|
| 629 |
+
el.querySelector(".cancel").addEventListener("click", () => cancelTask(id));
|
| 630 |
+
tasksEl.prepend(el);
|
| 631 |
+
el.dataset.start = Date.now();
|
| 632 |
+
el._timer = setInterval(() => {
|
| 633 |
+
el.querySelector(".chrono").textContent = Math.round((Date.now() - el.dataset.start) / 1000) + "s";
|
| 634 |
+
}, 1000);
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
function updateTaskCard(task) {
|
| 638 |
+
const el = document.getElementById(`task-${task.id}`);
|
| 639 |
+
if (!el) return;
|
| 640 |
+
if (task.status !== "running") {
|
| 641 |
+
clearInterval(el._timer);
|
| 642 |
+
const cls = { done: "done", cancelled: "cancelled" }[task.status] || "error";
|
| 643 |
+
const lbl = { done: "Terminé", cancelled: "Annulée" }[task.status] || "Erreur";
|
| 644 |
+
el.classList.add(cls);
|
| 645 |
+
el.querySelector(".lbl").textContent = lbl;
|
| 646 |
+
el.querySelector(".out").textContent = (task.output || "").slice(0, 1200);
|
| 647 |
+
const c = el.querySelector(".cancel"); if (c) c.remove();
|
| 648 |
+
}
|
| 649 |
+
}
|
| 650 |
+
</script>
|
| 651 |
+
</body>
|
| 652 |
+
</html>
|
designs/b-halo.html
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>JARVIS · Proposition B — Halo</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600&family=Fraunces:ital,opsz,wght@1,9..144,300&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
/* Direction B — "Halo" : minimal, calme, haut de gamme.
|
| 11 |
+
Pas de HUD : une orbe liquide qui respire et se déforme avec la voix,
|
| 12 |
+
de grands sous-titres, et l'information qui glisse en verre dépoli. */
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #07070b; --glass: rgba(255,255,255,.045); --glass-line: rgba(255,255,255,.08);
|
| 15 |
+
--text: #ecebf3; --muted: #7d7b8f; --soft: #a9a7bb;
|
| 16 |
+
--c1: #6c7bff; --c2: #31d6c4; --c3: #ff7ac6; /* idle / speaking / thinking hues */
|
| 17 |
+
--ui: "Manrope", system-ui, sans-serif; --serif: "Fraunces", Georgia, serif;
|
| 18 |
+
}
|
| 19 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 20 |
+
html, body { height: 100%; }
|
| 21 |
+
body { background: var(--bg); color: var(--text); font-family: var(--ui); overflow: hidden; }
|
| 22 |
+
/* very soft film grain to avoid flat black */
|
| 23 |
+
body::after {
|
| 24 |
+
content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .05;
|
| 25 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence baseFrequency='.9'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)'/%3E%3C/svg%3E");
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
header { position: fixed; top: 26px; left: 34px; right: 34px; display: flex; align-items: center; justify-content: space-between; z-index: 5; }
|
| 29 |
+
.brand { font-weight: 600; letter-spacing: .32em; font-size: 12px; }
|
| 30 |
+
.brand span { color: var(--muted); font-weight: 400; letter-spacing: .1em; margin-left: 10px; }
|
| 31 |
+
.chips { display: flex; gap: 8px; }
|
| 32 |
+
.chip {
|
| 33 |
+
display: flex; align-items: center; gap: 8px; padding: 7px 13px 7px 11px; border-radius: 999px;
|
| 34 |
+
background: var(--glass); border: 1px solid var(--glass-line); font-size: 12.5px; color: var(--soft);
|
| 35 |
+
backdrop-filter: blur(14px); animation: rise .5s cubic-bezier(.2,.9,.25,1);
|
| 36 |
+
}
|
| 37 |
+
.chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--c1); animation: breathe 1.1s infinite alternate; }
|
| 38 |
+
.chip.done i { background: var(--c2); animation: none; }
|
| 39 |
+
.chip .tag { font-size: 10.5px; color: var(--muted); }
|
| 40 |
+
@keyframes breathe { to { opacity: .25; } }
|
| 41 |
+
@keyframes rise { from { opacity: 0; transform: translateY(-8px); } }
|
| 42 |
+
|
| 43 |
+
main { position: fixed; inset: 0; display: grid; place-items: center; }
|
| 44 |
+
#orb { width: min(58vh, 520px); aspect-ratio: 1; cursor: pointer; margin-top: -12vh; }
|
| 45 |
+
#state { position: absolute; top: calc(50% + min(16vh, 150px)); font-size: 12px; color: var(--muted); letter-spacing: .28em; text-transform: uppercase; }
|
| 46 |
+
|
| 47 |
+
#subs { position: absolute; bottom: 15vh; width: min(900px, 84vw); text-align: center; }
|
| 48 |
+
#you { color: var(--muted); font-size: 16px; min-height: 24px; transition: opacity .4s; }
|
| 49 |
+
#say { margin-top: 14px; font-family: var(--serif); font-style: italic; font-weight: 300; font-size: clamp(24px, 3vw, 34px); line-height: 1.3; min-height: 44px; }
|
| 50 |
+
|
| 51 |
+
/* cards: one glass sheet sliding up from the bottom-right */
|
| 52 |
+
#cards { position: fixed; right: 34px; bottom: 34px; width: 360px; display: flex; flex-direction: column-reverse; gap: 12px; z-index: 4; }
|
| 53 |
+
.card {
|
| 54 |
+
padding: 18px 20px; border-radius: 22px; background: var(--glass); border: 1px solid var(--glass-line);
|
| 55 |
+
backdrop-filter: blur(22px) saturate(140%); box-shadow: 0 20px 60px rgba(0,0,0,.45);
|
| 56 |
+
animation: sheet .6s cubic-bezier(.2,.9,.25,1);
|
| 57 |
+
}
|
| 58 |
+
.card h3 { font-size: 12px; font-weight: 500; color: var(--muted); margin-bottom: 10px; display: flex; justify-content: space-between; }
|
| 59 |
+
.card h3 em { font-style: normal; color: var(--c2); }
|
| 60 |
+
.row { display: flex; justify-content: space-between; align-items: baseline; padding: 8px 0; border-top: 1px solid var(--glass-line); font-size: 14.5px; }
|
| 61 |
+
.row:first-of-type { border-top: 0; }
|
| 62 |
+
.row b { font-weight: 600; font-variant-numeric: tabular-nums; }
|
| 63 |
+
.row b.hi { color: var(--c3); }
|
| 64 |
+
@keyframes sheet { from { opacity: 0; transform: translateY(30px) scale(.97); } }
|
| 65 |
+
|
| 66 |
+
footer { position: fixed; left: 34px; bottom: 30px; display: flex; gap: 14px; align-items: center; font-size: 12px; color: var(--muted); z-index: 5; }
|
| 67 |
+
select { appearance: none; background: var(--glass); color: var(--soft); border: 1px solid var(--glass-line); border-radius: 999px; padding: 7px 14px; font: inherit; }
|
| 68 |
+
.badge { position: fixed; left: 50%; transform: translateX(-50%); bottom: 12px; font-size: 10px; letter-spacing: .25em; color: #44424f; }
|
| 69 |
+
</style>
|
| 70 |
+
</head>
|
| 71 |
+
<body>
|
| 72 |
+
<header>
|
| 73 |
+
<div class="brand">JARVIS<span>local</span></div>
|
| 74 |
+
<div class="chips" id="tasks"></div>
|
| 75 |
+
</header>
|
| 76 |
+
|
| 77 |
+
<main>
|
| 78 |
+
<canvas id="orb"></canvas>
|
| 79 |
+
<div id="state">touchez l'orbe</div>
|
| 80 |
+
<div id="subs"><div id="you"></div><div id="say"></div></div>
|
| 81 |
+
</main>
|
| 82 |
+
|
| 83 |
+
<section id="cards"></section>
|
| 84 |
+
<footer><select><option>Micro · Shure MV7</option></select><span id="clock"></span></footer>
|
| 85 |
+
<div class="badge">PROPOSITION B · HALO · démo</div>
|
| 86 |
+
|
| 87 |
+
<script>
|
| 88 |
+
"use strict";
|
| 89 |
+
/* ------------------------------------------------ orb: liquid blob, layered hues */
|
| 90 |
+
const cv = document.getElementById("orb"), g = cv.getContext("2d");
|
| 91 |
+
const fit = () => { const r = cv.getBoundingClientRect(); cv.width = r.width * devicePixelRatio; cv.height = r.height * devicePixelRatio; };
|
| 92 |
+
addEventListener("resize", fit); fit();
|
| 93 |
+
let level = 0, target = 0, mode = "idle", t = 0;
|
| 94 |
+
const HUE = { idle: ["#6c7bff", "#3a3f9e"], listening: ["#8a96ff", "#31d6c4"], thinking: ["#ff7ac6", "#6c7bff"], speaking: ["#31d6c4", "#6c7bff"] };
|
| 95 |
+
const TAU = Math.PI * 2;
|
| 96 |
+
|
| 97 |
+
function blob(cx, cy, R, amp, phase, color, alpha) {
|
| 98 |
+
g.beginPath();
|
| 99 |
+
for (let i = 0; i <= 120; i++) {
|
| 100 |
+
const a = i / 120 * TAU;
|
| 101 |
+
const n = Math.sin(a * 3 + t * 1.3 + phase) * .5 + Math.sin(a * 5 - t * 1.9 + phase * 2) * .3 + Math.sin(a * 2 + t * .7) * .2;
|
| 102 |
+
const r = R * (1 + amp * n);
|
| 103 |
+
const x = cx + r * Math.cos(a), y = cy + r * Math.sin(a);
|
| 104 |
+
i ? g.lineTo(x, y) : g.moveTo(x, y);
|
| 105 |
+
}
|
| 106 |
+
g.closePath();
|
| 107 |
+
const gr = g.createRadialGradient(cx - R * .3, cy - R * .4, R * .1, cx, cy, R * 1.3);
|
| 108 |
+
gr.addColorStop(0, color + "ff"); gr.addColorStop(1, color + "00");
|
| 109 |
+
g.globalAlpha = alpha; g.fillStyle = gr; g.fill(); g.globalAlpha = 1;
|
| 110 |
+
}
|
| 111 |
+
function draw() {
|
| 112 |
+
requestAnimationFrame(draw); t += 1 / 60;
|
| 113 |
+
level += (target - level) * .12;
|
| 114 |
+
const w = cv.width, h = cv.height, cx = w / 2, cy = h / 2;
|
| 115 |
+
const breath = Math.sin(t * 1.2) * .02;
|
| 116 |
+
const R = Math.min(w, h) * .26 * (1 + breath + level * .1);
|
| 117 |
+
g.clearRect(0, 0, w, h);
|
| 118 |
+
g.globalCompositeOperation = "lighter";
|
| 119 |
+
const [a, b] = HUE[mode];
|
| 120 |
+
const amp = .04 + level * .16 + (mode === "thinking" ? .05 : 0);
|
| 121 |
+
blob(cx, cy, R * 1.15, amp * 1.2, 0, b, .55);
|
| 122 |
+
blob(cx, cy, R, amp, 2.1, a, .8);
|
| 123 |
+
blob(cx, cy, R * .82, amp * .8, 4.2, mode === "thinking" ? "#ffb3e0" : "#c9f7ff", .45);
|
| 124 |
+
g.globalCompositeOperation = "source-over";
|
| 125 |
+
// thin orbit while thinking
|
| 126 |
+
if (mode === "thinking") {
|
| 127 |
+
g.strokeStyle = "rgba(255,255,255,.35)"; g.lineWidth = 1.2 * devicePixelRatio;
|
| 128 |
+
g.beginPath(); g.arc(cx, cy, R * 1.45, t * 2.5, t * 2.5 + 1.1); g.stroke();
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
draw();
|
| 132 |
+
|
| 133 |
+
/* ------------------------------------------------ demo conversation */
|
| 134 |
+
const $ = id => document.getElementById(id);
|
| 135 |
+
setInterval(() => $("clock").textContent = new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" }), 1000);
|
| 136 |
+
const LABEL = { idle: "à votre écoute", listening: "je vous écoute", thinking: "un instant", speaking: "" };
|
| 137 |
+
const setMode = m => { mode = m; $("state").textContent = LABEL[m]; };
|
| 138 |
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
| 139 |
+
async function type(el, text, speed = 30) { el.textContent = ""; for (const w of text.split(" ")) { el.textContent += (el.textContent ? " " : "") + w; await sleep(speed * 4); } }
|
| 140 |
+
setInterval(() => { target = mode === "listening" || mode === "speaking" ? .3 + Math.random() * .7 : 0; }, 100);
|
| 141 |
+
|
| 142 |
+
function chip(title, engine) {
|
| 143 |
+
const c = document.createElement("div"); c.className = "chip";
|
| 144 |
+
c.innerHTML = `<i></i>${title}<span class="tag">${engine}</span>`; $("tasks").prepend(c);
|
| 145 |
+
while ($("tasks").children.length > 3) $("tasks").lastChild.remove();
|
| 146 |
+
return () => c.classList.add("done");
|
| 147 |
+
}
|
| 148 |
+
function card(title, rows) {
|
| 149 |
+
const c = document.createElement("div"); c.className = "card";
|
| 150 |
+
c.innerHTML = `<h3>${title}<em>à l'instant</em></h3>` + rows.map(([k, v, hi]) => `<div class="row"><span>${k}</span><b class="${hi ? "hi" : ""}">${v}</b></div>`).join("");
|
| 151 |
+
$("cards").prepend(c); while ($("cards").children.length > 2) $("cards").lastChild.remove();
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async function demo() {
|
| 155 |
+
for (;;) {
|
| 156 |
+
setMode("idle"); $("you").textContent = ""; $("say").textContent = ""; await sleep(2000);
|
| 157 |
+
setMode("listening"); await type($("you"), "Lance une veille sur les CVE critiques de la semaine.", 22);
|
| 158 |
+
setMode("thinking"); await sleep(800);
|
| 159 |
+
setMode("speaking"); await type($("say"), "Très bien, Pascal. Je lance la veille et je vous préviens dès qu'elle est prête.");
|
| 160 |
+
const done = chip("Veille CVE critiques", "local");
|
| 161 |
+
await sleep(1200); setMode("idle"); await sleep(3500);
|
| 162 |
+
done(); $("you").textContent = "";
|
| 163 |
+
setMode("speaking"); await type($("say"), "C'est prêt : trois vulnérabilités critiques, dont une activement exploitée.");
|
| 164 |
+
card("Veille CVE · semaine 40", [["Exemple · passerelle VPN", "9.8", true], ["Exemple · messagerie", "9.1"], ["Exemple · Kubernetes", "8.8"]]);
|
| 165 |
+
await sleep(2800);
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
demo();
|
| 169 |
+
</script>
|
| 170 |
+
</body>
|
| 171 |
+
</html>
|
designs/c-commandement.html
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>JARVIS · Proposition C — Commandement</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
/* Direction C — "Commandement" : console de SOC.
|
| 11 |
+
L'orbe devient un radar, la conversation un journal horodaté,
|
| 12 |
+
et on voit en permanence l'état de la machine (latences, VRAM, moteurs). */
|
| 13 |
+
:root {
|
| 14 |
+
--bg: #0a0d0d; --panel: #0f1414; --panel-2: #131a1a; --line: #1f2a29; --line-2: #2b3a38;
|
| 15 |
+
--green: #3df5a6; --green-d: #1c7a55; --amber: #ffc94a; --red: #ff5a5a; --blue: #58c6ff;
|
| 16 |
+
--text: #d6e4e1; --muted: #6b807c;
|
| 17 |
+
--mono: "IBM Plex Mono", monospace; --cond: "IBM Plex Sans Condensed", sans-serif;
|
| 18 |
+
}
|
| 19 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 20 |
+
html, body { height: 100%; }
|
| 21 |
+
body {
|
| 22 |
+
background: var(--bg); color: var(--text); font-family: var(--cond); overflow: hidden;
|
| 23 |
+
display: grid; grid-template-rows: 44px 1fr 34px; grid-template-columns: 340px 1fr 340px;
|
| 24 |
+
grid-template-areas: "top top top" "log stage side" "bot bot bot"; gap: 1px; background-color: var(--line);
|
| 25 |
+
}
|
| 26 |
+
body > * { background: var(--bg); }
|
| 27 |
+
|
| 28 |
+
/* ---------- top status bar */
|
| 29 |
+
#top { grid-area: top; display: flex; align-items: center; gap: 26px; padding: 0 18px; font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
|
| 30 |
+
#top .brand { color: var(--green); font-weight: 500; letter-spacing: .18em; }
|
| 31 |
+
#top .k b { color: var(--text); font-weight: 400; }
|
| 32 |
+
#top .k.ok b { color: var(--green); }
|
| 33 |
+
#top .sp { flex: 1; }
|
| 34 |
+
#top #clock { color: var(--amber); }
|
| 35 |
+
|
| 36 |
+
.ph { display: flex; justify-content: space-between; align-items: center; height: 34px; padding: 0 14px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); background: var(--panel); }
|
| 37 |
+
.ph b { color: var(--green); font-weight: 500; }
|
| 38 |
+
|
| 39 |
+
/* ---------- left: conversation journal */
|
| 40 |
+
#log { grid-area: log; display: flex; flex-direction: column; min-height: 0; }
|
| 41 |
+
#lines { flex: 1; overflow: hidden; padding: 12px 14px; display: flex; flex-direction: column; justify-content: flex-end; gap: 10px; font-size: 14px; line-height: 1.45; }
|
| 42 |
+
.ln { display: grid; grid-template-columns: 54px 1fr; gap: 8px; animation: fade .3s; }
|
| 43 |
+
.ln time { font-family: var(--mono); font-size: 11px; color: var(--muted); padding-top: 2px; }
|
| 44 |
+
.ln .who { font-family: var(--mono); font-size: 10.5px; letter-spacing: .12em; display: block; margin-bottom: 1px; }
|
| 45 |
+
.ln.you .who { color: var(--blue); } .ln.jv .who { color: var(--green); } .ln.sys .who { color: var(--amber); }
|
| 46 |
+
.ln.sys { color: var(--muted); font-size: 13px; }
|
| 47 |
+
.cursor::after { content: "▌"; color: var(--green); animation: blink .8s steps(1) infinite; }
|
| 48 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 49 |
+
@keyframes fade { from { opacity: 0; transform: translateY(6px); } }
|
| 50 |
+
|
| 51 |
+
/* ---------- centre: radar + oscilloscope */
|
| 52 |
+
#stage { grid-area: stage; position: relative; display: grid; grid-template-rows: 1fr 120px; min-height: 0; }
|
| 53 |
+
#radarBox { position: relative; display: grid; place-items: center; min-height: 0; }
|
| 54 |
+
#radar { width: min(100%, 64vh); aspect-ratio: 1; cursor: pointer; }
|
| 55 |
+
#mode { position: absolute; top: 14px; left: 16px; font-family: var(--mono); font-size: 11px; letter-spacing: .2em; }
|
| 56 |
+
#mode span { padding: 3px 8px; border: 1px solid var(--green-d); color: var(--green); }
|
| 57 |
+
#mode.thinking span { border-color: var(--amber); color: var(--amber); }
|
| 58 |
+
#scope { width: 100%; height: 120px; border-top: 1px solid var(--line); background: var(--panel); display: block; }
|
| 59 |
+
|
| 60 |
+
/* ---------- right: tasks + telemetry + findings */
|
| 61 |
+
#side { grid-area: side; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
| 62 |
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
| 63 |
+
td { padding: 8px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
| 64 |
+
td.st { font-family: var(--mono); font-size: 10.5px; width: 78px; }
|
| 65 |
+
.run { color: var(--amber); } .ok { color: var(--green); }
|
| 66 |
+
td small { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; }
|
| 67 |
+
.meters { padding: 12px 14px; display: grid; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
| 68 |
+
.meter { display: grid; grid-template-columns: 70px 1fr 54px; align-items: center; gap: 10px; }
|
| 69 |
+
.meter .b { height: 6px; background: var(--panel-2); border: 1px solid var(--line-2); }
|
| 70 |
+
.meter .b i { display: block; height: 100%; background: var(--green); transition: width .6s; }
|
| 71 |
+
.meter .v { color: var(--text); text-align: right; }
|
| 72 |
+
.finding { margin: 10px 14px; border: 1px solid var(--line-2); border-left: 3px solid var(--red); background: var(--panel); padding: 10px 12px; animation: fade .4s; }
|
| 73 |
+
.finding h4 { font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; color: var(--red); margin-bottom: 6px; font-weight: 500; }
|
| 74 |
+
.finding div { display: flex; justify-content: space-between; font-size: 13px; padding: 2px 0; }
|
| 75 |
+
.finding b { font-family: var(--mono); font-weight: 500; color: var(--amber); }
|
| 76 |
+
|
| 77 |
+
/* ---------- bottom */
|
| 78 |
+
#bot { grid-area: bot; display: flex; align-items: center; gap: 18px; padding: 0 18px; font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
| 79 |
+
#bot kbd { border: 1px solid var(--line-2); padding: 1px 6px; color: var(--text); font: inherit; }
|
| 80 |
+
select { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); padding: 3px 6px; font: inherit; }
|
| 81 |
+
#bot .sp { flex: 1; }
|
| 82 |
+
</style>
|
| 83 |
+
</head>
|
| 84 |
+
<body>
|
| 85 |
+
<div id="top">
|
| 86 |
+
<span class="brand">■ JARVIS // CONSOLE</span>
|
| 87 |
+
<span class="k ok">état <b>OPÉRATIONNEL</b></span>
|
| 88 |
+
<span class="k">llm <b>qwen3-30b-a3b</b></span>
|
| 89 |
+
<span class="k">agent <b>local + claude</b></span>
|
| 90 |
+
<span class="k">réseau <b>aucune sortie</b></span>
|
| 91 |
+
<span class="sp"></span>
|
| 92 |
+
<span id="clock">--:--:--</span>
|
| 93 |
+
</div>
|
| 94 |
+
|
| 95 |
+
<section id="log">
|
| 96 |
+
<div class="ph">journal de session <b id="cnt">0</b></div>
|
| 97 |
+
<div id="lines"></div>
|
| 98 |
+
</section>
|
| 99 |
+
|
| 100 |
+
<section id="stage">
|
| 101 |
+
<div id="radarBox">
|
| 102 |
+
<div id="mode"><span>VEILLE</span></div>
|
| 103 |
+
<canvas id="radar"></canvas>
|
| 104 |
+
</div>
|
| 105 |
+
<canvas id="scope"></canvas>
|
| 106 |
+
</section>
|
| 107 |
+
|
| 108 |
+
<aside id="side">
|
| 109 |
+
<div class="ph">tâches <b id="tc">0 actives</b></div>
|
| 110 |
+
<table><tbody id="tasks"></tbody></table>
|
| 111 |
+
<div class="ph">télémétrie</div>
|
| 112 |
+
<div class="meters">
|
| 113 |
+
<div class="meter"><span>oreille</span><div class="b"><i id="m1" style="width:12%"></i></div><span class="v" id="v1">0,18 s</span></div>
|
| 114 |
+
<div class="meter"><span>cerveau</span><div class="b"><i id="m2" style="width:30%"></i></div><span class="v" id="v2">0,31 s</span></div>
|
| 115 |
+
<div class="meter"><span>voix</span><div class="b"><i id="m3" style="width:18%"></i></div><span class="v" id="v3">0,22 s</span></div>
|
| 116 |
+
<div class="meter"><span>vram</span><div class="b"><i style="width:75%;background:var(--amber)"></i></div><span class="v">24/32 Go</span></div>
|
| 117 |
+
</div>
|
| 118 |
+
<div id="finds"></div>
|
| 119 |
+
</aside>
|
| 120 |
+
|
| 121 |
+
<div id="bot">
|
| 122 |
+
<select><option>MIC · Shure MV7</option></select>
|
| 123 |
+
<span><kbd>Espace</kbd> micro on/off</span>
|
| 124 |
+
<span><kbd>Échap</kbd> interrompre</span>
|
| 125 |
+
<span class="sp"></span>
|
| 126 |
+
<span>PROPOSITION C · COMMANDEMENT · démo</span>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<script>
|
| 130 |
+
"use strict";
|
| 131 |
+
const $ = id => document.getElementById(id);
|
| 132 |
+
const TAU = Math.PI * 2;
|
| 133 |
+
let mode = "idle", level = 0, target = 0, t = 0;
|
| 134 |
+
|
| 135 |
+
/* ------------------------------------------------ radar */
|
| 136 |
+
const rc = $("radar"), r = rc.getContext("2d");
|
| 137 |
+
const sc = $("scope"), s = sc.getContext("2d");
|
| 138 |
+
const fit = () => [rc, sc].forEach(c => { const b = c.getBoundingClientRect(); c.width = b.width * devicePixelRatio; c.height = b.height * devicePixelRatio; });
|
| 139 |
+
addEventListener("resize", fit); fit();
|
| 140 |
+
const blips = [];
|
| 141 |
+
const wave = new Array(240).fill(0);
|
| 142 |
+
|
| 143 |
+
function frame() {
|
| 144 |
+
requestAnimationFrame(frame); t += 1 / 60; level += (target - level) * .2;
|
| 145 |
+
const w = rc.width, h = rc.height, cx = w / 2, cy = h / 2, R = Math.min(w, h) * .44, d = devicePixelRatio;
|
| 146 |
+
const G = mode === "thinking" ? "255,201,74" : "61,245,166";
|
| 147 |
+
r.clearRect(0, 0, w, h);
|
| 148 |
+
// rings + crosshair
|
| 149 |
+
r.strokeStyle = "rgba(61,245,166,.16)"; r.lineWidth = 1 * d;
|
| 150 |
+
for (let i = 1; i <= 4; i++) { r.beginPath(); r.arc(cx, cy, R * i / 4, 0, TAU); r.stroke(); }
|
| 151 |
+
r.beginPath(); r.moveTo(cx - R, cy); r.lineTo(cx + R, cy); r.moveTo(cx, cy - R); r.lineTo(cx, cy + R); r.stroke();
|
| 152 |
+
// bearing ticks
|
| 153 |
+
for (let i = 0; i < 72; i++) {
|
| 154 |
+
const a = i / 72 * TAU, k = i % 6 === 0 ? .94 : .97;
|
| 155 |
+
r.beginPath(); r.moveTo(cx + R * k * Math.cos(a), cy + R * k * Math.sin(a)); r.lineTo(cx + R * Math.cos(a), cy + R * Math.sin(a)); r.stroke();
|
| 156 |
+
}
|
| 157 |
+
// sweep (faster while thinking)
|
| 158 |
+
const sweep = t * (mode === "thinking" ? 3.2 : 1.1);
|
| 159 |
+
const grad = r.createConicGradient ? r.createConicGradient(sweep - .9, cx, cy) : null;
|
| 160 |
+
if (grad) {
|
| 161 |
+
grad.addColorStop(0, `rgba(${G},0)`); grad.addColorStop(.14, `rgba(${G},.28)`); grad.addColorStop(.143, `rgba(${G},0)`);
|
| 162 |
+
r.fillStyle = grad; r.beginPath(); r.arc(cx, cy, R, 0, TAU); r.fill();
|
| 163 |
+
}
|
| 164 |
+
// voice = pulsing core rings
|
| 165 |
+
const core = R * (.16 + level * .12);
|
| 166 |
+
r.fillStyle = `rgba(${G},.12)`; r.beginPath(); r.arc(cx, cy, core * 1.8, 0, TAU); r.fill();
|
| 167 |
+
r.fillStyle = `rgba(${G},.9)`; r.beginPath(); r.arc(cx, cy, core * .55, 0, TAU); r.fill();
|
| 168 |
+
r.strokeStyle = `rgba(${G},.7)`; r.lineWidth = 1.5 * d; r.beginPath(); r.arc(cx, cy, core, 0, TAU); r.stroke();
|
| 169 |
+
// blips = running / finished tasks
|
| 170 |
+
blips.forEach(b => {
|
| 171 |
+
const x = cx + R * b.r * Math.cos(b.a), y = cy + R * b.r * Math.sin(b.a);
|
| 172 |
+
const age = ((sweep - b.a) % TAU + TAU) % TAU, glow = Math.max(.25, 1 - age / TAU);
|
| 173 |
+
r.fillStyle = b.done ? `rgba(61,245,166,${glow})` : `rgba(255,201,74,${glow})`;
|
| 174 |
+
r.beginPath(); r.arc(x, y, 5 * d, 0, TAU); r.fill();
|
| 175 |
+
r.font = `${11 * d}px IBM Plex Mono`; r.fillText(b.label, x + 10 * d, y + 4 * d);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
// oscilloscope
|
| 179 |
+
wave.push(mode === "listening" || mode === "speaking" ? (Math.random() - .5) * level * 1.6 : (Math.random() - .5) * .03);
|
| 180 |
+
wave.shift();
|
| 181 |
+
const W = sc.width, H = sc.height;
|
| 182 |
+
s.clearRect(0, 0, W, H);
|
| 183 |
+
s.strokeStyle = "rgba(61,245,166,.08)"; s.lineWidth = 1;
|
| 184 |
+
for (let x = 0; x < W; x += 40 * d) { s.beginPath(); s.moveTo(x, 0); s.lineTo(x, H); s.stroke(); }
|
| 185 |
+
s.strokeStyle = mode === "listening" ? "#58c6ff" : `rgb(${G})`; s.lineWidth = 1.6 * d; s.beginPath();
|
| 186 |
+
wave.forEach((v, i) => { const x = i / (wave.length - 1) * W, y = H / 2 + v * H * .9; i ? s.lineTo(x, y) : s.moveTo(x, y); });
|
| 187 |
+
s.stroke();
|
| 188 |
+
}
|
| 189 |
+
frame();
|
| 190 |
+
|
| 191 |
+
/* ------------------------------------------------ demo */
|
| 192 |
+
setInterval(() => $("clock").textContent = new Date().toLocaleTimeString("fr-FR"), 1000);
|
| 193 |
+
setInterval(() => { target = mode === "listening" || mode === "speaking" ? .3 + Math.random() * .7 : mode === "thinking" ? .1 : 0; }, 80);
|
| 194 |
+
const sleep = ms => new Promise(res => setTimeout(res, ms));
|
| 195 |
+
const now = () => new Date().toLocaleTimeString("fr-FR");
|
| 196 |
+
const MODES = { idle: "VEILLE", listening: "ÉCOUTE", thinking: "ANALYSE", speaking: "RÉPONSE" };
|
| 197 |
+
function setMode(m) { mode = m; $("mode").className = m; $("mode").firstChild.textContent = MODES[m]; }
|
| 198 |
+
|
| 199 |
+
function line(kind, who, text = "") {
|
| 200 |
+
const el = document.createElement("div"); el.className = "ln " + kind;
|
| 201 |
+
el.innerHTML = `<time>${now().slice(0, 5)}</time><div><span class="who">${who}</span><span class="tx">${text}</span></div>`;
|
| 202 |
+
$("lines").append(el); while ($("lines").children.length > 14) $("lines").firstChild.remove();
|
| 203 |
+
$("cnt").textContent = +$("cnt").textContent + 1;
|
| 204 |
+
return el.querySelector(".tx");
|
| 205 |
+
}
|
| 206 |
+
async function type(el, text, speed = 26) { el.classList.add("cursor"); for (const ch of text) { el.textContent += ch; await sleep(speed); } el.classList.remove("cursor"); }
|
| 207 |
+
function meters(a, b, c) {
|
| 208 |
+
[[a, "m1", "v1"], [b, "m2", "v2"], [c, "m3", "v3"]].forEach(([v, m, l]) => { $(m).style.width = Math.min(100, v * 100) + "%"; $(l).textContent = v.toFixed(2).replace(".", ",") + " s"; });
|
| 209 |
+
}
|
| 210 |
+
let active = 0;
|
| 211 |
+
function task(title, engine) {
|
| 212 |
+
const tr = document.createElement("tr");
|
| 213 |
+
tr.innerHTML = `<td class="st run">● RUN</td><td>${title}<small>${engine} · ${now()}</small></td>`;
|
| 214 |
+
$("tasks").prepend(tr); active++; $("tc").textContent = active + " active" + (active > 1 ? "s" : "");
|
| 215 |
+
const b = { a: Math.random() * TAU, r: .5 + Math.random() * .35, label: title.split(" ")[0].toUpperCase(), done: false };
|
| 216 |
+
blips.push(b); if (blips.length > 4) blips.shift();
|
| 217 |
+
return () => { tr.firstChild.className = "st ok"; tr.firstChild.textContent = "✓ OK"; b.done = true; active--; $("tc").textContent = active + " actives"; };
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
async function demo() {
|
| 221 |
+
for (;;) {
|
| 222 |
+
setMode("idle"); await sleep(1600);
|
| 223 |
+
setMode("listening"); const u = line("you", "PASCAL"); await type(u, "Lance une veille sur les CVE critiques de la semaine.", 40);
|
| 224 |
+
setMode("thinking"); await sleep(600); meters(.18 + Math.random() * .1, .3 + Math.random() * .15, .2 + Math.random() * .08);
|
| 225 |
+
setMode("speaking"); const j = line("jv", "JARVIS"); await type(j, "Très bien, Pascal. Je lance la veille.");
|
| 226 |
+
const done = task("Veille CVE critiques", "local · qwen3");
|
| 227 |
+
line("sys", "SYSTÈME", "tâche lancée sur le modèle local");
|
| 228 |
+
setMode("idle"); await sleep(4200);
|
| 229 |
+
done(); line("sys", "SYSTÈME", "tâche terminée · notification envoyée");
|
| 230 |
+
setMode("speaking"); const j2 = line("jv", "JARVIS"); await type(j2, "Trois vulnérabilités critiques, dont une activement exploitée.");
|
| 231 |
+
const f = document.createElement("div"); f.className = "finding";
|
| 232 |
+
f.innerHTML = `<h4>▲ VEILLE CVE · SEMAINE 40</h4><div><span>Exemple · passerelle VPN</span><b>9.8</b></div><div><span>Exemple · messagerie</span><b>9.1</b></div><div><span>Exemple · Kubernetes</span><b>8.8</b></div>`;
|
| 233 |
+
$("finds").prepend(f); while ($("finds").children.length > 2) $("finds").lastChild.remove();
|
| 234 |
+
await sleep(2600);
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
demo();
|
| 238 |
+
</script>
|
| 239 |
+
</body>
|
| 240 |
+
</html>
|
index.html
ADDED
|
@@ -0,0 +1,681 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<title>JARVIS · Console</title>
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@400;500;600&display=swap" rel="stylesheet">
|
| 9 |
+
<link rel="stylesheet" href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css">
|
| 10 |
+
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
| 11 |
+
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
| 12 |
+
<script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
|
| 13 |
+
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
|
| 14 |
+
<style>
|
| 15 |
+
/* "Commandement": a SOC-style console. The orb is a radar (tasks are its
|
| 16 |
+
blips), the conversation a timestamped log, and the machine's state is
|
| 17 |
+
always on screen. */
|
| 18 |
+
:root {
|
| 19 |
+
--bg: #0a0d0d; --panel: #0f1414; --panel-2: #131a1a; --line: #1f2a29; --line-2: #2b3a38;
|
| 20 |
+
--green: #3df5a6; --green-d: #1c7a55; --amber: #ffc94a; --red: #ff5a5a; --blue: #58c6ff;
|
| 21 |
+
--text: #d6e4e1; --muted: #6b807c;
|
| 22 |
+
--mono: "IBM Plex Mono", monospace; --cond: "IBM Plex Sans Condensed", sans-serif;
|
| 23 |
+
}
|
| 24 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 25 |
+
html, body { height: 100%; }
|
| 26 |
+
body {
|
| 27 |
+
background: var(--line); color: var(--text); font-family: var(--cond); overflow: hidden;
|
| 28 |
+
display: grid; grid-template-rows: 44px minmax(0, 1fr) 34px; grid-template-columns: 360px minmax(0, 1fr) 360px;
|
| 29 |
+
grid-template-areas: "top top top" "log stage side" "bot bot bot"; gap: 1px;
|
| 30 |
+
}
|
| 31 |
+
body > * { background: var(--bg); }
|
| 32 |
+
|
| 33 |
+
/* ---------------------------------------------- top status bar */
|
| 34 |
+
#top { grid-area: top; display: flex; align-items: center; gap: 26px; padding: 0 18px; font-family: var(--mono); font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; }
|
| 35 |
+
#top .brand { color: var(--green); font-weight: 500; letter-spacing: .18em; }
|
| 36 |
+
#top b { color: var(--text); font-weight: 400; }
|
| 37 |
+
#top .on b { color: var(--green); } #top .off b { color: var(--muted); } #top .err b { color: var(--red); }
|
| 38 |
+
#top .sp { flex: 1; }
|
| 39 |
+
#clock { color: var(--amber); }
|
| 40 |
+
|
| 41 |
+
.ph { display: flex; justify-content: space-between; align-items: center; height: 34px; flex: none; padding: 0 14px; border-bottom: 1px solid var(--line); font-family: var(--mono); font-size: 10.5px; letter-spacing: .16em; text-transform: uppercase; color: var(--muted); background: var(--panel); }
|
| 42 |
+
.ph b { color: var(--green); font-weight: 500; }
|
| 43 |
+
|
| 44 |
+
/* ---------------------------------------------- left: conversation log */
|
| 45 |
+
#log { grid-area: log; display: flex; flex-direction: column; min-height: 0; }
|
| 46 |
+
#lines { flex: 1; overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; font-size: 14.5px; line-height: 1.45; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
|
| 47 |
+
#lines .empty { color: var(--muted); font-size: 13px; margin-top: auto; }
|
| 48 |
+
.ln { display: grid; grid-template-columns: 46px 1fr; gap: 8px; animation: fade .3s; }
|
| 49 |
+
.ln time { font-family: var(--mono); font-size: 11px; color: var(--muted); padding-top: 2px; }
|
| 50 |
+
.ln .who { font-family: var(--mono); font-size: 10.5px; letter-spacing: .12em; display: block; margin-bottom: 1px; }
|
| 51 |
+
.ln.you .who { color: var(--blue); } .ln.jv .who { color: var(--green); } .ln.sys .who { color: var(--amber); } .ln.err .who { color: var(--red); }
|
| 52 |
+
.ln.sys, .ln.err { color: var(--muted); font-size: 13px; }
|
| 53 |
+
.ln .tx { white-space: pre-wrap; word-break: break-word; }
|
| 54 |
+
.cursor::after { content: "▌"; color: var(--green); animation: blink .8s steps(1) infinite; }
|
| 55 |
+
@keyframes blink { 50% { opacity: 0; } }
|
| 56 |
+
@keyframes fade { from { opacity: 0; transform: translateY(6px); } }
|
| 57 |
+
|
| 58 |
+
/* ---------------------------------------------- centre: radar + oscilloscope */
|
| 59 |
+
#stage { grid-area: stage; display: grid; grid-template-rows: minmax(0, 1fr) 110px; min-height: 0; }
|
| 60 |
+
#radarBox { position: relative; display: grid; place-items: center; min-height: 0; overflow: hidden; }
|
| 61 |
+
#radar { width: min(94%, 66vh); aspect-ratio: 1; cursor: pointer; }
|
| 62 |
+
#mode { position: absolute; top: 14px; left: 16px; font-family: var(--mono); font-size: 11px; letter-spacing: .2em; }
|
| 63 |
+
#mode span { padding: 3px 8px; border: 1px solid var(--line-2); color: var(--muted); }
|
| 64 |
+
#mode.idle span, #mode.listening span, #mode.speaking span { border-color: var(--green-d); color: var(--green); }
|
| 65 |
+
#mode.listening span { border-color: var(--blue); color: var(--blue); }
|
| 66 |
+
#mode.thinking span, #mode.transcribing span { border-color: var(--amber); color: var(--amber); }
|
| 67 |
+
#mode.muted span { border-color: var(--red); color: var(--red); }
|
| 68 |
+
#hint { position: absolute; bottom: 16px; left: 0; right: 0; text-align: center; font-family: var(--mono); font-size: 11px; letter-spacing: .3em; color: var(--muted); pointer-events: none; }
|
| 69 |
+
#hint b { color: var(--green); font-weight: 500; }
|
| 70 |
+
#scope { width: 100%; height: 110px; border-top: 1px solid var(--line); background: var(--panel); display: block; }
|
| 71 |
+
|
| 72 |
+
/* ---------------------------------------------- right: tasks, telemetry, cards */
|
| 73 |
+
#side { grid-area: side; display: flex; flex-direction: column; min-height: 0; }
|
| 74 |
+
#taskWrap { max-height: 34%; overflow-y: auto; flex: none; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
|
| 75 |
+
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
| 76 |
+
td { padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
| 77 |
+
td.st { font-family: var(--mono); font-size: 10.5px; width: 74px; padding-right: 0; white-space: nowrap; }
|
| 78 |
+
td.x { width: 28px; text-align: center; color: var(--muted); cursor: pointer; font-family: var(--mono); }
|
| 79 |
+
td.x:hover { color: var(--red); }
|
| 80 |
+
tr.task { cursor: pointer; }
|
| 81 |
+
tr.task:hover td { background: var(--panel); }
|
| 82 |
+
td small { display: block; font-family: var(--mono); font-size: 10.5px; color: var(--muted); margin-top: 2px; }
|
| 83 |
+
td small .cl { color: var(--blue); }
|
| 84 |
+
td .out { display: none; margin-top: 6px; font-family: var(--mono); font-size: 11px; color: #a9bdb9; white-space: pre-wrap; max-height: 160px; overflow: auto; }
|
| 85 |
+
tr.open td .out { display: block; }
|
| 86 |
+
.run { color: var(--amber); } .ok { color: var(--green); } .ko { color: var(--red); } .cx { color: var(--muted); }
|
| 87 |
+
#noTask { padding: 12px 14px; font-size: 13px; color: var(--muted); }
|
| 88 |
+
|
| 89 |
+
.meters { padding: 12px 14px; display: grid; gap: 9px; font-family: var(--mono); font-size: 11px; color: var(--muted); flex: none; }
|
| 90 |
+
.meter { display: grid; grid-template-columns: 64px 1fr 66px; align-items: center; gap: 10px; }
|
| 91 |
+
.meter .b { height: 6px; background: var(--panel-2); border: 1px solid var(--line-2); }
|
| 92 |
+
.meter .b i { display: block; height: 100%; width: 0; background: var(--green); transition: width .6s; }
|
| 93 |
+
.meter .v { color: var(--text); text-align: right; }
|
| 94 |
+
|
| 95 |
+
#cards { flex: 1; overflow-y: auto; padding: 4px 0 12px; scrollbar-width: thin; scrollbar-color: var(--line-2) transparent; }
|
| 96 |
+
.card { margin: 10px 14px 0; border: 1px solid var(--line-2); border-left: 3px solid var(--blue); background: var(--panel); padding: 10px 12px; animation: fade .4s; }
|
| 97 |
+
.card.result { border-left-color: var(--green); } .card.warning { border-left-color: var(--red); } .card.code { border-left-color: var(--amber); }
|
| 98 |
+
.card h4 { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--blue); margin-bottom: 6px; font-weight: 500; }
|
| 99 |
+
.card.result h4 { color: var(--green); } .card.warning h4 { color: var(--red); } .card.code h4 { color: var(--amber); }
|
| 100 |
+
.card h4 .x { cursor: pointer; color: var(--muted); letter-spacing: 0; }
|
| 101 |
+
.card h4 .x:hover { color: var(--red); }
|
| 102 |
+
.card .body { font-size: 13.5px; line-height: 1.5; word-break: break-word; }
|
| 103 |
+
.card .body p + p { margin-top: 6px; }
|
| 104 |
+
.card .body ul, .card .body ol { margin: 2px 0 2px 18px; }
|
| 105 |
+
.card .body code { font-family: var(--mono); font-size: 12px; color: var(--amber); }
|
| 106 |
+
.card .body pre { font-family: var(--mono); font-size: 11.5px; white-space: pre-wrap; color: #a9bdb9; }
|
| 107 |
+
.card .body table { border-collapse: collapse; margin: 4px 0; font-size: 12.5px; }
|
| 108 |
+
.card .body th, .card .body td { border: 1px solid var(--line-2); padding: 3px 6px; }
|
| 109 |
+
|
| 110 |
+
/* ---------------------------------------------- bottom bar */
|
| 111 |
+
#bot { grid-area: bot; display: flex; align-items: center; gap: 18px; padding: 0 18px; font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; }
|
| 112 |
+
#bot kbd { border: 1px solid var(--line-2); padding: 1px 6px; color: var(--text); font: inherit; }
|
| 113 |
+
#bot .sp { flex: 1; }
|
| 114 |
+
#mic { background: var(--panel); color: var(--text); border: 1px solid var(--line-2); padding: 3px 6px; font: inherit; max-width: 300px; cursor: pointer; }
|
| 115 |
+
#mic:focus { outline: 1px solid var(--green-d); }
|
| 116 |
+
#msg.err { color: var(--red); }
|
| 117 |
+
|
| 118 |
+
/* ---------------------------------------------- report overlay */
|
| 119 |
+
#report { position: fixed; inset: 64px 380px 54px 380px; z-index: 20; display: none; background: var(--bg); border: 1px solid var(--line-2); border-top: 2px solid var(--green); box-shadow: 0 0 0 100vmax rgba(0,0,0,.55); padding: 20px 24px; overflow-y: auto; animation: fade .3s; }
|
| 120 |
+
#report.open { display: block; }
|
| 121 |
+
.rhead { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
| 122 |
+
.rhead h2 { font-family: var(--mono); font-size: 14px; letter-spacing: .16em; text-transform: uppercase; color: var(--green); font-weight: 500; }
|
| 123 |
+
.rhead .x { cursor: pointer; color: var(--muted); font-family: var(--mono); }
|
| 124 |
+
.rhead .x:hover { color: var(--red); }
|
| 125 |
+
#kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-bottom: 16px; }
|
| 126 |
+
.kpi { background: var(--panel); padding: 12px 14px; }
|
| 127 |
+
.kpi .l { font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); }
|
| 128 |
+
.kpi .v { font-size: 26px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; }
|
| 129 |
+
.kpi .d { font-family: var(--mono); font-size: 12px; color: var(--green); } .kpi .d.neg { color: var(--red); }
|
| 130 |
+
#rchart, #rtable { margin-bottom: 16px; }
|
| 131 |
+
#rtable .gridjs-wrapper, #rtable .gridjs-table { background: transparent; box-shadow: none; border-radius: 0; }
|
| 132 |
+
#rtable th.gridjs-th { background: var(--panel); color: var(--green); border-color: var(--line-2); font-family: var(--mono); font-size: 11px; letter-spacing: .08em; }
|
| 133 |
+
#rtable td.gridjs-td, #rtable .gridjs-tr, #rtable .gridjs-tbody { background: transparent !important; color: var(--text); border-color: var(--line); font-size: 13px; }
|
| 134 |
+
#rtable .gridjs-tr:hover td { background: var(--panel) !important; }
|
| 135 |
+
#rtable .gridjs-search input, #rtable .gridjs-pagination, #rtable .gridjs-pagination button { background: transparent; color: var(--muted); border-color: var(--line-2); }
|
| 136 |
+
#rtable .gridjs-pagination button.gridjs-currentPage { background: var(--panel-2); color: var(--green); }
|
| 137 |
+
#rmd { font-size: 14px; line-height: 1.6; }
|
| 138 |
+
#rmd h1, #rmd h2, #rmd h3 { color: var(--green); margin: 12px 0 6px; font-size: 15px; }
|
| 139 |
+
#rmd table { border-collapse: collapse; margin: 8px 0; }
|
| 140 |
+
#rmd th, #rmd td { border: 1px solid var(--line-2); padding: 5px 10px; font-size: 13px; }
|
| 141 |
+
#rmd code { font-family: var(--mono); color: var(--amber); }
|
| 142 |
+
#rmd ul, #rmd ol { margin: 6px 0 6px 20px; }
|
| 143 |
+
</style>
|
| 144 |
+
</head>
|
| 145 |
+
<body>
|
| 146 |
+
<div id="top">
|
| 147 |
+
<span class="brand">■ JARVIS // CONSOLE</span>
|
| 148 |
+
<span id="conn" class="off">état <b>HORS LIGNE</b></span>
|
| 149 |
+
<span>llm <b id="llm">—</b></span>
|
| 150 |
+
<span>oreille <b id="ear">—</b></span>
|
| 151 |
+
<span>agent <b id="agent">—</b></span>
|
| 152 |
+
<span class="sp"></span>
|
| 153 |
+
<span id="clock">--:--:--</span>
|
| 154 |
+
</div>
|
| 155 |
+
|
| 156 |
+
<section id="log">
|
| 157 |
+
<div class="ph">journal de session <b id="cnt">0</b></div>
|
| 158 |
+
<div id="lines"><div class="empty">Clique le radar puis parle : la conversation s'inscrit ici.</div></div>
|
| 159 |
+
</section>
|
| 160 |
+
|
| 161 |
+
<section id="stage">
|
| 162 |
+
<div id="radarBox">
|
| 163 |
+
<div id="mode" class="off"><span>HORS LIGNE</span></div>
|
| 164 |
+
<canvas id="radar" title="Clique pour démarrer / arrêter"></canvas>
|
| 165 |
+
<div id="hint">clique le radar pour <b>initialiser</b></div>
|
| 166 |
+
</div>
|
| 167 |
+
<canvas id="scope"></canvas>
|
| 168 |
+
</section>
|
| 169 |
+
|
| 170 |
+
<aside id="side">
|
| 171 |
+
<div class="ph">tâches <b id="tc">0 active</b></div>
|
| 172 |
+
<div id="taskWrap"><table><tbody id="tasks"></tbody></table><div id="noTask">Aucune tâche. Demande-la à la voix.</div></div>
|
| 173 |
+
<div class="ph">télémétrie <b id="tot">—</b></div>
|
| 174 |
+
<div class="meters">
|
| 175 |
+
<div class="meter"><span>oreille</span><div class="b"><i id="m-stt"></i></div><span class="v" id="v-stt">—</span></div>
|
| 176 |
+
<div class="meter"><span>cerveau</span><div class="b"><i id="m-llm"></i></div><span class="v" id="v-llm">—</span></div>
|
| 177 |
+
<div class="meter"><span>voix</span><div class="b"><i id="m-tts"></i></div><span class="v" id="v-tts">—</span></div>
|
| 178 |
+
<div class="meter"><span>vram</span><div class="b"><i id="m-vram" style="background:var(--amber)"></i></div><span class="v" id="v-vram">—</span></div>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="ph">affichage</div>
|
| 181 |
+
<div id="cards"></div>
|
| 182 |
+
</aside>
|
| 183 |
+
|
| 184 |
+
<div id="bot">
|
| 185 |
+
<select id="mic" title="Micro utilisé par JARVIS"><option value="">MIC · par défaut</option></select>
|
| 186 |
+
<span><kbd>Espace</kbd> couper le micro</span>
|
| 187 |
+
<span><kbd>Échap</kbd> interrompre</span>
|
| 188 |
+
<span class="sp"></span>
|
| 189 |
+
<span id="msg">100 % local · qwen3 · whisper · kokoro · silero</span>
|
| 190 |
+
</div>
|
| 191 |
+
|
| 192 |
+
<div id="report">
|
| 193 |
+
<div class="rhead"><h2 id="rtitle"></h2><span class="x" id="rclose">[ fermer ✕ ]</span></div>
|
| 194 |
+
<div id="kpis"></div>
|
| 195 |
+
<div id="rchart"></div>
|
| 196 |
+
<div id="rtable"></div>
|
| 197 |
+
<div id="rmd"></div>
|
| 198 |
+
</div>
|
| 199 |
+
|
| 200 |
+
<script>
|
| 201 |
+
"use strict";
|
| 202 |
+
const $ = (id) => document.getElementById(id);
|
| 203 |
+
const TAU = Math.PI * 2;
|
| 204 |
+
|
| 205 |
+
let micStream = null, analyserIn = null, analyserOut = null;
|
| 206 |
+
let running = false, muted = false, mode = "off";
|
| 207 |
+
let ws = null, ac = null, micNode = null;
|
| 208 |
+
let playing = new Set(), playHead = 0;
|
| 209 |
+
|
| 210 |
+
setInterval(() => { $("clock").textContent = new Date().toLocaleTimeString("fr-FR"); }, 1000);
|
| 211 |
+
|
| 212 |
+
function esc(s) {
|
| 213 |
+
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
| 214 |
+
}
|
| 215 |
+
const hhmm = () => new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" });
|
| 216 |
+
const secs = (v) => v.toFixed(2).replace(".", ",") + " s";
|
| 217 |
+
|
| 218 |
+
/* ---------------------------------------------------------- mode badge */
|
| 219 |
+
const MODES = {
|
| 220 |
+
off: "HORS LIGNE", idle: "VEILLE", listening: "ÉCOUTE", transcribing: "TRANSCRIPTION",
|
| 221 |
+
thinking: "ANALYSE", speaking: "RÉPONSE", muted: "MICRO COUPÉ",
|
| 222 |
+
};
|
| 223 |
+
function setMode(m) {
|
| 224 |
+
mode = m;
|
| 225 |
+
const shown = muted && running && (m === "idle" || m === "listening") ? "muted" : m;
|
| 226 |
+
$("mode").className = shown;
|
| 227 |
+
$("mode").firstChild.textContent = MODES[shown] || shown.toUpperCase();
|
| 228 |
+
}
|
| 229 |
+
function setConn(cls, label) { $("conn").className = cls; $("conn").innerHTML = `état <b>${label}</b>`; }
|
| 230 |
+
function message(text, isErr = false) { $("msg").textContent = text; $("msg").className = isErr ? "err" : ""; }
|
| 231 |
+
|
| 232 |
+
/* ---------------------------------------------------------- radar + oscilloscope */
|
| 233 |
+
const rc = $("radar"), r = rc.getContext("2d");
|
| 234 |
+
const sc = $("scope"), s = sc.getContext("2d");
|
| 235 |
+
function fit() { [rc, sc].forEach((c) => { const b = c.getBoundingClientRect(); c.width = b.width * devicePixelRatio; c.height = b.height * devicePixelRatio; }); }
|
| 236 |
+
addEventListener("resize", fit); fit();
|
| 237 |
+
|
| 238 |
+
const blips = new Map(); // task id -> {a, r, label, status}
|
| 239 |
+
let level = 0, t = 0;
|
| 240 |
+
const timeBuf = new Float32Array(1024);
|
| 241 |
+
|
| 242 |
+
function energy(an) {
|
| 243 |
+
if (!an) return 0;
|
| 244 |
+
const buf = new Uint8Array(an.frequencyBinCount);
|
| 245 |
+
an.getByteFrequencyData(buf);
|
| 246 |
+
let sum = 0; for (const v of buf) sum += v;
|
| 247 |
+
return sum / buf.length / 255;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function frame() {
|
| 251 |
+
requestAnimationFrame(frame);
|
| 252 |
+
t += 1 / 60;
|
| 253 |
+
const target = Math.min(1, Math.max(energy(analyserIn) * (muted ? 0 : 1), energy(analyserOut) * 1.3) * 2.2);
|
| 254 |
+
level += (target - level) * 0.25;
|
| 255 |
+
const w = rc.width, h = rc.height, cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.45, d = devicePixelRatio;
|
| 256 |
+
const thinking = mode === "thinking" || mode === "transcribing";
|
| 257 |
+
const G = !running ? "107,128,124" : thinking ? "255,201,74" : mode === "listening" ? "88,198,255" : muted ? "255,90,90" : "61,245,166";
|
| 258 |
+
r.clearRect(0, 0, w, h);
|
| 259 |
+
|
| 260 |
+
// rings, crosshair, bearing ticks
|
| 261 |
+
r.strokeStyle = "rgba(61,245,166,.15)"; r.lineWidth = d;
|
| 262 |
+
for (let i = 1; i <= 4; i++) { r.beginPath(); r.arc(cx, cy, R * i / 4, 0, TAU); r.stroke(); }
|
| 263 |
+
r.beginPath(); r.moveTo(cx - R, cy); r.lineTo(cx + R, cy); r.moveTo(cx, cy - R); r.lineTo(cx, cy + R); r.stroke();
|
| 264 |
+
for (let i = 0; i < 72; i++) {
|
| 265 |
+
const a = i / 72 * TAU, k = i % 6 === 0 ? 0.94 : 0.975;
|
| 266 |
+
r.beginPath(); r.moveTo(cx + R * k * Math.cos(a), cy + R * k * Math.sin(a)); r.lineTo(cx + R * Math.cos(a), cy + R * Math.sin(a)); r.stroke();
|
| 267 |
+
}
|
| 268 |
+
r.fillStyle = "rgba(107,128,124,.8)"; r.font = `${10 * d}px IBM Plex Mono`; r.textAlign = "center";
|
| 269 |
+
[["000", 0, -1], ["090", 1, 0], ["180", 0, 1], ["270", -1, 0]].forEach(([l, x, y]) => r.fillText(l, cx + x * R * 1.05, cy + y * R * 1.05 + 4 * d));
|
| 270 |
+
|
| 271 |
+
// sweep: slow when idle, fast while thinking, frozen when offline
|
| 272 |
+
const sweep = running ? t * (thinking ? 3.2 : 1.1) : -Math.PI / 2;
|
| 273 |
+
if (running && r.createConicGradient) {
|
| 274 |
+
const g = r.createConicGradient(sweep - 0.9, cx, cy);
|
| 275 |
+
g.addColorStop(0, `rgba(${G},0)`); g.addColorStop(0.14, `rgba(${G},.28)`); g.addColorStop(0.143, `rgba(${G},0)`);
|
| 276 |
+
r.fillStyle = g; r.beginPath(); r.arc(cx, cy, R, 0, TAU); r.fill();
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
// core = the voice (yours or JARVIS's)
|
| 280 |
+
const core = R * (0.15 + level * 0.14);
|
| 281 |
+
r.fillStyle = `rgba(${G},.12)`; r.beginPath(); r.arc(cx, cy, core * 1.9, 0, TAU); r.fill();
|
| 282 |
+
r.fillStyle = `rgba(${G},.9)`; r.beginPath(); r.arc(cx, cy, core * 0.55, 0, TAU); r.fill();
|
| 283 |
+
r.strokeStyle = `rgba(${G},.75)`; r.lineWidth = 1.5 * d; r.beginPath(); r.arc(cx, cy, core, 0, TAU); r.stroke();
|
| 284 |
+
|
| 285 |
+
// blips = tasks (amber running, green done, red error)
|
| 286 |
+
r.textAlign = "left";
|
| 287 |
+
for (const b of blips.values()) {
|
| 288 |
+
const x = cx + R * b.r * Math.cos(b.a), y = cy + R * b.r * Math.sin(b.a);
|
| 289 |
+
const age = ((sweep - b.a) % TAU + TAU) % TAU, glow = running ? Math.max(0.3, 1 - age / TAU) : 0.6;
|
| 290 |
+
const c = b.status === "running" ? "255,201,74" : b.status === "done" ? "61,245,166" : b.status === "error" ? "255,90,90" : "107,128,124";
|
| 291 |
+
r.fillStyle = `rgba(${c},${glow})`;
|
| 292 |
+
r.beginPath(); r.arc(x, y, (b.status === "running" ? 5 + Math.sin(t * 5) : 4.5) * d, 0, TAU); r.fill();
|
| 293 |
+
r.font = `${11 * d}px IBM Plex Mono`; r.fillText(b.label, x + 10 * d, y + 4 * d);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
// oscilloscope: JARVIS when it speaks, otherwise the mic
|
| 297 |
+
const W = sc.width, H = sc.height, an = playing.size ? analyserOut : (muted ? null : analyserIn);
|
| 298 |
+
s.clearRect(0, 0, W, H);
|
| 299 |
+
s.strokeStyle = "rgba(61,245,166,.07)"; s.lineWidth = 1;
|
| 300 |
+
for (let x = 0; x < W; x += 40 * d) { s.beginPath(); s.moveTo(x, 0); s.lineTo(x, H); s.stroke(); }
|
| 301 |
+
s.beginPath(); s.moveTo(0, H / 2); s.lineTo(W, H / 2); s.stroke();
|
| 302 |
+
s.strokeStyle = an === analyserIn ? "#58c6ff" : `rgb(${G})`; s.lineWidth = 1.6 * d; s.beginPath();
|
| 303 |
+
if (an) {
|
| 304 |
+
an.getFloatTimeDomainData(timeBuf);
|
| 305 |
+
for (let i = 0; i < timeBuf.length; i++) {
|
| 306 |
+
const x = i / (timeBuf.length - 1) * W, y = H / 2 + Math.max(-1, Math.min(1, timeBuf[i] * 2.5)) * H * 0.45;
|
| 307 |
+
i ? s.lineTo(x, y) : s.moveTo(x, y);
|
| 308 |
+
}
|
| 309 |
+
} else { s.moveTo(0, H / 2); s.lineTo(W, H / 2); }
|
| 310 |
+
s.stroke();
|
| 311 |
+
}
|
| 312 |
+
frame();
|
| 313 |
+
|
| 314 |
+
/* ---------------------------------------------------------- conversation log */
|
| 315 |
+
let curJarvis = null; // the line JARVIS is currently writing into
|
| 316 |
+
function line(kind, who, text = "") {
|
| 317 |
+
const box = $("lines");
|
| 318 |
+
const empty = box.querySelector(".empty"); if (empty) empty.remove();
|
| 319 |
+
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
| 320 |
+
const el = document.createElement("div");
|
| 321 |
+
el.className = "ln " + kind;
|
| 322 |
+
el.innerHTML = `<time>${hhmm()}</time><div><span class="who">${who}</span><span class="tx"></span></div>`;
|
| 323 |
+
el.querySelector(".tx").textContent = text;
|
| 324 |
+
box.append(el);
|
| 325 |
+
while (box.children.length > 200) box.firstChild.remove();
|
| 326 |
+
$("cnt").textContent = box.children.length;
|
| 327 |
+
if (stick) box.scrollTop = box.scrollHeight;
|
| 328 |
+
return el.querySelector(".tx");
|
| 329 |
+
}
|
| 330 |
+
function appendJarvis(delta) {
|
| 331 |
+
if (!curJarvis) { curJarvis = line("jv", "JARVIS"); curJarvis.classList.add("cursor"); }
|
| 332 |
+
curJarvis.textContent += delta;
|
| 333 |
+
const box = $("lines"); box.scrollTop = box.scrollHeight;
|
| 334 |
+
}
|
| 335 |
+
function endJarvis() { if (curJarvis) curJarvis.classList.remove("cursor"); curJarvis = null; }
|
| 336 |
+
|
| 337 |
+
/* ---------------------------------------------------------- audio: mic in, speech out */
|
| 338 |
+
// Mic -> 16 kHz PCM16 frames over the WebSocket. The server runs the VAD,
|
| 339 |
+
// Whisper, the LLM and Kokoro, and streams back 24 kHz PCM16 speech.
|
| 340 |
+
const WORKLET = `
|
| 341 |
+
class MicDown extends AudioWorkletProcessor {
|
| 342 |
+
constructor() { super(); this.step = sampleRate / 16000; this.phase = 0;
|
| 343 |
+
this.acc = 0; this.cnt = 0; this.out = new Int16Array(1024); this.n = 0; }
|
| 344 |
+
process(inputs) {
|
| 345 |
+
const ch = inputs[0][0];
|
| 346 |
+
if (!ch) return true;
|
| 347 |
+
for (let i = 0; i < ch.length; i++) { // box-filter decimation
|
| 348 |
+
this.acc += ch[i]; this.cnt++; this.phase += 1;
|
| 349 |
+
if (this.phase >= this.step) {
|
| 350 |
+
this.phase -= this.step;
|
| 351 |
+
const v = Math.max(-1, Math.min(1, this.acc / this.cnt));
|
| 352 |
+
this.out[this.n++] = v * 32767; this.acc = 0; this.cnt = 0;
|
| 353 |
+
if (this.n === this.out.length) { this.port.postMessage(this.out.slice().buffer); this.n = 0; }
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
return true;
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
registerProcessor("mic-down", MicDown);`;
|
| 360 |
+
|
| 361 |
+
async function start() {
|
| 362 |
+
if (running) return stop();
|
| 363 |
+
$("hint").innerHTML = "<b>connexion…</b>";
|
| 364 |
+
try {
|
| 365 |
+
const deviceId = micSel.value;
|
| 366 |
+
micStream = await navigator.mediaDevices.getUserMedia({ audio: {
|
| 367 |
+
channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true,
|
| 368 |
+
...(deviceId ? { deviceId: { exact: deviceId } } : {}) } });
|
| 369 |
+
listMics(); // labels only become readable once the permission is granted
|
| 370 |
+
ac = new AudioContext();
|
| 371 |
+
analyserIn = ac.createAnalyser(); analyserIn.fftSize = 2048;
|
| 372 |
+
analyserOut = ac.createAnalyser(); analyserOut.fftSize = 2048;
|
| 373 |
+
analyserOut.connect(ac.destination);
|
| 374 |
+
const src = ac.createMediaStreamSource(micStream);
|
| 375 |
+
src.connect(analyserIn);
|
| 376 |
+
await ac.audioWorklet.addModule(URL.createObjectURL(new Blob([WORKLET], { type: "text/javascript" })));
|
| 377 |
+
micNode = new AudioWorkletNode(ac, "mic-down");
|
| 378 |
+
src.connect(micNode);
|
| 379 |
+
// Chrome only runs nodes that reach the destination: route through a muted gain.
|
| 380 |
+
const sink = ac.createGain(); sink.gain.value = 0;
|
| 381 |
+
micNode.connect(sink).connect(ac.destination);
|
| 382 |
+
if (ac.state === "suspended") await ac.resume();
|
| 383 |
+
|
| 384 |
+
ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
| 385 |
+
ws.binaryType = "arraybuffer";
|
| 386 |
+
ws.onmessage = (e) => typeof e.data === "string" ? handleEvent(JSON.parse(e.data)) : playPcm(e.data);
|
| 387 |
+
ws.onclose = () => {
|
| 388 |
+
if (!running) return;
|
| 389 |
+
stop();
|
| 390 |
+
setConn("err", "DÉCONNECTÉ");
|
| 391 |
+
message("serveur injoignable : start.bat est-il lancé ?", true);
|
| 392 |
+
line("err", "SYSTÈME", "connexion perdue avec le serveur");
|
| 393 |
+
};
|
| 394 |
+
await new Promise((ok, ko) => { ws.onopen = ok; ws.onerror = () => ko(new Error("serveur injoignable")); });
|
| 395 |
+
micNode.port.onmessage = (e) => { if (!muted && ws && ws.readyState === 1) ws.send(e.data); };
|
| 396 |
+
|
| 397 |
+
running = true;
|
| 398 |
+
setConn("on", "OPÉRATIONNEL");
|
| 399 |
+
setMode("idle");
|
| 400 |
+
$("hint").textContent = "";
|
| 401 |
+
message(micSel.selectedOptions[0]?.textContent.replace(/^MIC · /, "micro : ") || "");
|
| 402 |
+
line("sys", "SYSTÈME", "session ouverte");
|
| 403 |
+
} catch (err) {
|
| 404 |
+
stop();
|
| 405 |
+
setConn("err", "ERREUR");
|
| 406 |
+
message(err.message || String(err), true);
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function stop() {
|
| 411 |
+
const was = running;
|
| 412 |
+
running = false;
|
| 413 |
+
if (ws) { ws.onclose = null; ws.close(); } ws = null;
|
| 414 |
+
if (micStream) micStream.getTracks().forEach((tr) => tr.stop());
|
| 415 |
+
stopAudio(false);
|
| 416 |
+
if (ac) ac.close(); ac = null; micNode = null;
|
| 417 |
+
analyserIn = analyserOut = null;
|
| 418 |
+
endJarvis();
|
| 419 |
+
setMode("off"); setConn("off", "HORS LIGNE");
|
| 420 |
+
$("hint").innerHTML = "clique le radar pour <b>initialiser</b>";
|
| 421 |
+
if (was) line("sys", "SYSTÈME", "session fermée");
|
| 422 |
+
}
|
| 423 |
+
rc.addEventListener("click", start);
|
| 424 |
+
|
| 425 |
+
function send(obj) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); }
|
| 426 |
+
|
| 427 |
+
/* speech playback: gapless queue of 24 kHz chunks */
|
| 428 |
+
function playPcm(buf) {
|
| 429 |
+
if (!ac) return;
|
| 430 |
+
const pcm = new Int16Array(buf);
|
| 431 |
+
const audio = ac.createBuffer(1, pcm.length, 24000);
|
| 432 |
+
const ch = audio.getChannelData(0);
|
| 433 |
+
for (let i = 0; i < pcm.length; i++) ch[i] = pcm[i] / 32768;
|
| 434 |
+
const node = ac.createBufferSource();
|
| 435 |
+
node.buffer = audio; node.connect(analyserOut);
|
| 436 |
+
if (!playing.size) send({ type: "playback", playing: true });
|
| 437 |
+
playHead = Math.max(playHead, ac.currentTime + 0.04);
|
| 438 |
+
node.start(playHead);
|
| 439 |
+
playHead += audio.duration;
|
| 440 |
+
playing.add(node);
|
| 441 |
+
node.onended = () => {
|
| 442 |
+
playing.delete(node);
|
| 443 |
+
if (!playing.size) send({ type: "playback", playing: false });
|
| 444 |
+
};
|
| 445 |
+
}
|
| 446 |
+
function stopAudio(notify = true) {
|
| 447 |
+
for (const n of playing) { n.onended = null; try { n.stop(); } catch (_) {} }
|
| 448 |
+
playing.clear(); playHead = 0;
|
| 449 |
+
if (notify) send({ type: "playback", playing: false });
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
/* microphone picker (choice remembered in this browser) */
|
| 453 |
+
const micSel = $("mic");
|
| 454 |
+
function savedMic() { try { return localStorage.getItem("jarvis.mic") || ""; } catch (_) { return ""; } }
|
| 455 |
+
async function listMics() {
|
| 456 |
+
const devices = (await navigator.mediaDevices.enumerateDevices()).filter((d) => d.kind === "audioinput");
|
| 457 |
+
const want = micSel.value || savedMic();
|
| 458 |
+
micSel.innerHTML = '<option value="">MIC · par défaut</option>' + devices
|
| 459 |
+
.filter((d) => d.deviceId && d.deviceId !== "default" && d.deviceId !== "communications")
|
| 460 |
+
.map((d, i) => `<option value="${esc(d.deviceId)}">MIC · ${esc(d.label || `micro ${i + 1}`)}</option>`).join("");
|
| 461 |
+
micSel.value = [...micSel.options].some((o) => o.value === want) ? want : "";
|
| 462 |
+
}
|
| 463 |
+
micSel.addEventListener("change", async () => {
|
| 464 |
+
try { localStorage.setItem("jarvis.mic", micSel.value); } catch (_) {}
|
| 465 |
+
if (running) { stop(); await start(); } // reconnect on the new mic
|
| 466 |
+
});
|
| 467 |
+
navigator.mediaDevices.addEventListener("devicechange", listMics);
|
| 468 |
+
listMics();
|
| 469 |
+
|
| 470 |
+
/* keyboard: Space = mute mic, Escape = interrupt JARVIS / close the report */
|
| 471 |
+
addEventListener("keydown", (e) => {
|
| 472 |
+
if (e.target.closest("select, input, textarea")) return;
|
| 473 |
+
if (e.code === "Space") {
|
| 474 |
+
e.preventDefault();
|
| 475 |
+
if (!running) return start();
|
| 476 |
+
muted = !muted;
|
| 477 |
+
setMode(mode);
|
| 478 |
+
message(muted ? "micro coupé (Espace pour le réactiver)" : "micro actif", muted);
|
| 479 |
+
} else if (e.code === "Escape") {
|
| 480 |
+
if ($("report").classList.contains("open")) return $("report").classList.remove("open");
|
| 481 |
+
stopAudio(false); send({ type: "interrupt" }); endJarvis();
|
| 482 |
+
}
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
/* ---------------------------------------------------------- server events */
|
| 486 |
+
function handleEvent(ev) {
|
| 487 |
+
switch (ev.type) {
|
| 488 |
+
case "ready":
|
| 489 |
+
$("llm").textContent = (ev.model || "—").replace(/-instruct.*$/, "");
|
| 490 |
+
$("ear").textContent = ev.whisper || "—";
|
| 491 |
+
$("agent").textContent = (ev.agent === "ollama" ? "local" : "claude") + (ev.complex && ev.complex !== ev.agent ? " + " + (ev.complex === "anthropic" ? "claude" : "local") : "");
|
| 492 |
+
break;
|
| 493 |
+
case "state":
|
| 494 |
+
if (running) setMode(ev.state);
|
| 495 |
+
if (ev.state === "idle") endJarvis();
|
| 496 |
+
break;
|
| 497 |
+
case "user_text":
|
| 498 |
+
endJarvis();
|
| 499 |
+
line("you", "VOUS", ev.text);
|
| 500 |
+
break;
|
| 501 |
+
case "reply_start":
|
| 502 |
+
endJarvis();
|
| 503 |
+
break;
|
| 504 |
+
case "assistant_delta":
|
| 505 |
+
appendJarvis(ev.text);
|
| 506 |
+
break;
|
| 507 |
+
case "stop_audio":
|
| 508 |
+
stopAudio(false); endJarvis();
|
| 509 |
+
break;
|
| 510 |
+
case "metrics":
|
| 511 |
+
showMetrics(ev);
|
| 512 |
+
break;
|
| 513 |
+
case "task":
|
| 514 |
+
upsertTask(ev.task);
|
| 515 |
+
break;
|
| 516 |
+
case "ui": {
|
| 517 |
+
const a = ev.args || {};
|
| 518 |
+
if (ev.name === "display_card") addCard(a.title || "Info", a.content || "", a.kind || "info");
|
| 519 |
+
else if (ev.name === "display_report") {
|
| 520 |
+
try { showReport(a); } catch (err) { addCard("Rapport", String(err), "warning"); }
|
| 521 |
+
}
|
| 522 |
+
break;
|
| 523 |
+
}
|
| 524 |
+
case "error":
|
| 525 |
+
line("err", "ERREUR", ev.text || "");
|
| 526 |
+
break;
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
/* ---------------------------------------------------------- telemetry */
|
| 531 |
+
function meter(key, v, max) { $("m-" + key).style.width = Math.min(100, v / max * 100) + "%"; $("v-" + key).textContent = secs(v); }
|
| 532 |
+
function showMetrics(m) {
|
| 533 |
+
meter("stt", m.stt, 1); meter("llm", m.llm, 2); meter("tts", m.tts, 1);
|
| 534 |
+
$("tot").textContent = "1er son " + secs(m.total);
|
| 535 |
+
}
|
| 536 |
+
async function pollStatus() {
|
| 537 |
+
try {
|
| 538 |
+
const st = await fetch("/api/status").then((res) => res.json());
|
| 539 |
+
if (st.gpu) {
|
| 540 |
+
const p = st.gpu.used_mb / st.gpu.total_mb;
|
| 541 |
+
$("m-vram").style.width = p * 100 + "%";
|
| 542 |
+
$("m-vram").style.background = p > 0.95 ? "var(--red)" : "var(--amber)";
|
| 543 |
+
$("v-vram").textContent = `${(st.gpu.used_mb / 1024).toFixed(1)}/${Math.round(st.gpu.total_mb / 1024)} Go`;
|
| 544 |
+
} else { $("v-vram").textContent = "n/d"; }
|
| 545 |
+
} catch (_) { /* server down: the WebSocket handler reports it */ }
|
| 546 |
+
}
|
| 547 |
+
pollStatus(); setInterval(pollStatus, 5000);
|
| 548 |
+
|
| 549 |
+
/* ---------------------------------------------------------- tasks (table + radar blips) */
|
| 550 |
+
const STATUS = {
|
| 551 |
+
running: ["run", "● EN COURS"], done: ["ok", "✓ TERMINÉ"],
|
| 552 |
+
error: ["ko", "✕ ERREUR"], cancelled: ["cx", "– ANNULÉ"],
|
| 553 |
+
};
|
| 554 |
+
const timers = {};
|
| 555 |
+
function upsertTask(task) {
|
| 556 |
+
let tr = $(`task-${task.id}`);
|
| 557 |
+
if (!tr) {
|
| 558 |
+
$("noTask").style.display = "none";
|
| 559 |
+
tr = document.createElement("tr");
|
| 560 |
+
tr.className = "task"; tr.id = `task-${task.id}`;
|
| 561 |
+
const engine = task.backend === "anthropic" ? '<span class="cl">claude</span>' : "local";
|
| 562 |
+
tr.innerHTML = `<td class="st"></td><td>${esc(task.title)}<small>${engine} · ${hhmm()} · <span class="chrono">0 s</span></small><div class="out"></div></td><td class="x" title="annuler">✕</td>`;
|
| 563 |
+
tr.addEventListener("click", (e) => { if (!e.target.closest(".x")) tr.classList.toggle("open"); });
|
| 564 |
+
tr.querySelector(".x").addEventListener("click", () => cancelTask(task.id));
|
| 565 |
+
$("tasks").prepend(tr);
|
| 566 |
+
const t0 = (task.started || Date.now() / 1000) * 1000;
|
| 567 |
+
timers[task.id] = setInterval(() => { tr.querySelector(".chrono").textContent = Math.round((Date.now() - t0) / 1000) + " s"; }, 1000);
|
| 568 |
+
blips.set(task.id, { a: Math.random() * TAU, r: 0.45 + Math.random() * 0.4, label: task.title.split(" ")[0].toUpperCase().slice(0, 12), status: "running" });
|
| 569 |
+
line("sys", "SYSTÈME", `tâche lancée · ${task.title} (${task.backend === "anthropic" ? "Claude" : "modèle local"})`);
|
| 570 |
+
}
|
| 571 |
+
const [cls, label] = STATUS[task.status] || STATUS.error;
|
| 572 |
+
tr.querySelector(".st").className = "st " + cls;
|
| 573 |
+
tr.querySelector(".st").textContent = label;
|
| 574 |
+
const b = blips.get(task.id); if (b) b.status = task.status;
|
| 575 |
+
if (task.status !== "running") {
|
| 576 |
+
clearInterval(timers[task.id]);
|
| 577 |
+
const x = tr.querySelector(".x"); if (x) x.textContent = "";
|
| 578 |
+
tr.querySelector(".out").textContent = (task.output || "").slice(0, 3000);
|
| 579 |
+
if (!tr.dataset.ended) {
|
| 580 |
+
tr.dataset.ended = "1";
|
| 581 |
+
line("sys", "SYSTÈME", `tâche ${label.slice(2).toLowerCase()} · ${task.title}`);
|
| 582 |
+
// finished blips fade off the radar after a minute
|
| 583 |
+
setTimeout(() => blips.delete(task.id), 60000);
|
| 584 |
+
}
|
| 585 |
+
}
|
| 586 |
+
const n = [...blips.values()].filter((v) => v.status === "running").length;
|
| 587 |
+
$("tc").textContent = n + (n > 1 ? " actives" : " active");
|
| 588 |
+
}
|
| 589 |
+
async function cancelTask(taskId) {
|
| 590 |
+
try {
|
| 591 |
+
const res = await fetch(`/api/task/${taskId}/cancel`, { method: "POST" }).then((x) => x.json());
|
| 592 |
+
if (res.cancelled) upsertTask(await fetch(`/api/task/${res.cancelled}`).then((x) => x.json()));
|
| 593 |
+
else if (res.error) message(res.error, true);
|
| 594 |
+
} catch (err) { message(String(err), true); }
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
/* ---------------------------------------------------------- display cards */
|
| 598 |
+
function mdLite(text) {
|
| 599 |
+
// minimal markdown when the CDN libs are unavailable: **bold**, `code`, "- " bullets
|
| 600 |
+
let html = esc(text).replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/`([^`]+)`/g, "<code>$1</code>");
|
| 601 |
+
let out = "", inList = false;
|
| 602 |
+
for (const l of html.split("\n")) {
|
| 603 |
+
if (l.startsWith("- ")) { if (!inList) { out += "<ul>"; inList = true; } out += `<li>${l.slice(2)}</li>`; }
|
| 604 |
+
else { if (inList) { out += "</ul>"; inList = false; } out += l + "<br>"; }
|
| 605 |
+
}
|
| 606 |
+
return out + (inList ? "</ul>" : "");
|
| 607 |
+
}
|
| 608 |
+
function mdRender(text) {
|
| 609 |
+
if (window.marked && window.DOMPurify) return DOMPurify.sanitize(marked.parse(text));
|
| 610 |
+
return mdLite(text);
|
| 611 |
+
}
|
| 612 |
+
function addCard(title, content, kind) {
|
| 613 |
+
const el = document.createElement("div");
|
| 614 |
+
el.className = `card ${kind}`;
|
| 615 |
+
el.innerHTML = `<h4><span>${esc(title)}</span><span class="x" title="fermer">✕</span></h4><div class="body">${mdRender(content)}</div>`;
|
| 616 |
+
el.querySelector(".x").addEventListener("click", () => el.remove());
|
| 617 |
+
$("cards").prepend(el);
|
| 618 |
+
while ($("cards").children.length > 8) $("cards").lastChild.remove();
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
/* ---------------------------------------------------------- report dashboard */
|
| 622 |
+
// Series palette checked for dark surfaces and colour-blind safety.
|
| 623 |
+
const SERIES_COLORS = ["#3df5a6", "#58c6ff", "#ffc94a"];
|
| 624 |
+
let _apex = null, _grid = null;
|
| 625 |
+
$("rclose").addEventListener("click", () => $("report").classList.remove("open"));
|
| 626 |
+
|
| 627 |
+
function showReport(rep) {
|
| 628 |
+
$("rtitle").textContent = rep.title || "Rapport";
|
| 629 |
+
const kpisEl = $("kpis");
|
| 630 |
+
kpisEl.innerHTML = "";
|
| 631 |
+
for (const k of (rep.kpis || []).slice(0, 4)) {
|
| 632 |
+
const d = k.delta ? `<div class="d ${String(k.delta).trim().startsWith("-") ? "neg" : ""}">${esc(k.delta)}</div>` : "";
|
| 633 |
+
kpisEl.insertAdjacentHTML("beforeend", `<div class="kpi"><div class="l">${esc(k.label)}</div><div class="v">${esc(k.value)}</div>${d}</div>`);
|
| 634 |
+
}
|
| 635 |
+
kpisEl.style.display = kpisEl.children.length ? "" : "none";
|
| 636 |
+
|
| 637 |
+
const chartEl = $("rchart");
|
| 638 |
+
if (_apex) { _apex.destroy(); _apex = null; }
|
| 639 |
+
chartEl.innerHTML = "";
|
| 640 |
+
const c = rep.chart;
|
| 641 |
+
if (c && window.ApexCharts && (c.series || []).length) {
|
| 642 |
+
const donut = c.type === "donut";
|
| 643 |
+
const nSeries = donut ? (c.categories || []).length : c.series.length;
|
| 644 |
+
const opts = {
|
| 645 |
+
chart: { type: c.type || "line", height: 280, background: "transparent", toolbar: { show: false },
|
| 646 |
+
foreColor: "#6b807c", fontFamily: "IBM Plex Sans Condensed, sans-serif" },
|
| 647 |
+
theme: { mode: "dark" }, colors: SERIES_COLORS,
|
| 648 |
+
stroke: { width: 2, curve: "straight" },
|
| 649 |
+
grid: { borderColor: "#1f2a29", strokeDashArray: 3 },
|
| 650 |
+
dataLabels: { enabled: false },
|
| 651 |
+
legend: { show: nSeries > 1, labels: { colors: "#d6e4e1" } },
|
| 652 |
+
tooltip: { theme: "dark" },
|
| 653 |
+
series: donut ? c.series[0].data : c.series,
|
| 654 |
+
};
|
| 655 |
+
if (donut) opts.labels = c.categories || [];
|
| 656 |
+
else opts.xaxis = { categories: c.categories || [] };
|
| 657 |
+
if (c.type === "area") opts.fill = { type: "gradient", gradient: { opacityFrom: 0.3, opacityTo: 0.02 } };
|
| 658 |
+
_apex = new ApexCharts(chartEl, opts);
|
| 659 |
+
_apex.render();
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
const tableEl = $("rtable");
|
| 663 |
+
if (_grid) { _grid.destroy(); _grid = null; }
|
| 664 |
+
tableEl.innerHTML = "";
|
| 665 |
+
const tb = rep.table;
|
| 666 |
+
if (tb && window.gridjs && (tb.rows || []).length) {
|
| 667 |
+
_grid = new gridjs.Grid({
|
| 668 |
+
columns: tb.columns || [], data: tb.rows, sort: true,
|
| 669 |
+
search: tb.rows.length > 8, pagination: tb.rows.length > 12 ? { limit: 10 } : false,
|
| 670 |
+
style: { table: { "font-family": "IBM Plex Sans Condensed, sans-serif" } },
|
| 671 |
+
});
|
| 672 |
+
_grid.render(tableEl);
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
$("rmd").innerHTML = rep.markdown ? mdRender(rep.markdown) : "";
|
| 676 |
+
$("report").classList.add("open");
|
| 677 |
+
line("sys", "SYSTÈME", `rapport affiché · ${rep.title || "Rapport"}`);
|
| 678 |
+
}
|
| 679 |
+
</script>
|
| 680 |
+
</body>
|
| 681 |
+
</html>
|
install.ps1
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# JARVIS Local - installation de la stack locale (Windows)
|
| 2 |
+
$ErrorActionPreference = "Stop"
|
| 3 |
+
Set-Location $PSScriptRoot
|
| 4 |
+
python -m pip install --user -q uv
|
| 5 |
+
if (-not (Test-Path .venv)) { python -m uv venv .venv --python 3.12 }
|
| 6 |
+
python -m uv pip install --python .venv\Scripts\python.exe -r requirements.txt
|
| 7 |
+
New-Item -ItemType Directory -Force models | Out-Null
|
| 8 |
+
$ProgressPreference = "SilentlyContinue"
|
| 9 |
+
$files = @{
|
| 10 |
+
"kokoro-v1.0.onnx" = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
|
| 11 |
+
"voices-v1.0.bin" = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
|
| 12 |
+
"silero_vad.onnx" = "https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx"
|
| 13 |
+
}
|
| 14 |
+
foreach ($f in $files.Keys) {
|
| 15 |
+
if (-not (Test-Path "models\$f")) { Write-Host "Téléchargement $f"; Invoke-WebRequest $files[$f] -OutFile "models\$f" }
|
| 16 |
+
}
|
| 17 |
+
ollama pull qwen3:30b-a3b-instruct-2507-q4_K_M
|
| 18 |
+
if (-not (Test-Path .env)) { Copy-Item .env.example .env }
|
| 19 |
+
Write-Host "OK. Lance start.bat (Whisper se télécharge au premier démarrage)."
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.110
|
| 2 |
+
uvicorn[standard]>=0.29
|
| 3 |
+
httpx>=0.27
|
| 4 |
+
pydantic>=2.6
|
| 5 |
+
numpy>=1.26
|
| 6 |
+
faster-whisper>=1.1
|
| 7 |
+
kokoro-onnx>=0.4
|
| 8 |
+
onnxruntime-gpu[cuda,cudnn]>=1.23 # Kokoro sur GPU (CUDA 13)
|
| 9 |
+
# CUDA libraries for faster-whisper on the GPU (Windows/Linux)
|
| 10 |
+
nvidia-cublas-cu12
|
| 11 |
+
nvidia-cudnn-cu12==9.*
|
server.py
ADDED
|
@@ -0,0 +1,1320 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""JARVIS Local: 100 % local voice assistant.
|
| 3 |
+
|
| 4 |
+
micro (browser) --WS PCM 16 kHz--> Silero VAD --> faster-whisper
|
| 5 |
+
--> Qwen3 via Ollama (tool calling) --> Kokoro TTS --WS PCM 24 kHz--> speakers
|
| 6 |
+
|
| 7 |
+
One small FastAPI server:
|
| 8 |
+
GET / the futuristic UI (orb + live task panels)
|
| 9 |
+
WS /ws the voice loop: mic audio in, speech audio + UI events out
|
| 10 |
+
POST /api/task spawns a background agent task (Claude Code, backed by
|
| 11 |
+
Ollama by default so it stays local)
|
| 12 |
+
GET /api/task/{id} a task's status and output
|
| 13 |
+
|
| 14 |
+
Nothing leaves the machine unless you set JARVIS_AGENT_BACKEND=anthropic.
|
| 15 |
+
"""
|
| 16 |
+
import asyncio
|
| 17 |
+
import glob
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import re
|
| 21 |
+
import shutil
|
| 22 |
+
import site
|
| 23 |
+
import subprocess
|
| 24 |
+
import threading
|
| 25 |
+
import time
|
| 26 |
+
import uuid
|
| 27 |
+
from collections import deque
|
| 28 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import httpx
|
| 32 |
+
import numpy as np
|
| 33 |
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
| 34 |
+
from fastapi.responses import FileResponse
|
| 35 |
+
from pydantic import BaseModel
|
| 36 |
+
|
| 37 |
+
ROOT = Path(__file__).parent
|
| 38 |
+
MODELS = ROOT / "models"
|
| 39 |
+
app = FastAPI(title="JARVIS Local")
|
| 40 |
+
|
| 41 |
+
# CUDA libs come from the nvidia-* pip wheels: CUDA 12 for faster-whisper
|
| 42 |
+
# (nvidia/*/bin), CUDA 13 for onnxruntime-gpu / Kokoro (nvidia/cu13/bin/x86_64).
|
| 43 |
+
for _sp in site.getsitepackages():
|
| 44 |
+
for _d in (glob.glob(os.path.join(_sp, "nvidia", "*", "bin"))
|
| 45 |
+
+ glob.glob(os.path.join(_sp, "nvidia", "*", "bin", "x86_64"))):
|
| 46 |
+
os.add_dll_directory(_d)
|
| 47 |
+
os.environ["PATH"] = _d + os.pathsep + os.environ["PATH"]
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------- config
|
| 50 |
+
|
| 51 |
+
def load_env():
|
| 52 |
+
env_file = ROOT / ".env"
|
| 53 |
+
if env_file.exists():
|
| 54 |
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
| 55 |
+
line = line.strip()
|
| 56 |
+
if line and not line.startswith("#") and "=" in line:
|
| 57 |
+
k, v = line.split("=", 1)
|
| 58 |
+
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
| 59 |
+
|
| 60 |
+
load_env()
|
| 61 |
+
|
| 62 |
+
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/")
|
| 63 |
+
# MoE: only ~3B active parameters per token, ~4x faster than the dense 32B.
|
| 64 |
+
LLM_MODEL = os.environ.get("JARVIS_LLM", "qwen3:30b-a3b-instruct-2507-q4_K_M")
|
| 65 |
+
NUM_CTX = int(os.environ.get("JARVIS_NUM_CTX", "32768"))
|
| 66 |
+
KEEP_ALIVE = os.environ.get("JARVIS_KEEP_ALIVE", "2h")
|
| 67 |
+
LANGUAGE = os.environ.get("JARVIS_LANGUAGE", "français")
|
| 68 |
+
# How JARVIS addresses you (empty = the classic "monsieur").
|
| 69 |
+
USER_NAME = os.environ.get("JARVIS_USER_NAME", "").strip()
|
| 70 |
+
ADDRESS = USER_NAME or "monsieur"
|
| 71 |
+
|
| 72 |
+
WHISPER_MODEL = os.environ.get("WHISPER_MODEL", "large-v3-turbo")
|
| 73 |
+
WHISPER_DEVICE = os.environ.get("WHISPER_DEVICE", "cuda")
|
| 74 |
+
WHISPER_COMPUTE = os.environ.get("WHISPER_COMPUTE", "int8_float16")
|
| 75 |
+
WHISPER_LANG = os.environ.get("WHISPER_LANG", "fr")
|
| 76 |
+
# Names Whisper should favour (companies, people, products), comma separated.
|
| 77 |
+
HOTWORDS = " ".join(w.strip() for w in ["JARVIS", USER_NAME, *os.environ.get(
|
| 78 |
+
"JARVIS_HOTWORDS", "").split(",")] if w.strip())
|
| 79 |
+
|
| 80 |
+
TTS_VOICE = os.environ.get("JARVIS_VOICE", "ff_siwis")
|
| 81 |
+
TTS_LANG = os.environ.get("TTS_LANG", "fr-fr")
|
| 82 |
+
TTS_SPEED = float(os.environ.get("TTS_SPEED", "1.0"))
|
| 83 |
+
TTS_DEVICE = os.environ.get("TTS_DEVICE", "cuda")
|
| 84 |
+
|
| 85 |
+
VAD_THRESHOLD = float(os.environ.get("VAD_THRESHOLD", "0.5"))
|
| 86 |
+
SILENCE_MS = int(os.environ.get("JARVIS_SILENCE_MS", "550"))
|
| 87 |
+
BARGE_IN = os.environ.get("JARVIS_BARGE_IN", "1") not in ("0", "false", "off")
|
| 88 |
+
# Speaking again within this delay continues the previous sentence (hesitations).
|
| 89 |
+
CONTINUE_S = float(os.environ.get("JARVIS_CONTINUE_MS", "1000")) / 1000
|
| 90 |
+
TEMPERATURE = float(os.environ.get("JARVIS_TEMPERATURE", "0.3"))
|
| 91 |
+
LOG_DIR = ROOT / "logs"
|
| 92 |
+
CORRECTIONS_FILE = ROOT / "corrections.json"
|
| 93 |
+
|
| 94 |
+
# Background tasks: Claude Code, pointed at Ollama's Anthropic-compatible API
|
| 95 |
+
# ("ollama") or at your Anthropic account ("anthropic").
|
| 96 |
+
AGENT_BACKEND = os.environ.get("JARVIS_AGENT_BACKEND", "ollama").lower()
|
| 97 |
+
AGENT_MODEL = os.environ.get("JARVIS_AGENT_MODEL", LLM_MODEL)
|
| 98 |
+
# Tasks the model flags as complex go there instead (your Claude subscription).
|
| 99 |
+
COMPLEX_BACKEND = os.environ.get("JARVIS_COMPLEX_BACKEND", "anthropic").lower()
|
| 100 |
+
# Where agent sessions run (their filesystem playground).
|
| 101 |
+
WORKDIR = os.path.expanduser(os.environ.get("JARVIS_WORKDIR", "~"))
|
| 102 |
+
|
| 103 |
+
# Headless sessions have nobody to answer permission prompts: a task that asks
|
| 104 |
+
# would just hang until the timeout. Run them in a non-interactive mode instead.
|
| 105 |
+
# bypassPermissions = no prompt at all; acceptEdits = files yes, commands still ask.
|
| 106 |
+
PERMISSION_MODE = os.environ.get("JARVIS_PERMISSION_MODE", "bypassPermissions")
|
| 107 |
+
NOTIFY = os.environ.get("JARVIS_NOTIFY", "1") not in ("0", "false", "off")
|
| 108 |
+
MEMORY_FILE = ROOT / "memory.json"
|
| 109 |
+
|
| 110 |
+
INSTRUCTIONS = f"""Tu es JARVIS, l'assistant vocal personnel de {ADDRESS}, dans
|
| 111 |
+
l'esprit du majordome d'Iron Man. Tu parles en {LANGUAGE} avec un flegme
|
| 112 |
+
impeccable de majordome britannique. Tu t'adresses à l'utilisateur par
|
| 113 |
+
"{ADDRESS}", avec une courtoisie raffinée et une pointe d'esprit pince-sans-rire
|
| 114 |
+
("Très bien, {ADDRESS}.", "Si {ADDRESS} veut bien patienter un instant.").
|
| 115 |
+
Réponses COURTES (une ou deux phrases), naturelles et directes.
|
| 116 |
+
|
| 117 |
+
Tes réponses sont LUES PAR UNE SYNTHÈSE VOCALE: jamais de markdown, d'emojis,
|
| 118 |
+
de listes à puces ni d'URL dans ce que tu dis. Écris comme on parle. Ce que
|
| 119 |
+
l'utilisateur dit t'arrive par reconnaissance vocale: s'il y a une petite
|
| 120 |
+
erreur de transcription évidente, devine le sens sans la relever. Si la
|
| 121 |
+
phrase reste ambiguë ou semble incomplète, demande une confirmation courte
|
| 122 |
+
("Vous voulez dire Spotify ?") plutôt que de répondre à côté ou d'agir au
|
| 123 |
+
hasard. Ne réponds jamais seulement "je ne comprends pas": propose ta
|
| 124 |
+
meilleure interprétation.
|
| 125 |
+
|
| 126 |
+
Pour toute tâche réelle (lire ou créer des fichiers, chercher sur internet,
|
| 127 |
+
coder, analyser, automatiser), tu appelles l'outil delegate_task avec un
|
| 128 |
+
prompt clair et complet. Mets complex=true pour une tâche exigeante (projet de
|
| 129 |
+
code, analyse approfondie, recherche multi-sources, rédaction longue) ou si
|
| 130 |
+
l'utilisateur demande Claude ou le "mode cloud": elle part alors sur Claude.
|
| 131 |
+
Sinon complex=false, elle reste sur le modèle local. Tu annonces brièvement
|
| 132 |
+
que tu lances la tâche, puis tu continues la conversation. Quand un résultat de
|
| 133 |
+
tâche arrive, tu le résumes à voix haute en une ou deux phrases. L'utilisateur
|
| 134 |
+
reçoit automatiquement une notification Windows à la fin de chaque tâche.
|
| 135 |
+
|
| 136 |
+
Pour l'heure ou la date, appelle get_datetime (ne délègue pas).
|
| 137 |
+
|
| 138 |
+
Quand l'utilisateur te dit quelque chose de durable sur lui ou sur ses
|
| 139 |
+
préférences ("appelle-moi...", "je travaille chez...", "souviens-toi que..."),
|
| 140 |
+
appelle remember avec le fait reformulé en une phrase. S'il te demande
|
| 141 |
+
d'oublier quelque chose, appelle forget. Pour le prévenir plus tard ou lui
|
| 142 |
+
envoyer un message, appelle notify (notification Windows).
|
| 143 |
+
|
| 144 |
+
Ne promets JAMAIS une action pour laquelle tu n'as pas d'outil.
|
| 145 |
+
|
| 146 |
+
Pour ouvrir un logiciel sur ce PC ("lance Discord", "ouvre Spotify"), tu
|
| 147 |
+
appelles open_app avec le nom de l'application. Pour un site web ou service
|
| 148 |
+
en ligne ("ouvre mes emails" -> https://mail.google.com, "ouvre YouTube"),
|
| 149 |
+
appelle open_url avec l'URL complète. Si l'utilisateur précise un écran
|
| 150 |
+
("sur l'écran de gauche", "à droite", "sur l'écran 2"), passe monitor
|
| 151 |
+
(left/right/top/bottom/primary ou un numéro). Tu peux enchaîner plusieurs
|
| 152 |
+
appels pour installer un setup multi-écrans. Confirme brièvement.
|
| 153 |
+
|
| 154 |
+
Si l'utilisateur demande d'annuler ou d'arrêter une tâche en cours, appelle
|
| 155 |
+
cancel_task (sans task_id pour la plus récente).
|
| 156 |
+
|
| 157 |
+
Quand tu veux MONTRER quelque chose à l'écran (résultat de calcul, liste,
|
| 158 |
+
tableau, extrait de code, définition), appelle display_card: le contenu
|
| 159 |
+
s'affiche sur l'interface. Utilise-la spontanément dès qu'un visuel aide
|
| 160 |
+
(chiffres, comparaisons, étapes), et garde ta réponse vocale courte.
|
| 161 |
+
|
| 162 |
+
Pour une ANALYSE DE DONNÉES ou un rapport (fichier Excel/CSV analysé, stats,
|
| 163 |
+
comparatifs chiffrés), appelle display_report: un tableau de bord s'affiche
|
| 164 |
+
avec indicateurs clés (kpis), graphique (chart) et tableau (table). Quand tu
|
| 165 |
+
délègues une analyse à delegate_task, demande-lui explicitement de terminer
|
| 166 |
+
sa réponse par les données chiffrées structurées (listes de valeurs, totaux,
|
| 167 |
+
moyennes) pour que tu puisses remplir le rapport ensuite.
|
| 168 |
+
|
| 169 |
+
Ne réponds jamais de mémoire à une question qui demande des données réelles:
|
| 170 |
+
délègue. Ne lis jamais de longues listes: résume."""
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def load_memory() -> list[str]:
|
| 175 |
+
try:
|
| 176 |
+
return json.loads(MEMORY_FILE.read_text(encoding="utf-8"))
|
| 177 |
+
except (OSError, ValueError):
|
| 178 |
+
return []
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def save_memory(facts: list[str]):
|
| 182 |
+
MEMORY_FILE.write_text(json.dumps(facts, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def system_prompt() -> str:
|
| 186 |
+
facts = load_memory()
|
| 187 |
+
if not facts:
|
| 188 |
+
return INSTRUCTIONS
|
| 189 |
+
return INSTRUCTIONS + "\n\nCe que tu sais de l'utilisateur (mémoire):\n" + "\n".join(
|
| 190 |
+
f"- {f}" for f in facts)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
TOOLS = [{
|
| 194 |
+
"name": "delegate_task",
|
| 195 |
+
"description": ("Delegate a real task to a background agent (Claude Code) "
|
| 196 |
+
"running on this machine (files, code, web research, "
|
| 197 |
+
"automation). Returns immediately; the result arrives "
|
| 198 |
+
"later as a [SYSTEM] message."),
|
| 199 |
+
"parameters": {
|
| 200 |
+
"type": "object",
|
| 201 |
+
"properties": {
|
| 202 |
+
"title": {"type": "string", "description": "Very short task label (3-5 words)"},
|
| 203 |
+
"prompt": {"type": "string", "description": "Complete, self-contained task instruction for the agent"},
|
| 204 |
+
"complex": {"type": "boolean",
|
| 205 |
+
"description": ("true = demanding task (big coding job, deep analysis, "
|
| 206 |
+
"multi-source research, long writing) or user asked for "
|
| 207 |
+
"Claude: runs on Claude. false = simple task, runs locally.")},
|
| 208 |
+
},
|
| 209 |
+
"required": ["title", "prompt"],
|
| 210 |
+
},
|
| 211 |
+
}, {
|
| 212 |
+
"name": "remember",
|
| 213 |
+
"description": "Store a lasting fact about the user or their preferences (persists across sessions).",
|
| 214 |
+
"parameters": {
|
| 215 |
+
"type": "object",
|
| 216 |
+
"properties": {
|
| 217 |
+
"fact": {"type": "string", "description": "One short sentence, e.g. 'Il s'appelle Pascal et préfère être tutoyé.'"},
|
| 218 |
+
},
|
| 219 |
+
"required": ["fact"],
|
| 220 |
+
},
|
| 221 |
+
}, {
|
| 222 |
+
"name": "forget",
|
| 223 |
+
"description": "Remove stored facts matching a keyword (or everything with 'tout').",
|
| 224 |
+
"parameters": {
|
| 225 |
+
"type": "object",
|
| 226 |
+
"properties": {
|
| 227 |
+
"keyword": {"type": "string", "description": "Word found in the facts to forget, or 'tout'"},
|
| 228 |
+
},
|
| 229 |
+
"required": ["keyword"],
|
| 230 |
+
},
|
| 231 |
+
}, {
|
| 232 |
+
"name": "notify",
|
| 233 |
+
"description": "Show a Windows desktop notification to the user.",
|
| 234 |
+
"parameters": {
|
| 235 |
+
"type": "object",
|
| 236 |
+
"properties": {
|
| 237 |
+
"title": {"type": "string"},
|
| 238 |
+
"message": {"type": "string"},
|
| 239 |
+
},
|
| 240 |
+
"required": ["message"],
|
| 241 |
+
},
|
| 242 |
+
}, {
|
| 243 |
+
"name": "get_datetime",
|
| 244 |
+
"description": "Current local date and time on this PC.",
|
| 245 |
+
"parameters": {"type": "object", "properties": {}},
|
| 246 |
+
}, {
|
| 247 |
+
"name": "open_app",
|
| 248 |
+
"description": ("Launch an application installed on this PC by name "
|
| 249 |
+
"(e.g. 'discord', 'spotify', 'chrome', 'notepad'). "
|
| 250 |
+
"Returns whether it was found and started."),
|
| 251 |
+
"parameters": {
|
| 252 |
+
"type": "object",
|
| 253 |
+
"properties": {
|
| 254 |
+
"name": {"type": "string", "description": "Application name as the user said it"},
|
| 255 |
+
"monitor": {"type": "string",
|
| 256 |
+
"description": ("Target screen: 'left', 'right', 'top', "
|
| 257 |
+
"'bottom', 'primary', or a number like '2'. "
|
| 258 |
+
"Omit to leave window placement alone.")},
|
| 259 |
+
},
|
| 260 |
+
"required": ["name"],
|
| 261 |
+
},
|
| 262 |
+
}, {
|
| 263 |
+
"name": "open_url",
|
| 264 |
+
"description": ("Open a website in the browser on this PC. Use for online "
|
| 265 |
+
"services: 'mes emails' -> https://mail.google.com, "
|
| 266 |
+
"'YouTube' -> https://youtube.com, etc."),
|
| 267 |
+
"parameters": {
|
| 268 |
+
"type": "object",
|
| 269 |
+
"properties": {
|
| 270 |
+
"url": {"type": "string", "description": "Full URL to open (https://...)"},
|
| 271 |
+
"monitor": {"type": "string",
|
| 272 |
+
"description": "Target screen: 'left', 'right', 'top', 'bottom', 'primary' or a number. Optional."},
|
| 273 |
+
},
|
| 274 |
+
"required": ["url"],
|
| 275 |
+
},
|
| 276 |
+
}, {
|
| 277 |
+
"name": "cancel_task",
|
| 278 |
+
"description": ("Cancel a running background task. Omit task_id to cancel "
|
| 279 |
+
"the most recently started running task."),
|
| 280 |
+
"parameters": {
|
| 281 |
+
"type": "object",
|
| 282 |
+
"properties": {
|
| 283 |
+
"task_id": {"type": "string", "description": "Task id to cancel (optional)"},
|
| 284 |
+
},
|
| 285 |
+
},
|
| 286 |
+
}, {
|
| 287 |
+
"name": "display_card",
|
| 288 |
+
"description": ("Show a visual card on the JARVIS screen: results, "
|
| 289 |
+
"numbers, lists, code, comparisons. Use markdown-lite: "
|
| 290 |
+
"**bold**, `code`, lines starting with '- ' for bullets. "
|
| 291 |
+
"Use whenever a visual helps; keep the spoken reply short."),
|
| 292 |
+
"parameters": {
|
| 293 |
+
"type": "object",
|
| 294 |
+
"properties": {
|
| 295 |
+
"title": {"type": "string", "description": "Short card title"},
|
| 296 |
+
"content": {"type": "string", "description": "Card body (markdown-lite)"},
|
| 297 |
+
"kind": {"type": "string", "enum": ["info", "result", "code", "warning"],
|
| 298 |
+
"description": "Visual style of the card"},
|
| 299 |
+
},
|
| 300 |
+
"required": ["title", "content"],
|
| 301 |
+
},
|
| 302 |
+
}, {
|
| 303 |
+
"name": "display_report",
|
| 304 |
+
"description": ("Show a full data report dashboard on screen: KPI tiles, "
|
| 305 |
+
"an interactive chart, a sortable table, and markdown notes. "
|
| 306 |
+
"Use for data analysis results (spreadsheets, stats, "
|
| 307 |
+
"comparisons). All sections are optional except title."),
|
| 308 |
+
"parameters": {
|
| 309 |
+
"type": "object",
|
| 310 |
+
"properties": {
|
| 311 |
+
"title": {"type": "string", "description": "Report title"},
|
| 312 |
+
"kpis": {"type": "array", "description": "Headline numbers (max 4)",
|
| 313 |
+
"items": {"type": "object", "properties": {
|
| 314 |
+
"label": {"type": "string"},
|
| 315 |
+
"value": {"type": "string", "description": "e.g. '12 480 €'"},
|
| 316 |
+
"delta": {"type": "string", "description": "e.g. '+12%' (optional)"},
|
| 317 |
+
}, "required": ["label", "value"]}},
|
| 318 |
+
"chart": {"type": "object", "description": "One chart", "properties": {
|
| 319 |
+
"type": {"type": "string", "enum": ["line", "bar", "area", "donut"]},
|
| 320 |
+
"categories": {"type": "array", "items": {"type": "string"},
|
| 321 |
+
"description": "X axis labels (or slice labels for donut)"},
|
| 322 |
+
"series": {"type": "array", "description": "1-3 series",
|
| 323 |
+
"items": {"type": "object", "properties": {
|
| 324 |
+
"name": {"type": "string"},
|
| 325 |
+
"data": {"type": "array", "items": {"type": "number"}},
|
| 326 |
+
}, "required": ["name", "data"]}},
|
| 327 |
+
}},
|
| 328 |
+
"table": {"type": "object", "properties": {
|
| 329 |
+
"columns": {"type": "array", "items": {"type": "string"}},
|
| 330 |
+
"rows": {"type": "array", "items": {"type": "array",
|
| 331 |
+
"items": {"type": ["string", "number"]}}},
|
| 332 |
+
}},
|
| 333 |
+
"markdown": {"type": "string", "description": "Notes / conclusions in markdown"},
|
| 334 |
+
},
|
| 335 |
+
"required": ["title"],
|
| 336 |
+
},
|
| 337 |
+
}]
|
| 338 |
+
OLLAMA_TOOLS = [{"type": "function", "function": t} for t in TOOLS]
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------- local models
|
| 341 |
+
|
| 342 |
+
STT_POOL = ThreadPoolExecutor(1, thread_name_prefix="stt")
|
| 343 |
+
TTS_POOL = ThreadPoolExecutor(1, thread_name_prefix="tts")
|
| 344 |
+
_models_lock = threading.Lock()
|
| 345 |
+
_whisper = None
|
| 346 |
+
_kokoro = None
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def log(*args):
|
| 350 |
+
line = " ".join(str(a) for a in args)
|
| 351 |
+
print(time.strftime(" %H:%M:%S"), line, flush=True)
|
| 352 |
+
try:
|
| 353 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 354 |
+
with open(LOG_DIR / time.strftime("%Y-%m-%d.log"), "a", encoding="utf-8") as f:
|
| 355 |
+
f.write(time.strftime("%H:%M:%S ") + line + "\n")
|
| 356 |
+
except OSError:
|
| 357 |
+
pass
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def get_whisper():
|
| 361 |
+
global _whisper
|
| 362 |
+
with _models_lock:
|
| 363 |
+
if _whisper is None:
|
| 364 |
+
from faster_whisper import WhisperModel
|
| 365 |
+
_whisper = WhisperModel(WHISPER_MODEL, device=WHISPER_DEVICE,
|
| 366 |
+
compute_type=WHISPER_COMPUTE)
|
| 367 |
+
return _whisper
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def get_kokoro():
|
| 371 |
+
global _kokoro
|
| 372 |
+
with _models_lock:
|
| 373 |
+
if _kokoro is None:
|
| 374 |
+
import onnxruntime as ort
|
| 375 |
+
from kokoro_onnx import Kokoro
|
| 376 |
+
ort.set_default_logger_severity(3) # hide harmless CUDA graph warnings
|
| 377 |
+
providers = ["CPUExecutionProvider"]
|
| 378 |
+
if TTS_DEVICE == "cuda" and "CUDAExecutionProvider" in ort.get_available_providers():
|
| 379 |
+
providers.insert(0, "CUDAExecutionProvider")
|
| 380 |
+
sess = ort.InferenceSession(str(MODELS / "kokoro-v1.0.onnx"), providers=providers)
|
| 381 |
+
_kokoro = Kokoro.from_session(sess, str(MODELS / "voices-v1.0.bin"))
|
| 382 |
+
return _kokoro
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# Whisper invents these on noise or silence (YouTube subtitle credits...).
|
| 386 |
+
HALLUCINATIONS = ("sous-titre", "sous titre", "amara.org", "merci d'avoir regardé",
|
| 387 |
+
"abonnez-vous", "merci de votre attention", "radio-canada")
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def load_corrections() -> dict:
|
| 391 |
+
"""corrections.json: {"mal entendu": "correct"}, re-read each time so edits apply live."""
|
| 392 |
+
try:
|
| 393 |
+
data = json.loads(CORRECTIONS_FILE.read_text(encoding="utf-8"))
|
| 394 |
+
return {k: v for k, v in data.items() if not k.startswith("_")}
|
| 395 |
+
except (OSError, ValueError):
|
| 396 |
+
return {}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def apply_corrections(text: str) -> str:
|
| 400 |
+
for wrong, right in load_corrections().items():
|
| 401 |
+
text = re.sub(rf"(?<!\w){re.escape(wrong)}(?!\w)", right, text, flags=re.I)
|
| 402 |
+
return text
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def transcribe(audio: np.ndarray, context: str = "") -> str:
|
| 406 |
+
"""context: the last thing JARVIS said, so Whisper knows what the talk is about."""
|
| 407 |
+
segments, _ = get_whisper().transcribe(
|
| 408 |
+
audio, language=WHISPER_LANG, beam_size=5, vad_filter=False,
|
| 409 |
+
condition_on_previous_text=False, without_timestamps=True, hotwords=HOTWORDS,
|
| 410 |
+
initial_prompt=context[-200:] or None)
|
| 411 |
+
text = " ".join(s.text.strip() for s in segments
|
| 412 |
+
if s.no_speech_prob < 0.6 and s.avg_logprob > -1.0).strip()
|
| 413 |
+
if any(h in text.lower() for h in HALLUCINATIONS):
|
| 414 |
+
return ""
|
| 415 |
+
if context and text and text.strip(" .") in context: # Whisper echoing its prompt
|
| 416 |
+
return ""
|
| 417 |
+
return apply_corrections(text) if re.search(r"\w", text) else ""
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def _spoken(text: str) -> str:
|
| 421 |
+
"""Clean a sentence for the TTS: no markdown, URLs or emojis."""
|
| 422 |
+
text = re.sub(r"https?://\S+", "le lien", text)
|
| 423 |
+
text = re.sub(r"[*_`#>|~]+", "", text)
|
| 424 |
+
text = re.sub(r"[\U0001F000-\U0001FAFF☀-➿️]", "", text)
|
| 425 |
+
return re.sub(r"\s+", " ", text).strip()
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
def synthesize(text: str) -> bytes:
|
| 429 |
+
"""Kokoro -> 24 kHz mono PCM16."""
|
| 430 |
+
samples, _ = get_kokoro().create(text, voice=TTS_VOICE, speed=TTS_SPEED, lang=TTS_LANG)
|
| 431 |
+
return (np.clip(samples, -1, 1) * 32767).astype("<i2").tobytes()
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
class SileroVAD:
|
| 435 |
+
"""Silero VAD v5 (ONNX), 512-sample frames at 16 kHz (32 ms)."""
|
| 436 |
+
FRAME = 512
|
| 437 |
+
CONTEXT = 64
|
| 438 |
+
|
| 439 |
+
_session = None
|
| 440 |
+
|
| 441 |
+
def __init__(self):
|
| 442 |
+
if SileroVAD._session is None:
|
| 443 |
+
import onnxruntime as ort
|
| 444 |
+
opts = ort.SessionOptions()
|
| 445 |
+
opts.intra_op_num_threads = opts.inter_op_num_threads = 1
|
| 446 |
+
SileroVAD._session = ort.InferenceSession(
|
| 447 |
+
str(MODELS / "silero_vad.onnx"), opts, providers=["CPUExecutionProvider"])
|
| 448 |
+
self.sr = np.array(16000, dtype=np.int64)
|
| 449 |
+
self.reset()
|
| 450 |
+
|
| 451 |
+
def reset(self):
|
| 452 |
+
self.state = np.zeros((2, 1, 128), dtype=np.float32)
|
| 453 |
+
self.context = np.zeros((1, self.CONTEXT), dtype=np.float32)
|
| 454 |
+
|
| 455 |
+
def __call__(self, frame: np.ndarray) -> float:
|
| 456 |
+
x = np.concatenate([self.context, frame.reshape(1, -1)], axis=1)
|
| 457 |
+
out, self.state = self._session.run(
|
| 458 |
+
None, {"input": x, "state": self.state, "sr": self.sr})
|
| 459 |
+
self.context = x[:, -self.CONTEXT:]
|
| 460 |
+
return float(out[0][0])
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def warmup():
|
| 464 |
+
"""Load every model up front (the first CUDA run JIT-compiles kernels)."""
|
| 465 |
+
t = time.time()
|
| 466 |
+
print(" · Silero VAD…", flush=True)
|
| 467 |
+
SileroVAD()
|
| 468 |
+
print(" · Kokoro TTS…", flush=True)
|
| 469 |
+
for text in ("Bonjour.", "Très bien, je m'en occupe tout de suite.",
|
| 470 |
+
"Le résultat de la tâche est arrivé, et tout s'est déroulé comme prévu, sans la moindre anicroche."):
|
| 471 |
+
synthesize(text) # several lengths: the GPU tunes its kernels per shape
|
| 472 |
+
print(f" · Whisper {WHISPER_MODEL} ({WHISPER_DEVICE})… (le tout premier lancement peut prendre 30 s)", flush=True)
|
| 473 |
+
transcribe(np.zeros(16000, dtype=np.float32))
|
| 474 |
+
print(f" · Ollama {LLM_MODEL}…", flush=True)
|
| 475 |
+
try:
|
| 476 |
+
httpx.post(f"{OLLAMA_URL}/api/chat", json={
|
| 477 |
+
"model": LLM_MODEL, "messages": [], "keep_alive": KEEP_ALIVE,
|
| 478 |
+
"options": {"num_ctx": NUM_CTX}}, timeout=300)
|
| 479 |
+
except httpx.HTTPError as exc:
|
| 480 |
+
print(f" ! Ollama injoignable ({exc}). Lance Ollama puis réessaie.")
|
| 481 |
+
print(f" Prêt en {time.time() - t:.0f} s.", flush=True)
|
| 482 |
+
|
| 483 |
+
# ---------------------------------------------------------------- windows notifications
|
| 484 |
+
|
| 485 |
+
_TOAST_PS = r"""
|
| 486 |
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
| 487 |
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
| 488 |
+
$x = New-Object Windows.Data.Xml.Dom.XmlDocument
|
| 489 |
+
$x.LoadXml($env:JARVIS_TOAST)
|
| 490 |
+
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(
|
| 491 |
+
'{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
|
| 492 |
+
).Show([Windows.UI.Notifications.ToastNotification]::new($x))
|
| 493 |
+
"""
|
| 494 |
+
|
| 495 |
+
|
| 496 |
+
def toast(title: str, message: str):
|
| 497 |
+
"""Fire-and-forget Windows toast (through PowerShell's registered app id)."""
|
| 498 |
+
from xml.sax.saxutils import escape
|
| 499 |
+
xml = ('<toast><visual><binding template="ToastGeneric">'
|
| 500 |
+
f"<text>{escape(title[:120])}</text><text>{escape(message[:400])}</text>"
|
| 501 |
+
"</binding></visual></toast>")
|
| 502 |
+
subprocess.Popen(["powershell", "-NoProfile", "-NonInteractive", "-Command", _TOAST_PS],
|
| 503 |
+
env={**os.environ, "JARVIS_TOAST": xml},
|
| 504 |
+
creationflags=subprocess.CREATE_NO_WINDOW)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
# ---------------------------------------------------------------- tasks
|
| 508 |
+
|
| 509 |
+
TASKS: dict = {}
|
| 510 |
+
PROCS: dict = {} # task_id -> Popen, kept out of TASKS so get_task stays JSON-safe
|
| 511 |
+
TASK_SESSIONS: dict = {} # task_id -> voice Session to notify when it ends
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _kill_tree(pid: int):
|
| 515 |
+
"""Kill a process and its children (claude.cmd spawns node)."""
|
| 516 |
+
subprocess.run(["taskkill", "/T", "/F", "/PID", str(pid)],
|
| 517 |
+
capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def _agent_env(backend: str):
|
| 521 |
+
env = os.environ.copy()
|
| 522 |
+
if backend == "ollama":
|
| 523 |
+
# Ollama speaks the Anthropic Messages API: Claude Code runs on the local model.
|
| 524 |
+
env.update({
|
| 525 |
+
"ANTHROPIC_BASE_URL": OLLAMA_URL,
|
| 526 |
+
"ANTHROPIC_AUTH_TOKEN": "ollama",
|
| 527 |
+
"ANTHROPIC_API_KEY": "",
|
| 528 |
+
"ANTHROPIC_MODEL": AGENT_MODEL,
|
| 529 |
+
"ANTHROPIC_DEFAULT_OPUS_MODEL": AGENT_MODEL,
|
| 530 |
+
"ANTHROPIC_DEFAULT_SONNET_MODEL": AGENT_MODEL,
|
| 531 |
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL": AGENT_MODEL,
|
| 532 |
+
"CLAUDE_CODE_SUBAGENT_MODEL": AGENT_MODEL,
|
| 533 |
+
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
| 534 |
+
})
|
| 535 |
+
return env
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
def _run_task(task_id: str, prompt: str):
|
| 539 |
+
task = TASKS[task_id]
|
| 540 |
+
backend = task["backend"]
|
| 541 |
+
try:
|
| 542 |
+
# shutil.which honours PATHEXT, so this also finds claude.cmd on Windows.
|
| 543 |
+
claude = shutil.which("claude")
|
| 544 |
+
if not claude:
|
| 545 |
+
raise FileNotFoundError("claude")
|
| 546 |
+
cmd = [claude, "-p", prompt, "--append-system-prompt",
|
| 547 |
+
f"Réponds en {LANGUAGE}, de façon concise: ta réponse sera résumée à voix haute."]
|
| 548 |
+
if backend == "ollama":
|
| 549 |
+
cmd += ["--model", AGENT_MODEL]
|
| 550 |
+
if PERMISSION_MODE and PERMISSION_MODE.lower() != "off":
|
| 551 |
+
cmd += ["--permission-mode", PERMISSION_MODE]
|
| 552 |
+
proc = subprocess.Popen(
|
| 553 |
+
cmd,
|
| 554 |
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
| 555 |
+
text=True, encoding="utf-8", errors="replace", cwd=WORKDIR,
|
| 556 |
+
env=_agent_env(backend), creationflags=subprocess.CREATE_NO_WINDOW,
|
| 557 |
+
)
|
| 558 |
+
PROCS[task_id] = proc
|
| 559 |
+
try:
|
| 560 |
+
stdout, stderr = proc.communicate(
|
| 561 |
+
timeout=int(os.environ.get("JARVIS_TASK_TIMEOUT", "600")))
|
| 562 |
+
except subprocess.TimeoutExpired:
|
| 563 |
+
_kill_tree(proc.pid)
|
| 564 |
+
proc.communicate()
|
| 565 |
+
task["status"] = "error"
|
| 566 |
+
task["output"] = "Timeout: la session a dépassé la limite de temps."
|
| 567 |
+
else:
|
| 568 |
+
if task["status"] == "cancelled":
|
| 569 |
+
pass # set by the cancel endpoint; don't overwrite
|
| 570 |
+
else:
|
| 571 |
+
out = (stdout or "").strip()
|
| 572 |
+
err = (stderr or "").strip()
|
| 573 |
+
task["status"] = "done" if proc.returncode == 0 else "error"
|
| 574 |
+
task["output"] = out if out else err[:2000]
|
| 575 |
+
except FileNotFoundError:
|
| 576 |
+
task["status"] = "error"
|
| 577 |
+
task["output"] = ("La commande 'claude' est introuvable. Installe Claude Code: "
|
| 578 |
+
"npm install -g @anthropic-ai/claude-code")
|
| 579 |
+
except Exception as exc: # noqa: BLE001
|
| 580 |
+
task["status"] = "error"
|
| 581 |
+
task["output"] = str(exc)
|
| 582 |
+
finally:
|
| 583 |
+
PROCS.pop(task_id, None)
|
| 584 |
+
task["ended"] = time.time()
|
| 585 |
+
if NOTIFY and task["status"] in ("done", "error"):
|
| 586 |
+
first = next((l.strip(" #*") for l in (task["output"] or "").splitlines() if l.strip()), "")
|
| 587 |
+
toast(f"JARVIS · {task['title']}" + (" — erreur" if task["status"] == "error" else " — terminé"),
|
| 588 |
+
first or task["status"])
|
| 589 |
+
sess = TASK_SESSIONS.pop(task_id, None)
|
| 590 |
+
if sess:
|
| 591 |
+
asyncio.run_coroutine_threadsafe(sess.task_finished(task), sess.loop)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
class TaskIn(BaseModel):
|
| 595 |
+
title: str
|
| 596 |
+
prompt: str
|
| 597 |
+
complex: bool = False
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
def start_task(title: str, prompt: str, session=None, complex_: bool = False) -> dict:
|
| 601 |
+
task_id = uuid.uuid4().hex[:8]
|
| 602 |
+
backend = COMPLEX_BACKEND if complex_ else AGENT_BACKEND
|
| 603 |
+
TASKS[task_id] = {
|
| 604 |
+
"id": task_id, "title": title, "prompt": prompt, "backend": backend,
|
| 605 |
+
"status": "running", "output": "", "started": time.time(), "ended": None,
|
| 606 |
+
}
|
| 607 |
+
if session:
|
| 608 |
+
TASK_SESSIONS[task_id] = session
|
| 609 |
+
log(f"tâche {task_id} « {title} » -> {'Claude' if backend == 'anthropic' else 'modèle local'}")
|
| 610 |
+
threading.Thread(target=_run_task, args=(task_id, prompt), daemon=True).start()
|
| 611 |
+
return TASKS[task_id]
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
@app.post("/api/task")
|
| 615 |
+
def create_task(body: TaskIn):
|
| 616 |
+
task = start_task(body.title, body.prompt, complex_=body.complex)
|
| 617 |
+
return {"id": task["id"], "status": "running"}
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
@app.post("/api/task/{task_id}/cancel")
|
| 621 |
+
def cancel_task(task_id: str):
|
| 622 |
+
if task_id in ("latest", "last", "-"):
|
| 623 |
+
running = [t for t in TASKS.values() if t["status"] == "running"]
|
| 624 |
+
if not running:
|
| 625 |
+
return {"ok": False, "error": "Aucune tâche en cours."}
|
| 626 |
+
task = max(running, key=lambda t: t["started"])
|
| 627 |
+
else:
|
| 628 |
+
task = TASKS.get(task_id)
|
| 629 |
+
if not task:
|
| 630 |
+
raise HTTPException(404, "unknown task")
|
| 631 |
+
if task["status"] != "running":
|
| 632 |
+
return {"ok": False, "error": f"La tâche est déjà {task['status']}."}
|
| 633 |
+
# Flag first so _run_task's communicate() return doesn't overwrite it.
|
| 634 |
+
task["status"] = "cancelled"
|
| 635 |
+
task["output"] = "Annulée par l'utilisateur."
|
| 636 |
+
proc = PROCS.get(task["id"])
|
| 637 |
+
if proc and proc.poll() is None:
|
| 638 |
+
_kill_tree(proc.pid)
|
| 639 |
+
return {"ok": True, "cancelled": task["id"], "title": task["title"]}
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
@app.get("/api/task/{task_id}")
|
| 643 |
+
def get_task(task_id: str):
|
| 644 |
+
task = TASKS.get(task_id)
|
| 645 |
+
if not task:
|
| 646 |
+
raise HTTPException(404, "unknown task")
|
| 647 |
+
return task
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
# ---------------------------------------------------------------- monitors & window placement (Windows API)
|
| 651 |
+
|
| 652 |
+
import ctypes
|
| 653 |
+
from ctypes import wintypes
|
| 654 |
+
|
| 655 |
+
user32 = ctypes.windll.user32
|
| 656 |
+
try: # accurate multi-monitor coordinates under display scaling
|
| 657 |
+
ctypes.windll.shcore.SetProcessDpiAwareness(2)
|
| 658 |
+
except Exception: # noqa: BLE001
|
| 659 |
+
pass
|
| 660 |
+
|
| 661 |
+
_MonitorEnumProc = ctypes.WINFUNCTYPE(
|
| 662 |
+
ctypes.c_int, wintypes.HMONITOR, wintypes.HDC,
|
| 663 |
+
ctypes.POINTER(wintypes.RECT), wintypes.LPARAM)
|
| 664 |
+
_EnumWindowsProc = ctypes.WINFUNCTYPE(ctypes.c_int, wintypes.HWND, wintypes.LPARAM)
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def _monitors():
|
| 668 |
+
"""List monitor work rects as (left, top, right, bottom)."""
|
| 669 |
+
mons = []
|
| 670 |
+
|
| 671 |
+
def cb(hmon, hdc, lprc, lparam):
|
| 672 |
+
r = lprc.contents
|
| 673 |
+
mons.append((r.left, r.top, r.right, r.bottom))
|
| 674 |
+
return 1
|
| 675 |
+
|
| 676 |
+
user32.EnumDisplayMonitors(0, 0, _MonitorEnumProc(cb), 0)
|
| 677 |
+
return mons
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def _pick_monitor(target: str):
|
| 681 |
+
mons = _monitors()
|
| 682 |
+
if not mons:
|
| 683 |
+
return None
|
| 684 |
+
t = (target or "").strip().lower()
|
| 685 |
+
if t.isdigit():
|
| 686 |
+
i = int(t) - 1
|
| 687 |
+
return mons[i] if 0 <= i < len(mons) else None
|
| 688 |
+
key = {
|
| 689 |
+
"left": lambda m: m[0], "gauche": lambda m: m[0],
|
| 690 |
+
"top": lambda m: m[1], "haut": lambda m: m[1],
|
| 691 |
+
}
|
| 692 |
+
if t in key:
|
| 693 |
+
return min(mons, key=key[t])
|
| 694 |
+
key = {
|
| 695 |
+
"right": lambda m: m[2], "droite": lambda m: m[2], "droit": lambda m: m[2],
|
| 696 |
+
"bottom": lambda m: m[3], "bas": lambda m: m[3],
|
| 697 |
+
}
|
| 698 |
+
if t in key:
|
| 699 |
+
return max(mons, key=key[t])
|
| 700 |
+
# primary: the monitor containing the origin (0,0)
|
| 701 |
+
for m in mons:
|
| 702 |
+
if m[0] <= 0 < m[2] and m[1] <= 0 < m[3]:
|
| 703 |
+
return m
|
| 704 |
+
return mons[0]
|
| 705 |
+
|
| 706 |
+
|
| 707 |
+
def _visible_windows():
|
| 708 |
+
"""Map of visible top-level windows: hwnd -> title."""
|
| 709 |
+
wins = {}
|
| 710 |
+
|
| 711 |
+
def cb(hwnd, lparam):
|
| 712 |
+
if user32.IsWindowVisible(hwnd):
|
| 713 |
+
n = user32.GetWindowTextLengthW(hwnd)
|
| 714 |
+
if n:
|
| 715 |
+
buf = ctypes.create_unicode_buffer(n + 1)
|
| 716 |
+
user32.GetWindowTextW(hwnd, buf, n + 1)
|
| 717 |
+
wins[hwnd] = buf.value
|
| 718 |
+
return 1
|
| 719 |
+
|
| 720 |
+
user32.EnumWindows(_EnumWindowsProc(cb), 0)
|
| 721 |
+
return wins
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def _move_to_monitor(hwnd, mon):
|
| 725 |
+
left, top, right, bottom = mon
|
| 726 |
+
SW_RESTORE, SW_MAXIMIZE = 9, 3
|
| 727 |
+
user32.ShowWindow(hwnd, SW_RESTORE) # a maximized window can't be moved
|
| 728 |
+
user32.MoveWindow(hwnd, left + 40, top + 40,
|
| 729 |
+
max(400, (right - left) - 80), max(300, (bottom - top) - 80), True)
|
| 730 |
+
user32.ShowWindow(hwnd, SW_MAXIMIZE)
|
| 731 |
+
user32.SetForegroundWindow(hwnd)
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def _place_app_window(app_name: str, before: dict, mon, timeout: float = 20.0):
|
| 735 |
+
"""Wait for the app's window to appear, then move it to the target monitor.
|
| 736 |
+
|
| 737 |
+
Prefers a NEW window whose title mentions the app; falls back to any new
|
| 738 |
+
window, then to an existing title match (single-instance apps like Discord
|
| 739 |
+
just refocus their already-open window).
|
| 740 |
+
"""
|
| 741 |
+
q = app_name.lower()
|
| 742 |
+
deadline = time.time() + timeout
|
| 743 |
+
fallback = None
|
| 744 |
+
while time.time() < deadline:
|
| 745 |
+
wins = _visible_windows()
|
| 746 |
+
new = {h: t for h, t in wins.items() if h not in before}
|
| 747 |
+
for h, title in new.items():
|
| 748 |
+
if q in title.lower():
|
| 749 |
+
_move_to_monitor(h, mon)
|
| 750 |
+
return title
|
| 751 |
+
if new and fallback is None:
|
| 752 |
+
fallback = max(new) # remember, but keep hoping for a title match
|
| 753 |
+
time.sleep(0.5)
|
| 754 |
+
if fallback and time.time() > deadline - timeout / 2:
|
| 755 |
+
break
|
| 756 |
+
if fallback:
|
| 757 |
+
wins = _visible_windows()
|
| 758 |
+
_move_to_monitor(fallback, mon)
|
| 759 |
+
return wins.get(fallback, app_name)
|
| 760 |
+
# No new window: single-instance app already running -> match existing title.
|
| 761 |
+
for h, title in _visible_windows().items():
|
| 762 |
+
if q in title.lower():
|
| 763 |
+
_move_to_monitor(h, mon)
|
| 764 |
+
return title
|
| 765 |
+
return None
|
| 766 |
+
|
| 767 |
+
|
| 768 |
+
# ---------------------------------------------------------------- open app
|
| 769 |
+
|
| 770 |
+
START_MENU_DIRS = [
|
| 771 |
+
Path(os.environ.get("APPDATA", "")) / "Microsoft/Windows/Start Menu/Programs",
|
| 772 |
+
Path(os.environ.get("PROGRAMDATA", "")) / "Microsoft/Windows/Start Menu/Programs",
|
| 773 |
+
]
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def _find_shortcut(name: str):
|
| 777 |
+
"""Fuzzy-match a Start Menu shortcut (where installed apps register)."""
|
| 778 |
+
q = name.lower().strip()
|
| 779 |
+
best, best_score = None, 0.0
|
| 780 |
+
for root in START_MENU_DIRS:
|
| 781 |
+
if not root.is_dir():
|
| 782 |
+
continue
|
| 783 |
+
for lnk in root.rglob("*.lnk"):
|
| 784 |
+
stem = lnk.stem.lower()
|
| 785 |
+
if q == stem:
|
| 786 |
+
return lnk
|
| 787 |
+
score = 0.0
|
| 788 |
+
if q in stem:
|
| 789 |
+
score = 2 + len(q) / len(stem) # substring: prefer tightest match
|
| 790 |
+
elif all(w in stem for w in q.split()):
|
| 791 |
+
score = 1
|
| 792 |
+
# Penalise uninstallers and docs.
|
| 793 |
+
if any(bad in stem for bad in ("uninstall", "désinstaller", "readme", "website")):
|
| 794 |
+
score -= 2
|
| 795 |
+
if score > best_score:
|
| 796 |
+
best, best_score = lnk, score
|
| 797 |
+
return best
|
| 798 |
+
|
| 799 |
+
|
| 800 |
+
class OpenIn(BaseModel):
|
| 801 |
+
name: str = ""
|
| 802 |
+
url: str | None = None
|
| 803 |
+
monitor: str | None = None
|
| 804 |
+
|
| 805 |
+
|
| 806 |
+
def _placed(name: str, monitor: str | None, before: dict, launched: str):
|
| 807 |
+
"""Optionally move the freshly launched app to the requested screen."""
|
| 808 |
+
if not monitor:
|
| 809 |
+
return {"ok": True, "launched": launched}
|
| 810 |
+
mon = _pick_monitor(monitor)
|
| 811 |
+
if not mon:
|
| 812 |
+
return {"ok": True, "launched": launched,
|
| 813 |
+
"warning": f"écran '{monitor}' introuvable, fenêtre laissée en place"}
|
| 814 |
+
title = _place_app_window(name, before, mon)
|
| 815 |
+
if title:
|
| 816 |
+
return {"ok": True, "launched": launched, "monitor": monitor, "window": title}
|
| 817 |
+
return {"ok": True, "launched": launched,
|
| 818 |
+
"warning": "fenêtre non détectée, placement impossible"}
|
| 819 |
+
|
| 820 |
+
|
| 821 |
+
@app.post("/api/open")
|
| 822 |
+
def open_app(body: OpenIn):
|
| 823 |
+
name = body.name.strip()
|
| 824 |
+
if body.url:
|
| 825 |
+
url = body.url.strip()
|
| 826 |
+
if not url.startswith(("http://", "https://")):
|
| 827 |
+
url = "https://" + url
|
| 828 |
+
before = _visible_windows() if body.monitor else {}
|
| 829 |
+
import webbrowser
|
| 830 |
+
if not webbrowser.open(url):
|
| 831 |
+
return {"ok": False, "error": "Impossible d'ouvrir le navigateur."}
|
| 832 |
+
# Match the browser window by the site's domain.
|
| 833 |
+
domain = url.split("//", 1)[1].split("/", 1)[0].removeprefix("www.")
|
| 834 |
+
return _placed(domain.split(".")[0], body.monitor, before, url)
|
| 835 |
+
if not name:
|
| 836 |
+
raise HTTPException(400, "missing app name or url")
|
| 837 |
+
before = _visible_windows() if body.monitor else {}
|
| 838 |
+
lnk = _find_shortcut(name)
|
| 839 |
+
if lnk:
|
| 840 |
+
os.startfile(lnk) # noqa: S606 - deliberate: local launcher
|
| 841 |
+
return _placed(name, body.monitor, before, lnk.stem)
|
| 842 |
+
# Fallback: resolve via PATH then the App Paths registry (chrome, notepad...).
|
| 843 |
+
exe = shutil.which(name) or shutil.which(name + ".exe")
|
| 844 |
+
if not exe:
|
| 845 |
+
try:
|
| 846 |
+
import winreg
|
| 847 |
+
for hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE):
|
| 848 |
+
try:
|
| 849 |
+
key = winreg.OpenKey(hive, rf"Software\Microsoft\Windows"
|
| 850 |
+
rf"\CurrentVersion\App Paths\{name}.exe")
|
| 851 |
+
exe = winreg.QueryValueEx(key, None)[0].strip('"')
|
| 852 |
+
break
|
| 853 |
+
except OSError:
|
| 854 |
+
continue
|
| 855 |
+
except ImportError:
|
| 856 |
+
pass
|
| 857 |
+
if not exe:
|
| 858 |
+
return {"ok": False, "error": f"Application '{name}' introuvable sur ce PC."}
|
| 859 |
+
try:
|
| 860 |
+
subprocess.Popen([exe], cwd=str(Path(exe).parent))
|
| 861 |
+
res = _placed(name, body.monitor, before, Path(exe).stem)
|
| 862 |
+
res.setdefault("via", "exe")
|
| 863 |
+
return res
|
| 864 |
+
except Exception as exc: # noqa: BLE001
|
| 865 |
+
return {"ok": False, "error": str(exc)}
|
| 866 |
+
|
| 867 |
+
|
| 868 |
+
# ---------------------------------------------------------------- voice session
|
| 869 |
+
|
| 870 |
+
FRAME_MS = 32 # one Silero frame (512 samples @ 16 kHz)
|
| 871 |
+
START_FRAMES = 3 # ~100 ms of speech opens an utterance
|
| 872 |
+
BARGE_FRAMES = 8 # ~250 ms needed to interrupt JARVIS
|
| 873 |
+
SILENCE_FRAMES = max(1, SILENCE_MS // FRAME_MS)
|
| 874 |
+
PREROLL_FRAMES = 10 # keep ~320 ms before the trigger
|
| 875 |
+
MIN_SPEECH = int(0.35 * 16000)
|
| 876 |
+
MAX_FRAMES = 30_000 // FRAME_MS # cut an utterance at 30 s
|
| 877 |
+
MAX_HISTORY = 40
|
| 878 |
+
# Flush a sentence to the TTS at its end punctuation (or on a newline).
|
| 879 |
+
SENT_END = re.compile(r"(?<=[.!?…:;])[\"»)]?\s+|\n+")
|
| 880 |
+
FIRST_CUT = re.compile(r"[,.!?…:;]\s+")
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
def split_sentences(buf: str, first: bool = False):
|
| 884 |
+
"""Pop complete sentences off the LLM stream; returns (sentences, rest).
|
| 885 |
+
|
| 886 |
+
For the very first chunk of a reply, a comma is enough: JARVIS starts
|
| 887 |
+
talking sooner while the rest of the sentence is still being written.
|
| 888 |
+
"""
|
| 889 |
+
if first:
|
| 890 |
+
m = FIRST_CUT.search(buf)
|
| 891 |
+
if m and m.start() >= 15:
|
| 892 |
+
return [buf[:m.start() + 1]], buf[m.end():]
|
| 893 |
+
out, start = [], 0
|
| 894 |
+
for m in SENT_END.finditer(buf):
|
| 895 |
+
if m.start() - start >= 12: # too short ("M. "): merge with the next one
|
| 896 |
+
out.append(buf[start:m.start()])
|
| 897 |
+
start = m.end()
|
| 898 |
+
rest = buf[start:]
|
| 899 |
+
if len(rest) > 160: # long clause with no full stop: cut at a comma
|
| 900 |
+
i = rest.rfind(", ", 0, 160)
|
| 901 |
+
if i > 40:
|
| 902 |
+
out.append(rest[:i + 1])
|
| 903 |
+
rest = rest[i + 2:]
|
| 904 |
+
return out, rest
|
| 905 |
+
|
| 906 |
+
|
| 907 |
+
class Session:
|
| 908 |
+
"""One browser connection: VAD -> STT -> LLM (+tools) -> TTS loop."""
|
| 909 |
+
|
| 910 |
+
def __init__(self, ws: WebSocket):
|
| 911 |
+
self.ws = ws
|
| 912 |
+
self.loop = asyncio.get_running_loop()
|
| 913 |
+
self.history = [{"role": "system", "content": system_prompt()}]
|
| 914 |
+
self.speech_end = 0.0 # when the user's last utterance ended
|
| 915 |
+
self.last_audio = None # that utterance, in case the user goes on talking
|
| 916 |
+
self.merge_prefix = None # previous fragment to glue in front of this one
|
| 917 |
+
self.utt_gen = 0 # bumps on each utterance; stale transcriptions drop out
|
| 918 |
+
self.turn_index = None # history index of the current user turn
|
| 919 |
+
self.turn_gen = -1 # utt_gen that produced it
|
| 920 |
+
self.tools_ran = False # side effects already happened: too late to merge
|
| 921 |
+
self.first_audio_logged = True
|
| 922 |
+
self.m_stt, self.m_llm, self.m_reply_t0 = 0.0, None, 0.0
|
| 923 |
+
self.reply: asyncio.Task | None = None
|
| 924 |
+
self.playing = False # the browser is still playing our audio
|
| 925 |
+
self.pending: list[str] = [] # task results waiting for a free turn
|
| 926 |
+
self.closed = False
|
| 927 |
+
self.http = httpx.AsyncClient(timeout=httpx.Timeout(600, connect=10))
|
| 928 |
+
self.vad = SileroVAD()
|
| 929 |
+
self.carry = np.zeros(0, dtype=np.float32)
|
| 930 |
+
self.preroll = deque(maxlen=PREROLL_FRAMES)
|
| 931 |
+
self.frames: list[np.ndarray] = []
|
| 932 |
+
self.in_speech = False
|
| 933 |
+
self.voiced = self.silent = 0
|
| 934 |
+
self.stats_t, self.stats_frames, self.stats_peak, self.stats_p = time.time(), 0, 0.0, 0.0
|
| 935 |
+
|
| 936 |
+
# ------------------------------------------------ outbound
|
| 937 |
+
async def send(self, obj: dict):
|
| 938 |
+
if not self.closed:
|
| 939 |
+
try:
|
| 940 |
+
await self.ws.send_text(json.dumps(obj, ensure_ascii=False))
|
| 941 |
+
except Exception: # noqa: BLE001 - socket gone
|
| 942 |
+
self.closed = True
|
| 943 |
+
|
| 944 |
+
async def send_audio(self, pcm: bytes):
|
| 945 |
+
if not self.closed:
|
| 946 |
+
try:
|
| 947 |
+
await self.ws.send_bytes(pcm)
|
| 948 |
+
except Exception: # noqa: BLE001
|
| 949 |
+
self.closed = True
|
| 950 |
+
|
| 951 |
+
@property
|
| 952 |
+
def busy(self) -> bool:
|
| 953 |
+
return self.playing or (self.reply is not None and not self.reply.done())
|
| 954 |
+
|
| 955 |
+
# ------------------------------------------------ inbound
|
| 956 |
+
async def on_text(self, msg: dict):
|
| 957 |
+
if msg.get("type") == "interrupt":
|
| 958 |
+
await self.interrupt()
|
| 959 |
+
await self.send({"type": "state", "state": "idle"})
|
| 960 |
+
return
|
| 961 |
+
if msg.get("type") == "playback":
|
| 962 |
+
self.playing = bool(msg.get("playing"))
|
| 963 |
+
if not self.busy and not self.in_speech:
|
| 964 |
+
await self.send({"type": "state", "state": "idle"})
|
| 965 |
+
await self._drain_pending()
|
| 966 |
+
|
| 967 |
+
async def feed(self, data: bytes):
|
| 968 |
+
"""Mic audio: PCM16 mono 16 kHz, any chunk size."""
|
| 969 |
+
pcm = np.frombuffer(data, dtype="<i2").astype(np.float32) / 32768.0
|
| 970 |
+
buf = np.concatenate([self.carry, pcm])
|
| 971 |
+
n = len(buf) // SileroVAD.FRAME * SileroVAD.FRAME
|
| 972 |
+
self.carry = buf[n:]
|
| 973 |
+
for frame in buf[:n].reshape(-1, SileroVAD.FRAME):
|
| 974 |
+
await self._on_frame(frame)
|
| 975 |
+
self.stats_frames += n // SileroVAD.FRAME
|
| 976 |
+
self.stats_peak = max(self.stats_peak, float(np.abs(pcm).max(initial=0)))
|
| 977 |
+
if time.time() - self.stats_t >= 5: # mic heartbeat, handy to debug audio
|
| 978 |
+
log(f"micro: {self.stats_frames * FRAME_MS / 5000:.0%} du flux reçu, "
|
| 979 |
+
f"pic {self.stats_peak:.2f}, voix max {self.stats_p:.2f}")
|
| 980 |
+
self.stats_t, self.stats_frames, self.stats_peak, self.stats_p = time.time(), 0, 0.0, 0.0
|
| 981 |
+
|
| 982 |
+
async def _on_frame(self, frame: np.ndarray):
|
| 983 |
+
p = self.vad(frame)
|
| 984 |
+
self.stats_p = max(self.stats_p, p)
|
| 985 |
+
if not self.in_speech:
|
| 986 |
+
self.preroll.append(frame)
|
| 987 |
+
busy = self.busy
|
| 988 |
+
if busy and not BARGE_IN:
|
| 989 |
+
self.voiced = 0
|
| 990 |
+
return
|
| 991 |
+
# Speaking again right after a pause continues the same sentence.
|
| 992 |
+
cont = (self.last_audio is not None and not self.tools_ran
|
| 993 |
+
and time.time() - self.speech_end < CONTINUE_S)
|
| 994 |
+
# While JARVIS talks, its own voice may leak into the mic: be stricter.
|
| 995 |
+
strict = self.playing or (busy and not cont)
|
| 996 |
+
thr = max(VAD_THRESHOLD, 0.8) if strict else VAD_THRESHOLD
|
| 997 |
+
self.voiced = self.voiced + 1 if p >= thr else 0
|
| 998 |
+
if self.voiced >= (BARGE_FRAMES if strict else START_FRAMES):
|
| 999 |
+
self.in_speech, self.silent = True, 0
|
| 1000 |
+
self.frames = list(self.preroll)
|
| 1001 |
+
if cont:
|
| 1002 |
+
await self._continue_turn()
|
| 1003 |
+
elif busy:
|
| 1004 |
+
await self.interrupt()
|
| 1005 |
+
await self.send({"type": "state", "state": "listening"})
|
| 1006 |
+
return
|
| 1007 |
+
self.frames.append(frame)
|
| 1008 |
+
self.silent = self.silent + 1 if p < VAD_THRESHOLD - 0.15 else 0
|
| 1009 |
+
if self.silent >= SILENCE_FRAMES or len(self.frames) >= MAX_FRAMES:
|
| 1010 |
+
self.speech_end = time.time()
|
| 1011 |
+
self.first_audio_logged = False
|
| 1012 |
+
audio = np.concatenate(self.frames)
|
| 1013 |
+
if self.merge_prefix is not None:
|
| 1014 |
+
audio = np.concatenate([self.merge_prefix, np.zeros(3200, np.float32), audio])
|
| 1015 |
+
self.merge_prefix = None
|
| 1016 |
+
self.in_speech, self.voiced, self.frames = False, 0, []
|
| 1017 |
+
self.preroll.clear()
|
| 1018 |
+
self.last_audio = audio
|
| 1019 |
+
self.utt_gen += 1
|
| 1020 |
+
asyncio.create_task(self._utterance(audio, self.utt_gen))
|
| 1021 |
+
|
| 1022 |
+
async def _continue_turn(self):
|
| 1023 |
+
"""The user paused mid-sentence: drop the reply to the first fragment."""
|
| 1024 |
+
prev_gen = self.utt_gen
|
| 1025 |
+
self.merge_prefix = self.last_audio
|
| 1026 |
+
self.utt_gen += 1 # an in-flight transcription of the fragment is now stale
|
| 1027 |
+
await self.interrupt()
|
| 1028 |
+
if self.turn_gen == prev_gen and self.turn_index is not None:
|
| 1029 |
+
self.history = self.history[:self.turn_index]
|
| 1030 |
+
log("suite de ta phrase : je recolle les morceaux")
|
| 1031 |
+
|
| 1032 |
+
async def _utterance(self, audio: np.ndarray, gen: int):
|
| 1033 |
+
if len(audio) < MIN_SPEECH:
|
| 1034 |
+
if not self.busy:
|
| 1035 |
+
await self.send({"type": "state", "state": "idle"})
|
| 1036 |
+
return
|
| 1037 |
+
await self.send({"type": "state", "state": "transcribing"})
|
| 1038 |
+
t = time.time()
|
| 1039 |
+
context = next((m["content"] for m in reversed(self.history)
|
| 1040 |
+
if m["role"] == "assistant" and m.get("content")), "")
|
| 1041 |
+
text = await self.loop.run_in_executor(STT_POOL, transcribe, audio, context)
|
| 1042 |
+
self.m_stt = time.time() - t
|
| 1043 |
+
if gen != self.utt_gen:
|
| 1044 |
+
return # the user kept talking: a merged utterance replaces this one
|
| 1045 |
+
log(f"entendu ({len(audio) / 16000:.1f} s, stt {time.time() - t:.2f} s): {text!r}")
|
| 1046 |
+
if not text:
|
| 1047 |
+
if not self.busy:
|
| 1048 |
+
await self.send({"type": "state", "state": "idle"})
|
| 1049 |
+
return
|
| 1050 |
+
await self.send({"type": "user_text", "text": text,
|
| 1051 |
+
"ms": int((time.time() - t) * 1000)})
|
| 1052 |
+
await self.start_reply({"role": "user", "content": text})
|
| 1053 |
+
self.turn_index, self.turn_gen = len(self.history) - 1, gen
|
| 1054 |
+
|
| 1055 |
+
# ------------------------------------------------ reply
|
| 1056 |
+
async def interrupt(self):
|
| 1057 |
+
if not self.busy:
|
| 1058 |
+
return
|
| 1059 |
+
if self.reply and not self.reply.done():
|
| 1060 |
+
self.reply.cancel()
|
| 1061 |
+
try:
|
| 1062 |
+
await self.reply
|
| 1063 |
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
| 1064 |
+
pass
|
| 1065 |
+
self.playing = False
|
| 1066 |
+
await self.send({"type": "stop_audio"})
|
| 1067 |
+
|
| 1068 |
+
async def start_reply(self, message: dict):
|
| 1069 |
+
await self.interrupt()
|
| 1070 |
+
self.tools_ran = False
|
| 1071 |
+
self.turn_gen = -1 # set again by _utterance for a spoken turn
|
| 1072 |
+
self.history.append(message)
|
| 1073 |
+
if len(self.history) > MAX_HISTORY:
|
| 1074 |
+
tail = self.history[-MAX_HISTORY:]
|
| 1075 |
+
while tail and tail[0]["role"] != "user": # never start on a tool result
|
| 1076 |
+
tail.pop(0)
|
| 1077 |
+
self.history = [self.history[0]] + tail
|
| 1078 |
+
self.reply = asyncio.create_task(self._respond())
|
| 1079 |
+
# Task results that arrived meanwhile get their turn once this one ends.
|
| 1080 |
+
self.reply.add_done_callback(lambda _: asyncio.create_task(self._drain_pending()))
|
| 1081 |
+
|
| 1082 |
+
async def _respond(self):
|
| 1083 |
+
speech: asyncio.Queue = asyncio.Queue()
|
| 1084 |
+
speaker = asyncio.create_task(self._speaker(speech))
|
| 1085 |
+
spoken = []
|
| 1086 |
+
await self.send({"type": "state", "state": "thinking"})
|
| 1087 |
+
await self.send({"type": "reply_start"})
|
| 1088 |
+
self.m_reply_t0, self.m_llm = time.time(), None
|
| 1089 |
+
try:
|
| 1090 |
+
for _ in range(6): # tool-call rounds
|
| 1091 |
+
content, calls = await self._llm_round(speech, spoken)
|
| 1092 |
+
if content:
|
| 1093 |
+
log(f"jarvis: {content[:200]!r}")
|
| 1094 |
+
msg = {"role": "assistant", "content": content}
|
| 1095 |
+
if calls:
|
| 1096 |
+
msg["tool_calls"] = calls
|
| 1097 |
+
self.history.append(msg)
|
| 1098 |
+
spoken.clear()
|
| 1099 |
+
if not calls:
|
| 1100 |
+
break
|
| 1101 |
+
for call in calls:
|
| 1102 |
+
fn = call.get("function", {})
|
| 1103 |
+
args = fn.get("arguments") or {}
|
| 1104 |
+
if isinstance(args, str):
|
| 1105 |
+
try:
|
| 1106 |
+
args = json.loads(args or "{}")
|
| 1107 |
+
except json.JSONDecodeError:
|
| 1108 |
+
args = {}
|
| 1109 |
+
log(f"outil {fn.get('name')}({json.dumps(args, ensure_ascii=False)[:120]})")
|
| 1110 |
+
result = await self._tool(fn.get("name", ""), args)
|
| 1111 |
+
self.history.append({"role": "tool", "tool_name": fn.get("name", ""),
|
| 1112 |
+
"content": json.dumps(result, ensure_ascii=False)})
|
| 1113 |
+
await speech.put(None)
|
| 1114 |
+
await speaker
|
| 1115 |
+
except asyncio.CancelledError:
|
| 1116 |
+
speaker.cancel()
|
| 1117 |
+
if spoken: # keep what was actually said, so the context stays honest
|
| 1118 |
+
self.history.append({"role": "assistant", "content": "".join(spoken) + " […interrompu]"})
|
| 1119 |
+
raise
|
| 1120 |
+
except httpx.HTTPError as exc:
|
| 1121 |
+
speaker.cancel()
|
| 1122 |
+
log(f"erreur Ollama: {exc}")
|
| 1123 |
+
await self.send({"type": "error", "text": f"Ollama: {exc}"})
|
| 1124 |
+
finally:
|
| 1125 |
+
if not self.playing:
|
| 1126 |
+
await self.send({"type": "state", "state": "idle"})
|
| 1127 |
+
|
| 1128 |
+
async def _llm_round(self, speech: asyncio.Queue, spoken: list):
|
| 1129 |
+
body = {"model": LLM_MODEL, "messages": self.history, "tools": OLLAMA_TOOLS,
|
| 1130 |
+
"stream": True, "think": False, "keep_alive": KEEP_ALIVE,
|
| 1131 |
+
"options": {"num_ctx": NUM_CTX, "temperature": TEMPERATURE}}
|
| 1132 |
+
content, buf, calls = "", "", []
|
| 1133 |
+
spoken_any = [False]
|
| 1134 |
+
async with self.http.stream("POST", f"{OLLAMA_URL}/api/chat", json=body) as r:
|
| 1135 |
+
if r.status_code >= 400:
|
| 1136 |
+
raise httpx.HTTPError(f"{r.status_code} {(await r.aread())[:300]!r}")
|
| 1137 |
+
async for line in r.aiter_lines():
|
| 1138 |
+
if not line:
|
| 1139 |
+
continue
|
| 1140 |
+
chunk = json.loads(line)
|
| 1141 |
+
if chunk.get("error"):
|
| 1142 |
+
raise httpx.HTTPError(chunk["error"])
|
| 1143 |
+
msg = chunk.get("message") or {}
|
| 1144 |
+
calls += msg.get("tool_calls") or []
|
| 1145 |
+
delta = msg.get("content") or ""
|
| 1146 |
+
if delta and self.m_llm is None:
|
| 1147 |
+
self.m_llm = time.time() - self.m_reply_t0
|
| 1148 |
+
if delta:
|
| 1149 |
+
content += delta
|
| 1150 |
+
spoken.append(delta)
|
| 1151 |
+
await self.send({"type": "assistant_delta", "text": delta})
|
| 1152 |
+
buf += delta
|
| 1153 |
+
sentences, buf = split_sentences(buf, first=not self.playing and not spoken_any[0])
|
| 1154 |
+
spoken_any[0] = spoken_any[0] or bool(sentences)
|
| 1155 |
+
for s in sentences:
|
| 1156 |
+
await speech.put(s)
|
| 1157 |
+
if buf.strip():
|
| 1158 |
+
await speech.put(buf)
|
| 1159 |
+
content = re.sub(r"<think>.*?</think>", "", content, flags=re.S).strip()
|
| 1160 |
+
return content, calls
|
| 1161 |
+
|
| 1162 |
+
async def _speaker(self, speech: asyncio.Queue):
|
| 1163 |
+
while (text := await speech.get()) is not None:
|
| 1164 |
+
text = _spoken(re.sub(r"<think>.*?</think>", "", text, flags=re.S))
|
| 1165 |
+
if not re.search(r"\w", text):
|
| 1166 |
+
continue
|
| 1167 |
+
t_tts = time.time()
|
| 1168 |
+
pcm = await self.loop.run_in_executor(TTS_POOL, synthesize, text)
|
| 1169 |
+
if not self.first_audio_logged and self.speech_end:
|
| 1170 |
+
self.first_audio_logged = True
|
| 1171 |
+
total = time.time() - self.speech_end
|
| 1172 |
+
log(f"1er son {total:.2f} s après ta phrase")
|
| 1173 |
+
await self.send({"type": "metrics", "stt": round(self.m_stt, 3),
|
| 1174 |
+
"llm": round(self.m_llm or 0, 3),
|
| 1175 |
+
"tts": round(time.time() - t_tts, 3), "total": round(total, 3)})
|
| 1176 |
+
if not self.playing:
|
| 1177 |
+
self.playing = True
|
| 1178 |
+
await self.send({"type": "state", "state": "speaking"})
|
| 1179 |
+
await self.send_audio(pcm)
|
| 1180 |
+
|
| 1181 |
+
# ------------------------------------------------ tools
|
| 1182 |
+
async def _tool(self, name: str, args: dict) -> dict:
|
| 1183 |
+
if name not in ("display_card", "display_report", "get_datetime"):
|
| 1184 |
+
self.tools_ran = True # real side effect: a late continuation won't undo it
|
| 1185 |
+
try:
|
| 1186 |
+
if name == "delegate_task":
|
| 1187 |
+
task = start_task(args.get("title") or "Tâche", args.get("prompt") or "", self,
|
| 1188 |
+
complex_=bool(args.get("complex")))
|
| 1189 |
+
await self.send({"type": "task", "task": task})
|
| 1190 |
+
return {"status": "started", "task_id": task["id"],
|
| 1191 |
+
"runs_on": "Claude" if task["backend"] == "anthropic" else "modèle local"}
|
| 1192 |
+
if name == "remember":
|
| 1193 |
+
fact = (args.get("fact") or "").strip()
|
| 1194 |
+
facts = load_memory()
|
| 1195 |
+
if fact and fact not in facts:
|
| 1196 |
+
facts.append(fact)
|
| 1197 |
+
save_memory(facts)
|
| 1198 |
+
self.history[0]["content"] = system_prompt()
|
| 1199 |
+
return {"ok": True, "memory": facts}
|
| 1200 |
+
if name == "forget":
|
| 1201 |
+
kw = (args.get("keyword") or "").strip().lower()
|
| 1202 |
+
facts = load_memory()
|
| 1203 |
+
kept = [] if kw in ("tout", "all", "*") else [f for f in facts if kw not in f.lower()]
|
| 1204 |
+
save_memory(kept)
|
| 1205 |
+
self.history[0]["content"] = system_prompt()
|
| 1206 |
+
return {"ok": True, "forgotten": len(facts) - len(kept), "memory": kept}
|
| 1207 |
+
if name == "notify":
|
| 1208 |
+
toast(args.get("title") or "J.A.R.V.I.S.", args.get("message") or "")
|
| 1209 |
+
return {"ok": True}
|
| 1210 |
+
if name == "get_datetime":
|
| 1211 |
+
return {"now": time.strftime("%A %d %B %Y, %H:%M"),
|
| 1212 |
+
"iso": time.strftime("%Y-%m-%dT%H:%M:%S")}
|
| 1213 |
+
if name in ("open_app", "open_url"):
|
| 1214 |
+
body = OpenIn(name=args.get("name") or "", url=args.get("url") or None,
|
| 1215 |
+
monitor=args.get("monitor") or None)
|
| 1216 |
+
label = body.name or body.url
|
| 1217 |
+
where = f" → écran **{body.monitor}**" if body.monitor else ""
|
| 1218 |
+
await self.send({"type": "ui", "name": "display_card", "args": {
|
| 1219 |
+
"title": "Lancement", "content": f"Ouverture de **{label}**{where}…"}})
|
| 1220 |
+
return await self.loop.run_in_executor(None, open_app, body)
|
| 1221 |
+
if name == "cancel_task":
|
| 1222 |
+
res = cancel_task(args.get("task_id") or "latest")
|
| 1223 |
+
if res.get("cancelled"):
|
| 1224 |
+
await self.send({"type": "task", "task": TASKS[res["cancelled"]]})
|
| 1225 |
+
return res
|
| 1226 |
+
if name in ("display_card", "display_report"):
|
| 1227 |
+
await self.send({"type": "ui", "name": name, "args": args})
|
| 1228 |
+
return {"status": "displayed"}
|
| 1229 |
+
return {"ok": False, "error": f"outil inconnu: {name}"}
|
| 1230 |
+
except HTTPException as exc:
|
| 1231 |
+
return {"ok": False, "error": exc.detail}
|
| 1232 |
+
except Exception as exc: # noqa: BLE001
|
| 1233 |
+
return {"ok": False, "error": str(exc)}
|
| 1234 |
+
|
| 1235 |
+
async def task_finished(self, task: dict):
|
| 1236 |
+
await self.send({"type": "task", "task": task})
|
| 1237 |
+
if task["status"] == "cancelled":
|
| 1238 |
+
return # the model already got the cancel_task output
|
| 1239 |
+
summary = (task.get("output") or "")[:4000]
|
| 1240 |
+
self.pending.append(
|
| 1241 |
+
f'[SYSTEM] Résultat de la tâche "{task["title"]}" ({task["status"]}): {summary}\n'
|
| 1242 |
+
"Résume oralement en une ou deux phrases. Si c'est une analyse de données "
|
| 1243 |
+
"(chiffres, stats, comparatifs), affiche un tableau de bord avec display_report "
|
| 1244 |
+
"(kpis, chart, table). Pour un simple résultat ponctuel, utilise display_card.")
|
| 1245 |
+
if not self.busy and not self.in_speech:
|
| 1246 |
+
await self._drain_pending()
|
| 1247 |
+
|
| 1248 |
+
async def _drain_pending(self):
|
| 1249 |
+
if self.pending and not self.busy and not self.in_speech and not self.closed:
|
| 1250 |
+
await self.start_reply({"role": "user", "content": self.pending.pop(0)})
|
| 1251 |
+
|
| 1252 |
+
async def close(self):
|
| 1253 |
+
self.closed = True
|
| 1254 |
+
if self.reply and not self.reply.done():
|
| 1255 |
+
self.reply.cancel()
|
| 1256 |
+
for tid, s in list(TASK_SESSIONS.items()):
|
| 1257 |
+
if s is self:
|
| 1258 |
+
TASK_SESSIONS.pop(tid, None)
|
| 1259 |
+
await self.http.aclose()
|
| 1260 |
+
|
| 1261 |
+
|
| 1262 |
+
@app.websocket("/ws")
|
| 1263 |
+
async def voice(ws: WebSocket):
|
| 1264 |
+
await ws.accept()
|
| 1265 |
+
log("navigateur connecté")
|
| 1266 |
+
session = Session(ws)
|
| 1267 |
+
await session.send({"type": "ready", "model": LLM_MODEL, "voice": TTS_VOICE, "address": ADDRESS,
|
| 1268 |
+
"barge_in": BARGE_IN, "whisper": WHISPER_MODEL,
|
| 1269 |
+
"agent": AGENT_BACKEND, "complex": COMPLEX_BACKEND})
|
| 1270 |
+
try:
|
| 1271 |
+
while True:
|
| 1272 |
+
msg = await ws.receive()
|
| 1273 |
+
if msg["type"] == "websocket.disconnect":
|
| 1274 |
+
break
|
| 1275 |
+
if msg.get("bytes"):
|
| 1276 |
+
await session.feed(msg["bytes"])
|
| 1277 |
+
elif msg.get("text"):
|
| 1278 |
+
await session.on_text(json.loads(msg["text"]))
|
| 1279 |
+
except WebSocketDisconnect:
|
| 1280 |
+
pass
|
| 1281 |
+
finally:
|
| 1282 |
+
log("navigateur déconnecté")
|
| 1283 |
+
await session.close()
|
| 1284 |
+
|
| 1285 |
+
|
| 1286 |
+
# ---------------------------------------------------------------- static
|
| 1287 |
+
|
| 1288 |
+
_gpu_cache = {"t": 0.0, "data": None}
|
| 1289 |
+
|
| 1290 |
+
|
| 1291 |
+
@app.get("/api/status")
|
| 1292 |
+
def status():
|
| 1293 |
+
"""GPU memory (NVIDIA only, cached a few seconds) for the telemetry panel."""
|
| 1294 |
+
if time.time() - _gpu_cache["t"] > 4:
|
| 1295 |
+
_gpu_cache["t"] = time.time()
|
| 1296 |
+
try:
|
| 1297 |
+
out = subprocess.run(
|
| 1298 |
+
["nvidia-smi", "--query-gpu=memory.used,memory.total", "--format=csv,noheader,nounits"],
|
| 1299 |
+
capture_output=True, text=True, timeout=3,
|
| 1300 |
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).stdout
|
| 1301 |
+
used, total = (int(x) for x in out.splitlines()[0].split(","))
|
| 1302 |
+
_gpu_cache["data"] = {"used_mb": used, "total_mb": total}
|
| 1303 |
+
except Exception: # noqa: BLE001 - no NVIDIA GPU / driver
|
| 1304 |
+
_gpu_cache["data"] = None
|
| 1305 |
+
running = sum(1 for t in TASKS.values() if t["status"] == "running")
|
| 1306 |
+
return {"gpu": _gpu_cache["data"], "tasks_running": running, "model": LLM_MODEL}
|
| 1307 |
+
|
| 1308 |
+
|
| 1309 |
+
@app.get("/")
|
| 1310 |
+
def index():
|
| 1311 |
+
return FileResponse(ROOT / "index.html")
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
if __name__ == "__main__":
|
| 1315 |
+
import uvicorn
|
| 1316 |
+
port = int(os.environ.get("JARVIS_PORT", "8788"))
|
| 1317 |
+
print("\n JARVIS Local — chargement des modèles")
|
| 1318 |
+
warmup()
|
| 1319 |
+
print(f"\n JARVIS Local -> http://127.0.0.1:{port}\n")
|
| 1320 |
+
uvicorn.run(app, host="127.0.0.1", port=port, log_level="warning")
|
start.bat
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
cd /d "%~dp0"
|
| 3 |
+
start "" http://127.0.0.1:8788
|
| 4 |
+
.venv\Scripts\python.exe server.py
|
| 5 |
+
pause
|