diff --git a/app/api/collections/[collection_id]/route.ts b/app/api/collections/[collection_id]/route.ts new file mode 100644 index 0000000..5bebdb5 --- /dev/null +++ b/app/api/collections/[collection_id]/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from 'next/server'; + +const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + +// GET /api/collections/[collection_id] - Get a specific collection +export async function GET( + request: Request, + { params }: { params: Promise<{ collection_id: string }> } +) { + const { collection_id } = await params; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${backendUrl}/api/v1/collections/${collection_id}?include_docs=true`, + { + headers: { + 'X-API-KEY': apiKey, + }, + } + ); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} + +// DELETE /api/collection/[collection_id] - Delete a collection +export async function DELETE( + request: Request, + { params }: { params: Promise<{ collection_id: string }> } +) { + const { collection_id } = await params; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${backendUrl}/api/v1/collections/${collection_id}`, + { + method: 'DELETE', + headers: { + 'X-API-KEY': apiKey, + }, + } + ); + + const text = await response.text(); + const data = text ? JSON.parse(text) : { success: true }; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} diff --git a/app/api/collections/jobs/[job_id]/route.ts b/app/api/collections/jobs/[job_id]/route.ts new file mode 100644 index 0000000..f5fc414 --- /dev/null +++ b/app/api/collections/jobs/[job_id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from 'next/server'; + +const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + +// GET /api/collections/jobs/[job_id] - Get collection job status +export async function GET( + request: Request, + { params }: { params: Promise<{ job_id: string }> } +) { + const { job_id } = await params; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch( + `${backendUrl}/api/v1/collections/jobs/${job_id}`, + { + headers: { + 'X-API-KEY': apiKey, + }, + } + ); + + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} diff --git a/app/api/collections/route.ts b/app/api/collections/route.ts new file mode 100644 index 0000000..edade40 --- /dev/null +++ b/app/api/collections/route.ts @@ -0,0 +1,84 @@ +import { NextRequest, NextResponse } from 'next/server'; + + +export async function GET(request: Request) { + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + try { + const response = await fetch(`${backendUrl}/api/v1/collections/`, { + headers: { + 'X-API-KEY': apiKey, + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : []; + + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + return NextResponse.json( + { success: false, error: error.message, data: null }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + // Get the API key from request headers + const apiKey = request.headers.get('X-API-KEY'); + + if (!apiKey) { + return NextResponse.json( + { error: 'Missing X-API-KEY header' }, + { status: 401 } + ); + } + + // Get the JSON body from the request + const body = await request.json(); + + // Get backend URL from environment variable + const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'; + + // Forward the request to the actual backend + const response = await fetch(`${backendUrl}/api/v1/collections/`, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'X-API-KEY': apiKey, + 'Content-Type': 'application/json', + }, + }); + + // Handle empty responses (204 No Content, etc.) + const text = await response.text(); + const data = text ? JSON.parse(text) : { success: true }; + + // Return the response with the same status code + if (!response.ok) { + return NextResponse.json(data, { status: response.status }); + } + + return NextResponse.json(data, { status: response.status }); + } catch (error: any) { + console.error('Proxy error:', error); + return NextResponse.json( + { error: 'Failed to forward request to backend', details: error.message }, + { status: 500 } + ); + } +} diff --git a/app/components/Sidebar.tsx b/app/components/Sidebar.tsx index 22f1d5d..c20a629 100644 --- a/app/components/Sidebar.tsx +++ b/app/components/Sidebar.tsx @@ -73,6 +73,7 @@ export default function Sidebar({ collapsed, activeRoute = '/evaluations' }: Sid ] }, { name: 'Documents', route: '/document' }, + { name: 'Knowledge Base', route: '/knowledge-base' }, // { name: 'Model Testing', route: '/model-testing', comingSoon: true }, // { name: 'Guardrails', route: '/guardrails', comingSoon: true }, // { name: 'Redteaming', route: '/redteaming', comingSoon: true }, diff --git a/app/components/utils.ts b/app/components/utils.ts index 6f14bbf..845c28f 100644 --- a/app/components/utils.ts +++ b/app/components/utils.ts @@ -5,14 +5,22 @@ /** * Converts a date string to IST timezone and formats it * @param dateString - Date string from backend (in IST format but without timezone info) - * @returns Formatted date string in en-GB locale with 12-hour format + * @returns Formatted date string (e.g., "15 Jan 2024, 14:30") */ -export const formatDate = (dateString: string): string => { - // Parse the date string and treat it as IST time - const date = new Date(dateString); - // Add 5.5 hours (IST offset) since the input is already in IST but parsed as UTC - const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); - return istDate.toLocaleString('en-GB', { hour12: true }); +export const formatDate = (dateString?: string): string => { + if (!dateString) return 'N/A'; + try { + const date = new Date(dateString); + const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); + const day = istDate.getDate(); + const month = istDate.toLocaleDateString('en-US', { month: 'short' }); + const year = istDate.getFullYear(); + const hours = String(istDate.getHours()).padStart(2, '0'); + const minutes = String(istDate.getMinutes()).padStart(2, '0'); + return `${day} ${month} ${year}, ${hours}:${minutes}`; + } catch { + return dateString; + } }; /** diff --git a/app/document/page.tsx b/app/document/page.tsx index 89c051e..9fc44e1 100644 --- a/app/document/page.tsx +++ b/app/document/page.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { APIKey, STORAGE_KEY } from '../keystore/page'; import Sidebar from '../components/Sidebar'; import { useToast } from '../components/Toast'; +import { formatDate } from '../components/utils'; // Backend response interface export interface Document { @@ -367,22 +368,6 @@ function DocumentListing({ currentPage, onPageChange, }: DocumentListingProps) { - const formatDate = (dateString?: string) => { - if (!dateString) return 'N/A'; - try { - const date = new Date(dateString); - const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); - const day = istDate.getDate(); - const month = istDate.toLocaleDateString('en-US', { month: 'short' }); - const year = istDate.getFullYear(); - const hours = String(istDate.getHours()).padStart(2, '0'); - const minutes = String(istDate.getMinutes()).padStart(2, '0'); - return `${day} ${month} ${year}, ${hours}:${minutes}`; - } catch { - return dateString; - } - }; - return (
@@ -594,23 +579,6 @@ function DocumentPreview({ document, isLoading }: DocumentPreviewProps) { setImageLoadError(false); }, [document?.id]); - const formatDate = (dateString?: string) => { - if (!dateString) return 'N/A'; - try { - const date = new Date(dateString); - // Add 5.5 hours (IST offset) since the input is already in IST but parsed as UTC - const istDate = new Date(date.getTime() + (5.5 * 60 * 60 * 1000)); - const day = istDate.getDate(); - const month = istDate.toLocaleDateString('en-US', { month: 'short' }); - const year = istDate.getFullYear(); - const hours = String(istDate.getHours()).padStart(2, '0'); - const minutes = String(istDate.getMinutes()).padStart(2, '0'); - return `${day} ${month} ${year}, ${hours}:${minutes}`; - } catch { - return dateString; - } - }; - const getFileExtension = (filename: string) => { const parts = filename.split('.'); return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ''; diff --git a/app/knowledge-base/page.tsx b/app/knowledge-base/page.tsx new file mode 100644 index 0000000..474e857 --- /dev/null +++ b/app/knowledge-base/page.tsx @@ -0,0 +1,1154 @@ +"use client" + +import { useState, useEffect, useRef } from 'react'; +import { colors } from '@/app/lib/colors'; +import { formatDate } from '@/app/components/utils'; +import Sidebar from '@/app/components/Sidebar'; +import { APIKey, STORAGE_KEY } from '../keystore/page'; + +export interface Document { + id: string; + fname: string; + object_store_url: string; + signed_url?: string; + file_size?: number; + inserted_at?: string; + updated_at?: string; +} + +export interface Collection { + id: string; + name?: string; + description?: string; + inserted_at: string; + updated_at: string; + status?: string; + job_id?: string; + documents?: Document[]; +} + +export default function KnowledgeBasePage() { + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [collections, setCollections] = useState([]); + const [availableDocuments, setAvailableDocuments] = useState([]); + const [selectedCollection, setSelectedCollection] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [showCreateForm, setShowCreateForm] = useState(false); + const [showDocumentPicker, setShowDocumentPicker] = useState(false); + const [showConfirmCreate, setShowConfirmCreate] = useState(false); + const [showDocPreviewModal, setShowDocPreviewModal] = useState(false); + const [previewDoc, setPreviewDoc] = useState(null); + const [apiKey, setApiKey] = useState(null); + + // Polling refs — persist across renders, no stale closures + const apiKeyRef = useRef(null); + const activeJobsRef = useRef>(new Map()); // collectionId → jobId + const pollingRef = useRef | 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 enrichCollectionWithCache = async (collection: Collection): Promise => { + // First try to look up cached data by collection_id + let 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 && apiKey) { + 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, fetch job info to check + if (!cacheData.collection_id) { + try { + const jobInfo = await fetchJobStatus(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; + } + } catch (error) { + console.error('Error checking job:', cachedJobId, error); + } + } + } + } + + // If we have job_id but no status yet, fetch it + if (jobId && !collectionJobStatus && apiKey) { + try { + const jobInfo = await fetchJobStatus(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); + } + } + } catch (error) { + console.error('Failed to fetch live status:', error); + } + } + + return { + ...collection, + name: name || 'Untitled Collection', + description: description || '', + status: collectionJobStatus || undefined, + job_id: jobId, + }; + }; + + // Fetch job status for a collection + const fetchJobStatus = async (jobId: string) => { + if (!apiKey) return null; + + try { + const response = await fetch(`/api/collections/jobs/${jobId}`, { + headers: { 'X-API-KEY': apiKey.key }, + }); + + if (response.ok) { + const result = await response.json(); + const jobData = result.data || result; + + // Extract collection ID from collection object or collection_id field + const collectionId = jobData.collection?.id || jobData.collection_id || null; + + return { + status: jobData.status || null, + collectionId: collectionId + }; + } + } catch (error) { + console.error('Error fetching job status:', error); + } + return null; + }; + + // Fetch collections + const fetchCollections = async () => { + if (!apiKey) return; + + setIsLoading(true); + try { + const response = await fetch('/api/collections', { + headers: { 'X-API-KEY': apiKey.key }, + }); + + if (response.ok) { + const result = await response.json(); + const collections = result.data || []; + + // Enrich collections with cached names and live status + const enrichedCollections = await Promise.all( + collections.map((collection: Collection) => enrichCollectionWithCache(collection)) + ); + // 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)) + ); + return [...activeOptimistic, ...enrichedCollections]; + }); + + // If selectedCollection is optimistic and the real one just arrived, swap it in + setSelectedCollection((prev) => { + if (prev?.id.startsWith('optimistic-') && prev.job_id) { + const replacement = enrichedCollections.find((c: Collection) => c.job_id === prev.job_id); + if (replacement) return replacement; + } + return prev; + }); + } else { + const error = await response.json().catch(() => ({})); + console.error('Failed to fetch collections:', response.status, error); + } + } catch (error) { + console.error('Error fetching collections:', error); + } finally { + setIsLoading(false); + } + }; + + // Fetch available documents + const fetchDocuments = async () => { + if (!apiKey) return; + + try { + const response = await fetch('/api/document', { + headers: { 'X-API-KEY': apiKey.key }, + }); + + if (response.ok) { + const result = await response.json(); + + // Handle both direct array and wrapped response + const documentList = Array.isArray(result) ? result : (result.data || []); + setAvailableDocuments(documentList); + } else { + const error = await response.json().catch(() => ({})); + console.error('Failed to fetch documents:', response.status, error); + } + } catch (error) { + console.error('Error fetching documents:', error); + } + }; + + // Fetch collection details with documents + const fetchCollectionDetails = async (collectionId: string) => { + if (!apiKey) return; + + setIsLoading(true); + try { + const response = await fetch(`/api/collections/${collectionId}`, { + headers: { 'X-API-KEY': apiKey.key }, + }); + + if (response.ok) { + const result = await response.json(); + + // Handle different response formats + const collectionData = result.data || result; + const enrichedCollection = await enrichCollectionWithCache(collectionData); + setSelectedCollection(enrichedCollection); + } + } catch (error) { + console.error('Error fetching collection details:', error); + } finally { + setIsLoading(false); + } + }; + + // 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) 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 response = await fetch(`/api/collections/jobs/${jobId}`, { + headers: { 'X-API-KEY': currentApiKey.key }, + }); + if (!response.ok) continue; + + const result = await response.json(); + const jobData = result.data || result; + const status = jobData.status || null; + const realCollectionId = jobData.collection?.id || jobData.collection_id || null; + + if (status && status !== 'in_progress' && status !== 'pending') { + // Update both the list card and the selected preview + setCollections((prev) => + prev.map((c) => (c.id === collectionId ? { ...c, status } : c)) + ); + setSelectedCollection((prev) => + prev?.id === collectionId ? { ...prev, status } : prev + ); + + 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) { + fetchCollections(); + } + }, 3000); + }; + + // Show confirmation dialog + const handleCreateClick = () => { + if (!apiKey) { + alert('No API key found'); + return; + } + + if (!collectionName.trim() || selectedDocuments.size === 0) { + alert('Please provide a name and select at least one document'); + return; + } + + setShowConfirmCreate(true); + }; + + // Create knowledge base + const handleConfirmCreate = async () => { + setShowConfirmCreate(false); + 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 + 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 response = await fetch('/api/collections', { + method: 'POST', + headers: { + 'X-API-KEY': apiKey.key, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: nameAtCreation, + description: descriptionAtCreation, + documents: docsAtCreation, + provider: 'openai', + }), + }); + + if (response.ok) { + const result = await response.json(); + 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(); + } else { + const error = await response.json(); + alert(`Failed to create knowledge base: ${error.error || 'Unknown error'}`); + // Remove the optimistic entry on failure + setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); + setSelectedCollection(null); + } + } catch (error) { + console.error('Error creating knowledge base:', error); + alert('Failed to create knowledge base'); + setCollections((prev) => prev.filter((c) => c.id !== optimisticId)); + setSelectedCollection(null); + } finally { + setIsCreating(false); + } + }; + + // Delete collection + const handleDeleteCollection = async (collectionId: string) => { + if (!apiKey) return; + if (!confirm('Are you sure you want to delete this collection?')) return; + + try { + const response = await fetch(`/api/collections/${collectionId}`, { + method: 'DELETE', + headers: { 'X-API-KEY': apiKey.key }, + }); + + if (response.ok) { + alert('Collection deleted successfully'); + setSelectedCollection(null); + fetchCollections(); + } else { + alert('Failed to delete collection'); + } + } catch (error) { + console.error('Error deleting collection:', error); + alert('Failed to delete collection'); + } + }; + + // 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); + }; + + // Load API key from localStorage + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const keys = JSON.parse(stored); + if (keys.length > 0) { + setApiKey(keys[0]); + } + } catch (e) { + console.error('Failed to load API key:', e); + } + } + }, []); + + useEffect(() => { + if (apiKey) { + fetchCollections(); + fetchDocuments(); + } + }, [apiKey]); + + // Keep apiKeyRef in sync so polling always has the current key + useEffect(() => { apiKeyRef.current = apiKey; }, [apiKey]); + + // 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 / in-progress collections + let newJobAdded = false; + collections.forEach((c) => { + if ((c.status === 'in_progress' || c.status === 'pending') && c.job_id && !activeJobsRef.current.has(c.id)) { + activeJobsRef.current.set(c.id, c.job_id); + newJobAdded = true; + } + }); + + if (newJobAdded && apiKey) startPolling(); + }, [collections, apiKey]); + + // Cleanup polling interval on unmount + useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + pollingRef.current = null; + } + }; + }, []); + + return ( +
+ + + {/* Main Content */} +
+ {/* Header with Collapse Button */} +
+
+ +
+

+ Knowledge Base +

+

+ Manage your knowledge bases for RAG +

+
+
+
+ + {/* Content Area - Split View */} +
+ {/* Left Panel - Collections List */} +
+ {/* Create Button */} +
+ +
+ + {/* Collections List */} +
+ {isLoading && collections.length === 0 ? ( +
+ Loading knowledge bases... +
+ ) : collections.length === 0 ? ( +
+ No knowledge bases yet. Create your first one! +
+ ) : ( +
+ {collections.map((collection) => ( +
{ + setShowCreateForm(false); + setShowDocumentPicker(false); + fetchCollectionDetails(collection.id); + }} + className={`border rounded-lg p-3 cursor-pointer transition-colors ${ + selectedCollection?.id === collection.id ? 'ring-2 ring-offset-1' : '' + }`} + style={{ + backgroundColor: selectedCollection?.id === collection.id + ? 'hsl(202, 100%, 95%)' + : colors.bg.primary, + borderColor: selectedCollection?.id === collection.id + ? 'hsl(202, 100%, 50%)' + : colors.border, + }} + > +
+
+
+ + + +

+ {collection.name} +

+
+ {collection.description && ( +

+ {collection.description} +

+ )} +

+ {formatDate(collection.inserted_at)} +

+
+ +
+
+ ))} +
+ )} +
+
+ + {/* Right Panel - Create Form or Preview */} +
+ {showCreateForm ? ( + /* Create Form */ +
+
+

+ Create Knowledge Base +

+ +
+ + {/* Name Input */} +
+ + setCollectionName(e.target.value)} + placeholder="Enter collection name" + className="w-full px-4 py-2 rounded-md border text-sm" + style={{ + borderColor: colors.border, + backgroundColor: colors.bg.secondary, + color: colors.text.primary, + }} + /> +
+ + {/* Description Input */} +
+ +