diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts index df652f37fa..a5f81b793b 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/[chunkId]/route.ts @@ -1,10 +1,10 @@ -import { eq } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { embedding } from '@/db/schema' +import { document, embedding } from '@/db/schema' import { checkChunkAccess } from '../../../../../utils' const logger = createLogger('ChunkByIdAPI') @@ -188,8 +188,37 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - // Delete the chunk - await db.delete(embedding).where(eq(embedding.id, chunkId)) + // Use transaction to atomically delete chunk and update document statistics + await db.transaction(async (tx) => { + // Get chunk data before deletion for statistics update + const chunkToDelete = await tx + .select({ + tokenCount: embedding.tokenCount, + contentLength: embedding.contentLength, + }) + .from(embedding) + .where(eq(embedding.id, chunkId)) + .limit(1) + + if (chunkToDelete.length === 0) { + throw new Error('Chunk not found') + } + + const chunk = chunkToDelete[0] + + // Delete the chunk + await tx.delete(embedding).where(eq(embedding.id, chunkId)) + + // Update document statistics + await tx + .update(document) + .set({ + chunkCount: sql`${document.chunkCount} - 1`, + tokenCount: sql`${document.tokenCount} - ${chunk.tokenCount}`, + characterCount: sql`${document.characterCount} - ${chunk.contentLength}`, + }) + .where(eq(document.id, documentId)) + }) logger.info( `[${requestId}] Chunk deleted: ${chunkId} from document ${documentId} in knowledge base ${knowledgeBaseId}` diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts index 465bb9c925..ba5fa51091 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/chunks/route.ts @@ -1,11 +1,12 @@ +import crypto from 'crypto' import { and, asc, eq, ilike, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console-logger' import { db } from '@/db' -import { embedding } from '@/db/schema' -import { checkDocumentAccess } from '../../../../utils' +import { document, embedding } from '@/db/schema' +import { checkDocumentAccess, generateEmbeddings } from '../../../../utils' const logger = createLogger('DocumentChunksAPI') @@ -17,6 +18,12 @@ const GetChunksQuerySchema = z.object({ offset: z.coerce.number().min(0).optional().default(0), }) +// Schema for creating manual chunks +const CreateChunkSchema = z.object({ + content: z.string().min(1, 'Content is required').max(10000, 'Content too long'), + enabled: z.boolean().optional().default(true), +}) + export async function GET( req: NextRequest, { params }: { params: Promise<{ id: string; documentId: string }> } @@ -142,3 +149,135 @@ export async function GET( return NextResponse.json({ error: 'Failed to fetch chunks' }, { status: 500 }) } } + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string; documentId: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + const { id: knowledgeBaseId, documentId } = await params + + try { + const session = await getSession() + if (!session?.user?.id) { + logger.warn(`[${requestId}] Unauthorized chunk creation attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const accessCheck = await checkDocumentAccess(knowledgeBaseId, documentId, session.user.id) + + if (!accessCheck.hasAccess) { + if (accessCheck.notFound) { + logger.warn( + `[${requestId}] ${accessCheck.reason}: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: accessCheck.reason }, { status: 404 }) + } + logger.warn( + `[${requestId}] User ${session.user.id} attempted unauthorized chunk creation: ${accessCheck.reason}` + ) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const doc = accessCheck.document + if (!doc) { + logger.warn( + `[${requestId}] Document data not available: KB=${knowledgeBaseId}, Doc=${documentId}` + ) + return NextResponse.json({ error: 'Document not found' }, { status: 404 }) + } + + // Allow manual chunk creation even if document is not fully processed + // but it should exist and not be in failed state + if (doc.processingStatus === 'failed') { + logger.warn(`[${requestId}] Document ${documentId} is in failed state, cannot add chunks`) + return NextResponse.json({ error: 'Cannot add chunks to failed document' }, { status: 400 }) + } + + const body = await req.json() + + try { + const validatedData = CreateChunkSchema.parse(body) + + // Generate embedding for the content first (outside transaction for performance) + logger.info(`[${requestId}] Generating embedding for manual chunk`) + const embeddings = await generateEmbeddings([validatedData.content]) + + const chunkId = crypto.randomUUID() + const now = new Date() + + // Use transaction to atomically get next index and insert chunk + const newChunk = await db.transaction(async (tx) => { + // Get the next chunk index atomically within the transaction + const lastChunk = await tx + .select({ chunkIndex: embedding.chunkIndex }) + .from(embedding) + .where(eq(embedding.documentId, documentId)) + .orderBy(sql`${embedding.chunkIndex} DESC`) + .limit(1) + + const nextChunkIndex = lastChunk.length > 0 ? lastChunk[0].chunkIndex + 1 : 0 + + const chunkData = { + id: chunkId, + knowledgeBaseId, + documentId, + chunkIndex: nextChunkIndex, + chunkHash: crypto.createHash('sha256').update(validatedData.content).digest('hex'), + content: validatedData.content, + contentLength: validatedData.content.length, + tokenCount: Math.ceil(validatedData.content.length / 4), // Rough approximation + embedding: embeddings[0], + embeddingModel: 'text-embedding-3-small', + startOffset: 0, // Manual chunks don't have document offsets + endOffset: validatedData.content.length, + overlapTokens: 0, + metadata: { manual: true }, // Mark as manually created + searchRank: '1.0', + accessCount: 0, + lastAccessedAt: null, + qualityScore: null, + enabled: validatedData.enabled, + createdAt: now, + updatedAt: now, + } + + // Insert the new chunk + await tx.insert(embedding).values(chunkData) + + // Update document statistics + await tx + .update(document) + .set({ + chunkCount: sql`${document.chunkCount} + 1`, + tokenCount: sql`${document.tokenCount} + ${chunkData.tokenCount}`, + characterCount: sql`${document.characterCount} + ${chunkData.contentLength}`, + }) + .where(eq(document.id, documentId)) + + return chunkData + }) + + logger.info(`[${requestId}] Manual chunk created: ${chunkId} in document ${documentId}`) + + return NextResponse.json({ + success: true, + data: newChunk, + }) + } catch (validationError) { + if (validationError instanceof z.ZodError) { + logger.warn(`[${requestId}] Invalid chunk creation data`, { + errors: validationError.errors, + }) + return NextResponse.json( + { error: 'Invalid request data', details: validationError.errors }, + { status: 400 } + ) + } + throw validationError + } + } catch (error) { + logger.error(`[${requestId}] Error creating chunk`, error) + return NextResponse.json({ error: 'Failed to create chunk' }, { status: 500 }) + } +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx new file mode 100644 index 0000000000..60475ab951 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/document-selector/document-selector.tsx @@ -0,0 +1,312 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, FileText } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { SubBlockConfig } from '@/blocks/types' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' + +interface DocumentData { + id: string + knowledgeBaseId: string + filename: string + fileUrl: string + fileSize: number + mimeType: string + fileHash: string | null + chunkCount: number + tokenCount: number + characterCount: number + processingStatus: string + processingStartedAt: Date | null + processingCompletedAt: Date | null + processingError: string | null + enabled: boolean + uploadedAt: Date +} + +interface DocumentSelectorProps { + blockId: string + subBlock: SubBlockConfig + disabled?: boolean + onDocumentSelect?: (documentId: string) => void + isPreview?: boolean + previewValue?: string | null +} + +export function DocumentSelector({ + blockId, + subBlock, + disabled = false, + onDocumentSelect, + isPreview = false, + previewValue, +}: DocumentSelectorProps) { + const { getValue, setValue } = useSubBlockStore() + + const [documents, setDocuments] = useState([]) + const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const [selectedDocument, setSelectedDocument] = useState(null) + const [initialFetchDone, setInitialFetchDone] = useState(false) + const [selectedId, setSelectedId] = useState('') + + // Get the current value from the store + const storeValue = getValue(blockId, subBlock.id) + + // Get the knowledge base ID from the same block's knowledgeBaseId subblock + const knowledgeBaseId = getValue(blockId, 'knowledgeBaseId') + + // Use preview value when in preview mode, otherwise use store value + const value = isPreview ? previewValue : storeValue + + // Initialize selectedId with the effective value + useEffect(() => { + if (isPreview && previewValue !== undefined) { + setSelectedId(previewValue || '') + } else { + setSelectedId(value || '') + } + }, [value, isPreview, previewValue]) + + // Update local state when external value changes + useEffect(() => { + const currentValue = isPreview ? previewValue : value + setSelectedId(currentValue || '') + }, [value, isPreview, previewValue]) + + // Fetch documents for the selected knowledge base + const fetchDocuments = useCallback(async () => { + if (!knowledgeBaseId) { + setDocuments([]) + setError('No knowledge base selected') + setInitialFetchDone(true) + return + } + + setError(null) + + try { + const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`) + + if (!response.ok) { + throw new Error(`Failed to fetch documents: ${response.statusText}`) + } + + const result = await response.json() + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch documents') + } + + const fetchedDocuments = result.data || [] + setDocuments(fetchedDocuments) + setInitialFetchDone(true) + + // Auto-selection logic: if we have a valid selection, keep it + // If there's only one document, select it + // If we have a value but it's not in the documents, reset it + if (selectedId && !fetchedDocuments.some((doc: DocumentData) => doc.id === selectedId)) { + setSelectedId('') + if (!isPreview) { + setValue(blockId, subBlock.id, '') + } + } + + if ( + (!selectedId || !fetchedDocuments.some((doc: DocumentData) => doc.id === selectedId)) && + fetchedDocuments.length > 0 + ) { + if (fetchedDocuments.length === 1) { + // If only one document, auto-select it + const singleDoc = fetchedDocuments[0] + setSelectedId(singleDoc.id) + setSelectedDocument(singleDoc) + if (!isPreview) { + setValue(blockId, subBlock.id, singleDoc.id) + } + onDocumentSelect?.(singleDoc.id) + } + } + } catch (err) { + if ((err as Error).name === 'AbortError') return + setError((err as Error).message) + setDocuments([]) + } + }, [knowledgeBaseId, selectedId, setValue, blockId, subBlock.id, isPreview, onDocumentSelect]) + + // Handle dropdown open/close - fetch documents when opening + const handleOpenChange = (isOpen: boolean) => { + if (isPreview) return + + setOpen(isOpen) + + // Fetch fresh documents when opening the dropdown + if (isOpen) { + fetchDocuments() + } + } + + // Handle document selection + const handleSelectDocument = (document: DocumentData) => { + if (isPreview) return + + setSelectedDocument(document) + setSelectedId(document.id) + + if (!isPreview) { + setValue(blockId, subBlock.id, document.id) + } + + onDocumentSelect?.(document.id) + setOpen(false) + } + + // Sync selected document with value prop + useEffect(() => { + if (selectedId && documents.length > 0) { + const docInfo = documents.find((doc) => doc.id === selectedId) + if (docInfo) { + setSelectedDocument(docInfo) + } else { + setSelectedDocument(null) + } + } else if (!selectedId) { + setSelectedDocument(null) + } + }, [selectedId, documents]) + + // Reset documents when knowledge base changes + useEffect(() => { + if (knowledgeBaseId) { + setDocuments([]) + setSelectedDocument(null) + setSelectedId('') + setInitialFetchDone(false) + setError(null) + if (!isPreview) { + setValue(blockId, subBlock.id, '') + } + } + }, [knowledgeBaseId, blockId, subBlock.id, setValue, isPreview]) + + // Fetch documents when knowledge base is available and we haven't fetched yet + useEffect(() => { + if (knowledgeBaseId && !initialFetchDone && !isPreview) { + fetchDocuments() + } + }, [knowledgeBaseId, initialFetchDone, fetchDocuments, isPreview]) + + const formatDocumentName = (document: DocumentData) => { + return document.filename + } + + const getDocumentDescription = (document: DocumentData) => { + const statusMap: Record = { + pending: 'Processing pending', + processing: 'Processing...', + completed: 'Ready', + failed: 'Processing failed', + } + + const status = statusMap[document.processingStatus] || document.processingStatus + const chunkText = `${document.chunkCount} chunk${document.chunkCount !== 1 ? 's' : ''}` + + return `${status} • ${chunkText}` + } + + const label = subBlock.placeholder || 'Select document' + + // Show disabled state if no knowledge base is selected + const isDisabled = disabled || isPreview || !knowledgeBaseId + + return ( +
+ + + + + + + + + + {error ? ( +
+

{error}

+
+ ) : !knowledgeBaseId ? ( +
+

No knowledge base selected

+

+ Please select a knowledge base first. +

+
+ ) : ( +
+

No documents found

+

+ Upload documents to this knowledge base to get started. +

+
+ )} +
+ + {documents.length > 0 && ( + +
+ Documents +
+ {documents.map((document) => ( + handleSelectDocument(document)} + className='cursor-pointer' + > +
+ +
+
{formatDocumentName(document)}
+
+ {getDocumentDescription(document)} +
+
+
+ {document.id === selectedId && } +
+ ))} +
+ )} +
+
+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/components/knowledge-base-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/components/knowledge-base-selector.tsx deleted file mode 100644 index 22ff776f6b..0000000000 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/components/knowledge-base-selector.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { Check, ChevronDown, RefreshCw } from 'lucide-react' -import { PackageSearchIcon } from '@/components/icons' -import { Button } from '@/components/ui/button' -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { type KnowledgeBaseData, useKnowledgeStore } from '@/stores/knowledge/knowledge' - -interface KnowledgeBaseSelectorProps { - value: string - onChange: (knowledgeBaseId: string, knowledgeBaseInfo?: KnowledgeBaseData) => void - label?: string - disabled?: boolean - isPreview?: boolean - previewValue?: string | null -} - -export function KnowledgeBaseSelector({ - value: propValue, - onChange, - label = 'Select knowledge base', - disabled = false, - isPreview = false, - previewValue, -}: KnowledgeBaseSelectorProps) { - const { getKnowledgeBasesList, knowledgeBasesList, loadingKnowledgeBasesList } = - useKnowledgeStore() - const [knowledgeBases, setKnowledgeBases] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [open, setOpen] = useState(false) - const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState(null) - const [initialFetchDone, setInitialFetchDone] = useState(false) - - // Use preview value when in preview mode, otherwise use prop value - const value = isPreview ? previewValue : propValue - - // Fetch knowledge bases - const fetchKnowledgeBases = useCallback(async () => { - setLoading(true) - setError(null) - - try { - const data = await getKnowledgeBasesList() - setKnowledgeBases(data) - setInitialFetchDone(true) - } catch (err) { - if ((err as Error).name === 'AbortError') return - setError((err as Error).message) - setKnowledgeBases([]) - } finally { - setLoading(false) - } - }, [getKnowledgeBasesList]) - - // Handle dropdown open/close - fetch knowledge bases when opening - const handleOpenChange = (isOpen: boolean) => { - if (isPreview) return - - setOpen(isOpen) - - // Only fetch knowledge bases when opening the dropdown if we haven't fetched yet - if (isOpen && (!initialFetchDone || knowledgeBasesList.length === 0)) { - fetchKnowledgeBases() - } - } - - // Sync selected knowledge base with value prop - useEffect(() => { - if (value && knowledgeBases.length > 0) { - const kbInfo = knowledgeBases.find((kb) => kb.id === value) - if (kbInfo) { - setSelectedKnowledgeBase(kbInfo) - } else { - setSelectedKnowledgeBase(null) - } - } else if (!value) { - setSelectedKnowledgeBase(null) - } - }, [value, knowledgeBases]) - - // Use cached data if available - useEffect(() => { - if (knowledgeBasesList.length > 0 && !initialFetchDone) { - setKnowledgeBases(knowledgeBasesList) - setInitialFetchDone(true) - } - }, [knowledgeBasesList, initialFetchDone]) - - // If we have a value but no knowledge base info and haven't fetched yet, fetch - useEffect(() => { - if (value && !selectedKnowledgeBase && !loading && !initialFetchDone && !isPreview) { - fetchKnowledgeBases() - } - }, [value, selectedKnowledgeBase, loading, initialFetchDone, fetchKnowledgeBases, isPreview]) - - const handleSelectKnowledgeBase = (knowledgeBase: KnowledgeBaseData) => { - if (isPreview) return - - setSelectedKnowledgeBase(knowledgeBase) - onChange(knowledgeBase.id, knowledgeBase) - setOpen(false) - } - - const formatKnowledgeBaseName = (knowledgeBase: KnowledgeBaseData) => { - return knowledgeBase.name - } - - const getKnowledgeBaseDescription = (knowledgeBase: KnowledgeBaseData) => { - const docCount = (knowledgeBase as any).docCount - if (docCount !== undefined) { - return `${docCount} document${docCount !== 1 ? 's' : ''}` - } - return knowledgeBase.description || 'No description' - } - - return ( - - - - - - - - - - {loading || loadingKnowledgeBasesList ? ( -
- - Loading knowledge bases... -
- ) : error ? ( -
-

{error}

-
- ) : ( -
-

No knowledge bases found

-

- Create a knowledge base to get started. -

-
- )} -
- - {knowledgeBases.length > 0 && ( - -
- Knowledge Bases -
- {knowledgeBases.map((knowledgeBase) => ( - handleSelectKnowledgeBase(knowledgeBase)} - className='cursor-pointer' - > -
- -
-
- {formatKnowledgeBaseName(knowledgeBase)} -
-
- {getKnowledgeBaseDescription(knowledgeBase)} -
-
-
- {knowledgeBase.id === value && } -
- ))} -
- )} -
-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector-input.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector-input.tsx deleted file mode 100644 index c300b6a037..0000000000 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector-input.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import type { SubBlockConfig } from '@/blocks/types' -import type { KnowledgeBaseData } from '@/stores/knowledge/knowledge' -import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { KnowledgeBaseSelector } from './components/knowledge-base-selector' - -interface KnowledgeBaseSelectorInputProps { - blockId: string - subBlock: SubBlockConfig - disabled?: boolean - onKnowledgeBaseSelect?: (knowledgeBaseId: string) => void - isPreview?: boolean - previewValue?: string | null -} - -export function KnowledgeBaseSelectorInput({ - blockId, - subBlock, - disabled = false, - onKnowledgeBaseSelect, - isPreview = false, - previewValue, -}: KnowledgeBaseSelectorInputProps) { - const { getValue, setValue } = useSubBlockStore() - const [knowledgeBaseInfo, setKnowledgeBaseInfo] = useState(null) - - // Get the current value from the store - const storeValue = getValue(blockId, subBlock.id) - - // Handle knowledge base selection - const handleKnowledgeBaseChange = (knowledgeBaseId: string, info?: KnowledgeBaseData) => { - setKnowledgeBaseInfo(info || null) - if (!isPreview) { - setValue(blockId, subBlock.id, knowledgeBaseId) - } - onKnowledgeBaseSelect?.(knowledgeBaseId) - } - - return ( - - - -
- { - handleKnowledgeBaseChange(knowledgeBaseId, knowledgeBaseInfo) - }} - label={subBlock.placeholder || 'Select knowledge base'} - disabled={disabled} - isPreview={isPreview} - previewValue={previewValue} - /> -
-
- -

