From ce74e45a2c4c590c5a2b3722bed54abf552dcfa6 Mon Sep 17 00:00:00 2001 From: Ayush8923 <80516839+Ayush8923@users.noreply.github.com> Date: Sat, 9 May 2026 01:01:02 +0530 Subject: [PATCH 1/8] fix(*): update the doucment and knowledge-base UI revamp --- app/(main)/document/page.tsx | 153 +- app/(main)/evaluations/[id]/page.tsx | 6 +- app/(main)/knowledge-base/page.tsx | 1392 ++--------------- app/components/FileExtBadge.tsx | 45 + app/components/Loader.tsx | 22 +- .../document/DeleteDocumentModal.tsx | 46 + app/components/document/DocumentListing.tsx | 137 +- .../document/DocumentListingSkeleton.tsx | 29 + app/components/document/DocumentPreview.tsx | 79 +- .../document/DocumentPreviewSkeleton.tsx | 29 + .../document/UploadDocumentModal.tsx | 164 +- app/components/icons/common/DownloadIcon.tsx | 21 + app/components/icons/index.tsx | 1 + .../knowledge-base/CollectionDetail.tsx | 192 +++ .../knowledge-base/CollectionsList.tsx | 104 ++ .../CollectionsListSkeleton.tsx | 27 + .../knowledge-base/CreateCollectionForm.tsx | 125 ++ .../knowledge-base/DeleteCollectionModal.tsx | 39 + .../knowledge-base/DocumentChip.tsx | 33 + .../knowledge-base/DocumentPickerModal.tsx | 131 ++ .../knowledge-base/DocumentPreviewModal.tsx | 73 + app/globals.css | 5 - app/hooks/useCollections.ts | 467 ++++++ app/lib/constants.ts | 3 + app/lib/utils/collectionEnrichment.ts | 124 ++ app/lib/utils/knowledgeBaseCache.ts | 108 ++ 26 files changed, 2033 insertions(+), 1522 deletions(-) create mode 100644 app/components/FileExtBadge.tsx create mode 100644 app/components/document/DeleteDocumentModal.tsx create mode 100644 app/components/document/DocumentListingSkeleton.tsx create mode 100644 app/components/document/DocumentPreviewSkeleton.tsx create mode 100644 app/components/icons/common/DownloadIcon.tsx create mode 100644 app/components/knowledge-base/CollectionDetail.tsx create mode 100644 app/components/knowledge-base/CollectionsList.tsx create mode 100644 app/components/knowledge-base/CollectionsListSkeleton.tsx create mode 100644 app/components/knowledge-base/CreateCollectionForm.tsx create mode 100644 app/components/knowledge-base/DeleteCollectionModal.tsx create mode 100644 app/components/knowledge-base/DocumentChip.tsx create mode 100644 app/components/knowledge-base/DocumentPickerModal.tsx create mode 100644 app/components/knowledge-base/DocumentPreviewModal.tsx create mode 100644 app/hooks/useCollections.ts create mode 100644 app/lib/utils/collectionEnrichment.ts create mode 100644 app/lib/utils/knowledgeBaseCache.ts diff --git a/app/(main)/document/page.tsx b/app/(main)/document/page.tsx index ac1b5a6b..ae20a761 100644 --- a/app/(main)/document/page.tsx +++ b/app/(main)/document/page.tsx @@ -15,10 +15,12 @@ import { import { DocumentListing } from "@/app/components/document/DocumentListing"; import { DocumentPreview } from "@/app/components/document/DocumentPreview"; import { UploadDocumentModal } from "@/app/components/document/UploadDocumentModal"; +import DeleteDocumentModal from "@/app/components/document/DeleteDocumentModal"; import { DEFAULT_PAGE_LIMIT, MAX_DOCUMENT_SIZE_BYTES, MAX_DOCUMENT_SIZE_MB, + MAX_DOCUMENT_UPLOAD_BATCH, } from "@/app/lib/constants"; import { Document } from "@/app/lib/types/document"; @@ -30,10 +32,14 @@ export default function DocumentPage() { null, ); const [isLoadingDocument, setIsLoadingDocument] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [uploadPhase, setUploadPhase] = useState("uploading"); + const [currentUploadIndex, setCurrentUploadIndex] = useState(0); + const [documentToDelete, setDocumentToDelete] = useState( + null, + ); const abortUploadRef = useRef<(() => void) | null>(null); const { activeKey: apiKey, isAuthenticated } = useAuth(); @@ -57,78 +63,126 @@ export default function DocumentPage() { }); const handleFileSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const files = Array.from(event.target.files ?? []); + event.target.value = ""; + if (files.length === 0) return; + + const remaining = MAX_DOCUMENT_UPLOAD_BATCH - selectedFiles.length; + if (remaining <= 0) return; + + const accepted: File[] = []; + let oversizedCount = 0; + for (const file of files.slice(0, remaining)) { + if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + oversizedCount += 1; + continue; + } + accepted.push(file); + } - if (file.size > MAX_DOCUMENT_SIZE_BYTES) { + if (oversizedCount > 0) { toast.error( - `File size exceeds ${MAX_DOCUMENT_SIZE_MB} MB limit. Please select a smaller file within ${MAX_DOCUMENT_SIZE_MB} MB.`, + `${oversizedCount} file${oversizedCount > 1 ? "s" : ""} exceed the ${MAX_DOCUMENT_SIZE_MB} MB limit and were skipped.`, + ); + } + if (files.length > remaining) { + toast.warning( + `You can upload up to ${MAX_DOCUMENT_UPLOAD_BATCH} documents at a time.`, ); - event.target.value = ""; - return; } - setSelectedFile(file); + if (accepted.length > 0) { + setSelectedFiles((prev) => [...prev, ...accepted]); + } }; - const handleUpload = async () => { - if (!isAuthenticated || !selectedFile) return; + const handleRemoveSelectedFile = (index: number) => { + setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); + }; - setIsUploading(true); + const uploadOneFile = async (file: File): Promise => { setUploadProgress(0); setUploadPhase("uploading"); - try { - const formData = new FormData(); - formData.append("src", selectedFile); + const formData = new FormData(); + formData.append("src", file); - const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( - "/api/document", - apiKey?.key ?? "", - formData, - (percent, phase) => { - setUploadProgress(percent); - setUploadPhase(phase); - }, - ); - abortUploadRef.current = abort; + const { promise, abort } = uploadWithProgress<{ data?: { id: string } }>( + "/api/document", + apiKey?.key ?? "", + formData, + (percent, phase) => { + setUploadProgress(percent); + setUploadPhase(phase); + }, + ); + abortUploadRef.current = abort; + + try { const data = await promise; - if (selectedFile && data.data?.id) { + if (data.data?.id) { const fileSizeMap = JSON.parse( localStorage.getItem("document_file_sizes") || "{}", ); - fileSizeMap[data.data.id] = selectedFile.size; + fileSizeMap[data.data.id] = file.size; localStorage.setItem( "document_file_sizes", JSON.stringify(fileSizeMap), ); } - - refetch(); - setSelectedFile(null); - setIsModalOpen(false); - - toast.success("Document uploaded successfully!"); + return true; } catch (error) { console.error("Upload error:", error); toast.error( - `Failed to upload document: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to upload "${file.name}": ${error instanceof Error ? error.message : "Unknown error"}`, ); + return false; } finally { - setIsUploading(false); abortUploadRef.current = null; } }; - const handleDeleteDocument = async (documentId: string) => { + const handleUpload = async () => { + if (!isAuthenticated || selectedFiles.length === 0) return; + + setIsUploading(true); + setCurrentUploadIndex(0); + + let successCount = 0; + for (let i = 0; i < selectedFiles.length; i += 1) { + setCurrentUploadIndex(i); + const ok = await uploadOneFile(selectedFiles[i]); + if (ok) successCount += 1; + } + + refetch(); + setSelectedFiles([]); + setIsModalOpen(false); + setIsUploading(false); + setCurrentUploadIndex(0); + + if (successCount > 0) { + toast.success( + successCount === 1 + ? "Document uploaded successfully!" + : `${successCount} documents uploaded successfully!`, + ); + } + }; + + const handleRequestDelete = (documentId: string) => { if (!isAuthenticated) { toast.error("Please log in to continue"); return; } + const doc = documents.find((d) => d.id === documentId); + if (doc) setDocumentToDelete(doc); + }; - if (!confirm("Are you sure you want to delete this document?")) { - return; - } + const handleConfirmDelete = async () => { + if (!documentToDelete) return; + const documentId = documentToDelete.id; + setDocumentToDelete(null); try { await apiFetch(`/api/document/${documentId}`, apiKey?.key ?? "", { @@ -180,7 +234,7 @@ export default function DocumentPage() { }; return ( -
+
@@ -190,13 +244,13 @@ export default function DocumentPage() { subtitle="Manage your uploaded documents" /> -
-
+
+
setIsModalOpen(true)} isLoading={isLoading} isLoadingMore={isLoadingMore} @@ -217,20 +271,31 @@ export default function DocumentPage() { setSelectedFiles([])} onUpload={handleUpload} onClose={() => { abortUploadRef.current?.(); setIsModalOpen(false); - setSelectedFile(null); + setSelectedFiles([]); setUploadProgress(0); setUploadPhase("uploading"); + setCurrentUploadIndex(0); }} /> + + setDocumentToDelete(null)} + onConfirm={handleConfirmDelete} + />
); } diff --git a/app/(main)/evaluations/[id]/page.tsx b/app/(main)/evaluations/[id]/page.tsx index 11a933fc..3d5b2776 100644 --- a/app/(main)/evaluations/[id]/page.tsx +++ b/app/(main)/evaluations/[id]/page.tsx @@ -252,7 +252,7 @@ export default function EvaluationReport() { job.status.toLowerCase() !== "failed"; const segmentedClass = - "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all cursor-pointer border border-transparent text-text-primary hover:bg-black/4 hover:shadow-[0_0_0_1px_rgba(0,0,0,0.06)] data-[selected=true]:bg-bg-primary data-[selected=true]:border-border data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.08)] data-[selected=true]:hover:bg-bg-primary data-[selected=true]:hover:shadow-[0_1px_2px_rgba(0,0,0,0.08)]"; + "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium transition-all cursor-pointer border border-border bg-bg-primary text-text-primary shadow-[0_1px_2px_rgba(0,0,0,0.04)] hover:bg-neutral-50 hover:shadow-[0_1px_2px_rgba(0,0,0,0.08)] data-[selected=true]:bg-accent-primary data-[selected=true]:border-accent-primary data-[selected=true]:text-white data-[selected=true]:shadow-[0_1px_2px_rgba(0,0,0,0.12)] data-[selected=true]:hover:bg-accent-hover data-[selected=true]:hover:shadow-[0_1px_2px_rgba(0,0,0,0.12)]"; return (
@@ -298,7 +298,7 @@ export default function EvaluationReport() { onClick={() => setExportFormat("row")} disabled={isFormatSwitching || isResyncing} data-selected={exportFormat === "row"} - className={`${segmentedClass} disabled:cursor-not-allowed disabled:opacity-60`} + className={`${segmentedClass} rounded-l-md rounded-r-none -mr-px disabled:cursor-not-allowed disabled:opacity-60`} > Individual Rows @@ -309,7 +309,7 @@ export default function EvaluationReport() { onClick={() => setExportFormat("grouped")} disabled={isFormatSwitching || isResyncing} data-selected={exportFormat === "grouped"} - className={`${segmentedClass} disabled:cursor-not-allowed disabled:opacity-60`} + className={`${segmentedClass} rounded-r-md rounded-l-none disabled:cursor-not-allowed disabled:opacity-60`} > Group by Questions diff --git a/app/(main)/knowledge-base/page.tsx b/app/(main)/knowledge-base/page.tsx index 6be08ee9..c6a107f9 100644 --- a/app/(main)/knowledge-base/page.tsx +++ b/app/(main)/knowledge-base/page.tsx @@ -1,38 +1,34 @@ "use client"; -import { useState, useEffect, useRef } from "react"; -import { formatDate } from "@/app/components/utils"; +import { useState } from "react"; import Sidebar from "@/app/components/Sidebar"; import PageHeader from "@/app/components/PageHeader"; -import Modal from "@/app/components/Modal"; -import { - CloseIcon, - TrashIcon, - BookOpenIcon, - ChevronRightIcon, -} from "@/app/components/icons"; -import { Button, Field } from "@/app/components"; -import { useAuth } from "@/app/lib/context/AuthContext"; +import CollectionsList from "@/app/components/knowledge-base/CollectionsList"; +import CreateCollectionForm from "@/app/components/knowledge-base/CreateCollectionForm"; +import CollectionDetail from "@/app/components/knowledge-base/CollectionDetail"; +import DocumentPickerModal from "@/app/components/knowledge-base/DocumentPickerModal"; +import DeleteCollectionModal from "@/app/components/knowledge-base/DeleteCollectionModal"; +import DocumentPreviewModal from "@/app/components/knowledge-base/DocumentPreviewModal"; +import { BookOpenIcon } from "@/app/components/icons"; import { useApp } from "@/app/lib/context/AppContext"; -import { apiFetch } from "@/app/lib/apiClient"; -import { - JobStatusData, - CollectionResponse, - DocumentResponse, - CreateCollectionResponse, - DeleteCollectionResponse, - DocumentDetailResponse, -} from "@/app/lib/types/knowledgeBase"; -import { Document, Collection } from "@/app/lib/types/document"; +import { useCollections } from "@/app/hooks/useCollections"; +import { Document } from "@/app/lib/types/document"; export default function KnowledgeBasePage() { const { sidebarCollapsed } = useApp(); - const [collections, setCollections] = useState([]); - const [availableDocuments, setAvailableDocuments] = useState([]); - const [selectedCollection, setSelectedCollection] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); + const { + collections, + availableDocuments, + selectedCollection, + isLoading, + isCreating, + setSelectedCollection, + fetchCollectionDetails, + createCollection, + deleteCollection, + fetchAndPreviewDoc, + } = useCollections(); + const [showCreateForm, setShowCreateForm] = useState(false); const [showDocumentPicker, setShowDocumentPicker] = useState(false); const [showConfirmDelete, setShowConfirmDelete] = useState(false); @@ -41,1328 +37,172 @@ export default function KnowledgeBasePage() { ); const [showDocPreviewModal, setShowDocPreviewModal] = useState(false); const [previewDoc, setPreviewDoc] = useState(null); - const { activeKey: apiKey, isAuthenticated } = useAuth(); - const [showAllDocs, setShowAllDocs] = useState(false); - - // Polling refs — persist across renders, no stale closures - const apiKeyRef = useRef(null); - const activeJobsRef = useRef>(new Map()); // collectionId → jobId - const pollingRef = useRef | null>(null); - const fetchCollectionsRef = useRef<(() => Promise) | null>(null); - // Form state const [collectionName, setCollectionName] = useState(""); const [collectionDescription, setCollectionDescription] = useState(""); const [selectedDocuments, setSelectedDocuments] = useState>( new Set(), ); - // Helper functions for name cache - using job_id as key - const CACHE_KEY = "collection_job_cache"; - - const saveCollectionData = ( - jobId: string, - name: string, - description: string, - collectionId?: string, - ) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - cache[jobId] = { name, description, collection_id: collectionId }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to save collection data:", e); - } - }; - - const getCollectionDataByCollectionId = ( - collectionId: string, - ): { name?: string; description?: string; job_id?: string } => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - // Find the entry where collection_id matches - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - if (cacheData.collection_id === collectionId) { - return { - name: cacheData.name, - description: cacheData.description, - job_id: jobId, - }; - } - } - return {}; - } catch (e) { - console.error("Failed to get collection data:", e); - return {}; - } - }; - - const deleteCollectionFromCache = (collectionId: string) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { collection_id?: string }; - if (cacheData.collection_id === collectionId) { - delete cache[jobId]; - break; - } - } - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to delete collection from cache:", e); - } - }; - - const pruneStaleCache = (liveCollectionIds: Set) => { - try { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - let changed = false; - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { collection_id?: string }; - // Only prune entries that have a collection_id but it's no longer in the live list - if ( - cacheData.collection_id && - !liveCollectionIds.has(cacheData.collection_id) - ) { - delete cache[jobId]; - changed = true; - } - } - if (changed) localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to prune stale cache:", e); - } - }; - - const enrichCollectionWithCache = async ( - collection: Collection, - jobStatusMap: Map< - string, - { status: string | null; collectionId: string | null } - >, - ): Promise => { - // First try to look up cached data by collection_id - const cached = getCollectionDataByCollectionId(collection.id); - - let jobId = cached.job_id; - let collectionJobStatus = null; - let name = cached.name; - let description = cached.description; - - // If we don't have cached data by collection_id, we need to find it by checking all jobs - if (!jobId) { - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - - // Try each job_id in the cache to find which one matches this collection - for (const [cachedJobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - - // If collection_id is not set yet, check the pre-fetched job status - if (!cacheData.collection_id) { - const jobInfo = jobStatusMap.get(cachedJobId); - if (jobInfo?.collectionId === collection.id) { - jobId = cachedJobId; - name = cacheData.name; - description = cacheData.description; - collectionJobStatus = jobInfo.status; - - // Update cache with collection_id for faster lookup next time - saveCollectionData( - cachedJobId, - cacheData.name, - cacheData.description, - collection.id, - ); - break; - } - } - } - } - - // If we have job_id but no status yet, get it from the map - if (jobId && !collectionJobStatus) { - const jobInfo = jobStatusMap.get(jobId); - if (jobInfo?.status) { - collectionJobStatus = jobInfo.status; - - // Update cache with collection_id if not already set - if (jobInfo.collectionId && !cached.job_id) { - saveCollectionData( - jobId, - name || "", - description || "", - jobInfo.collectionId, - ); - } - } - } - - return { - ...collection, - name: name || "Untitled Collection", - description: description || "", - status: collectionJobStatus || undefined, - job_id: jobId, - }; - }; - - // Pre-fetch job statuses only for entries that need collection_id resolution - const preFetchJobStatuses = async ( - collections: Collection[], - ): Promise< - Map - > => { - if (!isAuthenticated) return new Map(); - - const cache = JSON.parse(localStorage.getItem(CACHE_KEY) || "{}"); - - // Only fetch job statuses for entries without collection_id AND that might match our collections - const jobIdsToFetch = new Set(); - - collections.forEach((collection) => { - // First check if this collection already has cached data with collection_id - const cached = getCollectionDataByCollectionId(collection.id); - if (!cached.job_id) { - // No cached data found by collection_id, need to check all uncached jobs - for (const [jobId, data] of Object.entries(cache)) { - const cacheData = data as { - name: string; - description: string; - collection_id?: string; - }; - if (!cacheData.collection_id) { - jobIdsToFetch.add(jobId); - } - } - } - }); - - // If no jobs need fetching, return empty map - if (jobIdsToFetch.size === 0) { - return new Map(); - } - - // Fetch only the necessary job statuses in parallel - const results = await Promise.all( - Array.from(jobIdsToFetch).map(async (jobId) => { - try { - const result = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, apiKey?.key ?? ""); - const jobData = result.data || result; - const collectionId = - jobData.collection?.id || jobData.collection_id || null; - - return { - jobId, - status: jobData.status || null, - collectionId: collectionId, - }; - } catch (error) { - console.error("Error fetching job status:", error); - } - return { jobId, status: null, collectionId: null }; - }), - ); - - // Convert to Map for O(1) lookup - const jobStatusMap = new Map(); - results.forEach(({ jobId, status, collectionId }) => { - jobStatusMap.set(jobId, { status, collectionId }); - }); - - return jobStatusMap; - }; - - // Fetch collections - - const fetchCollections = async () => { - if (!isAuthenticated) return; - - setIsLoading(true); - try { - const result = await apiFetch( - "/api/collections", - apiKey?.key ?? "", - ); - const collections = ( - Array.isArray(result.data) ? result.data : [] - ) as Collection[]; - - // Pre-fetch job statuses only for collections that need it - const jobStatusMap = await preFetchJobStatuses(collections); - - // Enrich collections with cached names and live status - const enrichedCollections = await Promise.all( - collections.map((collection: Collection) => - enrichCollectionWithCache(collection, jobStatusMap), - ), - ); - - // Remove cache entries whose collection no longer exists on the backend - const liveIds = new Set( - enrichedCollections.map((c: Collection) => c.id), - ); - pruneStaleCache(liveIds); - - // Preserve optimistic entries not yet replaced by a real collection - setCollections((prev) => { - const fetchedJobIds = new Set( - enrichedCollections.map((c: Collection) => c.job_id).filter(Boolean), - ); - const activeOptimistic = prev.filter( - (c) => - c.id.startsWith("optimistic-") && - (!c.job_id || !fetchedJobIds.has(c.job_id)), - ); - // Sort by inserted_at in descending order (latest first) - const combined = [...activeOptimistic, ...enrichedCollections]; - return combined.sort( - (a, b) => - new Date(b.inserted_at).getTime() - - new Date(a.inserted_at).getTime(), - ); - }); - - // If selectedCollection is optimistic and the real one just arrived, fetch full details - // Extract the logic outside the updater to avoid side effects - let replacementId: string | null = null; - setSelectedCollection((prev) => { - if (prev?.id.startsWith("optimistic-") && prev.job_id) { - const replacement = enrichedCollections.find( - (c: Collection) => c.job_id === prev.job_id, - ); - if (replacement) { - replacementId = replacement.id; - // Don't set the replacement yet - let fetchCollectionDetails do it with full data - } - } - return prev; - }); - - // Fetch full details (including documents) for the replacement - if (replacementId) { - fetchCollectionDetails(replacementId); - } - } catch (error) { - console.error("Error fetching collections:", error); - } finally { - setIsLoading(false); + const toggleDocumentSelection = (documentId: string) => { + const newSelection = new Set(selectedDocuments); + if (newSelection.has(documentId)) { + newSelection.delete(documentId); + } else { + newSelection.add(documentId); } + setSelectedDocuments(newSelection); }; - // Fetch available documents - const fetchDocuments = async () => { - if (!isAuthenticated) return; - - try { - const result = await apiFetch( - "/api/document", - apiKey?.key ?? "", - ); - - // Handle both direct array and wrapped response - const documentList = Array.isArray(result) - ? result - : (result as DocumentResponse).data || []; - - // Sort by inserted_at in descending order (latest first) - const sortedDocuments = documentList.sort( - (a: Document, b: Document) => - new Date(b.inserted_at || 0).getTime() - - new Date(a.inserted_at || 0).getTime(), - ); - - setAvailableDocuments(sortedDocuments); - } catch (error) { - console.error("Error fetching documents:", error); - } + const handleSelectCollection = (collectionId: string) => { + setShowCreateForm(false); + setShowDocumentPicker(false); + fetchCollectionDetails(collectionId); }; - // Fetch collection details with documents - const fetchCollectionDetails = async (collectionId: string) => { - if (!isAuthenticated) return; - - // Don't fetch optimistic collections from the server - if (collectionId.startsWith("optimistic-")) { - const optimisticCollection = collections.find( - (c) => c.id === collectionId, - ); - if (optimisticCollection) { - setSelectedCollection(optimisticCollection); - } - return; - } - - setIsLoading(true); - try { - const result = await apiFetch( - `/api/collections/${collectionId}`, - apiKey?.key ?? "", - ); - - // Handle different response formats - const collectionData = (result.data as Collection) || result; - - // Get cached data to find the job_id - const cached = getCollectionDataByCollectionId(collectionId); - - // If we have a job_id, fetch its status - let status = undefined; - if (cached.job_id) { - try { - const jobResult = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${cached.job_id}`, apiKey?.key ?? ""); - const jobData = jobResult.data || jobResult; - status = jobData.status || undefined; - } catch (error) { - console.error( - "Error fetching job status for collection details:", - error, - ); - } - } - - // Enrich the collection with cached name/description and live status - const enrichedCollection = { - ...collectionData, - name: cached.name || collectionData.name || "Untitled Collection", - description: cached.description || collectionData.description || "", - status: status, - job_id: cached.job_id, - }; - - setSelectedCollection(enrichedCollection); - } catch (error) { - console.error("Error fetching collection details:", error); - } finally { - setIsLoading(false); - } + const handleCreateNew = () => { + setShowCreateForm(true); + setSelectedCollection(null); }; - // Start the 3-second polling loop (idempotent — safe to call multiple times) - const startPolling = () => { - if (pollingRef.current) return; - if (activeJobsRef.current.size === 0) return; - - pollingRef.current = setInterval(async () => { - const currentApiKey = apiKeyRef.current; - if (!currentApiKey && !isAuthenticated) return; - - const jobs = activeJobsRef.current; - if (jobs.size === 0) { - clearInterval(pollingRef.current!); - pollingRef.current = null; - return; - } - - let anyResolved = false; - - for (const [collectionId, jobId] of Array.from(jobs)) { - try { - const result = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); - const jobData = result.data || result; - const status = jobData.status || null; - const realCollectionId = - jobData.collection?.id || jobData.collection_id || null; - const knowledgeBaseId = jobData.collection?.knowledge_base_id || null; - - if (status) { - // Always update status in UI (including in_progress/pending states) - setCollections((prev) => - prev.map((c) => { - // Update by collectionId OR by job_id (handles optimistic->real transition) - if (c.id === collectionId || c.job_id === jobId) { - return { - ...c, - status, - knowledge_base_id: knowledgeBaseId || c.knowledge_base_id, - }; - } - return c; - }), - ); - setSelectedCollection((prev) => { - if (prev?.id === collectionId || prev?.job_id === jobId) { - return { - ...prev, - status, - knowledge_base_id: knowledgeBaseId || prev?.knowledge_base_id, - }; - } - return prev; - }); - - // If job is complete (not pending/in_progress/processing), remove from polling and trigger full refresh - const isComplete = !["pending", "processing"].includes( - status.toLowerCase(), - ); - if (isComplete) { - jobs.delete(collectionId); - anyResolved = true; - - // Persist real collectionId so enrichment finds it on next load - if (collectionId.startsWith("optimistic-") && realCollectionId) { - try { - const cache = JSON.parse( - localStorage.getItem(CACHE_KEY) || "{}", - ); - const existing = cache[jobId] || {}; - cache[jobId] = { - ...existing, - collection_id: realCollectionId, - }; - localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); - } catch (e) { - console.error("Failed to update cache:", e); - } - } - } - } - } catch (error) { - console.error("Polling error for job", jobId, error); - } - } - - // At least one job finished — refresh the full list to swap in real collections - if (anyResolved && fetchCollectionsRef.current) { - fetchCollectionsRef.current(); - } - }, 5000); + const handleCancelCreate = () => { + setShowCreateForm(false); + setShowDocumentPicker(false); + setCollectionName(""); + setCollectionDescription(""); + setSelectedDocuments(new Set()); }; - // Create knowledge base const handleCreateClick = async () => { - if (!isAuthenticated) { - alert("Please log in to continue"); - return; - } - - if (!collectionName.trim() || selectedDocuments.size === 0) { - alert("Please provide a name and select at least one document"); - return; - } - - setIsCreating(true); - - // Capture form values before clearing them - const nameAtCreation = collectionName; - const descriptionAtCreation = collectionDescription; - const docsAtCreation = Array.from(selectedDocuments); - - // Immediately clear the form and switch to preview + const params = { + name: collectionName, + description: collectionDescription, + documentIds: Array.from(selectedDocuments), + }; setShowCreateForm(false); setShowDocumentPicker(false); setCollectionName(""); setCollectionDescription(""); setSelectedDocuments(new Set()); - - // Build an optimistic collection and show the preview right away - const optimisticId = `optimistic-${Date.now()}`; - const now = new Date().toISOString(); - const optimisticDocuments: Document[] = docsAtCreation - .map((id) => availableDocuments.find((d) => d.id === id)) - .filter((d): d is Document => !!d); - - const optimisticCollection: Collection = { - id: optimisticId, - name: nameAtCreation, - description: descriptionAtCreation, - inserted_at: now, - updated_at: now, - status: "pending", - documents: optimisticDocuments, - }; - - setCollections((prev) => [optimisticCollection, ...prev]); - setSelectedCollection(optimisticCollection); - - try { - const result = await apiFetch( - "/api/collections", - apiKey?.key ?? "", - { - method: "POST", - body: JSON.stringify({ - name: nameAtCreation, - description: descriptionAtCreation, - documents: docsAtCreation, - provider: "openai", - }), - }, - ); - - const jobId = result.data?.job_id; - - if (jobId) { - saveCollectionData(jobId, nameAtCreation, descriptionAtCreation); - - // Attach job_id to the optimistic entry so polling picks it up - setCollections((prev) => - prev.map((c) => - c.id === optimisticId ? { ...c, job_id: jobId } : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === optimisticId ? { ...prev, job_id: jobId } : prev, - ); - - // Register for polling immediately — don't wait for the next collections render - activeJobsRef.current.set(optimisticId, jobId); - startPolling(); - } else { - console.error( - "No job ID found in response - cannot save name to cache", - ); - } - - // Refresh the real list from the backend (replaces the optimistic entry once the backend knows about it) - await fetchCollections(); - } catch (error) { - console.error("Error creating knowledge base:", error); - alert( - `Failed to create knowledge base: ${error instanceof Error ? error.message : "Unknown error"}`, - ); - setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); - setSelectedCollection(null); - } finally { - setIsCreating(false); - } + await createCollection(params); }; - // Delete collection - show confirmation modal - const handleDeleteCollection = (collectionId: string) => { - if (!isAuthenticated) return; + const handleRequestDelete = (collectionId: string) => { setCollectionToDelete(collectionId); setShowConfirmDelete(true); }; - // Confirm and execute delete const handleConfirmDelete = async () => { - if (!collectionToDelete || !isAuthenticated) return; - + if (!collectionToDelete) return; setShowConfirmDelete(false); - const collectionId = collectionToDelete; + const id = collectionToDelete; setCollectionToDelete(null); - - // Store the original collection in case we need to restore it - const originalCollection = collections.find((c) => c.id === collectionId); - - // Update status to "deleting" instead of removing immediately - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? { ...c, status: "deleting" } : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? { ...prev, status: "deleting" } : prev, - ); - - try { - const result = await apiFetch( - `/api/collections/${collectionId}`, - apiKey?.key ?? "", - { method: "DELETE" }, - ); - - const jobId = result.data?.job_id; - - if (jobId) { - // Poll the delete job status - const pollDeleteStatus = async () => { - const currentApiKey = apiKeyRef.current; - if (!currentApiKey) return; - - try { - const jobResult = await apiFetch< - { data?: JobStatusData } & JobStatusData - >(`/api/collections/jobs/${jobId}`, currentApiKey?.key ?? ""); - const jobData = jobResult.data || jobResult; - const status = jobData.status; - const statusLower = status?.toLowerCase(); - - if (statusLower === "successful") { - // Job completed successfully - remove from UI and clean up cache - deleteCollectionFromCache(collectionId); - setCollections((prev) => - prev.filter((c) => c.id !== collectionId), - ); - setSelectedCollection(null); - } else if (statusLower === "failed") { - // Job failed - restore original collection - alert("Failed to delete collection"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } else { - // Still processing - keep status as "deleting" and poll again - setTimeout(pollDeleteStatus, 2000); // Poll every 2 seconds - } - } catch (error) { - console.error("Error polling delete status:", error); - alert("Failed to check delete status"); - if (originalCollection) { - setCollections((prev) => - prev.map((c) => - c.id === collectionId ? originalCollection : c, - ), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } - }; - - // Start polling - pollDeleteStatus(); - } else { - // No job_id returned, assume immediate success - deleteCollectionFromCache(collectionId); - setCollections((prev) => prev.filter((c) => c.id !== collectionId)); - setSelectedCollection(null); - } - } catch (error) { - console.error("Error deleting collection:", error); - alert("Failed to delete collection"); - // Restore the original collection on error - if (originalCollection) { - setCollections((prev) => - prev.map((c) => (c.id === collectionId ? originalCollection : c)), - ); - setSelectedCollection((prev) => - prev?.id === collectionId ? originalCollection : prev, - ); - } - } + await deleteCollection(id); }; - // Fetch document details and set preview - const fetchAndPreviewDoc = async (doc: Document) => { - setPreviewDoc(doc); - if (isAuthenticated) { - try { - const data = await apiFetch( - `/api/document/${doc.id}`, - apiKey?.key ?? "", - ); - const documentDetails = (data.data || data) as Document; - setPreviewDoc(documentDetails); - } catch (err) { - console.error("Failed to fetch document details:", err); - } - } + const handlePreviewDocument = async (firstDocument: Document) => { + setShowDocPreviewModal(true); + setPreviewDoc(firstDocument); + const enriched = await fetchAndPreviewDoc(firstDocument); + setPreviewDoc(enriched); }; - // Toggle document selection - const toggleDocumentSelection = (documentId: string) => { - const newSelection = new Set(selectedDocuments); - if (newSelection.has(documentId)) { - newSelection.delete(documentId); - } else { - newSelection.add(documentId); - } - setSelectedDocuments(newSelection); + const handleSelectPreviewDoc = async (doc: Document) => { + setPreviewDoc(doc); + const enriched = await fetchAndPreviewDoc(doc); + setPreviewDoc(enriched); }; - useEffect(() => { - if (isAuthenticated) { - fetchCollections(); - fetchDocuments(); - } - }, [apiKey]); - - // Keep apiKeyRef in sync so polling always has the current key - useEffect(() => { - apiKeyRef.current = apiKey; - }, [apiKey, isAuthenticated]); - - // Keep fetchCollectionsRef in sync so polling always has the current function - useEffect(() => { - fetchCollectionsRef.current = fetchCollections; - }, [fetchCollections]); - - // Sync activeJobsRef when collections change (picks up in-progress entries on initial load) - useEffect(() => { - // Remove tracked jobs whose collections no longer exist in the list - const currentIds = new Set(collections.map((c) => c.id)); - for (const [id] of Array.from(activeJobsRef.current)) { - if (!currentIds.has(id)) activeJobsRef.current.delete(id); - } - - // Add any new pending / processing collections - let newJobAdded = false; - collections.forEach((c) => { - const isProcessing = - c.status && ["pending", "processing"].includes(c.status.toLowerCase()); - - if (isProcessing && c.job_id && !activeJobsRef.current.has(c.id)) { - activeJobsRef.current.set(c.id, c.job_id); - newJobAdded = true; - } - }); - - if (newJobAdded && isAuthenticated) startPolling(); - }, [collections, isAuthenticated]); - - // Reset showAllDocs when selectedCollection changes - useEffect(() => { - setShowAllDocs(false); - }, [selectedCollection?.id]); - - // Cleanup polling interval on unmount - useEffect(() => { - return () => { - if (pollingRef.current) { - clearInterval(pollingRef.current); - pollingRef.current = null; - } - }; - }, []); - return (
- {/* Main Content */}
- {/* Content Area - Split View */}
- {/* Left Panel - Collections List */} -
- {/* Create Button */} -
- -
- -
- {isLoading && collections.length === 0 ? ( -
- Loading knowledge bases... -
- ) : collections.length === 0 ? ( -
- No knowledge bases yet. Create your first one! -
- ) : ( -
- {collections.map((collection) => { - const isSelected = selectedCollection?.id === collection.id; - return ( - - ); - })} -
- )} -
-
+
{showCreateForm ? ( -
-
-

- Create Knowledge Base -

- -
- - {/* Name Input */} -
- -
- - {/* Description Input */} -
- -