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:
Vikhyath Mondreti
2025-08-05 13:58:48 -07:00
committed by GitHub
parent 6ec5cf46e2
commit be65bf795f
11 changed files with 6514 additions and 123 deletions

View File

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

View File

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -463,6 +463,13 @@
"when": 1754352106989,
"tag": "0066_talented_mentor",
"breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1754424644234,
"tag": "0067_safe_bushwacker",
"breakpoints": true
}
]
}

View File

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

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

View File

@@ -19,6 +19,8 @@ export interface TagDefinitionInput {
tagSlot: TagSlot
displayName: string
fieldType: string
// Optional: for editing existing definitions
_originalDisplayName?: string
}
/**

View File

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