- 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
173 lines
8.0 KiB
Python
173 lines
8.0 KiB
Python
#!/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")
|