mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
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:
committed by
GitHub
parent
a1a189f328
commit
4f69b171f2
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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}"`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
408
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal file
408
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
2
apps/sim/lib/knowledge/filters/index.ts
Normal file
2
apps/sim/lib/knowledge/filters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './query-builder'
|
||||
export * from './types'
|
||||
393
apps/sim/lib/knowledge/filters/query-builder.ts
Normal file
393
apps/sim/lib/knowledge/filters/query-builder.ts
Normal 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
|
||||
}
|
||||
191
apps/sim/lib/knowledge/filters/types.ts
Normal file
191
apps/sim/lib/knowledge/filters/types.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
89
apps/sim/lib/knowledge/tags/utils.ts
Normal file
89
apps/sim/lib/knowledge/tags/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
40
packages/db/migrations/0126_dapper_midnight.sql
Normal file
40
packages/db/migrations/0126_dapper_midnight.sql
Normal 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");
|
||||
8221
packages/db/migrations/meta/0126_snapshot.json
Normal file
8221
packages/db/migrations/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user