Files
2026-06-05 23:02:55 +01:00

568 lines
22 KiB
Python

"""WebUI Views for Context7 Docs using Jinja2 templates."""
import os
import json
from pathlib import Path
from typing import Any, Optional
from fastapi import Request
from fastapi.responses import HTML, JSONResponse
import requests
# Internal API base URL
DOCS_API_URL = os.environ.get("DOCS_API_URL", "http://docs-api:8787")
def api_request(method: str, endpoint: str, data: Optional[dict] = None) -> dict:
"""Make internal API request to docs-api."""
url = f"{DOCS_API_URL}{endpoint}"
headers = {}
if os.environ.get("WEBUI_API_KEY"):
headers["X-API-Key"] = os.environ.get("WEBUI_API_KEY")
resp = requests.request(method, url, headers=headers, json=data)
return resp.json()
def navbar_html(current: str) -> str:
"""Generate navigation bar HTML."""
links = [
("/health", "Health"),
("/libraries", "Libraries"),
("/upload", "Upload"),
("/ingest/all", "Ingest All"),
("/sources/git", "Git Sources"),
("/search", "Search"),
]
items = []
for path, label in links:
cls = "active" if current == path else ""
items.append(f'<a href="{path}" class="{cls}">{label}</a>')
return f"""<nav>
{' '.join(items)}
</nav>""".strip()
def footer_html() -> str:
"""Generate footer HTML."""
return "<footer>Context7 Docs WebUI</footer>"
def health(request: Request) -> HTML:
"""System health dashboard."""
try:
data = api_request("GET", "/health")
status = data.get("status", "unknown")
service = data.get("service", "Service")
except Exception as e:
status = "error"
service = str(e)
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Health</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/health")}</header>
<main><h2>System Health</h2>
<div class="status-card" data-status="{status}"><h3>{service}</h3>
<p>Status: <span class="status-ok">{status}</span></p></div>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def libraries(request: Request) -> HTML:
"""List all libraries."""
try:
data = api_request("GET", "/libraries")
libs = data.get("libraries", [])
except Exception as e:
libs = [{"id": "error", "name": str(e)}]
table_rows = []
for lib in libs:
if lib.get("id") != "error":
table_rows.append(
f"""<tr><td>{lib.get('id')}</td>
<td>{lib.get('name', '')}</td>
<td>{lib.get('description', '') or '(no description)'}</td>
<td><a href="/docs/{lib.get('id')}">View Docs</a></td></tr>"""
)
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Libraries</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/libraries")}</header>
<main>
<h2>Libraries ({len(libs)})</h2>
<div class="actions-bar">
<form action="/folders/create" method="post" style="display:inline;">
<input type="text" name="name" placeholder="New library folder name" required>
<button type="submit">Create Folder</button>
</form>
</div>
<table class="library-table">
<thead><tr><th>ID</th><th>Name</th><th>Description</th><th>Actions</th></tr></thead>
<tbody>{"".join(table_rows)}</tbody>
</table>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def upload(request: Request) -> HTML:
"""File upload form."""
if "file" in request.files:
uploaded_file = request.files["file"]
try:
content = uploaded_file.read().decode("utf-8")[:5000]
# Escape HTML
safe_content = content.replace("&", "&").replace("<", "<").replace(">", ">")
truncated = safe_content[:1000] + "..." if len(safe_content) > 1000 else safe_content
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Upload</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/upload")}</header>
<main>
<h2>Upload Complete!</h2>
<pre class="content-preview">{truncated}</pre>
<form method="post" action="/ingest/uploaded">
<input type="hidden" name="content" value="{safe_content[:5000]}">
<label for="library_id">Library (optional):</label>
<input type="text" id="library_id" name="library_id" placeholder="e.g., my-docs">
<button type="submit">Ingest</button>
</form>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
except Exception:
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Upload</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/upload")}</header>
<main>
<h2>File too large!</h2>
<p>Please upload smaller text files (limit: ~5MB).</p>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
else:
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Upload</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/upload")}</header>
<main>
<h2>Upload Documentation Files</h2>
<form method="post" enctype="multipart/form-data">
<label for="file">Select file:</label>
<input type="file" name="file" id="file" accept=".txt,.md,.json,.py,.js,.html,.css,.yaml,.yml" required>
<button type="submit">Upload</button>
</form>
<p class="hint">Supported formats: .txt, .md, .json, .py, .js, .html, .css, .yaml</p>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def ingest_all(request: Request) -> JSONResponse:
"""Trigger ingestion for all libraries."""
try:
result = api_request("POST", "/ingest")
return JSONResponse(content={"status": "ok", "message": f"Processed {result.get('chunks', 0)} chunks"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
def ingest_library(request: Request, library_id: str) -> HTML:
"""Ingest for specific library."""
if "content" in request.form:
content = request.form.get("content")[:10000]
safe_content = content.replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Ingest</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/ingest/{library_id}")}</header>
<main>
<h2>Ingest for Library: {library_id}</h2>
<form method="post" action="/ingest/{library_id}">
<label for="content">Content (text):</label>
<textarea id="content" name="content" rows="10" maxlength="10000"></textarea>
<button type="submit">Ingest</button>
</form>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
else:
try:
result = api_request("POST", f"/ingest/{library_id}")
safe_msg = result.get('message', '') or ''
safe_json = json.dumps(result, indent=2).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Ingest Result</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/ingest/{library_id}")}</header>
<main>
<h2>Ingestion Complete!</h2>
<p>{safe_msg}</p>
<pre>{safe_json}</pre>
<a href="/libraries">← Back to Libraries</a>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
except Exception as e:
safe_error = str(e).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Error</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/ingest/{library_id}")}</header>
<main>
<h2>Error</h2>
<pre>{safe_error}</pre>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
async def folders_create(request: Request) -> JSONResponse:
"""Create a new library folder."""
name = request.form.get("name", "").strip()
try:
from backend.app.db import upsert_library
await upsert_library(library_id=name, name=name, description=None, source_path=f"/docs/{name}")
return JSONResponse(content={"status": "ok", "message": f"Created folder '{name}'"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
async def folders_delete(request: Request) -> JSONResponse:
"""Delete a library."""
library_id = request.query_params.get("id", "").strip()
try:
from backend.app.db import delete_library
await delete_library(library_id)
return JSONResponse(content={"status": "ok", "message": f"Deleted library '{library_id}'"})
except Exception as e:
return JSONResponse(status_code=500, content={"error": str(e)})
async def ingest_uploaded(request: Request) -> HTML:
"""Ingest uploaded file content."""
content = request.form.get("content", "")[:10000]
library_id = request.form.get("library_id", "uploaded")
try:
result = api_request("POST", f"/ingest/{library_id}", data={"content": content})
safe_msg = result.get('message', '') or ''
safe_json = json.dumps(result, indent=2).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Upload Result</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/upload")}</header>
<main>
<h2>Ingestion Complete!</h2>
<p>{safe_msg}</p>
<pre>{safe_json}</pre>
<a href="/upload">← Upload Another</a>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
except Exception as e:
safe_error = str(e).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Error</title></head>
<body><h1>Upload Ingest Error</h1><pre>{safe_error}</pre><a href="/upload">← Try Again</a></body>
</html>""", media_type="text/html")
def docs(request: Request, library_id: str, topic: Optional[str] = None, tokens: int = 8000) -> HTML:
"""View docs from a library."""
try:
data = api_request("GET", f"/libraries/{library_id}/docs", params={"topic": topic, "tokens": tokens})
content = data.get("content", "")
except Exception as e:
content = str(e)
safe_content = content.replace("&", "&").replace("<", "<").replace(">", ">")[:10000]
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Library: {library_id}</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/docs/{}".format(library_id))}</header>
<main>
<h2>Library: {library_id}</h2>
<p><strong>Topic:</strong> {topic or '(all)'} | <strong>Tokens:</strong> {tokens}</p>
<pre class="docs-content">{safe_content}</pre>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def search_redirect(request: Request) -> JSONResponse:
"""Redirect to search form."""
return JSONResponse(content={"redirect": "/search/form"})
def search_form(request: Request) -> HTML:
"""Search form page."""
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Search</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/search")}</header>
<main>
<h2>Search Docs</h2>
<form method="post" action="/search">
<label for="query">Query:</label>
<input type="text" id="query" name="query" required placeholder="Enter your search query...">
<label for="library_id">Library (optional):</label>
<input type="text" id="library_id" name="library_id" placeholder="e.g., foundryvtt">
<label for="limit">Limit results:</label>
<select id="limit" name="limit">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
<button type="submit">Search</button>
</form>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def search_results(request: Request) -> HTML:
"""Display search results."""
try:
query = request.query_params.get("q", "")
limit = int(request.query_params.get("limit", "10"))
payload = {"query": query, "library_id": None, "limit": limit}
result = api_request("POST", "/search", data=payload)
results = result.get("results", [])
except Exception as e:
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Error</title></head>
<body><h1>Error</h1><pre>{str(e)}</pre><a href="/search/form">← Try Again</a></body>
</html>""", media_type="text/html")
cards = []
for r in results:
title = r.get("title", "Untitled") or (r.get("content", "")[:100] + "...")[:200]
content = (r.get("content", "") or r.get("chunk", ""))[:500]
cards.append(f"""<div class="result-card" data-id="{r.get('id')}"><h3>{title}</h3>
<p>{content}...</p><a href="/docs/{r.get('library_id')}">View Full</a></div>""")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Search Results</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/search")}</header>
<main>
<h2>Search Results for "{query}"</h2>
<div class="results-count">{len(results)} results found</div>
{''.join(cards)}
<a href="/search/form">← New Search</a>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def sync_sources(request: Request) -> HTML:
"""Sync git sources."""
if request.method == "POST":
try:
data = api_request("POST", "/sources/sync")
safe_json = json.dumps(data, indent=2).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Sync Result</title></head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/sync/sources")}</header>
<main><h2>Git Sync Complete!</h2><pre>{safe_json}</pre>
<form method="post"><button type="submit">Sync Again</button></form>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
except Exception as e:
safe_error = str(e).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Error</title></head>
<body><h1>Sync Error</h1><pre>{safe_error}</pre><a href="/sources/git">← Try Again</a></body>
</html>""", media_type="text/html")
else:
try:
data = api_request("GET", "/libraries")
libs = [l.get("id") for l in data.get("libraries", []) if l.get("id") != "error"]
except Exception:
libs = []
lib_list = ", ".join(libs) if libs else "(none)"
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Git Sync</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/sources/git")}</header>
<main>
<h2>Sync Git Repositories</h2>
<p>Syncs all git repositories configured in <code>docs_sources.yaml</code>.</p>
<form method="post" action="/sync/sources">
<label for="override">Override existing repos:</label>
<input type="checkbox" id="override" name="override">
<button type="submit">Sync All Repositories</button>
</form>
<h3>Libraries Found: {lib_list}</h3>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
def git_sources(request: Request) -> HTML:
"""List configured git sources."""
import yaml
config_path = Path(__file__).parent.parent.parent / "docs_sources.yaml"
try:
with open(config_path) as f:
data = yaml.safe_load(f)
sources = data.get("sources", [])
source_blocks = []
for src in sources:
url = src.get("repo_url", "")[:50] + "..." if len(src.get("repo_url", "")) > 50 else src.get("repo_url", "")
branch = src.get("branch", "main")
include = src.get("include_paths", ["*"])
exclude = src.get("exclude_paths", [])
source_blocks.append(f"""<div class="source-card">
<strong>{src.get('library_id', 'unknown')}</strong><br>
URL: {url}<br>
Branch: {branch}<br>
Include: {', '.join(include)}{' | Exclude: ' + ', '.join(exclude) if exclude else ''}
</div>""")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Git Sources</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/sources/git")}</header>
<main>
<h2>Configured Git Sources ({len(sources)})</h2>
{''.join(source_blocks)}
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
except Exception as e:
safe_error = str(e).replace("&", "&").replace("<", "<").replace(">", ">")
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Error</title></head>
<body><h1>Git Sources Error</h1><pre>{safe_error}</pre></body>
</html>""", media_type="text/html")
def logs(request: Request) -> HTML:
"""Logs/status page."""
return HTML(f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context7 Docs - Logs</title>
<link rel="stylesheet" href="/static/css/main.css">
</head>
<body>
<div class="container">
<header><h1>Context7 Docs UI</h1>{navbar_html("/logs")}</header>
<main>
<h2>Status Messages</h2>
<div class="status-message">Docs API: {DOCS_API_URL}</div>
<div class="status-message">Qdrant Health: healthy | MCP OK: yes</div>
<p class="hint">Logs are printed to container stdout/stderr. For full logs, inspect Docker containers directly.</p>
</main>{footer_html()}</div>
</body></html>""", media_type="text/html")
# Register all routes
__all__ = [
"health", "libraries", "upload", "ingest_all", "ingest_library",
"folders_create", "folders_delete", "docs", "search_redirect",
"search_form", "search_results", "sync_sources", "git_sources", "logs"
]