fix(kb-tags): docs page kb tags ui (#838)

* fix(kb-tags): docs page kb tags ui

* remove console logs

* remove console error
This commit is contained in:
Vikhyath Mondreti
2025-07-31 21:24:16 -07:00
committed by GitHub
parent 5b53cc2be6
commit 608964a8b3
2 changed files with 262 additions and 242 deletions

View File

@@ -6,10 +6,6 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { createLogger } from '@/lib/logs/console/logger'
import {
type DocumentTag,
DocumentTagEntry,
} from '@/app/workspace/[workspaceId]/knowledge/components/document-tag-entry/document-tag-entry'
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
const logger = createLogger('UploadModal')
@@ -50,7 +46,7 @@ export function UploadModal({
}: UploadModalProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<FileWithPreview[]>([])
const [tags, setTags] = useState<DocumentTag[]>([])
const [fileError, setFileError] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
@@ -66,7 +62,6 @@ export function UploadModal({
if (isUploading) return // Prevent closing during upload
setFiles([])
setTags([])
setFileError(null)
setIsDragging(false)
onOpenChange(false)
@@ -145,23 +140,7 @@ export function UploadModal({
if (files.length === 0) return
try {
// Convert DocumentTag array to TagData format
const tagData: Record<string, string> = {}
tags.forEach((tag) => {
if (tag.value.trim()) {
tagData[tag.slot] = tag.value.trim()
}
})
// Create files with tags for upload
const filesWithTags = files.map((file) => {
// Add tags as custom properties to the file object
const fileWithTags = file as unknown as File & Record<string, string>
Object.assign(fileWithTags, tagData)
return fileWithTags
})
await uploadFiles(filesWithTags, knowledgeBaseId, {
await uploadFiles(files, knowledgeBaseId, {
chunkSize: chunkingConfig?.maxSize || 1024,
minCharactersPerChunk: chunkingConfig?.minSize || 100,
chunkOverlap: chunkingConfig?.overlap || 200,
@@ -180,19 +159,6 @@ export function UploadModal({
</DialogHeader>
<div className='flex-1 space-y-6 overflow-auto'>
{/* Document Tag Entry Section */}
<DocumentTagEntry
tags={tags}
onTagsChange={setTags}
disabled={isUploading}
knowledgeBaseId={knowledgeBaseId}
documentId={null} // No specific document for upload
onSave={async () => {
// For upload modal, tags are saved when document is uploaded
// This is a placeholder as tags will be applied during upload
}}
/>
{/* File Upload Section */}
<div className='space-y-3'>
<Label>Select Files</Label>

View File

@@ -3,14 +3,23 @@
import { useState } from 'react'
import { ChevronDown, Plus, X } from 'lucide-react'
import {
Badge,
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
formatDisplayText,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/constants/knowledge'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
@@ -29,7 +38,7 @@ interface DocumentTagEntryProps {
disabled?: boolean
knowledgeBaseId: string
documentId: string | null
onSave: (tagsToSave: DocumentTag[]) => Promise<void>
onSave?: (tagsToSave: DocumentTag[]) => Promise<void>
}
export function DocumentTagEntry({
@@ -40,19 +49,26 @@ export function DocumentTagEntry({
documentId,
onSave,
}: DocumentTagEntryProps) {
const { saveTagDefinitions } = useTagDefinitions(knowledgeBaseId, documentId)
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// Use different hooks based on whether we have a documentId
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
const [editingTag, setEditingTag] = useState<{
index: number
value: string
tagName: string
isNew: boolean
} | null>(null)
// Use the document-level hook since we have documentId
const { saveTagDefinitions } = documentTagHook
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
// Modal state for tag editing
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editForm, setEditForm] = useState({
displayName: '',
fieldType: 'text',
value: '',
})
const getNextAvailableSlot = (): DocumentTag['slot'] => {
const usedSlots = new Set(tags.map((tag) => tag.slot))
// Check which slots are used at the KB level (tag definitions)
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
for (const slot of TAG_SLOTS) {
if (!usedSlots.has(slot)) {
return slot
@@ -61,83 +77,114 @@ export function DocumentTagEntry({
return TAG_SLOTS[0] // Fallback to first slot if all are used
}
const handleAddTag = () => {
if (tags.length >= MAX_TAG_SLOTS) return
const newTag: DocumentTag = {
slot: getNextAvailableSlot(),
displayName: '',
fieldType: 'text',
value: '',
}
const updatedTags = [...tags, newTag]
onTagsChange(updatedTags)
// Set editing state for the new tag
setEditingTag({
index: updatedTags.length - 1,
value: '',
tagName: '',
isNew: true,
})
}
const handleRemoveTag = (index: number) => {
const updatedTags = tags.filter((_, i) => i !== index)
onTagsChange(updatedTags)
}
const handleTagUpdate = (index: number, field: keyof DocumentTag, value: string) => {
const updatedTags = [...tags]
updatedTags[index] = { ...updatedTags[index], [field]: value }
onTagsChange(updatedTags)
// Open modal to edit tag
const openTagModal = (index: number) => {
const tag = tags[index]
setEditingTagIndex(index)
setEditForm({
displayName: tag.displayName,
fieldType: tag.fieldType,
value: tag.value,
})
setModalOpen(true)
}
const handleSaveTag = async (index: number, tagName: string) => {
if (!tagName.trim()) return
// Check if this is creating a new tag definition
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
)
if (!existingDefinition) {
// Create new tag definition
const newDefinition: TagDefinitionInput = {
displayName: tagName,
fieldType: 'text',
tagSlot: tags[index].slot as TagSlot,
}
try {
await saveTagDefinitions([newDefinition])
await refreshTagDefinitions()
} catch (error) {
console.error('Failed to save tag definition:', error)
return
}
}
// Update the tag
handleTagUpdate(index, 'displayName', tagName)
setEditingTag(null)
// Open modal to create new tag
const openNewTagModal = () => {
setEditingTagIndex(null)
setEditForm({
displayName: '',
fieldType: 'text',
value: '',
})
setModalOpen(true)
}
const handleCancelEdit = () => {
if (editingTag?.isNew) {
// Remove the new tag if editing was cancelled
handleRemoveTag(editingTag.index)
}
setEditingTag(null)
}
// Save tag from modal
const saveTagFromModal = async () => {
if (!editForm.displayName.trim()) return
const handleSaveAll = async () => {
try {
await onSave(tags)
} catch (error) {
console.error('Failed to save tags:', error)
}
if (editingTagIndex !== null) {
// Editing existing tag
const updatedTags = [...tags]
updatedTags[editingTagIndex] = {
...updatedTags[editingTagIndex],
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
onTagsChange(updatedTags)
} else {
// Creating new tag - calculate slot once
const newSlot = getNextAvailableSlot()
const newTag: DocumentTag = {
slot: newSlot,
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
const newTags = [...tags, newTag]
onTagsChange(newTags)
}
// Auto-save tag definition if it's a new name
const existingDefinition = kbTagDefinitions.find(
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
)
if (!existingDefinition) {
// Use the same slot for both tag and definition
const targetSlot =
editingTagIndex !== null ? tags[editingTagIndex].slot : getNextAvailableSlot()
const newDefinition: TagDefinitionInput = {
displayName: editForm.displayName,
fieldType: editForm.fieldType,
tagSlot: targetSlot as TagSlot,
}
if (saveTagDefinitions) {
await saveTagDefinitions([newDefinition])
} else {
throw new Error('Cannot save tag definitions without a document ID')
}
await refreshTagDefinitions()
}
// Save the actual document tags if onSave is provided
if (onSave) {
const updatedTags =
editingTagIndex !== null
? tags.map((tag, index) =>
index === editingTagIndex
? {
...tag,
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
}
: tag
)
: [
...tags,
{
slot: getNextAvailableSlot(),
displayName: editForm.displayName,
fieldType: editForm.fieldType,
value: editForm.value,
},
]
await onSave(updatedTags)
}
setModalOpen(false)
} catch (error) {}
}
// Filter available tag definitions (exclude already used ones)
@@ -149,142 +196,149 @@ export function DocumentTagEntry({
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<h3 className='font-medium text-sm'>Document Tags</h3>
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={handleAddTag}
disabled={disabled || tags.length >= MAX_TAG_SLOTS}
>
<Plus className='mr-1 h-3 w-3' />
Add Tag
</Button>
<Button variant='default' size='sm' onClick={handleSaveAll} disabled={disabled}>
Save Tags
</Button>
</div>
</div>
{tags.length === 0 ? (
{/* Tags as Badges */}
<div className='flex flex-wrap gap-2'>
{tags.map((tag, index) => (
<Badge
key={index}
variant='outline'
className='cursor-pointer gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-accent'
onClick={() => openTagModal(index)}
>
<span className='font-medium'>{tag.displayName || 'Unnamed Tag'}</span>
{tag.value && (
<>
<span className='text-muted-foreground'>:</span>
<span className='text-muted-foreground'>{tag.value}</span>
</>
)}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
handleRemoveTag(index)
}}
disabled={disabled}
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-3 w-3' />
</Button>
</Badge>
))}
{/* Add Tag Button */}
<Button
variant='outline'
size='sm'
onClick={openNewTagModal}
disabled={disabled || tags.length >= MAX_TAG_SLOTS}
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
>
<Plus className='h-4 w-4' />
Add Tag
</Button>
</div>
{tags.length === 0 && (
<div className='rounded-md border border-dashed p-4 text-center'>
<p className='text-muted-foreground text-sm'>No tags added yet</p>
</div>
) : (
<div className='space-y-2'>
{tags.map((tag, index) => (
<div key={index} className='flex items-center gap-2 rounded-md border p-3'>
<div className='flex-1'>
{editingTag?.index === index ? (
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<Input
value={editingTag.tagName}
onChange={(e) => setEditingTag({ ...editingTag, tagName: e.target.value })}
placeholder='Tag name'
className='flex-1'
autoFocus
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm'>
Select Existing <ChevronDown className='ml-1 h-3 w-3' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{availableDefinitions.map((def) => (
<DropdownMenuItem
key={def.id}
onClick={() =>
setEditingTag({ ...editingTag, tagName: def.displayName })
}
>
{def.displayName}
</DropdownMenuItem>
))}
{availableDefinitions.length === 0 && (
<DropdownMenuItem disabled>No available tags</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className='flex items-center gap-2'>
<Button
size='sm'
onClick={() => handleSaveTag(index, editingTag.tagName)}
disabled={!editingTag.tagName.trim()}
>
Save
</Button>
<Button variant='outline' size='sm' onClick={handleCancelEdit}>
Cancel
</Button>
</div>
</div>
) : (
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<div>
<div className='font-medium text-sm'>
{tag.displayName || 'Unnamed Tag'}
</div>
<div className='text-muted-foreground text-xs'>
Slot: {tag.slot} Type: {tag.fieldType}
</div>
</div>
<Button
variant='ghost'
size='sm'
onClick={() =>
setEditingTag({
index,
value: tag.value,
tagName: tag.displayName,
isNew: false,
})
}
disabled={disabled}
>
Edit Name
</Button>
</div>
<div className='flex items-center gap-2'>
<Label htmlFor={`tag-value-${index}`} className='text-xs'>
Value:
</Label>
<div className='relative flex-1'>
<Input
id={`tag-value-${index}`}
value={tag.value}
onChange={(e) => handleTagUpdate(index, 'value', e.target.value)}
placeholder='Enter tag value'
disabled={disabled}
className='w-full text-transparent caret-foreground'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>{formatDisplayText(tag.value)}</div>
</div>
</div>
</div>
</div>
)}
</div>
<Button
variant='ghost'
size='sm'
onClick={() => handleRemoveTag(index)}
disabled={disabled}
className='text-red-600 hover:text-red-800'
>
<X className='h-4 w-4' />
</Button>
</div>
))}
<p className='text-muted-foreground text-sm'>
No tags added yet. Click "Add Tag" to get started.
</p>
</div>
)}
<div className='text-muted-foreground text-xs'>
{tags.length} of {MAX_TAG_SLOTS} tag slots used
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
</div>
{/* Tag Edit Modal */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className='sm:max-w-md'>
<DialogHeader>
<DialogTitle>{editingTagIndex !== null ? 'Edit Tag' : 'Add New Tag'}</DialogTitle>
</DialogHeader>
<div className='space-y-4'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='tag-name'>Tag Name</Label>
<div className='flex gap-2'>
<Input
id='tag-name'
value={editForm.displayName}
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
placeholder='Enter tag name'
className='flex-1'
/>
{availableDefinitions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant='outline' size='sm'>
<ChevronDown className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
{availableDefinitions.map((def) => (
<DropdownMenuItem
key={def.id}
onClick={() =>
setEditForm({
...editForm,
displayName: def.displayName,
fieldType: def.fieldType,
})
}
>
{def.displayName}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Tag Type */}
<div className='space-y-2'>
<Label htmlFor='tag-type'>Type</Label>
<Select
value={editForm.fieldType}
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
</SelectContent>
</Select>
</div>
{/* Tag Value */}
<div className='space-y-2'>
<Label htmlFor='tag-value'>Value</Label>
<Input
id='tag-value'
value={editForm.value}
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
placeholder='Enter tag value'
/>
</div>
</div>
<div className='flex justify-end gap-2 pt-4'>
<Button variant='outline' onClick={() => setModalOpen(false)}>
Cancel
</Button>
<Button onClick={saveTagFromModal} disabled={!editForm.displayName.trim()}>
{editingTagIndex !== null ? 'Save Changes' : 'Add Tag'}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}