IntegraChat / frontend /components /knowledge-base-panel.tsx
nothingworry's picture
document deletion and improve tenant ID management
345b8ff
raw
history blame
21 kB
"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>
);
}