feat(v9): Phase 5 — Hermes-Web-Dashboard eingebettet, Eigenbau-Chat raus
Das Framework bringt eine fertige Web-UI mit (Chat mit Live-Tool-Aktivitaet, Approval-Prompts, Settings, Sessions). Statt sie nachzubauen, betten wir sie ein: - routers/hermes_ui.py: HTTP+WS-Reverse-Proxy auf das lokale Dashboard (:9119) unter /hermes-ui/ mit X-Forwarded-Prefix -> Dashboard rewritet Assets/Base-Path selbst, injiziert seinen Session-Token (kein zweiter Login). WS-Bruecke fuer pty/ws/pub/events. - HermesPanel: Chat -> iframe auf /hermes-ui/; Eigenbau-Chat/Voice/WS entfernt. Cockpit + Setup bleiben. Loest damit Kontext-/Lern-/Tool-Sichtbarkeits-Themen, da die UI direkt mit dem Agent-Loop spricht (kein Proxy-Bug mehr). - routers/hermes.py: Chat-WS + _proxy_chat entfernt (Cutover). - config.py: HERMES_DASHBOARD_URL. requirements: websockets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+8
-87
@@ -1,17 +1,19 @@
|
||||
"""
|
||||
Hermes Router — Chat-WebSocket, Voice-Endpoints, Setup-Status.
|
||||
Hermes Router — Voice-Endpoints, Status & Cockpit-Reads.
|
||||
|
||||
Der Chat selbst ist in das eingebettete Hermes-Web-Dashboard umgezogen
|
||||
(siehe `routers/hermes_ui.py`); der fruehere Eigenbau-Chat-WS/Proxy entfiel.
|
||||
|
||||
Endpunkte:
|
||||
WS /api/hermes/chat — Streaming Chat mit dem Hermes Agent
|
||||
POST /api/hermes/transcribe — Audio → Text (Whisper)
|
||||
POST /api/hermes/tts — Text → Audio WAV (Piper)
|
||||
GET /api/hermes/status — Komponentenstatus (Whisper, Piper, SSH)
|
||||
GET /api/hermes/status — Komponentenstatus (Hermes, Whisper, Piper, SSH)
|
||||
GET /api/hermes/{agent,cron,skills} — Cockpit-Reads (Phase 4)
|
||||
GET /api/hermes/pubkey — Bosgame SSH-Public-Key fuer Windows-Setup
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -19,14 +21,14 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, WebSocket, WebSocketDisconnect
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from fastapi.responses import Response, JSONResponse
|
||||
|
||||
from auth import auth
|
||||
from config import (
|
||||
PIPER_BIN, PIPER_VOICE, WHISPER_MODEL_SIZE,
|
||||
HERMES_WINDOWS_HOST, HERMES_WINDOWS_USER, HERMES_SSH_KEY,
|
||||
HERMES_API_URL, HERMES_API_KEY,
|
||||
HERMES_API_URL,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
@@ -99,87 +101,6 @@ def _tts(text: str) -> Optional[bytes]:
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.websocket("/hermes/chat")
|
||||
async def hermes_chat(websocket: WebSocket):
|
||||
"""Streaming-Chat: proxyt zum Hermes-Agent-Server (:8642, OpenAI-kompatibel).
|
||||
|
||||
Behaelt den WS-Contract des Frontends bei (thinking/token/done/error), damit
|
||||
die HermesPanel unveraendert bleibt. Modell-Wahl, Tools, Gedaechtnis und
|
||||
Mehr-Schritt-Logik macht der Hermes-Agent selbst.
|
||||
"""
|
||||
# Manuelle Token-Auth (WS kann keine HTTP-Header senden)
|
||||
from auth import TOKEN
|
||||
token_param = websocket.query_params.get("token", "")
|
||||
if TOKEN and token_param != TOKEN:
|
||||
await websocket.close(code=4001)
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
message = payload.get("message", "").strip()
|
||||
except Exception:
|
||||
message = raw.strip()
|
||||
|
||||
if not message:
|
||||
continue
|
||||
|
||||
await websocket.send_json({"type": "thinking"})
|
||||
try:
|
||||
await _proxy_chat(message, websocket)
|
||||
except Exception as exc:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "content": f"Hermes nicht erreichbar: {exc}"}
|
||||
)
|
||||
await websocket.send_json({"type": "done"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _proxy_chat(message: str, websocket: WebSocket) -> None:
|
||||
"""Eine Antwort vom Hermes-Agent-Server holen und Tokens ans WS relayen.
|
||||
|
||||
WICHTIG: bewusst **non-streaming**. Im Streaming-Modus (`stream:true`) gibt
|
||||
der Hermes-API-Server rohe Modell-Tokens aus und fuehrt Tool-Calls NICHT aus
|
||||
(`<function=…></tool_call>` leakt in den Text). Non-streaming durchlaeuft den
|
||||
vollen Agent-Loop (Tools, Gedaechtnis, Mehr-Schritt) und liefert die fertige
|
||||
Antwort. Fuer fluessige Optik streamen wir sie wortweise selbst ans Frontend.
|
||||
"""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if HERMES_API_KEY:
|
||||
headers["Authorization"] = f"Bearer {HERMES_API_KEY}"
|
||||
body = {
|
||||
"model": "hermes-agent",
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
}
|
||||
timeout = httpx.Timeout(300.0, connect=10.0)
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
r = await client.post(
|
||||
f"{HERMES_API_URL}/chat/completions", headers=headers, json=body
|
||||
)
|
||||
if r.status_code != 200:
|
||||
await websocket.send_json(
|
||||
{"type": "error", "content": f"HTTP {r.status_code}: {r.text[:300]}"}
|
||||
)
|
||||
return
|
||||
try:
|
||||
content = r.json()["choices"][0]["message"].get("content", "") or ""
|
||||
except Exception:
|
||||
content = r.text
|
||||
|
||||
words = content.split(" ")
|
||||
for i, word in enumerate(words):
|
||||
token = word + (" " if i < len(words) - 1 else "")
|
||||
await websocket.send_json({"type": "token", "content": token})
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
|
||||
@router.post("/hermes/transcribe", dependencies=[Depends(auth)])
|
||||
async def transcribe_audio(audio: UploadFile = File(...)):
|
||||
"""Audio-Datei (webm/ogg/wav) per Whisper transkribieren."""
|
||||
|
||||
Reference in New Issue
Block a user