mirror of
https://github.com/furyhawk/agent_alpha.git
synced 2026-07-21 02:25:34 +00:00
feat: Add RAG dashboard and API integration for document management
This commit is contained in:
+30
-11
@@ -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<UserSessionData[]>([]);
|
||||
@@ -419,7 +421,11 @@ export default function App() {
|
||||
/* ── Admin UI ────────────────────────────────────────────────────── */
|
||||
|
||||
if (showAdmin && authenticated.role === "admin") {
|
||||
return <AdminDashboard onBack={() => setShowAdmin(false)} />;
|
||||
return <AdminDashboard onBack={() => { setShowAdmin(false); setShowRag(false); }} />;
|
||||
}
|
||||
|
||||
if (showRag && authenticated.role === "admin") {
|
||||
return <RagDashboard onBack={() => { setShowRag(false); setShowAdmin(false); }} />;
|
||||
}
|
||||
|
||||
/* ── Chat UI ─────────────────────────────────────────────────────── */
|
||||
@@ -472,16 +478,29 @@ export default function App() {
|
||||
|
||||
{/* Admin button — only for admin role */}
|
||||
{authenticated.role === "admin" && (
|
||||
<button
|
||||
onClick={() => setShowAdmin(true)}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-purple-400 transition hover:border-purple-500 hover:text-purple-300"
|
||||
title="Admin dashboard"
|
||||
>
|
||||
<svg className="mr-1 inline-block h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Admin
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAdmin(true)}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-purple-400 transition hover:border-purple-500 hover:text-purple-300"
|
||||
title="Admin dashboard"
|
||||
>
|
||||
<svg className="mr-1 inline-block h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Admin
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRag(true)}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-emerald-400 transition hover:border-emerald-500 hover:text-emerald-300"
|
||||
title="RAG Dashboard"
|
||||
>
|
||||
<svg className="mr-1 inline-block h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9a2 2 0 00-1-1.73l-5-3a2 2 0 00-2 0l-5 3A2 2 0 005 9v10a2 2 0 002 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 17v-5m0 0l-2-2m2 2l2-2" />
|
||||
</svg>
|
||||
RAG
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Logout button */}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string[]>([]);
|
||||
const [collectionsInfo, setCollectionsInfo] = useState<Map<string, RAGCollectionInfo>>(new Map());
|
||||
const [documents, setDocuments] = useState<RAGTrackedDocument[]>([]);
|
||||
const [syncLogs, setSyncLogs] = useState<RAGSyncLog[]>([]);
|
||||
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<string, RAGCollectionInfo>();
|
||||
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 (
|
||||
<div className="mx-auto flex h-dvh max-w-6xl flex-col">
|
||||
{/* Header */}
|
||||
<header className="flex items-center gap-3 border-b border-gray-800 px-6 py-4">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-gray-700 p-2 text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400"
|
||||
title="Back to chat"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br from-emerald-600 to-teal-500 text-sm font-bold text-white shadow-lg shadow-emerald-500/25">
|
||||
R
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-lg font-semibold leading-tight tracking-tight">
|
||||
RAG Dashboard
|
||||
</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
{totalVectors.toLocaleString()} vectors · {totalDocuments} documents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={loadAll}
|
||||
disabled={busy}
|
||||
className="rounded-lg border border-gray-700 px-3 py-1.5 text-sm text-gray-400 transition hover:border-indigo-500 hover:text-indigo-400 disabled:opacity-40"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-800 px-6 pt-3">
|
||||
{(["overview", "documents", "collections"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`rounded-t-lg px-4 py-2 text-sm font-medium capitalize transition ${
|
||||
tab === t
|
||||
? "border border-b-0 border-gray-700 bg-gray-900 text-gray-100"
|
||||
: "text-gray-500 hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{t === "documents" && (
|
||||
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
|
||||
{totalDocuments}
|
||||
</span>
|
||||
)}
|
||||
{t === "collections" && (
|
||||
<span className="ml-1.5 rounded-full bg-gray-800 px-1.5 py-0.5 text-xs">
|
||||
{collections.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-900/40 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && tab === "overview" ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
</div>
|
||||
) : tab === "overview" ? (
|
||||
<OverviewTab
|
||||
collections={collections}
|
||||
collectionsInfo={collectionsInfo}
|
||||
totalDocuments={totalDocuments}
|
||||
doneDocuments={doneDocuments}
|
||||
errorDocuments={errorDocuments}
|
||||
syncLogs={syncLogs}
|
||||
/>
|
||||
) : tab === "documents" ? (
|
||||
<DocumentsTab
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRetry={handleRetryDocument}
|
||||
onUpload={loadAll}
|
||||
/>
|
||||
) : (
|
||||
<CollectionsTab
|
||||
collections={collections}
|
||||
collectionsInfo={collectionsInfo}
|
||||
onDelete={handleDeleteCollection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Overview Tab */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function OverviewTab({
|
||||
collections,
|
||||
collectionsInfo,
|
||||
totalDocuments,
|
||||
doneDocuments,
|
||||
errorDocuments,
|
||||
syncLogs,
|
||||
}: {
|
||||
collections: string[];
|
||||
collectionsInfo: Map<string, RAGCollectionInfo>;
|
||||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className="rounded-xl border border-gray-800 bg-gray-900/60 p-5"
|
||||
>
|
||||
<p className="text-sm text-gray-500">{card.label}</p>
|
||||
<p className="mt-1 text-3xl font-bold text-gray-100">
|
||||
{card.value}
|
||||
</p>
|
||||
<div
|
||||
className={`mt-3 h-1 w-full rounded-full bg-gradient-to-r ${card.color}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Document status breakdown */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Document Status
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<span className="w-24 text-sm text-gray-400">{label}</span>
|
||||
<div className="flex-1">
|
||||
<div className="h-2 rounded-full bg-gray-800">
|
||||
<div
|
||||
className={`h-2 rounded-full bg-gradient-to-r ${color}`}
|
||||
style={{
|
||||
width: `${totalDocuments > 0 ? (count / totalDocuments) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="w-8 text-right text-sm text-gray-400">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Collection details */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Collections
|
||||
</h3>
|
||||
{collections.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-gray-500">
|
||||
No collections yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{collections.slice(0, 10).map((name) => {
|
||||
const info = collectionsInfo.get(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded-lg bg-gray-800/50 px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm text-gray-200">{name}</p>
|
||||
{info && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{info.total_vectors.toLocaleString()} vectors · {info.dim} dims
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest sync */}
|
||||
{latestSync && (
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Latest Sync
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-gray-400">
|
||||
Collection:{" "}
|
||||
<span className="text-gray-200">{latestSync.collection_name}</span>
|
||||
</p>
|
||||
<p className="text-gray-400">
|
||||
Status:{" "}
|
||||
<span className={statusBadge(latestSync.status)}>
|
||||
{latestSync.status}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-400">
|
||||
Files:{" "}
|
||||
<span className="text-gray-200">
|
||||
{latestSync.ingested} ingested · {latestSync.failed} failed ·{" "}
|
||||
{latestSync.skipped} skipped
|
||||
</span>
|
||||
</p>
|
||||
{latestSync.error_message && (
|
||||
<p className="text-red-400">Error: {latestSync.error_message}</p>
|
||||
)}
|
||||
{latestSync.completed_at && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Completed: {new Date(latestSync.completed_at).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Documents Tab */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function DocumentsTab({
|
||||
documents,
|
||||
onDelete,
|
||||
onRetry,
|
||||
onUpload,
|
||||
}: {
|
||||
documents: RAGTrackedDocument[];
|
||||
onDelete: (id: string) => void;
|
||||
onRetry: (id: string) => void;
|
||||
onUpload: () => void;
|
||||
}) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [uploadError, setUploadError] = useState("");
|
||||
const [collectionFilter, setCollectionFilter] = useState("");
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Upload section */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold text-gray-300">
|
||||
Import Document
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileRef}
|
||||
onChange={handleUpload}
|
||||
accept=".pdf,.docx,.txt,.md"
|
||||
className="block w-full max-w-xs text-sm text-gray-400 file:mr-3 file:cursor-pointer file:rounded-lg file:border-0 file:bg-indigo-600 file:px-3 file:py-1.5 file:text-sm file:font-medium file:text-white hover:file:bg-indigo-500"
|
||||
/>
|
||||
<select
|
||||
value={collectionFilter}
|
||||
onChange={(e) => setCollectionFilter(e.target.value)}
|
||||
className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">Default collection</option>
|
||||
{collections.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{uploadBusy && (
|
||||
<span className="text-sm text-amber-400">Uploading…</span>
|
||||
)}
|
||||
</div>
|
||||
{uploadError && (
|
||||
<p className="mt-2 text-sm text-red-400">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documents table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="pb-3 pr-4">Filename</th>
|
||||
<th className="pb-3 pr-4">Collection</th>
|
||||
<th className="pb-3 pr-4">Size</th>
|
||||
<th className="pb-3 pr-4">Status</th>
|
||||
<th className="pb-3 pr-4">Chunks</th>
|
||||
<th className="pb-3 pr-4">Created</th>
|
||||
<th className="pb-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-8 text-center text-gray-500">
|
||||
No documents yet. Upload a PDF, DOCX, TXT, or MD file above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filtered.map((doc) => (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="max-w-48 py-3 pr-4">
|
||||
<p className="truncate text-sm text-gray-200">
|
||||
{doc.filename}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className="text-xs text-gray-400">
|
||||
{doc.collection_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-400">
|
||||
{formatSize(doc.filesize)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<span className={statusBadge(doc.status)}>
|
||||
{doc.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-400">
|
||||
{doc.chunk_count}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-xs text-gray-500">
|
||||
{doc.created_at
|
||||
? new Date(doc.created_at).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<div className="flex gap-1">
|
||||
{doc.status === "error" && (
|
||||
<button
|
||||
onClick={() => onRetry(doc.id)}
|
||||
className="rounded-lg px-2 py-1 text-xs text-amber-400 transition hover:bg-amber-900/30"
|
||||
title="Retry ingestion"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(doc.id)}
|
||||
className="rounded-lg px-2 py-1 text-xs text-red-400 transition hover:bg-red-900/30"
|
||||
title="Delete document"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Collections Tab */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
function CollectionsTab({
|
||||
collections,
|
||||
collectionsInfo,
|
||||
onDelete,
|
||||
}: {
|
||||
collections: string[];
|
||||
collectionsInfo: Map<string, RAGCollectionInfo>;
|
||||
onDelete: (name: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="pb-3 pr-4">Name</th>
|
||||
<th className="pb-3 pr-4">Vectors</th>
|
||||
<th className="pb-3 pr-4">Dimensions</th>
|
||||
<th className="pb-3 pr-4">Status</th>
|
||||
<th className="pb-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{collections.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-8 text-center text-gray-500">
|
||||
No collections found in Milvus.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
collections.map((name) => {
|
||||
const info = collectionsInfo.get(name);
|
||||
return (
|
||||
<tr
|
||||
key={name}
|
||||
className="border-b border-gray-800/50 transition hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<p className="font-medium text-gray-200">{name}</p>
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-400">
|
||||
{info
|
||||
? info.total_vectors.toLocaleString()
|
||||
: "…"}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-gray-400">
|
||||
{info ? info.dim : "…"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{info && (
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
info.indexing_status === "complete"
|
||||
? "bg-emerald-900/50 text-emerald-400"
|
||||
: "bg-amber-900/50 text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{info.indexing_status}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<button
|
||||
onClick={() => onDelete(name)}
|
||||
className="rounded-lg px-2 py-1 text-xs text-red-400 transition hover:bg-red-900/30"
|
||||
title="Delete collection"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -322,3 +322,220 @@ export async function adminDeleteSession(sessionId: string): Promise<void> {
|
||||
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<string, unknown>;
|
||||
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<string[]> {
|
||||
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<RAGCollectionInfo> {
|
||||
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<string> {
|
||||
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<RAGTrackedDocumentList> {
|
||||
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<string> {
|
||||
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<RAGIngestResponse> {
|
||||
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<RAGSyncLogList> {
|
||||
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<RAGSearchResponse> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user