improvement(knowledge-upload): upload content from workflow; improve knowledge/base/document (#465)

* improvement(knowledge-upload): added ability to upload chunks manually

* improvement(knowledge-upload): ui/ux and file structure

* improvement(knowledge-upload): added knowledge upload tool
This commit is contained in:
Emir Karabeg
2025-06-09 01:09:42 -07:00
committed by GitHub
parent 00f893e318
commit c7ee74ecf8
26 changed files with 1506 additions and 497 deletions

View File

@@ -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}`

View File

@@ -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 })
}
}

View File

@@ -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<DocumentData[]>([])
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedDocument, setSelectedDocument] = useState<DocumentData | null>(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<string, string> = {
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 (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={isDisabled}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
{selectedDocument ? (
<span className='truncate font-normal'>{formatDocumentName(selectedDocument)}</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search documents...' />
<CommandList>
<CommandEmpty>
{error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : !knowledgeBaseId ? (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No knowledge base selected</p>
<p className='text-muted-foreground text-xs'>
Please select a knowledge base first.
</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No documents found</p>
<p className='text-muted-foreground text-xs'>
Upload documents to this knowledge base to get started.
</p>
</div>
)}
</CommandEmpty>
{documents.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Documents
</div>
{documents.map((document) => (
<CommandItem
key={document.id}
value={`doc-${document.id}-${document.filename}`}
onSelect={() => handleSelectDocument(document)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<FileText className='h-4 w-4 text-muted-foreground' />
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='truncate font-normal'>{formatDocumentName(document)}</div>
<div className='truncate text-muted-foreground text-xs'>
{getDocumentDescription(document)}
</div>
</div>
</div>
{document.id === selectedId && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -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<KnowledgeBaseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBaseData | null>(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 (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled || isPreview}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<PackageSearchIcon className='h-4 w-4 text-[#00B0B0]' />
{selectedKnowledgeBase ? (
<span className='truncate font-normal'>
{formatKnowledgeBaseName(selectedKnowledgeBase)}
</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search knowledge bases...' />
<CommandList>
<CommandEmpty>
{loading || loadingKnowledgeBasesList ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading knowledge bases...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No knowledge bases found</p>
<p className='text-muted-foreground text-xs'>
Create a knowledge base to get started.
</p>
</div>
)}
</CommandEmpty>
{knowledgeBases.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Knowledge Bases
</div>
{knowledgeBases.map((knowledgeBase) => (
<CommandItem
key={knowledgeBase.id}
value={`kb-${knowledgeBase.id}-${knowledgeBase.name}`}
onSelect={() => handleSelectKnowledgeBase(knowledgeBase)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<PackageSearchIcon className='h-4 w-4 text-[#00B0B0]' />
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='truncate font-normal'>
{formatKnowledgeBaseName(knowledgeBase)}
</div>
<div className='truncate text-muted-foreground text-xs'>
{getKnowledgeBaseDescription(knowledgeBase)}
</div>
</div>
</div>
{knowledgeBase.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -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<KnowledgeBaseData | null>(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 (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className='w-full'>
<KnowledgeBaseSelector
value={storeValue}
onChange={(knowledgeBaseId: string, knowledgeBaseInfo?: KnowledgeBaseData) => {
handleKnowledgeBaseChange(knowledgeBaseId, knowledgeBaseInfo)
}}
label={subBlock.placeholder || 'Select knowledge base'}
disabled={disabled}
isPreview={isPreview}
previewValue={previewValue}
/>
</div>
</TooltipTrigger>
<TooltipContent side='top'>
<p>Select a knowledge base to search</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -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<KnowledgeBaseData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [open, setOpen] = useState(false)
const [selectedKnowledgeBase, setSelectedKnowledgeBase] = useState<KnowledgeBaseData | null>(null)
const [initialFetchDone, setInitialFetchDone] = useState(false)
const [knowledgeBaseInfo, setKnowledgeBaseInfo] = useState<KnowledgeBaseData | null>(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 (
<div className='w-full'>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
variant='outline'
role='combobox'
aria-expanded={open}
className='relative w-full justify-between'
disabled={disabled || isPreview}
>
<div className='flex max-w-[calc(100%-20px)] items-center gap-2 overflow-hidden'>
<PackageSearchIcon className='h-4 w-4 text-[#00B0B0]' />
{selectedKnowledgeBase ? (
<span className='truncate font-normal'>
{formatKnowledgeBaseName(selectedKnowledgeBase)}
</span>
) : (
<span className='truncate text-muted-foreground'>{label}</span>
)}
</div>
<ChevronDown className='absolute right-3 h-4 w-4 shrink-0 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-[300px] p-0' align='start'>
<Command>
<CommandInput placeholder='Search knowledge bases...' />
<CommandList>
<CommandEmpty>
{loading || loadingKnowledgeBasesList ? (
<div className='flex items-center justify-center p-4'>
<RefreshCw className='h-4 w-4 animate-spin' />
<span className='ml-2'>Loading knowledge bases...</span>
</div>
) : error ? (
<div className='p-4 text-center'>
<p className='text-destructive text-sm'>{error}</p>
</div>
) : (
<div className='p-4 text-center'>
<p className='font-medium text-sm'>No knowledge bases found</p>
<p className='text-muted-foreground text-xs'>
Create a knowledge base to get started.
</p>
</div>
)}
</CommandEmpty>
{knowledgeBases.length > 0 && (
<CommandGroup>
<div className='px-2 py-1.5 font-medium text-muted-foreground text-xs'>
Knowledge Bases
</div>
{knowledgeBases.map((knowledgeBase) => (
<CommandItem
key={knowledgeBase.id}
value={`kb-${knowledgeBase.id}-${knowledgeBase.name}`}
onSelect={() => handleSelectKnowledgeBase(knowledgeBase)}
className='cursor-pointer'
>
<div className='flex items-center gap-2 overflow-hidden'>
<PackageSearchIcon className='h-4 w-4 text-[#00B0B0]' />
<div className='min-w-0 flex-1 overflow-hidden'>
<div className='truncate font-normal'>
{formatKnowledgeBaseName(knowledgeBase)}
</div>
<div className='truncate text-muted-foreground text-xs'>
{getKnowledgeBaseDescription(knowledgeBase)}
</div>
</div>
</div>
{knowledgeBase.id === value && <Check className='ml-auto h-4 w-4' />}
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}

View File

@@ -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 (
<KnowledgeBaseSelectorInput
<KnowledgeBaseSelector
blockId={blockId}
subBlock={config}
disabled={isConnecting || isPreview}
isPreview={isPreview}
previewValue={previewValue}
/>
)
case 'document-selector':
return (
<DocumentSelector
blockId={blockId}
subBlock={config}
disabled={isConnecting || isPreview}

View File

@@ -0,0 +1,255 @@
'use client'
import { useRef, useState } from 'react'
import { AlertCircle, FileText, Loader2, X } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
const logger = createLogger('CreateChunkModal')
interface CreateChunkModalProps {
open: boolean
onOpenChange: (open: boolean) => 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<string | null>(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 (
<>
<Dialog open={open} onOpenChange={handleCloseAttempt}>
<DialogContent
className='flex h-[74vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<DialogTitle className='font-medium text-lg'>Create Chunk</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-8 w-8 p-0'
onClick={handleCloseAttempt}
>
<X className='h-4 w-4' />
<span className='sr-only'>Close</span>
</Button>
</div>
</DialogHeader>
<div className='flex flex-1 flex-col overflow-hidden'>
<div className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='py-4'>
<div className='space-y-4'>
{/* Document Info */}
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<FileText className='h-5 w-5 text-muted-foreground' />
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>Adding chunk to this document</p>
</div>
</div>
{/* Content Input */}
<div className='space-y-2'>
<Label htmlFor='content' className='font-medium text-sm'>
Chunk Content
</Label>
<Textarea
id='content'
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder='Enter the content for this chunk...'
className='min-h-[200px] resize-none'
disabled={isCreating}
/>
<div className='flex items-center justify-between text-muted-foreground text-xs'>
<span>{content.length}/10000 characters</span>
{content.length > 10000 && (
<span className='text-red-500'>Content too long</span>
)}
</div>
</div>
{/* Enabled Toggle */}
<div className='flex items-center space-x-2'>
<Checkbox
id='enabled'
checked={enabled}
onCheckedChange={(checked) => setEnabled(checked as boolean)}
disabled={isCreating}
className='h-4 w-4'
/>
<Label htmlFor='enabled' className='text-sm'>
Enable chunk for search
</Label>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
)}
</div>
</div>
</div>
{/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex justify-between'>
<Button variant='outline' onClick={handleCloseAttempt} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreateChunk}
disabled={!isFormValid || isCreating}
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isCreating ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Creating...
</>
) : (
'Create Chunk'
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Unsaved Changes Alert */}
<AlertDialog open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Discard changes?</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Are you sure you want to close without saving?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowUnsavedChangesAlert(false)}>
Keep editing
</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDiscard}>Discard changes</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,109 @@
'use client'
import { useState } from 'react'
import { Loader2, Trash2 } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { createLogger } from '@/lib/logs/console-logger'
import type { ChunkData } from '@/stores/knowledge/store'
const logger = createLogger('DeleteChunkModal')
interface DeleteChunkModalProps {
chunk: ChunkData | null
knowledgeBaseId: string
documentId: string
isOpen: boolean
onClose: () => void
onChunkDeleted?: () => void
}
export function DeleteChunkModal({
chunk,
knowledgeBaseId,
documentId,
isOpen,
onClose,
onChunkDeleted,
}: DeleteChunkModalProps) {
const [isDeleting, setIsDeleting] = useState(false)
const handleDeleteChunk = async () => {
if (!chunk || isDeleting) return
try {
setIsDeleting(true)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunk.id}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error('Failed to delete chunk')
}
const result = await response.json()
if (result.success) {
logger.info('Chunk deleted successfully:', chunk.id)
if (onChunkDeleted) {
onChunkDeleted()
}
onClose()
} else {
throw new Error(result.error || 'Failed to delete chunk')
}
} catch (err) {
logger.error('Error deleting chunk:', err)
// You might want to show an error state here
} finally {
setIsDeleting(false)
}
}
if (!chunk) return null
return (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Chunk</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this chunk? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteChunk}
disabled={isDeleting}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
>
{isDeleting ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Deleting...
</>
) : (
<>
<Trash2 className='mr-2 h-4 w-4' />
Delete
</>
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,6 +1,7 @@
'use client'
import { Search } from 'lucide-react'
import { Plus, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../../../components/knowledge-header/knowledge-header'
import { ChunkTableSkeleton } from '../../../components/skeletons/table-skeleton'
@@ -50,7 +51,7 @@ export function DocumentLoading({
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search Section */}
<div className='mb-4'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -62,6 +63,15 @@ export function DocumentLoading({
/>
</div>
</div>
<Button
disabled
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-3.5 w-3.5' />
<span>Create Chunk</span>
</Button>
</div>
{/* Table container */}

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { X } from 'lucide-react'
import { AlertCircle, FileText, Loader2, X } from 'lucide-react'
import {
AlertDialog,
AlertDialogAction,
@@ -17,27 +17,10 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger'
import type { DocumentData } from '@/stores/knowledge/knowledge'
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
const logger = createLogger('EditChunkModal')
interface ChunkData {
id: string
chunkIndex: number
content: string
contentLength: number
tokenCount: number
enabled: boolean
startOffset: number
endOffset: number
overlapTokens: number
metadata: any
searchRank: string
qualityScore: string | null
createdAt: string
updatedAt: string
}
interface EditChunkModalProps {
chunk: ChunkData | null
document: DocumentData | null
@@ -57,6 +40,7 @@ export function EditChunkModal({
}: EditChunkModalProps) {
const [editedContent, setEditedContent] = useState(chunk?.content || '')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
// Check if there are unsaved changes
@@ -74,6 +58,7 @@ export function EditChunkModal({
try {
setIsSaving(true)
setError(null)
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks/${chunk.id}`,
@@ -89,51 +74,38 @@ export function EditChunkModal({
)
if (!response.ok) {
throw new Error('Failed to update chunk')
const result = await response.json()
throw new Error(result.error || 'Failed to update chunk')
}
const result = await response.json()
if (result.success && onChunkUpdate) {
onChunkUpdate(result.data)
handleCloseModal()
onClose()
}
} catch (error) {
logger.error('Error updating chunk:', error)
} catch (err) {
logger.error('Error updating chunk:', err)
setError(err instanceof Error ? err.message : 'An error occurred')
} finally {
setIsSaving(false)
}
}
const handleCloseModal = () => {
onClose()
setEditedContent('')
}
const handleCloseAttempt = () => {
if (hasUnsavedChanges && !isSaving) {
setShowUnsavedChangesAlert(true)
} else {
handleCloseModal()
}
}
const handleCancel = () => {
if (hasUnsavedChanges) {
setShowUnsavedChangesAlert(true)
} else {
handleCloseModal()
onClose()
}
}
const handleConfirmDiscard = () => {
setShowUnsavedChangesAlert(false)
handleCloseModal()
onClose()
}
const handleKeepEditing = () => {
setShowUnsavedChangesAlert(false)
}
const isFormValid = editedContent.trim().length > 0 && editedContent.trim().length <= 10000
if (!chunk || !document) return null
@@ -141,23 +113,16 @@ export function EditChunkModal({
<>
<Dialog open={isOpen} onOpenChange={handleCloseAttempt}>
<DialogContent
className='flex h-[80vh] max-h-[900px] w-[95vw] max-w-4xl flex-col gap-0 overflow-hidden p-0'
className='flex h-[74vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
hideCloseButton
>
<DialogHeader className='flex-shrink-0 border-b bg-background/95 px-8 py-6 backdrop-blur supports-[backdrop-filter]:bg-background/80'>
<DialogHeader className='flex-shrink-0 border-b px-6 py-4'>
<div className='flex items-center justify-between'>
<div className='space-y-1'>
<DialogTitle className='font-semibold text-xl tracking-tight'>
Edit Chunk Content
</DialogTitle>
<p className='text-muted-foreground text-sm'>
Modify the content of this knowledge chunk
</p>
</div>
<DialogTitle className='font-medium text-lg'>Edit Chunk</DialogTitle>
<Button
variant='ghost'
size='icon'
className='h-9 w-9 rounded-full transition-colors hover:bg-muted/50'
className='h-8 w-8 p-0'
onClick={handleCloseAttempt}
>
<X className='h-4 w-4' />
@@ -167,69 +132,92 @@ export function EditChunkModal({
</DialogHeader>
<div className='flex flex-1 flex-col overflow-hidden'>
<form className='flex h-full flex-col'>
{/* Scrollable Content */}
<div className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-8'>
<div className='py-6'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<Label htmlFor='content' className='font-medium text-sm'>
Content
</Label>
<div className='flex items-center gap-4 text-muted-foreground text-xs'>
<span>Characters: {editedContent.length}</span>
<span></span>
<span>Tokens: ~{Math.ceil(editedContent.length / 4)}</span>
</div>
<div className='scrollbar-thin scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/25 scrollbar-track-transparent min-h-0 flex-1 overflow-y-auto px-6'>
<div className='py-4'>
<div className='space-y-4'>
{/* Document Info */}
<div className='flex items-center gap-3 rounded-lg border bg-muted/30 p-4'>
<FileText className='h-5 w-5 text-muted-foreground' />
<div className='min-w-0 flex-1'>
<p className='font-medium text-sm'>
{document?.filename || 'Unknown Document'}
</p>
<p className='text-muted-foreground text-xs'>
Editing chunk #{chunk.chunkIndex}
</p>
</div>
</div>
{/* Content Input */}
<div className='space-y-2'>
<Label htmlFor='content' className='font-medium text-sm'>
Chunk Content
</Label>
<Textarea
id='content'
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder='Enter chunk content...'
className='min-h-[500px] resize-none border-input/50 text-sm leading-relaxed focus:border-primary/50 focus:ring-2 focus:ring-primary/10'
/>
</div>
</div>
</div>
{/* Footer */}
<div className='flex-shrink-0 border-t bg-background/95 px-8 py-6 backdrop-blur supports-[backdrop-filter]:bg-background/80'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-xs'>
{hasUnsavedChanges && (
<span className='flex items-center gap-1 text-amber-600'>
<div className='h-1.5 w-1.5 rounded-full bg-amber-500' />
Unsaved changes
</span>
)}
</div>
<div className='flex items-center gap-3'>
<Button
variant='outline'
onClick={handleCancel}
type='button'
className='min-h-[300px] resize-none'
disabled={isSaving}
className='px-6'
>
Cancel
</Button>
<Button
type='button'
onClick={handleSaveContent}
disabled={isSaving || !hasUnsavedChanges}
className='bg-[#701FFC] px-8 font-medium text-white shadow-lg transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[#701FFC]/25 hover:shadow-xl disabled:opacity-50 disabled:shadow-none'
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
/>
<div className='flex items-center justify-between text-muted-foreground text-xs'>
<span>{editedContent.length}/10000 characters</span>
<span>Tokens: ~{Math.ceil(editedContent.length / 4)}</span>
{editedContent.length > 10000 && (
<span className='text-red-500'>Content too long</span>
)}
</div>
</div>
{/* Error Display */}
{error && (
<div className='flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3'>
<AlertCircle className='h-4 w-4 text-red-600' />
<p className='text-red-800 text-sm'>{error}</p>
</div>
)}
</div>
</div>
</form>
</div>
{/* Footer */}
<div className='mt-auto border-t px-6 pt-4 pb-6'>
<div className='flex items-center justify-between'>
<div className='text-muted-foreground text-xs'>
{hasUnsavedChanges && (
<span className='flex items-center gap-1 text-amber-600'>
<div className='h-1.5 w-1.5 rounded-full bg-amber-500' />
Unsaved changes
</span>
)}
</div>
<div className='flex justify-between gap-3'>
<Button variant='outline' onClick={handleCloseAttempt} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={handleSaveContent}
disabled={!isFormValid || isSaving || !hasUnsavedChanges}
className='bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
{isSaving ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
Saving...
</>
) : (
'Save Changes'
)}
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* Unsaved Changes Alert */}
<AlertDialog open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -240,7 +228,9 @@ export function EditChunkModal({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleKeepEditing}>Keep Editing</AlertDialogCancel>
<AlertDialogCancel onClick={() => setShowUnsavedChangesAlert(false)}>
Keep Editing
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDiscard}
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'

View File

@@ -1,17 +1,19 @@
'use client'
import { useEffect, useState } from 'react'
import { Circle, CircleOff, FileText, Search, Trash2, X } from 'lucide-react'
import { Circle, CircleOff, FileText, Plus, Search, Trash2, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console-logger'
import { useDocumentChunks } from '@/hooks/use-knowledge'
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/knowledge'
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header'
import { CreateChunkModal } from './components/create-chunk-modal/create-chunk-modal'
import { DeleteChunkModal } from './components/delete-chunk-modal/delete-chunk-modal'
import { DocumentLoading } from './components/document-loading'
import { EditChunkModal } from './components/edit-chunk-modal'
import { EditChunkModal } from './components/edit-chunk-modal/edit-chunk-modal'
const logger = createLogger('Document')
@@ -49,6 +51,9 @@ export function Document({
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [document, setDocument] = useState<DocumentData | null>(null)
const [isLoadingDocument, setIsLoadingDocument] = useState(true)
@@ -119,7 +124,10 @@ export function Document({
const breadcrumbs = [
{ label: 'Knowledge', href: '/w/knowledge' },
{ label: effectiveKnowledgeBaseName, href: `/w/knowledge/${knowledgeBaseId}` },
{
label: effectiveKnowledgeBaseName,
href: `/w/knowledge/${knowledgeBaseId}`,
},
{ label: effectiveDocumentName },
]
@@ -165,34 +173,30 @@ export function Document({
}
}
const handleDeleteChunk = async (chunkId: string) => {
try {
const response = await fetch(
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
{
method: 'DELETE',
}
)
if (!response.ok) {
throw new Error('Failed to delete chunk')
}
const result = await response.json()
if (result.success) {
await refreshChunks()
setSelectedChunks((prev) => {
const newSet = new Set(prev)
newSet.delete(chunkId)
return newSet
})
}
} catch (err) {
logger.error('Error deleting chunk:', err)
const handleDeleteChunk = (chunkId: string) => {
const chunk = chunks.find((c) => c.id === chunkId)
if (chunk) {
setChunkToDelete(chunk)
setIsDeleteModalOpen(true)
}
}
const handleChunkDeleted = async () => {
await refreshChunks()
if (chunkToDelete) {
setSelectedChunks((prev) => {
const newSet = new Set(prev)
newSet.delete(chunkToDelete.id)
return newSet
})
}
}
const handleCloseDeleteModal = () => {
setIsDeleteModalOpen(false)
setChunkToDelete(null)
}
const handleSelectChunk = (chunkId: string, checked: boolean) => {
setSelectedChunks((prev) => {
const newSet = new Set(prev)
@@ -213,6 +217,11 @@ export function Document({
}
}
const handleChunkCreated = async (newChunk: ChunkData) => {
// Refresh the chunks list to include the new chunk
await refreshChunks()
}
const isAllSelected = chunks.length > 0 && selectedChunks.size === chunks.length
if (isLoadingDocument || isLoadingChunks) {
@@ -228,7 +237,10 @@ export function Document({
if (combinedError && !isLoadingChunks) {
const errorBreadcrumbs = [
{ label: 'Knowledge', href: '/w/knowledge' },
{ label: effectiveKnowledgeBaseName, href: `/w/knowledge/${knowledgeBaseId}` },
{
label: effectiveKnowledgeBaseName,
href: `/w/knowledge/${knowledgeBaseId}`,
},
{ label: 'Error' },
]
@@ -265,7 +277,7 @@ export function Document({
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -291,6 +303,16 @@ export function Document({
)}
</div>
</div>
<Button
onClick={() => setIsCreateChunkModalOpen(true)}
disabled={document?.processingStatus === 'failed'}
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:cursor-not-allowed disabled:opacity-50'
>
<Plus className='h-3.5 w-3.5' />
<span>Create Chunk</span>
</Button>
</div>
{/* Error State for chunks */}
@@ -562,6 +584,25 @@ export function Document({
setSelectedChunk(updatedChunk)
}}
/>
{/* Create Chunk Modal */}
<CreateChunkModal
open={isCreateChunkModalOpen}
onOpenChange={setIsCreateChunkModalOpen}
document={document}
knowledgeBaseId={knowledgeBaseId}
onChunkCreated={handleChunkCreated}
/>
{/* Delete Chunk Modal */}
<DeleteChunkModal
chunk={chunkToDelete}
knowledgeBaseId={knowledgeBaseId}
documentId={documentId}
isOpen={isDeleteModalOpen}
onClose={handleCloseDeleteModal}
onChunkDeleted={handleChunkDeleted}
/>
</div>
)
}

View File

@@ -30,7 +30,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { createLogger } from '@/lib/logs/console-logger'
import { getDocumentIcon } from '@/app/w/knowledge/components/icons/document-icons'
import { useKnowledgeBase, useKnowledgeBaseDocuments } from '@/hooks/use-knowledge'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/knowledge'
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../components/knowledge-header/knowledge-header'
import { KnowledgeBaseLoading } from './components/knowledge-base-loading'
@@ -541,7 +541,7 @@ export function KnowledgeBase({
/>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -569,7 +569,7 @@ export function KnowledgeBase({
onClick={handleAddDocuments}
disabled={isUploading}
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)]'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
<Plus className='h-3.5 w-3.5' />
{isUploading ? 'Uploading...' : 'Add Documents'}

View File

@@ -1,6 +1,7 @@
'use client'
import { Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header'
import { DocumentTableSkeleton } from '../../components/skeletons/table-skeleton'
@@ -39,7 +40,7 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -54,13 +55,14 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
<div className='flex items-center gap-3'>
{/* Add Documents Button - disabled state */}
<button
<Button
disabled
className='mt-1 mr-1 flex items-center gap-1.5 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)] disabled:opacity-50'
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
<span>Add Documents</span>
</button>
</Button>
</div>
</div>

View File

@@ -12,8 +12,8 @@ import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { createLogger } from '@/lib/logs/console-logger'
import { getDocumentIcon } from '@/app/w/knowledge/components/icons/document-icons'
import type { DocumentData, KnowledgeBaseData } from '@/stores/knowledge/knowledge'
import { useKnowledgeStore } from '@/stores/knowledge/knowledge'
import type { DocumentData, KnowledgeBaseData } from '@/stores/knowledge/store'
import { useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('CreateForm')

View File

@@ -3,7 +3,7 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import type { KnowledgeBaseData } from '@/stores/knowledge/knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
import { CreateForm } from './components/create-form/create-form'
interface CreateModalProps {

View File

@@ -2,8 +2,9 @@
import { useMemo, useState } from 'react'
import { LibraryBig, Plus, Search, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/knowledge'
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
import { useSidebarStore } from '@/stores/sidebar/store'
import { BaseOverview } from './components/base-overview/base-overview'
import { CreateModal } from './components/create-modal/create-modal'
@@ -66,7 +67,7 @@ export function Knowledge() {
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -88,13 +89,14 @@ export function Knowledge() {
</div>
</div>
<button
<Button
onClick={() => setIsCreateModalOpen(true)}
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]'
>
<Plus className='h-4 w-4 font-[480]' />
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</button>
</Button>
</div>
{/* Error State */}

View File

@@ -1,6 +1,7 @@
'use client'
import { Plus, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useSidebarStore } from '@/stores/sidebar/store'
import { KnowledgeHeader } from './components/knowledge-header/knowledge-header'
import { KnowledgeBaseCardSkeletonGrid } from './components/skeletons/knowledge-base-card-skeleton'
@@ -25,7 +26,7 @@ export default function KnowledgeLoading() {
<div className='flex-1 overflow-auto'>
<div className='px-6 pb-6'>
{/* Search and Create Section */}
<div className='mb-4 flex items-center justify-between'>
<div className='mb-4 flex items-center justify-between pt-1'>
<div className='relative max-w-md flex-1'>
<div className='relative flex items-center'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
@@ -38,13 +39,14 @@ export default function KnowledgeLoading() {
</div>
</div>
<button
<Button
disabled
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
size='sm'
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-primary-foreground shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
>
<Plus className='h-4 w-4 font-[480]' />
<Plus className='h-3.5 w-3.5' />
<span>Create</span>
</button>
</Button>
</div>
{/* Content Area */}

View File

@@ -4,20 +4,35 @@ import type { BlockConfig } from '../types'
export const KnowledgeBlock: BlockConfig = {
type: 'knowledge',
name: 'Knowledge',
description: 'Search knowledge',
description: 'Use vector search',
longDescription:
'Perform semantic vector search across your knowledge base to find the most relevant content. Uses advanced AI embeddings to understand meaning and context, returning the most similar documents to your search query.',
'Perform semantic vector search across your knowledge base or upload new chunks to documents. Uses advanced AI embeddings to understand meaning and context for search operations.',
bgColor: '#00B0B0',
icon: PackageSearchIcon,
category: 'blocks',
docsLink: 'https://docs.simstudio.ai/blocks/knowledge',
tools: {
access: ['knowledge_search'],
access: ['knowledge_search', 'knowledge_upload_chunk'],
config: {
tool: (params) => {
switch (params.operation) {
case 'search':
return 'knowledge_search'
case 'upload_chunk':
return 'knowledge_upload_chunk'
default:
return 'knowledge_search'
}
},
},
},
inputs: {
operation: { type: 'string', required: true },
knowledgeBaseId: { type: 'string', required: true },
query: { type: 'string', required: true },
query: { type: 'string', required: false },
topK: { type: 'number', required: false },
documentId: { type: 'string', required: false },
content: { type: 'string', required: false },
},
outputs: {
response: {
@@ -28,10 +43,23 @@ export const KnowledgeBlock: BlockConfig = {
topK: 'number',
totalResults: 'number',
message: 'string',
success: 'boolean',
data: 'json',
},
},
},
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
layout: 'full',
options: [
{ label: 'Search', id: 'search' },
{ label: 'Upload Chunk', id: 'upload_chunk' },
],
value: () => 'search',
},
{
id: 'knowledgeBaseId',
title: 'Knowledge Base',
@@ -45,6 +73,7 @@ export const KnowledgeBlock: BlockConfig = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter your search query',
condition: { field: 'operation', value: 'search' },
},
{
id: 'topK',
@@ -52,6 +81,24 @@ export const KnowledgeBlock: BlockConfig = {
type: 'short-input',
layout: 'full',
placeholder: 'Enter number of results (default: 10)',
condition: { field: 'operation', value: 'search' },
},
{
id: 'documentId',
title: 'Document',
type: 'document-selector',
layout: 'full',
placeholder: 'Select document',
condition: { field: 'operation', value: 'upload_chunk' },
},
{
id: 'content',
title: 'Chunk Content',
type: 'long-input',
layout: 'full',
placeholder: 'Enter the chunk content to upload',
rows: 6,
condition: { field: 'operation', value: 'upload_chunk' },
},
],
}

View File

@@ -32,6 +32,7 @@ export type SubBlockType =
| 'channel-selector' // Channel selector for Slack, Discord, etc.
| 'folder-selector' // Folder selector for Gmail, etc.
| 'knowledge-base-selector' // Knowledge base selector
| 'document-selector' // Document selector for knowledge bases
| 'input-format' // Input structure format
| 'file-upload' // File uploader

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/knowledge'
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
export function useKnowledgeBase(id: string) {
const { getKnowledgeBase, getCachedKnowledgeBase, loadingKnowledgeBases } = useKnowledgeStore()

View File

@@ -588,65 +588,25 @@ export const useKnowledgeStore = create<KnowledgeStore>((set, get) => ({
const chunks = result.data
const pagination = result.pagination
set((state) => {
// Get existing chunks if any
const existingCache = state.chunks[documentId]
const currentChunks = existingCache?.chunks || []
// For each fetched chunk, decide whether to use server data or preserve local state
const mergedChunks = chunks.map((fetchedChunk: ChunkData) => {
const existingChunk = currentChunks.find((chunk) => chunk.id === fetchedChunk.id)
if (!existingChunk) {
// New chunk from server, use it as-is
return fetchedChunk
}
// If server chunk has different content or metadata, prefer it (indicates server update)
if (
fetchedChunk.content !== existingChunk.content ||
JSON.stringify(fetchedChunk.metadata) !== JSON.stringify(existingChunk.metadata)
) {
return fetchedChunk
}
// If server chunk has different enabled status, quality score, or other properties, prefer it
if (
fetchedChunk.enabled !== existingChunk.enabled ||
fetchedChunk.qualityScore !== existingChunk.qualityScore
) {
return fetchedChunk
}
// Otherwise, preserve the existing chunk (keeps optimistic updates)
return existingChunk
})
// Add any new chunks that weren't in the existing set
const newChunks = chunks.filter(
(fetchedChunk: ChunkData) => !currentChunks.find((chunk) => chunk.id === fetchedChunk.id)
)
return {
chunks: {
...state.chunks,
[documentId]: {
chunks: [...mergedChunks, ...newChunks],
pagination: {
total: pagination?.total || chunks.length,
limit: pagination?.limit || options?.limit || 50,
offset: pagination?.offset || options?.offset || 0,
hasMore: pagination?.hasMore || false,
},
searchQuery: options?.search,
lastFetchTime: Date.now(),
set((state) => ({
chunks: {
...state.chunks,
[documentId]: {
chunks, // Use server data as source of truth
pagination: {
total: pagination?.total || chunks.length,
limit: pagination?.limit || options?.limit || 50,
offset: pagination?.offset || options?.offset || 0,
hasMore: pagination?.hasMore || false,
},
searchQuery: options?.search,
lastFetchTime: Date.now(),
},
loadingChunks: new Set(
[...state.loadingChunks].filter((loadingId) => loadingId !== documentId)
),
}
})
},
loadingChunks: new Set(
[...state.loadingChunks].filter((loadingId) => loadingId !== documentId)
),
}))
logger.info(`Chunks refreshed for document: ${documentId}`)
return chunks

View File

@@ -1,3 +1,4 @@
import { knowledgeSearchTool } from './search'
import { knowledgeUploadChunkTool } from './upload_chunk'
export { knowledgeSearchTool }
export { knowledgeSearchTool, knowledgeUploadChunkTool }

View File

@@ -25,3 +25,32 @@ export interface KnowledgeSearchParams {
query: string
topK?: number
}
export interface KnowledgeUploadChunkResult {
id: string
chunkIndex: number
content: string
contentLength: number
tokenCount: number
enabled: boolean
createdAt: string
updatedAt: string
}
export interface KnowledgeUploadChunkResponse {
success: boolean
output: {
data: KnowledgeUploadChunkResult
message: string
documentId: string
}
error?: string
}
export interface KnowledgeUploadChunkParams {
documentId: string
content: string
enabled?: boolean
searchRank?: number
qualityScore?: number
}

View File

@@ -0,0 +1,109 @@
import type { ToolConfig } from '../types'
import type { KnowledgeUploadChunkResponse } from './types'
export const knowledgeUploadChunkTool: ToolConfig<any, KnowledgeUploadChunkResponse> = {
id: 'knowledge_upload_chunk',
name: 'Knowledge Upload Chunk',
description: 'Upload a new chunk to a document in a knowledge base',
version: '1.0.0',
params: {
knowledgeBaseId: {
type: 'string',
required: true,
description: 'ID of the knowledge base containing the document',
},
documentId: {
type: 'string',
required: true,
description: 'ID of the document to upload the chunk to',
},
content: {
type: 'string',
required: true,
description: 'Content of the chunk to upload',
},
},
request: {
url: (params) =>
`/api/knowledge/${params.knowledgeBaseId}/documents/${params.documentId}/chunks`,
method: 'POST',
headers: () => ({
'Content-Type': 'application/json',
}),
body: (params) => ({
content: params.content,
enabled: true,
}),
isInternalRoute: true,
},
transformResponse: async (response): Promise<KnowledgeUploadChunkResponse> => {
try {
const result = await response.json()
if (!response.ok) {
const errorMessage = result.error?.message || result.message || 'Failed to upload chunk'
throw new Error(errorMessage)
}
const data = result.data || result
return {
success: true,
output: {
data: {
id: data.id,
chunkIndex: data.chunkIndex || 0,
content: data.content,
contentLength: data.contentLength || data.content?.length || 0,
tokenCount: data.tokenCount || 0,
enabled: data.enabled !== undefined ? data.enabled : true,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
},
message: `Successfully uploaded chunk to document`,
documentId: data.documentId,
},
}
} catch (error: any) {
return {
success: false,
output: {
data: {
id: '',
chunkIndex: 0,
content: '',
contentLength: 0,
tokenCount: 0,
enabled: true,
createdAt: '',
updatedAt: '',
},
message: `Failed to upload chunk: ${error.message || 'Unknown error'}`,
documentId: '',
},
error: `Failed to upload chunk: ${error.message || 'Unknown error'}`,
}
}
},
transformError: async (error): Promise<KnowledgeUploadChunkResponse> => {
const errorMessage = `Failed to upload chunk: ${error.message || 'Unknown error'}`
return {
success: false,
output: {
data: {
id: '',
chunkIndex: 0,
content: '',
contentLength: 0,
tokenCount: 0,
enabled: true,
createdAt: '',
updatedAt: '',
},
message: errorMessage,
documentId: '',
},
error: errorMessage,
}
},
}

View File

@@ -52,7 +52,7 @@ import { requestTool as httpRequest } from './http'
import { contactsTool as hubspotContacts } from './hubspot/contacts'
import { readUrlTool } from './jina'
import { jiraBulkRetrieveTool, jiraRetrieveTool, jiraUpdateTool, jiraWriteTool } from './jira'
import { knowledgeSearchTool } from './knowledge'
import { knowledgeSearchTool, knowledgeUploadChunkTool } from './knowledge'
import { linearCreateIssueTool, linearReadIssuesTool } from './linear'
import { linkupSearchTool } from './linkup'
import { mem0AddMemoriesTool, mem0GetMemoriesTool, mem0SearchMemoriesTool } from './mem0'
@@ -187,6 +187,7 @@ export const tools: Record<string, ToolConfig> = {
memory_get_all: memoryGetAllTool,
memory_delete: memoryDeleteTool,
knowledge_search: knowledgeSearchTool,
knowledge_upload_chunk: knowledgeUploadChunkTool,
elevenlabs_tts: elevenLabsTtsTool,
s3_get_object: s3GetObjectTool,
telegram_message: telegramMessageTool,