/** * Collections page - List and manage document collections. * * Features: * - Grid of collection cards with stats * - Re-index action per collection * - Remove collection with confirmation * - Refresh button * - Empty state */ import { AlertCircleIcon, ArrowLeftIcon, CpuIcon, DatabaseIcon, FileTextIcon, FolderIcon, FolderMinusIcon, FolderPlusIcon, HomeIcon, LayersIcon, Loader2Icon, MoreVerticalIcon, RefreshCwIcon, Share2Icon, } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { AppStatusResponse } from "../../status-model"; import { AddCollectionDialog } from "../components/AddCollectionDialog"; import { Loader } from "../components/ai-elements/loader"; import { CollectionModelDialog, type CollectionModelDetails, } from "../components/CollectionModelDialog"; import { CollectionsEmptyState } from "../components/CollectionsEmptyState"; import { IndexingProgress } from "../components/IndexingProgress"; import { PublishExportDialog } from "../components/PublishExportDialog"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, } from "../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "../components/ui/tooltip"; import { apiFetch } from "../hooks/use-api"; interface PageProps { navigate: (to: string | number) => void; } interface CollectionStats { activePresetId?: string; name: string; path: string; documentCount: number; chunkCount: number; embeddedCount: number; include?: string[]; models?: Partial>; effectiveModels?: Record<"embed" | "rerank" | "expand" | "gen", string>; modelSources?: Record< "embed" | "rerank" | "expand" | "gen", "override" | "preset" | "default" >; pattern?: string; egressPolicy?: CollectionModelDetails["egressPolicy"]; } interface StatusResponse { collections: CollectionStats[]; totalDocuments: number; lastUpdated: string | null; healthy: boolean; onboarding: AppStatusResponse["onboarding"]; } interface SyncResponse { jobId: string; } interface EmbeddingCleanupResponse { note?: string; stats: { collection: string; deletedVectors: number; deletedModels: string[]; mode: "stale" | "all"; protectedSharedVectors: number; }; success: boolean; } interface CollectionsResponseItem extends CollectionModelDetails {} interface CollectionCardProps { actionsDisabled: boolean; collection: CollectionStats; onBrowse: () => void; onEmbeddingCleanup: () => void; onExport: () => void; onModelSettings: () => void; onReindex: () => void; onRemove: () => void; isReindexing: boolean; } type SyncTarget = { kind: "all" } | { kind: "collection"; name: string } | null; function formatNumber(n: number): string { if (n >= 1000000) { return `${(n / 1000000).toFixed(1)}M`; } if (n >= 1000) { return `${(n / 1000).toFixed(1)}K`; } return n.toString(); } function truncatePath(path: string, maxLength = 40): string { if (path.length <= maxLength) return path; const start = path.slice(0, 15); const end = path.slice(-22); return `${start}...${end}`; } function CollectionCard({ actionsDisabled, collection, onBrowse, onEmbeddingCleanup, onExport, onModelSettings, onReindex, onRemove, isReindexing, }: CollectionCardProps) { const embedPercent = collection.chunkCount > 0 ? Math.round((collection.embeddedCount / collection.chunkCount) * 100) : 100; return ( { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onBrowse(); } }} role="button" tabIndex={0} >
{collection.name}
{ event.stopPropagation(); onModelSettings(); }} > Model settings { event.stopPropagation(); onEmbeddingCleanup(); }} > Embedding cleanup { event.stopPropagation(); onExport(); }} > Export for gno.sh { event.stopPropagation(); onReindex(); }} > {isReindexing ? ( ) : ( )} Re-index { event.stopPropagation(); onRemove(); }} > Remove

{truncatePath(collection.path)}

{collection.path}

