fix(knowledge): ui and infinite load for knowledge

This commit is contained in:
Emir Karabeg
2025-06-03 19:56:15 -07:00
parent 96438c3a54
commit 77bbdc1089
7 changed files with 233 additions and 196 deletions

View File

@@ -64,19 +64,6 @@ export async function GET(req: NextRequest) {
.groupBy(knowledgeBase.id)
.orderBy(knowledgeBase.createdAt)
// Debug logging
logger.info(`[${requestId}] Knowledge bases with counts:`, {
data: knowledgeBasesWithCounts.map((kb) => ({
id: kb.id,
name: kb.name,
docCount: kb.docCount,
})),
})
logger.info(
`[${requestId}] Retrieved ${knowledgeBasesWithCounts.length} knowledge bases for user ${session.user.id}`
)
return NextResponse.json({
success: true,
data: knowledgeBasesWithCounts,

View File

@@ -202,9 +202,6 @@ export async function GET(request: Request) {
}
const elapsed = Date.now() - startTime
logger.info(
`[${requestId}] Workflow fetch completed in ${elapsed}ms for ${workflows.length} workflows`
)
// Return the workflows
return NextResponse.json({ data: workflows }, { status: 200 })

View File

@@ -62,64 +62,50 @@ function formatFileSize(bytes: number): string {
}
const getStatusDisplay = (doc: DocumentData) => {
const processingStatus = (() => {
switch (doc.processingStatus) {
case 'pending':
return {
text: 'Pending',
className:
'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300',
}
case 'processing':
return {
text: (
<>
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' />
Processing
</>
),
className:
'inline-flex items-center rounded-md bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300',
}
case 'completed':
return {
text: 'Completed',
className:
'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
case 'failed':
return {
text: 'Failed',
className:
'inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300',
}
default:
return {
text: 'Unknown',
className:
'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300',
}
}
})()
const activeStatus = (() => {
if (doc.processingStatus === 'completed') {
// Consolidated status: show processing status when not completed, otherwise show enabled/disabled
switch (doc.processingStatus) {
case 'pending':
return {
text: 'Pending',
className:
'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300',
}
case 'processing':
return {
text: (
<>
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' />
Processing
</>
),
className:
'inline-flex items-center rounded-md bg-[#701FFC]/10 px-2 py-1 text-xs font-medium text-[#701FFC] dark:bg-[#701FFC]/20 dark:text-[#8B5FFF]',
}
case 'failed':
return {
text: 'Failed',
className:
'inline-flex items-center rounded-md bg-red-100 px-2 py-1 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300',
}
case 'completed':
return doc.enabled
? {
text: 'Enabled',
className:
'inline-flex items-center rounded-md bg-emerald-100 px-2 py-1 text-xs font-medium text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400',
'inline-flex items-center rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
: {
text: 'Disabled',
className:
'inline-flex items-center rounded-md bg-orange-100 px-2 py-1 text-xs font-medium text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
}
}
return null
})()
return { processingStatus, activeStatus }
default:
return {
text: 'Unknown',
className:
'inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300',
}
}
}
const getProcessingTime = (doc: DocumentData) => {
@@ -583,9 +569,9 @@ export function KnowledgeBase({
onClick={handleAddDocuments}
disabled={isUploading}
size='sm'
className='mt-1 mr-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)]'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)]'
>
<Plus className='mr-1.5 h-3.5 w-3.5' />
<Plus className='h-3.5 w-3.5' />
{isUploading ? 'Uploading...' : 'Add Documents'}
</Button>
</div>
@@ -602,17 +588,16 @@ export function KnowledgeBase({
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */}
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
<table className='w-full min-w-[800px] table-fixed'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<thead>
<tr>
@@ -641,11 +626,6 @@ export function KnowledgeBase({
Uploaded
</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>
Processing
</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
@@ -661,17 +641,16 @@ export function KnowledgeBase({
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto'>
<table className='w-full min-w-[800px] table-fixed'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<tbody>
{filteredDocuments.length === 0 && !isLoadingDocuments ? (
@@ -713,11 +692,6 @@ export function KnowledgeBase({
<div className='text-muted-foreground text-xs'></div>
</td>
{/* Processing column */}
<td className='px-4 py-3'>
<div className='text-muted-foreground text-xs'></div>
</td>
{/* Status column */}
<td className='px-4 py-3'>
<div className='text-muted-foreground text-xs'></div>
@@ -752,9 +726,6 @@ export function KnowledgeBase({
<td className='px-4 py-3'>
<div className='h-4 w-16 animate-pulse rounded bg-muted' />
</td>
<td className='px-4 py-3'>
<div className='h-4 w-12 animate-pulse rounded bg-muted' />
</td>
<td className='px-4 py-3'>
<div className='h-4 w-20 animate-pulse rounded bg-muted' />
</td>
@@ -764,7 +735,7 @@ export function KnowledgeBase({
filteredDocuments.map((doc, index) => {
const isSelected = selectedDocuments.has(doc.id)
const statusDisplay = getStatusDisplay(doc)
const processingTime = getProcessingTime(doc)
// const processingTime = getProcessingTime(doc)
return (
<tr
@@ -859,22 +830,9 @@ export function KnowledgeBase({
</div>
</td>
{/* Processing column */}
<td className='px-4 py-3'>
<div className={statusDisplay.processingStatus.className}>
{statusDisplay.processingStatus.text}
</div>
</td>
{/* Status column */}
<td className='px-4 py-3'>
{statusDisplay.activeStatus ? (
<div className={statusDisplay.activeStatus.className}>
{statusDisplay.activeStatus.text}
</div>
) : (
<div className='text-muted-foreground text-xs'></div>
)}
<div className={statusDisplay.className}>{statusDisplay.text}</div>
</td>
{/* Actions column */}

View File

@@ -442,7 +442,7 @@ export function CreateForm({ onClose, onKnowledgeBaseCreated }: CreateFormProps)
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`relative cursor-pointer rounded-lg border-2 border-dashed p-16 text-center transition-all duration-200 ${
className={`relative cursor-pointer rounded-lg border-[1.5px] border-dashed p-16 text-center transition-all duration-200 ${
isDragging
? 'border-purple-300 bg-purple-50 shadow-sm'
: 'border-muted-foreground/25 hover:border-muted-foreground/40 hover:bg-muted/10'
@@ -457,13 +457,6 @@ export function CreateForm({ onClose, onKnowledgeBaseCreated }: CreateFormProps)
multiple
/>
<div className='flex flex-col items-center gap-3'>
<div
className={`text-4xl transition-all duration-200 ${
isDragging ? 'text-purple-500' : 'text-muted-foreground'
}`}
>
📁
</div>
<div className='space-y-1'>
<p
className={`font-medium text-sm transition-colors duration-200 ${
@@ -504,13 +497,6 @@ export function CreateForm({ onClose, onKnowledgeBaseCreated }: CreateFormProps)
multiple
/>
<div className='flex items-center justify-center gap-2'>
<div
className={`text-base transition-colors duration-200 ${
isDragging ? 'text-purple-500' : 'text-muted-foreground'
}`}
>
📁
</div>
<div>
<p
className={`font-medium text-sm transition-colors duration-200 ${

View File

@@ -37,12 +37,7 @@ export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarColl
</div>
</td>
{/* Processing Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
{/* Active Status column */}
{/* Status column */}
<td className='px-4 py-3'>
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
</td>
@@ -113,17 +108,16 @@ export function DocumentTableSkeleton({
<div className='flex flex-1 flex-col overflow-hidden'>
{/* Table header - fixed */}
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
<table className='w-full min-w-[800px] table-fixed'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<thead>
<tr>
@@ -145,9 +139,6 @@ export function DocumentTableSkeleton({
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Uploaded</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Processing</span>
</th>
<th className='px-4 pt-2 pb-3 text-left font-medium'>
<span className='text-muted-foreground text-xs leading-none'>Status</span>
</th>
@@ -161,17 +152,16 @@ export function DocumentTableSkeleton({
{/* Table body - scrollable */}
<div className='flex-1 overflow-auto'>
<table className='w-full min-w-[800px] table-fixed'>
<table className='w-full min-w-[700px] table-fixed'>
<colgroup>
<col className='w-[4%]' />
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
<col className='w-[8%]' />
<col className='w-[8%]' />
<col className='hidden w-[8%] lg:table-column' />
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
<col className='w-[10%]' />
<col className='w-[10%]' />
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
<col className='w-[12%]' />
<col className='w-[14%]' />
</colgroup>
<tbody>
{Array.from({ length: rowCount }).map((_, i) => (

View File

@@ -10,19 +10,27 @@ export function useKnowledgeBase(id: string) {
const isLoading = loadingKnowledgeBases.has(id)
useEffect(() => {
if (!id || knowledgeBase || isLoading) return
let isMounted = true
const loadData = async () => {
try {
setError(null)
await getKnowledgeBase(id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load knowledge base')
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load knowledge base')
}
}
}
if (id && !knowledgeBase && !isLoading) {
loadData()
loadData()
return () => {
isMounted = false
}
}, [id, knowledgeBase, isLoading, getKnowledgeBase])
}, [id, knowledgeBase, isLoading]) // Removed getKnowledgeBase from dependencies
return {
knowledgeBase,
@@ -41,19 +49,27 @@ export function useKnowledgeBaseDocuments(knowledgeBaseId: string) {
const isLoading = loadingDocuments.has(knowledgeBaseId)
useEffect(() => {
if (!knowledgeBaseId || documents.length > 0 || isLoading) return
let isMounted = true
const loadData = async () => {
try {
setError(null)
await getDocuments(knowledgeBaseId)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load documents')
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load documents')
}
}
}
if (knowledgeBaseId && documents.length === 0 && !isLoading) {
loadData()
loadData()
return () => {
isMounted = false
}
}, [knowledgeBaseId, documents.length, isLoading, getDocuments])
}, [knowledgeBaseId, documents.length, isLoading]) // Removed getDocuments from dependencies
const refreshDocumentsData = async () => {
try {
@@ -88,29 +104,95 @@ export function useKnowledgeBasesList() {
} = useKnowledgeStore()
const [error, setError] = useState<string | null>(null)
const [retryCount, setRetryCount] = useState(0)
const maxRetries = 3
useEffect(() => {
const loadData = async () => {
if (knowledgeBasesList.length > 0 || loadingKnowledgeBasesList) return
let isMounted = true
let retryTimeoutId: NodeJS.Timeout | null = null
const loadData = async (attempt = 0) => {
// Don't proceed if component is unmounted
if (!isMounted) return
try {
setError(null)
await getKnowledgeBasesList()
// Reset retry count on success
if (isMounted) {
setRetryCount(0)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load knowledge bases')
if (!isMounted) return
const errorMessage = err instanceof Error ? err.message : 'Failed to load knowledge bases'
// Only set error and retry if we haven't exceeded max retries
if (attempt < maxRetries) {
console.warn(`Knowledge bases load attempt ${attempt + 1} failed, retrying...`, err)
setRetryCount(attempt + 1)
// Exponential backoff: 1s, 2s, 4s
const delay = 2 ** attempt * 1000
retryTimeoutId = setTimeout(() => {
if (isMounted) {
loadData(attempt + 1)
}
}, delay)
} else {
console.error('All retry attempts failed for knowledge bases list:', err)
setError(errorMessage)
setRetryCount(maxRetries)
}
}
}
if (knowledgeBasesList.length === 0 && !loadingKnowledgeBasesList) {
loadData()
// Always start from attempt 0
loadData(0)
// Cleanup function
return () => {
isMounted = false
if (retryTimeoutId) {
clearTimeout(retryTimeoutId)
}
}
}, [knowledgeBasesList.length, loadingKnowledgeBasesList, getKnowledgeBasesList])
}, [knowledgeBasesList.length, loadingKnowledgeBasesList]) // Removed getKnowledgeBasesList from dependencies
const refreshList = async () => {
try {
setError(null)
setRetryCount(0)
clearKnowledgeBasesList()
await getKnowledgeBasesList()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to refresh knowledge bases')
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh knowledge bases'
setError(errorMessage)
console.error('Error refreshing knowledge bases list:', err)
}
}
// Force refresh function that bypasses cache and resets everything
const forceRefresh = async () => {
setError(null)
setRetryCount(0)
clearKnowledgeBasesList()
// Force reload by clearing cache and loading state
useKnowledgeStore.setState({
knowledgeBasesList: [],
loadingKnowledgeBasesList: false,
})
try {
await getKnowledgeBasesList()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to refresh knowledge bases'
setError(errorMessage)
console.error('Error force refreshing knowledge bases list:', err)
}
}
@@ -119,8 +201,11 @@ export function useKnowledgeBasesList() {
isLoading: loadingKnowledgeBasesList,
error,
refreshList,
forceRefresh,
addKnowledgeBase,
removeKnowledgeBase,
retryCount,
maxRetries,
}
}
@@ -140,79 +225,86 @@ export function useDocumentChunks(knowledgeBaseId: string, documentId: string) {
offset: 0,
hasMore: false,
})
const [initialLoadDone, setInitialLoadDone] = useState(false)
const isStoreLoading = isChunksLoading(documentId)
const combinedIsLoading = isLoading || isStoreLoading
// Single effect to handle all data loading and syncing
useEffect(() => {
if (!knowledgeBaseId || !documentId) return
const cached = getCachedChunks(documentId)
if (cached) {
setChunks(cached.chunks)
setPagination(cached.pagination)
setIsLoading(false)
}
}, [knowledgeBaseId, documentId, getCachedChunks])
let isMounted = true
// Initial load
useEffect(() => {
if (!knowledgeBaseId || !documentId) return
const loadChunks = async () => {
const loadAndSyncData = async () => {
try {
setIsLoading(true)
setError(null)
// Try to get cached chunks first
// Check cache first
const cached = getCachedChunks(documentId)
if (cached) {
setChunks(cached.chunks)
setPagination(cached.pagination)
setIsLoading(false)
if (isMounted) {
setChunks(cached.chunks)
setPagination(cached.pagination)
setIsLoading(false)
setInitialLoadDone(true)
}
return
}
// If not cached, fetch from API
const fetchedChunks = await getChunks(knowledgeBaseId, documentId, {
limit: pagination.limit,
offset: pagination.offset,
})
// If not cached and we haven't done initial load, fetch from API
if (!initialLoadDone && !isStoreLoading) {
setIsLoading(true)
setError(null)
setChunks(fetchedChunks)
const fetchedChunks = await getChunks(knowledgeBaseId, documentId, {
limit: 50, // Use fixed initial values to avoid dependency issues
offset: 0,
})
// Update pagination from cache after fetch
const updatedCache = getCachedChunks(documentId)
if (updatedCache) {
setPagination(updatedCache.pagination)
if (isMounted) {
setChunks(fetchedChunks)
// Update pagination from cache after fetch
const updatedCache = getCachedChunks(documentId)
if (updatedCache) {
setPagination(updatedCache.pagination)
}
setInitialLoadDone(true)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load chunks')
if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load chunks')
}
} finally {
setIsLoading(false)
if (isMounted) {
setIsLoading(false)
}
}
}
loadChunks()
}, [knowledgeBaseId, documentId, getChunks, getCachedChunks])
loadAndSyncData()
// Sync with store state changes
return () => {
isMounted = false
}
}, [knowledgeBaseId, documentId, isStoreLoading, initialLoadDone]) // Removed getCachedChunks and getChunks from dependencies
// Separate effect to sync with store state changes (no API calls)
useEffect(() => {
if (!documentId || !initialLoadDone) return
const cached = getCachedChunks(documentId)
if (cached) {
setChunks(cached.chunks)
setPagination(cached.pagination)
}
}, [documentId, getCachedChunks])
useEffect(() => {
// Update loading state based on store
if (!isStoreLoading && isLoading) {
const cached = getCachedChunks(documentId)
if (cached) {
setIsLoading(false)
}
setIsLoading(false)
}
}, [isStoreLoading, isLoading, documentId, getCachedChunks])
}, [documentId, isStoreLoading, isLoading, initialLoadDone]) // Removed getCachedChunks from dependencies
const refreshChunksData = async (options?: {
search?: string

View File

@@ -373,13 +373,29 @@ export const useKnowledgeStore = create<KnowledgeStore>((set, get) => ({
return state.knowledgeBasesList
}
// Create an AbortController for request cancellation
const abortController = new AbortController()
const timeoutId = setTimeout(() => {
abortController.abort()
}, 10000) // 10 second timeout
try {
set({ loadingKnowledgeBasesList: true })
const response = await fetch('/api/knowledge')
const response = await fetch('/api/knowledge', {
signal: abortController.signal,
headers: {
'Content-Type': 'application/json',
},
})
// Clear the timeout since request completed
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Failed to fetch knowledge bases: ${response.statusText}`)
throw new Error(
`Failed to fetch knowledge bases: ${response.status} ${response.statusText}`
)
}
const result = await response.json()
@@ -388,7 +404,7 @@ export const useKnowledgeStore = create<KnowledgeStore>((set, get) => ({
throw new Error(result.error || 'Failed to fetch knowledge bases')
}
const knowledgeBasesList = result.data
const knowledgeBasesList = result.data || []
set({
knowledgeBasesList,
@@ -398,9 +414,20 @@ export const useKnowledgeStore = create<KnowledgeStore>((set, get) => ({
logger.info(`Knowledge bases list loaded: ${knowledgeBasesList.length} items`)
return knowledgeBasesList
} catch (error) {
// Clear the timeout in case of error
clearTimeout(timeoutId)
logger.error('Error fetching knowledge bases list:', error)
// Always set loading to false, even on error
set({ loadingKnowledgeBasesList: false })
// Don't throw on AbortError (timeout or cancellation)
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('Knowledge bases list request was aborted (timeout or cancellation)')
return state.knowledgeBasesList // Return whatever we have cached
}
throw error
}
},