From 19abb59608333db1c76ee5e67d0df744823dc381 Mon Sep 17 00:00:00 2001 From: furyhawk Date: Sun, 14 Jun 2026 10:26:17 +0800 Subject: [PATCH] feat: Add RAG dashboard and API integration for document management --- frontend/src/App.tsx | 41 ++- frontend/src/RagDashboard.tsx | 658 ++++++++++++++++++++++++++++++++++ frontend/src/api.ts | 217 +++++++++++ frontend/tsconfig.tsbuildinfo | 1 + 4 files changed, 906 insertions(+), 11 deletions(-) create mode 100644 frontend/src/RagDashboard.tsx create mode 100644 frontend/tsconfig.tsbuildinfo diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e6f0475..3dd1d17 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { type UserSessionData, } from "./api"; import AdminDashboard from "./Admin"; +import RagDashboard from "./RagDashboard"; /* ------------------------------------------------------------------ */ /* Types */ @@ -226,6 +227,7 @@ export default function App() { /* ── Admin state ──────────────────────────────────────────────────── */ const [showAdmin, setShowAdmin] = useState(false); + const [showRag, setShowRag] = useState(false); /* ── Session sidebar state ───────────────────────────────────────── */ const [userSessions, setUserSessions] = useState([]); @@ -419,7 +421,11 @@ export default function App() { /* ── Admin UI ────────────────────────────────────────────────────── */ if (showAdmin && authenticated.role === "admin") { - return setShowAdmin(false)} />; + return { setShowAdmin(false); setShowRag(false); }} />; + } + + if (showRag && authenticated.role === "admin") { + return { setShowRag(false); setShowAdmin(false); }} />; } /* ── Chat UI ─────────────────────────────────────────────────────── */ @@ -472,16 +478,29 @@ export default function App() { {/* Admin button — only for admin role */} {authenticated.role === "admin" && ( - + <> + + + )} {/* Logout button */} diff --git a/frontend/src/RagDashboard.tsx b/frontend/src/RagDashboard.tsx new file mode 100644 index 0000000..d558f0e --- /dev/null +++ b/frontend/src/RagDashboard.tsx @@ -0,0 +1,658 @@ +import { useState, useEffect, useRef } from "react"; +import { + listRagCollections, + getRagCollectionInfo, + deleteRagCollection, + listRagDocuments, + deleteRagDocument, + retryRagDocument, + uploadRagDocument, + listRagSyncLogs, + type RAGCollectionInfo, + type RAGTrackedDocument, + type RAGSyncLog, +} from "./api"; + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Props */ +/* ──────────────────────────────────────────────────────────────────────── */ + +interface RagProps { + onBack: () => void; +} + +const STATUS_COLORS: Record = { + done: "bg-emerald-900/50 text-emerald-400", + processing: "bg-amber-900/50 text-amber-400", + error: "bg-red-900/50 text-red-400", + running: "bg-blue-900/50 text-blue-400", + cancelled: "bg-gray-800 text-gray-400", + partial: "bg-amber-900/50 text-amber-400", +}; + +function statusBadge(status: string) { + const c = STATUS_COLORS[status] ?? "bg-gray-800 text-gray-400"; + return `rounded-full px-2.5 py-0.5 text-xs font-medium ${c}`; +} + +function formatSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Main Dashboard */ +/* ──────────────────────────────────────────────────────────────────────── */ + +export default function RagDashboard({ onBack }: RagProps) { + const [tab, setTab] = useState<"overview" | "documents" | "collections">("overview"); + + const [collections, setCollections] = useState([]); + const [collectionsInfo, setCollectionsInfo] = useState>(new Map()); + const [documents, setDocuments] = useState([]); + const [syncLogs, setSyncLogs] = useState([]); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(""); + + /* ── Load all data ───────────────────────────────────────────────────── */ + + const loadAll = async () => { + setBusy(true); + setError(""); + try { + const [colNames, docs, logs] = await Promise.all([ + listRagCollections(), + listRagDocuments(), + listRagSyncLogs(), + ]); + setCollections(colNames); + setDocuments(docs.items); + setSyncLogs(logs.items); + + // Fetch info for each collection + const infoMap = new Map(); + await Promise.all( + colNames.map(async (name) => { + try { + infoMap.set(name, await getRagCollectionInfo(name)); + } catch { + /* skip */ + } + }), + ); + setCollectionsInfo(infoMap); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Failed to load RAG data"); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + loadAll(); + }, []); + + /* ── Derived stats ──────────────────────────────────────────────────── */ + + const totalVectors = Array.from(collectionsInfo.values()).reduce( + (sum, c) => sum + c.total_vectors, + 0, + ); + const totalDocuments = documents.length; + const doneDocuments = documents.filter((d) => d.status === "done").length; + const errorDocuments = documents.filter((d) => d.status === "error").length; + + /* ── Handlers ───────────────────────────────────────────────────────── */ + + const handleDeleteCollection = async (name: string) => { + if (!confirm(`Delete collection "${name}" and all its data?`)) return; + try { + await deleteRagCollection(name); + await loadAll(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Delete failed"); + } + }; + + const handleDeleteDocument = async (docId: string) => { + if (!confirm("Delete this document and all its vectors?")) return; + try { + await deleteRagDocument(docId); + await loadAll(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Delete failed"); + } + }; + + const handleRetryDocument = async (docId: string) => { + try { + await retryRagDocument(docId); + await loadAll(); + } catch (err: unknown) { + alert(err instanceof Error ? err.message : "Retry failed"); + } + }; + + /* ── Render ─────────────────────────────────────────────────────────── */ + + return ( +
+ {/* Header */} +
+ +
+ R +
+
+

+ RAG Dashboard +

+

+ {totalVectors.toLocaleString()} vectors · {totalDocuments} documents +

+
+ + +
+ + {/* Tabs */} +
+ {(["overview", "documents", "collections"] as const).map((t) => ( + + ))} +
+ + {/* Body */} +
+ {error && ( +
+ {error} +
+ )} + + {busy && tab === "overview" ? ( +
+

Loading…

+
+ ) : tab === "overview" ? ( + + ) : tab === "documents" ? ( + + ) : ( + + )} +
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Overview Tab */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function OverviewTab({ + collections, + collectionsInfo, + totalDocuments, + doneDocuments, + errorDocuments, + syncLogs, +}: { + collections: string[]; + collectionsInfo: Map; + totalDocuments: number; + doneDocuments: number; + errorDocuments: number; + syncLogs: RAGSyncLog[]; +}) { + const totalVectors = Array.from(collectionsInfo.values()).reduce( + (sum, c) => sum + c.total_vectors, + 0, + ); + const latestSync = syncLogs[0] ?? null; + + const cards = [ + { + label: "Collections", + value: collections.length, + color: "from-emerald-500 to-teal-600", + }, + { + label: "Total Vectors", + value: totalVectors.toLocaleString(), + color: "from-indigo-500 to-purple-600", + }, + { + label: "Documents", + value: totalDocuments, + color: "from-amber-500 to-orange-600", + }, + { + label: "Ingested", + value: doneDocuments, + color: "from-emerald-500 to-teal-600", + }, + ]; + + return ( +
+ {/* Stat cards */} +
+ {cards.map((card) => ( +
+

{card.label}

+

+ {card.value} +

+
+
+ ))} +
+ +
+ {/* Document status breakdown */} +
+

+ Document Status +

+
+ {[ + { label: "Done", count: doneDocuments, color: "from-emerald-500 to-teal-500" }, + { label: "Error", count: errorDocuments, color: "from-red-500 to-rose-500" }, + { label: "Processing", count: totalDocuments - doneDocuments - errorDocuments, color: "from-amber-500 to-orange-500" }, + ].map(({ label, count, color }) => ( +
+ {label} +
+
+
0 ? (count / totalDocuments) * 100 : 0}%`, + }} + /> +
+
+ + {count} + +
+ ))} +
+
+ + {/* Collection details */} +
+

+ Collections +

+ {collections.length === 0 ? ( +

+ No collections yet. +

+ ) : ( +
+ {collections.slice(0, 10).map((name) => { + const info = collectionsInfo.get(name); + return ( +
+
+

{name}

+ {info && ( +

+ {info.total_vectors.toLocaleString()} vectors · {info.dim} dims +

+ )} +
+
+ ); + })} +
+ )} +
+
+ + {/* Latest sync */} + {latestSync && ( +
+

+ Latest Sync +

+
+

+ Collection:{" "} + {latestSync.collection_name} +

+

+ Status:{" "} + + {latestSync.status} + +

+

+ Files:{" "} + + {latestSync.ingested} ingested · {latestSync.failed} failed ·{" "} + {latestSync.skipped} skipped + +

+ {latestSync.error_message && ( +

Error: {latestSync.error_message}

+ )} + {latestSync.completed_at && ( +

+ Completed: {new Date(latestSync.completed_at).toLocaleString()} +

+ )} +
+
+ )} +
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Documents Tab */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function DocumentsTab({ + documents, + onDelete, + onRetry, + onUpload, +}: { + documents: RAGTrackedDocument[]; + onDelete: (id: string) => void; + onRetry: (id: string) => void; + onUpload: () => void; +}) { + const fileRef = useRef(null); + const [uploadBusy, setUploadBusy] = useState(false); + const [uploadError, setUploadError] = useState(""); + const [collectionFilter, setCollectionFilter] = useState(""); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploadBusy(true); + setUploadError(""); + try { + const result = await uploadRagDocument(file, collectionFilter || undefined); + alert(`Uploaded: ${result.filename} (${result.status})`); + onUpload(); + } catch (err: unknown) { + setUploadError(err instanceof Error ? err.message : "Upload failed"); + } finally { + setUploadBusy(false); + if (fileRef.current) fileRef.current.value = ""; + } + }; + + const filtered = collectionFilter + ? documents.filter((d) => d.collection_name === collectionFilter) + : documents; + + const collections = [...new Set(documents.map((d) => d.collection_name))]; + + return ( +
+ {/* Upload section */} +
+

+ Import Document +

+
+ + + {uploadBusy && ( + Uploading… + )} +
+ {uploadError && ( +

{uploadError}

+ )} +
+ + {/* Documents table */} +
+ + + + + + + + + + + + + {filtered.length === 0 ? ( + + + + ) : ( + filtered.map((doc) => ( + + + + + + + + + + )) + )} + +
FilenameCollectionSizeStatusChunksCreated +
+ No documents yet. Upload a PDF, DOCX, TXT, or MD file above. +
+

+ {doc.filename} +

+
+ + {doc.collection_name} + + + {formatSize(doc.filesize)} + + + {doc.status} + + + {doc.chunk_count} + + {doc.created_at + ? new Date(doc.created_at).toLocaleDateString() + : "—"} + +
+ {doc.status === "error" && ( + + )} + +
+
+
+
+ ); +} + +/* ──────────────────────────────────────────────────────────────────────── */ +/* Collections Tab */ +/* ──────────────────────────────────────────────────────────────────────── */ + +function CollectionsTab({ + collections, + collectionsInfo, + onDelete, +}: { + collections: string[]; + collectionsInfo: Map; + onDelete: (name: string) => void; +}) { + return ( +
+ + + + + + + + + + + {collections.length === 0 ? ( + + + + ) : ( + collections.map((name) => { + const info = collectionsInfo.get(name); + return ( + + + + + + + + ); + }) + )} + +
NameVectorsDimensionsStatus +
+ No collections found in Milvus. +
+

{name}

+
+ {info + ? info.total_vectors.toLocaleString() + : "…"} + + {info ? info.dim : "…"} + + {info && ( + + {info.indexing_status} + + )} + + +
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0607b62..f9eee87 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -322,3 +322,220 @@ export async function adminDeleteSession(sessionId: string): Promise { throw new Error(body?.detail ?? `HTTP ${res.status}`); } } + +/* ── RAG API ─────────────────────────────────────────────────────── */ + +export interface RAGCollectionInfo { + name: string; + total_vectors: number; + dim: number; + indexing_status: string; +} + +export interface RAGCollectionList { + items: string[]; +} + +export interface RAGSearchResult { + content: string; + score: number; + metadata: Record; + parent_doc_id: string; +} + +export interface RAGSearchResponse { + results: RAGSearchResult[]; +} + +export interface RAGTrackedDocument { + id: string; + collection_name: string; + filename: string; + filesize: number; + filetype: string; + status: string; + error_message: string | null; + vector_document_id: string | null; + chunk_count: number; + has_file: boolean; + created_at: string | null; + completed_at: string | null; +} + +export interface RAGTrackedDocumentList { + items: RAGTrackedDocument[]; + total: number; +} + +export interface RAGIngestResponse { + id: string; + status: string; + filename: string; + collection: string; + message: string; +} + +export interface RAGSyncLog { + id: string; + source: string; + collection_name: string; + status: string; + mode: string; + total_files: number; + ingested: number; + updated: number; + skipped: number; + failed: number; + error_message: string | null; + started_at: string | null; + completed_at: string | null; +} + +export interface RAGSyncLogList { + items: RAGSyncLog[]; + total: number; +} + +export interface RAGMessageResponse { + message: string; +} + +/** List Milvus collections. */ +export async function listRagCollections(): Promise { + const res = await fetch(`${API_BASE}/rag/collections`, { + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + const data: RAGCollectionList = await res.json(); + return data.items; +} + +/** Get collection info. */ +export async function getRagCollectionInfo( + name: string, +): Promise { + const res = await fetch(`${API_BASE}/rag/collections/${encodeURIComponent(name)}`, { + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} + +/** Delete a collection. */ +export async function deleteRagCollection(name: string): Promise { + const res = await fetch(`${API_BASE}/rag/collections/${encodeURIComponent(name)}`, { + method: "DELETE", + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + const data: RAGMessageResponse = await res.json(); + return data.message; +} + +/** List tracked documents. */ +export async function listRagDocuments( + collectionName?: string, +): Promise { + const params = collectionName ? `?collection_name=${encodeURIComponent(collectionName)}` : ""; + const res = await fetch(`${API_BASE}/rag/documents${params}`, { + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} + +/** Delete a tracked document. */ +export async function deleteRagDocument(docId: string): Promise { + const res = await fetch(`${API_BASE}/rag/documents/${docId}`, { + method: "DELETE", + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + const data: RAGMessageResponse = await res.json(); + return data.message; +} + +/** Retry ingestion for a failed document. */ +export async function retryRagDocument(docId: string): Promise<{ id: string; status: string; message: string }> { + const res = await fetch(`${API_BASE}/rag/documents/${docId}/retry`, { + method: "POST", + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} + +/** Upload a file for RAG ingestion. */ +export async function uploadRagDocument( + file: File, + collectionName = "documents", + replace = true, +): Promise { + const formData = new FormData(); + formData.append("file", file); + const url = `${API_BASE}/rag/upload/${encodeURIComponent(collectionName)}?replace=${replace}`; + const res = await fetch(url, { + method: "POST", + headers: { ...authHeaders() }, // no Content-Type for FormData + body: formData, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} + +/** List sync logs. */ +export async function listRagSyncLogs( + collectionName?: string, + limit = 20, +): Promise { + const params = new URLSearchParams(); + if (collectionName) params.set("collection_name", collectionName); + params.set("limit", String(limit)); + const res = await fetch(`${API_BASE}/rag/sync-logs?${params}`, { + headers: { ...authHeaders() }, + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} + +/** Search across RAG collections. */ +export async function searchRag( + query: string, + collectionName = "documents", + limit = 5, +): Promise { + const res = await fetch(`${API_BASE}/rag/search`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ query, collection_name: collectionName, limit }), + }); + if (!res.ok) { + const body = await res.json().catch(() => null); + throw new Error(body?.detail ?? `HTTP ${res.status}`); + } + return res.json(); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..46050b4 --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/Admin.tsx","./src/App.tsx","./src/RagDashboard.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file