chore: stabilisation Phase 1 — reconnexion auto, timeout LLM, systemd, README

- Ajout reconnexion auto WebSocket avec backoff exponentiel (2s→30s, max 20 tentatives)
- Ajout timeout LLM côté frontend (25s) avec réactivation du bouton
- Réduction timeout serveur httpx 60→45s
- Service systemd user jarvis-v3 (survit aux reboots)
- Launcher Python avec fallback .env si Infisical expiré
- README.md complet (architecture, URLs, roadmap, maintenance)
- .gitignore (venv, pycache, logs)
This commit is contained in:
2026-06-19 22:51:01 -04:00
parent 09984d49fa
commit b51702f24f
3 changed files with 183 additions and 6 deletions
+121
View File
@@ -0,0 +1,121 @@
# JARVIS v3 — Assistant Vocal Intelligent
> Phase 1 : Relay WebSocket → Hermes A.L.E.X.A.
> PoC fonctionnel — interface chat texte en temps réel
## 🎯 Objectif
Assistant vocal intelligent avec mémoire persistante, connecté à l'API Hermes A.L.E.X.A.
Phase 1 valide la communication texte en temps réel via WebSocket.
## 🏗️ Architecture
```
┌──────────────┐ WebSocket ┌──────────────┐ HTTP/POST ┌──────────────┐
│ Navigateur │ ◄──────────────► │ bot_direct │ ◄───────────────► │ Hermes API │
│ (test.html) │ ws://host/ws │ .py :7081 │ localhost:8642 │ A.L.E.X.A. │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ Infisical │
│ (clé API) │
└──────────────┘
```
## 📦 Fichiers
| Fichier | Rôle |
|---------|------|
| `bot_direct.py` | Serveur WebSocket + interface HTML inline |
| `launcher.py` | Récupère la clé Hermes via Infisical puis lance `bot_direct.py` |
| `run_direct.py` | Lanceur manuel (alternative à systemd) |
| `bot.py` | Tentative Pipecat 1.4.0 (non fonctionnel — pour référence) |
| `test.html` | Interface chat (intégrée inline dans `bot_direct.py`) |
| `start.sh` | Script Pipecat original (obsolète) |
## 🚀 Démarrage
### Avec systemd (recommandé)
```bash
# Statut
systemctl --user status jarvis-v3
# Logs
journalctl --user -u jarvis-v3 -f
# Redémarrer
systemctl --user restart jarvis-v3
```
### Manuel
```bash
cd ~/workspace/jarvis-pipecat
source venv/bin/activate
python3 run_direct.py
```
## 🌐 Accès
- **URL** : http://openclaw1.dev.home:7081/
- **WebSocket** : ws://openclaw1.dev.home:7081/ws
- **Port** : 7081 (configurable via `JARVIS_PORT`)
## ⚙️ Configuration
| Variable | Valeur | Description |
|----------|--------|-------------|
| `JARVIS_PORT` | `7081` | Port d'écoute |
| `HERMES_API_KEY` | *(Infisical)* | Clé API Hermes |
| `HERMES_URL` | `http://localhost:8642/v1/chat/completions` | Endpoint API |
## 🔑 Secret
La clé API Hermes est stockée dans **Infisical** (`/hermes-claw/API_SERVER_KEY`).
Le launcher (`launcher.py`) la récupère automatiquement au démarrage.
## 🛠️ Dépendances
```
websockets==16.0
httpx
```
Installées dans `venv/`.
## 📊 Roadmap
| Phase | Contenu | Statut |
|-------|---------|--------|
| **Phase 1** | Chat texte WebSocket + Hermes | ✅ Stable |
| **Phase 2** | Audio (STT Parakeet + TTS Edge/Kokoro) | 🔜 Planifié |
| **Phase 3** | Intégration Pipecat WebRTC | 🔜 Planifié |
| **Phase 4** | Multi-agent, barge-in, wake word | 📋 Backlog |
## 🔧 Maintenance
```bash
# Voir les logs
journalctl --user -u jarvis-v3 -n 50
# Redémarrer après modification
rm -rf ~/workspace/jarvis-pipecat/__pycache__/
systemctl --user restart jarvis-v3
# Arrêter
systemctl --user stop jarvis-v3
```
## 📝 Notes techniques
- **websockets 16.0** : `respond()` ne prend que `(status, text)`. Pour les headers custom,
retourner un objet `Response` manuellement (`from websockets.http11 import Response, Headers`).
- **Content-Type** : doit être `text/html; charset=utf-8` pour que le navigateur rende l'interface.
- **Pipecat 1.4.0** : API massivement refactorée, `LLMMessagesFrame` supprimée, `LLMContext`
n'est plus un processeur. Abandonné pour la Phase 1, à réévaluer en Phase 3.
## 📄 Licence
Projet personnel — Bruno Charest, 2026.
+9 -6
View File
@@ -84,14 +84,17 @@ button:disabled{opacity:.5;cursor:default}
<script> <script>
const s=document.getElementById('status'),c=document.getElementById('chat'), const s=document.getElementById('status'),c=document.getElementById('chat'),
i=document.getElementById('inp'),b=document.getElementById('btn'); i=document.getElementById('inp'),b=document.getElementById('btn');
let ws=null; let ws=null,recon=0,reconMax=20,reconDelay=2000;
function m(t,r){const d=document.createElement('div');d.className='msg '+r;d.textContent=t;c.appendChild(d);c.scrollTop=c.scrollHeight} function m(t,r){const d=document.createElement('div');d.className='msg '+r;d.textContent=t;c.appendChild(d);c.scrollTop=c.scrollHeight}
function connect(){ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/ws'); function connect(){ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/ws');
ws.onopen=()=>{s.textContent='🟢 Connecté — JARVIS v3 prêt';s.className='on';b.disabled=false;i.focus()}; ws.onopen=()=>{recon=0;reconDelay=2000;s.textContent='🟢 Connecté — JARVIS v3 prêt';s.className='on';b.disabled=false;i.focus()};
ws.onclose=()=>{s.textContent='🔴 Déconnecté';s.className='off';b.disabled=true;setTimeout(connect,2000)}; ws.onclose=()=>{b.disabled=true;if(recon<reconMax){recon++;let d=Math.min(reconDelay*Math.pow(1.5,recon-1),30000);
ws.onmessage=e=>{try{let d=JSON.parse(e.data);if(d.type==='welcome')m(d.content,'sys'); s.textContent='🟡 Reconnexion '+recon+'/'+reconMax+' dans '+Math.round(d/1000)+'s...';s.className='off';setTimeout(connect,d)}
else{s.textContent='🔴 Connexion perdue — recharge la page';s.className='off'}};
function send(){let t=i.value.trim();if(!t||!ws||ws.readyState!==WebSocket.OPEN)return;m(t,'user');ws.send(JSON.stringify({text:t}));i.value='';b.disabled=true;s.textContent='🤔 JARVIS réfléchit...';let tid=setTimeout(()=>{if(b.disabled){b.disabled=false;s.textContent='⚠️ JARVIS prend du temps... tu peux réessayer'}},25000);ws._tid=tid}
ws.onmessage=e=>{try{let d=JSON.parse(e.data);if(ws._tid){clearTimeout(ws._tid);ws._tid=null};if(d.type==='welcome')m(d.content,'sys');
else if(d.type==='response')m(d.content,'jarvis');else if(d.type==='thinking')s.textContent='🤔 JARVIS réfléchit...'; else if(d.type==='response')m(d.content,'jarvis');else if(d.type==='thinking')s.textContent='🤔 JARVIS réfléchit...';
else if(d.type==='error')m('❌ '+d.content,'sys')}catch(x){m(e.data,'jarvis')};s.textContent='🟢 Connecté — JARVIS v3 prêt';b.disabled=false}} else if(d.type==='error')m('❌ '+d.content,'sys')}catch(x){m(e.data,'jarvis')};s.textContent='🟢 Connecté — JARVIS v3 prêt';b.disabled=false}
function send(){let t=i.value.trim();if(!t||!ws||ws.readyState!==WebSocket.OPEN)return;m(t,'user');ws.send(JSON.stringify({text:t}));i.value='';b.disabled=true;s.textContent='🤔 JARVIS réfléchit...'} function send(){let t=i.value.trim();if(!t||!ws||ws.readyState!==WebSocket.OPEN)return;m(t,'user');ws.send(JSON.stringify({text:t}));i.value='';b.disabled=true;s.textContent='🤔 JARVIS réfléchit...'}
b.onclick=send;i.onkeydown=e=>{if(e.key==='Enter')send()};connect() b.onclick=send;i.onkeydown=e=>{if(e.key==='Enter')send()};connect()
</script> </script>
@@ -116,7 +119,7 @@ async def process_request(connection, request):
async def ws_handler(websocket): async def ws_handler(websocket):
"""Handle WebSocket connection: relay messages to Hermes API.""" """Handle WebSocket connection: relay messages to Hermes API."""
messages = [{"role": "system", "content": SYSTEM_PROMPT}] messages = [{"role": "system", "content": SYSTEM_PROMPT}]
client = httpx.AsyncClient(timeout=60.0) client = httpx.AsyncClient(timeout=45.0)
try: try:
# Send welcome # Send welcome
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Launcher systemd pour JARVIS v3 — récupère la clé Hermes puis démarre le bot."""
import os
import json
import subprocess
import sys
def get_hermes_key():
"""Récupère la clé API Hermes depuis Infisical."""
key = os.environ.get("HERMES_API_KEY", "") or os.environ.get("HERMES_ALEXA_KEY", "")
if key:
return key
try:
creds = json.load(open("/opt/infisical/admin-credentials.json"))
r = subprocess.run(
["/home/openclaw/.local/lib/node_modules/@infisical/cli/bin/infisical",
"secrets", "get", "API_SERVER_KEY",
"--domain", "https://secret.dracodev.net",
"--projectId", "192c1eed-6666-41ee-80df-a4636a2da0ad",
"--env", "prod", "--path", "/hermes-claw", "--plain",
"--token", creds["token"]],
capture_output=True, text=True, timeout=15)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
except Exception as e:
print(f"[launcher] Infisical: {e}", file=sys.stderr)
# Fallback: lire depuis workspace/.env
try:
with open(os.path.expanduser("~/workspace/.env")) as f:
for line in f:
if line.startswith("HERMES_ALEXA_KEY="):
return line.split("=", 1)[1].strip()
if line.startswith("API_SERVER_KEY="):
return line.split("=", 1)[1].strip()
except Exception:
pass
return ""
if __name__ == "__main__":
key = get_hermes_key()
if not key:
print("[launcher] ERREUR: Impossible de récupérer la clé Hermes", file=sys.stderr)
sys.exit(1)
os.environ["HERMES_API_KEY"] = key
os.environ["JARVIS_PORT"] = os.environ.get("JARVIS_PORT", "7081")
# Lance bot_direct.py
bot_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bot_direct.py")
python = os.path.join(os.path.dirname(os.path.abspath(__file__)), "venv", "bin", "python")
os.execv(python, [python, bot_path])