mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types
This commit is contained in:
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { auth } from '@/lib/auth'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('SSO-Register')
|
||||
@@ -236,13 +237,13 @@ export async function POST(request: NextRequest) {
|
||||
oidcConfig: providerConfig.oidcConfig
|
||||
? {
|
||||
...providerConfig.oidcConfig,
|
||||
clientSecret: '[REDACTED]',
|
||||
clientSecret: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
samlConfig: providerConfig.samlConfig
|
||||
? {
|
||||
...providerConfig.samlConfig,
|
||||
cert: '[REDACTED]',
|
||||
cert: REDACTED_MARKER,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
|
||||
@@ -141,6 +141,23 @@ export async function DELETE(
|
||||
)
|
||||
}
|
||||
|
||||
// Check if deleting this folder would delete the last workflow(s) in the workspace
|
||||
const workflowsInFolder = await countWorkflowsInFolderRecursively(
|
||||
id,
|
||||
existingFolder.workspaceId
|
||||
)
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
|
||||
|
||||
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Recursively delete folder and all its contents
|
||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||
|
||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of workflows in a folder and all its subfolders recursively.
|
||||
*/
|
||||
async function countWorkflowsInFolderRecursively(
|
||||
folderId: string,
|
||||
workspaceId: string
|
||||
): Promise<number> {
|
||||
let count = 0
|
||||
|
||||
const workflowsInFolder = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId)))
|
||||
|
||||
count += workflowsInFolder.length
|
||||
|
||||
const childFolders = await db
|
||||
.select({ id: workflowFolder.id })
|
||||
.from(workflowFolder)
|
||||
.where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId)))
|
||||
|
||||
for (const childFolder of childFolders) {
|
||||
count += await countWorkflowsInFolderRecursively(childFolder.id, workspaceId)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// Helper function to check for circular references
|
||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||
let currentParentId: string | null = parentId
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
||||
|
||||
@@ -188,7 +189,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
if (variablesObject && Object.keys(variablesObject).length > 0) {
|
||||
const safeVarKeys = Object.keys(variablesObject).map((key) => {
|
||||
return key.toLowerCase().includes('password') ? `${key}: [REDACTED]` : key
|
||||
return isSensitiveKey(key) ? `${key}: ${REDACTED_MARKER}` : key
|
||||
})
|
||||
logger.info('Variables available for task', { variables: safeVarKeys })
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
||||
const mockGetWorkflowAccessContext = vi.fn()
|
||||
const mockDbDelete = vi.fn()
|
||||
const mockDbUpdate = vi.fn()
|
||||
const mockDbSelect = vi.fn()
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: () => mockGetSession(),
|
||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
||||
db: {
|
||||
delete: () => mockDbDelete(),
|
||||
update: () => mockDbUpdate(),
|
||||
select: () => mockDbSelect(),
|
||||
},
|
||||
workflow: {},
|
||||
}))
|
||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return multiple workflows so deletion is allowed
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }, { id: 'workflow-456' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
mockDbDelete.mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
})
|
||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it('should prevent deletion of the last workflow in workspace', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
userId: 'user-123',
|
||||
name: 'Test Workflow',
|
||||
workspaceId: 'workspace-456',
|
||||
}
|
||||
|
||||
mockGetSession.mockResolvedValue({
|
||||
user: { id: 'user-123' },
|
||||
})
|
||||
|
||||
mockGetWorkflowById.mockResolvedValue(mockWorkflow)
|
||||
mockGetWorkflowAccessContext.mockResolvedValue({
|
||||
workflow: mockWorkflow,
|
||||
workspaceOwnerId: 'workspace-456',
|
||||
workspacePermission: 'admin',
|
||||
isOwner: true,
|
||||
isWorkspaceOwner: false,
|
||||
})
|
||||
|
||||
// Mock db.select() to return only 1 workflow (the one being deleted)
|
||||
mockDbSelect.mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||
}),
|
||||
})
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const params = Promise.resolve({ id: 'workflow-123' })
|
||||
|
||||
const response = await DELETE(req, { params })
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
const data = await response.json()
|
||||
expect(data.error).toBe('Cannot delete the only workflow in the workspace')
|
||||
})
|
||||
|
||||
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||
const mockWorkflow = {
|
||||
id: 'workflow-123',
|
||||
|
||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if this is the last workflow in the workspace
|
||||
if (workflowData.workspaceId) {
|
||||
const totalWorkflowsInWorkspace = await db
|
||||
.select({ id: workflow.id })
|
||||
.from(workflow)
|
||||
.where(eq(workflow.workspaceId, workflowData.workspaceId))
|
||||
|
||||
if (totalWorkflowsInWorkspace.length <= 1) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot delete the only workflow in the workspace' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if workflow has published templates before deletion
|
||||
const { searchParams } = new URL(request.url)
|
||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||
|
||||
@@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can create workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { name } = CreateKeySchema.parse(body)
|
||||
|
||||
@@ -202,23 +185,6 @@ export async function DELETE(
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const workspaceRows = await db
|
||||
.select({ billedAccountUserId: workspace.billedAccountUserId })
|
||||
.from(workspace)
|
||||
.where(eq(workspace.id, workspaceId))
|
||||
.limit(1)
|
||||
|
||||
if (!workspaceRows.length) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
if (workspaceRows[0].billedAccountUserId !== userId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only the workspace billing account can delete workspace API keys' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { keys } = DeleteKeysSchema.parse(body)
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -339,12 +339,31 @@ export function CreateBaseModal({
|
||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||
<div className='space-y-[12px]'>
|
||||
<div className='flex flex-col gap-[8px]'>
|
||||
<Label htmlFor='name'>Name</Label>
|
||||
<Label htmlFor='kb-name'>Name</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
id='name'
|
||||
id='kb-name'
|
||||
placeholder='Enter knowledge base name'
|
||||
{...register('name')}
|
||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||
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 +18,8 @@ interface DocumentTagRow {
|
||||
id: string
|
||||
cells: {
|
||||
tagName: string
|
||||
type: string
|
||||
tagSlot?: string
|
||||
fieldType: string
|
||||
value: string
|
||||
}
|
||||
}
|
||||
@@ -66,17 +65,11 @@ export function DocumentTagEntry({
|
||||
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
|
||||
// State for 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 +78,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 +93,109 @@ 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)
|
||||
if (isPreview || disabled) return
|
||||
|
||||
// Store all remaining rows including empty ones - don't auto-remove
|
||||
if (rows.length <= 1) {
|
||||
// Clear the single row instead of deleting
|
||||
setStoreValue('')
|
||||
return
|
||||
}
|
||||
|
||||
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 +203,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,209 +219,82 @@ export function DocumentTagEntry({
|
||||
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
|
||||
}
|
||||
|
||||
if (tagDefinitions.length === 0) {
|
||||
return (
|
||||
<div className='flex h-32 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||
<div className='text-center'>
|
||||
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||
No tags defined for this knowledge base
|
||||
</p>
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
Define tags at the knowledge base level first
|
||||
</p>
|
||||
</div>
|
||||
</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>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||
<th className='w-[50%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Tag
|
||||
</th>
|
||||
<th className='w-[50%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.tagName || ''
|
||||
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
|
||||
const showDropdown = dropdownStates[rowIndex] || false
|
||||
|
||||
const setShowDropdown = (show: boolean) => {
|
||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
||||
}
|
||||
// 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())
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={tagOptions}
|
||||
value={cellValue}
|
||||
onChange={(value) => handleTagSelection(rowIndex, value)}
|
||||
disabled={disabled || isLoading}
|
||||
placeholder='Select tag'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</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 (
|
||||
<td className='p-1'>
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
<div className='relative w-full'>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
@@ -466,12 +305,13 @@ export function DocumentTagEntry({
|
||||
onKeyDown={handlers.onKeyDown}
|
||||
onDrop={handlers.onDrop}
|
||||
onDragOver={handlers.onDragOver}
|
||||
disabled={disabled}
|
||||
disabled={disabled || !isTagSelected}
|
||||
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'
|
||||
placeholder={isTagSelected ? placeholder : 'Select a tag first'}
|
||||
className='w-full border-0 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] 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'>
|
||||
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
|
||||
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||
{formatDisplayText(cellValue, {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
@@ -500,49 +340,33 @@ export function DocumentTagEntry({
|
||||
}
|
||||
|
||||
const renderDeleteButton = (rowIndex: number) => {
|
||||
// Allow deletion of any row
|
||||
const canDelete = !isPreview && !disabled
|
||||
if (isPreview || disabled) return null
|
||||
|
||||
return canDelete ? (
|
||||
return (
|
||||
<td className='w-0 p-0'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||
onClick={() => handleDeleteRow(rowIndex)}
|
||||
>
|
||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</td>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
// Show pre-fill button if there are available tags and only empty rows
|
||||
const showPreFillButton =
|
||||
tagDefinitions.length > 0 &&
|
||||
rows.length === 1 &&
|
||||
!rows[0].cells.tagName &&
|
||||
!rows[0].cells.value &&
|
||||
!isPreview &&
|
||||
!disabled
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
{showPreFillButton && (
|
||||
<div className='mb-2'>
|
||||
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
|
||||
Prefill Existing Tags
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className='overflow-visible rounded-md border'>
|
||||
<table className='w-full'>
|
||||
<div className='relative w-full'>
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
<tbody className='bg-transparent'>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={row.id} className='group relative border-t'>
|
||||
<tr
|
||||
key={row.id}
|
||||
className='group relative border-[var(--border-strong)] border-t bg-transparent'
|
||||
>
|
||||
{renderTagNameCell(row, rowIndex)}
|
||||
{renderTypeCell(row, rowIndex)}
|
||||
{renderValueCell(row, rowIndex)}
|
||||
{renderDeleteButton(rowIndex)}
|
||||
</tr>
|
||||
@@ -551,24 +375,13 @@ export function DocumentTagEntry({
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Add Row Button and Tag slots usage indicator */}
|
||||
{/* Add Tag Button */}
|
||||
{!isPreview && !disabled && (
|
||||
<div className='mt-3 flex items-center justify-between'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={!canAddMoreTags}
|
||||
className='h-7 px-2 text-xs'
|
||||
>
|
||||
<div className='mt-3'>
|
||||
<Button onClick={handleAddRow} disabled={!canAddMoreTags} className='h-7 px-2 text-xs'>
|
||||
<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,10 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
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 +20,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,21 +55,15 @@ export function KnowledgeTagFilters({
|
||||
previewValue,
|
||||
}: KnowledgeTagFiltersProps) {
|
||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||
|
||||
// Hook for immediate tag/dropdown selections
|
||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||
|
||||
// Get the knowledge base ID from other sub-blocks
|
||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||
|
||||
// Use KB tag definitions hook to get available tags
|
||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
// Get accessible prefixes for variable highlighting
|
||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||
|
||||
// State for managing tag dropdown
|
||||
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
||||
rowIndex: number
|
||||
showTags: boolean
|
||||
@@ -70,14 +72,15 @@ export function KnowledgeTagFilters({
|
||||
element?: HTMLElement | null
|
||||
} | null>(null)
|
||||
|
||||
// State for dropdown visibility - one for each row
|
||||
const [dropdownStates, setDropdownStates] = useState<Record<number, boolean>>({})
|
||||
|
||||
// Parse the current value to extract filters
|
||||
const parseFilters = (filterValue: string | null): TagFilter[] => {
|
||||
if (!filterValue) return []
|
||||
try {
|
||||
return JSON.parse(filterValue)
|
||||
const parsed = JSON.parse(filterValue)
|
||||
return parsed.map((f: TagFilter) => ({
|
||||
...f,
|
||||
fieldType: f.fieldType || 'text',
|
||||
operator: f.operator || 'eq',
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
@@ -86,20 +89,23 @@ export function KnowledgeTagFilters({
|
||||
const currentValue = isPreview ? previewValue : storeValue
|
||||
const filters = parseFilters(currentValue || null)
|
||||
|
||||
// Transform filters to table format for display
|
||||
const rows: TagFilterRow[] =
|
||||
filters.length > 0
|
||||
? filters.map((filter) => ({
|
||||
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: '', value: '' },
|
||||
},
|
||||
]
|
||||
|
||||
@@ -109,27 +115,72 @@ export function KnowledgeTagFilters({
|
||||
setStoreValue(value)
|
||||
}
|
||||
|
||||
const handleCellChange = (rowIndex: number, column: string, value: string) => {
|
||||
const rowsToFilters = (rowsToConvert: TagFilterRow[]): TagFilter[] => {
|
||||
return rowsToConvert
|
||||
.filter((row) => row.cells.tagName?.trim())
|
||||
.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 }
|
||||
|
||||
if (column === 'fieldType') {
|
||||
const operators = getOperatorsForFieldType(value as FilterFieldType)
|
||||
newCells.operator = operators[0]?.value || 'eq'
|
||||
newCells.value = ''
|
||||
newCells.valueTo = undefined
|
||||
}
|
||||
|
||||
if (column === 'operator' && value !== 'between') {
|
||||
newCells.valueTo = undefined
|
||||
}
|
||||
|
||||
return { ...row, cells: newCells }
|
||||
}
|
||||
return row
|
||||
})
|
||||
|
||||
updateFilters(rowsToFilters(updatedRows))
|
||||
}
|
||||
|
||||
const handleTagNameSelection = (rowIndex: number, tagName: string) => {
|
||||
if (isPreview || disabled) return
|
||||
|
||||
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: '',
|
||||
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 +196,36 @@ 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
|
||||
if (isPreview || disabled) return
|
||||
|
||||
if (rows.length <= 1) {
|
||||
// Clear the single row instead of deleting
|
||||
setStoreValue(null)
|
||||
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) {
|
||||
@@ -191,108 +242,88 @@ 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>
|
||||
<thead className='bg-transparent'>
|
||||
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Tag
|
||||
</th>
|
||||
<th className='w-[35%] min-w-0 border-[var(--border-strong)] border-r bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Operator
|
||||
</th>
|
||||
<th className='w-[30%] min-w-0 bg-transparent px-[10px] py-[5px] text-left font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||
Value
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
)
|
||||
|
||||
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const cellValue = row.cells.tagName || ''
|
||||
const showDropdown = 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 tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
|
||||
value: tag.displayName,
|
||||
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||
}))
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={tagOptions}
|
||||
value={cellValue}
|
||||
onChange={(value) => handleTagNameSelection(rowIndex, value)}
|
||||
disabled={disabled || isLoading}
|
||||
placeholder='Select tag'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => {
|
||||
const fieldType = row.cells.fieldType || 'text'
|
||||
const operator = row.cells.operator || ''
|
||||
const operators = getOperatorsForFieldType(fieldType)
|
||||
const isOperatorDisabled = disabled || !row.cells.tagName
|
||||
|
||||
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
|
||||
value: op.value,
|
||||
label: op.label,
|
||||
}))
|
||||
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||
<Combobox
|
||||
options={operatorOptions}
|
||||
value={operator}
|
||||
onChange={(value) => handleCellChange(rowIndex, 'operator', value)}
|
||||
disabled={isOperatorDisabled}
|
||||
placeholder='Select operator'
|
||||
className='!border-0 !bg-transparent hover:!bg-transparent px-[10px] py-[8px] font-medium text-sm leading-[21px] focus-visible:ring-0 focus-visible:ring-offset-0 [&>span]:truncate'
|
||||
/>
|
||||
</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
|
||||
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
|
||||
if (column === 'value') {
|
||||
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
|
||||
|
||||
setActiveTagDropdown({
|
||||
@@ -302,58 +333,78 @@ 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 bg-transparent px-[10px] py-[8px] font-medium text-sm text-transparent leading-[21px] caret-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
/>
|
||||
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
|
||||
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||
{formatDisplayText(value || '', {
|
||||
accessiblePrefixes,
|
||||
highlightAll: !accessiblePrefixes,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isBetween) {
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
<div className='flex items-center gap-1 px-[10px]'>
|
||||
{renderInput(cellValue, 'value')}
|
||||
<span className='flex-shrink-0 text-muted-foreground text-xs'>to</span>
|
||||
{renderInput(valueTo, 'valueTo')}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||
{renderInput(cellValue, 'value')}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
const renderDeleteButton = (rowIndex: number) => {
|
||||
const canDelete = !isPreview && !disabled
|
||||
if (isPreview || disabled) return null
|
||||
|
||||
return canDelete ? (
|
||||
return (
|
||||
<td className='w-0 p-0'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
||||
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||
onClick={() => handleDeleteRow(rowIndex)}
|
||||
>
|
||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
||||
<Trash className='h-[14px] w-[14px]' />
|
||||
</Button>
|
||||
</td>
|
||||
) : null
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -361,14 +412,18 @@ export function KnowledgeTagFilters({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='overflow-visible rounded-md border'>
|
||||
<table className='w-full'>
|
||||
<div className='relative w-full'>
|
||||
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||
<table className='w-full table-fixed bg-transparent'>
|
||||
{renderHeader()}
|
||||
<tbody>
|
||||
<tbody className='bg-transparent'>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<tr key={row.id} className='group relative border-t'>
|
||||
<tr
|
||||
key={row.id}
|
||||
className='group relative border-[var(--border-strong)] border-t bg-transparent'
|
||||
>
|
||||
{renderTagNameCell(row, rowIndex)}
|
||||
{renderOperatorCell(row, rowIndex)}
|
||||
{renderValueCell(row, rowIndex)}
|
||||
{renderDeleteButton(rowIndex)}
|
||||
</tr>
|
||||
@@ -400,7 +455,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 onClick={handleAddRow} className='h-7 px-2 text-xs'>
|
||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||
Add Filter
|
||||
</Button>
|
||||
|
||||
@@ -982,6 +982,11 @@ export function ToolInput({
|
||||
if (hasMultipleOperations(blockType)) {
|
||||
return false
|
||||
}
|
||||
// Allow multiple instances for workflow and knowledge blocks
|
||||
// Each instance can target a different workflow/knowledge base
|
||||
if (blockType === 'workflow' || blockType === 'knowledge') {
|
||||
return false
|
||||
}
|
||||
return selectedTools.some((tool) => tool.toolId === toolId)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { OAuthProvider } from '@/lib/oauth'
|
||||
import { DEFAULT_HORIZONTAL_SPACING } from '@/lib/workflows/autolayout/constants'
|
||||
import { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
import { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { NoteBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/note-block/note-block'
|
||||
import type { SubflowNodeData } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node'
|
||||
import { TrainingModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/training-modal/training-modal'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
@@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => {
|
||||
useEffect(() => {
|
||||
const handleRemoveFromSubflow = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ blockId: string }>
|
||||
const { blockId } = customEvent.detail || ({} as any)
|
||||
const blockId = customEvent.detail?.blockId
|
||||
if (!blockId) return
|
||||
|
||||
try {
|
||||
@@ -555,6 +557,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const candidates = Object.entries(blocks)
|
||||
.filter(([id, block]) => {
|
||||
if (!block.enabled) return false
|
||||
if (block.type === 'response') return false
|
||||
const node = nodeIndex.get(id)
|
||||
if (!node) return false
|
||||
|
||||
@@ -601,6 +604,152 @@ const WorkflowContent = React.memo(() => {
|
||||
return 'source'
|
||||
}, [])
|
||||
|
||||
/** Creates a standardized edge object for workflow connections. */
|
||||
const createEdgeObject = useCallback(
|
||||
(sourceId: string, targetId: string, sourceHandle: string): Edge => ({
|
||||
id: crypto.randomUUID(),
|
||||
source: sourceId,
|
||||
target: targetId,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
/** Gets the appropriate start handle for a container node (loop or parallel). */
|
||||
const getContainerStartHandle = useCallback(
|
||||
(containerId: string): string => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerId)
|
||||
return (containerNode?.data as SubflowNodeData)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
},
|
||||
[getNodes]
|
||||
)
|
||||
|
||||
/** Finds the closest non-response block to a position within a set of blocks. */
|
||||
const findClosestBlockInSet = useCallback(
|
||||
(
|
||||
candidateBlocks: { id: string; type: string; position: { x: number; y: number } }[],
|
||||
targetPosition: { x: number; y: number }
|
||||
): { id: string; type: string; position: { x: number; y: number } } | undefined => {
|
||||
return candidateBlocks
|
||||
.filter((b) => b.type !== 'response')
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - targetPosition.x) ** 2 + (b.position.y - targetPosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* Attempts to create an auto-connect edge for a new block being added.
|
||||
* Returns the edge object if auto-connect should occur, or undefined otherwise.
|
||||
*
|
||||
* @param position - The position where the new block will be placed
|
||||
* @param targetBlockId - The ID of the new block being added
|
||||
* @param options - Configuration for auto-connect behavior
|
||||
*/
|
||||
const tryCreateAutoConnectEdge = useCallback(
|
||||
(
|
||||
position: { x: number; y: number },
|
||||
targetBlockId: string,
|
||||
options: {
|
||||
blockType: string
|
||||
enableTriggerMode?: boolean
|
||||
targetParentId?: string | null
|
||||
existingChildBlocks?: { id: string; type: string; position: { x: number; y: number } }[]
|
||||
containerId?: string
|
||||
}
|
||||
): Edge | undefined => {
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
if (!isAutoConnectEnabled) return undefined
|
||||
|
||||
// Don't auto-connect starter or annotation-only blocks
|
||||
if (options.blockType === 'starter' || isAnnotationOnlyBlock(options.blockType)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Check if target is a trigger block
|
||||
const targetBlockConfig = getBlock(options.blockType)
|
||||
const isTargetTrigger =
|
||||
options.enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
if (isTargetTrigger) return undefined
|
||||
|
||||
// Case 1: Adding block inside a container with existing children
|
||||
if (options.existingChildBlocks && options.existingChildBlocks.length > 0) {
|
||||
const closestBlock = findClosestBlockInSet(options.existingChildBlocks, position)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Case 2: Adding block inside an empty container - connect from container start
|
||||
if (
|
||||
options.containerId &&
|
||||
(!options.existingChildBlocks || options.existingChildBlocks.length === 0)
|
||||
) {
|
||||
const startHandle = getContainerStartHandle(options.containerId)
|
||||
return createEdgeObject(options.containerId, targetBlockId, startHandle)
|
||||
}
|
||||
|
||||
// Case 3: Adding block at root level - use findClosestOutput
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (!closestBlock) return undefined
|
||||
|
||||
// Don't create cross-container edges
|
||||
const closestBlockParentId = blocks[closestBlock.id]?.data?.parentId
|
||||
if (closestBlockParentId && !options.targetParentId) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
return createEdgeObject(closestBlock.id, targetBlockId, sourceHandle)
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
createEdgeObject,
|
||||
getContainerStartHandle,
|
||||
findClosestBlockInSet,
|
||||
]
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if adding a trigger block would violate constraints and shows notification if so.
|
||||
* @returns true if validation failed (caller should return early), false if ok to proceed
|
||||
*/
|
||||
const checkTriggerConstraints = useCallback(
|
||||
(blockType: string): boolean => {
|
||||
const issue = TriggerUtils.getTriggerAdditionIssue(blocks, blockType)
|
||||
if (issue) {
|
||||
const message =
|
||||
issue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${issue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
[blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Shared handler for drops of toolbar items onto the workflow canvas.
|
||||
*
|
||||
@@ -629,21 +778,10 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle: determineSourceHandle(closestBlock),
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
addBlock(
|
||||
id,
|
||||
@@ -651,8 +789,8 @@ const WorkflowContent = React.memo(() => {
|
||||
name,
|
||||
position,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -674,12 +812,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const id = crypto.randomUUID()
|
||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
||||
const baseName =
|
||||
data.type === 'loop'
|
||||
? 'Loop'
|
||||
: data.type === 'parallel'
|
||||
? 'Parallel'
|
||||
: defaultTriggerNameDrop || blockConfig!.name
|
||||
const baseName = defaultTriggerNameDrop || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
if (containerInfo) {
|
||||
@@ -711,70 +844,18 @@ const WorkflowContent = React.memo(() => {
|
||||
estimateBlockDimensions(data.type)
|
||||
)
|
||||
|
||||
// Capture existing child blocks before adding the new one
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === containerInfo.loopId
|
||||
)
|
||||
// Capture existing child blocks for auto-connect
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === containerInfo.loopId)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
// Auto-connect logic for blocks inside containers
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect to the nearest existing child block within the container
|
||||
const closestBlock = existingChildBlocks
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePosition.x) ** 2 +
|
||||
(b.position.y - relativePosition.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No existing children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: containerInfo.loopId,
|
||||
target: id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: containerInfo.loopId,
|
||||
existingChildBlocks,
|
||||
containerId: containerInfo.loopId,
|
||||
})
|
||||
|
||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||
addBlock(
|
||||
@@ -796,49 +877,13 @@ const WorkflowContent = React.memo(() => {
|
||||
resizeLoopNodesWrapper()
|
||||
} else {
|
||||
// Centralized trigger constraints
|
||||
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
|
||||
if (dropIssue) {
|
||||
const message =
|
||||
dropIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${dropIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (checkTriggerConstraints(data.type)) return
|
||||
|
||||
// Regular auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (
|
||||
isAutoConnectEnabled &&
|
||||
data.type !== 'starter' &&
|
||||
!isAnnotationOnlyBlock(data.type)
|
||||
) {
|
||||
const closestBlock = findClosestOutput(position)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = getBlock(data.type)
|
||||
const isTargetTrigger =
|
||||
data.enableTriggerMode === true || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||
blockType: data.type,
|
||||
enableTriggerMode: data.enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Regular canvas drop with auto-connect edge
|
||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||
@@ -861,14 +906,13 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
[
|
||||
blocks,
|
||||
getNodes,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
isPointInLoopNode,
|
||||
resizeLoopNodesWrapper,
|
||||
addBlock,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
tryCreateAutoConnectEdge,
|
||||
checkTriggerConstraints,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -885,44 +929,73 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
// Calculate smart position - to the right of existing root-level blocks
|
||||
const calculateSmartPosition = (): { x: number; y: number } => {
|
||||
// Get all root-level blocks (no parentId)
|
||||
const rootBlocks = Object.values(blocks).filter((b) => !b.data?.parentId)
|
||||
|
||||
if (rootBlocks.length === 0) {
|
||||
// No blocks yet, use viewport center
|
||||
return screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
}
|
||||
|
||||
// Find the rightmost block
|
||||
let maxRight = Number.NEGATIVE_INFINITY
|
||||
let rightmostBlockY = 0
|
||||
for (const block of rootBlocks) {
|
||||
const blockWidth =
|
||||
block.type === 'loop' || block.type === 'parallel'
|
||||
? block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
const blockRight = block.position.x + blockWidth
|
||||
if (blockRight > maxRight) {
|
||||
maxRight = blockRight
|
||||
rightmostBlockY = block.position.y
|
||||
}
|
||||
}
|
||||
|
||||
// Position to the right with autolayout spacing
|
||||
const position = {
|
||||
x: maxRight + DEFAULT_HORIZONTAL_SPACING,
|
||||
y: rightmostBlockY,
|
||||
}
|
||||
|
||||
// Ensure position doesn't overlap any container
|
||||
let container = isPointInLoopNode(position)
|
||||
while (container) {
|
||||
position.x =
|
||||
container.loopPosition.x + container.dimensions.width + DEFAULT_HORIZONTAL_SPACING
|
||||
container = isPointInLoopNode(position)
|
||||
}
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
const basePosition = calculateSmartPosition()
|
||||
|
||||
// Special handling for container nodes (loop or parallel)
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Auto-connect logic for container nodes
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the container node with default dimensions and auto-connect edge
|
||||
addBlock(
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
{
|
||||
width: 500,
|
||||
height: 300,
|
||||
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
type: 'subflowNode',
|
||||
},
|
||||
undefined,
|
||||
@@ -939,11 +1012,8 @@ const WorkflowContent = React.memo(() => {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the center position of the viewport
|
||||
const centerPosition = screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
// Check trigger constraints first
|
||||
if (checkTriggerConstraints(type)) return
|
||||
|
||||
// Create a new block with a unique ID
|
||||
const id = crypto.randomUUID()
|
||||
@@ -952,51 +1022,11 @@ const WorkflowContent = React.memo(() => {
|
||||
const baseName = defaultTriggerName || blockConfig.name
|
||||
const name = getUniqueBlockName(baseName, blocks)
|
||||
|
||||
// Auto-connect logic
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
let autoConnectEdge
|
||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
||||
const closestBlock = findClosestOutput(centerPosition)
|
||||
logger.info('Closest block found:', closestBlock)
|
||||
if (closestBlock) {
|
||||
// Don't create edges into trigger blocks or annotation blocks
|
||||
const targetBlockConfig = blockConfig
|
||||
const isTargetTrigger = enableTriggerMode || targetBlockConfig?.category === 'triggers'
|
||||
|
||||
if (!isTargetTrigger) {
|
||||
const sourceHandle = determineSourceHandle(closestBlock)
|
||||
|
||||
autoConnectEdge = {
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
}
|
||||
logger.info('Auto-connect edge created:', autoConnectEdge)
|
||||
} else {
|
||||
logger.info('Skipping auto-connect into trigger block', {
|
||||
target: type,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Centralized trigger constraints
|
||||
const additionIssue = TriggerUtils.getTriggerAdditionIssue(blocks, type)
|
||||
if (additionIssue) {
|
||||
const message =
|
||||
additionIssue.issue === 'legacy'
|
||||
? 'Cannot add new trigger blocks when a legacy Start block exists. Available in newer workflows.'
|
||||
: `A workflow can only have one ${additionIssue.triggerName} trigger block. Please remove the existing one before adding a new one.`
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||
blockType: type,
|
||||
enableTriggerMode,
|
||||
targetParentId: null,
|
||||
})
|
||||
|
||||
// Add the block to the workflow with auto-connect edge
|
||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||
@@ -1004,7 +1034,7 @@ const WorkflowContent = React.memo(() => {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
centerPosition,
|
||||
basePosition,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1025,11 +1055,12 @@ const WorkflowContent = React.memo(() => {
|
||||
screenToFlowPosition,
|
||||
blocks,
|
||||
addBlock,
|
||||
findClosestOutput,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
isPointInLoopNode,
|
||||
effectivePermissions.canEdit,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
checkTriggerConstraints,
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -1220,12 +1251,12 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||
if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'loop'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
containerNode?.type === 'subflowNode' &&
|
||||
(containerNode.data as any)?.kind === 'parallel'
|
||||
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -1424,8 +1455,8 @@ const WorkflowContent = React.memo(() => {
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
width: block.data?.width || 500,
|
||||
height: block.data?.height || 300,
|
||||
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
||||
},
|
||||
})
|
||||
@@ -1484,8 +1515,8 @@ const WorkflowContent = React.memo(() => {
|
||||
},
|
||||
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||
width: 250, // Standard width for both block types
|
||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
||||
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||
height: Math.max(block.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1572,7 +1603,7 @@ const WorkflowContent = React.memo(() => {
|
||||
/**
|
||||
* Effect to resize loops when nodes change (add/remove/position change).
|
||||
* Runs on structural changes only - not during drag (position-only changes).
|
||||
* Skips during loading to avoid unnecessary work.
|
||||
* Skips during loading.
|
||||
*/
|
||||
useEffect(() => {
|
||||
// Skip during initial render when nodes aren't loaded yet or workflow not ready
|
||||
@@ -1794,12 +1825,15 @@ const WorkflowContent = React.memo(() => {
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
||||
const nodeWidth = node.type === 'subflowNode' ? node.data?.width || 500 : 250 // All workflow blocks use w-[250px] in workflow-block.tsx
|
||||
const nodeWidth =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH
|
||||
: BLOCK_DIMENSIONS.FIXED_WIDTH
|
||||
|
||||
const nodeHeight =
|
||||
node.type === 'subflowNode'
|
||||
? node.data?.height || 300
|
||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
||||
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
|
||||
// Check intersection using absolute coordinates
|
||||
const nodeRect = {
|
||||
@@ -1811,9 +1845,10 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
const containerRect = {
|
||||
left: containerAbsolutePos.x,
|
||||
right: containerAbsolutePos.x + (n.data?.width || 500),
|
||||
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
|
||||
top: containerAbsolutePos.y,
|
||||
bottom: containerAbsolutePos.y + (n.data?.height || 300),
|
||||
bottom:
|
||||
containerAbsolutePos.y + (n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}
|
||||
|
||||
// Check intersection with absolute coordinates for accurate detection
|
||||
@@ -1829,7 +1864,9 @@ const WorkflowContent = React.memo(() => {
|
||||
container: n,
|
||||
depth: getNodeDepth(n.id),
|
||||
// Calculate size for secondary sorting
|
||||
size: (n.data?.width || 500) * (n.data?.height || 300),
|
||||
size:
|
||||
(n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH) *
|
||||
(n.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT),
|
||||
}))
|
||||
|
||||
// Update potential parent if there's at least one intersecting container node
|
||||
@@ -1857,12 +1894,12 @@ const WorkflowContent = React.memo(() => {
|
||||
// Apply appropriate class based on container type
|
||||
if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'loop'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'loop'
|
||||
) {
|
||||
containerElement.classList.add('loop-node-drag-over')
|
||||
} else if (
|
||||
bestContainerMatch.container.type === 'subflowNode' &&
|
||||
(bestContainerMatch.container.data as any)?.kind === 'parallel'
|
||||
(bestContainerMatch.container.data as SubflowNodeData)?.kind === 'parallel'
|
||||
) {
|
||||
containerElement.classList.add('parallel-node-drag-over')
|
||||
}
|
||||
@@ -2034,62 +2071,19 @@ const WorkflowContent = React.memo(() => {
|
||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
||||
}
|
||||
|
||||
// Prepare edges that will be added when moving into the container
|
||||
const edgesToAdd: any[] = []
|
||||
|
||||
// Auto-connect when moving an existing block into a container
|
||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
||||
// Don't auto-connect annotation blocks (like note blocks)
|
||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
||||
// Existing children in the target container (excluding the moved node)
|
||||
const existingChildBlocks = Object.values(blocks).filter(
|
||||
(b) => b.data?.parentId === potentialParentId && b.id !== node.id
|
||||
)
|
||||
const existingChildBlocks = Object.values(blocks)
|
||||
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
|
||||
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||
|
||||
if (existingChildBlocks.length > 0) {
|
||||
// Connect from nearest existing child inside the container
|
||||
const closestBlock = existingChildBlocks
|
||||
.map((b) => ({
|
||||
block: b,
|
||||
distance: Math.sqrt(
|
||||
(b.position.x - relativePositionBefore.x) ** 2 +
|
||||
(b.position.y - relativePositionBefore.y) ** 2
|
||||
),
|
||||
}))
|
||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
||||
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
|
||||
blockType: node.data?.type || '',
|
||||
targetParentId: potentialParentId,
|
||||
existingChildBlocks,
|
||||
containerId: potentialParentId,
|
||||
})
|
||||
|
||||
if (closestBlock) {
|
||||
const sourceHandle = determineSourceHandle({
|
||||
id: closestBlock.id,
|
||||
type: closestBlock.type,
|
||||
})
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: closestBlock.id,
|
||||
target: node.id,
|
||||
sourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No children: connect from the container's start handle to the moved node
|
||||
const containerNode = getNodes().find((n) => n.id === potentialParentId)
|
||||
const startSourceHandle =
|
||||
(containerNode?.data as any)?.kind === 'loop'
|
||||
? 'loop-start-source'
|
||||
: 'parallel-start-source'
|
||||
|
||||
edgesToAdd.push({
|
||||
id: crypto.randomUUID(),
|
||||
source: potentialParentId,
|
||||
target: node.id,
|
||||
sourceHandle: startSourceHandle,
|
||||
targetHandle: 'target',
|
||||
type: 'workflowEdge',
|
||||
})
|
||||
}
|
||||
}
|
||||
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
|
||||
|
||||
// Skip recording these edges separately since they're part of the parent update
|
||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||
@@ -2114,7 +2108,7 @@ const WorkflowContent = React.memo(() => {
|
||||
updateNodeParent,
|
||||
collaborativeUpdateBlockPosition,
|
||||
addEdge,
|
||||
determineSourceHandle,
|
||||
tryCreateAutoConnectEdge,
|
||||
blocks,
|
||||
edgesForDisplay,
|
||||
removeEdgesForNode,
|
||||
|
||||
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Enter a name for your API key to help you identify it later.
|
||||
</p>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<EmcnInput
|
||||
value={newKeyName}
|
||||
onChange={(e) => {
|
||||
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
||||
placeholder='e.g., Development, Production'
|
||||
className='h-9'
|
||||
autoFocus
|
||||
name='api_key_label'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
{createError && (
|
||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/emcn'
|
||||
import { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||
referenceId = activeOrgId
|
||||
// Get subscription ID for team/enterprise
|
||||
subscriptionId = subData?.data?.id
|
||||
}
|
||||
|
||||
@@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
referenceId = activeOrgId
|
||||
subscriptionId = subData?.data?.id
|
||||
} else {
|
||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
||||
referenceId = session.user.id
|
||||
subscriptionId = undefined
|
||||
}
|
||||
|
||||
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
||||
|
||||
// Build restore params - only include subscriptionId if we have one (team/enterprise)
|
||||
const restoreParams: any = { referenceId }
|
||||
if (subscriptionId) {
|
||||
restoreParams.subscriptionId = subscriptionId
|
||||
@@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
logger.info('Subscription restored successfully', result)
|
||||
}
|
||||
|
||||
// Invalidate queries to refresh data
|
||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||
if (activeOrgId) {
|
||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(activeOrgId) })
|
||||
@@ -175,10 +179,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
if (!date) return 'end of current billing period'
|
||||
|
||||
try {
|
||||
// Ensure we have a valid Date object
|
||||
const dateObj = date instanceof Date ? date : new Date(date)
|
||||
|
||||
// Check if the date is valid
|
||||
if (Number.isNaN(dateObj.getTime())) {
|
||||
return 'end of current billing period'
|
||||
}
|
||||
@@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
const periodEndDate = getPeriodEndDate()
|
||||
|
||||
// Check if subscription is set to cancel at period end
|
||||
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div>
|
||||
<span className='font-medium text-[13px]'>
|
||||
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
|
||||
</span>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
|
||||
{isCancelAtPeriodEnd && (
|
||||
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
You'll keep access until {formatDate(periodEndDate)}
|
||||
</p>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -217,7 +216,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'h-8 rounded-[8px] font-medium text-xs',
|
||||
'h-8 rounded-[8px] text-[13px]',
|
||||
error && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||
)}
|
||||
>
|
||||
@@ -231,7 +230,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
{isCancelAtPeriodEnd
|
||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
||||
: `You'll be redirected to Stripe to manage your subscription. You'll keep access until ${formatDate(
|
||||
@@ -244,8 +243,8 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
||||
|
||||
{!isCancelAtPeriodEnd && (
|
||||
<div className='mt-3'>
|
||||
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
|
||||
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
|
||||
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
|
||||
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
|
||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||
<li>• No more charges</li>
|
||||
<li>• Data preserved</li>
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
@@ -90,7 +92,6 @@ export function CreditBalance({
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
// Generate new requestId when modal opens - same ID used for entire session
|
||||
setRequestId(crypto.randomUUID())
|
||||
} else {
|
||||
setAmount('')
|
||||
@@ -102,72 +103,66 @@ export function CreditBalance({
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>Credit Balance</span>
|
||||
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
|
||||
<div className='flex items-center gap-[8px]'>
|
||||
<Label>Credit Balance</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{isLoading ? '...' : `$${balance.toFixed(2)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPurchase && (
|
||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant='outline'>Add Credits</Button>
|
||||
<Button variant='outline' className='h-8 rounded-[8px] text-[13px]'>
|
||||
Add Credits
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Add Credits</ModalHeader>
|
||||
<div className='px-4'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Credits are used before overage charges. Min $10, max $1,000.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className='py-4 text-center'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
||||
<ModalBody>
|
||||
{success ? (
|
||||
<p className='text-center text-[13px] text-[var(--text-primary)]'>
|
||||
Credits added successfully!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-3 py-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label
|
||||
htmlFor='credit-amount'
|
||||
className='text-[12px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Amount (USD)
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id='credit-amount'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
placeholder='50'
|
||||
className='pl-7'
|
||||
disabled={isPurchasing}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className='text-[11px] text-red-500'>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Credits are non-refundable and don't expire. They'll be applied automatically to
|
||||
your {entityType === 'organization' ? 'team' : ''} usage.
|
||||
) : (
|
||||
<>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Credits are used before overage charges. Min $10, max $1,000.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='credit-amount'>Amount (USD)</Label>
|
||||
<div className='relative'>
|
||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[13px] text-[var(--text-secondary)]'>
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id='credit-amount'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
placeholder='50'
|
||||
className='pl-7'
|
||||
disabled={isPurchasing}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className='text-[12px] text-[var(--text-error)]'>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Credits are non-refundable and don't expire. They'll be applied automatically
|
||||
to your {entityType === 'organization' ? 'team' : ''} usage.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
{!success && (
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant='ghost' disabled={isPurchasing}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={isPurchasing}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant='primary'
|
||||
|
||||
@@ -45,9 +45,9 @@ export function PlanCard({
|
||||
if (typeof price === 'string') {
|
||||
return (
|
||||
<>
|
||||
<span className='font-semibold text-xl'>{price}</span>
|
||||
<span className='font-semibold text-[20px]'>{price}</span>
|
||||
{priceSubtext && (
|
||||
<span className='ml-1 text-[var(--text-muted)] text-xs'>{priceSubtext}</span>
|
||||
<span className='ml-1 text-[12px] text-[var(--text-muted)]'>{priceSubtext}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -58,13 +58,13 @@ export function PlanCard({
|
||||
const renderFeatures = () => {
|
||||
if (isHorizontal) {
|
||||
return (
|
||||
<div className='mt-3 flex flex-wrap items-center gap-4'>
|
||||
<div className='mt-3 flex flex-wrap items-center gap-3'>
|
||||
{features.map((feature, index) => (
|
||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
|
||||
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-muted)]' />
|
||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-[12px]'>
|
||||
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]' />
|
||||
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||
{index < features.length - 1 && (
|
||||
<div className='ml-4 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
||||
<div className='ml-3 h-4 w-px bg-[var(--border)]' aria-hidden='true' />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -75,12 +75,12 @@ export function PlanCard({
|
||||
return (
|
||||
<ul className='mb-4 flex-1 space-y-2'>
|
||||
{features.map((feature, index) => (
|
||||
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-xs'>
|
||||
<li key={`${feature.text}-${index}`} className='flex items-start gap-2 text-[12px]'>
|
||||
<feature.icon
|
||||
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-muted)]'
|
||||
className='mt-0.5 h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
||||
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -91,24 +91,24 @@ export function PlanCard({
|
||||
<article
|
||||
className={cn(
|
||||
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
|
||||
isHorizontal ? 'flex-row items-center justify-between' : 'flex-col',
|
||||
isHorizontal ? 'flex-row items-center justify-between gap-6' : 'flex-col',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<header className={isHorizontal ? undefined : 'mb-4'}>
|
||||
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
|
||||
<header className={isHorizontal ? 'flex-1' : 'mb-4'}>
|
||||
<h3 className='mb-2 font-semibold text-[14px]'>{name}</h3>
|
||||
<div className='flex items-baseline'>{renderPrice()}</div>
|
||||
{isHorizontal && renderFeatures()}
|
||||
</header>
|
||||
|
||||
{!isHorizontal && renderFeatures()}
|
||||
|
||||
<div className={isHorizontal ? 'ml-auto' : undefined}>
|
||||
<div className={isHorizontal ? 'flex-shrink-0' : undefined}>
|
||||
<Button
|
||||
onClick={onButtonClick}
|
||||
className={cn(
|
||||
'h-9 rounded-[8px] text-xs',
|
||||
isHorizontal ? 'px-4' : 'w-full',
|
||||
'h-9 rounded-[8px] text-[13px]',
|
||||
isHorizontal ? 'min-w-[100px] px-6' : 'w-full',
|
||||
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||
)}
|
||||
variant='outline'
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { Switch } from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
Label,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverItem,
|
||||
PopoverSection,
|
||||
PopoverTrigger,
|
||||
Switch,
|
||||
} from '@/components/emcn'
|
||||
import { Skeleton } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
@@ -270,7 +270,6 @@ export function Subscription() {
|
||||
}
|
||||
)
|
||||
|
||||
// UI state computed values
|
||||
const showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
||||
|
||||
@@ -333,7 +332,7 @@ export function Subscription() {
|
||||
<PlanCard
|
||||
key='enterprise'
|
||||
name='Enterprise'
|
||||
price={<span className='font-semibold text-xl'>Custom</span>}
|
||||
price={<span className='font-semibold text-[20px]'>Custom</span>}
|
||||
priceSubtext={
|
||||
layout === 'horizontal'
|
||||
? 'Custom solutions tailored to your enterprise needs'
|
||||
@@ -458,7 +457,7 @@ export function Subscription() {
|
||||
{/* Enterprise Usage Limit Notice */}
|
||||
{subscription.isEnterprise && (
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Contact enterprise for support usage limit changes
|
||||
</p>
|
||||
</div>
|
||||
@@ -467,7 +466,7 @@ export function Subscription() {
|
||||
{/* Team Member Notice */}
|
||||
{permissions.showTeamMemberView && (
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-xs'>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
Contact your team admin to increase limits
|
||||
</p>
|
||||
</div>
|
||||
@@ -534,72 +533,78 @@ export function Subscription() {
|
||||
{/* Next Billing Date */}
|
||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px]'>Next Billing Date</span>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
||||
<Label>Next Billing Date</Label>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing usage notifications toggle */}
|
||||
{/* Usage notifications */}
|
||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||
|
||||
{/* Cancel Subscription */}
|
||||
{permissions.canCancelSubscription && (
|
||||
<div className='mt-[8px]'>
|
||||
<CancelSubscription
|
||||
subscription={{
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
isPaid: subscription.isPaid,
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<CancelSubscription
|
||||
subscription={{
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
isPaid: subscription.isPaid,
|
||||
}}
|
||||
subscriptionData={{
|
||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Workspace API Billing Settings */}
|
||||
{/* Billed Account for Workspace */}
|
||||
{canManageWorkspaceKeys && (
|
||||
<div className='mt-[24px] flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px]'>Billed Account for Workspace</span>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label>Billed Account for Workspace</Label>
|
||||
{isWorkspaceLoading ? (
|
||||
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
||||
) : workspaceAdmins.length === 0 ? (
|
||||
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[var(--text-muted)] text-xs'>
|
||||
<div className='rounded-[6px] border border-dashed px-3 py-1.5 text-[12px] text-[var(--text-muted)]'>
|
||||
No admin members available
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={billedAccountUserId ?? ''}
|
||||
onValueChange={async (value) => {
|
||||
if (value === billedAccountUserId) return
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
||||
} catch (error) {
|
||||
// Error is already logged in updateWorkspaceSettings
|
||||
}
|
||||
}}
|
||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
||||
<SelectValue placeholder='Select admin' />
|
||||
</SelectTrigger>
|
||||
<SelectContent align='start' className='z-[10000050]'>
|
||||
<SelectGroup>
|
||||
<SelectLabel className='px-3 py-1 text-[11px] text-[var(--text-muted)] uppercase'>
|
||||
Workspace admins
|
||||
</SelectLabel>
|
||||
{workspaceAdmins.map((admin: any) => (
|
||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
||||
{admin.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className='flex h-8 w-[200px] items-center justify-between gap-2 rounded-[6px] border border-[var(--border)] bg-transparent px-3 text-left text-[13px] transition-colors hover:bg-[var(--surface-3)] disabled:pointer-events-none disabled:opacity-50'
|
||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||
>
|
||||
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||
{billedAccountUserId
|
||||
? workspaceAdmins.find((admin: any) => admin.userId === billedAccountUserId)
|
||||
?.email || 'Select admin'
|
||||
: 'Select admin'}
|
||||
</span>
|
||||
<ChevronDown className='h-3 w-3 shrink-0 text-[var(--text-secondary)]' />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' minWidth={200} border>
|
||||
<PopoverSection>Workspace admins</PopoverSection>
|
||||
{workspaceAdmins.map((admin: any) => (
|
||||
<PopoverItem
|
||||
key={admin.userId}
|
||||
active={billedAccountUserId === admin.userId}
|
||||
showCheck
|
||||
onClick={async () => {
|
||||
if (admin.userId === billedAccountUserId) return
|
||||
try {
|
||||
await updateWorkspaceSettings({ billedAccountUserId: admin.userId })
|
||||
} catch (error) {
|
||||
// Error is already logged in updateWorkspaceSettings
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className='flex-1 truncate'>{admin.email}</span>
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() {
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium text-[13px]'>Usage notifications</span>
|
||||
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<Label htmlFor='usage-notifications'>Usage notifications</Label>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
Email me when I reach 80% usage
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
id='usage-notifications'
|
||||
checked={!!enabled}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={(v: boolean) => {
|
||||
|
||||
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
|
||||
{/* Main invitation input */}
|
||||
<div className='flex items-start gap-2'>
|
||||
<div className='flex-1'>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Enter email address'
|
||||
value={inviteEmail}
|
||||
onChange={handleEmailChange}
|
||||
disabled={isInviting || !hasAvailableSeats}
|
||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
||||
name='member_invite_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
{emailError && (
|
||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
|
||||
@@ -55,16 +55,31 @@ export function NoOrganizationView({
|
||||
|
||||
{/* Form fields - clean layout without card */}
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='orgName' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
|
||||
Team Name
|
||||
</Label>
|
||||
<Input
|
||||
id='orgName'
|
||||
id='team-name-field'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
placeholder='My Team'
|
||||
className='mt-1'
|
||||
name='team_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,31 +131,52 @@ export function NoOrganizationView({
|
||||
</ModalHeader>
|
||||
|
||||
<div className='space-y-4'>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div>
|
||||
<Label htmlFor='org-name' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
|
||||
Organization Name
|
||||
</Label>
|
||||
<Input
|
||||
id='org-name'
|
||||
id='org-name-field'
|
||||
placeholder='Enter organization name'
|
||||
value={orgName}
|
||||
onChange={onOrgNameChange}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_name_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
|
||||
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
|
||||
Organization Slug
|
||||
</Label>
|
||||
<Input
|
||||
id='org-slug'
|
||||
id='org-slug-field'
|
||||
placeholder='organization-slug'
|
||||
value={orgSlug}
|
||||
onChange={(e) => setOrgSlug(e.target.value)}
|
||||
disabled={isCreatingOrg}
|
||||
className='mt-1'
|
||||
name='org_slug_field'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function TeamMembers({
|
||||
<div className='space-y-4'>
|
||||
{teamItems.map((item) => (
|
||||
<div key={item.id} className='flex items-center justify-between'>
|
||||
{/* Member info */}
|
||||
{/* Left section: Avatar + Name/Role + Action buttons */}
|
||||
<div className='flex flex-1 items-center gap-3'>
|
||||
{/* Avatar */}
|
||||
<UserAvatar
|
||||
@@ -165,7 +165,7 @@ export function TeamMembers({
|
||||
/>
|
||||
|
||||
{/* Name and email */}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='min-w-0'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='truncate font-medium text-sm'>{item.name}</span>
|
||||
{item.type === 'member' && (
|
||||
@@ -188,51 +188,50 @@ export function TeamMembers({
|
||||
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
||||
</div>
|
||||
|
||||
{/* Usage stats - matching subscription layout */}
|
||||
{/* Action buttons */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='hidden items-center text-xs tabular-nums sm:flex'>
|
||||
<div className='text-center'>
|
||||
<div className='text-[var(--text-muted)]'>Usage</div>
|
||||
<div className='font-medium'>
|
||||
{isLoadingUsage && item.type === 'member' ? (
|
||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||
) : (
|
||||
item.usage
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
{/* Admin/Owner can remove other members */}
|
||||
{item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right section: Usage column (right-aligned) */}
|
||||
{isAdminOrOwner && (
|
||||
<div className='ml-4 flex flex-col items-end'>
|
||||
<div className='text-[var(--text-muted)] text-xs'>Usage</div>
|
||||
<div className='font-medium text-xs tabular-nums'>
|
||||
{isLoadingUsage && item.type === 'member' ? (
|
||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
||||
) : (
|
||||
item.usage
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className='ml-4 flex gap-1'>
|
||||
{/* Admin/Owner can remove other members */}
|
||||
{isAdminOrOwner &&
|
||||
item.type === 'member' &&
|
||||
item.role !== 'owner' &&
|
||||
item.email !== currentUserEmail && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => onRemoveMember(item.member)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Admin can cancel invitations */}
|
||||
{isAdminOrOwner && item.type === 'invitation' && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => handleCancelInvitation(item.invitation.id)}
|
||||
disabled={cancellingInvitations.has(item.invitation.id)}
|
||||
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{cancellingInvitations.has(item.invitation.id) ? 'Cancelling...' : 'Cancel'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,11 +5,10 @@ import {
|
||||
type ComboboxOption,
|
||||
Label,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
Tooltip,
|
||||
} from '@/components/emcn'
|
||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||
@@ -55,50 +54,53 @@ export function TeamSeats({
|
||||
const totalMonthlyCost = selectedSeats * costPerSeat
|
||||
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm(selectedSeats)
|
||||
}
|
||||
|
||||
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
||||
value: num.toString(),
|
||||
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
|
||||
label: `${num} ${num === 1 ? 'seat' : 'seats'}`,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{title}</ModalTitle>
|
||||
<ModalDescription>{description}</ModalDescription>
|
||||
</ModalHeader>
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||
|
||||
<div className='py-4'>
|
||||
<Label htmlFor='seats'>Number of seats</Label>
|
||||
<Combobox
|
||||
options={seatOptions}
|
||||
value={selectedSeats.toString()}
|
||||
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
||||
placeholder='Select number of seats'
|
||||
/>
|
||||
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||
<Label htmlFor='seats'>Number of seats</Label>
|
||||
<Combobox
|
||||
options={seatOptions}
|
||||
value={selectedSeats > 0 ? selectedSeats.toString() : ''}
|
||||
onChange={(value) => {
|
||||
const num = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(num) && num > 0) {
|
||||
setSelectedSeats(num)
|
||||
}
|
||||
}}
|
||||
placeholder='Select or enter number of seats'
|
||||
editable
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className='mt-2 text-[var(--text-muted)] text-sm'>
|
||||
<p className='mt-3 text-[12px] text-[var(--text-muted)]'>
|
||||
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
||||
total of ${totalMonthlyCost} inference credits per month.
|
||||
</p>
|
||||
|
||||
{showCostBreakdown && currentSeats !== undefined && (
|
||||
<div className='mt-3 rounded-[8px] bg-[var(--surface-3)] p-3'>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||
<div className='flex justify-between text-[12px]'>
|
||||
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
||||
<span>{currentSeats}</span>
|
||||
<span className='text-[var(--text-primary)]'>{currentSeats}</span>
|
||||
</div>
|
||||
<div className='flex justify-between text-sm'>
|
||||
<div className='mt-2 flex justify-between text-[12px]'>
|
||||
<span className='text-[var(--text-muted)]'>New seats:</span>
|
||||
<span>{selectedSeats}</span>
|
||||
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
|
||||
</div>
|
||||
<div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'>
|
||||
<span className='text-[var(--text-muted)]'>Monthly cost change:</span>
|
||||
<span>
|
||||
<div className='mt-3 flex justify-between border-[var(--border)] border-t pt-3 text-[12px]'>
|
||||
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{costChange > 0 ? '+' : ''}${costChange}
|
||||
</span>
|
||||
</div>
|
||||
@@ -106,19 +108,14 @@ export function TeamSeats({
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className='mt-3 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||
<p className='mt-3 text-[12px] text-[var(--text-error)]'>
|
||||
{error instanceof Error && error.message ? error.message : String(error)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
<Button onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -127,22 +124,15 @@ export function TeamSeats({
|
||||
<span>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleConfirm}
|
||||
onClick={() => onConfirm(selectedSeats)}
|
||||
disabled={
|
||||
isLoading ||
|
||||
selectedSeats < 1 ||
|
||||
(showCostBreakdown && selectedSeats === currentSeats) ||
|
||||
isCancelledAtPeriodEnd
|
||||
}
|
||||
className='h-[32px] px-[12px]'
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className='flex items-center space-x-2'>
|
||||
<div className='h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent' />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
) : (
|
||||
<span>{confirmButtonText}</span>
|
||||
)}
|
||||
{isLoading ? 'Updating...' : confirmButtonText}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -390,11 +390,26 @@ export function TemplateProfile() {
|
||||
disabled={isUploadingProfilePicture}
|
||||
/>
|
||||
</div>
|
||||
{/* Hidden decoy field to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{ position: 'absolute', left: '-9999px', opacity: 0, pointerEvents: 'none' }}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<Input
|
||||
placeholder='Name'
|
||||
value={formData.name}
|
||||
onChange={(e) => updateField('name', e.target.value)}
|
||||
className='h-9 flex-1'
|
||||
name='profile_display_name'
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
/>
|
||||
</div>
|
||||
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||
import { useDeleteFolder, useDuplicateFolder } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteFolder,
|
||||
useDuplicateFolder,
|
||||
} from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
const createFolderMutation = useCreateFolder()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { canDeleteFolder } = useCanDelete({ workspaceId })
|
||||
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
|
||||
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDelete}
|
||||
/>
|
||||
|
||||
{/* Delete Modal */}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useItemRename,
|
||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import {
|
||||
useCanDelete,
|
||||
useDeleteWorkflow,
|
||||
useDuplicateWorkflow,
|
||||
useExportWorkflow,
|
||||
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isSelected = selectedWorkflows.has(workflow.id)
|
||||
|
||||
// Can delete check hook
|
||||
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||
|
||||
// Delete modal state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||
|
||||
// Presence avatars state
|
||||
const [hasAvatars, setHasAvatars] = useState(false)
|
||||
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
||||
}
|
||||
|
||||
// Check if the captured selection can be deleted
|
||||
setCanDeleteCaptured(canDeleteWorkflows(workflowIds))
|
||||
|
||||
// If already selected with multiple selections, keep all selections
|
||||
handleContextMenuBase(e)
|
||||
},
|
||||
[workflow.id, workflows, handleContextMenuBase]
|
||||
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
|
||||
)
|
||||
|
||||
// Rename hook
|
||||
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
||||
disableRename={!userPermissions.canEdit}
|
||||
disableDuplicate={!userPermissions.canEdit}
|
||||
disableExport={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit}
|
||||
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
|
||||
@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
<ModalContent className='w-[500px]'>
|
||||
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
|
||||
|
||||
<form ref={formRef} onSubmit={handleSubmit} className='flex min-h-0 flex-1 flex-col'>
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className='flex min-h-0 flex-1 flex-col'
|
||||
autoComplete='off'
|
||||
>
|
||||
<ModalBody>
|
||||
<div className='space-y-[12px]'>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor='emails'
|
||||
htmlFor='invite-field'
|
||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||
>
|
||||
Email Addresses
|
||||
</Label>
|
||||
{/* Hidden decoy fields to prevent browser autofill */}
|
||||
<input
|
||||
type='text'
|
||||
name='fakeusernameremembered'
|
||||
autoComplete='username'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
type='email'
|
||||
name='fakeemailremembered'
|
||||
autoComplete='email'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
tabIndex={-1}
|
||||
readOnly
|
||||
/>
|
||||
<div className='scrollbar-hide flex max-h-32 min-h-9 flex-wrap items-center gap-x-[8px] gap-y-[4px] overflow-y-auto rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] px-[6px] py-[4px] focus-within:outline-none dark:bg-[var(--surface-9)]'>
|
||||
{invalidEmails.map((email, index) => (
|
||||
<EmailTag
|
||||
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
id='emails'
|
||||
id='invite-field'
|
||||
name='invite_search_field'
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
||||
)}
|
||||
autoFocus={userPerms.canAdmin}
|
||||
disabled={isSubmitting || !userPerms.canAdmin}
|
||||
autoComplete='off'
|
||||
autoCorrect='off'
|
||||
autoCapitalize='off'
|
||||
spellCheck={false}
|
||||
data-lpignore='true'
|
||||
data-form-type='other'
|
||||
aria-autocomplete='none'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { useCanDelete } from './use-can-delete'
|
||||
export { useDeleteFolder } from './use-delete-folder'
|
||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||
|
||||
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
interface UseCanDeleteProps {
|
||||
/**
|
||||
* Current workspace ID
|
||||
*/
|
||||
workspaceId: string
|
||||
}
|
||||
|
||||
interface UseCanDeleteReturn {
|
||||
/**
|
||||
* Checks if the given workflow IDs can be deleted.
|
||||
* Returns false if deleting them would leave no workflows in the workspace.
|
||||
*/
|
||||
canDeleteWorkflows: (workflowIds: string[]) => boolean
|
||||
/**
|
||||
* Checks if the given folder can be deleted.
|
||||
* Returns false if deleting it would leave no workflows in the workspace.
|
||||
*/
|
||||
canDeleteFolder: (folderId: string) => boolean
|
||||
/**
|
||||
* Total number of workflows in the workspace.
|
||||
*/
|
||||
totalWorkflows: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for checking if workflows or folders can be deleted.
|
||||
* Prevents deletion if it would leave the workspace with no workflows.
|
||||
*
|
||||
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
|
||||
*
|
||||
* @param props - Hook configuration
|
||||
* @returns Functions to check deletion eligibility
|
||||
*/
|
||||
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
|
||||
const { workflows } = useWorkflowRegistry()
|
||||
const { folders } = useFolderStore()
|
||||
|
||||
/**
|
||||
* Pre-computed data structures for efficient lookups
|
||||
*/
|
||||
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
|
||||
useMemo(() => {
|
||||
const workspaceWorkflows = Object.values(workflows).filter(
|
||||
(w) => w.workspaceId === workspaceId
|
||||
)
|
||||
|
||||
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
|
||||
|
||||
const byFolderId = new Map<string, number>()
|
||||
for (const w of workspaceWorkflows) {
|
||||
if (w.folderId) {
|
||||
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const childrenByParent = new Map<string, string[]>()
|
||||
for (const folder of Object.values(folders)) {
|
||||
if (folder.workspaceId === workspaceId && folder.parentId) {
|
||||
const children = childrenByParent.get(folder.parentId) || []
|
||||
children.push(folder.id)
|
||||
childrenByParent.set(folder.parentId, children)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalWorkflows: workspaceWorkflows.length,
|
||||
workflowIdSet: idSet,
|
||||
workflowsByFolderId: byFolderId,
|
||||
childFoldersByParentId: childrenByParent,
|
||||
}
|
||||
}, [workflows, folders, workspaceId])
|
||||
|
||||
/**
|
||||
* Count workflows in a folder and all its subfolders recursively.
|
||||
* Uses pre-computed maps for efficient lookups.
|
||||
*/
|
||||
const countWorkflowsInFolder = useCallback(
|
||||
(folderId: string): number => {
|
||||
let count = workflowsByFolderId.get(folderId) || 0
|
||||
|
||||
const childFolders = childFoldersByParentId.get(folderId)
|
||||
if (childFolders) {
|
||||
for (const childId of childFolders) {
|
||||
count += countWorkflowsInFolder(childId)
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
},
|
||||
[workflowsByFolderId, childFoldersByParentId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the given workflow IDs can be deleted.
|
||||
* Returns false if deleting would remove all workflows from the workspace.
|
||||
*/
|
||||
const canDeleteWorkflows = useCallback(
|
||||
(workflowIds: string[]): boolean => {
|
||||
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
|
||||
|
||||
// Must have at least one workflow remaining after deletion
|
||||
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
|
||||
},
|
||||
[totalWorkflows, workflowIdSet]
|
||||
)
|
||||
|
||||
/**
|
||||
* Check if the given folder can be deleted.
|
||||
* Empty folders are always deletable. Folders containing all workspace workflows are not.
|
||||
*/
|
||||
const canDeleteFolder = useCallback(
|
||||
(folderId: string): boolean => {
|
||||
const workflowsInFolder = countWorkflowsInFolder(folderId)
|
||||
|
||||
if (workflowsInFolder === 0) return true
|
||||
return workflowsInFolder < totalWorkflows
|
||||
},
|
||||
[totalWorkflows, countWorkflowsInFolder]
|
||||
)
|
||||
|
||||
return {
|
||||
canDeleteWorkflows,
|
||||
canDeleteFolder,
|
||||
totalWorkflows,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,6 @@ export const KnowledgeBlock: BlockConfig = {
|
||||
type: 'knowledge-tag-filters',
|
||||
placeholder: 'Add tag filters',
|
||||
condition: { field: 'operation', value: 'search' },
|
||||
mode: 'advanced',
|
||||
},
|
||||
{
|
||||
id: 'documentId',
|
||||
|
||||
409
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal file
409
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* 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 { Button } from '@/components/emcn/components/button/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from '@/components/emcn/components/popover/popover'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
|
||||
/**
|
||||
* 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 variant='active' className='w-full' 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> {}
|
||||
|
||||
@@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn'
|
||||
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
|
||||
*/
|
||||
const POPOVER_ITEM_BASE_CLASSES =
|
||||
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75 disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
'flex h-[25px] min-w-0 cursor-pointer items-center gap-[8px] rounded-[6px] px-[6px] font-base text-[var(--text-primary)] text-[12px] transition-colors duration-75 dark:text-[var(--text-primary)] [&_svg]:transition-colors [&_svg]:duration-75'
|
||||
|
||||
/**
|
||||
* Variant-specific active state styles for popover items.
|
||||
@@ -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}
|
||||
@@ -425,7 +431,10 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
* ```
|
||||
*/
|
||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
({ className, active, rootOnly, disabled, showCheck = false, children, ...props }, ref) => {
|
||||
(
|
||||
{ className, active, rootOnly, disabled, showCheck = false, children, onClick, ...props },
|
||||
ref
|
||||
) => {
|
||||
// Try to get context - if not available, we're outside Popover (shouldn't happen)
|
||||
const context = React.useContext(PopoverContext)
|
||||
const variant = context?.variant || 'default'
|
||||
@@ -435,18 +444,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
||||
return null
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (disabled) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
POPOVER_ITEM_BASE_CLASSES,
|
||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
||||
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
||||
!disabled &&
|
||||
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
|
||||
disabled && 'cursor-default opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
role='menuitem'
|
||||
aria-selected={active}
|
||||
aria-disabled={disabled}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
@@ -707,8 +726,10 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
setSearchQuery('')
|
||||
onValueChange?.('')
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
}, [setSearchQuery, onValueChange])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
|
||||
|
||||
567
apps/sim/executor/dag/construction/edges.test.ts
Normal file
567
apps/sim/executor/dag/construction/edges.test.ts
Normal file
@@ -0,0 +1,567 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||
import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types'
|
||||
import { EdgeConstructor } from './edges'
|
||||
|
||||
vi.mock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
function createMockBlock(id: string, type = 'function', config: any = {}): SerializedBlock {
|
||||
return {
|
||||
id,
|
||||
metadata: { id: type, name: `Block ${id}` },
|
||||
position: { x: 0, y: 0 },
|
||||
config: { tool: type, params: config },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockNode(id: string): DAGNode {
|
||||
return {
|
||||
id,
|
||||
block: createMockBlock(id),
|
||||
outgoingEdges: new Map(),
|
||||
incomingEdges: new Set(),
|
||||
metadata: {},
|
||||
}
|
||||
}
|
||||
|
||||
function createMockDAG(nodeIds: string[]): DAG {
|
||||
const nodes = new Map<string, DAGNode>()
|
||||
for (const id of nodeIds) {
|
||||
nodes.set(id, createMockNode(id))
|
||||
}
|
||||
return {
|
||||
nodes,
|
||||
loopConfigs: new Map(),
|
||||
parallelConfigs: new Map(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockWorkflow(
|
||||
blocks: SerializedBlock[],
|
||||
connections: Array<{
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
}>,
|
||||
loops: Record<string, SerializedLoop> = {},
|
||||
parallels: Record<string, any> = {}
|
||||
): SerializedWorkflow {
|
||||
return {
|
||||
version: '1',
|
||||
blocks,
|
||||
connections,
|
||||
loops,
|
||||
parallels,
|
||||
}
|
||||
}
|
||||
|
||||
describe('EdgeConstructor', () => {
|
||||
let edgeConstructor: EdgeConstructor
|
||||
|
||||
beforeEach(() => {
|
||||
edgeConstructor = new EdgeConstructor()
|
||||
})
|
||||
|
||||
describe('Edge ID generation (bug fix verification)', () => {
|
||||
it('should generate unique edge IDs for multiple edges to same target with different handles', () => {
|
||||
const conditionId = 'condition-1'
|
||||
const targetId = 'target-1'
|
||||
|
||||
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||
conditions: JSON.stringify([
|
||||
{ id: 'if-id', label: 'if', condition: 'true' },
|
||||
{ id: 'else-id', label: 'else', condition: '' },
|
||||
]),
|
||||
})
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[conditionBlock, createMockBlock(targetId)],
|
||||
[
|
||||
{ source: conditionId, target: targetId, sourceHandle: 'condition-if-id' },
|
||||
{ source: conditionId, target: targetId, sourceHandle: 'condition-else-id' },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([conditionId, targetId])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([conditionId, targetId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const conditionNode = dag.nodes.get(conditionId)!
|
||||
|
||||
// Should have 2 edges, not 1 (the bug was that they would overwrite each other)
|
||||
expect(conditionNode.outgoingEdges.size).toBe(2)
|
||||
|
||||
// Verify edge IDs are unique and include the sourceHandle
|
||||
const edgeIds = Array.from(conditionNode.outgoingEdges.keys())
|
||||
expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-if-id`)
|
||||
expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-else-id`)
|
||||
})
|
||||
|
||||
it('should generate edge ID without handle suffix when no sourceHandle', () => {
|
||||
const sourceId = 'source-1'
|
||||
const targetId = 'target-1'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(sourceId), createMockBlock(targetId)],
|
||||
[{ source: sourceId, target: targetId }]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([sourceId, targetId])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([sourceId, targetId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const sourceNode = dag.nodes.get(sourceId)!
|
||||
const edgeIds = Array.from(sourceNode.outgoingEdges.keys())
|
||||
|
||||
expect(edgeIds).toContain(`${sourceId}→${targetId}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Condition block edge wiring', () => {
|
||||
it('should wire condition block edges with proper condition prefixes', () => {
|
||||
const conditionId = 'condition-1'
|
||||
const target1Id = 'target-1'
|
||||
const target2Id = 'target-2'
|
||||
|
||||
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||
conditions: JSON.stringify([
|
||||
{ id: 'cond-if', label: 'if', condition: 'x > 5' },
|
||||
{ id: 'cond-else', label: 'else', condition: '' },
|
||||
]),
|
||||
})
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[conditionBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
|
||||
[
|
||||
{ source: conditionId, target: target1Id, sourceHandle: 'condition-cond-if' },
|
||||
{ source: conditionId, target: target2Id, sourceHandle: 'condition-cond-else' },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([conditionId, target1Id, target2Id])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([conditionId, target1Id, target2Id]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const conditionNode = dag.nodes.get(conditionId)!
|
||||
|
||||
expect(conditionNode.outgoingEdges.size).toBe(2)
|
||||
|
||||
// Verify edges have correct targets and handles
|
||||
const edges = Array.from(conditionNode.outgoingEdges.values())
|
||||
const ifEdge = edges.find((e) => e.sourceHandle === 'condition-cond-if')
|
||||
const elseEdge = edges.find((e) => e.sourceHandle === 'condition-cond-else')
|
||||
|
||||
expect(ifEdge?.target).toBe(target1Id)
|
||||
expect(elseEdge?.target).toBe(target2Id)
|
||||
})
|
||||
|
||||
it('should handle condition block with if→A, elseif→B, else→A pattern', () => {
|
||||
const conditionId = 'condition-1'
|
||||
const targetAId = 'target-a'
|
||||
const targetBId = 'target-b'
|
||||
|
||||
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||
conditions: JSON.stringify([
|
||||
{ id: 'if-id', label: 'if', condition: 'x == 1' },
|
||||
{ id: 'elseif-id', label: 'else if', condition: 'x == 2' },
|
||||
{ id: 'else-id', label: 'else', condition: '' },
|
||||
]),
|
||||
})
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[conditionBlock, createMockBlock(targetAId), createMockBlock(targetBId)],
|
||||
[
|
||||
{ source: conditionId, target: targetAId, sourceHandle: 'condition-if-id' },
|
||||
{ source: conditionId, target: targetBId, sourceHandle: 'condition-elseif-id' },
|
||||
{ source: conditionId, target: targetAId, sourceHandle: 'condition-else-id' },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([conditionId, targetAId, targetBId])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([conditionId, targetAId, targetBId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const conditionNode = dag.nodes.get(conditionId)!
|
||||
|
||||
// Should have 3 edges (if→A, elseif→B, else→A)
|
||||
expect(conditionNode.outgoingEdges.size).toBe(3)
|
||||
|
||||
// Target A should have 2 incoming edges (from if and else)
|
||||
const targetANode = dag.nodes.get(targetAId)!
|
||||
expect(targetANode.incomingEdges.has(conditionId)).toBe(true)
|
||||
|
||||
// Target B should have 1 incoming edge (from elseif)
|
||||
const targetBNode = dag.nodes.get(targetBId)!
|
||||
expect(targetBNode.incomingEdges.has(conditionId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Router block edge wiring', () => {
|
||||
it('should wire router block edges with router prefix', () => {
|
||||
const routerId = 'router-1'
|
||||
const target1Id = 'target-1'
|
||||
const target2Id = 'target-2'
|
||||
|
||||
const routerBlock = createMockBlock(routerId, 'router')
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
|
||||
[
|
||||
{ source: routerId, target: target1Id },
|
||||
{ source: routerId, target: target2Id },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([routerId, target1Id, target2Id])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([routerId, target1Id, target2Id]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const routerNode = dag.nodes.get(routerId)!
|
||||
const edges = Array.from(routerNode.outgoingEdges.values())
|
||||
|
||||
// Router edges should have router- prefix with target ID
|
||||
expect(edges[0].sourceHandle).toBe(`router-${target1Id}`)
|
||||
expect(edges[1].sourceHandle).toBe(`router-${target2Id}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Simple linear workflow', () => {
|
||||
it('should wire linear workflow correctly', () => {
|
||||
const block1Id = 'block-1'
|
||||
const block2Id = 'block-2'
|
||||
const block3Id = 'block-3'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(block3Id)],
|
||||
[
|
||||
{ source: block1Id, target: block2Id },
|
||||
{ source: block2Id, target: block3Id },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([block1Id, block2Id, block3Id])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([block1Id, block2Id, block3Id]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Block 1 → Block 2
|
||||
const block1Node = dag.nodes.get(block1Id)!
|
||||
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
|
||||
|
||||
// Block 2 → Block 3
|
||||
const block2Node = dag.nodes.get(block2Id)!
|
||||
expect(block2Node.outgoingEdges.size).toBe(1)
|
||||
expect(Array.from(block2Node.outgoingEdges.values())[0].target).toBe(block3Id)
|
||||
expect(block2Node.incomingEdges.has(block1Id)).toBe(true)
|
||||
|
||||
// Block 3 has incoming from Block 2
|
||||
const block3Node = dag.nodes.get(block3Id)!
|
||||
expect(block3Node.incomingEdges.has(block2Id)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge reachability', () => {
|
||||
it('should not wire edges to blocks not in DAG nodes', () => {
|
||||
const block1Id = 'block-1'
|
||||
const block2Id = 'block-2'
|
||||
const unreachableId = 'unreachable'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(unreachableId)],
|
||||
[
|
||||
{ source: block1Id, target: block2Id },
|
||||
{ source: block1Id, target: unreachableId },
|
||||
]
|
||||
)
|
||||
|
||||
// Only create DAG nodes for block1 and block2 (not unreachable)
|
||||
const dag = createMockDAG([block1Id, block2Id])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([block1Id, block2Id]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const block1Node = dag.nodes.get(block1Id)!
|
||||
|
||||
// Should only have edge to block2, not unreachable (not in DAG)
|
||||
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
|
||||
})
|
||||
|
||||
it('should check both reachableBlocks and dag.nodes for edge validity', () => {
|
||||
const block1Id = 'block-1'
|
||||
const block2Id = 'block-2'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(block1Id), createMockBlock(block2Id)],
|
||||
[{ source: block1Id, target: block2Id }]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([block1Id, block2Id])
|
||||
|
||||
// Block2 exists in DAG but not in reachableBlocks - edge should still be wired
|
||||
// because isEdgeReachable checks: reachableBlocks.has(target) || dag.nodes.has(target)
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([block1Id]), // Only block1 is "reachable" but block2 exists in DAG
|
||||
new Map()
|
||||
)
|
||||
|
||||
const block1Node = dag.nodes.get(block1Id)!
|
||||
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error edge handling', () => {
|
||||
it('should preserve error sourceHandle', () => {
|
||||
const sourceId = 'source-1'
|
||||
const successTargetId = 'success-target'
|
||||
const errorTargetId = 'error-target'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[
|
||||
createMockBlock(sourceId),
|
||||
createMockBlock(successTargetId),
|
||||
createMockBlock(errorTargetId),
|
||||
],
|
||||
[
|
||||
{ source: sourceId, target: successTargetId, sourceHandle: 'source' },
|
||||
{ source: sourceId, target: errorTargetId, sourceHandle: 'error' },
|
||||
]
|
||||
)
|
||||
|
||||
const dag = createMockDAG([sourceId, successTargetId, errorTargetId])
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set(),
|
||||
new Set([sourceId, successTargetId, errorTargetId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
const sourceNode = dag.nodes.get(sourceId)!
|
||||
const edges = Array.from(sourceNode.outgoingEdges.values())
|
||||
|
||||
const successEdge = edges.find((e) => e.target === successTargetId)
|
||||
const errorEdge = edges.find((e) => e.target === errorTargetId)
|
||||
|
||||
expect(successEdge?.sourceHandle).toBe('source')
|
||||
expect(errorEdge?.sourceHandle).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Loop sentinel wiring', () => {
|
||||
it('should wire loop sentinels to nodes with no incoming edges from within loop', () => {
|
||||
const loopId = 'loop-1'
|
||||
const nodeInLoopId = 'node-in-loop'
|
||||
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
||||
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
||||
|
||||
// Create DAG with sentinels - nodeInLoop has no incoming edges from loop nodes
|
||||
// so it will be identified as a start node
|
||||
const dag = createMockDAG([nodeInLoopId, sentinelStartId, sentinelEndId])
|
||||
dag.loopConfigs.set(loopId, {
|
||||
id: loopId,
|
||||
nodes: [nodeInLoopId],
|
||||
iterations: 5,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop)
|
||||
|
||||
const workflow = createMockWorkflow([createMockBlock(nodeInLoopId)], [], {
|
||||
[loopId]: {
|
||||
id: loopId,
|
||||
nodes: [nodeInLoopId],
|
||||
iterations: 5,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop,
|
||||
})
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set([nodeInLoopId]),
|
||||
new Set([nodeInLoopId, sentinelStartId, sentinelEndId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Sentinel start should have edge to node in loop (it's a start node - no incoming from loop)
|
||||
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
|
||||
expect(sentinelStartNode.outgoingEdges.size).toBe(1)
|
||||
const startEdge = Array.from(sentinelStartNode.outgoingEdges.values())[0]
|
||||
expect(startEdge.target).toBe(nodeInLoopId)
|
||||
|
||||
// Node in loop should have edge to sentinel end (it's a terminal node - no outgoing to loop)
|
||||
const nodeInLoopNode = dag.nodes.get(nodeInLoopId)!
|
||||
const hasEdgeToEnd = Array.from(nodeInLoopNode.outgoingEdges.values()).some(
|
||||
(e) => e.target === sentinelEndId
|
||||
)
|
||||
expect(hasEdgeToEnd).toBe(true)
|
||||
|
||||
// Sentinel end should have loop_continue edge back to start
|
||||
const sentinelEndNode = dag.nodes.get(sentinelEndId)!
|
||||
const continueEdge = Array.from(sentinelEndNode.outgoingEdges.values()).find(
|
||||
(e) => e.sourceHandle === 'loop_continue'
|
||||
)
|
||||
expect(continueEdge?.target).toBe(sentinelStartId)
|
||||
})
|
||||
|
||||
it('should identify multiple start and terminal nodes in loop', () => {
|
||||
const loopId = 'loop-1'
|
||||
const node1Id = 'node-1'
|
||||
const node2Id = 'node-2'
|
||||
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
||||
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
||||
|
||||
// Create DAG with two nodes in loop - both are start and terminal (no edges between them)
|
||||
const dag = createMockDAG([node1Id, node2Id, sentinelStartId, sentinelEndId])
|
||||
dag.loopConfigs.set(loopId, {
|
||||
id: loopId,
|
||||
nodes: [node1Id, node2Id],
|
||||
iterations: 3,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop)
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(node1Id), createMockBlock(node2Id)],
|
||||
[],
|
||||
{
|
||||
[loopId]: {
|
||||
id: loopId,
|
||||
nodes: [node1Id, node2Id],
|
||||
iterations: 3,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop,
|
||||
}
|
||||
)
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set([node1Id, node2Id]),
|
||||
new Set([node1Id, node2Id, sentinelStartId, sentinelEndId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Sentinel start should have edges to both nodes (both are start nodes)
|
||||
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
|
||||
expect(sentinelStartNode.outgoingEdges.size).toBe(2)
|
||||
|
||||
// Both nodes should have edges to sentinel end (both are terminal nodes)
|
||||
const node1 = dag.nodes.get(node1Id)!
|
||||
const node2 = dag.nodes.get(node2Id)!
|
||||
expect(Array.from(node1.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
|
||||
true
|
||||
)
|
||||
expect(Array.from(node2.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cross-loop boundary detection', () => {
|
||||
it('should not wire edges that cross loop boundaries', () => {
|
||||
const outsideId = 'outside'
|
||||
const insideId = 'inside'
|
||||
const loopId = 'loop-1'
|
||||
|
||||
const workflow = createMockWorkflow(
|
||||
[createMockBlock(outsideId), createMockBlock(insideId)],
|
||||
[{ source: outsideId, target: insideId }],
|
||||
{
|
||||
[loopId]: {
|
||||
id: loopId,
|
||||
nodes: [insideId],
|
||||
iterations: 5,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop,
|
||||
}
|
||||
)
|
||||
|
||||
const dag = createMockDAG([outsideId, insideId])
|
||||
dag.loopConfigs.set(loopId, {
|
||||
id: loopId,
|
||||
nodes: [insideId],
|
||||
iterations: 5,
|
||||
loopType: 'for',
|
||||
} as SerializedLoop)
|
||||
|
||||
edgeConstructor.execute(
|
||||
workflow,
|
||||
dag,
|
||||
new Set(),
|
||||
new Set([insideId]),
|
||||
new Set([outsideId, insideId]),
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Edge should not be wired because it crosses loop boundary
|
||||
const outsideNode = dag.nodes.get(outsideId)!
|
||||
expect(outsideNode.outgoingEdges.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -578,7 +578,7 @@ export class EdgeConstructor {
|
||||
return
|
||||
}
|
||||
|
||||
const edgeId = `${sourceId}→${targetId}`
|
||||
const edgeId = `${sourceId}→${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
|
||||
|
||||
sourceNode.outgoingEdges.set(edgeId, {
|
||||
target: targetId,
|
||||
|
||||
1052
apps/sim/executor/execution/edge-manager.test.ts
Normal file
1052
apps/sim/executor/execution/edge-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,10 @@ export class EdgeManager {
|
||||
): string[] {
|
||||
const readyNodes: string[] = []
|
||||
const activatedTargets: string[] = []
|
||||
const edgesToDeactivate: Array<{ target: string; handle?: string }> = []
|
||||
|
||||
// First pass: categorize edges as activating or deactivating
|
||||
// Don't modify incomingEdges yet - we need the original state for deactivation checks
|
||||
for (const [edgeId, edge] of node.outgoingEdges) {
|
||||
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
||||
continue
|
||||
@@ -32,23 +35,31 @@ export class EdgeManager {
|
||||
edge.sourceHandle === EDGE.LOOP_EXIT
|
||||
|
||||
if (!isLoopEdge) {
|
||||
this.deactivateEdgeAndDescendants(node.id, edge.target, edge.sourceHandle)
|
||||
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const targetNode = this.dag.nodes.get(edge.target)
|
||||
if (!targetNode) {
|
||||
logger.warn('Target node not found', { target: edge.target })
|
||||
continue
|
||||
}
|
||||
|
||||
targetNode.incomingEdges.delete(node.id)
|
||||
activatedTargets.push(edge.target)
|
||||
}
|
||||
|
||||
// Check readiness after all edges processed to ensure cascade deactivations are complete
|
||||
// Second pass: process deactivations while incomingEdges is still intact
|
||||
// This ensures hasActiveIncomingEdges can find all potential sources
|
||||
for (const { target, handle } of edgesToDeactivate) {
|
||||
this.deactivateEdgeAndDescendants(node.id, target, handle)
|
||||
}
|
||||
|
||||
// Third pass: update incomingEdges for activated targets
|
||||
for (const targetId of activatedTargets) {
|
||||
const targetNode = this.dag.nodes.get(targetId)
|
||||
if (!targetNode) {
|
||||
logger.warn('Target node not found', { target: targetId })
|
||||
continue
|
||||
}
|
||||
targetNode.incomingEdges.delete(node.id)
|
||||
}
|
||||
|
||||
// Fourth pass: check readiness after all edge processing is complete
|
||||
for (const targetId of activatedTargets) {
|
||||
const targetNode = this.dag.nodes.get(targetId)
|
||||
if (targetNode && this.isNodeReady(targetNode)) {
|
||||
@@ -162,7 +173,10 @@ export class EdgeManager {
|
||||
const targetNode = this.dag.nodes.get(targetId)
|
||||
if (!targetNode) return
|
||||
|
||||
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, sourceId)
|
||||
// Check if target has other active incoming edges
|
||||
// Pass the specific edge key being deactivated, not just source ID,
|
||||
// to handle multiple edges from same source to same target (e.g., condition branches)
|
||||
const hasOtherActiveIncoming = this.hasActiveIncomingEdges(targetNode, edgeKey)
|
||||
if (!hasOtherActiveIncoming) {
|
||||
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
|
||||
this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle)
|
||||
@@ -170,10 +184,13 @@ export class EdgeManager {
|
||||
}
|
||||
}
|
||||
|
||||
private hasActiveIncomingEdges(node: DAGNode, excludeSourceId: string): boolean {
|
||||
/**
|
||||
* Checks if a node has any active incoming edges besides the one being excluded.
|
||||
* This properly handles the case where multiple edges from the same source go to
|
||||
* the same target (e.g., multiple condition branches pointing to one block).
|
||||
*/
|
||||
private hasActiveIncomingEdges(node: DAGNode, excludeEdgeKey: string): boolean {
|
||||
for (const incomingSourceId of node.incomingEdges) {
|
||||
if (incomingSourceId === excludeSourceId) continue
|
||||
|
||||
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
||||
if (!incomingNode) continue
|
||||
|
||||
@@ -184,6 +201,8 @@ export class EdgeManager {
|
||||
node.id,
|
||||
incomingEdge.sourceHandle
|
||||
)
|
||||
// Skip the specific edge being excluded, but check other edges from same source
|
||||
if (incomingEdgeKey === excludeEdgeKey) continue
|
||||
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -21,9 +21,18 @@ vi.mock('@/tools', () => ({
|
||||
executeTool: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/executor/utils/block-data', () => ({
|
||||
collectBlockData: vi.fn(() => ({
|
||||
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
|
||||
blockNameMapping: { 'Source Block': 'source-block-1' },
|
||||
})),
|
||||
}))
|
||||
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
|
||||
const mockCollectBlockData = collectBlockData as ReturnType<typeof vi.fn>
|
||||
|
||||
/**
|
||||
* Simulates what the function_execute tool does when evaluating condition code
|
||||
@@ -34,8 +43,6 @@ function simulateConditionExecution(code: string): {
|
||||
error?: string
|
||||
} {
|
||||
try {
|
||||
// The code is in format: "const context = {...};\nreturn Boolean(...)"
|
||||
// We need to execute it and return the result
|
||||
const fn = new Function(code)
|
||||
const result = fn()
|
||||
return { success: true, output: { result } }
|
||||
@@ -55,8 +62,6 @@ describe('ConditionBlockHandler', () => {
|
||||
let mockSourceBlock: SerializedBlock
|
||||
let mockTargetBlock1: SerializedBlock
|
||||
let mockTargetBlock2: SerializedBlock
|
||||
let mockResolver: any
|
||||
let mockPathTracker: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockSourceBlock = {
|
||||
@@ -113,18 +118,11 @@ describe('ConditionBlockHandler', () => {
|
||||
],
|
||||
}
|
||||
|
||||
mockResolver = {
|
||||
resolveVariableReferences: vi.fn((expr) => expr),
|
||||
resolveBlockReferences: vi.fn((expr) => expr),
|
||||
resolveEnvVariables: vi.fn((expr) => expr),
|
||||
}
|
||||
|
||||
mockPathTracker = {}
|
||||
|
||||
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
|
||||
handler = new ConditionBlockHandler()
|
||||
|
||||
mockContext = {
|
||||
workflowId: 'test-workflow-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
blockStates: new Map<string, BlockState>([
|
||||
[
|
||||
mockSourceBlock.id,
|
||||
@@ -137,7 +135,8 @@ describe('ConditionBlockHandler', () => {
|
||||
]),
|
||||
blockLogs: [],
|
||||
metadata: { duration: 0 },
|
||||
environmentVariables: {},
|
||||
environmentVariables: { API_KEY: 'test-key' },
|
||||
workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } },
|
||||
decisions: { router: new Map(), condition: new Map() },
|
||||
loopExecutions: new Map(),
|
||||
executedBlocks: new Set([mockSourceBlock.id]),
|
||||
@@ -178,26 +177,41 @@ describe('ConditionBlockHandler', () => {
|
||||
selectedOption: 'cond1',
|
||||
}
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value > 5')
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'context.value > 5',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'context.value > 5',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value > 5')
|
||||
expect(result).toEqual(expectedOutput)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should pass correct parameters to function_execute tool', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockExecuteTool).toHaveBeenCalledWith(
|
||||
'function_execute',
|
||||
expect.objectContaining({
|
||||
code: expect.stringContaining('context.value > 5'),
|
||||
timeout: 5000,
|
||||
envVars: mockContext.environmentVariables,
|
||||
workflowVariables: mockContext.workflowVariables,
|
||||
blockData: { 'source-block-1': { value: 10, text: 'hello' } },
|
||||
blockNameMapping: { 'Source Block': 'source-block-1' },
|
||||
_context: {
|
||||
workflowId: 'test-workflow-id',
|
||||
workspaceId: 'test-workspace-id',
|
||||
},
|
||||
}),
|
||||
false,
|
||||
false,
|
||||
mockContext
|
||||
)
|
||||
})
|
||||
|
||||
it('should select the else path if other conditions fail', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value < 0' }, // Should fail (10 < 0 is false)
|
||||
@@ -217,22 +231,8 @@ describe('ConditionBlockHandler', () => {
|
||||
selectedOption: 'else1',
|
||||
}
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.value < 0')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.value < 0')
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'context.value < 0',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'context.value < 0',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('context.value < 0')
|
||||
expect(result).toEqual(expectedOutput)
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
||||
})
|
||||
@@ -245,101 +245,6 @@ describe('ConditionBlockHandler', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should resolve references in conditions before evaluation', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '{{source-block-1.value}} > 5' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{source-block-1.value}} > 5')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('10 > 5')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('10 > 5')
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'{{source-block-1.value}} > 5',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'{{source-block-1.value}} > 5',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('10 > 5')
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should resolve variable references in conditions', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '<variable.userName> !== null' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('"john" !== null')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"john" !== null')
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'<variable.userName> !== null',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'"john" !== null',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('"john" !== null')
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should resolve environment variables in conditions', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '{{POOP}} === "hi"' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('{{POOP}} === "hi"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('"hi" === "hi"')
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockResolver.resolveVariableReferences).toHaveBeenCalledWith(
|
||||
'{{POOP}} === "hi"',
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveBlockReferences).toHaveBeenCalledWith(
|
||||
'{{POOP}} === "hi"',
|
||||
mockContext,
|
||||
mockBlock
|
||||
)
|
||||
expect(mockResolver.resolveEnvVariables).toHaveBeenCalledWith('{{POOP}} === "hi"')
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should throw error if reference resolution fails', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: '{{invalid-ref}}' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const resolutionError = new Error('Could not resolve reference: invalid-ref')
|
||||
mockResolver.resolveVariableReferences.mockImplementation(() => {
|
||||
throw resolutionError
|
||||
})
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
'Failed to resolve references in condition: Could not resolve reference: invalid-ref'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle evaluation errors gracefully', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
|
||||
@@ -347,12 +252,6 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue(
|
||||
'context.nonExistentProperty.doSomething()'
|
||||
)
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.nonExistentProperty.doSomething()')
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
/Evaluation error in condition "if".*doSomething/
|
||||
)
|
||||
@@ -367,10 +266,6 @@ describe('ConditionBlockHandler', () => {
|
||||
blockStates: new Map<string, BlockState>(),
|
||||
}
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('true')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('true')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('true')
|
||||
|
||||
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
|
||||
|
||||
expect(result).toHaveProperty('conditionResult', true)
|
||||
@@ -383,10 +278,6 @@ describe('ConditionBlockHandler', () => {
|
||||
|
||||
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('true')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('true')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('true')
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
`Target block ${mockTargetBlock1.id} not found`
|
||||
)
|
||||
@@ -408,16 +299,6 @@ describe('ConditionBlockHandler', () => {
|
||||
},
|
||||
]
|
||||
|
||||
mockResolver.resolveVariableReferences
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
mockResolver.resolveBlockReferences
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
mockResolver.resolveEnvVariables
|
||||
.mockReturnValueOnce('false')
|
||||
.mockReturnValueOnce('context.value === 99')
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).conditionResult).toBe(false)
|
||||
@@ -433,13 +314,317 @@ describe('ConditionBlockHandler', () => {
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockResolver.resolveVariableReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveBlockReferences.mockReturnValue('context.item === "apple"')
|
||||
mockResolver.resolveEnvVariables.mockReturnValue('context.item === "apple"')
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
})
|
||||
|
||||
it('should use collectBlockData to gather block state', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'true' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
|
||||
})
|
||||
|
||||
it('should handle function_execute tool failure', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockExecuteTool.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Execution timeout',
|
||||
})
|
||||
|
||||
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||
/Evaluation error in condition "if".*Execution timeout/
|
||||
)
|
||||
})
|
||||
|
||||
describe('Multiple branches to same target', () => {
|
||||
it('should handle if and else pointing to same target', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Both branches point to the same target
|
||||
mockContext.workflow!.connections = [
|
||||
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||
]
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
expect((result as any).selectedPath).toEqual({
|
||||
blockId: mockTargetBlock1.id,
|
||||
blockType: 'target',
|
||||
blockTitle: 'Target Block 1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should select else branch to same target when if fails', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// Both branches point to the same target
|
||||
mockContext.workflow!.connections = [
|
||||
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||
]
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
expect((result as any).selectedPath).toEqual({
|
||||
blockId: mockTargetBlock1.id,
|
||||
blockType: 'target',
|
||||
blockTitle: 'Target Block 1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle if→A, elseif→B, else→A pattern', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.value === 1' },
|
||||
{ id: 'cond2', title: 'else if', value: 'context.value === 2' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockContext.workflow!.connections = [
|
||||
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||
]
|
||||
|
||||
// value is 10, so else should be selected (pointing to target 1)
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).conditionResult).toBe(true)
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Condition evaluation with different data types', () => {
|
||||
it('should evaluate string comparison conditions', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { name: 'test', status: 'active' },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.status === "active"' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should evaluate boolean conditions', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { isEnabled: true, count: 5 },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.isEnabled' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should evaluate array length conditions', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { items: [1, 2, 3, 4, 5] },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.items.length > 3' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
|
||||
it('should evaluate null/undefined check conditions', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { data: null },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.data === null' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multiple else-if conditions', () => {
|
||||
it('should evaluate multiple else-if conditions in order', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { score: 75 },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const mockTargetBlock3: SerializedBlock = {
|
||||
id: 'target-block-3',
|
||||
metadata: { id: 'target', name: 'Target Block 3' },
|
||||
position: { x: 100, y: 200 },
|
||||
config: { tool: 'target_tool_3', params: {} },
|
||||
inputs: {},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
mockContext.workflow!.blocks!.push(mockTargetBlock3)
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
|
||||
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
|
||||
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
mockContext.workflow!.connections = [
|
||||
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' },
|
||||
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||
]
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
// Score is 75, so second condition (>=70) should match
|
||||
expect((result as any).selectedOption).toBe('cond2')
|
||||
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
|
||||
})
|
||||
|
||||
it('should skip to else when all else-if fail', async () => {
|
||||
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||
output: { score: 30 },
|
||||
executed: true,
|
||||
executionTime: 100,
|
||||
})
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
|
||||
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
|
||||
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('else1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Condition with no outgoing edge', () => {
|
||||
it('should return null path when condition matches but has no edge', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'true' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
// No connection for cond1
|
||||
mockContext.workflow!.connections = [
|
||||
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
|
||||
]
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
// Condition matches but no edge for it
|
||||
expect((result as any).conditionResult).toBe(false)
|
||||
expect((result as any).selectedPath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Empty conditions handling', () => {
|
||||
it('should handle empty conditions array', async () => {
|
||||
const conditions: unknown[] = []
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).conditionResult).toBe(false)
|
||||
expect((result as any).selectedPath).toBeNull()
|
||||
expect((result as any).selectedOption).toBeNull()
|
||||
})
|
||||
|
||||
it('should handle conditions passed as array directly', async () => {
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'true' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
// Pass as array instead of JSON string
|
||||
const inputs = { conditions }
|
||||
|
||||
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
expect((result as any).selectedOption).toBe('cond1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Virtual block ID handling', () => {
|
||||
it('should use currentVirtualBlockId for decision key when available', async () => {
|
||||
mockContext.currentVirtualBlockId = 'virtual-block-123'
|
||||
|
||||
const conditions = [
|
||||
{ id: 'cond1', title: 'if', value: 'true' },
|
||||
{ id: 'else1', title: 'else', value: '' },
|
||||
]
|
||||
const inputs = { conditions: JSON.stringify(conditions) }
|
||||
|
||||
await handler.execute(mockContext, mockBlock, inputs)
|
||||
|
||||
// Decision should be stored under virtual block ID, not actual block ID
|
||||
expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1')
|
||||
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
||||
import type { BlockOutput } from '@/blocks/types'
|
||||
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
|
||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||
import { collectBlockData } from '@/executor/utils/block-data'
|
||||
import type { SerializedBlock } from '@/serializer/types'
|
||||
import { executeTool } from '@/tools'
|
||||
|
||||
@@ -10,43 +11,32 @@ const logger = createLogger('ConditionBlockHandler')
|
||||
const CONDITION_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Evaluates a single condition expression with variable/block reference resolution
|
||||
* Returns true if condition is met, false otherwise
|
||||
* Evaluates a single condition expression.
|
||||
* Variable resolution is handled consistently with the function block via the function_execute tool.
|
||||
* Returns true if condition is met, false otherwise.
|
||||
*/
|
||||
export async function evaluateConditionExpression(
|
||||
ctx: ExecutionContext,
|
||||
conditionExpression: string,
|
||||
block: SerializedBlock,
|
||||
resolver: any,
|
||||
providedEvalContext?: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
const evalContext = providedEvalContext || {}
|
||||
|
||||
let resolvedConditionValue = conditionExpression
|
||||
try {
|
||||
if (resolver) {
|
||||
const resolvedVars = resolver.resolveVariableReferences(conditionExpression, block)
|
||||
const resolvedRefs = resolver.resolveBlockReferences(resolvedVars, ctx, block)
|
||||
resolvedConditionValue = resolver.resolveEnvVariables(resolvedRefs)
|
||||
}
|
||||
} catch (resolveError: any) {
|
||||
logger.error(`Failed to resolve references in condition: ${resolveError.message}`, {
|
||||
conditionExpression,
|
||||
resolveError,
|
||||
})
|
||||
throw new Error(`Failed to resolve references in condition: ${resolveError.message}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
||||
const code = `${contextSetup}\nreturn Boolean(${resolvedConditionValue})`
|
||||
const code = `${contextSetup}\nreturn Boolean(${conditionExpression})`
|
||||
|
||||
const { blockData, blockNameMapping } = collectBlockData(ctx)
|
||||
|
||||
const result = await executeTool(
|
||||
'function_execute',
|
||||
{
|
||||
code,
|
||||
timeout: CONDITION_TIMEOUT_MS,
|
||||
envVars: {},
|
||||
envVars: ctx.environmentVariables || {},
|
||||
workflowVariables: ctx.workflowVariables || {},
|
||||
blockData,
|
||||
blockNameMapping,
|
||||
_context: {
|
||||
workflowId: ctx.workflowId,
|
||||
workspaceId: ctx.workspaceId,
|
||||
@@ -60,26 +50,20 @@ export async function evaluateConditionExpression(
|
||||
if (!result.success) {
|
||||
logger.error(`Failed to evaluate condition: ${result.error}`, {
|
||||
originalCondition: conditionExpression,
|
||||
resolvedCondition: resolvedConditionValue,
|
||||
evalContext,
|
||||
error: result.error,
|
||||
})
|
||||
throw new Error(
|
||||
`Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})`
|
||||
)
|
||||
throw new Error(`Evaluation error in condition: ${result.error}`)
|
||||
}
|
||||
|
||||
return Boolean(result.output?.result)
|
||||
} catch (evalError: any) {
|
||||
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
||||
originalCondition: conditionExpression,
|
||||
resolvedCondition: resolvedConditionValue,
|
||||
evalContext,
|
||||
evalError,
|
||||
})
|
||||
throw new Error(
|
||||
`Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})`
|
||||
)
|
||||
throw new Error(`Evaluation error in condition: ${evalError.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,11 +71,6 @@ export async function evaluateConditionExpression(
|
||||
* Handler for Condition blocks that evaluate expressions to determine execution paths.
|
||||
*/
|
||||
export class ConditionBlockHandler implements BlockHandler {
|
||||
constructor(
|
||||
private pathTracker?: any,
|
||||
private resolver?: any
|
||||
) {}
|
||||
|
||||
canHandle(block: SerializedBlock): boolean {
|
||||
return block.metadata?.id === BlockType.CONDITION
|
||||
}
|
||||
@@ -104,7 +83,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
const conditions = this.parseConditions(inputs.conditions)
|
||||
|
||||
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
|
||||
const evalContext = this.buildEvaluationContext(ctx, block.id, sourceBlockId)
|
||||
const evalContext = this.buildEvaluationContext(ctx, sourceBlockId)
|
||||
const sourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
|
||||
|
||||
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
|
||||
@@ -113,8 +92,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
conditions,
|
||||
outgoingConnections || [],
|
||||
evalContext,
|
||||
ctx,
|
||||
block
|
||||
ctx
|
||||
)
|
||||
|
||||
if (!selectedConnection || !selectedCondition) {
|
||||
@@ -158,7 +136,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
|
||||
private buildEvaluationContext(
|
||||
ctx: ExecutionContext,
|
||||
blockId: string,
|
||||
sourceBlockId?: string
|
||||
): Record<string, any> {
|
||||
let evalContext: Record<string, any> = {}
|
||||
@@ -180,8 +157,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
conditions: Array<{ id: string; title: string; value: string }>,
|
||||
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
|
||||
evalContext: Record<string, any>,
|
||||
ctx: ExecutionContext,
|
||||
block: SerializedBlock
|
||||
ctx: ExecutionContext
|
||||
): Promise<{
|
||||
selectedConnection: { target: string; sourceHandle?: string } | null
|
||||
selectedCondition: { id: string; title: string; value: string } | null
|
||||
@@ -200,8 +176,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
const conditionMet = await evaluateConditionExpression(
|
||||
ctx,
|
||||
conditionValueString,
|
||||
block,
|
||||
this.resolver,
|
||||
evalContext
|
||||
)
|
||||
|
||||
@@ -211,13 +185,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
return { selectedConnection: connection, selectedCondition: condition }
|
||||
}
|
||||
// Condition is true but has no outgoing edge - branch ends gracefully
|
||||
logger.info(
|
||||
`Condition "${condition.title}" is true but has no outgoing edge - branch ending`,
|
||||
{
|
||||
blockId: block.id,
|
||||
conditionId: condition.id,
|
||||
}
|
||||
)
|
||||
return { selectedConnection: null, selectedCondition: null }
|
||||
}
|
||||
} catch (error: any) {
|
||||
@@ -228,18 +195,13 @@ export class ConditionBlockHandler implements BlockHandler {
|
||||
|
||||
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
|
||||
if (elseCondition) {
|
||||
logger.warn(`No condition met, selecting 'else' path`, { blockId: block.id })
|
||||
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
|
||||
if (elseConnection) {
|
||||
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
|
||||
}
|
||||
logger.info(`No condition matched and else has no connection - branch ending`, {
|
||||
blockId: block.id,
|
||||
})
|
||||
return { selectedConnection: null, selectedCondition: null }
|
||||
}
|
||||
|
||||
logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id })
|
||||
return { selectedConnection: null, selectedCondition: null }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { env } from './lib/core/config/env'
|
||||
import { sanitizeEventData } from './lib/core/security/redaction'
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
||||
@@ -41,37 +42,6 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize event data to remove sensitive information
|
||||
*/
|
||||
function sanitizeEvent(event: any): any {
|
||||
const patterns = ['password', 'token', 'secret', 'key', 'auth', 'credential', 'private']
|
||||
const sensitiveRe = new RegExp(patterns.join('|'), 'i')
|
||||
|
||||
const scrubString = (s: string) => (s && sensitiveRe.test(s) ? '[redacted]' : s)
|
||||
|
||||
if (event == null) return event
|
||||
if (typeof event === 'string') return scrubString(event)
|
||||
if (typeof event !== 'object') return event
|
||||
|
||||
if (Array.isArray(event)) {
|
||||
return event.map((item) => sanitizeEvent(item))
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
if (patterns.some((p) => lowerKey.includes(p))) continue
|
||||
|
||||
if (typeof value === 'string') sanitized[key] = scrubString(value)
|
||||
else if (Array.isArray(value)) sanitized[key] = value.map((v) => sanitizeEvent(v))
|
||||
else if (value && typeof value === 'object') sanitized[key] = sanitizeEvent(value)
|
||||
else sanitized[key] = value
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush batch of events to server
|
||||
*/
|
||||
@@ -84,7 +54,7 @@ if (typeof window !== 'undefined') {
|
||||
batchTimer = null
|
||||
}
|
||||
|
||||
const sanitizedBatch = batch.map(sanitizeEvent)
|
||||
const sanitizedBatch = batch.map(sanitizeEventData)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
category: 'batch',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createPinnedUrl,
|
||||
sanitizeForLogging,
|
||||
validateAlphanumericId,
|
||||
validateEnum,
|
||||
validateFileExtension,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
validateUrlWithDNS,
|
||||
validateUUID,
|
||||
} from '@/lib/core/security/input-validation'
|
||||
import { sanitizeForLogging } from '@/lib/core/security/redaction'
|
||||
|
||||
describe('validatePathSegment', () => {
|
||||
describe('valid inputs', () => {
|
||||
|
||||
@@ -556,29 +556,6 @@ export function validateFileExtension(
|
||||
return { isValid: true, sanitized: normalizedExt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for safe logging (removes potential sensitive data patterns)
|
||||
*
|
||||
* @param value - The value to sanitize
|
||||
* @param maxLength - Maximum length to return (default: 100)
|
||||
* @returns Sanitized string safe for logging
|
||||
*/
|
||||
export function sanitizeForLogging(value: string, maxLength = 100): string {
|
||||
if (!value) return ''
|
||||
|
||||
// Truncate long values
|
||||
let sanitized = value.substring(0, maxLength)
|
||||
|
||||
// Mask common sensitive patterns
|
||||
sanitized = sanitized
|
||||
.replace(/Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi, 'Bearer [REDACTED]')
|
||||
.replace(/password['":\s]*['"]\w+['"]/gi, 'password: "[REDACTED]"')
|
||||
.replace(/token['":\s]*['"]\w+['"]/gi, 'token: "[REDACTED]"')
|
||||
.replace(/api[_-]?key['":\s]*['"]\w+['"]/gi, 'api_key: "[REDACTED]"')
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates Microsoft Graph API resource IDs
|
||||
*
|
||||
|
||||
391
apps/sim/lib/core/security/redaction.test.ts
Normal file
391
apps/sim/lib/core/security/redaction.test.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isSensitiveKey,
|
||||
REDACTED_MARKER,
|
||||
redactApiKeys,
|
||||
redactSensitiveValues,
|
||||
sanitizeEventData,
|
||||
sanitizeForLogging,
|
||||
} from './redaction'
|
||||
|
||||
describe('REDACTED_MARKER', () => {
|
||||
it.concurrent('should be the standard marker', () => {
|
||||
expect(REDACTED_MARKER).toBe('[REDACTED]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSensitiveKey', () => {
|
||||
describe('exact matches', () => {
|
||||
it.concurrent('should match apiKey variations', () => {
|
||||
expect(isSensitiveKey('apiKey')).toBe(true)
|
||||
expect(isSensitiveKey('api_key')).toBe(true)
|
||||
expect(isSensitiveKey('api-key')).toBe(true)
|
||||
expect(isSensitiveKey('APIKEY')).toBe(true)
|
||||
expect(isSensitiveKey('API_KEY')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match token variations', () => {
|
||||
expect(isSensitiveKey('access_token')).toBe(true)
|
||||
expect(isSensitiveKey('refresh_token')).toBe(true)
|
||||
expect(isSensitiveKey('auth_token')).toBe(true)
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match secret variations', () => {
|
||||
expect(isSensitiveKey('client_secret')).toBe(true)
|
||||
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||
expect(isSensitiveKey('secret')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match other sensitive keys', () => {
|
||||
expect(isSensitiveKey('private_key')).toBe(true)
|
||||
expect(isSensitiveKey('authorization')).toBe(true)
|
||||
expect(isSensitiveKey('bearer')).toBe(true)
|
||||
expect(isSensitiveKey('private')).toBe(true)
|
||||
expect(isSensitiveKey('auth')).toBe(true)
|
||||
expect(isSensitiveKey('password')).toBe(true)
|
||||
expect(isSensitiveKey('credential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suffix matches', () => {
|
||||
it.concurrent('should match keys ending in secret', () => {
|
||||
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||
expect(isSensitiveKey('appSecret')).toBe(true)
|
||||
expect(isSensitiveKey('mySecret')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in password', () => {
|
||||
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||
expect(isSensitiveKey('dbPassword')).toBe(true)
|
||||
expect(isSensitiveKey('adminPassword')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in token', () => {
|
||||
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||
expect(isSensitiveKey('refreshToken')).toBe(true)
|
||||
expect(isSensitiveKey('bearerToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending in credential', () => {
|
||||
expect(isSensitiveKey('userCredential')).toBe(true)
|
||||
expect(isSensitiveKey('dbCredential')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('non-sensitive keys (no false positives)', () => {
|
||||
it.concurrent('should not match keys with sensitive words as prefix only', () => {
|
||||
expect(isSensitiveKey('tokenCount')).toBe(false)
|
||||
expect(isSensitiveKey('tokenizer')).toBe(false)
|
||||
expect(isSensitiveKey('secretKey')).toBe(false)
|
||||
expect(isSensitiveKey('passwordStrength')).toBe(false)
|
||||
expect(isSensitiveKey('authMethod')).toBe(false)
|
||||
})
|
||||
|
||||
it.concurrent('should match keys ending with sensitive words (intentional)', () => {
|
||||
expect(isSensitiveKey('hasSecret')).toBe(true)
|
||||
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||
expect(isSensitiveKey('sessionToken')).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not match normal field names', () => {
|
||||
expect(isSensitiveKey('name')).toBe(false)
|
||||
expect(isSensitiveKey('email')).toBe(false)
|
||||
expect(isSensitiveKey('id')).toBe(false)
|
||||
expect(isSensitiveKey('value')).toBe(false)
|
||||
expect(isSensitiveKey('data')).toBe(false)
|
||||
expect(isSensitiveKey('count')).toBe(false)
|
||||
expect(isSensitiveKey('status')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactSensitiveValues', () => {
|
||||
it.concurrent('should redact Bearer tokens', () => {
|
||||
const input = 'Authorization: Bearer abc123xyz456'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe('Authorization: Bearer [REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should redact Basic auth', () => {
|
||||
const input = 'Authorization: Basic dXNlcjpwYXNz'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe('Authorization: Basic [REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should redact API key prefixes', () => {
|
||||
const input = 'Using key sk-1234567890abcdefghijklmnop'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('sk-1234567890abcdefghijklmnop')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style password fields', () => {
|
||||
const input = 'password: "mysecretpass123"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('mysecretpass123')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style token fields', () => {
|
||||
const input = 'token: "tokenvalue123"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('tokenvalue123')
|
||||
})
|
||||
|
||||
it.concurrent('should redact JSON-style api_key fields', () => {
|
||||
const input = 'api_key: "key123456"'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('key123456')
|
||||
})
|
||||
|
||||
it.concurrent('should not modify safe strings', () => {
|
||||
const input = 'This is a normal string with no secrets'
|
||||
const result = redactSensitiveValues(input)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty strings', () => {
|
||||
expect(redactSensitiveValues('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should handle null/undefined gracefully', () => {
|
||||
expect(redactSensitiveValues(null as any)).toBe(null)
|
||||
expect(redactSensitiveValues(undefined as any)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactApiKeys', () => {
|
||||
describe('object redaction', () => {
|
||||
it.concurrent('should redact sensitive keys in flat objects', () => {
|
||||
const obj = {
|
||||
apiKey: 'secret-key',
|
||||
api_key: 'another-secret',
|
||||
access_token: 'token-value',
|
||||
secret: 'secret-value',
|
||||
password: 'password-value',
|
||||
normalField: 'normal-value',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.apiKey).toBe('[REDACTED]')
|
||||
expect(result.api_key).toBe('[REDACTED]')
|
||||
expect(result.access_token).toBe('[REDACTED]')
|
||||
expect(result.secret).toBe('[REDACTED]')
|
||||
expect(result.password).toBe('[REDACTED]')
|
||||
expect(result.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive keys in nested objects', () => {
|
||||
const obj = {
|
||||
config: {
|
||||
apiKey: 'secret-key',
|
||||
normalField: 'normal-value',
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.config.apiKey).toBe('[REDACTED]')
|
||||
expect(result.config.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive keys in arrays', () => {
|
||||
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
|
||||
|
||||
const result = redactApiKeys(arr)
|
||||
|
||||
expect(result[0].apiKey).toBe('[REDACTED]')
|
||||
expect(result[1].apiKey).toBe('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should handle deeply nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{
|
||||
name: 'John',
|
||||
credentials: {
|
||||
apiKey: 'secret-key',
|
||||
username: 'john_doe',
|
||||
},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
database: {
|
||||
password: 'db-password',
|
||||
host: 'localhost',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.users[0].name).toBe('John')
|
||||
expect(result.users[0].credentials.apiKey).toBe('[REDACTED]')
|
||||
expect(result.users[0].credentials.username).toBe('john_doe')
|
||||
expect(result.config.database.password).toBe('[REDACTED]')
|
||||
expect(result.config.database.host).toBe('localhost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive handling', () => {
|
||||
it.concurrent('should return primitives unchanged', () => {
|
||||
expect(redactApiKeys('string')).toBe('string')
|
||||
expect(redactApiKeys(123)).toBe(123)
|
||||
expect(redactApiKeys(true)).toBe(true)
|
||||
expect(redactApiKeys(null)).toBe(null)
|
||||
expect(redactApiKeys(undefined)).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no false positives', () => {
|
||||
it.concurrent('should not redact keys with sensitive words as prefix only', () => {
|
||||
const obj = {
|
||||
tokenCount: 100,
|
||||
secretKey: 'not-actually-secret',
|
||||
passwordStrength: 'strong',
|
||||
authMethod: 'oauth',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.tokenCount).toBe(100)
|
||||
expect(result.secretKey).toBe('not-actually-secret')
|
||||
expect(result.passwordStrength).toBe('strong')
|
||||
expect(result.authMethod).toBe('oauth')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeForLogging', () => {
|
||||
it.concurrent('should truncate long strings', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString, 50)
|
||||
expect(result.length).toBe(50)
|
||||
})
|
||||
|
||||
it.concurrent('should use default max length of 100', () => {
|
||||
const longString = 'a'.repeat(200)
|
||||
const result = sanitizeForLogging(longString)
|
||||
expect(result.length).toBe(100)
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns', () => {
|
||||
const input = 'Bearer abc123xyz456'
|
||||
const result = sanitizeForLogging(input)
|
||||
expect(result).toContain('[REDACTED]')
|
||||
expect(result).not.toContain('abc123xyz456')
|
||||
})
|
||||
|
||||
it.concurrent('should handle empty strings', () => {
|
||||
expect(sanitizeForLogging('')).toBe('')
|
||||
})
|
||||
|
||||
it.concurrent('should not modify safe short strings', () => {
|
||||
const input = 'Safe string'
|
||||
const result = sanitizeForLogging(input)
|
||||
expect(result).toBe(input)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeEventData', () => {
|
||||
describe('object sanitization', () => {
|
||||
it.concurrent('should remove sensitive keys entirely', () => {
|
||||
const event = {
|
||||
action: 'login',
|
||||
apiKey: 'secret-key',
|
||||
password: 'secret-pass',
|
||||
userId: '123',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.action).toBe('login')
|
||||
expect(result.userId).toBe('123')
|
||||
expect(result).not.toHaveProperty('apiKey')
|
||||
expect(result).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns in string values', () => {
|
||||
const event = {
|
||||
message: 'Auth: Bearer abc123token',
|
||||
normal: 'normal value',
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.message).toContain('[REDACTED]')
|
||||
expect(result.message).not.toContain('abc123token')
|
||||
expect(result.normal).toBe('normal value')
|
||||
})
|
||||
|
||||
it.concurrent('should handle nested objects', () => {
|
||||
const event = {
|
||||
user: {
|
||||
id: '123',
|
||||
accessToken: 'secret-token',
|
||||
},
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.user.id).toBe('123')
|
||||
expect(result.user).not.toHaveProperty('accessToken')
|
||||
})
|
||||
|
||||
it.concurrent('should handle arrays', () => {
|
||||
const event = {
|
||||
items: [
|
||||
{ id: 1, apiKey: 'key1' },
|
||||
{ id: 2, apiKey: 'key2' },
|
||||
],
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.items[0].id).toBe(1)
|
||||
expect(result.items[0]).not.toHaveProperty('apiKey')
|
||||
expect(result.items[1].id).toBe(2)
|
||||
expect(result.items[1]).not.toHaveProperty('apiKey')
|
||||
})
|
||||
})
|
||||
|
||||
describe('primitive handling', () => {
|
||||
it.concurrent('should return primitives appropriately', () => {
|
||||
expect(sanitizeEventData(null)).toBe(null)
|
||||
expect(sanitizeEventData(undefined)).toBe(undefined)
|
||||
expect(sanitizeEventData(123)).toBe(123)
|
||||
expect(sanitizeEventData(true)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should redact sensitive patterns in top-level strings', () => {
|
||||
const result = sanitizeEventData('Bearer secrettoken123')
|
||||
expect(result).toContain('[REDACTED]')
|
||||
})
|
||||
|
||||
it.concurrent('should not redact normal strings', () => {
|
||||
const result = sanitizeEventData('normal string')
|
||||
expect(result).toBe('normal string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('no false positives', () => {
|
||||
it.concurrent('should not remove keys with sensitive words in middle', () => {
|
||||
const event = {
|
||||
tokenCount: 500,
|
||||
isAuthenticated: true,
|
||||
hasSecretFeature: false,
|
||||
}
|
||||
|
||||
const result = sanitizeEventData(event)
|
||||
|
||||
expect(result.tokenCount).toBe(500)
|
||||
expect(result.isAuthenticated).toBe(true)
|
||||
expect(result.hasSecretFeature).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,28 +1,122 @@
|
||||
/**
|
||||
* Recursively redacts API keys in an object
|
||||
* @param obj The object to redact API keys from
|
||||
* @returns A new object with API keys redacted
|
||||
* Centralized redaction utilities for sensitive data
|
||||
*/
|
||||
export const redactApiKeys = (obj: any): any => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
|
||||
/** Standard marker used for all redacted values */
|
||||
export const REDACTED_MARKER = '[REDACTED]'
|
||||
|
||||
/**
|
||||
* Patterns for sensitive key names (case-insensitive matching)
|
||||
* These patterns match common naming conventions for sensitive data
|
||||
*/
|
||||
const SENSITIVE_KEY_PATTERNS: RegExp[] = [
|
||||
/^api[_-]?key$/i,
|
||||
/^access[_-]?token$/i,
|
||||
/^refresh[_-]?token$/i,
|
||||
/^client[_-]?secret$/i,
|
||||
/^private[_-]?key$/i,
|
||||
/^auth[_-]?token$/i,
|
||||
/^.*secret$/i,
|
||||
/^.*password$/i,
|
||||
/^.*token$/i,
|
||||
/^.*credential$/i,
|
||||
/^authorization$/i,
|
||||
/^bearer$/i,
|
||||
/^private$/i,
|
||||
/^auth$/i,
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns for sensitive values in strings (for redacting values, not keys)
|
||||
* Each pattern has a replacement function
|
||||
*/
|
||||
const SENSITIVE_VALUE_PATTERNS: Array<{
|
||||
pattern: RegExp
|
||||
replacement: string
|
||||
}> = [
|
||||
// Bearer tokens
|
||||
{
|
||||
pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/gi,
|
||||
replacement: `Bearer ${REDACTED_MARKER}`,
|
||||
},
|
||||
// Basic auth
|
||||
{
|
||||
pattern: /Basic\s+[A-Za-z0-9+/]+=*/gi,
|
||||
replacement: `Basic ${REDACTED_MARKER}`,
|
||||
},
|
||||
// API keys that look like sk-..., pk-..., etc.
|
||||
{
|
||||
pattern: /\b(sk|pk|api|key)[_-][A-Za-z0-9\-._]{20,}\b/gi,
|
||||
replacement: REDACTED_MARKER,
|
||||
},
|
||||
// JSON-style password fields: password: "value" or password: 'value'
|
||||
{
|
||||
pattern: /password['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `password: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
// JSON-style token fields: token: "value" or token: 'value'
|
||||
{
|
||||
pattern: /token['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `token: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
// JSON-style api_key fields: api_key: "value" or api-key: "value"
|
||||
{
|
||||
pattern: /api[_-]?key['":\s]*['"][^'"]+['"]/gi,
|
||||
replacement: `api_key: "${REDACTED_MARKER}"`,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Checks if a key name matches any sensitive pattern
|
||||
* @param key - The key name to check
|
||||
* @returns True if the key is considered sensitive
|
||||
*/
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const lowerKey = key.toLowerCase()
|
||||
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(lowerKey))
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacts sensitive patterns from a string value
|
||||
* @param value - The string to redact
|
||||
* @returns The string with sensitive patterns redacted
|
||||
*/
|
||||
export function redactSensitiveValues(value: string): string {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
let result = value
|
||||
for (const { pattern, replacement } of SENSITIVE_VALUE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redacts sensitive data (API keys, passwords, tokens, etc.) from an object
|
||||
*
|
||||
* @param obj - The object to redact sensitive data from
|
||||
* @returns A new object with sensitive data redacted
|
||||
*/
|
||||
export function redactApiKeys(obj: any): any {
|
||||
if (obj === null || obj === undefined) {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(redactApiKeys)
|
||||
return obj.map((item) => redactApiKeys(item))
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (
|
||||
key.toLowerCase() === 'apikey' ||
|
||||
key.toLowerCase() === 'api_key' ||
|
||||
key.toLowerCase() === 'access_token' ||
|
||||
/\bsecret\b/i.test(key.toLowerCase()) ||
|
||||
/\bpassword\b/i.test(key.toLowerCase())
|
||||
) {
|
||||
result[key] = '***REDACTED***'
|
||||
if (isSensitiveKey(key)) {
|
||||
result[key] = REDACTED_MARKER
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
result[key] = redactApiKeys(value)
|
||||
} else {
|
||||
@@ -32,3 +126,64 @@ export const redactApiKeys = (obj: any): any => {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string for safe logging by truncating and redacting sensitive patterns
|
||||
*
|
||||
* @param value - The string to sanitize
|
||||
* @param maxLength - Maximum length of the output (default: 100)
|
||||
* @returns The sanitized string
|
||||
*/
|
||||
export function sanitizeForLogging(value: string, maxLength = 100): string {
|
||||
if (!value) return ''
|
||||
|
||||
let sanitized = value.substring(0, maxLength)
|
||||
|
||||
sanitized = redactSensitiveValues(sanitized)
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes event data for error reporting/analytics
|
||||
*
|
||||
* @param event - The event data to sanitize
|
||||
* @returns Sanitized event data safe for external reporting
|
||||
*/
|
||||
export function sanitizeEventData(event: any): any {
|
||||
if (event === null || event === undefined) {
|
||||
return event
|
||||
}
|
||||
|
||||
if (typeof event === 'string') {
|
||||
return redactSensitiveValues(event)
|
||||
}
|
||||
|
||||
if (typeof event !== 'object') {
|
||||
return event
|
||||
}
|
||||
|
||||
if (Array.isArray(event)) {
|
||||
return event.map((item) => sanitizeEventData(item))
|
||||
}
|
||||
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(event)) {
|
||||
if (isSensitiveKey(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = redactSensitiveValues(value)
|
||||
} else if (Array.isArray(value)) {
|
||||
sanitized[key] = value.map((v) => sanitizeEventData(v))
|
||||
} else if (value && typeof value === 'object') {
|
||||
sanitized[key] = sanitizeEventData(value)
|
||||
} else {
|
||||
sanitized[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
formatDate,
|
||||
@@ -229,86 +228,6 @@ describe('getTimezoneAbbreviation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('redactApiKeys', () => {
|
||||
it.concurrent('should redact API keys in objects', () => {
|
||||
const obj = {
|
||||
apiKey: 'secret-key',
|
||||
api_key: 'another-secret',
|
||||
access_token: 'token-value',
|
||||
secret: 'secret-value',
|
||||
password: 'password-value',
|
||||
normalField: 'normal-value',
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.apiKey).toBe('***REDACTED***')
|
||||
expect(result.api_key).toBe('***REDACTED***')
|
||||
expect(result.access_token).toBe('***REDACTED***')
|
||||
expect(result.secret).toBe('***REDACTED***')
|
||||
expect(result.password).toBe('***REDACTED***')
|
||||
expect(result.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact API keys in nested objects', () => {
|
||||
const obj = {
|
||||
config: {
|
||||
apiKey: 'secret-key',
|
||||
normalField: 'normal-value',
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.config.apiKey).toBe('***REDACTED***')
|
||||
expect(result.config.normalField).toBe('normal-value')
|
||||
})
|
||||
|
||||
it.concurrent('should redact API keys in arrays', () => {
|
||||
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
|
||||
|
||||
const result = redactApiKeys(arr)
|
||||
|
||||
expect(result[0].apiKey).toBe('***REDACTED***')
|
||||
expect(result[1].apiKey).toBe('***REDACTED***')
|
||||
})
|
||||
|
||||
it.concurrent('should handle primitive values', () => {
|
||||
expect(redactApiKeys('string')).toBe('string')
|
||||
expect(redactApiKeys(123)).toBe(123)
|
||||
expect(redactApiKeys(null)).toBe(null)
|
||||
expect(redactApiKeys(undefined)).toBe(undefined)
|
||||
})
|
||||
|
||||
it.concurrent('should handle complex nested structures', () => {
|
||||
const obj = {
|
||||
users: [
|
||||
{
|
||||
name: 'John',
|
||||
credentials: {
|
||||
apiKey: 'secret-key',
|
||||
username: 'john_doe',
|
||||
},
|
||||
},
|
||||
],
|
||||
config: {
|
||||
database: {
|
||||
password: 'db-password',
|
||||
host: 'localhost',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const result = redactApiKeys(obj)
|
||||
|
||||
expect(result.users[0].name).toBe('John')
|
||||
expect(result.users[0].credentials.apiKey).toBe('***REDACTED***')
|
||||
expect(result.users[0].credentials.username).toBe('john_doe')
|
||||
expect(result.config.database.password).toBe('***REDACTED***')
|
||||
expect(result.config.database.host).toBe('localhost')
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateName', () => {
|
||||
it.concurrent('should remove invalid characters', () => {
|
||||
const result = validateName('test@#$%name')
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/**
|
||||
* Type guard to check if an object is a UserFile
|
||||
*/
|
||||
const MAX_STRING_LENGTH = 15000
|
||||
const MAX_DEPTH = 50
|
||||
|
||||
function truncateString(value: string, maxLength = MAX_STRING_LENGTH): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value
|
||||
}
|
||||
return `${value.substring(0, maxLength)}... [truncated ${value.length - maxLength} chars]`
|
||||
}
|
||||
|
||||
export function isUserFile(candidate: unknown): candidate is {
|
||||
id: string
|
||||
name: string
|
||||
@@ -23,11 +30,6 @@ export function isUserFile(candidate: unknown): candidate is {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter function that transforms UserFile objects for display
|
||||
* Removes internal fields: key, context
|
||||
* Keeps user-friendly fields: id, name, url, size, type
|
||||
*/
|
||||
function filterUserFile(data: any): any {
|
||||
if (isUserFile(data)) {
|
||||
const { id, name, url, size, type } = data
|
||||
@@ -36,50 +38,152 @@ function filterUserFile(data: any): any {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of filter functions to apply to data for cleaner display in logs/console.
|
||||
* Add new filter functions here to handle additional data types.
|
||||
*/
|
||||
const DISPLAY_FILTERS = [
|
||||
filterUserFile,
|
||||
// Add more filters here as needed
|
||||
]
|
||||
const DISPLAY_FILTERS = [filterUserFile]
|
||||
|
||||
/**
|
||||
* Generic helper to filter internal/technical fields from data for cleaner display in logs and console.
|
||||
* Applies all registered filters recursively to the data structure.
|
||||
*
|
||||
* To add a new filter:
|
||||
* 1. Create a filter function that checks and transforms a specific data type
|
||||
* 2. Add it to the DISPLAY_FILTERS array above
|
||||
*
|
||||
* @param data - Data to filter (objects, arrays, primitives)
|
||||
* @returns Filtered data with internal fields removed
|
||||
*/
|
||||
export function filterForDisplay(data: any): any {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return data
|
||||
}
|
||||
|
||||
// Apply all registered filters
|
||||
const filtered = data
|
||||
for (const filterFn of DISPLAY_FILTERS) {
|
||||
const result = filterFn(filtered)
|
||||
if (result !== filtered) {
|
||||
// Filter matched and transformed the data
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// No filters matched - recursively filter nested structures
|
||||
if (Array.isArray(filtered)) {
|
||||
return filtered.map(filterForDisplay)
|
||||
}
|
||||
|
||||
// Recursively filter object properties
|
||||
const result: any = {}
|
||||
for (const [key, value] of Object.entries(filtered)) {
|
||||
result[key] = filterForDisplay(value)
|
||||
}
|
||||
return result
|
||||
const seen = new WeakSet()
|
||||
return filterForDisplayInternal(data, seen, 0)
|
||||
}
|
||||
|
||||
function getObjectType(data: unknown): string {
|
||||
return Object.prototype.toString.call(data).slice(8, -1)
|
||||
}
|
||||
|
||||
function filterForDisplayInternal(data: any, seen: WeakSet<object>, depth: number): any {
|
||||
try {
|
||||
if (data === null || data === undefined) {
|
||||
return data
|
||||
}
|
||||
|
||||
const dataType = typeof data
|
||||
|
||||
if (dataType === 'string') {
|
||||
// Remove null bytes which are not allowed in PostgreSQL JSONB
|
||||
const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data
|
||||
return truncateString(sanitized)
|
||||
}
|
||||
|
||||
if (dataType === 'number') {
|
||||
if (Number.isNaN(data)) {
|
||||
return '[NaN]'
|
||||
}
|
||||
if (!Number.isFinite(data)) {
|
||||
return data > 0 ? '[Infinity]' : '[-Infinity]'
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
if (dataType === 'boolean') {
|
||||
return data
|
||||
}
|
||||
|
||||
if (dataType === 'bigint') {
|
||||
return `[BigInt: ${data.toString()}]`
|
||||
}
|
||||
|
||||
if (dataType === 'symbol') {
|
||||
return `[Symbol: ${data.toString()}]`
|
||||
}
|
||||
|
||||
if (dataType === 'function') {
|
||||
return `[Function: ${data.name || 'anonymous'}]`
|
||||
}
|
||||
|
||||
if (dataType !== 'object') {
|
||||
return '[Unknown Type]'
|
||||
}
|
||||
|
||||
if (seen.has(data)) {
|
||||
return '[Circular Reference]'
|
||||
}
|
||||
|
||||
if (depth > MAX_DEPTH) {
|
||||
return '[Max Depth Exceeded]'
|
||||
}
|
||||
|
||||
const objectType = getObjectType(data)
|
||||
|
||||
switch (objectType) {
|
||||
case 'Date': {
|
||||
const timestamp = (data as Date).getTime()
|
||||
if (Number.isNaN(timestamp)) {
|
||||
return '[Invalid Date]'
|
||||
}
|
||||
return (data as Date).toISOString()
|
||||
}
|
||||
|
||||
case 'RegExp':
|
||||
return (data as RegExp).toString()
|
||||
|
||||
case 'URL':
|
||||
return (data as URL).toString()
|
||||
|
||||
case 'Error': {
|
||||
const err = data as Error
|
||||
return {
|
||||
name: err.name,
|
||||
message: truncateString(err.message),
|
||||
stack: err.stack ? truncateString(err.stack) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
case 'ArrayBuffer':
|
||||
return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]`
|
||||
|
||||
case 'Map': {
|
||||
const obj: Record<string, any> = {}
|
||||
for (const [key, value] of (data as Map<any, any>).entries()) {
|
||||
const keyStr = typeof key === 'string' ? key : String(key)
|
||||
obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
case 'Set':
|
||||
return Array.from(data as Set<any>).map((item) =>
|
||||
filterForDisplayInternal(item, seen, depth + 1)
|
||||
)
|
||||
|
||||
case 'WeakMap':
|
||||
return '[WeakMap]'
|
||||
|
||||
case 'WeakSet':
|
||||
return '[WeakSet]'
|
||||
|
||||
case 'WeakRef':
|
||||
return '[WeakRef]'
|
||||
|
||||
case 'Promise':
|
||||
return '[Promise]'
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]`
|
||||
}
|
||||
|
||||
seen.add(data)
|
||||
|
||||
for (const filterFn of DISPLAY_FILTERS) {
|
||||
const result = filterFn(data)
|
||||
if (result !== data) {
|
||||
return filterForDisplayInternal(result, seen, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => filterForDisplayInternal(item, seen, depth + 1))
|
||||
}
|
||||
|
||||
const result: Record<string, any> = {}
|
||||
for (const key of Object.keys(data)) {
|
||||
try {
|
||||
result[key] = filterForDisplayInternal(data[key], seen, depth + 1)
|
||||
} catch {
|
||||
result[key] = '[Error accessing property]'
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return '[Unserializable]'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
traceSpans?: TraceSpan[]
|
||||
workflowInput?: any
|
||||
isResume?: boolean // If true, merge with existing data instead of replacing
|
||||
level?: 'info' | 'error' // Optional override for log level (used in cost-only fallback)
|
||||
}): Promise<WorkflowExecutionLog> {
|
||||
const {
|
||||
executionId,
|
||||
@@ -240,6 +241,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
traceSpans,
|
||||
workflowInput,
|
||||
isResume,
|
||||
level: levelOverride,
|
||||
} = params
|
||||
|
||||
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
|
||||
@@ -256,6 +258,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
}
|
||||
|
||||
// Determine if workflow failed by checking trace spans for errors
|
||||
// Use the override if provided (for cost-only fallback scenarios)
|
||||
const hasErrors = traceSpans?.some((span: any) => {
|
||||
const checkSpanForErrors = (s: any): boolean => {
|
||||
if (s.status === 'error') return true
|
||||
@@ -267,7 +270,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return checkSpanForErrors(span)
|
||||
})
|
||||
|
||||
const level = hasErrors ? 'error' : 'info'
|
||||
const level = levelOverride ?? (hasErrors ? 'error' : 'info')
|
||||
|
||||
// Extract files from trace spans, final output, and workflow input
|
||||
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface SessionCompleteParams {
|
||||
endedAt?: string
|
||||
totalDurationMs?: number
|
||||
finalOutput?: any
|
||||
traceSpans?: any[]
|
||||
traceSpans?: TraceSpan[]
|
||||
workflowInput?: any
|
||||
}
|
||||
|
||||
@@ -331,20 +331,85 @@ export class LoggingSession {
|
||||
try {
|
||||
await this.complete(params)
|
||||
} catch (error) {
|
||||
// Error already logged in complete(), log a summary here
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
logger.warn(
|
||||
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - execution data not persisted`
|
||||
`[${this.requestId || 'unknown'}] Complete failed for execution ${this.executionId}, attempting fallback`,
|
||||
{ error: errorMsg }
|
||||
)
|
||||
await this.completeWithCostOnlyLog({
|
||||
traceSpans: params.traceSpans,
|
||||
endedAt: params.endedAt,
|
||||
totalDurationMs: params.totalDurationMs,
|
||||
errorMessage: `Failed to store trace spans: ${errorMsg}`,
|
||||
isError: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async safeCompleteWithError(error?: SessionErrorCompleteParams): Promise<void> {
|
||||
async safeCompleteWithError(params?: SessionErrorCompleteParams): Promise<void> {
|
||||
try {
|
||||
await this.completeWithError(error)
|
||||
} catch (enhancedError) {
|
||||
// Error already logged in completeWithError(), log a summary here
|
||||
await this.completeWithError(params)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
logger.warn(
|
||||
`[${this.requestId || 'unknown'}] Error logging completion failed for execution ${this.executionId} - execution data not persisted`
|
||||
`[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`,
|
||||
{ error: errorMsg }
|
||||
)
|
||||
await this.completeWithCostOnlyLog({
|
||||
traceSpans: params?.traceSpans,
|
||||
endedAt: params?.endedAt,
|
||||
totalDurationMs: params?.totalDurationMs,
|
||||
errorMessage:
|
||||
params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
|
||||
isError: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async completeWithCostOnlyLog(params: {
|
||||
traceSpans?: TraceSpan[]
|
||||
endedAt?: string
|
||||
totalDurationMs?: number
|
||||
errorMessage: string
|
||||
isError: boolean
|
||||
}): Promise<void> {
|
||||
logger.warn(
|
||||
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`
|
||||
)
|
||||
|
||||
try {
|
||||
const costSummary = params.traceSpans?.length
|
||||
? calculateCostSummary(params.traceSpans)
|
||||
: {
|
||||
totalCost: BASE_EXECUTION_CHARGE,
|
||||
totalInputCost: 0,
|
||||
totalOutputCost: 0,
|
||||
totalTokens: 0,
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
baseExecutionCharge: BASE_EXECUTION_CHARGE,
|
||||
modelCost: 0,
|
||||
models: {},
|
||||
}
|
||||
|
||||
await executionLogger.completeWorkflowExecution({
|
||||
executionId: this.executionId,
|
||||
endedAt: params.endedAt || new Date().toISOString(),
|
||||
totalDurationMs: params.totalDurationMs || 0,
|
||||
costSummary,
|
||||
finalOutput: { _fallback: true, error: params.errorMessage },
|
||||
traceSpans: [],
|
||||
isResume: this.isResume,
|
||||
level: params.isError ? 'error' : 'info',
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}`
|
||||
)
|
||||
} catch (fallbackError) {
|
||||
logger.error(
|
||||
`[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`,
|
||||
{ error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,8 +479,16 @@ export async function transformBlockTool(
|
||||
|
||||
const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams)
|
||||
|
||||
// Create unique tool ID by appending resource ID for multi-instance tools
|
||||
let uniqueToolId = toolConfig.id
|
||||
if (toolId === 'workflow_executor' && userProvidedParams.workflowId) {
|
||||
uniqueToolId = `${toolConfig.id}_${userProvidedParams.workflowId}`
|
||||
} else if (toolId.startsWith('knowledge_') && userProvidedParams.knowledgeBaseId) {
|
||||
uniqueToolId = `${toolConfig.id}_${userProvidedParams.knowledgeBaseId}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: toolConfig.id,
|
||||
id: uniqueToolId,
|
||||
name: toolConfig.name,
|
||||
description: toolConfig.description,
|
||||
params: userProvidedParams,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
return { entries: state.entries }
|
||||
}
|
||||
|
||||
// Redact API keys from output
|
||||
// Redact API keys from output and input
|
||||
const redactedEntry = { ...entry }
|
||||
if (
|
||||
!isStreamingOutput(entry.output) &&
|
||||
@@ -89,6 +89,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
) {
|
||||
redactedEntry.output = redactApiKeys(redactedEntry.output)
|
||||
}
|
||||
if (redactedEntry.input && typeof redactedEntry.input === 'object') {
|
||||
redactedEntry.input = redactApiKeys(redactedEntry.input)
|
||||
}
|
||||
|
||||
// Create new entry with ID and timestamp
|
||||
const newEntry: ConsoleEntry = {
|
||||
@@ -275,12 +278,17 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
}
|
||||
|
||||
if (update.replaceOutput !== undefined) {
|
||||
updatedEntry.output = update.replaceOutput
|
||||
updatedEntry.output =
|
||||
typeof update.replaceOutput === 'object' && update.replaceOutput !== null
|
||||
? redactApiKeys(update.replaceOutput)
|
||||
: update.replaceOutput
|
||||
} else if (update.output !== undefined) {
|
||||
updatedEntry.output = {
|
||||
const mergedOutput = {
|
||||
...(entry.output || {}),
|
||||
...update.output,
|
||||
}
|
||||
updatedEntry.output =
|
||||
typeof mergedOutput === 'object' ? redactApiKeys(mergedOutput) : mergedOutput
|
||||
}
|
||||
|
||||
if (update.error !== undefined) {
|
||||
@@ -304,7 +312,10 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
||||
}
|
||||
|
||||
if (update.input !== undefined) {
|
||||
updatedEntry.input = update.input
|
||||
updatedEntry.input =
|
||||
typeof update.input === 'object' && update.input !== null
|
||||
? redactApiKeys(update.input)
|
||||
: update.input
|
||||
}
|
||||
|
||||
return updatedEntry
|
||||
|
||||
@@ -16,6 +16,26 @@ import {
|
||||
|
||||
const logger = createLogger('Tools')
|
||||
|
||||
/**
|
||||
* Normalizes a tool ID by stripping resource ID suffix (UUID).
|
||||
* Workflow tools: 'workflow_executor_<uuid>' -> 'workflow_executor'
|
||||
* Knowledge tools: 'knowledge_search_<uuid>' -> 'knowledge_search'
|
||||
*/
|
||||
function normalizeToolId(toolId: string): string {
|
||||
// Check for workflow_executor_<uuid> pattern
|
||||
if (toolId.startsWith('workflow_executor_') && toolId.length > 'workflow_executor_'.length) {
|
||||
return 'workflow_executor'
|
||||
}
|
||||
// Check for knowledge_<operation>_<uuid> pattern
|
||||
const knowledgeOps = ['knowledge_search', 'knowledge_upload_chunk', 'knowledge_create_document']
|
||||
for (const op of knowledgeOps) {
|
||||
if (toolId.startsWith(`${op}_`) && toolId.length > op.length + 1) {
|
||||
return op
|
||||
}
|
||||
}
|
||||
return toolId
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum request body size in bytes before we warn/error about size limits.
|
||||
* Next.js 16 has a default middleware/proxy body limit of 10MB.
|
||||
@@ -186,20 +206,29 @@ export async function executeTool(
|
||||
try {
|
||||
let tool: ToolConfig | undefined
|
||||
|
||||
// Normalize tool ID to strip resource suffixes (e.g., workflow_executor_<uuid> -> workflow_executor)
|
||||
const normalizedToolId = normalizeToolId(toolId)
|
||||
|
||||
// If it's a custom tool, use the async version with workflowId
|
||||
if (toolId.startsWith('custom_')) {
|
||||
if (normalizedToolId.startsWith('custom_')) {
|
||||
const workflowId = params._context?.workflowId
|
||||
tool = await getToolAsync(toolId, workflowId)
|
||||
tool = await getToolAsync(normalizedToolId, workflowId)
|
||||
if (!tool) {
|
||||
logger.error(`[${requestId}] Custom tool not found: ${toolId}`)
|
||||
logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`)
|
||||
}
|
||||
} else if (toolId.startsWith('mcp-')) {
|
||||
return await executeMcpTool(toolId, params, executionContext, requestId, startTimeISO)
|
||||
} else if (normalizedToolId.startsWith('mcp-')) {
|
||||
return await executeMcpTool(
|
||||
normalizedToolId,
|
||||
params,
|
||||
executionContext,
|
||||
requestId,
|
||||
startTimeISO
|
||||
)
|
||||
} else {
|
||||
// For built-in tools, use the synchronous version
|
||||
tool = getTool(toolId)
|
||||
tool = getTool(normalizedToolId)
|
||||
if (!tool) {
|
||||
logger.error(`[${requestId}] Built-in tool not found: ${toolId}`)
|
||||
logger.error(`[${requestId}] Built-in tool not found: ${normalizedToolId}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
}
|
||||
|
||||
|
||||
16
bun.lock
16
bun.lock
@@ -37,7 +37,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.6.3",
|
||||
"turbo": "2.7.0",
|
||||
},
|
||||
},
|
||||
"apps/docs": {
|
||||
@@ -3303,19 +3303,19 @@
|
||||
|
||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||
|
||||
"turbo": ["turbo@2.6.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.3", "turbo-darwin-arm64": "2.6.3", "turbo-linux-64": "2.6.3", "turbo-linux-arm64": "2.6.3", "turbo-windows-64": "2.6.3", "turbo-windows-arm64": "2.6.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA=="],
|
||||
"turbo": ["turbo@2.7.0", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.0", "turbo-darwin-arm64": "2.7.0", "turbo-linux-64": "2.7.0", "turbo-linux-arm64": "2.7.0", "turbo-windows-64": "2.7.0", "turbo-windows-arm64": "2.7.0" }, "bin": { "turbo": "bin/turbo" } }, "sha512-1dUGwi6cSSVZts1BwJa/Gh7w5dPNNGsNWZEAuRKxXWME44hTKWpQZrgiPnqMc5jJJOovzPK5N6tL+PHYRYL5Wg=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.6.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlJJDc1CQ7SK5Y5qnl7AzpkvKSnpkfPmnA+HeU/sgny3oHZckPV2776ebO2M33CYDSor7+8HQwaodY++IINhYg=="],
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.7.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-gwqL7cJOSYrV/jNmhXM8a2uzSFn7GcUASOuen6OgmUsafUj9SSWcgXZ/q0w9hRoL917hpidkdI//UpbxbZbwwg=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.6.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MwVt7rBKiOK7zdYerenfCRTypefw4kZCue35IJga9CH1+S50+KTiCkT6LBqo0hHeoH2iKuI0ldTF2a0aB72z3w=="],
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f3F5DYOnfE6lR6v/rSld7QGZgartKsnlIYY7jcF/AA7Wz27za9XjxMHzb+3i4pvRhAkryFgf2TNq7eCFrzyTpg=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.6.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cqpcw+dXxbnPtNnzeeSyWprjmuFVpHJqKcs7Jym5oXlu/ZcovEASUIUZVN3OGEM6Y/OTyyw0z09tOHNt5yBAVg=="],
|
||||
"turbo-linux-64": ["turbo-linux-64@2.7.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KsC+UuKlhjCL+lom10/IYoxUsdhJOsuEki72YSr7WGYUSRihcdJQnaUyIDTlm0nPOb+gVihVNBuVP4KsNg1UnA=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.6.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-MterpZQmjXyr4uM7zOgFSFL3oRdNKeflY7nsjxJb2TklsYqiu3Z9pQ4zRVFFH8n0mLGna7MbQMZuKoWqqHb45w=="],
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.7.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1tjIYULeJtpmE/ovoI9qPBFJCtUEM7mYfeIMOIs4bXR6t/8u+rHPwr3j+vRHcXanIc42V1n3Pz52VqmJtIAviw=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.6.3", "", { "os": "win32", "cpu": "x64" }, "sha512-biDU70v9dLwnBdLf+daoDlNJVvqOOP8YEjqNipBHzgclbQlXbsi6Gqqelp5er81Qo3BiRgmTNx79oaZQTPb07Q=="],
|
||||
"turbo-windows-64": ["turbo-windows-64@2.7.0", "", { "os": "win32", "cpu": "x64" }, "sha512-KThkAeax46XiH+qICCQm7R8V2pPdeTTP7ArCSRrSLqnlO75ftNm8Ljx4VAllwIZkILrq/GDM8PlyhZdPeUdDxQ=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.6.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dDHVKpSeukah3VsI/xMEKeTnV9V9cjlpFSUs4bmsUiLu3Yv2ENlgVEZv65wxbeE0bh0jjpmElDT+P1KaCxArQQ=="],
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.7.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-kzI6rsQ3Ejs+CkM9HEEP3Z4h5YMCRxwIlQXFQmgXSG3BIgorCkRF2Xr7iQ2i9AGwY/6jbiAYeJbvi3yCp+noFw=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "16.0.0",
|
||||
"turbo": "2.6.3"
|
||||
"turbo": "2.7.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||
|
||||
@@ -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