41e569cf99
- Neuer Router routers/events.py mit EventSource-Endpunkt\n- Heartbeat alle 25s, invalidate-Event bei Status-Änderung\n- Keys: system-status, models, agent-status, ideen, token-stats
163 lines
4.7 KiB
Python
163 lines
4.7 KiB
Python
"""SSE-Eventstrom /api/events — Server-Sent Events für Frontend-Updates.
|
|
|
|
EIN EventSource-Endpunkt, der Changes der heute gepollten Zustände pusht:
|
|
- System-Status (SystemStatus)
|
|
- Modelle/running (ModelsResp)
|
|
- Agent-Status (AgentStatus)
|
|
- Auftragsbuch/Ideen-Zaehler (IdeenResp)
|
|
- Token-Stats (TokenStats)
|
|
|
|
Event-Schema:
|
|
type: "invalidate"
|
|
data: {"keys": ["system-status", "models", "agent-status", "ideen", "token-stats"]}
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
from typing import Any, AsyncGenerator
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
from config import HERMES_API_URL, LLAMA_SWAP_URL, GATEWAY_URL
|
|
import httpx
|
|
|
|
from services import agent, llamaswap
|
|
from services.system import system_status
|
|
from services.token_stats import get_stats
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api")
|
|
|
|
# Aktueller Snapshot für Diff-Prüfung (kein Lock nötig: Python GIL, single writer)
|
|
_last_snapshot: dict[str, dict[str, Any]] = {
|
|
"system-status": {},
|
|
"models": {},
|
|
"agent-status": {},
|
|
"ideen": {},
|
|
"token-stats": {},
|
|
}
|
|
|
|
# Heartbeat-Intervall (Sekunden) — EventSource reconnectet nach ~3s ohne Daten
|
|
HEARTBEAT_INTERVAL = 25
|
|
|
|
|
|
async def _get_system_status() -> dict:
|
|
try:
|
|
return system_status()
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
async def _get_models() -> dict:
|
|
try:
|
|
items = llamaswap.list_models()
|
|
return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
|
|
except Exception:
|
|
return {"models": [], "count": 0, "running": []}
|
|
|
|
|
|
async def _get_agent_status() -> dict:
|
|
try:
|
|
return agent.agent_status()
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
async def _get_ideen_count() -> dict:
|
|
"""Ideen-Zaehler (nur count, nicht volle Liste — zu teuer)."""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
r = await client.get(f"{GATEWAY_URL}/v1/ideen", headers={"Authorization": "Bearer placeholder"})
|
|
if r.status_code == 200:
|
|
data = r.json()
|
|
return {"count": len(data.get("items", []))}
|
|
except Exception:
|
|
pass
|
|
return {"count": 0}
|
|
|
|
|
|
async def _get_token_stats() -> dict:
|
|
try:
|
|
return get_stats()
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _snapshot_diff(key: str, new: dict) -> bool:
|
|
"""Gibt True zurück, wenn sich das Snapshot geändert hat."""
|
|
old = _last_snapshot.get(key, {})
|
|
changed = old != new
|
|
if changed:
|
|
_last_snapshot[key] = new
|
|
return changed
|
|
|
|
|
|
async def event_stream() -> AsyncGenerator[str, None]:
|
|
"""Generiert SSE-Events."""
|
|
# Initialer Heartbeat sofort
|
|
yield f": heartbeat\n\n"
|
|
|
|
while True:
|
|
try:
|
|
# Alle Status abrufen
|
|
tasks = {
|
|
"system-status": asyncio.create_task(_get_system_status()),
|
|
"models": asyncio.create_task(_get_models()),
|
|
"agent-status": asyncio.create_task(_get_agent_status()),
|
|
"ideen": asyncio.create_task(_get_ideen_count()),
|
|
"token-stats": asyncio.create_task(_get_token_stats()),
|
|
}
|
|
|
|
# Warten auf alle (mit Timeout)
|
|
await asyncio.wait(tasks.values(), timeout=10)
|
|
|
|
# Prüfen, ob sich etwas geändert hat
|
|
keys_to_invalidate: list[str] = []
|
|
|
|
for key, task in tasks.items():
|
|
try:
|
|
new = task.result()
|
|
if _snapshot_diff(key, new):
|
|
keys_to_invalidate.append(key)
|
|
except Exception as exc:
|
|
log.warning(f"Event {key} failed: {exc}")
|
|
|
|
if keys_to_invalidate:
|
|
event = json.dumps({"keys": keys_to_invalidate})
|
|
yield f"type: invalidate\n"
|
|
yield f"data: {event}\n\n"
|
|
log.info(f"SSE invalidate: {keys_to_invalidate}")
|
|
|
|
except asyncio.CancelledError:
|
|
log.info("SSE stream cancelled")
|
|
break
|
|
except Exception as exc:
|
|
log.error(f"SSE stream error: {exc}")
|
|
break
|
|
|
|
# Heartbeat alle HEARTBEAT_INTERVAL Sekunden
|
|
try:
|
|
for _ in range(HEARTBEAT_INTERVAL * 10): # 100ms Intervall
|
|
await asyncio.sleep(0.1)
|
|
yield f": heartbeat\n\n"
|
|
except asyncio.CancelledError:
|
|
break
|
|
|
|
|
|
@router.get("/events")
|
|
async def events_endpoint(request: Request):
|
|
"""SSE-Endpunkt für EventSource."""
|
|
return StreamingResponse(
|
|
event_stream(),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive",
|
|
"X-Accel-Buffering": "no", # Nginx-Buffering deaktivieren
|
|
},
|
|
)
|