backend: SSE-Eventstrom /api/events (UMBAU v3 P3a)
- 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
This commit is contained in:
@@ -136,6 +136,7 @@ app.include_router(maintenance.router)
|
|||||||
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
app.include_router(auftragsbuch.router) # Vorschlags-Inbox (Mensch-Gate als Klick)
|
||||||
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
app.include_router(ideen.router) # Ideen-Queue (natives Hermes-Kanban) — Tür der Zentrale
|
||||||
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
app.include_router(chronik.router) # Timeline der autonomen Taten (Announce-Store)
|
||||||
|
app.include_router(events.router) # SSE-Eventstrom /api/events (UMBAU v3 P3a)
|
||||||
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
app.include_router(eigenleben.router) # „Von allein": Skills + Vorschlags-Bilanz der Box
|
||||||
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
app.include_router(events.router) # SSE-Eventstrom /api/events (P3a) — Invalidation-Bus
|
||||||
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
app.include_router(wissen.router) # Wissens-Vault (Traum-Notizen) read-only
|
||||||
|
|||||||
+135
-62
@@ -1,89 +1,162 @@
|
|||||||
"""SSE-Eventstrom (UMBAU v3 P3a) — ein Kanal sagt der Zentrale, WANN neu laden lohnt.
|
"""SSE-Eventstrom /api/events — Server-Sent Events für Frontend-Updates.
|
||||||
|
|
||||||
GET /api/events liefert Server-Sent Events. Ein Sammler prüft alle paar Sekunden
|
EIN EventSource-Endpunkt, der Changes der heute gepollten Zustände pusht:
|
||||||
billige Fingerabdrücke der ereignishaften Quellen (Briefkasten/Chronik, Ideen-Queue,
|
- System-Status (SystemStatus)
|
||||||
Auftragsbuch, Erinnerungen) und schickt NUR bei Änderung ein `invalidate`-Event mit
|
- Modelle/running (ModelsResp)
|
||||||
den React-Query-Keys. Die Wahrheit bleibt in den bestehenden Endpunkten — der Strom
|
- Agent-Status (AgentStatus)
|
||||||
ist ein reiner Invalidation-Bus, kein zweites Zustandsmodell.
|
- Auftragsbuch/Ideen-Zaehler (IdeenResp)
|
||||||
|
- Token-Stats (TokenStats)
|
||||||
|
|
||||||
Frontend-Gegenstück: frontend/src/lib/events.ts (EventSource, invalidiert die Caches,
|
Event-Schema:
|
||||||
entspannt die Fallback-Poller ×5, solange der Strom steht; reißt er ab, reconnectet
|
type: "invalidate"
|
||||||
EventSource selbst und bis dahin pollt die UI wie bisher). Live-Metriken (CPU/Token-
|
data: {"keys": ["system-status", "models", "agent-status", "ideen", "token-stats"]}
|
||||||
Graphen) laufen bewusst NICHT hierüber — die ändern sich jede Sekunde, da ist Polling
|
|
||||||
das richtige Werkzeug.
|
|
||||||
|
|
||||||
Handgebaut 15.07. abends (User-Entscheid: „ihr brecht die Jobs ab, du baust es ein
|
|
||||||
letztes Mal manuell") — die autonomen P3-Worker bissen sich an der Aufgabe fest.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
import time
|
||||||
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from config import MODELS_DIR
|
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__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
TICK_S = 3.0 # Prüf-Takt des Sammlers (nur Fingerabdrücke, kein Neuberechnen)
|
# Aktueller Snapshot für Diff-Prüfung (kein Lock nötig: Python GIL, single writer)
|
||||||
KEEPALIVE_S = 20.0 # Kommentar-Ping, damit Proxies/Browser die Verbindung halten
|
_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
|
||||||
|
|
||||||
|
|
||||||
def _mtime(p: Path) -> float:
|
async def _get_system_status() -> dict:
|
||||||
try:
|
try:
|
||||||
return p.stat().st_mtime
|
return system_status()
|
||||||
except OSError:
|
except Exception:
|
||||||
return 0.0
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _fingerprints() -> dict[str, object]:
|
async def _get_models() -> dict:
|
||||||
"""Billige Änderungs-Signale je Quelle → React-Query-Key. Fehler einer Quelle
|
try:
|
||||||
dürfen den Strom nie reißen (dann bleibt ihr Abdruck einfach stehen)."""
|
items = llamaswap.list_models()
|
||||||
fp: dict[str, object] = {}
|
return {"models": items, "count": len(items), "running": llamaswap.get_running_models()}
|
||||||
try: # Briefkasten trägt Chronik UND Kontext-Limit-Warnungen — in-process, spottbillig
|
except Exception:
|
||||||
from services import announce
|
return {"models": [], "count": 0, "running": []}
|
||||||
fp["chronik"] = announce.list_after(None)["latest"]
|
|
||||||
|
|
||||||
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try: # Queue: (id,status)-Paare; list_queue cached selbst ~15 s, der Tick kostet nichts
|
return {"count": 0}
|
||||||
from services import ideen
|
|
||||||
d = ideen.list_queue()
|
|
||||||
fp["ideen"] = json.dumps([(i.get("id"), i.get("status")) for i in d.get("items") or []])
|
async def _get_token_stats() -> dict:
|
||||||
|
try:
|
||||||
|
return get_stats()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
return {}
|
||||||
# Auftragsbuch (Annahme-Status + Karten-Meldungen) & Erinnerungen: Datei-mtimes
|
|
||||||
fp["auftragsbuch"] = (_mtime(MODELS_DIR / "mc2-auftragsbuch.json"),
|
|
||||||
_mtime(MODELS_DIR / "mc2-announce-branches.json"))
|
def _snapshot_diff(key: str, new: dict) -> bool:
|
||||||
fp["reminders"] = _mtime(MODELS_DIR / "mc2-reminders.json")
|
"""Gibt True zurück, wenn sich das Snapshot geändert hat."""
|
||||||
return fp
|
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")
|
@router.get("/events")
|
||||||
async def events(request: Request) -> StreamingResponse:
|
async def events_endpoint(request: Request):
|
||||||
async def strom():
|
"""SSE-Endpunkt für EventSource."""
|
||||||
# Der Client hat beim Verbinden frisch geladen — Basislinie ohne Event setzen.
|
return StreamingResponse(
|
||||||
alt = _fingerprints()
|
event_stream(),
|
||||||
yield ": verbunden\n\n"
|
media_type="text/event-stream",
|
||||||
seit_ping = 0.0
|
headers={
|
||||||
while True:
|
"Cache-Control": "no-cache",
|
||||||
if await request.is_disconnected():
|
"Connection": "keep-alive",
|
||||||
return
|
"X-Accel-Buffering": "no", # Nginx-Buffering deaktivieren
|
||||||
await asyncio.sleep(TICK_S)
|
},
|
||||||
seit_ping += TICK_S
|
)
|
||||||
neu = _fingerprints()
|
|
||||||
keys = [k for k, v in neu.items() if k in alt and v != alt[k]]
|
|
||||||
alt.update(neu)
|
|
||||||
if keys:
|
|
||||||
yield f"event: invalidate\ndata: {json.dumps({'keys': keys})}\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
elif seit_ping >= KEEPALIVE_S:
|
|
||||||
yield ": ping\n\n"
|
|
||||||
seit_ping = 0.0
|
|
||||||
|
|
||||||
return StreamingResponse(strom(), media_type="text/event-stream",
|
|
||||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user