"use client"; /* Knowledge: vertical-tab layout - Files (sources organized in folders), Q&A pairs (free-form kinds/categories + tags), and the search debugger. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Icon } from "../icons"; import { Drawer, Field, Modal, Segmented, StatusPill } from "../primitives"; import { api, ActivityEntry, KbSource, QaPair, SearchHit } from "@/lib/api"; import { ChunkMap } from "./chunk-map"; const VTABS = [ { value: "files", label: "Files", icon: "knowledge" }, { value: "qa", label: "Q&A pairs", icon: "n_qa" }, { value: "search", label: "Search debugger", icon: "search" }, { value: "map", label: "Chunk map", icon: "layers" }, ] as const; // Chunking strategies offered in the UI - mirrors CHUNK_STRATEGIES in the backend splitter. const CHUNK_OPTIONS = [ { value: "recursive", label: "Recursive" }, { value: "section", label: "By section" }, { value: "sentence", label: "By sentence" }, { value: "semantic", label: "Semantic" }, ] as const; const CHUNK_HELP = "Recursive suits most documents; By section keeps each Markdown heading’s content together; By sentence groups whole sentences (good for FAQs and transcripts); Semantic splits where the meaning shifts (uses the embedder, slower to ingest)."; export function KnowledgeScreen({ project }: { project: any }) { const [tab, setTab] = useState("files"); const [histOpen, setHistOpen] = useState(false); return (
Knowledge
Ground agents in your docs (Chroma vectors, organized in folders) and deflect FAQs with categorized Q&A pairs.
setHistOpen(false)} />
{/* vertical tab rail */}
{tab === "files" && } {tab === "qa" && } {tab === "search" && } {tab === "map" && }
); } /* Compact "3m ago" / "2d ago" relative time, falling back to a locale date. */ function knRelTime(iso?: string | null): string { if (!iso) return ""; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return String(iso); const s = Math.round((Date.now() - t) / 1000); if (s < 60) return "just now"; const m = Math.round(s / 60); if (m < 60) return `${m}m ago`; const h = Math.round(m / 60); if (h < 24) return `${h}h ago`; const d = Math.round(h / 24); if (d < 30) return `${d}d ago`; return new Date(iso).toLocaleDateString(); } /* Read-only, project-wide activity: what files / Q&A pairs were added, changed or removed. Opened from the Knowledge header - no per-row clutter, no restore (content lives in the vector store, so there's nothing to roll back). */ function KnowledgeHistory({ project, open, onClose }: { project: any; open: boolean; onClose: () => void }) { const [rows, setRows] = useState(null); const [err, setErr] = useState(null); useEffect(() => { if (!open || !project?.id) return; setRows(null); setErr(null); api.knowledgeActivity(project.id).then(setRows).catch((e) => setErr(String(e?.message || e))); }, [open, project?.id]); const ACTION: Record = { added: { label: "Added", cls: "pill-ok", icon: "plus" }, changed: { label: "Changed", cls: "pill-muted", icon: "edit" }, removed: { label: "Removed", cls: "pill-err", icon: "trash" }, }; return (
{err &&
{err}
} {!err && rows === null &&
Loading…
} {!err && rows?.length === 0 && (
No changes yet.
Adding, editing or removing files and Q&A pairs shows up here.
)} {rows?.map((r) => { const a = ACTION[r.action || ""] || { label: r.action || "changed", cls: "pill-muted", icon: "minus" }; return (
{a.label} {r.entity_type === "qa_pair" ? "Q&A" : "File"} {r.title}
{r.author_email || "unknown"}{r.created_at ? ` · ${knRelTime(r.created_at)}` : ""}
); })}
); } /* ---------------- Files (sources in folders) ---------------- */ const UNFILED = ""; // Fallback chunking shown/used when a source (or the project) hasn't set its own. Mirrors // the backend defaults in services/knowledge.py; the server stays authoritative for what's // actually applied at ingest. const DEFAULT_CHUNK_STRATEGY = "recursive"; const DEFAULT_CHUNK_SIZE = 1000; const DEFAULT_CHUNK_OVERLAP = 200; function Files({ project }: { project: any }) { const [rows, setRows] = useState([]); const [folder, setFolder] = useState(null); // null = All files const [open, setOpen] = useState(false); const [busy, setBusy] = useState(false); const [addErr, setAddErr] = useState(null); const [newFolder, setNewFolder] = useState(null); // non-null = naming a new folder // The modal never asks for a folder - sources land in the folder open at the time // ("" = Unfiled, e.g. from the All files view). const [targetFolder, setTargetFolder] = useState(""); const [form, setForm] = useState<{ kind: string; name: string; text: string; uri: string; file: globalThis.File | null; chunkStrategy: string }>({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" }); // Multi-select + re-chunk: select sources (across any folder) and re-split/re-embed // them with a shared strategy / size / overlap. const [selected, setSelected] = useState>(new Set()); const [rechunkOpen, setRechunkOpen] = useState(false); const [rechunkTargets, setRechunkTargets] = useState([]); const [rechunkBusy, setRechunkBusy] = useState(false); const [rechunkErr, setRechunkErr] = useState(null); const [rechunkForm, setRechunkForm] = useState<{ strategy: string; size: number; overlap: number }>({ strategy: DEFAULT_CHUNK_STRATEGY, size: DEFAULT_CHUNK_SIZE, overlap: DEFAULT_CHUNK_OVERLAP }); const [dedupeBusy, setDedupeBusy] = useState(false); const [dedupeMsg, setDedupeMsg] = useState(null); const [health, setHealth] = useState<{ needs_reembed: boolean; current_model: string; mismatched: { id: string; name: string }[] } | null>(null); const reload = useCallback(() => { if (!project?.id) return; api.listSources(project.id).then(setRows).catch(() => {}); api.embeddingHealth(project.id).then(setHealth).catch(() => setHealth(null)); }, [project?.id]); useEffect(() => { reload(); }, [reload]); // Ingestion (chunk + embed) runs in the background, so a new/re-chunked source starts as // "queued"/"processing". Poll until every source settles (ready/error) so the table, chunk // counts, and dim-mismatch banner update without a manual refresh. useEffect(() => { const pending = rows.some((s) => s.status === "queued" || s.status === "processing"); if (!pending || !project?.id) return; const t = setTimeout(async () => { const next = await api.listSources(project.id).catch(() => null); if (!next) return; setRows(next); if (!next.some((s) => s.status === "queued" || s.status === "processing")) { api.embeddingHealth(project.id).then(setHealth).catch(() => {}); } }, 1500); return () => clearTimeout(t); }, [rows, project?.id]); async function reingest(id: string) { await api.reingestSource(project.id, id).catch(() => {}); reload(); } async function dedupe() { if (!window.confirm("Remove exact-duplicate chunks (identical text) across this project, keeping one copy of each?\n\nIf duplicates come from the same document added twice, delete the duplicate source instead — re-ingesting regenerates the chunks.")) return; setDedupeBusy(true); setDedupeMsg(null); try { const r = await api.dedupeChunks(project.id); setDedupeMsg(r.removed === 0 ? "No duplicate chunks found." : `Removed ${r.removed} duplicate chunk${r.removed === 1 ? "" : "s"} (${r.groups} group${r.groups === 1 ? "" : "s"}) across ${r.sources_affected} source${r.sources_affected === 1 ? "" : "s"}. ${r.remaining} remain.`); reload(); } catch (e: any) { setDedupeMsg(`Dedupe failed: ${e?.message || e}`); } finally { setDedupeBusy(false); } } const folders = useMemo(() => { const set = new Set(); rows.forEach((s) => { if (s.folder) set.add(s.folder); }); return [...set].sort(); }, [rows]); const visible = folder === null ? rows : rows.filter((s) => (s.folder || UNFILED) === folder); const hasUnfiled = rows.some((s) => !s.folder); // Folder column is redundant when already viewing one named folder - show it only in // the All-files and Unfiled views (where moving files between folders is useful). const showFolderCol = folder === null || folder === UNFILED; const inNamedFolder = folder !== null && folder !== UNFILED; const allVisibleSelected = visible.length > 0 && visible.every((s) => selected.has(s.id)); function toggleSel(id: string) { setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); } function toggleAllVisible() { setSelected((prev) => { const n = new Set(prev); if (allVisibleSelected) visible.forEach((s) => n.delete(s.id)); else visible.forEach((s) => n.add(s.id)); return n; }); } function openRechunk(ids: string[]) { if (!ids.length) return; const first = rows.find((r) => r.id === ids[0]); setRechunkForm({ strategy: first?.chunking_strategy || DEFAULT_CHUNK_STRATEGY, size: first?.chunk_size || DEFAULT_CHUNK_SIZE, overlap: first?.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP }); setRechunkErr(null); setRechunkTargets(ids); setRechunkOpen(true); } async function doRechunk() { setRechunkBusy(true); setRechunkErr(null); try { await api.rechunkSources(project.id, rechunkTargets, { chunking_strategy: rechunkForm.strategy, chunk_size: Number(rechunkForm.size) || undefined, chunk_overlap: Number.isFinite(rechunkForm.overlap) ? Number(rechunkForm.overlap) : undefined, }); setRechunkOpen(false); setSelected(new Set()); reload(); } catch (e: any) { setRechunkErr(e?.message || "Re-chunk failed. Please try again."); } finally { setRechunkBusy(false); } } function openAdd(forFolder?: string) { setTargetFolder(forFolder ?? (inNamedFolder ? folder! : "")); setAddErr(null); setOpen(true); } async function add() { setBusy(true); setAddErr(null); try { if (form.kind === "file") { if (!form.file) { setAddErr("Choose a file to upload."); return; } await api.uploadSource(project.id, form.file, targetFolder || undefined, form.chunkStrategy); } else { await api.addSource(project.id, { kind: form.kind, name: form.name || "Untitled", folder: targetFolder || undefined, text: form.kind === "text" ? form.text : undefined, uri: (form.kind === "url" || form.kind === "crawl") ? form.uri : undefined, chunking_strategy: form.chunkStrategy, }); } setOpen(false); setForm({ kind: "text", name: "", text: "", uri: "", file: null, chunkStrategy: "recursive" }); reload(); } catch (e: any) { setAddErr(String(e?.message || e)); } finally { setBusy(false); } } function FolderRow({ value, label, icon, count }: { value: string | null; label: string; icon: string; count: number }) { const active = folder === value; return ( ); } return (
{health?.needs_reembed && (
{health.mismatched.length} source(s) were embedded with a different model than the current one ({health.current_model}) - they won't appear in search until re-embedded.
)}
{/* folder list */}
{hasUnfiled && !s.folder).length} />} {folders.map((f) => ( s.folder === f).length} /> ))} {newFolder === null ? ( ) : ( setNewFolder(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && newFolder.trim()) { // Folders exist through their files: open the add modal locked to the new folder. const nf = newFolder.trim(); setNewFolder(null); openAdd(nf); } if (e.key === "Escape") setNewFolder(null); }} onBlur={() => setNewFolder(null)} /> )}
Retrieval nodes and knowledge_search tools can filter by folder.
{/* sources table */}
{folder === null ? "All files" : folder === UNFILED ? "Unfiled" : folder}
{dedupeMsg && (
{dedupeMsg}
)} {selected.size > 0 && (
{selected.size} selected
)}
{showFolderCol && } {visible.map((s) => ( {showFolderCol && ( )} ))} {visible.length === 0 && }
NameKindFolderStatusChunksChunking
toggleSel(s.id)} /> {s.name} {s.kind} {s.chunks} {s.chunking_strategy || DEFAULT_CHUNK_STRATEGY}
{s.chunk_size || DEFAULT_CHUNK_SIZE}/{s.chunk_overlap ?? DEFAULT_CHUNK_OVERLAP}
{rows.length === 0 ? "No sources yet. Add text or a URL to feed your agents." : "No files in this folder yet."}
setOpen(false)} title={`Add source to “${targetFolder || "Unfiled"}”`} width={560} footer={<>}> setForm((f) => ({ ...f, kind: v }))} /> {form.kind !== "file" && ( setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Help Center FAQ" /> )} {form.kind === "text" && (