v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types

This commit is contained in:
Waleed
2025-12-19 22:31:55 -08:00
committed by GitHub
81 changed files with 15271 additions and 2047 deletions

View File

@@ -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,
},

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 })
}

View File

@@ -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',

View File

@@ -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'

View File

@@ -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)

View File

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

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
}

View File

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

View File

@@ -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,

View File

@@ -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'>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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) => {

View File

@@ -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]'>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>}

View File

@@ -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 */}

View File

@@ -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 */}

View File

@@ -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>

View File

@@ -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'

View 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,
}
}

View File

@@ -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',

View 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 }

View File

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

View File

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

View File

@@ -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}>

View 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)
})
})
})

View File

@@ -578,7 +578,7 @@ export class EdgeConstructor {
return
}
const edgeId = `${sourceId}${targetId}`
const edgeId = `${sourceId}${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
sourceNode.outgoingEdges.set(edgeId, {
target: targetId,

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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)
})
})
})

View File

@@ -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 }
}

View File

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

View File

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

View File

@@ -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',

View File

@@ -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', () => {

View File

@@ -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
*

View 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)
})
})
})

View File

@@ -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
}

View File

@@ -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')

View File

@@ -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]'
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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) }
)
}
}

View File

@@ -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,

View File

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

View File

@@ -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

View File

@@ -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}`)
}
}

View File

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

View File

@@ -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=="],

View File

@@ -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}": [

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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