Initial DocsMCP stack
This commit is contained in:
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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 '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case "'": return ''';
|
||||
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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user