mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(kb-tags): natural language pre-filter tag system for knowledge base searches (#800)
* fix lint * checkpoint * works * simplify * checkpoint * works * fix lint * checkpoint - create doc ui * working block * fix import conflicts * fix tests * add blockers to going past max tag slots * remove console logs * forgot a few * Update apps/sim/tools/knowledge/search.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * remove console.warn * Update apps/sim/hooks/use-tag-definitions.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * use tag slots consts in more places * remove duplicate title --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
cb17691c01
commit
5b1f948686
@@ -218,8 +218,15 @@ describe('Document By ID API Route', () => {
|
||||
}),
|
||||
}
|
||||
|
||||
// Mock transaction
|
||||
mockDbChain.transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
update: vi.fn().mockReturnValue(updateChain),
|
||||
}
|
||||
await callback(mockTx)
|
||||
})
|
||||
|
||||
// Mock db operations in sequence
|
||||
mockDbChain.update.mockReturnValue(updateChain)
|
||||
mockDbChain.select.mockReturnValue(selectChain)
|
||||
|
||||
const req = createMockRequest('PUT', validUpdateData)
|
||||
@@ -231,7 +238,7 @@ describe('Document By ID API Route', () => {
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.data.filename).toBe('updated-document.pdf')
|
||||
expect(data.data.enabled).toBe(false)
|
||||
expect(mockDbChain.update).toHaveBeenCalled()
|
||||
expect(mockDbChain.transaction).toHaveBeenCalled()
|
||||
expect(mockDbChain.select).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -298,8 +305,15 @@ describe('Document By ID API Route', () => {
|
||||
}),
|
||||
}
|
||||
|
||||
// Mock transaction
|
||||
mockDbChain.transaction.mockImplementation(async (callback) => {
|
||||
const mockTx = {
|
||||
update: vi.fn().mockReturnValue(updateChain),
|
||||
}
|
||||
await callback(mockTx)
|
||||
})
|
||||
|
||||
// Mock db operations in sequence
|
||||
mockDbChain.update.mockReturnValue(updateChain)
|
||||
mockDbChain.select.mockReturnValue(selectChain)
|
||||
|
||||
const req = createMockRequest('PUT', { markFailedDueToTimeout: true })
|
||||
@@ -309,7 +323,7 @@ describe('Document By ID API Route', () => {
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.success).toBe(true)
|
||||
expect(mockDbChain.update).toHaveBeenCalled()
|
||||
expect(mockDbChain.transaction).toHaveBeenCalled()
|
||||
expect(updateChain.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
processingStatus: 'failed',
|
||||
@@ -479,7 +493,9 @@ describe('Document By ID API Route', () => {
|
||||
document: mockDocument,
|
||||
knowledgeBase: { id: 'kb-123', userId: 'user-123' },
|
||||
})
|
||||
mockDbChain.set.mockRejectedValue(new Error('Database error'))
|
||||
|
||||
// Mock transaction to throw an error
|
||||
mockDbChain.transaction.mockRejectedValue(new Error('Database error'))
|
||||
|
||||
const req = createMockRequest('PUT', validUpdateData)
|
||||
const { PUT } = await import('@/app/api/knowledge/[id]/documents/[documentId]/route')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -26,6 +27,14 @@ const UpdateDocumentSchema = z.object({
|
||||
processingError: z.string().optional(),
|
||||
markFailedDueToTimeout: z.boolean().optional(),
|
||||
retryProcessing: z.boolean().optional(),
|
||||
// Tag fields
|
||||
tag1: z.string().optional(),
|
||||
tag2: z.string().optional(),
|
||||
tag3: z.string().optional(),
|
||||
tag4: z.string().optional(),
|
||||
tag5: z.string().optional(),
|
||||
tag6: z.string().optional(),
|
||||
tag7: z.string().optional(),
|
||||
})
|
||||
|
||||
export async function GET(
|
||||
@@ -213,9 +222,36 @@ export async function PUT(
|
||||
updateData.processingStatus = validatedData.processingStatus
|
||||
if (validatedData.processingError !== undefined)
|
||||
updateData.processingError = validatedData.processingError
|
||||
|
||||
// Tag field updates
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
if ((validatedData as any)[slot] !== undefined) {
|
||||
;(updateData as any)[slot] = (validatedData as any)[slot]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
await db.update(document).set(updateData).where(eq(document.id, documentId))
|
||||
await db.transaction(async (tx) => {
|
||||
// Update the document
|
||||
await tx.update(document).set(updateData).where(eq(document.id, documentId))
|
||||
|
||||
// If any tag fields were updated, also update the embeddings
|
||||
const hasTagUpdates = TAG_SLOTS.some((field) => (validatedData as any)[field] !== undefined)
|
||||
|
||||
if (hasTagUpdates) {
|
||||
const embeddingUpdateData: Record<string, string | null> = {}
|
||||
TAG_SLOTS.forEach((field) => {
|
||||
if ((validatedData as any)[field] !== undefined) {
|
||||
embeddingUpdateData[field] = (validatedData as any)[field] || null
|
||||
}
|
||||
})
|
||||
|
||||
await tx
|
||||
.update(embedding)
|
||||
.set(embeddingUpdateData)
|
||||
.where(eq(embedding.documentId, documentId))
|
||||
}
|
||||
})
|
||||
|
||||
// Fetch the updated document
|
||||
const updatedDocument = await db
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { document, knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('DocumentTagDefinitionsAPI')
|
||||
|
||||
const TagDefinitionSchema = z.object({
|
||||
tagSlot: z.enum(TAG_SLOTS as [string, ...string[]]),
|
||||
displayName: z.string().min(1, 'Display name is required').max(100, 'Display name too long'),
|
||||
fieldType: z.string().default('text'), // Currently only 'text', future: 'date', 'number', 'range'
|
||||
})
|
||||
|
||||
const BulkTagDefinitionsSchema = z.object({
|
||||
definitions: z
|
||||
.array(TagDefinitionSchema)
|
||||
.max(MAX_TAG_SLOTS, `Cannot define more than ${MAX_TAG_SLOTS} tags`),
|
||||
})
|
||||
|
||||
// Helper function to clean up unused tag definitions
|
||||
async function cleanupUnusedTagDefinitions(knowledgeBaseId: string, requestId: string) {
|
||||
try {
|
||||
logger.info(`[${requestId}] Starting cleanup for KB ${knowledgeBaseId}`)
|
||||
|
||||
// Get all tag definitions for this KB
|
||||
const allDefinitions = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
logger.info(`[${requestId}] Found ${allDefinitions.length} tag definitions to check`)
|
||||
|
||||
if (allDefinitions.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let cleanedCount = 0
|
||||
|
||||
// For each tag definition, check if any documents use that tag slot
|
||||
for (const definition of allDefinitions) {
|
||||
const slot = definition.tagSlot
|
||||
|
||||
// Use raw SQL with proper column name injection
|
||||
const countResult = await db.execute(sql`
|
||||
SELECT count(*) as count
|
||||
FROM document
|
||||
WHERE knowledge_base_id = ${knowledgeBaseId}
|
||||
AND ${sql.raw(slot)} IS NOT NULL
|
||||
AND trim(${sql.raw(slot)}) != ''
|
||||
`)
|
||||
const count = Number(countResult[0]?.count) || 0
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Tag ${definition.displayName} (${slot}): ${count} documents using it`
|
||||
)
|
||||
|
||||
// If count is 0, remove this tag definition
|
||||
if (count === 0) {
|
||||
await db
|
||||
.delete(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, definition.id))
|
||||
|
||||
cleanedCount++
|
||||
logger.info(
|
||||
`[${requestId}] Removed unused tag definition: ${definition.displayName} (${definition.tagSlot})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to cleanup unused tag definitions:`, error)
|
||||
return 0 // Don't fail the main operation if cleanup fails
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/knowledge/[id]/documents/[documentId]/tag-definitions - Get tag definitions for a document
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; documentId: string }> }
|
||||
) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Getting tag definitions for document ${documentId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify document exists and belongs to the knowledge base
|
||||
const documentExists = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(and(eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId)))
|
||||
.limit(1)
|
||||
|
||||
if (documentExists.length === 0) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Get tag definitions for the knowledge base
|
||||
const tagDefinitions = await db
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
createdAt: knowledgeBaseTagDefinitions.createdAt,
|
||||
updatedAt: knowledgeBaseTagDefinitions.updatedAt,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tagDefinitions,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tag definitions`, error)
|
||||
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/knowledge/[id]/documents/[documentId]/tag-definitions - Create/update tag definitions
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; documentId: string }> }
|
||||
) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating/updating tag definitions for document ${documentId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has write access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Verify document exists and belongs to the knowledge base
|
||||
const documentExists = await db
|
||||
.select({ id: document.id })
|
||||
.from(document)
|
||||
.where(and(eq(document.id, documentId), eq(document.knowledgeBaseId, knowledgeBaseId)))
|
||||
.limit(1)
|
||||
|
||||
if (documentExists.length === 0) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
let body
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Failed to parse JSON body:`, error)
|
||||
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
logger.error(`[${requestId}] Invalid request body:`, body)
|
||||
return NextResponse.json(
|
||||
{ error: 'Request body must be a valid JSON object' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const validatedData = BulkTagDefinitionsSchema.parse(body)
|
||||
|
||||
// Validate no duplicate tag slots
|
||||
const tagSlots = validatedData.definitions.map((def) => def.tagSlot)
|
||||
const uniqueTagSlots = new Set(tagSlots)
|
||||
if (tagSlots.length !== uniqueTagSlots.size) {
|
||||
return NextResponse.json({ error: 'Duplicate tag slots not allowed' }, { status: 400 })
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const createdDefinitions: (typeof knowledgeBaseTagDefinitions.$inferSelect)[] = []
|
||||
|
||||
// Get existing definitions count before transaction for cleanup check
|
||||
const existingDefinitions = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
// Check if we're trying to create more tag definitions than available slots
|
||||
const existingTagNames = new Set(existingDefinitions.map((def) => def.displayName))
|
||||
const trulyNewTags = validatedData.definitions.filter(
|
||||
(def) => !existingTagNames.has(def.displayName)
|
||||
)
|
||||
|
||||
if (existingDefinitions.length + trulyNewTags.length > MAX_TAG_SLOTS) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot create ${trulyNewTags.length} new tags. Knowledge base already has ${existingDefinitions.length} tag definitions. Maximum is ${MAX_TAG_SLOTS} total.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Use transaction to ensure consistency
|
||||
await db.transaction(async (tx) => {
|
||||
// Create maps for lookups
|
||||
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
|
||||
|
||||
// Process each new definition
|
||||
for (const definition of validatedData.definitions) {
|
||||
const existingByDisplayName = existingByName.get(definition.displayName)
|
||||
const existingByTagSlot = existingBySlot.get(definition.tagSlot)
|
||||
|
||||
if (existingByDisplayName) {
|
||||
// Update existing definition (same display name)
|
||||
if (existingByDisplayName.tagSlot !== definition.tagSlot) {
|
||||
// Slot is changing - check if target slot is available
|
||||
if (existingByTagSlot && existingByTagSlot.id !== existingByDisplayName.id) {
|
||||
// Target slot is occupied by a different definition - this is a conflict
|
||||
// For now, keep the existing slot to avoid constraint violation
|
||||
logger.warn(
|
||||
`[${requestId}] Slot conflict for ${definition.displayName}: keeping existing slot ${existingByDisplayName.tagSlot}`
|
||||
)
|
||||
createdDefinitions.push(existingByDisplayName)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
tagSlot: definition.tagSlot,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, existingByDisplayName.id))
|
||||
|
||||
createdDefinitions.push({
|
||||
...existingByDisplayName,
|
||||
tagSlot: definition.tagSlot,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
} else if (existingByTagSlot) {
|
||||
// Slot is occupied by a different display name - update it
|
||||
await tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, existingByTagSlot.id))
|
||||
|
||||
createdDefinitions.push({
|
||||
...existingByTagSlot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
} else {
|
||||
// Create new definition
|
||||
const newDefinition = {
|
||||
id: randomUUID(),
|
||||
knowledgeBaseId,
|
||||
tagSlot: definition.tagSlot,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await tx.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
||||
createdDefinitions.push(newDefinition)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Created/updated ${createdDefinitions.length} tag definitions`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: createdDefinitions,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid request data', details: error.errors },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating/updating tag definitions`, error)
|
||||
return NextResponse.json({ error: 'Failed to create/update tag definitions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/knowledge/[id]/documents/[documentId]/tag-definitions - Delete all tag definitions for a document
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; documentId: string }> }
|
||||
) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId, documentId } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const action = searchParams.get('action') // 'cleanup' or 'all'
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has write access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseWriteAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
if (action === 'cleanup') {
|
||||
// Just run cleanup
|
||||
logger.info(`[${requestId}] Running cleanup for KB ${knowledgeBaseId}`)
|
||||
const cleanedUpCount = await cleanupUnusedTagDefinitions(knowledgeBaseId, requestId)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { cleanedUp: cleanedUpCount },
|
||||
})
|
||||
}
|
||||
// Delete all tag definitions (original behavior)
|
||||
logger.info(`[${requestId}] Deleting all tag definitions for KB ${knowledgeBaseId}`)
|
||||
|
||||
const result = await db
|
||||
.delete(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Tag definitions deleted successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error with tag definitions operation`, error)
|
||||
return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
processDocumentAsync,
|
||||
} from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { document } from '@/db/schema'
|
||||
import { document, knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('DocumentsAPI')
|
||||
|
||||
@@ -22,6 +23,88 @@ const PROCESSING_CONFIG = {
|
||||
delayBetweenDocuments: 500,
|
||||
}
|
||||
|
||||
// Helper function to process structured document tags
|
||||
async function processDocumentTags(
|
||||
knowledgeBaseId: string,
|
||||
tagData: Array<{ tagName: string; fieldType: string; value: string }>,
|
||||
requestId: string
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result: Record<string, string | null> = {}
|
||||
|
||||
// Initialize all tag slots to null
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
result[slot] = null
|
||||
})
|
||||
|
||||
if (!Array.isArray(tagData) || tagData.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
// Get existing tag definitions
|
||||
const existingDefinitions = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
|
||||
|
||||
// Process each tag
|
||||
for (const tag of tagData) {
|
||||
if (!tag.tagName?.trim() || !tag.value?.trim()) continue
|
||||
|
||||
const tagName = tag.tagName.trim()
|
||||
const fieldType = tag.fieldType || 'text'
|
||||
const value = tag.value.trim()
|
||||
|
||||
let targetSlot: string | null = null
|
||||
|
||||
// Check if tag definition already exists
|
||||
const existingDef = existingByName.get(tagName)
|
||||
if (existingDef) {
|
||||
targetSlot = existingDef.tagSlot
|
||||
} else {
|
||||
// Find next available slot
|
||||
for (const slot of TAG_SLOTS) {
|
||||
if (!existingBySlot.has(slot)) {
|
||||
targetSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Create new tag definition if we have a slot
|
||||
if (targetSlot) {
|
||||
const newDefinition = {
|
||||
id: crypto.randomUUID(),
|
||||
knowledgeBaseId,
|
||||
tagSlot: targetSlot as any,
|
||||
displayName: tagName,
|
||||
fieldType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
await db.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
||||
existingBySlot.set(targetSlot as any, newDefinition)
|
||||
|
||||
logger.info(`[${requestId}] Created tag definition: ${tagName} -> ${targetSlot}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Assign value to the slot
|
||||
if (targetSlot) {
|
||||
result[targetSlot] = value
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error processing document tags:`, error)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
async function processDocumentsWithConcurrencyControl(
|
||||
createdDocuments: Array<{
|
||||
documentId: string
|
||||
@@ -158,7 +241,7 @@ const CreateDocumentSchema = z.object({
|
||||
fileUrl: z.string().url('File URL must be valid'),
|
||||
fileSize: z.number().min(1, 'File size must be greater than 0'),
|
||||
mimeType: z.string().min(1, 'MIME type is required'),
|
||||
// Document tags for filtering
|
||||
// Document tags for filtering (legacy format)
|
||||
tag1: z.string().optional(),
|
||||
tag2: z.string().optional(),
|
||||
tag3: z.string().optional(),
|
||||
@@ -166,6 +249,8 @@ const CreateDocumentSchema = z.object({
|
||||
tag5: z.string().optional(),
|
||||
tag6: z.string().optional(),
|
||||
tag7: z.string().optional(),
|
||||
// Structured tag data (new format)
|
||||
documentTagsData: z.string().optional(),
|
||||
})
|
||||
|
||||
const BulkCreateDocumentsSchema = z.object({
|
||||
@@ -350,6 +435,31 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const documentId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Process documentTagsData if provided (for knowledge base block)
|
||||
let processedTags: Record<string, string | null> = {
|
||||
tag1: null,
|
||||
tag2: null,
|
||||
tag3: null,
|
||||
tag4: null,
|
||||
tag5: null,
|
||||
tag6: null,
|
||||
tag7: null,
|
||||
}
|
||||
|
||||
if (docData.documentTagsData) {
|
||||
try {
|
||||
const tagData = JSON.parse(docData.documentTagsData)
|
||||
if (Array.isArray(tagData)) {
|
||||
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${requestId}] Failed to parse documentTagsData for bulk document:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const newDocument = {
|
||||
id: documentId,
|
||||
knowledgeBaseId,
|
||||
@@ -363,14 +473,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
processingStatus: 'pending' as const,
|
||||
enabled: true,
|
||||
uploadedAt: now,
|
||||
// Include tags from upload
|
||||
tag1: docData.tag1 || null,
|
||||
tag2: docData.tag2 || null,
|
||||
tag3: docData.tag3 || null,
|
||||
tag4: docData.tag4 || null,
|
||||
tag5: docData.tag5 || null,
|
||||
tag6: docData.tag6 || null,
|
||||
tag7: docData.tag7 || null,
|
||||
// Use processed tags if available, otherwise fall back to individual tag fields
|
||||
tag1: processedTags.tag1 || docData.tag1 || null,
|
||||
tag2: processedTags.tag2 || docData.tag2 || null,
|
||||
tag3: processedTags.tag3 || docData.tag3 || null,
|
||||
tag4: processedTags.tag4 || docData.tag4 || null,
|
||||
tag5: processedTags.tag5 || docData.tag5 || null,
|
||||
tag6: processedTags.tag6 || docData.tag6 || null,
|
||||
tag7: processedTags.tag7 || docData.tag7 || null,
|
||||
}
|
||||
|
||||
await tx.insert(document).values(newDocument)
|
||||
@@ -433,6 +543,29 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
const documentId = crypto.randomUUID()
|
||||
const now = new Date()
|
||||
|
||||
// Process structured tag data if provided
|
||||
let processedTags: Record<string, string | null> = {
|
||||
tag1: validatedData.tag1 || null,
|
||||
tag2: validatedData.tag2 || null,
|
||||
tag3: validatedData.tag3 || null,
|
||||
tag4: validatedData.tag4 || null,
|
||||
tag5: validatedData.tag5 || null,
|
||||
tag6: validatedData.tag6 || null,
|
||||
tag7: validatedData.tag7 || null,
|
||||
}
|
||||
|
||||
if (validatedData.documentTagsData) {
|
||||
try {
|
||||
const tagData = JSON.parse(validatedData.documentTagsData)
|
||||
if (Array.isArray(tagData)) {
|
||||
// Process structured tag data and create tag definitions
|
||||
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
const newDocument = {
|
||||
id: documentId,
|
||||
knowledgeBaseId,
|
||||
@@ -445,14 +578,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
characterCount: 0,
|
||||
enabled: true,
|
||||
uploadedAt: now,
|
||||
// Include tags from upload
|
||||
tag1: validatedData.tag1 || null,
|
||||
tag2: validatedData.tag2 || null,
|
||||
tag3: validatedData.tag3 || null,
|
||||
tag4: validatedData.tag4 || null,
|
||||
tag5: validatedData.tag5 || null,
|
||||
tag6: validatedData.tag6 || null,
|
||||
tag7: validatedData.tag7 || null,
|
||||
...processedTags,
|
||||
}
|
||||
|
||||
await db.insert(document).values(newDocument)
|
||||
|
||||
57
apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts
Normal file
57
apps/sim/app/api/knowledge/[id]/tag-definitions/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('KnowledgeBaseTagDefinitionsAPI')
|
||||
|
||||
// GET /api/knowledge/[id]/tag-definitions - Get all tag definitions for a knowledge base
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Getting tag definitions for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get tag definitions for the knowledge base
|
||||
const tagDefinitions = await db
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
fieldType: knowledgeBaseTagDefinitions.fieldType,
|
||||
createdAt: knowledgeBaseTagDefinitions.createdAt,
|
||||
updatedAt: knowledgeBaseTagDefinitions.updatedAt,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
.orderBy(knowledgeBaseTagDefinitions.tagSlot)
|
||||
|
||||
logger.info(`[${requestId}] Retrieved ${tagDefinitions.length} tag definitions`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tagDefinitions,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tag definitions`, error)
|
||||
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { retryWithExponentialBackoff } from '@/lib/documents/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -8,31 +9,50 @@ import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { embedding } from '@/db/schema'
|
||||
import { embedding, knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
import { calculateCost } from '@/providers/utils'
|
||||
|
||||
const logger = createLogger('VectorSearchAPI')
|
||||
|
||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
||||
return Object.entries(filters).map(([key, value]) => {
|
||||
switch (key) {
|
||||
case 'tag1':
|
||||
return sql`LOWER(${embedding.tag1}) = LOWER(${value})`
|
||||
case 'tag2':
|
||||
return sql`LOWER(${embedding.tag2}) = LOWER(${value})`
|
||||
case 'tag3':
|
||||
return sql`LOWER(${embedding.tag3}) = LOWER(${value})`
|
||||
case 'tag4':
|
||||
return sql`LOWER(${embedding.tag4}) = LOWER(${value})`
|
||||
case 'tag5':
|
||||
return sql`LOWER(${embedding.tag5}) = LOWER(${value})`
|
||||
case 'tag6':
|
||||
return sql`LOWER(${embedding.tag6}) = LOWER(${value})`
|
||||
case 'tag7':
|
||||
return sql`LOWER(${embedding.tag7}) = LOWER(${value})`
|
||||
default:
|
||||
return sql`1=1` // No-op for unknown keys
|
||||
// Handle OR logic within same tag
|
||||
const values = value.includes('|OR|') ? value.split('|OR|') : [value]
|
||||
logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values)
|
||||
|
||||
const getColumnForKey = (key: string) => {
|
||||
switch (key) {
|
||||
case 'tag1':
|
||||
return embedding.tag1
|
||||
case 'tag2':
|
||||
return embedding.tag2
|
||||
case 'tag3':
|
||||
return embedding.tag3
|
||||
case 'tag4':
|
||||
return embedding.tag4
|
||||
case 'tag5':
|
||||
return embedding.tag5
|
||||
case 'tag6':
|
||||
return embedding.tag6
|
||||
case 'tag7':
|
||||
return embedding.tag7
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const column = getColumnForKey(key)
|
||||
if (!column) return sql`1=1` // No-op for unknown keys
|
||||
|
||||
if (values.length === 1) {
|
||||
// Single value - simple equality
|
||||
logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`)
|
||||
return sql`LOWER(${column}) = LOWER(${values[0]})`
|
||||
}
|
||||
// Multiple values - OR logic
|
||||
logger.debug(`[getTagFilters] OR filter: ${key} IN (${values.join(', ')})`)
|
||||
const orConditions = values.map((v) => sql`LOWER(${column}) = LOWER(${v})`)
|
||||
return sql`(${sql.join(orConditions, sql` OR `)})`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -53,17 +73,7 @@ const VectorSearchSchema = z.object({
|
||||
]),
|
||||
query: z.string().min(1, 'Search query is required'),
|
||||
topK: z.number().min(1).max(100).default(10),
|
||||
filters: z
|
||||
.object({
|
||||
tag1: z.string().optional(),
|
||||
tag2: z.string().optional(),
|
||||
tag3: z.string().optional(),
|
||||
tag4: z.string().optional(),
|
||||
tag5: z.string().optional(),
|
||||
tag6: z.string().optional(),
|
||||
tag7: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
filters: z.record(z.string()).optional(), // Allow dynamic filter keys (display names)
|
||||
})
|
||||
|
||||
async function generateSearchEmbedding(query: string): Promise<number[]> {
|
||||
@@ -187,6 +197,7 @@ async function executeSingleQuery(
|
||||
distanceThreshold: number,
|
||||
filters?: Record<string, string>
|
||||
) {
|
||||
logger.debug(`[executeSingleQuery] Called with filters:`, filters)
|
||||
return await db
|
||||
.select({
|
||||
id: embedding.id,
|
||||
@@ -201,6 +212,7 @@ async function executeSingleQuery(
|
||||
tag6: embedding.tag6,
|
||||
tag7: embedding.tag7,
|
||||
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
|
||||
knowledgeBaseId: embedding.knowledgeBaseId,
|
||||
})
|
||||
.from(embedding)
|
||||
.where(
|
||||
@@ -208,28 +220,7 @@ async function executeSingleQuery(
|
||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||
eq(embedding.enabled, true),
|
||||
sql`${embedding.embedding} <=> ${queryVector}::vector < ${distanceThreshold}`,
|
||||
...(filters
|
||||
? Object.entries(filters).map(([key, value]) => {
|
||||
switch (key) {
|
||||
case 'tag1':
|
||||
return sql`LOWER(${embedding.tag1}) = LOWER(${value})`
|
||||
case 'tag2':
|
||||
return sql`LOWER(${embedding.tag2}) = LOWER(${value})`
|
||||
case 'tag3':
|
||||
return sql`LOWER(${embedding.tag3}) = LOWER(${value})`
|
||||
case 'tag4':
|
||||
return sql`LOWER(${embedding.tag4}) = LOWER(${value})`
|
||||
case 'tag5':
|
||||
return sql`LOWER(${embedding.tag5}) = LOWER(${value})`
|
||||
case 'tag6':
|
||||
return sql`LOWER(${embedding.tag6}) = LOWER(${value})`
|
||||
case 'tag7':
|
||||
return sql`LOWER(${embedding.tag7}) = LOWER(${value})`
|
||||
default:
|
||||
return sql`1=1` // No-op for unknown keys
|
||||
}
|
||||
})
|
||||
: [])
|
||||
...(filters ? getTagFilters(filters, embedding) : [])
|
||||
)
|
||||
)
|
||||
.orderBy(sql`${embedding.embedding} <=> ${queryVector}::vector`)
|
||||
@@ -271,6 +262,54 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
// Map display names to tag slots for filtering
|
||||
let mappedFilters: Record<string, string> = {}
|
||||
if (validatedData.filters && accessibleKbIds.length > 0) {
|
||||
try {
|
||||
// Fetch tag definitions for the first accessible KB (since we're using single KB now)
|
||||
const kbId = accessibleKbIds[0]
|
||||
const tagDefs = await db
|
||||
.select({
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, kbId))
|
||||
|
||||
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
|
||||
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
|
||||
|
||||
// Create mapping from display name to tag slot
|
||||
const displayNameToSlot: Record<string, string> = {}
|
||||
tagDefs.forEach((def) => {
|
||||
displayNameToSlot[def.displayName] = def.tagSlot
|
||||
})
|
||||
|
||||
// Map the filters and handle OR logic
|
||||
Object.entries(validatedData.filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
|
||||
|
||||
// Check if this is an OR filter (contains |OR| separator)
|
||||
if (value.includes('|OR|')) {
|
||||
logger.debug(
|
||||
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
|
||||
)
|
||||
}
|
||||
|
||||
mappedFilters[tagSlot] = value
|
||||
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
|
||||
}
|
||||
})
|
||||
|
||||
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Filter mapping error:`, error)
|
||||
// If mapping fails, use original filters
|
||||
mappedFilters = validatedData.filters
|
||||
}
|
||||
}
|
||||
|
||||
if (accessibleKbIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Knowledge base not found or access denied' },
|
||||
@@ -299,22 +338,24 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (strategy.useParallel) {
|
||||
// Execute parallel queries for better performance with many KBs
|
||||
logger.debug(`[${requestId}] Executing parallel queries with filters:`, mappedFilters)
|
||||
const parallelResults = await executeParallelQueries(
|
||||
accessibleKbIds,
|
||||
queryVector,
|
||||
validatedData.topK,
|
||||
strategy.distanceThreshold,
|
||||
validatedData.filters
|
||||
mappedFilters
|
||||
)
|
||||
results = mergeAndRankResults(parallelResults, validatedData.topK)
|
||||
} else {
|
||||
// Execute single optimized query for fewer KBs
|
||||
logger.debug(`[${requestId}] Executing single query with filters:`, mappedFilters)
|
||||
results = await executeSingleQuery(
|
||||
accessibleKbIds,
|
||||
queryVector,
|
||||
validatedData.topK,
|
||||
strategy.distanceThreshold,
|
||||
validatedData.filters
|
||||
mappedFilters
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,23 +372,64 @@ export async function POST(request: NextRequest) {
|
||||
// Continue without cost information rather than failing the search
|
||||
}
|
||||
|
||||
// Fetch tag definitions for display name mapping (reuse the same fetch from filtering)
|
||||
const tagDefinitionsMap: Record<string, Record<string, string>> = {}
|
||||
for (const kbId of accessibleKbIds) {
|
||||
try {
|
||||
const tagDefs = await db
|
||||
.select({
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, kbId))
|
||||
|
||||
tagDefinitionsMap[kbId] = {}
|
||||
tagDefs.forEach((def) => {
|
||||
tagDefinitionsMap[kbId][def.tagSlot] = def.displayName
|
||||
})
|
||||
logger.debug(
|
||||
`[${requestId}] Display mapping - KB ${kbId} tag definitions:`,
|
||||
tagDefinitionsMap[kbId]
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn(`[${requestId}] Failed to fetch tag definitions for display mapping:`, error)
|
||||
tagDefinitionsMap[kbId] = {}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
results: results.map((result) => ({
|
||||
id: result.id,
|
||||
content: result.content,
|
||||
documentId: result.documentId,
|
||||
chunkIndex: result.chunkIndex,
|
||||
tag1: result.tag1,
|
||||
tag2: result.tag2,
|
||||
tag3: result.tag3,
|
||||
tag4: result.tag4,
|
||||
tag5: result.tag5,
|
||||
tag6: result.tag6,
|
||||
tag7: result.tag7,
|
||||
similarity: 1 - result.distance,
|
||||
})),
|
||||
results: results.map((result) => {
|
||||
const kbTagMap = tagDefinitionsMap[result.knowledgeBaseId] || {}
|
||||
logger.debug(
|
||||
`[${requestId}] Result KB: ${result.knowledgeBaseId}, available mappings:`,
|
||||
kbTagMap
|
||||
)
|
||||
|
||||
// Create tags object with display names
|
||||
const tags: Record<string, any> = {}
|
||||
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
if (result[slot]) {
|
||||
const displayName = kbTagMap[slot] || slot
|
||||
logger.debug(
|
||||
`[${requestId}] Mapping ${slot}="${result[slot]}" -> "${displayName}"="${result[slot]}"`
|
||||
)
|
||||
tags[displayName] = result[slot]
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
id: result.id,
|
||||
content: result.content,
|
||||
documentId: result.documentId,
|
||||
chunkIndex: result.chunkIndex,
|
||||
tags, // Clean display name mapped tags
|
||||
similarity: 1 - result.distance,
|
||||
}
|
||||
}),
|
||||
query: validatedData.query,
|
||||
knowledgeBaseIds: accessibleKbIds,
|
||||
knowledgeBaseId: accessibleKbIds[0],
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/components/providers/workspace-permissions-provider'
|
||||
import {
|
||||
@@ -21,7 +22,12 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
|
||||
import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import { KnowledgeHeader, SearchInput } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
type DocumentTag,
|
||||
DocumentTagEntry,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
|
||||
import { useDocumentChunks } from '@/hooks/use-knowledge'
|
||||
import { useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||
import { type ChunkData, type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('Document')
|
||||
@@ -50,7 +56,11 @@ export function Document({
|
||||
knowledgeBaseName,
|
||||
documentName,
|
||||
}: DocumentProps) {
|
||||
const { getCachedKnowledgeBase, getCachedDocuments } = useKnowledgeStore()
|
||||
const {
|
||||
getCachedKnowledgeBase,
|
||||
getCachedDocuments,
|
||||
updateDocument: updateDocumentInStore,
|
||||
} = useKnowledgeStore()
|
||||
const { workspaceId } = useParams()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -60,7 +70,6 @@ export function Document({
|
||||
const {
|
||||
chunks: paginatedChunks,
|
||||
allChunks,
|
||||
filteredChunks,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
currentPage,
|
||||
@@ -81,15 +90,102 @@ export function Document({
|
||||
const [selectedChunks, setSelectedChunks] = useState<Set<string>>(new Set())
|
||||
const [selectedChunk, setSelectedChunk] = useState<ChunkData | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
|
||||
const [documentData, setDocumentData] = useState<DocumentData | null>(null)
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Use tag definitions hook for custom labels
|
||||
const { tagDefinitions, fetchTagDefinitions } = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
|
||||
// Function to build document tags from data and definitions
|
||||
const buildDocumentTags = useCallback(
|
||||
(docData: DocumentData, definitions: any[], currentTags?: DocumentTag[]) => {
|
||||
const tags: DocumentTag[] = []
|
||||
const tagSlots = TAG_SLOTS
|
||||
|
||||
tagSlots.forEach((slot) => {
|
||||
const value = (docData as any)[slot] as string | null | undefined
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
const currentTag = currentTags?.find((tag) => tag.slot === slot)
|
||||
|
||||
// Only include tag if the document actually has a value for it
|
||||
if (value?.trim()) {
|
||||
tags.push({
|
||||
slot,
|
||||
// Preserve existing displayName if definition is not found yet
|
||||
displayName: definition?.displayName || currentTag?.displayName || '',
|
||||
fieldType: definition?.fieldType || currentTag?.fieldType || 'text',
|
||||
value: value.trim(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle tag updates (local state only, no API calls)
|
||||
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
|
||||
// Only update local state, don't save to API
|
||||
setDocumentTags(newTags)
|
||||
}, [])
|
||||
|
||||
// Handle saving document tag values to the API
|
||||
const handleSaveDocumentTags = useCallback(
|
||||
async (tagsToSave: DocumentTag[]) => {
|
||||
if (!documentData) return
|
||||
|
||||
try {
|
||||
// Convert DocumentTag array to tag data for API
|
||||
const tagData: Record<string, string> = {}
|
||||
const tagSlots = TAG_SLOTS
|
||||
|
||||
// Clear all tags first
|
||||
tagSlots.forEach((slot) => {
|
||||
tagData[slot] = ''
|
||||
})
|
||||
|
||||
// Set values from tagsToSave
|
||||
tagsToSave.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
// Update document via API
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document tags')
|
||||
}
|
||||
|
||||
// Update the document in the store and local state
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||
setDocumentData((prev) => (prev ? { ...prev, ...tagData } : null))
|
||||
|
||||
// Refresh tag definitions to update the display
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
logger.error('Error updating document tags:', error)
|
||||
throw error // Re-throw so the component can handle it
|
||||
}
|
||||
},
|
||||
[documentData, knowledgeBaseId, documentId, updateDocumentInStore, fetchTagDefinitions]
|
||||
)
|
||||
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
|
||||
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||
|
||||
const [document, setDocument] = useState<DocumentData | null>(null)
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const combinedError = error || chunksError
|
||||
|
||||
// URL synchronization for pagination
|
||||
@@ -121,7 +217,10 @@ export function Document({
|
||||
const cachedDoc = cachedDocuments?.documents?.find((d) => d.id === documentId)
|
||||
|
||||
if (cachedDoc) {
|
||||
setDocument(cachedDoc)
|
||||
setDocumentData(cachedDoc)
|
||||
// Initialize tags from cached document
|
||||
const initialTags = buildDocumentTags(cachedDoc, tagDefinitions)
|
||||
setDocumentTags(initialTags)
|
||||
setIsLoadingDocument(false)
|
||||
return
|
||||
}
|
||||
@@ -138,7 +237,10 @@ export function Document({
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setDocument(result.data)
|
||||
setDocumentData(result.data)
|
||||
// Initialize tags from fetched document
|
||||
const initialTags = buildDocumentTags(result.data, tagDefinitions, [])
|
||||
setDocumentTags(initialTags)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to fetch document')
|
||||
}
|
||||
@@ -153,11 +255,19 @@ export function Document({
|
||||
if (knowledgeBaseId && documentId) {
|
||||
fetchDocument()
|
||||
}
|
||||
}, [knowledgeBaseId, documentId, getCachedDocuments])
|
||||
}, [knowledgeBaseId, documentId, getCachedDocuments, buildDocumentTags])
|
||||
|
||||
// Separate effect to rebuild tags when tag definitions change (without re-fetching document)
|
||||
useEffect(() => {
|
||||
if (documentData) {
|
||||
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions, documentTags)
|
||||
setDocumentTags(rebuiltTags)
|
||||
}
|
||||
}, [documentData, tagDefinitions, buildDocumentTags])
|
||||
|
||||
const knowledgeBase = getCachedKnowledgeBase(knowledgeBaseId)
|
||||
const effectiveKnowledgeBaseName = knowledgeBase?.name || knowledgeBaseName || 'Knowledge Base'
|
||||
const effectiveDocumentName = document?.filename || documentName || 'Document'
|
||||
const effectiveDocumentName = documentData?.filename || documentName || 'Document'
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Knowledge', href: `/workspace/${workspaceId}/knowledge` },
|
||||
@@ -254,7 +364,7 @@ export function Document({
|
||||
}
|
||||
}
|
||||
|
||||
const handleChunkCreated = async (newChunk: ChunkData) => {
|
||||
const handleChunkCreated = async () => {
|
||||
// Refresh the chunks list to include the new chunk
|
||||
await refreshChunks()
|
||||
}
|
||||
@@ -396,16 +506,16 @@ export function Document({
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder={
|
||||
document?.processingStatus === 'completed'
|
||||
documentData?.processingStatus === 'completed'
|
||||
? 'Search chunks...'
|
||||
: 'Document processing...'
|
||||
}
|
||||
disabled={document?.processingStatus !== 'completed'}
|
||||
disabled={documentData?.processingStatus !== 'completed'}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreateChunkModalOpen(true)}
|
||||
disabled={document?.processingStatus === 'failed' || !userPermissions.canEdit}
|
||||
disabled={documentData?.processingStatus === 'failed' || !userPermissions.canEdit}
|
||||
size='sm'
|
||||
className='flex items-center gap-1 bg-[#701FFC] font-[480] text-white 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'
|
||||
>
|
||||
@@ -414,36 +524,19 @@ export function Document({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Document Tags Display */}
|
||||
{document &&
|
||||
(() => {
|
||||
const tags = [
|
||||
{ label: 'Tag 1', value: document.tag1 },
|
||||
{ label: 'Tag 2', value: document.tag2 },
|
||||
{ label: 'Tag 3', value: document.tag3 },
|
||||
{ label: 'Tag 4', value: document.tag4 },
|
||||
{ label: 'Tag 5', value: document.tag5 },
|
||||
{ label: 'Tag 6', value: document.tag6 },
|
||||
{ label: 'Tag 7', value: document.tag7 },
|
||||
].filter((tag) => tag.value?.trim())
|
||||
|
||||
return tags.length > 0 ? (
|
||||
<div className='mb-4 rounded-md bg-muted/50 p-3'>
|
||||
<p className='mb-2 text-muted-foreground text-xs'>Document Tags:</p>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className='inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-primary text-xs'
|
||||
>
|
||||
<span className='font-medium'>{tag.label}:</span>
|
||||
<span>{tag.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
{/* Document Tag Entry */}
|
||||
{userPermissions.canEdit && (
|
||||
<div className='mb-4 rounded-md border p-4'>
|
||||
<DocumentTagEntry
|
||||
tags={documentTags}
|
||||
onTagsChange={handleTagsChange}
|
||||
disabled={false}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
documentId={documentId}
|
||||
onSave={handleSaveDocumentTags}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State for chunks */}
|
||||
{combinedError && !isLoadingAllChunks && (
|
||||
@@ -472,7 +565,8 @@ export function Document({
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
disabled={
|
||||
document?.processingStatus !== 'completed' || !userPermissions.canEdit
|
||||
documentData?.processingStatus !== 'completed' ||
|
||||
!userPermissions.canEdit
|
||||
}
|
||||
aria-label='Select all chunks'
|
||||
className='h-3.5 w-3.5 border-gray-300 focus-visible:ring-[#701FFC]/20 data-[state=checked]:border-[#701FFC] data-[state=checked]:bg-[#701FFC] [&>*]:h-3 [&>*]:w-3'
|
||||
@@ -514,7 +608,7 @@ export function Document({
|
||||
<col className='w-[12%]' />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{document?.processingStatus !== 'completed' ? (
|
||||
{documentData?.processingStatus !== 'completed' ? (
|
||||
<tr className='border-b transition-colors'>
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-3.5 w-3.5' />
|
||||
@@ -526,13 +620,13 @@ export function Document({
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='h-5 w-5 text-muted-foreground' />
|
||||
<span className='text-muted-foreground text-sm italic'>
|
||||
{document?.processingStatus === 'pending' &&
|
||||
{documentData?.processingStatus === 'pending' &&
|
||||
'Document processing pending...'}
|
||||
{document?.processingStatus === 'processing' &&
|
||||
{documentData?.processingStatus === 'processing' &&
|
||||
'Document processing in progress...'}
|
||||
{document?.processingStatus === 'failed' &&
|
||||
{documentData?.processingStatus === 'failed' &&
|
||||
'Document processing failed'}
|
||||
{!document?.processingStatus && 'Document not ready'}
|
||||
{!documentData?.processingStatus && 'Document not ready'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -558,7 +652,7 @@ export function Document({
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='h-5 w-5 text-muted-foreground' />
|
||||
<span className='text-muted-foreground text-sm italic'>
|
||||
{document?.processingStatus === 'completed'
|
||||
{documentData?.processingStatus === 'completed'
|
||||
? searchQuery.trim()
|
||||
? 'No chunks match your search'
|
||||
: 'No chunks found'
|
||||
@@ -708,7 +802,7 @@ export function Document({
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{document?.processingStatus === 'completed' && totalPages > 1 && (
|
||||
{documentData?.processingStatus === 'completed' && totalPages > 1 && (
|
||||
<div className='flex items-center justify-center border-t bg-background px-6 py-4'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
@@ -773,7 +867,7 @@ export function Document({
|
||||
{/* Edit Chunk Modal */}
|
||||
<EditChunkModal
|
||||
chunk={selectedChunk}
|
||||
document={document}
|
||||
document={documentData}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
@@ -811,7 +905,7 @@ export function Document({
|
||||
<CreateChunkModal
|
||||
open={isCreateChunkModalOpen}
|
||||
onOpenChange={setIsCreateChunkModalOpen}
|
||||
document={document}
|
||||
document={documentData}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
onChunkCreated={handleChunkCreated}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,10 @@ import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { type TagData, TagInput } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
type DocumentTag,
|
||||
DocumentTagEntry,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
|
||||
const logger = createLogger('UploadModal')
|
||||
@@ -47,7 +50,7 @@ export function UploadModal({
|
||||
}: UploadModalProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
const [tags, setTags] = useState<TagData>({})
|
||||
const [tags, setTags] = useState<DocumentTag[]>([])
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
@@ -63,7 +66,7 @@ export function UploadModal({
|
||||
if (isUploading) return // Prevent closing during upload
|
||||
|
||||
setFiles([])
|
||||
setTags({})
|
||||
setTags([])
|
||||
setFileError(null)
|
||||
setIsDragging(false)
|
||||
onOpenChange(false)
|
||||
@@ -142,11 +145,19 @@ export function UploadModal({
|
||||
if (files.length === 0) return
|
||||
|
||||
try {
|
||||
// Convert DocumentTag array to TagData format
|
||||
const tagData: Record<string, string> = {}
|
||||
tags.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
// Create files with tags for upload
|
||||
const filesWithTags = files.map((file) => {
|
||||
// Add tags as custom properties to the file object
|
||||
const fileWithTags = file as File & TagData
|
||||
Object.assign(fileWithTags, tags)
|
||||
const fileWithTags = file as unknown as File & Record<string, string>
|
||||
Object.assign(fileWithTags, tagData)
|
||||
return fileWithTags
|
||||
})
|
||||
|
||||
@@ -169,8 +180,14 @@ export function UploadModal({
|
||||
</DialogHeader>
|
||||
|
||||
<div className='flex-1 space-y-6 overflow-auto'>
|
||||
{/* Tag Input Section */}
|
||||
<TagInput tags={tags} onTagsChange={setTags} disabled={isUploading} />
|
||||
{/* Document Tag Entry Section */}
|
||||
<DocumentTagEntry
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
disabled={isUploading}
|
||||
knowledgeBaseId={knowledgeBaseId}
|
||||
documentId={null} // No specific document for upload
|
||||
/>
|
||||
|
||||
{/* File Upload Section */}
|
||||
<div className='space-y-3'>
|
||||
|
||||
@@ -13,11 +13,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import {
|
||||
getDocumentIcon,
|
||||
type TagData,
|
||||
TagInput,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||
|
||||
@@ -88,7 +84,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragCounter, setDragCounter] = useState(0) // Track drag events to handle nested elements
|
||||
const [tags, setTags] = useState<TagData>({})
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -283,14 +279,7 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
const newKnowledgeBase = result.data
|
||||
|
||||
if (files.length > 0) {
|
||||
// Add tags to files before upload
|
||||
const filesWithTags = files.map((file) => {
|
||||
const fileWithTags = file as File & TagData
|
||||
Object.assign(fileWithTags, tags)
|
||||
return fileWithTags
|
||||
})
|
||||
|
||||
const uploadedFiles = await uploadFiles(filesWithTags, newKnowledgeBase.id, {
|
||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||
chunkSize: data.maxChunkSize,
|
||||
minCharactersPerChunk: data.minChunkSize,
|
||||
chunkOverlap: data.overlapSize,
|
||||
@@ -314,7 +303,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
maxChunkSize: 1024,
|
||||
overlapSize: 200,
|
||||
})
|
||||
setTags({})
|
||||
|
||||
// Clean up file previews
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
@@ -490,11 +478,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tag Input Section */}
|
||||
<div className='mt-6'>
|
||||
<TagInput tags={tags} onTagsChange={setTags} disabled={isSubmitting} />
|
||||
</div>
|
||||
|
||||
{/* File Upload Section - Expands to fill remaining space */}
|
||||
<div className='mt-6 flex flex-1 flex-col'>
|
||||
<Label className='mb-2'>Upload Documents</Label>
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||
|
||||
export interface DocumentTag {
|
||||
slot: TagSlot
|
||||
displayName: string
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DocumentTagEntryProps {
|
||||
tags: DocumentTag[]
|
||||
onTagsChange: (tags: DocumentTag[]) => void
|
||||
disabled?: boolean
|
||||
knowledgeBaseId?: string | null
|
||||
documentId?: string | null
|
||||
onSave?: (tags: DocumentTag[]) => Promise<void>
|
||||
}
|
||||
|
||||
// TAG_SLOTS is now imported from constants
|
||||
|
||||
export function DocumentTagEntry({
|
||||
tags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
knowledgeBaseId = null,
|
||||
documentId = null,
|
||||
onSave,
|
||||
}: DocumentTagEntryProps) {
|
||||
const { saveTagDefinitions } = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const [editingTag, setEditingTag] = useState<{
|
||||
index: number
|
||||
value: string
|
||||
tagName: string
|
||||
isNew: boolean
|
||||
} | null>(null)
|
||||
|
||||
const getNextAvailableSlot = (): DocumentTag['slot'] => {
|
||||
const usedSlots = new Set(tags.map((tag) => tag.slot))
|
||||
for (const slot of TAG_SLOTS) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return 'tag1' // fallback
|
||||
}
|
||||
|
||||
const handleSaveDefinitions = async (tagsToSave?: DocumentTag[]) => {
|
||||
if (!knowledgeBaseId || !documentId) return
|
||||
|
||||
const currentTags = tagsToSave || tags
|
||||
|
||||
// Create definitions for tags that have display names
|
||||
const definitions: TagDefinitionInput[] = currentTags
|
||||
.filter((tag) => tag?.displayName?.trim())
|
||||
.map((tag) => ({
|
||||
tagSlot: tag.slot as TagSlot,
|
||||
displayName: tag.displayName.trim(),
|
||||
fieldType: tag.fieldType || 'text',
|
||||
}))
|
||||
|
||||
// Only save if we have valid definitions
|
||||
if (definitions.length > 0) {
|
||||
await saveTagDefinitions(definitions)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanupUnusedTags = async () => {
|
||||
if (!knowledgeBaseId || !documentId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions?action=cleanup`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Cleanup failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Cleanup result:', result)
|
||||
} catch (error) {
|
||||
console.error('Failed to cleanup unused tags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get available tag names that aren't already used in this document
|
||||
const availableTagNames = kbTagDefinitions
|
||||
.map((tag) => tag.displayName)
|
||||
.filter((tagName) => !tags.some((tag) => tag.displayName === tagName))
|
||||
|
||||
// Check if we can add more tags (KB has less than MAX_TAG_SLOTS tag definitions)
|
||||
const canAddMoreTags = kbTagDefinitions.length < MAX_TAG_SLOTS
|
||||
|
||||
const handleSuggestionClick = (tagName: string) => {
|
||||
setEditingTag({ index: -1, value: '', tagName, isNew: false })
|
||||
}
|
||||
|
||||
const handleCreateNewTag = async (tagName: string, value: string, fieldType = 'text') => {
|
||||
if (!tagName.trim() || !value.trim()) return
|
||||
|
||||
// Check if tag name already exists in current document
|
||||
const tagNameLower = tagName.trim().toLowerCase()
|
||||
const existingTag = tags.find((tag) => tag.displayName.toLowerCase() === tagNameLower)
|
||||
if (existingTag) {
|
||||
alert(`Tag "${tagName}" already exists. Please choose a different name.`)
|
||||
return
|
||||
}
|
||||
|
||||
const newTag: DocumentTag = {
|
||||
slot: getNextAvailableSlot(),
|
||||
displayName: tagName.trim(),
|
||||
fieldType: fieldType,
|
||||
value: value.trim(),
|
||||
}
|
||||
|
||||
const updatedTags = [...tags, newTag]
|
||||
|
||||
// SIMPLE ATOMIC OPERATION - NO CLEANUP
|
||||
try {
|
||||
// 1. Save tag definition first
|
||||
await handleSaveDefinitions(updatedTags)
|
||||
|
||||
// 2. Save document values
|
||||
if (onSave) {
|
||||
await onSave(updatedTags)
|
||||
}
|
||||
|
||||
// 3. Update UI
|
||||
onTagsChange(updatedTags)
|
||||
} catch (error) {
|
||||
console.error('Failed to save tag:', error)
|
||||
alert(`Failed to save tag "${tagName}". Please try again.`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateTag = async (index: number, newValue: string) => {
|
||||
if (!newValue.trim()) return
|
||||
|
||||
const updatedTags = tags.map((tag, i) =>
|
||||
i === index ? { ...tag, value: newValue.trim() } : tag
|
||||
)
|
||||
|
||||
// SIMPLE ATOMIC OPERATION - NO CLEANUP
|
||||
try {
|
||||
// 1. Save document values
|
||||
if (onSave) {
|
||||
await onSave(updatedTags)
|
||||
}
|
||||
// 2. Save tag definitions
|
||||
await handleSaveDefinitions(updatedTags)
|
||||
// 3. Update UI
|
||||
onTagsChange(updatedTags)
|
||||
} catch (error) {
|
||||
console.error('Failed to update tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
const updatedTags = tags.filter((_, i) => i !== index)
|
||||
|
||||
console.log('Removing tag, updated tags:', updatedTags)
|
||||
|
||||
// FULLY SYNCHRONOUS - DO NOT UPDATE UI UNTIL ALL OPERATIONS COMPLETE
|
||||
try {
|
||||
// 1. Save the document tag values
|
||||
console.log('Saving document values after tag removal...')
|
||||
if (onSave) {
|
||||
await onSave(updatedTags)
|
||||
}
|
||||
|
||||
// 2. Save the tag definitions
|
||||
console.log('Saving tag definitions after tag removal...')
|
||||
await handleSaveDefinitions(updatedTags)
|
||||
|
||||
// 3. Run cleanup to remove unused tag definitions
|
||||
console.log('Running cleanup to remove unused tag definitions...')
|
||||
await handleCleanupUnusedTags()
|
||||
|
||||
// 4. ONLY NOW update the UI
|
||||
onTagsChange(updatedTags)
|
||||
|
||||
// 5. Refresh tag definitions for dropdown
|
||||
await refreshTagDefinitions()
|
||||
} catch (error) {
|
||||
console.error('Failed to remove tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{/* Existing Tags as Chips */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={`${tag.slot}-${index}`}
|
||||
className='inline-flex cursor-pointer items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-sm transition-colors hover:bg-gray-200'
|
||||
onClick={() =>
|
||||
setEditingTag({ index, value: tag.value, tagName: tag.displayName, isNew: false })
|
||||
}
|
||||
>
|
||||
<span className='font-medium'>{tag.displayName}:</span>
|
||||
<span className='text-muted-foreground'>{tag.value}</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
disabled={disabled}
|
||||
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Tag Dropdown Selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={disabled || (!canAddMoreTags && availableTagNames.length === 0)}
|
||||
className='gap-1 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span>Add Tag</span>
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' className='w-48'>
|
||||
{/* Existing tag names */}
|
||||
{availableTagNames.length > 0 && (
|
||||
<>
|
||||
{availableTagNames.map((tagName) => {
|
||||
const tagDefinition = kbTagDefinitions.find((def) => def.displayName === tagName)
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={tagName}
|
||||
onClick={() => handleSuggestionClick(tagName)}
|
||||
className='flex items-center justify-between'
|
||||
>
|
||||
<span>{tagName}</span>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{tagDefinition?.fieldType || 'text'}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
<div className='my-1 h-px bg-border' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create new tag option or disabled message */}
|
||||
{canAddMoreTags ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingTag({ index: -1, value: '', tagName: '', isNew: true })
|
||||
}}
|
||||
className='flex items-center gap-2 text-blue-600'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
<span>Create new tag</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className='px-2 py-1.5 text-muted-foreground text-sm'>
|
||||
All {MAX_TAG_SLOTS} tag slots used in this knowledge base
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Edit Tag Value Modal */}
|
||||
{editingTag !== null && (
|
||||
<EditTagModal
|
||||
tagName={editingTag.tagName}
|
||||
initialValue={editingTag.value}
|
||||
isNew={editingTag.isNew}
|
||||
existingType={
|
||||
editingTag.isNew
|
||||
? undefined
|
||||
: kbTagDefinitions.find((t) => t.displayName === editingTag.tagName)?.fieldType
|
||||
}
|
||||
onSave={(value, type, newTagName) => {
|
||||
if (editingTag.index === -1) {
|
||||
// Creating new tag - use newTagName if provided, otherwise fall back to editingTag.tagName
|
||||
const tagName = newTagName || editingTag.tagName
|
||||
handleCreateNewTag(tagName, value, type)
|
||||
} else {
|
||||
// Updating existing tag
|
||||
handleUpdateTag(editingTag.index, value)
|
||||
}
|
||||
setEditingTag(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setEditingTag(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tag count display */}
|
||||
{kbTagDefinitions.length > 0 && (
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used in this knowledge base
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple modal for editing tag values
|
||||
interface EditTagModalProps {
|
||||
tagName: string
|
||||
initialValue: string
|
||||
isNew: boolean
|
||||
existingType?: string
|
||||
onSave: (value: string, type?: string, newTagName?: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function EditTagModal({
|
||||
tagName,
|
||||
initialValue,
|
||||
isNew,
|
||||
existingType,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: EditTagModalProps) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const [fieldType, setFieldType] = useState(existingType || 'text')
|
||||
const [newTagName, setNewTagName] = useState(tagName)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (value.trim() && (isNew ? newTagName.trim() : true)) {
|
||||
onSave(value.trim(), fieldType, isNew ? newTagName.trim() : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-50 flex items-center justify-center bg-black/50'>
|
||||
<div className='mx-4 w-96 max-w-sm rounded-lg bg-white p-4'>
|
||||
<div className='mb-3 flex items-start justify-between'>
|
||||
<h3 className='font-medium text-sm'>
|
||||
{isNew ? 'Create new tag' : `Edit "${tagName}" value`}
|
||||
</h3>
|
||||
{/* Type Badge in Top Right */}
|
||||
{!isNew && existingType && (
|
||||
<span className='rounded bg-gray-100 px-2 py-1 font-medium text-gray-500 text-xs'>
|
||||
{existingType.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className='space-y-3'>
|
||||
{/* Tag Name Input for New Tags */}
|
||||
{isNew && (
|
||||
<div>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Tag Name</Label>
|
||||
<Input
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
placeholder='Enter tag name'
|
||||
className='mt-1 text-sm'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type Selection for New Tags */}
|
||||
{isNew && (
|
||||
<div>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Type</Label>
|
||||
<Select value={fieldType} onValueChange={setFieldType}>
|
||||
<SelectTrigger className='mt-1 text-sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Value Input */}
|
||||
<div>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Value</Label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder='Enter tag value'
|
||||
className='mt-1 text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button type='button' variant='outline' size='sm' onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
size='sm'
|
||||
disabled={!value.trim() || (isNew && !newTagName.trim())}
|
||||
>
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,11 @@ import { Button } from '@/components/ui/button'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
|
||||
export interface TagData {
|
||||
tag1?: string
|
||||
tag2?: string
|
||||
tag3?: string
|
||||
tag4?: string
|
||||
tag5?: string
|
||||
tag6?: string
|
||||
tag7?: string
|
||||
export type TagData = {
|
||||
[K in TagSlot]?: string
|
||||
}
|
||||
|
||||
interface TagInputProps {
|
||||
@@ -22,22 +18,30 @@ interface TagInputProps {
|
||||
onTagsChange: (tags: TagData) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
knowledgeBaseId?: string | null
|
||||
documentId?: string | null
|
||||
}
|
||||
|
||||
const TAG_LABELS = [
|
||||
{ key: 'tag1' as keyof TagData, label: 'Tag 1', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag2' as keyof TagData, label: 'Tag 2', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag3' as keyof TagData, label: 'Tag 3', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag4' as keyof TagData, label: 'Tag 4', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag5' as keyof TagData, label: 'Tag 5', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag6' as keyof TagData, label: 'Tag 6', placeholder: 'Enter tag value' },
|
||||
{ key: 'tag7' as keyof TagData, label: 'Tag 7', placeholder: 'Enter tag value' },
|
||||
]
|
||||
const TAG_LABELS = TAG_SLOTS.map((slot, index) => ({
|
||||
key: slot as keyof TagData,
|
||||
label: `Tag ${index + 1}`,
|
||||
placeholder: 'Enter tag value',
|
||||
}))
|
||||
|
||||
export function TagInput({ tags, onTagsChange, disabled = false, className = '' }: TagInputProps) {
|
||||
export function TagInput({
|
||||
tags,
|
||||
onTagsChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
knowledgeBaseId = null,
|
||||
documentId = null,
|
||||
}: TagInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showAllTags, setShowAllTags] = useState(false)
|
||||
|
||||
// Use custom tag definitions if available
|
||||
const { getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const handleTagChange = (tagKey: keyof TagData, value: string) => {
|
||||
onTagsChange({
|
||||
...tags,
|
||||
@@ -53,7 +57,15 @@ export function TagInput({ tags, onTagsChange, disabled = false, className = ''
|
||||
}
|
||||
|
||||
const hasAnyTags = Object.values(tags).some((tag) => tag?.trim())
|
||||
const visibleTags = showAllTags ? TAG_LABELS : TAG_LABELS.slice(0, 2)
|
||||
|
||||
// Create tag labels using custom definitions or fallback to defaults
|
||||
const tagLabels = TAG_LABELS.map(({ key, placeholder }) => ({
|
||||
key,
|
||||
label: getTagLabel(key),
|
||||
placeholder,
|
||||
}))
|
||||
|
||||
const visibleTags = showAllTags ? tagLabels : tagLabels.slice(0, 2)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -153,7 +165,7 @@ export function TagInput({ tags, onTagsChange, disabled = false, className = ''
|
||||
<div className='flex flex-wrap gap-1'>
|
||||
{Object.entries(tags).map(([key, value]) => {
|
||||
if (!value?.trim()) return null
|
||||
const tagLabel = TAG_LABELS.find((t) => t.key === key)?.label || key
|
||||
const tagLabel = getTagLabel(key)
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
|
||||
@@ -274,14 +274,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||
const processPayload = {
|
||||
documents: uploadedFiles.map((file) => ({
|
||||
...file,
|
||||
// Extract tags from file if they exist (added by upload modal)
|
||||
tag1: (file as any).tag1,
|
||||
tag2: (file as any).tag2,
|
||||
tag3: (file as any).tag3,
|
||||
tag4: (file as any).tag4,
|
||||
tag5: (file as any).tag5,
|
||||
tag6: (file as any).tag6,
|
||||
tag7: (file as any).tag7,
|
||||
// Tags are already included in the file object from createUploadedFile
|
||||
})),
|
||||
processingOptions: {
|
||||
chunkSize: processingOptions.chunkSize || 1024,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface DocumentTag {
|
||||
id: string
|
||||
tagName: string // This will be mapped to displayName for API
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface DocumentTagEntryProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: any
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
export function DocumentTagEntry({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
isConnecting = false,
|
||||
}: DocumentTagEntryProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Parse the current value to extract tags
|
||||
const parseTags = (tagValue: string): DocumentTag[] => {
|
||||
if (!tagValue) return []
|
||||
try {
|
||||
return JSON.parse(tagValue)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const tags = parseTags(currentValue || '')
|
||||
|
||||
const updateTags = (newTags: DocumentTag[]) => {
|
||||
if (isPreview) return
|
||||
const value = newTags.length > 0 ? JSON.stringify(newTags) : null
|
||||
setStoreValue(value)
|
||||
}
|
||||
|
||||
const removeTag = (tagId: string) => {
|
||||
updateTags(tags.filter((t) => t.id !== tagId))
|
||||
}
|
||||
|
||||
const updateTag = (tagId: string, updates: Partial<DocumentTag>) => {
|
||||
updateTags(tags.map((tag) => (tag.id === tagId ? { ...tag, ...updates } : tag)))
|
||||
}
|
||||
|
||||
// Get available tag names that aren't already used
|
||||
const usedTagNames = new Set(tags.map((tag) => tag.tagName).filter(Boolean))
|
||||
const availableTagNames = tagDefinitions
|
||||
.map((def) => def.displayName)
|
||||
.filter((name) => !usedTagNames.has(name))
|
||||
|
||||
if (isLoading) {
|
||||
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* Available Tags Section */}
|
||||
{availableTagNames.length > 0 && (
|
||||
<div>
|
||||
<div className='mb-2 font-medium text-muted-foreground text-sm'>
|
||||
Available Tags (click to add)
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{availableTagNames.map((tagName) => {
|
||||
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
|
||||
return (
|
||||
<button
|
||||
key={tagName}
|
||||
onClick={() => {
|
||||
// Check for duplicates before adding
|
||||
if (!usedTagNames.has(tagName)) {
|
||||
const newTag: DocumentTag = {
|
||||
id: Date.now().toString(),
|
||||
tagName,
|
||||
fieldType: tagDef?.fieldType || 'text',
|
||||
value: '',
|
||||
}
|
||||
updateTags([...tags, newTag])
|
||||
}
|
||||
}}
|
||||
disabled={disabled || isConnecting}
|
||||
className='inline-flex items-center gap-1 rounded-full border border-gray-300 border-dashed bg-gray-50 px-3 py-1 text-gray-600 text-sm transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700 disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-3 w-3' />
|
||||
{tagName}
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
({tagDef?.fieldType || 'text'})
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Tags Section */}
|
||||
{tags.length > 0 && (
|
||||
<div>
|
||||
<div className='space-y-2'>
|
||||
{tags.map((tag) => (
|
||||
<div key={tag.id} className='flex items-center gap-2 rounded-lg border bg-white p-3'>
|
||||
{/* Tag Name */}
|
||||
<div className='flex-1'>
|
||||
<div className='font-medium text-gray-900 text-sm'>
|
||||
{tag.tagName || 'Unnamed Tag'}
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>{tag.fieldType}</div>
|
||||
</div>
|
||||
|
||||
{/* Value Input */}
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
value={tag.value}
|
||||
onChange={(e) => updateTag(tag.id, { value: e.target.value })}
|
||||
placeholder='Value'
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-9 placeholder:text-xs'
|
||||
type={tag.fieldType === 'number' ? 'number' : 'text'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<Button
|
||||
onClick={() => removeTag(tag.id)}
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-9 w-9 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create New Tag Section */}
|
||||
<div>
|
||||
<div className='mb-2 font-medium text-muted-foreground text-sm'>Create New Tag</div>
|
||||
<div className='flex items-center gap-2 rounded-lg border border-gray-300 border-dashed bg-gray-50 p-3'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder={tagDefinitions.length >= MAX_TAG_SLOTS ? '' : 'Tag name'}
|
||||
disabled={disabled || isConnecting || tagDefinitions.length >= MAX_TAG_SLOTS}
|
||||
className='h-9 border-0 bg-transparent p-0 placeholder:text-xs focus-visible:ring-0'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
|
||||
const tagName = e.currentTarget.value.trim()
|
||||
|
||||
// Check for duplicates
|
||||
if (usedTagNames.has(tagName)) {
|
||||
// Visual feedback for duplicate - could add toast notification here
|
||||
e.currentTarget.style.borderColor = '#ef4444'
|
||||
setTimeout(() => {
|
||||
e.currentTarget.style.borderColor = ''
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
|
||||
const newTag: DocumentTag = {
|
||||
id: Date.now().toString(),
|
||||
tagName,
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
}
|
||||
updateTags([...tags, newTag])
|
||||
e.currentTarget.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
{tagDefinitions.length >= MAX_TAG_SLOTS
|
||||
? `All ${MAX_TAG_SLOTS} tag slots used in this knowledge base`
|
||||
: usedTagNames.size > 0
|
||||
? 'Press Enter (no duplicates)'
|
||||
: 'Press Enter to add'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{tags.length === 0 && availableTagNames.length === 0 && (
|
||||
<div className='py-8 text-center text-muted-foreground'>
|
||||
<div className='text-sm'>No tags available</div>
|
||||
<div className='text-xs'>Create a new tag above to get started</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface KnowledgeTagFilterProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
export function KnowledgeTagFilter({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
isConnecting = false,
|
||||
}: KnowledgeTagFilterProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID and document ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseIds')
|
||||
const [knowledgeBaseIdSingleValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const [documentIdValue] = useSubBlockValue(blockId, 'documentId')
|
||||
|
||||
// Determine which knowledge base ID to use
|
||||
const knowledgeBaseId =
|
||||
knowledgeBaseIdSingleValue ||
|
||||
(typeof knowledgeBaseIdValue === 'string' ? knowledgeBaseIdValue.split(',')[0] : null)
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading, getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Parse the current value to extract tag name and value
|
||||
const parseTagFilter = (filterValue: string) => {
|
||||
if (!filterValue) return { tagName: '', tagValue: '' }
|
||||
const [tagName, ...valueParts] = filterValue.split(':')
|
||||
return { tagName: tagName?.trim() || '', tagValue: valueParts.join(':').trim() || '' }
|
||||
}
|
||||
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const { tagName, tagValue } = parseTagFilter(currentValue || '')
|
||||
|
||||
const handleTagNameChange = (newTagName: string) => {
|
||||
if (isPreview) return
|
||||
const newValue =
|
||||
newTagName && tagValue ? `${newTagName}:${tagValue}` : newTagName || tagValue || ''
|
||||
setStoreValue(newValue.trim() || null)
|
||||
}
|
||||
|
||||
const handleTagValueChange = (newTagValue: string) => {
|
||||
if (isPreview) return
|
||||
const newValue =
|
||||
tagName && newTagValue ? `${tagName}:${newTagValue}` : tagName || newTagValue || ''
|
||||
setStoreValue(newValue.trim() || null)
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Tag Filter</Label>
|
||||
<Input
|
||||
value={currentValue || ''}
|
||||
disabled
|
||||
placeholder='Tag filter preview'
|
||||
className='text-sm'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{/* Tag Name Selector */}
|
||||
<Select
|
||||
value={tagName}
|
||||
onValueChange={handleTagNameChange}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
>
|
||||
<SelectTrigger className='text-sm'>
|
||||
<SelectValue placeholder='Select tag' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.displayName}>
|
||||
{tag.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Tag Value Input - only show if tag is selected */}
|
||||
{tagName && (
|
||||
<Input
|
||||
value={tagValue}
|
||||
onChange={(e) => handleTagValueChange(e.target.value)}
|
||||
placeholder={`Enter ${tagName} value`}
|
||||
disabled={disabled || isConnecting}
|
||||
className='text-sm'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
||||
|
||||
interface TagFilter {
|
||||
id: string
|
||||
tagName: string
|
||||
tagValue: string
|
||||
}
|
||||
|
||||
interface KnowledgeTagFiltersProps {
|
||||
blockId: string
|
||||
subBlock: SubBlockConfig
|
||||
disabled?: boolean
|
||||
isPreview?: boolean
|
||||
previewValue?: string | null
|
||||
isConnecting?: boolean
|
||||
}
|
||||
|
||||
export function KnowledgeTagFilters({
|
||||
blockId,
|
||||
subBlock,
|
||||
disabled = false,
|
||||
isPreview = false,
|
||||
previewValue,
|
||||
isConnecting = false,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Parse the current value to extract filters
|
||||
const parseFilters = (filterValue: string): TagFilter[] => {
|
||||
if (!filterValue) return []
|
||||
try {
|
||||
return JSON.parse(filterValue)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const filters = parseFilters(currentValue || '')
|
||||
|
||||
const updateFilters = (newFilters: TagFilter[]) => {
|
||||
if (isPreview) return
|
||||
const value = newFilters.length > 0 ? JSON.stringify(newFilters) : null
|
||||
setStoreValue(value)
|
||||
}
|
||||
|
||||
const addFilter = () => {
|
||||
const newFilter: TagFilter = {
|
||||
id: Date.now().toString(),
|
||||
tagName: '',
|
||||
tagValue: '',
|
||||
}
|
||||
updateFilters([...filters, newFilter])
|
||||
}
|
||||
|
||||
const removeFilter = (filterId: string) => {
|
||||
updateFilters(filters.filter((f) => f.id !== filterId))
|
||||
}
|
||||
|
||||
const updateFilter = (filterId: string, field: keyof TagFilter, value: string) => {
|
||||
updateFilters(filters.map((f) => (f.id === filterId ? { ...f, [field]: value } : f)))
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<Label className='font-medium text-muted-foreground text-xs'>Tag Filters</Label>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
{filters.length > 0 ? `${filters.length} filter(s)` : 'No filters'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={addFilter}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
className='h-6 px-2 text-xs'
|
||||
>
|
||||
<Plus className='mr-1 h-3 w-3' />
|
||||
Add Filter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{filters.length === 0 && (
|
||||
<div className='py-4 text-center text-muted-foreground text-sm'>
|
||||
No tag filters. Click "Add Filter" to add one.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className='flex items-center gap-2 rounded-md border p-2'>
|
||||
{/* Tag Name Selector */}
|
||||
<div className='flex-1'>
|
||||
<Select
|
||||
value={filter.tagName}
|
||||
onValueChange={(value) => updateFilter(filter.id, 'tagName', value)}
|
||||
disabled={disabled || isConnecting || isLoading}
|
||||
>
|
||||
<SelectTrigger className='h-8 text-sm'>
|
||||
<SelectValue placeholder='Select tag' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tagDefinitions.map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.displayName}>
|
||||
{tag.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Tag Value Input */}
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
value={filter.tagValue}
|
||||
onChange={(e) => updateFilter(filter.id, 'tagValue', e.target.value)}
|
||||
placeholder={filter.tagName ? `Enter ${filter.tagName} value` : 'Enter value'}
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-8 text-sm'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remove Button */}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => removeFilter(filter.id)}
|
||||
disabled={disabled || isConnecting}
|
||||
className='h-8 w-8 p-0 text-muted-foreground hover:text-destructive'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
import { getBlock } from '@/blocks/index'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { DocumentTagEntry } from './components/document-tag-entry/document-tag-entry'
|
||||
import { KnowledgeTagFilter } from './components/knowledge-tag-filter/knowledge-tag-filter'
|
||||
import { KnowledgeTagFilters } from './components/knowledge-tag-filters/knowledge-tag-filters'
|
||||
|
||||
interface SubBlockProps {
|
||||
blockId: string
|
||||
@@ -353,6 +356,40 @@ export function SubBlock({
|
||||
previewValue={previewValue}
|
||||
/>
|
||||
)
|
||||
case 'knowledge-tag-filter':
|
||||
return (
|
||||
<KnowledgeTagFilter
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
)
|
||||
case 'knowledge-tag-filters':
|
||||
return (
|
||||
<KnowledgeTagFilters
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'document-tag-entry':
|
||||
return (
|
||||
<DocumentTagEntry
|
||||
blockId={blockId}
|
||||
subBlock={config}
|
||||
disabled={isDisabled}
|
||||
isPreview={isPreview}
|
||||
previewValue={previewValue}
|
||||
isConnecting={isConnecting}
|
||||
/>
|
||||
)
|
||||
case 'document-selector':
|
||||
return (
|
||||
<DocumentSelector
|
||||
|
||||
@@ -28,8 +28,8 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
},
|
||||
params: (params) => {
|
||||
// Validate required fields for each operation
|
||||
if (params.operation === 'search' && !params.knowledgeBaseIds) {
|
||||
throw new Error('Knowledge base IDs are required for search operation')
|
||||
if (params.operation === 'search' && !params.knowledgeBaseId) {
|
||||
throw new Error('Knowledge base ID is required for search operation')
|
||||
}
|
||||
if (
|
||||
(params.operation === 'upload_chunk' || params.operation === 'create_document') &&
|
||||
@@ -49,21 +49,16 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
},
|
||||
inputs: {
|
||||
operation: { type: 'string', required: true },
|
||||
knowledgeBaseIds: { type: 'string', required: false },
|
||||
knowledgeBaseId: { type: 'string', required: false },
|
||||
query: { type: 'string', required: false },
|
||||
topK: { type: 'number', required: false },
|
||||
documentId: { type: 'string', required: false },
|
||||
content: { type: 'string', required: false },
|
||||
name: { type: 'string', required: false },
|
||||
// Tag filters for search
|
||||
tag1: { type: 'string', required: false },
|
||||
tag2: { type: 'string', required: false },
|
||||
tag3: { type: 'string', required: false },
|
||||
tag4: { type: 'string', required: false },
|
||||
tag5: { type: 'string', required: false },
|
||||
tag6: { type: 'string', required: false },
|
||||
tag7: { type: 'string', required: false },
|
||||
// Dynamic tag filters for search
|
||||
tagFilters: { type: 'string', required: false },
|
||||
// Document tags for create document (JSON string of tag objects)
|
||||
documentTags: { type: 'string', required: false },
|
||||
},
|
||||
outputs: {
|
||||
results: 'json',
|
||||
@@ -83,15 +78,6 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
],
|
||||
value: () => 'search',
|
||||
},
|
||||
{
|
||||
id: 'knowledgeBaseIds',
|
||||
title: 'Knowledge Bases',
|
||||
type: 'knowledge-base-selector',
|
||||
layout: 'full',
|
||||
placeholder: 'Select knowledge bases',
|
||||
multiSelect: true,
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'knowledgeBaseId',
|
||||
title: 'Knowledge Base',
|
||||
@@ -99,7 +85,7 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
layout: 'full',
|
||||
placeholder: 'Select knowledge base',
|
||||
multiSelect: false,
|
||||
condition: { field: 'operation', value: ['upload_chunk', 'create_document'] },
|
||||
condition: { field: 'operation', value: ['search', 'upload_chunk', 'create_document'] },
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
@@ -118,65 +104,11 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
},
|
||||
{
|
||||
id: 'tag1',
|
||||
title: 'Tag 1 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 1',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag2',
|
||||
title: 'Tag 2 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 2',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag3',
|
||||
title: 'Tag 3 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 3',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag4',
|
||||
title: 'Tag 4 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 4',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag5',
|
||||
title: 'Tag 5 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 5',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag6',
|
||||
title: 'Tag 6 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 6',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag7',
|
||||
title: 'Tag 7 Filter',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Filter by tag 7',
|
||||
id: 'tagFilters',
|
||||
title: 'Tag Filters',
|
||||
type: 'knowledge-tag-filters',
|
||||
layout: 'full',
|
||||
placeholder: 'Add tag filters',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
@@ -214,69 +146,13 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
rows: 6,
|
||||
condition: { field: 'operation', value: ['create_document'] },
|
||||
},
|
||||
// Tag inputs for Create Document (in advanced mode)
|
||||
// Dynamic tag entry for Create Document
|
||||
{
|
||||
id: 'tag1',
|
||||
title: 'Tag 1',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 1 value',
|
||||
id: 'documentTags',
|
||||
title: 'Document Tags',
|
||||
type: 'document-tag-entry',
|
||||
layout: 'full',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag2',
|
||||
title: 'Tag 2',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 2 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag3',
|
||||
title: 'Tag 3',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 3 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag4',
|
||||
title: 'Tag 4',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 4 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag5',
|
||||
title: 'Tag 5',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 5 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag6',
|
||||
title: 'Tag 6',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 6 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'tag7',
|
||||
title: 'Tag 7',
|
||||
type: 'short-input',
|
||||
layout: 'half',
|
||||
placeholder: 'Enter tag 7 value',
|
||||
condition: { field: 'operation', value: 'create_document' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -33,7 +33,10 @@ export type SubBlockType =
|
||||
| 'channel-selector' // Channel selector for Slack, Discord, etc.
|
||||
| 'folder-selector' // Folder selector for Gmail, etc.
|
||||
| 'knowledge-base-selector' // Knowledge base selector
|
||||
| 'knowledge-tag-filter' // Dynamic tag filter for knowledge bases
|
||||
| 'knowledge-tag-filters' // Multiple tag filters for knowledge bases
|
||||
| 'document-selector' // Document selector for knowledge bases
|
||||
| 'document-tag-entry' // Document tag entry for creating documents
|
||||
| 'input-format' // Input structure format
|
||||
| 'response-format' // Response structure format
|
||||
| 'file-upload' // File uploader
|
||||
|
||||
13
apps/sim/db/migrations/0063_lame_sandman.sql
Normal file
13
apps/sim/db/migrations/0063_lame_sandman.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "knowledge_base_tag_definitions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"knowledge_base_id" text NOT NULL,
|
||||
"tag_slot" text NOT NULL,
|
||||
"display_name" text NOT NULL,
|
||||
"field_type" text DEFAULT 'text' NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "knowledge_base_tag_definitions" ADD CONSTRAINT "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk" FOREIGN KEY ("knowledge_base_id") REFERENCES "public"."knowledge_base"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "kb_tag_definitions_kb_slot_idx" ON "knowledge_base_tag_definitions" USING btree ("knowledge_base_id","tag_slot");--> statement-breakpoint
|
||||
CREATE INDEX "kb_tag_definitions_kb_id_idx" ON "knowledge_base_tag_definitions" USING btree ("knowledge_base_id");
|
||||
5634
apps/sim/db/migrations/meta/0063_snapshot.json
Normal file
5634
apps/sim/db/migrations/meta/0063_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -435,6 +435,13 @@
|
||||
"when": 1753383446084,
|
||||
"tag": "0062_previous_phantom_reporter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 63,
|
||||
"version": "7",
|
||||
"when": 1753558819517,
|
||||
"tag": "0063_lame_sandman",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
uuid,
|
||||
vector,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
import { TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
|
||||
// Custom tsvector type for full-text search
|
||||
export const tsvector = customType<{
|
||||
@@ -794,6 +795,32 @@ export const document = pgTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const knowledgeBaseTagDefinitions = pgTable(
|
||||
'knowledge_base_tag_definitions',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
knowledgeBaseId: text('knowledge_base_id')
|
||||
.notNull()
|
||||
.references(() => knowledgeBase.id, { onDelete: 'cascade' }),
|
||||
tagSlot: text('tag_slot', {
|
||||
enum: TAG_SLOTS,
|
||||
}).notNull(),
|
||||
displayName: text('display_name').notNull(),
|
||||
fieldType: text('field_type').notNull().default('text'), // 'text', future: 'date', 'number', 'range'
|
||||
createdAt: timestamp('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Ensure unique tag slot per knowledge base
|
||||
kbTagSlotIdx: uniqueIndex('kb_tag_definitions_kb_slot_idx').on(
|
||||
table.knowledgeBaseId,
|
||||
table.tagSlot
|
||||
),
|
||||
// Index for querying by knowledge base
|
||||
kbIdIdx: index('kb_tag_definitions_kb_id_idx').on(table.knowledgeBaseId),
|
||||
})
|
||||
)
|
||||
|
||||
export const embedding = pgTable(
|
||||
'embedding',
|
||||
{
|
||||
|
||||
88
apps/sim/hooks/use-knowledge-base-tag-definitions.ts
Normal file
88
apps/sim/hooks/use-knowledge-base-tag-definitions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { TagSlot } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useKnowledgeBaseTagDefinitions')
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string
|
||||
tagSlot: TagSlot
|
||||
displayName: string
|
||||
fieldType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching KB-scoped tag definitions (for filtering/selection)
|
||||
* @param knowledgeBaseId - The knowledge base ID
|
||||
*/
|
||||
export function useKnowledgeBaseTagDefinitions(knowledgeBaseId: string | null) {
|
||||
const [tagDefinitions, setTagDefinitions] = useState<TagDefinition[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTagDefinitions = useCallback(async () => {
|
||||
if (!knowledgeBaseId) {
|
||||
setTagDefinitions([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tag definitions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setTagDefinitions(data.data)
|
||||
} else {
|
||||
throw new Error('Invalid response format')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
logger.error('Error fetching tag definitions:', err)
|
||||
setError(errorMessage)
|
||||
setTagDefinitions([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
const getTagLabel = useCallback(
|
||||
(tagSlot: string): string => {
|
||||
const definition = tagDefinitions.find((def) => def.tagSlot === tagSlot)
|
||||
return definition?.displayName || tagSlot
|
||||
},
|
||||
[tagDefinitions]
|
||||
)
|
||||
|
||||
const getTagDefinition = useCallback(
|
||||
(tagSlot: string): TagDefinition | undefined => {
|
||||
return tagDefinitions.find((def) => def.tagSlot === tagSlot)
|
||||
},
|
||||
[tagDefinitions]
|
||||
)
|
||||
|
||||
// Auto-fetch on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
fetchTagDefinitions()
|
||||
}, [fetchTagDefinitions])
|
||||
|
||||
return {
|
||||
tagDefinitions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchTagDefinitions,
|
||||
getTagLabel,
|
||||
getTagDefinition,
|
||||
}
|
||||
}
|
||||
172
apps/sim/hooks/use-tag-definitions.ts
Normal file
172
apps/sim/hooks/use-tag-definitions.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { TagSlot } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useTagDefinitions')
|
||||
|
||||
export interface TagDefinition {
|
||||
id: string
|
||||
tagSlot: TagSlot
|
||||
displayName: string
|
||||
fieldType: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TagDefinitionInput {
|
||||
tagSlot: TagSlot
|
||||
displayName: string
|
||||
fieldType: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing KB-scoped tag definitions
|
||||
* @param knowledgeBaseId - The knowledge base ID
|
||||
* @param documentId - The document ID (required for API calls)
|
||||
*/
|
||||
export function useTagDefinitions(
|
||||
knowledgeBaseId: string | null,
|
||||
documentId: string | null = null
|
||||
) {
|
||||
const [tagDefinitions, setTagDefinitions] = useState<TagDefinition[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchTagDefinitions = useCallback(async () => {
|
||||
if (!knowledgeBaseId || !documentId) {
|
||||
setTagDefinitions([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tag definitions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && Array.isArray(data.data)) {
|
||||
setTagDefinitions(data.data)
|
||||
} else {
|
||||
throw new Error('Invalid response format')
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred'
|
||||
logger.error('Error fetching tag definitions:', err)
|
||||
setError(errorMessage)
|
||||
setTagDefinitions([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [knowledgeBaseId, documentId])
|
||||
|
||||
const saveTagDefinitions = useCallback(
|
||||
async (definitions: TagDefinitionInput[]) => {
|
||||
if (!knowledgeBaseId || !documentId) {
|
||||
throw new Error('Knowledge base ID and document ID are required')
|
||||
}
|
||||
|
||||
// Simple validation
|
||||
const validDefinitions = (definitions || []).filter(
|
||||
(def) => def?.tagSlot && def.displayName && def.displayName.trim()
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ definitions: validDefinitions }),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save tag definitions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to save tag definitions')
|
||||
}
|
||||
|
||||
// Refresh the definitions after saving
|
||||
await fetchTagDefinitions()
|
||||
|
||||
return data.data
|
||||
} catch (err) {
|
||||
logger.error('Error saving tag definitions:', err)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[knowledgeBaseId, documentId, fetchTagDefinitions]
|
||||
)
|
||||
|
||||
const deleteTagDefinitions = useCallback(async () => {
|
||||
if (!knowledgeBaseId || !documentId) {
|
||||
throw new Error('Knowledge base ID and document ID are required')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/tag-definitions`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete tag definitions: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Refresh the definitions after deleting
|
||||
await fetchTagDefinitions()
|
||||
} catch (err) {
|
||||
logger.error('Error deleting tag definitions:', err)
|
||||
throw err
|
||||
}
|
||||
}, [knowledgeBaseId, documentId, fetchTagDefinitions])
|
||||
|
||||
const getTagLabel = useCallback(
|
||||
(tagSlot: string): string => {
|
||||
const definition = tagDefinitions.find((def) => def.tagSlot === tagSlot)
|
||||
return definition?.displayName || tagSlot
|
||||
},
|
||||
[tagDefinitions]
|
||||
)
|
||||
|
||||
const getTagDefinition = useCallback(
|
||||
(tagSlot: string): TagDefinition | undefined => {
|
||||
return tagDefinitions.find((def) => def.tagSlot === tagSlot)
|
||||
},
|
||||
[tagDefinitions]
|
||||
)
|
||||
|
||||
// Auto-fetch on mount and when dependencies change
|
||||
useEffect(() => {
|
||||
fetchTagDefinitions()
|
||||
}, [fetchTagDefinitions])
|
||||
|
||||
return {
|
||||
tagDefinitions,
|
||||
isLoading,
|
||||
error,
|
||||
fetchTagDefinitions,
|
||||
saveTagDefinitions,
|
||||
deleteTagDefinitions,
|
||||
getTagLabel,
|
||||
getTagDefinition,
|
||||
}
|
||||
}
|
||||
15
apps/sim/lib/constants/knowledge.ts
Normal file
15
apps/sim/lib/constants/knowledge.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Knowledge base and document constants
|
||||
*/
|
||||
|
||||
// Maximum number of tag slots allowed per knowledge base
|
||||
export const MAX_TAG_SLOTS = 7
|
||||
|
||||
// Tag slot names (derived from MAX_TAG_SLOTS)
|
||||
export const TAG_SLOTS = Array.from({ length: MAX_TAG_SLOTS }, (_, i) => `tag${i + 1}`) as [
|
||||
string,
|
||||
...string[],
|
||||
]
|
||||
|
||||
// Type for tag slot names
|
||||
export type TagSlot = (typeof TAG_SLOTS)[number]
|
||||
@@ -57,6 +57,11 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen
|
||||
required: false,
|
||||
description: 'Tag 7 value for the document',
|
||||
},
|
||||
documentTagsData: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
description: 'Structured tag data with names, types, and values',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
url: (params) => `/api/knowledge/${params.knowledgeBaseId}/documents`,
|
||||
@@ -95,20 +100,32 @@ export const knowledgeCreateDocumentTool: ToolConfig<any, KnowledgeCreateDocumen
|
||||
|
||||
const dataUri = `data:text/plain;base64,${base64Content}`
|
||||
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
if (params.documentTags) {
|
||||
let parsedTags = params.documentTags
|
||||
|
||||
// Handle both string (JSON) and array formats
|
||||
if (typeof params.documentTags === 'string') {
|
||||
try {
|
||||
parsedTags = JSON.parse(params.documentTags)
|
||||
} catch (error) {
|
||||
parsedTags = []
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(parsedTags)) {
|
||||
tagData.documentTagsData = JSON.stringify(parsedTags)
|
||||
}
|
||||
}
|
||||
|
||||
const documents = [
|
||||
{
|
||||
filename: documentName.endsWith('.txt') ? documentName : `${documentName}.txt`,
|
||||
fileUrl: dataUri,
|
||||
fileSize: contentBytes,
|
||||
mimeType: 'text/plain',
|
||||
// Include tags if provided
|
||||
tag1: params.tag1 || undefined,
|
||||
tag2: params.tag2 || undefined,
|
||||
tag3: params.tag3 || undefined,
|
||||
tag4: params.tag4 || undefined,
|
||||
tag5: params.tag5 || undefined,
|
||||
tag6: params.tag6 || undefined,
|
||||
tag7: params.tag7 || undefined,
|
||||
...tagData,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import type { ToolConfig } from '@/tools/types'
|
||||
export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
id: 'knowledge_search',
|
||||
name: 'Knowledge Search',
|
||||
description: 'Search for similar content in one or more knowledge bases using vector similarity',
|
||||
description: 'Search for similar content in a knowledge base using vector similarity',
|
||||
version: '1.0.0',
|
||||
params: {
|
||||
knowledgeBaseIds: {
|
||||
knowledgeBaseId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description:
|
||||
'ID of the knowledge base to search in, or comma-separated IDs for multiple knowledge bases',
|
||||
description: 'ID of the knowledge base to search in',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
@@ -23,40 +22,10 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
required: false,
|
||||
description: 'Number of most similar results to return (1-100)',
|
||||
},
|
||||
tag1: {
|
||||
type: 'string',
|
||||
tagFilters: {
|
||||
type: 'any',
|
||||
required: false,
|
||||
description: 'Filter by tag 1 value',
|
||||
},
|
||||
tag2: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 2 value',
|
||||
},
|
||||
tag3: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 3 value',
|
||||
},
|
||||
tag4: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 4 value',
|
||||
},
|
||||
tag5: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 5 value',
|
||||
},
|
||||
tag6: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 6 value',
|
||||
},
|
||||
tag7: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: 'Filter by tag 7 value',
|
||||
description: 'Array of tag filters with tagName and tagValue properties',
|
||||
},
|
||||
},
|
||||
request: {
|
||||
@@ -68,25 +37,41 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
||||
body: (params) => {
|
||||
const workflowId = params._context?.workflowId
|
||||
|
||||
// Handle multiple knowledge base IDs
|
||||
let knowledgeBaseIds = params.knowledgeBaseIds
|
||||
if (typeof knowledgeBaseIds === 'string' && knowledgeBaseIds.includes(',')) {
|
||||
// Split comma-separated string into array
|
||||
knowledgeBaseIds = knowledgeBaseIds
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id.length > 0)
|
||||
}
|
||||
// Use single knowledge base ID
|
||||
const knowledgeBaseIds = [params.knowledgeBaseId]
|
||||
|
||||
// Build filters object from tag parameters
|
||||
// Parse dynamic tag filters and send display names to API
|
||||
const filters: Record<string, string> = {}
|
||||
if (params.tag1) filters.tag1 = params.tag1.toString()
|
||||
if (params.tag2) filters.tag2 = params.tag2.toString()
|
||||
if (params.tag3) filters.tag3 = params.tag3.toString()
|
||||
if (params.tag4) filters.tag4 = params.tag4.toString()
|
||||
if (params.tag5) filters.tag5 = params.tag5.toString()
|
||||
if (params.tag6) filters.tag6 = params.tag6.toString()
|
||||
if (params.tag7) filters.tag7 = params.tag7.toString()
|
||||
if (params.tagFilters) {
|
||||
let tagFilters = params.tagFilters
|
||||
|
||||
// Handle both string (JSON) and array formats
|
||||
if (typeof tagFilters === 'string') {
|
||||
try {
|
||||
tagFilters = JSON.parse(tagFilters)
|
||||
} catch (error) {
|
||||
tagFilters = []
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(tagFilters)) {
|
||||
// Group filters by tag name for OR logic within same tag
|
||||
const groupedFilters: Record<string, string[]> = {}
|
||||
tagFilters.forEach((filter: any) => {
|
||||
if (filter.tagName && filter.tagValue) {
|
||||
if (!groupedFilters[filter.tagName]) {
|
||||
groupedFilters[filter.tagName] = []
|
||||
}
|
||||
groupedFilters[filter.tagName].push(filter.tagValue)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to filters format - for now, join multiple values with OR separator
|
||||
Object.entries(groupedFilters).forEach(([tagName, values]) => {
|
||||
filters[tagName] = values.join('|OR|') // Use special separator for OR logic
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
knowledgeBaseIds,
|
||||
|
||||
Reference in New Issue
Block a user