From be65bf795f75ad4231d6970e66cb4998d7473ddc Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 5 Aug 2025 13:58:48 -0700 Subject: [PATCH] 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 --- .../[documentId]/tag-definitions/route.ts | 246 +- .../app/api/knowledge/[id]/documents/route.ts | 60 +- .../[id]/next-available-slot/route.ts | 84 + .../document-tag-entry/document-tag-entry.tsx | 216 +- .../db/migrations/0067_safe_bushwacker.sql | 1 + .../sim/db/migrations/meta/0067_snapshot.json | 5850 +++++++++++++++++ apps/sim/db/migrations/meta/_journal.json | 7 + apps/sim/db/schema.ts | 5 + apps/sim/hooks/use-next-available-slot.ts | 112 + apps/sim/hooks/use-tag-definitions.ts | 2 + apps/sim/lib/constants/knowledge.ts | 52 +- 11 files changed, 6513 insertions(+), 122 deletions(-) create mode 100644 apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts create mode 100644 apps/sim/db/migrations/0067_safe_bushwacker.sql create mode 100644 apps/sim/db/migrations/meta/0067_snapshot.json create mode 100644 apps/sim/hooks/use-next-available-slot.ts diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts index 135c74b8b7a..de013a3e312 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts @@ -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 +): Promise { + // Get available slots for this field type + const availableSlots = getSlotsForFieldType(fieldType) + let usedSlots: Set + + 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>() + 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() + 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() + 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) } } }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 7619a330f7b..b7492151f15 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -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 +): Promise { + let usedSlots: Set + + 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> { const result: Record = {} - // 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) { diff --git a/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts new file mode 100644 index 00000000000..dbb8f775eb1 --- /dev/null +++ b/apps/sim/app/api/knowledge/[id]/next-available-slot/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx index 1eab551645b..d45bbb293f0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry.tsx @@ -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([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) } + } else { + // CREATE MODE: Adding new tag + const existingDefinition = kbTagDefinitions.find( + (def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase() + ) - if (saveTagDefinitions) { - await saveTagDefinitions([newDefinition]) - } else { - throw new Error('Cannot save tag definitions without a document ID') + 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() } - 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 (
@@ -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' > @@ -274,7 +336,24 @@ export function DocumentTagEntry({
{/* Tag Name */}
- +
+ + {editingTagIndex !== null && ( + + + + + + +

+ Changing this tag name will update it for all documents in this knowledge + base +

+
+
+
+ )} +
- {availableDefinitions.length > 0 && ( + {editingTagIndex === null && availableDefinitions.length > 0 && (