Spaces:
Running
Running
| """FastAPI application main entry point.""" | |
| from __future__ import annotations | |
| import logging | |
| from pathlib import Path | |
| import asyncio | |
| from contextlib import asynccontextmanager | |
| from fastapi import FastAPI, HTTPException, Request | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from dotenv import load_dotenv | |
| load_dotenv() # Add this line at the top, before other imports | |
| # from fastapi.routing import ASGIRoute | |
| from starlette.responses import Response | |
| from fastmcp.server.http import StreamableHTTPSessionManager, set_http_request | |
| from fastapi.responses import FileResponse | |
| from .routes import auth, index, notes, search, graph, demo, system, rag, tts | |
| from ..mcp.server import mcp | |
| from ..services.seed import init_and_seed | |
| from ..services.config import get_config | |
| logger = logging.getLogger(__name__) | |
| # Hosted MCP HTTP endpoint (mounted Starlette app) | |
| session_manager = StreamableHTTPSessionManager( | |
| app=mcp._mcp_server, | |
| event_store=None, | |
| json_response=False, | |
| stateless=True, | |
| ) | |
| async def lifespan(app: FastAPI): | |
| """Lifespan handler to run startup tasks.""" | |
| logger.info("Running startup: initializing database and seeding demo vault...") | |
| try: | |
| init_and_seed(user_id="demo-user") | |
| logger.info("Startup complete: database and demo vault ready") | |
| except Exception as exc: | |
| logger.exception("Startup failed: %s", exc) | |
| logger.error("App starting without demo data due to initialization error") | |
| # Initialize FastMCP session manager task group | |
| async with session_manager.run(): | |
| yield | |
| app = FastAPI( | |
| title="Document Viewer API", | |
| description="Multi-tenant Obsidian-like documentation system", | |
| version="0.1.0", | |
| lifespan=lifespan, | |
| ) | |
| config = get_config() | |
| # CORS middleware | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=[ | |
| "http://localhost:5173", | |
| "http://localhost:3000", | |
| "https://huggingface.co", | |
| config.chatgpt_cors_origin, | |
| ], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Error handlers | |
| async def not_found_handler(request: Request, exc: Exception): | |
| """Handle 404 errors.""" | |
| return JSONResponse( | |
| status_code=404, | |
| content={"error": "Not found", "detail": str(exc)}, | |
| ) | |
| async def conflict_handler(request: Request, exc: Exception): | |
| """Handle 409 Conflict errors.""" | |
| return JSONResponse( | |
| status_code=409, | |
| content={"error": "Conflict", "detail": str(exc)}, | |
| ) | |
| async def internal_error_handler(request: Request, exc: Exception): | |
| """Handle 500 errors.""" | |
| return JSONResponse( | |
| status_code=500, | |
| content={"error": "Internal server error", "detail": str(exc)}, | |
| ) | |
| # Mount routers (auth must come first for /auth/login and /auth/callback) | |
| app.include_router(auth.router, tags=["auth"]) | |
| app.include_router(notes.router, tags=["notes"]) | |
| app.include_router(search.router, tags=["search"]) | |
| app.include_router(index.router, tags=["index"]) | |
| app.include_router(graph.router, tags=["graph"]) | |
| app.include_router(demo.router, tags=["demo"]) | |
| app.include_router(system.router, tags=["system"]) | |
| app.include_router(rag.router, tags=["rag"]) | |
| app.include_router(tts.router, tags=["tts"]) | |
| async def mcp_http_bridge(request: Request) -> Response: | |
| """Forward HTTP requests to the FastMCP streamable HTTP session manager.""" | |
| send_queue: asyncio.Queue = asyncio.Queue() | |
| async def send(message): | |
| await send_queue.put(message) | |
| try: | |
| with set_http_request(request): | |
| await session_manager.handle_request(request.scope, request.receive, send) | |
| except Exception as exc: | |
| logger.exception("FastMCP session manager crashed: %s", exc) | |
| raise HTTPException(status_code=500, detail=f"MCP Bridge Error: {exc}") | |
| await send_queue.put(None) | |
| result_body = b"" | |
| headers = {} | |
| status = 200 | |
| while True: | |
| message = await send_queue.get() | |
| if message is None: | |
| break | |
| msg_type = message["type"] | |
| if msg_type == "http.response.start": | |
| status = message.get("status", 200) | |
| raw_headers = message.get("headers", []) | |
| headers = {key.decode(): value.decode() for key, value in raw_headers} | |
| elif msg_type == "http.response.body": | |
| result_body += message.get("body", b"") | |
| if not message.get("more_body"): | |
| break | |
| return Response(content=result_body, status_code=status, headers=headers) | |
| logger.info("MCP HTTP endpoint mounted at /mcp via StreamableHTTPSessionManager") | |
| async def health(): | |
| """Health check endpoint for HF Spaces.""" | |
| return {"status": "healthy"} | |
| frontend_dist = Path(__file__).resolve().parents[3] / "frontend" / "dist" | |
| if frontend_dist.exists(): | |
| # Mount static assets | |
| app.mount( | |
| "/assets", StaticFiles(directory=str(frontend_dist / "assets")), name="assets" | |
| ) | |
| # Catch-all route for SPA - serve index.html for all non-API routes | |
| async def serve_spa(full_path: str): | |
| """Serve the SPA for all non-API routes.""" | |
| # Don't intercept API or auth routes | |
| if ( | |
| full_path.startswith(("api/", "auth/")) | |
| or full_path == "health" | |
| or full_path.startswith("mcp/") | |
| or full_path == "mcp" | |
| ): | |
| # Let FastAPI's 404 handler take over | |
| raise HTTPException(status_code=404, detail="Not found") | |
| # Serve widget entry point | |
| if full_path == "widget.html" or full_path.startswith("widget"): | |
| widget_path = frontend_dist / "widget.html" | |
| if widget_path.is_file(): | |
| # ChatGPT requires specific MIME type for widgets | |
| return FileResponse(widget_path, media_type="text/html+skybridge") | |
| logger.warning("widget.html requested but not found") | |
| # If the path looks like a file (has extension), try to serve it | |
| file_path = frontend_dist / full_path | |
| if file_path.is_file(): | |
| return FileResponse(file_path) | |
| # Otherwise serve index.html for SPA routing | |
| return FileResponse(frontend_dist / "index.html") | |
| logger.info(f"Serving frontend SPA from: {frontend_dist}") | |
| else: | |
| logger.warning(f"Frontend dist not found at: {frontend_dist}") | |
| # Fallback health endpoint if no frontend | |
| async def root(): | |
| """API health check endpoint.""" | |
| return {"status": "ok", "service": "Document Viewer API"} | |
| __all__ = ["app"] | |