finai / apps /web /components /dynamic /KnowledgeBaseModal.tsx
Sandy-sys's picture
Deploy FinAI
db7ef8e verified
Raw History Blame Contribute Delete
19.6 kB
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
X,
Upload,
Database,
Trash2,
RefreshCcw,
Loader2,
CheckCircle2,
AlertTriangle,
Pencil,
Check,
} from "lucide-react";
type TableInfo = { name: string; rows: number | null; columns: string[] };
export function KnowledgeBaseModal({
open,
onClose,
onChange,
}: {
open: boolean;
onClose: () => void;
onChange?: (version: number) => void;
}) {
const [tables, setTables] = useState<TableInfo[]>([]);
const [version, setVersion] = useState<number>(0);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [uploadMode, setUploadMode] = useState<"replace" | "append">("replace");
const [tableNameOverride, setTableNameOverride] = useState("");
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchTables = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/backend/kb/tables");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json();
setTables(d.tables ?? []);
setVersion(d.version ?? 0);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) fetchTables();
}, [open, fetchTables]);
// Auto-clear toast messages
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(null), 3000);
return () => clearTimeout(t);
}, [success]);
const notifyChange = useCallback(
(v: number) => {
onChange?.(v);
// Broadcast a window-level event so other components (LiveDashboard,
// chat results, etc.) can refetch when the KB changes.
try {
window.dispatchEvent(new CustomEvent("finai:kb-changed", { detail: { version: v } }));
} catch {}
},
[onChange]
);
const doUpload = useCallback(
async (file: File) => {
setBusy(`upload:${file.name}`);
setError(null);
setSuccess(null);
try {
const fd = new FormData();
fd.append("file", file);
if (tableNameOverride.trim()) fd.append("table", tableNameOverride.trim());
const url = `/api/backend/kb/upload?mode=${uploadMode}${
tableNameOverride.trim() ? `&table=${encodeURIComponent(tableNameOverride.trim())}` : ""
}`;
const res = await fetch(url, { method: "POST", body: fd });
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || `HTTP ${res.status}`);
setSuccess(`Loaded ${d.rows} rows into ${d.table}`);
setTableNameOverride("");
await fetchTables();
notifyChange(d.version ?? version + 1);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}
},
[tableNameOverride, uploadMode, fetchTables, notifyChange, version]
);
const doDelete = useCallback(
async (name: string) => {
if (!confirm(`Drop table "${name}"? This cannot be undone.`)) return;
setBusy(`delete:${name}`);
setError(null);
try {
const res = await fetch(`/api/backend/kb/tables/${encodeURIComponent(name)}`, {
method: "DELETE",
});
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || `HTTP ${res.status}`);
setSuccess(`Dropped ${name}`);
await fetchTables();
notifyChange(d.version ?? version + 1);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
},
[fetchTables, notifyChange, version]
);
const doRename = useCallback(
async (oldName: string, newName: string) => {
if (!newName.trim() || newName === oldName) {
setRenameTarget(null);
return;
}
setBusy(`rename:${oldName}`);
setError(null);
try {
const res = await fetch("/api/backend/kb/rename", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ old_name: oldName, new_name: newName.trim() }),
});
const d = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(d.detail || `HTTP ${res.status}`);
setSuccess(`Renamed ${oldName} → ${newName}`);
setRenameTarget(null);
await fetchTables();
notifyChange(d.version ?? version + 1);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
},
[fetchTables, notifyChange, version]
);
if (!open) return null;
return (
<AnimatePresence>
<motion.div
key="kb-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
key="kb-panel"
initial={{ opacity: 0, y: 14, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 14, scale: 0.97 }}
transition={{ duration: 0.2 }}
onClick={(e) => e.stopPropagation()}
className="flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-mb-amber-200/60 bg-white shadow-2xl"
>
{/* Header */}
<div className="flex items-center justify-between border-b border-mb-amber-100 bg-gradient-to-r from-mb-amber-50/60 to-white px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 shadow-sm">
<Database className="h-4.5 w-4.5 text-white" />
</div>
<div>
<h2 className="text-base font-semibold text-mb-ink leading-tight">
Knowledge Base
</h2>
<p className="text-[11px] text-mb-slate/60">
Add, rename, or remove tables · changes propagate everywhere
</p>
</div>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-slate-100 px-2.5 py-0.5 text-[10px] font-mono text-slate-600">
v{version}
</span>
<button
type="button"
onClick={fetchTables}
disabled={loading}
suppressHydrationWarning
className="rounded-md p-1.5 text-mb-slate/60 hover:bg-mb-amber-50 hover:text-mb-amber-700 disabled:opacity-50"
title="Refresh"
>
<RefreshCcw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<button
type="button"
onClick={onClose}
suppressHydrationWarning
className="rounded-md p-1.5 text-mb-slate/60 hover:bg-rose-50 hover:text-rose-600"
title="Close"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Toasts */}
{(error || success) && (
<div className="border-b border-mb-amber-100 px-5 py-2">
{error && (
<div className="flex items-start gap-2 rounded-md bg-rose-50 px-3 py-2 text-[12px] text-rose-800">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span className="break-words">{error}</span>
</div>
)}
{success && (
<div className="flex items-start gap-2 rounded-md bg-emerald-50 px-3 py-2 text-[12px] text-emerald-800">
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<span>{success}</span>
</div>
)}
</div>
)}
{/* Body */}
<div className="grid flex-1 overflow-hidden md:grid-cols-[1fr_360px]">
{/* Tables list */}
<div className="flex flex-col overflow-hidden">
<div className="border-b border-mb-amber-100 bg-mb-paper/40 px-5 py-2 text-[11px] font-semibold uppercase tracking-wider text-mb-slate/60">
Tables ({tables.length})
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
{loading && tables.length === 0 ? (
<div className="flex items-center justify-center py-10 text-mb-slate/50">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : tables.length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-mb-slate/50">
No tables yet. Upload a CSV to get started →
</div>
) : (
<ul className="space-y-1">
{tables.map((t) => (
<li
key={t.name}
className="group flex items-start gap-2 rounded-lg border border-transparent px-3 py-2 transition hover:border-mb-amber-200/60 hover:bg-mb-amber-50/40"
>
<Database className="h-4 w-4 shrink-0 mt-0.5 text-mb-amber-700" />
<div className="min-w-0 flex-1">
{renameTarget === t.name ? (
<div className="flex items-center gap-1">
<input
autoFocus
title="New table name"
placeholder="new_table_name"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") doRename(t.name, renameValue);
else if (e.key === "Escape") setRenameTarget(null);
}}
className="min-w-0 flex-1 rounded-md border border-mb-amber-300 px-2 py-0.5 text-[13px] outline-none focus:border-mb-amber-500"
/>
<button
type="button"
onClick={() => doRename(t.name, renameValue)}
suppressHydrationWarning
className="rounded-md p-1 text-emerald-600 hover:bg-emerald-50"
title="Confirm"
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<code className="truncate text-[13px] font-medium text-mb-ink">
{t.name}
</code>
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{t.rows == null ? "—" : t.rows.toLocaleString("en-IN")} rows
</span>
</div>
)}
{t.columns.length > 0 && renameTarget !== t.name && (
<p className="mt-0.5 truncate text-[10px] text-mb-slate/55">
{t.columns.slice(0, 8).join(", ")}
{t.columns.length > 8 ? ` … +${t.columns.length - 8}` : ""}
</p>
)}
</div>
{renameTarget !== t.name && (
<div className="flex shrink-0 items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => {
setRenameTarget(t.name);
setRenameValue(t.name);
}}
suppressHydrationWarning
disabled={busy != null}
className="rounded-md p-1 text-mb-slate/60 hover:bg-mb-amber-100 hover:text-mb-amber-700 disabled:opacity-50"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => doDelete(t.name)}
suppressHydrationWarning
disabled={busy != null}
className="rounded-md p-1 text-mb-slate/60 hover:bg-rose-50 hover:text-rose-600 disabled:opacity-50"
title="Drop table"
>
{busy === `delete:${t.name}` ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
</button>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
{/* Upload pane */}
<div className="flex flex-col border-t border-mb-amber-100 md:border-l md:border-t-0">
<div className="border-b border-mb-amber-100 bg-mb-paper/40 px-5 py-2 text-[11px] font-semibold uppercase tracking-wider text-mb-slate/60">
Upload CSV
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-4">
<div>
<label className="block text-[11px] font-medium text-mb-slate mb-1">
Table name (optional)
</label>
<input
value={tableNameOverride}
onChange={(e) => setTableNameOverride(e.target.value)}
placeholder="auto-derived from filename"
className="w-full rounded-md border border-mb-amber-200 px-2.5 py-1.5 text-[13px] outline-none focus:border-mb-amber-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-mb-slate mb-1">
Mode
</label>
<div className="flex rounded-md border border-mb-amber-200 p-0.5">
{(["replace", "append"] as const).map((m) => (
<button
key={m}
type="button"
suppressHydrationWarning
onClick={() => setUploadMode(m)}
className={`flex-1 rounded px-3 py-1 text-[12px] font-medium transition ${
uploadMode === m
? "bg-mb-amber-100 text-mb-amber-800"
: "text-mb-slate/70 hover:bg-mb-paper"
}`}
>
{m === "replace" ? "Replace" : "Append"}
</button>
))}
</div>
<p className="mt-1 text-[10px] text-mb-slate/55">
{uploadMode === "replace"
? "Drops existing table (if any) and creates a new one."
: "Appends rows to the existing table (must already exist)."}
</p>
</div>
<label
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) doUpload(f);
}}
className={`flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition cursor-pointer ${
dragOver
? "border-emerald-400 bg-emerald-50/50"
: "border-mb-amber-200 hover:border-mb-amber-400 hover:bg-mb-amber-50/30"
} ${busy?.startsWith("upload:") ? "pointer-events-none opacity-60" : ""}`}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.tsv,text/csv"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) doUpload(f);
}}
/>
{busy?.startsWith("upload:") ? (
<>
<Loader2 className="h-6 w-6 animate-spin text-emerald-600" />
<span className="text-[12px] text-mb-slate/70">Loading…</span>
</>
) : (
<>
<Upload className="h-6 w-6 text-mb-amber-600" />
<span className="text-[12px] font-medium text-mb-ink">
Click or drag CSV here
</span>
<span className="text-[10px] text-mb-slate/55">
Max 50MB · TSV also supported
</span>
</>
)}
</label>
<div className="rounded-lg bg-sky-50/60 px-3 py-2 text-[11px] text-sky-800/80 leading-relaxed">
Once you upload, the dashboard, chat suggestions, and query
engine all see the new tables instantly — no restart needed.
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
);
}