mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(kb-tag-slots): finding next slot, create versus edit differentiation (#882)
* fix(kb-tag-slots): finding next slot, create versus edit differentiation * remove unused test file * fix lint
This commit is contained in:
committed by
GitHub
parent
6ec5cf46e2
commit
be65bf795f
@@ -3,7 +3,11 @@ 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 {
|
||||
getMaxSlotsForFieldType,
|
||||
getSlotsForFieldType,
|
||||
SUPPORTED_FIELD_TYPES,
|
||||
} from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
@@ -14,17 +18,60 @@ export const dynamic = 'force-dynamic'
|
||||
const logger = createLogger('DocumentTagDefinitionsAPI')
|
||||
|
||||
const TagDefinitionSchema = z.object({
|
||||
tagSlot: z.enum(TAG_SLOTS as [string, ...string[]]),
|
||||
tagSlot: z.string(), // Will be validated against field type slots
|
||||
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'
|
||||
fieldType: z.enum(SUPPORTED_FIELD_TYPES as [string, ...string[]]).default('text'),
|
||||
// Optional: for editing existing definitions
|
||||
_originalDisplayName: z.string().optional(),
|
||||
})
|
||||
|
||||
const BulkTagDefinitionsSchema = z.object({
|
||||
definitions: z
|
||||
.array(TagDefinitionSchema)
|
||||
.max(MAX_TAG_SLOTS, `Cannot define more than ${MAX_TAG_SLOTS} tags`),
|
||||
definitions: z.array(TagDefinitionSchema),
|
||||
})
|
||||
|
||||
// Helper function to get the next available slot for a knowledge base and field type
|
||||
async function getNextAvailableSlot(
|
||||
knowledgeBaseId: string,
|
||||
fieldType: string,
|
||||
existingBySlot?: Map<string, any>
|
||||
): Promise<string | null> {
|
||||
// Get available slots for this field type
|
||||
const availableSlots = getSlotsForFieldType(fieldType)
|
||||
let usedSlots: Set<string>
|
||||
|
||||
if (existingBySlot) {
|
||||
// Use provided map if available (for performance in batch operations)
|
||||
// Filter by field type
|
||||
usedSlots = new Set(
|
||||
Array.from(existingBySlot.entries())
|
||||
.filter(([_, def]) => def.fieldType === fieldType)
|
||||
.map(([slot, _]) => slot)
|
||||
)
|
||||
} else {
|
||||
// Query database for existing tag definitions of the same field type
|
||||
const existingDefinitions = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(knowledgeBaseTagDefinitions.fieldType, fieldType)
|
||||
)
|
||||
)
|
||||
|
||||
usedSlots = new Set(existingDefinitions.map((def) => def.tagSlot))
|
||||
}
|
||||
|
||||
// Find the first available slot for this field type
|
||||
for (const slot of availableSlots) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
|
||||
return null // No available slots for this field type
|
||||
}
|
||||
|
||||
// Helper function to clean up unused tag definitions
|
||||
async function cleanupUnusedTagDefinitions(knowledgeBaseId: string, requestId: string) {
|
||||
try {
|
||||
@@ -191,35 +238,93 @@ export async function POST(
|
||||
|
||||
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 })
|
||||
// Validate slots are valid for their field types
|
||||
for (const definition of validatedData.definitions) {
|
||||
const validSlots = getSlotsForFieldType(definition.fieldType)
|
||||
if (validSlots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported field type: ${definition.fieldType}` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!validSlots.includes(definition.tagSlot)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Invalid slot '${definition.tagSlot}' for field type '${definition.fieldType}'. Valid slots: ${validSlots.join(', ')}`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate no duplicate tag slots within the same field type
|
||||
const slotsByFieldType = new Map<string, Set<string>>()
|
||||
for (const definition of validatedData.definitions) {
|
||||
if (!slotsByFieldType.has(definition.fieldType)) {
|
||||
slotsByFieldType.set(definition.fieldType, new Set())
|
||||
}
|
||||
const slotsForType = slotsByFieldType.get(definition.fieldType)!
|
||||
if (slotsForType.has(definition.tagSlot)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Duplicate slot '${definition.tagSlot}' for field type '${definition.fieldType}'`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
slotsForType.add(definition.tagSlot)
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const createdDefinitions: (typeof knowledgeBaseTagDefinitions.$inferSelect)[] = []
|
||||
|
||||
// Get existing definitions count before transaction for cleanup check
|
||||
// Get existing definitions
|
||||
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)
|
||||
)
|
||||
// Group by field type for validation
|
||||
const existingByFieldType = new Map<string, number>()
|
||||
for (const def of existingDefinitions) {
|
||||
existingByFieldType.set(def.fieldType, (existingByFieldType.get(def.fieldType) || 0) + 1)
|
||||
}
|
||||
|
||||
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 }
|
||||
// Validate we don't exceed limits per field type
|
||||
const newByFieldType = new Map<string, number>()
|
||||
for (const definition of validatedData.definitions) {
|
||||
// Skip validation for edit operations - they don't create new slots
|
||||
if (definition._originalDisplayName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existingTagNames = new Set(
|
||||
existingDefinitions
|
||||
.filter((def) => def.fieldType === definition.fieldType)
|
||||
.map((def) => def.displayName)
|
||||
)
|
||||
|
||||
if (!existingTagNames.has(definition.displayName)) {
|
||||
newByFieldType.set(
|
||||
definition.fieldType,
|
||||
(newByFieldType.get(definition.fieldType) || 0) + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [fieldType, newCount] of newByFieldType.entries()) {
|
||||
const existingCount = existingByFieldType.get(fieldType) || 0
|
||||
const maxSlots = getMaxSlotsForFieldType(fieldType)
|
||||
|
||||
if (existingCount + newCount > maxSlots) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Cannot create ${newCount} new '${fieldType}' tags. Knowledge base already has ${existingCount} '${fieldType}' tag definitions. Maximum is ${maxSlots} per field type.`,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Use transaction to ensure consistency
|
||||
@@ -228,30 +333,51 @@ export async function POST(
|
||||
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot, def]))
|
||||
|
||||
// Process each new definition
|
||||
// Process each definition
|
||||
for (const definition of validatedData.definitions) {
|
||||
if (definition._originalDisplayName) {
|
||||
// This is an EDIT operation - find by original name and update
|
||||
const originalDefinition = existingByName.get(definition._originalDisplayName)
|
||||
|
||||
if (originalDefinition) {
|
||||
logger.info(
|
||||
`[${requestId}] Editing tag definition: ${definition._originalDisplayName} -> ${definition.displayName} (slot ${originalDefinition.tagSlot})`
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, originalDefinition.id))
|
||||
|
||||
createdDefinitions.push({
|
||||
...originalDefinition,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
continue
|
||||
}
|
||||
logger.warn(
|
||||
`[${requestId}] Could not find original definition for: ${definition._originalDisplayName}`
|
||||
)
|
||||
}
|
||||
|
||||
// Regular create/update logic
|
||||
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
|
||||
}
|
||||
}
|
||||
// Display name exists - UPDATE operation
|
||||
logger.info(
|
||||
`[${requestId}] Updating existing tag definition: ${definition.displayName} (slot ${existingByDisplayName.tagSlot})`
|
||||
)
|
||||
|
||||
await tx
|
||||
.update(knowledgeBaseTagDefinitions)
|
||||
.set({
|
||||
tagSlot: definition.tagSlot,
|
||||
fieldType: definition.fieldType,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -259,33 +385,32 @@ export async function POST(
|
||||
|
||||
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
|
||||
// Display name doesn't exist - CREATE operation
|
||||
const targetSlot = await getNextAvailableSlot(
|
||||
knowledgeBaseId,
|
||||
definition.fieldType,
|
||||
existingBySlot
|
||||
)
|
||||
|
||||
if (!targetSlot) {
|
||||
logger.error(
|
||||
`[${requestId}] No available slots for new tag definition: ${definition.displayName}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Creating new tag definition: ${definition.displayName} -> ${targetSlot}`
|
||||
)
|
||||
|
||||
const newDefinition = {
|
||||
id: randomUUID(),
|
||||
knowledgeBaseId,
|
||||
tagSlot: definition.tagSlot,
|
||||
tagSlot: targetSlot as any,
|
||||
displayName: definition.displayName,
|
||||
fieldType: definition.fieldType,
|
||||
createdAt: now,
|
||||
@@ -293,7 +418,8 @@ export async function POST(
|
||||
}
|
||||
|
||||
await tx.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
||||
createdDefinitions.push(newDefinition)
|
||||
existingBySlot.set(targetSlot as any, newDefinition)
|
||||
createdDefinitions.push(newDefinition as any)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +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 { getSlotsForFieldType } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||
import {
|
||||
@@ -23,6 +23,48 @@ const PROCESSING_CONFIG = {
|
||||
delayBetweenDocuments: 500,
|
||||
}
|
||||
|
||||
// Helper function to get the next available slot for a knowledge base and field type
|
||||
async function getNextAvailableSlot(
|
||||
knowledgeBaseId: string,
|
||||
fieldType: string,
|
||||
existingBySlot?: Map<string, any>
|
||||
): Promise<string | null> {
|
||||
let usedSlots: Set<string>
|
||||
|
||||
if (existingBySlot) {
|
||||
// Use provided map if available (for performance in batch operations)
|
||||
// Filter by field type
|
||||
usedSlots = new Set(
|
||||
Array.from(existingBySlot.entries())
|
||||
.filter(([_, def]) => def.fieldType === fieldType)
|
||||
.map(([slot, _]) => slot)
|
||||
)
|
||||
} else {
|
||||
// Query database for existing tag definitions of the same field type
|
||||
const existingDefinitions = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(knowledgeBaseTagDefinitions.fieldType, fieldType)
|
||||
)
|
||||
)
|
||||
|
||||
usedSlots = new Set(existingDefinitions.map((def) => def.tagSlot))
|
||||
}
|
||||
|
||||
// Find the first available slot for this field type
|
||||
const availableSlots = getSlotsForFieldType(fieldType)
|
||||
for (const slot of availableSlots) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
|
||||
return null // No available slots for this field type
|
||||
}
|
||||
|
||||
// Helper function to process structured document tags
|
||||
async function processDocumentTags(
|
||||
knowledgeBaseId: string,
|
||||
@@ -31,8 +73,9 @@ async function processDocumentTags(
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result: Record<string, string | null> = {}
|
||||
|
||||
// Initialize all tag slots to null
|
||||
TAG_SLOTS.forEach((slot) => {
|
||||
// Initialize all text tag slots to null (only text type is supported currently)
|
||||
const textSlots = getSlotsForFieldType('text')
|
||||
textSlots.forEach((slot) => {
|
||||
result[slot] = null
|
||||
})
|
||||
|
||||
@@ -55,7 +98,7 @@ async function processDocumentTags(
|
||||
if (!tag.tagName?.trim() || !tag.value?.trim()) continue
|
||||
|
||||
const tagName = tag.tagName.trim()
|
||||
const fieldType = tag.fieldType || 'text'
|
||||
const fieldType = tag.fieldType
|
||||
const value = tag.value.trim()
|
||||
|
||||
let targetSlot: string | null = null
|
||||
@@ -65,13 +108,8 @@ async function processDocumentTags(
|
||||
if (existingDef) {
|
||||
targetSlot = existingDef.tagSlot
|
||||
} else {
|
||||
// Find next available slot
|
||||
for (const slot of TAG_SLOTS) {
|
||||
if (!existingBySlot.has(slot)) {
|
||||
targetSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
// Find next available slot using the helper function
|
||||
targetSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot)
|
||||
|
||||
// Create new tag definition if we have a slot
|
||||
if (targetSlot) {
|
||||
|
||||
84
apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts
Normal file
84
apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getMaxSlotsForFieldType, getSlotsForFieldType } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
const logger = createLogger('NextAvailableSlotAPI')
|
||||
|
||||
// GET /api/knowledge/[id]/next-available-slot - Get the next available tag slot for a knowledge base and field type
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId } = await params
|
||||
const { searchParams } = new URL(req.url)
|
||||
const fieldType = searchParams.get('fieldType')
|
||||
|
||||
if (!fieldType) {
|
||||
return NextResponse.json({ error: 'fieldType parameter is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`[${requestId}] Getting next available slot for knowledge base ${knowledgeBaseId}, fieldType: ${fieldType}`
|
||||
)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has read access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get available slots for this field type
|
||||
const availableSlots = getSlotsForFieldType(fieldType)
|
||||
const maxSlots = getMaxSlotsForFieldType(fieldType)
|
||||
|
||||
// Get existing tag definitions to find used slots for this field type
|
||||
const existingDefinitions = await db
|
||||
.select({ tagSlot: knowledgeBaseTagDefinitions.tagSlot })
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(knowledgeBaseTagDefinitions.fieldType, fieldType)
|
||||
)
|
||||
)
|
||||
|
||||
const usedSlots = new Set(existingDefinitions.map((def) => def.tagSlot as string))
|
||||
|
||||
// Find the first available slot for this field type
|
||||
let nextAvailableSlot: string | null = null
|
||||
for (const slot of availableSlots) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
nextAvailableSlot = slot
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Next available slot for fieldType ${fieldType}: ${nextAvailableSlot}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
nextAvailableSlot,
|
||||
fieldType,
|
||||
usedSlots: Array.from(usedSlots),
|
||||
totalSlots: maxSlots,
|
||||
availableSlots: maxSlots - usedSlots.size,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting next available slot`, error)
|
||||
return NextResponse.json({ error: 'Failed to get next available slot' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, X } from 'lucide-react'
|
||||
import { ChevronDown, Info, Plus, X } from 'lucide-react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -20,9 +20,14 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||
|
||||
export interface DocumentTag {
|
||||
@@ -52,6 +57,7 @@ export function DocumentTagEntry({
|
||||
// Use different hooks based on whether we have a documentId
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||
|
||||
// Use the document-level hook since we have documentId
|
||||
const { saveTagDefinitions } = documentTagHook
|
||||
@@ -66,17 +72,6 @@ export function DocumentTagEntry({
|
||||
value: '',
|
||||
})
|
||||
|
||||
const getNextAvailableSlot = (): DocumentTag['slot'] => {
|
||||
// Check which slots are used at the KB level (tag definitions)
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
for (const slot of TAG_SLOTS) {
|
||||
if (!usedSlots.has(slot)) {
|
||||
return slot
|
||||
}
|
||||
}
|
||||
return TAG_SLOTS[0] // Fallback to first slot if all are used
|
||||
}
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
const updatedTags = tags.filter((_, i) => i !== index)
|
||||
onTagsChange(updatedTags)
|
||||
@@ -117,15 +112,36 @@ export function DocumentTagEntry({
|
||||
|
||||
// Save tag from modal
|
||||
const saveTagFromModal = async () => {
|
||||
if (!editForm.displayName.trim()) return
|
||||
if (!editForm.displayName.trim() || !editForm.value.trim()) return
|
||||
|
||||
try {
|
||||
// Calculate slot once at the beginning
|
||||
const targetSlot =
|
||||
editingTagIndex !== null ? tags[editingTagIndex].slot : getNextAvailableSlot()
|
||||
let targetSlot: string
|
||||
|
||||
if (editingTagIndex !== null) {
|
||||
// Editing existing tag - use existing slot
|
||||
// EDIT MODE: Editing existing tag - use existing slot
|
||||
targetSlot = tags[editingTagIndex].slot
|
||||
} else {
|
||||
// CREATE MODE: Check if using existing definition or creating new one
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (existingDefinition) {
|
||||
// Using existing definition - use its slot
|
||||
targetSlot = existingDefinition.tagSlot
|
||||
} else {
|
||||
// Creating new definition - get next available slot from server
|
||||
const serverSlot = await getServerNextSlot(editForm.fieldType)
|
||||
if (!serverSlot) {
|
||||
throw new Error(`No available slots for new tag of type '${editForm.fieldType}'`)
|
||||
}
|
||||
targetSlot = serverSlot
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tags array
|
||||
if (editingTagIndex !== null) {
|
||||
// Editing existing tag
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[editingTagIndex] = {
|
||||
...updatedTags[editingTagIndex],
|
||||
@@ -135,7 +151,7 @@ export function DocumentTagEntry({
|
||||
}
|
||||
onTagsChange(updatedTags)
|
||||
} else {
|
||||
// Creating new tag - use calculated slot
|
||||
// Creating new tag
|
||||
const newTag: DocumentTag = {
|
||||
slot: targetSlot,
|
||||
displayName: editForm.displayName,
|
||||
@@ -146,25 +162,60 @@ export function DocumentTagEntry({
|
||||
onTagsChange(newTags)
|
||||
}
|
||||
|
||||
// Auto-save tag definition if it's a new name
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
// Handle tag definition creation/update based on edit mode
|
||||
if (editingTagIndex !== null) {
|
||||
// EDIT MODE: Always update existing definition, never create new slots
|
||||
const currentTag = tags[editingTagIndex]
|
||||
const currentDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === currentTag.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Use the same slot for both tag and definition
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
if (currentDefinition) {
|
||||
const updatedDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
|
||||
tagSlot: currentDefinition.tagSlot, // Keep existing slot
|
||||
_originalDisplayName: currentTag.displayName, // Tell server which definition to update
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([updatedDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
|
||||
// Update the document tag's display name
|
||||
const updatedTags = [...tags]
|
||||
updatedTags[editingTagIndex] = {
|
||||
...currentTag,
|
||||
displayName: editForm.displayName,
|
||||
fieldType: currentDefinition.fieldType,
|
||||
}
|
||||
onTagsChange(updatedTags)
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
} else {
|
||||
// CREATE MODE: Adding new tag
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Create new definition
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: editForm.displayName,
|
||||
fieldType: editForm.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
} else {
|
||||
throw new Error('Cannot save tag definitions without a document ID')
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
// If existingDefinition exists, use it (no server update needed)
|
||||
}
|
||||
|
||||
// Save the actual document tags if onSave is provided
|
||||
@@ -194,13 +245,24 @@ export function DocumentTagEntry({
|
||||
}
|
||||
|
||||
setModalOpen(false)
|
||||
} catch (error) {}
|
||||
} catch (error) {
|
||||
console.error('Error saving tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter available tag definitions (exclude already used ones)
|
||||
const availableDefinitions = kbTagDefinitions.filter(
|
||||
(def) => !tags.some((tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase())
|
||||
)
|
||||
// Filter available tag definitions based on context
|
||||
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
||||
if (editingTagIndex !== null) {
|
||||
// When editing, exclude only other used tag names (not the current one being edited)
|
||||
return !tags.some(
|
||||
(tag, index) =>
|
||||
index !== editingTagIndex &&
|
||||
tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
||||
)
|
||||
}
|
||||
// When creating new, exclude all already used tag names
|
||||
return !tags.some((tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase())
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
@@ -244,7 +306,7 @@ export function DocumentTagEntry({
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={openNewTagModal}
|
||||
disabled={disabled || tags.length >= MAX_TAG_SLOTS}
|
||||
disabled={disabled}
|
||||
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
@@ -274,7 +336,24 @@ export function DocumentTagEntry({
|
||||
<div className='space-y-4'>
|
||||
{/* Tag Name */}
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='tag-name'>Tag Name</Label>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Label htmlFor='tag-name'>Tag Name</Label>
|
||||
{editingTagIndex !== null && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className='h-4 w-4 cursor-help text-muted-foreground' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className='text-sm'>
|
||||
Changing this tag name will update it for all documents in this knowledge
|
||||
base
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='tag-name'
|
||||
@@ -283,7 +362,7 @@ export function DocumentTagEntry({
|
||||
placeholder='Enter tag name'
|
||||
className='flex-1'
|
||||
/>
|
||||
{availableDefinitions.length > 0 && (
|
||||
{editingTagIndex === null && availableDefinitions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm'>
|
||||
@@ -317,6 +396,7 @@ export function DocumentTagEntry({
|
||||
<Select
|
||||
value={editForm.fieldType}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
|
||||
disabled={editingTagIndex !== null} // Disable in edit mode
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -339,12 +419,60 @@ export function DocumentTagEntry({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show warning when at max slots in create mode */}
|
||||
{editingTagIndex === null && kbTagDefinitions.length >= MAX_TAG_SLOTS && (
|
||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-3'>
|
||||
<div className='flex items-center gap-2 text-amber-800 text-sm'>
|
||||
<span className='font-medium'>Maximum tag definitions reached</span>
|
||||
</div>
|
||||
<p className='mt-1 text-amber-700 text-xs'>
|
||||
You can still use existing tag definitions from the dropdown, but cannot create new
|
||||
ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
<Button variant='outline' onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveTagFromModal} disabled={!editForm.displayName.trim()}>
|
||||
{editingTagIndex !== null ? 'Save Changes' : 'Add Tag'}
|
||||
<Button
|
||||
onClick={saveTagFromModal}
|
||||
disabled={(() => {
|
||||
if (!editForm.displayName.trim()) return true
|
||||
|
||||
// In edit mode, always allow
|
||||
if (editingTagIndex !== null) return false
|
||||
|
||||
// In create mode, check if we're creating a new definition at max slots
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
// If using existing definition, allow
|
||||
if (existingDefinition) return false
|
||||
|
||||
// If creating new definition and at max slots, disable
|
||||
return kbTagDefinitions.length >= MAX_TAG_SLOTS
|
||||
})()}
|
||||
>
|
||||
{(() => {
|
||||
if (editingTagIndex !== null) {
|
||||
return 'Save Changes'
|
||||
}
|
||||
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (existingDefinition) {
|
||||
return 'Use Existing Tag'
|
||||
}
|
||||
if (kbTagDefinitions.length >= MAX_TAG_SLOTS) {
|
||||
return 'Max Tags Reached'
|
||||
}
|
||||
return 'Create New Tag'
|
||||
})()}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
1
apps/sim/db/migrations/0067_safe_bushwacker.sql
Normal file
1
apps/sim/db/migrations/0067_safe_bushwacker.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE UNIQUE INDEX "kb_tag_definitions_kb_display_name_idx" ON "knowledge_base_tag_definitions" USING btree ("knowledge_base_id","display_name");
|
||||
5850
apps/sim/db/migrations/meta/0067_snapshot.json
Normal file
5850
apps/sim/db/migrations/meta/0067_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -463,6 +463,13 @@
|
||||
"when": 1754352106989,
|
||||
"tag": "0066_talented_mentor",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1754424644234,
|
||||
"tag": "0067_safe_bushwacker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -822,6 +822,11 @@ export const knowledgeBaseTagDefinitions = pgTable(
|
||||
table.knowledgeBaseId,
|
||||
table.tagSlot
|
||||
),
|
||||
// Ensure unique display name per knowledge base
|
||||
kbDisplayNameIdx: uniqueIndex('kb_tag_definitions_kb_display_name_idx').on(
|
||||
table.knowledgeBaseId,
|
||||
table.displayName
|
||||
),
|
||||
// Index for querying by knowledge base
|
||||
kbIdIdx: index('kb_tag_definitions_kb_id_idx').on(table.knowledgeBaseId),
|
||||
})
|
||||
|
||||
112
apps/sim/hooks/use-next-available-slot.ts
Normal file
112
apps/sim/hooks/use-next-available-slot.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('useNextAvailableSlot')
|
||||
|
||||
interface NextAvailableSlotResponse {
|
||||
success: boolean
|
||||
data?: {
|
||||
nextAvailableSlot: string | null
|
||||
fieldType: string
|
||||
usedSlots: string[]
|
||||
totalSlots: number
|
||||
availableSlots: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useNextAvailableSlot(knowledgeBaseId: string | null) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const getNextAvailableSlot = useCallback(
|
||||
async (fieldType: string): Promise<string | null> => {
|
||||
if (!knowledgeBaseId) {
|
||||
setError('Knowledge base ID is required')
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot`,
|
||||
window.location.origin
|
||||
)
|
||||
url.searchParams.set('fieldType', fieldType)
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get next available slot: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: NextAvailableSlotResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to get next available slot')
|
||||
}
|
||||
|
||||
return data.data?.nextAvailableSlot || null
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
logger.error('Error getting next available slot:', err)
|
||||
setError(errorMessage)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[knowledgeBaseId]
|
||||
)
|
||||
|
||||
const getSlotInfo = useCallback(
|
||||
async (fieldType: string) => {
|
||||
if (!knowledgeBaseId) {
|
||||
setError('Knowledge base ID is required')
|
||||
return null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const url = new URL(
|
||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot`,
|
||||
window.location.origin
|
||||
)
|
||||
url.searchParams.set('fieldType', fieldType)
|
||||
|
||||
const response = await fetch(url.toString())
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get slot info: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data: NextAvailableSlotResponse = await response.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to get slot info')
|
||||
}
|
||||
|
||||
return data.data || null
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error'
|
||||
logger.error('Error getting slot info:', err)
|
||||
setError(errorMessage)
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
[knowledgeBaseId]
|
||||
)
|
||||
|
||||
return {
|
||||
getNextAvailableSlot,
|
||||
getSlotInfo,
|
||||
isLoading,
|
||||
error,
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export interface TagDefinitionInput {
|
||||
tagSlot: TagSlot
|
||||
displayName: string
|
||||
fieldType: string
|
||||
// Optional: for editing existing definitions
|
||||
_originalDisplayName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,14 +2,52 @@
|
||||
* Knowledge base and document constants
|
||||
*/
|
||||
|
||||
// Maximum number of tag slots allowed per knowledge base
|
||||
export const MAX_TAG_SLOTS = 7
|
||||
// Tag slot configuration by field type
|
||||
// Each field type maps to specific database columns
|
||||
export const TAG_SLOT_CONFIG = {
|
||||
text: {
|
||||
slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const,
|
||||
maxSlots: 7,
|
||||
},
|
||||
// Future field types would be added here with their own database columns
|
||||
// date: {
|
||||
// slots: ['tag8', 'tag9'] as const,
|
||||
// maxSlots: 2,
|
||||
// },
|
||||
// number: {
|
||||
// slots: ['tag10', 'tag11'] as const,
|
||||
// maxSlots: 2,
|
||||
// },
|
||||
} as const
|
||||
|
||||
// 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[],
|
||||
]
|
||||
// Currently supported field types
|
||||
export const SUPPORTED_FIELD_TYPES = Object.keys(TAG_SLOT_CONFIG) as Array<
|
||||
keyof typeof TAG_SLOT_CONFIG
|
||||
>
|
||||
|
||||
// All tag slots (for backward compatibility)
|
||||
export const TAG_SLOTS = TAG_SLOT_CONFIG.text.slots
|
||||
|
||||
// Maximum number of tag slots for text type (for backward compatibility)
|
||||
export const MAX_TAG_SLOTS = TAG_SLOT_CONFIG.text.maxSlots
|
||||
|
||||
// Type for tag slot names
|
||||
export type TagSlot = (typeof TAG_SLOTS)[number]
|
||||
|
||||
// Helper function to get available slots for a field type
|
||||
export function getSlotsForFieldType(fieldType: string): readonly string[] {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) {
|
||||
return [] // Return empty array for unsupported field types - system will naturally handle this
|
||||
}
|
||||
return config.slots
|
||||
}
|
||||
|
||||
// Helper function to get max slots for a field type
|
||||
export function getMaxSlotsForFieldType(fieldType: string): number {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) {
|
||||
return 0 // Return 0 for unsupported field types
|
||||
}
|
||||
return config.maxSlots
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user