mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(chunks): instantaneous search + server side searching instead of client-side (#940)
* fix(chunks): instantaneous search + server side searching instead of client-side * add knowledge tags component to sidebar, replace old knowledge tags UI * add types, remove extraneous comments * added knowledge-base level tag definitions viewer, ability to create/delete slots in sidebar and respective routes * ui * fix stale tag issue * use logger
This commit is contained in:
118
apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts
Normal file
118
apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { document, embedding, knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('TagDefinitionAPI')
|
||||
|
||||
// DELETE /api/knowledge/[id]/tag-definitions/[tagId] - Delete a tag definition
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; tagId: string }> }
|
||||
) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId, tagId } = await params
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
`[${requestId}] Deleting tag definition ${tagId} from knowledge base ${knowledgeBaseId}`
|
||||
)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get the tag definition to find which slot it uses
|
||||
const tagDefinition = await db
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.id, tagId),
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (tagDefinition.length === 0) {
|
||||
return NextResponse.json({ error: 'Tag definition not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const tagDef = tagDefinition[0]
|
||||
|
||||
// Delete the tag definition and clear all document tags in a transaction
|
||||
await db.transaction(async (tx) => {
|
||||
logger.info(`[${requestId}] Starting transaction to delete ${tagDef.tagSlot}`)
|
||||
|
||||
try {
|
||||
// Clear the tag from documents that actually have this tag set
|
||||
logger.info(`[${requestId}] Clearing tag from documents...`)
|
||||
await tx
|
||||
.update(document)
|
||||
.set({ [tagDef.tagSlot]: null })
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
isNotNull(document[tagDef.tagSlot as keyof typeof document.$inferSelect])
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Documents updated successfully`)
|
||||
|
||||
// Clear the tag from embeddings that actually have this tag set
|
||||
logger.info(`[${requestId}] Clearing tag from embeddings...`)
|
||||
await tx
|
||||
.update(embedding)
|
||||
.set({ [tagDef.tagSlot]: null })
|
||||
.where(
|
||||
and(
|
||||
eq(embedding.knowledgeBaseId, knowledgeBaseId),
|
||||
isNotNull(embedding[tagDef.tagSlot as keyof typeof embedding.$inferSelect])
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Embeddings updated successfully`)
|
||||
|
||||
// Delete the tag definition
|
||||
logger.info(`[${requestId}] Deleting tag definition...`)
|
||||
await tx
|
||||
.delete(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.id, tagId))
|
||||
|
||||
logger.info(`[${requestId}] Tag definition deleted successfully`)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error in transaction:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Successfully deleted tag definition ${tagDef.displayName} (${tagDef.tagSlot})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Tag definition "${tagDef.displayName}" deleted successfully`,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error deleting tag definition`, error)
|
||||
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -55,3 +55,89 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Failed to get tag definitions' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/knowledge/[id]/tag-definitions - Create a new tag definition
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Creating tag definition for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await req.json()
|
||||
const { tagSlot, displayName, fieldType } = body
|
||||
|
||||
if (!tagSlot || !displayName || !fieldType) {
|
||||
return NextResponse.json(
|
||||
{ error: 'tagSlot, displayName, and fieldType are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if tag slot is already used
|
||||
const existingTag = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(knowledgeBaseTagDefinitions.tagSlot, tagSlot)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingTag.length > 0) {
|
||||
return NextResponse.json({ error: 'Tag slot is already in use' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Check if display name is already used
|
||||
const existingName = await db
|
||||
.select()
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId),
|
||||
eq(knowledgeBaseTagDefinitions.displayName, displayName)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingName.length > 0) {
|
||||
return NextResponse.json({ error: 'Tag name is already in use' }, { status: 409 })
|
||||
}
|
||||
|
||||
// Create the new tag definition
|
||||
const newTagDefinition = {
|
||||
id: randomUUID(),
|
||||
knowledgeBaseId,
|
||||
tagSlot,
|
||||
displayName,
|
||||
fieldType,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
await db.insert(knowledgeBaseTagDefinitions).values(newTagDefinition)
|
||||
|
||||
logger.info(`[${requestId}] Successfully created tag definition ${displayName} (${tagSlot})`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: newTagDefinition,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error creating tag definition`, error)
|
||||
return NextResponse.json({ error: 'Failed to create tag definition' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
88
apps/sim/app/api/knowledge/[id]/tag-usage/route.ts
Normal file
88
apps/sim/app/api/knowledge/[id]/tag-usage/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { and, eq, isNotNull } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils'
|
||||
import { db } from '@/db'
|
||||
import { document, knowledgeBaseTagDefinitions } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('TagUsageAPI')
|
||||
|
||||
// GET /api/knowledge/[id]/tag-usage - Get usage statistics for all tag definitions
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = randomUUID().slice(0, 8)
|
||||
const { id: knowledgeBaseId } = await params
|
||||
|
||||
try {
|
||||
logger.info(`[${requestId}] Getting tag usage statistics for knowledge base ${knowledgeBaseId}`)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Check if user has access to the knowledge base
|
||||
const accessCheck = await checkKnowledgeBaseAccess(knowledgeBaseId, session.user.id)
|
||||
if (!accessCheck.hasAccess) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Get all tag definitions for the knowledge base
|
||||
const tagDefinitions = await db
|
||||
.select({
|
||||
id: knowledgeBaseTagDefinitions.id,
|
||||
tagSlot: knowledgeBaseTagDefinitions.tagSlot,
|
||||
displayName: knowledgeBaseTagDefinitions.displayName,
|
||||
})
|
||||
.from(knowledgeBaseTagDefinitions)
|
||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||
|
||||
// Get usage statistics for each tag definition
|
||||
const usageStats = await Promise.all(
|
||||
tagDefinitions.map(async (tagDef) => {
|
||||
// Count documents using this tag slot
|
||||
const tagSlotColumn = tagDef.tagSlot as keyof typeof document.$inferSelect
|
||||
|
||||
const documentsWithTag = await db
|
||||
.select({
|
||||
id: document.id,
|
||||
filename: document.filename,
|
||||
[tagDef.tagSlot]: document[tagSlotColumn as keyof typeof document.$inferSelect] as any,
|
||||
})
|
||||
.from(document)
|
||||
.where(
|
||||
and(
|
||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
||||
isNotNull(document[tagSlotColumn as keyof typeof document.$inferSelect])
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
tagName: tagDef.displayName,
|
||||
tagSlot: tagDef.tagSlot,
|
||||
documentCount: documentsWithTag.length,
|
||||
documents: documentsWithTag.map((doc) => ({
|
||||
id: doc.id,
|
||||
name: doc.filename,
|
||||
tagValue: doc[tagDef.tagSlot],
|
||||
})),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Retrieved usage statistics for ${tagDefinitions.length} tag definitions`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: usageStats,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error getting tag usage statistics`, error)
|
||||
return NextResponse.json({ error: 'Failed to get tag usage statistics' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -680,19 +680,6 @@ export function KnowledgeBase({
|
||||
/>
|
||||
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* Clear Search Button */}
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className='text-muted-foreground text-sm hover:text-foreground'
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Add Documents Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -26,10 +26,13 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
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'
|
||||
|
||||
const logger = createLogger('DocumentTagEntry')
|
||||
|
||||
export interface DocumentTag {
|
||||
slot: string
|
||||
displayName: string
|
||||
@@ -246,7 +249,7 @@ export function DocumentTagEntry({
|
||||
|
||||
setModalOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Error saving tag:', error)
|
||||
logger.error('Error saving tag:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ interface SearchInputProps {
|
||||
placeholder: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
@@ -16,6 +17,7 @@ export function SearchInput({
|
||||
placeholder,
|
||||
disabled = false,
|
||||
className = 'max-w-md flex-1',
|
||||
isLoading = false,
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
@@ -29,13 +31,20 @@ export function SearchInput({
|
||||
disabled={disabled}
|
||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
/>
|
||||
{value && !disabled && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='h-[18px] w-[18px]' />
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
|
||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[#701FFC]' />
|
||||
</div>
|
||||
) : (
|
||||
value &&
|
||||
!disabled && (
|
||||
<button
|
||||
onClick={() => onChange('')}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<X className='h-[18px] w-[18px]' />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export { CreateMenu } from './create-menu/create-menu'
|
||||
export { FolderTree } from './folder-tree/folder-tree'
|
||||
export { HelpModal } from './help-modal/help-modal'
|
||||
export { KnowledgeBaseTags } from './knowledge-base-tags/knowledge-base-tags'
|
||||
export { KnowledgeTags } from './knowledge-tags/knowledge-tags'
|
||||
export { LogsFilters } from './logs-filters/logs-filters'
|
||||
export { SettingsModal } from './settings-modal/settings-modal'
|
||||
export { SubscriptionModal } from './subscription-modal/subscription-modal'
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Eye, MoreHorizontal, Plus, Trash2, X } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components/icons/document-icons'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/use-knowledge-base-tag-definitions'
|
||||
|
||||
const logger = createLogger('KnowledgeBaseTags')
|
||||
|
||||
// Predetermined colors for each tag slot (same as document tags)
|
||||
const TAG_SLOT_COLORS = [
|
||||
'#701FFC', // Purple
|
||||
'#FF6B35', // Orange
|
||||
'#4ECDC4', // Teal
|
||||
'#45B7D1', // Blue
|
||||
'#96CEB4', // Green
|
||||
'#FFEAA7', // Yellow
|
||||
'#DDA0DD', // Plum
|
||||
'#FF7675', // Red
|
||||
'#74B9FF', // Light Blue
|
||||
'#A29BFE', // Lavender
|
||||
] as const
|
||||
|
||||
interface KnowledgeBaseTagsProps {
|
||||
knowledgeBaseId: string
|
||||
}
|
||||
|
||||
interface TagUsageData {
|
||||
tagName: string
|
||||
tagSlot: string
|
||||
documentCount: number
|
||||
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||
}
|
||||
|
||||
export function KnowledgeBaseTags({ knowledgeBaseId }: KnowledgeBaseTagsProps) {
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isLoadingUsage, setIsLoadingUsage] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
|
||||
// Get color for a tag based on its slot
|
||||
const getTagColor = (slot: string) => {
|
||||
const slotMatch = slot.match(/tag(\d+)/)
|
||||
const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0
|
||||
return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length]
|
||||
}
|
||||
|
||||
// Fetch tag usage data from API
|
||||
const fetchTagUsage = async () => {
|
||||
if (!knowledgeBaseId) return
|
||||
|
||||
setIsLoadingUsage(true)
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tag usage')
|
||||
}
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setTagUsageData(result.data)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tag usage:', error)
|
||||
} finally {
|
||||
setIsLoadingUsage(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load tag usage data when component mounts or knowledge base changes
|
||||
useEffect(() => {
|
||||
fetchTagUsage()
|
||||
}, [knowledgeBaseId])
|
||||
|
||||
// Get usage data for a tag
|
||||
const getTagUsage = (tagName: string): TagUsageData => {
|
||||
return (
|
||||
tagUsageData.find((usage) => usage.tagName === tagName) || {
|
||||
tagName,
|
||||
tagSlot: '',
|
||||
documentCount: 0,
|
||||
documents: [],
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const handleDeleteTag = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
// Fetch fresh usage data before showing the delete dialog
|
||||
await fetchTagUsage()
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleViewDocuments = async (tag: TagDefinition) => {
|
||||
setSelectedTag(tag)
|
||||
// Fetch fresh usage data before showing the view documents dialog
|
||||
await fetchTagUsage()
|
||||
setViewDocumentsDialogOpen(true)
|
||||
}
|
||||
|
||||
const openTagCreator = () => {
|
||||
setCreateForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreating(true)
|
||||
}
|
||||
|
||||
const cancelCreating = () => {
|
||||
setCreateForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
const hasNameConflict = (name: string) => {
|
||||
if (!name.trim()) return false
|
||||
return kbTagDefinitions.some(
|
||||
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
// Check for conflicts in real-time during creation (but not while saving)
|
||||
const nameConflict = isCreating && !isSaving && hasNameConflict(createForm.displayName)
|
||||
|
||||
const canSave = () => {
|
||||
return createForm.displayName.trim() && !hasNameConflict(createForm.displayName)
|
||||
}
|
||||
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSave()) return
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
// Find next available slot
|
||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||
const availableSlot = (
|
||||
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||
).find((slot) => !usedSlots.has(slot))
|
||||
|
||||
if (!availableSlot) {
|
||||
throw new Error('No available tag slots')
|
||||
}
|
||||
|
||||
// Create the tag definition
|
||||
const newTagDefinition = {
|
||||
tagSlot: availableSlot,
|
||||
displayName: createForm.displayName.trim(),
|
||||
fieldType: createForm.fieldType,
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newTagDefinition),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create tag definition')
|
||||
}
|
||||
|
||||
// Refresh tag definitions and usage data
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
// Reset form and close creator
|
||||
setCreateForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
})
|
||||
setIsCreating(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating tag definition:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!selectedTag) return
|
||||
|
||||
logger.info('Starting delete operation for:', selectedTag.displayName)
|
||||
setIsDeleting(true)
|
||||
|
||||
try {
|
||||
logger.info('Calling delete API for tag:', selectedTag.displayName)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
)
|
||||
|
||||
logger.info('Delete API response status:', response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error('Delete API failed:', errorText)
|
||||
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
logger.info('Delete API successful, refreshing data...')
|
||||
|
||||
// Refresh both tag definitions and usage data
|
||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||
|
||||
logger.info('Data refresh complete, closing dialog')
|
||||
|
||||
// Only close dialog and reset state after successful deletion and refresh
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedTag(null)
|
||||
|
||||
logger.info('Delete operation completed successfully')
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tag definition:', error)
|
||||
// Don't close dialog on error - let user see the error and try again or cancel
|
||||
} finally {
|
||||
logger.info('Setting isDeleting to false')
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show if user can't edit
|
||||
if (!userPermissions.canEdit) {
|
||||
return null
|
||||
}
|
||||
|
||||
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.displayName) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='h-full w-full overflow-hidden'>
|
||||
<ScrollArea className='h-full' hideScrollbar={true}>
|
||||
<div className='px-2 py-2'>
|
||||
{/* KB Tag Definitions Section */}
|
||||
<div className='mb-1 space-y-1'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Knowledge Base Tags</div>
|
||||
<div>
|
||||
{/* Existing Tag Definitions */}
|
||||
<div>
|
||||
{kbTagDefinitions.length === 0 && !isCreating ? (
|
||||
<div className='mb-1 rounded-[10px] border border-dashed bg-card p-3 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
No tag definitions yet.
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
kbTagDefinitions.length > 0 &&
|
||||
kbTagDefinitions.map((tag, index) => {
|
||||
const usage = getTagUsage(tag.displayName)
|
||||
return (
|
||||
<div key={tag.id} className='mb-1'>
|
||||
<div className='cursor-default rounded-[10px] border bg-card p-2 transition-colors'>
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<div
|
||||
className='h-2 w-2 rounded-full'
|
||||
style={{ backgroundColor: getTagColor(tag.tagSlot) }}
|
||||
/>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium'>{tag.displayName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
|
||||
>
|
||||
<MoreHorizontal className='h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='w-[180px] rounded-lg border bg-card shadow-xs'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleViewDocuments(tag)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
|
||||
>
|
||||
<Eye className='mr-2 h-3 w-3 flex-shrink-0' />
|
||||
<span className='whitespace-nowrap'>View Docs</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDeleteTag(tag)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 text-red-600 text-sm hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-950'
|
||||
>
|
||||
<Trash2 className='mr-2 h-3 w-3' />
|
||||
Delete Tag
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add New Tag Button or Inline Creator */}
|
||||
{!isCreating && userPermissions.canEdit && (
|
||||
<div className='mb-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={openTagCreator}
|
||||
className='w-full justify-start gap-2 rounded-[10px] border border-dashed bg-card text-muted-foreground hover:text-foreground'
|
||||
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Tag Definition
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Tag Creation Form */}
|
||||
{isCreating && (
|
||||
<div className='mb-1 w-full max-w-full space-y-2 rounded-[10px] border bg-card p-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='font-medium text-xs'>Tag Name</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={cancelCreating}
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={createForm.displayName}
|
||||
onChange={(e) =>
|
||||
setCreateForm({ ...createForm, displayName: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag name'
|
||||
className='h-8 w-full rounded-md text-sm'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSave()) {
|
||||
e.preventDefault()
|
||||
saveTagDefinition()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelCreating()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{nameConflict && (
|
||||
<div className='text-red-600 text-xs'>
|
||||
A tag with this name already exists
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Type</Label>
|
||||
<Select
|
||||
value={createForm.fieldType}
|
||||
onValueChange={(value) =>
|
||||
setCreateForm({ ...createForm, fieldType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full text-sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className='flex pt-1.5'>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={saveTagDefinition}
|
||||
className='h-7 w-full text-xs'
|
||||
disabled={!canSave() || isSaving}
|
||||
>
|
||||
{isSaving ? 'Creating...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-2 text-muted-foreground text-xs'>
|
||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
|
||||
remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
|
||||
<span className='text-red-500 dark:text-red-500'>
|
||||
This action cannot be undone.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
|
||||
<div className='mt-4'>
|
||||
<div className='mb-2 font-medium text-sm'>Affected documents:</div>
|
||||
<div className='rounded-md border border-border bg-background'>
|
||||
<div className='max-h-32 overflow-y-auto'>
|
||||
{selectedTagUsage.documents.slice(0, 5).map((doc, index) => {
|
||||
const DocumentIcon = getDocumentIcon('', doc.name)
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className='flex items-center gap-3 border-border/50 border-b p-3 last:border-b-0'
|
||||
>
|
||||
<DocumentIcon className='h-4 w-4 flex-shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>{doc.name}</div>
|
||||
{doc.tagValue && (
|
||||
<div className='mt-1 text-muted-foreground text-xs'>
|
||||
Tag value: <span className='font-medium'>{doc.tagValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{selectedTagUsage.documentCount > 5 && (
|
||||
<div className='flex items-center gap-3 p-3 text-muted-foreground text-sm'>
|
||||
<div className='h-4 w-4' />
|
||||
<div className='font-medium'>
|
||||
and {selectedTagUsage.documentCount - 5} more documents...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className='flex'>
|
||||
<AlertDialogCancel className='h-9 w-full rounded-[8px]' disabled={isDeleting}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
onClick={confirmDeleteTag}
|
||||
disabled={isDeleting}
|
||||
className='h-9 w-full rounded-[8px] bg-red-500 text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-500 dark:hover:bg-red-600'
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Tag'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* View Documents Dialog */}
|
||||
<AlertDialog open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Documents using "{selectedTag?.displayName}"</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<div className='mb-4 text-muted-foreground'>
|
||||
{selectedTagUsage?.documentCount || 0} document
|
||||
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||
definition.
|
||||
</div>
|
||||
|
||||
{selectedTagUsage?.documentCount === 0 ? (
|
||||
<div className='rounded-md bg-muted/30 p-6 text-center'>
|
||||
<div className='text-muted-foreground text-sm'>
|
||||
This tag definition is not being used by any documents. You can safely delete
|
||||
it to free up the tag slot.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='rounded-md border border-border bg-background'>
|
||||
<div className='max-h-80 overflow-y-auto'>
|
||||
{selectedTagUsage?.documents.map((doc, index) => {
|
||||
const DocumentIcon = getDocumentIcon('', doc.name)
|
||||
return (
|
||||
<div
|
||||
key={doc.id}
|
||||
className='flex items-center gap-3 border-border/50 border-b p-3 transition-colors last:border-b-0 hover:bg-muted/30'
|
||||
>
|
||||
<DocumentIcon className='h-4 w-4 flex-shrink-0' />
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-sm'>{doc.name}</div>
|
||||
{doc.tagValue && (
|
||||
<div className='mt-1 text-muted-foreground text-xs'>
|
||||
Tag value: <span className='font-medium'>{doc.tagValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronDown, Plus, X } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { DocumentTag } from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
type TagDefinition,
|
||||
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'
|
||||
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||
|
||||
const logger = createLogger('KnowledgeTags')
|
||||
|
||||
interface KnowledgeTagsProps {
|
||||
knowledgeBaseId: string
|
||||
documentId: string
|
||||
}
|
||||
|
||||
// Predetermined colors for each tag slot
|
||||
const TAG_SLOT_COLORS = [
|
||||
'#701FFC', // Purple
|
||||
'#FF6B35', // Orange
|
||||
'#4ECDC4', // Teal
|
||||
'#45B7D1', // Blue
|
||||
'#96CEB4', // Green
|
||||
'#FFEAA7', // Yellow
|
||||
'#DDA0DD', // Plum
|
||||
'#FF7675', // Red
|
||||
'#74B9FF', // Light Blue
|
||||
'#A29BFE', // Lavender
|
||||
] as const
|
||||
|
||||
export function KnowledgeTags({ knowledgeBaseId, documentId }: KnowledgeTagsProps) {
|
||||
const { getCachedDocuments, updateDocument: updateDocumentInStore } = useKnowledgeStore()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// 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, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
|
||||
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
|
||||
const [documentData, setDocumentData] = useState<DocumentData | null>(null)
|
||||
const [isLoadingDocument, setIsLoadingDocument] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Inline editing state
|
||||
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [editForm, setEditForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
|
||||
// Function to build document tags from data and definitions
|
||||
const buildDocumentTags = useCallback(
|
||||
(docData: DocumentData, definitions: TagDefinition[], currentTags?: DocumentTag[]) => {
|
||||
const tags: DocumentTag[] = []
|
||||
const tagSlots = TAG_SLOTS
|
||||
|
||||
tagSlots.forEach((slot) => {
|
||||
const value = docData[slot] as string | null | undefined
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
const currentTag = currentTags?.find((tag) => tag.slot === slot)
|
||||
|
||||
// Only include tag if the document actually has a value for it
|
||||
if (value?.trim()) {
|
||||
tags.push({
|
||||
slot,
|
||||
// Preserve existing displayName if definition is not found yet
|
||||
displayName: definition?.displayName || currentTag?.displayName || '',
|
||||
fieldType: definition?.fieldType || currentTag?.fieldType || 'text',
|
||||
value: value.trim(),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return tags
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Handle tag updates (local state only, no API calls)
|
||||
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
|
||||
// Only update local state, don't save to API
|
||||
setDocumentTags(newTags)
|
||||
}, [])
|
||||
|
||||
// Handle saving document tag values to the API
|
||||
const handleSaveDocumentTags = useCallback(
|
||||
async (tagsToSave: DocumentTag[]) => {
|
||||
if (!documentData) return
|
||||
|
||||
try {
|
||||
// Convert DocumentTag array to tag data for API
|
||||
const tagData: Record<string, string> = {}
|
||||
const tagSlots = TAG_SLOTS
|
||||
|
||||
// Clear all tags first
|
||||
tagSlots.forEach((slot) => {
|
||||
tagData[slot] = ''
|
||||
})
|
||||
|
||||
// Set values from tagsToSave
|
||||
tagsToSave.forEach((tag) => {
|
||||
if (tag.value.trim()) {
|
||||
tagData[tag.slot] = tag.value.trim()
|
||||
}
|
||||
})
|
||||
|
||||
// Update document via API
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document tags')
|
||||
}
|
||||
|
||||
// Update the document in the store and local state
|
||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||
setDocumentData((prev) => (prev ? { ...prev, ...tagData } : null))
|
||||
|
||||
// Refresh tag definitions to update the display
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
logger.error('Error updating document tags:', error)
|
||||
throw error // Re-throw so the component can handle it
|
||||
}
|
||||
},
|
||||
[documentData, knowledgeBaseId, documentId, updateDocumentInStore, fetchTagDefinitions]
|
||||
)
|
||||
|
||||
// Handle removing a tag
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
const updatedTags = documentTags.filter((_, i) => i !== index)
|
||||
handleTagsChange(updatedTags)
|
||||
|
||||
// Persist the changes
|
||||
try {
|
||||
await handleSaveDocumentTags(updatedTags)
|
||||
} catch (error) {
|
||||
// Handle error silently - the UI will show the optimistic update
|
||||
// but the user can retry if needed
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle inline editor for existing tag
|
||||
const toggleTagEditor = (index: number) => {
|
||||
if (editingTagIndex === index) {
|
||||
// Already editing this tag - collapse it
|
||||
cancelEditing()
|
||||
} else {
|
||||
// Start editing this tag
|
||||
const tag = documentTags[index]
|
||||
setEditingTagIndex(index)
|
||||
setEditForm({
|
||||
displayName: tag.displayName,
|
||||
fieldType: tag.fieldType,
|
||||
value: tag.value,
|
||||
})
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Open inline creator for new tag
|
||||
const openTagCreator = () => {
|
||||
setEditingTagIndex(null)
|
||||
setEditForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setIsCreating(true)
|
||||
}
|
||||
|
||||
// Save tag (create or edit)
|
||||
const saveTag = async () => {
|
||||
if (!editForm.displayName.trim() || !editForm.value.trim()) return
|
||||
|
||||
// Close the edit form immediately and set saving flag
|
||||
const formData = { ...editForm }
|
||||
const currentEditingIndex = editingTagIndex
|
||||
// Capture original tag data before updating
|
||||
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
|
||||
setEditingTagIndex(null)
|
||||
setIsCreating(false)
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
let targetSlot: string
|
||||
|
||||
if (currentEditingIndex !== null && originalTag) {
|
||||
// EDIT MODE: Editing existing tag - use existing slot
|
||||
targetSlot = originalTag.slot
|
||||
} else {
|
||||
// CREATE MODE: Check if using existing definition or creating new one
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === formData.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(formData.fieldType)
|
||||
if (!serverSlot) {
|
||||
throw new Error(`No available slots for new tag of type '${formData.fieldType}'`)
|
||||
}
|
||||
targetSlot = serverSlot
|
||||
}
|
||||
}
|
||||
|
||||
// Update the tags array
|
||||
let updatedTags: DocumentTag[]
|
||||
if (currentEditingIndex !== null) {
|
||||
// Editing existing tag
|
||||
updatedTags = [...documentTags]
|
||||
updatedTags[currentEditingIndex] = {
|
||||
...updatedTags[currentEditingIndex],
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
value: formData.value,
|
||||
}
|
||||
} else {
|
||||
// Creating new tag
|
||||
const newTag: DocumentTag = {
|
||||
slot: targetSlot,
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
value: formData.value,
|
||||
}
|
||||
updatedTags = [...documentTags, newTag]
|
||||
}
|
||||
|
||||
handleTagsChange(updatedTags)
|
||||
|
||||
// Handle tag definition creation/update based on edit mode
|
||||
if (currentEditingIndex !== null && originalTag) {
|
||||
// EDIT MODE: Always update existing definition, never create new slots
|
||||
const currentDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (currentDefinition) {
|
||||
const updatedDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
|
||||
tagSlot: currentDefinition.tagSlot, // Keep existing slot
|
||||
_originalDisplayName: originalTag.displayName, // Tell server which definition to update
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([updatedDefinition])
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
} else {
|
||||
// CREATE MODE: Adding new tag
|
||||
const existingDefinition = kbTagDefinitions.find(
|
||||
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
|
||||
)
|
||||
|
||||
if (!existingDefinition) {
|
||||
// Create new definition
|
||||
const newDefinition: TagDefinitionInput = {
|
||||
displayName: formData.displayName,
|
||||
fieldType: formData.fieldType,
|
||||
tagSlot: targetSlot as TagSlot,
|
||||
}
|
||||
|
||||
if (saveTagDefinitions) {
|
||||
await saveTagDefinitions([newDefinition])
|
||||
}
|
||||
await refreshTagDefinitions()
|
||||
}
|
||||
}
|
||||
|
||||
// Save the actual document tags
|
||||
await handleSaveDocumentTags(updatedTags)
|
||||
|
||||
// Reset form
|
||||
setEditForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error saving tag:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if tag name already exists on this document
|
||||
const hasNameConflict = (name: string) => {
|
||||
if (!name.trim()) return false
|
||||
|
||||
return documentTags.some((tag, index) => {
|
||||
// When editing, don't consider the current tag being edited as a conflict
|
||||
if (editingTagIndex !== null && index === editingTagIndex) {
|
||||
return false
|
||||
}
|
||||
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
// Get color for a tag based on its slot
|
||||
const getTagColor = (slot: string) => {
|
||||
// Extract slot number from slot string (e.g., "tag1" -> 1, "tag2" -> 2, etc.)
|
||||
const slotMatch = slot.match(/tag(\d+)/)
|
||||
const slotNumber = slotMatch ? Number.parseInt(slotMatch[1]) - 1 : 0
|
||||
return TAG_SLOT_COLORS[slotNumber % TAG_SLOT_COLORS.length]
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditForm({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
value: '',
|
||||
})
|
||||
setEditingTagIndex(null)
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
// Filter available tag definitions - exclude all used tag names on this document
|
||||
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
||||
// Always exclude all already used tag names (including current tag being edited)
|
||||
return !documentTags.some(
|
||||
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
||||
)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDocument = async () => {
|
||||
try {
|
||||
setIsLoadingDocument(true)
|
||||
setError(null)
|
||||
|
||||
const cachedDocuments = getCachedDocuments(knowledgeBaseId)
|
||||
const cachedDoc = cachedDocuments?.documents?.find((d) => d.id === documentId)
|
||||
|
||||
if (cachedDoc) {
|
||||
setDocumentData(cachedDoc)
|
||||
// Initialize tags from cached document
|
||||
const initialTags = buildDocumentTags(cachedDoc, tagDefinitions)
|
||||
setDocumentTags(initialTags)
|
||||
setIsLoadingDocument(false)
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Document not found')
|
||||
}
|
||||
throw new Error(`Failed to fetch document: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
setDocumentData(result.data)
|
||||
// Initialize tags from fetched document
|
||||
const initialTags = buildDocumentTags(result.data, tagDefinitions, [])
|
||||
setDocumentTags(initialTags)
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to fetch document')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Error fetching document:', err)
|
||||
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||
} finally {
|
||||
setIsLoadingDocument(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (knowledgeBaseId && documentId) {
|
||||
fetchDocument()
|
||||
}
|
||||
}, [knowledgeBaseId, documentId, getCachedDocuments, buildDocumentTags])
|
||||
|
||||
// Separate effect to rebuild tags when tag definitions change (without re-fetching document)
|
||||
useEffect(() => {
|
||||
if (documentData && !isSaving) {
|
||||
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions, documentTags)
|
||||
setDocumentTags(rebuiltTags)
|
||||
}
|
||||
}, [documentData, tagDefinitions, buildDocumentTags, isSaving])
|
||||
|
||||
if (isLoadingDocument) {
|
||||
return (
|
||||
<div className='h-full'>
|
||||
<ScrollArea className='h-full' hideScrollbar={true}>
|
||||
<div className='px-2 py-2'>
|
||||
<div className='h-20 animate-pulse rounded-md bg-muted' />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !documentData) {
|
||||
return null // Don't show anything if there's an error or no document
|
||||
}
|
||||
|
||||
const isEditing = editingTagIndex !== null || isCreating
|
||||
const nameConflict = hasNameConflict(editForm.displayName)
|
||||
|
||||
// Check if there are actual changes (for editing mode)
|
||||
const hasChanges = () => {
|
||||
if (editingTagIndex === null) return true // Creating new tag always has changes
|
||||
|
||||
const originalTag = documentTags[editingTagIndex]
|
||||
if (!originalTag) return true
|
||||
|
||||
return (
|
||||
originalTag.displayName !== editForm.displayName ||
|
||||
originalTag.value !== editForm.value ||
|
||||
originalTag.fieldType !== editForm.fieldType
|
||||
)
|
||||
}
|
||||
|
||||
// Check if save should be enabled
|
||||
const canSave =
|
||||
editForm.displayName.trim() && editForm.value.trim() && !nameConflict && hasChanges()
|
||||
|
||||
return (
|
||||
<div className='h-full w-full overflow-hidden'>
|
||||
<ScrollArea className='h-full' hideScrollbar={true}>
|
||||
<div className='px-2 py-2'>
|
||||
{/* Document Tags Section */}
|
||||
<div className='mb-1 space-y-1'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Document Tags</div>
|
||||
<div>
|
||||
{/* Existing Tags */}
|
||||
<div>
|
||||
{documentTags.map((tag, index) => {
|
||||
return (
|
||||
<div key={index} className='mb-1'>
|
||||
<div
|
||||
className={`cursor-pointer rounded-[10px] border bg-card transition-colors hover:bg-muted ${editingTagIndex === index ? 'space-y-2 p-2' : 'p-2'}`}
|
||||
onClick={() => userPermissions.canEdit && toggleTagEditor(index)}
|
||||
>
|
||||
{/* Always show the tag display */}
|
||||
<div className='flex items-center justify-between text-sm'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
<div
|
||||
className='h-2 w-2 rounded-full'
|
||||
style={{ backgroundColor: getTagColor(tag.slot) }}
|
||||
/>
|
||||
<div className='truncate font-medium'>{tag.displayName}</div>
|
||||
</div>
|
||||
{userPermissions.canEdit && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveTag(index)
|
||||
}}
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Show edit form when this tag is being edited */}
|
||||
{editingTagIndex === index && (
|
||||
<div className='space-y-1.5' onClick={(e) => e.stopPropagation()}>
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Tag Name</Label>
|
||||
<div className='flex gap-1.5'>
|
||||
<Input
|
||||
value={editForm.displayName}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, displayName: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag name'
|
||||
className='h-8 min-w-0 flex-1 rounded-md text-sm'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
e.preventDefault()
|
||||
saveTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditing()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{availableDefinitions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 w-7 flex-shrink-0 p-0'
|
||||
>
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='w-[160px] rounded-lg border bg-card shadow-xs'
|
||||
>
|
||||
{availableDefinitions.map((def) => (
|
||||
<DropdownMenuItem
|
||||
key={def.id}
|
||||
onClick={() =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
displayName: def.displayName,
|
||||
fieldType: def.fieldType,
|
||||
})
|
||||
}
|
||||
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
|
||||
>
|
||||
{def.displayName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{nameConflict && (
|
||||
<div className='text-red-600 text-xs'>
|
||||
A tag with this name already exists on this document
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Type</Label>
|
||||
<Select
|
||||
value={editForm.fieldType}
|
||||
onValueChange={(value) =>
|
||||
setEditForm({ ...editForm, fieldType: value })
|
||||
}
|
||||
disabled={editingTagIndex !== null} // Disable in edit mode
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full text-sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Value</Label>
|
||||
<Input
|
||||
value={editForm.value}
|
||||
onChange={(e) =>
|
||||
setEditForm({ ...editForm, value: e.target.value })
|
||||
}
|
||||
placeholder='Enter tag value'
|
||||
className='h-8 w-full rounded-md text-sm'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
e.preventDefault()
|
||||
saveTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditing()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pt-1'>
|
||||
<Button
|
||||
onClick={saveTag}
|
||||
size='sm'
|
||||
className='h-7 w-full text-xs'
|
||||
disabled={!canSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{documentTags.length === 0 && !isCreating && (
|
||||
<div className='mb-1 rounded-[10px] border border-dashed bg-card p-3 text-center'>
|
||||
<p className='text-muted-foreground text-xs'>No tags added yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Tag Button or Inline Creator */}
|
||||
{!isEditing && userPermissions.canEdit && (
|
||||
<div className='mb-1'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={openTagCreator}
|
||||
className='w-full justify-start gap-2 rounded-[10px] border border-dashed bg-card text-muted-foreground hover:text-foreground'
|
||||
disabled={
|
||||
kbTagDefinitions.length >= MAX_TAG_SLOTS && availableDefinitions.length === 0
|
||||
}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
Add Tag
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Tag Creation Form */}
|
||||
{isCreating && (
|
||||
<div className='mb-1 w-full max-w-full space-y-2 rounded-[10px] border bg-card p-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label className='font-medium text-xs'>Tag Name</Label>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={cancelEditing}
|
||||
className='h-6 w-6 p-0 text-muted-foreground hover:text-red-600'
|
||||
>
|
||||
<X className='h-3 w-3' />
|
||||
</Button>
|
||||
</div>
|
||||
<div className='flex gap-1.5'>
|
||||
<Input
|
||||
value={editForm.displayName}
|
||||
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
|
||||
placeholder='Enter tag name'
|
||||
className='h-8 min-w-0 flex-1 rounded-md text-sm'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
e.preventDefault()
|
||||
saveTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditing()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{availableDefinitions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 w-7 flex-shrink-0 p-0'
|
||||
>
|
||||
<ChevronDown className='h-3 w-3' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='w-[160px] rounded-lg border bg-card shadow-xs'
|
||||
>
|
||||
{availableDefinitions.map((def) => (
|
||||
<DropdownMenuItem
|
||||
key={def.id}
|
||||
onClick={() =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
displayName: def.displayName,
|
||||
fieldType: def.fieldType,
|
||||
})
|
||||
}
|
||||
className='cursor-pointer rounded-md px-3 py-2 text-sm hover:bg-secondary/50'
|
||||
>
|
||||
{def.displayName}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
{nameConflict && (
|
||||
<div className='text-red-600 text-xs'>
|
||||
A tag with this name already exists on this document
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Type</Label>
|
||||
<Select
|
||||
value={editForm.fieldType}
|
||||
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-full text-sm'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value='text'>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label className='font-medium text-xs'>Value</Label>
|
||||
<Input
|
||||
value={editForm.value}
|
||||
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
|
||||
placeholder='Enter tag value'
|
||||
className='h-8 w-full rounded-md text-sm'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && canSave) {
|
||||
e.preventDefault()
|
||||
saveTag()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelEditing()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Warning when at max slots */}
|
||||
{kbTagDefinitions.length >= MAX_TAG_SLOTS && (
|
||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-800 dark:bg-amber-950'>
|
||||
<div className='text-amber-800 text-xs dark:text-amber-200'>
|
||||
<span className='font-medium'>Maximum tag definitions reached</span>
|
||||
</div>
|
||||
<p className='text-amber-700 text-xs dark:text-amber-300'>
|
||||
You can still use existing tag definitions, but cannot create new ones.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='pt-2'>
|
||||
<Button
|
||||
onClick={saveTag}
|
||||
size='sm'
|
||||
className='h-7 w-full text-xs'
|
||||
disabled={
|
||||
!canSave ||
|
||||
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||
!kbTagDefinitions.find(
|
||||
(def) =>
|
||||
def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
||||
))
|
||||
}
|
||||
>
|
||||
Create Tag
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-2 text-muted-foreground text-xs'>
|
||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
CreateMenu,
|
||||
FolderTree,
|
||||
HelpModal,
|
||||
KnowledgeBaseTags,
|
||||
KnowledgeTags,
|
||||
LogsFilters,
|
||||
SettingsModal,
|
||||
SubscriptionModal,
|
||||
@@ -250,6 +252,41 @@ export function Sidebar() {
|
||||
return logsPageRegex.test(pathname)
|
||||
}, [pathname])
|
||||
|
||||
// Check if we're on any knowledge base page (overview or document)
|
||||
const isOnKnowledgePage = useMemo(() => {
|
||||
// Pattern: /workspace/[workspaceId]/knowledge/[id] or /workspace/[workspaceId]/knowledge/[id]/[documentId]
|
||||
const knowledgePageRegex = /^\/workspace\/[^/]+\/knowledge\/[^/]+/
|
||||
return knowledgePageRegex.test(pathname)
|
||||
}, [pathname])
|
||||
|
||||
// Extract knowledge base ID and document ID from the pathname
|
||||
const { knowledgeBaseId, documentId } = useMemo(() => {
|
||||
if (!isOnKnowledgePage) {
|
||||
return { knowledgeBaseId: null, documentId: null }
|
||||
}
|
||||
|
||||
// Handle both KB overview (/knowledge/[kbId]) and document page (/knowledge/[kbId]/[docId])
|
||||
const kbOverviewMatch = pathname.match(/^\/workspace\/[^/]+\/knowledge\/([^/]+)$/)
|
||||
const docPageMatch = pathname.match(/^\/workspace\/[^/]+\/knowledge\/([^/]+)\/([^/]+)$/)
|
||||
|
||||
if (docPageMatch) {
|
||||
// Document page - has both kbId and docId
|
||||
return {
|
||||
knowledgeBaseId: docPageMatch[1],
|
||||
documentId: docPageMatch[2],
|
||||
}
|
||||
}
|
||||
if (kbOverviewMatch) {
|
||||
// KB overview page - has only kbId
|
||||
return {
|
||||
knowledgeBaseId: kbOverviewMatch[1],
|
||||
documentId: null,
|
||||
}
|
||||
}
|
||||
|
||||
return { knowledgeBaseId: null, documentId: null }
|
||||
}, [pathname, isOnKnowledgePage])
|
||||
|
||||
// Use optimized auto-scroll hook
|
||||
const { handleDragOver, stopScroll } = useAutoScroll(workflowScrollAreaRef)
|
||||
|
||||
@@ -1043,6 +1080,22 @@ export function Sidebar() {
|
||||
<LogsFilters />
|
||||
</div>
|
||||
|
||||
{/* Floating Knowledge Tags - Only on knowledge pages */}
|
||||
<div
|
||||
className={`pointer-events-auto fixed left-4 z-50 w-56 rounded-[10px] border bg-background shadow-xs ${
|
||||
!isOnKnowledgePage || isSidebarCollapsed || !knowledgeBaseId ? 'hidden' : ''
|
||||
}`}
|
||||
style={{
|
||||
top: `${toolbarTop}px`,
|
||||
bottom: `${navigationBottom + SIDEBAR_HEIGHTS.NAVIGATION + SIDEBAR_GAP + (isBillingEnabled ? SIDEBAR_HEIGHTS.USAGE_INDICATOR + SIDEBAR_GAP : 0)}px`, // Navigation height + gap + UsageIndicator height + gap (if billing enabled)
|
||||
}}
|
||||
>
|
||||
{knowledgeBaseId && documentId && (
|
||||
<KnowledgeTags knowledgeBaseId={knowledgeBaseId} documentId={documentId} />
|
||||
)}
|
||||
{knowledgeBaseId && !documentId && <KnowledgeBaseTags knowledgeBaseId={knowledgeBaseId} />}
|
||||
</div>
|
||||
|
||||
{/* Floating Usage Indicator - Only shown when billing enabled */}
|
||||
{isBillingEnabled && (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user