| """ |
| web_app.py β FastAPI web UI + REST API for the Knowledge Agent |
| ============================================================== |
| Run with: uvicorn web_app:app --host 0.0.0.0 --port 8000 --reload |
| |
| Endpoints: |
| GET / β Web UI (chat interface) |
| POST /api/ask β JSON Q&A |
| GET /api/stream β SSE streaming answer |
| POST /api/index β Trigger re-indexing |
| POST /api/upload β Upload and index a new document |
| GET /api/docs-list β List indexed documents |
| GET /api/stats β Knowledge base stats |
| DELETE /api/doc/{filename} β Remove a document |
| """ |
|
|
| import os |
| import shutil |
| import asyncio |
| from pathlib import Path |
| from typing import Optional |
|
|
| from fastapi import FastAPI, UploadFile, File, HTTPException, Query |
| from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse |
| from fastapi.middleware.cors import CORSMiddleware |
| from pydantic import BaseModel |
|
|
| from knowledge_api import ( |
| query_knowledge, |
| index_docs, |
| index_single, |
| get_knowledge_stats, |
| list_indexed_docs, |
| delete_doc, |
| stream_knowledge_answer, |
| ) |
|
|
| DOCS_DIR = os.getenv("DOCS_DIR", "./documents") |
|
|
| |
| app = FastAPI( |
| title="Knowledge Agent", |
| description="Personal RAG system over your documents", |
| version="1.0.0", |
| ) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
|
|
| |
| class AskRequest(BaseModel): |
| question: str |
| top_k: int = 5 |
|
|
| class IndexRequest(BaseModel): |
| docs_dir: Optional[str] = None |
| force: bool = False |
|
|
|
|
| |
|
|
| @app.post("/api/ask") |
| async def ask(req: AskRequest): |
| """Ask a question and get a grounded answer from your documents.""" |
| if not req.question.strip(): |
| raise HTTPException(400, "Question cannot be empty.") |
| result = query_knowledge(req.question, top_k=req.top_k) |
| return result |
|
|
|
|
| @app.get("/api/stream") |
| async def stream( |
| question: str = Query(..., description="Your question"), |
| top_k: int = Query(5, description="Number of chunks to retrieve"), |
| ): |
| """ |
| SSE endpoint β streams the answer token by token. |
| Used by the web UI for a typing-cursor effect. |
| """ |
| if not question.strip(): |
| raise HTTPException(400, "Question cannot be empty.") |
|
|
| def event_generator(): |
| for token in stream_knowledge_answer(question, top_k=top_k): |
| |
| yield f"data: {token}\n\n" |
| yield "data: [DONE]\n\n" |
|
|
| return StreamingResponse( |
| event_generator(), |
| media_type="text/event-stream", |
| headers={ |
| "Cache-Control": "no-cache", |
| "X-Accel-Buffering": "no", |
| }, |
| ) |
|
|
|
|
| @app.post("/api/index") |
| async def trigger_index(req: IndexRequest): |
| """Trigger indexing of the documents directory.""" |
| docs_dir = req.docs_dir or DOCS_DIR |
| |
| loop = asyncio.get_event_loop() |
| await loop.run_in_executor( |
| None, lambda: index_docs(docs_dir=docs_dir, force=req.force) |
| ) |
| stats = get_knowledge_stats() |
| return {"message": "Indexing complete.", "stats": stats} |
|
|
|
|
| @app.post("/api/upload") |
| async def upload_document(file: UploadFile = File(...)): |
| """ |
| Upload a document file; save it to the documents dir and index it. |
| Supports: .pdf, .docx, .txt, .md |
| """ |
| allowed = {".pdf", ".docx", ".doc", ".txt", ".md", ".markdown"} |
| ext = Path(file.filename).suffix.lower() |
| if ext not in allowed: |
| raise HTTPException(400, f"Unsupported file type: {ext}. Allowed: {allowed}") |
|
|
| os.makedirs(DOCS_DIR, exist_ok=True) |
| dest = os.path.join(DOCS_DIR, file.filename) |
|
|
| |
| with open(dest, "wb") as f: |
| shutil.copyfileobj(file.file, f) |
|
|
| |
| loop = asyncio.get_event_loop() |
| await loop.run_in_executor(None, lambda: index_single(dest)) |
|
|
| return { |
| "message": f"'{file.filename}' uploaded and indexed.", |
| "path": dest, |
| } |
|
|
|
|
| @app.get("/api/docs-list") |
| async def docs_list(): |
| """List all documents currently in the knowledge base.""" |
| return {"documents": list_indexed_docs()} |
|
|
|
|
| @app.get("/api/stats") |
| async def stats(): |
| """Return knowledge base statistics.""" |
| return get_knowledge_stats() |
|
|
|
|
| @app.delete("/api/doc/{filename}") |
| async def remove_doc(filename: str): |
| """Remove a document and all its chunks from the index.""" |
| delete_doc(filename) |
| return {"message": f"Deleted '{filename}' from the knowledge base."} |
|
|
|
|
| |
|
|
| @app.get("/", response_class=HTMLResponse) |
| async def ui(): |
| """Serve the single-page chat interface.""" |
| return HTMLResponse(content=WEB_UI_HTML) |
|
|
|
|
| |
| WEB_UI_HTML = """<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Knowledge Agent</title> |
| <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;600&display=swap" rel="stylesheet"> |
| <style> |
| :root { |
| --bg: #0d0f14; |
| --surface: #161921; |
| --border: #2a2f3d; |
| --accent: #5b8def; |
| --accent2: #7ecfb0; |
| --text: #d4dae8; |
| --muted: #5c6480; |
| --danger: #e05c7a; |
| --mono: 'IBM Plex Mono', monospace; |
| --sans: 'IBM Plex Sans', sans-serif; |
| } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: var(--sans); |
| height: 100vh; |
| display: flex; |
| flex-direction: column; |
| } |
| |
| /* ββ Header ββ */ |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| padding: 14px 24px; |
| border-bottom: 1px solid var(--border); |
| background: var(--surface); |
| flex-shrink: 0; |
| } |
| .logo { |
| font-family: var(--mono); |
| font-size: 1rem; |
| color: var(--accent); |
| letter-spacing: .04em; |
| } |
| .logo span { color: var(--accent2); } |
| .header-actions { display: flex; gap: 10px; } |
| .btn { |
| font-family: var(--mono); |
| font-size: .75rem; |
| padding: 6px 14px; |
| border: 1px solid var(--border); |
| border-radius: 4px; |
| background: transparent; |
| color: var(--text); |
| cursor: pointer; |
| transition: all .15s; |
| } |
| .btn:hover { border-color: var(--accent); color: var(--accent); } |
| .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; } |
| .btn.primary:hover { opacity: .85; } |
| .btn.danger { border-color: var(--danger); color: var(--danger); } |
| |
| /* ββ Layout ββ */ |
| .main { display: flex; flex: 1; overflow: hidden; } |
| |
| /* ββ Sidebar ββ */ |
| .sidebar { |
| width: 260px; |
| border-right: 1px solid var(--border); |
| background: var(--surface); |
| display: flex; |
| flex-direction: column; |
| flex-shrink: 0; |
| overflow: hidden; |
| } |
| .sidebar-section { padding: 16px; border-bottom: 1px solid var(--border); } |
| .sidebar-title { |
| font-family: var(--mono); |
| font-size: .65rem; |
| text-transform: uppercase; |
| letter-spacing: .12em; |
| color: var(--muted); |
| margin-bottom: 10px; |
| } |
| #stats-panel { font-size: .78rem; line-height: 1.9; color: var(--muted); } |
| #stats-panel strong { color: var(--text); } |
| .doc-list { flex: 1; overflow-y: auto; padding: 8px 0; } |
| .doc-item { |
| padding: 8px 16px; |
| font-size: .78rem; |
| color: var(--muted); |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| cursor: default; |
| } |
| .doc-item .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); flex-shrink: 0; } |
| .doc-item .del { |
| margin-left: auto; |
| color: var(--danger); |
| cursor: pointer; |
| font-size: .9rem; |
| opacity: 0; |
| transition: opacity .15s; |
| } |
| .doc-item:hover .del { opacity: 1; } |
| |
| /* Upload zone */ |
| .upload-zone { |
| margin: 0 16px 16px; |
| border: 1px dashed var(--border); |
| border-radius: 6px; |
| padding: 12px; |
| text-align: center; |
| font-size: .75rem; |
| color: var(--muted); |
| cursor: pointer; |
| transition: border-color .15s; |
| } |
| .upload-zone:hover { border-color: var(--accent); color: var(--accent); } |
| #file-input { display: none; } |
| |
| /* ββ Chat area ββ */ |
| .chat-area { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| overflow: hidden; |
| } |
| .messages { |
| flex: 1; |
| overflow-y: auto; |
| padding: 28px 32px; |
| display: flex; |
| flex-direction: column; |
| gap: 24px; |
| } |
| .msg { display: flex; gap: 14px; } |
| .msg-avatar { |
| width: 30px; height: 30px; |
| border-radius: 4px; |
| display: flex; align-items: center; justify-content: center; |
| font-family: var(--mono); |
| font-size: .65rem; |
| font-weight: 500; |
| flex-shrink: 0; |
| margin-top: 2px; |
| } |
| .msg.user .msg-avatar { background: #2b3354; color: var(--accent); } |
| .msg.agent .msg-avatar { background: #1e3330; color: var(--accent2); } |
| .msg-body { flex: 1; } |
| .msg-label { |
| font-size: .68rem; |
| font-family: var(--mono); |
| color: var(--muted); |
| margin-bottom: 5px; |
| text-transform: uppercase; |
| letter-spacing: .08em; |
| } |
| .msg-text { |
| font-size: .875rem; |
| line-height: 1.75; |
| color: var(--text); |
| white-space: pre-wrap; |
| word-break: break-word; |
| } |
| .msg.user .msg-text { color: #aebcdc; } |
| .sources-bar { |
| margin-top: 10px; |
| font-size: .7rem; |
| font-family: var(--mono); |
| color: var(--muted); |
| } |
| .sources-bar span { |
| display: inline-block; |
| background: #1a2238; |
| border: 1px solid var(--border); |
| border-radius: 3px; |
| padding: 2px 8px; |
| margin: 2px 3px 0 0; |
| color: var(--accent); |
| } |
| |
| /* ββ Input row ββ */ |
| .input-row { |
| padding: 18px 32px; |
| border-top: 1px solid var(--border); |
| display: flex; |
| gap: 10px; |
| background: var(--surface); |
| flex-shrink: 0; |
| } |
| #question-input { |
| flex: 1; |
| background: var(--bg); |
| border: 1px solid var(--border); |
| border-radius: 6px; |
| padding: 12px 16px; |
| font-family: var(--sans); |
| font-size: .875rem; |
| color: var(--text); |
| resize: none; |
| outline: none; |
| transition: border-color .15s; |
| line-height: 1.5; |
| min-height: 48px; |
| max-height: 160px; |
| } |
| #question-input:focus { border-color: var(--accent); } |
| #question-input::placeholder { color: var(--muted); } |
| #send-btn { |
| align-self: flex-end; |
| height: 48px; |
| padding: 0 22px; |
| } |
| |
| /* ββ Status bar ββ */ |
| #status-bar { |
| padding: 6px 32px; |
| font-size: .7rem; |
| font-family: var(--mono); |
| color: var(--muted); |
| background: var(--bg); |
| border-top: 1px solid var(--border); |
| flex-shrink: 0; |
| } |
| |
| /* Scrollbar */ |
| ::-webkit-scrollbar { width: 6px; } |
| ::-webkit-scrollbar-track { background: transparent; } |
| ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } |
| |
| /* Loading dots */ |
| .typing { display: flex; gap: 5px; align-items: center; padding: 4px 0; } |
| .typing .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); animation: blink 1.2s infinite; } |
| .typing .dot:nth-child(2) { animation-delay: .2s; } |
| .typing .dot:nth-child(3) { animation-delay: .4s; } |
| @keyframes blink { 0%,80%,100%{opacity:.2} 40%{opacity:1} } |
| |
| .empty-state { |
| flex: 1; |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| color: var(--muted); |
| font-size: .875rem; |
| gap: 10px; |
| text-align: center; |
| } |
| .empty-state .icon { font-size: 2.5rem; margin-bottom: 4px; } |
| </style> |
| </head> |
| <body> |
| |
| <header> |
| <div class="logo">knowledge<span>_agent</span></div> |
| <div class="header-actions"> |
| <button class="btn" onclick="loadStats()">β» Refresh</button> |
| <button class="btn primary" onclick="triggerIndex()">β‘ Re-index</button> |
| </div> |
| </header> |
| |
| <div class="main"> |
| <!-- Sidebar --> |
| <div class="sidebar"> |
| <div class="sidebar-section"> |
| <div class="sidebar-title">Knowledge Base</div> |
| <div id="stats-panel">Loadingβ¦</div> |
| </div> |
| <div class="sidebar-section" style="flex:1;overflow:hidden;display:flex;flex-direction:column;padding-bottom:0"> |
| <div class="sidebar-title">Indexed Documents</div> |
| <div class="doc-list" id="doc-list"></div> |
| </div> |
| <div class="upload-zone" onclick="document.getElementById('file-input').click()"> |
| οΌ Upload document |
| </div> |
| <input type="file" id="file-input" accept=".pdf,.docx,.txt,.md,.markdown" |
| onchange="uploadFile(this.files[0])"> |
| </div> |
| |
| <!-- Chat --> |
| <div class="chat-area"> |
| <div class="messages" id="messages"> |
| <div class="empty-state" id="empty-state"> |
| <div class="icon">π§ </div> |
| <strong>Ask anything about your documents</strong> |
| <span>Add files via the sidebar, then start asking questions.</span> |
| </div> |
| </div> |
| |
| <div class="input-row"> |
| <textarea id="question-input" |
| placeholder="Ask a question about your documentsβ¦" |
| rows="1" |
| onkeydown="handleKey(event)"></textarea> |
| <button class="btn primary" id="send-btn" onclick="sendQuestion()">Ask β</button> |
| </div> |
| <div id="status-bar">Ready</div> |
| </div> |
| </div> |
| |
| <script> |
| // ββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| let isLoading = false; |
| |
| // ββ On load ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| window.addEventListener('DOMContentLoaded', () => { |
| loadStats(); |
| loadDocs(); |
| autoResize(document.getElementById('question-input')); |
| }); |
| |
| // ββ Stats & docs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| async function loadStats() { |
| try { |
| const r = await fetch('/api/stats'); |
| const s = await r.json(); |
| document.getElementById('stats-panel').innerHTML = |
| `<strong>${s.total_chunks}</strong> chunks indexed<br> |
| <strong>${s.indexed_files}</strong> documents<br> |
| <span style="font-size:.7rem">${s.embed_model}</span>`; |
| } catch(e) { |
| document.getElementById('stats-panel').textContent = 'Error loading stats.'; |
| } |
| } |
| |
| async function loadDocs() { |
| try { |
| const r = await fetch('/api/docs-list'); |
| const data = await r.json(); |
| const list = document.getElementById('doc-list'); |
| list.innerHTML = ''; |
| if (!data.documents.length) { |
| list.innerHTML = '<div class="doc-item" style="font-style:italic">No documents yet</div>'; |
| return; |
| } |
| data.documents.forEach(doc => { |
| const div = document.createElement('div'); |
| div.className = 'doc-item'; |
| div.innerHTML = ` |
| <span class="dot"></span> |
| <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap" |
| title="${doc.path}">${doc.name}</span> |
| <span class="del" onclick="removeDoc('${doc.name}', this)" title="Remove">β</span>`; |
| list.appendChild(div); |
| }); |
| } catch(e) {} |
| } |
| |
| async function removeDoc(name, el) { |
| if (!confirm(`Remove "${name}" from the knowledge base?`)) return; |
| await fetch(`/api/doc/${encodeURIComponent(name)}`, { method: 'DELETE' }); |
| el.closest('.doc-item').remove(); |
| loadStats(); |
| } |
| |
| async function triggerIndex() { |
| setStatus('Indexing documentsβ¦'); |
| try { |
| const r = await fetch('/api/index', { method: 'POST', |
| headers: {'Content-Type':'application/json'}, |
| body: JSON.stringify({ force: false }) |
| }); |
| const d = await r.json(); |
| setStatus(`Indexing complete β ${d.stats.total_chunks} chunks.`); |
| loadStats(); loadDocs(); |
| } catch(e) { setStatus('Indexing failed.'); } |
| } |
| |
| async function uploadFile(file) { |
| if (!file) return; |
| setStatus(`Uploading ${file.name}β¦`); |
| const fd = new FormData(); |
| fd.append('file', file); |
| try { |
| const r = await fetch('/api/upload', { method: 'POST', body: fd }); |
| const d = await r.json(); |
| setStatus(d.message); |
| loadStats(); loadDocs(); |
| } catch(e) { setStatus('Upload failed.'); } |
| document.getElementById('file-input').value = ''; |
| } |
| |
| // ββ Chat βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| function handleKey(e) { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendQuestion(); } |
| setTimeout(() => autoResize(e.target), 0); |
| } |
| |
| function autoResize(el) { |
| el.style.height = 'auto'; |
| el.style.height = Math.min(el.scrollHeight, 160) + 'px'; |
| } |
| |
| async function sendQuestion() { |
| const input = document.getElementById('question-input'); |
| const question = input.value.trim(); |
| if (!question || isLoading) return; |
| |
| isLoading = true; |
| input.value = ''; |
| input.style.height = '48px'; |
| document.getElementById('send-btn').disabled = true; |
| document.getElementById('empty-state')?.remove(); |
| |
| addMessage('user', question); |
| const agentMsg = addMessage('agent', '', true); |
| setStatus('Searching knowledge baseβ¦'); |
| |
| // SSE streaming |
| try { |
| const url = `/api/stream?question=${encodeURIComponent(question)}&top_k=5`; |
| const es = new EventSource(url); |
| let buffer = ''; |
| |
| es.onmessage = (e) => { |
| if (e.data === '[DONE]') { |
| es.close(); |
| finishMessage(agentMsg, buffer); |
| isLoading = false; |
| document.getElementById('send-btn').disabled = false; |
| setStatus('Ready'); |
| return; |
| } |
| buffer += e.data; |
| agentMsg.querySelector('.msg-text').textContent = buffer; |
| scrollToBottom(); |
| }; |
| es.onerror = () => { |
| es.close(); |
| if (!buffer) { |
| agentMsg.querySelector('.msg-text').textContent = |
| 'Error: Could not reach the server.'; |
| } |
| isLoading = false; |
| document.getElementById('send-btn').disabled = false; |
| setStatus('Error'); |
| }; |
| } catch(e) { |
| agentMsg.querySelector('.msg-text').textContent = 'Unexpected error.'; |
| isLoading = false; |
| document.getElementById('send-btn').disabled = false; |
| setStatus('Error'); |
| } |
| } |
| |
| function addMessage(role, text, loading = false) { |
| const container = document.getElementById('messages'); |
| const div = document.createElement('div'); |
| div.className = `msg ${role}`; |
| const label = role === 'user' ? 'You' : 'Agent'; |
| const avatarText = role === 'user' ? 'YOU' : 'KB'; |
| div.innerHTML = ` |
| <div class="msg-avatar">${avatarText}</div> |
| <div class="msg-body"> |
| <div class="msg-label">${label}</div> |
| <div class="msg-text">${loading ? '<div class="typing"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>' : escapeHtml(text)}</div> |
| </div>`; |
| container.appendChild(div); |
| scrollToBottom(); |
| return div; |
| } |
| |
| function finishMessage(div, text) { |
| div.querySelector('.msg-text').textContent = text; |
| scrollToBottom(); |
| } |
| |
| function scrollToBottom() { |
| const c = document.getElementById('messages'); |
| c.scrollTop = c.scrollHeight; |
| } |
| |
| function setStatus(msg) { |
| document.getElementById('status-bar').textContent = msg; |
| } |
| |
| function escapeHtml(s) { |
| return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); |
| } |
| </script> |
| </body> |
| </html>""" |