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:
Waleed Latif
2025-08-12 01:53:47 -07:00
committed by GitHub
parent 3c7b3e1a4b
commit e1d5e38528
11 changed files with 2194 additions and 506 deletions

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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