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:
Vikhyath Mondreti
2025-07-28 18:43:52 -07:00
committed by GitHub
parent cb17691c01
commit 5b1f948686
27 changed files with 8020 additions and 403 deletions

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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],

View File

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

View File

@@ -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'>

View File

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

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
},
],
}

View File

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

View 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");

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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',
{

View 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,
}
}

View 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,
}
}

View 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]

View File

@@ -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,
},
]

View File

@@ -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,