mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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:
committed by
GitHub
parent
27e49217cc
commit
1b929c72a5
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user