File size: 6,836 Bytes
df1fcb2
 
 
 
d97497a
 
 
d497a35
b9a4f82
bc6b6db
df1fcb2
 
d97497a
3777688
 
 
 
 
d497a35
 
0301e87
3777688
d97497a
888b792
bc6b6db
d97497a
531624f
df1fcb2
d97497a
df1fcb2
4cdd1ea
 
 
 
 
 
 
 
b9a4f82
 
 
 
 
 
 
 
 
 
4cdd1ea
 
 
 
b9a4f82
 
df1fcb2
 
 
 
b9a4f82
df1fcb2
 
531624f
 
df1fcb2
 
 
3777688
 
 
 
531624f
3777688
df1fcb2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d97497a
 
df1fcb2
 
 
aca3d0b
ffcd038
8339370
1f5b714
888b792
df1fcb2
d497a35
 
 
 
 
 
 
 
 
 
dab8714
0301e87
 
dab8714
 
 
 
d497a35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
df1fcb2
 
 
 
d97497a
df1fcb2
 
 
d97497a
 
a7be466
3777688
 
 
 
a7be466
 
 
 
5e07ced
3777688
 
 
 
 
 
5e07ced
 
3777688
531624f
 
 
 
 
 
 
 
a7be466
 
 
 
 
 
3777688
a7be466
d97497a
 
3777688
d97497a
 
 
 
 
 
 
df1fcb2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
"""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,
)

@asynccontextmanager
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
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: Exception):
    """Handle 404 errors."""
    return JSONResponse(
        status_code=404,
        content={"error": "Not found", "detail": str(exc)},
    )


@app.exception_handler(409)
async def conflict_handler(request: Request, exc: Exception):
    """Handle 409 Conflict errors."""
    return JSONResponse(
        status_code=409,
        content={"error": "Conflict", "detail": str(exc)},
    )


@app.exception_handler(500)
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"])


@app.api_route("/mcp", methods=["GET", "POST", "DELETE"])
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")


@app.get("/health")
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
    @app.get("/{full_path:path}")
    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
    @app.get("/")
    async def root():
        """API health check endpoint."""
        return {"status": "ok", "service": "Document Viewer API"}


__all__ = ["app"]