feat(kb): added tags information to kb docs table (#2589)

* feat(kb): added tags information to kb docs table

* improvement(base): table and tags styling

---------

Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
Waleed
2025-12-26 13:54:33 -08:00
committed by GitHub
parent 66b8434861
commit 7793a6d597

View File

@@ -18,6 +18,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { import {
Badge,
Breadcrumb, Breadcrumb,
Button, Button,
Modal, Modal,
@@ -40,6 +41,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { cn } from '@/lib/core/utils/cn'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { import {
ActionBar, ActionBar,
@@ -53,6 +55,10 @@ import {
useKnowledgeBaseDocuments, useKnowledgeBaseDocuments,
useKnowledgeBasesList, useKnowledgeBasesList,
} from '@/hooks/use-knowledge' } from '@/hooks/use-knowledge'
import {
type TagDefinition,
useKnowledgeBaseTagDefinitions,
} from '@/hooks/use-knowledge-base-tag-definitions'
import type { DocumentData } from '@/stores/knowledge/store' import type { DocumentData } from '@/stores/knowledge/store'
const logger = createLogger('KnowledgeBase') const logger = createLogger('KnowledgeBase')
@@ -73,28 +79,27 @@ function DocumentTableRowSkeleton() {
<Skeleton className='h-[17px] w-[120px]' /> <Skeleton className='h-[17px] w-[120px]' />
</div> </div>
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='hidden px-[12px] py-[8px] lg:table-cell'>
<Skeleton className='h-[15px] w-[48px]' /> <Skeleton className='h-[15px] w-[48px]' />
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='hidden px-[12px] py-[8px] lg:table-cell'>
<Skeleton className='h-[15px] w-[32px]' /> <Skeleton className='h-[15px] w-[32px]' />
</TableCell> </TableCell>
<TableCell className='hidden px-[12px] py-[8px] lg:table-cell'> <TableCell className='px-[12px] py-[8px]'>
<Skeleton className='h-[15px] w-[24px]' /> <Skeleton className='h-[15px] w-[24px]' />
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='px-[12px] py-[8px]'>
<div className='flex flex-col justify-center'> <Skeleton className='h-[15px] w-[60px]' />
<div className='flex items-center font-medium text-[12px]'>
<Skeleton className='h-[15px] w-[50px]' />
<span className='mx-[6px] hidden text-[var(--text-muted)] xl:inline'>|</span>
<Skeleton className='hidden h-[15px] w-[70px] xl:inline-block' />
</div>
<Skeleton className='mt-[2px] h-[15px] w-[40px] lg:hidden' />
</div>
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='px-[12px] py-[8px]'>
<Skeleton className='h-[24px] w-[64px] rounded-md' /> <Skeleton className='h-[24px] w-[64px] rounded-md' />
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'>
<div className='flex items-center gap-[4px]'>
<Skeleton className='h-[18px] w-[40px] rounded-full' />
<Skeleton className='h-[18px] w-[40px] rounded-full' />
</div>
</TableCell>
<TableCell className='py-[8px] pr-[4px] pl-[12px]'> <TableCell className='py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'> <div className='flex items-center gap-[4px]'>
<Skeleton className='h-[28px] w-[28px] rounded-[4px]' /> <Skeleton className='h-[28px] w-[28px] rounded-[4px]' />
@@ -118,22 +123,25 @@ function DocumentTableSkeleton({ rowCount = 5 }: { rowCount?: number }) {
<TableHead className='w-[180px] max-w-[180px] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='w-[180px] max-w-[180px] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Name Name
</TableHead> </TableHead>
<TableHead className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='hidden w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)] lg:table-cell'>
Size Size
</TableHead> </TableHead>
<TableHead className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='hidden w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)] lg:table-cell'>
Tokens Tokens
</TableHead> </TableHead>
<TableHead className='hidden w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)] lg:table-cell'> <TableHead className='w-[8%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Chunks Chunks
</TableHead> </TableHead>
<TableHead className='w-[16%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='w-[11%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Uploaded Uploaded
</TableHead> </TableHead>
<TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='w-[10%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Status Status
</TableHead> </TableHead>
<TableHead className='w-[14%] py-[8px] pr-[4px] pl-[12px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Tags
</TableHead>
<TableHead className='w-[11%] py-[8px] pr-[4px] pl-[12px] text-[12px] text-[var(--text-secondary)]'>
Actions Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -274,58 +282,124 @@ function formatFileSize(bytes: number): string {
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}` return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`
} }
const getStatusDisplay = (doc: DocumentData) => { const AnimatedLoader = ({ className }: { className?: string }) => (
// Consolidated status: show processing status when not completed, otherwise show enabled/disabled <Loader2 className={cn(className, 'animate-spin')} />
)
const getStatusBadge = (doc: DocumentData) => {
switch (doc.processingStatus) { switch (doc.processingStatus) {
case 'pending': case 'pending':
return { return (
text: 'Pending', <Badge variant='gray' size='sm'>
className: Pending
'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', </Badge>
} )
case 'processing': case 'processing':
return { return (
text: ( <Badge variant='purple' size='sm' icon={AnimatedLoader}>
<> Processing
<Loader2 className='mr-1.5 h-3 w-3 animate-spin' /> </Badge>
Processing )
</>
),
className:
'inline-flex items-center rounded-md bg-purple-100 px-2 py-1 text-xs font-medium text-[var(--brand-primary-hex)] dark:bg-purple-900/30 dark:text-[var(--brand-primary-hex)]',
}
case 'failed': case 'failed':
return { return doc.processingError ? (
text: ( <Badge variant='red' size='sm' icon={AlertCircle}>
<> Failed
Failed </Badge>
{doc.processingError && <AlertCircle className='ml-1.5 h-3 w-3' />} ) : (
</> <Badge variant='red' size='sm'>
), Failed
className: </Badge>
'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': case 'completed':
return doc.enabled return doc.enabled ? (
? { <Badge variant='green' size='sm'>
text: 'Enabled', Enabled
className: </Badge>
'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', ) : (
} <Badge variant='gray' size='sm'>
: { Disabled
text: 'Disabled', </Badge>
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',
}
default: default:
return { return (
text: 'Unknown', <Badge variant='gray' size='sm'>
className: Unknown
'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', </Badge>
} )
} }
} }
const TAG_SLOTS = [
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
'number1',
'number2',
'number3',
'number4',
'number5',
'date1',
'date2',
'boolean1',
'boolean2',
'boolean3',
] as const
type TagSlot = (typeof TAG_SLOTS)[number]
interface TagValue {
slot: TagSlot
displayName: string
value: string
}
const TAG_FIELD_TYPES: Record<string, string> = {
tag: 'text',
number: 'number',
date: 'date',
boolean: 'boolean',
}
/**
* Computes tag values for a document
*/
function getDocumentTags(doc: DocumentData, definitions: TagDefinition[]): TagValue[] {
const result: TagValue[] = []
for (const slot of TAG_SLOTS) {
const raw = doc[slot]
if (raw == null) continue
const def = definitions.find((d) => d.tagSlot === slot)
const fieldType = def?.fieldType || TAG_FIELD_TYPES[slot.replace(/\d+$/, '')] || 'text'
let value: string
if (fieldType === 'date') {
try {
value = format(new Date(raw as string), 'MMM d, yyyy')
} catch {
value = String(raw)
}
} else if (fieldType === 'boolean') {
value = raw ? 'Yes' : 'No'
} else if (fieldType === 'number' && typeof raw === 'number') {
value = raw.toLocaleString()
} else {
value = String(raw)
}
if (value) {
result.push({ slot, displayName: def?.displayName || slot, value })
}
}
return result
}
export function KnowledgeBase({ export function KnowledgeBase({
id, id,
knowledgeBaseName: passedKnowledgeBaseName, knowledgeBaseName: passedKnowledgeBaseName,
@@ -379,6 +453,8 @@ export function KnowledgeBase({
sortOrder, sortOrder,
}) })
const { tagDefinitions } = useKnowledgeBaseTagDefinitions(id)
const router = useRouter() const router = useRouter()
const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base' const knowledgeBaseName = knowledgeBase?.name || passedKnowledgeBaseName || 'Knowledge Base'
@@ -1058,12 +1134,15 @@ export function KnowledgeBase({
</div> </div>
</TableHead> </TableHead>
{renderSortableHeader('filename', 'Name', 'w-[180px] max-w-[180px]')} {renderSortableHeader('filename', 'Name', 'w-[180px] max-w-[180px]')}
{renderSortableHeader('fileSize', 'Size', 'w-[8%]')} {renderSortableHeader('fileSize', 'Size', 'hidden w-[8%] lg:table-cell')}
{renderSortableHeader('tokenCount', 'Tokens', 'w-[8%]')} {renderSortableHeader('tokenCount', 'Tokens', 'hidden w-[8%] lg:table-cell')}
{renderSortableHeader('chunkCount', 'Chunks', 'hidden w-[8%] lg:table-cell')} {renderSortableHeader('chunkCount', 'Chunks', 'w-[8%]')}
{renderSortableHeader('uploadedAt', 'Uploaded', 'w-[16%]')} {renderSortableHeader('uploadedAt', 'Uploaded', 'w-[11%]')}
{renderSortableHeader('processingStatus', 'Status', 'w-[12%]')} {renderSortableHeader('processingStatus', 'Status', 'w-[10%]')}
<TableHead className='w-[14%] py-[8px] pr-[4px] pl-[12px] text-[12px] text-[var(--text-secondary)]'> <TableHead className='w-[12%] px-[12px] py-[8px] text-[12px] text-[var(--text-secondary)]'>
Tags
</TableHead>
<TableHead className='w-[11%] py-[8px] pr-[4px] pl-[12px] text-[12px] text-[var(--text-secondary)]'>
Actions Actions
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -1071,7 +1150,6 @@ export function KnowledgeBase({
<TableBody> <TableBody>
{documents.map((doc) => { {documents.map((doc) => {
const isSelected = selectedDocuments.has(doc.id) const isSelected = selectedDocuments.has(doc.id)
const statusDisplay = getStatusDisplay(doc)
return ( return (
<TableRow <TableRow
@@ -1115,10 +1193,10 @@ export function KnowledgeBase({
</Tooltip.Root> </Tooltip.Root>
</div> </div>
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'> <TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'>
{formatFileSize(doc.fileSize)} {formatFileSize(doc.fileSize)}
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px] text-[12px]'> <TableCell className='hidden px-[12px] py-[8px] text-[12px] lg:table-cell'>
{doc.processingStatus === 'completed' ? ( {doc.processingStatus === 'completed' ? (
doc.tokenCount > 1000 ? ( doc.tokenCount > 1000 ? (
`${(doc.tokenCount / 1000).toFixed(1)}k` `${(doc.tokenCount / 1000).toFixed(1)}k`
@@ -1129,43 +1207,73 @@ export function KnowledgeBase({
<span className='text-[var(--text-muted)]'></span> <span className='text-[var(--text-muted)]'></span>
)} )}
</TableCell> </TableCell>
<TableCell className='hidden px-[12px] py-[8px] text-[12px] text-[var(--text-muted)] lg:table-cell'> <TableCell className='px-[12px] py-[8px] text-[12px] text-[var(--text-muted)]'>
{doc.processingStatus === 'completed' {doc.processingStatus === 'completed'
? doc.chunkCount.toLocaleString() ? doc.chunkCount.toLocaleString()
: '—'} : '—'}
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='px-[12px] py-[8px]'>
<div className='flex flex-col justify-center'> <Tooltip.Root>
<div className='flex items-center font-medium text-[12px]'> <Tooltip.Trigger asChild>
<span>{format(new Date(doc.uploadedAt), 'h:mm a')}</span> <span className='text-[12px] text-[var(--text-muted)]'>
<span className='mx-[6px] hidden text-[var(--text-muted)] xl:inline'> {format(new Date(doc.uploadedAt), 'MMM d')}
|
</span> </span>
<span className='hidden text-[var(--text-muted)] xl:inline'> </Tooltip.Trigger>
{format(new Date(doc.uploadedAt), 'MMM d, yyyy')} <Tooltip.Content side='top'>
</span> {format(new Date(doc.uploadedAt), 'MMM d, yyyy h:mm a')}
</div> </Tooltip.Content>
<div className='mt-[2px] text-[12px] text-[var(--text-muted)] lg:hidden'> </Tooltip.Root>
{format(new Date(doc.uploadedAt), 'MMM d')}
</div>
</div>
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'> <TableCell className='px-[12px] py-[8px]'>
{doc.processingStatus === 'failed' && doc.processingError ? ( {doc.processingStatus === 'failed' && doc.processingError ? (
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger asChild> <Tooltip.Trigger asChild>
<div className={statusDisplay.className} style={{ cursor: 'help' }}> <div style={{ cursor: 'help' }}>{getStatusBadge(doc)}</div>
{statusDisplay.text}
</div>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side='top' className='max-w-xs'> <Tooltip.Content side='top' className='max-w-xs'>
{doc.processingError} {doc.processingError}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
) : ( ) : (
<div className={statusDisplay.className}>{statusDisplay.text}</div> getStatusBadge(doc)
)} )}
</TableCell> </TableCell>
<TableCell className='px-[12px] py-[8px]'>
{(() => {
const tags = getDocumentTags(doc, tagDefinitions)
if (tags.length === 0) {
return <span className='text-[12px] text-[var(--text-muted)]'></span>
}
const displayText = tags.map((t) => t.value).join(', ')
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span
className='block max-w-full truncate text-[12px] text-[var(--text-secondary)]'
onClick={(e) => e.stopPropagation()}
>
{displayText}
</span>
</Tooltip.Trigger>
<Tooltip.Content
side='top'
className='max-h-[104px] max-w-[240px] overflow-y-auto'
>
<div className='flex flex-col gap-[2px]'>
{tags.map((tag) => (
<div key={tag.slot} className='text-[11px]'>
<span className='text-[var(--text-muted)]'>
{tag.displayName}:
</span>{' '}
{tag.value}
</div>
))}
</div>
</Tooltip.Content>
</Tooltip.Root>
)
})()}
</TableCell>
<TableCell className='py-[8px] pr-[4px] pl-[12px]'> <TableCell className='py-[8px] pr-[4px] pl-[12px]'>
<div className='flex items-center gap-[4px]'> <div className='flex items-center gap-[4px]'>
{doc.processingStatus === 'failed' && ( {doc.processingStatus === 'failed' && (