improvement(doc-tags-subblock): use table for doc tags subblock in create_document tool for KB (#827)

* improvement(doc-tags-subblock): use table for doc tags create doc tool in KB block

* enforce max tags

* remove red warning text
This commit is contained in:
Vikhyath Mondreti
2025-07-30 12:59:47 -07:00
committed by GitHub
parent 27e49217cc
commit 1b929c72a5

View File

@@ -1,18 +1,29 @@
'use client'
import { Plus, X } from 'lucide-react'
import { useMemo, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { MAX_TAG_SLOTS } from '@/lib/constants/knowledge'
import { cn } from '@/lib/utils'
import type { SubBlockConfig } from '@/blocks/types'
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
interface DocumentTag {
interface DocumentTagRow {
id: string
tagName: string // This will be mapped to displayName for API
fieldType: string
value: string
cells: {
tagName: string
type: string
value: string
}
}
interface DocumentTagEntryProps {
@@ -32,7 +43,7 @@ export function DocumentTagEntry({
previewValue,
isConnecting = false,
}: DocumentTagEntryProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id)
const [storeValue, setStoreValue] = useSubBlockValue<string>(blockId, subBlock.id)
// Get the knowledge base ID from other sub-blocks
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
@@ -41,176 +52,345 @@ export function DocumentTagEntry({
// Use KB tag definitions hook to get available tags
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
// Parse the current value to extract tags
const parseTags = (tagValue: string): DocumentTag[] => {
if (!tagValue) return []
try {
return JSON.parse(tagValue)
} catch {
return []
}
}
// State for dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// Use preview value when in preview mode, otherwise use store value
const currentValue = isPreview ? previewValue : storeValue
const tags = parseTags(currentValue || '')
const updateTags = (newTags: DocumentTag[]) => {
if (isPreview) return
const value = newTags.length > 0 ? JSON.stringify(newTags) : null
setStoreValue(value)
// Transform stored JSON string to table format for display
const rows = useMemo(() => {
// If we have stored data, use it
if (currentValue) {
try {
const tagData = JSON.parse(currentValue)
if (Array.isArray(tagData) && tagData.length > 0) {
return tagData.map((tag: any, index: number) => ({
id: `tag-${index}`,
cells: {
tagName: tag.tagName || '',
type: tag.fieldType || 'text',
value: tag.value || '',
},
}))
}
} catch {
// If parsing fails, fall through to default
}
}
// Default: just one empty row
return [
{
id: 'empty-row',
cells: { tagName: '', type: 'text', value: '' },
},
]
}, [currentValue])
// Get available tag names and check for case-insensitive duplicates
const usedTagNames = new Set(
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name && name.trim())
)
const availableTagDefinitions = tagDefinitions.filter(
(def) => !usedTagNames.has(def.displayName.toLowerCase())
)
// Check if we can add more tags based on MAX_TAG_SLOTS
const newTagsBeingCreated = rows.filter(
(row) =>
row.cells.tagName?.trim() &&
!tagDefinitions.some(
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
)
).length
const canAddMoreTags = tagDefinitions.length + newTagsBeingCreated < MAX_TAG_SLOTS
// Function to pre-fill existing tags
const handlePreFillTags = () => {
if (isPreview || disabled) return
const existingTagRows = tagDefinitions.map((tagDef) => ({
tagName: tagDef.displayName,
fieldType: tagDef.fieldType,
value: '',
}))
const jsonString = existingTagRows.length > 0 ? JSON.stringify(existingTagRows) : ''
setStoreValue(jsonString)
}
const removeTag = (tagId: string) => {
updateTags(tags.filter((t) => t.id !== tagId))
const handleCellChange = (rowIndex: number, column: string, value: string) => {
if (isPreview || disabled) return
// Check if this is a new tag name that would exceed the limit
if (column === 'tagName' && value.trim()) {
const isExistingTag = tagDefinitions.some(
(def) => def.displayName.toLowerCase() === value.toLowerCase()
)
if (!isExistingTag) {
// Count current new tags being created (excluding the current row)
const currentNewTags = rows.filter(
(row, idx) =>
idx !== rowIndex &&
row.cells.tagName?.trim() &&
!tagDefinitions.some(
(def) => def.displayName.toLowerCase() === row.cells.tagName.toLowerCase()
)
).length
if (tagDefinitions.length + currentNewTags >= MAX_TAG_SLOTS) {
// Don't allow creating new tags if we've reached the limit
return
}
}
}
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
const newCells = { ...row.cells, [column]: value }
// Auto-select type when existing tag is selected
if (column === 'tagName' && value) {
const tagDef = tagDefinitions.find(
(def) => def.displayName.toLowerCase() === value.toLowerCase()
)
if (tagDef) {
newCells.type = tagDef.fieldType
}
}
return {
...row,
cells: newCells,
}
}
return row
})
// No auto-add rows - user will manually add them with plus button
// Store all rows including empty ones - don't auto-remove
const dataToStore = updatedRows.map((row) => ({
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
}))
const jsonString = dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
setStoreValue(jsonString)
}
const updateTag = (tagId: string, updates: Partial<DocumentTag>) => {
updateTags(tags.map((tag) => (tag.id === tagId ? { ...tag, ...updates } : tag)))
const handleAddRow = () => {
if (isPreview || disabled) return
// Get current data and add a new empty row
const currentData = currentValue ? JSON.parse(currentValue) : []
const newData = [...currentData, { tagName: '', fieldType: 'text', value: '' }]
setStoreValue(JSON.stringify(newData))
}
// Get available tag names that aren't already used
const usedTagNames = new Set(tags.map((tag) => tag.tagName).filter(Boolean))
const availableTagNames = tagDefinitions
.map((def) => def.displayName)
.filter((name) => !usedTagNames.has(name))
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
// Store all remaining rows including empty ones - don't auto-remove
const tableDataForStorage = updatedRows.map((row) => ({
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
value: row.cells.value || '',
}))
const jsonString = tableDataForStorage.length > 0 ? JSON.stringify(tableDataForStorage) : ''
setStoreValue(jsonString)
}
// Check for duplicate tag names (case-insensitive)
const getDuplicateStatus = (rowIndex: number, tagName: string) => {
if (!tagName.trim()) return false
const lowerTagName = tagName.toLowerCase()
return rows.some(
(row, idx) =>
idx !== rowIndex &&
row.cells.tagName?.toLowerCase() === lowerTagName &&
row.cells.tagName.trim()
)
}
if (isLoading) {
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
}
return (
<div className='space-y-4'>
{/* Available Tags Section */}
{availableTagNames.length > 0 && (
<div>
<div className='mb-2 font-medium text-muted-foreground text-sm'>
Available Tags (click to add)
</div>
<div className='flex flex-wrap gap-2'>
{availableTagNames.map((tagName) => {
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
return (
<button
key={tagName}
onClick={() => {
// Check for duplicates before adding
if (!usedTagNames.has(tagName)) {
const newTag: DocumentTag = {
id: Date.now().toString(),
tagName,
fieldType: tagDef?.fieldType || 'text',
value: '',
}
updateTags([...tags, newTag])
}
}}
disabled={disabled || isConnecting}
className='inline-flex items-center gap-1 rounded-full border border-gray-300 border-dashed bg-gray-50 px-3 py-1 text-gray-600 text-sm transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-700 disabled:opacity-50'
>
<Plus className='h-3 w-3' />
{tagName}
<span className='text-muted-foreground text-xs'>
({tagDef?.fieldType || 'text'})
</span>
</button>
)
})}
</div>
</div>
)}
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='px-4 py-2 text-center font-medium text-sm border-r'>Tag Name</th>
<th className='px-4 py-2 text-center font-medium text-sm border-r'>Type</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
</tr>
</thead>
)
{/* Selected Tags Section */}
{tags.length > 0 && (
<div>
<div className='space-y-2'>
{tags.map((tag) => (
<div key={tag.id} className='flex items-center gap-2 rounded-lg border bg-white p-3'>
{/* Tag Name */}
<div className='flex-1'>
<div className='font-medium text-gray-900 text-sm'>
{tag.tagName || 'Unnamed Tag'}
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
const showDropdown = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
return (
<td className='relative p-1 border-r'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
onFocus={() => setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 200)}
disabled={disabled || isConnecting}
className={cn(isDuplicate && 'border-red-500 bg-red-50')}
/>
{showDropdown && availableTagDefinitions.length > 0 && (
<div className='absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-md max-h-60 overflow-auto'>
{availableTagDefinitions
.filter((tagDef) =>
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
)
.map((tagDef) => (
<div
key={tagDef.id}
className='px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground'
onMouseDown={() => {
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
setShowDropdown(false)
}}
>
{tagDef.displayName}
</div>
<div className='text-muted-foreground text-xs'>{tag.fieldType}</div>
</div>
))}
</div>
)}
</div>
</td>
)
}
{/* Value Input */}
<div className='flex-1'>
<Input
value={tag.value}
onChange={(e) => updateTag(tag.id, { value: e.target.value })}
placeholder='Value'
disabled={disabled || isConnecting}
className='h-9 placeholder:text-xs'
type={tag.fieldType === 'number' ? 'number' : 'text'}
/>
</div>
const renderTypeCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.type || 'text'
const tagName = row.cells.tagName || ''
{/* Remove Button */}
<Button
onClick={() => removeTag(tag.id)}
variant='ghost'
size='sm'
disabled={disabled || isConnecting}
className='h-9 w-9 p-0 text-muted-foreground hover:text-red-600'
>
<X className='h-4 w-4' />
</Button>
</div>
))}
</div>
// Check if this is an existing tag (should be read-only)
const existingTag = tagDefinitions.find(
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
)
const isReadOnly = !!existingTag
return (
<td className='p-1 border-r'>
<Select
value={cellValue}
onValueChange={(value) => handleCellChange(rowIndex, 'type', value)}
disabled={disabled || isConnecting || isReadOnly}
>
<SelectTrigger
className={cn(
isReadOnly && 'bg-gray-50 dark:bg-gray-800',
'text-foreground' // Ensure proper text color in dark mode
)}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='text'>Text</SelectItem>
<SelectItem value='number'>Number</SelectItem>
<SelectItem value='date'>Date</SelectItem>
</SelectContent>
</Select>
</td>
)
}
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
return (
<td className='p-1'>
<Input
value={cellValue}
onChange={(e) => handleCellChange(rowIndex, 'value', e.target.value)}
disabled={disabled || isConnecting}
/>
</td>
)
}
const renderDeleteButton = (rowIndex: number) => {
// Allow deletion of any row
const canDelete = !isPreview && !disabled
return canDelete ? (
<td className='w-0 p-0'>
<Button
variant='ghost'
size='icon'
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash2 className='h-4 w-4 text-muted-foreground' />
</Button>
</td>
) : null
}
// Show pre-fill button if there are available tags and only empty rows
const showPreFillButton =
tagDefinitions.length > 0 &&
rows.length === 1 &&
!rows[0].cells.tagName &&
!rows[0].cells.value &&
!isPreview &&
!disabled
return (
<div className='relative'>
{showPreFillButton && (
<div className='mb-2'>
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
Prefill Existing Tags
</Button>
</div>
)}
{/* Create New Tag Section */}
<div>
<div className='mb-2 font-medium text-muted-foreground text-sm'>Create New Tag</div>
<div className='flex items-center gap-2 rounded-lg border border-gray-300 border-dashed bg-gray-50 p-3'>
<div className='flex-1'>
<Input
placeholder={tagDefinitions.length >= MAX_TAG_SLOTS ? '' : 'Tag name'}
disabled={disabled || isConnecting || tagDefinitions.length >= MAX_TAG_SLOTS}
className='h-9 border-0 bg-transparent p-0 placeholder:text-xs focus-visible:ring-0'
onKeyDown={(e) => {
if (e.key === 'Enter' && e.currentTarget.value.trim()) {
const tagName = e.currentTarget.value.trim()
// Check for duplicates
if (usedTagNames.has(tagName)) {
// Visual feedback for duplicate - could add toast notification here
e.currentTarget.style.borderColor = '#ef4444'
setTimeout(() => {
e.currentTarget.style.borderColor = ''
}, 1000)
return
}
const newTag: DocumentTag = {
id: Date.now().toString(),
tagName,
fieldType: 'text',
value: '',
}
updateTags([...tags, newTag])
e.currentTarget.value = ''
}
}}
/>
</div>
<div className='text-muted-foreground text-xs'>
{tagDefinitions.length >= MAX_TAG_SLOTS
? `All ${MAX_TAG_SLOTS} tag slots used in this knowledge base`
: usedTagNames.size > 0
? 'Press Enter (no duplicates)'
: 'Press Enter to add'}
</div>
</div>
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
{renderHeader()}
<tbody>
{rows.map((row, rowIndex) => (
<tr key={row.id} className='group relative border-t'>
{renderTagNameCell(row, rowIndex)}
{renderTypeCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
))}
</tbody>
</table>
</div>
{/* Empty State */}
{tags.length === 0 && availableTagNames.length === 0 && (
<div className='py-8 text-center text-muted-foreground'>
<div className='text-sm'>No tags available</div>
<div className='text-xs'>Create a new tag above to get started</div>
{/* Add Row Button */}
{!isPreview && !disabled && (
<div className='flex flex-col items-center mt-3 gap-2'>
<Button variant='outline' size='sm' onClick={handleAddRow} disabled={!canAddMoreTags}>
<Plus className='h-3 w-3 mr-1' />
Add Tag
</Button>
{/* Tag slots usage indicator */}
<div className='text-xs text-muted-foreground text-center'>
{tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used
</div>
</div>
)}
</div>