ResearchRAG / frontend /src /components /sidebar /KnowledgeBase.tsx
riezqidr's picture
feat: implement useSingleFlight hook to prevent duplicate async actions and optimize request handling
c2d8746
Raw
History Blame Contribute Delete
5.46 kB
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>
);
}