Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import Link from "next/link"; | |
| import { useTenant } from "@/contexts/TenantContext"; | |
| type Document = { | |
| id: number; | |
| text: string; | |
| created_at: string | null; | |
| }; | |
| type DocumentListResponse = { | |
| documents: Document[]; | |
| total: number; | |
| limit: number; | |
| offset: number; | |
| }; | |
| const API_BASE = | |
| process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000"; | |
| export default function KnowledgeBasePage() { | |
| const { tenantId, isLoading: tenantLoading } = useTenant(); | |
| const [documents, setDocuments] = useState<Document[]>([]); | |
| const [total, setTotal] = useState(0); | |
| const [loading, setLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [searchFilter, setSearchFilter] = useState(""); | |
| const [filterType, setFilterType] = useState<"all" | "pdf" | "text" | "faq" | "link">("all"); | |
| const [isDeleting, setIsDeleting] = useState<number | null>(null); | |
| const [isDeletingAll, setIsDeletingAll] = useState(false); | |
| async function loadDocuments() { | |
| // Guard against empty tenant ID | |
| if (!tenantId || !tenantId.trim()) { | |
| setError("Please enter a tenant ID"); | |
| setLoading(false); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const response = await fetch( | |
| `${API_BASE}/rag/list?limit=1000&offset=0`, | |
| { | |
| headers: { | |
| "x-tenant-id": tenantId, | |
| }, | |
| }, | |
| ); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| const errorMsg = errorData.detail || errorData.message || `Failed to load documents (${response.status})`; | |
| if (response.status === 400) { | |
| throw new Error(errorMsg.includes("tenant") | |
| ? "Missing tenant ID. Please enter a tenant ID in the navbar." | |
| : errorMsg); | |
| } else if (response.status === 503) { | |
| throw new Error("Cannot connect to RAG MCP server. Please ensure the RAG server is running."); | |
| } else { | |
| throw new Error(errorMsg); | |
| } | |
| } | |
| const data: DocumentListResponse = await response.json(); | |
| setDocuments(data.documents || []); | |
| setTotal(data.total || 0); | |
| } catch (err) { | |
| console.error(err); | |
| setError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to load knowledge base. Please check if the backend services are running.", | |
| ); | |
| } finally { | |
| setLoading(false); | |
| } | |
| } | |
| useEffect(() => { | |
| // Wait for tenant context to finish loading, then load documents if tenant ID is available | |
| if (!tenantLoading && tenantId && tenantId.trim()) { | |
| loadDocuments(); | |
| } | |
| }, [tenantId, tenantLoading]); | |
| // Filter documents based on search and type | |
| const filteredDocuments = documents.filter((doc) => { | |
| const matchesSearch = | |
| !searchFilter || | |
| doc.text.toLowerCase().includes(searchFilter.toLowerCase()); | |
| // Simple heuristics for document type detection | |
| const textLower = doc.text.toLowerCase(); | |
| let docType: "pdf" | "text" | "faq" | "link" = "text"; | |
| if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) { | |
| docType = "link"; | |
| } else if ( | |
| textLower.includes("q:") || | |
| textLower.includes("question:") || | |
| textLower.includes("faq") || | |
| textLower.includes("frequently asked") | |
| ) { | |
| docType = "faq"; | |
| } else if (textLower.includes(".pdf") || textLower.includes("pdf document")) { | |
| docType = "pdf"; | |
| } | |
| const matchesType = filterType === "all" || docType === filterType; | |
| return matchesSearch && matchesType; | |
| }); | |
| const getDocumentType = (text: string): "pdf" | "text" | "faq" | "link" => { | |
| const textLower = text.toLowerCase(); | |
| if (textLower.includes("http://") || textLower.includes("https://") || textLower.includes("www.")) { | |
| return "link"; | |
| } else if ( | |
| textLower.includes("q:") || | |
| textLower.includes("question:") || | |
| textLower.includes("faq") || | |
| textLower.includes("frequently asked") | |
| ) { | |
| return "faq"; | |
| } else if (textLower.includes(".pdf") || textLower.includes("pdf document")) { | |
| return "pdf"; | |
| } | |
| return "text"; | |
| }; | |
| const getTypeColor = (type: string) => { | |
| switch (type) { | |
| case "pdf": | |
| return "bg-red-500/20 text-red-300 border-red-500/30"; | |
| case "faq": | |
| return "bg-purple-500/20 text-purple-300 border-purple-500/30"; | |
| case "link": | |
| return "bg-blue-500/20 text-blue-300 border-blue-500/30"; | |
| default: | |
| return "bg-slate-500/20 text-slate-300 border-slate-500/30"; | |
| } | |
| }; | |
| async function handleDeleteDocument(documentId: number) { | |
| if (!tenantId.trim() || isDeleting !== null) return; | |
| setIsDeleting(documentId); | |
| try { | |
| const response = await fetch(`${API_BASE}/rag/delete/${documentId}`, { | |
| method: "DELETE", | |
| headers: { | |
| "x-tenant-id": tenantId, | |
| }, | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| const errorMsg = errorData.detail || `Failed to delete document: ${response.status}`; | |
| throw new Error(errorMsg); | |
| } | |
| // Remove from local state and update total | |
| setDocuments(docs => docs.filter(doc => doc.id !== documentId)); | |
| setTotal(prev => Math.max(0, prev - 1)); | |
| } catch (err) { | |
| console.error(err); | |
| setError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to delete document", | |
| ); | |
| } finally { | |
| setIsDeleting(null); | |
| } | |
| } | |
| async function handleDeleteAll() { | |
| if (!tenantId.trim() || isDeletingAll) return; | |
| if (!confirm("Are you sure you want to delete ALL documents for this tenant? This action cannot be undone.")) { | |
| return; | |
| } | |
| setIsDeletingAll(true); | |
| try { | |
| const response = await fetch(`${API_BASE}/rag/delete-all`, { | |
| method: "DELETE", | |
| headers: { | |
| "x-tenant-id": tenantId, | |
| }, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to delete all documents: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setDocuments([]); | |
| setTotal(0); | |
| } catch (err) { | |
| console.error(err); | |
| setError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to delete all documents", | |
| ); | |
| } finally { | |
| setIsDeletingAll(false); | |
| } | |
| } | |
| return ( | |
| <main className="mx-auto flex min-h-screen max-w-7xl flex-col gap-8 px-4 pb-16 pt-12 sm:px-6 lg:px-8"> | |
| {/* Header */} | |
| <header className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-sm text-slate-100 shadow-lg shadow-slate-950/40"> | |
| <div className="flex items-center gap-3"> | |
| <Link | |
| href="/" | |
| className="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-gradient-to-br from-sky-400 to-cyan-500 text-slate-950 transition hover:scale-105" | |
| > | |
| ← | |
| </Link> | |
| <div> | |
| <h1 className="text-xl font-semibold">Knowledge Base Library</h1> | |
| <p className="text-xs text-slate-400"> | |
| All ingested documents, PDFs, FAQs, links, and text content | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| {documents.length > 0 && ( | |
| <button | |
| onClick={handleDeleteAll} | |
| disabled={isDeletingAll} | |
| className="rounded-full border border-red-500/50 bg-red-500/10 px-5 py-2 text-sm font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60" | |
| > | |
| {isDeletingAll ? "Deleting…" : "Delete All"} | |
| </button> | |
| )} | |
| <button | |
| onClick={loadDocuments} | |
| disabled={loading} | |
| className="rounded-full bg-gradient-to-r from-sky-400 to-cyan-500 px-5 py-2 text-sm font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:opacity-60" | |
| > | |
| {loading ? "Loading…" : "Refresh"} | |
| </button> | |
| </div> | |
| </header> | |
| {/* Stats & Filters */} | |
| <div className="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-white/10 bg-slate-950/40 p-6"> | |
| <div className="flex flex-wrap items-center gap-6"> | |
| <div> | |
| <p className="text-xs uppercase tracking-widest text-slate-400"> | |
| Total Documents | |
| </p> | |
| <p className="mt-1 text-2xl font-semibold text-white">{total}</p> | |
| </div> | |
| <div> | |
| <p className="text-xs uppercase tracking-widest text-slate-400"> | |
| Filtered | |
| </p> | |
| <p className="mt-1 text-2xl font-semibold text-cyan-300"> | |
| {filteredDocuments.length} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex flex-wrap items-center gap-3"> | |
| <input | |
| type="text" | |
| placeholder="Search documents..." | |
| value={searchFilter} | |
| onChange={(e) => setSearchFilter(e.target.value)} | |
| className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-white outline-none focus:border-cyan-300" | |
| /> | |
| <div className="flex gap-2"> | |
| {(["all", "text", "pdf", "faq", "link"] as const).map((type) => ( | |
| <button | |
| key={type} | |
| onClick={() => setFilterType(type)} | |
| className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-wider transition ${ | |
| filterType === type | |
| ? "bg-cyan-500 text-slate-950" | |
| : "bg-white/5 text-slate-300 hover:bg-white/10" | |
| }`} | |
| > | |
| {type} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Error Message */} | |
| {error && ( | |
| <div className="rounded-2xl border border-red-500/40 bg-red-500/10 px-6 py-4 text-red-200"> | |
| <p className="font-semibold">⚠️ Error loading knowledge base</p> | |
| <p className="mt-1 text-sm">{error}</p> | |
| <button | |
| onClick={() => { | |
| setError(null); | |
| loadDocuments(); | |
| }} | |
| className="mt-3 rounded-lg border border-red-500/50 bg-red-500/20 px-4 py-2 text-sm font-semibold text-red-200 transition hover:bg-red-500/30" | |
| > | |
| Try Again | |
| </button> | |
| </div> | |
| )} | |
| {/* Documents Grid */} | |
| {loading ? ( | |
| <div className="flex items-center justify-center py-20"> | |
| <p className="text-slate-400">Loading documents...</p> | |
| </div> | |
| ) : filteredDocuments.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center rounded-2xl border border-white/10 bg-slate-950/40 py-20"> | |
| <p className="text-lg font-semibold text-slate-300"> | |
| No documents found | |
| </p> | |
| <p className="mt-2 text-sm text-slate-400"> | |
| {documents.length === 0 | |
| ? "Start by ingesting some content in the Knowledge Base panel." | |
| : "Try adjusting your search or filter criteria."} | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> | |
| {filteredDocuments.map((doc) => { | |
| const docType = getDocumentType(doc.text); | |
| const preview = doc.text.slice(0, 200) + (doc.text.length > 200 ? "..." : ""); | |
| return ( | |
| <div | |
| key={doc.id} | |
| className="group relative rounded-2xl border border-white/10 bg-slate-950/40 p-5 transition hover:border-cyan-500/50 hover:bg-slate-900/60" | |
| > | |
| <div className="mb-3 flex items-start justify-between gap-2"> | |
| <span | |
| className={`rounded-full border px-2.5 py-1 text-xs font-semibold uppercase tracking-wider ${getTypeColor( | |
| docType, | |
| )}`} | |
| > | |
| {docType} | |
| </span> | |
| {doc.created_at && ( | |
| <span className="text-xs text-slate-500"> | |
| {new Date(doc.created_at).toLocaleDateString()} | |
| </span> | |
| )} | |
| </div> | |
| <p className="text-sm leading-relaxed text-slate-200 line-clamp-6"> | |
| {preview} | |
| </p> | |
| <div className="mt-4 flex items-center justify-between"> | |
| <div className="flex items-center gap-2 text-xs text-slate-400"> | |
| <span>ID: {doc.id}</span> | |
| <span>•</span> | |
| <span>{doc.text.length} chars</span> | |
| </div> | |
| <button | |
| onClick={() => handleDeleteDocument(doc.id)} | |
| disabled={isDeleting === doc.id} | |
| className="rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60" | |
| > | |
| {isDeleting === doc.id ? "Deleting…" : "Delete"} | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Footer */} | |
| <div className="mt-8 rounded-2xl border border-white/10 bg-white/5 px-6 py-4 text-center text-sm text-slate-400"> | |
| <p> | |
| Knowledge base powered by pgvector + MiniLM embeddings •{" "} | |
| <Link href="/" className="text-cyan-300 hover:text-cyan-200"> | |
| Back to Console | |
| </Link> | |
| </p> | |
| </div> | |
| </main> | |
| ); | |
| } | |