diff --git a/scripts/api.py b/scripts/api.py index bd78e7f..7d16fcc 100644 --- a/scripts/api.py +++ b/scripts/api.py @@ -26,6 +26,8 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware import uvicorn +import asyncio +from fastapi.responses import StreamingResponse load_dotenv(Path.home() / "aaronai" / ".env") @@ -767,5 +769,51 @@ async def clear_all_conversations(auth: str = Depends(require_auth)): return JSONResponse({"cleared": True}) +# SSE client registry +sse_clients: list[asyncio.Queue] = [] + +async def sse_generator(queue: asyncio.Queue): + try: + yield 'data: {"type": "connected"}\n\n' + while True: + try: + event = await asyncio.wait_for(queue.get(), timeout=30.0) + import json as _json + yield 'data: ' + _json.dumps(event) + '\n\n' + except asyncio.TimeoutError: + yield 'data: {"type": "heartbeat"}\n\n' + + except asyncio.CancelledError: + pass + finally: + if queue in sse_clients: + sse_clients.remove(queue) + +@app.get("/api/events") +async def sse_endpoint(request: Request, auth: str = Depends(require_auth)): + queue: asyncio.Queue = asyncio.Queue() + sse_clients.append(queue) + return StreamingResponse( + sse_generator(queue), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + } + ) + +@app.post("/api/events/notify") +async def notify_clients(request: Request): + """Internal endpoint — called by dream.py when a dream is delivered""" + # Only allow from localhost + client_host = request.client.host if request.client else "" + if client_host not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException(status_code=403, detail="Internal only") + data = await request.json() + for queue in sse_clients: + await queue.put(data) + return JSONResponse({"notified": len(sse_clients)}) + if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/scripts/dream.py b/scripts/dream.py index 9261965..f720f1c 100644 --- a/scripts/dream.py +++ b/scripts/dream.py @@ -289,6 +289,18 @@ def deliver(dream_text, mode, task=None): print(f"Dream written to Nextcloud: Journal/Dreams/{filename}") + # Notify any open browser connections via SSE + try: + import requests as _req + _req.post("http://localhost:8000/api/events/notify", json={ + "type": "dream", + "mode": mode, + "filename": filename, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M"), + }, timeout=3) + except Exception as _e: + print(f"SSE notify failed (non-critical): {_e}") + state = load_dreamer_state() state["last_dream_timestamp"] = datetime.now().timestamp() state["last_dream_mode"] = mode