nothingworry's picture
document deletion and improve tenant ID management
345b8ff
raw
history blame
14 kB
"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>
);
}