Select a knowledge base to search

-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx new file mode 100644 index 0000000000..01e78aeec1 --- /dev/null +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import { Check, ChevronDown, RefreshCw } from 'lucide-react' +import { PackageSearchIcon } from '@/components/icons' +import { Button } from '@/components/ui/button' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import type { SubBlockConfig } from '@/blocks/types' +import { type KnowledgeBaseData, useKnowledgeStore } from '@/stores/knowledge/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' + +interface KnowledgeBaseSelectorProps { + blockId: string + subBlock: SubBlockConfig + disabled?: boolean + onKnowledgeBaseSelect?: (knowledgeBaseId: string) => void + isPreview?: boolean + previewValue?: string | null +} + +export function KnowledgeBaseSelector({ + blockId, + subBlock, + disabled = false, + onKnowledgeBaseSelect, + isPreview = false, + previewValue, +}: KnowledgeBaseSelectorProps) { + const { getKnowledgeBasesList, knowledgeBasesList, loadingKnowledgeBasesList } = + useKnowledgeStore() + const { getValue, setValue } = useSubBlockStore() + + const [knowledgeBases, setKnowledgeBases] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [open, setOpen] = useState(false) + const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState(null) + const [initialFetchDone, setInitialFetchDone] = useState(false) + const [knowledgeBaseInfo, setKnowledgeBaseInfo] = useState(null) + + // Get the current value from the store + const storeValue = getValue(blockId, subBlock.id) + + // Use preview value when in preview mode, otherwise use store value + const value = isPreview ? previewValue : storeValue + + // Fetch knowledge bases + const fetchKnowledgeBases = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const data = await getKnowledgeBasesList() + setKnowledgeBases(data) + setInitialFetchDone(true) + } catch (err) { + if ((err as Error).name === 'AbortError') return + setError((err as Error).message) + setKnowledgeBases([]) + } finally { + setLoading(false) + } + }, [getKnowledgeBasesList]) + + // Handle dropdown open/close - fetch knowledge bases when opening + const handleOpenChange = (isOpen: boolean) => { + if (isPreview) return + + setOpen(isOpen) + + // Only fetch knowledge bases when opening the dropdown if we haven't fetched yet + if (isOpen && (!initialFetchDone || knowledgeBasesList.length === 0)) { + fetchKnowledgeBases() + } + } + + // Handle knowledge base selection + const handleSelectKnowledgeBase = (knowledgeBase: KnowledgeBaseData) => { + if (isPreview) return + + setSelectedKnowledgeBase(knowledgeBase) + setKnowledgeBaseInfo(knowledgeBase) + + if (!isPreview) { + setValue(blockId, subBlock.id, knowledgeBase.id) + } + + onKnowledgeBaseSelect?.(knowledgeBase.id) + setOpen(false) + } + + // Sync selected knowledge base with value prop + useEffect(() => { + if (value && knowledgeBases.length > 0) { + const kbInfo = knowledgeBases.find((kb) => kb.id === value) + if (kbInfo) { + setSelectedKnowledgeBase(kbInfo) + setKnowledgeBaseInfo(kbInfo) + } else { + setSelectedKnowledgeBase(null) + setKnowledgeBaseInfo(null) + } + } else if (!value) { + setSelectedKnowledgeBase(null) + setKnowledgeBaseInfo(null) + } + }, [value, knowledgeBases]) + + // Use cached data if available + useEffect(() => { + if (knowledgeBasesList.length > 0 && !initialFetchDone) { + setKnowledgeBases(knowledgeBasesList) + setInitialFetchDone(true) + } + }, [knowledgeBasesList, initialFetchDone]) + + // If we have a value but no knowledge base info and haven't fetched yet, fetch + useEffect(() => { + if (value && !selectedKnowledgeBase && !loading && !initialFetchDone && !isPreview) { + fetchKnowledgeBases() + } + }, [value, selectedKnowledgeBase, loading, initialFetchDone, fetchKnowledgeBases, isPreview]) + + const formatKnowledgeBaseName = (knowledgeBase: KnowledgeBaseData) => { + return knowledgeBase.name + } + + const getKnowledgeBaseDescription = (knowledgeBase: KnowledgeBaseData) => { + const docCount = (knowledgeBase as any).docCount + if (docCount !== undefined) { + return `${docCount} document${docCount !== 1 ? 's' : ''}` + } + return knowledgeBase.description || 'No description' + } + + const label = subBlock.placeholder || 'Select knowledge base' + + return ( +
+ + + + + + + + + + {loading || loadingKnowledgeBasesList ? ( +
+ + Loading knowledge bases... +
+ ) : error ? ( +
+

{error}

+
+ ) : ( +
+

No knowledge bases found

+

+ Create a knowledge base to get started. +

+
+ )} +
+ + {knowledgeBases.length > 0 && ( + +
+ Knowledge Bases +
+ {knowledgeBases.map((knowledgeBase) => ( + handleSelectKnowledgeBase(knowledgeBase)} + className='cursor-pointer' + > +
+ +
+
+ {formatKnowledgeBaseName(knowledgeBase)} +
+
+ {getKnowledgeBaseDescription(knowledgeBase)} +
+
+
+ {knowledgeBase.id === value && } +
+ ))} +
+ )} +
+
+
+
+
+ ) +} diff --git a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx index 7396ff4ac2..db1fffd61b 100644 --- a/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx +++ b/apps/sim/app/w/[id]/components/workflow-block/components/sub-block/sub-block.tsx @@ -10,12 +10,13 @@ import { Code } from './components/code' import { ConditionInput } from './components/condition-input' import { CredentialSelector } from './components/credential-selector/credential-selector' import { DateInput } from './components/date-input' +import { DocumentSelector } from './components/document-selector/document-selector' import { Dropdown } from './components/dropdown' import { EvalInput } from './components/eval-input' import { FileSelectorInput } from './components/file-selector/file-selector-input' import { FileUpload } from './components/file-upload' import { FolderSelectorInput } from './components/folder-selector/components/folder-selector-input' -import { KnowledgeBaseSelectorInput } from './components/knowledge-base-selector/knowledge-base-selector-input' +import { KnowledgeBaseSelector } from './components/knowledge-base-selector/knowledge-base-selector' import { LongInput } from './components/long-input' import { ProjectSelectorInput } from './components/project-selector/project-selector-input' import { ScheduleConfig } from './components/schedule/schedule-config' @@ -313,7 +314,17 @@ export function SubBlock({ ) case 'knowledge-base-selector': return ( - + ) + case 'document-selector': + return ( + void + document: DocumentData | null + knowledgeBaseId: string + onChunkCreated?: (chunk: ChunkData) => void +} + +export function CreateChunkModal({ + open, + onOpenChange, + document, + knowledgeBaseId, + onChunkCreated, +}: CreateChunkModalProps) { + const [content, setContent] = useState('') + const [enabled, setEnabled] = useState(true) + const [isCreating, setIsCreating] = useState(false) + const [error, setError] = useState(null) + const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) + const isProcessingRef = useRef(false) + + const hasUnsavedChanges = content.trim().length > 0 + + const handleCreateChunk = async () => { + if (!document || content.trim().length === 0 || isProcessingRef.current) { + if (isProcessingRef.current) { + logger.warn('Chunk creation already in progress, ignoring duplicate request') + } + return + } + + try { + isProcessingRef.current = true + setIsCreating(true) + setError(null) + + const response = await fetch( + `/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + content: content.trim(), + enabled, + }), + } + ) + + if (!response.ok) { + const result = await response.json() + throw new Error(result.error || 'Failed to create chunk') + } + + const result = await response.json() + + if (result.success && result.data) { + logger.info('Chunk created successfully:', result.data.id) + + if (onChunkCreated) { + onChunkCreated(result.data) + } + + onClose() + } else { + throw new Error(result.error || 'Failed to create chunk') + } + } catch (err) { + logger.error('Error creating chunk:', err) + setError(err instanceof Error ? err.message : 'An error occurred') + } finally { + isProcessingRef.current = false + setIsCreating(false) + } + } + + const onClose = () => { + onOpenChange(false) + // Reset form state when modal closes + setContent('') + setEnabled(true) + setError(null) + setShowUnsavedChangesAlert(false) + } + + const handleCloseAttempt = () => { + if (hasUnsavedChanges && !isCreating) { + setShowUnsavedChangesAlert(true) + } else { + onClose() + } + } + + const handleConfirmDiscard = () => { + setShowUnsavedChangesAlert(false) + onClose() + } + + const isFormValid = content.trim().length > 0 && content.trim().length <= 10000 + + return ( + <> + + + +
+ Create Chunk + +
+
+ +
+
+
+
+ {/* Document Info */} +
+ +
+

+ {document?.filename || 'Unknown Document'} +

+

Adding chunk to this document

+
+
+ + {/* Content Input */} +
+ +