{formatNumber(collection.documentCount)}
documents
{formatNumber(collection.chunkCount)}
chunks
{embedPercent}%
embedded
{isReindexing && (
Re-indexing...
)} {collection.modelSources ? (
{(["embed", "rerank", "expand", "gen"] as const).map((role) => { const source = collection.modelSources?.[role]; if (source !== "override") { return null; } return ( {role} override ); })}
) : null}
Click card to browse documents in this collection.
); } export default function Collections({ navigate }: PageProps) { const [collections, setCollections] = useState([]); const [onboarding, setOnboarding] = useState< AppStatusResponse["onboarding"] | null >(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); const [syncJobId, setSyncJobId] = useState(null); const [syncTarget, setSyncTarget] = useState(null); const [syncError, setSyncError] = useState(null); const [exportCollectionName, setExportCollectionName] = useState< string | null >(null); const [removeDialog, setRemoveDialog] = useState( null ); const [embeddingCleanupDialog, setEmbeddingCleanupDialog] = useState(null); const [embeddingCleanupBusy, setEmbeddingCleanupBusy] = useState< "stale" | "all" | null >(null); const [embeddingCleanupNote, setEmbeddingCleanupNote] = useState< string | null >(null); const [removing, setRemoving] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false); const [modelDialogCollection, setModelDialogCollection] = useState(null); const [initialCollectionPath, setInitialCollectionPath] = useState< string | undefined >(undefined); const loadCollections = useCallback(async () => { const [statusResult, collectionsResult] = await Promise.all([ apiFetch("/api/status"), apiFetch("/api/collections"), ]); if (statusResult.error) { setError(statusResult.error); return; } if (!statusResult.data) { return; } const collectionsByName = new Map( (collectionsResult.data ?? []).map((item) => [item.name, item] as const) ); const merged = statusResult.data.collections.map((collection) => { const config = collectionsByName.get(collection.name); return { ...collection, activePresetId: config?.activePresetId, effectiveModels: config?.effectiveModels, include: config?.include, models: config?.models, modelSources: config?.modelSources, pattern: config?.pattern, egressPolicy: config?.egressPolicy, }; }); setCollections(merged); setOnboarding(statusResult.data.onboarding); setError(collectionsResult.error ?? null); }, []); // Initial load useEffect(() => { void loadCollections().finally(() => setLoading(false)); }, [loadCollections]); // Refresh const handleRefresh = async () => { setRefreshing(true); await loadCollections(); setRefreshing(false); }; // Re-index collection const handleReindex = async (name?: string) => { setSyncError(null); const { data, error: err } = await apiFetch("/api/sync", { method: "POST", body: JSON.stringify(name ? { collection: name } : {}), }); if (err) { setSyncError(err); return; } if (data?.jobId) { setSyncJobId(data.jobId); setSyncTarget(name ? { kind: "collection", name } : { kind: "all" }); } }; // Remove collection const handleRemove = async () => { if (!removeDialog) return; setRemoving(true); const { error: err } = await apiFetch( `/api/collections/${encodeURIComponent(removeDialog.name)}`, { method: "DELETE" } ); setRemoving(false); setRemoveDialog(null); if (!err) { await loadCollections(); } }; const handleEmbeddingCleanup = async (mode: "stale" | "all") => { if (!embeddingCleanupDialog) return; setEmbeddingCleanupBusy(mode); setEmbeddingCleanupNote(null); const { data, error: err } = await apiFetch( `/api/collections/${encodeURIComponent(embeddingCleanupDialog.name)}/embeddings/clear`, { method: "POST", body: JSON.stringify({ mode }), } ); setEmbeddingCleanupBusy(null); if (err) { setEmbeddingCleanupNote(err); return; } setEmbeddingCleanupNote( data?.note ?? `Cleared ${data?.stats.deletedVectors ?? 0} embedding(s) for ${embeddingCleanupDialog.name}.` ); await loadCollections(); }; // Loading state if (loading) { return (

Loading collections...

); } return (
{/* Header */}

Collections

{collections.length}

Add new sources, re-index after external edits or git pulls, and retire old folders without losing indexed history.

{(syncJobId || syncError) && (

Re-index

{syncTarget?.kind === "all" ? "Refreshing every configured collection." : `Refreshing ${syncTarget?.name ?? "selected collection"}.`}

{syncJobId ? ( { setSyncError(null); setSyncJobId(null); setSyncTarget(null); void loadCollections(); }} onError={(message) => { setSyncError(message); setSyncJobId(null); setSyncTarget(null); }} /> ) : (

{syncError}

)}
)} {exportCollectionName !== null && ( setExportCollectionName(null)} target={exportCollectionName} title={exportCollectionName} /> )} {/* Error */} {error && (

{error}

)} {/* Empty state */} {!error && collections.length === 0 && ( { setInitialCollectionPath(path); setAddDialogOpen(true); }} suggestedCollections={onboarding?.suggestedCollections ?? []} /> )} {/* Collections grid */} {!error && collections.length > 0 && (
{collections.map((collection) => ( navigate( `/browse?collection=${encodeURIComponent(collection.name)}` ) } onEmbeddingCleanup={() => { setEmbeddingCleanupDialog(collection); setEmbeddingCleanupNote(null); }} onExport={() => { setExportCollectionName(collection.name); }} onModelSettings={() => setModelDialogCollection(collection)} onReindex={() => void handleReindex(collection.name)} onRemove={() => setRemoveDialog(collection)} /> ))}
)}
{/* Add collection dialog */} void loadCollections()} open={addDialogOpen} /> name)} collection={modelDialogCollection} onOpenChange={(open) => { if (!open) { setModelDialogCollection(null); } }} onSaved={() => void loadCollections()} open={!!modelDialogCollection} /> { if (!open) { setEmbeddingCleanupDialog(null); setEmbeddingCleanupBusy(null); setEmbeddingCleanupNote(null); } }} open={!!embeddingCleanupDialog} > Embedding cleanup Remove stale embeddings or clear all embeddings for{" "} {embeddingCleanupDialog?.name}.

Clear stale embeddings

Removes embeddings for models that are not the currently active embed model for this collection.

Clear all embeddings

Removes every embedding for this collection. You will need to run embeddings again afterward.

{embeddingCleanupNote ? (
{embeddingCleanupNote}
) : null}
{/* Remove confirmation dialog */} !open && setRemoveDialog(null)} open={!!removeDialog} > Remove collection Are you sure you want to remove{" "} {removeDialog?.name}? Indexed documents will be kept in the database but won't appear in searches.
); }