'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChipDropdownOption } from '@sim/emcn' import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn' import { Database } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import type { FilterTag, ResourceAction, ResourceCell, ResourceColumn, ResourceRow, SearchConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, ownerCell, Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, DeleteKnowledgeBaseModal, EditKnowledgeBaseModal, KnowledgeBaseContextMenu, KnowledgeListContextMenu, } from '@/app/workspace/[workspaceId]/knowledge/components' import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { usePermissionConfig } from '@/hooks/use-permission-config' const logger = createLogger('Knowledge') interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { docCount?: number } const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, { id: 'documents', header: 'Documents', widthMultiplier: 0.6 }, { id: 'tokens', header: 'Tokens', widthMultiplier: 0.6 }, { id: 'connectors', header: 'Connectors', widthMultiplier: 0.7 }, { id: 'created', header: 'Created' }, { id: 'owner', header: 'Owner' }, { id: 'updated', header: 'Last Updated' }, ] const KNOWLEDGE_BASE_ICON = const CONNECTOR_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'all', label: 'All' }, { value: 'connected', label: 'With connectors' }, { value: 'unconnected', label: 'Without connectors' }, ] const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'all', label: 'All' }, { value: 'has-docs', label: 'Has documents' }, { value: 'empty', label: 'Empty' }, ] const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' function connectorCell(connectorTypes?: string[]): ResourceCell { if (!connectorTypes || connectorTypes.length === 0) { return { label: EMPTY_CELL_PLACEHOLDER } } const entries = connectorTypes .map((type) => ({ type, def: CONNECTOR_META_REGISTRY[type] })) .filter( (e): e is { type: string; def: NonNullable<(typeof CONNECTOR_META_REGISTRY)[string]> } => Boolean(e.def?.icon) ) if (entries.length === 0) return { label: EMPTY_CELL_PLACEHOLDER } const visibleEntries = entries.slice(0, 3) const hiddenEntries = entries.slice(3) return { content: (
{visibleEntries.map(({ type, def }) => { const Icon = def.icon return ( {def.name} ) })} {hiddenEntries.length > 0 && ( +{hiddenEntries.length} {hiddenEntries.map(({ def }) => def.name).join(', ')} )}
), } } export function Knowledge() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const { config: permissionConfig } = usePermissionConfig() useEffect(() => { if (permissionConfig.hideKnowledgeBaseTab) { router.replace(`/workspace/${workspaceId}`) } }, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId]) const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) if (error) { logger.error('Failed to load knowledge bases:', error) } const userPermissions = useUserPermissionsContext() const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) const [activeSort, setActiveSort] = useState<{ column: string direction: 'asc' | 'desc' } | null>(null) const [connectorFilter, setConnectorFilter] = useState([]) const [contentFilter, setContentFilter] = useState([]) const [ownerFilter, setOwnerFilter] = useState([]) const [searchInputValue, setSearchInputValue] = useState('') const debouncedSearchQuery = useDebounce(searchInputValue, 300) const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) const [activeKnowledgeBase, setActiveKnowledgeBase] = useState( null ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const { isOpen: isListContextMenuOpen, position: listContextMenuPosition, handleContextMenu: handleListContextMenu, closeMenu: closeListContextMenu, } = useContextMenu() const { isOpen: isRowContextMenuOpen, position: rowContextMenuPosition, handleContextMenu: handleRowCtxMenu, closeMenu: closeRowContextMenu, } = useContextMenu() const isRowContextMenuOpenRef = useRef(isRowContextMenuOpen) isRowContextMenuOpenRef.current = isRowContextMenuOpen const knowledgeBasesRef = useRef(knowledgeBases) knowledgeBasesRef.current = knowledgeBases const activeKnowledgeBaseRef = useRef(activeKnowledgeBase) activeKnowledgeBaseRef.current = activeKnowledgeBase const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement if ( target.closest('[data-resource-row]') || target.closest('button, input, a, [role="button"]') ) { return } handleListContextMenu(e) }, [handleListContextMenu] ) const handleOpenCreateModal = useCallback(() => { setIsCreateModalOpen(true) }, []) const handleUpdateKnowledgeBase = useCallback( async (id: string, name: string, description: string) => { await updateKnowledgeBaseMutation({ knowledgeBaseId: id, updates: { name, description }, }) logger.info(`Knowledge base updated: ${id}`) }, [updateKnowledgeBaseMutation] ) const handleDeleteKnowledgeBase = useCallback( async (id: string) => { await deleteKnowledgeBaseMutation({ knowledgeBaseId: id }) logger.info(`Knowledge base deleted: ${id}`) }, [deleteKnowledgeBaseMutation] ) const processedKBs = useMemo(() => { let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery) if (connectorFilter.length > 0) { result = result.filter((kb) => { const hasConnectors = (kb.connectorTypes?.length ?? 0) > 0 if (connectorFilter.includes('connected') && hasConnectors) return true if (connectorFilter.includes('unconnected') && !hasConnectors) return true return false }) } if (contentFilter.length > 0) { const docCount = (kb: KnowledgeBaseData) => (kb as KnowledgeBaseWithDocCount).docCount ?? 0 result = result.filter((kb) => { if (contentFilter.includes('has-docs') && docCount(kb) > 0) return true if (contentFilter.includes('empty') && docCount(kb) === 0) return true return false }) } if (ownerFilter.length > 0) { result = result.filter((kb) => ownerFilter.includes(kb.userId)) } const col = activeSort?.column ?? 'updated' const dir = activeSort?.direction ?? 'desc' return [...result].sort((a, b) => { let cmp = 0 switch (col) { case 'name': cmp = a.name.localeCompare(b.name) break case 'documents': cmp = ((a as KnowledgeBaseWithDocCount).docCount || 0) - ((b as KnowledgeBaseWithDocCount).docCount || 0) break case 'tokens': cmp = (a.tokenCount || 0) - (b.tokenCount || 0) break case 'created': cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() break case 'updated': cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() break case 'connectors': cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) break case 'owner': cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare( members?.find((m) => m.userId === b.userId)?.name ?? '' ) break } return dir === 'asc' ? cmp : -cmp }) }, [ knowledgeBases, debouncedSearchQuery, connectorFilter, contentFilter, ownerFilter, activeSort, members, ]) const rows: ResourceRow[] = useMemo( () => processedKBs.map((kb) => { const kbWithCount = kb as KnowledgeBaseWithDocCount return { id: kb.id, cells: { name: { icon: KNOWLEDGE_BASE_ICON, label: kb.name, }, documents: { label: String(kbWithCount.docCount || 0), }, tokens: { label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', }, connectors: connectorCell(kb.connectorTypes), created: timeCell(kb.createdAt), owner: ownerCell(kb.userId, members), updated: timeCell(kb.updatedAt), }, } }), [processedKBs, members] ) const handleRowClick = useCallback( (rowId: string) => { if (isRowContextMenuOpenRef.current) return const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) if (!kb) return const urlParams = new URLSearchParams({ kbName: kb.name }) router.push(`/workspace/${workspaceId}/knowledge/${rowId}?${urlParams.toString()}`) }, [router, workspaceId] ) const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { const kb = knowledgeBasesRef.current.find((k) => k.id === rowId) as | KnowledgeBaseWithDocCount | undefined setActiveKnowledgeBase(kb ?? null) handleRowCtxMenu(e) }, [handleRowCtxMenu] ) const handleConfirmDelete = useCallback(async () => { const kb = activeKnowledgeBaseRef.current if (!kb) return setIsDeleting(true) try { await handleDeleteKnowledgeBase(kb.id) setIsDeleteModalOpen(false) setActiveKnowledgeBase(null) } finally { setIsDeleting(false) } }, [handleDeleteKnowledgeBase]) const handleCloseDeleteModal = useCallback(() => { setIsDeleteModalOpen(false) setActiveKnowledgeBase(null) }, []) const handleOpenInNewTab = useCallback(() => { const kb = activeKnowledgeBaseRef.current if (!kb) return const urlParams = new URLSearchParams({ kbName: kb.name }) window.open(`/workspace/${workspaceId}/knowledge/${kb.id}?${urlParams.toString()}`, '_blank') }, [workspaceId]) const handleViewTags = useCallback(() => { setIsTagsModalOpen(true) }, []) const handleCopyId = useCallback(() => { const kb = activeKnowledgeBaseRef.current if (kb) { navigator.clipboard.writeText(kb.id) } }, []) const handleEdit = useCallback(() => { setIsEditModalOpen(true) }, []) const handleDelete = useCallback(() => { setIsDeleteModalOpen(true) }, []) const canEdit = userPermissions.canEdit === true const headerActions: ResourceAction[] = useMemo( () => [ { text: 'New base', icon: Plus, onSelect: handleOpenCreateModal, disabled: !canEdit, variant: 'primary', }, ], [handleOpenCreateModal, canEdit] ) const searchConfig: SearchConfig = useMemo( () => ({ value: searchInputValue, onChange: setSearchInputValue, onClearAll: () => setSearchInputValue(''), placeholder: 'Search knowledge bases...', }), [searchInputValue] ) const sortConfig: SortConfig = useMemo( () => ({ options: [ { id: 'name', label: 'Name' }, { id: 'documents', label: 'Documents' }, { id: 'tokens', label: 'Tokens' }, { id: 'connectors', label: 'Connectors' }, { id: 'created', label: 'Created' }, { id: 'updated', label: 'Last Updated' }, { id: 'owner', label: 'Owner' }, ], active: activeSort, onSort: (column, direction) => setActiveSort({ column, direction }), onClear: () => setActiveSort(null), }), [activeSort] ) const memberOptions: ChipDropdownOption[] = useMemo( () => (members ?? []).map((m) => ({ value: m.userId, label: m.name, iconElement: m.image ? ( {m.name} ) : ( {m.name.charAt(0).toUpperCase()} ), })), [members] ) const filterContent = useMemo( () => (
Connectors {connectorFilter.length > 0 && ( )}
setConnectorFilter(value === 'all' ? [] : [value])} align='start' fullWidth flush />
Content {contentFilter.length > 0 && ( )}
setContentFilter(value === 'all' ? [] : [value])} align='start' fullWidth flush />
{memberOptions.length > 0 && (
Owner {ownerFilter.length > 0 && ( )}
)}
), [connectorFilter, contentFilter, ownerFilter, memberOptions] ) const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (connectorFilter.length > 0) { const label = connectorFilter.length === 1 ? `Connectors: ${connectorFilter[0] === 'connected' ? 'With connectors' : 'Without connectors'}` : `Connectors: ${connectorFilter.length} types` tags.push({ label, onRemove: () => setConnectorFilter([]) }) } if (contentFilter.length > 0) { const label = contentFilter.length === 1 ? `Content: ${contentFilter[0] === 'has-docs' ? 'Has documents' : 'Empty'}` : `Content: ${contentFilter.length} types` tags.push({ label, onRemove: () => setContentFilter([]) }) } if (ownerFilter.length > 0) { const label = ownerFilter.length === 1 ? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}` : `Owner: ${ownerFilter.length} members` tags.push({ label, onRemove: () => setOwnerFilter([]) }) } return tags }, [connectorFilter, contentFilter, ownerFilter, members]) return ( <> {activeKnowledgeBase && ( )} {activeKnowledgeBase && ( )} {activeKnowledgeBase && ( )} {activeKnowledgeBase && ( )} ) }