Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useState, useRef, useEffect } from "react"; | |
| import Link from "next/link"; | |
| import { useTenant } from "@/contexts/TenantContext"; | |
| type SearchResult = { | |
| text: string; | |
| similarity?: number; | |
| relevance?: number; | |
| }; | |
| type Document = { | |
| id: number; | |
| text: string; | |
| created_at?: string; | |
| }; | |
| type SourceType = "raw_text" | "url" | "pdf" | "docx" | "txt" | "markdown"; | |
| const API_BASE = | |
| process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, "") || "http://localhost:8000"; | |
| export function KnowledgeBasePanel() { | |
| const { tenantId, isLoading: tenantLoading } = useTenant(); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [searchResults, setSearchResults] = useState<SearchResult[]>([]); | |
| const [isSearching, setIsSearching] = useState(false); | |
| const [ingestContent, setIngestContent] = useState(""); | |
| const [sourceType, setSourceType] = useState<SourceType>("raw_text"); | |
| const [filename, setFilename] = useState(""); | |
| const [url, setUrl] = useState(""); | |
| const [isIngesting, setIsIngesting] = useState(false); | |
| const [ingestStatus, setIngestStatus] = useState<string | null>(null); | |
| const [searchError, setSearchError] = useState<string | null>(null); | |
| const [documents, setDocuments] = useState<Document[]>([]); | |
| const [isLoadingDocs, setIsLoadingDocs] = useState(false); | |
| const [isDeleting, setIsDeleting] = useState<number | null>(null); | |
| const [isDeletingAll, setIsDeletingAll] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| async function handleSearch() { | |
| if (!searchQuery.trim() || isSearching) return; | |
| setIsSearching(true); | |
| setSearchError(null); | |
| setSearchResults([]); | |
| try { | |
| const response = await fetch(`${API_BASE}/rag/search`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "x-tenant-id": tenantId, | |
| }, | |
| body: JSON.stringify({ query: searchQuery }), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Search failed: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| setSearchResults(data.results || []); | |
| } catch (err) { | |
| console.error(err); | |
| setSearchError( | |
| err instanceof Error | |
| ? err.message | |
| : "Failed to search knowledge base. Is the RAG MCP server running?", | |
| ); | |
| } finally { | |
| setIsSearching(false); | |
| } | |
| } | |
| async function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| // Detect file type from extension | |
| const ext = file.name.split('.').pop()?.toLowerCase(); | |
| let detectedType: SourceType = "raw_text"; | |
| if (ext === "pdf") detectedType = "pdf"; | |
| else if (ext === "docx" || ext === "doc") detectedType = "docx"; | |
| else if (ext === "txt" || ext === "text") detectedType = "txt"; | |
| else if (ext === "md" || ext === "markdown") detectedType = "markdown"; | |
| setSourceType(detectedType); | |
| setFilename(file.name); | |
| // For binary files (PDF, DOCX), upload directly to server | |
| if (detectedType === "pdf" || detectedType === "docx") { | |
| await handleFileIngest(file); | |
| return; | |
| } | |
| // For text files, read and show in textarea | |
| const reader = new FileReader(); | |
| reader.onload = async (e) => { | |
| const text = e.target?.result as string; | |
| setIngestContent(text); | |
| }; | |
| reader.readAsText(file); | |
| } | |
| async function handleFileIngest(file: File) { | |
| setIsIngesting(true); | |
| setIngestStatus(null); | |
| try { | |
| const formData = new FormData(); | |
| formData.append("file", file); | |
| const response = await fetch(`${API_BASE}/rag/ingest-file`, { | |
| method: "POST", | |
| headers: { | |
| "x-tenant-id": tenantId, | |
| }, | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error( | |
| errorData.detail || `File ingestion failed: ${response.status}`, | |
| ); | |
| } | |
| const data = await response.json(); | |
| setIngestStatus( | |
| `✅ ${data.message || `Successfully ingested ${data.chunks_stored || 0} chunk(s)`}`, | |
| ); | |
| setFilename(""); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ""; | |
| } | |
| // Reload documents after successful ingestion | |
| loadDocuments(); | |
| } catch (err) { | |
| console.error(err); | |
| setIngestStatus( | |
| err instanceof Error | |
| ? `❌ Error: ${err.message}` | |
| : "Failed to ingest file. Is the RAG MCP server running?", | |
| ); | |
| } finally { | |
| setIsIngesting(false); | |
| } | |
| } | |
| async function handleIngest() { | |
| if (!ingestContent.trim() || isIngesting) return; | |
| setIsIngesting(true); | |
| setIngestStatus(null); | |
| try { | |
| // Prepare metadata | |
| const metadata: Record<string, string> = {}; | |
| if (filename) metadata.filename = filename; | |
| if (url || sourceType === "url") { | |
| const ingestUrl = url || ingestContent.trim(); | |
| metadata.url = ingestUrl; | |
| } | |
| if (filename) { | |
| // Generate doc_id from filename | |
| metadata.doc_id = filename.replace(/[^a-zA-Z0-9]/g, "_").toLowerCase(); | |
| } | |
| // Use the new enhanced endpoint | |
| const response = await fetch(`${API_BASE}/rag/ingest-document`, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "x-tenant-id": tenantId, | |
| }, | |
| body: JSON.stringify({ | |
| action: "ingest_document", | |
| tenant_id: tenantId, | |
| source_type: sourceType, | |
| content: sourceType === "url" ? (url || ingestContent.trim()) : ingestContent, | |
| metadata: Object.keys(metadata).length > 0 ? metadata : undefined, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error( | |
| errorData.detail || `Ingestion failed: ${response.status}`, | |
| ); | |
| } | |
| const data = await response.json(); | |
| setIngestStatus( | |
| `✅ ${data.message || `Successfully ingested ${data.chunks_stored || 0} chunk(s)`}`, | |
| ); | |
| setIngestContent(""); | |
| setFilename(""); | |
| setUrl(""); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ""; | |
| } | |
| // Reload documents after successful ingestion | |
| loadDocuments(); | |
| } catch (err) { | |
| console.error(err); | |
| setIngestStatus( | |
| err instanceof Error | |
| ? `❌ Error: ${err.message}` | |
| : "Failed to ingest content. Is the RAG MCP server running?", | |
| ); | |
| } finally { | |
| setIsIngesting(false); | |
| } | |
| } | |
| async function loadDocuments() { | |
| // Guard against empty tenant ID | |
| if (!tenantId || !tenantId.trim() || isLoadingDocs) return; | |
| setIsLoadingDocs(true); | |
| try { | |
| const response = await fetch(`${API_BASE}/rag/list?limit=10&offset=0`, { | |
| method: "GET", | |
| 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) { | |
| // Missing tenant ID - silently fail, user will see empty list | |
| console.warn("Cannot load documents: Missing tenant ID"); | |
| setDocuments([]); | |
| return; | |
| } else if (response.status === 503) { | |
| console.error("Cannot connect to RAG MCP server"); | |
| setDocuments([]); | |
| return; | |
| } else { | |
| throw new Error(errorMsg); | |
| } | |
| } | |
| const data = await response.json(); | |
| setDocuments(data.documents || []); | |
| } catch (err) { | |
| console.error(err); | |
| setDocuments([]); | |
| // Don't show error in status for document loading - it's not critical | |
| } finally { | |
| setIsLoadingDocs(false); | |
| } | |
| } | |
| 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 | |
| setDocuments(docs => docs.filter(doc => doc.id !== documentId)); | |
| setIngestStatus("✅ Document deleted successfully"); | |
| } catch (err) { | |
| console.error(err); | |
| setIngestStatus( | |
| err instanceof Error | |
| ? `❌ 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([]); | |
| setIngestStatus(`✅ Deleted ${data.deleted_count || 0} document(s)`); | |
| } catch (err) { | |
| console.error(err); | |
| setIngestStatus( | |
| err instanceof Error | |
| ? `❌ Error: ${err.message}` | |
| : "Failed to delete all documents", | |
| ); | |
| } finally { | |
| setIsDeletingAll(false); | |
| } | |
| } | |
| // Load documents on mount and when tenant changes | |
| useEffect(() => { | |
| // Wait for tenant context to finish loading, then load documents if tenant ID is available | |
| if (!tenantLoading && tenantId && tenantId.trim()) { | |
| loadDocuments(); | |
| } | |
| }, [tenantId, tenantLoading]); | |
| return ( | |
| <section | |
| id="knowledge-base" | |
| className="gradient-border relative rounded-[28px] p-1 text-white" | |
| > | |
| <div className="glass-panel relative rounded-[26px] p-6"> | |
| <div className="flex flex-wrap items-center justify-between gap-4"> | |
| <div> | |
| <p className="text-sm uppercase tracking-[0.5em] text-cyan-200/70"> | |
| Knowledge Base | |
| </p> | |
| <h2 className="mt-2 text-3xl font-semibold"> | |
| Search & ingest documents | |
| </h2> | |
| </div> | |
| <Link | |
| href="/knowledge-base" | |
| className="rounded-full border border-cyan-500/50 bg-cyan-500/10 px-5 py-2.5 text-sm font-semibold text-cyan-300 transition hover:bg-cyan-500/20" | |
| > | |
| View All Documents → | |
| </Link> | |
| </div> | |
| {/* Search Section */} | |
| <div className="mt-6"> | |
| <div className="flex flex-col gap-3 md:flex-row"> | |
| <input | |
| type="text" | |
| placeholder="Search knowledge base (e.g., 'HR policy', 'refund procedure')..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| onKeyDown={(e) => e.key === "Enter" && handleSearch()} | |
| className="flex-1 rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80" | |
| /> | |
| <button | |
| onClick={handleSearch} | |
| disabled={isSearching} | |
| className="min-w-[140px] rounded-2xl bg-gradient-to-r from-sky-400 to-cyan-500 px-6 py-3 font-semibold text-slate-950 shadow-lg shadow-cyan-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60" | |
| > | |
| {isSearching ? "Searching…" : "Search"} | |
| </button> | |
| </div> | |
| {searchError && ( | |
| <p className="mt-3 rounded-2xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200"> | |
| {searchError} | |
| </p> | |
| )} | |
| {searchResults.length > 0 && ( | |
| <div className="mt-4 space-y-3"> | |
| <p className="text-sm uppercase tracking-widest text-slate-400"> | |
| Found {searchResults.length} result(s) | |
| </p> | |
| {searchResults.map((result, idx) => ( | |
| <div | |
| key={idx} | |
| className="rounded-2xl border border-white/10 bg-slate-950/40 p-4" | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <p className="flex-1 text-sm text-slate-200"> | |
| {result.text} | |
| </p> | |
| {(result.similarity !== undefined || | |
| result.relevance !== undefined) && ( | |
| <span className="text-xs text-cyan-300"> | |
| {( | |
| result.similarity ?? result.relevance ?? 0 | |
| ).toFixed(2)} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Ingest Section */} | |
| <div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4"> | |
| <p className="text-sm uppercase tracking-[0.5em] text-slate-400"> | |
| Add to Knowledge Base | |
| </p> | |
| <p className="mt-2 text-sm text-slate-300"> | |
| Upload files (PDF, DOCX, TXT, MD), paste text, or provide URLs. Content will be chunked, embedded, and stored. | |
| </p> | |
| {/* Source Type Selector */} | |
| <div className="mt-4 flex flex-wrap gap-2"> | |
| {(["raw_text", "url", "pdf", "docx", "txt", "markdown"] as SourceType[]).map((type) => ( | |
| <button | |
| key={type} | |
| onClick={() => { | |
| setSourceType(type); | |
| if (type !== "url") setUrl(""); | |
| }} | |
| className={`rounded-full px-4 py-2 text-xs font-semibold uppercase tracking-wider transition ${ | |
| sourceType === type | |
| ? "bg-cyan-500 text-slate-950" | |
| : "bg-white/5 text-slate-300 hover:bg-white/10" | |
| }`} | |
| > | |
| {type.replace("_", " ")} | |
| </button> | |
| ))} | |
| </div> | |
| {/* File Upload */} | |
| <div className="mt-4"> | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".pdf,.docx,.doc,.txt,.md,.markdown" | |
| onChange={handleFileUpload} | |
| className="hidden" | |
| id="file-upload" | |
| /> | |
| <label | |
| htmlFor="file-upload" | |
| className="inline-flex cursor-pointer items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/10" | |
| > | |
| 📄 Upload File (PDF, DOCX, TXT, MD) | |
| </label> | |
| {filename && ( | |
| <span className="ml-3 text-sm text-cyan-300">{filename}</span> | |
| )} | |
| </div> | |
| {/* URL Input (when source type is URL) */} | |
| {sourceType === "url" && ( | |
| <input | |
| type="url" | |
| placeholder="Enter URL to fetch content from..." | |
| value={url} | |
| onChange={(e) => setUrl(e.target.value)} | |
| className="mt-4 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80" | |
| /> | |
| )} | |
| {/* Content Textarea */} | |
| <textarea | |
| placeholder={ | |
| sourceType === "url" | |
| ? "Or paste URL here..." | |
| : "Paste document content here (e.g., policy text, procedures, documentation, FAQs)..." | |
| } | |
| value={ingestContent} | |
| onChange={(e) => setIngestContent(e.target.value)} | |
| className="mt-4 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80" | |
| rows={6} | |
| /> | |
| {/* Filename Input (optional) */} | |
| {sourceType !== "url" && ( | |
| <input | |
| type="text" | |
| placeholder="Filename (optional, e.g., policy.pdf)" | |
| value={filename} | |
| onChange={(e) => setFilename(e.target.value)} | |
| className="mt-3 w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white outline-none focus:border-cyan-200/80" | |
| /> | |
| )} | |
| <div className="mt-4 flex items-center gap-3"> | |
| <button | |
| onClick={handleIngest} | |
| disabled={ | |
| isIngesting || | |
| (!ingestContent.trim() && !url.trim() && sourceType === "url") | |
| } | |
| className="rounded-2xl bg-gradient-to-r from-emerald-400 to-teal-500 px-6 py-2.5 font-semibold text-slate-950 shadow-lg shadow-emerald-500/30 transition hover:-translate-y-0.5 disabled:cursor-not-allowed disabled:opacity-60" | |
| > | |
| {isIngesting ? "Ingesting…" : `Ingest as ${sourceType.replace("_", " ")}`} | |
| </button> | |
| {ingestStatus && ( | |
| <p className="text-sm text-slate-300">{ingestStatus}</p> | |
| )} | |
| </div> | |
| </div> | |
| {/* Manage Documents Section */} | |
| <div className="mt-8 rounded-2xl border border-white/10 bg-slate-950/40 p-4"> | |
| <div className="flex items-center justify-between mb-4"> | |
| <div> | |
| <p className="text-sm uppercase tracking-[0.5em] text-slate-400"> | |
| Manage Documents | |
| </p> | |
| <p className="mt-2 text-sm text-slate-300"> | |
| View and delete your ingested documents | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={loadDocuments} | |
| disabled={isLoadingDocs} | |
| className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs font-semibold text-slate-300 transition hover:bg-white/10 disabled:opacity-60" | |
| > | |
| {isLoadingDocs ? "Loading…" : "Refresh"} | |
| </button> | |
| {documents.length > 0 && ( | |
| <button | |
| onClick={handleDeleteAll} | |
| disabled={isDeletingAll} | |
| className="rounded-full border border-red-500/50 bg-red-500/10 px-4 py-2 text-xs font-semibold text-red-300 transition hover:bg-red-500/20 disabled:opacity-60" | |
| > | |
| {isDeletingAll ? "Deleting…" : "Delete All"} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {documents.length === 0 && !isLoadingDocs && ( | |
| <p className="text-sm text-slate-400 text-center py-4"> | |
| No documents found. Ingest some content to get started. | |
| </p> | |
| )} | |
| {isLoadingDocs && ( | |
| <p className="text-sm text-slate-400 text-center py-4"> | |
| Loading documents… | |
| </p> | |
| )} | |
| {documents.length > 0 && ( | |
| <div className="space-y-2 max-h-96 overflow-y-auto"> | |
| {documents.map((doc) => ( | |
| <div | |
| key={doc.id} | |
| className="flex items-start justify-between gap-3 rounded-xl border border-white/10 bg-white/5 p-3" | |
| > | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-xs text-slate-400 mb-1"> | |
| ID: {doc.id} | |
| {doc.created_at && ( | |
| <span className="ml-2"> | |
| • {new Date(doc.created_at).toLocaleDateString()} | |
| </span> | |
| )} | |
| </p> | |
| <p className="text-sm text-slate-200 line-clamp-2"> | |
| {doc.text.length > 150 | |
| ? `${doc.text.substring(0, 150)}...` | |
| : doc.text} | |
| </p> | |
| </div> | |
| <button | |
| onClick={() => handleDeleteDocument(doc.id)} | |
| disabled={isDeleting === doc.id} | |
| className="flex-shrink-0 rounded-lg border border-red-500/50 bg-red-500/10 px-3 py-1.5 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> | |
| </div> | |
| </section> | |
| ); | |
| } | |