diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts new file mode 100644 index 0000000000..caa0446194 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts @@ -0,0 +1,118 @@ +import { randomUUID } from 'crypto' +import { and, eq, isNotNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { db } from '@/db' +import { document, embedding, knowledgeBaseTagDefinitions } from '@/db/schema' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TagDefinitionAPI') + +// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string; tagId: string }> } +) { + const requestId = randomUUID().slice(0, 8) + const { id: knowledgeBaseId, tagId } = await params + + try { + logger.info( + `[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}` + ) + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user has access to the knowledge base + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + if (!accessCheck.hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Get the tag definition to find which slot it uses + const tagDefinition = await db + .select({ + id: knowledgeBaseTagDefinitions.id, + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + }) + .from(knowledgeBaseTagDefinitions) + .where( + and( + eq(knowledgeBaseTagDefinitions.id, tagId), + eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId) + ) + ) + .limit(1) + + if (tagDefinition.length === 0) { + return NextResponse.json({ error: 'Tag definition not found' }, { status: 404 }) + } + + const tagDef = tagDefinition[0] + + // Delete the tag definition and clear all document tags in a transaction + await db.transaction(async (tx) => { + logger.info(`[${requestId}] Starting transaction to delete ${tagDef.tagSlot}`) + + try { + // Clear the tag from documents that actually have this tag set + logger.info(`[${requestId}] Clearing tag from documents...`) + await tx + .update(document) + .set({ [tagDef.tagSlot]: null }) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + isNotNull(document[tagDef.tagSlot as keyof typeof document.$inferSelect]) + ) + ) + + logger.info(`[${requestId}] Documents updated successfully`) + + // Clear the tag from embeddings that actually have this tag set + logger.info(`[${requestId}] Clearing tag from embeddings...`) + await tx + .update(embedding) + .set({ [tagDef.tagSlot]: null }) + .where( + and( + eq(embedding.knowledgeBaseId, knowledgeBaseId), + isNotNull(embedding[tagDef.tagSlot as keyof typeof embedding.$inferSelect]) + ) + ) + + logger.info(`[${requestId}] Embeddings updated successfully`) + + // Delete the tag definition + logger.info(`[${requestId}] Deleting tag definition...`) + await tx + .delete(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.id, tagId)) + + logger.info(`[${requestId}] Tag definition deleted successfully`) + } catch (error) { + logger.error(`[${requestId}] Error in transaction:`, error) + throw error + } + }) + + logger.info( + `[${requestId}] Successfully deleted tag definition ${tagDef.displayName} (${tagDef.tagSlot})` + ) + + return NextResponse.json({ + success: true, + message: `Tag definition "${tagDef.displayName}" deleted successfully`, + }) + } catch (error) { + logger.error(`[${requestId}] Error deleting tag definition`, error) + return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts index 6d43155f03..af74e474a5 100644 --- a/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -55,3 +55,89 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id: return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 }) } } + +// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = randomUUID().slice(0, 8) + const { id: knowledgeBaseId } = await params + + try { + logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`) + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user has access to the knowledge base + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + if (!accessCheck.hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + const body = await req.json() + const { tagSlot, displayName, fieldType } = body + + if (!tagSlot || !displayName || !fieldType) { + return NextResponse.json( + { error: 'tagSlot, displayName, and fieldType are required' }, + { status: 400 } + ) + } + + // Check if tag slot is already used + const existingTag = await db + .select() + .from(knowledgeBaseTagDefinitions) + .where( + and( + eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId), + eq(knowledgeBaseTagDefinitions.tagSlot, tagSlot) + ) + ) + .limit(1) + + if (existingTag.length > 0) { + return NextResponse.json({ error: 'Tag slot is already in use' }, { status: 409 }) + } + + // Check if display name is already used + const existingName = await db + .select() + .from(knowledgeBaseTagDefinitions) + .where( + and( + eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId), + eq(knowledgeBaseTagDefinitions.displayName, displayName) + ) + ) + .limit(1) + + if (existingName.length > 0) { + return NextResponse.json({ error: 'Tag name is already in use' }, { status: 409 }) + } + + // Create the new tag definition + const newTagDefinition = { + id: randomUUID(), + knowledgeBaseId, + tagSlot, + displayName, + fieldType, + createdAt: new Date(), + updatedAt: new Date(), + } + + await db.insert(knowledgeBaseTagDefinitions).values(newTagDefinition) + + logger.info(`[${requestId}] Successfully created tag definition ${displayName} (${tagSlot})`) + + return NextResponse.json({ + success: true, + data: newTagDefinition, + }) + } catch (error) { + logger.error(`[${requestId}] Error creating tag definition`, error) + return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts new file mode 100644 index 0000000000..bf2fc7e173 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/tag-usage/route.ts @@ -0,0 +1,88 @@ +import { randomUUID } from 'crypto' +import { and, eq, isNotNull } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { createLogger } from '@/lib/logs/console/logger' +import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' +import { db } from '@/db' +import { document, knowledgeBaseTagDefinitions } from '@/db/schema' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('TagUsageAPI') + +// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const requestId = randomUUID().slice(0, 8) + const { id: knowledgeBaseId } = await params + + try { + logger.info(`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`) + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user has access to the knowledge base + const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id) + if (!accessCheck.hasAccess) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Get all tag definitions for the knowledge base + const tagDefinitions = await db + .select({ + id: knowledgeBaseTagDefinitions.id, + tagSlot: knowledgeBaseTagDefinitions.tagSlot, + displayName: knowledgeBaseTagDefinitions.displayName, + }) + .from(knowledgeBaseTagDefinitions) + .where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)) + + // Get usage statistics for each tag definition + const usageStats = await Promise.all( + tagDefinitions.map(async (tagDef) => { + // Count documents using this tag slot + const tagSlotColumn = tagDef.tagSlot as keyof typeof document.$inferSelect + + const documentsWithTag = await db + .select({ + id: document.id, + filename: document.filename, + [tagDef.tagSlot]: document[tagSlotColumn as keyof typeof document.$inferSelect] as any, + }) + .from(document) + .where( + and( + eq(document.knowledgeBaseId, knowledgeBaseId), + isNotNull(document[tagSlotColumn as keyof typeof document.$inferSelect]) + ) + ) + + return { + tagName: tagDef.displayName, + tagSlot: tagDef.tagSlot, + documentCount: documentsWithTag.length, + documents: documentsWithTag.map((doc) => ({ + id: doc.id, + name: doc.filename, + tagValue: doc[tagDef.tagSlot], + })), + } + }) + ) + + logger.info( + `[${requestId}] Retrieved usage statistics for ${tagDefinitions.length} tag definitions` + ) + + return NextResponse.json({ + success: true, + data: usageStats, + }) + } catch (error) { + logger.error(`[${requestId}] Error getting tag usage statistics`, error) + return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index b5fb1ecd6a..d4bd05ab8f 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -1,8 +1,8 @@ 'use client' -import { useCallback, useEffect, useState } from 'react' +import { Suspense, startTransition, useCallback, useEffect, useState } from 'react' import { ChevronLeft, ChevronRight, Circle, CircleOff, FileText, Plus, Trash2 } from 'lucide-react' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useSearchParams } from 'next/navigation' import { Button, Checkbox, @@ -11,7 +11,6 @@ import { TooltipContent, TooltipTrigger, } from '@/components/ui' -import { TAG_SLOTS } from '@/lib/constants/knowledge' import { createLogger } from '@/lib/logs/console/logger' import { CreateChunkModal, @@ -21,13 +20,8 @@ import { } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { KnowledgeHeader, SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components' -import { - type DocumentTag, - DocumentTagEntry, -} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useDocumentChunks } from '@/hooks/use-knowledge' -import { useTagDefinitions } from '@/hooks/use-tag-definitions' import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' const logger = createLogger('Document') @@ -62,150 +56,337 @@ export function Document({ updateDocument: updateDocumentInStore, } = useKnowledgeStore() const { workspaceId } = useParams() - const router = useRouter() const searchParams = useSearchParams() const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10) const userPermissions = useUserPermissionsContext() + // Search state management + const [searchQuery, setSearchQuery] = useState('') + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('') + const [isSearching, setIsSearching] = useState(false) + + // Load initial chunks (no search) for immediate display const { - chunks: paginatedChunks, - allChunks, - searchQuery, - setSearchQuery, - currentPage, - totalPages, - hasNextPage, - hasPrevPage, - goToPage, - nextPage, - prevPage, - isLoading: isLoadingAllChunks, - error: chunksError, - refreshChunks, - updateChunk, + chunks: initialChunks, + currentPage: initialPage, + totalPages: initialTotalPages, + hasNextPage: initialHasNextPage, + hasPrevPage: initialHasPrevPage, + goToPage: initialGoToPage, + error: initialError, + refreshChunks: initialRefreshChunks, + updateChunk: initialUpdateChunk, } = useDocumentChunks(knowledgeBaseId, documentId, currentPageFromURL, '', { - enableClientSearch: true, + enableClientSearch: false, }) + // Search results state + const [searchResults, setSearchResults] = useState([]) + const [isLoadingSearch, setIsLoadingSearch] = useState(false) + const [searchError, setSearchError] = useState(null) + + // Load all search results when query changes + useEffect(() => { + if (!debouncedSearchQuery.trim()) { + setSearchResults([]) + setSearchError(null) + return + } + + let isMounted = true + + const searchAllChunks = async () => { + try { + setIsLoadingSearch(true) + setSearchError(null) + + const allResults: ChunkData[] = [] + let hasMore = true + let offset = 0 + const limit = 100 // Larger batches for search + + while (hasMore && isMounted) { + const response = await fetch( + `/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks?search=${encodeURIComponent(debouncedSearchQuery)}&limit=${limit}&offset=${offset}` + ) + + if (!response.ok) { + throw new Error('Search failed') + } + + const result = await response.json() + + if (result.success && result.data) { + allResults.push(...result.data) + hasMore = result.pagination?.hasMore || false + offset += limit + } else { + hasMore = false + } + } + + if (isMounted) { + setSearchResults(allResults) + } + } catch (err) { + if (isMounted) { + setSearchError(err instanceof Error ? err.message : 'Search failed') + } + } finally { + if (isMounted) { + setIsLoadingSearch(false) + } + } + } + + searchAllChunks() + + return () => { + isMounted = false + } + }, [debouncedSearchQuery, knowledgeBaseId, documentId]) + const [selectedChunks, setSelectedChunks] = useState>(new Set()) const [selectedChunk, setSelectedChunk] = useState(null) const [isModalOpen, setIsModalOpen] = useState(false) - const [documentTags, setDocumentTags] = useState([]) + // Debounce search query with 200ms delay for optimal UX + useEffect(() => { + const handler = setTimeout(() => { + startTransition(() => { + setDebouncedSearchQuery(searchQuery) + setIsSearching(searchQuery.trim().length > 0) + }) + }, 200) + + return () => { + clearTimeout(handler) + } + }, [searchQuery]) + + // Determine which data to show + const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0 + + // Removed unused allDisplayChunks variable + + // Client-side pagination for search results + const SEARCH_PAGE_SIZE = 50 + const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE) + const searchCurrentPage = + showingSearch && maxSearchPages > 0 + ? Math.max(1, Math.min(currentPageFromURL, maxSearchPages)) + : 1 + const searchTotalPages = Math.max(1, maxSearchPages) + const searchStartIndex = (searchCurrentPage - 1) * SEARCH_PAGE_SIZE + const paginatedSearchResults = searchResults.slice( + searchStartIndex, + searchStartIndex + SEARCH_PAGE_SIZE + ) + + const displayChunks = showingSearch ? paginatedSearchResults : initialChunks + const currentPage = showingSearch ? searchCurrentPage : initialPage + const totalPages = showingSearch ? searchTotalPages : initialTotalPages + const hasNextPage = showingSearch ? searchCurrentPage < searchTotalPages : initialHasNextPage + const hasPrevPage = showingSearch ? searchCurrentPage > 1 : initialHasPrevPage + + const goToPage = useCallback( + async (page: number) => { + // Update URL first for both modes + const params = new URLSearchParams(window.location.search) + if (page > 1) { + params.set('page', page.toString()) + } else { + params.delete('page') + } + window.history.replaceState(null, '', `?${params.toString()}`) + + if (showingSearch) { + // For search, URL update is sufficient (client-side pagination) + return + } + // For normal view, also trigger server-side pagination + return await initialGoToPage(page) + }, + [showingSearch, initialGoToPage] + ) + + const nextPage = useCallback(async () => { + if (hasNextPage) { + await goToPage(currentPage + 1) + } + }, [hasNextPage, currentPage, goToPage]) + + const prevPage = useCallback(async () => { + if (hasPrevPage) { + await goToPage(currentPage - 1) + } + }, [hasPrevPage, currentPage, goToPage]) + + const refreshChunks = showingSearch ? async () => {} : initialRefreshChunks + const updateChunk = showingSearch ? (id: string, updates: any) => {} : initialUpdateChunk + const [documentData, setDocumentData] = useState(null) const [isLoadingDocument, setIsLoadingDocument] = useState(true) const [error, setError] = useState(null) - // Use tag definitions hook for custom labels - const { tagDefinitions, fetchTagDefinitions } = useTagDefinitions(knowledgeBaseId, documentId) - - // Function to build document tags from data and definitions - const buildDocumentTags = useCallback( - (docData: DocumentData, definitions: any[], currentTags?: DocumentTag[]) => { - const tags: DocumentTag[] = [] - const tagSlots = TAG_SLOTS - - tagSlots.forEach((slot) => { - const value = (docData as any)[slot] as string | null | undefined - const definition = definitions.find((def) => def.tagSlot === slot) - const currentTag = currentTags?.find((tag) => tag.slot === slot) - - // Only include tag if the document actually has a value for it - if (value?.trim()) { - tags.push({ - slot, - // Preserve existing displayName if definition is not found yet - displayName: definition?.displayName || currentTag?.displayName || '', - fieldType: definition?.fieldType || currentTag?.fieldType || 'text', - value: value.trim(), - }) - } - }) - - return tags - }, - [] - ) - - // Handle tag updates (local state only, no API calls) - const handleTagsChange = useCallback((newTags: DocumentTag[]) => { - // Only update local state, don't save to API - setDocumentTags(newTags) - }, []) - - // Handle saving document tag values to the API - const handleSaveDocumentTags = useCallback( - async (tagsToSave: DocumentTag[]) => { - if (!documentData) return - - try { - // Convert DocumentTag array to tag data for API - const tagData: Record = {} - const tagSlots = TAG_SLOTS - - // Clear all tags first - tagSlots.forEach((slot) => { - tagData[slot] = '' - }) - - // Set values from tagsToSave - tagsToSave.forEach((tag) => { - if (tag.value.trim()) { - tagData[tag.slot] = tag.value.trim() - } - }) - - // Update document via API - const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(tagData), - }) - - if (!response.ok) { - throw new Error('Failed to update document tags') - } - - // Update the document in the store and local state - updateDocumentInStore(knowledgeBaseId, documentId, tagData) - setDocumentData((prev) => (prev ? { ...prev, ...tagData } : null)) - - // Refresh tag definitions to update the display - await fetchTagDefinitions() - } catch (error) { - logger.error('Error updating document tags:', error) - throw error // Re-throw so the component can handle it - } - }, - [documentData, knowledgeBaseId, documentId, updateDocumentInStore, fetchTagDefinitions] - ) const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false) const [chunkToDelete, setChunkToDelete] = useState(null) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isBulkOperating, setIsBulkOperating] = useState(false) - const combinedError = error || chunksError + const combinedError = error || searchError || initialError - // URL synchronization for pagination - const updatePageInURL = useCallback( - (newPage: number) => { - const params = new URLSearchParams(searchParams) - if (newPage > 1) { - params.set('page', newPage.toString()) - } else { - params.delete('page') - } - router.replace(`?${params.toString()}`, { scroll: false }) - }, - [router, searchParams] - ) + // Render chunks with proper search highlighting + const renderChunks = () => { + if (documentData?.processingStatus !== 'completed') { + return ( + + +
+ + +
+ + +
+ + + {documentData?.processingStatus === 'pending' && 'Document processing pending...'} + {documentData?.processingStatus === 'processing' && + 'Document processing in progress...'} + {documentData?.processingStatus === 'failed' && 'Document processing failed'} + {!documentData?.processingStatus && 'Document not ready'} + +
+ + +
+ + +
+ + +
+ + + ) + } - // Sync URL when page changes - useEffect(() => { - updatePageInURL(currentPage) - }, [currentPage, updatePageInURL]) + if (displayChunks.length === 0) { + return ( + + +
+ + +
+ + +
+ + + {searchQuery.trim() ? 'No chunks match your search' : 'No chunks found'} + +
+ + +
+ + +
+ + +
+ + + ) + } + + return displayChunks.map((chunk: ChunkData) => ( + handleChunkClick(chunk)} + > + + handleSelectChunk(chunk.id, checked as boolean)} + disabled={!userPermissions.canEdit} + aria-label={`Select chunk ${chunk.chunkIndex}`} + className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3' + onClick={(e) => e.stopPropagation()} + /> + + +
{chunk.chunkIndex}
+ + +
+ +
+ + +
+ {chunk.tokenCount > 1000 + ? `${(chunk.tokenCount / 1000).toFixed(1)}k` + : chunk.tokenCount} +
+ + +
+ {chunk.enabled ? 'Enabled' : 'Disabled'} +
+ + +
+ + + + + + {chunk.enabled ? 'Disable Chunk' : 'Enable Chunk'} + + + + + + + Delete Chunk + +
+ + + )) + } + + // URL updates are handled directly in goToPage function to prevent pagination conflicts useEffect(() => { const fetchDocument = async () => { @@ -218,9 +399,6 @@ export function Document({ if (cachedDoc) { setDocumentData(cachedDoc) - // Initialize tags from cached document - const initialTags = buildDocumentTags(cachedDoc, tagDefinitions) - setDocumentTags(initialTags) setIsLoadingDocument(false) return } @@ -238,9 +416,6 @@ export function Document({ if (result.success) { setDocumentData(result.data) - // Initialize tags from fetched document - const initialTags = buildDocumentTags(result.data, tagDefinitions, []) - setDocumentTags(initialTags) } else { throw new Error(result.error || 'Failed to fetch document') } @@ -255,15 +430,7 @@ export function Document({ if (knowledgeBaseId && documentId) { fetchDocument() } - }, [knowledgeBaseId, documentId, getCachedDocuments, buildDocumentTags]) - - // Separate effect to rebuild tags when tag definitions change (without re-fetching document) - useEffect(() => { - if (documentData) { - const rebuiltTags = buildDocumentTags(documentData, tagDefinitions, documentTags) - setDocumentTags(rebuiltTags) - } - }, [documentData, tagDefinitions, buildDocumentTags]) + }, [knowledgeBaseId, documentId, getCachedDocuments]) const knowledgeBase = getCachedKnowledgeBase(knowledgeBaseId) const effectiveKnowledgeBaseName = knowledgeBase?.name || knowledgeBaseName || 'Knowledge Base' @@ -289,7 +456,7 @@ export function Document({ } const handleToggleEnabled = async (chunkId: string) => { - const chunk = allChunks.find((c) => c.id === chunkId) + const chunk = displayChunks.find((c) => c.id === chunkId) if (!chunk) return try { @@ -321,7 +488,7 @@ export function Document({ } const handleDeleteChunk = (chunkId: string) => { - const chunk = allChunks.find((c) => c.id === chunkId) + const chunk = displayChunks.find((c) => c.id === chunkId) if (chunk) { setChunkToDelete(chunk) setIsDeleteModalOpen(true) @@ -358,7 +525,7 @@ export function Document({ const handleSelectAll = (checked: boolean) => { if (checked) { - setSelectedChunks(new Set(paginatedChunks.map((chunk: ChunkData) => chunk.id))) + setSelectedChunks(new Set(displayChunks.map((chunk: ChunkData) => chunk.id))) } else { setSelectedChunks(new Set()) } @@ -427,32 +594,32 @@ export function Document({ } const handleBulkEnable = async () => { - const chunksToEnable = allChunks.filter( + const chunksToEnable = displayChunks.filter( (chunk) => selectedChunks.has(chunk.id) && !chunk.enabled ) await performBulkChunkOperation('enable', chunksToEnable) } const handleBulkDisable = async () => { - const chunksToDisable = allChunks.filter( + const chunksToDisable = displayChunks.filter( (chunk) => selectedChunks.has(chunk.id) && chunk.enabled ) await performBulkChunkOperation('disable', chunksToDisable) } const handleBulkDelete = async () => { - const chunksToDelete = allChunks.filter((chunk) => selectedChunks.has(chunk.id)) + const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id)) await performBulkChunkOperation('delete', chunksToDelete) } // Calculate bulk operation counts - const selectedChunksList = allChunks.filter((chunk) => selectedChunks.has(chunk.id)) + const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id)) const enabledCount = selectedChunksList.filter((chunk) => chunk.enabled).length const disabledCount = selectedChunksList.filter((chunk) => !chunk.enabled).length - const isAllSelected = paginatedChunks.length > 0 && selectedChunks.size === paginatedChunks.length + const isAllSelected = displayChunks.length > 0 && selectedChunks.size === displayChunks.length - if (isLoadingDocument || isLoadingAllChunks) { + if (isLoadingDocument) { return (
- {/* Document Tag Entry */} - {userPermissions.canEdit && ( -
- -
- )} + {/* Document Tag Entry moved to sidebar */} {/* Error State for chunks */} - {combinedError && !isLoadingAllChunks && ( + {combinedError && (

Error loading chunks: {combinedError}

)} - {/* Table container */} -
- {/* Table header - fixed */} -
- - - - - - - - - - - - - - - - - - - -
- - - Index - - - Content - - - Tokens - - Status - - - Actions - -
-
- - {/* Table body - scrollable */} -
- - - - - - - - - - - {documentData?.processingStatus !== 'completed' ? ( - - - - - - - + { + /* Table container */ +
+ {/* Table header - fixed */} +
+
-
-
-
-
-
- - - {documentData?.processingStatus === 'pending' && - 'Document processing pending...'} - {documentData?.processingStatus === 'processing' && - 'Document processing in progress...'} - {documentData?.processingStatus === 'failed' && - 'Document processing failed'} - {!documentData?.processingStatus && 'Document not ready'} - -
-
-
-
-
-
-
-
+ + + + + + + + + + + + + + + + - ) : paginatedChunks.length === 0 && !isLoadingAllChunks ? ( - - - - - - - - - ) : isLoadingAllChunks ? ( - // Show loading skeleton rows when chunks are loading - Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - - )) - ) : ( - paginatedChunks.map((chunk: ChunkData) => ( - handleChunkClick(chunk)} - > - {/* Select column */} - - - {/* Index column */} - - - {/* Content column */} - - - {/* Tokens column */} - - - {/* Status column */} - - - {/* Actions column */} - - - )) - )} - -
+ + + + Index + + + + Content + + + + Tokens + + + + Status + + + + Actions + +
-
-
-
-
-
- - - {documentData?.processingStatus === 'completed' - ? searchQuery.trim() - ? 'No chunks match your search' - : 'No chunks found' - : 'Document is still processing...'} - -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - handleSelectChunk(chunk.id, checked as boolean) - } - disabled={!userPermissions.canEdit} - aria-label={`Select chunk ${chunk.chunkIndex}`} - className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3' - onClick={(e) => e.stopPropagation()} - /> - -
{chunk.chunkIndex}
-
-
- -
-
-
- {chunk.tokenCount > 1000 - ? `${(chunk.tokenCount / 1000).toFixed(1)}k` - : chunk.tokenCount} -
-
-
- - {chunk.enabled ? 'Enabled' : 'Disabled'} - -
-
-
- - - - - - {chunk.enabled ? 'Disable Chunk' : 'Enable Chunk'} - - - - - - - - Delete Chunk - -
-
-
- - {/* Pagination Controls */} - {documentData?.processingStatus === 'completed' && totalPages > 1 && ( -
-
- - - {/* Page numbers - show a few around current page */} -
- {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { - let page: number - if (totalPages <= 5) { - page = i + 1 - } else if (currentPage <= 3) { - page = i + 1 - } else if (currentPage >= totalPages - 2) { - page = totalPages - 4 + i - } else { - page = currentPage - 2 + i - } - - if (page < 1 || page > totalPages) return null - - return ( - - ) - })} -
- - -
+ +
- )} -
+ + {/* Table body - scrollable */} +
+ + + + + + + + + + + {showingSearch ? ( + {renderChunks()} + ) : ( + renderChunks() + )} + +
+
+ + {/* Pagination Controls */} + {documentData?.processingStatus === 'completed' && totalPages > 1 && ( +
+
+ + + {/* Page numbers - show a few around current page */} +
+ {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => { + let page: number + if (totalPages <= 5) { + page = i + 1 + } else if (currentPage <= 3) { + page = i + 1 + } else if (currentPage >= totalPages - 2) { + page = totalPages - 4 + i + } else { + page = currentPage - 2 + i + } + + if (page < 1 || page > totalPages) return null + + return ( + + ) + })} +
+ + +
+
+ )} +
+ } @@ -875,7 +855,7 @@ export function Document({ updateChunk(updatedChunk.id, updatedChunk) setSelectedChunk(updatedChunk) }} - allChunks={allChunks} + allChunks={displayChunks} currentPage={currentPage} totalPages={totalPages} onNavigateToChunk={(chunk: ChunkData) => { @@ -885,11 +865,11 @@ export function Document({ await goToPage(page) const checkAndSelectChunk = () => { - if (!isLoadingAllChunks && paginatedChunks.length > 0) { + if (displayChunks.length > 0) { if (selectChunk === 'first') { - setSelectedChunk(paginatedChunks[0]) + setSelectedChunk(displayChunks[0]) } else { - setSelectedChunk(paginatedChunks[paginatedChunks.length - 1]) + setSelectedChunk(displayChunks[displayChunks.length - 1]) } } else { // Retry after a short delay if chunks aren't loaded yet diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 5852607265..529e9e4b49 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -680,19 +680,6 @@ export function KnowledgeBase({ />
- {/* Clear Search Button */} - {searchQuery && ( - - )} - {/* Add Documents Button */} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx index d45bbb293f..6834814720 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx @@ -26,10 +26,13 @@ import { TooltipTrigger, } from '@/components/ui' import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge' +import { createLogger } from '@/lib/logs/console/logger' import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions' import { useNextAvailableSlot } from '@/hooks/use-next-available-slot' import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions' +const logger = createLogger('DocumentTagEntry') + export interface DocumentTag { slot: string displayName: string @@ -246,7 +249,7 @@ export function DocumentTagEntry({ setModalOpen(false) } catch (error) { - console.error('Error saving tag:', error) + logger.error('Error saving tag:', error) } } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx index 8927238833..2472433bea 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/search-input/search-input.tsx @@ -8,6 +8,7 @@ interface SearchInputProps { placeholder: string disabled?: boolean className?: string + isLoading?: boolean } export function SearchInput({ @@ -16,6 +17,7 @@ export function SearchInput({ placeholder, disabled = false, className = 'max-w-md flex-1', + isLoading = false, }: SearchInputProps) { return (
@@ -29,13 +31,20 @@ export function SearchInput({ disabled={disabled} className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50' /> - {value && !disabled && ( - + {isLoading ? ( +
+
+
+ ) : ( + value && + !disabled && ( + + ) )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index af42dd4c58..cdb7de74a6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -1,6 +1,8 @@ export { CreateMenu } from './create-menu/create-menu' export { FolderTree } from './folder-tree/folder-tree' export { HelpModal } from './help-modal/help-modal' +export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags' +export { KnowledgeTags } from './knowledge-tags/knowledge-tags' export { LogsFilters } from './logs-filters/logs-filters' export { SettingsModal } from './settings-modal/settings-modal' export { SubscriptionModal } from './subscription-modal/subscription-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-base-tags/knowledge-base-tags.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-base-tags/knowledge-base-tags.tsx new file mode 100644 index 0000000000..1e6e678e94 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-base-tags/knowledge-base-tags.tsx @@ -0,0 +1,565 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Eye, MoreHorizontal, Plus, Trash2, X } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui' +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { ScrollArea } from '@/components/ui/scroll-area' +import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge' +import { createLogger } from '@/lib/logs/console/logger' +import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + type TagDefinition, + useKnowledgeBaseTagDefinitions, +} from '@/hooks/use-knowledge-base-tag-definitions' + +const logger = createLogger('KnowledgeBaseTags') + +// Predetermined colors for each tag slot (same as document tags) +const TAG_SLOT_COLORS = [ + '#701FFC', // Purple + '#FF6B35', // Orange + '#4ECDC4', // Teal + '#45B7D1', // Blue + '#96CEB4', // Green + '#FFEAA7', // Yellow + '#DDA0DD', // Plum + '#FF7675', // Red + '#74B9FF', // Light Blue + '#A29BFE', // Lavender +] as const + +interface KnowledgeBaseTagsProps { + knowledgeBaseId: string +} + +interface TagUsageData { + tagName: string + tagSlot: string + documentCount: number + documents: Array<{ id: string; name: string; tagValue: string }> +} + +export function KnowledgeBaseTags({ knowledgeBaseId }: KnowledgeBaseTagsProps) { + const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = + useKnowledgeBaseTagDefinitions(knowledgeBaseId) + const userPermissions = useUserPermissionsContext() + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [selectedTag, setSelectedTag] = useState(null) + const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const [tagUsageData, setTagUsageData] = useState([]) + const [isLoadingUsage, setIsLoadingUsage] = useState(false) + const [isCreating, setIsCreating] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [createForm, setCreateForm] = useState({ + displayName: '', + fieldType: 'text', + }) + + // Get color for a tag based on its slot + const getTagColor = (slot: string) => { + const slotMatch = slot.match(/tag(\d+)/) + const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0 + return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length] + } + + // Fetch tag usage data from API + const fetchTagUsage = async () => { + if (!knowledgeBaseId) return + + setIsLoadingUsage(true) + try { + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`) + if (!response.ok) { + throw new Error('Failed to fetch tag usage') + } + const result = await response.json() + if (result.success) { + setTagUsageData(result.data) + } + } catch (error) { + logger.error('Error fetching tag usage:', error) + } finally { + setIsLoadingUsage(false) + } + } + + // Load tag usage data when component mounts or knowledge base changes + useEffect(() => { + fetchTagUsage() + }, [knowledgeBaseId]) + + // Get usage data for a tag + const getTagUsage = (tagName: string): TagUsageData => { + return ( + tagUsageData.find((usage) => usage.tagName === tagName) || { + tagName, + tagSlot: '', + documentCount: 0, + documents: [], + } + ) + } + + const handleDeleteTag = async (tag: TagDefinition) => { + setSelectedTag(tag) + // Fetch fresh usage data before showing the delete dialog + await fetchTagUsage() + setDeleteDialogOpen(true) + } + + const handleViewDocuments = async (tag: TagDefinition) => { + setSelectedTag(tag) + // Fetch fresh usage data before showing the view documents dialog + await fetchTagUsage() + setViewDocumentsDialogOpen(true) + } + + const openTagCreator = () => { + setCreateForm({ + displayName: '', + fieldType: 'text', + }) + setIsCreating(true) + } + + const cancelCreating = () => { + setCreateForm({ + displayName: '', + fieldType: 'text', + }) + setIsCreating(false) + } + + const hasNameConflict = (name: string) => { + if (!name.trim()) return false + return kbTagDefinitions.some( + (tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase() + ) + } + + // Check for conflicts in real-time during creation (but not while saving) + const nameConflict = isCreating && !isSaving && hasNameConflict(createForm.displayName) + + const canSave = () => { + return createForm.displayName.trim() && !hasNameConflict(createForm.displayName) + } + + const saveTagDefinition = async () => { + if (!canSave()) return + + setIsSaving(true) + try { + // Find next available slot + const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot)) + const availableSlot = ( + ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const + ).find((slot) => !usedSlots.has(slot)) + + if (!availableSlot) { + throw new Error('No available tag slots') + } + + // Create the tag definition + const newTagDefinition = { + tagSlot: availableSlot, + displayName: createForm.displayName.trim(), + fieldType: createForm.fieldType, + } + + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newTagDefinition), + }) + + if (!response.ok) { + throw new Error('Failed to create tag definition') + } + + // Refresh tag definitions and usage data + await Promise.all([refreshTagDefinitions(), fetchTagUsage()]) + + // Reset form and close creator + setCreateForm({ + displayName: '', + fieldType: 'text', + }) + setIsCreating(false) + } catch (error) { + logger.error('Error creating tag definition:', error) + } finally { + setIsSaving(false) + } + } + + const confirmDeleteTag = async () => { + if (!selectedTag) return + + logger.info('Starting delete operation for:', selectedTag.displayName) + setIsDeleting(true) + + try { + logger.info('Calling delete API for tag:', selectedTag.displayName) + + const response = await fetch( + `/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`, + { + method: 'DELETE', + } + ) + + logger.info('Delete API response status:', response.status) + + if (!response.ok) { + const errorText = await response.text() + logger.error('Delete API failed:', errorText) + throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`) + } + + logger.info('Delete API successful, refreshing data...') + + // Refresh both tag definitions and usage data + await Promise.all([refreshTagDefinitions(), fetchTagUsage()]) + + logger.info('Data refresh complete, closing dialog') + + // Only close dialog and reset state after successful deletion and refresh + setDeleteDialogOpen(false) + setSelectedTag(null) + + logger.info('Delete operation completed successfully') + } catch (error) { + logger.error('Error deleting tag definition:', error) + // Don't close dialog on error - let user see the error and try again or cancel + } finally { + logger.info('Setting isDeleting to false') + setIsDeleting(false) + } + } + + // Don't show if user can't edit + if (!userPermissions.canEdit) { + return null + } + + const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.displayName) : null + + return ( + <> +
+ +
+ {/* KB Tag Definitions Section */} +
+
Knowledge Base Tags
+
+ {/* Existing Tag Definitions */} +
+ {kbTagDefinitions.length === 0 && !isCreating ? ( +
+

+ No tag definitions yet. +
+

+
+ ) : ( + kbTagDefinitions.length > 0 && + kbTagDefinitions.map((tag, index) => { + const usage = getTagUsage(tag.displayName) + return ( +
+
+
+
+
+
+
{tag.displayName}
+
+
+ + + + + + handleViewDocuments(tag)} + className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50' + > + + View Docs + + handleDeleteTag(tag)} + className='cursor-pointer rounded-md px-3 py-2 text-red-600 text-sm hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950' + > + + Delete Tag + + + +
+
+
+ ) + }) + )} +
+ + {/* Add New Tag Button or Inline Creator */} + {!isCreating && userPermissions.canEdit && ( +
+ +
+ )} + + {/* Inline Tag Creation Form */} + {isCreating && ( +
+
+
+ + +
+ + setCreateForm({ ...createForm, displayName: e.target.value }) + } + placeholder='Enter tag name' + className='h-8 w-full rounded-md text-sm' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave()) { + e.preventDefault() + saveTagDefinition() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelCreating() + } + }} + /> + {nameConflict && ( +
+ A tag with this name already exists +
+ )} +
+ +
+ + +
+ + {/* Action buttons */} +
+ +
+
+ )} + +
+ {kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used +
+
+
+
+ +
+ + {/* Delete Confirmation Dialog */} + + + + Delete Tag + +
+
+ Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will + remove this tag from {selectedTagUsage?.documentCount || 0} document + {selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '} + + This action cannot be undone. + +
+ + {selectedTagUsage && selectedTagUsage.documentCount > 0 && ( +
+
Affected documents:
+
+
+ {selectedTagUsage.documents.slice(0, 5).map((doc, index) => { + const DocumentIcon = getDocumentIcon('', doc.name) + return ( +
+ +
+
{doc.name}
+ {doc.tagValue && ( +
+ Tag value: {doc.tagValue} +
+ )} +
+
+ ) + })} + {selectedTagUsage.documentCount > 5 && ( +
+
+
+ and {selectedTagUsage.documentCount - 5} more documents... +
+
+ )} +
+
+
+ )} +
+ + + + + Cancel + + + + + + + {/* View Documents Dialog */} + + + + Documents using "{selectedTag?.displayName}" + +
+
+ {selectedTagUsage?.documentCount || 0} document + {selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag + definition. +
+ + {selectedTagUsage?.documentCount === 0 ? ( +
+
+ This tag definition is not being used by any documents. You can safely delete + it to free up the tag slot. +
+
+ ) : ( +
+
+ {selectedTagUsage?.documents.map((doc, index) => { + const DocumentIcon = getDocumentIcon('', doc.name) + return ( +
+ +
+
{doc.name}
+ {doc.tagValue && ( +
+ Tag value: {doc.tagValue} +
+ )} +
+
+ ) + })} +
+
+ )} +
+
+
+
+
+ + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-tags/knowledge-tags.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-tags/knowledge-tags.tsx new file mode 100644 index 0000000000..325afe2fb4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/knowledge-tags/knowledge-tags.tsx @@ -0,0 +1,797 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { ChevronDown, Plus, X } from 'lucide-react' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui' +import { ScrollArea } from '@/components/ui/scroll-area' +import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge' +import { createLogger } from '@/lib/logs/console/logger' +import type { DocumentTag } from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + type TagDefinition, + useKnowledgeBaseTagDefinitions, +} from '@/hooks/use-knowledge-base-tag-definitions' +import { useNextAvailableSlot } from '@/hooks/use-next-available-slot' +import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions' +import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store' + +const logger = createLogger('KnowledgeTags') + +interface KnowledgeTagsProps { + knowledgeBaseId: string + documentId: string +} + +// Predetermined colors for each tag slot +const TAG_SLOT_COLORS = [ + '#701FFC', // Purple + '#FF6B35', // Orange + '#4ECDC4', // Teal + '#45B7D1', // Blue + '#96CEB4', // Green + '#FFEAA7', // Yellow + '#DDA0DD', // Plum + '#FF7675', // Red + '#74B9FF', // Light Blue + '#A29BFE', // Lavender +] as const + +export function KnowledgeTags({ knowledgeBaseId, documentId }: KnowledgeTagsProps) { + const { getCachedDocuments, updateDocument: updateDocumentInStore } = useKnowledgeStore() + const userPermissions = useUserPermissionsContext() + + // Use different hooks based on whether we have a documentId + const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId) + const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId) + const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId) + + // Use the document-level hook since we have documentId + const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook + const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook + + const [documentTags, setDocumentTags] = useState([]) + const [documentData, setDocumentData] = useState(null) + const [isLoadingDocument, setIsLoadingDocument] = useState(true) + const [error, setError] = useState(null) + + // Inline editing state + const [editingTagIndex, setEditingTagIndex] = useState(null) + const [isCreating, setIsCreating] = useState(false) + const [isSaving, setIsSaving] = useState(false) + const [editForm, setEditForm] = useState({ + displayName: '', + fieldType: 'text', + value: '', + }) + + // Function to build document tags from data and definitions + const buildDocumentTags = useCallback( + (docData: DocumentData, definitions: TagDefinition[], currentTags?: DocumentTag[]) => { + const tags: DocumentTag[] = [] + const tagSlots = TAG_SLOTS + + tagSlots.forEach((slot) => { + const value = docData[slot] as string | null | undefined + const definition = definitions.find((def) => def.tagSlot === slot) + const currentTag = currentTags?.find((tag) => tag.slot === slot) + + // Only include tag if the document actually has a value for it + if (value?.trim()) { + tags.push({ + slot, + // Preserve existing displayName if definition is not found yet + displayName: definition?.displayName || currentTag?.displayName || '', + fieldType: definition?.fieldType || currentTag?.fieldType || 'text', + value: value.trim(), + }) + } + }) + + return tags + }, + [] + ) + + // Handle tag updates (local state only, no API calls) + const handleTagsChange = useCallback((newTags: DocumentTag[]) => { + // Only update local state, don't save to API + setDocumentTags(newTags) + }, []) + + // Handle saving document tag values to the API + const handleSaveDocumentTags = useCallback( + async (tagsToSave: DocumentTag[]) => { + if (!documentData) return + + try { + // Convert DocumentTag array to tag data for API + const tagData: Record = {} + const tagSlots = TAG_SLOTS + + // Clear all tags first + tagSlots.forEach((slot) => { + tagData[slot] = '' + }) + + // Set values from tagsToSave + tagsToSave.forEach((tag) => { + if (tag.value.trim()) { + tagData[tag.slot] = tag.value.trim() + } + }) + + // Update document via API + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tagData), + }) + + if (!response.ok) { + throw new Error('Failed to update document tags') + } + + // Update the document in the store and local state + updateDocumentInStore(knowledgeBaseId, documentId, tagData) + setDocumentData((prev) => (prev ? { ...prev, ...tagData } : null)) + + // Refresh tag definitions to update the display + await fetchTagDefinitions() + } catch (error) { + logger.error('Error updating document tags:', error) + throw error // Re-throw so the component can handle it + } + }, + [documentData, knowledgeBaseId, documentId, updateDocumentInStore, fetchTagDefinitions] + ) + + // Handle removing a tag + const handleRemoveTag = async (index: number) => { + const updatedTags = documentTags.filter((_, i) => i !== index) + handleTagsChange(updatedTags) + + // Persist the changes + try { + await handleSaveDocumentTags(updatedTags) + } catch (error) { + // Handle error silently - the UI will show the optimistic update + // but the user can retry if needed + } + } + + // Toggle inline editor for existing tag + const toggleTagEditor = (index: number) => { + if (editingTagIndex === index) { + // Already editing this tag - collapse it + cancelEditing() + } else { + // Start editing this tag + const tag = documentTags[index] + setEditingTagIndex(index) + setEditForm({ + displayName: tag.displayName, + fieldType: tag.fieldType, + value: tag.value, + }) + setIsCreating(false) + } + } + + // Open inline creator for new tag + const openTagCreator = () => { + setEditingTagIndex(null) + setEditForm({ + displayName: '', + fieldType: 'text', + value: '', + }) + setIsCreating(true) + } + + // Save tag (create or edit) + const saveTag = async () => { + if (!editForm.displayName.trim() || !editForm.value.trim()) return + + // Close the edit form immediately and set saving flag + const formData = { ...editForm } + const currentEditingIndex = editingTagIndex + // Capture original tag data before updating + const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null + setEditingTagIndex(null) + setIsCreating(false) + setIsSaving(true) + + try { + let targetSlot: string + + if (currentEditingIndex !== null && originalTag) { + // EDIT MODE: Editing existing tag - use existing slot + targetSlot = originalTag.slot + } else { + // CREATE MODE: Check if using existing definition or creating new one + const existingDefinition = kbTagDefinitions.find( + (def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase() + ) + + if (existingDefinition) { + // Using existing definition - use its slot + targetSlot = existingDefinition.tagSlot + } else { + // Creating new definition - get next available slot from server + const serverSlot = await getServerNextSlot(formData.fieldType) + if (!serverSlot) { + throw new Error(`No available slots for new tag of type '${formData.fieldType}'`) + } + targetSlot = serverSlot + } + } + + // Update the tags array + let updatedTags: DocumentTag[] + if (currentEditingIndex !== null) { + // Editing existing tag + updatedTags = [...documentTags] + updatedTags[currentEditingIndex] = { + ...updatedTags[currentEditingIndex], + displayName: formData.displayName, + fieldType: formData.fieldType, + value: formData.value, + } + } else { + // Creating new tag + const newTag: DocumentTag = { + slot: targetSlot, + displayName: formData.displayName, + fieldType: formData.fieldType, + value: formData.value, + } + updatedTags = [...documentTags, newTag] + } + + handleTagsChange(updatedTags) + + // Handle tag definition creation/update based on edit mode + if (currentEditingIndex !== null && originalTag) { + // EDIT MODE: Always update existing definition, never create new slots + const currentDefinition = kbTagDefinitions.find( + (def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase() + ) + + if (currentDefinition) { + const updatedDefinition: TagDefinitionInput = { + displayName: formData.displayName, + fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode) + tagSlot: currentDefinition.tagSlot, // Keep existing slot + _originalDisplayName: originalTag.displayName, // Tell server which definition to update + } + + if (saveTagDefinitions) { + await saveTagDefinitions([updatedDefinition]) + } + await refreshTagDefinitions() + } + } else { + // CREATE MODE: Adding new tag + const existingDefinition = kbTagDefinitions.find( + (def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase() + ) + + if (!existingDefinition) { + // Create new definition + const newDefinition: TagDefinitionInput = { + displayName: formData.displayName, + fieldType: formData.fieldType, + tagSlot: targetSlot as TagSlot, + } + + if (saveTagDefinitions) { + await saveTagDefinitions([newDefinition]) + } + await refreshTagDefinitions() + } + } + + // Save the actual document tags + await handleSaveDocumentTags(updatedTags) + + // Reset form + setEditForm({ + displayName: '', + fieldType: 'text', + value: '', + }) + } catch (error) { + logger.error('Error saving tag:', error) + } finally { + setIsSaving(false) + } + } + + // Check if tag name already exists on this document + const hasNameConflict = (name: string) => { + if (!name.trim()) return false + + return documentTags.some((tag, index) => { + // When editing, don't consider the current tag being edited as a conflict + if (editingTagIndex !== null && index === editingTagIndex) { + return false + } + return tag.displayName.toLowerCase() === name.trim().toLowerCase() + }) + } + + // Get color for a tag based on its slot + const getTagColor = (slot: string) => { + // Extract slot number from slot string (e.g., "tag1" -> 1, "tag2" -> 2, etc.) + const slotMatch = slot.match(/tag(\d+)/) + const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0 + return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length] + } + + const cancelEditing = () => { + setEditForm({ + displayName: '', + fieldType: 'text', + value: '', + }) + setEditingTagIndex(null) + setIsCreating(false) + } + + // Filter available tag definitions - exclude all used tag names on this document + const availableDefinitions = kbTagDefinitions.filter((def) => { + // Always exclude all already used tag names (including current tag being edited) + return !documentTags.some( + (tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase() + ) + }) + + useEffect(() => { + const fetchDocument = async () => { + try { + setIsLoadingDocument(true) + setError(null) + + const cachedDocuments = getCachedDocuments(knowledgeBaseId) + const cachedDoc = cachedDocuments?.documents?.find((d) => d.id === documentId) + + if (cachedDoc) { + setDocumentData(cachedDoc) + // Initialize tags from cached document + const initialTags = buildDocumentTags(cachedDoc, tagDefinitions) + setDocumentTags(initialTags) + setIsLoadingDocument(false) + return + } + + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`) + + if (!response.ok) { + if (response.status === 404) { + throw new Error('Document not found') + } + throw new Error(`Failed to fetch document: ${response.statusText}`) + } + + const result = await response.json() + + if (result.success) { + setDocumentData(result.data) + // Initialize tags from fetched document + const initialTags = buildDocumentTags(result.data, tagDefinitions, []) + setDocumentTags(initialTags) + } else { + throw new Error(result.error || 'Failed to fetch document') + } + } catch (err) { + logger.error('Error fetching document:', err) + setError(err instanceof Error ? err.message : 'An error occurred') + } finally { + setIsLoadingDocument(false) + } + } + + if (knowledgeBaseId && documentId) { + fetchDocument() + } + }, [knowledgeBaseId, documentId, getCachedDocuments, buildDocumentTags]) + + // Separate effect to rebuild tags when tag definitions change (without re-fetching document) + useEffect(() => { + if (documentData && !isSaving) { + const rebuiltTags = buildDocumentTags(documentData, tagDefinitions, documentTags) + setDocumentTags(rebuiltTags) + } + }, [documentData, tagDefinitions, buildDocumentTags, isSaving]) + + if (isLoadingDocument) { + return ( +
+ +
+
+
+ +
+ ) + } + + if (error || !documentData) { + return null // Don't show anything if there's an error or no document + } + + const isEditing = editingTagIndex !== null || isCreating + const nameConflict = hasNameConflict(editForm.displayName) + + // Check if there are actual changes (for editing mode) + const hasChanges = () => { + if (editingTagIndex === null) return true // Creating new tag always has changes + + const originalTag = documentTags[editingTagIndex] + if (!originalTag) return true + + return ( + originalTag.displayName !== editForm.displayName || + originalTag.value !== editForm.value || + originalTag.fieldType !== editForm.fieldType + ) + } + + // Check if save should be enabled + const canSave = + editForm.displayName.trim() && editForm.value.trim() && !nameConflict && hasChanges() + + return ( +
+ +
+ {/* Document Tags Section */} +
+
Document Tags
+
+ {/* Existing Tags */} +
+ {documentTags.map((tag, index) => { + return ( +
+
userPermissions.canEdit && toggleTagEditor(index)} + > + {/* Always show the tag display */} +
+
+
+
{tag.displayName}
+
+ {userPermissions.canEdit && ( + + )} +
+ + {/* Show edit form when this tag is being edited */} + {editingTagIndex === index && ( +
e.stopPropagation()}> +
+ +
+ + setEditForm({ ...editForm, displayName: e.target.value }) + } + placeholder='Enter tag name' + className='h-8 min-w-0 flex-1 rounded-md text-sm' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave) { + e.preventDefault() + saveTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditing() + } + }} + /> + {availableDefinitions.length > 0 && ( + + + + + + {availableDefinitions.map((def) => ( + + setEditForm({ + ...editForm, + displayName: def.displayName, + fieldType: def.fieldType, + }) + } + className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50' + > + {def.displayName} + + ))} + + + )} +
+ {nameConflict && ( +
+ A tag with this name already exists on this document +
+ )} +
+ +
+ + +
+ +
+ + + setEditForm({ ...editForm, value: e.target.value }) + } + placeholder='Enter tag value' + className='h-8 w-full rounded-md text-sm' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave) { + e.preventDefault() + saveTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditing() + } + }} + /> +
+ +
+ +
+
+ )} +
+
+ ) + })} +
+ + {documentTags.length === 0 && !isCreating && ( +
+

No tags added yet.

+
+ )} + + {/* Add New Tag Button or Inline Creator */} + {!isEditing && userPermissions.canEdit && ( +
+ +
+ )} + + {/* Inline Tag Creation Form */} + {isCreating && ( +
+
+
+ + +
+
+ setEditForm({ ...editForm, displayName: e.target.value })} + placeholder='Enter tag name' + className='h-8 min-w-0 flex-1 rounded-md text-sm' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave) { + e.preventDefault() + saveTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditing() + } + }} + /> + {availableDefinitions.length > 0 && ( + + + + + + {availableDefinitions.map((def) => ( + + setEditForm({ + ...editForm, + displayName: def.displayName, + fieldType: def.fieldType, + }) + } + className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50' + > + {def.displayName} + + ))} + + + )} +
+ {nameConflict && ( +
+ A tag with this name already exists on this document +
+ )} +
+ +
+ + +
+ +
+ + setEditForm({ ...editForm, value: e.target.value })} + placeholder='Enter tag value' + className='h-8 w-full rounded-md text-sm' + onKeyDown={(e) => { + if (e.key === 'Enter' && canSave) { + e.preventDefault() + saveTag() + } + if (e.key === 'Escape') { + e.preventDefault() + cancelEditing() + } + }} + /> +
+ + {/* Warning when at max slots */} + {kbTagDefinitions.length >= MAX_TAG_SLOTS && ( +
+
+ Maximum tag definitions reached +
+

+ You can still use existing tag definitions, but cannot create new ones. +

+
+ )} + +
+ +
+
+ )} + +
+ {kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used +
+
+
+
+ +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx index cbd151efe3..4099b71ce5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx @@ -15,6 +15,8 @@ import { CreateMenu, FolderTree, HelpModal, + KnowledgeBaseTags, + KnowledgeTags, LogsFilters, SettingsModal, SubscriptionModal, @@ -250,6 +252,41 @@ export function Sidebar() { return logsPageRegex.test(pathname) }, [pathname]) + // Check if we're on any knowledge base page (overview or document) + const isOnKnowledgePage = useMemo(() => { + // Pattern: /workspace/[workspaceId]/knowledge/[id] or /workspace/[workspaceId]/knowledge/[id]/[documentId] + const knowledgePageRegex = /^\/workspace\/[^/]+\/knowledge\/[^/]+/ + return knowledgePageRegex.test(pathname) + }, [pathname]) + + // Extract knowledge base ID and document ID from the pathname + const { knowledgeBaseId, documentId } = useMemo(() => { + if (!isOnKnowledgePage) { + return { knowledgeBaseId: null, documentId: null } + } + + // Handle both KB overview (/knowledge/[kbId]) and document page (/knowledge/[kbId]/[docId]) + const kbOverviewMatch = pathname.match(/^\/workspace\/[^/]+\/knowledge\/([^/]+)$/) + const docPageMatch = pathname.match(/^\/workspace\/[^/]+\/knowledge\/([^/]+)\/([^/]+)$/) + + if (docPageMatch) { + // Document page - has both kbId and docId + return { + knowledgeBaseId: docPageMatch[1], + documentId: docPageMatch[2], + } + } + if (kbOverviewMatch) { + // KB overview page - has only kbId + return { + knowledgeBaseId: kbOverviewMatch[1], + documentId: null, + } + } + + return { knowledgeBaseId: null, documentId: null } + }, [pathname, isOnKnowledgePage]) + // Use optimized auto-scroll hook const { handleDragOver, stopScroll } = useAutoScroll(workflowScrollAreaRef) @@ -1043,6 +1080,22 @@ export function Sidebar() {
+ {/* Floating Knowledge Tags - Only on knowledge pages */} +
+ {knowledgeBaseId && documentId && ( + + )} + {knowledgeBaseId && !documentId && } +
+ {/* Floating Usage Indicator - Only shown when billing enabled */} {isBillingEnabled && (