ff4da0cb9e
- Fix discover_files: rel_path always computed (was stuck at '.' at root), include_path_match now uses relative path, 'return' changed to 'continue' - Fix ingest_git_source: files were cloned but ingested from wrong path (docs/repo-id instead of data/repos/repo-id). Now stages filtered files into DOCS_PATH/library_id before calling ingest_library. - Add browse_repo_tree() for interactive repo exploration - Add POST /api/v1/sources/browse endpoint to backend - Add /sources/browse proxy route to webui - Rewrite sources.html: browse repo, expand/collapse tree, check paths to include, then save source and sync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
310 lines
10 KiB
Python
310 lines
10 KiB
Python
"""WebUI FastAPI application."""
|
|
import html
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from .api_client import DocsAPIClient
|
|
|
|
|
|
app = FastAPI(
|
|
title="Context7 Docs WebUI",
|
|
description="Web dashboard for managing documentation system",
|
|
version="1.0.0",
|
|
)
|
|
|
|
templates = Jinja2Templates(directory=os.path.join(os.path.dirname(__file__), "templates"))
|
|
templates.env.globals["escapeHtml"] = lambda value: html.escape(str(value or ""))
|
|
app.mount("/static", StaticFiles(directory=os.path.join(os.path.dirname(__file__), "static")), name="static")
|
|
|
|
_client: Optional[DocsAPIClient] = None
|
|
|
|
|
|
def get_client() -> DocsAPIClient:
|
|
global _client
|
|
if _client is None:
|
|
_client = DocsAPIClient(
|
|
os.environ.get("DOCS_API_URL", "http://docs-api:8787"),
|
|
os.environ.get("WEBUI_API_KEY"),
|
|
)
|
|
return _client
|
|
|
|
|
|
@app.on_event("shutdown")
|
|
async def shutdown() -> None:
|
|
if _client is not None:
|
|
await _client.close()
|
|
|
|
|
|
def page(title: str, body: str) -> HTMLResponse:
|
|
return HTMLResponse(
|
|
f"""<!DOCTYPE html>
|
|
<html><head><meta charset="UTF-8"><title>{html.escape(title)}</title></head>
|
|
<body style="font-family:sans-serif;padding:20px;">{body}</body></html>"""
|
|
)
|
|
|
|
|
|
@app.get("/")
|
|
async def dashboard(request: Request):
|
|
client = get_client()
|
|
health = await client.health()
|
|
|
|
try:
|
|
collections_data = await client.get("/collections")
|
|
total_vectors = sum(
|
|
item.get("vectors", 0)
|
|
for item in collections_data.get("collections", {}).values()
|
|
if isinstance(item, dict)
|
|
)
|
|
except Exception:
|
|
total_vectors = 0
|
|
|
|
try:
|
|
libs_data = await client.get("/libraries")
|
|
libraries = libs_data.get("libraries", [])
|
|
except Exception:
|
|
libraries = []
|
|
|
|
return templates.TemplateResponse(
|
|
"dashboard.html",
|
|
{"request": request, "health": health, "vectors": total_vectors, "libraries": libraries},
|
|
)
|
|
|
|
|
|
@app.post("/actions/ingest-all")
|
|
async def ingest_all():
|
|
client = get_client()
|
|
try:
|
|
result = await client.post("/ingest/all")
|
|
body = f"<h1>Ingestion Complete</h1><pre>{html.escape(str(result))}</pre><a href='/'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Ingestion Failed</h1><pre>{html.escape(str(e))}</pre><a href='/'>Back</a>"
|
|
return page("Ingestion", body)
|
|
|
|
|
|
@app.post("/actions/sync-sources")
|
|
async def sync_sources_action():
|
|
client = get_client()
|
|
try:
|
|
result = await client.post("/sources/sync", json={"override": False})
|
|
body = f"<h1>Git Sync Complete</h1><pre>{html.escape(str(result))}</pre><a href='/'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Git Sync Failed</h1><pre>{html.escape(str(e))}</pre><a href='/'>Back</a>"
|
|
return page("Git Sync", body)
|
|
|
|
|
|
@app.get("/libraries")
|
|
async def libraries(request: Request):
|
|
client = get_client()
|
|
try:
|
|
data = await client.get("/libraries")
|
|
libraries_data = data.get("libraries", [])
|
|
except Exception:
|
|
libraries_data = []
|
|
return templates.TemplateResponse("libraries.html", {"request": request, "data": libraries_data})
|
|
|
|
|
|
@app.post("/libraries/create")
|
|
async def create_library(
|
|
library_id: str = Form(...),
|
|
name: str = Form(...),
|
|
description: Optional[str] = Form(None),
|
|
):
|
|
client = get_client()
|
|
try:
|
|
result = await client.post(
|
|
f"/api/v1/libraries/{library_id.strip()}",
|
|
data={"name": name, "description": description or ""},
|
|
)
|
|
body = f"<h1>Library Created</h1><pre>{html.escape(str(result))}</pre><a href='/libraries'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Create Failed</h1><pre>{html.escape(str(e))}</pre><a href='/libraries'>Back</a>"
|
|
return page("Library Created", body)
|
|
|
|
|
|
@app.post("/libraries/{library_id}/ingest")
|
|
async def ingest_library(library_id: str):
|
|
client = get_client()
|
|
try:
|
|
result = await client.post(f"/ingest/{library_id}")
|
|
body = f"<h1>Ingestion Complete</h1><pre>{html.escape(str(result))}</pre><a href='/libraries'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Ingestion Failed</h1><pre>{html.escape(str(e))}</pre><a href='/libraries'>Back</a>"
|
|
return page("Ingest Library", body)
|
|
|
|
|
|
@app.post("/libraries/{library_id}/delete")
|
|
async def delete_library(library_id: str):
|
|
client = get_client()
|
|
try:
|
|
result = await client.delete(f"/api/v1/libraries/{library_id}")
|
|
body = f"<h1>Library Deleted</h1><pre>{html.escape(str(result))}</pre><a href='/libraries'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Delete Failed</h1><pre>{html.escape(str(e))}</pre><a href='/libraries'>Back</a>"
|
|
return page("Delete Library", body)
|
|
|
|
|
|
@app.get("/libraries/{library_id}/docs")
|
|
async def view_library_docs(library_id: str):
|
|
client = get_client()
|
|
try:
|
|
result = await client.get(f"/docs/{library_id}")
|
|
content = result.get("content", "")
|
|
except Exception as e:
|
|
content = str(e)
|
|
return page(
|
|
f"Docs: {library_id}",
|
|
f"<h1>{html.escape(library_id)}</h1><pre>{html.escape(content)}</pre><a href='/libraries'>Back</a>",
|
|
)
|
|
|
|
|
|
@app.get("/upload")
|
|
async def upload_form(request: Request):
|
|
client = get_client()
|
|
try:
|
|
libs_data = await client.get("/libraries")
|
|
libraries = libs_data.get("libraries", [])
|
|
except Exception:
|
|
libraries = []
|
|
return templates.TemplateResponse("upload.html", {"request": request, "libraries": libraries})
|
|
|
|
|
|
@app.post("/upload")
|
|
async def upload_file(
|
|
request: Request,
|
|
library_id: str = Form(""),
|
|
ingest_after_upload: Optional[str] = Form(None),
|
|
files: List[UploadFile] = File(...),
|
|
):
|
|
client = get_client()
|
|
results = []
|
|
total_size = 0
|
|
|
|
for upload in files:
|
|
filename = upload.filename or "upload.txt"
|
|
target_library = library_id.strip()
|
|
if not target_library:
|
|
target_library = Path(filename).stem.lower().replace(" ", "-") or "uploaded"
|
|
|
|
try:
|
|
contents = await upload.read()
|
|
total_size += len(contents)
|
|
result = await client.upload_file(target_library, filename, contents)
|
|
results.append({"filename": filename, "status": "success", "message": result})
|
|
except Exception as e:
|
|
results.append({"filename": filename, "status": "error", "message": str(e)})
|
|
|
|
if ingest_after_upload == "on":
|
|
for result in list(results):
|
|
if result["status"] != "success":
|
|
continue
|
|
target_library = result["message"]["library_id"]
|
|
try:
|
|
ingest_result = await client.post(f"/ingest/{target_library}")
|
|
results.append({"filename": "__INGEST__", "status": "success", "message": ingest_result})
|
|
except Exception as e:
|
|
results.append({"filename": "__INGEST__", "status": "error", "message": str(e)})
|
|
|
|
return templates.TemplateResponse(
|
|
"upload.html",
|
|
{"request": request, "libraries": [], "results": results, "total_size_bytes": total_size},
|
|
)
|
|
|
|
|
|
@app.get("/search")
|
|
async def search_form(request: Request):
|
|
return templates.TemplateResponse("search.html", {"request": request, "query": "", "results": []})
|
|
|
|
|
|
@app.get("/search/results")
|
|
async def search_results(request: Request, q: str = "", limit: int = 10):
|
|
client = get_client()
|
|
results = []
|
|
if q:
|
|
try:
|
|
data = await client.post("/search", json={"query": q, "library_id": None, "limit": limit})
|
|
results = data.get("results", [])
|
|
except Exception:
|
|
results = []
|
|
return templates.TemplateResponse(
|
|
"search.html",
|
|
{"request": request, "query": q, "results": results, "limit": limit},
|
|
)
|
|
|
|
|
|
@app.get("/sources")
|
|
async def sources_page(request: Request):
|
|
client = get_client()
|
|
try:
|
|
data = await client.get("/api/v1/sources")
|
|
sources = data.get("sources", [])
|
|
except Exception:
|
|
sources = []
|
|
return templates.TemplateResponse("sources.html", {"request": request, "sources": sources})
|
|
|
|
|
|
def parse_path_list(value: str) -> List[str]:
|
|
paths = []
|
|
for line in value.replace(",", "\n").splitlines():
|
|
item = line.strip()
|
|
if item:
|
|
paths.append(item)
|
|
return paths
|
|
|
|
|
|
@app.post("/sources/browse")
|
|
async def browse_source(repo_url: str = Form(...), branch: str = Form("main")):
|
|
client = get_client()
|
|
try:
|
|
result = await client.post(
|
|
"/api/v1/sources/browse",
|
|
json={"repo_url": repo_url, "branch": branch},
|
|
)
|
|
return JSONResponse(content=result)
|
|
except Exception as e:
|
|
return JSONResponse(status_code=400, content={"success": False, "error": str(e)})
|
|
|
|
|
|
@app.post("/sources/add")
|
|
async def add_source(
|
|
library_id: str = Form(...),
|
|
repo_url: str = Form(...),
|
|
name: str = Form(""),
|
|
description: str = Form(""),
|
|
branch: str = Form("main"),
|
|
include_paths: str = Form("docs"),
|
|
exclude_paths: str = Form("node_modules\n.git"),
|
|
):
|
|
client = get_client()
|
|
payload = {
|
|
"library_id": library_id,
|
|
"repo_url": repo_url,
|
|
"name": name or None,
|
|
"description": description or None,
|
|
"branch": branch or "main",
|
|
"include_paths": parse_path_list(include_paths),
|
|
"exclude_paths": parse_path_list(exclude_paths),
|
|
}
|
|
try:
|
|
result = await client.post("/api/v1/sources", json=payload)
|
|
body = f"<h1>Git Source Saved</h1><pre>{html.escape(str(result))}</pre><a href='/sources'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Save Failed</h1><pre>{html.escape(str(e))}</pre><a href='/sources'>Back</a>"
|
|
return page("Git Source Saved", body)
|
|
|
|
|
|
@app.post("/sources/sync")
|
|
async def sync_sources(override: bool = Form(False)):
|
|
client = get_client()
|
|
try:
|
|
result = await client.post("/sources/sync", json={"override": override})
|
|
body = f"<h1>Git Sync Complete</h1><pre>{html.escape(str(result))}</pre><a href='/sources'>Back</a>"
|
|
except Exception as e:
|
|
body = f"<h1>Git Sync Failed</h1><pre>{html.escape(str(e))}</pre><a href='/sources'>Back</a>"
|
|
return page("Git Sync", body)
|