Initial DocsMCP stack

This commit is contained in:
george
2026-06-05 23:02:55 +01:00
commit 421b6f973a
51 changed files with 7414 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# WebUI Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
DOCS_API_URL=http://docs-api:8787
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app /app/webui
RUN mkdir -p /app/webui/templates/uploads
EXPOSE 8790
CMD ["uvicorn", "webui.main:app", "--host", "0.0.0.0", "--port", "8790"]
+72
View File
@@ -0,0 +1,72 @@
"""Async docs-api client for the WebUI."""
import os
from typing import Any, Dict, Optional
from httpx import AsyncClient, Timeout
class DocsAPIClient:
"""Small async HTTP client for the docs-api backend."""
def __init__(self, base_url: Optional[str] = None, api_key: Optional[str] = None):
self.base_url = (base_url or os.environ.get("DOCS_API_URL", "http://docs-api:8787")).rstrip("/")
self.api_key = api_key if api_key is not None else os.environ.get("WEBUI_API_KEY")
self.headers = {"X-API-Key": self.api_key} if self.api_key else {}
self._client: Optional[AsyncClient] = None
async def _get_client(self) -> AsyncClient:
if self._client is None or self._client.is_closed:
self._client = AsyncClient(
base_url=self.base_url,
headers=self.headers,
timeout=Timeout(120.0),
)
return self._client
async def request(self, method: str, path: str, **kwargs: Any) -> Dict[str, Any]:
client = await self._get_client()
resp = await client.request(method, path, **kwargs)
if resp.status_code >= 400:
raise RuntimeError(f"{method} {path} failed: {resp.status_code} {resp.text}")
if resp.headers.get("content-type", "").startswith("application/json"):
data = resp.json()
return data if isinstance(data, dict) else {"data": data}
return {"data": resp.text}
async def get(self, path: str, **kwargs: Any) -> Dict[str, Any]:
return await self.request("GET", path, **kwargs)
async def post(self, path: str, **kwargs: Any) -> Dict[str, Any]:
return await self.request("POST", path, **kwargs)
async def delete(self, path: str, **kwargs: Any) -> Dict[str, Any]:
return await self.request("DELETE", path, **kwargs)
async def health(self) -> Dict[str, Any]:
try:
return await self.get("/health")
except Exception as e:
return {"status": "error", "message": str(e)}
async def upload_file(self, library_id: str, filename: str, content: bytes) -> Dict[str, Any]:
files = {"file": (filename, content)}
return await self.post(f"/api/v1/upload/{library_id}", files=files)
async def close(self) -> None:
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
_client_instance: Optional[DocsAPIClient] = None
async def get_client() -> DocsAPIClient:
global _client_instance
if _client_instance is None:
_client_instance = DocsAPIClient()
return _client_instance
async def close_client() -> None:
if _client_instance is not None:
await _client_instance.close()
+17
View File
@@ -0,0 +1,17 @@
"""WebUI configuration."""
from typing import Optional
class Settings:
"""WebUI settings from environment variables."""
# Core API connection
DOCS_API_URL: str = "http://docs-api:8787"
WEBUI_API_KEY: Optional[str] = None
# Default parameters for common operations
DEFAULT_SEARCH_LIMIT: int = 10
DEFAULT_RESULT_TOKENS: int = 8000
settings = Settings()
+259
View File
@@ -0,0 +1,259 @@
"""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, 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})
@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)
+159
View File
@@ -0,0 +1,159 @@
// WebUI Static JavaScript Utilities
// Simple helper functions shared across templates
/**
* Escape HTML to prevent XSS attacks when displaying user content
*/
function escapeHtml(text) {
if (typeof text !== 'string') return "";
var e = document.createElement('div');
try {
e.textContent = text;
return e.innerHTML;
} catch (err) {
return String(text).replace(/[&<>"']/g, function(m) {
switch (m) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#x27;';
default: return m;
}
});
}
}
/**
* Format number with thousands separators
*/
function formatNumber(num) {
if (num === null || num === undefined) return "N/A";
return new Intl.NumberFormat().format(Math.floor(num));
}
/**
* Show loading spinner
*/
function showLoading(elementId) {
var el = document.getElementById(elementId);
if (el) {
el.innerHTML = '<div class="loading-spinner">Loading...</div>';
}
}
/**
* Hide loading spinner
*/
function hideLoading(elementId) {
var el = document.getElementById(elementId);
if (el) {
el.innerHTML = "";
}
}
/**
* Create a toast notification
*/
function showToast(message, type) {
var toast = document.createElement('div');
toast.className = 'toast ' + (type || 'info');
toast.textContent = message;
toast.style.cssText = 'position:fixed;bottom:20px;right:20px;' +
'padding:12px 20px;border-radius:4px;margin-bottom:10px;' +
'background:#333;color:white;font-size:0.9rem;z-index:1000';
document.body.appendChild(toast);
setTimeout(function() {
toast.style.opacity = '0';
setTimeout(function() { toast.remove(); }, 200);
}, 3000);
}
/**
* Show error notification
*/
function showError(message) {
showToast("Error: " + message, "error");
}
/**
* Show success notification
*/
function showSuccess(message) {
showToast("Success: " + message, "success");
}
/**
* Make an API request with error handling
*/
async function apiRequest(endpoint, method = 'GET', data = null) {
const config = window.webuiConfig;
let url = config.apiUrl;
if (!url.endsWith('/')) url += '/';
url += endpoint;
const headers = {};
if (config.apiKey) {
headers['X-API-Key'] = config.apiKey;
}
try {
let response;
if (method === 'POST') {
response = await fetch(url, {
method: method,
headers: headers,
body: JSON.stringify(data)
});
} else {
response = await fetch(url, {
method: method,
headers: headers
});
}
if (!response.ok) {
throw new Error(response.statusText);
}
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
} else {
return await response.text();
}
} catch (err) {
console.error('API request failed:', err);
throw err;
}
}
/**
* Initialize tooltips if using them
*/
function initTooltips() {
// Add tooltip functionality here if needed
}
/**
* Debounce function for input handling
*/
function debounce(func, wait) {
var timeout;
return function executedFunction(...args) {
var later = function() {
clearTimeout(timeout);
func.apply(this, args);
};
timeout = setTimeout(later, wait);
};
}
// Export to window for use in templates
window.escapeHtml = escapeHtml;
window.formatNumber = formatNumber;
window.showToast = showToast;
window.showError = showError;
window.showSuccess = showSuccess;
+395
View File
@@ -0,0 +1,395 @@
.container {
max-width: 1000px;
margin: 0 auto;
padding: 20px;
}
header {
border-bottom: 1px solid #ccc;
padding-bottom: 15px;
margin-bottom: 20px;
}
header h1 {
margin: 0 0 10px 0;
font-size: 1.5rem;
}
nav {
display: flex;
gap: 15px;
}
nav a {
text-decoration: none;
color: #0066cc;
font-size: 0.9rem;
}
nav a.active {
font-weight: bold;
text-decoration: underline;
}
main h2 {
margin-bottom: 15px;
}
footer {
margin-top: 40px;
padding-top: 15px;
border-top: 1px solid #ccc;
font-size: 0.8rem;
color: #666;
}
.status-card {
background: #f5f5f5;
padding: 20px;
border-radius: 8px;
border-left: 4px solid #00c467;
margin-bottom: 15px;
}
.status-message {
background: #e8f4fd;
padding: 10px;
border-radius: 4px;
margin: 5px 0;
}
pre.code-block {
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Tables */
.library-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.library-table th, .library-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.library-table th {
background: #f5f5f5;
font-weight: bold;
}
/* Forms */
form input[type="text"], form textarea, form select {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
margin-right: 10px;
margin-bottom: 10px;
}
button {
background: #0066cc;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0055aa;
}
/* Upload form */
.upload-form, .search-form, .sync-form {
max-width: 600px;
}
/* Search results */
.results-count {
background: #e8f4fd;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.result-card {
background: #fff;
border: 1px solid #ddd;
padding: 15px;
margin: 10px 0;
border-radius: 4px;
}
.result-card h3 {
margin: 0 0 8px 0;
}
/* Results box */
.results-box {
max-height: 600px;
overflow-y: auto;
}
.results-box .new-search-link {
display: block;
text-align: center;
margin-top: 15px;
}
/* Source cards */
.source-cards {
display: grid;
gap: 10px;
}
.source-card {
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
border-left: 4px solid #666;
}
.status-message code {
background: #333;
color: #fff;
padding: 2px 6px;
border-radius: 3px;
}
.hint {
color: #666;
font-size: 0.85rem;
margin-top: 15px;
}
.results-box .error {
color: #cc0000;
font-weight: bold;
}
.source-list, .source-cards, pre {
white-space: normal;
}
/* Status cards grid */
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.status-card h3 {
margin: 0 0 8px 0;
font-size: 0.9rem;
color: #555;
}
.status-card p {
margin: 0;
font-size: 1.2rem;
font-weight: bold;
}
/* Message box */
.message-box {
background: #e8f4fd;
padding: 12px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #3b82f6;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 15px;
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 0.9rem;
}
.btn-primary {
background: #00c467;
color: white;
}
.btn-primary:hover {
background: #00a855;
}
.btn-secondary {
background: #2563eb;
color: white;
}
.btn-secondary:hover {
background: #1d4ed8;
}
/* Links section */
.links-section h2 {
font-size: 1rem;
margin-bottom: 10px;
}
.links-section a {
color: #0066cc;
text-decoration: none;
padding: 5px 10px;
}
.links-section a:hover {
text-decoration: underline;
}
/* Create library form */
.create-form {
background: #f9f9f9;
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #00c467;
}
.create-form label {
display: block;
margin-bottom: 8px;
font-weight: bold;
color: #333;
}
.create-form input[type="text"] {
width: 100%;
padding: 8px;
margin-bottom: 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
/* Table actions column */
.actions {
white-space: nowrap;
}
/* Button sizes */
.btn-sm {
padding: 5px 12px;
font-size: 0.8rem;
}
/* Additional action button colors */
.btn-info {
background: #17a2b8;
color: white;
}
.btn-info:hover {
background: #138496;
}
.btn-warning {
background: #ffc107;
color: black;
}
.btn-warning:hover {
background: #ffa000;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-primary:hover {
background: #0056b3;
}
/* Highlight row for popular libraries */
tr.highlight {
background: #f0fdf4;
}
/* Upload form specific styles */
#library_id, #files {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 12px;
box-sizing: border-box;
}
#files {
font-family: sans-serif;
}
/* Results box for upload */
.result-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-top: 20px;
min-height: 100px;
}
.result-box.error {
border-color: #dc3545;
background: #fff5f5;
}
/* Result items */
.result-item {
padding: 6px;
margin: 4px 0;
border-radius: 3px;
font-family: monospace;
font-size: 0.85rem;
word-break: break-word;
}
.result-item.success {
background: #d4edda;
border-left: 3px solid #28a745;
color: #155724;
}
.result-item.error {
background: #f8d7da;
border-left: 3px solid #dc3545;
color: #721c24;
}
.result-item.info {
background: #d1ecf1;
border-left: 3px solid #17a2b8;
color: #0c5460;
}
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Context7 Docs{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', path='style.css') }}">
</head>
<body>
<div class="container">
<header>
<h1>Context7 Docs UI</h1>
<nav>
<a href="/" {% if request.url.path == '/' %}class="active"{% endif %}>Dashboard</a>
<a href="/libraries" {% if request.url.path.startswith('/libraries') %}class="active"{% endif %}>Libraries</a>
<a href="/upload" {% if request.url.path.startswith('/upload') %}class="active"{% endif %}>Upload</a>
<a href="/search" {% if request.url.path.startswith('/search') %}class="active"{% endif %}>Search</a>
<a href="/sources" {% if request.url.path.startswith('/sources') %}class="active"{% endif %}>Sources</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>Context7 Docs WebUI</footer>
</div>
<script src="{{ url_for('static', path='app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Dashboard - Context7 Docs{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<!-- Status Cards -->
<div class="status-cards">
<div class="status-card" style="{% if health.status == 'ok' %}border-left-color: #00c467{% else %}border-left-color: #f53800{% endif %}">
<h3>Docs API Service</h3>
{% if health.status and health.status == 'ok' %}
<p style="color: #00c467;"><strong>Status:</strong> Online ✓</p>
{% else %}
<p style="color: #f53800;"><strong>Status:</strong> {% if health.status == 'error' %}Error{% else %}Offline{% endif %}</p>
{% endif %}
</div>
<div class="status-card">
<h3>Vectors Stored</h3>
<p>{{ vectors|default(0) }}</p>
</div>
<div class="status-card">
<h3>Libraries Registered</h3>
<p>{{ libraries|length }}</p>
</div>
</div>
<!-- Recent Messages -->
{% if libraries and libraries|length > 0 %}
<div class="message-box" style="background: #e8f4fd;">
<strong>Libraries:</strong> {{ escapeHtml(libraries) }}
</div>
{% endif %}
<!-- Action Buttons -->
<div class="action-buttons">
<form method="post" action="/actions/ingest-all" style="display: inline;">
<button type="submit" name="ingest-all" class="btn btn-primary">
🔄 Ingest All Libraries
</button>
</form>
<form method="post" action="/actions/sync-sources" style="display: inline;">
<input type="hidden" name="override" value="false">
<button type="submit" name="sync-sources" class="btn btn-secondary">
📦 Sync Git Sources
</button>
</form>
</div>
<!-- Links -->
<div class="links-section">
<h2>Navigate to Other Pages</h2>
<a href="/libraries" style="display: inline-block; margin-right: 15px;">View Libraries →</a>
<a href="/upload" style="display: inline-block; margin-right: 15px;">Upload Files →</a>
<a href="/search" style="display: inline-block; margin-right: 15px;">Search Docs →</a>
<a href="/sources" style="display: inline-block;">Git Sources →</a>
</div>
<!-- Script for health refresh on reload -->
<script>
// On page reload, re-fetch and update status if needed
document.addEventListener("DOMContentLoaded", async function() {
try {
const api = window.docsApiClient;
// Refresh health status from server-rendered data
document.querySelector('.status-cards .status-card:first-of-type')?.classList.remove('error');
const newHealth = await api.get("/health");
if (newHealth.status === 'ok') {
document.querySelector('.status-cards .status-card:first-of-type')?.querySelector('p')?.classList.add('online');
} else {
document.querySelector('.status-cards .status-card:first-of-type')?.querySelector('p')?.classList.add('error');
}
} catch (err) {
console.log('Health refresh skipped:', err);
}
});
</script>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}Libraries - Context7 Docs{% endblock %}
{% block content %}
<h1>Libraries</h1>
<!-- Create Library Form -->
<div class="create-form">
<form method="post" action="/libraries/create">
<label for="new_library_id">Library ID:</label>
<input type="text" id="new_library_id" name="library_id" placeholder="e.g., foundryvtt" required>
<label for="new_name">Name:</label>
<input type="text" id="new_name" name="name" placeholder="Display name for this library" required>
<label for="new_description">Description (optional):</label>
<input type="text" id="new_description" name="description" placeholder="Brief description...">
<button type="submit" class="btn btn-primary">Create Library</button>
</form>
</div>
<hr>
<!-- Libraries Table -->
<table class="library-table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Source Path</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="libraries-body">
{% if data|length > 0 %}
{% for lib in data %}
<tr class="{% if lib.source_path and 'foundry' in (lib.source_path or '').lower() %}highlight{% endif %}">
<td><code>{{ escapeHtml(lib.id) }}</code></td>
<td><strong>{{ escapeHtml(lib.name) }}</strong></td>
<td>{{ escapeHtml(lib.description) or '-' }}</td>
<td><small>{{ escapeHtml(lib.source_path) or '-' }}</small></td>
<td><small>{{ lib.updated_at|default('N/A') }}</small></td>
<td class="actions">
<a href="/libraries/{{ lib.id }}/docs" class="btn btn-sm btn-info">View Docs</a> |
<form method="post" action="/libraries/{{ lib.id }}/ingest" style="display:inline;"
onsubmit="return confirm('Trigger ingestion for this library?');">
<button type="submit" class="btn btn-sm btn-warning">Ingest</button>
</form> |
<form method="post" action="/libraries/{{ lib.id }}/delete"
onsubmit="return confirm('Delete this library and all its contents? This cannot be undone.');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" style="text-align:center;">No libraries found. Create one above.</td>
</tr>
{% endif %}
</tbody>
</table>
{% if data and data[0] and data[0].get('content') %}
<!-- Docs view mode -->
<pre class="code-block">{% for chunk in data.get('content', []) %}{% if chunk|length > 0 %}{{ chunk.text | default(chunk.content) | default(chunk) }}{% endif %}{% endfor %}</pre>
<a href="/libraries" style="display:block;margin-top:20px;">← Back to Libraries</a>
{% endif %}
{% endblock %}
+71
View File
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Search - Context7 Docs{% endblock %}
{% block content %}
<h2>Search Documentation</h2>
<form method="get" action="/search/results" class="search-form">
<label for="query">Query:</label>
<input type="text" id="query" name="q" required placeholder="Enter your search query..." value="{{ query or '' }}">
<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>
<div id="search-results" class="results-box"></div>
{% if results %}
<div class="results-count">{{ results|length }} results found</div>
{% endif %}
<script>
async function loadResults(query, limit) {
const searchBox = document.getElementById("search-results");
try {
const payload = { query: query || "{{ initial_query or '' }}", library_id: null, limit: parseInt(limit) };
const api = window.docsApiClient;
const result = await api.post("/search", payload);
if (result.results && Array.isArray(result.results)) {
searchBox.className = "results-box";
let html = '<div class="results-count">' + result.results.length + ' results found</div>';
for (const r of result.results) {
const title = r.title || (r.content || '').substring(0, 100);
const content = (r.content || '').substring(0, 500);
html += '<div class="result-card">' +
'<h3>' + escapeHtml(title) + '</h3>' +
'<p>' + escapeHtml(content) + '...</p>' +
'<a href="/docs/' + (r.library_id || '') + '">View Full</a></div>';
}
html += '<a href="/search/form" class="new-search-link">← New Search</a>';
searchBox.innerHTML = html;
}
} catch (err) {
searchBox.innerHTML = '<p class="error">Error loading results: ' + escapeHtml(err.message) + '</p>';
}
}
// Load initial results if query parameter exists in URL
var urlParams = new URLSearchParams(window.location.search);
{% if query %}loadResults(urlParams.get('q') || urlParams.get('q'), urlParams.get('limit'));{% endif %}
function escapeHtml(str) {
if (!str) return "";
var e = document.createElement('div');
e.textContent = str;
return e.innerHTML;
}
</script>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Sources - Context7 Docs{% endblock %}
{% block content %}
<h2>Git Repository Sync</h2>
<div class="status-message">Syncs all git repositories configured in <code>docs_sources.yaml</code>.</div>
<form method="post" action="/sources/sync" class="sync-form">
<label for="override">Override existing repos:</label>
<input type="checkbox" id="override" name="override">
<button type="submit">Sync All Repositories</button>
</form>
<div id="source-list"></div>
{% if sources %}
<h3>Configured Sources</h3>
<div class="source-cards">
{% for src in sources %}
<div class="source-card">
<strong>{{ src.library_id | default('unknown') }}</strong><br>
URL: {{ src.repo_url | default('N/A')[:60] }}<br>
Branch: {{ src.branch | default('main') }}<br>
Include: {{ (src.include_paths | default(['*']) | join(', ')) }}
</div>
{% endfor %}
</div>
{% else %}
<p>No git sources configured. Add repositories to <code>docs_sources.yaml</code>.</p>
{% endif %}
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Upload - Context7 Docs{% endblock %}
{% block content %}
<h2>Upload Documentation Files</h2>
<form method="post" enctype="multipart/form-data" class="upload-form">
<!-- Library Selector -->
<label for="library_id">Select Library:</label>
<select id="library_id" name="library_id" required>
<option value="">(New library - will be created from filename)</option>
{% for lib in libraries %}
<option value="{{ lib.id }}" data-name="{{ lib.name or lib.id }}">{{ lib.name or lib.id }}</option>
{% endfor %}
</select>
<!-- File Input (multiple files allowed) -->
<label for="files">Select Files:</label>
<input type="file" name="files" id="files" multiple accept=".md,.txt,.py,.js,.ts,.json,.yaml,.yml,.html,.css,.pdf" required>
<!-- Ingest Checkbox -->
<div style="margin-top: 10px;">
<label>
<input type="checkbox" name="ingest_after_upload" value="on">
Trigger ingestion after upload
</label>
</div>
<button type="submit" class="btn btn-primary">Upload Files</button>
</form>
<!-- Allowed extensions hint -->
<p class="hint">Allowed: .md, .txt, .py, .js, .ts, .json, .yaml, .yml, .html, .css, .pdf (max 5MB each)</p>
<!-- Results Display -->
<div id="upload-result" class="result-box"></div>
{% if results %}
<h3>Upload Results</h3>
<ul>
{% for result in results %}
<li><strong>{{ result.filename }}</strong>: {{ result.status }} - {{ escapeHtml(result.message) }}</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
# WebUI Dependencies
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
python-multipart==0.0.6
httpx==0.26.0
PyYAML==6.0.1