mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 22:48:14 -05:00
v0.5.37: redaction utils consolidation, logs updates, autoconnect improvements, additional kb tag types
This commit is contained in:
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { auth } from '@/lib/auth'
|
import { auth } from '@/lib/auth'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
|
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('SSO-Register')
|
const logger = createLogger('SSO-Register')
|
||||||
@@ -236,13 +237,13 @@ export async function POST(request: NextRequest) {
|
|||||||
oidcConfig: providerConfig.oidcConfig
|
oidcConfig: providerConfig.oidcConfig
|
||||||
? {
|
? {
|
||||||
...providerConfig.oidcConfig,
|
...providerConfig.oidcConfig,
|
||||||
clientSecret: '[REDACTED]',
|
clientSecret: REDACTED_MARKER,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
samlConfig: providerConfig.samlConfig
|
samlConfig: providerConfig.samlConfig
|
||||||
? {
|
? {
|
||||||
...providerConfig.samlConfig,
|
...providerConfig.samlConfig,
|
||||||
cert: '[REDACTED]',
|
cert: REDACTED_MARKER,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -141,6 +141,23 @@ export async function DELETE(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if deleting this folder would delete the last workflow(s) in the workspace
|
||||||
|
const workflowsInFolder = await countWorkflowsInFolderRecursively(
|
||||||
|
id,
|
||||||
|
existingFolder.workspaceId
|
||||||
|
)
|
||||||
|
const totalWorkflowsInWorkspace = await db
|
||||||
|
.select({ id: workflow.id })
|
||||||
|
.from(workflow)
|
||||||
|
.where(eq(workflow.workspaceId, existingFolder.workspaceId))
|
||||||
|
|
||||||
|
if (workflowsInFolder > 0 && workflowsInFolder >= totalWorkflowsInWorkspace.length) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Cannot delete folder containing the only workflow(s) in the workspace' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Recursively delete folder and all its contents
|
// Recursively delete folder and all its contents
|
||||||
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId)
|
||||||
|
|
||||||
@@ -202,6 +219,34 @@ async function deleteFolderRecursively(
|
|||||||
return stats
|
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
|
// Helper function to check for circular references
|
||||||
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
async function checkForCircularReference(folderId: string, parentId: string): Promise<boolean> {
|
||||||
let currentParentId: string | null = parentId
|
let currentParentId: string | null = parentId
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export async function POST(
|
|||||||
const validatedData = CreateChunkSchema.parse(searchParams)
|
const validatedData = CreateChunkSchema.parse(searchParams)
|
||||||
|
|
||||||
const docTags = {
|
const docTags = {
|
||||||
|
// Text tags (7 slots)
|
||||||
tag1: doc.tag1 ?? null,
|
tag1: doc.tag1 ?? null,
|
||||||
tag2: doc.tag2 ?? null,
|
tag2: doc.tag2 ?? null,
|
||||||
tag3: doc.tag3 ?? null,
|
tag3: doc.tag3 ?? null,
|
||||||
@@ -163,6 +164,19 @@ export async function POST(
|
|||||||
tag5: doc.tag5 ?? null,
|
tag5: doc.tag5 ?? null,
|
||||||
tag6: doc.tag6 ?? null,
|
tag6: doc.tag6 ?? null,
|
||||||
tag7: doc.tag7 ?? 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(
|
const newChunk = await createChunk(
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ describe('Document By ID API Route', () => {
|
|||||||
tag5: null,
|
tag5: null,
|
||||||
tag6: null,
|
tag6: null,
|
||||||
tag7: 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,
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const UpdateDocumentSchema = z.object({
|
|||||||
processingError: z.string().optional(),
|
processingError: z.string().optional(),
|
||||||
markFailedDueToTimeout: z.boolean().optional(),
|
markFailedDueToTimeout: z.boolean().optional(),
|
||||||
retryProcessing: z.boolean().optional(),
|
retryProcessing: z.boolean().optional(),
|
||||||
// Tag fields
|
// Text tag fields
|
||||||
tag1: z.string().optional(),
|
tag1: z.string().optional(),
|
||||||
tag2: z.string().optional(),
|
tag2: z.string().optional(),
|
||||||
tag3: z.string().optional(),
|
tag3: z.string().optional(),
|
||||||
@@ -31,6 +31,19 @@ const UpdateDocumentSchema = z.object({
|
|||||||
tag5: z.string().optional(),
|
tag5: z.string().optional(),
|
||||||
tag6: z.string().optional(),
|
tag6: z.string().optional(),
|
||||||
tag7: 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(
|
export async function GET(
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ describe('Knowledge Base Documents API Route', () => {
|
|||||||
tag5: null,
|
tag5: null,
|
||||||
tag6: null,
|
tag6: null,
|
||||||
tag7: 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,
|
deletedAt: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ vi.mock('@/app/api/knowledge/utils', () => ({
|
|||||||
checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess,
|
checkKnowledgeBaseAccess: mockCheckKnowledgeBaseAccess,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const mockGetDocumentTagDefinitions = vi.fn()
|
||||||
|
vi.mock('@/lib/knowledge/tags/service', () => ({
|
||||||
|
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
|
||||||
|
}))
|
||||||
|
|
||||||
const mockHandleTagOnlySearch = vi.fn()
|
const mockHandleTagOnlySearch = vi.fn()
|
||||||
const mockHandleVectorOnlySearch = vi.fn()
|
const mockHandleVectorOnlySearch = vi.fn()
|
||||||
const mockHandleTagAndVectorSearch = vi.fn()
|
const mockHandleTagAndVectorSearch = vi.fn()
|
||||||
@@ -156,6 +161,7 @@ describe('Knowledge Search API Route', () => {
|
|||||||
doc1: 'Document 1',
|
doc1: 'Document 1',
|
||||||
doc2: 'Document 2',
|
doc2: 'Document 2',
|
||||||
})
|
})
|
||||||
|
mockGetDocumentTagDefinitions.mockClear()
|
||||||
|
|
||||||
vi.stubGlobal('crypto', {
|
vi.stubGlobal('crypto', {
|
||||||
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
||||||
@@ -659,8 +665,8 @@ describe('Knowledge Search API Route', () => {
|
|||||||
|
|
||||||
describe('Optional Query Search', () => {
|
describe('Optional Query Search', () => {
|
||||||
const mockTagDefinitions = [
|
const mockTagDefinitions = [
|
||||||
{ tagSlot: 'tag1', displayName: 'category' },
|
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
|
||||||
{ tagSlot: 'tag2', displayName: 'priority' },
|
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const mockTaggedResults = [
|
const mockTaggedResults = [
|
||||||
@@ -689,9 +695,7 @@ describe('Knowledge Search API Route', () => {
|
|||||||
it('should perform tag-only search without query', async () => {
|
it('should perform tag-only search without query', async () => {
|
||||||
const tagOnlyData = {
|
const tagOnlyData = {
|
||||||
knowledgeBaseIds: 'kb-123',
|
knowledgeBaseIds: 'kb-123',
|
||||||
filters: {
|
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||||
category: 'api',
|
|
||||||
},
|
|
||||||
topK: 10,
|
topK: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,10 +710,11 @@ describe('Knowledge Search API Route', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock tag definitions queries for filter mapping and display mapping
|
// Mock tag definitions for validation
|
||||||
mockDbChain.limit
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
|
||||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
// Mock tag definitions queries for display mapping
|
||||||
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||||
|
|
||||||
// Mock the tag-only search handler
|
// Mock the tag-only search handler
|
||||||
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||||
@@ -729,7 +734,9 @@ describe('Knowledge Search API Route', () => {
|
|||||||
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
|
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
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 = {
|
const combinedData = {
|
||||||
knowledgeBaseIds: 'kb-123',
|
knowledgeBaseIds: 'kb-123',
|
||||||
query: 'test search',
|
query: 'test search',
|
||||||
filters: {
|
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||||
category: 'api',
|
|
||||||
},
|
|
||||||
topK: 10,
|
topK: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,10 +759,11 @@ describe('Knowledge Search API Route', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mock tag definitions queries for filter mapping and display mapping
|
// Mock tag definitions for validation
|
||||||
mockDbChain.limit
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for filter mapping
|
|
||||||
.mockResolvedValueOnce(mockTagDefinitions) // Tag definitions for display mapping
|
// Mock tag definitions queries for display mapping
|
||||||
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||||
|
|
||||||
// Mock the tag + vector search handler
|
// Mock the tag + vector search handler
|
||||||
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
|
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
|
||||||
@@ -784,7 +790,9 @@ describe('Knowledge Search API Route', () => {
|
|||||||
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
|
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
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),
|
queryVector: JSON.stringify(mockEmbedding),
|
||||||
distanceThreshold: 1, // Single KB uses threshold of 1.0
|
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 () => {
|
it('should handle tag-only search with multiple knowledge bases', async () => {
|
||||||
const multiKbTagData = {
|
const multiKbTagData = {
|
||||||
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
||||||
filters: {
|
tagFilters: [
|
||||||
category: 'docs',
|
{ tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' },
|
||||||
priority: 'high',
|
{ tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' },
|
||||||
},
|
],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,37 +959,14 @@ describe('Knowledge Search API Route', () => {
|
|||||||
knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' },
|
knowledgeBase: { id: 'kb-456', userId: 'user-123', name: 'Test KB 2' },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reset all mocks before setting up specific behavior
|
// Mock tag definitions for validation
|
||||||
Object.values(mockDbChain).forEach((fn) => {
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
||||||
if (typeof fn === 'function') {
|
|
||||||
fn.mockClear().mockReturnThis()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create fresh mocks for multiple database calls needed for multi-KB tag search
|
// Mock the tag-only search handler
|
||||||
const mockTagDefsQuery1 = {
|
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
||||||
...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),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chain the mocks for: tag defs, search, display mapping KB1, display mapping KB2
|
// Mock tag definitions queries for display mapping
|
||||||
mockDbChain.select
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
||||||
.mockReturnValueOnce(mockTagDefsQuery1)
|
|
||||||
.mockReturnValueOnce(mockTagSearchQuery)
|
|
||||||
.mockReturnValueOnce(mockTagDefsQuery2)
|
|
||||||
.mockReturnValueOnce(mockTagDefsQuery3)
|
|
||||||
|
|
||||||
const req = createMockRequest('POST', multiKbTagData)
|
const req = createMockRequest('POST', multiKbTagData)
|
||||||
const { POST } = await import('@/app/api/knowledge/search/route')
|
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([
|
mockHandleTagOnlySearch.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'chunk-2',
|
id: 'chunk-2',
|
||||||
@@ -1108,13 +1098,15 @@ describe('Knowledge Search API Route', () => {
|
|||||||
const mockTagDefs = {
|
const mockTagDefs = {
|
||||||
select: vi.fn().mockReturnThis(),
|
select: vi.fn().mockReturnThis(),
|
||||||
from: 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)
|
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
filters: { tag1: 'api' },
|
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
|
||||||
topK: 10,
|
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([
|
mockHandleTagAndVectorSearch.mockResolvedValue([
|
||||||
{
|
{
|
||||||
id: 'chunk-3',
|
id: 'chunk-3',
|
||||||
@@ -1176,14 +1173,16 @@ describe('Knowledge Search API Route', () => {
|
|||||||
const mockTagDefs = {
|
const mockTagDefs = {
|
||||||
select: vi.fn().mockReturnThis(),
|
select: vi.fn().mockReturnThis(),
|
||||||
from: 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)
|
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
||||||
|
|
||||||
const req = createMockRequest('POST', {
|
const req = createMockRequest('POST', {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
query: 'relevant content',
|
query: 'relevant content',
|
||||||
filters: { tag1: 'guide' },
|
tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { generateRequestId } from '@/lib/core/utils/request'
|
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 { 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 { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
||||||
import { getUserId } from '@/app/api/auth/oauth/utils'
|
import { getUserId } from '@/app/api/auth/oauth/utils'
|
||||||
@@ -20,6 +22,16 @@ import { calculateCost } from '@/providers/utils'
|
|||||||
|
|
||||||
const logger = createLogger('VectorSearchAPI')
|
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
|
const VectorSearchSchema = z
|
||||||
.object({
|
.object({
|
||||||
knowledgeBaseIds: z.union([
|
knowledgeBaseIds: z.union([
|
||||||
@@ -39,18 +51,17 @@ const VectorSearchSchema = z
|
|||||||
.nullable()
|
.nullable()
|
||||||
.default(10)
|
.default(10)
|
||||||
.transform((val) => val ?? 10),
|
.transform((val) => val ?? 10),
|
||||||
filters: z
|
tagFilters: z
|
||||||
.record(z.string())
|
.array(StructuredTagFilterSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
.transform((val) => val || undefined), // Allow dynamic filter keys (display names)
|
.transform((val) => val || undefined),
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// Ensure at least query or filters are provided
|
|
||||||
const hasQuery = data.query && data.query.trim().length > 0
|
const hasQuery = data.query && data.query.trim().length > 0
|
||||||
const hasFilters = data.filters && Object.keys(data.filters).length > 0
|
const hasTagFilters = data.tagFilters && data.tagFilters.length > 0
|
||||||
return hasQuery || hasFilters
|
return hasQuery || hasTagFilters
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: 'Please provide either a search query or tag filters to search your knowledge base',
|
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
|
// Map display names to tag slots for filtering
|
||||||
let mappedFilters: Record<string, string> = {}
|
let structuredFilters: StructuredFilter[] = []
|
||||||
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)
|
|
||||||
|
|
||||||
logger.debug(`[${requestId}] Found tag definitions:`, tagDefs)
|
// Handle tag filters
|
||||||
logger.debug(`[${requestId}] Original filters:`, validatedData.filters)
|
if (validatedData.tagFilters && accessibleKbIds.length > 0) {
|
||||||
|
const kbId = accessibleKbIds[0]
|
||||||
|
const tagDefs = await getDocumentTagDefinitions(kbId)
|
||||||
|
|
||||||
// Create mapping from display name to tag slot
|
// Create mapping from display name to tag slot and fieldType
|
||||||
const displayNameToSlot: Record<string, string> = {}
|
const displayNameToTagDef: Record<string, { tagSlot: string; fieldType: string }> = {}
|
||||||
tagDefs.forEach((def) => {
|
tagDefs.forEach((def) => {
|
||||||
displayNameToSlot[def.displayName] = def.tagSlot
|
displayNameToTagDef[def.displayName] = {
|
||||||
})
|
tagSlot: def.tagSlot,
|
||||||
|
fieldType: def.fieldType,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Map the filters and handle OR logic
|
// Validate all tag filters first
|
||||||
Object.entries(validatedData.filters).forEach(([key, value]) => {
|
const undefinedTags: string[] = []
|
||||||
if (value) {
|
const typeErrors: string[] = []
|
||||||
const tagSlot = displayNameToSlot[key] || key // Fallback to key if no mapping found
|
|
||||||
|
|
||||||
// Check if this is an OR filter (contains |OR| separator)
|
for (const filter of validatedData.tagFilters) {
|
||||||
if (value.includes('|OR|')) {
|
const tagDef = displayNameToTagDef[filter.tagName]
|
||||||
logger.debug(
|
|
||||||
`[${requestId}] OR filter detected: "${key}" -> "${tagSlot}" = "${value}"`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
mappedFilters[tagSlot] = value
|
// Check if tag exists
|
||||||
logger.debug(`[${requestId}] Mapped filter: "${key}" -> "${tagSlot}" = "${value}"`)
|
if (!tagDef) {
|
||||||
}
|
undefinedTags.push(filter.tagName)
|
||||||
})
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(`[${requestId}] Final mapped filters:`, mappedFilters)
|
// Validate value type using shared validation
|
||||||
} catch (error) {
|
const validationError = validateTagValue(
|
||||||
logger.error(`[${requestId}] Filter mapping error:`, error)
|
filter.tagName,
|
||||||
// If mapping fails, use original filters
|
String(filter.value),
|
||||||
mappedFilters = validatedData.filters
|
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) {
|
if (accessibleKbIds.length === 0) {
|
||||||
@@ -155,26 +202,29 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
let results: SearchResult[]
|
let results: SearchResult[]
|
||||||
|
|
||||||
const hasFilters = mappedFilters && Object.keys(mappedFilters).length > 0
|
const hasFilters = structuredFilters && structuredFilters.length > 0
|
||||||
|
|
||||||
if (!hasQuery && hasFilters) {
|
if (!hasQuery && hasFilters) {
|
||||||
// Tag-only search without vector similarity
|
// 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({
|
results = await handleTagOnlySearch({
|
||||||
knowledgeBaseIds: accessibleKbIds,
|
knowledgeBaseIds: accessibleKbIds,
|
||||||
topK: validatedData.topK,
|
topK: validatedData.topK,
|
||||||
filters: mappedFilters,
|
structuredFilters,
|
||||||
})
|
})
|
||||||
} else if (hasQuery && hasFilters) {
|
} else if (hasQuery && hasFilters) {
|
||||||
// Tag + Vector search
|
// 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 strategy = getQueryStrategy(accessibleKbIds.length, validatedData.topK)
|
||||||
const queryVector = JSON.stringify(await queryEmbeddingPromise)
|
const queryVector = JSON.stringify(await queryEmbeddingPromise)
|
||||||
|
|
||||||
results = await handleTagAndVectorSearch({
|
results = await handleTagAndVectorSearch({
|
||||||
knowledgeBaseIds: accessibleKbIds,
|
knowledgeBaseIds: accessibleKbIds,
|
||||||
topK: validatedData.topK,
|
topK: validatedData.topK,
|
||||||
filters: mappedFilters,
|
structuredFilters,
|
||||||
queryVector,
|
queryVector,
|
||||||
distanceThreshold: strategy.distanceThreshold,
|
distanceThreshold: strategy.distanceThreshold,
|
||||||
})
|
})
|
||||||
@@ -257,9 +307,9 @@ export async function POST(request: NextRequest) {
|
|||||||
// Create tags object with display names
|
// Create tags object with display names
|
||||||
const tags: Record<string, any> = {}
|
const tags: Record<string, any> = {}
|
||||||
|
|
||||||
TAG_SLOTS.forEach((slot) => {
|
ALL_TAG_SLOTS.forEach((slot) => {
|
||||||
const tagValue = (result as any)[slot]
|
const tagValue = (result as any)[slot]
|
||||||
if (tagValue) {
|
if (tagValue !== null && tagValue !== undefined) {
|
||||||
const displayName = kbTagMap[slot] || slot
|
const displayName = kbTagMap[slot] || slot
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`
|
`[${requestId}] Mapping ${slot}="${tagValue}" -> "${displayName}"="${tagValue}"`
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('Knowledge Search Utils', () => {
|
|||||||
const params = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: {},
|
structuredFilters: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(handleTagOnlySearch(params)).rejects.toThrow(
|
await expect(handleTagOnlySearch(params)).rejects.toThrow(
|
||||||
@@ -66,14 +66,14 @@ describe('Knowledge Search Utils', () => {
|
|||||||
const params = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: { tag1: 'api' },
|
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// This test validates the function accepts the right parameters
|
// This test validates the function accepts the right parameters
|
||||||
// The actual database interaction is tested via route tests
|
// The actual database interaction is tested via route tests
|
||||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||||
expect(params.topK).toBe(10)
|
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 = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: {},
|
structuredFilters: [],
|
||||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||||
distanceThreshold: 0.8,
|
distanceThreshold: 0.8,
|
||||||
}
|
}
|
||||||
@@ -137,7 +137,7 @@ describe('Knowledge Search Utils', () => {
|
|||||||
const params = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: { tag1: 'api' },
|
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||||
distanceThreshold: 0.8,
|
distanceThreshold: 0.8,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ describe('Knowledge Search Utils', () => {
|
|||||||
const params = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: { tag1: 'api' },
|
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +163,7 @@ describe('Knowledge Search Utils', () => {
|
|||||||
const params = {
|
const params = {
|
||||||
knowledgeBaseIds: ['kb-123'],
|
knowledgeBaseIds: ['kb-123'],
|
||||||
topK: 10,
|
topK: 10,
|
||||||
filters: { tag1: 'api' },
|
structuredFilters: [{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api' }],
|
||||||
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
queryVector: JSON.stringify([0.1, 0.2, 0.3]),
|
||||||
distanceThreshold: 0.8,
|
distanceThreshold: 0.8,
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ describe('Knowledge Search Utils', () => {
|
|||||||
// This test validates the function accepts the right parameters
|
// This test validates the function accepts the right parameters
|
||||||
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
expect(params.knowledgeBaseIds).toEqual(['kb-123'])
|
||||||
expect(params.topK).toBe(10)
|
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.queryVector).toBe(JSON.stringify([0.1, 0.2, 0.3]))
|
||||||
expect(params.distanceThreshold).toBe(0.8)
|
expect(params.distanceThreshold).toBe(0.8)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { document, embedding } from '@sim/db/schema'
|
import { document, embedding } from '@sim/db/schema'
|
||||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
import { and, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||||
|
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('KnowledgeSearchUtils')
|
const logger = createLogger('KnowledgeSearchUtils')
|
||||||
@@ -34,6 +35,7 @@ export interface SearchResult {
|
|||||||
content: string
|
content: string
|
||||||
documentId: string
|
documentId: string
|
||||||
chunkIndex: number
|
chunkIndex: number
|
||||||
|
// Text tags
|
||||||
tag1: string | null
|
tag1: string | null
|
||||||
tag2: string | null
|
tag2: string | null
|
||||||
tag3: string | null
|
tag3: string | null
|
||||||
@@ -41,6 +43,19 @@ export interface SearchResult {
|
|||||||
tag5: string | null
|
tag5: string | null
|
||||||
tag6: string | null
|
tag6: string | null
|
||||||
tag7: 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
|
distance: number
|
||||||
knowledgeBaseId: string
|
knowledgeBaseId: string
|
||||||
}
|
}
|
||||||
@@ -48,7 +63,7 @@ export interface SearchResult {
|
|||||||
export interface SearchParams {
|
export interface SearchParams {
|
||||||
knowledgeBaseIds: string[]
|
knowledgeBaseIds: string[]
|
||||||
topK: number
|
topK: number
|
||||||
filters?: Record<string, string>
|
structuredFilters?: StructuredFilter[]
|
||||||
queryVector?: string
|
queryVector?: string
|
||||||
distanceThreshold?: number
|
distanceThreshold?: number
|
||||||
}
|
}
|
||||||
@@ -56,46 +71,230 @@ export interface SearchParams {
|
|||||||
// Use shared embedding utility
|
// Use shared embedding utility
|
||||||
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
|
export { generateSearchEmbedding } from '@/lib/knowledge/embeddings'
|
||||||
|
|
||||||
function getTagFilters(filters: Record<string, string>, embedding: any) {
|
/** All valid tag slot keys */
|
||||||
return Object.entries(filters).map(([key, value]) => {
|
const TAG_SLOT_KEYS = [
|
||||||
// Handle OR logic within same tag
|
// Text tags (7 slots)
|
||||||
const values = value.includes('|OR|') ? value.split('|OR|') : [value]
|
'tag1',
|
||||||
logger.debug(`[getTagFilters] Processing ${key}="${value}" -> values:`, values)
|
'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) => {
|
type TagSlotKey = (typeof TAG_SLOT_KEYS)[number]
|
||||||
switch (key) {
|
|
||||||
case 'tag1':
|
function isTagSlotKey(key: string): key is TagSlotKey {
|
||||||
return embedding.tag1
|
return TAG_SLOT_KEYS.includes(key as TagSlotKey)
|
||||||
case 'tag2':
|
}
|
||||||
return embedding.tag2
|
|
||||||
case 'tag3':
|
/** Common fields selected for search results */
|
||||||
return embedding.tag3
|
const getSearchResultFields = (distanceExpr: any) => ({
|
||||||
case 'tag4':
|
id: embedding.id,
|
||||||
return embedding.tag4
|
content: embedding.content,
|
||||||
case 'tag5':
|
documentId: embedding.documentId,
|
||||||
return embedding.tag5
|
chunkIndex: embedding.chunkIndex,
|
||||||
case 'tag6':
|
// Text tags
|
||||||
return embedding.tag6
|
tag1: embedding.tag1,
|
||||||
case 'tag7':
|
tag2: embedding.tag2,
|
||||||
return embedding.tag7
|
tag3: embedding.tag3,
|
||||||
default:
|
tag4: embedding.tag4,
|
||||||
return null
|
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)
|
switch (operator) {
|
||||||
if (!column) return sql`1=1` // No-op for unknown keys
|
case 'eq':
|
||||||
|
return sql`${column}::date = ${dateStr}::date`
|
||||||
if (values.length === 1) {
|
case 'neq':
|
||||||
// Single value - simple equality
|
return sql`${column}::date != ${dateStr}::date`
|
||||||
logger.debug(`[getTagFilters] Single value filter: ${key} = ${values[0]}`)
|
case 'gt':
|
||||||
return sql`LOWER(${column}) = LOWER(${values[0]})`
|
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})`)
|
// Handle boolean operators
|
||||||
return sql`(${sql.join(orConditions, sql` OR `)})`
|
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) {
|
export function getQueryStrategy(kbCount: number, topK: number) {
|
||||||
@@ -113,8 +312,10 @@ export function getQueryStrategy(kbCount: number, topK: number) {
|
|||||||
|
|
||||||
async function executeTagFilterQuery(
|
async function executeTagFilterQuery(
|
||||||
knowledgeBaseIds: string[],
|
knowledgeBaseIds: string[],
|
||||||
filters: Record<string, string>
|
structuredFilters: StructuredFilter[]
|
||||||
): Promise<{ id: string }[]> {
|
): Promise<{ id: string }[]> {
|
||||||
|
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||||
|
|
||||||
if (knowledgeBaseIds.length === 1) {
|
if (knowledgeBaseIds.length === 1) {
|
||||||
return await db
|
return await db
|
||||||
.select({ id: embedding.id })
|
.select({ id: embedding.id })
|
||||||
@@ -125,7 +326,7 @@ async function executeTagFilterQuery(
|
|||||||
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
|
eq(embedding.knowledgeBaseId, knowledgeBaseIds[0]),
|
||||||
eq(embedding.enabled, true),
|
eq(embedding.enabled, true),
|
||||||
isNull(document.deletedAt),
|
isNull(document.deletedAt),
|
||||||
...getTagFilters(filters, embedding)
|
...tagFilterConditions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -138,7 +339,7 @@ async function executeTagFilterQuery(
|
|||||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||||
eq(embedding.enabled, true),
|
eq(embedding.enabled, true),
|
||||||
isNull(document.deletedAt),
|
isNull(document.deletedAt),
|
||||||
...getTagFilters(filters, embedding)
|
...tagFilterConditions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -154,21 +355,11 @@ async function executeVectorSearchOnIds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select(
|
||||||
id: embedding.id,
|
getSearchResultFields(
|
||||||
content: embedding.content,
|
sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(embedding)
|
.from(embedding)
|
||||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -183,15 +374,16 @@ async function executeVectorSearchOnIds(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleTagOnlySearch(params: SearchParams): Promise<SearchResult[]> {
|
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')
|
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 strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||||
|
const tagFilterConditions = getStructuredTagFilters(structuredFilters, embedding)
|
||||||
|
|
||||||
if (strategy.useParallel) {
|
if (strategy.useParallel) {
|
||||||
// Parallel approach for many KBs
|
// Parallel approach for many KBs
|
||||||
@@ -199,21 +391,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
|||||||
|
|
||||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(embedding)
|
.from(embedding)
|
||||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -221,7 +399,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
|||||||
eq(embedding.knowledgeBaseId, kbId),
|
eq(embedding.knowledgeBaseId, kbId),
|
||||||
eq(embedding.enabled, true),
|
eq(embedding.enabled, true),
|
||||||
isNull(document.deletedAt),
|
isNull(document.deletedAt),
|
||||||
...getTagFilters(filters, embedding)
|
...tagFilterConditions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(parallelLimit)
|
.limit(parallelLimit)
|
||||||
@@ -232,21 +410,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
|||||||
}
|
}
|
||||||
// Single query for fewer KBs
|
// Single query for fewer KBs
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select(getSearchResultFields(sql<number>`0`.as('distance')))
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(embedding)
|
.from(embedding)
|
||||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -254,7 +418,7 @@ export async function handleTagOnlySearch(params: SearchParams): Promise<SearchR
|
|||||||
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
inArray(embedding.knowledgeBaseId, knowledgeBaseIds),
|
||||||
eq(embedding.enabled, true),
|
eq(embedding.enabled, true),
|
||||||
isNull(document.deletedAt),
|
isNull(document.deletedAt),
|
||||||
...getTagFilters(filters, embedding)
|
...tagFilterConditions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(topK)
|
.limit(topK)
|
||||||
@@ -271,27 +435,15 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
|||||||
|
|
||||||
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
const strategy = getQueryStrategy(knowledgeBaseIds.length, topK)
|
||||||
|
|
||||||
|
const distanceExpr = sql<number>`${embedding.embedding} <=> ${queryVector}::vector`.as('distance')
|
||||||
|
|
||||||
if (strategy.useParallel) {
|
if (strategy.useParallel) {
|
||||||
// Parallel approach for many KBs
|
// Parallel approach for many KBs
|
||||||
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
|
const parallelLimit = Math.ceil(topK / knowledgeBaseIds.length) + 5
|
||||||
|
|
||||||
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
const queryPromises = knowledgeBaseIds.map(async (kbId) => {
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select(getSearchResultFields(distanceExpr))
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(embedding)
|
.from(embedding)
|
||||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -312,21 +464,7 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
|||||||
}
|
}
|
||||||
// Single query for fewer KBs
|
// Single query for fewer KBs
|
||||||
return await db
|
return await db
|
||||||
.select({
|
.select(getSearchResultFields(distanceExpr))
|
||||||
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,
|
|
||||||
})
|
|
||||||
.from(embedding)
|
.from(embedding)
|
||||||
.innerJoin(document, eq(embedding.documentId, document.id))
|
.innerJoin(document, eq(embedding.documentId, document.id))
|
||||||
.where(
|
.where(
|
||||||
@@ -342,19 +480,22 @@ export async function handleVectorOnlySearch(params: SearchParams): Promise<Sear
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function handleTagAndVectorSearch(params: SearchParams): Promise<SearchResult[]> {
|
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')
|
throw new Error('Tag filters are required for tag and vector search')
|
||||||
}
|
}
|
||||||
if (!queryVector || !distanceThreshold) {
|
if (!queryVector || !distanceThreshold) {
|
||||||
throw new Error('Query vector and distance threshold are required for tag and vector search')
|
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
|
// Step 1: Filter by tags first
|
||||||
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, filters)
|
const tagFilteredIds = await executeTagFilterQuery(knowledgeBaseIds, structuredFilters)
|
||||||
|
|
||||||
if (tagFilteredIds.length === 0) {
|
if (tagFilteredIds.length === 0) {
|
||||||
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)
|
logger.debug(`[handleTagAndVectorSearch] No results found after tag filtering`)
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface DocumentData {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
deletedAt?: Date | null
|
deletedAt?: Date | null
|
||||||
uploadedAt: Date
|
uploadedAt: Date
|
||||||
// Document tags
|
// Text tags
|
||||||
tag1?: string | null
|
tag1?: string | null
|
||||||
tag2?: string | null
|
tag2?: string | null
|
||||||
tag3?: string | null
|
tag3?: string | null
|
||||||
@@ -43,6 +43,19 @@ export interface DocumentData {
|
|||||||
tag5?: string | null
|
tag5?: string | null
|
||||||
tag6?: string | null
|
tag6?: string | null
|
||||||
tag7?: 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 {
|
export interface EmbeddingData {
|
||||||
@@ -58,7 +71,7 @@ export interface EmbeddingData {
|
|||||||
embeddingModel: string
|
embeddingModel: string
|
||||||
startOffset: number
|
startOffset: number
|
||||||
endOffset: number
|
endOffset: number
|
||||||
// Tag fields for filtering
|
// Text tags
|
||||||
tag1?: string | null
|
tag1?: string | null
|
||||||
tag2?: string | null
|
tag2?: string | null
|
||||||
tag3?: string | null
|
tag3?: string | null
|
||||||
@@ -66,6 +79,19 @@ export interface EmbeddingData {
|
|||||||
tag5?: string | null
|
tag5?: string | null
|
||||||
tag6?: string | null
|
tag6?: string | null
|
||||||
tag7?: 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
|
enabled: boolean
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
@@ -232,6 +258,27 @@ export async function checkDocumentWriteAccess(
|
|||||||
processingStartedAt: document.processingStartedAt,
|
processingStartedAt: document.processingStartedAt,
|
||||||
processingCompletedAt: document.processingCompletedAt,
|
processingCompletedAt: document.processingCompletedAt,
|
||||||
knowledgeBaseId: document.knowledgeBaseId,
|
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)
|
.from(document)
|
||||||
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))
|
.where(and(eq(document.id, documentId), isNull(document.deletedAt)))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
|
import { isSensitiveKey, REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { ensureZodObject, normalizeUrl } from '@/app/api/tools/stagehand/utils'
|
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) {
|
if (variablesObject && Object.keys(variablesObject).length > 0) {
|
||||||
const safeVarKeys = Object.keys(variablesObject).map((key) => {
|
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 })
|
logger.info('Variables available for task', { variables: safeVarKeys })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mockGetWorkflowById = vi.fn()
|
|||||||
const mockGetWorkflowAccessContext = vi.fn()
|
const mockGetWorkflowAccessContext = vi.fn()
|
||||||
const mockDbDelete = vi.fn()
|
const mockDbDelete = vi.fn()
|
||||||
const mockDbUpdate = vi.fn()
|
const mockDbUpdate = vi.fn()
|
||||||
|
const mockDbSelect = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/lib/auth', () => ({
|
vi.mock('@/lib/auth', () => ({
|
||||||
getSession: () => mockGetSession(),
|
getSession: () => mockGetSession(),
|
||||||
@@ -49,6 +50,7 @@ vi.mock('@sim/db', () => ({
|
|||||||
db: {
|
db: {
|
||||||
delete: () => mockDbDelete(),
|
delete: () => mockDbDelete(),
|
||||||
update: () => mockDbUpdate(),
|
update: () => mockDbUpdate(),
|
||||||
|
select: () => mockDbSelect(),
|
||||||
},
|
},
|
||||||
workflow: {},
|
workflow: {},
|
||||||
}))
|
}))
|
||||||
@@ -327,6 +329,13 @@ describe('Workflow By ID API Route', () => {
|
|||||||
isWorkspaceOwner: false,
|
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({
|
mockDbDelete.mockReturnValue({
|
||||||
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
where: vi.fn().mockResolvedValue([{ id: 'workflow-123' }]),
|
||||||
})
|
})
|
||||||
@@ -347,6 +356,46 @@ describe('Workflow By ID API Route', () => {
|
|||||||
expect(data.success).toBe(true)
|
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 () => {
|
it.concurrent('should deny deletion for non-admin users', async () => {
|
||||||
const mockWorkflow = {
|
const mockWorkflow = {
|
||||||
id: 'workflow-123',
|
id: 'workflow-123',
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
|
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
|
// Check if workflow has published templates before deletion
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const checkTemplates = searchParams.get('check-templates') === 'true'
|
const checkTemplates = searchParams.get('check-templates') === 'true'
|
||||||
|
|||||||
@@ -98,23 +98,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
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 body = await request.json()
|
||||||
const { name } = CreateKeySchema.parse(body)
|
const { name } = CreateKeySchema.parse(body)
|
||||||
|
|
||||||
@@ -202,23 +185,6 @@ export async function DELETE(
|
|||||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
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 body = await request.json()
|
||||||
const { keys } = DeleteKeysSchema.parse(body)
|
const { keys } = DeleteKeysSchema.parse(body)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Loader2 } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Combobox,
|
Combobox,
|
||||||
|
DatePicker,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
Trash,
|
Trash,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 type { DocumentTag } from '@/lib/knowledge/tags/types'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +29,54 @@ import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
|||||||
|
|
||||||
const logger = createLogger('DocumentTagsModal')
|
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 {
|
interface DocumentTagsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -67,17 +116,21 @@ export function DocumentTagsModal({
|
|||||||
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
||||||
const tags: DocumentTag[] = []
|
const tags: DocumentTag[] = []
|
||||||
|
|
||||||
TAG_SLOTS.forEach((slot) => {
|
ALL_TAG_SLOTS.forEach((slot) => {
|
||||||
const value = docData[slot] as string | null | undefined
|
const rawValue = docData[slot]
|
||||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||||
|
|
||||||
if (value?.trim() && definition) {
|
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||||
tags.push({
|
// Convert value to string for storage
|
||||||
slot,
|
const stringValue = String(rawValue).trim()
|
||||||
displayName: definition.displayName,
|
if (stringValue) {
|
||||||
fieldType: definition.fieldType,
|
tags.push({
|
||||||
value: value.trim(),
|
slot,
|
||||||
})
|
displayName: definition.displayName,
|
||||||
|
fieldType: definition.fieldType,
|
||||||
|
value: stringValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -95,13 +148,15 @@ export function DocumentTagsModal({
|
|||||||
try {
|
try {
|
||||||
const tagData: Record<string, string> = {}
|
const tagData: Record<string, string> = {}
|
||||||
|
|
||||||
TAG_SLOTS.forEach((slot) => {
|
// Only include tags that have values (omit empty ones)
|
||||||
tagData[slot] = ''
|
// Use empty string for slots that should be cleared
|
||||||
})
|
ALL_TAG_SLOTS.forEach((slot) => {
|
||||||
|
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||||
tagsToSave.forEach((tag) => {
|
if (tag?.value.trim()) {
|
||||||
if (tag.value.trim()) {
|
tagData[slot] = tag.value.trim()
|
||||||
tagData[tag.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')
|
throw new Error('Failed to update document tags')
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
updateDocumentInStore(knowledgeBaseId, documentId, tagData as Record<string, string>)
|
||||||
onDocumentUpdate?.(tagData)
|
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||||
|
|
||||||
await fetchTagDefinitions()
|
await fetchTagDefinitions()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -279,7 +334,7 @@ export function DocumentTagsModal({
|
|||||||
const newDefinition: TagDefinitionInput = {
|
const newDefinition: TagDefinitionInput = {
|
||||||
displayName: formData.displayName,
|
displayName: formData.displayName,
|
||||||
fieldType: formData.fieldType,
|
fieldType: formData.fieldType,
|
||||||
tagSlot: targetSlot as TagSlot,
|
tagSlot: targetSlot as AllTagSlot,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saveTagDefinitions) {
|
if (saveTagDefinitions) {
|
||||||
@@ -359,20 +414,7 @@ export function DocumentTagsModal({
|
|||||||
<ModalBody className='!pb-[16px]'>
|
<ModalBody className='!pb-[16px]'>
|
||||||
<div className='min-h-0 flex-1 overflow-y-auto'>
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
<div className='space-y-[8px]'>
|
<div className='space-y-[8px]'>
|
||||||
<Label>
|
<Label>Tags</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{documentTags.map((tag, index) => (
|
{documentTags.map((tag, index) => (
|
||||||
<div key={index} className='space-y-[8px]'>
|
<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)]'>
|
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
{tag.displayName}
|
{tag.displayName}
|
||||||
</span>
|
</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]' />
|
<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)]'>
|
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||||
{tag.value}
|
{formatValueForDisplay(tag.value, tag.fieldType)}
|
||||||
</span>
|
</span>
|
||||||
<div className='flex flex-shrink-0 items-center gap-1'>
|
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||||
<Button
|
<Button
|
||||||
@@ -415,10 +460,16 @@ export function DocumentTagsModal({
|
|||||||
const def = kbTagDefinitions.find(
|
const def = kbTagDefinitions.find(
|
||||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||||
)
|
)
|
||||||
|
const newFieldType = def?.fieldType || 'text'
|
||||||
setEditTagForm({
|
setEditTagForm({
|
||||||
...editTagForm,
|
...editTagForm,
|
||||||
displayName: value,
|
displayName: value,
|
||||||
fieldType: def?.fieldType || 'text',
|
fieldType: newFieldType,
|
||||||
|
value: getValueForFieldType(
|
||||||
|
newFieldType,
|
||||||
|
editTagForm.fieldType,
|
||||||
|
editTagForm.value
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
placeholder='Enter or select tag name'
|
placeholder='Enter or select tag name'
|
||||||
@@ -453,33 +504,70 @@ export function DocumentTagsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
||||||
<Input
|
{editTagForm.fieldType === 'boolean' ? (
|
||||||
id={`tagValue-${index}`}
|
<Combobox
|
||||||
value={editTagForm.value}
|
id={`tagValue-${index}`}
|
||||||
onChange={(e) =>
|
options={[
|
||||||
setEditTagForm({ ...editTagForm, value: e.target.value })
|
{ label: 'True', value: 'true' },
|
||||||
}
|
{ label: 'False', value: 'false' },
|
||||||
placeholder='Enter tag value'
|
]}
|
||||||
onKeyDown={(e) => {
|
value={editTagForm.value}
|
||||||
if (e.key === 'Enter' && canSaveTag) {
|
selectedValue={editTagForm.value}
|
||||||
e.preventDefault()
|
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||||
saveDocumentTag()
|
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') {
|
placeholder='Enter tag value'
|
||||||
e.preventDefault()
|
onKeyDown={(e) => {
|
||||||
cancelEditingTag()
|
if (e.key === 'Enter' && canSaveTag) {
|
||||||
}
|
e.preventDefault()
|
||||||
}}
|
saveDocumentTag()
|
||||||
/>
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex gap-[8px]'>
|
<div className='flex gap-[8px]'>
|
||||||
@@ -500,7 +588,7 @@ export function DocumentTagsModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!isTagEditing && (
|
{documentTags.length > 0 && !isTagEditing && (
|
||||||
<Button
|
<Button
|
||||||
variant='default'
|
variant='default'
|
||||||
onClick={openTagCreator}
|
onClick={openTagCreator}
|
||||||
@@ -511,7 +599,7 @@ export function DocumentTagsModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isCreatingTag && (
|
{(isCreatingTag || documentTags.length === 0) && editingTagIndex === null && (
|
||||||
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||||
<div className='flex flex-col gap-[8px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<Label htmlFor='newTagName'>Tag Name</Label>
|
<Label htmlFor='newTagName'>Tag Name</Label>
|
||||||
@@ -525,10 +613,16 @@ export function DocumentTagsModal({
|
|||||||
const def = kbTagDefinitions.find(
|
const def = kbTagDefinitions.find(
|
||||||
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||||
)
|
)
|
||||||
|
const newFieldType = def?.fieldType || 'text'
|
||||||
setEditTagForm({
|
setEditTagForm({
|
||||||
...editTagForm,
|
...editTagForm,
|
||||||
displayName: value,
|
displayName: value,
|
||||||
fieldType: def?.fieldType || 'text',
|
fieldType: newFieldType,
|
||||||
|
value: getValueForFieldType(
|
||||||
|
newFieldType,
|
||||||
|
editTagForm.fieldType,
|
||||||
|
editTagForm.value
|
||||||
|
),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
placeholder='Enter or select tag name'
|
placeholder='Enter or select tag name'
|
||||||
@@ -563,31 +657,68 @@ export function DocumentTagsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<Label htmlFor='newTagValue'>Value</Label>
|
<Label htmlFor='newTagValue'>Value</Label>
|
||||||
<Input
|
{editTagForm.fieldType === 'boolean' ? (
|
||||||
id='newTagValue'
|
<Combobox
|
||||||
value={editTagForm.value}
|
id='newTagValue'
|
||||||
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
options={[
|
||||||
placeholder='Enter tag value'
|
{ label: 'True', value: 'true' },
|
||||||
onKeyDown={(e) => {
|
{ label: 'False', value: 'false' },
|
||||||
if (e.key === 'Enter' && canSaveTag) {
|
]}
|
||||||
e.preventDefault()
|
value={editTagForm.value}
|
||||||
saveDocumentTag()
|
selectedValue={editTagForm.value}
|
||||||
}
|
onChange={(value) => setEditTagForm({ ...editTagForm, value })}
|
||||||
if (e.key === 'Escape') {
|
placeholder='Select value'
|
||||||
e.preventDefault()
|
/>
|
||||||
cancelEditingTag()
|
) : 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>
|
</div>
|
||||||
|
|
||||||
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||||
@@ -604,9 +735,11 @@ export function DocumentTagsModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className='flex gap-[8px]'>
|
<div className='flex gap-[8px]'>
|
||||||
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
{documentTags.length > 0 && (
|
||||||
Cancel
|
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||||
</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
onClick={saveDocumentTag}
|
onClick={saveDocumentTag}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Loader2 } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Combobox,
|
||||||
|
type ComboboxOption,
|
||||||
Input,
|
Input,
|
||||||
Label,
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -14,7 +16,7 @@ import {
|
|||||||
Trash,
|
Trash,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,14 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('BaseTagsModal')
|
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 {
|
interface TagUsageData {
|
||||||
tagName: string
|
tagName: string
|
||||||
tagSlot: string
|
tagSlot: string
|
||||||
@@ -174,22 +184,55 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
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 () => {
|
const saveTagDefinition = async () => {
|
||||||
if (!canSaveTag()) return
|
if (!canSaveTag()) return
|
||||||
|
|
||||||
setIsSavingTag(true)
|
setIsSavingTag(true)
|
||||||
try {
|
try {
|
||||||
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
// Check if selected field type has available slots
|
||||||
const availableSlot = (
|
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||||
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||||
).find((slot) => !usedSlots.has(slot))
|
}
|
||||||
|
|
||||||
if (!availableSlot) {
|
// Get the next available slot from the API
|
||||||
throw new Error('No available tag slots')
|
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 = {
|
const newTagDefinition = {
|
||||||
tagSlot: availableSlot,
|
tagSlot: slotResult.data.nextAvailableSlot,
|
||||||
displayName: createTagForm.displayName.trim(),
|
displayName: createTagForm.displayName.trim(),
|
||||||
fieldType: createTagForm.fieldType,
|
fieldType: createTagForm.fieldType,
|
||||||
}
|
}
|
||||||
@@ -277,7 +320,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
<Label>
|
<Label>
|
||||||
Tags:{' '}
|
Tags:{' '}
|
||||||
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||||
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
|
{kbTagDefinitions.length} defined
|
||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
@@ -300,6 +343,9 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
{tag.displayName}
|
{tag.displayName}
|
||||||
</span>
|
</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]' />
|
<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)]'>
|
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
|
||||||
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
||||||
@@ -324,7 +370,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
<Button
|
<Button
|
||||||
variant='default'
|
variant='default'
|
||||||
onClick={openTagCreator}
|
onClick={openTagCreator}
|
||||||
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
disabled={!SUPPORTED_FIELD_TYPES.some((type) => hasAvailableSlots(type))}
|
||||||
className='w-full'
|
className='w-full'
|
||||||
>
|
>
|
||||||
Add Tag
|
Add Tag
|
||||||
@@ -361,12 +407,22 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Type selector commented out - only "text" type is currently supported
|
|
||||||
<div className='flex flex-col gap-[8px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
<Label htmlFor='tagType'>Type</Label>
|
<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>
|
||||||
*/}
|
|
||||||
|
|
||||||
<div className='flex gap-[8px]'>
|
<div className='flex gap-[8px]'>
|
||||||
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
||||||
@@ -376,7 +432,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
variant='primary'
|
variant='primary'
|
||||||
onClick={saveTagDefinition}
|
onClick={saveTagDefinition}
|
||||||
className='flex-1'
|
className='flex-1'
|
||||||
disabled={!canSaveTag() || isSavingTag}
|
disabled={
|
||||||
|
!canSaveTag() ||
|
||||||
|
isSavingTag ||
|
||||||
|
!hasAvailableSlots(createTagForm.fieldType)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSavingTag ? (
|
{isSavingTag ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -339,12 +339,31 @@ export function CreateBaseModal({
|
|||||||
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
<div className='space-y-[12px]'>
|
<div className='space-y-[12px]'>
|
||||||
<div className='flex flex-col gap-[8px]'>
|
<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
|
<Input
|
||||||
id='name'
|
id='kb-name'
|
||||||
placeholder='Enter knowledge base name'
|
placeholder='Enter knowledge base name'
|
||||||
{...register('name')}
|
{...register('name')}
|
||||||
className={cn(errors.name && 'border-[var(--text-error)]')}
|
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useRef, useState } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { FIELD_TYPE_LABELS, getPlaceholderForFieldType } from '@/lib/knowledge/constants'
|
||||||
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
|
||||||
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
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 { 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'
|
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
|
id: string
|
||||||
cells: {
|
cells: {
|
||||||
tagName: string
|
tagName: string
|
||||||
type: string
|
tagSlot?: string
|
||||||
|
fieldType: string
|
||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,17 +65,11 @@ export function DocumentTagEntry({
|
|||||||
|
|
||||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
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
|
// Use preview value when in preview mode, otherwise use store value
|
||||||
const currentValue = isPreview ? previewValue : storeValue
|
const currentValue = isPreview ? previewValue : storeValue
|
||||||
|
|
||||||
// Transform stored JSON string to table format for display
|
// Transform stored JSON string to table format for display
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
// If we have stored data, use it
|
|
||||||
if (currentValue) {
|
if (currentValue) {
|
||||||
try {
|
try {
|
||||||
const tagData = JSON.parse(currentValue)
|
const tagData = JSON.parse(currentValue)
|
||||||
@@ -85,7 +78,8 @@ export function DocumentTagEntry({
|
|||||||
id: tag.id || `tag-${index}`,
|
id: tag.id || `tag-${index}`,
|
||||||
cells: {
|
cells: {
|
||||||
tagName: tag.tagName || '',
|
tagName: tag.tagName || '',
|
||||||
type: tag.fieldType || 'text',
|
tagSlot: tag.tagSlot,
|
||||||
|
fieldType: tag.fieldType || 'text',
|
||||||
value: tag.value || '',
|
value: tag.value || '',
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -99,137 +93,109 @@ export function DocumentTagEntry({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'empty-row-0',
|
id: 'empty-row-0',
|
||||||
cells: { tagName: '', type: 'text', value: '' },
|
cells: { tagName: '', tagSlot: undefined, fieldType: 'text', value: '' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [currentValue])
|
}, [currentValue])
|
||||||
|
|
||||||
// Get available tag names and check for case-insensitive duplicates
|
// Get tag names already used in rows (case-insensitive)
|
||||||
const usedTagNames = new Set(
|
const usedTagNames = useMemo(() => {
|
||||||
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
|
return new Set(
|
||||||
)
|
rows.map((row) => row.cells.tagName?.toLowerCase()).filter((name) => name?.trim())
|
||||||
|
)
|
||||||
|
}, [rows])
|
||||||
|
|
||||||
const availableTagDefinitions = tagDefinitions.filter(
|
// Filter available tags (exclude already used ones)
|
||||||
(def) => !usedTagNames.has(def.displayName.toLowerCase())
|
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
|
// Can add more tags if there are available tag definitions
|
||||||
const newTagsBeingCreated = rows.filter(
|
const canAddMoreTags = availableTagDefinitions.length > 0
|
||||||
(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared helper function for updating rows and generating JSON
|
// 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) => {
|
const updatedRows = [...rows].map((row, idx) => {
|
||||||
if (idx === rowIndex) {
|
if (idx === rowIndex) {
|
||||||
const newCells = { ...row.cells, [column]: value }
|
const newCells = { ...row.cells, [column]: value }
|
||||||
|
|
||||||
// Auto-select type when existing tag is selected
|
// When selecting a tag, also set the tagSlot and fieldType
|
||||||
if (column === 'tagName' && value) {
|
if (column === 'tagName' && tagDef) {
|
||||||
const tagDef = tagDefinitions.find(
|
newCells.tagSlot = tagDef.tagSlot
|
||||||
(def) => def.displayName.toLowerCase() === value.toLowerCase()
|
newCells.fieldType = tagDef.fieldType
|
||||||
)
|
// Clear value when tag changes
|
||||||
if (tagDef) {
|
if (row.cells.tagName !== value) {
|
||||||
newCells.type = tagDef.fieldType
|
newCells.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { ...row, cells: newCells }
|
||||||
...row,
|
|
||||||
cells: newCells,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
|
|
||||||
// Store all rows including empty ones - don't auto-remove
|
|
||||||
const dataToStore = updatedRows.map((row) => ({
|
const dataToStore = updatedRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
tagName: row.cells.tagName || '',
|
tagName: row.cells.tagName || '',
|
||||||
fieldType: row.cells.type || 'text',
|
tagSlot: row.cells.tagSlot,
|
||||||
|
fieldType: row.cells.fieldType || 'text',
|
||||||
value: row.cells.value || '',
|
value: row.cells.value || '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return dataToStore.length > 0 ? JSON.stringify(dataToStore) : ''
|
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
|
if (isPreview || disabled) return
|
||||||
|
|
||||||
// Check if this is a new tag name that would exceed the limit
|
const tagDef = tagDefinitions.find((def) => def.displayName === tagName)
|
||||||
if (column === 'tagName' && value.trim()) {
|
const jsonString = updateRowsAndGenerateJson(rowIndex, 'tagName', tagName, tagDef)
|
||||||
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)
|
|
||||||
setStoreValue(jsonString)
|
setStoreValue(jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
|
const handleValueChange = (rowIndex: number, value: string) => {
|
||||||
if (isPreview || disabled) return
|
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)
|
emitTagSelection(jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddRow = () => {
|
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 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: '' }]
|
const newData = [...currentData, { id: newRowId, tagName: '', fieldType: 'text', value: '' }]
|
||||||
setStoreValue(JSON.stringify(newData))
|
setStoreValue(JSON.stringify(newData))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteRow = (rowIndex: number) => {
|
const handleDeleteRow = (rowIndex: number) => {
|
||||||
if (isPreview || disabled || rows.length <= 1) return
|
if (isPreview || disabled) return
|
||||||
const updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
|
||||||
|
|
||||||
// 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) => ({
|
const tableDataForStorage = updatedRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
tagName: row.cells.tagName || '',
|
tagName: row.cells.tagName || '',
|
||||||
fieldType: row.cells.type || 'text',
|
tagSlot: row.cells.tagSlot,
|
||||||
|
fieldType: row.cells.fieldType || 'text',
|
||||||
value: row.cells.value || '',
|
value: row.cells.value || '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -237,15 +203,15 @@ export function DocumentTagEntry({
|
|||||||
setStoreValue(jsonString)
|
setStoreValue(jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate tag names (case-insensitive)
|
if (isPreview) {
|
||||||
const getDuplicateStatus = (rowIndex: number, tagName: string) => {
|
const tagCount = rows.filter((r) => r.cells.tagName?.trim()).length
|
||||||
if (!tagName.trim()) return false
|
return (
|
||||||
const lowerTagName = tagName.toLowerCase()
|
<div className='space-y-1'>
|
||||||
return rows.some(
|
<Label className='font-medium text-muted-foreground text-xs'>Document Tags</Label>
|
||||||
(row, idx) =>
|
<div className='text-muted-foreground text-sm'>
|
||||||
idx !== rowIndex &&
|
{tagCount > 0 ? `${tagCount} tag(s) configured` : 'No tags'}
|
||||||
row.cells.tagName?.toLowerCase() === lowerTagName &&
|
</div>
|
||||||
row.cells.tagName.trim()
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,209 +219,82 @@ export function DocumentTagEntry({
|
|||||||
return <div className='p-4 text-muted-foreground text-sm'>Loading tag definitions...</div>
|
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 = () => (
|
const renderHeader = () => (
|
||||||
<thead>
|
<thead className='bg-transparent'>
|
||||||
<tr className='border-b'>
|
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</th>
|
<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)]'>
|
||||||
<th className='w-1/5 border-r px-4 py-2 text-center font-medium text-sm'>Type</th>
|
Tag
|
||||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
|
const renderTagNameCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||||
const cellValue = row.cells.tagName || ''
|
const cellValue = row.cells.tagName || ''
|
||||||
const isDuplicate = getDuplicateStatus(rowIndex, cellValue)
|
|
||||||
const showDropdown = dropdownStates[rowIndex] || false
|
|
||||||
|
|
||||||
const setShowDropdown = (show: boolean) => {
|
// Show tags that are either available OR currently selected for this row
|
||||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
const selectableTags = tagDefinitions.filter(
|
||||||
}
|
(def) => def.displayName === cellValue || !usedTagNames.has(def.displayName.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
const handleDropdownClick = (e: React.MouseEvent) => {
|
const tagOptions: ComboboxOption[] = selectableTags.map((tag) => ({
|
||||||
e.preventDefault()
|
value: tag.displayName,
|
||||||
e.stopPropagation()
|
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||||
if (!disabled) {
|
}))
|
||||||
if (!showDropdown) {
|
|
||||||
setShowDropdown(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFocus = () => {
|
|
||||||
if (!disabled) {
|
|
||||||
setShowDropdown(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
|
||||||
// Delay closing to allow dropdown selection
|
|
||||||
setTimeout(() => setShowDropdown(false), 150)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className='relative border-r p-1'>
|
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||||
<div className='relative w-full'>
|
<Combobox
|
||||||
<Input
|
options={tagOptions}
|
||||||
value={cellValue}
|
value={cellValue}
|
||||||
onChange={(e) => handleCellChange(rowIndex, 'tagName', e.target.value)}
|
onChange={(value) => handleTagSelection(rowIndex, value)}
|
||||||
onFocus={handleFocus}
|
disabled={disabled || isLoading}
|
||||||
onBlur={handleBlur}
|
placeholder='Select tag'
|
||||||
disabled={disabled}
|
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'
|
||||||
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>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
|
const renderValueCell = (row: DocumentTagRow, rowIndex: number) => {
|
||||||
const cellValue = row.cells.value || ''
|
const cellValue = row.cells.value || ''
|
||||||
|
const fieldType = row.cells.fieldType || 'text'
|
||||||
const cellKey = `value-${rowIndex}`
|
const cellKey = `value-${rowIndex}`
|
||||||
|
const placeholder = getPlaceholderForFieldType(fieldType)
|
||||||
|
const isTagSelected = !!row.cells.tagName?.trim()
|
||||||
|
|
||||||
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
|
const fieldState = inputController.fieldHelpers.getFieldState(cellKey)
|
||||||
const handlers = inputController.fieldHelpers.createFieldHandlers(
|
const handlers = inputController.fieldHelpers.createFieldHandlers(
|
||||||
cellKey,
|
cellKey,
|
||||||
cellValue,
|
cellValue,
|
||||||
(newValue) => handleCellChange(rowIndex, 'value', newValue)
|
(newValue) => handleValueChange(rowIndex, newValue)
|
||||||
)
|
)
|
||||||
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
|
const tagSelectHandler = inputController.fieldHelpers.createTagSelectHandler(
|
||||||
cellKey,
|
cellKey,
|
||||||
cellValue,
|
cellValue,
|
||||||
(newValue) => handleTagDropdownSelection(rowIndex, 'value', newValue)
|
(newValue) => handleTagDropdownSelection(rowIndex, newValue)
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className='p-1'>
|
<td className='relative min-w-0 overflow-hidden bg-transparent p-0'>
|
||||||
<div className='relative w-full'>
|
<div className='relative w-full'>
|
||||||
<Input
|
<Input
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -466,12 +305,13 @@ export function DocumentTagEntry({
|
|||||||
onKeyDown={handlers.onKeyDown}
|
onKeyDown={handlers.onKeyDown}
|
||||||
onDrop={handlers.onDrop}
|
onDrop={handlers.onDrop}
|
||||||
onDragOver={handlers.onDragOver}
|
onDragOver={handlers.onDragOver}
|
||||||
disabled={disabled}
|
disabled={disabled || !isTagSelected}
|
||||||
autoComplete='off'
|
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='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'>
|
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||||
{formatDisplayText(cellValue, {
|
{formatDisplayText(cellValue, {
|
||||||
accessiblePrefixes,
|
accessiblePrefixes,
|
||||||
highlightAll: !accessiblePrefixes,
|
highlightAll: !accessiblePrefixes,
|
||||||
@@ -500,49 +340,33 @@ export function DocumentTagEntry({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderDeleteButton = (rowIndex: number) => {
|
const renderDeleteButton = (rowIndex: number) => {
|
||||||
// Allow deletion of any row
|
if (isPreview || disabled) return null
|
||||||
const canDelete = !isPreview && !disabled
|
|
||||||
|
|
||||||
return canDelete ? (
|
return (
|
||||||
<td className='w-0 p-0'>
|
<td className='w-0 p-0'>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='icon'
|
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
|
||||||
onClick={() => handleDeleteRow(rowIndex)}
|
onClick={() => handleDeleteRow(rowIndex)}
|
||||||
>
|
>
|
||||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
<Trash className='h-[14px] w-[14px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</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 (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative w-full'>
|
||||||
{showPreFillButton && (
|
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||||
<div className='mb-2'>
|
<table className='w-full table-fixed bg-transparent'>
|
||||||
<Button variant='outline' size='sm' onClick={handlePreFillTags}>
|
|
||||||
Prefill Existing Tags
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className='overflow-visible rounded-md border'>
|
|
||||||
<table className='w-full'>
|
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
<tbody>
|
<tbody className='bg-transparent'>
|
||||||
{rows.map((row, rowIndex) => (
|
{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)}
|
{renderTagNameCell(row, rowIndex)}
|
||||||
{renderTypeCell(row, rowIndex)}
|
|
||||||
{renderValueCell(row, rowIndex)}
|
{renderValueCell(row, rowIndex)}
|
||||||
{renderDeleteButton(rowIndex)}
|
{renderDeleteButton(rowIndex)}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -551,24 +375,13 @@ export function DocumentTagEntry({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Row Button and Tag slots usage indicator */}
|
{/* Add Tag Button */}
|
||||||
{!isPreview && !disabled && (
|
{!isPreview && !disabled && (
|
||||||
<div className='mt-3 flex items-center justify-between'>
|
<div className='mt-3'>
|
||||||
<Button
|
<Button onClick={handleAddRow} disabled={!canAddMoreTags} className='h-7 px-2 text-xs'>
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={handleAddRow}
|
|
||||||
disabled={!canAddMoreTags}
|
|
||||||
className='h-7 px-2 text-xs'
|
|
||||||
>
|
|
||||||
<Plus className='mr-1 h-2.5 w-2.5' />
|
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||||
Add Tag
|
Add Tag
|
||||||
</Button>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus } from 'lucide-react'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Button, Combobox, type ComboboxOption, Label, Trash } from '@/components/emcn'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Input } from '@/components/ui/input'
|
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 { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
|
||||||
import {
|
import {
|
||||||
checkTagTrigger,
|
checkTagTrigger,
|
||||||
@@ -20,14 +20,22 @@ import { useSubBlockValue } from '../../hooks/use-sub-block-value'
|
|||||||
interface TagFilter {
|
interface TagFilter {
|
||||||
id: string
|
id: string
|
||||||
tagName: string
|
tagName: string
|
||||||
|
tagSlot?: string
|
||||||
|
fieldType: FilterFieldType
|
||||||
|
operator: string
|
||||||
tagValue: string
|
tagValue: string
|
||||||
|
valueTo?: string // For 'between' operator
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagFilterRow {
|
interface TagFilterRow {
|
||||||
id: string
|
id: string
|
||||||
cells: {
|
cells: {
|
||||||
tagName: string
|
tagName: string
|
||||||
|
tagSlot?: string
|
||||||
|
fieldType: FilterFieldType
|
||||||
|
operator: string
|
||||||
value: string
|
value: string
|
||||||
|
valueTo?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,21 +55,15 @@ export function KnowledgeTagFilters({
|
|||||||
previewValue,
|
previewValue,
|
||||||
}: KnowledgeTagFiltersProps) {
|
}: KnowledgeTagFiltersProps) {
|
||||||
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
const [storeValue, setStoreValue] = useSubBlockValue<string | null>(blockId, subBlock.id)
|
||||||
|
|
||||||
// Hook for immediate tag/dropdown selections
|
|
||||||
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
const emitTagSelection = useTagSelection(blockId, subBlock.id)
|
||||||
|
|
||||||
// Get the knowledge base ID from other sub-blocks
|
|
||||||
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
const [knowledgeBaseIdValue] = useSubBlockValue(blockId, 'knowledgeBaseId')
|
||||||
const knowledgeBaseId = knowledgeBaseIdValue || null
|
const knowledgeBaseId = knowledgeBaseIdValue || null
|
||||||
|
|
||||||
// Use KB tag definitions hook to get available tags
|
|
||||||
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
const { tagDefinitions, isLoading } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||||
|
|
||||||
// Get accessible prefixes for variable highlighting
|
|
||||||
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
const accessiblePrefixes = useAccessibleReferencePrefixes(blockId)
|
||||||
|
|
||||||
// State for managing tag dropdown
|
|
||||||
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
const [activeTagDropdown, setActiveTagDropdown] = useState<{
|
||||||
rowIndex: number
|
rowIndex: number
|
||||||
showTags: boolean
|
showTags: boolean
|
||||||
@@ -70,14 +72,15 @@ export function KnowledgeTagFilters({
|
|||||||
element?: HTMLElement | null
|
element?: HTMLElement | null
|
||||||
} | null>(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[] => {
|
const parseFilters = (filterValue: string | null): TagFilter[] => {
|
||||||
if (!filterValue) return []
|
if (!filterValue) return []
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -86,20 +89,23 @@ export function KnowledgeTagFilters({
|
|||||||
const currentValue = isPreview ? previewValue : storeValue
|
const currentValue = isPreview ? previewValue : storeValue
|
||||||
const filters = parseFilters(currentValue || null)
|
const filters = parseFilters(currentValue || null)
|
||||||
|
|
||||||
// Transform filters to table format for display
|
|
||||||
const rows: TagFilterRow[] =
|
const rows: TagFilterRow[] =
|
||||||
filters.length > 0
|
filters.length > 0
|
||||||
? filters.map((filter) => ({
|
? filters.map((filter) => ({
|
||||||
id: filter.id,
|
id: filter.id,
|
||||||
cells: {
|
cells: {
|
||||||
tagName: filter.tagName || '',
|
tagName: filter.tagName || '',
|
||||||
|
tagSlot: filter.tagSlot,
|
||||||
|
fieldType: filter.fieldType || 'text',
|
||||||
|
operator: filter.operator || 'eq',
|
||||||
value: filter.tagValue || '',
|
value: filter.tagValue || '',
|
||||||
|
valueTo: filter.valueTo,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
id: 'empty-row-0',
|
id: 'empty-row-0',
|
||||||
cells: { tagName: '', value: '' },
|
cells: { tagName: '', fieldType: 'text', operator: '', value: '' },
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -109,27 +115,72 @@ export function KnowledgeTagFilters({
|
|||||||
setStoreValue(value)
|
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
|
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) => {
|
const updatedRows = [...rows].map((row, idx) => {
|
||||||
if (idx === rowIndex) {
|
if (idx === rowIndex) {
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
cells: { ...row.cells, [column]: value },
|
cells: {
|
||||||
|
...row.cells,
|
||||||
|
tagName,
|
||||||
|
tagSlot: tagDef?.tagSlot,
|
||||||
|
fieldType,
|
||||||
|
operator: operators[0]?.value || 'eq',
|
||||||
|
value: '',
|
||||||
|
valueTo: undefined,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert back to TagFilter format - keep all rows, even empty ones
|
updateFilters(rowsToFilters(updatedRows))
|
||||||
const updatedFilters = updatedRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
tagName: row.cells.tagName || '',
|
|
||||||
tagValue: row.cells.value || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
updateFilters(updatedFilters)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
|
const handleTagDropdownSelection = (rowIndex: number, column: string, value: string) => {
|
||||||
@@ -145,36 +196,36 @@ export function KnowledgeTagFilters({
|
|||||||
return row
|
return row
|
||||||
})
|
})
|
||||||
|
|
||||||
// Convert back to TagFilter format - keep all rows, even empty ones
|
const jsonValue =
|
||||||
const updatedFilters = updatedRows.map((row) => ({
|
rowsToFilters(updatedRows).length > 0 ? JSON.stringify(rowsToFilters(updatedRows)) : null
|
||||||
id: row.id,
|
|
||||||
tagName: row.cells.tagName || '',
|
|
||||||
tagValue: row.cells.value || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const jsonValue = updatedFilters.length > 0 ? JSON.stringify(updatedFilters) : null
|
|
||||||
emitTagSelection(jsonValue)
|
emitTagSelection(jsonValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddRow = () => {
|
const handleAddRow = () => {
|
||||||
if (isPreview || disabled) return
|
if (isPreview || disabled) return
|
||||||
|
|
||||||
const newRowId = `filter-${filters.length}-${Math.random().toString(36).substr(2, 9)}`
|
const newRowId = `filter-${filters.length}-${Math.random().toString(36).slice(2, 11)}`
|
||||||
const newFilters = [...filters, { id: newRowId, tagName: '', tagValue: '' }]
|
const newFilter: TagFilter = {
|
||||||
updateFilters(newFilters)
|
id: newRowId,
|
||||||
|
tagName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
operator: 'eq',
|
||||||
|
tagValue: '',
|
||||||
|
}
|
||||||
|
updateFilters([...filters, newFilter])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteRow = (rowIndex: number) => {
|
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 updatedRows = rows.filter((_, idx) => idx !== rowIndex)
|
||||||
|
updateFilters(rowsToFilters(updatedRows))
|
||||||
const updatedFilters = updatedRows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
tagName: row.cells.tagName || '',
|
|
||||||
tagValue: row.cells.value || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
updateFilters(updatedFilters)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPreview) {
|
if (isPreview) {
|
||||||
@@ -191,108 +242,88 @@ export function KnowledgeTagFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderHeader = () => (
|
const renderHeader = () => (
|
||||||
<thead>
|
<thead className='bg-transparent'>
|
||||||
<tr className='border-b'>
|
<tr className='border-[var(--border-strong)] border-b bg-transparent'>
|
||||||
<th className='w-2/5 border-r px-4 py-2 text-center font-medium text-sm'>Tag Name</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)]'>
|
||||||
<th className='px-4 py-2 text-center font-medium text-sm'>Value</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
)
|
)
|
||||||
|
|
||||||
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
|
const renderTagNameCell = (row: TagFilterRow, rowIndex: number) => {
|
||||||
const cellValue = row.cells.tagName || ''
|
const cellValue = row.cells.tagName || ''
|
||||||
const showDropdown = dropdownStates[rowIndex] || false
|
|
||||||
|
|
||||||
const setShowDropdown = (show: boolean) => {
|
const tagOptions: ComboboxOption[] = tagDefinitions.map((tag) => ({
|
||||||
setDropdownStates((prev) => ({ ...prev, [rowIndex]: show }))
|
value: tag.displayName,
|
||||||
}
|
label: `${tag.displayName} (${FIELD_TYPE_LABELS[tag.fieldType] || 'Text'})`,
|
||||||
|
}))
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td className='relative border-r p-1'>
|
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||||
<div className='relative w-full'>
|
<Combobox
|
||||||
<Input
|
options={tagOptions}
|
||||||
value={cellValue}
|
value={cellValue}
|
||||||
readOnly
|
onChange={(value) => handleTagNameSelection(rowIndex, value)}
|
||||||
disabled={disabled || isLoading}
|
disabled={disabled || isLoading}
|
||||||
autoComplete='off'
|
placeholder='Select tag'
|
||||||
className='w-full cursor-pointer border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
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'
|
||||||
onClick={handleDropdownClick}
|
/>
|
||||||
onFocus={handleFocus}
|
</td>
|
||||||
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'>
|
const renderOperatorCell = (row: TagFilterRow, rowIndex: number) => {
|
||||||
{formatDisplayText(cellValue || 'Select tag', {
|
const fieldType = row.cells.fieldType || 'text'
|
||||||
accessiblePrefixes,
|
const operator = row.cells.operator || ''
|
||||||
highlightAll: !accessiblePrefixes,
|
const operators = getOperatorsForFieldType(fieldType)
|
||||||
})}
|
const isOperatorDisabled = disabled || !row.cells.tagName
|
||||||
</div>
|
|
||||||
</div>
|
const operatorOptions: ComboboxOption[] = operators.map((op) => ({
|
||||||
{showDropdown && tagDefinitions.length > 0 && (
|
value: op.value,
|
||||||
<div className='absolute top-full left-0 z-[100] mt-1 w-full'>
|
label: op.label,
|
||||||
<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'
|
return (
|
||||||
style={{ scrollbarWidth: 'thin' }}
|
<td className='relative min-w-0 overflow-hidden border-[var(--border-strong)] border-r bg-transparent p-0'>
|
||||||
>
|
<Combobox
|
||||||
{tagDefinitions.map((tag) => (
|
options={operatorOptions}
|
||||||
<div
|
value={operator}
|
||||||
key={tag.id}
|
onChange={(value) => handleCellChange(rowIndex, 'operator', 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'
|
disabled={isOperatorDisabled}
|
||||||
onMouseDown={(e) => {
|
placeholder='Select operator'
|
||||||
e.preventDefault()
|
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'
|
||||||
handleCellChange(rowIndex, 'tagName', tag.displayName)
|
/>
|
||||||
setShowDropdown(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className='flex-1 truncate'>{tag.displayName}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
|
const renderValueCell = (row: TagFilterRow, rowIndex: number) => {
|
||||||
const cellValue = row.cells.value || ''
|
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 (
|
const renderInput = (value: string, column: 'value' | 'valueTo') => (
|
||||||
<td className='p-1'>
|
<div className='relative w-full'>
|
||||||
<div className='relative w-full'>
|
<Input
|
||||||
<Input
|
value={value}
|
||||||
value={cellValue}
|
onChange={(e) => {
|
||||||
onChange={(e) => {
|
const newValue = e.target.value
|
||||||
const newValue = e.target.value
|
const cursorPosition = e.target.selectionStart ?? 0
|
||||||
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)
|
const tagTrigger = checkTagTrigger(newValue, cursorPosition)
|
||||||
|
|
||||||
setActiveTagDropdown({
|
setActiveTagDropdown({
|
||||||
@@ -302,58 +333,78 @@ export function KnowledgeTagFilters({
|
|||||||
activeSourceBlockId: null,
|
activeSourceBlockId: null,
|
||||||
element: e.target,
|
element: e.target,
|
||||||
})
|
})
|
||||||
}}
|
}
|
||||||
onFocus={(e) => {
|
}}
|
||||||
if (!disabled) {
|
onFocus={(e) => {
|
||||||
setActiveTagDropdown({
|
if (!isDisabled && column === 'value') {
|
||||||
rowIndex,
|
setActiveTagDropdown({
|
||||||
showTags: false,
|
rowIndex,
|
||||||
cursorPosition: 0,
|
showTags: false,
|
||||||
activeSourceBlockId: null,
|
cursorPosition: 0,
|
||||||
element: e.target,
|
activeSourceBlockId: null,
|
||||||
})
|
element: e.target,
|
||||||
}
|
})
|
||||||
}}
|
}
|
||||||
onBlur={() => {
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (column === 'value') {
|
||||||
setTimeout(() => setActiveTagDropdown(null), 200)
|
setTimeout(() => setActiveTagDropdown(null), 200)
|
||||||
}}
|
}
|
||||||
onKeyDown={(e) => {
|
}}
|
||||||
if (e.key === 'Escape') {
|
onKeyDown={(e) => {
|
||||||
setActiveTagDropdown(null)
|
if (e.key === 'Escape') {
|
||||||
}
|
setActiveTagDropdown(null)
|
||||||
}}
|
}
|
||||||
disabled={disabled}
|
}}
|
||||||
autoComplete='off'
|
disabled={isDisabled}
|
||||||
className='w-full border-0 text-transparent caret-foreground placeholder:text-muted-foreground/50 focus-visible:ring-0 focus-visible:ring-offset-0'
|
autoComplete='off'
|
||||||
/>
|
placeholder={placeholder}
|
||||||
<div className='pointer-events-none absolute inset-0 flex items-center overflow-hidden bg-transparent px-3 text-sm'>
|
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='whitespace-pre'>
|
/>
|
||||||
{formatDisplayText(cellValue, {
|
<div className='scrollbar-hide pointer-events-none absolute top-0 right-[10px] bottom-0 left-[10px] overflow-x-auto overflow-y-hidden bg-transparent'>
|
||||||
accessiblePrefixes,
|
<div className='whitespace-pre py-[8px] font-medium text-[var(--text-primary)] text-sm leading-[21px]'>
|
||||||
highlightAll: !accessiblePrefixes,
|
{formatDisplayText(value || '', {
|
||||||
})}
|
accessiblePrefixes,
|
||||||
</div>
|
highlightAll: !accessiblePrefixes,
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</td>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderDeleteButton = (rowIndex: number) => {
|
const renderDeleteButton = (rowIndex: number) => {
|
||||||
const canDelete = !isPreview && !disabled
|
if (isPreview || disabled) return null
|
||||||
|
|
||||||
return canDelete ? (
|
return (
|
||||||
<td className='w-0 p-0'>
|
<td className='w-0 p-0'>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='icon'
|
className='-translate-y-1/2 absolute top-1/2 right-[8px] transition-opacity'
|
||||||
className='-translate-y-1/2 absolute top-1/2 right-2 h-8 w-8 opacity-0 group-hover:opacity-100'
|
|
||||||
onClick={() => handleDeleteRow(rowIndex)}
|
onClick={() => handleDeleteRow(rowIndex)}
|
||||||
>
|
>
|
||||||
<Trash className='h-4 w-4 text-muted-foreground' />
|
<Trash className='h-[14px] w-[14px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
) : null
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -361,14 +412,18 @@ export function KnowledgeTagFilters({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='relative'>
|
<div className='relative w-full'>
|
||||||
<div className='overflow-visible rounded-md border'>
|
<div className='overflow-hidden rounded-[4px] border border-[var(--border-strong)] bg-[var(--surface-2)] dark:bg-[#1F1F1F]'>
|
||||||
<table className='w-full'>
|
<table className='w-full table-fixed bg-transparent'>
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
<tbody>
|
<tbody className='bg-transparent'>
|
||||||
{rows.map((row, rowIndex) => (
|
{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)}
|
{renderTagNameCell(row, rowIndex)}
|
||||||
|
{renderOperatorCell(row, rowIndex)}
|
||||||
{renderValueCell(row, rowIndex)}
|
{renderValueCell(row, rowIndex)}
|
||||||
{renderDeleteButton(rowIndex)}
|
{renderDeleteButton(rowIndex)}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -400,7 +455,7 @@ export function KnowledgeTagFilters({
|
|||||||
{/* Add Filter Button */}
|
{/* Add Filter Button */}
|
||||||
{!isPreview && !disabled && (
|
{!isPreview && !disabled && (
|
||||||
<div className='mt-3 flex items-center justify-between'>
|
<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' />
|
<Plus className='mr-1 h-2.5 w-2.5' />
|
||||||
Add Filter
|
Add Filter
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -982,6 +982,11 @@ export function ToolInput({
|
|||||||
if (hasMultipleOperations(blockType)) {
|
if (hasMultipleOperations(blockType)) {
|
||||||
return false
|
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)
|
return selectedTools.some((tool) => tool.toolId === toolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,29 +134,111 @@ const isMessagesArray = (value: unknown): value is Array<{ role: string; content
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for tag filter array (used in knowledge block filters)
|
||||||
|
*/
|
||||||
|
interface TagFilterItem {
|
||||||
|
id: string
|
||||||
|
tagName: string
|
||||||
|
fieldType?: string
|
||||||
|
operator?: string
|
||||||
|
tagValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTagFilterArray = (value: unknown): value is TagFilterItem[] => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) return false
|
||||||
|
const firstItem = value[0]
|
||||||
|
return (
|
||||||
|
typeof firstItem === 'object' &&
|
||||||
|
firstItem !== null &&
|
||||||
|
'tagName' in firstItem &&
|
||||||
|
'tagValue' in firstItem
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type guard for document tag entry array (used in knowledge block create document)
|
||||||
|
*/
|
||||||
|
interface DocumentTagItem {
|
||||||
|
id: string
|
||||||
|
tagName: string
|
||||||
|
fieldType?: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
|
||||||
|
if (!Array.isArray(value) || value.length === 0) return false
|
||||||
|
const firstItem = value[0]
|
||||||
|
return (
|
||||||
|
typeof firstItem === 'object' &&
|
||||||
|
firstItem !== null &&
|
||||||
|
'tagName' in firstItem &&
|
||||||
|
'value' in firstItem &&
|
||||||
|
!('tagValue' in firstItem) // Distinguish from tag filters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to parse a JSON string, returns the parsed value or the original value if parsing fails
|
||||||
|
*/
|
||||||
|
const tryParseJson = (value: unknown): unknown => {
|
||||||
|
if (typeof value !== 'string') return value
|
||||||
|
try {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (
|
||||||
|
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
|
||||||
|
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
||||||
|
) {
|
||||||
|
return JSON.parse(trimmed)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON, return original
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
* Formats a subblock value for display, intelligently handling nested objects and arrays.
|
||||||
*/
|
*/
|
||||||
const getDisplayValue = (value: unknown): string => {
|
const getDisplayValue = (value: unknown): string => {
|
||||||
if (value == null || value === '') return '-'
|
if (value == null || value === '') return '-'
|
||||||
|
|
||||||
if (isMessagesArray(value)) {
|
// Try parsing JSON strings first
|
||||||
const firstMessage = value[0]
|
const parsedValue = tryParseJson(value)
|
||||||
|
|
||||||
|
if (isMessagesArray(parsedValue)) {
|
||||||
|
const firstMessage = parsedValue[0]
|
||||||
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
|
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
|
||||||
const content = firstMessage.content.trim()
|
const content = firstMessage.content.trim()
|
||||||
return content.length > 50 ? `${content.slice(0, 50)}...` : content
|
return content.length > 50 ? `${content.slice(0, 50)}...` : content
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isVariableAssignmentsArray(value)) {
|
if (isVariableAssignmentsArray(parsedValue)) {
|
||||||
const names = value.map((a) => a.variableName).filter((name): name is string => !!name)
|
const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name)
|
||||||
if (names.length === 0) return '-'
|
if (names.length === 0) return '-'
|
||||||
if (names.length === 1) return names[0]
|
if (names.length === 1) return names[0]
|
||||||
if (names.length === 2) return `${names[0]}, ${names[1]}`
|
if (names.length === 2) return `${names[0]}, ${names[1]}`
|
||||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTableRowArray(value)) {
|
if (isTagFilterArray(parsedValue)) {
|
||||||
const nonEmptyRows = value.filter((row) => {
|
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)
|
const cellValues = Object.values(row.cells)
|
||||||
return cellValues.some((cell) => cell && cell.trim() !== '')
|
return cellValues.some((cell) => cell && cell.trim() !== '')
|
||||||
})
|
})
|
||||||
@@ -175,16 +257,16 @@ const getDisplayValue = (value: unknown): string => {
|
|||||||
return `${nonEmptyRows.length} rows`
|
return `${nonEmptyRows.length} rows`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFieldFormatArray(value)) {
|
if (isFieldFormatArray(parsedValue)) {
|
||||||
const namedFields = value.filter((field) => field.name && field.name.trim() !== '')
|
const namedFields = parsedValue.filter((field) => field.name && field.name.trim() !== '')
|
||||||
if (namedFields.length === 0) return '-'
|
if (namedFields.length === 0) return '-'
|
||||||
if (namedFields.length === 1) return namedFields[0].name
|
if (namedFields.length === 1) return namedFields[0].name
|
||||||
if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}`
|
if (namedFields.length === 2) return `${namedFields[0].name}, ${namedFields[1].name}`
|
||||||
return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}`
|
return `${namedFields[0].name}, ${namedFields[1].name} +${namedFields.length - 2}`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlainObject(value)) {
|
if (isPlainObject(parsedValue)) {
|
||||||
const entries = Object.entries(value).filter(
|
const entries = Object.entries(parsedValue).filter(
|
||||||
([, val]) => val !== null && val !== undefined && val !== ''
|
([, val]) => val !== null && val !== undefined && val !== ''
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -201,8 +283,10 @@ const getDisplayValue = (value: unknown): string => {
|
|||||||
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
|
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(parsedValue)) {
|
||||||
const nonEmptyItems = value.filter((item) => item !== null && item !== undefined && item !== '')
|
const nonEmptyItems = parsedValue.filter(
|
||||||
|
(item) => item !== null && item !== undefined && item !== ''
|
||||||
|
)
|
||||||
if (nonEmptyItems.length === 0) return '-'
|
if (nonEmptyItems.length === 0) return '-'
|
||||||
|
|
||||||
const getItemDisplayValue = (item: unknown): string => {
|
const getItemDisplayValue = (item: unknown): string => {
|
||||||
@@ -220,10 +304,11 @@ const getDisplayValue = (value: unknown): string => {
|
|||||||
return `${getItemDisplayValue(nonEmptyItems[0])}, ${getItemDisplayValue(nonEmptyItems[1])} +${nonEmptyItems.length - 2}`
|
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)
|
const stringValue = String(value)
|
||||||
if (stringValue === '[object Object]') {
|
if (stringValue === '[object Object]') {
|
||||||
try {
|
try {
|
||||||
const json = JSON.stringify(value)
|
const json = JSON.stringify(parsedValue)
|
||||||
if (json.length <= 40) return json
|
if (json.length <= 40) return json
|
||||||
return `${json.slice(0, 37)}...`
|
return `${json.slice(0, 37)}...`
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useShallow } from 'zustand/react/shallow'
|
|||||||
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
import type { OAuthConnectEventDetail } from '@/lib/copilot/tools/client/other/oauth-request-access'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import type { OAuthProvider } from '@/lib/oauth'
|
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 { BLOCK_DIMENSIONS, CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
|
||||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||||
import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
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 { Cursors } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/cursors/cursors'
|
||||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
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 { 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 { 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 { 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'
|
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||||
@@ -523,7 +525,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleRemoveFromSubflow = (event: Event) => {
|
const handleRemoveFromSubflow = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent<{ blockId: string }>
|
const customEvent = event as CustomEvent<{ blockId: string }>
|
||||||
const { blockId } = customEvent.detail || ({} as any)
|
const blockId = customEvent.detail?.blockId
|
||||||
if (!blockId) return
|
if (!blockId) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -555,6 +557,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const candidates = Object.entries(blocks)
|
const candidates = Object.entries(blocks)
|
||||||
.filter(([id, block]) => {
|
.filter(([id, block]) => {
|
||||||
if (!block.enabled) return false
|
if (!block.enabled) return false
|
||||||
|
if (block.type === 'response') return false
|
||||||
const node = nodeIndex.get(id)
|
const node = nodeIndex.get(id)
|
||||||
if (!node) return false
|
if (!node) return false
|
||||||
|
|
||||||
@@ -601,6 +604,152 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return 'source'
|
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.
|
* 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 baseName = data.type === 'loop' ? 'Loop' : 'Parallel'
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||||
let autoConnectEdge
|
blockType: data.type,
|
||||||
if (isAutoConnectEnabled) {
|
targetParentId: null,
|
||||||
const closestBlock = findClosestOutput(position)
|
})
|
||||||
if (closestBlock) {
|
|
||||||
autoConnectEdge = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
source: closestBlock.id,
|
|
||||||
target: id,
|
|
||||||
sourceHandle: determineSourceHandle(closestBlock),
|
|
||||||
targetHandle: 'target',
|
|
||||||
type: 'workflowEdge',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
id,
|
id,
|
||||||
@@ -651,8 +789,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
name,
|
name,
|
||||||
position,
|
position,
|
||||||
{
|
{
|
||||||
width: 500,
|
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: 300,
|
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -674,12 +812,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
// Prefer semantic default names for triggers; then ensure unique numbering centrally
|
||||||
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
const defaultTriggerNameDrop = TriggerUtils.getDefaultTriggerName(data.type)
|
||||||
const baseName =
|
const baseName = defaultTriggerNameDrop || blockConfig.name
|
||||||
data.type === 'loop'
|
|
||||||
? 'Loop'
|
|
||||||
: data.type === 'parallel'
|
|
||||||
? 'Parallel'
|
|
||||||
: defaultTriggerNameDrop || blockConfig!.name
|
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
if (containerInfo) {
|
if (containerInfo) {
|
||||||
@@ -711,70 +844,18 @@ const WorkflowContent = React.memo(() => {
|
|||||||
estimateBlockDimensions(data.type)
|
estimateBlockDimensions(data.type)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Capture existing child blocks before adding the new one
|
// Capture existing child blocks for auto-connect
|
||||||
const existingChildBlocks = Object.values(blocks).filter(
|
const existingChildBlocks = Object.values(blocks)
|
||||||
(b) => b.data?.parentId === containerInfo.loopId
|
.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 autoConnectEdge = tryCreateAutoConnectEdge(relativePosition, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: data.type,
|
||||||
let autoConnectEdge
|
enableTriggerMode: data.enableTriggerMode,
|
||||||
if (
|
targetParentId: containerInfo.loopId,
|
||||||
isAutoConnectEnabled &&
|
existingChildBlocks,
|
||||||
data.type !== 'starter' &&
|
containerId: containerInfo.loopId,
|
||||||
!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',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add block with parent info AND autoConnectEdge (atomic operation)
|
// Add block with parent info AND autoConnectEdge (atomic operation)
|
||||||
addBlock(
|
addBlock(
|
||||||
@@ -796,49 +877,13 @@ const WorkflowContent = React.memo(() => {
|
|||||||
resizeLoopNodesWrapper()
|
resizeLoopNodesWrapper()
|
||||||
} else {
|
} else {
|
||||||
// Centralized trigger constraints
|
// Centralized trigger constraints
|
||||||
const dropIssue = TriggerUtils.getTriggerAdditionIssue(blocks, data.type)
|
if (checkTriggerConstraints(data.type)) return
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular auto-connect logic
|
const autoConnectEdge = tryCreateAutoConnectEdge(position, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: data.type,
|
||||||
let autoConnectEdge
|
enableTriggerMode: data.enableTriggerMode,
|
||||||
if (
|
targetParentId: null,
|
||||||
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',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular canvas drop with auto-connect edge
|
// Regular canvas drop with auto-connect edge
|
||||||
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
// Use enableTriggerMode from drag data if present (when dragging from Triggers tab)
|
||||||
@@ -861,14 +906,13 @@ const WorkflowContent = React.memo(() => {
|
|||||||
},
|
},
|
||||||
[
|
[
|
||||||
blocks,
|
blocks,
|
||||||
getNodes,
|
|
||||||
findClosestOutput,
|
|
||||||
determineSourceHandle,
|
|
||||||
isPointInLoopNode,
|
isPointInLoopNode,
|
||||||
resizeLoopNodesWrapper,
|
resizeLoopNodesWrapper,
|
||||||
addBlock,
|
addBlock,
|
||||||
addNotification,
|
addNotification,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
tryCreateAutoConnectEdge,
|
||||||
|
checkTriggerConstraints,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -885,44 +929,73 @@ const WorkflowContent = React.memo(() => {
|
|||||||
if (!type) return
|
if (!type) return
|
||||||
if (type === 'connectionBlock') 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)
|
// Special handling for container nodes (loop or parallel)
|
||||||
if (type === 'loop' || type === 'parallel') {
|
if (type === 'loop' || type === 'parallel') {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
const baseName = type === 'loop' ? 'Loop' : 'Parallel'
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
const centerPosition = screenToFlowPosition({
|
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||||
x: window.innerWidth / 2,
|
blockType: type,
|
||||||
y: window.innerHeight / 2,
|
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
|
// Add the container node with default dimensions and auto-connect edge
|
||||||
addBlock(
|
addBlock(
|
||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
centerPosition,
|
basePosition,
|
||||||
{
|
{
|
||||||
width: 500,
|
width: CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: 300,
|
height: CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
type: 'subflowNode',
|
type: 'subflowNode',
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
@@ -939,11 +1012,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the center position of the viewport
|
// Check trigger constraints first
|
||||||
const centerPosition = screenToFlowPosition({
|
if (checkTriggerConstraints(type)) return
|
||||||
x: window.innerWidth / 2,
|
|
||||||
y: window.innerHeight / 2,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create a new block with a unique ID
|
// Create a new block with a unique ID
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
@@ -952,51 +1022,11 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const baseName = defaultTriggerName || blockConfig.name
|
const baseName = defaultTriggerName || blockConfig.name
|
||||||
const name = getUniqueBlockName(baseName, blocks)
|
const name = getUniqueBlockName(baseName, blocks)
|
||||||
|
|
||||||
// Auto-connect logic
|
const autoConnectEdge = tryCreateAutoConnectEdge(basePosition, id, {
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
blockType: type,
|
||||||
let autoConnectEdge
|
enableTriggerMode,
|
||||||
if (isAutoConnectEnabled && type !== 'starter' && !isAnnotationOnlyBlock(type)) {
|
targetParentId: null,
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the block to the workflow with auto-connect edge
|
// Add the block to the workflow with auto-connect edge
|
||||||
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
// Enable trigger mode if this is a trigger-capable block from the triggers tab
|
||||||
@@ -1004,7 +1034,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
id,
|
id,
|
||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
centerPosition,
|
basePosition,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -1025,11 +1055,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
screenToFlowPosition,
|
screenToFlowPosition,
|
||||||
blocks,
|
blocks,
|
||||||
addBlock,
|
addBlock,
|
||||||
findClosestOutput,
|
tryCreateAutoConnectEdge,
|
||||||
determineSourceHandle,
|
isPointInLoopNode,
|
||||||
effectivePermissions.canEdit,
|
effectivePermissions.canEdit,
|
||||||
addNotification,
|
addNotification,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
|
checkTriggerConstraints,
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1220,12 +1251,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
const containerNode = getNodes().find((n) => n.id === containerInfo.loopId)
|
||||||
if (
|
if (
|
||||||
containerNode?.type === 'subflowNode' &&
|
containerNode?.type === 'subflowNode' &&
|
||||||
(containerNode.data as any)?.kind === 'loop'
|
(containerNode.data as SubflowNodeData)?.kind === 'loop'
|
||||||
) {
|
) {
|
||||||
containerElement.classList.add('loop-node-drag-over')
|
containerElement.classList.add('loop-node-drag-over')
|
||||||
} else if (
|
} else if (
|
||||||
containerNode?.type === 'subflowNode' &&
|
containerNode?.type === 'subflowNode' &&
|
||||||
(containerNode.data as any)?.kind === 'parallel'
|
(containerNode.data as SubflowNodeData)?.kind === 'parallel'
|
||||||
) {
|
) {
|
||||||
containerElement.classList.add('parallel-node-drag-over')
|
containerElement.classList.add('parallel-node-drag-over')
|
||||||
}
|
}
|
||||||
@@ -1424,8 +1455,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
data: {
|
data: {
|
||||||
...block.data,
|
...block.data,
|
||||||
name: block.name,
|
name: block.name,
|
||||||
width: block.data?.width || 500,
|
width: block.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH,
|
||||||
height: block.data?.height || 300,
|
height: block.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT,
|
||||||
kind: block.type === 'loop' ? 'loop' : 'parallel',
|
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)
|
// Include dynamic dimensions for container resizing calculations (must match rendered size)
|
||||||
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
// Both note and workflow blocks calculate dimensions deterministically via useBlockDimensions
|
||||||
width: 250, // Standard width for both block types
|
width: BLOCK_DIMENSIONS.FIXED_WIDTH,
|
||||||
height: Math.max(block.height || 100, 100), // Use calculated height with minimum
|
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).
|
* Effect to resize loops when nodes change (add/remove/position change).
|
||||||
* Runs on structural changes only - not during drag (position-only changes).
|
* Runs on structural changes only - not during drag (position-only changes).
|
||||||
* Skips during loading to avoid unnecessary work.
|
* Skips during loading.
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip during initial render when nodes aren't loaded yet or workflow not ready
|
// 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)
|
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||||
|
|
||||||
// Get dimensions based on node type (must match actual rendered dimensions)
|
// 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 =
|
const nodeHeight =
|
||||||
node.type === 'subflowNode'
|
node.type === 'subflowNode'
|
||||||
? node.data?.height || 300
|
? node.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT
|
||||||
: Math.max(node.height || 100, 100) // Use actual node height with minimum 100
|
: Math.max(node.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||||
|
|
||||||
// Check intersection using absolute coordinates
|
// Check intersection using absolute coordinates
|
||||||
const nodeRect = {
|
const nodeRect = {
|
||||||
@@ -1811,9 +1845,10 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const containerRect = {
|
const containerRect = {
|
||||||
left: containerAbsolutePos.x,
|
left: containerAbsolutePos.x,
|
||||||
right: containerAbsolutePos.x + (n.data?.width || 500),
|
right: containerAbsolutePos.x + (n.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH),
|
||||||
top: containerAbsolutePos.y,
|
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
|
// Check intersection with absolute coordinates for accurate detection
|
||||||
@@ -1829,7 +1864,9 @@ const WorkflowContent = React.memo(() => {
|
|||||||
container: n,
|
container: n,
|
||||||
depth: getNodeDepth(n.id),
|
depth: getNodeDepth(n.id),
|
||||||
// Calculate size for secondary sorting
|
// 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
|
// 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
|
// Apply appropriate class based on container type
|
||||||
if (
|
if (
|
||||||
bestContainerMatch.container.type === 'subflowNode' &&
|
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')
|
containerElement.classList.add('loop-node-drag-over')
|
||||||
} else if (
|
} else if (
|
||||||
bestContainerMatch.container.type === 'subflowNode' &&
|
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')
|
containerElement.classList.add('parallel-node-drag-over')
|
||||||
}
|
}
|
||||||
@@ -2034,62 +2071,19 @@ const WorkflowContent = React.memo(() => {
|
|||||||
y: nodeAbsPosBefore.y - containerAbsPosBefore.y - headerHeight - topPadding,
|
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
|
// Auto-connect when moving an existing block into a container
|
||||||
const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled
|
const existingChildBlocks = Object.values(blocks)
|
||||||
// Don't auto-connect annotation blocks (like note blocks)
|
.filter((b) => b.data?.parentId === potentialParentId && b.id !== node.id)
|
||||||
if (isAutoConnectEnabled && !isAnnotationOnlyBlock(node.data?.type)) {
|
.map((b) => ({ id: b.id, type: b.type, position: b.position }))
|
||||||
// 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
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingChildBlocks.length > 0) {
|
const autoConnectEdge = tryCreateAutoConnectEdge(relativePositionBefore, node.id, {
|
||||||
// Connect from nearest existing child inside the container
|
blockType: node.data?.type || '',
|
||||||
const closestBlock = existingChildBlocks
|
targetParentId: potentialParentId,
|
||||||
.map((b) => ({
|
existingChildBlocks,
|
||||||
block: b,
|
containerId: potentialParentId,
|
||||||
distance: Math.sqrt(
|
})
|
||||||
(b.position.x - relativePositionBefore.x) ** 2 +
|
|
||||||
(b.position.y - relativePositionBefore.y) ** 2
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.distance - b.distance)[0]?.block
|
|
||||||
|
|
||||||
if (closestBlock) {
|
const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : []
|
||||||
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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip recording these edges separately since they're part of the parent update
|
// Skip recording these edges separately since they're part of the parent update
|
||||||
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } }))
|
||||||
@@ -2114,7 +2108,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
updateNodeParent,
|
updateNodeParent,
|
||||||
collaborativeUpdateBlockPosition,
|
collaborativeUpdateBlockPosition,
|
||||||
addEdge,
|
addEdge,
|
||||||
determineSourceHandle,
|
tryCreateAutoConnectEdge,
|
||||||
blocks,
|
blocks,
|
||||||
edgesForDisplay,
|
edgesForDisplay,
|
||||||
removeEdgesForNode,
|
removeEdgesForNode,
|
||||||
|
|||||||
@@ -490,6 +490,20 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
<p className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||||
Enter a name for your API key to help you identify it later.
|
Enter a name for your API key to help you identify it later.
|
||||||
</p>
|
</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
|
<EmcnInput
|
||||||
value={newKeyName}
|
value={newKeyName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -499,6 +513,12 @@ export function ApiKeys({ onOpenChange, registerCloseHandler }: ApiKeysProps) {
|
|||||||
placeholder='e.g., Development, Production'
|
placeholder='e.g., Development, Production'
|
||||||
className='h-9'
|
className='h-9'
|
||||||
autoFocus
|
autoFocus
|
||||||
|
name='api_key_label'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
{createError && (
|
{createError && (
|
||||||
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
<p className='text-[11px] text-[var(--text-error)] leading-tight'>
|
||||||
|
|||||||
@@ -2,7 +2,15 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
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 { useSession, useSubscription } from '@/lib/auth/auth-client'
|
||||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
@@ -68,7 +76,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
|
|
||||||
if (subscriptionStatus.isTeam && activeOrgId) {
|
if (subscriptionStatus.isTeam && activeOrgId) {
|
||||||
referenceId = activeOrgId
|
referenceId = activeOrgId
|
||||||
// Get subscription ID for team/enterprise
|
|
||||||
subscriptionId = subData?.data?.id
|
subscriptionId = subData?.data?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,14 +139,12 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
referenceId = activeOrgId
|
referenceId = activeOrgId
|
||||||
subscriptionId = subData?.data?.id
|
subscriptionId = subData?.data?.id
|
||||||
} else {
|
} else {
|
||||||
// For personal subscriptions, use user ID and let better-auth find the subscription
|
|
||||||
referenceId = session.user.id
|
referenceId = session.user.id
|
||||||
subscriptionId = undefined
|
subscriptionId = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
logger.info('Restoring subscription', { referenceId, subscriptionId })
|
||||||
|
|
||||||
// Build restore params - only include subscriptionId if we have one (team/enterprise)
|
|
||||||
const restoreParams: any = { referenceId }
|
const restoreParams: any = { referenceId }
|
||||||
if (subscriptionId) {
|
if (subscriptionId) {
|
||||||
restoreParams.subscriptionId = subscriptionId
|
restoreParams.subscriptionId = subscriptionId
|
||||||
@@ -150,7 +155,6 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
logger.info('Subscription restored successfully', result)
|
logger.info('Subscription restored successfully', result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate queries to refresh data
|
|
||||||
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.user() })
|
||||||
if (activeOrgId) {
|
if (activeOrgId) {
|
||||||
await queryClient.invalidateQueries({ queryKey: organizationKeys.detail(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'
|
if (!date) return 'end of current billing period'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure we have a valid Date object
|
|
||||||
const dateObj = date instanceof Date ? date : new Date(date)
|
const dateObj = date instanceof Date ? date : new Date(date)
|
||||||
|
|
||||||
// Check if the date is valid
|
|
||||||
if (Number.isNaN(dateObj.getTime())) {
|
if (Number.isNaN(dateObj.getTime())) {
|
||||||
return 'end of current billing period'
|
return 'end of current billing period'
|
||||||
}
|
}
|
||||||
@@ -196,20 +198,17 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
|
|
||||||
const periodEndDate = getPeriodEndDate()
|
const periodEndDate = getPeriodEndDate()
|
||||||
|
|
||||||
// Check if subscription is set to cancel at period end
|
|
||||||
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
const isCancelAtPeriodEnd = subscriptionData?.cancelAtPeriodEnd === true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px]'>
|
<Label>{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}</Label>
|
||||||
{isCancelAtPeriodEnd ? 'Restore Subscription' : 'Manage Subscription'}
|
|
||||||
</span>
|
|
||||||
{isCancelAtPeriodEnd && (
|
{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)}
|
You'll keep access until {formatDate(periodEndDate)}
|
||||||
</p>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -217,7 +216,7 @@ export function CancelSubscription({ subscription, subscriptionData }: CancelSub
|
|||||||
onClick={() => setIsDialogOpen(true)}
|
onClick={() => setIsDialogOpen(true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className={cn(
|
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)]'
|
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
|
{isCancelAtPeriodEnd ? 'Restore' : 'Cancel'} {subscription.plan} Subscription
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
{isCancelAtPeriodEnd
|
{isCancelAtPeriodEnd
|
||||||
? 'Your subscription is set to cancel at the end of the billing period. Would you like to keep your subscription active?'
|
? '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(
|
: `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 && (
|
{!isCancelAtPeriodEnd && (
|
||||||
<div className='mt-3'>
|
<div className='mt-3'>
|
||||||
<div className='rounded-[8px] bg-[var(--surface-3)] p-3 text-sm'>
|
<div className='rounded-[8px] bg-[var(--surface-5)] p-3'>
|
||||||
<ul className='space-y-1 text-[var(--text-muted)] text-xs'>
|
<ul className='space-y-1 text-[12px] text-[var(--text-muted)]'>
|
||||||
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
<li>• Keep all features until {formatDate(periodEndDate)}</li>
|
||||||
<li>• No more charges</li>
|
<li>• No more charges</li>
|
||||||
<li>• Data preserved</li>
|
<li>• Data preserved</li>
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalBody,
|
||||||
ModalClose,
|
ModalClose,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
@@ -90,7 +92,6 @@ export function CreditBalance({
|
|||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
setIsOpen(open)
|
setIsOpen(open)
|
||||||
if (open) {
|
if (open) {
|
||||||
// Generate new requestId when modal opens - same ID used for entire session
|
|
||||||
setRequestId(crypto.randomUUID())
|
setRequestId(crypto.randomUUID())
|
||||||
} else {
|
} else {
|
||||||
setAmount('')
|
setAmount('')
|
||||||
@@ -102,72 +103,66 @@ export function CreditBalance({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<span className='text-muted-foreground text-sm'>Credit Balance</span>
|
<Label>Credit Balance</Label>
|
||||||
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
{isLoading ? '...' : `$${balance.toFixed(2)}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canPurchase && (
|
{canPurchase && (
|
||||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||||
<ModalTrigger asChild>
|
<ModalTrigger asChild>
|
||||||
<Button variant='outline'>Add Credits</Button>
|
<Button variant='outline' className='h-8 rounded-[8px] text-[13px]'>
|
||||||
|
Add Credits
|
||||||
|
</Button>
|
||||||
</ModalTrigger>
|
</ModalTrigger>
|
||||||
<ModalContent>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Add Credits</ModalHeader>
|
<ModalHeader>Add Credits</ModalHeader>
|
||||||
<div className='px-4'>
|
<ModalBody>
|
||||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
{success ? (
|
||||||
Credits are used before overage charges. Min $10, max $1,000.
|
<p className='text-center text-[13px] text-[var(--text-primary)]'>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{success ? (
|
|
||||||
<div className='py-4 text-center'>
|
|
||||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
|
||||||
Credits added successfully!
|
Credits added successfully!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<div className='flex flex-col gap-3 py-2'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
<div className='flex flex-col gap-1'>
|
Credits are used before overage charges. Min $10, max $1,000.
|
||||||
<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>
|
</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 && (
|
{!success && (
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<ModalClose asChild>
|
<ModalClose asChild>
|
||||||
<Button variant='ghost' disabled={isPurchasing}>
|
<Button disabled={isPurchasing}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</ModalClose>
|
</ModalClose>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export function PlanCard({
|
|||||||
if (typeof price === 'string') {
|
if (typeof price === 'string') {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className='font-semibold text-xl'>{price}</span>
|
<span className='font-semibold text-[20px]'>{price}</span>
|
||||||
{priceSubtext && (
|
{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 = () => {
|
const renderFeatures = () => {
|
||||||
if (isHorizontal) {
|
if (isHorizontal) {
|
||||||
return (
|
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) => (
|
{features.map((feature, index) => (
|
||||||
<div key={`${feature.text}-${index}`} className='flex items-center gap-2 text-xs'>
|
<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-muted)]' />
|
<feature.icon className='h-3 w-3 flex-shrink-0 text-[var(--text-secondary)]' />
|
||||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||||
{index < features.length - 1 && (
|
{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>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -75,12 +75,12 @@ export function PlanCard({
|
|||||||
return (
|
return (
|
||||||
<ul className='mb-4 flex-1 space-y-2'>
|
<ul className='mb-4 flex-1 space-y-2'>
|
||||||
{features.map((feature, index) => (
|
{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
|
<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'
|
aria-hidden='true'
|
||||||
/>
|
/>
|
||||||
<span className='text-[var(--text-muted)]'>{feature.text}</span>
|
<span className='text-[var(--text-secondary)]'>{feature.text}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -91,24 +91,24 @@ export function PlanCard({
|
|||||||
<article
|
<article
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex rounded-[8px] border p-4 transition-colors hover:border-[var(--border-hover)]',
|
'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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<header className={isHorizontal ? undefined : 'mb-4'}>
|
<header className={isHorizontal ? 'flex-1' : 'mb-4'}>
|
||||||
<h3 className='mb-2 font-semibold text-sm'>{name}</h3>
|
<h3 className='mb-2 font-semibold text-[14px]'>{name}</h3>
|
||||||
<div className='flex items-baseline'>{renderPrice()}</div>
|
<div className='flex items-baseline'>{renderPrice()}</div>
|
||||||
{isHorizontal && renderFeatures()}
|
{isHorizontal && renderFeatures()}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{!isHorizontal && renderFeatures()}
|
{!isHorizontal && renderFeatures()}
|
||||||
|
|
||||||
<div className={isHorizontal ? 'ml-auto' : undefined}>
|
<div className={isHorizontal ? 'flex-shrink-0' : undefined}>
|
||||||
<Button
|
<Button
|
||||||
onClick={onButtonClick}
|
onClick={onButtonClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-9 rounded-[8px] text-xs',
|
'h-9 rounded-[8px] text-[13px]',
|
||||||
isHorizontal ? 'px-4' : 'w-full',
|
isHorizontal ? 'min-w-[100px] px-6' : 'w-full',
|
||||||
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
isError && 'border-[var(--text-error)] text-[var(--text-error)]'
|
||||||
)}
|
)}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { Switch } from '@/components/emcn'
|
|
||||||
import { Skeleton } from '@/components/ui'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Label,
|
||||||
SelectContent,
|
Popover,
|
||||||
SelectGroup,
|
PopoverContent,
|
||||||
SelectItem,
|
PopoverItem,
|
||||||
SelectLabel,
|
PopoverSection,
|
||||||
SelectTrigger,
|
PopoverTrigger,
|
||||||
SelectValue,
|
Switch,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/emcn'
|
||||||
|
import { Skeleton } from '@/components/ui'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
import { useSubscriptionUpgrade } from '@/lib/billing/client/upgrade'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
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 showBadge = permissions.canEditUsageLimit && !permissions.showTeamMemberView
|
||||||
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
const badgeText = subscription.isFree ? 'Upgrade' : 'Increase Limit'
|
||||||
|
|
||||||
@@ -333,7 +332,7 @@ export function Subscription() {
|
|||||||
<PlanCard
|
<PlanCard
|
||||||
key='enterprise'
|
key='enterprise'
|
||||||
name='Enterprise'
|
name='Enterprise'
|
||||||
price={<span className='font-semibold text-xl'>Custom</span>}
|
price={<span className='font-semibold text-[20px]'>Custom</span>}
|
||||||
priceSubtext={
|
priceSubtext={
|
||||||
layout === 'horizontal'
|
layout === 'horizontal'
|
||||||
? 'Custom solutions tailored to your enterprise needs'
|
? 'Custom solutions tailored to your enterprise needs'
|
||||||
@@ -458,7 +457,7 @@ export function Subscription() {
|
|||||||
{/* Enterprise Usage Limit Notice */}
|
{/* Enterprise Usage Limit Notice */}
|
||||||
{subscription.isEnterprise && (
|
{subscription.isEnterprise && (
|
||||||
<div className='text-center'>
|
<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
|
Contact enterprise for support usage limit changes
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -467,7 +466,7 @@ export function Subscription() {
|
|||||||
{/* Team Member Notice */}
|
{/* Team Member Notice */}
|
||||||
{permissions.showTeamMemberView && (
|
{permissions.showTeamMemberView && (
|
||||||
<div className='text-center'>
|
<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
|
Contact your team admin to increase limits
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -534,72 +533,78 @@ export function Subscription() {
|
|||||||
{/* Next Billing Date */}
|
{/* Next Billing Date */}
|
||||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px]'>Next Billing Date</span>
|
<Label>Next Billing Date</Label>
|
||||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Billing usage notifications toggle */}
|
{/* Usage notifications */}
|
||||||
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
{subscription.isPaid && <BillingUsageNotificationsToggle />}
|
||||||
|
|
||||||
{/* Cancel Subscription */}
|
{/* Cancel Subscription */}
|
||||||
{permissions.canCancelSubscription && (
|
{permissions.canCancelSubscription && (
|
||||||
<div className='mt-[8px]'>
|
<CancelSubscription
|
||||||
<CancelSubscription
|
subscription={{
|
||||||
subscription={{
|
plan: subscription.plan,
|
||||||
plan: subscription.plan,
|
status: subscription.status,
|
||||||
status: subscription.status,
|
isPaid: subscription.isPaid,
|
||||||
isPaid: subscription.isPaid,
|
}}
|
||||||
}}
|
subscriptionData={{
|
||||||
subscriptionData={{
|
periodEnd: subscriptionData?.data?.periodEnd || null,
|
||||||
periodEnd: subscriptionData?.data?.periodEnd || null,
|
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
||||||
cancelAtPeriodEnd: subscriptionData?.data?.cancelAtPeriodEnd,
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Workspace API Billing Settings */}
|
{/* Billed Account for Workspace */}
|
||||||
{canManageWorkspaceKeys && (
|
{canManageWorkspaceKeys && (
|
||||||
<div className='mt-[24px] flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className='font-medium text-[13px]'>Billed Account for Workspace</span>
|
<Label>Billed Account for Workspace</Label>
|
||||||
{isWorkspaceLoading ? (
|
{isWorkspaceLoading ? (
|
||||||
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
<Skeleton className='h-8 w-[200px] rounded-[6px]' />
|
||||||
) : workspaceAdmins.length === 0 ? (
|
) : 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
|
No admin members available
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Popover>
|
||||||
value={billedAccountUserId ?? ''}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={async (value) => {
|
<button
|
||||||
if (value === billedAccountUserId) return
|
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'
|
||||||
try {
|
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
||||||
await updateWorkspaceSettings({ billedAccountUserId: value })
|
>
|
||||||
} catch (error) {
|
<span className='flex-1 truncate text-[var(--text-primary)]'>
|
||||||
// Error is already logged in updateWorkspaceSettings
|
{billedAccountUserId
|
||||||
}
|
? workspaceAdmins.find((admin: any) => admin.userId === billedAccountUserId)
|
||||||
}}
|
?.email || 'Select admin'
|
||||||
disabled={!canManageWorkspaceKeys || updateWorkspaceMutation.isPending}
|
: 'Select admin'}
|
||||||
>
|
</span>
|
||||||
<SelectTrigger className='h-8 w-[200px] justify-between text-left text-xs'>
|
<ChevronDown className='h-3 w-3 shrink-0 text-[var(--text-secondary)]' />
|
||||||
<SelectValue placeholder='Select admin' />
|
</button>
|
||||||
</SelectTrigger>
|
</PopoverTrigger>
|
||||||
<SelectContent align='start' className='z-[10000050]'>
|
<PopoverContent align='end' minWidth={200} border>
|
||||||
<SelectGroup>
|
<PopoverSection>Workspace admins</PopoverSection>
|
||||||
<SelectLabel className='px-3 py-1 text-[11px] text-[var(--text-muted)] uppercase'>
|
{workspaceAdmins.map((admin: any) => (
|
||||||
Workspace admins
|
<PopoverItem
|
||||||
</SelectLabel>
|
key={admin.userId}
|
||||||
{workspaceAdmins.map((admin: any) => (
|
active={billedAccountUserId === admin.userId}
|
||||||
<SelectItem key={admin.userId} value={admin.userId} className='py-1 text-xs'>
|
showCheck
|
||||||
{admin.email}
|
onClick={async () => {
|
||||||
</SelectItem>
|
if (admin.userId === billedAccountUserId) return
|
||||||
))}
|
try {
|
||||||
</SelectGroup>
|
await updateWorkspaceSettings({ billedAccountUserId: admin.userId })
|
||||||
</SelectContent>
|
} catch (error) {
|
||||||
</Select>
|
// Error is already logged in updateWorkspaceSettings
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className='flex-1 truncate'>{admin.email}</span>
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -614,11 +619,14 @@ function BillingUsageNotificationsToggle() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<div className='flex flex-col'>
|
<div className='flex flex-col gap-[2px]'>
|
||||||
<span className='font-medium text-[13px]'>Usage notifications</span>
|
<Label htmlFor='usage-notifications'>Usage notifications</Label>
|
||||||
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
|
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
Email me when I reach 80% usage
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
|
id='usage-notifications'
|
||||||
checked={!!enabled}
|
checked={!!enabled}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
onCheckedChange={(v: boolean) => {
|
onCheckedChange={(v: boolean) => {
|
||||||
|
|||||||
@@ -141,12 +141,37 @@ export function MemberInvitationCard({
|
|||||||
{/* Main invitation input */}
|
{/* Main invitation input */}
|
||||||
<div className='flex items-start gap-2'>
|
<div className='flex items-start gap-2'>
|
||||||
<div className='flex-1'>
|
<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
|
<Input
|
||||||
placeholder='Enter email address'
|
placeholder='Enter email address'
|
||||||
value={inviteEmail}
|
value={inviteEmail}
|
||||||
onChange={handleEmailChange}
|
onChange={handleEmailChange}
|
||||||
disabled={isInviting || !hasAvailableSeats}
|
disabled={isInviting || !hasAvailableSeats}
|
||||||
className={cn(emailError && 'border-red-500 focus-visible:ring-red-500')}
|
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 && (
|
{emailError && (
|
||||||
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
<p className='mt-1 text-[#DC2626] text-[11px] leading-tight dark:text-[#F87171]'>
|
||||||
|
|||||||
@@ -55,16 +55,31 @@ export function NoOrganizationView({
|
|||||||
|
|
||||||
{/* Form fields - clean layout without card */}
|
{/* Form fields - clean layout without card */}
|
||||||
<div className='space-y-4'>
|
<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>
|
<div>
|
||||||
<Label htmlFor='orgName' className='font-medium text-[13px]'>
|
<Label htmlFor='team-name-field' className='font-medium text-[13px]'>
|
||||||
Team Name
|
Team Name
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='orgName'
|
id='team-name-field'
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={onOrgNameChange}
|
onChange={onOrgNameChange}
|
||||||
placeholder='My Team'
|
placeholder='My Team'
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='team_name_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,31 +131,52 @@ export function NoOrganizationView({
|
|||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
|
|
||||||
<div className='space-y-4'>
|
<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>
|
<div>
|
||||||
<Label htmlFor='org-name' className='font-medium text-[13px]'>
|
<Label htmlFor='org-name-field' className='font-medium text-[13px]'>
|
||||||
Organization Name
|
Organization Name
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='org-name'
|
id='org-name-field'
|
||||||
placeholder='Enter organization name'
|
placeholder='Enter organization name'
|
||||||
value={orgName}
|
value={orgName}
|
||||||
onChange={onOrgNameChange}
|
onChange={onOrgNameChange}
|
||||||
disabled={isCreatingOrg}
|
disabled={isCreatingOrg}
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='org_name_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor='org-slug' className='font-medium text-[13px]'>
|
<Label htmlFor='org-slug-field' className='font-medium text-[13px]'>
|
||||||
Organization Slug
|
Organization Slug
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id='org-slug'
|
id='org-slug-field'
|
||||||
placeholder='organization-slug'
|
placeholder='organization-slug'
|
||||||
value={orgSlug}
|
value={orgSlug}
|
||||||
onChange={(e) => setOrgSlug(e.target.value)}
|
onChange={(e) => setOrgSlug(e.target.value)}
|
||||||
disabled={isCreatingOrg}
|
disabled={isCreatingOrg}
|
||||||
className='mt-1'
|
className='mt-1'
|
||||||
|
name='org_slug_field'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function TeamMembers({
|
|||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
{teamItems.map((item) => (
|
{teamItems.map((item) => (
|
||||||
<div key={item.id} className='flex items-center justify-between'>
|
<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'>
|
<div className='flex flex-1 items-center gap-3'>
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -165,7 +165,7 @@ export function TeamMembers({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Name and email */}
|
{/* Name and email */}
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0'>
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<span className='truncate font-medium text-sm'>{item.name}</span>
|
<span className='truncate font-medium text-sm'>{item.name}</span>
|
||||||
{item.type === 'member' && (
|
{item.type === 'member' && (
|
||||||
@@ -188,51 +188,50 @@ export function TeamMembers({
|
|||||||
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
<div className='truncate text-[var(--text-muted)] text-xs'>{item.email}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Usage stats - matching subscription layout */}
|
{/* Action buttons */}
|
||||||
{isAdminOrOwner && (
|
{isAdminOrOwner && (
|
||||||
<div className='hidden items-center text-xs tabular-nums sm:flex'>
|
<>
|
||||||
<div className='text-center'>
|
{/* Admin/Owner can remove other members */}
|
||||||
<div className='text-[var(--text-muted)]'>Usage</div>
|
{item.type === 'member' &&
|
||||||
<div className='font-medium'>
|
item.role !== 'owner' &&
|
||||||
{isLoadingUsage && item.type === 'member' ? (
|
item.email !== currentUserEmail && (
|
||||||
<span className='inline-block h-3 w-12 animate-pulse rounded bg-[var(--surface-3)]' />
|
<Button
|
||||||
) : (
|
variant='ghost'
|
||||||
item.usage
|
onClick={() => onRemoveMember(item.member)}
|
||||||
)}
|
className='h-8 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
</div>
|
>
|
||||||
</div>
|
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>
|
||||||
)}
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import {
|
|||||||
type ComboboxOption,
|
type ComboboxOption,
|
||||||
Label,
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalBody,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalDescription,
|
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalTitle,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
import { DEFAULT_TEAM_TIER_COST_LIMIT } from '@/lib/billing/constants'
|
||||||
@@ -55,50 +54,53 @@ export function TeamSeats({
|
|||||||
const totalMonthlyCost = selectedSeats * costPerSeat
|
const totalMonthlyCost = selectedSeats * costPerSeat
|
||||||
const costChange = currentSeats ? (selectedSeats - currentSeats) * costPerSeat : 0
|
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) => ({
|
const seatOptions: ComboboxOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 40, 50].map((num) => ({
|
||||||
value: num.toString(),
|
value: num.toString(),
|
||||||
label: `${num} ${num === 1 ? 'seat' : 'seats'} ($${num * costPerSeat}/month)`,
|
label: `${num} ${num === 1 ? 'seat' : 'seats'}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onOpenChange={onOpenChange}>
|
<Modal open={open} onOpenChange={onOpenChange}>
|
||||||
<ModalContent>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>
|
<ModalHeader>{title}</ModalHeader>
|
||||||
<ModalTitle>{title}</ModalTitle>
|
<ModalBody>
|
||||||
<ModalDescription>{description}</ModalDescription>
|
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<div className='py-4'>
|
<div className='mt-4 flex flex-col gap-[4px]'>
|
||||||
<Label htmlFor='seats'>Number of seats</Label>
|
<Label htmlFor='seats'>Number of seats</Label>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={seatOptions}
|
options={seatOptions}
|
||||||
value={selectedSeats.toString()}
|
value={selectedSeats > 0 ? selectedSeats.toString() : ''}
|
||||||
onChange={(value) => setSelectedSeats(Number.parseInt(value))}
|
onChange={(value) => {
|
||||||
placeholder='Select number of seats'
|
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
|
Your team will have {selectedSeats} {selectedSeats === 1 ? 'seat' : 'seats'} with a
|
||||||
total of ${totalMonthlyCost} inference credits per month.
|
total of ${totalMonthlyCost} inference credits per month.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{showCostBreakdown && currentSeats !== undefined && (
|
{showCostBreakdown && currentSeats !== undefined && (
|
||||||
<div className='mt-3 rounded-[8px] bg-[var(--surface-3)] p-3'>
|
<div className='mt-4 rounded-[6px] bg-[var(--surface-5)] p-3'>
|
||||||
<div className='flex justify-between text-sm'>
|
<div className='flex justify-between text-[12px]'>
|
||||||
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
<span className='text-[var(--text-muted)]'>Current seats:</span>
|
||||||
<span>{currentSeats}</span>
|
<span className='text-[var(--text-primary)]'>{currentSeats}</span>
|
||||||
</div>
|
</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 className='text-[var(--text-muted)]'>New seats:</span>
|
||||||
<span>{selectedSeats}</span>
|
<span className='text-[var(--text-primary)]'>{selectedSeats}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-2 flex justify-between border-t pt-2 font-medium text-sm'>
|
<div className='mt-3 flex justify-between border-[var(--border)] border-t pt-3 text-[12px]'>
|
||||||
<span className='text-[var(--text-muted)]'>Monthly cost change:</span>
|
<span className='font-medium text-[var(--text-primary)]'>Monthly cost change:</span>
|
||||||
<span>
|
<span className='font-medium text-[var(--text-primary)]'>
|
||||||
{costChange > 0 ? '+' : ''}${costChange}
|
{costChange > 0 ? '+' : ''}${costChange}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,19 +108,14 @@ export function TeamSeats({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{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)}
|
{error instanceof Error && error.message ? error.message : String(error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
variant='outline'
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
disabled={isLoading}
|
|
||||||
className='h-[32px] px-[12px]'
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -127,22 +124,15 @@ export function TeamSeats({
|
|||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
variant='primary'
|
variant='primary'
|
||||||
onClick={handleConfirm}
|
onClick={() => onConfirm(selectedSeats)}
|
||||||
disabled={
|
disabled={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
|
selectedSeats < 1 ||
|
||||||
(showCostBreakdown && selectedSeats === currentSeats) ||
|
(showCostBreakdown && selectedSeats === currentSeats) ||
|
||||||
isCancelledAtPeriodEnd
|
isCancelledAtPeriodEnd
|
||||||
}
|
}
|
||||||
className='h-[32px] px-[12px]'
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? 'Updating...' : confirmButtonText}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
|
|||||||
@@ -390,11 +390,26 @@ export function TemplateProfile() {
|
|||||||
disabled={isUploadingProfilePicture}
|
disabled={isUploadingProfilePicture}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Input
|
||||||
placeholder='Name'
|
placeholder='Name'
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => updateField('name', e.target.value)}
|
onChange={(e) => updateField('name', e.target.value)}
|
||||||
className='h-9 flex-1'
|
className='h-9 flex-1'
|
||||||
|
name='profile_display_name'
|
||||||
|
autoComplete='off'
|
||||||
|
autoCorrect='off'
|
||||||
|
autoCapitalize='off'
|
||||||
|
data-lpignore='true'
|
||||||
|
data-form-type='other'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
{uploadError && <p className='text-[12px] text-[var(--text-error)]'>{uploadError}</p>}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
import { ChevronRight, Folder, FolderOpen } from 'lucide-react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
useItemRename,
|
useItemRename,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import { SIDEBAR_SCROLL_EVENT } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
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 { useCreateFolder, useUpdateFolder } from '@/hooks/queries/folders'
|
||||||
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
import { useCreateWorkflow } from '@/hooks/queries/workflows'
|
||||||
import type { FolderTreeNode } from '@/stores/folders/store'
|
import type { FolderTreeNode } from '@/stores/folders/store'
|
||||||
@@ -52,6 +56,9 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
|||||||
const createFolderMutation = useCreateFolder()
|
const createFolderMutation = useCreateFolder()
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
|
const { canDeleteFolder } = useCanDelete({ workspaceId })
|
||||||
|
const canDelete = useMemo(() => canDeleteFolder(folder.id), [canDeleteFolder, folder.id])
|
||||||
|
|
||||||
// Delete modal state
|
// Delete modal state
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -316,7 +323,7 @@ export function FolderItem({ folder, level, hoverHandlers }: FolderItemProps) {
|
|||||||
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
disableCreate={!userPermissions.canEdit || createWorkflowMutation.isPending}
|
||||||
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
disableCreateFolder={!userPermissions.canEdit || createFolderMutation.isPending}
|
||||||
disableDuplicate={!userPermissions.canEdit}
|
disableDuplicate={!userPermissions.canEdit}
|
||||||
disableDelete={!userPermissions.canEdit}
|
disableDelete={!userPermissions.canEdit || !canDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Modal */}
|
{/* Delete Modal */}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
useItemRename,
|
useItemRename,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import {
|
import {
|
||||||
|
useCanDelete,
|
||||||
useDeleteWorkflow,
|
useDeleteWorkflow,
|
||||||
useDuplicateWorkflow,
|
useDuplicateWorkflow,
|
||||||
useExportWorkflow,
|
useExportWorkflow,
|
||||||
@@ -44,10 +45,14 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const isSelected = selectedWorkflows.has(workflow.id)
|
const isSelected = selectedWorkflows.has(workflow.id)
|
||||||
|
|
||||||
|
// Can delete check hook
|
||||||
|
const { canDeleteWorkflows } = useCanDelete({ workspaceId })
|
||||||
|
|
||||||
// Delete modal state
|
// Delete modal state
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
const [workflowIdsToDelete, setWorkflowIdsToDelete] = useState<string[]>([])
|
||||||
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
const [deleteModalNames, setDeleteModalNames] = useState<string | string[]>('')
|
||||||
|
const [canDeleteCaptured, setCanDeleteCaptured] = useState(true)
|
||||||
|
|
||||||
// Presence avatars state
|
// Presence avatars state
|
||||||
const [hasAvatars, setHasAvatars] = useState(false)
|
const [hasAvatars, setHasAvatars] = useState(false)
|
||||||
@@ -172,10 +177,13 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
workflowNames: workflowNames.length > 1 ? workflowNames : workflowNames[0],
|
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
|
// If already selected with multiple selections, keep all selections
|
||||||
handleContextMenuBase(e)
|
handleContextMenuBase(e)
|
||||||
},
|
},
|
||||||
[workflow.id, workflows, handleContextMenuBase]
|
[workflow.id, workflows, handleContextMenuBase, canDeleteWorkflows]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Rename hook
|
// Rename hook
|
||||||
@@ -319,7 +327,7 @@ export function WorkflowItem({ workflow, active, level, onWorkflowClick }: Workf
|
|||||||
disableRename={!userPermissions.canEdit}
|
disableRename={!userPermissions.canEdit}
|
||||||
disableDuplicate={!userPermissions.canEdit}
|
disableDuplicate={!userPermissions.canEdit}
|
||||||
disableExport={!userPermissions.canEdit}
|
disableExport={!userPermissions.canEdit}
|
||||||
disableDelete={!userPermissions.canEdit}
|
disableDelete={!userPermissions.canEdit || !canDeleteCaptured}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
|
|||||||
@@ -677,16 +677,48 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
<ModalContent className='w-[500px]'>
|
<ModalContent className='w-[500px]'>
|
||||||
<ModalHeader>Invite members to {workspaceName || 'Workspace'}</ModalHeader>
|
<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>
|
<ModalBody>
|
||||||
<div className='space-y-[12px]'>
|
<div className='space-y-[12px]'>
|
||||||
<div>
|
<div>
|
||||||
<Label
|
<Label
|
||||||
htmlFor='emails'
|
htmlFor='invite-field'
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
||||||
>
|
>
|
||||||
Email Addresses
|
Email Addresses
|
||||||
</Label>
|
</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)]'>
|
<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) => (
|
{invalidEmails.map((email, index) => (
|
||||||
<EmailTag
|
<EmailTag
|
||||||
@@ -706,7 +738,8 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<Input
|
<Input
|
||||||
id='emails'
|
id='invite-field'
|
||||||
|
name='invite_search_field'
|
||||||
type='text'
|
type='text'
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
@@ -726,6 +759,13 @@ export function InviteModal({ open, onOpenChange, workspaceName }: InviteModalPr
|
|||||||
)}
|
)}
|
||||||
autoFocus={userPerms.canAdmin}
|
autoFocus={userPerms.canAdmin}
|
||||||
disabled={isSubmitting || !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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { useCanDelete } from './use-can-delete'
|
||||||
export { useDeleteFolder } from './use-delete-folder'
|
export { useDeleteFolder } from './use-delete-folder'
|
||||||
export { useDeleteWorkflow } from './use-delete-workflow'
|
export { useDeleteWorkflow } from './use-delete-workflow'
|
||||||
export { useDuplicateFolder } from './use-duplicate-folder'
|
export { useDuplicateFolder } from './use-duplicate-folder'
|
||||||
|
|||||||
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
130
apps/sim/app/workspace/[workspaceId]/w/hooks/use-can-delete.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useCallback, useMemo } from 'react'
|
||||||
|
import { useFolderStore } from '@/stores/folders/store'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
|
interface UseCanDeleteProps {
|
||||||
|
/**
|
||||||
|
* Current workspace ID
|
||||||
|
*/
|
||||||
|
workspaceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseCanDeleteReturn {
|
||||||
|
/**
|
||||||
|
* Checks if the given workflow IDs can be deleted.
|
||||||
|
* Returns false if deleting them would leave no workflows in the workspace.
|
||||||
|
*/
|
||||||
|
canDeleteWorkflows: (workflowIds: string[]) => boolean
|
||||||
|
/**
|
||||||
|
* Checks if the given folder can be deleted.
|
||||||
|
* Returns false if deleting it would leave no workflows in the workspace.
|
||||||
|
*/
|
||||||
|
canDeleteFolder: (folderId: string) => boolean
|
||||||
|
/**
|
||||||
|
* Total number of workflows in the workspace.
|
||||||
|
*/
|
||||||
|
totalWorkflows: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for checking if workflows or folders can be deleted.
|
||||||
|
* Prevents deletion if it would leave the workspace with no workflows.
|
||||||
|
*
|
||||||
|
* Uses pre-computed lookup maps for O(1) access instead of repeated filter() calls.
|
||||||
|
*
|
||||||
|
* @param props - Hook configuration
|
||||||
|
* @returns Functions to check deletion eligibility
|
||||||
|
*/
|
||||||
|
export function useCanDelete({ workspaceId }: UseCanDeleteProps): UseCanDeleteReturn {
|
||||||
|
const { workflows } = useWorkflowRegistry()
|
||||||
|
const { folders } = useFolderStore()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-computed data structures for efficient lookups
|
||||||
|
*/
|
||||||
|
const { totalWorkflows, workflowIdSet, workflowsByFolderId, childFoldersByParentId } =
|
||||||
|
useMemo(() => {
|
||||||
|
const workspaceWorkflows = Object.values(workflows).filter(
|
||||||
|
(w) => w.workspaceId === workspaceId
|
||||||
|
)
|
||||||
|
|
||||||
|
const idSet = new Set(workspaceWorkflows.map((w) => w.id))
|
||||||
|
|
||||||
|
const byFolderId = new Map<string, number>()
|
||||||
|
for (const w of workspaceWorkflows) {
|
||||||
|
if (w.folderId) {
|
||||||
|
byFolderId.set(w.folderId, (byFolderId.get(w.folderId) || 0) + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childrenByParent = new Map<string, string[]>()
|
||||||
|
for (const folder of Object.values(folders)) {
|
||||||
|
if (folder.workspaceId === workspaceId && folder.parentId) {
|
||||||
|
const children = childrenByParent.get(folder.parentId) || []
|
||||||
|
children.push(folder.id)
|
||||||
|
childrenByParent.set(folder.parentId, children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalWorkflows: workspaceWorkflows.length,
|
||||||
|
workflowIdSet: idSet,
|
||||||
|
workflowsByFolderId: byFolderId,
|
||||||
|
childFoldersByParentId: childrenByParent,
|
||||||
|
}
|
||||||
|
}, [workflows, folders, workspaceId])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count workflows in a folder and all its subfolders recursively.
|
||||||
|
* Uses pre-computed maps for efficient lookups.
|
||||||
|
*/
|
||||||
|
const countWorkflowsInFolder = useCallback(
|
||||||
|
(folderId: string): number => {
|
||||||
|
let count = workflowsByFolderId.get(folderId) || 0
|
||||||
|
|
||||||
|
const childFolders = childFoldersByParentId.get(folderId)
|
||||||
|
if (childFolders) {
|
||||||
|
for (const childId of childFolders) {
|
||||||
|
count += countWorkflowsInFolder(childId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
},
|
||||||
|
[workflowsByFolderId, childFoldersByParentId]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given workflow IDs can be deleted.
|
||||||
|
* Returns false if deleting would remove all workflows from the workspace.
|
||||||
|
*/
|
||||||
|
const canDeleteWorkflows = useCallback(
|
||||||
|
(workflowIds: string[]): boolean => {
|
||||||
|
const workflowsToDelete = workflowIds.filter((id) => workflowIdSet.has(id)).length
|
||||||
|
|
||||||
|
// Must have at least one workflow remaining after deletion
|
||||||
|
return totalWorkflows > 0 && workflowsToDelete < totalWorkflows
|
||||||
|
},
|
||||||
|
[totalWorkflows, workflowIdSet]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the given folder can be deleted.
|
||||||
|
* Empty folders are always deletable. Folders containing all workspace workflows are not.
|
||||||
|
*/
|
||||||
|
const canDeleteFolder = useCallback(
|
||||||
|
(folderId: string): boolean => {
|
||||||
|
const workflowsInFolder = countWorkflowsInFolder(folderId)
|
||||||
|
|
||||||
|
if (workflowsInFolder === 0) return true
|
||||||
|
return workflowsInFolder < totalWorkflows
|
||||||
|
},
|
||||||
|
[totalWorkflows, countWorkflowsInFolder]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
canDeleteWorkflows,
|
||||||
|
canDeleteFolder,
|
||||||
|
totalWorkflows,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,6 @@ export const KnowledgeBlock: BlockConfig = {
|
|||||||
type: 'knowledge-tag-filters',
|
type: 'knowledge-tag-filters',
|
||||||
placeholder: 'Add tag filters',
|
placeholder: 'Add tag filters',
|
||||||
condition: { field: 'operation', value: 'search' },
|
condition: { field: 'operation', value: 'search' },
|
||||||
mode: 'advanced',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'documentId',
|
id: 'documentId',
|
||||||
|
|||||||
409
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal file
409
apps/sim/components/emcn/components/date-picker/date-picker.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
/**
|
||||||
|
* DatePicker component with calendar dropdown for date selection.
|
||||||
|
* Uses Radix UI Popover primitives for positioning and accessibility.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* // Basic date picker
|
||||||
|
* <DatePicker
|
||||||
|
* value={date}
|
||||||
|
* onChange={(dateString) => setDate(dateString)}
|
||||||
|
* placeholder="Select date"
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/emcn/components/button/button'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
} from '@/components/emcn/components/popover/popover'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant styles for the date picker trigger button.
|
||||||
|
* Matches the combobox and input styling patterns.
|
||||||
|
*/
|
||||||
|
const datePickerVariants = cva(
|
||||||
|
'flex w-full rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] dark:bg-[var(--surface-9)] px-[8px] font-sans font-medium text-[var(--text-primary)] placeholder:text-[var(--text-muted)] dark:placeholder:text-[var(--text-muted)] outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 hover:border-[var(--surface-14)] hover:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)] dark:hover:bg-[var(--surface-11)]',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'py-[6px] text-sm',
|
||||||
|
sm: 'py-[5px] text-[12px]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface DatePickerProps
|
||||||
|
extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'>,
|
||||||
|
VariantProps<typeof datePickerVariants> {
|
||||||
|
/** Current selected date value (YYYY-MM-DD string or Date) */
|
||||||
|
value?: string | Date
|
||||||
|
/** Callback when date changes, returns YYYY-MM-DD format */
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
/** Placeholder text when no value is selected */
|
||||||
|
placeholder?: string
|
||||||
|
/** Whether the picker is disabled */
|
||||||
|
disabled?: boolean
|
||||||
|
/** Size variant */
|
||||||
|
size?: 'default' | 'sm'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Month names for calendar display.
|
||||||
|
*/
|
||||||
|
const MONTHS = [
|
||||||
|
'January',
|
||||||
|
'February',
|
||||||
|
'March',
|
||||||
|
'April',
|
||||||
|
'May',
|
||||||
|
'June',
|
||||||
|
'July',
|
||||||
|
'August',
|
||||||
|
'September',
|
||||||
|
'October',
|
||||||
|
'November',
|
||||||
|
'December',
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day abbreviations for calendar header.
|
||||||
|
*/
|
||||||
|
const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the number of days in a given month.
|
||||||
|
*/
|
||||||
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the day of the week (0-6) for the first day of the month.
|
||||||
|
*/
|
||||||
|
function getFirstDayOfMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date for display in the trigger button.
|
||||||
|
*/
|
||||||
|
function formatDateForDisplay(date: Date | null): string {
|
||||||
|
if (!date) return ''
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date as YYYY-MM-DD string.
|
||||||
|
*/
|
||||||
|
function formatDateAsString(year: number, month: number, day: number): string {
|
||||||
|
const m = (month + 1).toString().padStart(2, '0')
|
||||||
|
const d = day.toString().padStart(2, '0')
|
||||||
|
return `${year}-${m}-${d}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a string or Date value into a Date object.
|
||||||
|
* Handles various date formats including YYYY-MM-DD and ISO strings.
|
||||||
|
*/
|
||||||
|
function parseDate(value: string | Date | undefined): Date | null {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) return null
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Handle YYYY-MM-DD format (treat as local date)
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
return new Date(year, month - 1, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ISO strings with timezone (extract date part as local)
|
||||||
|
if (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value)) {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
// Use UTC date components to prevent timezone shift
|
||||||
|
return new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try parsing as-is
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DatePicker component matching emcn design patterns.
|
||||||
|
* Provides a calendar dropdown for date selection.
|
||||||
|
*/
|
||||||
|
const DatePicker = React.forwardRef<HTMLDivElement, DatePickerProps>(
|
||||||
|
(
|
||||||
|
{ className, variant, size, value, onChange, placeholder = 'Select date', disabled, ...props },
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
const selectedDate = parseDate(value)
|
||||||
|
|
||||||
|
const [viewMonth, setViewMonth] = React.useState(() => {
|
||||||
|
const d = selectedDate || new Date()
|
||||||
|
return d.getMonth()
|
||||||
|
})
|
||||||
|
const [viewYear, setViewYear] = React.useState(() => {
|
||||||
|
const d = selectedDate || new Date()
|
||||||
|
return d.getFullYear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update view when value changes externally
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedDate) {
|
||||||
|
setViewMonth(selectedDate.getMonth())
|
||||||
|
setViewYear(selectedDate.getFullYear())
|
||||||
|
}
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles selection of a specific day in the calendar.
|
||||||
|
*/
|
||||||
|
const handleSelectDate = React.useCallback(
|
||||||
|
(day: number) => {
|
||||||
|
onChange?.(formatDateAsString(viewYear, viewMonth, day))
|
||||||
|
setOpen(false)
|
||||||
|
},
|
||||||
|
[viewYear, viewMonth, onChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates to the previous month.
|
||||||
|
*/
|
||||||
|
const goToPrevMonth = React.useCallback(() => {
|
||||||
|
if (viewMonth === 0) {
|
||||||
|
setViewMonth(11)
|
||||||
|
setViewYear((prev) => prev - 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((prev) => prev - 1)
|
||||||
|
}
|
||||||
|
}, [viewMonth])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigates to the next month.
|
||||||
|
*/
|
||||||
|
const goToNextMonth = React.useCallback(() => {
|
||||||
|
if (viewMonth === 11) {
|
||||||
|
setViewMonth(0)
|
||||||
|
setViewYear((prev) => prev + 1)
|
||||||
|
} else {
|
||||||
|
setViewMonth((prev) => prev + 1)
|
||||||
|
}
|
||||||
|
}, [viewMonth])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects today's date and closes the picker.
|
||||||
|
*/
|
||||||
|
const handleSelectToday = React.useCallback(() => {
|
||||||
|
const now = new Date()
|
||||||
|
setViewMonth(now.getMonth())
|
||||||
|
setViewYear(now.getFullYear())
|
||||||
|
onChange?.(formatDateAsString(now.getFullYear(), now.getMonth(), now.getDate()))
|
||||||
|
setOpen(false)
|
||||||
|
}, [onChange])
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(viewYear, viewMonth)
|
||||||
|
const firstDayOfMonth = getFirstDayOfMonth(viewYear, viewMonth)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a day is today's date.
|
||||||
|
*/
|
||||||
|
const isToday = React.useCallback(
|
||||||
|
(day: number) => {
|
||||||
|
const today = new Date()
|
||||||
|
return (
|
||||||
|
today.getDate() === day &&
|
||||||
|
today.getMonth() === viewMonth &&
|
||||||
|
today.getFullYear() === viewYear
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[viewMonth, viewYear]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a day is the currently selected date.
|
||||||
|
*/
|
||||||
|
const isSelected = React.useCallback(
|
||||||
|
(day: number) => {
|
||||||
|
return (
|
||||||
|
selectedDate &&
|
||||||
|
selectedDate.getDate() === day &&
|
||||||
|
selectedDate.getMonth() === viewMonth &&
|
||||||
|
selectedDate.getFullYear() === viewYear
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[selectedDate, viewMonth, viewYear]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build calendar grid
|
||||||
|
const calendarDays = React.useMemo(() => {
|
||||||
|
const days: (number | null)[] = []
|
||||||
|
for (let i = 0; i < firstDayOfMonth; i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
days.push(day)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}, [firstDayOfMonth, daysInMonth])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles keyboard events on the trigger.
|
||||||
|
*/
|
||||||
|
const handleKeyDown = React.useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault()
|
||||||
|
setOpen(!open)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles click on the trigger.
|
||||||
|
*/
|
||||||
|
const handleTriggerClick = React.useCallback(() => {
|
||||||
|
if (!disabled) {
|
||||||
|
setOpen(!open)
|
||||||
|
}
|
||||||
|
}, [disabled, open])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<div ref={ref} className='relative w-full' {...props}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<div
|
||||||
|
role='button'
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
datePickerVariants({ variant, size }),
|
||||||
|
'relative cursor-pointer items-center justify-between',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={handleTriggerClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<span className={cn('flex-1 truncate', !selectedDate && 'text-[var(--text-muted)]')}>
|
||||||
|
{selectedDate ? formatDateForDisplay(selectedDate) : placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'ml-[8px] h-4 w-4 flex-shrink-0 opacity-50 transition-transform',
|
||||||
|
open && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverAnchor>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
side='bottom'
|
||||||
|
align='start'
|
||||||
|
sideOffset={4}
|
||||||
|
avoidCollisions={false}
|
||||||
|
className='w-[280px] rounded-[6px] border border-[var(--surface-11)] p-0'
|
||||||
|
>
|
||||||
|
{/* Calendar Header */}
|
||||||
|
<div className='flex items-center justify-between border-[var(--surface-11)] border-b px-[12px] py-[10px]'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
|
||||||
|
onClick={goToPrevMonth}
|
||||||
|
>
|
||||||
|
<ChevronLeft className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
<span className='font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
{MONTHS[viewMonth]} {viewYear}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex h-[24px] w-[24px] items-center justify-center rounded-[4px] text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-9)] hover:text-[var(--text-primary)]'
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRight className='h-4 w-4' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day Headers */}
|
||||||
|
<div className='grid grid-cols-7 px-[8px] pt-[8px]'>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className='flex h-[28px] items-center justify-center text-[11px] text-[var(--text-muted)]'
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar Grid */}
|
||||||
|
<div className='grid grid-cols-7 px-[8px] pb-[8px]'>
|
||||||
|
{calendarDays.map((day, index) => (
|
||||||
|
<div key={index} className='flex h-[32px] items-center justify-center'>
|
||||||
|
{day !== null && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={cn(
|
||||||
|
'flex h-[28px] w-[28px] items-center justify-center rounded-[4px] text-[12px] transition-colors',
|
||||||
|
isSelected(day)
|
||||||
|
? 'bg-[var(--brand-secondary)] text-[var(--bg)]'
|
||||||
|
: isToday(day)
|
||||||
|
? 'bg-[var(--surface-9)] text-[var(--text-primary)]'
|
||||||
|
: 'text-[var(--text-primary)] hover:bg-[var(--surface-9)]'
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectDate(day)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today Button */}
|
||||||
|
<div className='border-[var(--surface-11)] border-t px-[8px] py-[8px]'>
|
||||||
|
<Button variant='active' className='w-full' onClick={handleSelectToday}>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DatePicker.displayName = 'DatePicker'
|
||||||
|
|
||||||
|
export { DatePicker, datePickerVariants }
|
||||||
@@ -10,7 +10,8 @@ export {
|
|||||||
languages,
|
languages,
|
||||||
} from './code/code'
|
} from './code/code'
|
||||||
export { Combobox, type ComboboxOption } from './combobox/combobox'
|
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 { Label } from './label/label'
|
||||||
export {
|
export {
|
||||||
MODAL_SIZES,
|
MODAL_SIZES,
|
||||||
|
|||||||
@@ -1,7 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* A minimal input component matching the emcn design system.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* import { Input } from '@/components/emcn'
|
||||||
|
*
|
||||||
|
* // Basic usage
|
||||||
|
* <Input placeholder="Enter text..." />
|
||||||
|
*
|
||||||
|
* // Controlled input
|
||||||
|
* <Input value={value} onChange={(e) => setValue(e.target.value)} />
|
||||||
|
*
|
||||||
|
* // Disabled state
|
||||||
|
* <Input disabled placeholder="Cannot edit" />
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @see inputVariants for available styling variants
|
||||||
|
*/
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Variant styles for the Input component.
|
||||||
|
* Currently supports a 'default' variant.
|
||||||
|
*/
|
||||||
const inputVariants = cva(
|
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',
|
'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
|
export interface InputProps
|
||||||
extends React.InputHTMLAttributes<HTMLInputElement>,
|
extends React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
VariantProps<typeof inputVariants> {}
|
VariantProps<typeof inputVariants> {}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
|
* Uses fast transitions (duration-75) to prevent hover state "jumping" during rapid mouse movement.
|
||||||
*/
|
*/
|
||||||
const POPOVER_ITEM_BASE_CLASSES =
|
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.
|
* Variant-specific active state styles for popover items.
|
||||||
@@ -247,6 +247,11 @@ export interface PopoverContentProps
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
border?: boolean
|
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,
|
sideOffset,
|
||||||
collisionPadding = 8,
|
collisionPadding = 8,
|
||||||
border = false,
|
border = false,
|
||||||
|
avoidCollisions = true,
|
||||||
...restProps
|
...restProps
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -328,7 +334,7 @@ const PopoverContent = React.forwardRef<
|
|||||||
align={align}
|
align={align}
|
||||||
sideOffset={effectiveSideOffset}
|
sideOffset={effectiveSideOffset}
|
||||||
collisionPadding={collisionPadding}
|
collisionPadding={collisionPadding}
|
||||||
avoidCollisions={true}
|
avoidCollisions={avoidCollisions}
|
||||||
sticky='partial'
|
sticky='partial'
|
||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
@@ -425,7 +431,10 @@ export interface PopoverItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
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)
|
// Try to get context - if not available, we're outside Popover (shouldn't happen)
|
||||||
const context = React.useContext(PopoverContext)
|
const context = React.useContext(PopoverContext)
|
||||||
const variant = context?.variant || 'default'
|
const variant = context?.variant || 'default'
|
||||||
@@ -435,18 +444,28 @@ const PopoverItem = React.forwardRef<HTMLDivElement, PopoverItemProps>(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (disabled) {
|
||||||
|
e.stopPropagation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onClick?.(e)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
POPOVER_ITEM_BASE_CLASSES,
|
POPOVER_ITEM_BASE_CLASSES,
|
||||||
active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant],
|
!disabled &&
|
||||||
disabled && 'pointer-events-none cursor-not-allowed opacity-50',
|
(active ? POPOVER_ITEM_ACTIVE_CLASSES[variant] : POPOVER_ITEM_HOVER_CLASSES[variant]),
|
||||||
|
disabled && 'cursor-default opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
|
onClick={handleClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -707,8 +726,10 @@ const PopoverSearch = React.forwardRef<HTMLDivElement, PopoverSearchProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
setSearchQuery('')
|
||||||
|
onValueChange?.('')
|
||||||
inputRef.current?.focus()
|
inputRef.current?.focus()
|
||||||
}, [])
|
}, [setSearchQuery, onValueChange])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
|
<div ref={ref} className={cn('flex items-center px-[8px] py-[6px]', className)} {...props}>
|
||||||
|
|||||||
567
apps/sim/executor/dag/construction/edges.test.ts
Normal file
567
apps/sim/executor/dag/construction/edges.test.ts
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { DAG, DAGNode } from '@/executor/dag/builder'
|
||||||
|
import type { SerializedBlock, SerializedLoop, SerializedWorkflow } from '@/serializer/types'
|
||||||
|
import { EdgeConstructor } from './edges'
|
||||||
|
|
||||||
|
vi.mock('@/lib/logs/console/logger', () => ({
|
||||||
|
createLogger: vi.fn(() => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
function createMockBlock(id: string, type = 'function', config: any = {}): SerializedBlock {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
metadata: { id: type, name: `Block ${id}` },
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { tool: type, params: config },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockNode(id: string): DAGNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
block: createMockBlock(id),
|
||||||
|
outgoingEdges: new Map(),
|
||||||
|
incomingEdges: new Set(),
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockDAG(nodeIds: string[]): DAG {
|
||||||
|
const nodes = new Map<string, DAGNode>()
|
||||||
|
for (const id of nodeIds) {
|
||||||
|
nodes.set(id, createMockNode(id))
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nodes,
|
||||||
|
loopConfigs: new Map(),
|
||||||
|
parallelConfigs: new Map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockWorkflow(
|
||||||
|
blocks: SerializedBlock[],
|
||||||
|
connections: Array<{
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
sourceHandle?: string
|
||||||
|
targetHandle?: string
|
||||||
|
}>,
|
||||||
|
loops: Record<string, SerializedLoop> = {},
|
||||||
|
parallels: Record<string, any> = {}
|
||||||
|
): SerializedWorkflow {
|
||||||
|
return {
|
||||||
|
version: '1',
|
||||||
|
blocks,
|
||||||
|
connections,
|
||||||
|
loops,
|
||||||
|
parallels,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('EdgeConstructor', () => {
|
||||||
|
let edgeConstructor: EdgeConstructor
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
edgeConstructor = new EdgeConstructor()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge ID generation (bug fix verification)', () => {
|
||||||
|
it('should generate unique edge IDs for multiple edges to same target with different handles', () => {
|
||||||
|
const conditionId = 'condition-1'
|
||||||
|
const targetId = 'target-1'
|
||||||
|
|
||||||
|
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||||
|
conditions: JSON.stringify([
|
||||||
|
{ id: 'if-id', label: 'if', condition: 'true' },
|
||||||
|
{ id: 'else-id', label: 'else', condition: '' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[conditionBlock, createMockBlock(targetId)],
|
||||||
|
[
|
||||||
|
{ source: conditionId, target: targetId, sourceHandle: 'condition-if-id' },
|
||||||
|
{ source: conditionId, target: targetId, sourceHandle: 'condition-else-id' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([conditionId, targetId])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([conditionId, targetId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const conditionNode = dag.nodes.get(conditionId)!
|
||||||
|
|
||||||
|
// Should have 2 edges, not 1 (the bug was that they would overwrite each other)
|
||||||
|
expect(conditionNode.outgoingEdges.size).toBe(2)
|
||||||
|
|
||||||
|
// Verify edge IDs are unique and include the sourceHandle
|
||||||
|
const edgeIds = Array.from(conditionNode.outgoingEdges.keys())
|
||||||
|
expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-if-id`)
|
||||||
|
expect(edgeIds).toContain(`${conditionId}→${targetId}-condition-else-id`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate edge ID without handle suffix when no sourceHandle', () => {
|
||||||
|
const sourceId = 'source-1'
|
||||||
|
const targetId = 'target-1'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(sourceId), createMockBlock(targetId)],
|
||||||
|
[{ source: sourceId, target: targetId }]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([sourceId, targetId])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([sourceId, targetId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const sourceNode = dag.nodes.get(sourceId)!
|
||||||
|
const edgeIds = Array.from(sourceNode.outgoingEdges.keys())
|
||||||
|
|
||||||
|
expect(edgeIds).toContain(`${sourceId}→${targetId}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Condition block edge wiring', () => {
|
||||||
|
it('should wire condition block edges with proper condition prefixes', () => {
|
||||||
|
const conditionId = 'condition-1'
|
||||||
|
const target1Id = 'target-1'
|
||||||
|
const target2Id = 'target-2'
|
||||||
|
|
||||||
|
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||||
|
conditions: JSON.stringify([
|
||||||
|
{ id: 'cond-if', label: 'if', condition: 'x > 5' },
|
||||||
|
{ id: 'cond-else', label: 'else', condition: '' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[conditionBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
|
||||||
|
[
|
||||||
|
{ source: conditionId, target: target1Id, sourceHandle: 'condition-cond-if' },
|
||||||
|
{ source: conditionId, target: target2Id, sourceHandle: 'condition-cond-else' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([conditionId, target1Id, target2Id])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([conditionId, target1Id, target2Id]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const conditionNode = dag.nodes.get(conditionId)!
|
||||||
|
|
||||||
|
expect(conditionNode.outgoingEdges.size).toBe(2)
|
||||||
|
|
||||||
|
// Verify edges have correct targets and handles
|
||||||
|
const edges = Array.from(conditionNode.outgoingEdges.values())
|
||||||
|
const ifEdge = edges.find((e) => e.sourceHandle === 'condition-cond-if')
|
||||||
|
const elseEdge = edges.find((e) => e.sourceHandle === 'condition-cond-else')
|
||||||
|
|
||||||
|
expect(ifEdge?.target).toBe(target1Id)
|
||||||
|
expect(elseEdge?.target).toBe(target2Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle condition block with if→A, elseif→B, else→A pattern', () => {
|
||||||
|
const conditionId = 'condition-1'
|
||||||
|
const targetAId = 'target-a'
|
||||||
|
const targetBId = 'target-b'
|
||||||
|
|
||||||
|
const conditionBlock = createMockBlock(conditionId, 'condition', {
|
||||||
|
conditions: JSON.stringify([
|
||||||
|
{ id: 'if-id', label: 'if', condition: 'x == 1' },
|
||||||
|
{ id: 'elseif-id', label: 'else if', condition: 'x == 2' },
|
||||||
|
{ id: 'else-id', label: 'else', condition: '' },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[conditionBlock, createMockBlock(targetAId), createMockBlock(targetBId)],
|
||||||
|
[
|
||||||
|
{ source: conditionId, target: targetAId, sourceHandle: 'condition-if-id' },
|
||||||
|
{ source: conditionId, target: targetBId, sourceHandle: 'condition-elseif-id' },
|
||||||
|
{ source: conditionId, target: targetAId, sourceHandle: 'condition-else-id' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([conditionId, targetAId, targetBId])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([conditionId, targetAId, targetBId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const conditionNode = dag.nodes.get(conditionId)!
|
||||||
|
|
||||||
|
// Should have 3 edges (if→A, elseif→B, else→A)
|
||||||
|
expect(conditionNode.outgoingEdges.size).toBe(3)
|
||||||
|
|
||||||
|
// Target A should have 2 incoming edges (from if and else)
|
||||||
|
const targetANode = dag.nodes.get(targetAId)!
|
||||||
|
expect(targetANode.incomingEdges.has(conditionId)).toBe(true)
|
||||||
|
|
||||||
|
// Target B should have 1 incoming edge (from elseif)
|
||||||
|
const targetBNode = dag.nodes.get(targetBId)!
|
||||||
|
expect(targetBNode.incomingEdges.has(conditionId)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Router block edge wiring', () => {
|
||||||
|
it('should wire router block edges with router prefix', () => {
|
||||||
|
const routerId = 'router-1'
|
||||||
|
const target1Id = 'target-1'
|
||||||
|
const target2Id = 'target-2'
|
||||||
|
|
||||||
|
const routerBlock = createMockBlock(routerId, 'router')
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[routerBlock, createMockBlock(target1Id), createMockBlock(target2Id)],
|
||||||
|
[
|
||||||
|
{ source: routerId, target: target1Id },
|
||||||
|
{ source: routerId, target: target2Id },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([routerId, target1Id, target2Id])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([routerId, target1Id, target2Id]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const routerNode = dag.nodes.get(routerId)!
|
||||||
|
const edges = Array.from(routerNode.outgoingEdges.values())
|
||||||
|
|
||||||
|
// Router edges should have router- prefix with target ID
|
||||||
|
expect(edges[0].sourceHandle).toBe(`router-${target1Id}`)
|
||||||
|
expect(edges[1].sourceHandle).toBe(`router-${target2Id}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Simple linear workflow', () => {
|
||||||
|
it('should wire linear workflow correctly', () => {
|
||||||
|
const block1Id = 'block-1'
|
||||||
|
const block2Id = 'block-2'
|
||||||
|
const block3Id = 'block-3'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(block3Id)],
|
||||||
|
[
|
||||||
|
{ source: block1Id, target: block2Id },
|
||||||
|
{ source: block2Id, target: block3Id },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([block1Id, block2Id, block3Id])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([block1Id, block2Id, block3Id]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Block 1 → Block 2
|
||||||
|
const block1Node = dag.nodes.get(block1Id)!
|
||||||
|
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||||
|
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
|
||||||
|
|
||||||
|
// Block 2 → Block 3
|
||||||
|
const block2Node = dag.nodes.get(block2Id)!
|
||||||
|
expect(block2Node.outgoingEdges.size).toBe(1)
|
||||||
|
expect(Array.from(block2Node.outgoingEdges.values())[0].target).toBe(block3Id)
|
||||||
|
expect(block2Node.incomingEdges.has(block1Id)).toBe(true)
|
||||||
|
|
||||||
|
// Block 3 has incoming from Block 2
|
||||||
|
const block3Node = dag.nodes.get(block3Id)!
|
||||||
|
expect(block3Node.incomingEdges.has(block2Id)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Edge reachability', () => {
|
||||||
|
it('should not wire edges to blocks not in DAG nodes', () => {
|
||||||
|
const block1Id = 'block-1'
|
||||||
|
const block2Id = 'block-2'
|
||||||
|
const unreachableId = 'unreachable'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(block1Id), createMockBlock(block2Id), createMockBlock(unreachableId)],
|
||||||
|
[
|
||||||
|
{ source: block1Id, target: block2Id },
|
||||||
|
{ source: block1Id, target: unreachableId },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Only create DAG nodes for block1 and block2 (not unreachable)
|
||||||
|
const dag = createMockDAG([block1Id, block2Id])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([block1Id, block2Id]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const block1Node = dag.nodes.get(block1Id)!
|
||||||
|
|
||||||
|
// Should only have edge to block2, not unreachable (not in DAG)
|
||||||
|
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||||
|
expect(Array.from(block1Node.outgoingEdges.values())[0].target).toBe(block2Id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should check both reachableBlocks and dag.nodes for edge validity', () => {
|
||||||
|
const block1Id = 'block-1'
|
||||||
|
const block2Id = 'block-2'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(block1Id), createMockBlock(block2Id)],
|
||||||
|
[{ source: block1Id, target: block2Id }]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([block1Id, block2Id])
|
||||||
|
|
||||||
|
// Block2 exists in DAG but not in reachableBlocks - edge should still be wired
|
||||||
|
// because isEdgeReachable checks: reachableBlocks.has(target) || dag.nodes.has(target)
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([block1Id]), // Only block1 is "reachable" but block2 exists in DAG
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const block1Node = dag.nodes.get(block1Id)!
|
||||||
|
expect(block1Node.outgoingEdges.size).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error edge handling', () => {
|
||||||
|
it('should preserve error sourceHandle', () => {
|
||||||
|
const sourceId = 'source-1'
|
||||||
|
const successTargetId = 'success-target'
|
||||||
|
const errorTargetId = 'error-target'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[
|
||||||
|
createMockBlock(sourceId),
|
||||||
|
createMockBlock(successTargetId),
|
||||||
|
createMockBlock(errorTargetId),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ source: sourceId, target: successTargetId, sourceHandle: 'source' },
|
||||||
|
{ source: sourceId, target: errorTargetId, sourceHandle: 'error' },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([sourceId, successTargetId, errorTargetId])
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set(),
|
||||||
|
new Set([sourceId, successTargetId, errorTargetId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
const sourceNode = dag.nodes.get(sourceId)!
|
||||||
|
const edges = Array.from(sourceNode.outgoingEdges.values())
|
||||||
|
|
||||||
|
const successEdge = edges.find((e) => e.target === successTargetId)
|
||||||
|
const errorEdge = edges.find((e) => e.target === errorTargetId)
|
||||||
|
|
||||||
|
expect(successEdge?.sourceHandle).toBe('source')
|
||||||
|
expect(errorEdge?.sourceHandle).toBe('error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Loop sentinel wiring', () => {
|
||||||
|
it('should wire loop sentinels to nodes with no incoming edges from within loop', () => {
|
||||||
|
const loopId = 'loop-1'
|
||||||
|
const nodeInLoopId = 'node-in-loop'
|
||||||
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
||||||
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
||||||
|
|
||||||
|
// Create DAG with sentinels - nodeInLoop has no incoming edges from loop nodes
|
||||||
|
// so it will be identified as a start node
|
||||||
|
const dag = createMockDAG([nodeInLoopId, sentinelStartId, sentinelEndId])
|
||||||
|
dag.loopConfigs.set(loopId, {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [nodeInLoopId],
|
||||||
|
iterations: 5,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop)
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow([createMockBlock(nodeInLoopId)], [], {
|
||||||
|
[loopId]: {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [nodeInLoopId],
|
||||||
|
iterations: 5,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop,
|
||||||
|
})
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set([nodeInLoopId]),
|
||||||
|
new Set([nodeInLoopId, sentinelStartId, sentinelEndId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel start should have edge to node in loop (it's a start node - no incoming from loop)
|
||||||
|
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
|
||||||
|
expect(sentinelStartNode.outgoingEdges.size).toBe(1)
|
||||||
|
const startEdge = Array.from(sentinelStartNode.outgoingEdges.values())[0]
|
||||||
|
expect(startEdge.target).toBe(nodeInLoopId)
|
||||||
|
|
||||||
|
// Node in loop should have edge to sentinel end (it's a terminal node - no outgoing to loop)
|
||||||
|
const nodeInLoopNode = dag.nodes.get(nodeInLoopId)!
|
||||||
|
const hasEdgeToEnd = Array.from(nodeInLoopNode.outgoingEdges.values()).some(
|
||||||
|
(e) => e.target === sentinelEndId
|
||||||
|
)
|
||||||
|
expect(hasEdgeToEnd).toBe(true)
|
||||||
|
|
||||||
|
// Sentinel end should have loop_continue edge back to start
|
||||||
|
const sentinelEndNode = dag.nodes.get(sentinelEndId)!
|
||||||
|
const continueEdge = Array.from(sentinelEndNode.outgoingEdges.values()).find(
|
||||||
|
(e) => e.sourceHandle === 'loop_continue'
|
||||||
|
)
|
||||||
|
expect(continueEdge?.target).toBe(sentinelStartId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should identify multiple start and terminal nodes in loop', () => {
|
||||||
|
const loopId = 'loop-1'
|
||||||
|
const node1Id = 'node-1'
|
||||||
|
const node2Id = 'node-2'
|
||||||
|
const sentinelStartId = `loop-${loopId}-sentinel-start`
|
||||||
|
const sentinelEndId = `loop-${loopId}-sentinel-end`
|
||||||
|
|
||||||
|
// Create DAG with two nodes in loop - both are start and terminal (no edges between them)
|
||||||
|
const dag = createMockDAG([node1Id, node2Id, sentinelStartId, sentinelEndId])
|
||||||
|
dag.loopConfigs.set(loopId, {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [node1Id, node2Id],
|
||||||
|
iterations: 3,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop)
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(node1Id), createMockBlock(node2Id)],
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
[loopId]: {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [node1Id, node2Id],
|
||||||
|
iterations: 3,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set([node1Id, node2Id]),
|
||||||
|
new Set([node1Id, node2Id, sentinelStartId, sentinelEndId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel start should have edges to both nodes (both are start nodes)
|
||||||
|
const sentinelStartNode = dag.nodes.get(sentinelStartId)!
|
||||||
|
expect(sentinelStartNode.outgoingEdges.size).toBe(2)
|
||||||
|
|
||||||
|
// Both nodes should have edges to sentinel end (both are terminal nodes)
|
||||||
|
const node1 = dag.nodes.get(node1Id)!
|
||||||
|
const node2 = dag.nodes.get(node2Id)!
|
||||||
|
expect(Array.from(node1.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(Array.from(node2.outgoingEdges.values()).some((e) => e.target === sentinelEndId)).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cross-loop boundary detection', () => {
|
||||||
|
it('should not wire edges that cross loop boundaries', () => {
|
||||||
|
const outsideId = 'outside'
|
||||||
|
const insideId = 'inside'
|
||||||
|
const loopId = 'loop-1'
|
||||||
|
|
||||||
|
const workflow = createMockWorkflow(
|
||||||
|
[createMockBlock(outsideId), createMockBlock(insideId)],
|
||||||
|
[{ source: outsideId, target: insideId }],
|
||||||
|
{
|
||||||
|
[loopId]: {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [insideId],
|
||||||
|
iterations: 5,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const dag = createMockDAG([outsideId, insideId])
|
||||||
|
dag.loopConfigs.set(loopId, {
|
||||||
|
id: loopId,
|
||||||
|
nodes: [insideId],
|
||||||
|
iterations: 5,
|
||||||
|
loopType: 'for',
|
||||||
|
} as SerializedLoop)
|
||||||
|
|
||||||
|
edgeConstructor.execute(
|
||||||
|
workflow,
|
||||||
|
dag,
|
||||||
|
new Set(),
|
||||||
|
new Set([insideId]),
|
||||||
|
new Set([outsideId, insideId]),
|
||||||
|
new Map()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Edge should not be wired because it crosses loop boundary
|
||||||
|
const outsideNode = dag.nodes.get(outsideId)!
|
||||||
|
expect(outsideNode.outgoingEdges.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -578,7 +578,7 @@ export class EdgeConstructor {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeId = `${sourceId}→${targetId}`
|
const edgeId = `${sourceId}→${targetId}${sourceHandle ? `-${sourceHandle}` : ''}`
|
||||||
|
|
||||||
sourceNode.outgoingEdges.set(edgeId, {
|
sourceNode.outgoingEdges.set(edgeId, {
|
||||||
target: targetId,
|
target: targetId,
|
||||||
|
|||||||
1052
apps/sim/executor/execution/edge-manager.test.ts
Normal file
1052
apps/sim/executor/execution/edge-manager.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,10 @@ export class EdgeManager {
|
|||||||
): string[] {
|
): string[] {
|
||||||
const readyNodes: string[] = []
|
const readyNodes: string[] = []
|
||||||
const activatedTargets: 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) {
|
for (const [edgeId, edge] of node.outgoingEdges) {
|
||||||
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
if (skipBackwardsEdge && this.isBackwardsEdge(edge.sourceHandle)) {
|
||||||
continue
|
continue
|
||||||
@@ -32,23 +35,31 @@ export class EdgeManager {
|
|||||||
edge.sourceHandle === EDGE.LOOP_EXIT
|
edge.sourceHandle === EDGE.LOOP_EXIT
|
||||||
|
|
||||||
if (!isLoopEdge) {
|
if (!isLoopEdge) {
|
||||||
this.deactivateEdgeAndDescendants(node.id, edge.target, edge.sourceHandle)
|
edgesToDeactivate.push({ target: edge.target, handle: edge.sourceHandle })
|
||||||
}
|
}
|
||||||
|
|
||||||
continue
|
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)
|
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) {
|
for (const targetId of activatedTargets) {
|
||||||
const targetNode = this.dag.nodes.get(targetId)
|
const targetNode = this.dag.nodes.get(targetId)
|
||||||
if (targetNode && this.isNodeReady(targetNode)) {
|
if (targetNode && this.isNodeReady(targetNode)) {
|
||||||
@@ -162,7 +173,10 @@ export class EdgeManager {
|
|||||||
const targetNode = this.dag.nodes.get(targetId)
|
const targetNode = this.dag.nodes.get(targetId)
|
||||||
if (!targetNode) return
|
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) {
|
if (!hasOtherActiveIncoming) {
|
||||||
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
|
for (const [_, outgoingEdge] of targetNode.outgoingEdges) {
|
||||||
this.deactivateEdgeAndDescendants(targetId, outgoingEdge.target, outgoingEdge.sourceHandle)
|
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) {
|
for (const incomingSourceId of node.incomingEdges) {
|
||||||
if (incomingSourceId === excludeSourceId) continue
|
|
||||||
|
|
||||||
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
const incomingNode = this.dag.nodes.get(incomingSourceId)
|
||||||
if (!incomingNode) continue
|
if (!incomingNode) continue
|
||||||
|
|
||||||
@@ -184,6 +201,8 @@ export class EdgeManager {
|
|||||||
node.id,
|
node.id,
|
||||||
incomingEdge.sourceHandle
|
incomingEdge.sourceHandle
|
||||||
)
|
)
|
||||||
|
// Skip the specific edge being excluded, but check other edges from same source
|
||||||
|
if (incomingEdgeKey === excludeEdgeKey) continue
|
||||||
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
if (!this.deactivatedEdges.has(incomingEdgeKey)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,18 @@ vi.mock('@/tools', () => ({
|
|||||||
executeTool: vi.fn(),
|
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'
|
import { executeTool } from '@/tools'
|
||||||
|
|
||||||
const mockExecuteTool = executeTool as ReturnType<typeof vi.fn>
|
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
|
* Simulates what the function_execute tool does when evaluating condition code
|
||||||
@@ -34,8 +43,6 @@ function simulateConditionExecution(code: string): {
|
|||||||
error?: string
|
error?: string
|
||||||
} {
|
} {
|
||||||
try {
|
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 fn = new Function(code)
|
||||||
const result = fn()
|
const result = fn()
|
||||||
return { success: true, output: { result } }
|
return { success: true, output: { result } }
|
||||||
@@ -55,8 +62,6 @@ describe('ConditionBlockHandler', () => {
|
|||||||
let mockSourceBlock: SerializedBlock
|
let mockSourceBlock: SerializedBlock
|
||||||
let mockTargetBlock1: SerializedBlock
|
let mockTargetBlock1: SerializedBlock
|
||||||
let mockTargetBlock2: SerializedBlock
|
let mockTargetBlock2: SerializedBlock
|
||||||
let mockResolver: any
|
|
||||||
let mockPathTracker: any
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSourceBlock = {
|
mockSourceBlock = {
|
||||||
@@ -113,18 +118,11 @@ describe('ConditionBlockHandler', () => {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
mockResolver = {
|
handler = new ConditionBlockHandler()
|
||||||
resolveVariableReferences: vi.fn((expr) => expr),
|
|
||||||
resolveBlockReferences: vi.fn((expr) => expr),
|
|
||||||
resolveEnvVariables: vi.fn((expr) => expr),
|
|
||||||
}
|
|
||||||
|
|
||||||
mockPathTracker = {}
|
|
||||||
|
|
||||||
handler = new ConditionBlockHandler(mockPathTracker, mockResolver)
|
|
||||||
|
|
||||||
mockContext = {
|
mockContext = {
|
||||||
workflowId: 'test-workflow-id',
|
workflowId: 'test-workflow-id',
|
||||||
|
workspaceId: 'test-workspace-id',
|
||||||
blockStates: new Map<string, BlockState>([
|
blockStates: new Map<string, BlockState>([
|
||||||
[
|
[
|
||||||
mockSourceBlock.id,
|
mockSourceBlock.id,
|
||||||
@@ -137,7 +135,8 @@ describe('ConditionBlockHandler', () => {
|
|||||||
]),
|
]),
|
||||||
blockLogs: [],
|
blockLogs: [],
|
||||||
metadata: { duration: 0 },
|
metadata: { duration: 0 },
|
||||||
environmentVariables: {},
|
environmentVariables: { API_KEY: 'test-key' },
|
||||||
|
workflowVariables: { userName: { name: 'userName', value: 'john', type: 'plain' } },
|
||||||
decisions: { router: new Map(), condition: new Map() },
|
decisions: { router: new Map(), condition: new Map() },
|
||||||
loopExecutions: new Map(),
|
loopExecutions: new Map(),
|
||||||
executedBlocks: new Set([mockSourceBlock.id]),
|
executedBlocks: new Set([mockSourceBlock.id]),
|
||||||
@@ -178,26 +177,41 @@ describe('ConditionBlockHandler', () => {
|
|||||||
selectedOption: 'cond1',
|
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)
|
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(result).toEqual(expectedOutput)
|
||||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('cond1')
|
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 () => {
|
it('should select the else path if other conditions fail', async () => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
{ id: 'cond1', title: 'if', value: 'context.value < 0' }, // Should fail (10 < 0 is false)
|
{ id: 'cond1', title: 'if', value: 'context.value < 0' }, // Should fail (10 < 0 is false)
|
||||||
@@ -217,22 +231,8 @@ describe('ConditionBlockHandler', () => {
|
|||||||
selectedOption: 'else1',
|
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)
|
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(result).toEqual(expectedOutput)
|
||||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
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 () => {
|
it('should handle evaluation errors gracefully', async () => {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
|
{ id: 'cond1', title: 'if', value: 'context.nonExistentProperty.doSomething()' },
|
||||||
@@ -347,12 +252,6 @@ describe('ConditionBlockHandler', () => {
|
|||||||
]
|
]
|
||||||
const inputs = { conditions: JSON.stringify(conditions) }
|
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(
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||||
/Evaluation error in condition "if".*doSomething/
|
/Evaluation error in condition "if".*doSomething/
|
||||||
)
|
)
|
||||||
@@ -367,10 +266,6 @@ describe('ConditionBlockHandler', () => {
|
|||||||
blockStates: new Map<string, BlockState>(),
|
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)
|
const result = await handler.execute(contextWithoutSource, mockBlock, inputs)
|
||||||
|
|
||||||
expect(result).toHaveProperty('conditionResult', true)
|
expect(result).toHaveProperty('conditionResult', true)
|
||||||
@@ -383,10 +278,6 @@ describe('ConditionBlockHandler', () => {
|
|||||||
|
|
||||||
mockContext.workflow!.blocks = [mockSourceBlock, mockBlock, mockTargetBlock2]
|
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(
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||||
`Target block ${mockTargetBlock1.id} not found`
|
`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)
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
expect((result as any).conditionResult).toBe(false)
|
expect((result as any).conditionResult).toBe(false)
|
||||||
@@ -433,13 +314,317 @@ describe('ConditionBlockHandler', () => {
|
|||||||
]
|
]
|
||||||
const inputs = { conditions: JSON.stringify(conditions) }
|
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)
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
expect(mockContext.decisions.condition.get(mockBlock.id)).toBe('else1')
|
||||||
expect((result as any).selectedOption).toBe('else1')
|
expect((result as any).selectedOption).toBe('else1')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should use collectBlockData to gather block state', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'true' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect(mockCollectBlockData).toHaveBeenCalledWith(mockContext)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle function_execute tool failure', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
mockExecuteTool.mockResolvedValueOnce({
|
||||||
|
success: false,
|
||||||
|
error: 'Execution timeout',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow(
|
||||||
|
/Evaluation error in condition "if".*Execution timeout/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Multiple branches to same target', () => {
|
||||||
|
it('should handle if and else pointing to same target', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.value > 5' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
// Both branches point to the same target
|
||||||
|
mockContext.workflow!.connections = [
|
||||||
|
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).conditionResult).toBe(true)
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
expect((result as any).selectedPath).toEqual({
|
||||||
|
blockId: mockTargetBlock1.id,
|
||||||
|
blockType: 'target',
|
||||||
|
blockTitle: 'Target Block 1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should select else branch to same target when if fails', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.value < 0' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
// Both branches point to the same target
|
||||||
|
mockContext.workflow!.connections = [
|
||||||
|
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).conditionResult).toBe(true)
|
||||||
|
expect((result as any).selectedOption).toBe('else1')
|
||||||
|
expect((result as any).selectedPath).toEqual({
|
||||||
|
blockId: mockTargetBlock1.id,
|
||||||
|
blockType: 'target',
|
||||||
|
blockTitle: 'Target Block 1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle if→A, elseif→B, else→A pattern', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.value === 1' },
|
||||||
|
{ id: 'cond2', title: 'else if', value: 'context.value === 2' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
mockContext.workflow!.connections = [
|
||||||
|
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// value is 10, so else should be selected (pointing to target 1)
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).conditionResult).toBe(true)
|
||||||
|
expect((result as any).selectedOption).toBe('else1')
|
||||||
|
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock1.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Condition evaluation with different data types', () => {
|
||||||
|
it('should evaluate string comparison conditions', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { name: 'test', status: 'active' },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.status === "active"' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should evaluate boolean conditions', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { isEnabled: true, count: 5 },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.isEnabled' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should evaluate array length conditions', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { items: [1, 2, 3, 4, 5] },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.items.length > 3' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should evaluate null/undefined check conditions', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { data: null },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.data === null' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Multiple else-if conditions', () => {
|
||||||
|
it('should evaluate multiple else-if conditions in order', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { score: 75 },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockTargetBlock3: SerializedBlock = {
|
||||||
|
id: 'target-block-3',
|
||||||
|
metadata: { id: 'target', name: 'Target Block 3' },
|
||||||
|
position: { x: 100, y: 200 },
|
||||||
|
config: { tool: 'target_tool_3', params: {} },
|
||||||
|
inputs: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
mockContext.workflow!.blocks!.push(mockTargetBlock3)
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
|
||||||
|
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
|
||||||
|
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
mockContext.workflow!.connections = [
|
||||||
|
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-cond1' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-cond2' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock3.id, sourceHandle: 'condition-cond3' },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock1.id, sourceHandle: 'condition-else1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
// Score is 75, so second condition (>=70) should match
|
||||||
|
expect((result as any).selectedOption).toBe('cond2')
|
||||||
|
expect((result as any).selectedPath?.blockId).toBe(mockTargetBlock2.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip to else when all else-if fail', async () => {
|
||||||
|
;(mockContext.blockStates as any).set(mockSourceBlock.id, {
|
||||||
|
output: { score: 30 },
|
||||||
|
executed: true,
|
||||||
|
executionTime: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'context.score >= 90' },
|
||||||
|
{ id: 'cond2', title: 'else if', value: 'context.score >= 70' },
|
||||||
|
{ id: 'cond3', title: 'else if', value: 'context.score >= 50' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('else1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Condition with no outgoing edge', () => {
|
||||||
|
it('should return null path when condition matches but has no edge', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'true' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
// No connection for cond1
|
||||||
|
mockContext.workflow!.connections = [
|
||||||
|
{ source: mockSourceBlock.id, target: mockBlock.id },
|
||||||
|
{ source: mockBlock.id, target: mockTargetBlock2.id, sourceHandle: 'condition-else1' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
// Condition matches but no edge for it
|
||||||
|
expect((result as any).conditionResult).toBe(false)
|
||||||
|
expect((result as any).selectedPath).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Empty conditions handling', () => {
|
||||||
|
it('should handle empty conditions array', async () => {
|
||||||
|
const conditions: unknown[] = []
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).conditionResult).toBe(false)
|
||||||
|
expect((result as any).selectedPath).toBeNull()
|
||||||
|
expect((result as any).selectedOption).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle conditions passed as array directly', async () => {
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'true' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
// Pass as array instead of JSON string
|
||||||
|
const inputs = { conditions }
|
||||||
|
|
||||||
|
const result = await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
expect((result as any).selectedOption).toBe('cond1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Virtual block ID handling', () => {
|
||||||
|
it('should use currentVirtualBlockId for decision key when available', async () => {
|
||||||
|
mockContext.currentVirtualBlockId = 'virtual-block-123'
|
||||||
|
|
||||||
|
const conditions = [
|
||||||
|
{ id: 'cond1', title: 'if', value: 'true' },
|
||||||
|
{ id: 'else1', title: 'else', value: '' },
|
||||||
|
]
|
||||||
|
const inputs = { conditions: JSON.stringify(conditions) }
|
||||||
|
|
||||||
|
await handler.execute(mockContext, mockBlock, inputs)
|
||||||
|
|
||||||
|
// Decision should be stored under virtual block ID, not actual block ID
|
||||||
|
expect(mockContext.decisions.condition.get('virtual-block-123')).toBe('cond1')
|
||||||
|
expect(mockContext.decisions.condition.has(mockBlock.id)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createLogger } from '@/lib/logs/console/logger'
|
|||||||
import type { BlockOutput } from '@/blocks/types'
|
import type { BlockOutput } from '@/blocks/types'
|
||||||
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
|
import { BlockType, CONDITION, DEFAULTS, EDGE } from '@/executor/constants'
|
||||||
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
import type { BlockHandler, ExecutionContext } from '@/executor/types'
|
||||||
|
import { collectBlockData } from '@/executor/utils/block-data'
|
||||||
import type { SerializedBlock } from '@/serializer/types'
|
import type { SerializedBlock } from '@/serializer/types'
|
||||||
import { executeTool } from '@/tools'
|
import { executeTool } from '@/tools'
|
||||||
|
|
||||||
@@ -10,43 +11,32 @@ const logger = createLogger('ConditionBlockHandler')
|
|||||||
const CONDITION_TIMEOUT_MS = 5000
|
const CONDITION_TIMEOUT_MS = 5000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluates a single condition expression with variable/block reference resolution
|
* Evaluates a single condition expression.
|
||||||
* Returns true if condition is met, false otherwise
|
* 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(
|
export async function evaluateConditionExpression(
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
conditionExpression: string,
|
conditionExpression: string,
|
||||||
block: SerializedBlock,
|
|
||||||
resolver: any,
|
|
||||||
providedEvalContext?: Record<string, any>
|
providedEvalContext?: Record<string, any>
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const evalContext = providedEvalContext || {}
|
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 {
|
try {
|
||||||
const contextSetup = `const context = ${JSON.stringify(evalContext)};`
|
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(
|
const result = await executeTool(
|
||||||
'function_execute',
|
'function_execute',
|
||||||
{
|
{
|
||||||
code,
|
code,
|
||||||
timeout: CONDITION_TIMEOUT_MS,
|
timeout: CONDITION_TIMEOUT_MS,
|
||||||
envVars: {},
|
envVars: ctx.environmentVariables || {},
|
||||||
|
workflowVariables: ctx.workflowVariables || {},
|
||||||
|
blockData,
|
||||||
|
blockNameMapping,
|
||||||
_context: {
|
_context: {
|
||||||
workflowId: ctx.workflowId,
|
workflowId: ctx.workflowId,
|
||||||
workspaceId: ctx.workspaceId,
|
workspaceId: ctx.workspaceId,
|
||||||
@@ -60,26 +50,20 @@ export async function evaluateConditionExpression(
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(`Failed to evaluate condition: ${result.error}`, {
|
logger.error(`Failed to evaluate condition: ${result.error}`, {
|
||||||
originalCondition: conditionExpression,
|
originalCondition: conditionExpression,
|
||||||
resolvedCondition: resolvedConditionValue,
|
|
||||||
evalContext,
|
evalContext,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
})
|
})
|
||||||
throw new Error(
|
throw new Error(`Evaluation error in condition: ${result.error}`)
|
||||||
`Evaluation error in condition: ${result.error}. (Resolved: ${resolvedConditionValue})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Boolean(result.output?.result)
|
return Boolean(result.output?.result)
|
||||||
} catch (evalError: any) {
|
} catch (evalError: any) {
|
||||||
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
logger.error(`Failed to evaluate condition: ${evalError.message}`, {
|
||||||
originalCondition: conditionExpression,
|
originalCondition: conditionExpression,
|
||||||
resolvedCondition: resolvedConditionValue,
|
|
||||||
evalContext,
|
evalContext,
|
||||||
evalError,
|
evalError,
|
||||||
})
|
})
|
||||||
throw new Error(
|
throw new Error(`Evaluation error in condition: ${evalError.message}`)
|
||||||
`Evaluation error in condition: ${evalError.message}. (Resolved: ${resolvedConditionValue})`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,11 +71,6 @@ export async function evaluateConditionExpression(
|
|||||||
* Handler for Condition blocks that evaluate expressions to determine execution paths.
|
* Handler for Condition blocks that evaluate expressions to determine execution paths.
|
||||||
*/
|
*/
|
||||||
export class ConditionBlockHandler implements BlockHandler {
|
export class ConditionBlockHandler implements BlockHandler {
|
||||||
constructor(
|
|
||||||
private pathTracker?: any,
|
|
||||||
private resolver?: any
|
|
||||||
) {}
|
|
||||||
|
|
||||||
canHandle(block: SerializedBlock): boolean {
|
canHandle(block: SerializedBlock): boolean {
|
||||||
return block.metadata?.id === BlockType.CONDITION
|
return block.metadata?.id === BlockType.CONDITION
|
||||||
}
|
}
|
||||||
@@ -104,7 +83,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
const conditions = this.parseConditions(inputs.conditions)
|
const conditions = this.parseConditions(inputs.conditions)
|
||||||
|
|
||||||
const sourceBlockId = ctx.workflow?.connections.find((conn) => conn.target === block.id)?.source
|
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 sourceOutput = sourceBlockId ? ctx.blockStates.get(sourceBlockId)?.output : null
|
||||||
|
|
||||||
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
|
const outgoingConnections = ctx.workflow?.connections.filter((conn) => conn.source === block.id)
|
||||||
@@ -113,8 +92,7 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
conditions,
|
conditions,
|
||||||
outgoingConnections || [],
|
outgoingConnections || [],
|
||||||
evalContext,
|
evalContext,
|
||||||
ctx,
|
ctx
|
||||||
block
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!selectedConnection || !selectedCondition) {
|
if (!selectedConnection || !selectedCondition) {
|
||||||
@@ -158,7 +136,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
private buildEvaluationContext(
|
private buildEvaluationContext(
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
blockId: string,
|
|
||||||
sourceBlockId?: string
|
sourceBlockId?: string
|
||||||
): Record<string, any> {
|
): Record<string, any> {
|
||||||
let evalContext: 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 }>,
|
conditions: Array<{ id: string; title: string; value: string }>,
|
||||||
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
|
outgoingConnections: Array<{ source: string; target: string; sourceHandle?: string }>,
|
||||||
evalContext: Record<string, any>,
|
evalContext: Record<string, any>,
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext
|
||||||
block: SerializedBlock
|
|
||||||
): Promise<{
|
): Promise<{
|
||||||
selectedConnection: { target: string; sourceHandle?: string } | null
|
selectedConnection: { target: string; sourceHandle?: string } | null
|
||||||
selectedCondition: { id: string; title: string; value: string } | null
|
selectedCondition: { id: string; title: string; value: string } | null
|
||||||
@@ -200,8 +176,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
const conditionMet = await evaluateConditionExpression(
|
const conditionMet = await evaluateConditionExpression(
|
||||||
ctx,
|
ctx,
|
||||||
conditionValueString,
|
conditionValueString,
|
||||||
block,
|
|
||||||
this.resolver,
|
|
||||||
evalContext
|
evalContext
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -211,13 +185,6 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
return { selectedConnection: connection, selectedCondition: condition }
|
return { selectedConnection: connection, selectedCondition: condition }
|
||||||
}
|
}
|
||||||
// Condition is true but has no outgoing edge - branch ends gracefully
|
// 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 }
|
return { selectedConnection: null, selectedCondition: null }
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -228,18 +195,13 @@ export class ConditionBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
|
const elseCondition = conditions.find((c) => c.title === CONDITION.ELSE_TITLE)
|
||||||
if (elseCondition) {
|
if (elseCondition) {
|
||||||
logger.warn(`No condition met, selecting 'else' path`, { blockId: block.id })
|
|
||||||
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
|
const elseConnection = this.findConnectionForCondition(outgoingConnections, elseCondition.id)
|
||||||
if (elseConnection) {
|
if (elseConnection) {
|
||||||
return { selectedConnection: elseConnection, selectedCondition: elseCondition }
|
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 }
|
return { selectedConnection: null, selectedCondition: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`No condition matched and no else block - branch ending`, { blockId: block.id })
|
|
||||||
return { selectedConnection: null, selectedCondition: null }
|
return { selectedConnection: null, selectedCondition: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
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'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('useKnowledgeBaseTagDefinitions')
|
const logger = createLogger('useKnowledgeBaseTagDefinitions')
|
||||||
|
|
||||||
export interface TagDefinition {
|
export interface TagDefinition {
|
||||||
id: string
|
id: string
|
||||||
tagSlot: TagSlot
|
tagSlot: AllTagSlot
|
||||||
displayName: string
|
displayName: string
|
||||||
fieldType: string
|
fieldType: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
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'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('useTagDefinitions')
|
const logger = createLogger('useTagDefinitions')
|
||||||
|
|
||||||
export interface TagDefinition {
|
export interface TagDefinition {
|
||||||
id: string
|
id: string
|
||||||
tagSlot: TagSlot
|
tagSlot: AllTagSlot
|
||||||
displayName: string
|
displayName: string
|
||||||
fieldType: string
|
fieldType: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -16,7 +16,7 @@ export interface TagDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TagDefinitionInput {
|
export interface TagDefinitionInput {
|
||||||
tagSlot: TagSlot
|
tagSlot: AllTagSlot
|
||||||
displayName: string
|
displayName: string
|
||||||
fieldType: string
|
fieldType: string
|
||||||
// Optional: for editing existing definitions
|
// Optional: for editing existing definitions
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { env } from './lib/core/config/env'
|
import { env } from './lib/core/config/env'
|
||||||
|
import { sanitizeEventData } from './lib/core/security/redaction'
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const TELEMETRY_STATUS_KEY = 'simstudio-telemetry-status'
|
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
|
* Flush batch of events to server
|
||||||
*/
|
*/
|
||||||
@@ -84,7 +54,7 @@ if (typeof window !== 'undefined') {
|
|||||||
batchTimer = null
|
batchTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedBatch = batch.map(sanitizeEvent)
|
const sanitizedBatch = batch.map(sanitizeEventData)
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
category: 'batch',
|
category: 'batch',
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
createPinnedUrl,
|
createPinnedUrl,
|
||||||
sanitizeForLogging,
|
|
||||||
validateAlphanumericId,
|
validateAlphanumericId,
|
||||||
validateEnum,
|
validateEnum,
|
||||||
validateFileExtension,
|
validateFileExtension,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
validateUrlWithDNS,
|
validateUrlWithDNS,
|
||||||
validateUUID,
|
validateUUID,
|
||||||
} from '@/lib/core/security/input-validation'
|
} from '@/lib/core/security/input-validation'
|
||||||
|
import { sanitizeForLogging } from '@/lib/core/security/redaction'
|
||||||
|
|
||||||
describe('validatePathSegment', () => {
|
describe('validatePathSegment', () => {
|
||||||
describe('valid inputs', () => {
|
describe('valid inputs', () => {
|
||||||
|
|||||||
@@ -556,29 +556,6 @@ export function validateFileExtension(
|
|||||||
return { isValid: true, sanitized: normalizedExt }
|
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
|
* Validates Microsoft Graph API resource IDs
|
||||||
*
|
*
|
||||||
|
|||||||
391
apps/sim/lib/core/security/redaction.test.ts
Normal file
391
apps/sim/lib/core/security/redaction.test.ts
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
isSensitiveKey,
|
||||||
|
REDACTED_MARKER,
|
||||||
|
redactApiKeys,
|
||||||
|
redactSensitiveValues,
|
||||||
|
sanitizeEventData,
|
||||||
|
sanitizeForLogging,
|
||||||
|
} from './redaction'
|
||||||
|
|
||||||
|
describe('REDACTED_MARKER', () => {
|
||||||
|
it.concurrent('should be the standard marker', () => {
|
||||||
|
expect(REDACTED_MARKER).toBe('[REDACTED]')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isSensitiveKey', () => {
|
||||||
|
describe('exact matches', () => {
|
||||||
|
it.concurrent('should match apiKey variations', () => {
|
||||||
|
expect(isSensitiveKey('apiKey')).toBe(true)
|
||||||
|
expect(isSensitiveKey('api_key')).toBe(true)
|
||||||
|
expect(isSensitiveKey('api-key')).toBe(true)
|
||||||
|
expect(isSensitiveKey('APIKEY')).toBe(true)
|
||||||
|
expect(isSensitiveKey('API_KEY')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match token variations', () => {
|
||||||
|
expect(isSensitiveKey('access_token')).toBe(true)
|
||||||
|
expect(isSensitiveKey('refresh_token')).toBe(true)
|
||||||
|
expect(isSensitiveKey('auth_token')).toBe(true)
|
||||||
|
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match secret variations', () => {
|
||||||
|
expect(isSensitiveKey('client_secret')).toBe(true)
|
||||||
|
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||||
|
expect(isSensitiveKey('secret')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match other sensitive keys', () => {
|
||||||
|
expect(isSensitiveKey('private_key')).toBe(true)
|
||||||
|
expect(isSensitiveKey('authorization')).toBe(true)
|
||||||
|
expect(isSensitiveKey('bearer')).toBe(true)
|
||||||
|
expect(isSensitiveKey('private')).toBe(true)
|
||||||
|
expect(isSensitiveKey('auth')).toBe(true)
|
||||||
|
expect(isSensitiveKey('password')).toBe(true)
|
||||||
|
expect(isSensitiveKey('credential')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('suffix matches', () => {
|
||||||
|
it.concurrent('should match keys ending in secret', () => {
|
||||||
|
expect(isSensitiveKey('clientSecret')).toBe(true)
|
||||||
|
expect(isSensitiveKey('appSecret')).toBe(true)
|
||||||
|
expect(isSensitiveKey('mySecret')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match keys ending in password', () => {
|
||||||
|
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||||
|
expect(isSensitiveKey('dbPassword')).toBe(true)
|
||||||
|
expect(isSensitiveKey('adminPassword')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match keys ending in token', () => {
|
||||||
|
expect(isSensitiveKey('accessToken')).toBe(true)
|
||||||
|
expect(isSensitiveKey('refreshToken')).toBe(true)
|
||||||
|
expect(isSensitiveKey('bearerToken')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match keys ending in credential', () => {
|
||||||
|
expect(isSensitiveKey('userCredential')).toBe(true)
|
||||||
|
expect(isSensitiveKey('dbCredential')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('non-sensitive keys (no false positives)', () => {
|
||||||
|
it.concurrent('should not match keys with sensitive words as prefix only', () => {
|
||||||
|
expect(isSensitiveKey('tokenCount')).toBe(false)
|
||||||
|
expect(isSensitiveKey('tokenizer')).toBe(false)
|
||||||
|
expect(isSensitiveKey('secretKey')).toBe(false)
|
||||||
|
expect(isSensitiveKey('passwordStrength')).toBe(false)
|
||||||
|
expect(isSensitiveKey('authMethod')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should match keys ending with sensitive words (intentional)', () => {
|
||||||
|
expect(isSensitiveKey('hasSecret')).toBe(true)
|
||||||
|
expect(isSensitiveKey('userPassword')).toBe(true)
|
||||||
|
expect(isSensitiveKey('sessionToken')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should not match normal field names', () => {
|
||||||
|
expect(isSensitiveKey('name')).toBe(false)
|
||||||
|
expect(isSensitiveKey('email')).toBe(false)
|
||||||
|
expect(isSensitiveKey('id')).toBe(false)
|
||||||
|
expect(isSensitiveKey('value')).toBe(false)
|
||||||
|
expect(isSensitiveKey('data')).toBe(false)
|
||||||
|
expect(isSensitiveKey('count')).toBe(false)
|
||||||
|
expect(isSensitiveKey('status')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redactSensitiveValues', () => {
|
||||||
|
it.concurrent('should redact Bearer tokens', () => {
|
||||||
|
const input = 'Authorization: Bearer abc123xyz456'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toBe('Authorization: Bearer [REDACTED]')
|
||||||
|
expect(result).not.toContain('abc123xyz456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact Basic auth', () => {
|
||||||
|
const input = 'Authorization: Basic dXNlcjpwYXNz'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toBe('Authorization: Basic [REDACTED]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact API key prefixes', () => {
|
||||||
|
const input = 'Using key sk-1234567890abcdefghijklmnop'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
expect(result).not.toContain('sk-1234567890abcdefghijklmnop')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact JSON-style password fields', () => {
|
||||||
|
const input = 'password: "mysecretpass123"'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
expect(result).not.toContain('mysecretpass123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact JSON-style token fields', () => {
|
||||||
|
const input = 'token: "tokenvalue123"'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
expect(result).not.toContain('tokenvalue123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact JSON-style api_key fields', () => {
|
||||||
|
const input = 'api_key: "key123456"'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
expect(result).not.toContain('key123456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should not modify safe strings', () => {
|
||||||
|
const input = 'This is a normal string with no secrets'
|
||||||
|
const result = redactSensitiveValues(input)
|
||||||
|
expect(result).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle empty strings', () => {
|
||||||
|
expect(redactSensitiveValues('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle null/undefined gracefully', () => {
|
||||||
|
expect(redactSensitiveValues(null as any)).toBe(null)
|
||||||
|
expect(redactSensitiveValues(undefined as any)).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('redactApiKeys', () => {
|
||||||
|
describe('object redaction', () => {
|
||||||
|
it.concurrent('should redact sensitive keys in flat objects', () => {
|
||||||
|
const obj = {
|
||||||
|
apiKey: 'secret-key',
|
||||||
|
api_key: 'another-secret',
|
||||||
|
access_token: 'token-value',
|
||||||
|
secret: 'secret-value',
|
||||||
|
password: 'password-value',
|
||||||
|
normalField: 'normal-value',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = redactApiKeys(obj)
|
||||||
|
|
||||||
|
expect(result.apiKey).toBe('[REDACTED]')
|
||||||
|
expect(result.api_key).toBe('[REDACTED]')
|
||||||
|
expect(result.access_token).toBe('[REDACTED]')
|
||||||
|
expect(result.secret).toBe('[REDACTED]')
|
||||||
|
expect(result.password).toBe('[REDACTED]')
|
||||||
|
expect(result.normalField).toBe('normal-value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact sensitive keys in nested objects', () => {
|
||||||
|
const obj = {
|
||||||
|
config: {
|
||||||
|
apiKey: 'secret-key',
|
||||||
|
normalField: 'normal-value',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = redactApiKeys(obj)
|
||||||
|
|
||||||
|
expect(result.config.apiKey).toBe('[REDACTED]')
|
||||||
|
expect(result.config.normalField).toBe('normal-value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact sensitive keys in arrays', () => {
|
||||||
|
const arr = [{ apiKey: 'secret-key-1' }, { apiKey: 'secret-key-2' }]
|
||||||
|
|
||||||
|
const result = redactApiKeys(arr)
|
||||||
|
|
||||||
|
expect(result[0].apiKey).toBe('[REDACTED]')
|
||||||
|
expect(result[1].apiKey).toBe('[REDACTED]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle deeply nested structures', () => {
|
||||||
|
const obj = {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
name: 'John',
|
||||||
|
credentials: {
|
||||||
|
apiKey: 'secret-key',
|
||||||
|
username: 'john_doe',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
config: {
|
||||||
|
database: {
|
||||||
|
password: 'db-password',
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = redactApiKeys(obj)
|
||||||
|
|
||||||
|
expect(result.users[0].name).toBe('John')
|
||||||
|
expect(result.users[0].credentials.apiKey).toBe('[REDACTED]')
|
||||||
|
expect(result.users[0].credentials.username).toBe('john_doe')
|
||||||
|
expect(result.config.database.password).toBe('[REDACTED]')
|
||||||
|
expect(result.config.database.host).toBe('localhost')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('primitive handling', () => {
|
||||||
|
it.concurrent('should return primitives unchanged', () => {
|
||||||
|
expect(redactApiKeys('string')).toBe('string')
|
||||||
|
expect(redactApiKeys(123)).toBe(123)
|
||||||
|
expect(redactApiKeys(true)).toBe(true)
|
||||||
|
expect(redactApiKeys(null)).toBe(null)
|
||||||
|
expect(redactApiKeys(undefined)).toBe(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('no false positives', () => {
|
||||||
|
it.concurrent('should not redact keys with sensitive words as prefix only', () => {
|
||||||
|
const obj = {
|
||||||
|
tokenCount: 100,
|
||||||
|
secretKey: 'not-actually-secret',
|
||||||
|
passwordStrength: 'strong',
|
||||||
|
authMethod: 'oauth',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = redactApiKeys(obj)
|
||||||
|
|
||||||
|
expect(result.tokenCount).toBe(100)
|
||||||
|
expect(result.secretKey).toBe('not-actually-secret')
|
||||||
|
expect(result.passwordStrength).toBe('strong')
|
||||||
|
expect(result.authMethod).toBe('oauth')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sanitizeForLogging', () => {
|
||||||
|
it.concurrent('should truncate long strings', () => {
|
||||||
|
const longString = 'a'.repeat(200)
|
||||||
|
const result = sanitizeForLogging(longString, 50)
|
||||||
|
expect(result.length).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should use default max length of 100', () => {
|
||||||
|
const longString = 'a'.repeat(200)
|
||||||
|
const result = sanitizeForLogging(longString)
|
||||||
|
expect(result.length).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact sensitive patterns', () => {
|
||||||
|
const input = 'Bearer abc123xyz456'
|
||||||
|
const result = sanitizeForLogging(input)
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
expect(result).not.toContain('abc123xyz456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle empty strings', () => {
|
||||||
|
expect(sanitizeForLogging('')).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should not modify safe short strings', () => {
|
||||||
|
const input = 'Safe string'
|
||||||
|
const result = sanitizeForLogging(input)
|
||||||
|
expect(result).toBe(input)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sanitizeEventData', () => {
|
||||||
|
describe('object sanitization', () => {
|
||||||
|
it.concurrent('should remove sensitive keys entirely', () => {
|
||||||
|
const event = {
|
||||||
|
action: 'login',
|
||||||
|
apiKey: 'secret-key',
|
||||||
|
password: 'secret-pass',
|
||||||
|
userId: '123',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sanitizeEventData(event)
|
||||||
|
|
||||||
|
expect(result.action).toBe('login')
|
||||||
|
expect(result.userId).toBe('123')
|
||||||
|
expect(result).not.toHaveProperty('apiKey')
|
||||||
|
expect(result).not.toHaveProperty('password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact sensitive patterns in string values', () => {
|
||||||
|
const event = {
|
||||||
|
message: 'Auth: Bearer abc123token',
|
||||||
|
normal: 'normal value',
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sanitizeEventData(event)
|
||||||
|
|
||||||
|
expect(result.message).toContain('[REDACTED]')
|
||||||
|
expect(result.message).not.toContain('abc123token')
|
||||||
|
expect(result.normal).toBe('normal value')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle nested objects', () => {
|
||||||
|
const event = {
|
||||||
|
user: {
|
||||||
|
id: '123',
|
||||||
|
accessToken: 'secret-token',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sanitizeEventData(event)
|
||||||
|
|
||||||
|
expect(result.user.id).toBe('123')
|
||||||
|
expect(result.user).not.toHaveProperty('accessToken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should handle arrays', () => {
|
||||||
|
const event = {
|
||||||
|
items: [
|
||||||
|
{ id: 1, apiKey: 'key1' },
|
||||||
|
{ id: 2, apiKey: 'key2' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sanitizeEventData(event)
|
||||||
|
|
||||||
|
expect(result.items[0].id).toBe(1)
|
||||||
|
expect(result.items[0]).not.toHaveProperty('apiKey')
|
||||||
|
expect(result.items[1].id).toBe(2)
|
||||||
|
expect(result.items[1]).not.toHaveProperty('apiKey')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('primitive handling', () => {
|
||||||
|
it.concurrent('should return primitives appropriately', () => {
|
||||||
|
expect(sanitizeEventData(null)).toBe(null)
|
||||||
|
expect(sanitizeEventData(undefined)).toBe(undefined)
|
||||||
|
expect(sanitizeEventData(123)).toBe(123)
|
||||||
|
expect(sanitizeEventData(true)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should redact sensitive patterns in top-level strings', () => {
|
||||||
|
const result = sanitizeEventData('Bearer secrettoken123')
|
||||||
|
expect(result).toContain('[REDACTED]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.concurrent('should not redact normal strings', () => {
|
||||||
|
const result = sanitizeEventData('normal string')
|
||||||
|
expect(result).toBe('normal string')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('no false positives', () => {
|
||||||
|
it.concurrent('should not remove keys with sensitive words in middle', () => {
|
||||||
|
const event = {
|
||||||
|
tokenCount: 500,
|
||||||
|
isAuthenticated: true,
|
||||||
|
hasSecretFeature: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = sanitizeEventData(event)
|
||||||
|
|
||||||
|
expect(result.tokenCount).toBe(500)
|
||||||
|
expect(result.isAuthenticated).toBe(true)
|
||||||
|
expect(result.hasSecretFeature).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,28 +1,122 @@
|
|||||||
/**
|
/**
|
||||||
* Recursively redacts API keys in an object
|
* Centralized redaction utilities for sensitive data
|
||||||
* @param obj The object to redact API keys from
|
|
||||||
* @returns A new object with API keys redacted
|
|
||||||
*/
|
*/
|
||||||
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
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
return obj.map(redactApiKeys)
|
return obj.map((item) => redactApiKeys(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Record<string, any> = {}
|
const result: Record<string, any> = {}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(obj)) {
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
if (
|
if (isSensitiveKey(key)) {
|
||||||
key.toLowerCase() === 'apikey' ||
|
result[key] = REDACTED_MARKER
|
||||||
key.toLowerCase() === 'api_key' ||
|
|
||||||
key.toLowerCase() === 'access_token' ||
|
|
||||||
/\bsecret\b/i.test(key.toLowerCase()) ||
|
|
||||||
/\bpassword\b/i.test(key.toLowerCase())
|
|
||||||
) {
|
|
||||||
result[key] = '***REDACTED***'
|
|
||||||
} else if (typeof value === 'object' && value !== null) {
|
} else if (typeof value === 'object' && value !== null) {
|
||||||
result[key] = redactApiKeys(value)
|
result[key] = redactApiKeys(value)
|
||||||
} else {
|
} else {
|
||||||
@@ -32,3 +126,64 @@ export const redactApiKeys = (obj: any): any => {
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a string for safe logging by truncating and redacting sensitive patterns
|
||||||
|
*
|
||||||
|
* @param value - The string to sanitize
|
||||||
|
* @param maxLength - Maximum length of the output (default: 100)
|
||||||
|
* @returns The sanitized string
|
||||||
|
*/
|
||||||
|
export function sanitizeForLogging(value: string, maxLength = 100): string {
|
||||||
|
if (!value) return ''
|
||||||
|
|
||||||
|
let sanitized = value.substring(0, maxLength)
|
||||||
|
|
||||||
|
sanitized = redactSensitiveValues(sanitized)
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes event data for error reporting/analytics
|
||||||
|
*
|
||||||
|
* @param event - The event data to sanitize
|
||||||
|
* @returns Sanitized event data safe for external reporting
|
||||||
|
*/
|
||||||
|
export function sanitizeEventData(event: any): any {
|
||||||
|
if (event === null || event === undefined) {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof event === 'string') {
|
||||||
|
return redactSensitiveValues(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof event !== 'object') {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(event)) {
|
||||||
|
return event.map((item) => sanitizeEventData(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(event)) {
|
||||||
|
if (isSensitiveKey(key)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
sanitized[key] = redactSensitiveValues(value)
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
sanitized[key] = value.map((v) => sanitizeEventData(v))
|
||||||
|
} else if (value && typeof value === 'object') {
|
||||||
|
sanitized[key] = sanitizeEventData(value)
|
||||||
|
} else {
|
||||||
|
sanitized[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
|
||||||
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
|
||||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
formatDate,
|
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', () => {
|
describe('validateName', () => {
|
||||||
it.concurrent('should remove invalid characters', () => {
|
it.concurrent('should remove invalid characters', () => {
|
||||||
const result = validateName('test@#$%name')
|
const result = validateName('test@#$%name')
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
/**
|
const MAX_STRING_LENGTH = 15000
|
||||||
* Type guard to check if an object is a UserFile
|
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 {
|
export function isUserFile(candidate: unknown): candidate is {
|
||||||
id: string
|
id: string
|
||||||
name: 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 {
|
function filterUserFile(data: any): any {
|
||||||
if (isUserFile(data)) {
|
if (isUserFile(data)) {
|
||||||
const { id, name, url, size, type } = data
|
const { id, name, url, size, type } = data
|
||||||
@@ -36,50 +38,152 @@ function filterUserFile(data: any): any {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const DISPLAY_FILTERS = [filterUserFile]
|
||||||
* 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
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
export function filterForDisplay(data: any): any {
|
||||||
if (!data || typeof data !== 'object') {
|
const seen = new WeakSet()
|
||||||
return data
|
return filterForDisplayInternal(data, seen, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply all registered filters
|
function getObjectType(data: unknown): string {
|
||||||
const filtered = data
|
return Object.prototype.toString.call(data).slice(8, -1)
|
||||||
for (const filterFn of DISPLAY_FILTERS) {
|
}
|
||||||
const result = filterFn(filtered)
|
|
||||||
if (result !== filtered) {
|
function filterForDisplayInternal(data: any, seen: WeakSet<object>, depth: number): any {
|
||||||
// Filter matched and transformed the data
|
try {
|
||||||
return result
|
if (data === null || data === undefined) {
|
||||||
}
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
// No filters matched - recursively filter nested structures
|
const dataType = typeof data
|
||||||
if (Array.isArray(filtered)) {
|
|
||||||
return filtered.map(filterForDisplay)
|
if (dataType === 'string') {
|
||||||
}
|
// Remove null bytes which are not allowed in PostgreSQL JSONB
|
||||||
|
const sanitized = data.includes('\u0000') ? data.replace(/\u0000/g, '') : data
|
||||||
// Recursively filter object properties
|
return truncateString(sanitized)
|
||||||
const result: any = {}
|
}
|
||||||
for (const [key, value] of Object.entries(filtered)) {
|
|
||||||
result[key] = filterForDisplay(value)
|
if (dataType === 'number') {
|
||||||
}
|
if (Number.isNaN(data)) {
|
||||||
return result
|
return '[NaN]'
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(data)) {
|
||||||
|
return data > 0 ? '[Infinity]' : '[-Infinity]'
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType === 'boolean') {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType === 'bigint') {
|
||||||
|
return `[BigInt: ${data.toString()}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType === 'symbol') {
|
||||||
|
return `[Symbol: ${data.toString()}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType === 'function') {
|
||||||
|
return `[Function: ${data.name || 'anonymous'}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataType !== 'object') {
|
||||||
|
return '[Unknown Type]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(data)) {
|
||||||
|
return '[Circular Reference]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
return '[Max Depth Exceeded]'
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectType = getObjectType(data)
|
||||||
|
|
||||||
|
switch (objectType) {
|
||||||
|
case 'Date': {
|
||||||
|
const timestamp = (data as Date).getTime()
|
||||||
|
if (Number.isNaN(timestamp)) {
|
||||||
|
return '[Invalid Date]'
|
||||||
|
}
|
||||||
|
return (data as Date).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'RegExp':
|
||||||
|
return (data as RegExp).toString()
|
||||||
|
|
||||||
|
case 'URL':
|
||||||
|
return (data as URL).toString()
|
||||||
|
|
||||||
|
case 'Error': {
|
||||||
|
const err = data as Error
|
||||||
|
return {
|
||||||
|
name: err.name,
|
||||||
|
message: truncateString(err.message),
|
||||||
|
stack: err.stack ? truncateString(err.stack) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ArrayBuffer':
|
||||||
|
return `[ArrayBuffer: ${(data as ArrayBuffer).byteLength} bytes]`
|
||||||
|
|
||||||
|
case 'Map': {
|
||||||
|
const obj: Record<string, any> = {}
|
||||||
|
for (const [key, value] of (data as Map<any, any>).entries()) {
|
||||||
|
const keyStr = typeof key === 'string' ? key : String(key)
|
||||||
|
obj[keyStr] = filterForDisplayInternal(value, seen, depth + 1)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Set':
|
||||||
|
return Array.from(data as Set<any>).map((item) =>
|
||||||
|
filterForDisplayInternal(item, seen, depth + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'WeakMap':
|
||||||
|
return '[WeakMap]'
|
||||||
|
|
||||||
|
case 'WeakSet':
|
||||||
|
return '[WeakSet]'
|
||||||
|
|
||||||
|
case 'WeakRef':
|
||||||
|
return '[WeakRef]'
|
||||||
|
|
||||||
|
case 'Promise':
|
||||||
|
return '[Promise]'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ArrayBuffer.isView(data)) {
|
||||||
|
return `[${objectType}: ${(data as ArrayBufferView).byteLength} bytes]`
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(data)
|
||||||
|
|
||||||
|
for (const filterFn of DISPLAY_FILTERS) {
|
||||||
|
const result = filterFn(data)
|
||||||
|
if (result !== data) {
|
||||||
|
return filterForDisplayInternal(result, seen, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data.map((item) => filterForDisplayInternal(item, seen, depth + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const key of Object.keys(data)) {
|
||||||
|
try {
|
||||||
|
result[key] = filterForDisplayInternal(data[key], seen, depth + 1)
|
||||||
|
} catch {
|
||||||
|
result[key] = '[Error accessing property]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return '[Unserializable]'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export async function queryChunks(
|
|||||||
export async function createChunk(
|
export async function createChunk(
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
documentId: string,
|
documentId: string,
|
||||||
docTags: Record<string, string | null>,
|
docTags: Record<string, string | number | boolean | Date | null>,
|
||||||
chunkData: CreateChunkData,
|
chunkData: CreateChunkData,
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<ChunkData> {
|
): Promise<ChunkData> {
|
||||||
@@ -131,14 +131,27 @@ export async function createChunk(
|
|||||||
embeddingModel: 'text-embedding-3-small',
|
embeddingModel: 'text-embedding-3-small',
|
||||||
startOffset: 0, // Manual chunks don't have document offsets
|
startOffset: 0, // Manual chunks don't have document offsets
|
||||||
endOffset: chunkData.content.length,
|
endOffset: chunkData.content.length,
|
||||||
// Inherit tags from parent document
|
// Inherit text tags from parent document
|
||||||
tag1: docTags.tag1,
|
tag1: docTags.tag1 as string | null,
|
||||||
tag2: docTags.tag2,
|
tag2: docTags.tag2 as string | null,
|
||||||
tag3: docTags.tag3,
|
tag3: docTags.tag3 as string | null,
|
||||||
tag4: docTags.tag4,
|
tag4: docTags.tag4 as string | null,
|
||||||
tag5: docTags.tag5,
|
tag5: docTags.tag5 as string | null,
|
||||||
tag6: docTags.tag6,
|
tag6: docTags.tag6 as string | null,
|
||||||
tag7: docTags.tag7,
|
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,
|
enabled: chunkData.enabled ?? true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -3,18 +3,55 @@ export const TAG_SLOT_CONFIG = {
|
|||||||
slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const,
|
slots: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const,
|
||||||
maxSlots: 7,
|
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
|
} as const
|
||||||
|
|
||||||
export const SUPPORTED_FIELD_TYPES = Object.keys(TAG_SLOT_CONFIG) as Array<
|
export const SUPPORTED_FIELD_TYPES = Object.keys(TAG_SLOT_CONFIG) as Array<
|
||||||
keyof typeof TAG_SLOT_CONFIG
|
keyof typeof TAG_SLOT_CONFIG
|
||||||
>
|
>
|
||||||
|
|
||||||
|
/** Text tag slots (for backwards compatibility) */
|
||||||
export const TAG_SLOTS = TAG_SLOT_CONFIG.text.slots
|
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
|
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]
|
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[] {
|
export function getSlotsForFieldType(fieldType: string): readonly string[] {
|
||||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -22,3 +59,52 @@ export function getSlotsForFieldType(fieldType: string): readonly string[] {
|
|||||||
}
|
}
|
||||||
return config.slots
|
return config.slots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the field type for a tag slot
|
||||||
|
*/
|
||||||
|
export function getFieldTypeForSlot(tagSlot: string): keyof typeof TAG_SLOT_CONFIG | null {
|
||||||
|
for (const [fieldType, config] of Object.entries(TAG_SLOT_CONFIG)) {
|
||||||
|
if ((config.slots as readonly string[]).includes(tagSlot)) {
|
||||||
|
return fieldType as keyof typeof TAG_SLOT_CONFIG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a slot is valid for a given field type
|
||||||
|
*/
|
||||||
|
export function isValidSlotForFieldType(tagSlot: string, fieldType: string): boolean {
|
||||||
|
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||||
|
if (!config) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (config.slots as readonly string[]).includes(tagSlot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display labels for field types
|
||||||
|
*/
|
||||||
|
export const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||||
|
text: 'Text',
|
||||||
|
number: 'Number',
|
||||||
|
date: 'Date',
|
||||||
|
boolean: 'Boolean',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get placeholder text for value input based on field type
|
||||||
|
*/
|
||||||
|
export function getPlaceholderForFieldType(fieldType: string): string {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'boolean':
|
||||||
|
return 'true or false'
|
||||||
|
case 'number':
|
||||||
|
return 'Enter number'
|
||||||
|
case 'date':
|
||||||
|
return 'YYYY-MM-DD'
|
||||||
|
default:
|
||||||
|
return 'Enter value'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ import { tasks } from '@trigger.dev/sdk'
|
|||||||
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
import { and, asc, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
|
||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { getStorageMethod, isRedisStorage } from '@/lib/core/storage'
|
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 { processDocument } from '@/lib/knowledge/documents/document-processor'
|
||||||
import { DocumentProcessingQueue } from '@/lib/knowledge/documents/queue'
|
import { DocumentProcessingQueue } from '@/lib/knowledge/documents/queue'
|
||||||
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types'
|
||||||
import { generateEmbeddings } from '@/lib/knowledge/embeddings'
|
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 { createLogger } from '@/lib/logs/console/logger'
|
||||||
import type { DocumentProcessingPayload } from '@/background/knowledge-processing'
|
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(
|
export async function processDocumentTags(
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
tagData: DocumentTagData[],
|
tagData: DocumentTagData[],
|
||||||
requestId: string
|
requestId: string
|
||||||
): Promise<Record<string, string | null>> {
|
): Promise<ProcessedDocumentTags> {
|
||||||
const result: Record<string, string | null> = {}
|
// 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')
|
const result: ProcessedDocumentTags = {
|
||||||
textSlots.forEach((slot) => {
|
tag1: null,
|
||||||
result[slot] = 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) {
|
if (!Array.isArray(tagData) || tagData.length === 0) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Fetch existing tag definitions
|
||||||
const existingDefinitions = await db
|
const existingDefinitions = await db
|
||||||
.select()
|
.select()
|
||||||
.from(knowledgeBaseTagDefinitions)
|
.from(knowledgeBaseTagDefinitions)
|
||||||
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
.where(eq(knowledgeBaseTagDefinitions.knowledgeBaseId, knowledgeBaseId))
|
||||||
|
|
||||||
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
const existingByName = new Map(existingDefinitions.map((def) => [def.displayName, def]))
|
||||||
const existingBySlot = new Map(existingDefinitions.map((def) => [def.tagSlot as string, def]))
|
|
||||||
|
|
||||||
for (const tag of tagData) {
|
// First pass: collect all validation errors
|
||||||
if (!tag.tagName?.trim() || !tag.value?.trim()) continue
|
const undefinedTags: string[] = []
|
||||||
|
const typeErrors: string[] = []
|
||||||
|
|
||||||
const tagName = tag.tagName.trim()
|
for (const tag of tagData) {
|
||||||
const fieldType = tag.fieldType
|
// Skip if no tag name
|
||||||
const value = tag.value.trim()
|
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
|
// For boolean, check if value is defined; for others, check if value is non-empty
|
||||||
const existingDef = existingByName.get(tagName)
|
const hasValue =
|
||||||
if (existingDef) {
|
fieldType === 'boolean'
|
||||||
targetSlot = existingDef.tagSlot
|
? tag.value !== undefined && tag.value !== null && tag.value !== ''
|
||||||
} else {
|
: tag.value?.trim && tag.value.trim().length > 0
|
||||||
// Find next available slot using the tags service function
|
|
||||||
targetSlot = await getNextAvailableSlot(knowledgeBaseId, fieldType, existingBySlot)
|
|
||||||
|
|
||||||
// Create new tag definition if we have a slot
|
if (!hasValue) continue
|
||||||
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(),
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.insert(knowledgeBaseTagDefinitions).values(newDefinition)
|
// Check if tag exists
|
||||||
existingBySlot.set(targetSlot, newDefinition)
|
const existingDef = existingByName.get(tagName)
|
||||||
|
if (!existingDef) {
|
||||||
logger.info(`[${requestId}] Created tag definition: ${tagName} -> ${targetSlot}`)
|
undefinedTags.push(tagName)
|
||||||
}
|
continue
|
||||||
}
|
|
||||||
|
|
||||||
// Assign value to the slot
|
|
||||||
if (targetSlot) {
|
|
||||||
result[targetSlot] = value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
// Validate value type using shared validation
|
||||||
} catch (error) {
|
const rawValue = typeof tag.value === 'string' ? tag.value.trim() : tag.value
|
||||||
logger.error(`[${requestId}] Error processing document tags:`, error)
|
const actualFieldType = existingDef.fieldType || fieldType
|
||||||
return result
|
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
|
const documentRecord = await db
|
||||||
.select({
|
.select({
|
||||||
|
// Text tags (7 slots)
|
||||||
tag1: document.tag1,
|
tag1: document.tag1,
|
||||||
tag2: document.tag2,
|
tag2: document.tag2,
|
||||||
tag3: document.tag3,
|
tag3: document.tag3,
|
||||||
@@ -382,6 +503,19 @@ export async function processDocumentAsync(
|
|||||||
tag5: document.tag5,
|
tag5: document.tag5,
|
||||||
tag6: document.tag6,
|
tag6: document.tag6,
|
||||||
tag7: document.tag7,
|
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)
|
.from(document)
|
||||||
.where(eq(document.id, documentId))
|
.where(eq(document.id, documentId))
|
||||||
@@ -404,7 +538,7 @@ export async function processDocumentAsync(
|
|||||||
embeddingModel: 'text-embedding-3-small',
|
embeddingModel: 'text-embedding-3-small',
|
||||||
startOffset: chunk.metadata.startIndex,
|
startOffset: chunk.metadata.startIndex,
|
||||||
endOffset: chunk.metadata.endIndex,
|
endOffset: chunk.metadata.endIndex,
|
||||||
// Copy tags from document
|
// Copy text tags from document (7 slots)
|
||||||
tag1: documentTags.tag1,
|
tag1: documentTags.tag1,
|
||||||
tag2: documentTags.tag2,
|
tag2: documentTags.tag2,
|
||||||
tag3: documentTags.tag3,
|
tag3: documentTags.tag3,
|
||||||
@@ -412,6 +546,19 @@ export async function processDocumentAsync(
|
|||||||
tag5: documentTags.tag5,
|
tag5: documentTags.tag5,
|
||||||
tag6: documentTags.tag6,
|
tag6: documentTags.tag6,
|
||||||
tag7: documentTags.tag7,
|
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,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}))
|
}))
|
||||||
@@ -568,15 +715,7 @@ export async function createDocumentRecords(
|
|||||||
for (const docData of documents) {
|
for (const docData of documents) {
|
||||||
const documentId = randomUUID()
|
const documentId = randomUUID()
|
||||||
|
|
||||||
let processedTags: Record<string, string | null> = {
|
let processedTags: Record<string, any> = {}
|
||||||
tag1: null,
|
|
||||||
tag2: null,
|
|
||||||
tag3: null,
|
|
||||||
tag4: null,
|
|
||||||
tag5: null,
|
|
||||||
tag6: null,
|
|
||||||
tag7: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (docData.documentTagsData) {
|
if (docData.documentTagsData) {
|
||||||
try {
|
try {
|
||||||
@@ -585,7 +724,12 @@ export async function createDocumentRecords(
|
|||||||
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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,
|
processingStatus: 'pending' as const,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
uploadedAt: now,
|
uploadedAt: now,
|
||||||
// Use processed tags if available, otherwise fall back to individual tag fields
|
// Text tags - use processed tags if available, otherwise fall back to individual tag fields
|
||||||
tag1: processedTags.tag1 || docData.tag1 || null,
|
tag1: processedTags.tag1 ?? docData.tag1 ?? null,
|
||||||
tag2: processedTags.tag2 || docData.tag2 || null,
|
tag2: processedTags.tag2 ?? docData.tag2 ?? null,
|
||||||
tag3: processedTags.tag3 || docData.tag3 || null,
|
tag3: processedTags.tag3 ?? docData.tag3 ?? null,
|
||||||
tag4: processedTags.tag4 || docData.tag4 || null,
|
tag4: processedTags.tag4 ?? docData.tag4 ?? null,
|
||||||
tag5: processedTags.tag5 || docData.tag5 || null,
|
tag5: processedTags.tag5 ?? docData.tag5 ?? null,
|
||||||
tag6: processedTags.tag6 || docData.tag6 || null,
|
tag6: processedTags.tag6 ?? docData.tag6 ?? null,
|
||||||
tag7: processedTags.tag7 || docData.tag7 || 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)
|
documentRecords.push(newDocument)
|
||||||
@@ -679,6 +836,7 @@ export async function getDocuments(
|
|||||||
processingError: string | null
|
processingError: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
uploadedAt: Date
|
uploadedAt: Date
|
||||||
|
// Text tags
|
||||||
tag1: string | null
|
tag1: string | null
|
||||||
tag2: string | null
|
tag2: string | null
|
||||||
tag3: string | null
|
tag3: string | null
|
||||||
@@ -686,6 +844,19 @@ export async function getDocuments(
|
|||||||
tag5: string | null
|
tag5: string | null
|
||||||
tag6: string | null
|
tag6: string | null
|
||||||
tag7: 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: {
|
pagination: {
|
||||||
total: number
|
total: number
|
||||||
@@ -772,7 +943,7 @@ export async function getDocuments(
|
|||||||
processingError: document.processingError,
|
processingError: document.processingError,
|
||||||
enabled: document.enabled,
|
enabled: document.enabled,
|
||||||
uploadedAt: document.uploadedAt,
|
uploadedAt: document.uploadedAt,
|
||||||
// Include tags in response
|
// Text tags (7 slots)
|
||||||
tag1: document.tag1,
|
tag1: document.tag1,
|
||||||
tag2: document.tag2,
|
tag2: document.tag2,
|
||||||
tag3: document.tag3,
|
tag3: document.tag3,
|
||||||
@@ -780,6 +951,19 @@ export async function getDocuments(
|
|||||||
tag5: document.tag5,
|
tag5: document.tag5,
|
||||||
tag6: document.tag6,
|
tag6: document.tag6,
|
||||||
tag7: document.tag7,
|
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)
|
.from(document)
|
||||||
.where(and(...whereConditions))
|
.where(and(...whereConditions))
|
||||||
@@ -807,6 +991,7 @@ export async function getDocuments(
|
|||||||
processingError: doc.processingError,
|
processingError: doc.processingError,
|
||||||
enabled: doc.enabled,
|
enabled: doc.enabled,
|
||||||
uploadedAt: doc.uploadedAt,
|
uploadedAt: doc.uploadedAt,
|
||||||
|
// Text tags
|
||||||
tag1: doc.tag1,
|
tag1: doc.tag1,
|
||||||
tag2: doc.tag2,
|
tag2: doc.tag2,
|
||||||
tag3: doc.tag3,
|
tag3: doc.tag3,
|
||||||
@@ -814,6 +999,19 @@ export async function getDocuments(
|
|||||||
tag5: doc.tag5,
|
tag5: doc.tag5,
|
||||||
tag6: doc.tag6,
|
tag6: doc.tag6,
|
||||||
tag7: doc.tag7,
|
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: {
|
pagination: {
|
||||||
total,
|
total,
|
||||||
@@ -883,14 +1081,28 @@ export async function createSingleDocument(
|
|||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
// Process structured tag data if provided
|
// Process structured tag data if provided
|
||||||
let processedTags: Record<string, string | null> = {
|
let processedTags: Record<string, any> = {
|
||||||
tag1: documentData.tag1 || null,
|
// Text tags (7 slots)
|
||||||
tag2: documentData.tag2 || null,
|
tag1: documentData.tag1 ?? null,
|
||||||
tag3: documentData.tag3 || null,
|
tag2: documentData.tag2 ?? null,
|
||||||
tag4: documentData.tag4 || null,
|
tag3: documentData.tag3 ?? null,
|
||||||
tag5: documentData.tag5 || null,
|
tag4: documentData.tag4 ?? null,
|
||||||
tag6: documentData.tag6 || null,
|
tag5: documentData.tag5 ?? null,
|
||||||
tag7: documentData.tag7 || 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) {
|
if (documentData.documentTagsData) {
|
||||||
@@ -901,7 +1113,12 @@ export async function createSingleDocument(
|
|||||||
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
processedTags = await processDocumentTags(knowledgeBaseId, tagData, requestId)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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
|
characterCount?: number
|
||||||
processingStatus?: 'pending' | 'processing' | 'completed' | 'failed'
|
processingStatus?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
processingError?: string
|
processingError?: string
|
||||||
|
// Text tags
|
||||||
tag1?: string
|
tag1?: string
|
||||||
tag2?: string
|
tag2?: string
|
||||||
tag3?: string
|
tag3?: string
|
||||||
@@ -1190,6 +1408,19 @@ export async function updateDocument(
|
|||||||
tag5?: string
|
tag5?: string
|
||||||
tag6?: string
|
tag6?: string
|
||||||
tag7?: 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
|
requestId: string
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -1215,6 +1446,16 @@ export async function updateDocument(
|
|||||||
tag5: string | null
|
tag5: string | null
|
||||||
tag6: string | null
|
tag6: string | null
|
||||||
tag7: 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
|
deletedAt: Date | null
|
||||||
}> {
|
}> {
|
||||||
const dbUpdateData: Partial<{
|
const dbUpdateData: Partial<{
|
||||||
@@ -1234,9 +1475,38 @@ export async function updateDocument(
|
|||||||
tag5: string | null
|
tag5: string | null
|
||||||
tag6: string | null
|
tag6: string | null
|
||||||
tag7: 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
|
// All tag slots across all field types
|
||||||
type TagSlot = (typeof TAG_SLOTS)[number]
|
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
|
// Regular field updates
|
||||||
if (updateData.filename !== undefined) dbUpdateData.filename = updateData.filename
|
if (updateData.filename !== undefined) dbUpdateData.filename = updateData.filename
|
||||||
@@ -1250,23 +1520,49 @@ export async function updateDocument(
|
|||||||
if (updateData.processingError !== undefined)
|
if (updateData.processingError !== undefined)
|
||||||
dbUpdateData.processingError = updateData.processingError
|
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]
|
const updateValue = (updateData as any)[slot]
|
||||||
if (updateValue !== undefined) {
|
if (updateValue !== undefined) {
|
||||||
;(dbUpdateData as any)[slot] = updateValue
|
;(dbUpdateData as any)[slot] = convertTagValue(slot, updateValue)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.update(document).set(dbUpdateData).where(eq(document.id, documentId))
|
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) {
|
if (hasTagUpdates) {
|
||||||
const embeddingUpdateData: Record<string, string | null> = {}
|
const embeddingUpdateData: Record<string, any> = {}
|
||||||
TAG_SLOTS.forEach((field) => {
|
ALL_TAG_SLOTS.forEach((field) => {
|
||||||
if ((updateData as any)[field] !== undefined) {
|
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,
|
tag5: doc.tag5,
|
||||||
tag6: doc.tag6,
|
tag6: doc.tag6,
|
||||||
tag7: doc.tag7,
|
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,
|
deletedAt: doc.deletedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/sim/lib/knowledge/filters/index.ts
Normal file
2
apps/sim/lib/knowledge/filters/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './query-builder'
|
||||||
|
export * from './types'
|
||||||
393
apps/sim/lib/knowledge/filters/query-builder.ts
Normal file
393
apps/sim/lib/knowledge/filters/query-builder.ts
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
import { document, embedding } from '@sim/db/schema'
|
||||||
|
import { and, eq, gt, gte, ilike, lt, lte, ne, not, or, type SQL } from 'drizzle-orm'
|
||||||
|
import type {
|
||||||
|
BooleanFilterCondition,
|
||||||
|
DateFilterCondition,
|
||||||
|
FilterCondition,
|
||||||
|
FilterGroup,
|
||||||
|
NumberFilterCondition,
|
||||||
|
SimpleTagFilter,
|
||||||
|
TagFilter,
|
||||||
|
TextFilterCondition,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid tag slots that can be used in filters
|
||||||
|
*/
|
||||||
|
const VALID_TEXT_SLOTS = ['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||||
|
const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const
|
||||||
|
const VALID_DATE_SLOTS = ['date1', 'date2'] as const
|
||||||
|
const VALID_BOOLEAN_SLOTS = ['boolean1', 'boolean2', 'boolean3'] as const
|
||||||
|
|
||||||
|
type TextSlot = (typeof VALID_TEXT_SLOTS)[number]
|
||||||
|
type NumberSlot = (typeof VALID_NUMBER_SLOTS)[number]
|
||||||
|
type DateSlot = (typeof VALID_DATE_SLOTS)[number]
|
||||||
|
type BooleanSlot = (typeof VALID_BOOLEAN_SLOTS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a tag slot is valid for the given field type
|
||||||
|
*/
|
||||||
|
function isValidSlotForType(
|
||||||
|
slot: string,
|
||||||
|
fieldType: string
|
||||||
|
): slot is TextSlot | NumberSlot | DateSlot | BooleanSlot {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'text':
|
||||||
|
return (VALID_TEXT_SLOTS as readonly string[]).includes(slot)
|
||||||
|
case 'number':
|
||||||
|
return (VALID_NUMBER_SLOTS as readonly string[]).includes(slot)
|
||||||
|
case 'date':
|
||||||
|
return (VALID_DATE_SLOTS as readonly string[]).includes(slot)
|
||||||
|
case 'boolean':
|
||||||
|
return (VALID_BOOLEAN_SLOTS as readonly string[]).includes(slot)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a text filter
|
||||||
|
*/
|
||||||
|
function buildTextCondition(
|
||||||
|
condition: TextFilterCondition,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const { tagSlot, operator, value } = condition
|
||||||
|
|
||||||
|
if (!isValidSlotForType(tagSlot, 'text')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = table[tagSlot as TextSlot]
|
||||||
|
if (!column) return null
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
return eq(column, value)
|
||||||
|
case 'neq':
|
||||||
|
return ne(column, value)
|
||||||
|
case 'contains':
|
||||||
|
return ilike(column, `%${value}%`)
|
||||||
|
case 'not_contains':
|
||||||
|
return not(ilike(column, `%${value}%`))
|
||||||
|
case 'starts_with':
|
||||||
|
return ilike(column, `${value}%`)
|
||||||
|
case 'ends_with':
|
||||||
|
return ilike(column, `%${value}`)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a number filter
|
||||||
|
*/
|
||||||
|
function buildNumberCondition(
|
||||||
|
condition: NumberFilterCondition,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const { tagSlot, operator, value, valueTo } = condition
|
||||||
|
|
||||||
|
if (!isValidSlotForType(tagSlot, 'number')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = table[tagSlot as NumberSlot]
|
||||||
|
if (!column) return null
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
return eq(column, value)
|
||||||
|
case 'neq':
|
||||||
|
return ne(column, value)
|
||||||
|
case 'gt':
|
||||||
|
return gt(column, value)
|
||||||
|
case 'gte':
|
||||||
|
return gte(column, value)
|
||||||
|
case 'lt':
|
||||||
|
return lt(column, value)
|
||||||
|
case 'lte':
|
||||||
|
return lte(column, value)
|
||||||
|
case 'between':
|
||||||
|
if (valueTo !== undefined) {
|
||||||
|
return and(gte(column, value), lte(column, valueTo)) ?? null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a YYYY-MM-DD date string into a UTC Date object.
|
||||||
|
*/
|
||||||
|
function parseDateValue(value: string): Date | null {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||||
|
const [year, month, day] = value.split('-').map(Number)
|
||||||
|
return new Date(Date.UTC(year, month - 1, day))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a date filter.
|
||||||
|
* Expects date values in YYYY-MM-DD format.
|
||||||
|
*/
|
||||||
|
function buildDateCondition(
|
||||||
|
condition: DateFilterCondition,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const { tagSlot, operator, value, valueTo } = condition
|
||||||
|
|
||||||
|
if (!isValidSlotForType(tagSlot, 'date')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = table[tagSlot as DateSlot]
|
||||||
|
if (!column) return null
|
||||||
|
|
||||||
|
const dateValue = parseDateValue(value)
|
||||||
|
if (!dateValue) return null
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
return eq(column, dateValue)
|
||||||
|
case 'neq':
|
||||||
|
return ne(column, dateValue)
|
||||||
|
case 'gt':
|
||||||
|
return gt(column, dateValue)
|
||||||
|
case 'gte':
|
||||||
|
return gte(column, dateValue)
|
||||||
|
case 'lt':
|
||||||
|
return lt(column, dateValue)
|
||||||
|
case 'lte':
|
||||||
|
return lte(column, dateValue)
|
||||||
|
case 'between':
|
||||||
|
if (valueTo !== undefined) {
|
||||||
|
const dateValueTo = parseDateValue(valueTo)
|
||||||
|
if (!dateValueTo) return null
|
||||||
|
return and(gte(column, dateValue), lte(column, dateValueTo)) ?? null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a boolean filter
|
||||||
|
*/
|
||||||
|
function buildBooleanCondition(
|
||||||
|
condition: BooleanFilterCondition,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const { tagSlot, operator, value } = condition
|
||||||
|
|
||||||
|
if (!isValidSlotForType(tagSlot, 'boolean')) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = table[tagSlot as BooleanSlot]
|
||||||
|
if (!column) return null
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'eq':
|
||||||
|
return eq(column, value)
|
||||||
|
case 'neq':
|
||||||
|
return ne(column, value)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a single filter condition
|
||||||
|
*/
|
||||||
|
function buildCondition(
|
||||||
|
condition: FilterCondition,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
switch (condition.fieldType) {
|
||||||
|
case 'text':
|
||||||
|
return buildTextCondition(condition, table)
|
||||||
|
case 'number':
|
||||||
|
return buildNumberCondition(condition, table)
|
||||||
|
case 'date':
|
||||||
|
return buildDateCondition(condition, table)
|
||||||
|
case 'boolean':
|
||||||
|
return buildBooleanCondition(condition, table)
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL condition for a filter group
|
||||||
|
*/
|
||||||
|
function buildGroupCondition(
|
||||||
|
group: FilterGroup,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const conditions = group.conditions
|
||||||
|
.map((condition) => buildCondition(condition, table))
|
||||||
|
.filter((c): c is SQL => c !== null)
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 1) {
|
||||||
|
return conditions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (group.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL WHERE clause from a TagFilter
|
||||||
|
* Supports nested groups with AND/OR logic
|
||||||
|
*/
|
||||||
|
export function buildTagFilterQuery(
|
||||||
|
filter: TagFilter,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const groupConditions = filter.groups
|
||||||
|
.map((group) => buildGroupCondition(group, table))
|
||||||
|
.filter((c): c is SQL => c !== null)
|
||||||
|
|
||||||
|
if (groupConditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupConditions.length === 1) {
|
||||||
|
return groupConditions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (filter.rootOperator === 'AND' ? and(...groupConditions) : or(...groupConditions)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL WHERE clause from a SimpleTagFilter
|
||||||
|
* For flat filter structures without nested groups
|
||||||
|
*/
|
||||||
|
export function buildSimpleTagFilterQuery(
|
||||||
|
filter: SimpleTagFilter,
|
||||||
|
table: typeof document | typeof embedding
|
||||||
|
): SQL | null {
|
||||||
|
const conditions = filter.conditions
|
||||||
|
.map((condition) => buildCondition(condition, table))
|
||||||
|
.filter((c): c is SQL => c !== null)
|
||||||
|
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length === 1) {
|
||||||
|
return conditions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (filter.operator === 'AND' ? and(...conditions) : or(...conditions)) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build SQL WHERE clause from an array of filter conditions
|
||||||
|
* Combines all conditions with AND by default
|
||||||
|
*/
|
||||||
|
export function buildFilterConditionsQuery(
|
||||||
|
conditions: FilterCondition[],
|
||||||
|
table: typeof document | typeof embedding,
|
||||||
|
operator: 'AND' | 'OR' = 'AND'
|
||||||
|
): SQL | null {
|
||||||
|
return buildSimpleTagFilterQuery({ operator, conditions }, table)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to build filter for document table
|
||||||
|
*/
|
||||||
|
export function buildDocumentFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null {
|
||||||
|
if ('rootOperator' in filter) {
|
||||||
|
return buildTagFilterQuery(filter, document)
|
||||||
|
}
|
||||||
|
return buildSimpleTagFilterQuery(filter, document)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience function to build filter for embedding table
|
||||||
|
*/
|
||||||
|
export function buildEmbeddingFilterQuery(filter: TagFilter | SimpleTagFilter): SQL | null {
|
||||||
|
if ('rootOperator' in filter) {
|
||||||
|
return buildTagFilterQuery(filter, embedding)
|
||||||
|
}
|
||||||
|
return buildSimpleTagFilterQuery(filter, embedding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a filter condition
|
||||||
|
* Returns an array of validation errors, empty if valid
|
||||||
|
*/
|
||||||
|
export function validateFilterCondition(condition: FilterCondition): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if (!isValidSlotForType(condition.tagSlot, condition.fieldType)) {
|
||||||
|
errors.push(`Invalid tag slot "${condition.tagSlot}" for field type "${condition.fieldType}"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (condition.fieldType) {
|
||||||
|
case 'text':
|
||||||
|
if (typeof condition.value !== 'string') {
|
||||||
|
errors.push('Text filter value must be a string')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'number':
|
||||||
|
if (typeof condition.value !== 'number' || Number.isNaN(condition.value)) {
|
||||||
|
errors.push('Number filter value must be a valid number')
|
||||||
|
}
|
||||||
|
if (condition.operator === 'between' && condition.valueTo === undefined) {
|
||||||
|
errors.push('Between operator requires a second value')
|
||||||
|
}
|
||||||
|
if (condition.valueTo !== undefined && typeof condition.valueTo !== 'number') {
|
||||||
|
errors.push('Number filter second value must be a valid number')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'date':
|
||||||
|
if (typeof condition.value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.value)) {
|
||||||
|
errors.push('Date filter value must be in YYYY-MM-DD format')
|
||||||
|
}
|
||||||
|
if (condition.operator === 'between' && condition.valueTo === undefined) {
|
||||||
|
errors.push('Between operator requires a second value')
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
condition.valueTo !== undefined &&
|
||||||
|
(typeof condition.valueTo !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(condition.valueTo))
|
||||||
|
) {
|
||||||
|
errors.push('Date filter second value must be in YYYY-MM-DD format')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'boolean':
|
||||||
|
if (typeof condition.value !== 'boolean') {
|
||||||
|
errors.push('Boolean filter value must be true or false')
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all conditions in a filter
|
||||||
|
*/
|
||||||
|
export function validateFilter(filter: TagFilter | SimpleTagFilter): string[] {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
if ('rootOperator' in filter) {
|
||||||
|
for (const group of filter.groups) {
|
||||||
|
for (const condition of group.conditions) {
|
||||||
|
errors.push(...validateFilterCondition(condition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const condition of filter.conditions) {
|
||||||
|
errors.push(...validateFilterCondition(condition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
191
apps/sim/lib/knowledge/filters/types.ts
Normal file
191
apps/sim/lib/knowledge/filters/types.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Filter operators for different field types
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text filter operators
|
||||||
|
*/
|
||||||
|
export type TextOperator = 'eq' | 'neq' | 'contains' | 'not_contains' | 'starts_with' | 'ends_with'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number filter operators
|
||||||
|
*/
|
||||||
|
export type NumberOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter operators
|
||||||
|
*/
|
||||||
|
export type DateOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'between'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean filter operators
|
||||||
|
*/
|
||||||
|
export type BooleanOperator = 'eq' | 'neq'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All filter operators union
|
||||||
|
*/
|
||||||
|
export type FilterOperator = TextOperator | NumberOperator | DateOperator | BooleanOperator
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field types supported for filtering
|
||||||
|
*/
|
||||||
|
export type FilterFieldType = 'text' | 'number' | 'date' | 'boolean'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical operators for combining filters
|
||||||
|
*/
|
||||||
|
export type LogicalOperator = 'AND' | 'OR'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base filter condition interface
|
||||||
|
*/
|
||||||
|
interface BaseFilterCondition {
|
||||||
|
tagSlot: string
|
||||||
|
fieldType: FilterFieldType
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text filter condition
|
||||||
|
*/
|
||||||
|
export interface TextFilterCondition extends BaseFilterCondition {
|
||||||
|
fieldType: 'text'
|
||||||
|
operator: TextOperator
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number filter condition
|
||||||
|
*/
|
||||||
|
export interface NumberFilterCondition extends BaseFilterCondition {
|
||||||
|
fieldType: 'number'
|
||||||
|
operator: NumberOperator
|
||||||
|
value: number
|
||||||
|
valueTo?: number // For 'between' operator
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date filter condition
|
||||||
|
*/
|
||||||
|
export interface DateFilterCondition extends BaseFilterCondition {
|
||||||
|
fieldType: 'date'
|
||||||
|
operator: DateOperator
|
||||||
|
value: string // ISO date string
|
||||||
|
valueTo?: string // For 'between' operator (ISO date string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean filter condition
|
||||||
|
*/
|
||||||
|
export interface BooleanFilterCondition extends BaseFilterCondition {
|
||||||
|
fieldType: 'boolean'
|
||||||
|
operator: BooleanOperator
|
||||||
|
value: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all filter conditions
|
||||||
|
*/
|
||||||
|
export type FilterCondition =
|
||||||
|
| TextFilterCondition
|
||||||
|
| NumberFilterCondition
|
||||||
|
| DateFilterCondition
|
||||||
|
| BooleanFilterCondition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter group with logical operator
|
||||||
|
*/
|
||||||
|
export interface FilterGroup {
|
||||||
|
operator: LogicalOperator
|
||||||
|
conditions: FilterCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete filter query structure
|
||||||
|
* Supports nested groups with AND/OR logic
|
||||||
|
*/
|
||||||
|
export interface TagFilter {
|
||||||
|
rootOperator: LogicalOperator
|
||||||
|
groups: FilterGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified flat filter structure for simple use cases
|
||||||
|
*/
|
||||||
|
export interface SimpleTagFilter {
|
||||||
|
operator: LogicalOperator
|
||||||
|
conditions: FilterCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Operator metadata for UI display
|
||||||
|
*/
|
||||||
|
export interface OperatorInfo {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
requiresSecondValue?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text operators metadata
|
||||||
|
*/
|
||||||
|
export const TEXT_OPERATORS: OperatorInfo[] = [
|
||||||
|
{ value: 'eq', label: 'equals' },
|
||||||
|
{ value: 'neq', label: 'not equals' },
|
||||||
|
{ value: 'contains', label: 'contains' },
|
||||||
|
{ value: 'not_contains', label: 'does not contain' },
|
||||||
|
{ value: 'starts_with', label: 'starts with' },
|
||||||
|
{ value: 'ends_with', label: 'ends with' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number operators metadata
|
||||||
|
*/
|
||||||
|
export const NUMBER_OPERATORS: OperatorInfo[] = [
|
||||||
|
{ value: 'eq', label: 'equals' },
|
||||||
|
{ value: 'neq', label: 'not equals' },
|
||||||
|
{ value: 'gt', label: 'greater than' },
|
||||||
|
{ value: 'gte', label: 'greater than or equal' },
|
||||||
|
{ value: 'lt', label: 'less than' },
|
||||||
|
{ value: 'lte', label: 'less than or equal' },
|
||||||
|
{ value: 'between', label: 'between', requiresSecondValue: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Date operators metadata
|
||||||
|
*/
|
||||||
|
export const DATE_OPERATORS: OperatorInfo[] = [
|
||||||
|
{ value: 'eq', label: 'equals' },
|
||||||
|
{ value: 'neq', label: 'not equals' },
|
||||||
|
{ value: 'gt', label: 'after' },
|
||||||
|
{ value: 'gte', label: 'on or after' },
|
||||||
|
{ value: 'lt', label: 'before' },
|
||||||
|
{ value: 'lte', label: 'on or before' },
|
||||||
|
{ value: 'between', label: 'between', requiresSecondValue: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boolean operators metadata
|
||||||
|
*/
|
||||||
|
export const BOOLEAN_OPERATORS: OperatorInfo[] = [
|
||||||
|
{ value: 'eq', label: 'is' },
|
||||||
|
{ value: 'neq', label: 'is not' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get operators for a field type
|
||||||
|
*/
|
||||||
|
export function getOperatorsForFieldType(fieldType: FilterFieldType): OperatorInfo[] {
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'text':
|
||||||
|
return TEXT_OPERATORS
|
||||||
|
case 'number':
|
||||||
|
return NUMBER_OPERATORS
|
||||||
|
case 'date':
|
||||||
|
return DATE_OPERATORS
|
||||||
|
case 'boolean':
|
||||||
|
return BOOLEAN_OPERATORS
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,7 @@ import { randomUUID } from 'crypto'
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { document, embedding, knowledgeBaseTagDefinitions } from '@sim/db/schema'
|
import { document, embedding, knowledgeBaseTagDefinitions } from '@sim/db/schema'
|
||||||
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'
|
import { and, eq, isNotNull, isNull, sql } from 'drizzle-orm'
|
||||||
import {
|
import { getSlotsForFieldType, SUPPORTED_FIELD_TYPES } from '@/lib/knowledge/constants'
|
||||||
getSlotsForFieldType,
|
|
||||||
SUPPORTED_FIELD_TYPES,
|
|
||||||
type TAG_SLOT_CONFIG,
|
|
||||||
} from '@/lib/knowledge/constants'
|
|
||||||
import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types'
|
import type { BulkTagDefinitionsData, DocumentTagDefinition } from '@/lib/knowledge/tags/types'
|
||||||
import type {
|
import type {
|
||||||
CreateTagDefinitionData,
|
CreateTagDefinitionData,
|
||||||
@@ -17,14 +13,45 @@ import { createLogger } from '@/lib/logs/console/logger'
|
|||||||
|
|
||||||
const logger = createLogger('TagsService')
|
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] {
|
const VALID_NUMBER_SLOTS = ['number1', 'number2', 'number3', 'number4', 'number5'] as const
|
||||||
if (!VALID_TAG_SLOTS.includes(tagSlot as (typeof VALID_TAG_SLOTS)[number])) {
|
/** 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(', ')}`)
|
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
|
* Get the next available slot for a knowledge base and field type
|
||||||
*/
|
*/
|
||||||
@@ -215,7 +242,7 @@ export async function createOrUpdateTagDefinitionsBulk(
|
|||||||
const newDefinition = {
|
const newDefinition = {
|
||||||
id,
|
id,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
tagSlot: finalTagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number],
|
tagSlot: finalTagSlot as ValidTagSlot,
|
||||||
displayName,
|
displayName,
|
||||||
fieldType,
|
fieldType,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -466,7 +493,7 @@ export async function createTagDefinition(
|
|||||||
const newDefinition = {
|
const newDefinition = {
|
||||||
id: tagDefinitionId,
|
id: tagDefinitionId,
|
||||||
knowledgeBaseId: data.knowledgeBaseId,
|
knowledgeBaseId: data.knowledgeBaseId,
|
||||||
tagSlot: data.tagSlot as (typeof TAG_SLOT_CONFIG.text.slots)[number],
|
tagSlot: data.tagSlot as ValidTagSlot,
|
||||||
displayName: data.displayName,
|
displayName: data.displayName,
|
||||||
fieldType: data.fieldType,
|
fieldType: data.fieldType,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
@@ -562,21 +589,31 @@ export async function getTagUsage(
|
|||||||
const tagSlot = def.tagSlot
|
const tagSlot = def.tagSlot
|
||||||
validateTagSlot(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
|
const documentsWithTag = await db
|
||||||
.select({
|
.select({
|
||||||
id: document.id,
|
id: document.id,
|
||||||
filename: document.filename,
|
filename: document.filename,
|
||||||
tagValue: sql<string>`${sql.raw(tagSlot)}`,
|
tagValue: sql<string>`${sql.raw(tagSlot)}::text`,
|
||||||
})
|
})
|
||||||
.from(document)
|
.from(document)
|
||||||
.where(
|
.where(and(...whereConditions))
|
||||||
and(
|
|
||||||
eq(document.knowledgeBaseId, knowledgeBaseId),
|
|
||||||
isNull(document.deletedAt),
|
|
||||||
isNotNull(sql`${sql.raw(tagSlot)}`),
|
|
||||||
sql`${sql.raw(tagSlot)} != ''`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
usage.push({
|
usage.push({
|
||||||
tagName: def.displayName,
|
tagName: def.displayName,
|
||||||
|
|||||||
89
apps/sim/lib/knowledge/tags/utils.ts
Normal file
89
apps/sim/lib/knowledge/tags/utils.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Validate a tag value against its expected field type
|
||||||
|
* Returns an error message if invalid, or null if valid
|
||||||
|
*/
|
||||||
|
export function validateTagValue(tagName: string, value: string, fieldType: string): string | null {
|
||||||
|
const stringValue = String(value).trim()
|
||||||
|
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'boolean': {
|
||||||
|
const lowerValue = stringValue.toLowerCase()
|
||||||
|
if (lowerValue !== 'true' && lowerValue !== 'false') {
|
||||||
|
return `Tag "${tagName}" expects a boolean value (true/false), but received "${value}"`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'number': {
|
||||||
|
const numValue = Number(stringValue)
|
||||||
|
if (Number.isNaN(numValue)) {
|
||||||
|
return `Tag "${tagName}" expects a number value, but received "${value}"`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
case 'date': {
|
||||||
|
// Check format first
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||||
|
return `Tag "${tagName}" expects a date in YYYY-MM-DD format, but received "${value}"`
|
||||||
|
}
|
||||||
|
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||||
|
const [year, month, day] = stringValue.split('-').map(Number)
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||||
|
return `Tag "${tagName}" has an invalid date: "${value}"`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build error message for undefined tags
|
||||||
|
*/
|
||||||
|
export function buildUndefinedTagsError(undefinedTags: string[]): string {
|
||||||
|
const tagList = undefinedTags.map((t) => `"${t}"`).join(', ')
|
||||||
|
return `The following tags are not defined in this knowledge base: ${tagList}. Please define them at the knowledge base level first.`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string to number with strict validation
|
||||||
|
* Returns null if invalid
|
||||||
|
*/
|
||||||
|
export function parseNumberValue(value: string): number | null {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isNaN(num) ? null : num
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string to Date with strict YYYY-MM-DD validation
|
||||||
|
* Returns null if invalid format or invalid date
|
||||||
|
*/
|
||||||
|
export function parseDateValue(value: string): Date | null {
|
||||||
|
const stringValue = String(value).trim()
|
||||||
|
|
||||||
|
// Must be YYYY-MM-DD format
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(stringValue)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the date is actually valid (e.g., reject 2024-02-31)
|
||||||
|
const [year, month, day] = stringValue.split('-').map(Number)
|
||||||
|
const date = new Date(year, month - 1, day)
|
||||||
|
if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a string to boolean with strict validation
|
||||||
|
* Returns null if not 'true' or 'false'
|
||||||
|
*/
|
||||||
|
export function parseBooleanValue(value: string): boolean | null {
|
||||||
|
const lowerValue = String(value).trim().toLowerCase()
|
||||||
|
if (lowerValue === 'true') return true
|
||||||
|
if (lowerValue === 'false') return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -48,3 +48,40 @@ export interface UpdateTagDefinitionData {
|
|||||||
displayName?: string
|
displayName?: string
|
||||||
fieldType?: string
|
fieldType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tag filter for knowledge base search */
|
||||||
|
export interface StructuredFilter {
|
||||||
|
tagName?: string // Human-readable name (input from frontend)
|
||||||
|
tagSlot: string // Database column (resolved from tagName)
|
||||||
|
fieldType: string
|
||||||
|
operator: string
|
||||||
|
value: string | number | boolean
|
||||||
|
valueTo?: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Processed document tags ready for database storage */
|
||||||
|
export interface ProcessedDocumentTags {
|
||||||
|
// Text tags
|
||||||
|
tag1: string | null
|
||||||
|
tag2: string | null
|
||||||
|
tag3: string | null
|
||||||
|
tag4: string | null
|
||||||
|
tag5: string | null
|
||||||
|
tag6: string | null
|
||||||
|
tag7: string | null
|
||||||
|
// Number tags
|
||||||
|
number1: number | null
|
||||||
|
number2: number | null
|
||||||
|
number3: number | null
|
||||||
|
number4: number | null
|
||||||
|
number5: number | null
|
||||||
|
// Date tags
|
||||||
|
date1: Date | null
|
||||||
|
date2: Date | null
|
||||||
|
// Boolean tags
|
||||||
|
boolean1: boolean | null
|
||||||
|
boolean2: boolean | null
|
||||||
|
boolean3: boolean | null
|
||||||
|
// Index signature for dynamic access
|
||||||
|
[key: string]: string | number | Date | boolean | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
traceSpans?: TraceSpan[]
|
traceSpans?: TraceSpan[]
|
||||||
workflowInput?: any
|
workflowInput?: any
|
||||||
isResume?: boolean // If true, merge with existing data instead of replacing
|
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> {
|
}): Promise<WorkflowExecutionLog> {
|
||||||
const {
|
const {
|
||||||
executionId,
|
executionId,
|
||||||
@@ -240,6 +241,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
traceSpans,
|
traceSpans,
|
||||||
workflowInput,
|
workflowInput,
|
||||||
isResume,
|
isResume,
|
||||||
|
level: levelOverride,
|
||||||
} = params
|
} = params
|
||||||
|
|
||||||
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
|
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
|
// 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 hasErrors = traceSpans?.some((span: any) => {
|
||||||
const checkSpanForErrors = (s: any): boolean => {
|
const checkSpanForErrors = (s: any): boolean => {
|
||||||
if (s.status === 'error') return true
|
if (s.status === 'error') return true
|
||||||
@@ -267,7 +270,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
|||||||
return checkSpanForErrors(span)
|
return checkSpanForErrors(span)
|
||||||
})
|
})
|
||||||
|
|
||||||
const level = hasErrors ? 'error' : 'info'
|
const level = levelOverride ?? (hasErrors ? 'error' : 'info')
|
||||||
|
|
||||||
// Extract files from trace spans, final output, and workflow input
|
// Extract files from trace spans, final output, and workflow input
|
||||||
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)
|
const executionFiles = this.extractFilesFromExecution(traceSpans, finalOutput, workflowInput)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export interface SessionCompleteParams {
|
|||||||
endedAt?: string
|
endedAt?: string
|
||||||
totalDurationMs?: number
|
totalDurationMs?: number
|
||||||
finalOutput?: any
|
finalOutput?: any
|
||||||
traceSpans?: any[]
|
traceSpans?: TraceSpan[]
|
||||||
workflowInput?: any
|
workflowInput?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,20 +331,85 @@ export class LoggingSession {
|
|||||||
try {
|
try {
|
||||||
await this.complete(params)
|
await this.complete(params)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Error already logged in complete(), log a summary here
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
logger.warn(
|
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 {
|
try {
|
||||||
await this.completeWithError(error)
|
await this.completeWithError(params)
|
||||||
} catch (enhancedError) {
|
} catch (error) {
|
||||||
// Error already logged in completeWithError(), log a summary here
|
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${this.requestId || 'unknown'}] Error logging completion failed for execution ${this.executionId} - execution data not persisted`
|
`[${this.requestId || 'unknown'}] CompleteWithError failed for execution ${this.executionId}, attempting fallback`,
|
||||||
|
{ error: errorMsg }
|
||||||
|
)
|
||||||
|
await this.completeWithCostOnlyLog({
|
||||||
|
traceSpans: params?.traceSpans,
|
||||||
|
endedAt: params?.endedAt,
|
||||||
|
totalDurationMs: params?.totalDurationMs,
|
||||||
|
errorMessage:
|
||||||
|
params?.error?.message || `Execution failed to store trace spans: ${errorMsg}`,
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeWithCostOnlyLog(params: {
|
||||||
|
traceSpans?: TraceSpan[]
|
||||||
|
endedAt?: string
|
||||||
|
totalDurationMs?: number
|
||||||
|
errorMessage: string
|
||||||
|
isError: boolean
|
||||||
|
}): Promise<void> {
|
||||||
|
logger.warn(
|
||||||
|
`[${this.requestId || 'unknown'}] Logging completion failed for execution ${this.executionId} - attempting cost-only fallback`
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const costSummary = params.traceSpans?.length
|
||||||
|
? calculateCostSummary(params.traceSpans)
|
||||||
|
: {
|
||||||
|
totalCost: BASE_EXECUTION_CHARGE,
|
||||||
|
totalInputCost: 0,
|
||||||
|
totalOutputCost: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
totalPromptTokens: 0,
|
||||||
|
totalCompletionTokens: 0,
|
||||||
|
baseExecutionCharge: BASE_EXECUTION_CHARGE,
|
||||||
|
modelCost: 0,
|
||||||
|
models: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
await executionLogger.completeWorkflowExecution({
|
||||||
|
executionId: this.executionId,
|
||||||
|
endedAt: params.endedAt || new Date().toISOString(),
|
||||||
|
totalDurationMs: params.totalDurationMs || 0,
|
||||||
|
costSummary,
|
||||||
|
finalOutput: { _fallback: true, error: params.errorMessage },
|
||||||
|
traceSpans: [],
|
||||||
|
isResume: this.isResume,
|
||||||
|
level: params.isError ? 'error' : 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[${this.requestId || 'unknown'}] Cost-only fallback succeeded for execution ${this.executionId}`
|
||||||
|
)
|
||||||
|
} catch (fallbackError) {
|
||||||
|
logger.error(
|
||||||
|
`[${this.requestId || 'unknown'}] Cost-only fallback also failed for execution ${this.executionId}:`,
|
||||||
|
{ error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -479,8 +479,16 @@ export async function transformBlockTool(
|
|||||||
|
|
||||||
const llmSchema = await createLLMToolSchema(toolConfig, userProvidedParams)
|
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 {
|
return {
|
||||||
id: toolConfig.id,
|
id: uniqueToolId,
|
||||||
name: toolConfig.name,
|
name: toolConfig.name,
|
||||||
description: toolConfig.description,
|
description: toolConfig.description,
|
||||||
params: userProvidedParams,
|
params: userProvidedParams,
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export interface DocumentData {
|
|||||||
processingError?: string | null
|
processingError?: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
uploadedAt: string
|
uploadedAt: string
|
||||||
// Document tags
|
// Text tags
|
||||||
tag1?: string | null
|
tag1?: string | null
|
||||||
tag2?: string | null
|
tag2?: string | null
|
||||||
tag3?: string | null
|
tag3?: string | null
|
||||||
@@ -52,6 +52,19 @@ export interface DocumentData {
|
|||||||
tag5?: string | null
|
tag5?: string | null
|
||||||
tag6?: string | null
|
tag6?: string | null
|
||||||
tag7?: 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 {
|
export interface ChunkData {
|
||||||
@@ -63,6 +76,7 @@ export interface ChunkData {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
startOffset: number
|
startOffset: number
|
||||||
endOffset: number
|
endOffset: number
|
||||||
|
// Text tags
|
||||||
tag1?: string | null
|
tag1?: string | null
|
||||||
tag2?: string | null
|
tag2?: string | null
|
||||||
tag3?: string | null
|
tag3?: string | null
|
||||||
@@ -70,6 +84,19 @@ export interface ChunkData {
|
|||||||
tag5?: string | null
|
tag5?: string | null
|
||||||
tag6?: string | null
|
tag6?: string | null
|
||||||
tag7?: 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
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
return { entries: state.entries }
|
return { entries: state.entries }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redact API keys from output
|
// Redact API keys from output and input
|
||||||
const redactedEntry = { ...entry }
|
const redactedEntry = { ...entry }
|
||||||
if (
|
if (
|
||||||
!isStreamingOutput(entry.output) &&
|
!isStreamingOutput(entry.output) &&
|
||||||
@@ -89,6 +89,9 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
) {
|
) {
|
||||||
redactedEntry.output = redactApiKeys(redactedEntry.output)
|
redactedEntry.output = redactApiKeys(redactedEntry.output)
|
||||||
}
|
}
|
||||||
|
if (redactedEntry.input && typeof redactedEntry.input === 'object') {
|
||||||
|
redactedEntry.input = redactApiKeys(redactedEntry.input)
|
||||||
|
}
|
||||||
|
|
||||||
// Create new entry with ID and timestamp
|
// Create new entry with ID and timestamp
|
||||||
const newEntry: ConsoleEntry = {
|
const newEntry: ConsoleEntry = {
|
||||||
@@ -275,12 +278,17 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (update.replaceOutput !== undefined) {
|
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) {
|
} else if (update.output !== undefined) {
|
||||||
updatedEntry.output = {
|
const mergedOutput = {
|
||||||
...(entry.output || {}),
|
...(entry.output || {}),
|
||||||
...update.output,
|
...update.output,
|
||||||
}
|
}
|
||||||
|
updatedEntry.output =
|
||||||
|
typeof mergedOutput === 'object' ? redactApiKeys(mergedOutput) : mergedOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.error !== undefined) {
|
if (update.error !== undefined) {
|
||||||
@@ -304,7 +312,10 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (update.input !== undefined) {
|
if (update.input !== undefined) {
|
||||||
updatedEntry.input = update.input
|
updatedEntry.input =
|
||||||
|
typeof update.input === 'object' && update.input !== null
|
||||||
|
? redactApiKeys(update.input)
|
||||||
|
: update.input
|
||||||
}
|
}
|
||||||
|
|
||||||
return updatedEntry
|
return updatedEntry
|
||||||
|
|||||||
@@ -16,6 +16,26 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('Tools')
|
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.
|
* 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.
|
* Next.js 16 has a default middleware/proxy body limit of 10MB.
|
||||||
@@ -186,20 +206,29 @@ export async function executeTool(
|
|||||||
try {
|
try {
|
||||||
let tool: ToolConfig | undefined
|
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 it's a custom tool, use the async version with workflowId
|
||||||
if (toolId.startsWith('custom_')) {
|
if (normalizedToolId.startsWith('custom_')) {
|
||||||
const workflowId = params._context?.workflowId
|
const workflowId = params._context?.workflowId
|
||||||
tool = await getToolAsync(toolId, workflowId)
|
tool = await getToolAsync(normalizedToolId, workflowId)
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
logger.error(`[${requestId}] Custom tool not found: ${toolId}`)
|
logger.error(`[${requestId}] Custom tool not found: ${normalizedToolId}`)
|
||||||
}
|
}
|
||||||
} else if (toolId.startsWith('mcp-')) {
|
} else if (normalizedToolId.startsWith('mcp-')) {
|
||||||
return await executeMcpTool(toolId, params, executionContext, requestId, startTimeISO)
|
return await executeMcpTool(
|
||||||
|
normalizedToolId,
|
||||||
|
params,
|
||||||
|
executionContext,
|
||||||
|
requestId,
|
||||||
|
startTimeISO
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
// For built-in tools, use the synchronous version
|
// For built-in tools, use the synchronous version
|
||||||
tool = getTool(toolId)
|
tool = getTool(normalizedToolId)
|
||||||
if (!tool) {
|
if (!tool) {
|
||||||
logger.error(`[${requestId}] Built-in tool not found: ${toolId}`)
|
logger.error(`[${requestId}] Built-in tool not found: ${normalizedToolId}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { StructuredFilter } from '@/lib/knowledge/types'
|
||||||
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
|
import type { KnowledgeSearchResponse } from '@/tools/knowledge/types'
|
||||||
import type { ToolConfig } from '@/tools/types'
|
import type { ToolConfig } from '@/tools/types'
|
||||||
|
|
||||||
@@ -53,8 +54,8 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
|||||||
// Use single knowledge base ID
|
// Use single knowledge base ID
|
||||||
const knowledgeBaseIds = [params.knowledgeBaseId]
|
const knowledgeBaseIds = [params.knowledgeBaseId]
|
||||||
|
|
||||||
// Parse dynamic tag filters and send display names to API
|
// Parse dynamic tag filters
|
||||||
const filters: Record<string, string> = {}
|
let structuredFilters: StructuredFilter[] = []
|
||||||
if (params.tagFilters) {
|
if (params.tagFilters) {
|
||||||
let tagFilters = params.tagFilters
|
let tagFilters = params.tagFilters
|
||||||
|
|
||||||
@@ -62,27 +63,29 @@ export const knowledgeSearchTool: ToolConfig<any, KnowledgeSearchResponse> = {
|
|||||||
if (typeof tagFilters === 'string') {
|
if (typeof tagFilters === 'string') {
|
||||||
try {
|
try {
|
||||||
tagFilters = JSON.parse(tagFilters)
|
tagFilters = JSON.parse(tagFilters)
|
||||||
} catch (error) {
|
} catch {
|
||||||
tagFilters = []
|
tagFilters = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(tagFilters)) {
|
if (Array.isArray(tagFilters)) {
|
||||||
// Group filters by tag name for OR logic within same tag
|
// Send full filter objects with operator support
|
||||||
const groupedFilters: Record<string, string[]> = {}
|
structuredFilters = tagFilters
|
||||||
tagFilters.forEach((filter: any) => {
|
.filter((filter: Record<string, unknown>) => {
|
||||||
if (filter.tagName && filter.tagValue && filter.tagValue.trim().length > 0) {
|
// For boolean, any value is valid; for others, check for non-empty string
|
||||||
if (!groupedFilters[filter.tagName]) {
|
if (filter.fieldType === 'boolean') {
|
||||||
groupedFilters[filter.tagName] = []
|
return filter.tagName && filter.tagValue !== undefined
|
||||||
}
|
}
|
||||||
groupedFilters[filter.tagName].push(filter.tagValue)
|
return filter.tagName && filter.tagValue && String(filter.tagValue).trim().length > 0
|
||||||
}
|
})
|
||||||
})
|
.map((filter: Record<string, unknown>) => ({
|
||||||
|
tagName: filter.tagName as string,
|
||||||
// Convert to filters format - for now, join multiple values with OR separator
|
tagSlot: (filter.tagSlot as string) || '', // Will be resolved by API from tagName
|
||||||
Object.entries(groupedFilters).forEach(([tagName, values]) => {
|
fieldType: (filter.fieldType as string) || 'text',
|
||||||
filters[tagName] = values.join('|OR|') // Use special separator for OR logic
|
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,
|
knowledgeBaseIds,
|
||||||
query: params.query,
|
query: params.query,
|
||||||
topK: params.topK ? Math.max(1, Math.min(100, Number(params.topK))) : 10,
|
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 }),
|
...(workflowId && { workflowId }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
bun.lock
16
bun.lock
@@ -37,7 +37,7 @@
|
|||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.0.0",
|
"lint-staged": "16.0.0",
|
||||||
"turbo": "2.6.3",
|
"turbo": "2.7.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"apps/docs": {
|
"apps/docs": {
|
||||||
@@ -3303,19 +3303,19 @@
|
|||||||
|
|
||||||
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
"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=="],
|
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
"drizzle-kit": "^0.31.4",
|
"drizzle-kit": "^0.31.4",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"lint-staged": "16.0.0",
|
"lint-staged": "16.0.0",
|
||||||
"turbo": "2.6.3"
|
"turbo": "2.7.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
"*.{js,jsx,ts,tsx,json,css,scss}": [
|
||||||
|
|||||||
@@ -18,11 +18,56 @@ export const DEFAULT_TEAM_STORAGE_LIMIT_GB = 500
|
|||||||
export const DEFAULT_ENTERPRISE_STORAGE_LIMIT_GB = 500
|
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]
|
export type TagSlot = (typeof TAG_SLOTS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for text tag slot names
|
||||||
|
*/
|
||||||
|
export type TextTagSlot = (typeof TEXT_TAG_SLOTS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for number tag slot names
|
||||||
|
*/
|
||||||
|
export type NumberTagSlot = (typeof NUMBER_TAG_SLOTS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for date tag slot names
|
||||||
|
*/
|
||||||
|
export type DateTagSlot = (typeof DATE_TAG_SLOTS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type for boolean tag slot names
|
||||||
|
*/
|
||||||
|
export type BooleanTagSlot = (typeof BOOLEAN_TAG_SLOTS)[number]
|
||||||
|
|||||||
40
packages/db/migrations/0126_dapper_midnight.sql
Normal file
40
packages/db/migrations/0126_dapper_midnight.sql
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
ALTER TABLE "document" ADD COLUMN "number1" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "number2" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "number3" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "number4" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "number5" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "date1" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "date2" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "boolean1" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "boolean2" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "document" ADD COLUMN "boolean3" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "number1" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "number2" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "number3" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "number4" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "number5" double precision;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "date1" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "date2" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "boolean1" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "boolean2" boolean;--> statement-breakpoint
|
||||||
|
ALTER TABLE "embedding" ADD COLUMN "boolean3" boolean;--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_number1_idx" ON "document" USING btree ("number1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_number2_idx" ON "document" USING btree ("number2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_number3_idx" ON "document" USING btree ("number3");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_number4_idx" ON "document" USING btree ("number4");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_number5_idx" ON "document" USING btree ("number5");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_date1_idx" ON "document" USING btree ("date1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_date2_idx" ON "document" USING btree ("date2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_boolean1_idx" ON "document" USING btree ("boolean1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_boolean2_idx" ON "document" USING btree ("boolean2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "doc_boolean3_idx" ON "document" USING btree ("boolean3");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_number1_idx" ON "embedding" USING btree ("number1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_number2_idx" ON "embedding" USING btree ("number2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_number3_idx" ON "embedding" USING btree ("number3");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_number4_idx" ON "embedding" USING btree ("number4");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_number5_idx" ON "embedding" USING btree ("number5");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_date1_idx" ON "embedding" USING btree ("date1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_date2_idx" ON "embedding" USING btree ("date2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_boolean1_idx" ON "embedding" USING btree ("boolean1");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_boolean2_idx" ON "embedding" USING btree ("boolean2");--> statement-breakpoint
|
||||||
|
CREATE INDEX "emb_boolean3_idx" ON "embedding" USING btree ("boolean3");
|
||||||
8221
packages/db/migrations/meta/0126_snapshot.json
Normal file
8221
packages/db/migrations/meta/0126_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -876,6 +876,13 @@
|
|||||||
"when": 1766133598113,
|
"when": 1766133598113,
|
||||||
"tag": "0125_eager_lily_hollister",
|
"tag": "0125_eager_lily_hollister",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 126,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1766203036010,
|
||||||
|
"tag": "0126_dapper_midnight",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
check,
|
check,
|
||||||
customType,
|
customType,
|
||||||
decimal,
|
decimal,
|
||||||
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
json,
|
json,
|
||||||
@@ -1047,6 +1048,7 @@ export const document = pgTable(
|
|||||||
deletedAt: timestamp('deleted_at'), // Soft delete
|
deletedAt: timestamp('deleted_at'), // Soft delete
|
||||||
|
|
||||||
// Document tags for filtering (inherited by all chunks)
|
// Document tags for filtering (inherited by all chunks)
|
||||||
|
// Text tags (7 slots)
|
||||||
tag1: text('tag1'),
|
tag1: text('tag1'),
|
||||||
tag2: text('tag2'),
|
tag2: text('tag2'),
|
||||||
tag3: text('tag3'),
|
tag3: text('tag3'),
|
||||||
@@ -1054,6 +1056,19 @@ export const document = pgTable(
|
|||||||
tag5: text('tag5'),
|
tag5: text('tag5'),
|
||||||
tag6: text('tag6'),
|
tag6: text('tag6'),
|
||||||
tag7: text('tag7'),
|
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
|
// Timestamps
|
||||||
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
|
uploadedAt: timestamp('uploaded_at').notNull().defaultNow(),
|
||||||
@@ -1068,7 +1083,7 @@ export const document = pgTable(
|
|||||||
table.knowledgeBaseId,
|
table.knowledgeBaseId,
|
||||||
table.processingStatus
|
table.processingStatus
|
||||||
),
|
),
|
||||||
// Tag indexes for filtering
|
// Text tag indexes
|
||||||
tag1Idx: index('doc_tag1_idx').on(table.tag1),
|
tag1Idx: index('doc_tag1_idx').on(table.tag1),
|
||||||
tag2Idx: index('doc_tag2_idx').on(table.tag2),
|
tag2Idx: index('doc_tag2_idx').on(table.tag2),
|
||||||
tag3Idx: index('doc_tag3_idx').on(table.tag3),
|
tag3Idx: index('doc_tag3_idx').on(table.tag3),
|
||||||
@@ -1076,6 +1091,19 @@ export const document = pgTable(
|
|||||||
tag5Idx: index('doc_tag5_idx').on(table.tag5),
|
tag5Idx: index('doc_tag5_idx').on(table.tag5),
|
||||||
tag6Idx: index('doc_tag6_idx').on(table.tag6),
|
tag6Idx: index('doc_tag6_idx').on(table.tag6),
|
||||||
tag7Idx: index('doc_tag7_idx').on(table.tag7),
|
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(),
|
endOffset: integer('end_offset').notNull(),
|
||||||
|
|
||||||
// Tag columns inherited from document for efficient filtering
|
// Tag columns inherited from document for efficient filtering
|
||||||
|
// Text tags (7 slots)
|
||||||
tag1: text('tag1'),
|
tag1: text('tag1'),
|
||||||
tag2: text('tag2'),
|
tag2: text('tag2'),
|
||||||
tag3: text('tag3'),
|
tag3: text('tag3'),
|
||||||
@@ -1144,6 +1173,19 @@ export const embedding = pgTable(
|
|||||||
tag5: text('tag5'),
|
tag5: text('tag5'),
|
||||||
tag6: text('tag6'),
|
tag6: text('tag6'),
|
||||||
tag7: text('tag7'),
|
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
|
// Chunk state - enable/disable from knowledge base
|
||||||
enabled: boolean('enabled').notNull().default(true),
|
enabled: boolean('enabled').notNull().default(true),
|
||||||
@@ -1182,7 +1224,7 @@ export const embedding = pgTable(
|
|||||||
ef_construction: 64,
|
ef_construction: 64,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Tag indexes for efficient filtering
|
// Text tag indexes
|
||||||
tag1Idx: index('emb_tag1_idx').on(table.tag1),
|
tag1Idx: index('emb_tag1_idx').on(table.tag1),
|
||||||
tag2Idx: index('emb_tag2_idx').on(table.tag2),
|
tag2Idx: index('emb_tag2_idx').on(table.tag2),
|
||||||
tag3Idx: index('emb_tag3_idx').on(table.tag3),
|
tag3Idx: index('emb_tag3_idx').on(table.tag3),
|
||||||
@@ -1190,6 +1232,19 @@ export const embedding = pgTable(
|
|||||||
tag5Idx: index('emb_tag5_idx').on(table.tag5),
|
tag5Idx: index('emb_tag5_idx').on(table.tag5),
|
||||||
tag6Idx: index('emb_tag6_idx').on(table.tag6),
|
tag6Idx: index('emb_tag6_idx').on(table.tag6),
|
||||||
tag7Idx: index('emb_tag7_idx').on(table.tag7),
|
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
|
// Full-text search index
|
||||||
contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),
|
contentFtsIdx: index('emb_content_fts_idx').using('gin', table.contentTsv),
|
||||||
|
|||||||
Reference in New Issue
Block a user