import time from pathlib import Path from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from .config import ServerSettings from .grid import GridManager from .models import ( ActionPayload, GridDescriptor, GridInitRequest, GridPlanRequest, GridRefreshRequest, ) from .planner import GridPlanner from .streamer import ScreenshotStreamer settings = ServerSettings() manager = GridManager(settings) planner = GridPlanner() streamer = ScreenshotStreamer() app = FastAPI( title="Clickthrough", description="Grid-aware surface that lets an agent plan clicks, drags, and typing on a fake screenshot", version="0.3.0", ) client_dir = Path(__file__).resolve().parent.parent / "client" if client_dir.exists(): app.mount("/ui", StaticFiles(directory=str(client_dir), html=True), name="ui") @app.get("/") async def root(): if client_dir.exists(): return RedirectResponse("/ui/") return {"status": "ok", "grid_count": manager.grid_count} @app.get("/health") def health_check() -> dict[str, str]: return {"status": "ok", "grid_count": str(manager.grid_count)} @app.post("/grid/init", response_model=GridDescriptor) def init_grid(request: GridInitRequest) -> GridDescriptor: grid = manager.create_grid(request) return grid.describe() @app.post("/grid/action") def apply_action(payload: ActionPayload): try: grid = manager.get_grid(payload.grid_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return grid.apply_action(payload) @app.get("/grid/{grid_id}/summary") def grid_summary(grid_id: str): try: grid = manager.get_grid(grid_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc descriptor = grid.describe() return { "grid_id": grid_id, "summary": planner.describe(descriptor), "details": grid.summary(), "descriptor": descriptor, } @app.get("/grid/{grid_id}/history") def grid_history(grid_id: str): try: history = manager.get_history(grid_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return {"grid_id": grid_id, "history": history} @app.post("/grid/{grid_id}/plan") def plan_grid(grid_id: str, request: GridPlanRequest): try: grid = manager.get_grid(grid_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc descriptor = grid.describe() payload = planner.build_payload( descriptor, action=request.action, preferred_label=request.preferred_label, text=request.text, comment=request.comment, ) result = grid.preview_action(payload) return {"plan": payload.model_dump(), "result": result, "descriptor": descriptor} @app.post("/grid/{grid_id}/refresh") async def refresh_grid(grid_id: str, payload: GridRefreshRequest): try: grid = manager.get_grid(grid_id) except KeyError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc grid.update_screenshot(payload.screenshot_base64, payload.memo) descriptor = grid.describe() await streamer.broadcast( grid_id, { "grid_id": grid_id, "timestamp": time.time(), "descriptor": descriptor, "screenshot_base64": payload.screenshot_base64, }, ) return {"status": "updated", "grid_id": grid_id} @app.websocket("/stream/screenshots") async def stream_screenshots(websocket: WebSocket, grid_id: str | None = None): key = await streamer.connect(websocket, grid_id) try: while True: await websocket.receive_text() except WebSocketDisconnect: streamer.disconnect(websocket, key)