mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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}`
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -1,3 +1,4 @@
|
||||
import { knowledgeSearchTool } from './search'
|
||||
import { knowledgeUploadChunkTool } from './upload_chunk'
|
||||
|
||||
export { knowledgeSearchTool }
|
||||
export { knowledgeSearchTool, knowledgeUploadChunkTool }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
109
apps/sim/tools/knowledge/upload_chunk.ts
Normal file
109
apps/sim/tools/knowledge/upload_chunk.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user