feat(kb): Adding support for more tags to the KB (#2433)

* creating boolean, number and date tags with different equality matchings

* feat: add UI for tag field types with filter operators

- Update base-tags-modal with field type selector dropdown
- Update document-tags-modal with different input types per fieldType
- Update knowledge-tag-filters with operator dropdown and type-specific inputs
- Update search routes to support all tag slot types
- Update hook to use AllTagSlot type

* feat: add field type support to document-tag-entry component

- Add dropdown with all field types (Text, Number, Date, Boolean)
- Render different value inputs based on field type
- Update slot counting to include all field types (28 total)

* fix: resolve MAX_TAG_SLOTS error and z-index dropdown issue

- Replace MAX_TAG_SLOTS with totalSlots in document-tag-entry
- Add z-index to SelectContent in base-tags-modal for proper layering

* fix: handle non-text columns in getTagUsage query

- Only apply empty string check for text columns (tag1-tag7)
- Numeric/date/boolean columns only check IS NOT NULL
- Cast values to text for consistent output

* refactor: use EMCN components for KB UI

- Replace @/components/ui imports with @/components/emcn
- Use Combobox instead of Select for dropdowns
- Use EMCN Switch, Button, Input, Label components
- Remove unsupported 'size' prop from EMCN Button

* fix: layout for delete button next to date picker

- Change delete button from absolute to inline positioning
- Add proper column width (w-10) for delete button
- Add empty header cell for delete column
- Apply fix to both document-tag-entry and knowledge-tag-filters

* fix: clear value when switching tag field type

- Reset value to empty when changing type (e.g., boolean to text)
- Reset value when tag name changes and type differs
- Prevents 'true'/'false' from sticking in text inputs

* feat: add full support for number/date/boolean tag filtering in KB search

- Copy all tag types (number, date, boolean) from document to embedding records
- Update processDocumentTags to handle all field types with proper type conversion
- Add number/date/boolean columns to document queries in checkDocumentWriteAccess
- Update chunk creation to inherit all tag types from parent document
- Add getSearchResultFields helper for consistent query result selection
- Support structured filters with operators (eq, gt, lt, between, etc.)
- Fix search queries to include all 28 tag fields in results

* fixing tags import issue

* fix rm file

* reduced number to 3 and date to 2

* fixing lint

* fixed the prop size issue

* increased number from 3 to 5 and boolean from 7 to 2

* fixed number the sql stuff

* progress

* fix document tag and kb tag modals

* update datepicker emcn component

* fix ui

* progress on KB block tags UI

* fix issues with date filters

* fix execution parsing of types for KB tags

* remove migration before merge

* regen migrations

* fix tests and types

* address greptile comments

* fix more greptile comments

* fix filtering logic for multiple of same row

* fix tests

---------

Co-authored-by: priyanshu.solanki <priyanshu.solanki@saviynt.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Priyanshu Solanki
2025-12-19 22:00:35 -07:00
committed by GitHub
parent a1a189f328
commit 4f69b171f2
36 changed files with 11459 additions and 947 deletions

View File

@@ -156,6 +156,7 @@ export async function POST(
const validatedData = CreateChunkSchema.parse(searchParams)
const docTags = {
// Text tags (7 slots)
tag1: doc.tag1 ?? null,
tag2: doc.tag2 ?? null,
tag3: doc.tag3 ?? null,
@@ -163,6 +164,19 @@ export async function POST(
tag5: doc.tag5 ?? null,
tag6: doc.tag6 ?? null,
tag7: doc.tag7 ?? null,
// Number tags (5 slots)
number1: doc.number1 ?? null,
number2: doc.number2 ?? null,
number3: doc.number3 ?? null,
number4: doc.number4 ?? null,
number5: doc.number5 ?? null,
// Date tags (2 slots)
date1: doc.date1 ?? null,
date2: doc.date2 ?? null,
// Boolean tags (3 slots)
boolean1: doc.boolean1 ?? null,
boolean2: doc.boolean2 ?? null,
boolean3: doc.boolean3 ?? null,
}
const newChunk = await createChunk(

View File

@@ -72,6 +72,16 @@ describe('Document By ID API Route', () => {
tag5: null,
tag6: null,
tag7: null,
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
date1: null,
date2: null,
boolean1: null,
boolean2: null,
boolean3: null,
deletedAt: null,
}

View File

@@ -23,7 +23,7 @@ const UpdateDocumentSchema = z.object({
processingError: z.string().optional(),
markFailedDueToTimeout: z.boolean().optional(),
retryProcessing: z.boolean().optional(),
// Tag fields
// Text tag fields
tag1: z.string().optional(),
tag2: z.string().optional(),
tag3: z.string().optional(),
@@ -31,6 +31,19 @@ const UpdateDocumentSchema = z.object({
tag5: z.string().optional(),
tag6: z.string().optional(),
tag7: z.string().optional(),
// Number tag fields
number1: z.string().optional(),
number2: z.string().optional(),
number3: z.string().optional(),
number4: z.string().optional(),
number5: z.string().optional(),
// Date tag fields
date1: z.string().optional(),
date2: z.string().optional(),
// Boolean tag fields
boolean1: z.string().optional(),
boolean2: z.string().optional(),
boolean3: z.string().optional(),
})
export async function GET(

View File

@@ -80,6 +80,16 @@ describe('Knowledge Base Documents API Route', () => {
tag5: null,
tag6: null,
tag7: null,
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
date1: null,
date2: null,
boolean1: null,
boolean2: null,
boolean3: null,
deletedAt: null,
}

View File

@@ -64,6 +64,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess,
}))
const mockGetDocumentTagDefinitions = vi.fn()
vi.mock('@/lib/knowledge/tags/service', () => ({
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
}))
const mockHandleTagOnlySearch = vi.fn()
const mockHandleVectorOnlySearch = vi.fn()
const mockHandleTagAndVectorSearch = vi.fn()
@@ -156,6 +161,7 @@ describe('Knowledge Search API Route', () => {
doc1: 'Document 1',
doc2: 'Document 2',
})
mockGetDocumentTagDefinitions.mockClear()
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
@@ -659,8 +665,8 @@ describe('Knowledge Search API Route', () => {
describe('Optional Query Search', () => {
const mockTagDefinitions = [
{ tagSlot: 'tag1', displayName: 'category' },
{ tagSlot: 'tag2', displayName: 'priority' },
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
]
const mockTaggedResults = [
@@ -689,9 +695,7 @@ describe('Knowledge Search API Route', () => {
it('should perform tag-only search without query', async () => {
const tagOnlyData = {
knowledgeBaseIds: 'kb-123',
filters: {
category: 'api',
},
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
topK: 10,
}
@@ -706,10 +710,11 @@ describe('Knowledge Search API Route', () => {
},
})
// Mock tag definitions queries for filter mapping and display mapping
mockDbChain.limit
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
// Mock tag definitions for validation
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
// Mock tag definitions queries for display mapping
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
// Mock the tag-only search handler
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
@@ -729,7 +734,9 @@ describe('Knowledge Search API Route', () => {
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
structuredFilters: [
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
],
})
})
@@ -737,9 +744,7 @@ describe('Knowledge Search API Route', () => {
const combinedData = {
knowledgeBaseIds: 'kb-123',
query: 'test search',
filters: {
category: 'api',
},
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
topK: 10,
}
@@ -754,10 +759,11 @@ describe('Knowledge Search API Route', () => {
},
})
// Mock tag definitions queries for filter mapping and display mapping
mockDbChain.limit
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
// Mock tag definitions for validation
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
// Mock tag definitions queries for display mapping
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
// Mock the tag + vector search handler
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
@@ -784,7 +790,9 @@ describe('Knowledge Search API Route', () => {
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { category: 'api' }, // Note: When no tag definitions are found, it uses the original filter key
structuredFilters: [
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
],
queryVector: JSON.stringify(mockEmbedding),
distanceThreshold: 1, // Single KB uses threshold of 1.0
})
@@ -928,10 +936,10 @@ describe('Knowledge Search API Route', () => {
it('should handle tag-only search with multiple knowledge bases', async () => {
const multiKbTagData = {
knowledgeBaseIds: ['kb-123', 'kb-456'],
filters: {
category: 'docs',
priority: 'high',
},
tagFilters: [
{ tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' },
{ tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' },
],
topK: 10,
}
@@ -951,37 +959,14 @@ describe('Knowledge Search API Route', () => {
knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' },
})
// Reset all mocks before setting up specific behavior
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear().mockReturnThis()
}
})
// Mock tag definitions for validation
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
// Create fresh mocks for multiple database calls needed for multi-KB tag search
const mockTagDefsQuery1 = {
...mockDbChain,
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
}
const mockTagSearchQuery = {
...mockDbChain,
limit: vi.fn().mockResolvedValue(mockTaggedResults),
}
const mockTagDefsQuery2 = {
...mockDbChain,
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
}
const mockTagDefsQuery3 = {
...mockDbChain,
limit: vi.fn().mockResolvedValue(mockTagDefinitions),
}
// Mock the tag-only search handler
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
// Chain the mocks for: tag defs, search, display mapping KB1, display mapping KB2
mockDbChain.select
.mockReturnValueOnce(mockTagDefsQuery1)
.mockReturnValueOnce(mockTagSearchQuery)
.mockReturnValueOnce(mockTagDefsQuery2)
.mockReturnValueOnce(mockTagDefsQuery3)
// Mock tag definitions queries for display mapping
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
const req = createMockRequest('POST', multiKbTagData)
const { POST } = await import('@/app/api/knowledge/search/route')
@@ -1076,6 +1061,11 @@ describe('Knowledge Search API Route', () => {
},
})
// Mock tag definitions for validation
mockGetDocumentTagDefinitions.mockResolvedValue([
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
])
mockHandleTagOnlySearch.mockResolvedValue([
{
id: 'chunk-2',
@@ -1108,13 +1098,15 @@ describe('Knowledge Search API Route', () => {
const mockTagDefs = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
where: vi
.fn()
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
}
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
const req = createMockRequest('POST', {
knowledgeBaseIds: ['kb-123'],
filters: { tag1: 'api' },
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
topK: 10,
})
@@ -1143,6 +1135,11 @@ describe('Knowledge Search API Route', () => {
},
})
// Mock tag definitions for validation
mockGetDocumentTagDefinitions.mockResolvedValue([
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
])
mockHandleTagAndVectorSearch.mockResolvedValue([
{
id: 'chunk-3',
@@ -1176,14 +1173,16 @@ describe('Knowledge Search API Route', () => {
const mockTagDefs = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
where: vi
.fn()
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
}
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
const req = createMockRequest('POST', {
knowledgeBaseIds: ['kb-123'],
query: 'relevant content',
filters: { tag1: 'guide' },
tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }],
topK: 10,
})

View File

@@ -1,8 +1,10 @@
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { generateRequestId } from '@/lib/core/utils/request'
import { TAG_SLOTS } from '@/lib/knowledge/constants'
import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants'
import { getDocumentTagDefinitions } from '@/lib/knowledge/tags/service'
import { buildUndefinedTagsError, validateTagValue } from '@/lib/knowledge/tags/utils'
import type { StructuredFilter } from '@/lib/knowledge/types'
import { createLogger } from '@/lib/logs/console/logger'
import { estimateTokenCount } from '@/lib/tokenization/estimators'
import { getUserId } from '@/app/api/auth/oauth/utils'
@@ -20,6 +22,16 @@ import { calculateCost } from '@/providers/utils'
const logger = createLogger('VectorSearchAPI')
/** Structured tag filter with operator support */
const StructuredTagFilterSchema = z.object({
tagName: z.string(),
tagSlot: z.string().optional(),
fieldType: z.enum(['text', 'number', 'date', 'boolean']).default('text'),
operator: z.string().default('eq'),
value: z.union([z.string(), z.number(), z.boolean()]),
valueTo: z.union([z.string(), z.number()]).optional(),
})
const VectorSearchSchema = z
.object({
knowledgeBaseIds: z.union([
@@ -39,18 +51,17 @@ const VectorSearchSchema = z
.nullable()
.default(10)
.transform((val) => val ?? 10),
filters: z
.record(z.string())
tagFilters: z
.array(StructuredTagFilterSchema)
.optional()
.nullable()
.transform((val) => val || undefined), // Allow dynamic filter keys (display names)
.transform((val) => val || undefined),
})
.refine(
(data) => {
// Ensure at least query or filters are provided
const hasQuery = data.query && data.query.trim().length > 0
const hasFilters = data.filters && Object.keys(data.filters).length > 0
return hasQuery || hasFilters
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
return hasQuery || hasTagFilters
},
{
message: 'Please provide either a search query or tag filters to search your knowledge base',
@@ -88,45 +99,81 @@ export async function POST(request: NextRequest) {
)
// Map display names to tag slots for filtering
let mappedFilters: Record<string, string> = {}
if (validatedData.filters && accessibleKbIds.length > 0) {
try {
// Fetch tag definitions for the first accessible KB (since we're using single KB now)
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
let structuredFilters: StructuredFilter[] = []
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
// Handle tag filters
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
const kbId = accessibleKbIds[0]
const tagDefs = await getDocumentTagDefinitions(kbId)
// Create mapping from display name to tag slot
const displayNameToSlot: Record<string, string> = {}
tagDefs.forEach((def) => {
displayNameToSlot[def.displayName] = def.tagSlot
})
// Create mapping from display name to tag slot and fieldType
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
tagDefs.forEach((def) => {
displayNameToTagDef[def.displayName] = {
tagSlot: def.tagSlot,
fieldType: def.fieldType,
}
})
// Map the filters and handle OR logic
Object.entries(validatedData.filters).forEach(([key, value]) => {
if (value) {
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
// Validate all tag filters first
const undefinedTags: string[] = []
const typeErrors: string[] = []
// Check if this is an OR filter (contains |OR| separator)
if (value.includes('|OR|')) {
logger.debug(
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
)
}
for (const filter of validatedData.tagFilters) {
const tagDef = displayNameToTagDef[filter.tagName]
mappedFilters[tagSlot] = value
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
}
})
// Check if tag exists
if (!tagDef) {
undefinedTags.push(filter.tagName)
continue
}
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
} catch (error) {
logger.error(`[${requestId}] Filter mapping error:`, error)
// If mapping fails, use original filters
mappedFilters = validatedData.filters
// Validate value type using shared validation
const validationError = validateTagValue(
filter.tagName,
String(filter.value),
tagDef.fieldType
)
if (validationError) {
typeErrors.push(validationError)
}
}
// Throw combined error if there are any validation issues
if (undefinedTags.length > 0 || typeErrors.length > 0) {
const errorParts: string[] = []
if (undefinedTags.length > 0) {
errorParts.push(buildUndefinedTagsError(undefinedTags))
}
if (typeErrors.length > 0) {
errorParts.push(...typeErrors)
}
return NextResponse.json({ error: errorParts.join('\n') }, { status: 400 })
}
// Build structured filters with validated data
structuredFilters = validatedData.tagFilters.map((filter) => {
const tagDef = displayNameToTagDef[filter.tagName]!
const tagSlot = filter.tagSlot || tagDef.tagSlot
const fieldType = filter.fieldType || tagDef.fieldType
logger.debug(
`[${requestId}] Structured filter: ${filter.tagName} -> ${tagSlot} (${fieldType}) ${filter.operator} ${filter.value}`
)
return {
tagSlot,
fieldType,
operator: filter.operator,
value: filter.value,
valueTo: filter.valueTo,
}
})
logger.debug(`[${requestId}] Processed ${structuredFilters.length} structured filters`)
}
if (accessibleKbIds.length === 0) {
@@ -155,26 +202,29 @@ export async function POST(request: NextRequest) {
let results: SearchResult[]
const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0
const hasFilters = structuredFilters && structuredFilters.length > 0
if (!hasQuery && hasFilters) {
// Tag-only search without vector similarity
logger.debug(`[${requestId}] Executing tag-only search with filters:`, mappedFilters)
logger.debug(`[${requestId}] Executing tag-only search with filters:`, structuredFilters)
results = await handleTagOnlySearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
filters: mappedFilters,
structuredFilters,
})
} else if (hasQuery && hasFilters) {
// Tag + Vector search
logger.debug(`[${requestId}] Executing tag + vector search with filters:`, mappedFilters)
logger.debug(
`[${requestId}] Executing tag + vector search with filters:`,
structuredFilters
)
const strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
const queryVector = JSON.stringify(await queryEmbeddingPromise)
results = await handleTagAndVectorSearch({
knowledgeBaseIds: accessibleKbIds,
topK: validatedData.topK,
filters: mappedFilters,
structuredFilters,
queryVector,
distanceThreshold: strategy.distanceThreshold,
})
@@ -257,9 +307,9 @@ export async function POST(request: NextRequest) {
// Create tags object with display names
const tags: Record<string, any> = {}
TAG_SLOTS.forEach((slot) => {
ALL_TAG_SLOTS.forEach((slot) => {
const tagValue = (result as any)[slot]
if (tagValue) {
if (tagValue !== null && tagValue !== undefined) {
const displayName = kbTagMap[slot] || slot
logger.debug(
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`

View File

@@ -54,7 +54,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: {},
structuredFilters: [],
}
await expect(handleTagOnlySearch(params)).rejects.toThrow(
@@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { tag1: 'api' },
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
}
// This test validates the function accepts the right parameters
// The actual database interaction is tested via route tests
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
expect(params.topK).toBe(10)
expect(params.filters).toEqual({ tag1: 'api' })
expect(params.structuredFilters).toHaveLength(1)
})
})
@@ -123,7 +123,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: {},
structuredFilters: [],
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
distanceThreshold: 0.8,
}
@@ -137,7 +137,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { tag1: 'api' },
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
distanceThreshold: 0.8,
}
@@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { tag1: 'api' },
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
}
@@ -163,7 +163,7 @@ describe('Knowledge Search Utils', () => {
const params = {
knowledgeBaseIds: ['kb-123'],
topK: 10,
filters: { tag1: 'api' },
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
distanceThreshold: 0.8,
}
@@ -171,7 +171,7 @@ describe('Knowledge Search Utils', () => {
// This test validates the function accepts the right parameters
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
expect(params.topK).toBe(10)
expect(params.filters).toEqual({ tag1: 'api' })
expect(params.structuredFilters).toHaveLength(1)
expect(params.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3]))
expect(params.distanceThreshold).toBe(0.8)
})

View File

@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { document, embedding } from '@sim/db/schema'
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
import type { StructuredFilter } from '@/lib/knowledge/types'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('KnowledgeSearchUtils')
@@ -34,6 +35,7 @@ export interface SearchResult {
content: string
documentId: string
chunkIndex: number
// Text tags
tag1: string | null
tag2: string | null
tag3: string | null
@@ -41,6 +43,19 @@ export interface SearchResult {
tag5: string | null
tag6: string | null
tag7: string | null
// Number tags (5 slots)
number1: number | null
number2: number | null
number3: number | null
number4: number | null
number5: number | null
// Date tags (2 slots)
date1: Date | null
date2: Date | null
// Boolean tags (3 slots)
boolean1: boolean | null
boolean2: boolean | null
boolean3: boolean | null
distance: number
knowledgeBaseId: string
}
@@ -48,7 +63,7 @@ export interface SearchResult {
export interface SearchParams {
knowledgeBaseIds: string[]
topK: number
filters?: Record<string, string>
structuredFilters?: StructuredFilter[]
queryVector?: string
distanceThreshold?: number
}
@@ -56,46 +71,230 @@ export interface SearchParams {
// Use shared embedding utility
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
function getTagFilters(filters: Record<string, string>, embedding: any) {
return Object.entries(filters).map(([key, value]) => {
// Handle OR logic within same tag
const values = value.includes('|OR|') ? value.split('|OR|') : [value]
logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values)
/** All valid tag slot keys */
const TAG_SLOT_KEYS = [
// Text tags (7 slots)
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
// Number tags (5 slots)
'number1',
'number2',
'number3',
'number4',
'number5',
// Date tags (2 slots)
'date1',
'date2',
// Boolean tags (3 slots)
'boolean1',
'boolean2',
'boolean3',
] as const
const getColumnForKey = (key: string) => {
switch (key) {
case 'tag1':
return embedding.tag1
case 'tag2':
return embedding.tag2
case 'tag3':
return embedding.tag3
case 'tag4':
return embedding.tag4
case 'tag5':
return embedding.tag5
case 'tag6':
return embedding.tag6
case 'tag7':
return embedding.tag7
default:
return null
}
type TagSlotKey = (typeof TAG_SLOT_KEYS)[number]
function isTagSlotKey(key: string): key is TagSlotKey {
return TAG_SLOT_KEYS.includes(key as TagSlotKey)
}
/** Common fields selected for search results */
const getSearchResultFields = (distanceExpr: any) => ({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
// Text tags
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
// Number tags (5 slots)
number1: embedding.number1,
number2: embedding.number2,
number3: embedding.number3,
number4: embedding.number4,
number5: embedding.number5,
// Date tags (2 slots)
date1: embedding.date1,
date2: embedding.date2,
// Boolean tags (3 slots)
boolean1: embedding.boolean1,
boolean2: embedding.boolean2,
boolean3: embedding.boolean3,
distance: distanceExpr,
knowledgeBaseId: embedding.knowledgeBaseId,
})
/**
* Build a single SQL condition for a filter
*/
function buildFilterCondition(filter: StructuredFilter, embeddingTable: any) {
const { tagSlot, fieldType, operator, value, valueTo } = filter
if (!isTagSlotKey(tagSlot)) {
logger.debug(`[getStructuredTagFilters] Unknown tag slot: ${tagSlot}`)
return null
}
const column = embeddingTable[tagSlot]
if (!column) return null
logger.debug(
`[getStructuredTagFilters] Processing ${tagSlot} (${fieldType}) ${operator} ${value}`
)
// Handle text operators
if (fieldType === 'text') {
const stringValue = String(value)
switch (operator) {
case 'eq':
return sql`LOWER(${column}) = LOWER(${stringValue})`
case 'neq':
return sql`LOWER(${column}) != LOWER(${stringValue})`
case 'contains':
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}%`})`
case 'not_contains':
return sql`LOWER(${column}) NOT LIKE LOWER(${`%${stringValue}%`})`
case 'starts_with':
return sql`LOWER(${column}) LIKE LOWER(${`${stringValue}%`})`
case 'ends_with':
return sql`LOWER(${column}) LIKE LOWER(${`%${stringValue}`})`
default:
return sql`LOWER(${column}) = LOWER(${stringValue})`
}
}
// Handle number operators
if (fieldType === 'number') {
const numValue = typeof value === 'number' ? value : Number.parseFloat(String(value))
if (Number.isNaN(numValue)) return null
switch (operator) {
case 'eq':
return sql`${column} = ${numValue}`
case 'neq':
return sql`${column} != ${numValue}`
case 'gt':
return sql`${column} > ${numValue}`
case 'gte':
return sql`${column} >= ${numValue}`
case 'lt':
return sql`${column} < ${numValue}`
case 'lte':
return sql`${column} <= ${numValue}`
case 'between':
if (valueTo !== undefined) {
const numValueTo =
typeof valueTo === 'number' ? valueTo : Number.parseFloat(String(valueTo))
if (Number.isNaN(numValueTo)) return sql`${column} = ${numValue}`
return sql`${column} >= ${numValue} AND ${column} <= ${numValueTo}`
}
return sql`${column} = ${numValue}`
default:
return sql`${column} = ${numValue}`
}
}
// Handle date operators - expects YYYY-MM-DD format from frontend
if (fieldType === 'date') {
const dateStr = String(value)
// Validate YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
logger.debug(`[getStructuredTagFilters] Invalid date format: ${dateStr}, expected YYYY-MM-DD`)
return null
}
const column = getColumnForKey(key)
if (!column) return sql`1=1` // No-op for unknown keys
if (values.length === 1) {
// Single value - simple equality
logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`)
return sql`LOWER(${column}) = LOWER(${values[0]})`
switch (operator) {
case 'eq':
return sql`${column}::date = ${dateStr}::date`
case 'neq':
return sql`${column}::date != ${dateStr}::date`
case 'gt':
return sql`${column}::date > ${dateStr}::date`
case 'gte':
return sql`${column}::date >= ${dateStr}::date`
case 'lt':
return sql`${column}::date < ${dateStr}::date`
case 'lte':
return sql`${column}::date <= ${dateStr}::date`
case 'between':
if (valueTo !== undefined) {
const dateStrTo = String(valueTo)
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStrTo)) {
return sql`${column}::date = ${dateStr}::date`
}
return sql`${column}::date >= ${dateStr}::date AND ${column}::date <= ${dateStrTo}::date`
}
return sql`${column}::date = ${dateStr}::date`
default:
return sql`${column}::date = ${dateStr}::date`
}
// Multiple values - OR logic
logger.debug(`[getTagFilters] OR filter: ${key} IN (${values.join(', ')})`)
const orConditions = values.map((v) => sql`LOWER(${column}) = LOWER(${v})`)
return sql`(${sql.join(orConditions, sql` OR `)})`
})
}
// Handle boolean operators
if (fieldType === 'boolean') {
const boolValue = value === true || value === 'true'
switch (operator) {
case 'eq':
return sql`${column} = ${boolValue}`
case 'neq':
return sql`${column} != ${boolValue}`
default:
return sql`${column} = ${boolValue}`
}
}
// Fallback to equality
return sql`${column} = ${value}`
}
/**
* Build SQL conditions from structured filters with operator support
* - Same tag multiple times: OR logic
* - Different tags: AND logic
*/
function getStructuredTagFilters(filters: StructuredFilter[], embeddingTable: any) {
// Group filters by tagSlot
const filtersBySlot = new Map<string, StructuredFilter[]>()
for (const filter of filters) {
const slot = filter.tagSlot
if (!filtersBySlot.has(slot)) {
filtersBySlot.set(slot, [])
}
filtersBySlot.get(slot)!.push(filter)
}
// Build conditions: OR within same slot, AND across different slots
const conditions: ReturnType<typeof sql>[] = []
for (const [slot, slotFilters] of filtersBySlot) {
const slotConditions = slotFilters
.map((f) => buildFilterCondition(f, embeddingTable))
.filter((c): c is ReturnType<typeof sql> => c !== null)
if (slotConditions.length === 0) continue
if (slotConditions.length === 1) {
// Single condition for this slot
conditions.push(slotConditions[0])
} else {
// Multiple conditions for same slot - OR them together
logger.debug(
`[getStructuredTagFilters] OR'ing ${slotConditions.length} conditions for ${slot}`
)
conditions.push(sql`(${sql.join(slotConditions, sql` OR `)})`)
}
}
return conditions
}
export function getQueryStrategy(kbCount: number, topK: number) {
@@ -113,8 +312,10 @@ export function getQueryStrategy(kbCount: number, topK: number) {
async function executeTagFilterQuery(
knowledgeBaseIds: string[],
filters: Record<string, string>
structuredFilters: StructuredFilter[]
): Promise<{ id: string }[]> {
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
if (knowledgeBaseIds.length === 1) {
return await db
.select({ id: embedding.id })
@@ -125,7 +326,7 @@ async function executeTagFilterQuery(
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...getTagFilters(filters, embedding)
...tagFilterConditions
)
)
}
@@ -138,7 +339,7 @@ async function executeTagFilterQuery(
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...getTagFilters(filters, embedding)
...tagFilterConditions
)
)
}
@@ -154,21 +355,11 @@ async function executeVectorSearchOnIds(
}
return await db
.select({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
knowledgeBaseId: embedding.knowledgeBaseId,
})
.select(
getSearchResultFields(
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
)
)
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -183,15 +374,16 @@ async function executeVectorSearchOnIds(
}
export async function handleTagOnlySearch(params: SearchParams): Promise<SearchResult[]> {
const { knowledgeBaseIds, topK, filters } = params
const { knowledgeBaseIds, topK, structuredFilters } = params
if (!filters || Object.keys(filters).length === 0) {
if (!structuredFilters || structuredFilters.length === 0) {
throw new Error('Tag filters are required for tag-only search')
}
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, filters)
logger.debug(`[handleTagOnlySearch] Executing tag-only search with filters:`, structuredFilters)
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
if (strategy.useParallel) {
// Parallel approach for many KBs
@@ -199,21 +391,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
return await db
.select({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
knowledgeBaseId: embedding.knowledgeBaseId,
})
.select(getSearchResultFields(sql<number>`0`.as('distance')))
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -221,7 +399,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
eq(embedding.knowledgeBaseId, kbId),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...getTagFilters(filters, embedding)
...tagFilterConditions
)
)
.limit(parallelLimit)
@@ -232,21 +410,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
}
// Single query for fewer KBs
return await db
.select({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
distance: sql<number>`0`.as('distance'), // No distance for tag-only searches
knowledgeBaseId: embedding.knowledgeBaseId,
})
.select(getSearchResultFields(sql<number>`0`.as('distance')))
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -254,7 +418,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
eq(embedding.enabled, true),
isNull(document.deletedAt),
...getTagFilters(filters, embedding)
...tagFilterConditions
)
)
.limit(topK)
@@ -271,27 +435,15 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
const distanceExpr = sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
if (strategy.useParallel) {
// Parallel approach for many KBs
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
return await db
.select({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
knowledgeBaseId: embedding.knowledgeBaseId,
})
.select(getSearchResultFields(distanceExpr))
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -312,21 +464,7 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
}
// Single query for fewer KBs
return await db
.select({
id: embedding.id,
content: embedding.content,
documentId: embedding.documentId,
chunkIndex: embedding.chunkIndex,
tag1: embedding.tag1,
tag2: embedding.tag2,
tag3: embedding.tag3,
tag4: embedding.tag4,
tag5: embedding.tag5,
tag6: embedding.tag6,
tag7: embedding.tag7,
distance: sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance'),
knowledgeBaseId: embedding.knowledgeBaseId,
})
.select(getSearchResultFields(distanceExpr))
.from(embedding)
.innerJoin(document, eq(embedding.documentId, document.id))
.where(
@@ -342,19 +480,22 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
}
export async function handleTagAndVectorSearch(params: SearchParams): Promise<SearchResult[]> {
const { knowledgeBaseIds, topK, filters, queryVector, distanceThreshold } = params
const { knowledgeBaseIds, topK, structuredFilters, queryVector, distanceThreshold } = params
if (!filters || Object.keys(filters).length === 0) {
if (!structuredFilters || structuredFilters.length === 0) {
throw new Error('Tag filters are required for tag and vector search')
}
if (!queryVector || !distanceThreshold) {
throw new Error('Query vector and distance threshold are required for tag and vector search')
}
logger.debug(`[handleTagAndVectorSearch] Executing tag + vector search with filters:`, filters)
logger.debug(
`[handleTagAndVectorSearch] Executing tag + vector search with filters:`,
structuredFilters
)
// Step 1: Filter by tags first
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters)
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
if (tagFilteredIds.length === 0) {
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)

View File

@@ -35,7 +35,7 @@ export interface DocumentData {
enabled: boolean
deletedAt?: Date | null
uploadedAt: Date
// Document tags
// Text tags
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -43,6 +43,19 @@ export interface DocumentData {
tag5?: string | null
tag6?: string | null
tag7?: string | null
// Number tags (5 slots)
number1?: number | null
number2?: number | null
number3?: number | null
number4?: number | null
number5?: number | null
// Date tags (2 slots)
date1?: Date | null
date2?: Date | null
// Boolean tags (3 slots)
boolean1?: boolean | null
boolean2?: boolean | null
boolean3?: boolean | null
}
export interface EmbeddingData {
@@ -58,7 +71,7 @@ export interface EmbeddingData {
embeddingModel: string
startOffset: number
endOffset: number
// Tag fields for filtering
// Text tags
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -66,6 +79,19 @@ export interface EmbeddingData {
tag5?: string | null
tag6?: string | null
tag7?: string | null
// Number tags (5 slots)
number1?: number | null
number2?: number | null
number3?: number | null
number4?: number | null
number5?: number | null
// Date tags (2 slots)
date1?: Date | null
date2?: Date | null
// Boolean tags (3 slots)
boolean1?: boolean | null
boolean2?: boolean | null
boolean3?: boolean | null
enabled: boolean
createdAt: Date
updatedAt: Date
@@ -232,6 +258,27 @@ export async function checkDocumentWriteAccess(
processingStartedAt: document.processingStartedAt,
processingCompletedAt: document.processingCompletedAt,
knowledgeBaseId: document.knowledgeBaseId,
// Text tags
tag1: document.tag1,
tag2: document.tag2,
tag3: document.tag3,
tag4: document.tag4,
tag5: document.tag5,
tag6: document.tag6,
tag7: document.tag7,
// Number tags (5 slots)
number1: document.number1,
number2: document.number2,
number3: document.number3,
number4: document.number4,
number5: document.number5,
// Date tags (2 slots)
date1: document.date1,
date2: document.date2,
// Boolean tags (3 slots)
boolean1: document.boolean1,
boolean2: document.boolean2,
boolean3: document.boolean3,
})
.from(document)
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))

View File

@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
import {
Button,
Combobox,
DatePicker,
Input,
Label,
Modal,
@@ -15,7 +16,7 @@ import {
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import { createLogger } from '@/lib/logs/console/logger'
import {
@@ -28,6 +29,54 @@ import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
const logger = createLogger('DocumentTagsModal')
/** Field type display labels */
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Boolean',
}
/**
* Gets the appropriate value when changing field types.
* Clears value when type changes to allow placeholder to show.
*/
function getValueForFieldType(
newFieldType: string,
currentFieldType: string,
currentValue: string
): string {
return newFieldType === currentFieldType ? currentValue : ''
}
/** Format value for display based on field type */
function formatValueForDisplay(value: string, fieldType: string): string {
if (!value) return ''
switch (fieldType) {
case 'boolean':
return value === 'true' ? 'True' : 'False'
case 'date':
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
// For UTC dates, display the UTC date to prevent timezone shifts
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
return new Date(
date.getUTCFullYear(),
date.getUTCMonth(),
date.getUTCDate()
).toLocaleDateString()
}
return date.toLocaleDateString()
} catch {
return value
}
default:
return value
}
}
interface DocumentTagsModalProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -67,17 +116,21 @@ export function DocumentTagsModal({
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
const tags: DocumentTag[] = []
TAG_SLOTS.forEach((slot) => {
const value = docData[slot] as string | null | undefined
ALL_TAG_SLOTS.forEach((slot) => {
const rawValue = docData[slot]
const definition = definitions.find((def) => def.tagSlot === slot)
if (value?.trim() && definition) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: value.trim(),
})
if (rawValue !== null && rawValue !== undefined && definition) {
// Convert value to string for storage
const stringValue = String(rawValue).trim()
if (stringValue) {
tags.push({
slot,
displayName: definition.displayName,
fieldType: definition.fieldType,
value: stringValue,
})
}
}
})
@@ -95,13 +148,15 @@ export function DocumentTagsModal({
try {
const tagData: Record<string, string> = {}
TAG_SLOTS.forEach((slot) => {
tagData[slot] = ''
})
tagsToSave.forEach((tag) => {
if (tag.value.trim()) {
tagData[tag.slot] = tag.value.trim()
// Only include tags that have values (omit empty ones)
// Use empty string for slots that should be cleared
ALL_TAG_SLOTS.forEach((slot) => {
const tag = tagsToSave.find((t) => t.slot === slot)
if (tag?.value.trim()) {
tagData[slot] = tag.value.trim()
} else {
// Use empty string to clear a tag (API schema expects string, not null)
tagData[slot] = ''
}
})
@@ -117,8 +172,8 @@ export function DocumentTagsModal({
throw new Error('Failed to update document tags')
}
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
onDocumentUpdate?.(tagData)
updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record<string, string>)
onDocumentUpdate?.(tagData as Record<string, string>)
await fetchTagDefinitions()
} catch (error) {
@@ -279,7 +334,7 @@ export function DocumentTagsModal({
const newDefinition: TagDefinitionInput = {
displayName: formData.displayName,
fieldType: formData.fieldType,
tagSlot: targetSlot as TagSlot,
tagSlot: targetSlot as AllTagSlot,
}
if (saveTagDefinitions) {
@@ -359,20 +414,7 @@ export function DocumentTagsModal({
<ModalBody className='!pb-[16px]'>
<div className='min-h-0 flex-1 overflow-y-auto'>
<div className='space-y-[8px]'>
<Label>
Tags{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{documentTags.length}/{MAX_TAG_SLOTS} slots used
</span>
</Label>
{documentTags.length === 0 && !isCreatingTag && (
<div className='rounded-[6px] border p-[16px] text-center'>
<p className='text-[12px] text-[var(--text-tertiary)]'>
No tags added yet. Add tags to help organize this document.
</p>
</div>
)}
<Label>Tags</Label>
{documentTags.map((tag, index) => (
<div key={index} className='space-y-[8px]'>
@@ -383,9 +425,12 @@ export function DocumentTagsModal({
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
{tag.value}
{formatValueForDisplay(tag.value, tag.fieldType)}
</span>
<div className='flex flex-shrink-0 items-center gap-1'>
<Button
@@ -415,10 +460,16 @@ export function DocumentTagsModal({
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: def?.fieldType || 'text',
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
})
}}
placeholder='Enter or select tag name'
@@ -453,33 +504,70 @@ export function DocumentTagsModal({
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={`tagType-${index}`}>Type</Label>
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor={`tagValue-${index}`}>Value</Label>
<Input
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) =>
setEditTagForm({ ...editTagForm, value: e.target.value })
}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
{editTagForm.fieldType === 'boolean' ? (
<Combobox
id={`tagValue-${index}`}
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={editTagForm.value}
selectedValue={editTagForm.value}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select value'
/>
) : editTagForm.fieldType === 'number' ? (
<Input
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) => {
const val = e.target.value
// Allow empty, digits, decimal point, and negative sign
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
setEditTagForm({ ...editTagForm, value: val })
}
}}
placeholder='Enter number'
inputMode='decimal'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
) : editTagForm.fieldType === 'date' ? (
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
/>
) : (
<Input
id={`tagValue-${index}`}
value={editTagForm.value}
onChange={(e) =>
setEditTagForm({ ...editTagForm, value: e.target.value })
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
</div>
<div className='flex gap-[8px]'>
@@ -500,7 +588,7 @@ export function DocumentTagsModal({
</div>
))}
{!isTagEditing && (
{documentTags.length > 0 && !isTagEditing && (
<Button
variant='default'
onClick={openTagCreator}
@@ -511,7 +599,7 @@ export function DocumentTagsModal({
</Button>
)}
{isCreatingTag && (
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagName'>Tag Name</Label>
@@ -525,10 +613,16 @@ export function DocumentTagsModal({
const def = kbTagDefinitions.find(
(d) => d.displayName.toLowerCase() === value.toLowerCase()
)
const newFieldType = def?.fieldType || 'text'
setEditTagForm({
...editTagForm,
displayName: value,
fieldType: def?.fieldType || 'text',
fieldType: newFieldType,
value: getValueForFieldType(
newFieldType,
editTagForm.fieldType,
editTagForm.value
),
})
}}
placeholder='Enter or select tag name'
@@ -563,31 +657,68 @@ export function DocumentTagsModal({
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagType'>Type</Label>
<Input id='newTagType' value='Text' disabled className='capitalize' />
</div>
*/}
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='newTagValue'>Value</Label>
<Input
id='newTagValue'
value={editTagForm.value}
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
{editTagForm.fieldType === 'boolean' ? (
<Combobox
id='newTagValue'
options={[
{ label: 'True', value: 'true' },
{ label: 'False', value: 'false' },
]}
value={editTagForm.value}
selectedValue={editTagForm.value}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select value'
/>
) : editTagForm.fieldType === 'number' ? (
<Input
id='newTagValue'
value={editTagForm.value}
onChange={(e) => {
const val = e.target.value
// Allow empty, digits, decimal point, and negative sign
if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
setEditTagForm({ ...editTagForm, value: val })
}
}}
placeholder='Enter number'
inputMode='decimal'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
) : editTagForm.fieldType === 'date' ? (
<DatePicker
value={editTagForm.value || undefined}
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
placeholder='Select date'
/>
) : (
<Input
id='newTagValue'
value={editTagForm.value}
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
placeholder='Enter tag value'
onKeyDown={(e) => {
if (e.key === 'Enter' && canSaveTag) {
e.preventDefault()
saveDocumentTag()
}
if (e.key === 'Escape') {
e.preventDefault()
cancelEditingTag()
}
}}
/>
)}
</div>
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
@@ -604,9 +735,11 @@ export function DocumentTagsModal({
)}
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
{documentTags.length > 0 && (
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
Cancel
</Button>
)}
<Button
variant='primary'
onClick={saveDocumentTag}

View File

@@ -1,9 +1,11 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Modal,
@@ -14,7 +16,7 @@ import {
Trash,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import { SUPPORTED_FIELD_TYPES, TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
import {
@@ -24,6 +26,14 @@ import {
const logger = createLogger('BaseTagsModal')
/** Field type display labels */
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Boolean',
}
interface TagUsageData {
tagName: string
tagSlot: string
@@ -174,22 +184,55 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
}
/** Get slot usage counts per field type */
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
if (!config) return { used: 0, max: 0 }
const used = kbTagDefinitions.filter((def) => def.fieldType === fieldType).length
return { used, max: config.maxSlots }
}
/** Check if a field type has available slots */
const hasAvailableSlots = (fieldType: string): boolean => {
const { used, max } = getSlotUsageByFieldType(fieldType)
return used < max
}
/** Field type options for Combobox */
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
const { used, max } = getSlotUsageByFieldType(type)
return {
value: type,
label: `${FIELD_TYPE_LABELS[type]} (${used}/${max})`,
}
})
}, [kbTagDefinitions])
const saveTagDefinition = async () => {
if (!canSaveTag()) return
setIsSavingTag(true)
try {
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))
// Check if selected field type has available slots
if (!hasAvailableSlots(createTagForm.fieldType)) {
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
}
if (!availableSlot) {
throw new Error('No available tag slots')
// Get the next available slot from the API
const slotResponse = await fetch(
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
)
if (!slotResponse.ok) {
throw new Error('Failed to get available slot')
}
const slotResult = await slotResponse.json()
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
throw new Error('No available tag slots for this field type')
}
const newTagDefinition = {
tagSlot: availableSlot,
tagSlot: slotResult.data.nextAvailableSlot,
displayName: createTagForm.displayName.trim(),
fieldType: createTagForm.fieldType,
}
@@ -277,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Label>
Tags:{' '}
<span className='pl-[6px] text-[var(--text-tertiary)]'>
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
{kbTagDefinitions.length} defined
</span>
</Label>
@@ -300,6 +343,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
{tag.displayName}
</span>
<span className='rounded-[3px] bg-[var(--surface-3)] px-[6px] py-[2px] text-[10px] text-[var(--text-muted)]'>
{FIELD_TYPE_LABELS[tag.fieldType] || tag.fieldType}
</span>
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
@@ -324,7 +370,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
<Button
variant='default'
onClick={openTagCreator}
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
className='w-full'
>
Add Tag
@@ -361,12 +407,22 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
)}
</div>
{/* Type selector commented out - only "text" type is currently supported
<div className='flex flex-col gap-[8px]'>
<Label htmlFor='tagType'>Type</Label>
<Input id='tagType' value='Text' disabled className='capitalize' />
<Combobox
options={fieldTypeOptions}
value={createTagForm.fieldType}
onChange={(value) =>
setCreateTagForm({ ...createTagForm, fieldType: value })
}
placeholder='Select type'
/>
{!hasAvailableSlots(createTagForm.fieldType) && (
<span className='text-[11px] text-[var(--text-error)]'>
No available slots for this type. Choose a different type.
</span>
)}
</div>
*/}
<div className='flex gap-[8px]'>
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
@@ -376,7 +432,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
variant='primary'
onClick={saveTagDefinition}
className='flex-1'
disabled={!canSaveTag() || isSavingTag}
disabled={
!canSaveTag() ||
isSavingTag ||
!hasAvailableSlots(createTagForm.fieldType)
}
>
{isSavingTag ? (
<>

View File

@@ -2,11 +2,18 @@
import { useMemo, useRef, useState } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/core/utils/cn'
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import {
Button,
Input,
Label,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverScrollArea,
Trash,
} from '@/components/emcn'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { TagDropdown } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown'
import { useSubBlockInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-input'
@@ -20,7 +27,8 @@ interface DocumentTagRow {
id: string
cells: {
tagName: string
type: string
tagSlot?: string
fieldType: string
value: string
}
}
@@ -66,17 +74,14 @@ export function DocumentTagEntry({
const emitTagSelection = useTagSelection(blockId, subBlock.id)
// State for dropdown visibility - one for each row
// State for tag name dropdown visibility - one for each row
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
// State for type dropdown visibility - one for each row
const [typeDropdownStates, setTypeDropdownStates] = useState<Record<number, boolean>>({})
// Use preview value when in preview mode, otherwise use store value
const currentValue = isPreview ? previewValue : storeValue
// 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)
@@ -85,7 +90,8 @@ export function DocumentTagEntry({
id: tag.id || `tag-${index}`,
cells: {
tagName: tag.tagName || '',
type: tag.fieldType || 'text',
tagSlot: tag.tagSlot,
fieldType: tag.fieldType || 'text',
value: tag.value || '',
},
}))
@@ -99,137 +105,103 @@ export function DocumentTagEntry({
return [
{
id: 'empty-row-0',
cells: { tagName: '', type: 'text', value: '' },
cells: { tagName: '', tagSlot: undefined, fieldType: '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?.trim())
)
// Get tag names already used in rows (case-insensitive)
const usedTagNames = useMemo(() => {
return new Set(
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
)
}, [rows])
const availableTagDefinitions = tagDefinitions.filter(
(def) => !usedTagNames.has(def.displayName.toLowerCase())
)
// Filter available tags (exclude already used ones)
const availableTagDefinitions = useMemo(() => {
return tagDefinitions.filter((def) => !usedTagNames.has(def.displayName.toLowerCase()))
}, [tagDefinitions, usedTagNames])
// 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, index) => ({
id: `prefill-${tagDef.id}-${index}`,
tagName: tagDef.displayName,
fieldType: tagDef.fieldType,
value: '',
}))
const jsonString = existingTagRows.length > 0 ? JSON.stringify(existingTagRows) : ''
setStoreValue(jsonString)
}
// Can add more tags if there are available tag definitions
const canAddMoreTags = availableTagDefinitions.length > 0
// Shared helper function for updating rows and generating JSON
const updateRowsAndGenerateJson = (rowIndex: number, column: string, value: string) => {
const updateRowsAndGenerateJson = (
rowIndex: number,
column: string,
value: string,
tagDef?: { tagSlot: string; fieldType: string }
) => {
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
// When selecting a tag, also set the tagSlot and fieldType
if (column === 'tagName' && tagDef) {
newCells.tagSlot = tagDef.tagSlot
newCells.fieldType = tagDef.fieldType
// Clear value when tag changes
if (row.cells.tagName !== value) {
newCells.value = ''
}
}
return {
...row,
cells: newCells,
}
return { ...row, cells: newCells }
}
return row
})
// Store all rows including empty ones - don't auto-remove
const dataToStore = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
value: row.cells.value || '',
}))
return dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
}
const handleCellChange = (rowIndex: number, column: string, value: string) => {
const handleTagSelection = (rowIndex: number, tagName: 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 jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef)
setStoreValue(jsonString)
}
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
const handleValueChange = (rowIndex: number, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, column, value)
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
setStoreValue(jsonString)
}
const handleTagDropdownSelection = (rowIndex: number, value: string) => {
if (isPreview || disabled) return
const jsonString = updateRowsAndGenerateJson(rowIndex, 'value', value)
emitTagSelection(jsonString)
}
const handleAddRow = () => {
if (isPreview || disabled) return
if (isPreview || disabled || !canAddMoreTags) return
// Get current data and add a new empty row
const currentData = currentValue ? JSON.parse(currentValue) : []
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).substr(2, 9)}`
const newRowId = `tag-${currentData.length}-${Math.random().toString(36).slice(2, 11)}`
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
setStoreValue(JSON.stringify(newData))
}
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 updatedRows = rows.filter((_, idx) => idx !== rowIndex)
const tableDataForStorage = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
fieldType: row.cells.type || 'text',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
value: row.cells.value || '',
}))
@@ -237,15 +209,15 @@ export function DocumentTagEntry({
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 (isPreview) {
const tagCount = rows.filter((r) => r.cells.tagName?.trim()).length
return (
<div className='space-y-1'>
<Label className='font-medium text-muted-foreground text-xs'>Document Tags</Label>
<div className='text-muted-foreground text-sm'>
{tagCount > 0 ? `${tagCount} tag(s) configured` : 'No tags'}
</div>
</div>
)
}
@@ -253,205 +225,111 @@ export function DocumentTagEntry({
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
}
if (tagDefinitions.length === 0) {
return (
<div className='rounded-md border p-4 text-center text-muted-foreground text-sm'>
No tags defined for this knowledge base.
<br />
Define tags at the knowledge base level first.
</div>
)
}
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag</th>
<th className='border-r px-4 py-2 text-center font-medium text-sm'>Value</th>
<th className='w-10' />
</tr>
</thead>
)
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
const showDropdown = dropdownStates[rowIndex] || false
const isOpen = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
const setIsOpen = (open: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: open }))
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
}
// Show tags that are either available OR currently selected for this row
const selectableTags = tagDefinitions.filter(
(def) => def.displayName === cellValue || !usedTagNames.has(def.displayName.toLowerCase())
)
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
autoComplete='off'
className={cn(
'w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0',
isDuplicate && 'border-red-500 bg-red-50'
)}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showDropdown && availableTagDefinitions.length > 0 && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{availableTagDefinitions
.filter((tagDef) =>
tagDef.displayName.toLowerCase().includes(cellValue.toLowerCase())
)
.map((tagDef) => (
<div
key={tagDef.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tagDef.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tagDef.displayName}</span>
</div>
))}
</div>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<div
className='relative w-full cursor-pointer'
onClick={() => !disabled && setIsOpen(true)}
>
<Input
value={cellValue}
readOnly
disabled={disabled}
autoComplete='off'
placeholder='Select tag'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] font-medium font-sans text-sm'>
<span className='truncate'>
{cellValue || <span className='text-muted-foreground/50'>Select tag</span>}
</span>
</div>
</div>
</PopoverAnchor>
{selectableTags.length > 0 && (
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
maxHeight={192}
className='w-[200px]'
>
<PopoverScrollArea>
{selectableTags.map((tagDef) => (
<PopoverItem
key={tagDef.id}
active={tagDef.displayName === cellValue}
onClick={() => {
handleTagSelection(rowIndex, tagDef.displayName)
setIsOpen(false)
}}
>
<span className='flex-1 truncate'>{tagDef.displayName}</span>
<span className='flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground'>
{FIELD_TYPE_LABELS[tagDef.fieldType] || 'Text'}
</span>
</PopoverItem>
))}
</PopoverScrollArea>
</PopoverContent>
)}
</div>
</td>
)
}
const renderTypeCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.type || 'text'
const tagName = row.cells.tagName || ''
// Check if this is an existing tag (should be read-only)
const existingTag = tagDefinitions.find(
(def) => def.displayName.toLowerCase() === tagName.toLowerCase()
)
const isReadOnly = !!existingTag
const showTypeDropdown = typeDropdownStates[rowIndex] || false
const setShowTypeDropdown = (show: boolean) => {
setTypeDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleTypeDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isReadOnly) {
if (!showTypeDropdown) {
setShowTypeDropdown(true)
}
}
}
const handleTypeFocus = () => {
if (!disabled && !isReadOnly) {
setShowTypeDropdown(true)
}
}
const handleTypeBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowTypeDropdown(false), 150)
}
const typeOptions = [{ value: 'text', label: 'Text' }]
return (
<td className='border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isReadOnly}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleTypeDropdownClick}
onFocus={handleTypeFocus}
onBlur={handleTypeBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre text-muted-foreground'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showTypeDropdown && !isReadOnly && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{typeOptions.map((option) => (
<div
key={option.value}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'type', option.value)
setShowTypeDropdown(false)
}}
>
<span className='flex-1 truncate'>{option.label}</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
</Popover>
</td>
)
}
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
const fieldType = row.cells.fieldType || 'text'
const cellKey = `value-${rowIndex}`
const placeholder = getPlaceholderForFieldType(fieldType)
const isTagSelected = !!row.cells.tagName?.trim()
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
const handlers = inputController.fieldHelpers.createFieldHandlers(
cellKey,
cellValue,
(newValue) => handleCellChange(rowIndex, 'value', newValue)
(newValue) => handleValueChange(rowIndex, newValue)
)
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
cellKey,
cellValue,
(newValue) => handleTagDropdownSelection(rowIndex, 'value', newValue)
(newValue) => handleTagDropdownSelection(rowIndex, newValue)
)
return (
@@ -466,11 +344,12 @@ export function DocumentTagEntry({
onKeyDown={handlers.onKeyDown}
onDrop={handlers.onDrop}
onDragOver={handlers.onDragOver}
disabled={disabled}
disabled={disabled || !isTagSelected}
autoComplete='off'
placeholder={isTagSelected ? placeholder : 'Select a tag first'}
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
@@ -500,15 +379,13 @@ export function DocumentTagEntry({
}
const renderDeleteButton = (rowIndex: number) => {
// Allow deletion of any row
const canDelete = !isPreview && !disabled
return canDelete ? (
<td className='w-0 p-0'>
<td className='w-10 p-1'>
<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'
className='h-8 w-8 p-0 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-4 w-4 text-muted-foreground' />
@@ -517,24 +394,8 @@ export function DocumentTagEntry({
) : 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>
)}
<div className='overflow-visible rounded-md border'>
<table className='w-full'>
{renderHeader()}
@@ -542,7 +403,6 @@ export function DocumentTagEntry({
{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>
@@ -551,12 +411,11 @@ export function DocumentTagEntry({
</table>
</div>
{/* Add Row Button and Tag slots usage indicator */}
{/* Add Row Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<div className='mt-3'>
<Button
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={!canAddMoreTags}
className='h-7 px-2 text-xs'
@@ -564,11 +423,6 @@ export function DocumentTagEntry({
<Plus className='mr-1 h-2.5 w-2.5' />
Add Tag
</Button>
{/* Tag slots usage indicator */}
<div className='text-muted-foreground text-xs'>
{tagDefinitions.length + newTagsBeingCreated} of {MAX_TAG_SLOTS} tag slots used
</div>
</div>
)}
</div>

View File

@@ -2,10 +2,21 @@
import { useState } from 'react'
import { Plus } from 'lucide-react'
import { Trash } from '@/components/emcn/icons/trash'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Button,
Combobox,
type ComboboxOption,
Input,
Label,
Popover,
PopoverAnchor,
PopoverContent,
PopoverItem,
PopoverScrollArea,
Trash,
} from '@/components/emcn'
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import {
checkTagTrigger,
@@ -20,14 +31,22 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value'
interface TagFilter {
id: string
tagName: string
tagSlot?: string
fieldType: FilterFieldType
operator: string
tagValue: string
valueTo?: string // For 'between' operator
}
interface TagFilterRow {
id: string
cells: {
tagName: string
tagSlot?: string
fieldType: FilterFieldType
operator: string
value: string
valueTo?: string
}
}
@@ -77,7 +96,13 @@ export function KnowledgeTagFilters({
const parseFilters = (filterValue: string | null): TagFilter[] => {
if (!filterValue) return []
try {
return JSON.parse(filterValue)
const parsed = JSON.parse(filterValue)
// Handle legacy format (without fieldType/operator)
return parsed.map((f: TagFilter) => ({
...f,
fieldType: f.fieldType || 'text',
operator: f.operator || 'eq',
}))
} catch {
return []
}
@@ -93,13 +118,17 @@ export function KnowledgeTagFilters({
id: filter.id,
cells: {
tagName: filter.tagName || '',
tagSlot: filter.tagSlot,
fieldType: filter.fieldType || 'text',
operator: filter.operator || 'eq',
value: filter.tagValue || '',
valueTo: filter.valueTo,
},
}))
: [
{
id: 'empty-row-0',
cells: { tagName: '', value: '' },
cells: { tagName: '', fieldType: 'text', operator: 'eq', value: '' },
},
]
@@ -109,27 +138,75 @@ export function KnowledgeTagFilters({
setStoreValue(value)
}
const handleCellChange = (rowIndex: number, column: string, value: string) => {
/** Convert rows back to TagFilter format */
const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => {
return rowsToConvert.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagSlot: row.cells.tagSlot,
fieldType: row.cells.fieldType || 'text',
operator: row.cells.operator || 'eq',
tagValue: row.cells.value || '',
valueTo: row.cells.valueTo,
}))
}
const handleCellChange = (rowIndex: number, column: string, value: string | FilterFieldType) => {
if (isPreview || disabled) return
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
const newCells = { ...row.cells, [column]: value }
// Reset operator when field type changes
if (column === 'fieldType') {
const operators = getOperatorsForFieldType(value as FilterFieldType)
newCells.operator = operators[0]?.value || 'eq'
newCells.value = '' // Reset value when type changes
newCells.valueTo = undefined
}
// Reset valueTo if operator is not 'between'
if (column === 'operator' && value !== 'between') {
newCells.valueTo = undefined
}
return { ...row, cells: newCells }
}
return row
})
updateFilters(rowsToFilters(updatedRows))
}
/** Handle tag name selection from dropdown */
const handleTagNameSelection = (rowIndex: number, tagName: string) => {
if (isPreview || disabled) return
// Find the tag definition to get fieldType and tagSlot
const tagDef = tagDefinitions.find((t) => t.displayName === tagName)
const fieldType = (tagDef?.fieldType || 'text') as FilterFieldType
const operators = getOperatorsForFieldType(fieldType)
const updatedRows = [...rows].map((row, idx) => {
if (idx === rowIndex) {
return {
...row,
cells: { ...row.cells, [column]: value },
cells: {
...row.cells,
tagName,
tagSlot: tagDef?.tagSlot,
fieldType,
operator: operators[0]?.value || 'eq',
value: '', // Reset value when tag changes
valueTo: undefined,
},
}
}
return row
})
// Convert back to TagFilter format - keep all rows, even empty ones
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
updateFilters(rowsToFilters(updatedRows))
}
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
@@ -145,36 +222,29 @@ export function KnowledgeTagFilters({
return row
})
// Convert back to TagFilter format - keep all rows, even empty ones
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null
const jsonValue =
rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null
emitTagSelection(jsonValue)
}
const handleAddRow = () => {
if (isPreview || disabled) return
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
updateFilters(newFilters)
const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}`
const newFilter: TagFilter = {
id: newRowId,
tagName: '',
fieldType: 'text',
operator: 'eq',
tagValue: '',
}
updateFilters([...filters, newFilter])
}
const handleDeleteRow = (rowIndex: number) => {
if (isPreview || disabled || rows.length <= 1) return
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
const updatedFilters = updatedRows.map((row) => ({
id: row.id,
tagName: row.cells.tagName || '',
tagValue: row.cells.value || '',
}))
updateFilters(updatedFilters)
updateFilters(rowsToFilters(updatedRows))
}
if (isPreview) {
@@ -193,106 +263,120 @@ export function KnowledgeTagFilters({
const renderHeader = () => (
<thead>
<tr className='border-b'>
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
<th className='w-[35%] border-r px-2 py-2 text-center font-medium text-sm'>Tag</th>
<th className='w-[25%] border-r px-2 py-2 text-center font-medium text-sm'>Operator</th>
<th className='border-r px-2 py-2 text-center font-medium text-sm'>Value</th>
<th className='w-10' />
</tr>
</thead>
)
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.tagName || ''
const showDropdown = dropdownStates[rowIndex] || false
const isOpen = dropdownStates[rowIndex] || false
const setShowDropdown = (show: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
}
const handleDropdownClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!disabled && !isLoading) {
if (!showDropdown) {
setShowDropdown(true)
}
}
}
const handleFocus = () => {
if (!disabled && !isLoading) {
setShowDropdown(true)
}
}
const handleBlur = () => {
// Delay closing to allow dropdown selection
setTimeout(() => setShowDropdown(false), 150)
const setIsOpen = (open: boolean) => {
setDropdownStates((prev) => ({ ...prev, [rowIndex]: open }))
}
return (
<td className='relative border-r p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
readOnly
disabled={disabled || isLoading}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
onClick={handleDropdownClick}
onFocus={handleFocus}
onBlur={handleBlur}
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue || 'Select tag', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
{showDropdown && tagDefinitions.length > 0 && (
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
<div className='allow-scroll fade-in-0 zoom-in-95 animate-in rounded-md border bg-popover text-popover-foreground shadow-lg'>
<div
className='allow-scroll max-h-48 overflow-y-auto p-1'
style={{ scrollbarWidth: 'thin' }}
>
{tagDefinitions.map((tag) => (
<div
key={tag.id}
className='relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground'
onMouseDown={(e) => {
e.preventDefault()
handleCellChange(rowIndex, 'tagName', tag.displayName)
setShowDropdown(false)
}}
>
<span className='flex-1 truncate'>{tag.displayName}</span>
</div>
))}
</div>
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<div
className='relative w-full cursor-pointer'
onClick={() => !disabled && !isLoading && setIsOpen(true)}
>
<Input
value={cellValue}
readOnly
disabled={disabled || isLoading}
autoComplete='off'
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] font-medium font-sans text-sm'>
<span className='truncate'>{cellValue || 'Select tag'}</span>
</div>
</div>
</PopoverAnchor>
{tagDefinitions.length > 0 && (
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
maxHeight={192}
className='w-[200px]'
>
<PopoverScrollArea>
{tagDefinitions.map((tag) => (
<PopoverItem
key={tag.id}
onClick={() => {
handleTagNameSelection(rowIndex, tag.displayName)
setIsOpen(false)
}}
>
<span className='flex-1 truncate'>{tag.displayName}</span>
<span className='flex-shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground'>
{FIELD_TYPE_LABELS[tag.fieldType] || 'Text'}
</span>
</PopoverItem>
))}
</PopoverScrollArea>
</PopoverContent>
)}
</div>
</Popover>
</td>
)
}
/** Render operator cell */
const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => {
const fieldType = row.cells.fieldType || 'text'
const operator = row.cells.operator || 'eq'
const operators = getOperatorsForFieldType(fieldType)
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
value: op.value,
label: op.label,
}))
return (
<td className='border-r p-1'>
<Combobox
options={operatorOptions}
value={operator}
onChange={(value) => handleCellChange(rowIndex, 'operator', value)}
disabled={disabled || !row.cells.tagName}
placeholder='Operator'
size='sm'
/>
</td>
)
}
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
const cellValue = row.cells.value || ''
const fieldType = row.cells.fieldType || 'text'
const operator = row.cells.operator || 'eq'
const isBetween = operator === 'between'
const valueTo = row.cells.valueTo || ''
const isDisabled = disabled || !row.cells.tagName
const placeholder = getPlaceholderForFieldType(fieldType)
return (
<td className='p-1'>
<div className='relative w-full'>
<Input
value={cellValue}
onChange={(e) => {
const newValue = e.target.value
const cursorPosition = e.target.selectionStart ?? 0
// Single text input for all field types with variable support
const renderInput = (value: string, column: 'value' | 'valueTo') => (
<div className='relative w-full'>
<Input
value={value}
onChange={(e) => {
const newValue = e.target.value
const cursorPosition = e.target.selectionStart ?? 0
handleCellChange(rowIndex, 'value', newValue)
handleCellChange(rowIndex, column, newValue)
// Check for tag trigger
// Check for tag trigger (only for primary value input)
if (column === 'value') {
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
setActiveTagDropdown({
@@ -302,52 +386,69 @@ export function KnowledgeTagFilters({
activeSourceBlockId: null,
element: e.target,
})
}}
onFocus={(e) => {
if (!disabled) {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
}
}}
onFocus={(e) => {
if (!isDisabled && column === 'value') {
setActiveTagDropdown({
rowIndex,
showTags: false,
cursorPosition: 0,
activeSourceBlockId: null,
element: e.target,
})
}
}}
onBlur={() => {
if (column === 'value') {
setTimeout(() => setActiveTagDropdown(null), 200)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveTagDropdown(null)
}
}}
disabled={disabled}
autoComplete='off'
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(cellValue, {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setActiveTagDropdown(null)
}
}}
disabled={isDisabled}
autoComplete='off'
placeholder={placeholder}
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
/>
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-[8px] font-medium font-sans text-sm'>
<div className='whitespace-pre'>
{formatDisplayText(value || '', {
accessiblePrefixes,
highlightAll: !accessiblePrefixes,
})}
</div>
</div>
</td>
</div>
)
// Render with optional "between" second input
if (isBetween) {
return (
<td className='p-1'>
<div className='flex items-center gap-1'>
{renderInput(cellValue, 'value')}
<span className='flex-shrink-0 text-muted-foreground text-xs'>to</span>
{renderInput(valueTo, 'valueTo')}
</div>
</td>
)
}
return <td className='p-1'>{renderInput(cellValue, 'value')}</td>
}
const renderDeleteButton = (rowIndex: number) => {
const canDelete = !isPreview && !disabled
return canDelete ? (
<td className='w-0 p-0'>
<td className='w-10 p-1'>
<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'
className='h-8 w-8 p-0 opacity-0 group-hover:opacity-100'
onClick={() => handleDeleteRow(rowIndex)}
>
<Trash className='h-4 w-4 text-muted-foreground' />
@@ -369,6 +470,7 @@ export function KnowledgeTagFilters({
{rows.map((row, rowIndex) => (
<tr key={row.id} className='group relative border-t'>
{renderTagNameCell(row, rowIndex)}
{renderOperatorCell(row, rowIndex)}
{renderValueCell(row, rowIndex)}
{renderDeleteButton(rowIndex)}
</tr>
@@ -400,7 +502,7 @@ export function KnowledgeTagFilters({
{/* Add Filter Button */}
{!isPreview && !disabled && (
<div className='mt-3 flex items-center justify-between'>
<Button variant='outline' size='sm' onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Button variant='outline' onClick={handleAddRow} className='h-7 px-2 text-xs'>
<Plus className='mr-1 h-2.5 w-2.5' />
Add Filter
</Button>

View File

@@ -134,29 +134,111 @@ const isMessagesArray = (value: unknown): value is Array<{ role: string; content
)
}
/**
* Type guard for tag filter array (used in knowledge block filters)
*/
interface TagFilterItem {
id: string
tagName: string
fieldType?: string
operator?: string
tagValue: string
}
const isTagFilterArray = (value: unknown): value is TagFilterItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'tagName' in firstItem &&
'tagValue' in firstItem
)
}
/**
* Type guard for document tag entry array (used in knowledge block create document)
*/
interface DocumentTagItem {
id: string
tagName: string
fieldType?: string
value: string
}
const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'tagName' in firstItem &&
'value' in firstItem &&
!('tagValue' in firstItem) // Distinguish from tag filters
)
}
/**
* Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails
*/
const tryParseJson = (value: unknown): unknown => {
if (typeof value !== 'string') return value
try {
const trimmed = value.trim()
if (
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
(trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
return JSON.parse(trimmed)
}
} catch {
// Not valid JSON, return original
}
return value
}
/**
* Formats a subblock value for display, intelligently handling nested objects and arrays.
*/
const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
if (isMessagesArray(value)) {
const firstMessage = value[0]
// Try parsing JSON strings first
const parsedValue = tryParseJson(value)
if (isMessagesArray(parsedValue)) {
const firstMessage = parsedValue[0]
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
const content = firstMessage.content.trim()
return content.length > 50 ? `${content.slice(0, 50)}...` : content
}
if (isVariableAssignmentsArray(value)) {
const names = value.map((a) => a.variableName).filter((name): name is string => !!name)
if (isVariableAssignmentsArray(parsedValue)) {
const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name)
if (names.length === 0) return '-'
if (names.length === 1) return names[0]
if (names.length === 2) return `${names[0]}, ${names[1]}`
return `${names[0]}, ${names[1]} +${names.length - 2}`
}
if (isTableRowArray(value)) {
const nonEmptyRows = value.filter((row) => {
if (isTagFilterArray(parsedValue)) {
const validFilters = parsedValue.filter((f) => f.tagName?.trim())
if (validFilters.length === 0) return '-'
if (validFilters.length === 1) return validFilters[0].tagName
if (validFilters.length === 2) return `${validFilters[0].tagName}, ${validFilters[1].tagName}`
return `${validFilters[0].tagName}, ${validFilters[1].tagName} +${validFilters.length - 2}`
}
if (isDocumentTagArray(parsedValue)) {
const validTags = parsedValue.filter((t) => t.tagName?.trim())
if (validTags.length === 0) return '-'
if (validTags.length === 1) return validTags[0].tagName
if (validTags.length === 2) return `${validTags[0].tagName}, ${validTags[1].tagName}`
return `${validTags[0].tagName}, ${validTags[1].tagName} +${validTags.length - 2}`
}
if (isTableRowArray(parsedValue)) {
const nonEmptyRows = parsedValue.filter((row) => {
const cellValues = Object.values(row.cells)
return cellValues.some((cell) => cell && cell.trim() !== '')
})
@@ -175,16 +257,16 @@ const getDisplayValue = (value: unknown): string => {
return `${nonEmptyRows.length} rows`
}
if (isFieldFormatArray(value)) {
const namedFields = value.filter((field) => field.name && field.name.trim() !== '')
if (isFieldFormatArray(parsedValue)) {
const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '')
if (namedFields.length === 0) return '-'
if (namedFields.length === 1) return namedFields[0].name
if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}`
return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}`
}
if (isPlainObject(value)) {
const entries = Object.entries(value).filter(
if (isPlainObject(parsedValue)) {
const entries = Object.entries(parsedValue).filter(
([, val]) => val !== null && val !== undefined && val !== ''
)
@@ -201,8 +283,10 @@ const getDisplayValue = (value: unknown): string => {
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
}
if (Array.isArray(value)) {
const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '')
if (Array.isArray(parsedValue)) {
const nonEmptyItems = parsedValue.filter(
(item) => item !== null && item !== undefined && item !== ''
)
if (nonEmptyItems.length === 0) return '-'
const getItemDisplayValue = (item: unknown): string => {
@@ -220,10 +304,11 @@ const getDisplayValue = (value: unknown): string => {
return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}`
}
// For non-array, non-object values, use original value for string conversion
const stringValue = String(value)
if (stringValue === '[object Object]') {
try {
const json = JSON.stringify(value)
const json = JSON.stringify(parsedValue)
if (json.length <= 40) return json
return `${json.slice(0, 37)}...`
} catch {

View File

@@ -0,0 +1,408 @@
/**
* DatePicker component with calendar dropdown for date selection.
* Uses Radix UI Popover primitives for positioning and accessibility.
*
* @example
* ```tsx
* // Basic date picker
* <DatePicker
* value={date}
* onChange={(dateString) => setDate(dateString)}
* placeholder="Select date"
* />
* ```
*/
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/core/utils/cn'
import { Popover, PopoverAnchor, PopoverContent } from '../popover/popover'
/**
* Variant styles for the date picker trigger button.
* Matches the combobox and input styling patterns.
*/
const datePickerVariants = cva(
'flex w-full rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] dark:placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]',
{
variants: {
variant: {
default: '',
},
size: {
default: 'py-[6px] text-sm',
sm: 'py-[5px] text-[12px]',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface DatePickerProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
VariantProps<typeof datePickerVariants> {
/** Current selected date value (YYYY-MM-DD string or Date) */
value?: string | Date
/** Callback when date changes, returns YYYY-MM-DD format */
onChange?: (value: string) => void
/** Placeholder text when no value is selected */
placeholder?: string
/** Whether the picker is disabled */
disabled?: boolean
/** Size variant */
size?: 'default' | 'sm'
}
/**
* Month names for calendar display.
*/
const MONTHS = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
]
/**
* Day abbreviations for calendar header.
*/
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
/**
* Gets the number of days in a given month.
*/
function getDaysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate()
}
/**
* Gets the day of the week (0-6) for the first day of the month.
*/
function getFirstDayOfMonth(year: number, month: number): number {
return new Date(year, month, 1).getDay()
}
/**
* Formats a date for display in the trigger button.
*/
function formatDateForDisplay(date: Date | null): string {
if (!date) return ''
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
/**
* Formats a date as YYYY-MM-DD string.
*/
function formatDateAsString(year: number, month: number, day: number): string {
const m = (month + 1).toString().padStart(2, '0')
const d = day.toString().padStart(2, '0')
return `${year}-${m}-${d}`
}
/**
* Parses a string or Date value into a Date object.
* Handles various date formats including YYYY-MM-DD and ISO strings.
*/
function parseDate(value: string | Date | undefined): Date | null {
if (!value) return null
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) return null
return value
}
try {
// Handle YYYY-MM-DD format (treat as local date)
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [year, month, day] = value.split('-').map(Number)
return new Date(year, month - 1, day)
}
// Handle ISO strings with timezone (extract date part as local)
if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
// Use UTC date components to prevent timezone shift
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
// Fallback: try parsing as-is
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date
} catch {
return null
}
}
/**
* DatePicker component matching emcn design patterns.
* Provides a calendar dropdown for date selection.
*/
const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
(
{ className, variant, size, value, onChange, placeholder = 'Select date', disabled, ...props },
ref
) => {
const [open, setOpen] = React.useState(false)
const selectedDate = parseDate(value)
const [viewMonth, setViewMonth] = React.useState(() => {
const d = selectedDate || new Date()
return d.getMonth()
})
const [viewYear, setViewYear] = React.useState(() => {
const d = selectedDate || new Date()
return d.getFullYear()
})
// Update view when value changes externally
React.useEffect(() => {
if (selectedDate) {
setViewMonth(selectedDate.getMonth())
setViewYear(selectedDate.getFullYear())
}
}, [value])
/**
* Handles selection of a specific day in the calendar.
*/
const handleSelectDate = React.useCallback(
(day: number) => {
onChange?.(formatDateAsString(viewYear, viewMonth, day))
setOpen(false)
},
[viewYear, viewMonth, onChange]
)
/**
* Navigates to the previous month.
*/
const goToPrevMonth = React.useCallback(() => {
if (viewMonth === 0) {
setViewMonth(11)
setViewYear((prev) => prev - 1)
} else {
setViewMonth((prev) => prev - 1)
}
}, [viewMonth])
/**
* Navigates to the next month.
*/
const goToNextMonth = React.useCallback(() => {
if (viewMonth === 11) {
setViewMonth(0)
setViewYear((prev) => prev + 1)
} else {
setViewMonth((prev) => prev + 1)
}
}, [viewMonth])
/**
* Selects today's date and closes the picker.
*/
const handleSelectToday = React.useCallback(() => {
const now = new Date()
setViewMonth(now.getMonth())
setViewYear(now.getFullYear())
onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
setOpen(false)
}, [onChange])
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth)
/**
* Checks if a day is today's date.
*/
const isToday = React.useCallback(
(day: number) => {
const today = new Date()
return (
today.getDate() === day &&
today.getMonth() === viewMonth &&
today.getFullYear() === viewYear
)
},
[viewMonth, viewYear]
)
/**
* Checks if a day is the currently selected date.
*/
const isSelected = React.useCallback(
(day: number) => {
return (
selectedDate &&
selectedDate.getDate() === day &&
selectedDate.getMonth() === viewMonth &&
selectedDate.getFullYear() === viewYear
)
},
[selectedDate, viewMonth, viewYear]
)
// Build calendar grid
const calendarDays = React.useMemo(() => {
const days: (number | null)[] = []
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(null)
}
for (let day = 1; day <= daysInMonth; day++) {
days.push(day)
}
return days
}, [firstDayOfMonth, daysInMonth])
/**
* Handles keyboard events on the trigger.
*/
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setOpen(!open)
}
},
[disabled, open]
)
/**
* Handles click on the trigger.
*/
const handleTriggerClick = React.useCallback(() => {
if (!disabled) {
setOpen(!open)
}
}, [disabled, open])
return (
<Popover open={open} onOpenChange={setOpen}>
<div ref={ref} className='relative w-full' {...props}>
<PopoverAnchor asChild>
<div
role='button'
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
className={cn(
datePickerVariants({ variant, size }),
'relative cursor-pointer items-center justify-between',
className
)}
onClick={handleTriggerClick}
onKeyDown={handleKeyDown}
>
<span className={cn('flex-1 truncate', !selectedDate && 'text-[var(--text-muted)]')}>
{selectedDate ? formatDateForDisplay(selectedDate) : placeholder}
</span>
<ChevronDown
className={cn(
'ml-[8px] h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
open && 'rotate-180'
)}
/>
</div>
</PopoverAnchor>
<PopoverContent
side='bottom'
align='start'
sideOffset={4}
avoidCollisions={false}
className='w-[280px] rounded-[6px] border border-[var(--surface-11)] p-0'
>
{/* Calendar Header */}
<div className='flex items-center justify-between border-[var(--surface-11)] border-b px-[12px] py-[10px]'>
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
onClick={goToPrevMonth}
>
<ChevronLeft className='h-4 w-4' />
</button>
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
{MONTHS[viewMonth]} {viewYear}
</span>
<button
type='button'
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
onClick={goToNextMonth}
>
<ChevronRight className='h-4 w-4' />
</button>
</div>
{/* Day Headers */}
<div className='grid grid-cols-7 px-[8px] pt-[8px]'>
{DAYS.map((day) => (
<div
key={day}
className='flex h-[28px] items-center justify-center text-[11px] text-[var(--text-muted)]'
>
{day}
</div>
))}
</div>
{/* Calendar Grid */}
<div className='grid grid-cols-7 px-[8px] pb-[8px]'>
{calendarDays.map((day, index) => (
<div key={index} className='flex h-[32px] items-center justify-center'>
{day !== null && (
<button
type='button'
className={cn(
'flex h-[28px] w-[28px] items-center justify-center rounded-[4px] text-[12px] transition-colors',
isSelected(day)
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
: isToday(day)
? 'bg-[var(--surface-9)] text-[var(--text-primary)]'
: 'text-[var(--text-primary)] hover:bg-[var(--surface-9)]'
)}
onClick={() => handleSelectDate(day)}
>
{day}
</button>
)}
</div>
))}
</div>
{/* Today Button */}
<div className='border-[var(--surface-11)] border-t px-[8px] py-[8px]'>
<button
type='button'
className='w-full rounded-[4px] py-[6px] text-[12px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
onClick={handleSelectToday}
>
Today
</button>
</div>
</PopoverContent>
</div>
</Popover>
)
}
)
DatePicker.displayName = 'DatePicker'
export { DatePicker, datePickerVariants }

View File

@@ -10,7 +10,8 @@ export {
languages,
} from './code/code'
export { Combobox, type ComboboxOption } from './combobox/combobox'
export { Input } from './input/input'
export { DatePicker, type DatePickerProps, datePickerVariants } from './date-picker/date-picker'
export { Input, type InputProps, inputVariants } from './input/input'
export { Label } from './label/label'
export {
MODAL_SIZES,

View File

@@ -1,7 +1,30 @@
/**
* A minimal input component matching the emcn design system.
*
* @example
* ```tsx
* import { Input } from '@/components/emcn'
*
* // Basic usage
* <Input placeholder="Enter text..." />
*
* // Controlled input
* <Input value={value} onChange={(e) => setValue(e.target.value)} />
*
* // Disabled state
* <Input disabled placeholder="Cannot edit" />
* ```
*
* @see inputVariants for available styling variants
*/
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/core/utils/cn'
/**
* Variant styles for the Input component.
* Currently supports a 'default' variant.
*/
const inputVariants = cva(
'flex w-full rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] px-[8px] py-[6px] font-medium font-sans text-sm text-foreground transition-colors placeholder:text-[var(--text-muted)] dark:placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
{
@@ -16,6 +39,10 @@ const inputVariants = cva(
}
)
/**
* Props for the Input component.
* Extends native input attributes with variant support.
*/
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {}

View File

@@ -247,6 +247,11 @@ export interface PopoverContentProps
* @default false
*/
border?: boolean
/**
* When true, the popover will flip to avoid collisions with viewport edges
* @default true
*/
avoidCollisions?: boolean
}
/**
@@ -279,6 +284,7 @@ const PopoverContent = React.forwardRef<
sideOffset,
collisionPadding = 8,
border = false,
avoidCollisions = true,
...restProps
},
ref
@@ -328,7 +334,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={effectiveSideOffset}
collisionPadding={collisionPadding}
avoidCollisions={true}
avoidCollisions={avoidCollisions}
sticky='partial'
onWheel={handleWheel}
{...restProps}

View File

@@ -1,14 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { TagSlot } from '@/lib/knowledge/constants'
import type { AllTagSlot } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useKnowledgeBaseTagDefinitions')
export interface TagDefinition {
id: string
tagSlot: TagSlot
tagSlot: AllTagSlot
displayName: string
fieldType: string
createdAt: string

View File

@@ -1,14 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import type { TagSlot } from '@/lib/knowledge/constants'
import type { AllTagSlot } from '@/lib/knowledge/constants'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('useTagDefinitions')
export interface TagDefinition {
id: string
tagSlot: TagSlot
tagSlot: AllTagSlot
displayName: string
fieldType: string
createdAt: string
@@ -16,7 +16,7 @@ export interface TagDefinition {
}
export interface TagDefinitionInput {
tagSlot: TagSlot
tagSlot: AllTagSlot
displayName: string
fieldType: string
// Optional: for editing existing definitions

View File

@@ -92,7 +92,7 @@ export async function queryChunks(
export async function createChunk(
knowledgeBaseId: string,
documentId: string,
docTags: Record<string, string | null>,
docTags: Record<string, string | number | boolean | Date | null>,
chunkData: CreateChunkData,
requestId: string
): Promise<ChunkData> {
@@ -131,14 +131,27 @@ export async function createChunk(
embeddingModel: 'text-embedding-3-small',
startOffset: 0, // Manual chunks don't have document offsets
endOffset: chunkData.content.length,
// Inherit tags from parent document
tag1: docTags.tag1,
tag2: docTags.tag2,
tag3: docTags.tag3,
tag4: docTags.tag4,
tag5: docTags.tag5,
tag6: docTags.tag6,
tag7: docTags.tag7,
// Inherit text tags from parent document
tag1: docTags.tag1 as string | null,
tag2: docTags.tag2 as string | null,
tag3: docTags.tag3 as string | null,
tag4: docTags.tag4 as string | null,
tag5: docTags.tag5 as string | null,
tag6: docTags.tag6 as string | null,
tag7: docTags.tag7 as string | null,
// Inherit number tags from parent document (5 slots)
number1: docTags.number1 as number | null,
number2: docTags.number2 as number | null,
number3: docTags.number3 as number | null,
number4: docTags.number4 as number | null,
number5: docTags.number5 as number | null,
// Inherit date tags from parent document (2 slots)
date1: docTags.date1 as Date | null,
date2: docTags.date2 as Date | null,
// Inherit boolean tags from parent document (3 slots)
boolean1: docTags.boolean1 as boolean | null,
boolean2: docTags.boolean2 as boolean | null,
boolean3: docTags.boolean3 as boolean | null,
enabled: chunkData.enabled ?? true,
createdAt: now,
updatedAt: now,

View File

@@ -3,18 +3,55 @@ export const TAG_SLOT_CONFIG = {
slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const,
maxSlots: 7,
},
number: {
slots: ['number1', 'number2', 'number3', 'number4', 'number5'] as const,
maxSlots: 5,
},
date: {
slots: ['date1', 'date2'] as const,
maxSlots: 2,
},
boolean: {
slots: ['boolean1', 'boolean2', 'boolean3'] as const,
maxSlots: 3,
},
} as const
export const SUPPORTED_FIELD_TYPES = Object.keys(TAG_SLOT_CONFIG) as Array<
keyof typeof TAG_SLOT_CONFIG
>
/** Text tag slots (for backwards compatibility) */
export const TAG_SLOTS = TAG_SLOT_CONFIG.text.slots
/** All tag slots across all field types */
export const ALL_TAG_SLOTS = [
...TAG_SLOT_CONFIG.text.slots,
...TAG_SLOT_CONFIG.number.slots,
...TAG_SLOT_CONFIG.date.slots,
...TAG_SLOT_CONFIG.boolean.slots,
] as const
export const MAX_TAG_SLOTS = TAG_SLOT_CONFIG.text.maxSlots
/** Type for text tag slots (for backwards compatibility) */
export type TagSlot = (typeof TAG_SLOTS)[number]
/** Type for all tag slots */
export type AllTagSlot = (typeof ALL_TAG_SLOTS)[number]
/** Type for number tag slots */
export type NumberTagSlot = (typeof TAG_SLOT_CONFIG.number.slots)[number]
/** Type for date tag slots */
export type DateTagSlot = (typeof TAG_SLOT_CONFIG.date.slots)[number]
/** Type for boolean tag slots */
export type BooleanTagSlot = (typeof TAG_SLOT_CONFIG.boolean.slots)[number]
/**
* Get the available slots for a field type
*/
export function getSlotsForFieldType(fieldType: string): readonly string[] {
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
if (!config) {
@@ -22,3 +59,52 @@ export function getSlotsForFieldType(fieldType: string): readonly string[] {
}
return config.slots
}
/**
* Get the field type for a tag slot
*/
export function getFieldTypeForSlot(tagSlot: string): keyof typeof TAG_SLOT_CONFIG | null {
for (const [fieldType, config] of Object.entries(TAG_SLOT_CONFIG)) {
if ((config.slots as readonly string[]).includes(tagSlot)) {
return fieldType as keyof typeof TAG_SLOT_CONFIG
}
}
return null
}
/**
* Check if a slot is valid for a given field type
*/
export function isValidSlotForFieldType(tagSlot: string, fieldType: string): boolean {
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
if (!config) {
return false
}
return (config.slots as readonly string[]).includes(tagSlot)
}
/**
* Display labels for field types
*/
export const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Boolean',
}
/**
* Get placeholder text for value input based on field type
*/
export function getPlaceholderForFieldType(fieldType: string): string {
switch (fieldType) {
case 'boolean':
return 'true or false'
case 'number':
return 'Enter number'
case 'date':
return 'YYYY-MM-DD'
default:
return 'Enter value'
}
}

View File

@@ -5,12 +5,18 @@ import { tasks } from '@trigger.dev/sdk'
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { env } from '@/lib/core/config/env'
import { getStorageMethod, isRedisStorage } from '@/lib/core/storage'
import { getSlotsForFieldType, type TAG_SLOT_CONFIG } from '@/lib/knowledge/constants'
import { processDocument } from '@/lib/knowledge/documents/document-processor'
import { DocumentProcessingQueue } from '@/lib/knowledge/documents/queue'
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
import { generateEmbeddings } from '@/lib/knowledge/embeddings'
import { getNextAvailableSlot } from '@/lib/knowledge/tags/service'
import {
buildUndefinedTagsError,
parseBooleanValue,
parseDateValue,
parseNumberValue,
validateTagValue,
} from '@/lib/knowledge/tags/utils'
import type { ProcessedDocumentTags } from '@/lib/knowledge/types'
import { createLogger } from '@/lib/logs/console/logger'
import type { DocumentProcessingPayload } from '@/background/knowledge-processing'
@@ -113,80 +119,194 @@ export interface DocumentTagData {
}
/**
* Process structured document tags and create tag definitions
* Process structured document tags and validate them against existing definitions
* Throws an error if a tag doesn't exist or if the value doesn't match the expected type
*/
export async function processDocumentTags(
knowledgeBaseId: string,
tagData: DocumentTagData[],
requestId: string
): Promise<Record<string, string | null>> {
const result: Record<string, string | null> = {}
): Promise<ProcessedDocumentTags> {
// Helper to set a tag value with proper typing
const setTagValue = (
tags: ProcessedDocumentTags,
slot: string,
value: string | number | Date | boolean | null
): void => {
switch (slot) {
case 'tag1':
tags.tag1 = value as string | null
break
case 'tag2':
tags.tag2 = value as string | null
break
case 'tag3':
tags.tag3 = value as string | null
break
case 'tag4':
tags.tag4 = value as string | null
break
case 'tag5':
tags.tag5 = value as string | null
break
case 'tag6':
tags.tag6 = value as string | null
break
case 'tag7':
tags.tag7 = value as string | null
break
case 'number1':
tags.number1 = value as number | null
break
case 'number2':
tags.number2 = value as number | null
break
case 'number3':
tags.number3 = value as number | null
break
case 'number4':
tags.number4 = value as number | null
break
case 'number5':
tags.number5 = value as number | null
break
case 'date1':
tags.date1 = value as Date | null
break
case 'date2':
tags.date2 = value as Date | null
break
case 'boolean1':
tags.boolean1 = value as boolean | null
break
case 'boolean2':
tags.boolean2 = value as boolean | null
break
case 'boolean3':
tags.boolean3 = value as boolean | null
break
}
}
const textSlots = getSlotsForFieldType('text')
textSlots.forEach((slot) => {
result[slot] = null
})
const result: ProcessedDocumentTags = {
tag1: null,
tag2: null,
tag3: null,
tag4: null,
tag5: null,
tag6: null,
tag7: null,
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
date1: null,
date2: null,
boolean1: null,
boolean2: null,
boolean3: null,
}
if (!Array.isArray(tagData) || tagData.length === 0) {
return result
}
try {
const existingDefinitions = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
// Fetch existing tag definitions
const existingDefinitions = await db
.select()
.from(knowledgeBaseTagDefinitions)
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def]))
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
for (const tag of tagData) {
if (!tag.tagName?.trim() || !tag.value?.trim()) continue
// First pass: collect all validation errors
const undefinedTags: string[] = []
const typeErrors: string[] = []
const tagName = tag.tagName.trim()
const fieldType = tag.fieldType
const value = tag.value.trim()
for (const tag of tagData) {
// Skip if no tag name
if (!tag.tagName?.trim()) continue
let targetSlot: string | null = null
const tagName = tag.tagName.trim()
const fieldType = tag.fieldType || 'text'
// Check if tag definition already exists
const existingDef = existingByName.get(tagName)
if (existingDef) {
targetSlot = existingDef.tagSlot
} else {
// Find next available slot using the tags service function
targetSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot)
// For boolean, check if value is defined; for others, check if value is non-empty
const hasValue =
fieldType === 'boolean'
? tag.value !== undefined && tag.value !== null && tag.value !== ''
: tag.value?.trim && tag.value.trim().length > 0
// Create new tag definition if we have a slot
if (targetSlot) {
const newDefinition = {
id: randomUUID(),
knowledgeBaseId,
tagSlot: targetSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number],
displayName: tagName,
fieldType,
createdAt: new Date(),
updatedAt: new Date(),
}
if (!hasValue) continue
await db.insert(knowledgeBaseTagDefinitions).values(newDefinition)
existingBySlot.set(targetSlot, newDefinition)
logger.info(`[${requestId}] Created tag definition: ${tagName} -> ${targetSlot}`)
}
}
// Assign value to the slot
if (targetSlot) {
result[targetSlot] = value
}
// Check if tag exists
const existingDef = existingByName.get(tagName)
if (!existingDef) {
undefinedTags.push(tagName)
continue
}
return result
} catch (error) {
logger.error(`[${requestId}] Error processing document tags:`, error)
return result
// Validate value type using shared validation
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
const actualFieldType = existingDef.fieldType || fieldType
const validationError = validateTagValue(tagName, String(rawValue), actualFieldType)
if (validationError) {
typeErrors.push(validationError)
}
}
// Throw combined error if there are any validation issues
if (undefinedTags.length > 0 || typeErrors.length > 0) {
const errorParts: string[] = []
if (undefinedTags.length > 0) {
errorParts.push(buildUndefinedTagsError(undefinedTags))
}
if (typeErrors.length > 0) {
errorParts.push(...typeErrors)
}
throw new Error(errorParts.join('\n'))
}
// Second pass: process valid tags
for (const tag of tagData) {
if (!tag.tagName?.trim()) continue
const tagName = tag.tagName.trim()
const fieldType = tag.fieldType || 'text'
const hasValue =
fieldType === 'boolean'
? tag.value !== undefined && tag.value !== null && tag.value !== ''
: tag.value?.trim && tag.value.trim().length > 0
if (!hasValue) continue
const existingDef = existingByName.get(tagName)
if (!existingDef) continue // Already validated above
const targetSlot = existingDef.tagSlot
const actualFieldType = existingDef.fieldType || fieldType
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
const stringValue = String(rawValue).trim()
// Assign value to the slot with proper type conversion (values already validated)
if (actualFieldType === 'boolean') {
setTagValue(result, targetSlot, parseBooleanValue(stringValue) ?? false)
} else if (actualFieldType === 'number') {
setTagValue(result, targetSlot, parseNumberValue(stringValue))
} else if (actualFieldType === 'date') {
setTagValue(result, targetSlot, parseDateValue(stringValue))
} else {
setTagValue(result, targetSlot, stringValue)
}
logger.info(`[${requestId}] Set tag ${tagName} (${targetSlot}) = ${stringValue}`)
}
return result
}
/**
@@ -375,6 +495,7 @@ export async function processDocumentAsync(
const documentRecord = await db
.select({
// Text tags (7 slots)
tag1: document.tag1,
tag2: document.tag2,
tag3: document.tag3,
@@ -382,6 +503,19 @@ export async function processDocumentAsync(
tag5: document.tag5,
tag6: document.tag6,
tag7: document.tag7,
// Number tags (5 slots)
number1: document.number1,
number2: document.number2,
number3: document.number3,
number4: document.number4,
number5: document.number5,
// Date tags (2 slots)
date1: document.date1,
date2: document.date2,
// Boolean tags (3 slots)
boolean1: document.boolean1,
boolean2: document.boolean2,
boolean3: document.boolean3,
})
.from(document)
.where(eq(document.id, documentId))
@@ -404,7 +538,7 @@ export async function processDocumentAsync(
embeddingModel: 'text-embedding-3-small',
startOffset: chunk.metadata.startIndex,
endOffset: chunk.metadata.endIndex,
// Copy tags from document
// Copy text tags from document (7 slots)
tag1: documentTags.tag1,
tag2: documentTags.tag2,
tag3: documentTags.tag3,
@@ -412,6 +546,19 @@ export async function processDocumentAsync(
tag5: documentTags.tag5,
tag6: documentTags.tag6,
tag7: documentTags.tag7,
// Copy number tags from document (5 slots)
number1: documentTags.number1,
number2: documentTags.number2,
number3: documentTags.number3,
number4: documentTags.number4,
number5: documentTags.number5,
// Copy date tags from document (2 slots)
date1: documentTags.date1,
date2: documentTags.date2,
// Copy boolean tags from document (3 slots)
boolean1: documentTags.boolean1,
boolean2: documentTags.boolean2,
boolean3: documentTags.boolean3,
createdAt: now,
updatedAt: now,
}))
@@ -568,15 +715,7 @@ export async function createDocumentRecords(
for (const docData of documents) {
const documentId = randomUUID()
let processedTags: Record<string, string | null> = {
tag1: null,
tag2: null,
tag3: null,
tag4: null,
tag5: null,
tag6: null,
tag7: null,
}
let processedTags: Record<string, any> = {}
if (docData.documentTagsData) {
try {
@@ -585,7 +724,12 @@ export async function createDocumentRecords(
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
}
} catch (error) {
logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error)
// Re-throw validation errors, only catch JSON parse errors
if (error instanceof SyntaxError) {
logger.warn(`[${requestId}] Failed to parse documentTagsData for bulk document:`, error)
} else {
throw error
}
}
}
@@ -602,14 +746,27 @@ export async function createDocumentRecords(
processingStatus: 'pending' as const,
enabled: true,
uploadedAt: now,
// Use processed tags if available, otherwise fall back to individual tag fields
tag1: processedTags.tag1 || docData.tag1 || null,
tag2: processedTags.tag2 || docData.tag2 || null,
tag3: processedTags.tag3 || docData.tag3 || null,
tag4: processedTags.tag4 || docData.tag4 || null,
tag5: processedTags.tag5 || docData.tag5 || null,
tag6: processedTags.tag6 || docData.tag6 || null,
tag7: processedTags.tag7 || docData.tag7 || null,
// Text tags - use processed tags if available, otherwise fall back to individual tag fields
tag1: processedTags.tag1 ?? docData.tag1 ?? null,
tag2: processedTags.tag2 ?? docData.tag2 ?? null,
tag3: processedTags.tag3 ?? docData.tag3 ?? null,
tag4: processedTags.tag4 ?? docData.tag4 ?? null,
tag5: processedTags.tag5 ?? docData.tag5 ?? null,
tag6: processedTags.tag6 ?? docData.tag6 ?? null,
tag7: processedTags.tag7 ?? docData.tag7 ?? null,
// Number tags (5 slots)
number1: processedTags.number1 ?? null,
number2: processedTags.number2 ?? null,
number3: processedTags.number3 ?? null,
number4: processedTags.number4 ?? null,
number5: processedTags.number5 ?? null,
// Date tags (2 slots)
date1: processedTags.date1 ?? null,
date2: processedTags.date2 ?? null,
// Boolean tags (3 slots)
boolean1: processedTags.boolean1 ?? null,
boolean2: processedTags.boolean2 ?? null,
boolean3: processedTags.boolean3 ?? null,
}
documentRecords.push(newDocument)
@@ -679,6 +836,7 @@ export async function getDocuments(
processingError: string | null
enabled: boolean
uploadedAt: Date
// Text tags
tag1: string | null
tag2: string | null
tag3: string | null
@@ -686,6 +844,19 @@ export async function getDocuments(
tag5: string | null
tag6: string | null
tag7: string | null
// Number tags
number1: number | null
number2: number | null
number3: number | null
number4: number | null
number5: number | null
// Date tags
date1: Date | null
date2: Date | null
// Boolean tags
boolean1: boolean | null
boolean2: boolean | null
boolean3: boolean | null
}>
pagination: {
total: number
@@ -772,7 +943,7 @@ export async function getDocuments(
processingError: document.processingError,
enabled: document.enabled,
uploadedAt: document.uploadedAt,
// Include tags in response
// Text tags (7 slots)
tag1: document.tag1,
tag2: document.tag2,
tag3: document.tag3,
@@ -780,6 +951,19 @@ export async function getDocuments(
tag5: document.tag5,
tag6: document.tag6,
tag7: document.tag7,
// Number tags (5 slots)
number1: document.number1,
number2: document.number2,
number3: document.number3,
number4: document.number4,
number5: document.number5,
// Date tags (2 slots)
date1: document.date1,
date2: document.date2,
// Boolean tags (3 slots)
boolean1: document.boolean1,
boolean2: document.boolean2,
boolean3: document.boolean3,
})
.from(document)
.where(and(...whereConditions))
@@ -807,6 +991,7 @@ export async function getDocuments(
processingError: doc.processingError,
enabled: doc.enabled,
uploadedAt: doc.uploadedAt,
// Text tags
tag1: doc.tag1,
tag2: doc.tag2,
tag3: doc.tag3,
@@ -814,6 +999,19 @@ export async function getDocuments(
tag5: doc.tag5,
tag6: doc.tag6,
tag7: doc.tag7,
// Number tags
number1: doc.number1,
number2: doc.number2,
number3: doc.number3,
number4: doc.number4,
number5: doc.number5,
// Date tags
date1: doc.date1,
date2: doc.date2,
// Boolean tags
boolean1: doc.boolean1,
boolean2: doc.boolean2,
boolean3: doc.boolean3,
})),
pagination: {
total,
@@ -883,14 +1081,28 @@ export async function createSingleDocument(
const now = new Date()
// Process structured tag data if provided
let processedTags: Record<string, string | null> = {
tag1: documentData.tag1 || null,
tag2: documentData.tag2 || null,
tag3: documentData.tag3 || null,
tag4: documentData.tag4 || null,
tag5: documentData.tag5 || null,
tag6: documentData.tag6 || null,
tag7: documentData.tag7 || null,
let processedTags: Record<string, any> = {
// Text tags (7 slots)
tag1: documentData.tag1 ?? null,
tag2: documentData.tag2 ?? null,
tag3: documentData.tag3 ?? null,
tag4: documentData.tag4 ?? null,
tag5: documentData.tag5 ?? null,
tag6: documentData.tag6 ?? null,
tag7: documentData.tag7 ?? null,
// Number tags (5 slots)
number1: null,
number2: null,
number3: null,
number4: null,
number5: null,
// Date tags (2 slots)
date1: null,
date2: null,
// Boolean tags (3 slots)
boolean1: null,
boolean2: null,
boolean3: null,
}
if (documentData.documentTagsData) {
@@ -901,7 +1113,12 @@ export async function createSingleDocument(
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
}
} catch (error) {
logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error)
// Re-throw validation errors, only catch JSON parse errors
if (error instanceof SyntaxError) {
logger.warn(`[${requestId}] Failed to parse documentTagsData:`, error)
} else {
throw error
}
}
}
@@ -1183,6 +1400,7 @@ export async function updateDocument(
characterCount?: number
processingStatus?: 'pending' | 'processing' | 'completed' | 'failed'
processingError?: string
// Text tags
tag1?: string
tag2?: string
tag3?: string
@@ -1190,6 +1408,19 @@ export async function updateDocument(
tag5?: string
tag6?: string
tag7?: string
// Number tags
number1?: string
number2?: string
number3?: string
number4?: string
number5?: string
// Date tags
date1?: string
date2?: string
// Boolean tags
boolean1?: string
boolean2?: string
boolean3?: string
},
requestId: string
): Promise<{
@@ -1215,6 +1446,16 @@ export async function updateDocument(
tag5: string | null
tag6: string | null
tag7: string | null
number1: number | null
number2: number | null
number3: number | null
number4: number | null
number5: number | null
date1: Date | null
date2: Date | null
boolean1: boolean | null
boolean2: boolean | null
boolean3: boolean | null
deletedAt: Date | null
}> {
const dbUpdateData: Partial<{
@@ -1234,9 +1475,38 @@ export async function updateDocument(
tag5: string | null
tag6: string | null
tag7: string | null
number1: number | null
number2: number | null
number3: number | null
number4: number | null
number5: number | null
date1: Date | null
date2: Date | null
boolean1: boolean | null
boolean2: boolean | null
boolean3: boolean | null
}> = {}
const TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
type TagSlot = (typeof TAG_SLOTS)[number]
// All tag slots across all field types
const ALL_TAG_SLOTS = [
'tag1',
'tag2',
'tag3',
'tag4',
'tag5',
'tag6',
'tag7',
'number1',
'number2',
'number3',
'number4',
'number5',
'date1',
'date2',
'boolean1',
'boolean2',
'boolean3',
] as const
type TagSlot = (typeof ALL_TAG_SLOTS)[number]
// Regular field updates
if (updateData.filename !== undefined) dbUpdateData.filename = updateData.filename
@@ -1250,23 +1520,49 @@ export async function updateDocument(
if (updateData.processingError !== undefined)
dbUpdateData.processingError = updateData.processingError
TAG_SLOTS.forEach((slot: TagSlot) => {
// Helper to convert string values to proper types for the database
const convertTagValue = (
slot: string,
value: string | undefined
): string | number | Date | boolean | null => {
if (value === undefined || value === '') return null
// Number slots
if (slot.startsWith('number')) {
return parseNumberValue(value)
}
// Date slots
if (slot.startsWith('date')) {
return parseDateValue(value)
}
// Boolean slots
if (slot.startsWith('boolean')) {
return parseBooleanValue(value) ?? false
}
// Text slots: keep as string
return value || null
}
ALL_TAG_SLOTS.forEach((slot: TagSlot) => {
const updateValue = (updateData as any)[slot]
if (updateValue !== undefined) {
;(dbUpdateData as any)[slot] = updateValue
;(dbUpdateData as any)[slot] = convertTagValue(slot, updateValue)
}
})
await db.transaction(async (tx) => {
await tx.update(document).set(dbUpdateData).where(eq(document.id, documentId))
const hasTagUpdates = TAG_SLOTS.some((field) => (updateData as any)[field] !== undefined)
const hasTagUpdates = ALL_TAG_SLOTS.some((field) => (updateData as any)[field] !== undefined)
if (hasTagUpdates) {
const embeddingUpdateData: Record<string, string | null> = {}
TAG_SLOTS.forEach((field) => {
const embeddingUpdateData: Record<string, any> = {}
ALL_TAG_SLOTS.forEach((field) => {
if ((updateData as any)[field] !== undefined) {
embeddingUpdateData[field] = (updateData as any)[field] || null
embeddingUpdateData[field] = convertTagValue(field, (updateData as any)[field])
}
})
@@ -1313,6 +1609,16 @@ export async function updateDocument(
tag5: doc.tag5,
tag6: doc.tag6,
tag7: doc.tag7,
number1: doc.number1,
number2: doc.number2,
number3: doc.number3,
number4: doc.number4,
number5: doc.number5,
date1: doc.date1,
date2: doc.date2,
boolean1: doc.boolean1,
boolean2: doc.boolean2,
boolean3: doc.boolean3,
deletedAt: doc.deletedAt,
}
}

View File

@@ -0,0 +1,2 @@
export * from './query-builder'
export * from './types'

View File

@@ -0,0 +1,393 @@
import { document, embedding } from '@sim/db/schema'
import { and, eq, gt, gte, ilike, lt, lte, ne, not, or, type SQL } from 'drizzle-orm'
import type {
BooleanFilterCondition,
DateFilterCondition,
FilterCondition,
FilterGroup,
NumberFilterCondition,
SimpleTagFilter,
TagFilter,
TextFilterCondition,
} from './types'
/**
* Valid tag slots that can be used in filters
*/
const VALID_TEXT_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const
const VALID_DATE_SLOTS = ['date1', 'date2'] as const
const VALID_BOOLEAN_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const
type TextSlot = (typeof VALID_TEXT_SLOTS)[number]
type NumberSlot = (typeof VALID_NUMBER_SLOTS)[number]
type DateSlot = (typeof VALID_DATE_SLOTS)[number]
type BooleanSlot = (typeof VALID_BOOLEAN_SLOTS)[number]
/**
* Validates that a tag slot is valid for the given field type
*/
function isValidSlotForType(
slot: string,
fieldType: string
): slot is TextSlot | NumberSlot | DateSlot | BooleanSlot {
switch (fieldType) {
case 'text':
return (VALID_TEXT_SLOTS as readonly string[]).includes(slot)
case 'number':
return (VALID_NUMBER_SLOTS as readonly string[]).includes(slot)
case 'date':
return (VALID_DATE_SLOTS as readonly string[]).includes(slot)
case 'boolean':
return (VALID_BOOLEAN_SLOTS as readonly string[]).includes(slot)
default:
return false
}
}
/**
* Build SQL condition for a text filter
*/
function buildTextCondition(
condition: TextFilterCondition,
table: typeof document | typeof embedding
): SQL | null {
const { tagSlot, operator, value } = condition
if (!isValidSlotForType(tagSlot, 'text')) {
return null
}
const column = table[tagSlot as TextSlot]
if (!column) return null
switch (operator) {
case 'eq':
return eq(column, value)
case 'neq':
return ne(column, value)
case 'contains':
return ilike(column, `%${value}%`)
case 'not_contains':
return not(ilike(column, `%${value}%`))
case 'starts_with':
return ilike(column, `${value}%`)
case 'ends_with':
return ilike(column, `%${value}`)
default:
return null
}
}
/**
* Build SQL condition for a number filter
*/
function buildNumberCondition(
condition: NumberFilterCondition,
table: typeof document | typeof embedding
): SQL | null {
const { tagSlot, operator, value, valueTo } = condition
if (!isValidSlotForType(tagSlot, 'number')) {
return null
}
const column = table[tagSlot as NumberSlot]
if (!column) return null
switch (operator) {
case 'eq':
return eq(column, value)
case 'neq':
return ne(column, value)
case 'gt':
return gt(column, value)
case 'gte':
return gte(column, value)
case 'lt':
return lt(column, value)
case 'lte':
return lte(column, value)
case 'between':
if (valueTo !== undefined) {
return and(gte(column, value), lte(column, valueTo)) ?? null
}
return null
default:
return null
}
}
/**
* Parse a YYYY-MM-DD date string into a UTC Date object.
*/
function parseDateValue(value: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
const [year, month, day] = value.split('-').map(Number)
return new Date(Date.UTC(year, month - 1, day))
}
/**
* Build SQL condition for a date filter.
* Expects date values in YYYY-MM-DD format.
*/
function buildDateCondition(
condition: DateFilterCondition,
table: typeof document | typeof embedding
): SQL | null {
const { tagSlot, operator, value, valueTo } = condition
if (!isValidSlotForType(tagSlot, 'date')) {
return null
}
const column = table[tagSlot as DateSlot]
if (!column) return null
const dateValue = parseDateValue(value)
if (!dateValue) return null
switch (operator) {
case 'eq':
return eq(column, dateValue)
case 'neq':
return ne(column, dateValue)
case 'gt':
return gt(column, dateValue)
case 'gte':
return gte(column, dateValue)
case 'lt':
return lt(column, dateValue)
case 'lte':
return lte(column, dateValue)
case 'between':
if (valueTo !== undefined) {
const dateValueTo = parseDateValue(valueTo)
if (!dateValueTo) return null
return and(gte(column, dateValue), lte(column, dateValueTo)) ?? null
}
return null
default:
return null
}
}
/**
* Build SQL condition for a boolean filter
*/
function buildBooleanCondition(
condition: BooleanFilterCondition,
table: typeof document | typeof embedding
): SQL | null {
const { tagSlot, operator, value } = condition
if (!isValidSlotForType(tagSlot, 'boolean')) {
return null
}
const column = table[tagSlot as BooleanSlot]
if (!column) return null
switch (operator) {
case 'eq':
return eq(column, value)
case 'neq':
return ne(column, value)
default:
return null
}
}
/**
* Build SQL condition for a single filter condition
*/
function buildCondition(
condition: FilterCondition,
table: typeof document | typeof embedding
): SQL | null {
switch (condition.fieldType) {
case 'text':
return buildTextCondition(condition, table)
case 'number':
return buildNumberCondition(condition, table)
case 'date':
return buildDateCondition(condition, table)
case 'boolean':
return buildBooleanCondition(condition, table)
default:
return null
}
}
/**
* Build SQL condition for a filter group
*/
function buildGroupCondition(
group: FilterGroup,
table: typeof document | typeof embedding
): SQL | null {
const conditions = group.conditions
.map((condition) => buildCondition(condition, table))
.filter((c): c is SQL => c !== null)
if (conditions.length === 0) {
return null
}
if (conditions.length === 1) {
return conditions[0]
}
return (group.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null
}
/**
* Build SQL WHERE clause from a TagFilter
* Supports nested groups with AND/OR logic
*/
export function buildTagFilterQuery(
filter: TagFilter,
table: typeof document | typeof embedding
): SQL | null {
const groupConditions = filter.groups
.map((group) => buildGroupCondition(group, table))
.filter((c): c is SQL => c !== null)
if (groupConditions.length === 0) {
return null
}
if (groupConditions.length === 1) {
return groupConditions[0]
}
return (filter.rootOperator === 'AND' ? and(...groupConditions) : or(...groupConditions)) ?? null
}
/**
* Build SQL WHERE clause from a SimpleTagFilter
* For flat filter structures without nested groups
*/
export function buildSimpleTagFilterQuery(
filter: SimpleTagFilter,
table: typeof document | typeof embedding
): SQL | null {
const conditions = filter.conditions
.map((condition) => buildCondition(condition, table))
.filter((c): c is SQL => c !== null)
if (conditions.length === 0) {
return null
}
if (conditions.length === 1) {
return conditions[0]
}
return (filter.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null
}
/**
* Build SQL WHERE clause from an array of filter conditions
* Combines all conditions with AND by default
*/
export function buildFilterConditionsQuery(
conditions: FilterCondition[],
table: typeof document | typeof embedding,
operator: 'AND' | 'OR' = 'AND'
): SQL | null {
return buildSimpleTagFilterQuery({ operator, conditions }, table)
}
/**
* Convenience function to build filter for document table
*/
export function buildDocumentFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null {
if ('rootOperator' in filter) {
return buildTagFilterQuery(filter, document)
}
return buildSimpleTagFilterQuery(filter, document)
}
/**
* Convenience function to build filter for embedding table
*/
export function buildEmbeddingFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null {
if ('rootOperator' in filter) {
return buildTagFilterQuery(filter, embedding)
}
return buildSimpleTagFilterQuery(filter, embedding)
}
/**
* Validate a filter condition
* Returns an array of validation errors, empty if valid
*/
export function validateFilterCondition(condition: FilterCondition): string[] {
const errors: string[] = []
if (!isValidSlotForType(condition.tagSlot, condition.fieldType)) {
errors.push(`Invalid tag slot "${condition.tagSlot}" for field type "${condition.fieldType}"`)
}
switch (condition.fieldType) {
case 'text':
if (typeof condition.value !== 'string') {
errors.push('Text filter value must be a string')
}
break
case 'number':
if (typeof condition.value !== 'number' || Number.isNaN(condition.value)) {
errors.push('Number filter value must be a valid number')
}
if (condition.operator === 'between' && condition.valueTo === undefined) {
errors.push('Between operator requires a second value')
}
if (condition.valueTo !== undefined && typeof condition.valueTo !== 'number') {
errors.push('Number filter second value must be a valid number')
}
break
case 'date':
if (typeof condition.value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.value)) {
errors.push('Date filter value must be in YYYY-MM-DD format')
}
if (condition.operator === 'between' && condition.valueTo === undefined) {
errors.push('Between operator requires a second value')
}
if (
condition.valueTo !== undefined &&
(typeof condition.valueTo !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.valueTo))
) {
errors.push('Date filter second value must be in YYYY-MM-DD format')
}
break
case 'boolean':
if (typeof condition.value !== 'boolean') {
errors.push('Boolean filter value must be true or false')
}
break
}
return errors
}
/**
* Validate all conditions in a filter
*/
export function validateFilter(filter: TagFilter | SimpleTagFilter): string[] {
const errors: string[] = []
if ('rootOperator' in filter) {
for (const group of filter.groups) {
for (const condition of group.conditions) {
errors.push(...validateFilterCondition(condition))
}
}
} else {
for (const condition of filter.conditions) {
errors.push(...validateFilterCondition(condition))
}
}
return errors
}

View File

@@ -0,0 +1,191 @@
/**
* Filter operators for different field types
*/
/**
* Text filter operators
*/
export type TextOperator = 'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with'
/**
* Number filter operators
*/
export type NumberOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between'
/**
* Date filter operators
*/
export type DateOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between'
/**
* Boolean filter operators
*/
export type BooleanOperator = 'eq' | 'neq'
/**
* All filter operators union
*/
export type FilterOperator = TextOperator | NumberOperator | DateOperator | BooleanOperator
/**
* Field types supported for filtering
*/
export type FilterFieldType = 'text' | 'number' | 'date' | 'boolean'
/**
* Logical operators for combining filters
*/
export type LogicalOperator = 'AND' | 'OR'
/**
* Base filter condition interface
*/
interface BaseFilterCondition {
tagSlot: string
fieldType: FilterFieldType
}
/**
* Text filter condition
*/
export interface TextFilterCondition extends BaseFilterCondition {
fieldType: 'text'
operator: TextOperator
value: string
}
/**
* Number filter condition
*/
export interface NumberFilterCondition extends BaseFilterCondition {
fieldType: 'number'
operator: NumberOperator
value: number
valueTo?: number // For 'between' operator
}
/**
* Date filter condition
*/
export interface DateFilterCondition extends BaseFilterCondition {
fieldType: 'date'
operator: DateOperator
value: string // ISO date string
valueTo?: string // For 'between' operator (ISO date string)
}
/**
* Boolean filter condition
*/
export interface BooleanFilterCondition extends BaseFilterCondition {
fieldType: 'boolean'
operator: BooleanOperator
value: boolean
}
/**
* Union of all filter conditions
*/
export type FilterCondition =
| TextFilterCondition
| NumberFilterCondition
| DateFilterCondition
| BooleanFilterCondition
/**
* Filter group with logical operator
*/
export interface FilterGroup {
operator: LogicalOperator
conditions: FilterCondition[]
}
/**
* Complete filter query structure
* Supports nested groups with AND/OR logic
*/
export interface TagFilter {
rootOperator: LogicalOperator
groups: FilterGroup[]
}
/**
* Simplified flat filter structure for simple use cases
*/
export interface SimpleTagFilter {
operator: LogicalOperator
conditions: FilterCondition[]
}
/**
* Operator metadata for UI display
*/
export interface OperatorInfo {
value: string
label: string
requiresSecondValue?: boolean
}
/**
* Text operators metadata
*/
export const TEXT_OPERATORS: OperatorInfo[] = [
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'not equals' },
{ value: 'contains', label: 'contains' },
{ value: 'not_contains', label: 'does not contain' },
{ value: 'starts_with', label: 'starts with' },
{ value: 'ends_with', label: 'ends with' },
]
/**
* Number operators metadata
*/
export const NUMBER_OPERATORS: OperatorInfo[] = [
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'not equals' },
{ value: 'gt', label: 'greater than' },
{ value: 'gte', label: 'greater than or equal' },
{ value: 'lt', label: 'less than' },
{ value: 'lte', label: 'less than or equal' },
{ value: 'between', label: 'between', requiresSecondValue: true },
]
/**
* Date operators metadata
*/
export const DATE_OPERATORS: OperatorInfo[] = [
{ value: 'eq', label: 'equals' },
{ value: 'neq', label: 'not equals' },
{ value: 'gt', label: 'after' },
{ value: 'gte', label: 'on or after' },
{ value: 'lt', label: 'before' },
{ value: 'lte', label: 'on or before' },
{ value: 'between', label: 'between', requiresSecondValue: true },
]
/**
* Boolean operators metadata
*/
export const BOOLEAN_OPERATORS: OperatorInfo[] = [
{ value: 'eq', label: 'is' },
{ value: 'neq', label: 'is not' },
]
/**
* Get operators for a field type
*/
export function getOperatorsForFieldType(fieldType: FilterFieldType): OperatorInfo[] {
switch (fieldType) {
case 'text':
return TEXT_OPERATORS
case 'number':
return NUMBER_OPERATORS
case 'date':
return DATE_OPERATORS
case 'boolean':
return BOOLEAN_OPERATORS
default:
return []
}
}

View File

@@ -2,11 +2,7 @@ import { randomUUID } from 'crypto'
import { db } from '@sim/db'
import { document, embedding, knowledgeBaseTagDefinitions } from '@sim/db/schema'
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'
import {
getSlotsForFieldType,
SUPPORTED_FIELD_TYPES,
type TAG_SLOT_CONFIG,
} from '@/lib/knowledge/constants'
import { getSlotsForFieldType, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types'
import type {
CreateTagDefinitionData,
@@ -17,14 +13,45 @@ import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('TagsService')
const VALID_TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
/** Text tag slots */
const VALID_TEXT_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
function validateTagSlot(tagSlot: string): asserts tagSlot is (typeof VALID_TAG_SLOTS)[number] {
if (!VALID_TAG_SLOTS.includes(tagSlot as (typeof VALID_TAG_SLOTS)[number])) {
const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const
/** Date tag slots (reduced to 2 for write performance) */
const VALID_DATE_SLOTS = ['date1', 'date2'] as const
/** Boolean tag slots */
const VALID_BOOLEAN_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const
/** All valid tag slots combined */
const VALID_TAG_SLOTS = [
...VALID_TEXT_SLOTS,
...VALID_NUMBER_SLOTS,
...VALID_DATE_SLOTS,
...VALID_BOOLEAN_SLOTS,
] as const
type ValidTagSlot = (typeof VALID_TAG_SLOTS)[number]
/**
* Validates that a tag slot is a valid slot name
*/
function validateTagSlot(tagSlot: string): asserts tagSlot is ValidTagSlot {
if (!VALID_TAG_SLOTS.includes(tagSlot as ValidTagSlot)) {
throw new Error(`Invalid tag slot: ${tagSlot}. Must be one of: ${VALID_TAG_SLOTS.join(', ')}`)
}
}
/**
* Get the field type for a tag slot
*/
function getFieldTypeForSlot(tagSlot: string): string | null {
if ((VALID_TEXT_SLOTS as readonly string[]).includes(tagSlot)) return 'text'
if ((VALID_NUMBER_SLOTS as readonly string[]).includes(tagSlot)) return 'number'
if ((VALID_DATE_SLOTS as readonly string[]).includes(tagSlot)) return 'date'
if ((VALID_BOOLEAN_SLOTS as readonly string[]).includes(tagSlot)) return 'boolean'
return null
}
/**
* Get the next available slot for a knowledge base and field type
*/
@@ -215,7 +242,7 @@ export async function createOrUpdateTagDefinitionsBulk(
const newDefinition = {
id,
knowledgeBaseId,
tagSlot: finalTagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number],
tagSlot: finalTagSlot as ValidTagSlot,
displayName,
fieldType,
createdAt: now,
@@ -466,7 +493,7 @@ export async function createTagDefinition(
const newDefinition = {
id: tagDefinitionId,
knowledgeBaseId: data.knowledgeBaseId,
tagSlot: data.tagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number],
tagSlot: data.tagSlot as ValidTagSlot,
displayName: data.displayName,
fieldType: data.fieldType,
createdAt: now,
@@ -562,21 +589,31 @@ export async function getTagUsage(
const tagSlot = def.tagSlot
validateTagSlot(tagSlot)
// Build WHERE conditions based on field type
// Text columns need both IS NOT NULL and != '' checks
// Numeric/date/boolean columns only need IS NOT NULL
const fieldType = getFieldTypeForSlot(tagSlot)
const isTextColumn = fieldType === 'text'
const whereConditions = [
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt),
isNotNull(sql`${sql.raw(tagSlot)}`),
]
// Only add empty string check for text columns
if (isTextColumn) {
whereConditions.push(sql`${sql.raw(tagSlot)} != ''`)
}
const documentsWithTag = await db
.select({
id: document.id,
filename: document.filename,
tagValue: sql<string>`${sql.raw(tagSlot)}`,
tagValue: sql<string>`${sql.raw(tagSlot)}::text`,
})
.from(document)
.where(
and(
eq(document.knowledgeBaseId, knowledgeBaseId),
isNull(document.deletedAt),
isNotNull(sql`${sql.raw(tagSlot)}`),
sql`${sql.raw(tagSlot)} != ''`
)
)
.where(and(...whereConditions))
usage.push({
tagName: def.displayName,

View File

@@ -0,0 +1,89 @@
/**
* Validate a tag value against its expected field type
* Returns an error message if invalid, or null if valid
*/
export function validateTagValue(tagName: string, value: string, fieldType: string): string | null {
const stringValue = String(value).trim()
switch (fieldType) {
case 'boolean': {
const lowerValue = stringValue.toLowerCase()
if (lowerValue !== 'true' && lowerValue !== 'false') {
return `Tag "${tagName}" expects a boolean value (true/false), but received "${value}"`
}
return null
}
case 'number': {
const numValue = Number(stringValue)
if (Number.isNaN(numValue)) {
return `Tag "${tagName}" expects a number value, but received "${value}"`
}
return null
}
case 'date': {
// Check format first
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
}
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return `Tag "${tagName}" has an invalid date: "${value}"`
}
return null
}
default:
return null
}
}
/**
* Build error message for undefined tags
*/
export function buildUndefinedTagsError(undefinedTags: string[]): string {
const tagList = undefinedTags.map((t) => `"${t}"`).join(', ')
return `The following tags are not defined in this knowledge base: ${tagList}. Please define them at the knowledge base level first.`
}
/**
* Parse a string to number with strict validation
* Returns null if invalid
*/
export function parseNumberValue(value: string): number | null {
const num = Number(value)
return Number.isNaN(num) ? null : num
}
/**
* Parse a string to Date with strict YYYY-MM-DD validation
* Returns null if invalid format or invalid date
*/
export function parseDateValue(value: string): Date | null {
const stringValue = String(value).trim()
// Must be YYYY-MM-DD format
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
return null
}
// Validate the date is actually valid (e.g., reject 2024-02-31)
const [year, month, day] = stringValue.split('-').map(Number)
const date = new Date(year, month - 1, day)
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null
}
return date
}
/**
* Parse a string to boolean with strict validation
* Returns null if not 'true' or 'false'
*/
export function parseBooleanValue(value: string): boolean | null {
const lowerValue = String(value).trim().toLowerCase()
if (lowerValue === 'true') return true
if (lowerValue === 'false') return false
return null
}

View File

@@ -48,3 +48,40 @@ export interface UpdateTagDefinitionData {
displayName?: string
fieldType?: string
}
/** Tag filter for knowledge base search */
export interface StructuredFilter {
tagName?: string // Human-readable name (input from frontend)
tagSlot: string // Database column (resolved from tagName)
fieldType: string
operator: string
value: string | number | boolean
valueTo?: string | number
}
/** Processed document tags ready for database storage */
export interface ProcessedDocumentTags {
// Text tags
tag1: string | null
tag2: string | null
tag3: string | null
tag4: string | null
tag5: string | null
tag6: string | null
tag7: string | null
// Number tags
number1: number | null
number2: number | null
number3: number | null
number4: number | null
number5: number | null
// Date tags
date1: Date | null
date2: Date | null
// Boolean tags
boolean1: boolean | null
boolean2: boolean | null
boolean3: boolean | null
// Index signature for dynamic access
[key: string]: string | number | Date | boolean | null
}

View File

@@ -44,7 +44,7 @@ export interface DocumentData {
processingError?: string | null
enabled: boolean
uploadedAt: string
// Document tags
// Text tags
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -52,6 +52,19 @@ export interface DocumentData {
tag5?: string | null
tag6?: string | null
tag7?: string | null
// Number tags (5 slots)
number1?: number | null
number2?: number | null
number3?: number | null
number4?: number | null
number5?: number | null
// Date tags (2 slots)
date1?: string | null
date2?: string | null
// Boolean tags (3 slots)
boolean1?: boolean | null
boolean2?: boolean | null
boolean3?: boolean | null
}
export interface ChunkData {
@@ -63,6 +76,7 @@ export interface ChunkData {
enabled: boolean
startOffset: number
endOffset: number
// Text tags
tag1?: string | null
tag2?: string | null
tag3?: string | null
@@ -70,6 +84,19 @@ export interface ChunkData {
tag5?: string | null
tag6?: string | null
tag7?: string | null
// Number tags (5 slots)
number1?: number | null
number2?: number | null
number3?: number | null
number4?: number | null
number5?: number | null
// Date tags (2 slots)
date1?: string | null
date2?: string | null
// Boolean tags (3 slots)
boolean1?: boolean | null
boolean2?: boolean | null
boolean3?: boolean | null
createdAt: string
updatedAt: string
}

View File

@@ -1,3 +1,4 @@
import type { StructuredFilter } from '@/lib/knowledge/types'
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
import type { ToolConfig } from '@/tools/types'
@@ -53,8 +54,8 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
// Use single knowledge base ID
const knowledgeBaseIds = [params.knowledgeBaseId]
// Parse dynamic tag filters and send display names to API
const filters: Record<string, string> = {}
// Parse dynamic tag filters
let structuredFilters: StructuredFilter[] = []
if (params.tagFilters) {
let tagFilters = params.tagFilters
@@ -62,27 +63,29 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
if (typeof tagFilters === 'string') {
try {
tagFilters = JSON.parse(tagFilters)
} catch (error) {
} catch {
tagFilters = []
}
}
if (Array.isArray(tagFilters)) {
// Group filters by tag name for OR logic within same tag
const groupedFilters: Record<string, string[]> = {}
tagFilters.forEach((filter: any) => {
if (filter.tagName && filter.tagValue && filter.tagValue.trim().length > 0) {
if (!groupedFilters[filter.tagName]) {
groupedFilters[filter.tagName] = []
// Send full filter objects with operator support
structuredFilters = tagFilters
.filter((filter: Record<string, unknown>) => {
// For boolean, any value is valid; for others, check for non-empty string
if (filter.fieldType === 'boolean') {
return filter.tagName && filter.tagValue !== undefined
}
groupedFilters[filter.tagName].push(filter.tagValue)
}
})
// Convert to filters format - for now, join multiple values with OR separator
Object.entries(groupedFilters).forEach(([tagName, values]) => {
filters[tagName] = values.join('|OR|') // Use special separator for OR logic
})
return filter.tagName && filter.tagValue && String(filter.tagValue).trim().length > 0
})
.map((filter: Record<string, unknown>) => ({
tagName: filter.tagName as string,
tagSlot: (filter.tagSlot as string) || '', // Will be resolved by API from tagName
fieldType: (filter.fieldType as string) || 'text',
operator: (filter.operator as string) || 'eq',
value: filter.tagValue as string | number | boolean,
valueTo: filter.valueTo as string | number | undefined,
}))
}
}
@@ -90,7 +93,7 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
knowledgeBaseIds,
query: params.query,
topK: params.topK ? Math.max(1, Math.min(100, Number(params.topK))) : 10,
...(Object.keys(filters).length > 0 && { filters }),
...(structuredFilters.length > 0 && { tagFilters: structuredFilters }),
...(workflowId && { workflowId }),
}

View File

@@ -18,11 +18,56 @@ export const DEFAULT_TEAM_STORAGE_LIMIT_GB = 500
export const DEFAULT_ENTERPRISE_STORAGE_LIMIT_GB = 500
/**
* Tag slots available for knowledge base documents and embeddings
* Text tag slots for knowledge base documents and embeddings
*/
export const TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
export const TEXT_TAG_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
/**
* Type for tag slot names
* Number tag slots for knowledge base documents and embeddings (5 slots)
*/
export const NUMBER_TAG_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const
/**
* Date tag slots for knowledge base documents and embeddings (2 slots)
*/
export const DATE_TAG_SLOTS = ['date1', 'date2'] as const
/**
* Boolean tag slots for knowledge base documents and embeddings (3 slots)
*/
export const BOOLEAN_TAG_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const
/**
* All tag slots combined (for backwards compatibility)
*/
export const TAG_SLOTS = [
...TEXT_TAG_SLOTS,
...NUMBER_TAG_SLOTS,
...DATE_TAG_SLOTS,
...BOOLEAN_TAG_SLOTS,
] as const
/**
* Type for all tag slot names
*/
export type TagSlot = (typeof TAG_SLOTS)[number]
/**
* Type for text tag slot names
*/
export type TextTagSlot = (typeof TEXT_TAG_SLOTS)[number]
/**
* Type for number tag slot names
*/
export type NumberTagSlot = (typeof NUMBER_TAG_SLOTS)[number]
/**
* Type for date tag slot names
*/
export type DateTagSlot = (typeof DATE_TAG_SLOTS)[number]
/**
* Type for boolean tag slot names
*/
export type BooleanTagSlot = (typeof BOOLEAN_TAG_SLOTS)[number]

View File

@@ -0,0 +1,40 @@
ALTER TABLE "document" ADD COLUMN "number1" double precision;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "number2" double precision;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "number3" double precision;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "number4" double precision;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "number5" double precision;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "date1" timestamp;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "date2" timestamp;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "boolean1" boolean;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "boolean2" boolean;--> statement-breakpoint
ALTER TABLE "document" ADD COLUMN "boolean3" boolean;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "number1" double precision;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "number2" double precision;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "number3" double precision;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "number4" double precision;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "number5" double precision;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "date1" timestamp;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "date2" timestamp;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "boolean1" boolean;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "boolean2" boolean;--> statement-breakpoint
ALTER TABLE "embedding" ADD COLUMN "boolean3" boolean;--> statement-breakpoint
CREATE INDEX "doc_number1_idx" ON "document" USING btree ("number1");--> statement-breakpoint
CREATE INDEX "doc_number2_idx" ON "document" USING btree ("number2");--> statement-breakpoint
CREATE INDEX "doc_number3_idx" ON "document" USING btree ("number3");--> statement-breakpoint
CREATE INDEX "doc_number4_idx" ON "document" USING btree ("number4");--> statement-breakpoint
CREATE INDEX "doc_number5_idx" ON "document" USING btree ("number5");--> statement-breakpoint
CREATE INDEX "doc_date1_idx" ON "document" USING btree ("date1");--> statement-breakpoint
CREATE INDEX "doc_date2_idx" ON "document" USING btree ("date2");--> statement-breakpoint
CREATE INDEX "doc_boolean1_idx" ON "document" USING btree ("boolean1");--> statement-breakpoint
CREATE INDEX "doc_boolean2_idx" ON "document" USING btree ("boolean2");--> statement-breakpoint
CREATE INDEX "doc_boolean3_idx" ON "document" USING btree ("boolean3");--> statement-breakpoint
CREATE INDEX "emb_number1_idx" ON "embedding" USING btree ("number1");--> statement-breakpoint
CREATE INDEX "emb_number2_idx" ON "embedding" USING btree ("number2");--> statement-breakpoint
CREATE INDEX "emb_number3_idx" ON "embedding" USING btree ("number3");--> statement-breakpoint
CREATE INDEX "emb_number4_idx" ON "embedding" USING btree ("number4");--> statement-breakpoint
CREATE INDEX "emb_number5_idx" ON "embedding" USING btree ("number5");--> statement-breakpoint
CREATE INDEX "emb_date1_idx" ON "embedding" USING btree ("date1");--> statement-breakpoint
CREATE INDEX "emb_date2_idx" ON "embedding" USING btree ("date2");--> statement-breakpoint
CREATE INDEX "emb_boolean1_idx" ON "embedding" USING btree ("boolean1");--> statement-breakpoint
CREATE INDEX "emb_boolean2_idx" ON "embedding" USING btree ("boolean2");--> statement-breakpoint
CREATE INDEX "emb_boolean3_idx" ON "embedding" USING btree ("boolean3");

File diff suppressed because it is too large Load Diff

View File

@@ -876,6 +876,13 @@
"when": 1766133598113,
"tag": "0125_eager_lily_hollister",
"breakpoints": true
},
{
"idx": 126,
"version": "7",
"when": 1766203036010,
"tag": "0126_dapper_midnight",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,7 @@ import {
check,
customType,
decimal,
doublePrecision,
index,
integer,
json,
@@ -1047,6 +1048,7 @@ export const document = pgTable(
deletedAt: timestamp('deleted_at'), // Soft delete
// Document tags for filtering (inherited by all chunks)
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
@@ -1054,6 +1056,19 @@ export const document = pgTable(
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Timestamps
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
@@ -1068,7 +1083,7 @@ export const document = pgTable(
table.knowledgeBaseId,
table.processingStatus
),
// Tag indexes for filtering
// Text tag indexes
tag1Idx: index('doc_tag1_idx').on(table.tag1),
tag2Idx: index('doc_tag2_idx').on(table.tag2),
tag3Idx: index('doc_tag3_idx').on(table.tag3),
@@ -1076,6 +1091,19 @@ export const document = pgTable(
tag5Idx: index('doc_tag5_idx').on(table.tag5),
tag6Idx: index('doc_tag6_idx').on(table.tag6),
tag7Idx: index('doc_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('doc_number1_idx').on(table.number1),
number2Idx: index('doc_number2_idx').on(table.number2),
number3Idx: index('doc_number3_idx').on(table.number3),
number4Idx: index('doc_number4_idx').on(table.number4),
number5Idx: index('doc_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('doc_date1_idx').on(table.date1),
date2Idx: index('doc_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('doc_boolean1_idx').on(table.boolean1),
boolean2Idx: index('doc_boolean2_idx').on(table.boolean2),
boolean3Idx: index('doc_boolean3_idx').on(table.boolean3),
})
)
@@ -1137,6 +1165,7 @@ export const embedding = pgTable(
endOffset: integer('end_offset').notNull(),
// Tag columns inherited from document for efficient filtering
// Text tags (7 slots)
tag1: text('tag1'),
tag2: text('tag2'),
tag3: text('tag3'),
@@ -1144,6 +1173,19 @@ export const embedding = pgTable(
tag5: text('tag5'),
tag6: text('tag6'),
tag7: text('tag7'),
// Number tags (5 slots)
number1: doublePrecision('number1'),
number2: doublePrecision('number2'),
number3: doublePrecision('number3'),
number4: doublePrecision('number4'),
number5: doublePrecision('number5'),
// Date tags (2 slots)
date1: timestamp('date1'),
date2: timestamp('date2'),
// Boolean tags (3 slots)
boolean1: boolean('boolean1'),
boolean2: boolean('boolean2'),
boolean3: boolean('boolean3'),
// Chunk state - enable/disable from knowledge base
enabled: boolean('enabled').notNull().default(true),
@@ -1182,7 +1224,7 @@ export const embedding = pgTable(
ef_construction: 64,
}),
// Tag indexes for efficient filtering
// Text tag indexes
tag1Idx: index('emb_tag1_idx').on(table.tag1),
tag2Idx: index('emb_tag2_idx').on(table.tag2),
tag3Idx: index('emb_tag3_idx').on(table.tag3),
@@ -1190,6 +1232,19 @@ export const embedding = pgTable(
tag5Idx: index('emb_tag5_idx').on(table.tag5),
tag6Idx: index('emb_tag6_idx').on(table.tag6),
tag7Idx: index('emb_tag7_idx').on(table.tag7),
// Number tag indexes (5 slots)
number1Idx: index('emb_number1_idx').on(table.number1),
number2Idx: index('emb_number2_idx').on(table.number2),
number3Idx: index('emb_number3_idx').on(table.number3),
number4Idx: index('emb_number4_idx').on(table.number4),
number5Idx: index('emb_number5_idx').on(table.number5),
// Date tag indexes (2 slots)
date1Idx: index('emb_date1_idx').on(table.date1),
date2Idx: index('emb_date2_idx').on(table.date2),
// Boolean tag indexes (3 slots)
boolean1Idx: index('emb_boolean1_idx').on(table.boolean1),
boolean2Idx: index('emb_boolean2_idx').on(table.boolean2),
boolean3Idx: index('emb_boolean3_idx').on(table.boolean3),
// Full-text search index
contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),