Spaces:
Running
Running
| import { useState } from "react"; | |
| import { useQueryClient } from "@tanstack/react-query"; | |
| import { FileText, Newspaper, NotePencil, Stack, Trash } from "@phosphor-icons/react"; | |
| import { Button } from "@/components/ui/Button"; | |
| import { Section } from "@/components/ui/Card"; | |
| import { Collapsible } from "@/components/ui/Collapsible"; | |
| import { Markdown } from "@/components/ui/Markdown"; | |
| import { Spinner } from "@/components/ui/Spinner"; | |
| import { useAuth } from "@/context/AuthContext"; | |
| import { useSettings } from "@/context/SettingsContext"; | |
| import { useToast } from "@/context/ToastContext"; | |
| import { useWorkspace } from "@/context/WorkspaceContext"; | |
| import { useDocuments, useKbStats } from "@/hooks/useServerData"; | |
| import { useSingleFlight } from "@/hooks/useSingleFlight"; | |
| import { clearKnowledgeBase, deleteDocument, summarizeDocument } from "@/lib/api"; | |
| import { truncate } from "@/lib/utils"; | |
| export function KnowledgeBase() { | |
| const { session } = useAuth(); | |
| const { selectedModel, activeApiKey } = useSettings(); | |
| const toast = useToast(); | |
| const qc = useQueryClient(); | |
| const { setSuggestions } = useWorkspace(); | |
| const scope = session?.userId ?? "anonymous"; | |
| const { data: stats } = useKbStats(); | |
| const { data: docs } = useDocuments(); | |
| const [summaries, setSummaries] = useState<Record<string, string>>({}); | |
| const [busyTitle, setBusyTitle] = useState<string | null>(null); | |
| const invalidate = () => { | |
| qc.invalidateQueries({ queryKey: ["documents", scope] }); | |
| qc.invalidateQueries({ queryKey: ["kb-stats", scope] }); | |
| }; | |
| // Latched: summarize is an LLM call, and `busyTitle` was written but never | |
| // checked, so repeated clicks on the same document each spent a completion. | |
| const { run: summarize } = useSingleFlight(async (title: string) => { | |
| if (!activeApiKey) { | |
| toast.warning("An API key is required to summarize. Set one above."); | |
| return; | |
| } | |
| setBusyTitle(title); | |
| try { | |
| const res = await summarizeDocument(title, activeApiKey, selectedModel); | |
| setSummaries((prev) => ({ ...prev, [title]: res.summary })); | |
| } catch (err) { | |
| toast.error((err as Error).message); | |
| } finally { | |
| setBusyTitle(null); | |
| } | |
| }); | |
| const remove = async (title: string) => { | |
| try { | |
| const res = await deleteDocument(title); | |
| setSummaries((prev) => { | |
| const next = { ...prev }; | |
| delete next[title]; | |
| return next; | |
| }); | |
| toast.success(`Deleted ${res.deleted} chunks.`); | |
| invalidate(); | |
| } catch (err) { | |
| toast.error((err as Error).message); | |
| } | |
| }; | |
| const clearAll = async () => { | |
| try { | |
| const res = await clearKnowledgeBase(); | |
| setSummaries({}); | |
| setSuggestions([]); | |
| toast.success(`Cleared ${res.cleared} chunks.`); | |
| invalidate(); | |
| } catch (err) { | |
| toast.error((err as Error).message); | |
| } | |
| }; | |
| const documents = docs?.documents ?? []; | |
| return ( | |
| <Section title="Knowledge Base" icon={<Stack className="h-4 w-4" />}> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <Metric label="Total chunks" value={stats?.total_chunks ?? 0} /> | |
| <Metric label="Documents" value={stats?.documents ?? 0} /> | |
| </div> | |
| {documents.length > 0 && ( | |
| <Collapsible title={`Manage documents (${documents.length})`}> | |
| <div className="space-y-1"> | |
| {documents.slice(0, 30).map((d) => ( | |
| <div key={d.title} className="flex items-center gap-2 text-xs"> | |
| {d.source === "upload" ? ( | |
| <FileText className="h-3.5 w-3.5 shrink-0 text-muted" /> | |
| ) : ( | |
| <Newspaper className="h-3.5 w-3.5 shrink-0 text-muted" /> | |
| )} | |
| <span className="flex-1 truncate text-zinc-300" title={d.title}> | |
| {truncate(d.title, 30)} | |
| </span> | |
| <button | |
| onClick={() => summarize(d.title)} | |
| disabled={busyTitle === d.title} | |
| title="Summarize" | |
| className="text-muted transition-colors hover:text-accent disabled:opacity-50" | |
| > | |
| {busyTitle === d.title ? ( | |
| <Spinner className="h-3.5 w-3.5" /> | |
| ) : ( | |
| <NotePencil className="h-3.5 w-3.5" /> | |
| )} | |
| </button> | |
| <button | |
| onClick={() => remove(d.title)} | |
| title="Delete" | |
| className="text-muted transition-colors hover:text-rose-400" | |
| > | |
| <Trash className="h-3.5 w-3.5" /> | |
| </button> | |
| </div> | |
| ))} | |
| </div> | |
| </Collapsible> | |
| )} | |
| {Object.entries(summaries).map(([title, text]) => ( | |
| <Collapsible key={title} title={`Summary — ${truncate(title, 36)}`} defaultOpen> | |
| <Markdown>{text}</Markdown> | |
| </Collapsible> | |
| ))} | |
| <Button variant="outline" fullWidth onClick={clearAll}> | |
| <Trash className="h-4 w-4" /> Clear all documents | |
| </Button> | |
| </Section> | |
| ); | |
| } | |
| function Metric({ label, value }: { label: string; value: number }) { | |
| return ( | |
| <div className="rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2.5"> | |
| <div className="font-mono text-xl font-semibold tracking-tight text-white">{value}</div> | |
| <div className="mt-0.5 text-[11px] text-muted">{label}</div> | |
| </div> | |
| ); | |
| } | |