Download apps/web/components/dynamic/KnowledgeBaseModal.tsx from Sandy-sys/finai: direct link, hf CLI and curl.
- Browser
- Download file 19.6 kB
-
https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/dynamic/KnowledgeBaseModal.tsx
- Command line
-
hf download hf://spaces/Sandy-sys/finai/apps/web/components/dynamic/KnowledgeBaseModal.tsx
-
curl -L -o KnowledgeBaseModal.tsx https://huggingface.co/spaces/Sandy-sys/finai/resolve/main/apps/web/components/dynamic/KnowledgeBaseModal.tsx
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> | |
| ); | |
| } | |