""" Voice-Endpoints für „Mit Hermes reden" (Browser-Voice + 3D-Avatar). Dünner Layer: STT/TTS werden zum Voice-Sidecar (:8650) geproxyt; der Chat geht an den Hermes-`api_server` (:8642, OpenAI-kompatibel) — denselben vollen Agenten mit Tools + geteiltem Mem0 wie CLI/Telegram. Mit stabilem `X-Hermes-Session-Id` hält die Plattform den Transcript server-seitig, daher schickt der Client je Turn nur die neue User-Nachricht. LAN-only (kein Token in der 2.0-Phase), wie die übrigen MC2-Endpoints. """ import logging import os import httpx from fastapi import APIRouter, File, Form, HTTPException, UploadFile from fastapi.responses import Response, StreamingResponse from pydantic import BaseModel from config import HERMES_API_KEY, HERMES_API_MODEL, HERMES_API_URL, LLAMA_SWAP_URL, VOICE_SERVICE_URL log = logging.getLogger(__name__) router = APIRouter(prefix="/api") # Bildschirm-Sicht: das DEDIZIERTE Vision-Modell (Qwen3-VL-8B) beschreibt das Bild; die Beschreibung # geht als TEXT an Hermes -> Lucy behält ihr volles Hirn/Gedächtnis UND nutzt das bessere VL-Modell # (statt der schwächeren Vision der fast-MoE). Per Env abschaltbar/umstellbar. VISION_MODEL = os.environ.get("MC_VISION_MODEL", "vision") async def _describe_image(image_url: str, hint: str) -> str: """Lässt das Vision-Modell den Screenshot knapp beschreiben (Deutsch). Leerer String bei Fehler.""" prompt = ("Beschreibe knapp und präzise auf Deutsch, was auf diesem Screenshot zu sehen ist " "(App/Fenster, wichtige Inhalte, sichtbarer Text/Code). Frage des Nutzers dazu: " + hint) try: async with httpx.AsyncClient(timeout=httpx.Timeout(90.0, connect=5.0)) as client: r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={ "model": VISION_MODEL, "max_tokens": 450, "stream": False, "messages": [{"role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": image_url}}, ]}], }) r.raise_for_status() return (r.json().get("choices") or [{}])[0].get("message", {}).get("content", "").strip() except Exception as exc: log.warning("Vision-Beschreibung fehlgeschlagen: %s", exc) return "" _TIMEOUT = httpx.Timeout(120.0, connect=5.0) # Chatterbox-TTS auf CPU darf dauern class TTSIn(BaseModel): text: str engine: str = "piper" voice: str = "" language: str = "" ref_path: str = "" class ChatIn(BaseModel): text: str # die neue User-Äußerung (STT-Ergebnis) session_id: str # stabiler Voice-Faden → server-seitiger Transcript session_key: str = "" # optional: Langzeit-Memory-Scope system: str = "" # optionaler ephemerer System-Prompt (z.B. „antworte knapp/gesprochen") model: str = "" image: str = "" # optionale Bildschirm-Sicht: Bild als data:-URL (Lucys „Augen") @router.get("/voice/health") def voice_health() -> dict: """Erreichbarkeit des Voice-Sidecars + ob der Hermes-API-Key gesetzt ist.""" out: dict = {"sidecar": False, "hermes_key": bool(HERMES_API_KEY)} try: r = httpx.get(f"{VOICE_SERVICE_URL}/health", timeout=httpx.Timeout(5.0)) out["sidecar"] = r.status_code == 200 out["detail"] = r.json() if r.status_code == 200 else None except Exception as exc: # noqa: BLE001 out["error"] = str(exc) return out @router.get("/voice/voices") def voice_voices() -> dict: try: r = httpx.get(f"{VOICE_SERVICE_URL}/voices", timeout=httpx.Timeout(10.0)) r.raise_for_status() return r.json() except Exception as exc: # noqa: BLE001 raise HTTPException(502, f"Voice-Sidecar nicht erreichbar: {exc}") @router.post("/voice/stt") async def voice_stt(audio: UploadFile = File(...), language: str = Form(default="")) -> dict: """Mikro-Audio → Text (Proxy auf Sidecar /stt).""" data = await audio.read() if not data: raise HTTPException(400, "Leeres Audio.") files = {"audio": (audio.filename or "rec.webm", data, audio.content_type or "audio/webm")} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await client.post(f"{VOICE_SERVICE_URL}/stt", files=files, data={"language": language}) r.raise_for_status() return r.json() except httpx.HTTPError as exc: raise HTTPException(502, f"STT fehlgeschlagen: {exc}") @router.post("/voice/reference") async def voice_set_reference(audio: UploadFile = File(...)) -> dict: """Klon-Referenz (z.B. ElevenLabs-Erzeugnis) hochladen → Chatterbox nutzt sie. Proxy → Sidecar.""" data = await audio.read() if not data: raise HTTPException(400, "Leeres Audio.") files = {"audio": (audio.filename or "ref.wav", data, audio.content_type or "audio/mpeg")} try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await client.post(f"{VOICE_SERVICE_URL}/reference", files=files) r.raise_for_status() return r.json() except httpx.HTTPError as exc: raise HTTPException(502, f"Referenz-Upload fehlgeschlagen: {exc}") @router.get("/voice/reference") def voice_get_reference() -> dict: try: r = httpx.get(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) r.raise_for_status() return r.json() except Exception as exc: # noqa: BLE001 return {"active": False, "error": str(exc)} @router.delete("/voice/reference") def voice_clear_reference() -> dict: try: r = httpx.delete(f"{VOICE_SERVICE_URL}/reference", timeout=httpx.Timeout(8.0)) r.raise_for_status() return r.json() except httpx.HTTPError as exc: raise HTTPException(502, f"Löschen fehlgeschlagen: {exc}") @router.post("/voice/tts") async def voice_tts(body: TTSIn) -> Response: """Text → Sprache (Proxy auf Sidecar /tts), liefert WAV-Bytes.""" try: async with httpx.AsyncClient(timeout=_TIMEOUT) as client: r = await client.post(f"{VOICE_SERVICE_URL}/tts", json=body.model_dump()) r.raise_for_status() return Response(content=r.content, media_type=r.headers.get("content-type", "audio/wav")) except httpx.HTTPError as exc: raise HTTPException(502, f"TTS fehlgeschlagen: {exc}") @router.post("/voice/chat") async def voice_chat(body: ChatIn) -> StreamingResponse: """Neue User-Äußerung → Hermes-Agent (api_server, streamend). SSE wird 1:1 durchgereicht. Mit `X-Hermes-Session-Id` hält die Plattform den Verlauf — wir senden nur die neue Nachricht. Auth per Bearer (API_SERVER_KEY); ohne Key liefert :8642 ein 401.""" if not HERMES_API_KEY: raise HTTPException(503, "HERMES_API_KEY/API_SERVER_KEY nicht gesetzt — Agent-Auth fehlt.") messages = [] if body.system: messages.append({"role": "system", "content": body.system}) user_text = body.text if body.image: # Bildschirm-Sicht: erst das Vision-Modell beschreiben lassen, dann die Beschreibung als TEXT- # Kontext an Hermes (Lucy antwortet mit vollem Hirn/Gedächtnis, sieht aber via besserem VL-Modell). desc = await _describe_image(body.image, body.text) if desc: user_text = f"[Bildschirm-Sicht — das ist gerade auf dem Schirm zu sehen:\n{desc}\n]\n\n{body.text}" messages.append({"role": "user", "content": user_text}) payload = {"model": body.model or HERMES_API_MODEL, "messages": messages, "stream": True} headers = { "Authorization": f"Bearer {HERMES_API_KEY}", "X-Hermes-Session-Id": body.session_id, } if body.session_key: headers["X-Hermes-Session-Key"] = body.session_key async def gen(): try: async with httpx.AsyncClient(timeout=httpx.Timeout(None, connect=5.0)) as client: async with client.stream( "POST", f"{HERMES_API_URL}/v1/chat/completions", json=payload, headers=headers, ) as r: if r.status_code != 200: detail = (await r.aread()).decode("utf-8", "replace")[:500] yield f"data: {{\"error\": \"Hermes {r.status_code}: {detail}\"}}\n\n".encode() return async for chunk in r.aiter_raw(): yield chunk except httpx.HTTPError as exc: yield f"data: {{\"error\": \"Verbindung zu Hermes fehlgeschlagen: {exc}\"}}\n\n".encode() return StreamingResponse(gen(), media_type="text/event-stream")