#!/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 = """
JARVIS v3 — Phase 1 PoC
⚡ JARVIS v3 — Phase 1 (Hermes direct)
🔴 Déconnecté
"""
# ─── 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())