Le guillemet simple français cassait la string JavaScript. Corrigé avec des guillemets doubles pour la chaîne contenant l'apostrophe.
280 lines
13 KiB
Python
280 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
JARVIS v3 Phase 1 — Relay WebSocket ↔ Hermes API
|
|
==================================================
|
|
PoC minimal : WebSocket texte → API Hermes → réponse texte.
|
|
Pas de Pipecat — intégration directe pour valider le concept.
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import httpx
|
|
from websockets.asyncio.server import serve
|
|
from websockets.http11 import Response, Headers
|
|
|
|
PORT = int(os.environ.get("JARVIS_PORT", "7081"))
|
|
|
|
# ─── Clé API ─────────────────────────────────────────────────
|
|
def get_hermes_key():
|
|
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/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)
|
|
return r.stdout.strip()
|
|
except Exception as e:
|
|
print(f"⚠️ Infisical: {e}"); return ""
|
|
|
|
HERMES_KEY = get_hermes_key()
|
|
HERMES_URL = "http://localhost:8642/v1/chat/completions"
|
|
|
|
SYSTEM_PROMPT = (
|
|
"Tu es JARVIS, l'assistant vocal de Bruno. "
|
|
"Parle en français, sois concis (3 phrases max), chaleureux et utile. "
|
|
"Pas de markdown. Réponds comme si tu parlais à l'oral."
|
|
)
|
|
|
|
# ─── TTS (Text-to-Speech) ─────────────────────────────────────
|
|
EDGE_TTS_VOICE = "fr-FR-DeniseNeural" # Voix féminine française naturelle
|
|
EDGE_TTS_BIN = "/home/openclaw/workspace/jarvis-pipecat/venv/bin/edge-tts"
|
|
|
|
async def generate_tts(text: str) -> bytes:
|
|
"""Génère un MP3 à partir du texte avec edge-tts. Retourne les bytes audio."""
|
|
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
|
mp3_path = f.name
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
EDGE_TTS_BIN, "--voice", EDGE_TTS_VOICE,
|
|
"--text", text, "--write-media", mp3_path,
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await asyncio.wait_for(proc.wait(), timeout=15)
|
|
with open(mp3_path, "rb") as f:
|
|
return f.read()
|
|
except Exception as e:
|
|
print(f"⚠️ TTS error: {e}")
|
|
return b""
|
|
finally:
|
|
try:
|
|
os.unlink(mp3_path)
|
|
except:
|
|
pass
|
|
|
|
# ─── Page HTML ───────────────────────────────────────────────
|
|
HTML_PAGE = """<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>JARVIS v3 — Phase 1 PoC</title>
|
|
<style>
|
|
:root{--bg:#010314;--surface:#060e24;--text:#cdd6f4;--cyan:#06d6f0;--blue:#3b82f6;--green:#10b981;--red:#ef4444;--border:#121e3d}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);
|
|
height:100vh;display:flex;flex-direction:column;align-items:center;padding:16px}
|
|
h1{color:var(--cyan);font-size:1.2em;margin-bottom:6px}
|
|
.status{font-size:.8em;padding:4px 12px;border-radius:6px;margin-bottom:8px}
|
|
.on{background:#10b98122;color:var(--green);border:1px solid var(--green)}
|
|
.off{background:#ef444422;color:var(--red);border:1px solid var(--red)}
|
|
#chat{width:100%;max-width:600px;flex:1;overflow-y:auto;
|
|
background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px;margin-bottom:10px}
|
|
.msg{margin:6px 0;padding:8px 12px;border-radius:8px;max-width:85%;font-size:.95em}
|
|
.user{background:var(--blue);margin-left:auto;text-align:right}
|
|
.jarvis{background:#1a1a3e;margin-right:auto;border-left:2px solid var(--cyan)}
|
|
.sys{text-align:center;color:#4a5a7f;font-style:italic;font-size:.8em}
|
|
.row{display:flex;width:100%;max-width:600px;gap:8px}
|
|
input{flex:1;padding:10px 12px;border-radius:8px;border:1px solid var(--border);
|
|
background:var(--surface);color:var(--text);font-size:1em}
|
|
button{padding:10px 20px;border-radius:8px;border:none;background:var(--cyan);
|
|
color:var(--bg);font-weight:bold;cursor:pointer;font-size:.95em}
|
|
button:hover{opacity:.9}
|
|
button:disabled{opacity:.5;cursor:default}
|
|
#mic{width:40px;height:40px;font-size:1.2em;flex-shrink:0;padding:0;display:flex;align-items:center;justify-content:center;border-radius:50%}
|
|
#mic.listening{background:var(--red);animation:pulse 1s infinite}
|
|
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 var(--red)}50%{box-shadow:0 0 0 8px transparent}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>⚡ JARVIS v3 — Phase 1 (Hermes direct)</h1>
|
|
<div id="status" class="off">🔴 Déconnecté</div>
|
|
<div id="chat"></div>
|
|
<div class="row">
|
|
<input id="inp" placeholder="Pose ta question ou parle...">
|
|
<button id="mic" title="Parler">🎤</button>
|
|
<button id="btn">Envoyer</button>
|
|
</div>
|
|
<script>
|
|
const s=document.getElementById('status'),c=document.getElementById('chat'),
|
|
i=document.getElementById('inp'),b=document.getElementById('btn'),
|
|
mic=document.getElementById('mic');
|
|
let ws=null,recon=0,reconMax=20,reconDelay=2000,audioCtx=null;
|
|
|
|
function addMsg(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');
|
|
ws.onopen=()=>{recon=0;reconDelay=2000;s.textContent='🟢 Connecté — JARVIS v3 prêt';s.className='on';b.disabled=false;i.focus()};
|
|
ws.onclose=()=>{b.disabled=true;if(recon<reconMax){recon++;let d=Math.min(reconDelay*Math.pow(1.5,recon-1),30000);
|
|
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'}};
|
|
ws.onerror=()=>ws.close();
|
|
ws.onmessage=e=>{try{let d=JSON.parse(e.data);if(ws._tid){clearTimeout(ws._tid);ws._tid=null}
|
|
if(d.type==='welcome')addMsg(d.content,'sys');
|
|
else if(d.type==='response'){addMsg(d.content,'jarvis');if(d.audio)playAudio(d.audio)}
|
|
else if(d.type==='thinking')s.textContent='🤔 JARVIS réfléchit...';
|
|
else if(d.type==='error')addMsg('❌ '+d.content,'sys')}catch(x){addMsg(e.data,'jarvis')};
|
|
s.textContent='🟢 Connecté — JARVIS v3 prêt';b.disabled=false;mic.disabled=false}}
|
|
|
|
function send(){let t=i.value.trim();if(!t||!ws||ws.readyState!==WebSocket.OPEN)return;addMsg(t,'user');ws.send(JSON.stringify({text:t}));i.value='';b.disabled=true;mic.disabled=true;s.textContent='🤔 JARVIS réfléchit...';let tid=setTimeout(()=>{if(b.disabled){b.disabled=false;mic.disabled=false;s.textContent='⚠️ JARVIS prend du temps... tu peux réessayer'}},25000);ws._tid=tid}
|
|
|
|
// ── Audio playback ──
|
|
function playAudio(b64){try{const bin=atob(b64);const bytes=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)bytes[i]=bin.charCodeAt(i);const blob=new Blob([bytes],{type:'audio/mp3'});const url=URL.createObjectURL(blob);const a=new Audio(url);a.onended=()=>URL.revokeObjectURL(url);a.play().catch(e=>console.log('Audio play:',e))}catch(e){console.log('Audio error:',e)}}
|
|
|
|
// ── Speech Recognition ──
|
|
const SpeechRecognition=window.SpeechRecognition||window.webkitSpeechRecognition;
|
|
let rec=null,listening=false;
|
|
if(SpeechRecognition){
|
|
rec=new SpeechRecognition();rec.lang='fr-FR';rec.interimResults=false;rec.continuous=false;
|
|
rec.onresult=e=>{const t=e.results[0][0].transcript.trim();if(t){i.value=t;send()}};
|
|
rec.onerror=e=>{console.log('STT error:',e.error);stopListening()};
|
|
rec.onend=()=>stopListening();
|
|
}
|
|
function toggleMic(){
|
|
if(!rec){alert('Reconnaissance vocale non supportée sur ce navigateur');return}
|
|
if(listening){rec.stop();return}
|
|
listening=true;mic.classList.add('listening');mic.textContent='🔴';s.textContent="🎤 Je t'écoute...";
|
|
try{rec.start()}catch(e){stopListening()}
|
|
}
|
|
function stopListening(){listening=false;mic.classList.remove('listening');mic.textContent='🎤';if(s.textContent.includes('écoute'))s.textContent='🟢 Connecté — JARVIS v3 prêt'}
|
|
mic.onclick=toggleMic;
|
|
b.onclick=send;i.onkeydown=e=>{if(e.key==='Enter')send()};connect()
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
# ─── HTTP Handler ────────────────────────────────────────────
|
|
async def http_handler(websocket):
|
|
"""Serve the test page over raw HTTP (before upgrade)."""
|
|
# This is handled by serve()'s process_request
|
|
pass
|
|
|
|
async def process_request(connection, request):
|
|
"""Serve HTML for HTTP requests, upgrade for WebSocket."""
|
|
if request.path == "/" or request.path == "/test.html":
|
|
headers = Headers([
|
|
("Content-Type", "text/html; charset=utf-8"),
|
|
("Cache-Control", "no-store, no-cache, must-revalidate"),
|
|
])
|
|
return Response(200, "OK", headers, HTML_PAGE.encode())
|
|
if request.path == "/ws":
|
|
return None # Let WebSocket handler take over
|
|
# Return 404 for everything else (favicon, etc.)
|
|
return Response(404, "Not Found", Headers([]), b"")
|
|
|
|
# ─── WebSocket Handler ───────────────────────────────────────
|
|
async def ws_handler(websocket):
|
|
"""Handle WebSocket connection: relay messages to Hermes API."""
|
|
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
client = httpx.AsyncClient(timeout=45.0)
|
|
|
|
try:
|
|
# Send welcome
|
|
await websocket.send(json.dumps({
|
|
"type": "welcome",
|
|
"content": "👋 Bonjour Bruno ! Je suis JARVIS v3 (Phase 1). Pose-moi une question !"
|
|
}))
|
|
print("✅ Client connecté")
|
|
|
|
async for raw in websocket:
|
|
try:
|
|
data = json.loads(raw)
|
|
text = data.get("text", "").strip()
|
|
except:
|
|
text = raw.strip()
|
|
|
|
if not text:
|
|
continue
|
|
|
|
print(f"👤 {text[:80]}")
|
|
messages.append({"role": "user", "content": text})
|
|
# Keep last 20 messages + system prompt
|
|
if len(messages) > 21:
|
|
messages = [messages[0]] + messages[-20:]
|
|
|
|
# Send thinking indicator
|
|
await websocket.send(json.dumps({"type": "thinking"}))
|
|
|
|
# Call Hermes API
|
|
try:
|
|
resp = await client.post(
|
|
HERMES_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {HERMES_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": "Hermes Agent",
|
|
"messages": messages,
|
|
"temperature": 0.8,
|
|
"max_tokens": 300,
|
|
"stream": False,
|
|
},
|
|
)
|
|
resp.raise_for_status()
|
|
body = resp.json()
|
|
reply = body["choices"][0]["message"]["content"]
|
|
|
|
messages.append({"role": "assistant", "content": reply})
|
|
print(f"🤖 {reply[:80]}")
|
|
|
|
# Générer TTS en parallèle de l'envoi texte
|
|
audio_bytes = await generate_tts(reply)
|
|
audio_b64 = base64.b64encode(audio_bytes).decode() if audio_bytes else ""
|
|
|
|
await websocket.send(json.dumps({
|
|
"type": "response",
|
|
"content": reply,
|
|
"audio": audio_b64,
|
|
}))
|
|
|
|
except Exception as e:
|
|
print(f"❌ API error: {e}")
|
|
await websocket.send(json.dumps({
|
|
"type": "error",
|
|
"content": f"Erreur API: {str(e)[:200]}",
|
|
}))
|
|
|
|
except Exception as e:
|
|
print(f"👋 Déconnecté: {e}")
|
|
finally:
|
|
await client.aclose()
|
|
|
|
|
|
# ─── Main ────────────────────────────────────────────────────
|
|
async def main():
|
|
if not HERMES_KEY:
|
|
print("❌ Pas de clé API Hermes"); sys.exit(1)
|
|
|
|
print(f"🔑 {HERMES_KEY[:15]}...")
|
|
print(f"🌐 {HERMES_URL}")
|
|
print(f"🚀 JARVIS v3 PoC → http://0.0.0.0:{PORT}")
|
|
print(f" WebSocket → ws://0.0.0.0:{PORT}/ws")
|
|
|
|
async with serve(ws_handler, "0.0.0.0", PORT, process_request=process_request):
|
|
print(f"✅ Serveur prêt sur le port {PORT}")
|
|
await asyncio.get_running_loop().create_future() # run forever
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|