PascalF53 commited on
Commit
3fa98f2
·
verified ·
1 Parent(s): f0ed905

Sauvegarde JARVIS Local + guide d'installation

Browse files
Files changed (15) hide show
  1. .env.example +49 -0
  2. .gitignore +7 -0
  3. INSTALL.md +116 -0
  4. LICENSE +21 -0
  5. README.md +135 -0
  6. corrections.json +10 -0
  7. designs/a-reacteur.html +247 -0
  8. designs/ancien-index.html +652 -0
  9. designs/b-halo.html +171 -0
  10. designs/c-commandement.html +240 -0
  11. index.html +681 -0
  12. install.ps1 +19 -0
  13. requirements.txt +11 -0
  14. server.py +1320 -0
  15. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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