feat: JARVIS v3 Phase 1 — WebSocket relay vers Hermes A.L.E.X.A.
- Serveur WebSocket direct avec websockets 16.0 - Interface HTML/CSS/JS dark theme avec chat - Relay vers API Hermes sur port 8642 - Historique de conversation (20 messages) - Clé API via Infisical - Testé et fonctionnel sur openclaw1.dev.home:7081
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JARVIS v3 PoC — Pipecat + Hermes API
|
||||
=====================================
|
||||
Bot WebSocket texte : transport → LLM → transport
|
||||
Sans agrégateur — notre processeur custom gère tout.
|
||||
"""
|
||||
import json, os, subprocess, sys
|
||||
from fastapi import FastAPI, WebSocket
|
||||
from fastapi.responses import HTMLResponse
|
||||
import uvicorn
|
||||
|
||||
import pipecat.frames.frames as pframes
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.runner import PipelineRunner
|
||||
from pipecat.pipeline.task import PipelineTask
|
||||
from pipecat.processors.frame_processor import FrameProcessor, FrameDirection
|
||||
from pipecat.services.openai.llm import OpenAILLMService
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
PORT = int(os.environ.get("JARVIS_PIPECAT_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"
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
# ─── Processeur custom ultra-simple ──────────────────────────
|
||||
class SimpleLLMRelay(FrameProcessor):
|
||||
"""
|
||||
Capture les messages texte du transport et les envoie au LLM via
|
||||
LLMContextFrame. Les réponses du LLM passent directement au transport.
|
||||
"""
|
||||
def __init__(self, system_prompt: str):
|
||||
super().__init__()
|
||||
self._messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
async def process_frame(self, frame, direction):
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, pframes.InputTransportMessageFrame):
|
||||
text = frame.message
|
||||
if isinstance(text, str) and text.strip():
|
||||
self._messages.append({"role": "user", "content": text})
|
||||
if len(self._messages) > 21:
|
||||
self._messages = [self._messages[0]] + self._messages[-20:]
|
||||
print(f"👤 {text[:80]}")
|
||||
ctx = pframes.LLMContextFrame(context=list(self._messages))
|
||||
await self.push_frame(ctx)
|
||||
|
||||
|
||||
# ─── Page HTML ───────────────────────────────────────────────
|
||||
HTML_PAGE = open(os.path.join(os.path.dirname(__file__), "test.html"), "r").read() if os.path.exists("test.html") else """<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>JARVIS v3</title>
|
||||
<style>
|
||||
:root{--bg:#010314;--surface:#060e24;--text:#cdd6f4;--cyan:#06d6f0;--blue:#3b82f6}
|
||||
*{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:20px}
|
||||
h1{color:var(--cyan);margin-bottom:10px;font-size:1.3em}
|
||||
#status{padding:6px 14px;border-radius:8px;margin:8px 0;font-size:.85em}
|
||||
.on{background:#10b98122;color:#10b981;border:1px solid #10b981}
|
||||
.off{background:#ef444422;color:#ef4444;border:1px solid #ef4444}
|
||||
#chat{width:100%;max-width:600px;flex:1;overflow-y:auto;background:var(--surface);border:1px solid #121e3d;border-radius:12px;padding:16px;margin:10px 0}
|
||||
.msg{margin:8px 0;padding:8px 12px;border-radius:8px;max-width:85%}
|
||||
.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:.85em}
|
||||
.row{display:flex;width:100%;max-width:600px;gap:8px}
|
||||
input{flex:1;padding:12px;border-radius:8px;border:1px solid #121e3d;background:var(--surface);color:var(--text);font-size:1em}
|
||||
button{padding:12px 24px;border-radius:8px;border:none;background:var(--cyan);color:var(--bg);font-weight:bold;cursor:pointer}
|
||||
</style></head>
|
||||
<body><h1>⚡ JARVIS v3 — Pipecat PoC</h1>
|
||||
<div id="status" class="off">🔴 Déconnecté</div><div id="chat"></div>
|
||||
<div class="row"><input id="inp" placeholder="Pose ta question..." autofocus><button id="btn">Envoyer</button></div>
|
||||
<script>
|
||||
const s=document.getElementById('status'),c=document.getElementById('chat'),i=document.getElementById('inp'),b=document.getElementById('btn');
|
||||
let ws=null;
|
||||
function m(t,r){const d=document.createElement('div');d.className='msg '+r;d.textContent=t;c.appendChild(d);c.scrollTop=c.scrollHeight}
|
||||
function co(){ws=new WebSocket((location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/ws');
|
||||
ws.onopen=()=>{s.textContent='🟢 Connecté';s.className='on'};
|
||||
ws.onclose=()=>{s.textContent='🔴 Déconnecté';s.className='off';setTimeout(co,2000)};
|
||||
ws.onmessage=e=>{try{let d=JSON.parse(e.data);m(d.content||d.message||e.data,d.content?'jarvis':'sys')}catch(x){m(e.data,'jarvis')}}}
|
||||
function se(){let t=i.value.trim();if(!t||!ws||ws.readyState!==WebSocket.OPEN)return;m(t,'user');ws.send(t);i.value=''}
|
||||
b.onclick=se;i.onkeydown=e=>{if(e.key==='Enter')se()};co()
|
||||
</script></body></html>"""
|
||||
|
||||
# ─── FastAPI ─────────────────────────────────────────────────
|
||||
app = FastAPI(title="JARVIS v3 PoC", version="0.1.0")
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return HTMLResponse(content=HTML_PAGE)
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def ws_endpoint(websocket: WebSocket):
|
||||
transport = FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
add_wav_header=False,
|
||||
session_timeout=None,
|
||||
# allowed_origins vide = autorise toutes les origines
|
||||
),
|
||||
)
|
||||
|
||||
relay = SimpleLLMRelay(SYSTEM_PROMPT)
|
||||
llm = OpenAILLMService(
|
||||
api_key=_hermes_key,
|
||||
base_url=HERMES_URL,
|
||||
settings=OpenAILLMService.Settings(
|
||||
model="Hermes Agent", temperature=0.8, max_tokens=300,
|
||||
),
|
||||
)
|
||||
|
||||
pipeline = Pipeline(processors=[transport.input(), relay, llm, transport.output()])
|
||||
task = PipelineTask(pipeline)
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_connect(transport, client):
|
||||
print(f"✅ Connecté")
|
||||
await transport.output().send_message(
|
||||
pframes.OutputTransportMessageFrame(
|
||||
message="👋 Bonjour Bruno ! Je suis JARVIS v3. Pose-moi une question !"
|
||||
)
|
||||
)
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_disconnect(transport, client):
|
||||
print(f"👋 Déconnecté")
|
||||
await task.cancel()
|
||||
|
||||
runner = PipelineRunner()
|
||||
await runner.add_workers(task)
|
||||
try:
|
||||
await runner.run()
|
||||
except Exception as e:
|
||||
print(f"Pipeline error: {e}")
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────
|
||||
if __name__ == "__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"🚀 http://0.0.0.0:{PORT}")
|
||||
print(f" ws://0.0.0.0:{PORT}/ws")
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")
|
||||
Reference in New Issue
Block a user